mirror of
https://github.com/ZwareBear/awx.git
synced 2026-05-13 07:48:39 -05:00
Merge pull request #13560 from slemrmartin/host-metric-summary-api
[usage-collection] HostMetric + HostMetricSummary db & api
This commit is contained in:
@@ -55,6 +55,8 @@ from awx.main.models import (
|
|||||||
ExecutionEnvironment,
|
ExecutionEnvironment,
|
||||||
Group,
|
Group,
|
||||||
Host,
|
Host,
|
||||||
|
HostMetric,
|
||||||
|
HostMetricSummaryMonthly,
|
||||||
Instance,
|
Instance,
|
||||||
InstanceGroup,
|
InstanceGroup,
|
||||||
InstanceLink,
|
InstanceLink,
|
||||||
@@ -5002,6 +5004,32 @@ class InstanceHealthCheckSerializer(BaseSerializer):
|
|||||||
fields = read_only_fields
|
fields = read_only_fields
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricSerializer(BaseSerializer):
|
||||||
|
show_capabilities = ['delete']
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = HostMetric
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"hostname",
|
||||||
|
"url",
|
||||||
|
"first_automation",
|
||||||
|
"last_automation",
|
||||||
|
"last_deleted",
|
||||||
|
"automated_counter",
|
||||||
|
"deleted_counter",
|
||||||
|
"deleted",
|
||||||
|
"used_in_inventories",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricSummaryMonthlySerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = HostMetricSummaryMonthly
|
||||||
|
read_only_fields = ("id", "date", "license_consumed", "license_capacity", "hosts_added", "hosts_deleted", "indirectly_managed_hosts")
|
||||||
|
fields = read_only_fields
|
||||||
|
|
||||||
|
|
||||||
class InstanceGroupSerializer(BaseSerializer):
|
class InstanceGroupSerializer(BaseSerializer):
|
||||||
show_capabilities = ['edit', 'delete']
|
show_capabilities = ['edit', 'delete']
|
||||||
capacity = serializers.SerializerMethodField()
|
capacity = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% ifmeth GET %}
|
||||||
|
# Retrieve {{ model_verbose_name|title|anora }}:
|
||||||
|
|
||||||
|
Make GET request to this resource to retrieve a single {{ model_verbose_name }}
|
||||||
|
record containing the following fields:
|
||||||
|
|
||||||
|
{% include "api/_result_fields_common.md" %}
|
||||||
|
{% endifmeth %}
|
||||||
|
|
||||||
|
{% ifmeth DELETE %}
|
||||||
|
# Delete {{ model_verbose_name|title|anora }}:
|
||||||
|
|
||||||
|
Make a DELETE request to this resource to soft-delete this {{ model_verbose_name }}.
|
||||||
|
|
||||||
|
A soft deletion will mark the `deleted` field as true and exclude the host
|
||||||
|
metric from license calculations.
|
||||||
|
This may be undone later if the same hostname is automated again afterwards.
|
||||||
|
{% endifmeth %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Intended Use Case
|
||||||
|
|
||||||
|
To get summaries from a certain day or earlier, you can filter this
|
||||||
|
endpoint in the following way.
|
||||||
|
|
||||||
|
?date__gte=2023-01-01
|
||||||
|
|
||||||
|
This will return summaries that were produced on that date or later.
|
||||||
|
These host metric monthly summaries should be automatically produced
|
||||||
|
by a background task that runs once each month.
|
||||||
|
|
||||||
|
{% include "api/list_api_view.md" %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Copyright (c) 2017 Ansible, Inc.
|
||||||
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from awx.api.views import HostMetricList, HostMetricDetail
|
||||||
|
|
||||||
|
urls = [re_path(r'^$', HostMetricList.as_view(), name='host_metric_list'), re_path(r'^(?P<pk>[0-9]+)/$', HostMetricDetail.as_view(), name='host_metric_detail')]
|
||||||
|
|
||||||
|
__all__ = ['urls']
|
||||||
@@ -30,6 +30,7 @@ from awx.api.views import (
|
|||||||
OAuth2TokenList,
|
OAuth2TokenList,
|
||||||
ApplicationOAuth2TokenList,
|
ApplicationOAuth2TokenList,
|
||||||
OAuth2ApplicationDetail,
|
OAuth2ApplicationDetail,
|
||||||
|
HostMetricSummaryMonthlyList,
|
||||||
)
|
)
|
||||||
from awx.api.views.mesh_visualizer import MeshVisualizer
|
from awx.api.views.mesh_visualizer import MeshVisualizer
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ from .inventory import urls as inventory_urls
|
|||||||
from .execution_environments import urls as execution_environment_urls
|
from .execution_environments import urls as execution_environment_urls
|
||||||
from .team import urls as team_urls
|
from .team import urls as team_urls
|
||||||
from .host import urls as host_urls
|
from .host import urls as host_urls
|
||||||
|
from .host_metric import urls as host_metric_urls
|
||||||
from .group import urls as group_urls
|
from .group import urls as group_urls
|
||||||
from .inventory_source import urls as inventory_source_urls
|
from .inventory_source import urls as inventory_source_urls
|
||||||
from .inventory_update import urls as inventory_update_urls
|
from .inventory_update import urls as inventory_update_urls
|
||||||
@@ -111,6 +113,8 @@ v2_urls = [
|
|||||||
re_path(r'^teams/', include(team_urls)),
|
re_path(r'^teams/', include(team_urls)),
|
||||||
re_path(r'^inventories/', include(inventory_urls)),
|
re_path(r'^inventories/', include(inventory_urls)),
|
||||||
re_path(r'^hosts/', include(host_urls)),
|
re_path(r'^hosts/', include(host_urls)),
|
||||||
|
re_path(r'^host_metrics/', include(host_metric_urls)),
|
||||||
|
re_path(r'^host_metric_summary_monthly/$', HostMetricSummaryMonthlyList.as_view(), name='host_metric_summary_monthly_list'),
|
||||||
re_path(r'^groups/', include(group_urls)),
|
re_path(r'^groups/', include(group_urls)),
|
||||||
re_path(r'^inventory_sources/', include(inventory_source_urls)),
|
re_path(r'^inventory_sources/', include(inventory_source_urls)),
|
||||||
re_path(r'^inventory_updates/', include(inventory_update_urls)),
|
re_path(r'^inventory_updates/', include(inventory_update_urls)),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from collections import OrderedDict
|
|||||||
|
|
||||||
from urllib3.exceptions import ConnectTimeoutError
|
from urllib3.exceptions import ConnectTimeoutError
|
||||||
|
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import FieldError, ObjectDoesNotExist
|
from django.core.exceptions import FieldError, ObjectDoesNotExist
|
||||||
@@ -1531,6 +1530,40 @@ class HostRelatedSearchMixin(object):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricList(ListAPIView):
|
||||||
|
name = _("Host Metrics List")
|
||||||
|
model = models.HostMetric
|
||||||
|
serializer_class = serializers.HostMetricSerializer
|
||||||
|
permission_classes = (IsSystemAdminOrAuditor,)
|
||||||
|
search_fields = ('hostname', 'deleted')
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.model.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricDetail(RetrieveDestroyAPIView):
|
||||||
|
name = _("Host Metric Detail")
|
||||||
|
model = models.HostMetric
|
||||||
|
serializer_class = serializers.HostMetricSerializer
|
||||||
|
permission_classes = (IsSystemAdminOrAuditor,)
|
||||||
|
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
self.get_object().soft_delete()
|
||||||
|
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricSummaryMonthlyList(ListAPIView):
|
||||||
|
name = _("Host Metrics Summary Monthly")
|
||||||
|
model = models.HostMetricSummaryMonthly
|
||||||
|
serializer_class = serializers.HostMetricSummaryMonthlySerializer
|
||||||
|
permission_classes = (IsSystemAdminOrAuditor,)
|
||||||
|
search_fields = ('date',)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.model.objects.all()
|
||||||
|
|
||||||
|
|
||||||
class HostList(HostRelatedSearchMixin, ListCreateAPIView):
|
class HostList(HostRelatedSearchMixin, ListCreateAPIView):
|
||||||
always_allow_superuser = False
|
always_allow_superuser = False
|
||||||
model = models.Host
|
model = models.Host
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ class ApiVersionRootView(APIView):
|
|||||||
data['inventory_updates'] = reverse('api:inventory_update_list', request=request)
|
data['inventory_updates'] = reverse('api:inventory_update_list', request=request)
|
||||||
data['groups'] = reverse('api:group_list', request=request)
|
data['groups'] = reverse('api:group_list', request=request)
|
||||||
data['hosts'] = reverse('api:host_list', request=request)
|
data['hosts'] = reverse('api:host_list', request=request)
|
||||||
|
data['host_metrics'] = reverse('api:host_metric_list', request=request)
|
||||||
|
data['host_metric_summary_monthly'] = reverse('api:host_metric_summary_monthly_list', request=request)
|
||||||
data['job_templates'] = reverse('api:job_template_list', request=request)
|
data['job_templates'] = reverse('api:job_template_list', request=request)
|
||||||
data['jobs'] = reverse('api:job_list', request=request)
|
data['jobs'] = reverse('api:job_list', request=request)
|
||||||
data['ad_hoc_commands'] = reverse('api:ad_hoc_command_list', request=request)
|
data['ad_hoc_commands'] = reverse('api:ad_hoc_command_list', request=request)
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ class HostManager(models.Manager):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricActiveManager(models.Manager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(deleted=False)
|
||||||
|
|
||||||
|
|
||||||
def get_ig_ig_mapping(ig_instance_mapping, instance_ig_mapping):
|
def get_ig_ig_mapping(ig_instance_mapping, instance_ig_mapping):
|
||||||
# Create IG mapping by union of all groups their instances are members of
|
# Create IG mapping by union of all groups their instances are members of
|
||||||
ig_ig_mapping = {}
|
ig_ig_mapping = {}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 3.2.16 on 2023-02-03 09:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('main', '0174_ensure_org_ee_admin_roles'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(model_name='hostmetric', name='hostname', field=models.CharField(max_length=512, primary_key=False, serialize=True, unique=True)),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric',
|
||||||
|
name='last_deleted',
|
||||||
|
field=models.DateTimeField(db_index=True, null=True, help_text='When the host was last deleted'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric',
|
||||||
|
name='automated_counter',
|
||||||
|
field=models.BigIntegerField(default=0, help_text='How many times was the host automated'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric',
|
||||||
|
name='deleted_counter',
|
||||||
|
field=models.IntegerField(default=0, help_text='How many times was the host deleted'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric',
|
||||||
|
name='deleted',
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text='Boolean flag saying whether the host is deleted and therefore not counted into the subscription consumption'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric',
|
||||||
|
name='used_in_inventories',
|
||||||
|
field=models.IntegerField(null=True, help_text='How many inventories contain this host'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='hostmetric', name='id', field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.2.16 on 2023-02-10 12:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('main', '0175_add_hostmetric_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HostMetricSummaryMonthly',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('date', models.DateField(unique=True)),
|
||||||
|
('license_consumed', models.BigIntegerField(default=0, help_text='How many unique hosts are consumed from the license')),
|
||||||
|
('license_capacity', models.BigIntegerField(default=0, help_text="'License capacity as max. number of unique hosts")),
|
||||||
|
(
|
||||||
|
'hosts_added',
|
||||||
|
models.IntegerField(default=0, help_text='How many hosts were added in the associated month, consuming more license capacity'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'hosts_deleted',
|
||||||
|
models.IntegerField(default=0, help_text='How many hosts were deleted in the associated month, freeing the license capacity'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'indirectly_managed_hosts',
|
||||||
|
models.IntegerField(default=0, help_text='Manually entered number indirectly managed hosts for a certain month'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -16,6 +16,7 @@ from awx.main.models.inventory import ( # noqa
|
|||||||
Group,
|
Group,
|
||||||
Host,
|
Host,
|
||||||
HostMetric,
|
HostMetric,
|
||||||
|
HostMetricSummaryMonthly,
|
||||||
Inventory,
|
Inventory,
|
||||||
InventorySource,
|
InventorySource,
|
||||||
InventoryUpdate,
|
InventoryUpdate,
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ class JobEvent(BasePlaybookEvent):
|
|||||||
return
|
return
|
||||||
job = self.job
|
job = self.job
|
||||||
|
|
||||||
from awx.main.models import Host, JobHostSummary, HostMetric # circular import
|
from awx.main.models import Host, JobHostSummary # circular import
|
||||||
|
|
||||||
all_hosts = Host.objects.filter(pk__in=self.host_map.values()).only('id', 'name')
|
all_hosts = Host.objects.filter(pk__in=self.host_map.values()).only('id', 'name')
|
||||||
existing_host_ids = set(h.id for h in all_hosts)
|
existing_host_ids = set(h.id for h in all_hosts)
|
||||||
@@ -575,12 +575,26 @@ class JobEvent(BasePlaybookEvent):
|
|||||||
|
|
||||||
Host.objects.bulk_update(list(updated_hosts), ['last_job_id', 'last_job_host_summary_id'], batch_size=100)
|
Host.objects.bulk_update(list(updated_hosts), ['last_job_id', 'last_job_host_summary_id'], batch_size=100)
|
||||||
|
|
||||||
# bulk-create
|
# Create/update Host Metrics
|
||||||
current_time = now()
|
self._update_host_metrics(updated_hosts_list)
|
||||||
HostMetric.objects.bulk_create(
|
|
||||||
[HostMetric(hostname=hostname, last_automation=current_time) for hostname in updated_hosts_list], ignore_conflicts=True, batch_size=100
|
@staticmethod
|
||||||
|
def _update_host_metrics(updated_hosts_list):
|
||||||
|
from awx.main.models import HostMetric # circular import
|
||||||
|
|
||||||
|
# bulk-create
|
||||||
|
current_time = now()
|
||||||
|
HostMetric.objects.bulk_create(
|
||||||
|
[HostMetric(hostname=hostname, last_automation=current_time) for hostname in updated_hosts_list], ignore_conflicts=True, batch_size=100
|
||||||
|
)
|
||||||
|
# bulk-update
|
||||||
|
batch_start, batch_size = 0, 1000
|
||||||
|
while batch_start <= len(updated_hosts_list):
|
||||||
|
batched_host_list = updated_hosts_list[batch_start : (batch_start + batch_size)]
|
||||||
|
HostMetric.objects.filter(hostname__in=batched_host_list).update(
|
||||||
|
last_automation=current_time, automated_counter=models.F('automated_counter') + 1, deleted=False
|
||||||
)
|
)
|
||||||
HostMetric.objects.filter(hostname__in=updated_hosts_list).update(last_automation=current_time)
|
batch_start += batch_size
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def job_verbosity(self):
|
def job_verbosity(self):
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from awx.main.fields import (
|
|||||||
SmartFilterField,
|
SmartFilterField,
|
||||||
OrderedManyToManyField,
|
OrderedManyToManyField,
|
||||||
)
|
)
|
||||||
from awx.main.managers import HostManager
|
from awx.main.managers import HostManager, HostMetricActiveManager
|
||||||
from awx.main.models.base import BaseModel, CommonModelNameNotUnique, VarsDictProperty, CLOUD_INVENTORY_SOURCES, prevent_search, accepts_json
|
from awx.main.models.base import BaseModel, CommonModelNameNotUnique, VarsDictProperty, CLOUD_INVENTORY_SOURCES, prevent_search, accepts_json
|
||||||
from awx.main.models.events import InventoryUpdateEvent, UnpartitionedInventoryUpdateEvent
|
from awx.main.models.events import InventoryUpdateEvent, UnpartitionedInventoryUpdateEvent
|
||||||
from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate
|
from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate
|
||||||
@@ -53,7 +53,7 @@ from awx.main.utils.execution_environments import to_container_path
|
|||||||
from awx.main.utils.licensing import server_product_name
|
from awx.main.utils.licensing import server_product_name
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'SmartInventoryMembership']
|
__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'SmartInventoryMembership', 'HostMetric', 'HostMetricSummaryMonthly']
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.models.inventory')
|
logger = logging.getLogger('awx.main.models.inventory')
|
||||||
|
|
||||||
@@ -820,9 +820,47 @@ class Group(CommonModelNameNotUnique, RelatedJobsMixin):
|
|||||||
|
|
||||||
|
|
||||||
class HostMetric(models.Model):
|
class HostMetric(models.Model):
|
||||||
hostname = models.CharField(primary_key=True, max_length=512)
|
hostname = models.CharField(unique=True, max_length=512)
|
||||||
first_automation = models.DateTimeField(auto_now_add=True, null=False, db_index=True, help_text=_('When the host was first automated against'))
|
first_automation = models.DateTimeField(auto_now_add=True, null=False, db_index=True, help_text=_('When the host was first automated against'))
|
||||||
last_automation = models.DateTimeField(db_index=True, help_text=_('When the host was last automated against'))
|
last_automation = models.DateTimeField(db_index=True, help_text=_('When the host was last automated against'))
|
||||||
|
last_deleted = models.DateTimeField(null=True, db_index=True, help_text=_('When the host was last deleted'))
|
||||||
|
automated_counter = models.BigIntegerField(default=0, help_text=_('How many times was the host automated'))
|
||||||
|
deleted_counter = models.IntegerField(default=0, help_text=_('How many times was the host deleted'))
|
||||||
|
deleted = models.BooleanField(
|
||||||
|
default=False, help_text=_('Boolean flag saying whether the host is deleted and therefore not counted into the subscription consumption')
|
||||||
|
)
|
||||||
|
used_in_inventories = models.IntegerField(null=True, help_text=_('How many inventories contain this host'))
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
active_objects = HostMetricActiveManager()
|
||||||
|
|
||||||
|
def get_absolute_url(self, request=None):
|
||||||
|
return reverse('api:host_metric_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
|
def soft_delete(self):
|
||||||
|
if not self.deleted:
|
||||||
|
self.deleted_counter = (self.deleted_counter or 0) + 1
|
||||||
|
self.last_deleted = now()
|
||||||
|
self.deleted = True
|
||||||
|
self.save(update_fields=['deleted', 'deleted_counter', 'last_deleted'])
|
||||||
|
|
||||||
|
def soft_restore(self):
|
||||||
|
if self.deleted:
|
||||||
|
self.deleted = False
|
||||||
|
self.save(update_fields=['deleted'])
|
||||||
|
|
||||||
|
|
||||||
|
class HostMetricSummaryMonthly(models.Model):
|
||||||
|
"""
|
||||||
|
HostMetric summaries computed by scheduled task <TODO> monthly
|
||||||
|
"""
|
||||||
|
|
||||||
|
date = models.DateField(unique=True)
|
||||||
|
license_consumed = models.BigIntegerField(default=0, help_text=_("How many unique hosts are consumed from the license"))
|
||||||
|
license_capacity = models.BigIntegerField(default=0, help_text=_("'License capacity as max. number of unique hosts"))
|
||||||
|
hosts_added = models.IntegerField(default=0, help_text=_("How many hosts were added in the associated month, consuming more license capacity"))
|
||||||
|
hosts_deleted = models.IntegerField(default=0, help_text=_("How many hosts were deleted in the associated month, freeing the license capacity"))
|
||||||
|
indirectly_managed_hosts = models.IntegerField(default=0, help_text=("Manually entered number indirectly managed hosts for a certain month"))
|
||||||
|
|
||||||
|
|
||||||
class InventorySourceOptions(BaseModel):
|
class InventorySourceOptions(BaseModel):
|
||||||
|
|||||||
@@ -3,178 +3,209 @@ import pytest
|
|||||||
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from awx.main.models import Job, JobEvent, Inventory, Host, JobHostSummary
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from awx.main.models import Job, JobEvent, Inventory, Host, JobHostSummary, HostMetric
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@mock.patch('awx.main.models.events.emit_event_detail')
|
class TestEvents:
|
||||||
def test_parent_changed(emit):
|
def setup_method(self):
|
||||||
j = Job()
|
self.hostnames = []
|
||||||
j.save()
|
self.host_map = dict()
|
||||||
JobEvent.create_from_data(job_id=j.pk, uuid='abc123', event='playbook_on_task_start').save()
|
self.inventory = None
|
||||||
assert JobEvent.objects.count() == 1
|
self.job = None
|
||||||
for e in JobEvent.objects.all():
|
|
||||||
assert e.changed is False
|
|
||||||
|
|
||||||
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='runner_on_ok', event_data={'res': {'changed': ['localhost']}}).save()
|
@mock.patch('awx.main.models.events.emit_event_detail')
|
||||||
# the `playbook_on_stats` event is where we update the parent changed linkage
|
def test_parent_changed(self, emit):
|
||||||
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='playbook_on_stats').save()
|
j = Job()
|
||||||
events = JobEvent.objects.filter(event__in=['playbook_on_task_start', 'runner_on_ok'])
|
j.save()
|
||||||
assert events.count() == 2
|
JobEvent.create_from_data(job_id=j.pk, uuid='abc123', event='playbook_on_task_start').save()
|
||||||
for e in events.all():
|
assert JobEvent.objects.count() == 1
|
||||||
assert e.changed is True
|
for e in JobEvent.objects.all():
|
||||||
|
assert e.changed is False
|
||||||
|
|
||||||
|
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='runner_on_ok', event_data={'res': {'changed': ['localhost']}}).save()
|
||||||
|
# the `playbook_on_stats` event is where we update the parent changed linkage
|
||||||
|
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='playbook_on_stats').save()
|
||||||
|
events = JobEvent.objects.filter(event__in=['playbook_on_task_start', 'runner_on_ok'])
|
||||||
|
assert events.count() == 2
|
||||||
|
for e in events.all():
|
||||||
|
assert e.changed is True
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.parametrize('event', JobEvent.FAILED_EVENTS)
|
||||||
@pytest.mark.parametrize('event', JobEvent.FAILED_EVENTS)
|
@mock.patch('awx.main.models.events.emit_event_detail')
|
||||||
@mock.patch('awx.main.models.events.emit_event_detail')
|
def test_parent_failed(self, emit, event):
|
||||||
def test_parent_failed(emit, event):
|
j = Job()
|
||||||
j = Job()
|
j.save()
|
||||||
j.save()
|
JobEvent.create_from_data(job_id=j.pk, uuid='abc123', event='playbook_on_task_start').save()
|
||||||
JobEvent.create_from_data(job_id=j.pk, uuid='abc123', event='playbook_on_task_start').save()
|
assert JobEvent.objects.count() == 1
|
||||||
assert JobEvent.objects.count() == 1
|
for e in JobEvent.objects.all():
|
||||||
for e in JobEvent.objects.all():
|
assert e.failed is False
|
||||||
assert e.failed is False
|
|
||||||
|
|
||||||
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event=event).save()
|
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event=event).save()
|
||||||
|
|
||||||
# the `playbook_on_stats` event is where we update the parent failed linkage
|
# the `playbook_on_stats` event is where we update the parent failed linkage
|
||||||
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='playbook_on_stats').save()
|
JobEvent.create_from_data(job_id=j.pk, parent_uuid='abc123', event='playbook_on_stats').save()
|
||||||
events = JobEvent.objects.filter(event__in=['playbook_on_task_start', event])
|
events = JobEvent.objects.filter(event__in=['playbook_on_task_start', event])
|
||||||
assert events.count() == 2
|
assert events.count() == 2
|
||||||
for e in events.all():
|
for e in events.all():
|
||||||
assert e.failed is True
|
assert e.failed is True
|
||||||
|
|
||||||
|
def test_host_summary_generation(self):
|
||||||
|
self._generate_hosts(100)
|
||||||
|
self._create_job_event(ok=dict((hostname, len(hostname)) for hostname in self.hostnames))
|
||||||
|
|
||||||
@pytest.mark.django_db
|
assert self.job.job_host_summaries.count() == len(self.hostnames)
|
||||||
def test_host_summary_generation():
|
assert sorted([s.host_name for s in self.job.job_host_summaries.all()]) == sorted(self.hostnames)
|
||||||
hostnames = [f'Host {i}' for i in range(100)]
|
|
||||||
inv = Inventory()
|
for s in self.job.job_host_summaries.all():
|
||||||
inv.save()
|
assert self.host_map[s.host_name] == s.host_id
|
||||||
Host.objects.bulk_create([Host(created=now(), modified=now(), name=h, inventory_id=inv.id) for h in hostnames])
|
assert s.ok == len(s.host_name)
|
||||||
j = Job(inventory=inv)
|
assert s.changed == 0
|
||||||
j.save()
|
assert s.dark == 0
|
||||||
host_map = dict((host.name, host.id) for host in inv.hosts.all())
|
assert s.failures == 0
|
||||||
JobEvent.create_from_data(
|
assert s.ignored == 0
|
||||||
job_id=j.pk,
|
assert s.processed == 0
|
||||||
|
assert s.rescued == 0
|
||||||
|
assert s.skipped == 0
|
||||||
|
|
||||||
|
for host in Host.objects.all():
|
||||||
|
assert host.last_job_id == self.job.id
|
||||||
|
assert host.last_job_host_summary.host == host
|
||||||
|
|
||||||
|
def test_host_summary_generation_with_deleted_hosts(self):
|
||||||
|
self._generate_hosts(10)
|
||||||
|
|
||||||
|
# delete half of the hosts during the playbook run
|
||||||
|
for h in self.inventory.hosts.all()[:5]:
|
||||||
|
h.delete()
|
||||||
|
|
||||||
|
self._create_job_event(ok=dict((hostname, len(hostname)) for hostname in self.hostnames))
|
||||||
|
|
||||||
|
ids = sorted([s.host_id or -1 for s in self.job.job_host_summaries.order_by('id').all()])
|
||||||
|
names = sorted([s.host_name for s in self.job.job_host_summaries.all()])
|
||||||
|
assert ids == [-1, -1, -1, -1, -1, 6, 7, 8, 9, 10]
|
||||||
|
assert names == ['Host 0', 'Host 1', 'Host 2', 'Host 3', 'Host 4', 'Host 5', 'Host 6', 'Host 7', 'Host 8', 'Host 9']
|
||||||
|
|
||||||
|
def test_host_summary_generation_with_limit(self):
|
||||||
|
# Make an inventory with 10 hosts, run a playbook with a --limit
|
||||||
|
# pointed at *one* host,
|
||||||
|
# Verify that *only* that host has an associated JobHostSummary and that
|
||||||
|
# *only* that host has an updated value for .last_job.
|
||||||
|
self._generate_hosts(10)
|
||||||
|
|
||||||
|
# by making the playbook_on_stats *only* include Host 1, we're emulating
|
||||||
|
# the behavior of a `--limit=Host 1`
|
||||||
|
matching_host = Host.objects.get(name='Host 1')
|
||||||
|
self._create_job_event(ok={matching_host.name: len(matching_host.name)}) # effectively, limit=Host 1
|
||||||
|
|
||||||
|
# since the playbook_on_stats only references one host,
|
||||||
|
# there should *only* be on JobHostSummary record (and it should
|
||||||
|
# be related to the appropriate Host)
|
||||||
|
assert JobHostSummary.objects.count() == 1
|
||||||
|
for h in Host.objects.all():
|
||||||
|
if h.name == 'Host 1':
|
||||||
|
assert h.last_job_id == self.job.id
|
||||||
|
assert h.last_job_host_summary_id == JobHostSummary.objects.first().id
|
||||||
|
else:
|
||||||
|
# all other hosts in the inventory should remain untouched
|
||||||
|
assert h.last_job_id is None
|
||||||
|
assert h.last_job_host_summary_id is None
|
||||||
|
|
||||||
|
def test_host_metrics_insert(self):
|
||||||
|
self._generate_hosts(10)
|
||||||
|
|
||||||
|
self._create_job_event(
|
||||||
|
ok=dict((hostname, len(hostname)) for hostname in self.hostnames[0:3]),
|
||||||
|
failures=dict((hostname, len(hostname)) for hostname in self.hostnames[3:6]),
|
||||||
|
processed=dict((hostname, len(hostname)) for hostname in self.hostnames[6:9]),
|
||||||
|
skipped=dict((hostname, len(hostname)) for hostname in [self.hostnames[9]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = HostMetric.objects.all()
|
||||||
|
assert len(metrics) == 10
|
||||||
|
for hm in metrics:
|
||||||
|
assert hm.automated_counter == 1
|
||||||
|
assert hm.last_automation is not None
|
||||||
|
assert hm.deleted is False
|
||||||
|
|
||||||
|
def test_host_metrics_update(self):
|
||||||
|
self._generate_hosts(12)
|
||||||
|
|
||||||
|
self._create_job_event(ok=dict((hostname, len(hostname)) for hostname in self.hostnames))
|
||||||
|
|
||||||
|
# Soft delete 6 host metrics
|
||||||
|
for hm in HostMetric.objects.filter(id__in=[1, 3, 5, 7, 9, 11]):
|
||||||
|
hm.soft_delete()
|
||||||
|
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=0) & Q(last_deleted__isnull=True))) == 6
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=True) & Q(deleted_counter=1) & Q(last_deleted__isnull=False))) == 6
|
||||||
|
|
||||||
|
# hostnames in 'ignored' and 'rescued' stats are ignored
|
||||||
|
self.job = Job(inventory=self.inventory)
|
||||||
|
self.job.save()
|
||||||
|
self._create_job_event(
|
||||||
|
ignored=dict((hostname, len(hostname)) for hostname in self.hostnames[0:6]),
|
||||||
|
rescued=dict((hostname, len(hostname)) for hostname in self.hostnames[6:11]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=0) & Q(last_deleted__isnull=True))) == 6
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=True) & Q(deleted_counter=1) & Q(last_deleted__isnull=False))) == 6
|
||||||
|
|
||||||
|
# hostnames in 'changed', 'dark', 'failures', 'ok', 'processed', 'skipped' are processed
|
||||||
|
self.job = Job(inventory=self.inventory)
|
||||||
|
self.job.save()
|
||||||
|
self._create_job_event(
|
||||||
|
changed=dict((hostname, len(hostname)) for hostname in self.hostnames[0:2]),
|
||||||
|
dark=dict((hostname, len(hostname)) for hostname in self.hostnames[2:4]),
|
||||||
|
failures=dict((hostname, len(hostname)) for hostname in self.hostnames[4:6]),
|
||||||
|
ok=dict((hostname, len(hostname)) for hostname in self.hostnames[6:8]),
|
||||||
|
processed=dict((hostname, len(hostname)) for hostname in self.hostnames[8:10]),
|
||||||
|
skipped=dict((hostname, len(hostname)) for hostname in self.hostnames[10:12]),
|
||||||
|
)
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=0) & Q(last_deleted__isnull=True))) == 6
|
||||||
|
assert len(HostMetric.objects.filter(Q(deleted=False) & Q(deleted_counter=1) & Q(last_deleted__isnull=False))) == 6
|
||||||
|
|
||||||
|
def _generate_hosts(self, cnt, id_from=0):
|
||||||
|
self.hostnames = [f'Host {i}' for i in range(id_from, id_from + cnt)]
|
||||||
|
self.inventory = Inventory()
|
||||||
|
self.inventory.save()
|
||||||
|
Host.objects.bulk_create([Host(created=now(), modified=now(), name=h, inventory_id=self.inventory.id) for h in self.hostnames])
|
||||||
|
self.job = Job(inventory=self.inventory)
|
||||||
|
self.job.save()
|
||||||
|
|
||||||
|
# host map is a data structure that tracks a mapping of host name --> ID
|
||||||
|
# for the inventory, _regardless_ of whether or not there's a limit
|
||||||
|
# applied to the actual playbook run
|
||||||
|
self.host_map = dict((host.name, host.id) for host in self.inventory.hosts.all())
|
||||||
|
|
||||||
|
def _create_job_event(
|
||||||
|
self,
|
||||||
parent_uuid='abc123',
|
parent_uuid='abc123',
|
||||||
event='playbook_on_stats',
|
event='playbook_on_stats',
|
||||||
event_data={
|
ok=None,
|
||||||
'ok': dict((hostname, len(hostname)) for hostname in hostnames),
|
changed=None,
|
||||||
'changed': {},
|
dark=None,
|
||||||
'dark': {},
|
failures=None,
|
||||||
'failures': {},
|
ignored=None,
|
||||||
'ignored': {},
|
processed=None,
|
||||||
'processed': {},
|
rescued=None,
|
||||||
'rescued': {},
|
skipped=None,
|
||||||
'skipped': {},
|
):
|
||||||
},
|
JobEvent.create_from_data(
|
||||||
host_map=host_map,
|
job_id=self.job.pk,
|
||||||
).save()
|
parent_uuid=parent_uuid,
|
||||||
|
event=event,
|
||||||
assert j.job_host_summaries.count() == len(hostnames)
|
event_data={
|
||||||
assert sorted([s.host_name for s in j.job_host_summaries.all()]) == sorted(hostnames)
|
'ok': ok or {},
|
||||||
|
'changed': changed or {},
|
||||||
for s in j.job_host_summaries.all():
|
'dark': dark or {},
|
||||||
assert host_map[s.host_name] == s.host_id
|
'failures': failures or {},
|
||||||
assert s.ok == len(s.host_name)
|
'ignored': ignored or {},
|
||||||
assert s.changed == 0
|
'processed': processed or {},
|
||||||
assert s.dark == 0
|
'rescued': rescued or {},
|
||||||
assert s.failures == 0
|
'skipped': skipped or {},
|
||||||
assert s.ignored == 0
|
},
|
||||||
assert s.processed == 0
|
host_map=self.host_map,
|
||||||
assert s.rescued == 0
|
).save()
|
||||||
assert s.skipped == 0
|
|
||||||
|
|
||||||
for host in Host.objects.all():
|
|
||||||
assert host.last_job_id == j.id
|
|
||||||
assert host.last_job_host_summary.host == host
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_host_summary_generation_with_deleted_hosts():
|
|
||||||
hostnames = [f'Host {i}' for i in range(10)]
|
|
||||||
inv = Inventory()
|
|
||||||
inv.save()
|
|
||||||
Host.objects.bulk_create([Host(created=now(), modified=now(), name=h, inventory_id=inv.id) for h in hostnames])
|
|
||||||
j = Job(inventory=inv)
|
|
||||||
j.save()
|
|
||||||
host_map = dict((host.name, host.id) for host in inv.hosts.all())
|
|
||||||
|
|
||||||
# delete half of the hosts during the playbook run
|
|
||||||
for h in inv.hosts.all()[:5]:
|
|
||||||
h.delete()
|
|
||||||
|
|
||||||
JobEvent.create_from_data(
|
|
||||||
job_id=j.pk,
|
|
||||||
parent_uuid='abc123',
|
|
||||||
event='playbook_on_stats',
|
|
||||||
event_data={
|
|
||||||
'ok': dict((hostname, len(hostname)) for hostname in hostnames),
|
|
||||||
'changed': {},
|
|
||||||
'dark': {},
|
|
||||||
'failures': {},
|
|
||||||
'ignored': {},
|
|
||||||
'processed': {},
|
|
||||||
'rescued': {},
|
|
||||||
'skipped': {},
|
|
||||||
},
|
|
||||||
host_map=host_map,
|
|
||||||
).save()
|
|
||||||
|
|
||||||
ids = sorted([s.host_id or -1 for s in j.job_host_summaries.order_by('id').all()])
|
|
||||||
names = sorted([s.host_name for s in j.job_host_summaries.all()])
|
|
||||||
assert ids == [-1, -1, -1, -1, -1, 6, 7, 8, 9, 10]
|
|
||||||
assert names == ['Host 0', 'Host 1', 'Host 2', 'Host 3', 'Host 4', 'Host 5', 'Host 6', 'Host 7', 'Host 8', 'Host 9']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_host_summary_generation_with_limit():
|
|
||||||
# Make an inventory with 10 hosts, run a playbook with a --limit
|
|
||||||
# pointed at *one* host,
|
|
||||||
# Verify that *only* that host has an associated JobHostSummary and that
|
|
||||||
# *only* that host has an updated value for .last_job.
|
|
||||||
hostnames = [f'Host {i}' for i in range(10)]
|
|
||||||
inv = Inventory()
|
|
||||||
inv.save()
|
|
||||||
Host.objects.bulk_create([Host(created=now(), modified=now(), name=h, inventory_id=inv.id) for h in hostnames])
|
|
||||||
j = Job(inventory=inv)
|
|
||||||
j.save()
|
|
||||||
|
|
||||||
# host map is a data structure that tracks a mapping of host name --> ID
|
|
||||||
# for the inventory, _regardless_ of whether or not there's a limit
|
|
||||||
# applied to the actual playbook run
|
|
||||||
host_map = dict((host.name, host.id) for host in inv.hosts.all())
|
|
||||||
|
|
||||||
# by making the playbook_on_stats *only* include Host 1, we're emulating
|
|
||||||
# the behavior of a `--limit=Host 1`
|
|
||||||
matching_host = Host.objects.get(name='Host 1')
|
|
||||||
JobEvent.create_from_data(
|
|
||||||
job_id=j.pk,
|
|
||||||
parent_uuid='abc123',
|
|
||||||
event='playbook_on_stats',
|
|
||||||
event_data={
|
|
||||||
'ok': {matching_host.name: len(matching_host.name)}, # effectively, limit=Host 1
|
|
||||||
'changed': {},
|
|
||||||
'dark': {},
|
|
||||||
'failures': {},
|
|
||||||
'ignored': {},
|
|
||||||
'processed': {},
|
|
||||||
'rescued': {},
|
|
||||||
'skipped': {},
|
|
||||||
},
|
|
||||||
host_map=host_map,
|
|
||||||
).save()
|
|
||||||
|
|
||||||
# since the playbook_on_stats only references one host,
|
|
||||||
# there should *only* be on JobHostSummary record (and it should
|
|
||||||
# be related to the appropriate Host)
|
|
||||||
assert JobHostSummary.objects.count() == 1
|
|
||||||
for h in Host.objects.all():
|
|
||||||
if h.name == 'Host 1':
|
|
||||||
assert h.last_job_id == j.id
|
|
||||||
assert h.last_job_host_summary_id == JobHostSummary.objects.first().id
|
|
||||||
else:
|
|
||||||
# all other hosts in the inventory should remain untouched
|
|
||||||
assert h.last_job_id is None
|
|
||||||
assert h.last_job_host_summary_id is None
|
|
||||||
|
|||||||
@@ -20,3 +20,53 @@ def test_host_metrics_generation():
|
|||||||
date_today = now().strftime('%Y-%m-%d')
|
date_today = now().strftime('%Y-%m-%d')
|
||||||
result = HostMetric.objects.filter(first_automation__startswith=date_today).count()
|
result = HostMetric.objects.filter(first_automation__startswith=date_today).count()
|
||||||
assert result == len(hostnames)
|
assert result == len(hostnames)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_soft_delete():
|
||||||
|
hostnames = [f'Host to delete {i}' for i in range(2)]
|
||||||
|
current_time = now()
|
||||||
|
HostMetric.objects.bulk_create([HostMetric(hostname=h, last_automation=current_time, automated_counter=42) for h in hostnames])
|
||||||
|
|
||||||
|
hm = HostMetric.objects.get(hostname="Host to delete 0")
|
||||||
|
assert hm.last_deleted is None
|
||||||
|
|
||||||
|
last_deleted = None
|
||||||
|
for _ in range(3):
|
||||||
|
# soft delete 1st
|
||||||
|
# 2nd/3rd delete don't have an effect
|
||||||
|
hm.soft_delete()
|
||||||
|
if last_deleted is None:
|
||||||
|
last_deleted = hm.last_deleted
|
||||||
|
|
||||||
|
assert hm.deleted is True
|
||||||
|
assert hm.deleted_counter == 1
|
||||||
|
assert hm.last_deleted == last_deleted
|
||||||
|
assert hm.automated_counter == 42
|
||||||
|
|
||||||
|
# 2nd record is not touched
|
||||||
|
hm = HostMetric.objects.get(hostname="Host to delete 1")
|
||||||
|
assert hm.deleted is False
|
||||||
|
assert hm.deleted_counter == 0
|
||||||
|
assert hm.last_deleted is None
|
||||||
|
assert hm.automated_counter == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_soft_restore():
|
||||||
|
current_time = now()
|
||||||
|
HostMetric.objects.create(hostname="Host 1", last_automation=current_time, deleted=True)
|
||||||
|
HostMetric.objects.create(hostname="Host 2", last_automation=current_time, deleted=True, last_deleted=current_time)
|
||||||
|
HostMetric.objects.create(hostname="Host 3", last_automation=current_time, deleted=False, last_deleted=current_time)
|
||||||
|
HostMetric.objects.all().update(automated_counter=42, deleted_counter=10)
|
||||||
|
|
||||||
|
# 1. deleted, last_deleted not null
|
||||||
|
for hm in HostMetric.objects.all():
|
||||||
|
for _ in range(3):
|
||||||
|
hm.soft_restore()
|
||||||
|
assert hm.deleted is False
|
||||||
|
assert hm.automated_counter == 42 and hm.deleted_counter == 10
|
||||||
|
if hm.hostname == "Host 1":
|
||||||
|
assert hm.last_deleted is None
|
||||||
|
else:
|
||||||
|
assert hm.last_deleted == current_time
|
||||||
|
|||||||
@@ -382,8 +382,8 @@ class Licenser(object):
|
|||||||
|
|
||||||
current_instances = Host.objects.active_count()
|
current_instances = Host.objects.active_count()
|
||||||
license_date = int(attrs.get('license_date', 0) or 0)
|
license_date = int(attrs.get('license_date', 0) or 0)
|
||||||
automated_instances = HostMetric.objects.count()
|
automated_instances = HostMetric.active_objects.count()
|
||||||
first_host = HostMetric.objects.only('first_automation').order_by('first_automation').first()
|
first_host = HostMetric.active_objects.only('first_automation').order_by('first_automation').first()
|
||||||
if first_host:
|
if first_host:
|
||||||
automated_since = int(first_host.first_automation.timestamp())
|
automated_since = int(first_host.first_automation.timestamp())
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user