mirror of
https://github.com/ansible/awx.git
synced 2026-06-18 21:27:43 -02:30
* Demo of sorting hosts live test * Sort both bulk updates and add batch size to facts bulk update to resolve deadlock issue * Update tests to expect batch_size to agree with changes * Add utility method to bulk update and sort hosts and applied that to the appropriate locations Remove unused imports Add utility method for sorting bulk updates Remove try except OperationalError for loop Remove unused import of django.db.OperationalError Remove batch size as it is now on the bulk update utility method as 100 Remove batch size here since it is specified in sortedbulkupdate Add transaction.atomic to have entire transaction is run as a signle transaction before committing to the db Revert change to bulk update as it's not needed here and just sort instead Move bulk_sorted utility method into db.py and updated name to not be specific to Hosts Revise to import bulk_update_sorted.. rather than calling it as an argument Fix way I'm importing bulk_update_sorted.. Remove unneeded Host import and remove calls to bul_update as args Rebise calls to bulk_update_sorted.. to include Host in the args REmove raw_update_hosts method and replace with bulk_update_sorted_by_id in update_hosts Remove update_hosts function and replace with bulk_update_sorted_by_id Update live tests to use bulk_update_sorted_by_id Fix the fields in bulk_update to agree with test * Update functional tests to use bulk_update_sorted_by_id since update_hosts has been deleted Replace update_hosts with bulk_update_sorted_by_id Remove referenes to update_hosts Update corresponding fact cachin tests to use bulk_update_sorted_by_id Remove import of bulk_sorted_update Add code comment to live test to silence Sonarqube hotspot * Add comment NOSONAR to get rid of Sonarqube warning since this is just a test and it's not actually a security issue Get test_finish_job_fact_cache_with_existing_data passing Get test_finish_job_fact_cache_clear passing Remove reference to raw_update and replace with new bulk update utility method Add pytest.mark.django_db to appropriate tests Corrent which model is called in bulk_update_sorted_by_id Remove now unused Host import Point to where bulk_update_sorted_by_id to where that is actually being used Correct import of bulk_update_sorted_by_id Revert changes in this file to avoid db calls issue Remove @pytest.mark.django_db from unit tests Remove commented out host sorting suggested fix Fix failing tests test_pre_post_run_hook_facts_deleted_sliced & test_pre_post_run_hook_facts Remove atomic transaction line, add return, and add docstring * Fix failing test test_finish_job_fact_cache_clear & test_finish_job_fact_cache_with_existing_data --------- Co-authored-by: Alan Rominger <arominge@redhat.com>
977 lines
36 KiB
Python
977 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import datetime
|
|
from datetime import timezone
|
|
import logging
|
|
from collections import defaultdict
|
|
import itertools
|
|
import time
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.db import models, DatabaseError, transaction
|
|
from django.db.models.functions import Cast
|
|
from django.utils.dateparse import parse_datetime
|
|
from django.utils.text import Truncator
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.utils.encoding import force_str
|
|
|
|
from awx.api.versioning import reverse
|
|
from awx.main import consumers
|
|
from awx.main.fields import JSONBlob
|
|
from awx.main.managers import DeferJobCreatedManager
|
|
from awx.main.constants import MINIMAL_EVENTS
|
|
from awx.main.models.base import CreatedModifiedModel
|
|
from awx.main.utils import ignore_inventory_computed_fields, camelcase_to_underscore
|
|
from awx.main.utils.db import bulk_update_sorted_by_id
|
|
|
|
analytics_logger = logging.getLogger('awx.analytics.job_events')
|
|
|
|
logger = logging.getLogger('awx.main.models.events')
|
|
|
|
__all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent']
|
|
|
|
|
|
def sanitize_event_keys(kwargs, valid_keys):
|
|
# Sanity check: Don't honor keys that we don't recognize.
|
|
for key in list(kwargs.keys()):
|
|
if key not in valid_keys:
|
|
kwargs.pop(key)
|
|
|
|
# Truncate certain values over 1k
|
|
for key in ['play', 'role', 'task', 'playbook']:
|
|
if isinstance(kwargs.get('event_data', {}).get(key), str):
|
|
if len(kwargs['event_data'][key]) > 1024:
|
|
kwargs['event_data'][key] = Truncator(kwargs['event_data'][key]).chars(1024)
|
|
|
|
|
|
def create_host_status_counts(event_data):
|
|
host_status = {}
|
|
host_status_keys = ['skipped', 'ok', 'changed', 'failures', 'dark']
|
|
|
|
for key in host_status_keys:
|
|
for host in event_data.get(key, {}):
|
|
host_status[host] = key
|
|
|
|
host_status_counts = defaultdict(lambda: 0)
|
|
|
|
for value in host_status.values():
|
|
host_status_counts[value] += 1
|
|
|
|
return dict(host_status_counts)
|
|
|
|
|
|
def emit_event_detail(event):
|
|
if settings.UI_LIVE_UPDATES_ENABLED is False and event.event not in MINIMAL_EVENTS:
|
|
return
|
|
cls = event.__class__
|
|
relation = {
|
|
JobEvent: 'job_id',
|
|
AdHocCommandEvent: 'ad_hoc_command_id',
|
|
ProjectUpdateEvent: 'project_update_id',
|
|
InventoryUpdateEvent: 'inventory_update_id',
|
|
SystemJobEvent: 'system_job_id',
|
|
}[cls]
|
|
url = ''
|
|
if isinstance(event, JobEvent):
|
|
url = '/api/v2/job_events/{}'.format(event.id)
|
|
if isinstance(event, AdHocCommandEvent):
|
|
url = '/api/v2/ad_hoc_command_events/{}'.format(event.id)
|
|
group = camelcase_to_underscore(cls.__name__) + 's'
|
|
timestamp = event.created.isoformat()
|
|
consumers.emit_channel_notification(
|
|
'-'.join([group, str(getattr(event, relation))]),
|
|
{
|
|
'id': event.id,
|
|
relation.replace('_id', ''): getattr(event, relation),
|
|
'created': timestamp,
|
|
'modified': timestamp,
|
|
'group_name': group,
|
|
'url': url,
|
|
'stdout': event.stdout,
|
|
'counter': event.counter,
|
|
'uuid': event.uuid,
|
|
'parent_uuid': getattr(event, 'parent_uuid', ''),
|
|
'start_line': event.start_line,
|
|
'end_line': event.end_line,
|
|
'event': event.event,
|
|
'event_data': getattr(event, 'event_data', {}),
|
|
'failed': event.failed,
|
|
'changed': event.changed,
|
|
'event_level': getattr(event, 'event_level', ''),
|
|
'play': getattr(event, 'play', ''),
|
|
'role': getattr(event, 'role', ''),
|
|
'task': getattr(event, 'task', ''),
|
|
},
|
|
)
|
|
|
|
|
|
class BasePlaybookEvent(CreatedModifiedModel):
|
|
"""
|
|
An event/message logged from a playbook callback for each host.
|
|
"""
|
|
|
|
VALID_KEYS = [
|
|
'event',
|
|
'event_data',
|
|
'playbook',
|
|
'play',
|
|
'role',
|
|
'task',
|
|
'created',
|
|
'counter',
|
|
'uuid',
|
|
'stdout',
|
|
'parent_uuid',
|
|
'start_line',
|
|
'end_line',
|
|
'verbosity',
|
|
]
|
|
WRAPUP_EVENT = 'playbook_on_stats'
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
# Playbook events will be structured to form the following hierarchy:
|
|
# - playbook_on_start (once for each playbook file)
|
|
# - playbook_on_vars_prompt (for each play, but before play starts, we
|
|
# currently don't handle responding to these prompts)
|
|
# - playbook_on_play_start (once for each play)
|
|
# - playbook_on_import_for_host (not logged, not used for v2)
|
|
# - playbook_on_not_import_for_host (not logged, not used for v2)
|
|
# - playbook_on_no_hosts_matched
|
|
# - playbook_on_no_hosts_remaining
|
|
# - playbook_on_include (only v2 - only used for handlers?)
|
|
# - playbook_on_setup (not used for v2)
|
|
# - runner_on*
|
|
# - playbook_on_task_start (once for each task within a play)
|
|
# - runner_on_failed
|
|
# - runner_on_start
|
|
# - runner_on_ok
|
|
# - runner_on_error (not used for v2)
|
|
# - runner_on_skipped
|
|
# - runner_on_unreachable
|
|
# - runner_on_no_hosts (not used for v2)
|
|
# - runner_on_async_poll (not used for v2)
|
|
# - runner_on_async_ok (not used for v2)
|
|
# - runner_on_async_failed (not used for v2)
|
|
# - runner_on_file_diff (v2 event is v2_on_file_diff)
|
|
# - runner_item_on_ok (v2 only)
|
|
# - runner_item_on_failed (v2 only)
|
|
# - runner_item_on_skipped (v2 only)
|
|
# - runner_retry (v2 only)
|
|
# - playbook_on_notify (once for each notification from the play, not used for v2)
|
|
# - playbook_on_stats
|
|
|
|
EVENT_TYPES = [
|
|
# (level, event, verbose name, failed)
|
|
(3, 'runner_on_failed', _('Host Failed'), True),
|
|
(3, 'runner_on_start', _('Host Started'), False),
|
|
(3, 'runner_on_ok', _('Host OK'), False),
|
|
(3, 'runner_on_error', _('Host Failure'), True),
|
|
(3, 'runner_on_skipped', _('Host Skipped'), False),
|
|
(3, 'runner_on_unreachable', _('Host Unreachable'), True),
|
|
(3, 'runner_on_no_hosts', _('No Hosts Remaining'), False),
|
|
(3, 'runner_on_async_poll', _('Host Polling'), False),
|
|
(3, 'runner_on_async_ok', _('Host Async OK'), False),
|
|
(3, 'runner_on_async_failed', _('Host Async Failure'), True),
|
|
(3, 'runner_item_on_ok', _('Item OK'), False),
|
|
(3, 'runner_item_on_failed', _('Item Failed'), True),
|
|
(3, 'runner_item_on_skipped', _('Item Skipped'), False),
|
|
(3, 'runner_retry', _('Host Retry'), False),
|
|
# Tower does not yet support --diff mode.
|
|
(3, 'runner_on_file_diff', _('File Difference'), False),
|
|
(0, 'playbook_on_start', _('Playbook Started'), False),
|
|
(2, 'playbook_on_notify', _('Running Handlers'), False),
|
|
(2, 'playbook_on_include', _('Including File'), False),
|
|
(2, 'playbook_on_no_hosts_matched', _('No Hosts Matched'), False),
|
|
(2, 'playbook_on_no_hosts_remaining', _('No Hosts Remaining'), False),
|
|
(2, 'playbook_on_task_start', _('Task Started'), False),
|
|
# Tower does not yet support vars_prompt (and will probably hang :)
|
|
(1, 'playbook_on_vars_prompt', _('Variables Prompted'), False),
|
|
(2, 'playbook_on_setup', _('Gathering Facts'), False),
|
|
(2, 'playbook_on_import_for_host', _('internal: on Import for Host'), False),
|
|
(2, 'playbook_on_not_import_for_host', _('internal: on Not Import for Host'), False),
|
|
(1, 'playbook_on_play_start', _('Play Started'), False),
|
|
(1, 'playbook_on_stats', _('Playbook Complete'), False),
|
|
# Additional event types for captured stdout not directly related to
|
|
# playbook or runner events.
|
|
(0, 'debug', _('Debug'), False),
|
|
(0, 'verbose', _('Verbose'), False),
|
|
(0, 'deprecated', _('Deprecated'), False),
|
|
(0, 'warning', _('Warning'), False),
|
|
(0, 'system_warning', _('System Warning'), False),
|
|
(0, 'error', _('Error'), True),
|
|
]
|
|
FAILED_EVENTS = [x[1] for x in EVENT_TYPES if x[3]]
|
|
EVENT_CHOICES = [(x[1], x[2]) for x in EVENT_TYPES]
|
|
LEVEL_FOR_EVENT = dict([(x[1], x[0]) for x in EVENT_TYPES])
|
|
|
|
event = models.CharField(
|
|
max_length=100,
|
|
choices=EVENT_CHOICES,
|
|
)
|
|
event_data = JSONBlob(default=dict, blank=True)
|
|
failed = models.BooleanField(
|
|
default=False,
|
|
editable=False,
|
|
)
|
|
changed = models.BooleanField(
|
|
default=False,
|
|
editable=False,
|
|
)
|
|
uuid = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
playbook = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
play = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
role = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
task = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
counter = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
stdout = models.TextField(
|
|
default='',
|
|
editable=False,
|
|
)
|
|
verbosity = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
start_line = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
end_line = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
created = models.DateTimeField(
|
|
null=True,
|
|
default=None,
|
|
editable=False,
|
|
)
|
|
modified = models.DateTimeField(
|
|
default=None,
|
|
editable=False,
|
|
db_index=True,
|
|
)
|
|
|
|
@property
|
|
def event_level(self):
|
|
return self.LEVEL_FOR_EVENT.get(self.event, 0)
|
|
|
|
def get_host_status_counts(self):
|
|
return create_host_status_counts(getattr(self, 'event_data', {}))
|
|
|
|
def get_event_display2(self):
|
|
msg = self.get_event_display()
|
|
if self.event == 'playbook_on_play_start':
|
|
if self.play:
|
|
msg = "%s (%s)" % (msg, self.play)
|
|
elif self.event == 'playbook_on_task_start':
|
|
if self.task:
|
|
if self.event_data.get('is_conditional', False):
|
|
msg = 'Handler Notified'
|
|
if self.role:
|
|
msg = '%s (%s | %s)' % (msg, self.role, self.task)
|
|
else:
|
|
msg = "%s (%s)" % (msg, self.task)
|
|
|
|
# Change display for runner events triggered by async polling. Some of
|
|
# these events may not show in most cases, due to filterting them out
|
|
# of the job event queryset returned to the user.
|
|
res = self.event_data.get('res', {})
|
|
# Fix for existing records before we had added the workaround on save
|
|
# to change async_ok to async_failed.
|
|
if self.event == 'runner_on_async_ok':
|
|
try:
|
|
if res.get('failed', False) or res.get('rc', 0) != 0:
|
|
msg = 'Host Async Failed'
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
# Runner events with ansible_job_id are part of async starting/polling.
|
|
if self.event in ('runner_on_ok', 'runner_on_failed'):
|
|
try:
|
|
module_name = res['invocation']['module_name']
|
|
job_id = res['ansible_job_id']
|
|
except (TypeError, KeyError, AttributeError):
|
|
module_name = None
|
|
job_id = None
|
|
if module_name and job_id:
|
|
if module_name == 'async_status':
|
|
msg = 'Host Async Checking'
|
|
else:
|
|
msg = 'Host Async Started'
|
|
# Handle both 1.2 on_failed and 1.3+ on_async_failed events when an
|
|
# async task times out.
|
|
if self.event in ('runner_on_failed', 'runner_on_async_failed'):
|
|
try:
|
|
if res['msg'] == 'timed out':
|
|
msg = 'Host Async Timeout'
|
|
except (TypeError, KeyError, AttributeError):
|
|
pass
|
|
return msg
|
|
|
|
def _update_from_event_data(self):
|
|
# Update event model fields from event data.
|
|
event_data = self.event_data
|
|
res = event_data.get('res', None)
|
|
if self.event in self.FAILED_EVENTS and not event_data.get('ignore_errors', False):
|
|
self.failed = True
|
|
if isinstance(res, dict):
|
|
if res.get('changed', False):
|
|
self.changed = True
|
|
if self.event == 'playbook_on_stats':
|
|
try:
|
|
failures_dict = event_data.get('failures', {})
|
|
dark_dict = event_data.get('dark', {})
|
|
self.failed = bool(sum(failures_dict.values()) + sum(dark_dict.values()))
|
|
changed_dict = event_data.get('changed', {})
|
|
self.changed = bool(sum(changed_dict.values()))
|
|
except (AttributeError, TypeError):
|
|
pass
|
|
|
|
if isinstance(self, JobEvent):
|
|
try:
|
|
job = self.job
|
|
except ObjectDoesNotExist:
|
|
job = None
|
|
if job:
|
|
hostnames = self._hostnames()
|
|
self._update_host_summary_from_stats(set(hostnames))
|
|
if job.inventory:
|
|
try:
|
|
job.inventory.update_computed_fields()
|
|
except DatabaseError:
|
|
logger.exception('Computed fields database error saving event {}'.format(self.pk))
|
|
|
|
# find parent links and progagate changed=T and failed=T
|
|
changed = (
|
|
job.get_event_queryset()
|
|
.filter(changed=True)
|
|
.exclude(parent_uuid=None)
|
|
.only('parent_uuid')
|
|
.values_list('parent_uuid', flat=True)
|
|
.distinct()
|
|
) # noqa
|
|
failed = (
|
|
job.get_event_queryset()
|
|
.filter(failed=True)
|
|
.exclude(parent_uuid=None)
|
|
.only('parent_uuid')
|
|
.values_list('parent_uuid', flat=True)
|
|
.distinct()
|
|
) # noqa
|
|
|
|
# NOTE: we take a set of changed and failed parent uuids because the subquery
|
|
# complicates the plan with large event tables causing very long query execution time
|
|
changed_start = time.time()
|
|
changed_res = job.get_event_queryset().filter(uuid__in=set(changed)).update(changed=True)
|
|
failed_start = time.time()
|
|
failed_res = job.get_event_queryset().filter(uuid__in=set(failed)).update(failed=True)
|
|
logger.debug(
|
|
f'Event propagation for job {job.id}: '
|
|
f'marked {changed_res} as changed in {failed_start - changed_start:.4f}s, '
|
|
f'{failed_res} as failed in {time.time() - failed_start:.4f}s'
|
|
)
|
|
|
|
for field in ('playbook', 'play', 'task', 'role'):
|
|
value = force_str(event_data.get(field, '')).strip()
|
|
if value != getattr(self, field):
|
|
setattr(self, field, value)
|
|
if settings.LOG_AGGREGATOR_ENABLED:
|
|
analytics_logger.info('Event data saved.', extra=dict(python_objects=dict(job_event=self)))
|
|
|
|
@classmethod
|
|
def create_from_data(cls, **kwargs):
|
|
#
|
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
|
# This function is called by the callback receiver *once* for *every
|
|
# event* emitted by Ansible as a playbook runs. That means that
|
|
# changes to this function are _very_ susceptible to introducing
|
|
# performance regressions (which the user will experience as "my
|
|
# playbook stdout takes too long to show up"), *especially* code which
|
|
# might invoke additional database queries per event.
|
|
#
|
|
# Proceed with caution!
|
|
#
|
|
pk = None
|
|
for key in ('job_id', 'project_update_id'):
|
|
if key in kwargs:
|
|
pk = key
|
|
if pk is None:
|
|
# payload must contain either a job_id or a project_update_id
|
|
return
|
|
|
|
# Convert the datetime for the job event's creation appropriately,
|
|
# and include a time zone for it.
|
|
#
|
|
# In the event of any issue, throw it out, and Django will just save
|
|
# the current time.
|
|
try:
|
|
if not isinstance(kwargs['created'], datetime.datetime):
|
|
kwargs['created'] = parse_datetime(kwargs['created'])
|
|
if not kwargs['created'].tzinfo:
|
|
kwargs['created'] = kwargs['created'].replace(tzinfo=timezone.utc)
|
|
except (KeyError, ValueError):
|
|
kwargs.pop('created', None)
|
|
|
|
# same as above, for job_created
|
|
# TODO: if this approach, identical to above, works, can convert to for loop
|
|
try:
|
|
if not isinstance(kwargs['job_created'], datetime.datetime):
|
|
kwargs['job_created'] = parse_datetime(kwargs['job_created'])
|
|
if not kwargs['job_created'].tzinfo:
|
|
kwargs['job_created'] = kwargs['job_created'].replace(tzinfo=timezone.utc)
|
|
except (KeyError, ValueError):
|
|
kwargs.pop('job_created', None)
|
|
|
|
host_map = kwargs.pop('host_map', {})
|
|
|
|
sanitize_event_keys(kwargs, cls.VALID_KEYS)
|
|
workflow_job_id = kwargs.pop('workflow_job_id', None)
|
|
event = cls(**kwargs)
|
|
if workflow_job_id:
|
|
setattr(event, 'workflow_job_id', workflow_job_id)
|
|
# shouldn't job_created _always_ be present?
|
|
# if it's not, how could we save the event to the db?
|
|
job_created = kwargs.pop('job_created', None)
|
|
if job_created:
|
|
setattr(event, 'job_created', job_created)
|
|
setattr(event, 'host_map', host_map)
|
|
event._update_from_event_data()
|
|
return event
|
|
|
|
@property
|
|
def job_verbosity(self):
|
|
return 0
|
|
|
|
|
|
class JobEvent(BasePlaybookEvent):
|
|
"""
|
|
An event/message logged from the callback when running a job.
|
|
"""
|
|
|
|
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['job_id', 'workflow_job_id', 'job_created', 'host_id', 'host_name']
|
|
JOB_REFERENCE = 'job_id'
|
|
|
|
objects = DeferJobCreatedManager()
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('pk',)
|
|
indexes = [
|
|
models.Index(fields=['job', 'job_created', 'event']),
|
|
models.Index(fields=['job', 'job_created', 'uuid']),
|
|
models.Index(fields=['job', 'job_created', 'parent_uuid']),
|
|
models.Index(fields=['job', 'job_created', 'counter']),
|
|
]
|
|
|
|
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
|
job = models.ForeignKey(
|
|
'Job',
|
|
related_name='job_events',
|
|
null=True,
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_index=False,
|
|
)
|
|
# When we partitioned the table we accidentally "lost" the foreign key constraint.
|
|
# However this is good because the cascade on delete at the django layer was causing DB issues
|
|
# We are going to leave this as a foreign key but mark it as not having a DB relation and
|
|
# prevent cascading on delete.
|
|
host = models.ForeignKey(
|
|
'Host',
|
|
related_name='job_events_as_primary_host',
|
|
null=True,
|
|
default=None,
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_constraint=False,
|
|
)
|
|
host_name = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
parent_uuid = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
job_created = models.DateTimeField(null=True, editable=False)
|
|
|
|
def get_absolute_url(self, request=None):
|
|
return reverse('api:job_event_detail', kwargs={'pk': self.pk}, request=request)
|
|
|
|
def __str__(self):
|
|
return u'%s @ %s' % (self.get_event_display2(), self.created.isoformat())
|
|
|
|
def _hostnames(self):
|
|
hostnames = set()
|
|
try:
|
|
for stat in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'):
|
|
hostnames.update(self.event_data.get(stat, {}).keys())
|
|
except AttributeError: # In case event_data or v isn't a dict.
|
|
pass
|
|
return hostnames
|
|
|
|
def _update_host_summary_from_stats(self, hostnames):
|
|
with ignore_inventory_computed_fields():
|
|
try:
|
|
if not self.job or not self.job.inventory:
|
|
logger.info('Event {} missing job or inventory, host summaries not updated'.format(self.pk))
|
|
return
|
|
except ObjectDoesNotExist:
|
|
logger.info('Event {} missing job or inventory, host summaries not updated'.format(self.pk))
|
|
return
|
|
job = self.job
|
|
|
|
from awx.main.models import Host, JobHostSummary # circular import
|
|
|
|
if self.job.inventory.kind == 'constructed':
|
|
all_hosts = Host.objects.filter(id__in=self.job.inventory.hosts.values_list(Cast('instance_id', output_field=models.IntegerField()))).only(
|
|
'id', 'name'
|
|
)
|
|
constructed_host_map = self.host_map
|
|
host_map = {host.name: host.id for host in all_hosts}
|
|
else:
|
|
all_hosts = Host.objects.filter(pk__in=self.host_map.values()).only('id', 'name')
|
|
constructed_host_map = {}
|
|
host_map = self.host_map
|
|
|
|
existing_host_ids = set(h.id for h in all_hosts)
|
|
|
|
summaries = dict()
|
|
updated_hosts_list = list()
|
|
for host in hostnames:
|
|
host_id = host_map.get(host)
|
|
if host_id not in existing_host_ids:
|
|
host_id = None
|
|
constructed_host_id = constructed_host_map.get(host)
|
|
host_stats = {}
|
|
for stat in ('changed', 'dark', 'failures', 'ignored', 'ok', 'processed', 'rescued', 'skipped'):
|
|
try:
|
|
host_stats[stat] = self.event_data.get(stat, {}).get(host, 0)
|
|
except AttributeError: # in case event_data[stat] isn't a dict.
|
|
pass
|
|
summary = JobHostSummary(
|
|
created=now(), modified=now(), job_id=job.id, host_id=host_id, constructed_host_id=constructed_host_id, host_name=host, **host_stats
|
|
)
|
|
summary.failed = bool(summary.dark or summary.failures)
|
|
summaries[(host_id, host)] = summary
|
|
|
|
# do not count dark / unreachable hosts as updated
|
|
if not bool(summary.dark):
|
|
updated_hosts_list.append(host.lower())
|
|
else:
|
|
logger.warning(f'host {host.lower()} is dark / unreachable, not marking it as updated')
|
|
|
|
JobHostSummary.objects.bulk_create(summaries.values())
|
|
|
|
# update the last_job_id and last_job_host_summary_id
|
|
# in single queries
|
|
host_mapping = dict((summary['host_id'], summary['id']) for summary in JobHostSummary.objects.filter(job_id=job.id).values('id', 'host_id'))
|
|
updated_hosts = set()
|
|
for h in all_hosts:
|
|
# if the hostname *shows up* in the playbook_on_stats event
|
|
if h.name in hostnames:
|
|
h.last_job_id = job.id
|
|
updated_hosts.add(h)
|
|
if h.id in host_mapping:
|
|
h.last_job_host_summary_id = host_mapping[h.id]
|
|
updated_hosts.add(h)
|
|
|
|
bulk_update_sorted_by_id(Host, updated_hosts, ['last_job_id', 'last_job_host_summary_id'])
|
|
|
|
# Create/update Host Metrics
|
|
self._update_host_metrics(updated_hosts_list)
|
|
|
|
@staticmethod
|
|
def _update_host_metrics(updated_hosts_list):
|
|
from awx.main.models import HostMetric # circular import
|
|
|
|
current_time = now()
|
|
|
|
# FUTURE:
|
|
# - Hand-rolled implementation of itertools.batched(), introduced in Python 3.12. Replace.
|
|
# - Ability to do ORM upserts *may* have been introduced in Django 5.0.
|
|
# See the entry about `create_defaults` in https://docs.djangoproject.com/en/5.0/releases/5.0/#models.
|
|
# Hopefully this will be fully ready for batch use by 5.2 LTS.
|
|
|
|
args = [iter(updated_hosts_list)] * 500
|
|
for hosts in itertools.zip_longest(*args):
|
|
with transaction.atomic():
|
|
HostMetric.objects.bulk_create(
|
|
[HostMetric(hostname=hostname, last_automation=current_time) for hostname in hosts if hostname is not None], ignore_conflicts=True
|
|
)
|
|
HostMetric.objects.filter(hostname__in=hosts).update(
|
|
last_automation=current_time, automated_counter=models.F('automated_counter') + 1, deleted=False
|
|
)
|
|
|
|
@property
|
|
def job_verbosity(self):
|
|
return self.job.verbosity
|
|
|
|
|
|
class UnpartitionedJobEvent(JobEvent):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
|
|
UnpartitionedJobEvent._meta.db_table = '_unpartitioned_' + JobEvent._meta.db_table # noqa
|
|
|
|
|
|
class ProjectUpdateEvent(BasePlaybookEvent):
|
|
VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['project_update_id', 'workflow_job_id', 'job_created']
|
|
JOB_REFERENCE = 'project_update_id'
|
|
|
|
objects = DeferJobCreatedManager()
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('pk',)
|
|
indexes = [
|
|
models.Index(fields=['project_update', 'job_created', 'event']),
|
|
models.Index(fields=['project_update', 'job_created', 'uuid']),
|
|
models.Index(fields=['project_update', 'job_created', 'counter']),
|
|
]
|
|
|
|
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
|
project_update = models.ForeignKey(
|
|
'ProjectUpdate',
|
|
related_name='project_update_events',
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_index=False,
|
|
)
|
|
job_created = models.DateTimeField(null=True, editable=False)
|
|
|
|
@property
|
|
def host_name(self):
|
|
return 'localhost'
|
|
|
|
|
|
class UnpartitionedProjectUpdateEvent(ProjectUpdateEvent):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
|
|
UnpartitionedProjectUpdateEvent._meta.db_table = '_unpartitioned_' + ProjectUpdateEvent._meta.db_table # noqa
|
|
|
|
|
|
class BaseCommandEvent(CreatedModifiedModel):
|
|
"""
|
|
An event/message logged from a command for each host.
|
|
"""
|
|
|
|
VALID_KEYS = ['event_data', 'created', 'counter', 'uuid', 'stdout', 'start_line', 'end_line', 'verbosity']
|
|
WRAPUP_EVENT = 'EOF'
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
event_data = JSONBlob(default=dict, blank=True)
|
|
uuid = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
counter = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
stdout = models.TextField(
|
|
default='',
|
|
editable=False,
|
|
)
|
|
verbosity = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
start_line = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
end_line = models.PositiveIntegerField(
|
|
default=0,
|
|
editable=False,
|
|
)
|
|
created = models.DateTimeField(
|
|
null=True,
|
|
default=None,
|
|
editable=False,
|
|
)
|
|
modified = models.DateTimeField(
|
|
default=None,
|
|
editable=False,
|
|
db_index=True,
|
|
)
|
|
|
|
def __str__(self):
|
|
return u'%s @ %s' % (self.get_event_display(), self.created.isoformat())
|
|
|
|
@classmethod
|
|
def create_from_data(cls, **kwargs):
|
|
#
|
|
# ⚠️ D-D-D-DANGER ZONE ⚠️
|
|
# This function is called by the callback receiver *once* for *every
|
|
# event* emitted by Ansible as a playbook runs. That means that
|
|
# changes to this function are _very_ susceptible to introducing
|
|
# performance regressions (which the user will experience as "my
|
|
# playbook stdout takes too long to show up"), *especially* code which
|
|
# might invoke additional database queries per event.
|
|
#
|
|
# Proceed with caution!
|
|
#
|
|
# Convert the datetime for the event's creation
|
|
# appropriately, and include a time zone for it.
|
|
#
|
|
# In the event of any issue, throw it out, and Django will just save
|
|
# the current time.
|
|
try:
|
|
if not isinstance(kwargs['created'], datetime.datetime):
|
|
kwargs['created'] = parse_datetime(kwargs['created'])
|
|
if not kwargs['created'].tzinfo:
|
|
kwargs['created'] = kwargs['created'].replace(tzinfo=timezone.utc)
|
|
except (KeyError, ValueError):
|
|
kwargs.pop('created', None)
|
|
|
|
sanitize_event_keys(kwargs, cls.VALID_KEYS)
|
|
kwargs.pop('workflow_job_id', None)
|
|
event = cls(**kwargs)
|
|
event._update_from_event_data()
|
|
return event
|
|
|
|
def get_event_display(self):
|
|
"""
|
|
Needed for __unicode__
|
|
"""
|
|
return self.event
|
|
|
|
def get_event_display2(self):
|
|
return self.get_event_display()
|
|
|
|
def get_host_status_counts(self):
|
|
return create_host_status_counts(getattr(self, 'event_data', {}))
|
|
|
|
def _update_from_event_data(self):
|
|
pass
|
|
|
|
|
|
class AdHocCommandEvent(BaseCommandEvent):
|
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['ad_hoc_command_id', 'event', 'host_name', 'host_id', 'workflow_job_id', 'job_created']
|
|
WRAPUP_EVENT = 'playbook_on_stats' # exception to BaseCommandEvent
|
|
JOB_REFERENCE = 'ad_hoc_command_id'
|
|
|
|
objects = DeferJobCreatedManager()
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('-pk',)
|
|
indexes = [
|
|
models.Index(fields=['ad_hoc_command', 'job_created', 'event']),
|
|
models.Index(fields=['ad_hoc_command', 'job_created', 'uuid']),
|
|
models.Index(fields=['ad_hoc_command', 'job_created', 'counter']),
|
|
]
|
|
|
|
EVENT_TYPES = [
|
|
# (event, verbose name, failed)
|
|
('runner_on_failed', _('Host Failed'), True),
|
|
('runner_on_ok', _('Host OK'), False),
|
|
('runner_on_unreachable', _('Host Unreachable'), True),
|
|
# Tower won't see no_hosts (check is done earlier without callback).
|
|
# ('runner_on_no_hosts', _('No Hosts Matched'), False),
|
|
# Tower will see skipped (when running in check mode for a module that
|
|
# does not support check mode).
|
|
('runner_on_skipped', _('Host Skipped'), False),
|
|
# Tower does not support async for ad hoc commands (not used in v2).
|
|
# ('runner_on_async_poll', _('Host Polling'), False),
|
|
# ('runner_on_async_ok', _('Host Async OK'), False),
|
|
# ('runner_on_async_failed', _('Host Async Failure'), True),
|
|
# Tower does not yet support --diff mode.
|
|
# ('runner_on_file_diff', _('File Difference'), False),
|
|
# Additional event types for captured stdout not directly related to
|
|
# runner events.
|
|
('debug', _('Debug'), False),
|
|
('verbose', _('Verbose'), False),
|
|
('deprecated', _('Deprecated'), False),
|
|
('warning', _('Warning'), False),
|
|
('system_warning', _('System Warning'), False),
|
|
('error', _('Error'), False),
|
|
]
|
|
FAILED_EVENTS = [x[0] for x in EVENT_TYPES if x[2]]
|
|
EVENT_CHOICES = [(x[0], x[1]) for x in EVENT_TYPES]
|
|
|
|
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
|
event = models.CharField(
|
|
max_length=100,
|
|
choices=EVENT_CHOICES,
|
|
)
|
|
failed = models.BooleanField(
|
|
default=False,
|
|
editable=False,
|
|
)
|
|
changed = models.BooleanField(
|
|
default=False,
|
|
editable=False,
|
|
)
|
|
ad_hoc_command = models.ForeignKey(
|
|
'AdHocCommand',
|
|
related_name='ad_hoc_command_events',
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_index=False,
|
|
)
|
|
# We need to keep this as a FK in the model because AdHocCommand uses a ManyToMany field
|
|
# to hosts through adhoc_events. But in https://github.com/ansible/awx/pull/8236/ we
|
|
# removed the nulling of the field in case of a host going away before an event is saved
|
|
# so this needs to stay SET_NULL on the ORM level
|
|
host = models.ForeignKey(
|
|
'Host',
|
|
related_name='ad_hoc_command_events',
|
|
null=True,
|
|
default=None,
|
|
on_delete=models.SET_NULL,
|
|
editable=False,
|
|
db_constraint=False,
|
|
)
|
|
host_name = models.CharField(
|
|
max_length=1024,
|
|
default='',
|
|
editable=False,
|
|
)
|
|
job_created = models.DateTimeField(null=True, editable=False)
|
|
|
|
def get_absolute_url(self, request=None):
|
|
return reverse('api:ad_hoc_command_event_detail', kwargs={'pk': self.pk}, request=request)
|
|
|
|
def _update_from_event_data(self):
|
|
res = self.event_data.get('res', None)
|
|
if self.event in self.FAILED_EVENTS:
|
|
if not self.event_data.get('ignore_errors', False):
|
|
self.failed = True
|
|
if isinstance(res, dict) and res.get('changed', False):
|
|
self.changed = True
|
|
|
|
analytics_logger.info('Event data saved.', extra=dict(python_objects=dict(job_event=self)))
|
|
|
|
|
|
class UnpartitionedAdHocCommandEvent(AdHocCommandEvent):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
|
|
UnpartitionedAdHocCommandEvent._meta.db_table = '_unpartitioned_' + AdHocCommandEvent._meta.db_table # noqa
|
|
|
|
|
|
class InventoryUpdateEvent(BaseCommandEvent):
|
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['inventory_update_id', 'workflow_job_id', 'job_created']
|
|
JOB_REFERENCE = 'inventory_update_id'
|
|
|
|
objects = DeferJobCreatedManager()
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('-pk',)
|
|
indexes = [
|
|
models.Index(fields=['inventory_update', 'job_created', 'uuid']),
|
|
models.Index(fields=['inventory_update', 'job_created', 'counter']),
|
|
]
|
|
|
|
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
|
inventory_update = models.ForeignKey(
|
|
'InventoryUpdate',
|
|
related_name='inventory_update_events',
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_index=False,
|
|
)
|
|
job_created = models.DateTimeField(null=True, editable=False)
|
|
|
|
@property
|
|
def event(self):
|
|
return 'verbose'
|
|
|
|
@property
|
|
def failed(self):
|
|
return False
|
|
|
|
@property
|
|
def changed(self):
|
|
return False
|
|
|
|
|
|
class UnpartitionedInventoryUpdateEvent(InventoryUpdateEvent):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
|
|
UnpartitionedInventoryUpdateEvent._meta.db_table = '_unpartitioned_' + InventoryUpdateEvent._meta.db_table # noqa
|
|
|
|
|
|
class SystemJobEvent(BaseCommandEvent):
|
|
VALID_KEYS = BaseCommandEvent.VALID_KEYS + ['system_job_id', 'job_created']
|
|
JOB_REFERENCE = 'system_job_id'
|
|
|
|
objects = DeferJobCreatedManager()
|
|
|
|
class Meta:
|
|
app_label = 'main'
|
|
ordering = ('-pk',)
|
|
indexes = [
|
|
models.Index(fields=['system_job', 'job_created', 'uuid']),
|
|
models.Index(fields=['system_job', 'job_created', 'counter']),
|
|
]
|
|
|
|
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
|
system_job = models.ForeignKey(
|
|
'SystemJob',
|
|
related_name='system_job_events',
|
|
on_delete=models.DO_NOTHING,
|
|
editable=False,
|
|
db_index=False,
|
|
)
|
|
job_created = models.DateTimeField(null=True, editable=False)
|
|
|
|
@property
|
|
def event(self):
|
|
return 'verbose'
|
|
|
|
@property
|
|
def failed(self):
|
|
return False
|
|
|
|
@property
|
|
def changed(self):
|
|
return False
|
|
|
|
|
|
class UnpartitionedSystemJobEvent(SystemJobEvent):
|
|
class Meta:
|
|
proxy = True
|
|
|
|
|
|
UnpartitionedSystemJobEvent._meta.db_table = '_unpartitioned_' + SystemJobEvent._meta.db_table # noqa
|