375 lines
12 KiB
Python
375 lines
12 KiB
Python
# Copyright (c) 2018 Red Hat, Inc.
|
|
#
|
|
# This file is part of ARA Records Ansible.
|
|
#
|
|
# 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 itertools
|
|
import logging
|
|
import os
|
|
|
|
from ansible import __version__ as ansible_version
|
|
from ansible.plugins.callback import CallbackBase
|
|
from ara import models
|
|
from ara.models import db
|
|
from ara.webapp import create_app
|
|
from datetime import datetime
|
|
from distutils.version import LooseVersion
|
|
from flask import current_app
|
|
from oslo_serialization import jsonutils
|
|
|
|
# To retrieve Ansible CLI options
|
|
try:
|
|
from __main__ import cli
|
|
except ImportError:
|
|
cli = None
|
|
|
|
log = logging.getLogger('ara.callback')
|
|
app = create_app()
|
|
|
|
|
|
class IncludeResult(object):
|
|
"""
|
|
This is used by the v2_playbook_on_include callback to synthesize a task
|
|
result for calling log_task.
|
|
"""
|
|
def __init__(self, host, path):
|
|
self._host = host
|
|
self._result = {'included_file': path}
|
|
|
|
|
|
class CallbackModule(CallbackBase):
|
|
"""
|
|
Saves data from an Ansible run into a database
|
|
"""
|
|
CALLBACK_VERSION = 2.0
|
|
CALLBACK_TYPE = 'notification'
|
|
CALLBACK_NAME = 'ara'
|
|
|
|
def __init__(self):
|
|
super(CallbackModule, self).__init__()
|
|
|
|
log.debug('Initializing callback')
|
|
if not current_app:
|
|
ctx = app.app_context()
|
|
ctx.push()
|
|
|
|
self.taskresult = None
|
|
self.task = None
|
|
self.play = None
|
|
self.playbook = None
|
|
self.stats = None
|
|
self.loop_items = []
|
|
|
|
self.play_counter = itertools.count()
|
|
self.task_counter = itertools.count()
|
|
|
|
if cli:
|
|
self._options = cli.options
|
|
else:
|
|
self._options = None
|
|
log.debug('Callback initialized')
|
|
|
|
def get_or_create_host(self, hostname):
|
|
try:
|
|
host = (models.Host.query
|
|
.filter_by(name=hostname)
|
|
.filter_by(playbook_id=self.playbook.id)
|
|
.one())
|
|
except models.NoResultFound:
|
|
host = models.Host(name=hostname, playbook=self.playbook)
|
|
db.session.add(host)
|
|
db.session.commit()
|
|
|
|
return host
|
|
|
|
def get_or_create_file(self, path):
|
|
try:
|
|
if self.playbook.id:
|
|
file_ = (models.File.query
|
|
.filter_by(path=path)
|
|
.filter_by(playbook_id=self.playbook.id)
|
|
.one())
|
|
return file_
|
|
except models.NoResultFound:
|
|
pass
|
|
|
|
file_ = models.File(path=path, playbook=self.playbook)
|
|
db.session.add(file_)
|
|
db.session.commit()
|
|
|
|
try:
|
|
with open(path, 'r') as fd:
|
|
data = fd.read()
|
|
sha1 = models.content_sha1(data)
|
|
content = models.FileContent.query.get(sha1)
|
|
|
|
if content is None:
|
|
content = models.FileContent(content=data)
|
|
|
|
file_.content = content
|
|
except IOError:
|
|
log.warn('failed to open %s for reading', path)
|
|
|
|
return file_
|
|
|
|
def log_task(self, result, status, **kwargs):
|
|
"""
|
|
'log_task' is called when an individual task instance on a single
|
|
host completes. It is responsible for logging a single
|
|
'TaskResult' record to the database.
|
|
"""
|
|
log.debug('Logging task result for task %s (%s), host %s',
|
|
self.task.name, self.task.id, result._host.get_name())
|
|
|
|
# An include_role task might end up putting an IncludeRole object
|
|
# inside the result object which we don't need
|
|
# https://github.com/ansible/ansible/issues/30385
|
|
if 'include_role' in result._result:
|
|
del result._result['include_role']
|
|
|
|
result.task_start = self.task.time_start
|
|
result.task_end = datetime.now()
|
|
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 = jsonutils.loads(jsonutils.dumps(results))
|
|
else:
|
|
results = jsonutils.loads(self._dump_results(result._result))
|
|
|
|
# Ignore errors can be "yes" instead of a proper boolean in <2.3
|
|
# for some reason
|
|
ignore_errors = kwargs.get('ignore_errors', False)
|
|
if LooseVersion(ansible_version) < LooseVersion('2.3.0'):
|
|
if not isinstance(ignore_errors, bool):
|
|
ignore_errors = True if ignore_errors == "yes" else 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 app.config['ARA_IGNORE_FACTS']:
|
|
if fact in results['ansible_facts']:
|
|
msg = "Not saved by ARA as configured by ARA_IGNORE_FACTS"
|
|
results['ansible_facts'][fact] = msg
|
|
|
|
values = jsonutils.dumps(result._result['ansible_facts'])
|
|
facts = models.HostFacts(values=values)
|
|
host.facts = facts
|
|
|
|
db.session.add(facts)
|
|
db.session.commit()
|
|
|
|
self.taskresult = models.TaskResult(
|
|
task=self.task,
|
|
host=host,
|
|
time_start=result.task_start,
|
|
time_end=result.task_end,
|
|
result=jsonutils.dumps(results),
|
|
status=status,
|
|
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=ignore_errors,
|
|
)
|
|
|
|
db.session.add(self.taskresult)
|
|
db.session.commit()
|
|
|
|
def log_stats(self, stats):
|
|
"""
|
|
Logs playbook statistics to the database.
|
|
"""
|
|
log.debug('logging stats')
|
|
hosts = sorted(stats.processed.keys())
|
|
for hostname in hosts:
|
|
host = self.get_or_create_host(hostname)
|
|
host_stats = stats.summarize(hostname)
|
|
db.session.add(models.Stats(
|
|
playbook=self.playbook,
|
|
host=host,
|
|
changed=host_stats['changed'],
|
|
unreachable=host_stats['unreachable'],
|
|
failed=host_stats['failures'],
|
|
ok=host_stats['ok'],
|
|
skipped=host_stats['skipped']
|
|
))
|
|
db.session.commit()
|
|
|
|
def close_task(self):
|
|
"""
|
|
Marks the completion time of the currently active task.
|
|
"""
|
|
if self.task is not None:
|
|
log.debug('Closing task %s (%s)',
|
|
self.task.name,
|
|
self.task.id)
|
|
self.task.stop()
|
|
db.session.add(self.task)
|
|
db.session.commit()
|
|
|
|
self.task = None
|
|
self.loop_items = []
|
|
|
|
def close_play(self):
|
|
"""
|
|
Marks the completion time of the currently active play.
|
|
"""
|
|
if self.play is not None:
|
|
log.debug('Closing play %s (%s)', self.play.name, self.play.id)
|
|
self.play.stop()
|
|
db.session.add(self.play)
|
|
db.session.commit()
|
|
|
|
self.play = None
|
|
|
|
def close_playbook(self):
|
|
"""
|
|
Marks the completion time of the currently active playbook.
|
|
"""
|
|
if self.playbook is not None:
|
|
log.debug('Closing playbook %s', self.playbook.path)
|
|
self.playbook.stop()
|
|
self.playbook.complete = True
|
|
db.session.add(self.playbook)
|
|
db.session.commit()
|
|
|
|
def v2_runner_item_on_ok(self, result):
|
|
self.loop_items.append(result)
|
|
|
|
def v2_runner_item_on_failed(self, result):
|
|
self.loop_items.append(result)
|
|
|
|
def v2_runner_item_on_skipped(self, result):
|
|
self.loop_items.append(result)
|
|
|
|
def v2_runner_retry(self, result):
|
|
self.loop_items.append(result)
|
|
|
|
def v2_runner_on_ok(self, result, **kwargs):
|
|
self.log_task(result, 'ok', **kwargs)
|
|
|
|
def v2_runner_on_unreachable(self, result, **kwargs):
|
|
self.log_task(result, 'unreachable', **kwargs)
|
|
|
|
def v2_runner_on_failed(self, result, **kwargs):
|
|
self.log_task(result, 'failed', **kwargs)
|
|
|
|
def v2_runner_on_skipped(self, result, **kwargs):
|
|
self.log_task(result, 'skipped', **kwargs)
|
|
|
|
def v2_playbook_on_task_start(self, task, is_conditional,
|
|
is_handler=False):
|
|
self.close_task()
|
|
|
|
log.debug('Starting task %s (action %s)',
|
|
task.name, task.action)
|
|
pathspec = task.get_path()
|
|
if pathspec:
|
|
path, lineno = pathspec.split(':', 1)
|
|
lineno = int(lineno)
|
|
file_ = self.get_or_create_file(path)
|
|
else:
|
|
path = self.playbook.path
|
|
lineno = 1
|
|
file_ = self.get_or_create_file(self.playbook.path)
|
|
|
|
self.task = models.Task(
|
|
name=task.get_name(),
|
|
sortkey=next(self.task_counter),
|
|
action=task.action,
|
|
play=self.play,
|
|
playbook=self.playbook,
|
|
tags=jsonutils.dumps(task._attributes['tags']),
|
|
file=file_,
|
|
lineno=lineno,
|
|
is_handler=is_handler)
|
|
|
|
self.task.start()
|
|
db.session.add(self.task)
|
|
db.session.commit()
|
|
|
|
def v2_playbook_on_handler_task_start(self, task):
|
|
self.v2_playbook_on_task_start(task, False, is_handler=True)
|
|
|
|
def v2_playbook_on_start(self, playbook):
|
|
path = os.path.abspath(playbook._file_name)
|
|
if self._options is not None:
|
|
options = self._options.__dict__.copy()
|
|
else:
|
|
options = {}
|
|
|
|
# Potentially sanitize some user-specified keys
|
|
for parameter in app.config['ARA_IGNORE_PARAMETERS']:
|
|
if parameter in options:
|
|
msg = "Not saved by ARA as configured by ARA_IGNORE_PARAMETERS"
|
|
options[parameter] = msg
|
|
|
|
log.debug('Starting playbook %s', path)
|
|
self.playbook = models.Playbook(
|
|
ansible_version=ansible_version,
|
|
path=path,
|
|
options=options
|
|
)
|
|
|
|
self.playbook.start()
|
|
db.session.add(self.playbook)
|
|
db.session.commit()
|
|
|
|
file_ = self.get_or_create_file(path)
|
|
file_.is_playbook = True
|
|
|
|
# Cache the playbook data in memory for ara_record/ara_read
|
|
current_app._cache['playbook'] = self.playbook.id
|
|
|
|
def v2_playbook_on_play_start(self, play):
|
|
self.close_task()
|
|
self.close_play()
|
|
|
|
log.debug('Starting play %s', play.name)
|
|
if self.play is not None:
|
|
self.play.stop()
|
|
|
|
self.play = models.Play(
|
|
name=play.name,
|
|
sortkey=next(self.play_counter),
|
|
playbook=self.playbook
|
|
)
|
|
|
|
self.play.start()
|
|
db.session.add(self.play)
|
|
db.session.commit()
|
|
|
|
def v2_playbook_on_stats(self, stats):
|
|
self.log_stats(stats)
|
|
|
|
self.close_task()
|
|
self.close_play()
|
|
self.close_playbook()
|
|
|
|
log.debug('Closing database')
|
|
db.session.close()
|