ara/ara/plugins/callback/ara_default.py
David Moreau Simard 9ff1630b04
Add support for API authentication in the client
This adds support for authentication to the offline and http clients
as well as the plumbing for the callback and the ara_record module to
support it.

This works but is missing a lot of tests. New issues have been created
to make sure we don't forget about adding tests.

Fixes: https://github.com/ansible-community/ara/issues/16
Change-Id: I62937dac1eb349baf6dea386dbd22b59d52319cd
Co-Authored-By: David Moreau-Simard <dmsimard@redhat.com>
Co-Authored-By: Florian Apolloner <florian@apolloner.eu>
2019-05-03 10:37:20 -04:00

365 lines
13 KiB
Python

# Copyright (c) 2018 Red Hat, Inc.
#
# This file is part of ARA: Ansible Run Analysis.
#
# ARA is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ARA is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ARA. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
import datetime
import json
import logging
import os
import six
from ansible import __version__ as ansible_version
from ansible.plugins.callback import CallbackBase
from ara.clients import utils as client_utils
# Ansible CLI options are now in ansible.context in >= 2.8
# https://github.com/ansible/ansible/commit/afdbb0d9d5bebb91f632f0d4a1364de5393ba17a
try:
from ansible import context
cli_options = {key: value for key, value in context.CLIARGS.items()}
except ImportError:
# < 2.8 doesn't have ansible.context
try:
from __main__ import cli
cli_options = cli.options.__dict__
except ImportError:
# using API without CLI
cli_options = {}
DOCUMENTATION = """
callback: ara
callback_type: notification
requirements:
- ara
short_description: Sends playbook execution data to the ARA API internally or over HTTP
description:
- Sends playbook execution data to the ARA API internally or over HTTP
options:
api_client:
description: The client to use for communicating with the API
default: offline
env:
- name: ARA_API_CLIENT
ini:
- section: ara
key: api_client
choices: ['offline', 'http']
api_server:
description: When using the HTTP client, the base URL to the ARA API server
default: http://127.0.0.1:8000
env:
- name: ARA_API_SERVER
ini:
- section: ara
key: api_server
api_username:
description: If authentication is required, the username to authenticate with
default: null
env:
- name: ARA_API_USERNAME
ini:
- section: ara
key: api_username
api_password:
description: If authentication is required, the password to authenticate with
default: null
env:
- name: ARA_API_PASSWORD
ini:
- section: ara
key: api_password
api_timeout:
description: Timeout, in seconds, before giving up on HTTP requests
default: 30
env:
- name: ARA_API_TIMEOUT
ini:
- section: ara
key: api_timeout
ignored_facts:
description: List of host facts that will not be saved by ARA
type: list
default: ["ansible_env"]
env:
- name: ARA_IGNORED_FACTS
ini:
- section: ara
key: ignored_facts
ignored_arguments:
description: List of Ansible arguments that will not be saved by ARA
type: list
default: ["extra_vars"]
env:
- name: ARA_IGNORED_ARGUMENTS
ini:
- section: ara
key: ignored_arguments
"""
class CallbackModule(CallbackBase):
"""
Saves data from an Ansible run into a database
"""
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = "awesome"
CALLBACK_NAME = "ara_default"
def __init__(self):
super(CallbackModule, self).__init__()
self.log = logging.getLogger("ara.plugins.callback.default")
self.client = None
self.ignored_facts = []
self.ignored_arguments = []
self.result = None
self.task = None
self.play = None
self.playbook = None
self.stats = None
self.loop_items = []
def set_options(self, task_keys=None, var_options=None, direct=None):
super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)
self.ignored_facts = self.get_option("ignored_facts")
self.ignored_arguments = self.get_option("ignored_arguments")
client = self.get_option("api_client")
endpoint = self.get_option("api_server")
timeout = self.get_option("api_timeout")
username = self.get_option("api_username")
password = self.get_option("api_password")
self.client = client_utils.get_client(
client=client, endpoint=endpoint, timeout=timeout, username=username, password=password
)
def v2_playbook_on_start(self, playbook):
self.log.debug("v2_playbook_on_start")
path = os.path.abspath(playbook._file_name)
# Potentially sanitize some user-specified keys
for argument in self.ignored_arguments:
if argument in cli_options:
self.log.debug("Ignoring argument: %s" % argument)
cli_options[argument] = "Not saved by ARA as configured by 'ignored_arguments'"
# Create the playbook
self.playbook = self.client.post(
"/api/v1/playbooks", ansible_version=ansible_version, arguments=cli_options, status="running", path=path
)
# Record the playbook file
self._get_or_create_file(path)
return self.playbook
def v2_playbook_on_play_start(self, play):
self.log.debug("v2_playbook_on_play_start")
self._end_task()
self._end_play()
# Load variables to verify if there is anything relevant for ara
play_vars = play._variable_manager.get_vars(play=play)["vars"]
if "ara_playbook_name" in play_vars:
self._set_playbook_name(name=play_vars["ara_playbook_name"])
# Record all the files involved in the play
for path in play._loader._FILE_CACHE.keys():
self._get_or_create_file(path)
# Create the play
self.play = self.client.post(
"/api/v1/plays", name=play.name, status="running", uuid=play._uuid, playbook=self.playbook["id"]
)
# Record all the hosts involved in the play
for host in play.hosts:
hostvars = play_vars["hostvars"][host] if host in play_vars["hostvars"] else {}
host_alias = hostvars["ara_host_alias"] if "ara_host_alias" in hostvars else host
self._get_or_create_host(host=host, host_alias=host_alias)
return self.play
def v2_playbook_on_task_start(self, task, is_conditional, handler=False):
self.log.debug("v2_playbook_on_task_start")
self._end_task()
pathspec = task.get_path()
if pathspec:
path, lineno = pathspec.split(":", 1)
lineno = int(lineno)
else:
# Task doesn't have a path, default to "something"
path = self.playbook["path"]
lineno = 1
# Get task file
task_file = self._get_or_create_file(path)
self.task = self.client.post(
"/api/v1/tasks",
name=task.get_name(),
status="running",
action=task.action,
play=self.play["id"],
playbook=self.playbook["id"],
file=task_file["id"],
tags=task._attributes["tags"],
lineno=lineno,
handler=handler,
)
return self.task
def v2_runner_on_ok(self, result, **kwargs):
self._load_result(result, "ok", **kwargs)
def v2_runner_on_unreachable(self, result, **kwargs):
self._load_result(result, "unreachable", **kwargs)
def v2_runner_on_failed(self, result, **kwargs):
self._load_result(result, "failed", **kwargs)
def v2_runner_on_skipped(self, result, **kwargs):
self._load_result(result, "skipped", **kwargs)
def v2_playbook_on_stats(self, stats):
self.log.debug("v2_playbook_on_stats")
self._end_task()
self._end_play()
self._load_stats(stats)
self._end_playbook(stats)
def _end_task(self):
if self.task is not None:
self.client.patch(
"/api/v1/tasks/%s" % self.task["id"], status="completed", ended=datetime.datetime.now().isoformat()
)
self.task = None
self.loop_items = []
def _end_play(self):
if self.play is not None:
self.client.patch(
"/api/v1/plays/%s" % self.play["id"], status="completed", ended=datetime.datetime.now().isoformat()
)
self.play = None
def _end_playbook(self, stats):
status = "unknown"
if len(stats.failures) >= 1 or len(stats.dark) >= 1:
status = "failed"
else:
status = "completed"
self.playbook = self.client.patch(
"/api/v1/playbooks/%s" % self.playbook["id"], status=status, ended=datetime.datetime.now().isoformat()
)
def _set_playbook_name(self, name):
if self.playbook["name"] != name:
self.playbook = self.client.patch("/api/v1/playbooks/%s" % self.playbook["id"], name=name)
def _get_or_create_file(self, path):
self.log.debug("Getting or creating file: %s" % path)
# Note: The get_or_create is handled through the serializer of the API server.
try:
with open(path, "r") as fd:
content = fd.read()
except IOError as e:
self.log.error("Unable to open {0} for reading: {1}".format(path, six.text_type(e)))
content = """ARA was not able to read this file successfully.
Refer to the logs for more information"""
return self.client.post("/api/v1/files", playbook=self.playbook["id"], path=path, content=content)
def _get_or_create_host(self, host, host_alias=None):
self.log.debug("Getting or creating host: %s" % host)
# Note: The get_or_create is handled through the serializer of the API server.
return self.client.post("/api/v1/hosts", name=host, alias=host_alias, playbook=self.playbook["id"])
def _load_result(self, result, status, **kwargs):
"""
This method is called when an individual task instance on a single
host completes. It is responsible for logging a single result to the
database.
"""
host = self._get_or_create_host(result._host.get_name())
# Use Ansible's CallbackBase._dump_results in order to strip internal
# keys, respect no_log directive, etc.
if self.loop_items:
# NOTE (dmsimard): There is a known issue in which Ansible can send
# callback hooks out of order and "exit" the task before all items
# have returned, this can cause one of the items to be missing
# from the task result in ARA.
# https://github.com/ansible/ansible/issues/24207
results = [self._dump_results(result._result)]
for item in self.loop_items:
results.append(self._dump_results(item._result))
results = json.loads(json.dumps(results))
else:
results = json.loads(self._dump_results(result._result))
self.result = self.client.post(
"/api/v1/results",
playbook=self.playbook["id"],
task=self.task["id"],
host=host["id"],
play=self.task["play"],
content=results,
status=status,
started=self.task["started"],
ended=datetime.datetime.now().isoformat(),
changed=result._result.get("changed", False),
failed=result._result.get("failed", False),
skipped=result._result.get("skipped", False),
unreachable=result._result.get("unreachable", False),
ignore_errors=kwargs.get("ignore_errors", False),
)
if self.task["action"] == "setup" and "ansible_facts" in results:
# Potentially sanitize some Ansible facts to prevent them from
# being saved both in the host facts and in the task results.
for fact in self.ignored_facts:
if fact in results["ansible_facts"]:
self.log.debug("Ignoring fact: %s" % fact)
results["ansible_facts"][fact] = "Not saved by ARA as configured by 'ignored_facts'"
self.client.patch("/api/v1/hosts/%s" % host["id"], facts=results["ansible_facts"])
def _load_stats(self, stats):
hosts = sorted(stats.processed.keys())
for hostname in hosts:
host = self._get_or_create_host(hostname)
host_stats = stats.summarize(hostname)
self.client.patch(
"/api/v1/hosts/%s" % host["id"],
changed=host_stats["changed"],
unreachable=host_stats["unreachable"],
failed=host_stats["failures"],
ok=host_stats["ok"],
skipped=host_stats["skipped"],
)