From 2fba3db48fd8b5a2950543f6abc9690c70dce0fe Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Mon, 18 Jul 2022 11:10:59 -0400 Subject: [PATCH 01/68] Add state fields to Instance and InstanceLink Also, listener_port to Instance. --- .../migrations/0170_node_and_link_state.py | 79 +++++++++++++++++++ awx/main/models/ha.py | 45 +++++++++-- 2 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 awx/main/migrations/0170_node_and_link_state.py diff --git a/awx/main/migrations/0170_node_and_link_state.py b/awx/main/migrations/0170_node_and_link_state.py new file mode 100644 index 0000000000..6fbc3dd12b --- /dev/null +++ b/awx/main/migrations/0170_node_and_link_state.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.13 on 2022-08-02 17:53 + +import django.core.validators +from django.db import migrations, models + + +def forwards(apps, schema_editor): + # All existing InstanceLink objects need to be in the state + # 'Established', which is the default, so nothing needs to be done + # for that. + + Instance = apps.get_model('main', 'Instance') + for instance in Instance.objects.all(): + instance.node_state = 'ready' if not instance.errors else 'unavailable' + instance.save(update_fields=['node_state']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0169_jt_prompt_everything_on_launch'), + ] + + operations = [ + migrations.AddField( + model_name='instance', + name='listener_port', + field=models.PositiveIntegerField( + blank=True, + default=27199, + help_text='Port that Receptor will listen for incoming connections on.', + validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], + ), + ), + migrations.AddField( + model_name='instance', + name='node_state', + field=models.CharField( + choices=[ + ('provisioning', 'Provisioning'), + ('provision-fail', 'Provisioning Failure'), + ('installed', 'Installed'), + ('ready', 'Ready'), + ('unavailable', 'Unavailable'), + ('deprovisioning', 'De-provisioning'), + ('deprovision-fail', 'De-provisioning Failure'), + ], + default='ready', + help_text='Indicates the current life cycle stage of this instance.', + max_length=16, + ), + ), + migrations.AddField( + model_name='instancelink', + name='link_state', + field=models.CharField( + choices=[('adding', 'Adding'), ('established', 'Established'), ('removing', 'Removing')], + default='established', + help_text='Indicates the current life cycle stage of this peer link.', + max_length=16, + ), + ), + migrations.AlterField( + model_name='instance', + name='node_type', + field=models.CharField( + choices=[ + ('control', 'Control plane node'), + ('execution', 'Execution plane node'), + ('hybrid', 'Controller and execution'), + ('hop', 'Message-passing node, no execution capability'), + ], + default='hybrid', + help_text='Role that this node plays in the mesh.', + max_length=16, + ), + ), + migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop), + ] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index eeed06bc60..f7388181f3 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -5,7 +5,7 @@ from decimal import Decimal import logging import os -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -59,6 +59,15 @@ class InstanceLink(BaseModel): source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+') target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers') + class States(models.TextChoices): + ADDING = 'adding', _('Adding') + ESTABLISHED = 'established', _('Established') + REMOVING = 'removing', _('Removing') + + link_state = models.CharField( + choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.") + ) + class Meta: unique_together = ('source', 'target') @@ -127,13 +136,33 @@ class Instance(HasPolicyEditsMixin, BaseModel): default=0, editable=False, ) - NODE_TYPE_CHOICES = [ - ("control", "Control plane node"), - ("execution", "Execution plane node"), - ("hybrid", "Controller and execution"), - ("hop", "Message-passing node, no execution capability"), - ] - node_type = models.CharField(default='hybrid', choices=NODE_TYPE_CHOICES, max_length=16) + + class Types(models.TextChoices): + CONTROL = 'control', _("Control plane node") + EXECUTION = 'execution', _("Execution plane node") + HYBRID = 'hybrid', _("Controller and execution") + HOP = 'hop', _("Message-passing node, no execution capability") + + node_type = models.CharField(default=Types.HYBRID, choices=Types.choices, max_length=16, help_text=_("Role that this node plays in the mesh.")) + + class States(models.TextChoices): + PROVISIONING = 'provisioning', _('Provisioning') + PROVISION_FAIL = 'provision-fail', _('Provisioning Failure') + INSTALLED = 'installed', _('Installed') + READY = 'ready', _('Ready') + UNAVAILABLE = 'unavailable', _('Unavailable') + DEPROVISIONING = 'deprovisioning', _('De-provisioning') + DEPROVISION_FAIL = 'deprovision-fail', _('De-provisioning Failure') + + node_state = models.CharField( + choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.") + ) + listener_port = models.PositiveIntegerField( + blank=True, + default=27199, + validators=[MinValueValidator(1), MaxValueValidator(65535)], + help_text=_("Port that Receptor will listen for incoming connections on."), + ) peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target')) From a575f17db5194b3e35089f3862b15209ff7d9f9e Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Mon, 18 Jul 2022 13:38:05 -0400 Subject: [PATCH 02/68] Add the state fields and the peer relationships to the serializers --- awx/api/serializers.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 47f121a58f..62626cd83b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4859,7 +4859,7 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria class InstanceLinkSerializer(BaseSerializer): class Meta: model = InstanceLink - fields = ('source', 'target') + fields = ('source', 'target', 'link_state') source = serializers.SlugRelatedField(slug_field="hostname", read_only=True) target = serializers.SlugRelatedField(slug_field="hostname", read_only=True) @@ -4868,31 +4868,25 @@ class InstanceLinkSerializer(BaseSerializer): class InstanceNodeSerializer(BaseSerializer): class Meta: model = Instance - fields = ('id', 'hostname', 'node_type', 'node_state') - - node_state = serializers.SerializerMethodField() - - def get_node_state(self, obj): - if not obj.enabled: - return "disabled" - return "error" if obj.errors else "healthy" + fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled') class InstanceSerializer(BaseSerializer): consumed_capacity = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField() - jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that ' 'are targeted for this instance'), read_only=True) + jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True) jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True) class Meta: model = Instance - read_only_fields = ('uuid', 'hostname', 'version', 'node_type') + read_only_fields = ('uuid', 'hostname', 'version', 'node_type', 'node_state') fields = ( "id", "type", "url", "related", + "summary_fields", "uuid", "hostname", "created", @@ -4914,6 +4908,7 @@ class InstanceSerializer(BaseSerializer): "enabled", "managed_by_policy", "node_type", + "node_state", ) def get_related(self, obj): @@ -4925,6 +4920,14 @@ class InstanceSerializer(BaseSerializer): res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) return res + def get_summary_fields(self, obj): + summary = super().get_summary_fields(obj) + + if self.is_detail_view: + summary['links'] = InstanceLinkSerializer(InstanceLink.objects.select_related('target', 'source').filter(source=obj), many=True).data + + return summary + def get_consumed_capacity(self, obj): return obj.consumed_capacity From 81e68cb9bf7587333dc83734ac337b4f51dba414 Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Mon, 25 Jul 2022 15:50:11 -0400 Subject: [PATCH 03/68] Update node and link registration to put them in the right state 'Installed' for the nodes, 'Established' for the links. --- awx/main/management/commands/register_peers.py | 10 ++++++++-- awx/main/managers.py | 10 +++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/awx/main/management/commands/register_peers.py b/awx/main/management/commands/register_peers.py index 6d26ebfbb2..078edb08c7 100644 --- a/awx/main/management/commands/register_peers.py +++ b/awx/main/management/commands/register_peers.py @@ -27,7 +27,9 @@ class Command(BaseCommand): ) def handle(self, **options): + # provides a mapping of hostname to Instance objects nodes = Instance.objects.in_bulk(field_name='hostname') + if options['source'] not in nodes: raise CommandError(f"Host {options['source']} is not a registered instance.") if not (options['peers'] or options['disconnect'] or options['exact'] is not None): @@ -57,7 +59,9 @@ class Command(BaseCommand): results = 0 for target in options['peers']: - _, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) + _, created = InstanceLink.objects.update_or_create( + source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED} + ) if created: results += 1 @@ -80,7 +84,9 @@ class Command(BaseCommand): links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True)) removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete() for target in peers - links: - _, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) + _, created = InstanceLink.objects.update_or_create( + source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED} + ) if created: additions += 1 diff --git a/awx/main/managers.py b/awx/main/managers.py index 23acd15139..88e8384c43 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -129,10 +129,13 @@ class InstanceManager(models.Manager): # if instance was not retrieved by uuid and hostname was, use the hostname instance = self.filter(hostname=hostname) + from awx.main.models import Instance + # Return existing instance if instance.exists(): instance = instance.first() # in the unusual occasion that there is more than one, only get one - update_fields = [] + instance.node_state = Instance.States.INSTALLED # Wait for it to show up on the mesh + update_fields = ['node_state'] # if instance was retrieved by uuid and hostname has changed, update hostname if instance.hostname != hostname: logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname)) @@ -141,6 +144,7 @@ class InstanceManager(models.Manager): # if any other fields are to be updated if instance.ip_address != ip_address: instance.ip_address = ip_address + update_fields.append('ip_address') if instance.node_type != node_type: instance.node_type = node_type update_fields.append('node_type') @@ -151,12 +155,12 @@ class InstanceManager(models.Manager): return (False, instance) # Create new instance, and fill in default values - create_defaults = dict(capacity=0) + create_defaults = {'node_state': Instance.States.INSTALLED, 'capacity': 0} if defaults is not None: create_defaults.update(defaults) uuid_option = {} if uuid is not None: - uuid_option = dict(uuid=uuid) + uuid_option = {'uuid': uuid} if node_type == 'execution' and 'version' not in create_defaults: create_defaults['version'] = RECEPTOR_PENDING instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option) From 3bcd539b3d18afec3905e8b39fd35d24a1d77fa1 Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Wed, 27 Jul 2022 17:17:13 -0400 Subject: [PATCH 04/68] Make sure that the health checks handle the state transitions properly - nodes with states Provisioning, Provisioning Fail, Deprovisioning, and Deprovisioning Fail should bypass health checks and should never transition due to the existing machinery - nodes with states Unavailable and Installed can transition to Ready if they check out as healthy - nodes in the Ready state should transition to Unavailable if they fail a check --- awx/api/views/__init__.py | 1 + awx/main/models/ha.py | 12 +++++++++--- awx/main/tasks/system.py | 13 ++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index dfc1140a70..a318f36c54 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -441,6 +441,7 @@ class InstanceHealthCheck(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() + # Note: hop nodes are already excluded by the get_queryset method if obj.node_type == 'execution': from awx.main.tasks.system import execution_node_health_check diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index f7388181f3..7de957d4d5 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -242,15 +242,18 @@ class Instance(HasPolicyEditsMixin, BaseModel): return self.last_seen < ref_time - timedelta(seconds=grace_period) def mark_offline(self, update_last_seen=False, perform_save=True, errors=''): - if self.cpu_capacity == 0 and self.mem_capacity == 0 and self.capacity == 0 and self.errors == errors and (not update_last_seen): + if self.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED): return + if self.node_state == Instance.States.UNAVAILABLE and self.errors == errors and (not update_last_seen): + return + self.node_state = Instance.States.UNAVAILABLE self.cpu_capacity = self.mem_capacity = self.capacity = 0 self.errors = errors if update_last_seen: self.last_seen = now() if perform_save: - update_fields = ['capacity', 'cpu_capacity', 'mem_capacity', 'errors'] + update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors'] if update_last_seen: update_fields += ['last_seen'] self.save(update_fields=update_fields) @@ -307,6 +310,9 @@ class Instance(HasPolicyEditsMixin, BaseModel): if not errors: self.refresh_capacity_fields() self.errors = '' + if self.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED): + self.node_state = Instance.States.READY + update_fields.append('node_state') else: self.mark_offline(perform_save=False, errors=errors) update_fields.extend(['cpu_capacity', 'mem_capacity', 'capacity']) @@ -325,7 +331,7 @@ class Instance(HasPolicyEditsMixin, BaseModel): # playbook event data; we should consider this a zero capacity event redis.Redis.from_url(settings.BROKER_URL).ping() except redis.ConnectionError: - errors = _('Failed to connect ot Redis') + errors = _('Failed to connect to Redis') self.save_health_data(awx_application_version, get_cpu_count(), get_mem_in_bytes(), update_last_seen=True, errors=errors) diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index d4f067115e..e9f564b125 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -122,7 +122,7 @@ def inform_cluster_of_shutdown(): reaper.reap_waiting(this_inst, grace_period=0) except Exception: logger.exception('failed to reap waiting jobs for {}'.format(this_inst.hostname)) - logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname)) + logger.warning('Normal shutdown signal for instance {}, removed self from capacity pool.'.format(this_inst.hostname)) except Exception: logger.exception('Encountered problem with normal shutdown signal.') @@ -407,6 +407,9 @@ def execution_node_health_check(node): if instance.node_type != 'execution': raise RuntimeError(f'Execution node health check ran against {instance.node_type} node {instance.hostname}') + if instance.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED): + raise RuntimeError(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}") + data = worker_info(node) prior_capacity = instance.capacity @@ -463,7 +466,7 @@ def inspect_execution_nodes(instance_list): # Only execution nodes should be dealt with by execution_node_health_check if instance.node_type == 'hop': - if was_lost and (not instance.is_lost(ref_time=nowtime)): + if was_lost: logger.warning(f'Hop node {hostname}, has rejoined the receptor mesh') instance.save_health_data(errors='') continue @@ -487,7 +490,7 @@ def inspect_execution_nodes(instance_list): def cluster_node_heartbeat(dispatch_time=None, worker_tasks=None): logger.debug("Cluster node heartbeat task.") nowtime = now() - instance_list = list(Instance.objects.all()) + instance_list = list(Instance.objects.filter(node_state__in=(Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED))) this_inst = None lost_instances = [] @@ -551,9 +554,9 @@ def cluster_node_heartbeat(dispatch_time=None, worker_tasks=None): try: if settings.AWX_AUTO_DEPROVISION_INSTANCES: deprovision_hostname = other_inst.hostname - other_inst.delete() + other_inst.delete() # FIXME: what about associated inbound links? logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname)) - elif other_inst.capacity != 0 or (not other_inst.errors): + elif other_inst.node_state == Instance.States.READY: other_inst.mark_offline(errors=_('Another cluster node has determined this instance to be unresponsive')) logger.error("Host {} last checked in at {}, marked as lost.".format(other_inst.hostname, other_inst.last_seen)) From 24bfacb654dfa24feb875cd7f7ccfe73ea18e2ea Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Fri, 29 Jul 2022 10:54:30 -0400 Subject: [PATCH 05/68] Check state when processing receptorctl advertisements Nodes that show up and were in one of the unready states need to be transitioned to ready, even if the logic in Instance.is_lost was not met. --- awx/main/tasks/system.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index e9f564b125..f416bdd825 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -443,6 +443,7 @@ def inspect_execution_nodes(instance_list): nowtime = now() workers = mesh_status['Advertisements'] + for ad in workers: hostname = ad['NodeID'] @@ -456,9 +457,7 @@ def inspect_execution_nodes(instance_list): if instance.node_type in ('control', 'hybrid'): continue - was_lost = instance.is_lost(ref_time=nowtime) last_seen = parse_date(ad['Time']) - if instance.last_seen and instance.last_seen >= last_seen: continue instance.last_seen = last_seen @@ -466,12 +465,12 @@ def inspect_execution_nodes(instance_list): # Only execution nodes should be dealt with by execution_node_health_check if instance.node_type == 'hop': - if was_lost: + if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED): logger.warning(f'Hop node {hostname}, has rejoined the receptor mesh') instance.save_health_data(errors='') continue - if was_lost: + if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED): # if the instance *was* lost, but has appeared again, # attempt to re-establish the initial capacity and version # check From 604fac2295e22f04a3538021cb75de4e2365a99c Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Fri, 29 Jul 2022 16:11:34 -0400 Subject: [PATCH 06/68] Update task management to only do things with ready instances --- awx/main/scheduler/task_manager_models.py | 6 +++++- awx/main/tasks/system.py | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/awx/main/scheduler/task_manager_models.py b/awx/main/scheduler/task_manager_models.py index b84cdfcf82..b9187c0e9c 100644 --- a/awx/main/scheduler/task_manager_models.py +++ b/awx/main/scheduler/task_manager_models.py @@ -37,7 +37,11 @@ class TaskManagerInstances: def __init__(self, active_tasks, instances=None, instance_fields=('node_type', 'capacity', 'hostname', 'enabled')): self.instances_by_hostname = dict() if instances is None: - instances = Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only(*instance_fields) + instances = ( + Instance.objects.filter(hostname__isnull=False, node_state=Instance.States.READY, enabled=True) + .exclude(node_type='hop') + .only('node_type', 'node_state', 'capacity', 'hostname', 'enabled') + ) for instance in instances: self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance) diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index f416bdd825..c2443b1a51 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -349,9 +349,13 @@ def _cleanup_images_and_files(**kwargs): logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}') # if we are the first instance alphabetically, then run cleanup on execution nodes - checker_instance = Instance.objects.filter(node_type__in=['hybrid', 'control'], enabled=True, capacity__gt=0).order_by('-hostname').first() + checker_instance = ( + Instance.objects.filter(node_type__in=['hybrid', 'control'], node_state=Instance.States.READY, enabled=True, capacity__gt=0) + .order_by('-hostname') + .first() + ) if checker_instance and this_inst.hostname == checker_instance.hostname: - for inst in Instance.objects.filter(node_type='execution', enabled=True, capacity__gt=0): + for inst in Instance.objects.filter(node_type='execution', node_state=Instance.States.READY, enabled=True, capacity__gt=0): runner_cleanup_kwargs = inst.get_cleanup_task_kwargs(**kwargs) if not runner_cleanup_kwargs: continue From 350efc12f541d5c2443fd3937b0b774180df52ac Mon Sep 17 00:00:00 2001 From: Sarabraj Singh Date: Wed, 20 Jul 2022 16:22:25 -0400 Subject: [PATCH 07/68] machinery to allow POSTing payloads to instances/ endpoint --- awx/api/serializers.py | 33 +++++++++++++++++--- awx/api/views/__init__.py | 2 +- awx_collection/test/awx/test_completeness.py | 3 +- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 62626cd83b..7ca485bd27 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4877,10 +4877,11 @@ class InstanceSerializer(BaseSerializer): percent_capacity_remaining = serializers.SerializerMethodField() jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True) jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True) + ip_address = serializers.IPAddressField(required=False) class Meta: model = Instance - read_only_fields = ('uuid', 'hostname', 'version', 'node_type', 'node_state') + read_only_fields = ('uuid', 'version') fields = ( "id", "type", @@ -4909,6 +4910,7 @@ class InstanceSerializer(BaseSerializer): "managed_by_policy", "node_type", "node_state", + "ip_address", ) def get_related(self, obj): @@ -4923,6 +4925,7 @@ class InstanceSerializer(BaseSerializer): def get_summary_fields(self, obj): summary = super().get_summary_fields(obj) + # use this handle to distinguish between a listView and a detailView if self.is_detail_view: summary['links'] = InstanceLinkSerializer(InstanceLink.objects.select_related('target', 'source').filter(source=obj), many=True).data @@ -4937,10 +4940,30 @@ class InstanceSerializer(BaseSerializer): else: return float("{0:.2f}".format(((float(obj.capacity) - float(obj.consumed_capacity)) / (float(obj.capacity))) * 100)) - def validate(self, attrs): - if self.instance.node_type == 'hop': - raise serializers.ValidationError(_('Hop node instances may not be changed.')) - return attrs + def validate_node_type(self, value): + # ensure that new node type is execution node-only + if not self.instance: + if value not in [Instance.Types.EXECUTION, Instance.Types.HOP]: + raise serializers.ValidationError('invalid node_type; can only create execution and hop nodes') + else: + if self.instance.node_type != value: + raise serializers.ValidationError('cannot change node_type') + + def validate_node_state(self, value): + if not self.instance: + if value not in [Instance.States.PROVISIONING, Instance.States.INSTALLED]: + raise serializers.ValidationError('net new execution node creation must be in installed or provisioning node_state') + else: + if self.instance.node_state != value and value not in [Instance.States.PROVISIONING, Instance.States.INSTALLED, Instance.States.DEPROVISIONING]: + raise serializers.ValidationError('modifying an existing instance can only be in provisioning or deprovisoning node_states') + + def validate_peers(self, value): + pass + # 1- dont wanna remove links between two control plane nodes + # 2- can of worms - reversing links + + def validate_instance_group(self, value): + pass class InstanceHealthCheckSerializer(BaseSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index a318f36c54..0b2a1a252e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -359,7 +359,7 @@ class DashboardJobsGraphView(APIView): return Response(dashboard_data) -class InstanceList(ListAPIView): +class InstanceList(ListCreateAPIView): name = _("Instances") model = models.Instance diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index 75e6bff29f..93ddd52fea 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -78,10 +78,11 @@ no_api_parameter_ok = { # When this tool was created we were not feature complete. Adding something in here indicates a module # that needs to be developed. If the module is found on the file system it will auto-detect that the # work is being done and will bypass this check. At some point this module should be removed from this list. -needs_development = ['inventory_script'] +needs_development = ['inventory_script', 'instance'] needs_param_development = { 'host': ['instance_id'], 'workflow_approval': ['description', 'execution_environment'], + 'instances': ['capacity_adjustment', 'enabled', 'hostname', 'ip_address', 'managed_by_policy', 'node_state', 'node_type'], } # ----------------------------------------------------------------------------------------------------------- From e4518f7b13dbad90e6766a5337d083557f4aaf6e Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Thu, 28 Jul 2022 14:29:31 -0400 Subject: [PATCH 08/68] Changes in posting constraints due to rescoping to OCP/K8S-only - node_state is now read only - node_state gets set automatically to Installed in the create view - raise a validation error when creating on non-K8S - allow SystemAdministrator the 'add' permission for Instances - expose the new listener_port field --- awx/api/serializers.py | 84 +++++++++++++++++---------------------- awx/api/views/__init__.py | 3 ++ awx/main/access.py | 2 +- 3 files changed, 41 insertions(+), 48 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7ca485bd27..48efd6a204 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4877,40 +4877,40 @@ class InstanceSerializer(BaseSerializer): percent_capacity_remaining = serializers.SerializerMethodField() jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True) jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True) - ip_address = serializers.IPAddressField(required=False) class Meta: model = Instance - read_only_fields = ('uuid', 'version') + read_only_fields = ('ip_address', 'uuid', 'version', 'node_state') fields = ( - "id", - "type", - "url", - "related", - "summary_fields", - "uuid", - "hostname", - "created", - "modified", - "last_seen", - "last_health_check", - "errors", + 'id', + 'type', + 'url', + 'related', + 'summary_fields', + 'uuid', + 'hostname', + 'created', + 'modified', + 'last_seen', + 'last_health_check', + 'errors', 'capacity_adjustment', - "version", - "capacity", - "consumed_capacity", - "percent_capacity_remaining", - "jobs_running", - "jobs_total", - "cpu", - "memory", - "cpu_capacity", - "mem_capacity", - "enabled", - "managed_by_policy", - "node_type", - "node_state", - "ip_address", + 'version', + 'capacity', + 'consumed_capacity', + 'percent_capacity_remaining', + 'jobs_running', + 'jobs_total', + 'cpu', + 'memory', + 'cpu_capacity', + 'mem_capacity', + 'enabled', + 'managed_by_policy', + 'node_type', + 'node_state', + 'ip_address', + 'listener_port', ) def get_related(self, obj): @@ -4940,30 +4940,20 @@ class InstanceSerializer(BaseSerializer): else: return float("{0:.2f}".format(((float(obj.capacity) - float(obj.consumed_capacity)) / (float(obj.capacity))) * 100)) + def validate(self, data): + if not self.instance and not settings.IS_K8S: + raise serializers.ValidationError("Can only create instances on Kubernetes or OpenShift.") + return data + def validate_node_type(self, value): - # ensure that new node type is execution node-only if not self.instance: if value not in [Instance.Types.EXECUTION, Instance.Types.HOP]: - raise serializers.ValidationError('invalid node_type; can only create execution and hop nodes') + raise serializers.ValidationError("Can only create execution and hop nodes.") else: if self.instance.node_type != value: - raise serializers.ValidationError('cannot change node_type') + raise serializers.ValidationError("Cannot change node type.") - def validate_node_state(self, value): - if not self.instance: - if value not in [Instance.States.PROVISIONING, Instance.States.INSTALLED]: - raise serializers.ValidationError('net new execution node creation must be in installed or provisioning node_state') - else: - if self.instance.node_state != value and value not in [Instance.States.PROVISIONING, Instance.States.INSTALLED, Instance.States.DEPROVISIONING]: - raise serializers.ValidationError('modifying an existing instance can only be in provisioning or deprovisoning node_states') - - def validate_peers(self, value): - pass - # 1- dont wanna remove links between two control plane nodes - # 2- can of worms - reversing links - - def validate_instance_group(self, value): - pass + return value class InstanceHealthCheckSerializer(BaseSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0b2a1a252e..3c282f3e2c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -367,6 +367,9 @@ class InstanceList(ListCreateAPIView): search_fields = ('hostname',) ordering = ('id',) + def perform_create(self, serializer): + serializer.save(node_state=models.Instance.States.INSTALLED) + class InstanceDetail(RetrieveUpdateAPIView): diff --git a/awx/main/access.py b/awx/main/access.py index e8deea8f36..a11789ee81 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,7 +579,7 @@ class InstanceAccess(BaseAccess): return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data) def can_add(self, data): - return False + return self.user.is_superuser def can_change(self, obj, data): return False From 5d3a19e542a4e58075ab2c401b92ea679140e873 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 3 Aug 2022 16:09:30 -0400 Subject: [PATCH 09/68] Adds Instance Add form --- .../Instances/InstanceAdd/InstanceAdd.js | 45 ++++++ .../screens/Instances/InstanceAdd/index.js | 1 + .../screens/Instances/Shared/InstanceForm.js | 130 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js create mode 100644 awx/ui/src/screens/Instances/InstanceAdd/index.js create mode 100644 awx/ui/src/screens/Instances/Shared/InstanceForm.js diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js new file mode 100644 index 0000000000..0fa6f1c630 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Card, PageSection } from '@patternfly/react-core'; +import { InstancesAPI } from 'api'; +import InstanceForm from '../Shared/InstanceForm'; + +function InstanceAdd() { + const history = useHistory(); + const [formError, setFormError] = useState(); + const handleSubmit = async (values) => { + const { instanceGroups, executionEnvironment } = values; + values.execution_environment = executionEnvironment?.id; + + try { + const { + data: { id }, + } = await InstancesAPI.create(); + + for (const group of instanceGroups) { + await InstancesAPI.associateInstanceGroup(id, group.id); + } + history.push(`/instances/${id}/details`); + } catch (err) { + setFormError(err); + } + }; + + const handleCancel = () => { + history.push('/instances'); + }; + + return ( + + + + + + ); +} + +export default InstanceAdd; diff --git a/awx/ui/src/screens/Instances/InstanceAdd/index.js b/awx/ui/src/screens/Instances/InstanceAdd/index.js new file mode 100644 index 0000000000..c6ddcff5bc --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/index.js @@ -0,0 +1 @@ +export { default } from './InstanceAdd'; diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.js new file mode 100644 index 0000000000..6d706b39cf --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.js @@ -0,0 +1,130 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { Formik, useField } from 'formik'; +import { Form, FormGroup, CardBody } from '@patternfly/react-core'; +import { FormColumnLayout } from 'components/FormLayout'; +import FormField, { FormSubmitError } from 'components/FormField'; +import FormActionGroup from 'components/FormActionGroup'; +import { required } from 'util/validators'; +import AnsibleSelect from 'components/AnsibleSelect'; +import { + ExecutionEnvironmentLookup, + InstanceGroupsLookup, +} from 'components/Lookup'; + +// This is hard coded because the API does not have the ability to send us a list that contains +// only the types of instances that can be added. Control and Hybrid instances cannot be added. + +const INSTANCE_TYPES = [ + { id: 2, name: t`Execution`, value: 'execution' }, + { id: 3, name: t`Hop`, value: 'hop' }, +]; + +function InstanceFormFields() { + const [instanceType, , instanceTypeHelpers] = useField('type'); + const [instanceGroupsField, , instanceGroupsHelpers] = + useField('instanceGroups'); + const [ + executionEnvironmentField, + executionEnvironmentMeta, + executionEnvironmentHelpers, + ] = useField('executionEnvironment'); + return ( + <> + + + + ({ + key: type.id, + value: type.value, + label: type.name, + isDisabled: false, + }))} + value={instanceType.value} + onChange={(e, opt) => { + instanceTypeHelpers.setValue(opt); + }} + /> + + { + instanceGroupsHelpers.setValue(value); + }} + fieldName="instanceGroups" + /> + executionEnvironmentHelpers.setTouched()} + value={executionEnvironmentField.value} + onChange={(value) => { + executionEnvironmentHelpers.setValue(value); + }} + /> + + ); +} + +function InstanceForm({ + instance = {}, + submitError, + handleCancel, + handleSubmit, +}) { + return ( + + { + handleSubmit(values); + }} + > + {(formik) => ( +
+ + + + + +
+ )} +
+
+ ); +} + +export default InstanceForm; From d2c63a9b36e973ba0b79636ebcb80346a1ff3584 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 4 Aug 2022 14:03:42 -0400 Subject: [PATCH 10/68] Adds tests --- .../Instances/InstanceAdd/InstanceAdd.js | 8 +- .../Instances/InstanceAdd/InstanceAdd.test.js | 53 ++++++++++ .../Instances/InstanceList/InstanceList.js | 22 ++++- .../InstanceList/InstanceList.test.js | 51 +++++++++- awx/ui/src/screens/Instances/Instances.js | 5 + .../screens/Instances/Shared/InstanceForm.js | 96 ++++++++++-------- .../Instances/Shared/InstanceForm.test.js | 98 +++++++++++++++++++ 7 files changed, 280 insertions(+), 53 deletions(-) create mode 100644 awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js create mode 100644 awx/ui/src/screens/Instances/Shared/InstanceForm.test.js diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js index 0fa6f1c630..1c0e86400d 100644 --- a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js @@ -8,17 +8,11 @@ function InstanceAdd() { const history = useHistory(); const [formError, setFormError] = useState(); const handleSubmit = async (values) => { - const { instanceGroups, executionEnvironment } = values; - values.execution_environment = executionEnvironment?.id; - try { const { data: { id }, - } = await InstancesAPI.create(); + } = await InstancesAPI.create(values); - for (const group of instanceGroups) { - await InstancesAPI.associateInstanceGroup(id, group.id); - } history.push(`/instances/${id}/details`); } catch (err) { setFormError(err); diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js new file mode 100644 index 0000000000..e79b0471c8 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { InstancesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import InstanceAdd from './InstanceAdd'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/instances'] }); + InstancesAPI.create.mockResolvedValue({ data: { id: 13 } }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('handleSubmit should call the api and redirect to details page', async () => { + await waitForElement(wrapper, 'isLoading', (el) => el.length === 0); + await act(async () => { + wrapper.find('InstanceForm').prop('handleSubmit')({ + name: 'new Foo', + node_type: 'hop', + }); + }); + expect(InstancesAPI.create).toHaveBeenCalledWith({ + name: 'new Foo', + node_type: 'hop', + }); + expect(history.location.pathname).toBe('/instances/13/details'); + }); + + test('handleCancel should return the user back to the instances list', async () => { + await waitForElement(wrapper, 'isLoading', (el) => el.length === 0); + await act(async () => { + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + }); + expect(history.location.pathname).toEqual('/instances'); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index 782fcdd187..a50891bc68 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -10,12 +10,14 @@ import PaginatedTable, { HeaderRow, HeaderCell, getSearchableKeys, + ToolbarAddButton, } from 'components/PaginatedTable'; import AlertModal from 'components/AlertModal'; import ErrorDetail from 'components/ErrorDetail'; +import { useConfig } from 'contexts/Config'; import useRequest, { useDismissableError } from 'hooks/useRequest'; import useSelected from 'hooks/useSelected'; -import { InstancesAPI } from 'api'; +import { InstancesAPI, SettingsAPI } from 'api'; import { getQSConfig, parseQueryString } from 'util/qs'; import HealthCheckButton from 'components/HealthCheckButton'; import InstanceListItem from './InstanceListItem'; @@ -28,21 +30,24 @@ const QS_CONFIG = getQSConfig('instance', { function InstanceList() { const location = useLocation(); + const { me } = useConfig(); const { - result: { instances, count, relatedSearchableKeys, searchableKeys }, + result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 }, error: contentError, isLoading, request: fetchInstances, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const [response, responseActions] = await Promise.all([ + const [response, responseActions, sysSettings] = await Promise.all([ InstancesAPI.read(params), InstancesAPI.readOptions(), + SettingsAPI.readCategory('system'), ]); return { instances: response.data.results, + isK8: sysSettings.data.IS_K8S, count: response.data.count, actions: responseActions.data.actions, relatedSearchableKeys: ( @@ -57,6 +62,7 @@ function InstanceList() { actions: {}, relatedSearchableKeys: [], searchableKeys: [], + isK8: false, } ); @@ -89,6 +95,7 @@ function InstanceList() { const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(instances); + return ( <> @@ -135,6 +142,15 @@ function InstanceList() { onExpandAll={expandAll} qsConfig={QS_CONFIG} additionalControls={[ + ...(isK8 && me.is_superuser + ? [ + , + ] + : []), ', () => { }, }); InstancesAPI.readOptions.mockResolvedValue(options); + SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: false } }); const history = createMemoryHistory({ initialEntries: ['/instances/1'], }); @@ -190,4 +191,52 @@ describe('', () => { wrapper.update(); expect(wrapper.find('AlertModal')).toHaveLength(1); }); + test('Should not show Add button', () => { + expect(wrapper.find('Button[ouiaId="instances-add-button"]')).toHaveLength( + 0 + ); + }); +}); + +describe('InstanceList should show Add button', () => { + let wrapper; + + const options = { data: { actions: { POST: true } } }; + + beforeEach(async () => { + InstancesAPI.read.mockResolvedValue({ + data: { + count: instances.length, + results: instances, + }, + }); + InstancesAPI.readOptions.mockResolvedValue(options); + SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: true } }); + const history = createMemoryHistory({ + initialEntries: ['/instances/1'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Should show Add button', () => { + expect(wrapper.find('Button[ouiaId="instances-add-button"]')).toHaveLength( + 1 + ); + }); }); diff --git a/awx/ui/src/screens/Instances/Instances.js b/awx/ui/src/screens/Instances/Instances.js index a230fb9a67..ca42498e41 100644 --- a/awx/ui/src/screens/Instances/Instances.js +++ b/awx/ui/src/screens/Instances/Instances.js @@ -6,10 +6,12 @@ import ScreenHeader from 'components/ScreenHeader'; import PersistentFilters from 'components/PersistentFilters'; import { InstanceList } from './InstanceList'; import Instance from './Instance'; +import InstanceAdd from './InstanceAdd'; function Instances() { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ '/instances': t`Instances`, + '/instances/add': t`Create new Instance`, }); const buildBreadcrumbConfig = useCallback((instance) => { @@ -27,6 +29,9 @@ function Instances() { <> + + + diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.js index 6d706b39cf..4dff50df77 100644 --- a/awx/ui/src/screens/Instances/Shared/InstanceForm.js +++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.js @@ -1,40 +1,36 @@ import React from 'react'; import { t } from '@lingui/macro'; import { Formik, useField } from 'formik'; -import { Form, FormGroup, CardBody } from '@patternfly/react-core'; +import { + Form, + FormGroup, + CardBody, + Switch, + Popover, +} from '@patternfly/react-core'; import { FormColumnLayout } from 'components/FormLayout'; import FormField, { FormSubmitError } from 'components/FormField'; import FormActionGroup from 'components/FormActionGroup'; import { required } from 'util/validators'; import AnsibleSelect from 'components/AnsibleSelect'; -import { - ExecutionEnvironmentLookup, - InstanceGroupsLookup, -} from 'components/Lookup'; // This is hard coded because the API does not have the ability to send us a list that contains // only the types of instances that can be added. Control and Hybrid instances cannot be added. const INSTANCE_TYPES = [ - { id: 2, name: t`Execution`, value: 'execution' }, - { id: 3, name: t`Hop`, value: 'hop' }, + { id: 'execution', name: t`Execution` }, + { id: 'hop', name: t`Hop` }, ]; function InstanceFormFields() { - const [instanceType, , instanceTypeHelpers] = useField('type'); - const [instanceGroupsField, , instanceGroupsHelpers] = - useField('instanceGroups'); - const [ - executionEnvironmentField, - executionEnvironmentMeta, - executionEnvironmentHelpers, - ] = useField('executionEnvironment'); + const [instanceType, , instanceTypeHelpers] = useField('node_type'); + const [enabled, , enabledHelpers] = useField('enabled'); return ( <> + + ({ key: type.id, - value: type.value, + value: type.id, label: type.name, - isDisabled: false, }))} value={instanceType.value} onChange={(e, opt) => { @@ -67,25 +76,27 @@ function InstanceFormFields() { }} /> - { - instanceGroupsHelpers.setValue(value); - }} - fieldName="instanceGroups" - /> - } - fieldName={executionEnvironmentField.name} - onBlur={() => executionEnvironmentHelpers.setTouched()} - value={executionEnvironmentField.value} - onChange={(value) => { - executionEnvironmentHelpers.setValue(value); - }} - /> + > + { + enabledHelpers.setValue(!enabled.value); + }} + ouiaId="enable-instance-switch" + /> + ); } @@ -100,11 +111,12 @@ function InstanceForm({ { handleSubmit(values); diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js new file mode 100644 index 0000000000..8a3b9898bf --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import InstanceForm from './InstanceForm'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + let handleCancel; + let handleSubmit; + + beforeAll(async () => { + handleCancel = jest.fn(); + handleSubmit = jest.fn(); + + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + + test('should display form fields properly', async () => { + await waitForElement(wrapper, 'InstanceForm', (el) => el.length > 0); + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Instance State"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Listener Port"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Instance Type"]').length).toBe(1); + }); + + test('should update form values', async () => { + await act(async () => { + wrapper.find('input#name').simulate('change', { + target: { value: 'new Foo', name: 'hostname' }, + }); + }); + + wrapper.update(); + expect(wrapper.find('input#name').prop('value')).toEqual('new Foo'); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + wrapper.update(); + expect(handleCancel).toBeCalled(); + }); + + test('should call handleSubmit when Cancel button is clicked', async () => { + expect(handleSubmit).not.toHaveBeenCalled(); + await act(async () => { + wrapper.find('input#name').simulate('change', { + target: { value: 'new Foo', name: 'hostname' }, + }); + wrapper.find('input#instance-description').simulate('change', { + target: { value: 'This is a repeat song', name: 'description' }, + }); + wrapper.find('input#instance-port').simulate('change', { + target: { value: 'This is a repeat song', name: 'listener_port' }, + }); + }); + wrapper.update(); + expect( + wrapper.find('FormField[label="Instance State"]').prop('isDisabled') + ).toBe(true); + await act(async () => { + wrapper.find('button[aria-label="Save"]').invoke('onClick')(); + }); + + expect(handleSubmit).toBeCalledWith({ + description: 'This is a repeat song', + enabled: true, + hostname: 'new Foo', + listener_port: 'This is a repeat song', + node_state: 'Installed', + node_type: 'execution', + }); + }); +}); From 4bf9925cf70786ddefd098d2e1eaf3b2708d9426 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 9 Aug 2022 07:42:26 -0700 Subject: [PATCH 11/68] Topology changes: - add new node and link states - add directionality to links - update icons --- awx/ui/src/screens/TopologyView/MeshGraph.js | 111 +++++++++++++----- awx/ui/src/screens/TopologyView/constants.js | 35 ++++-- .../src/screens/TopologyView/utils/helpers.js | 71 +++++++++-- .../TopologyView/utils/helpers__RTL.test.js | 6 +- 4 files changed, 174 insertions(+), 49 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 4e89937291..9e838f4377 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -11,6 +11,9 @@ import { renderLabelText, renderNodeType, renderNodeIcon, + renderLinkState, + renderLabelIcons, + renderIconPosition, redirectToDetailsPage, getHeight, getWidth, @@ -20,7 +23,8 @@ import { DEFAULT_RADIUS, DEFAULT_NODE_COLOR, DEFAULT_NODE_HIGHLIGHT_COLOR, - DEFAULT_NODE_LABEL_TEXT_COLOR, + DEFAULT_NODE_SYMBOL_TEXT_COLOR, + DEFAULT_NODE_STROKE_COLOR, DEFAULT_FONT_SIZE, SELECTOR, } from './constants'; @@ -95,6 +99,24 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .forceSimulation(nodes) .force('center', d3.forceCenter(width / 2, height / 2)); simulation.tick(); + // build the arrow. + mesh + .append('defs') + .selectAll('marker') + .data(['end', 'end-active']) + .join('marker') + .attr('id', String) + .attr('viewBox', '0 -5 10 10') + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5'); + + mesh.select('#end').attr('refX', 23).attr('fill', '#ccc'); + mesh.select('#end-active').attr('refX', 18).attr('fill', '#0066CC'); + // Add links mesh .append('g') @@ -108,11 +130,13 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('y1', (d) => d.source.y) .attr('x2', (d) => d.target.x) .attr('y2', (d) => d.target.y) + .attr('marker-end', 'url(#end)') .attr('class', (_, i) => `link-${i}`) .attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`) .style('fill', 'none') .style('stroke', '#ccc') .style('stroke-width', '2px') + .style('stroke-dasharray', (d) => renderLinkState(d.link_state)) .attr('pointer-events', 'none') .on('mouseover', function showPointer() { d3.select(this).transition().style('cursor', 'pointer'); @@ -147,7 +171,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) .attr('fill', DEFAULT_NODE_COLOR) - .attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR); + .attr('stroke', DEFAULT_NODE_STROKE_COLOR); // node type labels node @@ -157,41 +181,65 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('y', (d) => d.y) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR); + .attr('fill', DEFAULT_NODE_SYMBOL_TEXT_COLOR); - // node hostname labels - const hostNames = node.append('g'); - hostNames + const placeholder = node.append('g').attr('class', 'placeholder'); + + placeholder .append('text') + .text((d) => renderLabelText(d.node_state, d.hostname)) .attr('x', (d) => d.x) .attr('y', (d) => d.y + 40) - .text((d) => renderLabelText(d.node_state, d.hostname)) - .attr('class', 'placeholder') - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) + .attr('fill', 'black') + .attr('font-size', '18px') .attr('text-anchor', 'middle') .each(function calculateLabelWidth() { // eslint-disable-next-line react/no-this-in-sfc const bbox = this.getBBox(); // eslint-disable-next-line react/no-this-in-sfc d3.select(this.parentNode) - .append('rect') - .attr('x', bbox.x) - .attr('y', bbox.y) - .attr('width', bbox.width) - .attr('height', bbox.height) - .attr('rx', 8) - .attr('ry', 8) - .style('fill', (d) => renderStateColor(d.node_state)); + .append('path') + .attr('d', (d) => renderLabelIcons(d.node_state)) + .attr('transform', (d) => renderIconPosition(d.node_state, bbox)) + .style('fill', 'black'); }); - svg.selectAll('text.placeholder').remove(); + + placeholder.each(function calculateLabelWidth() { + // eslint-disable-next-line react/no-this-in-sfc + const bbox = this.getBBox(); + // eslint-disable-next-line react/no-this-in-sfc + d3.select(this.parentNode) + .append('rect') + .attr('x', (d) => d.x - bbox.width / 2) + .attr('y', bbox.y + 5) + .attr('width', bbox.width) + .attr('height', bbox.height) + .attr('rx', 8) + .attr('ry', 8) + .style('fill', (d) => renderStateColor(d.node_state)); + }); + + const hostNames = node.append('g'); hostNames .append('text') - .attr('x', (d) => d.x) - .attr('y', (d) => d.y + 38) .text((d) => renderLabelText(d.node_state, d.hostname)) + .attr('x', (d) => d.x + 6) + .attr('y', (d) => d.y + 42) + .attr('fill', 'white') .attr('font-size', DEFAULT_FONT_SIZE) - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) - .attr('text-anchor', 'middle'); + .attr('text-anchor', 'middle') + .each(function calculateLabelWidth() { + // eslint-disable-next-line react/no-this-in-sfc + const bbox = this.getBBox(); + // eslint-disable-next-line react/no-this-in-sfc + d3.select(this.parentNode) + .append('path') + .attr('class', (d) => `icon-${d.node_state}`) + .attr('d', (d) => renderLabelIcons(d.node_state)) + .attr('transform', (d) => renderIconPosition(d.node_state, bbox)) + .attr('fill', 'white'); + }); + svg.selectAll('g.placeholder').remove(); svg.call(zoom); @@ -208,7 +256,8 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .selectAll(`.link-${s.index}`) .transition() .style('stroke', '#0066CC') - .style('stroke-width', '3px'); + .style('stroke-width', '3px') + .attr('marker-end', 'url(#end-active)'); }); } @@ -222,25 +271,33 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { svg .selectAll(`.link-${s.index}`) .transition() + .duration(50) .style('stroke', '#ccc') - .style('stroke-width', '2px'); + .style('stroke-width', '2px') + .attr('marker-end', 'url(#end)'); }); } function highlightSelected(n) { if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) { // toggle rings - svg.select(`circle.id-${n.id}`).attr('stroke-width', null); + svg + .select(`circle.id-${n.id}`) + .attr('stroke', '#ccc') + .attr('stroke-width', null); // show default empty state of tooltip setIsNodeSelected(false); setSelectedNode(null); return; } - svg.selectAll('circle').attr('stroke-width', null); + svg + .selectAll('circle') + .attr('stroke', '#ccc') + .attr('stroke-width', null); svg .select(`circle.id-${n.id}`) .attr('stroke-width', '5px') - .attr('stroke', '#D2D2D2'); + .attr('stroke', '#0066CC'); setIsNodeSelected(true); setSelectedNode(n); } diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index d217078f6c..e3ad1445ae 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -9,21 +9,22 @@ export const MESH_FORCE_LAYOUT = { defaultForceX: 0, defaultForceY: 0, }; -export const DEFAULT_NODE_COLOR = '#0066CC'; -export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#16407C'; +export const DEFAULT_NODE_COLOR = 'white'; +export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#eee'; export const DEFAULT_NODE_LABEL_TEXT_COLOR = 'white'; +export const DEFAULT_NODE_SYMBOL_TEXT_COLOR = 'black'; +export const DEFAULT_NODE_STROKE_COLOR = '#ccc'; export const DEFAULT_FONT_SIZE = '12px'; export const LABEL_TEXT_MAX_LENGTH = 15; export const MARGIN = 15; export const NODE_STATE_COLOR_KEY = { - disabled: '#6A6E73', - healthy: '#3E8635', - error: '#C9190B', -}; -export const NODE_STATE_HTML_ENTITY_KEY = { - disabled: '\u25EF', - healthy: '\u2713', - error: '\u0021', + unavailable: '#F0AB00', + ready: '#3E8635', + 'provision-fail': '#C9190B', + 'deprovision-fail': '#C9190B', + installed: '#0066CC', + provisioning: '#666', + deprovisioning: '#666', }; export const NODE_TYPE_SYMBOL_KEY = { @@ -32,3 +33,17 @@ export const NODE_TYPE_SYMBOL_KEY = { hybrid: 'Hy', control: 'C', }; + +export const ICONS = { + clock: + 'M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z', + checkmark: + 'M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z', + exclaimation: + 'M176 432c0 44.112-35.888 80-80 80s-80-35.888-80-80 35.888-80 80-80 80 35.888 80 80zM25.26 25.199l13.6 272C39.499 309.972 50.041 320 62.83 320h66.34c12.789 0 23.331-10.028 23.97-22.801l13.6-272C167.425 11.49 156.496 0 142.77 0H49.23C35.504 0 24.575 11.49 25.26 25.199z', + minus: + 'M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z', + plus: 'M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z', + empty: + 'M512,896 C300.2,896 128,723.9 128,512 C128,300.3 300.2,128 512,128 C723.7,128 896,300.2 896,512 C896,723.8 723.7,896 512,896 L512,896 Z M512.1,0 C229.7,0 0,229.8 0,512 C0,794.3 229.8,1024 512.1,1024 C794.4,1024 1024,794.3 1024,512 C1024,229.7 794.4,0 512.1,0 L512.1,0 Z', +}; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index 11356d0e34..3d96692765 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -3,9 +3,9 @@ import { truncateString } from '../../../util/strings'; import { NODE_STATE_COLOR_KEY, - NODE_STATE_HTML_ENTITY_KEY, NODE_TYPE_SYMBOL_KEY, LABEL_TEXT_MAX_LENGTH, + ICONS, } from '../constants'; export function getWidth(selector) { @@ -22,12 +22,7 @@ export function renderStateColor(nodeState) { export function renderLabelText(nodeState, name) { if (typeof nodeState === 'string' && typeof name === 'string') { - return NODE_STATE_HTML_ENTITY_KEY[nodeState] - ? `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString( - name, - LABEL_TEXT_MAX_LENGTH - )}` - : ` ${truncateString(name, LABEL_TEXT_MAX_LENGTH)}`; + return `${truncateString(name, LABEL_TEXT_MAX_LENGTH)}`; } return ``; } @@ -44,6 +39,41 @@ export function renderNodeIcon(selectedNode) { return false; } +export function renderLabelIcons(nodeState) { + if (nodeState) { + const nodeLabelIconMapper = { + unavailable: 'empty', + ready: 'checkmark', + installed: 'clock', + 'provision-fail': 'exclaimation', + 'deprovision-fail': 'exclaimation', + provisioning: 'plus', + deprovisioning: 'minus', + }; + return ICONS[nodeLabelIconMapper[nodeState]] + ? ICONS[nodeLabelIconMapper[nodeState]] + : ``; + } + return false; +} +export function renderIconPosition(nodeState, bbox) { + if (nodeState) { + const iconPositionMapper = { + unavailable: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.01)`, + ready: `translate(${bbox.x - 15}, ${bbox.y + 3}), scale(0.02)`, + installed: `translate(${bbox.x - 18}, ${bbox.y + 1}), scale(0.03)`, + 'provision-fail': `translate(${bbox.x - 9}, ${bbox.y + 3}), scale(0.02)`, + 'deprovision-fail': `translate(${bbox.x - 9}, ${ + bbox.y + 3 + }), scale(0.02)`, + provisioning: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.02)`, + deprovisioning: `translate(${bbox.x - 12}, ${bbox.y + 3}), scale(0.02)`, + }; + return iconPositionMapper[nodeState] ? iconPositionMapper[nodeState] : ``; + } + return false; +} + export function redirectToDetailsPage(selectedNode, history) { if (selectedNode && history) { const { id: nodeId } = selectedNode; @@ -53,6 +83,14 @@ export function redirectToDetailsPage(selectedNode, history) { return false; } +export function renderLinkState(linkState) { + const linkPattern = { + established: null, + adding: 3, + removing: 3, + }; + return linkPattern[linkState] ? linkPattern[linkState] : null; +} // DEBUG TOOLS export function getRandomInt(min, max) { min = Math.ceil(min); @@ -62,13 +100,20 @@ export function getRandomInt(min, max) { const generateRandomLinks = (n, r) => { const links = []; + function getRandomLinkState() { + return ['established', 'adding', 'removing'][getRandomInt(0, 2)]; + } for (let i = 0; i < r; i++) { const link = { source: n[getRandomInt(0, n.length - 1)].hostname, target: n[getRandomInt(0, n.length - 1)].hostname, + link_state: getRandomLinkState(), }; - links.push(link); + if (link.source !== link.target) { + links.push(link); + } } + return { nodes: n, links }; }; @@ -78,7 +123,15 @@ export const generateRandomNodes = (n) => { return ['hybrid', 'execution', 'control', 'hop'][getRandomInt(0, 3)]; } function getRandomState() { - return ['healthy', 'error', 'disabled'][getRandomInt(0, 2)]; + return [ + 'ready', + 'provisioning', + 'deprovisioning', + 'installed', + 'unavailable', + 'provision-fail', + 'deprovision-fail', + ][getRandomInt(0, 6)]; } for (let i = 0; i < n; i++) { const id = i + 1; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js index 860b699468..4b0ec8cd40 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js @@ -10,7 +10,7 @@ import { describe('renderStateColor', () => { test('returns correct node state color', () => { - expect(renderStateColor('healthy')).toBe('#3E8635'); + expect(renderStateColor('ready')).toBe('#3E8635'); }); test('returns empty string if state is not found', () => { expect(renderStateColor('foo')).toBe(''); @@ -68,10 +68,10 @@ describe('getHeight', () => { }); describe('renderLabelText', () => { test('returns label text correctly', () => { - expect(renderLabelText('error', 'foo')).toBe('! foo'); + expect(renderLabelText('error', 'foo')).toBe('foo'); }); test('returns label text if invalid node state is passed', () => { - expect(renderLabelText('foo', 'bar')).toBe(' bar'); + expect(renderLabelText('foo', 'bar')).toBe('bar'); }); test('returns empty string if non string params are passed', () => { expect(renderLabelText(0, null)).toBe(''); From 9b034ad5742930f1122846edb5d27cb7ecdf17f0 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Mon, 1 Aug 2022 14:13:59 -0400 Subject: [PATCH 12/68] generate control node receptor.conf when a new remote execution/hop node is added regenerate the receptor.conf for all control node to peer out to the new remote execution node Signed-off-by: Hao Liu Co-Authored-By: Seth Foster Co-Authored-By: Shane McDonald --- awx/main/models/ha.py | 7 ++ awx/main/scheduler/task_manager.py | 4 - awx/main/tasks/jobs.py | 2 +- awx/main/tasks/receptor.py | 103 ++++++++++++++++-- awx/main/tasks/system.py | 8 +- docs/licenses/filelock.txt | 24 ++++ requirements/requirements.in | 1 + requirements/requirements.txt | 2 + .../ansible/roles/sources/tasks/main.yml | 14 +++ .../sources/templates/docker-compose.yml.j2 | 1 + 10 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 docs/licenses/filelock.txt diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 7de957d4d5..1e52646958 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -423,6 +423,13 @@ def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs @receiver(post_save, sender=Instance) def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): + # TODO: handle update to instance + if settings.IS_K8S and created and instance.node_type in ('execution', 'hop'): + from awx.main.tasks.receptor import write_receptor_config # prevents circular import + + # on commit broadcast to all control instance to update their receptor configs + connection.on_commit(lambda: write_receptor_config.apply_async(queue='tower_broadcast_all')) + if created or instance.has_policy_changes(): schedule_policy_task() diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 45f262ebe6..a0a125729d 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -642,10 +642,6 @@ class TaskManager(TaskBase): found_acceptable_queue = True break - # TODO: remove this after we have confidence that OCP control nodes are reporting node_type=control - if settings.IS_K8S and task.capacity_type == 'execution': - logger.debug("Skipping group {}, task cannot run on control plane".format(instance_group.name)) - continue # at this point we know the instance group is NOT a container group # because if it was, it would have started the task and broke out of the loop. execution_instance = self.instance_groups.fit_task_to_most_remaining_capacity_instance( diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 33cfc30cd1..ff64f8ee64 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -145,7 +145,7 @@ class BaseTask(object): """ Return params structure to be executed by the container runtime """ - if settings.IS_K8S: + if settings.IS_K8S and instance.instance_group.is_container_group: return {} image = instance.execution_environment.image diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 0350a96836..0dda5b48ad 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -27,12 +27,17 @@ from awx.main.utils.common import ( ) from awx.main.constants import MAX_ISOLATED_PATH_COLON_DELIMITER from awx.main.tasks.signals import signal_state, signal_callback, SignalExit +from awx.main.models import Instance +from awx.main.dispatch.publish import task # Receptorctl from receptorctl.socket_interface import ReceptorControl +from filelock import FileLock + logger = logging.getLogger('awx.main.tasks.receptor') __RECEPTOR_CONF = '/etc/receptor/receptor.conf' +__RECEPTOR_CONF_LOCKFILE = f'{__RECEPTOR_CONF}.lock' RECEPTOR_ACTIVE_STATES = ('Pending', 'Running') @@ -43,8 +48,10 @@ class ReceptorConnectionType(Enum): def get_receptor_sockfile(): - with open(__RECEPTOR_CONF, 'r') as f: - data = yaml.safe_load(f) + lock = FileLock(__RECEPTOR_CONF_LOCKFILE) + with lock: + with open(__RECEPTOR_CONF, 'r') as f: + data = yaml.safe_load(f) for section in data: for entry_name, entry_data in section.items(): if entry_name == 'control-service': @@ -60,8 +67,10 @@ def get_tls_client(use_stream_tls=None): if not use_stream_tls: return None - with open(__RECEPTOR_CONF, 'r') as f: - data = yaml.safe_load(f) + lock = FileLock(__RECEPTOR_CONF_LOCKFILE) + with lock: + with open(__RECEPTOR_CONF, 'r') as f: + data = yaml.safe_load(f) for section in data: for entry_name, entry_data in section.items(): if entry_name == 'tls-client': @@ -78,12 +87,25 @@ def get_receptor_ctl(): return ReceptorControl(receptor_sockfile) +def find_node_in_mesh(node_name, receptor_ctl): + attempts = 10 + backoff = 1 + for attempt in range(attempts): + all_nodes = receptor_ctl.simple_command("status").get('Advertisements', None) + for node in all_nodes: + if node.get('NodeID') == node_name: + return node + else: + logger.warning(f"Instance {node_name} is not in the receptor mesh. {attempts-attempt} attempts left.") + time.sleep(backoff) + backoff += 1 + else: + raise ReceptorNodeNotFound(f'Instance {node_name} is not in the receptor mesh') + + def get_conn_type(node_name, receptor_ctl): - all_nodes = receptor_ctl.simple_command("status").get('Advertisements', None) - for node in all_nodes: - if node.get('NodeID') == node_name: - return ReceptorConnectionType(node.get('ConnType')) - raise ReceptorNodeNotFound(f'Instance {node_name} is not in the receptor mesh') + node = find_node_in_mesh(node_name, receptor_ctl) + return ReceptorConnectionType(node.get('ConnType')) def administrative_workunit_reaper(work_list=None): @@ -574,3 +596,66 @@ class AWXReceptorJob: else: config["clusters"][0]["cluster"]["insecure-skip-tls-verify"] = True return config + + +RECEPTOR_CONFIG_STARTER = ( + {'control-service': {'service': 'control', 'filename': '/var/run/receptor/receptor.sock', 'permissions': '0600'}}, + {'local-only': None}, + {'work-command': {'worktype': 'local', 'command': 'ansible-runner', 'params': 'worker', 'allowruntimeparams': True}}, + { + 'work-kubernetes': { + 'worktype': 'kubernetes-runtime-auth', + 'authmethod': 'runtime', + 'allowruntimeauth': True, + 'allowruntimepod': True, + 'allowruntimeparams': True, + } + }, + { + 'work-kubernetes': { + 'worktype': 'kubernetes-incluster-auth', + 'authmethod': 'incluster', + 'allowruntimeauth': True, + 'allowruntimepod': True, + 'allowruntimeparams': True, + } + }, + { + 'tls-client': { + 'name': 'tlsclient', + 'rootcas': '/etc/receptor/tls/ca/receptor-ca.crt', + 'cert': '/etc/receptor/tls/receptor.crt', + 'key': '/etc/receptor/tls/receptor.key', + } + }, +) + + +@task() +def write_receptor_config(): + receptor_config = list(RECEPTOR_CONFIG_STARTER) + + instances = Instance.objects.exclude(node_type='control') + for instance in instances: + peer = {'tcp-peer': {'address': f'{instance.hostname}:{instance.listener_port}', 'tls': 'tlsclient'}} + receptor_config.append(peer) + + lock = FileLock(__RECEPTOR_CONF_LOCKFILE) + with lock: + with open(__RECEPTOR_CONF, 'w') as file: + yaml.dump(receptor_config, file, default_flow_style=False) + + receptor_ctl = get_receptor_ctl() + + attempts = 10 + backoff = 1 + for attempt in range(attempts): + try: + receptor_ctl.simple_command("reload") + break + except ValueError: + logger.warning(f"Unable to reload Receptor configuration. {attempts-attempt} attempts left.") + time.sleep(backoff) + backoff += 1 + else: + raise RuntimeError("Receptor reload failed") diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index c2443b1a51..0c22051f5a 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -61,7 +61,7 @@ from awx.main.utils.common import ( from awx.main.utils.external_logging import reconfigure_rsyslog from awx.main.utils.reload import stop_local_services from awx.main.utils.pglock import advisory_lock -from awx.main.tasks.receptor import get_receptor_ctl, worker_info, worker_cleanup, administrative_workunit_reaper +from awx.main.tasks.receptor import get_receptor_ctl, worker_info, worker_cleanup, administrative_workunit_reaper, write_receptor_config from awx.main.consumers import emit_channel_notification from awx.main import analytics from awx.conf import settings_registry @@ -81,6 +81,10 @@ Try upgrading OpenSSH or providing your private key in an different format. \ def dispatch_startup(): startup_logger = logging.getLogger('awx.main.tasks') + # TODO: Enable this on VM installs + if settings.IS_K8S: + write_receptor_config() + startup_logger.debug("Syncing Schedules") for sch in Schedule.objects.all(): try: @@ -555,7 +559,7 @@ def cluster_node_heartbeat(dispatch_time=None, worker_tasks=None): except Exception: logger.exception('failed to reap jobs for {}'.format(other_inst.hostname)) try: - if settings.AWX_AUTO_DEPROVISION_INSTANCES: + if settings.AWX_AUTO_DEPROVISION_INSTANCES and other_inst.node_type == "control": deprovision_hostname = other_inst.hostname other_inst.delete() # FIXME: what about associated inbound links? logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname)) diff --git a/docs/licenses/filelock.txt b/docs/licenses/filelock.txt new file mode 100644 index 0000000000..cf1ab25da0 --- /dev/null +++ b/docs/licenses/filelock.txt @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/requirements/requirements.in b/requirements/requirements.in index e0f707b8bc..3951faf06c 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -25,6 +25,7 @@ django-split-settings django-taggit djangorestframework==3.13.1 djangorestframework-yaml +filelock GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119 irc jinja2>=2.11.3 # CVE-2020-28493 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index f05f27b807..143506c21e 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -132,6 +132,8 @@ docutils==0.16 # via python-daemon ecdsa==0.18.0 # via python-jose +filelock==3.8.0 + # via -r /awx_devel/requirements/requirements.in # via # -r /awx_devel/requirements/requirements_git.txt # django-radius diff --git a/tools/docker-compose/ansible/roles/sources/tasks/main.yml b/tools/docker-compose/ansible/roles/sources/tasks/main.yml index e566a97073..b6dd95aedb 100644 --- a/tools/docker-compose/ansible/roles/sources/tasks/main.yml +++ b/tools/docker-compose/ansible/roles/sources/tasks/main.yml @@ -109,6 +109,20 @@ mode: '0600' with_sequence: start=1 end={{ control_plane_node_count }} +- name: Create Receptor Config Lock File + file: + path: "{{ sources_dest }}/receptor/receptor-awx-{{ item }}.conf.lock" + state: touch + mode: '0600' + with_sequence: start=1 end={{ control_plane_node_count }} + +- name: Render Receptor Config(s) for Control Plane + template: + src: "receptor-awx.conf.j2" + dest: "{{ sources_dest }}/receptor/receptor-awx-{{ item }}.conf" + mode: '0600' + with_sequence: start=1 end={{ control_plane_node_count }} + - name: Render Receptor Hop Config template: src: "receptor-hop.conf.j2" diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 index 2f7fc3cf41..db4988b207 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 @@ -42,6 +42,7 @@ services: - "../../docker-compose/_sources/local_settings.py:/etc/tower/conf.d/local_settings.py" - "../../docker-compose/_sources/SECRET_KEY:/etc/tower/SECRET_KEY" - "../../docker-compose/_sources/receptor/receptor-awx-{{ loop.index }}.conf:/etc/receptor/receptor.conf" + - "../../docker-compose/_sources/receptor/receptor-awx-{{ loop.index }}.conf.lock:/etc/receptor/receptor.conf.lock" - "../../docker-compose/_sources/receptor/work_public_key.pem:/etc/receptor/work_public_key.pem" - "../../docker-compose/_sources/receptor/work_private_key.pem:/etc/receptor/work_private_key.pem" # - "../../docker-compose/_sources/certs:/etc/receptor/certs" # TODO: optionally generate certs From 7956fc3c31a8edc47eadfb164ad5af6b02139051 Mon Sep 17 00:00:00 2001 From: TheRealHaoLiu Date: Mon, 18 Jul 2022 11:10:59 -0400 Subject: [PATCH 13/68] add instance install bundle endpoint add scaffolding for instance install_bundle endpoint - add instance_install_bundle view (does not do anything yet) - add `instance_install_bundle` related field to serializer - add `/install_bundle` to instance URL - `/install_bundle` only available for execution and hop node - `/install_bundle` endpoint response contain a downloadable tgz with moc data TODO: add actual data to the install bundle response Signed-off-by: Hao Liu --- awx/api/serializers.py | 1 + awx/api/urls/instance.py | 3 +- awx/api/views/__init__.py | 1 + awx/api/views/instance_install_bundle.py | 54 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 awx/api/views/instance_install_bundle.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 48efd6a204..07c9b3aaf2 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4917,6 +4917,7 @@ class InstanceSerializer(BaseSerializer): res = super(InstanceSerializer, self).get_related(obj) res['jobs'] = self.reverse('api:instance_unified_jobs_list', kwargs={'pk': obj.pk}) res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk}) + res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor: if obj.node_type != 'hop': res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) diff --git a/awx/api/urls/instance.py b/awx/api/urls/instance.py index 6c70e285c5..a9ef203384 100644 --- a/awx/api/urls/instance.py +++ b/awx/api/urls/instance.py @@ -3,7 +3,7 @@ from django.urls import re_path -from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck +from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck, InstanceInstallBundle urls = [ @@ -12,6 +12,7 @@ urls = [ re_path(r'^(?P[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'), re_path(r'^(?P[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'), re_path(r'^(?P[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'), + re_path(r'^(?P[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 3c282f3e2c..9991bcfe1c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -174,6 +174,7 @@ from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, Gitlab from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ +from awx.api.views.instance_install_bundle import InstanceInstallBundle # noqa logger = logging.getLogger('awx.api.views') diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py new file mode 100644 index 0000000000..f3f017d146 --- /dev/null +++ b/awx/api/views/instance_install_bundle.py @@ -0,0 +1,54 @@ +# Copyright (c) 2018 Red Hat, Inc. +# All Rights Reserved. + +import os +import tarfile +import tempfile + +from awx.api import serializers +from awx.api.generics import GenericAPIView, Response +from awx.api.permissions import IsSystemAdminOrAuditor +from awx.main import models +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from django.http import HttpResponse + +# generate install bundle for the instance +class InstanceInstallBundle(GenericAPIView): + + name = _('Install Bundle') + model = models.Instance + serializer_class = serializers.InstanceSerializer + permission_classes = (IsSystemAdminOrAuditor,) + + def get(self, request, *args, **kwargs): + instance_obj = self.get_object() + + # if the instance is not a hop or execution node than return 400 + if instance_obj.node_type not in ('execution', 'hop'): + return Response( + data=dict(msg=_('Install bundle can only be generated for execution or hop nodes.')), + status=status.HTTP_400_BAD_REQUEST, + ) + + # TODO: add actual data into the bundle + # create a named temporary file directory to store the content of the install bundle + with tempfile.TemporaryDirectory() as tmpdirname: + # create a empty file named "moc_content.txt" in the temporary directory + with open(os.path.join(tmpdirname, 'mock_content.txt'), 'w') as f: + f.write('mock content') + + # create empty directory in temporary directory + os.mkdir(os.path.join(tmpdirname, 'mock_dir')) + + # tar.gz and create a temporary file from the temporary directory + # the directory will be renamed and prefixed with the hostname of the instance + with tempfile.NamedTemporaryFile(suffix='.tar.gz') as tmpfile: + with tarfile.open(tmpfile.name, 'w:gz') as tar: + tar.add(tmpdirname, arcname=f"{instance_obj.hostname}_install_bundle") + + # read the temporary file and send it to the client + with open(tmpfile.name, 'rb') as f: + response = HttpResponse(f.read(), status=status.HTTP_200_OK) + response['Content-Disposition'] = f"attachment; filename={instance_obj.hostname}_install_bundle.tar.gz" + return response From 5051224781c8a49d59e41632d1dffb7a454395a3 Mon Sep 17 00:00:00 2001 From: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com> Date: Wed, 17 Aug 2022 14:41:18 -0400 Subject: [PATCH 14/68] conditionally show install_bundle link for instances (#12679) - only show install_bundle link for k8s - only show install_bundle link for execution and hop nodes --- awx/api/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 07c9b3aaf2..25b389a77e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4917,7 +4917,8 @@ class InstanceSerializer(BaseSerializer): res = super(InstanceSerializer, self).get_related(obj) res['jobs'] = self.reverse('api:instance_unified_jobs_list', kwargs={'pk': obj.pk}) res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk}) - res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) + if settings.IS_K8S and obj.node_type in ('execution', 'hop'): + res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor: if obj.node_type != 'hop': res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) From 0465a10df5a8a7e82d6e56c7b5b02c15e18ee53d Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Wed, 24 Aug 2022 15:36:35 -0400 Subject: [PATCH 15/68] Deal with exceptions when running execution_node_health_check (#12733) --- awx/main/tasks/receptor.py | 2 +- awx/main/tasks/system.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 0dda5b48ad..a5e2586f4d 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -234,7 +234,7 @@ def worker_info(node_name, work_type='ansible-runner'): else: error_list.append(details) - except (ReceptorNodeNotFound, RuntimeError) as exc: + except Exception as exc: error_list.append(str(exc)) # If we have a connection error, missing keys would be trivial consequence of that diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index 0c22051f5a..24dbd98b6e 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -413,10 +413,12 @@ def execution_node_health_check(node): return if instance.node_type != 'execution': - raise RuntimeError(f'Execution node health check ran against {instance.node_type} node {instance.hostname}') + logger.warning(f'Execution node health check ran against {instance.node_type} node {instance.hostname}') + return if instance.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED): - raise RuntimeError(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}") + logger.warning(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}") + return data = worker_info(node) From 7e627e1d1e0bd22a5ca4844c44cba97cf2c0c296 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 26 Aug 2022 09:46:40 -0400 Subject: [PATCH 16/68] Adds Instance Peers Tab and update Instance Details view with more data (#12655) * Adds InstancePeers tab and updates details view * attempt to fix failing api tests --- awx/api/serializers.py | 1 + awx/api/urls/instance.py | 11 +- awx/api/views/__init__.py | 13 + awx/main/access.py | 1 + awx/main/tests/functional/test_tasks.py | 5 +- awx/ui/src/api/models/Instances.js | 4 + awx/ui/src/screens/Instances/Instance.js | 37 ++- .../InstanceDetail/InstanceDetail.js | 78 +++++- .../InstanceDetail/InstanceDetail.test.js | 11 + .../Instances/InstanceList/InstanceList.js | 8 +- .../InstancePeers/InstancePeerList.js | 164 ++++++++++++ .../InstancePeers/InstancePeerListItem.js | 247 ++++++++++++++++++ .../screens/Instances/InstancePeers/index.js | 1 + 13 files changed, 567 insertions(+), 14 deletions(-) create mode 100644 awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js create mode 100644 awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js create mode 100644 awx/ui/src/screens/Instances/InstancePeers/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 25b389a77e..4f94175afd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4919,6 +4919,7 @@ class InstanceSerializer(BaseSerializer): res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk}) if settings.IS_K8S and obj.node_type in ('execution', 'hop'): res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) + res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk}) if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor: if obj.node_type != 'hop': res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) diff --git a/awx/api/urls/instance.py b/awx/api/urls/instance.py index a9ef203384..8dad087b82 100644 --- a/awx/api/urls/instance.py +++ b/awx/api/urls/instance.py @@ -3,7 +3,15 @@ from django.urls import re_path -from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck, InstanceInstallBundle +from awx.api.views import ( + InstanceList, + InstanceDetail, + InstanceUnifiedJobsList, + InstanceInstanceGroupsList, + InstanceHealthCheck, + InstanceInstallBundle, + InstancePeersList, +) urls = [ @@ -12,6 +20,7 @@ urls = [ re_path(r'^(?P[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'), re_path(r'^(?P[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'), re_path(r'^(?P[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'), + re_path(r'^(?P[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'), re_path(r'^(?P[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'), ] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 9991bcfe1c..5d95e13b98 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -384,6 +384,8 @@ class InstanceDetail(RetrieveUpdateAPIView): obj = self.get_object() obj.set_capacity_value() obj.save(update_fields=['capacity']) + for instance in models.Instance.objects.filter(node_type__in=['control', 'hybrid']): + models.InstanceLink.objects.create(source=instance, target=obj) r.data = serializers.InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj) return r @@ -402,6 +404,17 @@ class InstanceUnifiedJobsList(SubListAPIView): return qs +class InstancePeersList(SubListAPIView): + + name = _("Instance Peers") + parent_model = models.Instance + model = models.Instance + serializer_class = serializers.InstanceSerializer + parent_access = 'read' + search_fields = {'hostname'} + relationship = 'peers' + + class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): name = _("Instance's Instance Groups") diff --git a/awx/main/access.py b/awx/main/access.py index a11789ee81..665c8e1f8d 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,6 +579,7 @@ class InstanceAccess(BaseAccess): return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data) def can_add(self, data): + return self.user.is_superuser def can_change(self, obj, data): diff --git a/awx/main/tests/functional/test_tasks.py b/awx/main/tests/functional/test_tasks.py index ce385cfced..6de551cf9f 100644 --- a/awx/main/tests/functional/test_tasks.py +++ b/awx/main/tests/functional/test_tasks.py @@ -19,12 +19,11 @@ def scm_revision_file(tmpdir_factory): @pytest.mark.django_db -@pytest.mark.parametrize('node_type', ('control', 'hybrid')) +@pytest.mark.parametrize('node_type', ('control. hybrid')) def test_no_worker_info_on_AWX_nodes(node_type): hostname = 'us-south-3-compute.invalid' Instance.objects.create(hostname=hostname, node_type=node_type) - with pytest.raises(RuntimeError): - execution_node_health_check(hostname) + assert execution_node_health_check(hostname) is None @pytest.fixture diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 07ee085c14..460b809ec1 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -18,6 +18,10 @@ class Instances extends Base { return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } + readPeers(instanceId) { + return this.http.get(`${this.baseUrl}${instanceId}/peers`); + } + readInstanceGroup(instanceId) { return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`); } diff --git a/awx/ui/src/screens/Instances/Instance.js b/awx/ui/src/screens/Instances/Instance.js index 8efe4b55f6..535bb1ea39 100644 --- a/awx/ui/src/screens/Instances/Instance.js +++ b/awx/ui/src/screens/Instances/Instance.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import { t } from '@lingui/macro'; import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom'; @@ -6,7 +6,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; import ContentError from 'components/ContentError'; import RoutedTabs from 'components/RoutedTabs'; +import useRequest from 'hooks/useRequest'; +import { SettingsAPI } from 'api'; +import ContentLoading from 'components/ContentLoading'; import InstanceDetail from './InstanceDetail'; +import InstancePeerList from './InstancePeers'; function Instance({ setBreadcrumb }) { const match = useRouteMatch(); @@ -25,6 +29,32 @@ function Instance({ setBreadcrumb }) { { name: t`Details`, link: `${match.url}/details`, id: 0 }, ]; + const { + result: { isK8s }, + error, + isLoading, + request, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('system'); + return data.IS_K8S; + }, []), + { isK8s: false, isLoading: true } + ); + useEffect(() => { + request(); + }, [request]); + + if (isK8s) { + tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 1 }); + } + if (isLoading) { + return ; + } + + if (error) { + return ; + } return ( @@ -34,6 +64,11 @@ function Instance({ setBreadcrumb }) { + {isK8s && ( + + + + )} {match.params.id && ( diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js index 9338b7c8d2..9d3ae47864 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { t, Plural } from '@lingui/macro'; import { Button, @@ -11,7 +11,9 @@ import { CodeBlockCode, Tooltip, Slider, + Chip, } from '@patternfly/react-core'; +import { DownloadIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { useConfig } from 'contexts/Config'; @@ -27,6 +29,7 @@ import ContentLoading from 'components/ContentLoading'; import { Detail, DetailList } from 'components/DetailList'; import StatusLabel from 'components/StatusLabel'; import useRequest, { useDismissableError } from 'hooks/useRequest'; +import ChipGroup from 'components/ChipGroup'; const Unavailable = styled.span` color: var(--pf-global--danger-color--200); @@ -65,10 +68,18 @@ function InstanceDetail({ setBreadcrumb }) { isLoading, error: contentError, request: fetchDetails, - result: instance, + result: { instance, instanceGroups }, } = useRequest( useCallback(async () => { - const { data: details } = await InstancesAPI.readDetail(id); + const [ + { data: details }, + { + data: { results }, + }, + ] = await Promise.all([ + InstancesAPI.readDetail(id), + InstancesAPI.readInstanceGroup(id), + ]); if (details.node_type !== 'hop') { const { data: healthCheckData } = @@ -83,9 +94,12 @@ function InstanceDetail({ setBreadcrumb }) { details.capacity_adjustment ) ); - return details; + return { + instance: details, + instanceGroups: results, + }; }, [id]), - {} + { instance: {}, instanceGroups: [] } ); useEffect(() => { fetchDetails(); @@ -127,6 +141,11 @@ function InstanceDetail({ setBreadcrumb }) { debounceUpdateInstance({ capacity_adjustment: roundedValue }); }; + const buildLinkURL = (inst) => + inst.is_container_group + ? '/instance_groups/container_group/' + : '/instance_groups/'; + const { error, dismissError } = useDismissableError( updateInstanceError || healthCheckError ); @@ -137,6 +156,7 @@ function InstanceDetail({ setBreadcrumb }) { return ; } const isHopNode = instance.node_type === 'hop'; + return ( @@ -158,12 +178,60 @@ function InstanceDetail({ setBreadcrumb }) { label={t`Policy Type`} value={instance.managed_by_policy ? t`Auto` : t`Manual`} /> + + {instanceGroups && ( + + {instanceGroups.map((ig) => ( + + + {ig.name} + + + ))} + + } + isEmpty={instanceGroups.length === 0} + /> + )} + {instance.related?.install_bundle && ( + + + + } + /> + )} ', () => { InstancesAPI.readDetail.mockResolvedValue({ data: { + related: {}, id: 1, type: 'instance', url: '/api/v2/instances/1/', @@ -51,6 +52,16 @@ describe('', () => { node_type: 'hybrid', }, }); + InstancesAPI.readInstanceGroup.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Foo', + }, + ], + }, + }); InstancesAPI.readHealthCheckDetail.mockResolvedValue({ data: { uuid: '00000000-0000-0000-0000-000000000000', diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index a50891bc68..ec3b59be9c 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -33,7 +33,7 @@ function InstanceList() { const { me } = useConfig(); const { - result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 }, + result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s }, error: contentError, isLoading, request: fetchInstances, @@ -47,7 +47,7 @@ function InstanceList() { ]); return { instances: response.data.results, - isK8: sysSettings.data.IS_K8S, + isK8s: sysSettings.data.IS_K8S, count: response.data.count, actions: responseActions.data.actions, relatedSearchableKeys: ( @@ -62,7 +62,7 @@ function InstanceList() { actions: {}, relatedSearchableKeys: [], searchableKeys: [], - isK8: false, + isK8s: false, } ); @@ -142,7 +142,7 @@ function InstanceList() { onExpandAll={expandAll} qsConfig={QS_CONFIG} additionalControls={[ - ...(isK8 && me.is_superuser + ...(isK8s && me.is_superuser ? [ { + const [ + { + data: { results, count: itemNumber }, + }, + actions, + ] = await Promise.all([ + InstancesAPI.readPeers(id), + InstancesAPI.readOptions(), + ]); + return { + peers: results, + count: itemNumber, + relatedSearchableKeys: (actions?.data?.related_search_fields || []).map( + (val) => val.slice(0, -8) + ), + searchableKeys: getSearchableKeys(actions.data.actions?.GET), + }; + }, [id]), + { + peers: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => fetchPeers(), [fetchPeers, id]); + + const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = + useSelected(peers.filter((i) => i.node_type !== 'hop')); + + const { + error: healthCheckError, + request: fetchHealthCheck, + isLoading: isHealthCheckLoading, + } = useRequest( + useCallback(async () => { + await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ instanceId }) => InstancesAPI.healthCheck(instanceId)) + ); + fetchPeers(); + }, [selected, fetchPeers]) + ); + const handleHealthCheck = async () => { + await fetchHealthCheck(); + clearSelected(); + }; + + const { error, dismissError } = useDismissableError(healthCheckError); + + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(peers); + + return ( + + ( + , + ]} + /> + )} + headerRow={ + + {t`Name`} + + } + renderRow={(peer, index) => ( + handleSelect(peer)} + isSelected={selected.some((row) => row.id === peer.id)} + isExpanded={expanded.some((row) => row.id === peer.id)} + onExpand={() => handleExpand(peer)} + key={peer.id} + peerInstance={peer} + rowIndex={index} + fetchInstance={fetchPeers} + /> + )} + /> + {error && ( + + {t`Failed to run a health check on one or more peers.`} + + + )} + + ); +} + +export default InstancePeerList; diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js new file mode 100644 index 0000000000..defa7c11d9 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js @@ -0,0 +1,247 @@ +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { t, Plural } from '@lingui/macro'; +import styled from 'styled-components'; +import 'styled-components/macro'; +import { + Progress, + ProgressMeasureLocation, + ProgressSize, + Slider, + Tooltip, +} from '@patternfly/react-core'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import { formatDateString } from 'util/dates'; +import computeForks from 'util/computeForks'; +import { ActionsTd, ActionItem } from 'components/PaginatedTable'; +import InstanceToggle from 'components/InstanceToggle'; +import StatusLabel from 'components/StatusLabel'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import useDebounce from 'hooks/useDebounce'; +import { InstancesAPI } from 'api'; +import { useConfig } from 'contexts/Config'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import { Detail, DetailList } from 'components/DetailList'; + +const Unavailable = styled.span` + color: var(--pf-global--danger-color--200); +`; + +const SliderHolder = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const SliderForks = styled.div` + flex-grow: 1; + margin-right: 8px; + margin-left: 8px; + text-align: center; +`; + +function InstancePeerListItem({ + peerInstance, + fetchInstances, + isSelected, + onSelect, + isExpanded, + onExpand, + rowIndex, +}) { + const { me = {} } = useConfig(); + const [forks, setForks] = useState( + computeForks( + peerInstance.mem_capacity, + peerInstance.cpu_capacity, + peerInstance.capacity_adjustment + ) + ); + const labelId = `check-action-${peerInstance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {t`Unavailable`}; + } + + const { error: updateInstanceError, request: updateInstance } = useRequest( + useCallback( + async (values) => { + await InstancesAPI.update(peerInstance.id, values); + }, + [peerInstance] + ) + ); + + const { error: updateError, dismissError: dismissUpdateError } = + useDismissableError(updateInstanceError); + + const debounceUpdateInstance = useDebounce(updateInstance, 200); + + const handleChangeValue = (value) => { + const roundedValue = Math.round(value * 100) / 100; + setForks( + computeForks( + peerInstance.mem_capacity, + peerInstance.cpu_capacity, + roundedValue + ) + ); + debounceUpdateInstance({ capacity_adjustment: roundedValue }); + }; + const isHopNode = peerInstance.node_type === 'hop'; + return ( + <> + + {isHopNode ? ( + + ) : ( + + )} + + + + {peerInstance.hostname} + + + + + + {t`Last Health Check`} +   + {formatDateString( + peerInstance.last_health_check ?? peerInstance.last_seen + )} + + } + > + + + + + {peerInstance.node_type} + {!isHopNode && ( + <> + + +
{t`CPU ${peerInstance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${peerInstance.mem_capacity}`}
+
+ + + + {usedCapacity(peerInstance)} + + + + + + + + + )} + + {!isHopNode && ( + + + + + + + + + + + + + + )} + {updateError && ( + + {t`Failed to update capacity adjustment.`} + + + )} + + ); +} + +export default InstancePeerListItem; diff --git a/awx/ui/src/screens/Instances/InstancePeers/index.js b/awx/ui/src/screens/Instances/InstancePeers/index.js new file mode 100644 index 0000000000..1be96e8381 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/index.js @@ -0,0 +1 @@ +export { default } from './InstancePeerList'; From 89a6162dcd077af12caa0dfa2925edea1794f33b Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Sun, 21 Aug 2022 20:10:40 -0700 Subject: [PATCH 17/68] Add new node details; update legend. --- awx/ui/src/screens/TopologyView/Legend.js | 159 +++++++++-- awx/ui/src/screens/TopologyView/MeshGraph.js | 82 +++++- awx/ui/src/screens/TopologyView/Tooltip.js | 285 ++++++++++++++++--- 3 files changed, 447 insertions(+), 79 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 5fe35beb51..b292786a59 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -14,8 +14,12 @@ import { } from '@patternfly/react-core'; import { - ExclamationIcon as PFExclamationIcon, - CheckIcon as PFCheckIcon, + ExclamationIcon, + CheckIcon, + OutlinedClockIcon, + PlusIcon, + MinusIcon, + ResourcesEmptyIcon, } from '@patternfly/react-icons'; const Wrapper = styled.div` @@ -27,23 +31,20 @@ const Wrapper = styled.div` background-color: rgba(255, 255, 255, 0.85); `; const Button = styled(PFButton)` - width: 20px; - height: 20px; - border-radius: 10px; - padding: 0; - font-size: 11px; + &&& { + width: 20px; + height: 20px; + border-radius: 10px; + padding: 0; + font-size: 11px; + background-color: white; + border: 1px solid #ccc; + color: black; + } `; const DescriptionListDescription = styled(PFDescriptionListDescription)` font-size: 11px; `; -const ExclamationIcon = styled(PFExclamationIcon)` - fill: white; - margin-left: 2px; -`; -const CheckIcon = styled(PFCheckIcon)` - fill: white; - margin-left: 2px; -`; const DescriptionList = styled(PFDescriptionList)` gap: 7px; `; @@ -70,9 +71,7 @@ function Legend() { - + {t`Control node`} @@ -110,27 +109,133 @@ function Legend() { - - - - {nodeDetail.hostname} - - - - - {t`Type`} - - {nodeDetail.node_type} {t`node`} - - - - {t`Status`} - - - - - + {isLoading && } + {!isLoading && ( + + + + {' '} + + {instanceDetail.hostname} + + + + + {t`Instance status`} + + + + + + {t`Instance type`} + + {instanceDetail.node_type} + + + {instanceDetail.related?.install_bundle && ( + + {t`Download bundle`} + + + + + + + + + )} + {instanceDetail.ip_address && ( + + {t`IP address`} + + {instanceDetail.ip_address} + + + )} + {instanceGroups && ( + + {t`Instance groups`} + + {renderInstanceGroups(instanceGroups.results)} + + + )} + {instanceDetail.node_type !== 'hop' && ( + <> + + {t`Forks`} + + +
{t`CPU ${instanceDetail.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instanceDetail.mem_capacity}`}
+
+
+
+ + {t`Capacity`} + + {usedCapacity(instanceDetail)} + + + + + + + + + )} + + + {t`Last modified`} + + {formatDateString(instanceDetail.modified)} + + + + {t`Last seen`} + + {instanceDetail.last_seen + ? formatDateString(instanceDetail.last_seen) + : `not found`} + + +
+ )} )} From 28f24c8811a50e94f500d029ca88f649b04d706f Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 24 Aug 2022 15:37:44 -0700 Subject: [PATCH 18/68] Represent `enabled` field in Topology View: - use dotted circles to represent `enabled: false` - use solid circle stroke to represent `enabled: true` - excise places where `Unavailable` node state is used in the UI. --- awx/ui/src/screens/TopologyView/Legend.js | 15 --------------- awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- awx/ui/src/screens/TopologyView/constants.js | 3 --- awx/ui/src/screens/TopologyView/utils/helpers.js | 6 ++---- 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index b292786a59..c57cba43da 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -19,7 +19,6 @@ import { OutlinedClockIcon, PlusIcon, MinusIcon, - ResourcesEmptyIcon, } from '@patternfly/react-icons'; const Wrapper = styled.div` @@ -162,20 +161,6 @@ function Legend() { {t`Deprovisioning`} - - - + + + )} + + {isModalOpen && ( + toggleModal(false)} + actions={[ + , + , + ]} + > +
{t`This action will remove the following instances:`}
+ {itemsToRemove.map((item) => ( + + {item.hostname} +
+
+ ))} + {removeDetails && ( + + )} +
+ )} + + ); +} + +export default RemoveInstanceButton; diff --git a/awx/ui/src/util/getRelatedResourceDeleteDetails.js b/awx/ui/src/util/getRelatedResourceDeleteDetails.js index f9d9496aec..a3289c57f8 100644 --- a/awx/ui/src/util/getRelatedResourceDeleteDetails.js +++ b/awx/ui/src/util/getRelatedResourceDeleteDetails.js @@ -15,6 +15,7 @@ import { ExecutionEnvironmentsAPI, ApplicationsAPI, OrganizationsAPI, + InstanceGroupsAPI, } from 'api'; export async function getRelatedResourceDeleteCounts(requests) { @@ -274,4 +275,11 @@ export const relatedResourceDeleteRequests = { label: t`Templates`, }, ], + + instance: (selected) => [ + { + request: () => InstanceGroupsAPI.read({ instances: selected.id }), + label: t`Instance Groups`, + }, + ], }; From 68a44529b6b77d2d43d7099b654560bfd8bbf518 Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Tue, 6 Sep 2022 16:26:46 -0400 Subject: [PATCH 35/68] Register pages for the Instance peers and install bundle endpoints This includes exposing a new interface for Page objects, Page.bytes, to return the full bytestring contents of the response. --- awx/api/views/instance_install_bundle.py | 4 +-- awxkit/awxkit/api/pages/instances.py | 13 +++++++++- awxkit/awxkit/api/pages/page.py | 31 ++++++++++++++++-------- awxkit/awxkit/api/resources.py | 2 ++ 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py index ff75bfbcd2..63a650d96f 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -48,9 +48,9 @@ class InstanceInstallBundle(GenericAPIView): instance_obj = self.get_object() # if the instance is not a hop or execution node than return 400 - if instance_obj.node_type not in ('execution', 'hop'): + if instance_obj.node_type not in ('execution',): return Response( - data=dict(msg=_('Install bundle can only be generated for execution or hop nodes.')), + data=dict(msg=_('Install bundle can only be generated for execution nodes.')), status=status.HTTP_400_BAD_REQUEST, ) diff --git a/awxkit/awxkit/api/pages/instances.py b/awxkit/awxkit/api/pages/instances.py index 38695014bf..d30694ed6c 100644 --- a/awxkit/awxkit/api/pages/instances.py +++ b/awxkit/awxkit/api/pages/instances.py @@ -16,4 +16,15 @@ class Instances(page.PageList, Instance): pass -page.register_page([resources.instances, resources.related_instances], Instances) +page.register_page([resources.instances, resources.related_instances, resources.instance_peers], Instances) + + +class InstanceInstallBundle(page.Page): + def extract_data(self, response): + # The actual content of this response will be in the full set + # of bytes from response.content, which will be exposed via + # the Page.bytes interface. + return {} + + +page.register_page(resources.instance_install_bundle, InstanceInstallBundle) diff --git a/awxkit/awxkit/api/pages/page.py b/awxkit/awxkit/api/pages/page.py index 25e26c636d..65f3012587 100644 --- a/awxkit/awxkit/api/pages/page.py +++ b/awxkit/awxkit/api/pages/page.py @@ -154,6 +154,26 @@ class Page(object): resp.status_code = 200 return cls(r=resp, connection=connection) + @property + def bytes(self): + if self.r is None: + return b'' + return self.r.content + + def extract_data(self, response): + """Takes a `requests.Response` and returns a data dict.""" + try: + data = response.json() + except ValueError as e: # If there was no json to parse + data = {} + if response.text or response.status_code not in (200, 202, 204): + text = response.text + if len(text) > 1024: + text = text[:1024] + '... <<< Truncated >>> ...' + log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) + + return data + def page_identity(self, response, request_json=None): """Takes a `requests.Response` and returns a new __item_class__ instance if the request method is not a get, or returns @@ -171,16 +191,7 @@ class Page(object): else: ds = None - try: - data = response.json() - except ValueError as e: # If there was no json to parse - data = dict() - if response.text or response.status_code not in (200, 202, 204): - text = response.text - if len(text) > 1024: - text = text[:1024] + '... <<< Truncated >>> ...' - log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) - + data = self.extract_data(response) exc_str = "%s (%s) received" % (http.responses[response.status_code], response.status_code) exception = exception_from_status_code(response.status_code) diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 815f10ac9d..982e7c2573 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -53,6 +53,8 @@ class Resources(object): _instance_group = r'instance_groups/\d+/' _instance_group_related_jobs = r'instance_groups/\d+/jobs/' _instance_groups = 'instance_groups/' + _instance_install_bundle = r'instances/\d+/install_bundle/' + _instance_peers = r'instances/\d+/peers/' _instance_related_jobs = r'instances/\d+/jobs/' _instances = 'instances/' _inventories = 'inventories/' From eaa4f2483f4213fe464e885a37996a05402d1ada Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Fri, 2 Sep 2022 20:38:01 -0400 Subject: [PATCH 36/68] Run instance health check in task container awx-web container does not have access to receptor socket, and the execution node health check requires receptorctl. This change runs the health check asynchronously in the task container. --- awx/api/views/__init__.py | 31 +++---------------------------- awx/main/models/ha.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7865527a2b..a7803bca4e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -460,41 +460,16 @@ class InstanceHealthCheck(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - # Note: hop nodes are already excluded by the get_queryset method if obj.node_type == 'execution': from awx.main.tasks.system import execution_node_health_check - runner_data = execution_node_health_check(obj.hostname) - obj.refresh_from_db() - data = self.get_serializer(data=request.data).to_representation(obj) - # Add in some extra unsaved fields - for extra_field in ('transmit_timing', 'run_timing'): - if extra_field in runner_data: - data[extra_field] = runner_data[extra_field] + execution_node_health_check.apply_async([obj.hostname]) else: from awx.main.tasks.system import cluster_node_health_check - if settings.CLUSTER_HOST_ID == obj.hostname: - cluster_node_health_check(obj.hostname) - else: - cluster_node_health_check.apply_async([obj.hostname], queue=obj.hostname) - start_time = time.time() - prior_check_time = obj.last_health_check - while time.time() - start_time < 50.0: - obj.refresh_from_db(fields=['last_health_check']) - if obj.last_health_check != prior_check_time: - break - if time.time() - start_time < 1.0: - time.sleep(0.1) - else: - time.sleep(1.0) - else: - obj.mark_offline(errors=_('Health check initiated by user determined this instance to be unresponsive')) - obj.refresh_from_db() - data = self.get_serializer(data=request.data).to_representation(obj) - - return Response(data, status=status.HTTP_200_OK) + cluster_node_health_check.apply_async([obj.hostname], queue=obj.hostname) + return Response(dict(msg=f"Health check is running for {obj.hostname}."), status=status.HTTP_200_OK) class InstanceGroupList(ListCreateAPIView): diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index cd6313ecaa..9ecaece5de 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -243,20 +243,21 @@ class Instance(HasPolicyEditsMixin, BaseModel): def mark_offline(self, update_last_seen=False, perform_save=True, errors=''): if self.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED): - return + return [] if self.node_state == Instance.States.UNAVAILABLE and self.errors == errors and (not update_last_seen): - return + return [] self.node_state = Instance.States.UNAVAILABLE self.cpu_capacity = self.mem_capacity = self.capacity = 0 self.errors = errors if update_last_seen: self.last_seen = now() + update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors'] + if update_last_seen: + update_fields += ['last_seen'] if perform_save: - update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors'] - if update_last_seen: - update_fields += ['last_seen'] self.save(update_fields=update_fields) + return update_fields def set_capacity_value(self): """Sets capacity according to capacity adjustment rule (no save)""" @@ -314,7 +315,8 @@ class Instance(HasPolicyEditsMixin, BaseModel): self.node_state = Instance.States.READY update_fields.append('node_state') else: - self.mark_offline(perform_save=False, errors=errors) + fields_to_update = self.mark_offline(perform_save=False, errors=errors) + update_fields.extend(fields_to_update) update_fields.extend(['cpu_capacity', 'mem_capacity', 'capacity']) # disabling activity stream will avoid extra queries, which is important for heatbeat actions From dfe6ce1ba85ab9f2e60439ec4f57ec7352884049 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 6 Sep 2022 18:18:56 -0400 Subject: [PATCH 37/68] remove tests that assume health check runs in view --- .../tests/functional/api/test_instance.py | 34 +++---------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/awx/main/tests/functional/api/test_instance.py b/awx/main/tests/functional/api/test_instance.py index 4184d876aa..ec569d945f 100644 --- a/awx/main/tests/functional/api/test_instance.py +++ b/awx/main/tests/functional/api/test_instance.py @@ -1,16 +1,9 @@ import pytest -from unittest import mock - from awx.api.versioning import reverse from awx.main.models.activity_stream import ActivityStream from awx.main.models.ha import Instance -import redis - -# Django -from django.test.utils import override_settings - INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_capacity=6, mem_capacity=42) @@ -50,33 +43,14 @@ def test_enabled_sets_capacity(patch, admin_user): def test_auditor_user_health_check(get, post, system_auditor): instance = Instance.objects.create(**INSTANCE_KWARGS) url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - r = get(url=url, user=system_auditor, expect=200) - assert r.data['cpu_capacity'] == instance.cpu_capacity + get(url=url, user=system_auditor, expect=200) post(url=url, user=system_auditor, expect=403) @pytest.mark.django_db -def test_health_check_throws_error(post, admin_user): - instance = Instance.objects.create(node_type='execution', **INSTANCE_KWARGS) - url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - # we will simulate a receptor error, similar to this one - # https://github.com/ansible/receptor/blob/156e6e24a49fbf868734507f9943ac96208ed8f5/receptorctl/receptorctl/socket_interface.py#L204 - # related to issue https://github.com/ansible/tower/issues/5315 - with mock.patch('awx.main.tasks.receptor.run_until_complete', side_effect=RuntimeError('Remote error: foobar')): - post(url=url, user=admin_user, expect=200) - instance.refresh_from_db() - assert 'Remote error: foobar' in instance.errors - assert instance.capacity == 0 - - -@pytest.mark.django_db -@mock.patch.object(redis.client.Redis, 'ping', lambda self: True) def test_health_check_usage(get, post, admin_user): instance = Instance.objects.create(**INSTANCE_KWARGS) url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - r = get(url=url, user=admin_user, expect=200) - assert r.data['cpu_capacity'] == instance.cpu_capacity - assert r.data['last_health_check'] is None - with override_settings(CLUSTER_HOST_ID=instance.hostname): # force direct call of cluster_node_health_check - r = post(url=url, user=admin_user, expect=200) - assert r.data['last_health_check'] is not None + get(url=url, user=admin_user, expect=200) + r = post(url=url, user=admin_user, expect=200) + assert r.data['msg'] == f"Health check is running for {instance.hostname}." From 08c18d71bf05158112686405b596b604dc0b8bdb Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Thu, 1 Sep 2022 11:34:21 -0400 Subject: [PATCH 38/68] Move InstanceLink creation and updating to the async tasks So that they get applied in situations that do not go through the API. --- awx/api/views/__init__.py | 8 -------- awx/main/models/ha.py | 4 ++-- awx/main/tasks/receptor.py | 11 ++++++++++- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index a7803bca4e..128bc3cca1 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -368,11 +368,6 @@ class InstanceList(ListCreateAPIView): search_fields = ('hostname',) ordering = ('id',) - def perform_create(self, serializer): - obj = serializer.save(node_state=models.Instance.States.INSTALLED) - for instance in models.Instance.objects.filter(node_type__in=[models.Instance.Types.CONTROL, models.Instance.Types.HYBRID]): - models.InstanceLink.objects.create(source=instance, target=obj, link_state=models.InstanceLink.States.ADDING) - class InstanceDetail(RetrieveUpdateAPIView): @@ -384,9 +379,6 @@ class InstanceDetail(RetrieveUpdateAPIView): r = super(InstanceDetail, self).update(request, *args, **kwargs) if status.is_success(r.status_code): obj = self.get_object() - if obj.node_state == models.Instance.States.DEPROVISIONING: - models.InstanceLink.objects.filter(target=obj).update(link_state=models.InstanceLink.States.REMOVING) - models.InstanceLink.objects.filter(source=obj).update(link_state=models.InstanceLink.States.REMOVING) obj.set_capacity_value() obj.save(update_fields=['capacity']) r.data = serializers.InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 9ecaece5de..38e8ac0068 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -427,11 +427,11 @@ def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): if settings.IS_K8S and instance.node_type in (Instance.Types.EXECUTION,): if instance.node_state == Instance.States.DEPROVISIONING: - from awx.main.tasks.receptor import wait_for_jobs # prevents circular import + from awx.main.tasks.receptor import remove_deprovisioned_node # prevents circular import # wait for jobs on the node to complete, then delete the # node and kick off write_receptor_config - connection.on_commit(lambda: wait_for_jobs.apply_async(instance.hostname)) + connection.on_commit(lambda: remove_deprovisioned_node.apply_async([instance.hostname])) if instance.node_state == Instance.States.INSTALLED: from awx.main.tasks.receptor import write_receptor_config # prevents circular import diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 3ec579844e..baf95dd412 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -646,9 +646,15 @@ def write_receptor_config(): this_inst = Instance.objects.me() instances = Instance.objects.filter(node_type=Instance.Types.EXECUTION) + existing_peers = {link.target_id for link in InstanceLink.objects.filter(source=this_inst)} + new_links = [] for instance in instances: peer = {'tcp-peer': {'address': f'{instance.hostname}:{instance.listener_port}', 'tls': 'tlsclient'}} receptor_config.append(peer) + if instance.id not in existing_peers: + new_links.append(InstanceLink(source=this_inst, target=instance, link_state=InstanceLink.States.ADDING)) + + InstanceLink.objects.bulk_create(new_links) with open(__RECEPTOR_CONF, 'w') as file: yaml.dump(receptor_config, file, default_flow_style=False) @@ -672,7 +678,10 @@ def write_receptor_config(): @task(queue=get_local_queuename) -def wait_for_jobs(hostname): +def remove_deprovisioned_node(hostname): + InstanceLink.objects.filter(source__hostname=hostname).update(link_state=InstanceLink.States.REMOVING) + InstanceLink.objects.filter(target__hostname=hostname).update(link_state=InstanceLink.States.REMOVING) + node_jobs = UnifiedJob.objects.filter( execution_node=hostname, status__in=( From 03685e51b5f2e7e687d6df1937355612efe70858 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Fri, 2 Sep 2022 12:39:54 -0700 Subject: [PATCH 39/68] Fix Instance Detail StatusLabel to show node_state. --- .../InstanceGroup/InstanceDetails/InstanceDetails.js | 6 +++--- .../InstanceGroup/InstanceDetails/InstanceDetails.test.js | 1 + .../src/screens/Instances/InstanceDetail/InstanceDetail.js | 5 +++-- .../screens/Instances/InstanceDetail/InstanceDetail.test.js | 1 + 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js index f2449a1556..43d9fe6eae 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js @@ -115,7 +115,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { useEffect(() => { fetchDetails(); }, [fetchDetails]); - const { error: healthCheckError, isLoading: isRunningHealthCheck, @@ -148,7 +147,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { [instance] ) ); - const debounceUpdateInstance = useDebounce(updateInstance, 200); const handleChangeValue = (value) => { @@ -200,7 +198,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { + instance.node_state ? ( + + ) : null } /> ', () => { enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', }, }); InstancesAPI.readHealthCheckDetail.mockResolvedValue({ diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js index c8d35ac10d..9d3e081f1b 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js @@ -113,7 +113,6 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { setBreadcrumb(instance); } }, [instance, setBreadcrumb]); - const { error: healthCheckError, isLoading: isRunningHealthCheck, @@ -186,7 +185,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { + instance.node_state ? ( + + ) : null } /> diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js index a2bff6d281..019b43a3dd 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js @@ -50,6 +50,7 @@ describe('', () => { enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', }, }); InstancesAPI.readInstanceGroup.mockResolvedValue({ From 1fde9c4f0c02a805ca74186df2e9494ad0c79115 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 8 Sep 2022 13:55:18 -0400 Subject: [PATCH 40/68] add firewall rules to control node --- awx/main/tasks/receptor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index baf95dd412..916210c075 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -606,6 +606,7 @@ class AWXReceptorJob: RECEPTOR_CONFIG_STARTER = ( {'local-only': None}, {'log-level': 'debug'}, + {'node': {'firewallrules': [{'action': 'reject', 'tonode': settings.CLUSTER_HOST_ID, 'toservice': 'control'}]}}, {'control-service': {'service': 'control', 'filename': '/var/run/receptor/receptor.sock', 'permissions': '0660'}}, {'work-command': {'worktype': 'local', 'command': 'ansible-runner', 'params': 'worker', 'allowruntimeparams': True}}, {'work-signing': {'privatekey': '/etc/receptor/signing/work-private-key.pem', 'tokenexpiration': '1m'}}, From b1168ce77d4c0b6dadb9967a236d533405032cd5 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Thu, 8 Sep 2022 16:11:32 -0400 Subject: [PATCH 41/68] update receptor collection role name in install bundle --- awx/api/views/instance_install_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py index 63a650d96f..d470d5c8ca 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -110,7 +110,7 @@ def generate_playbook(): name: "{{ receptor_user }}" shell: /bin/bash - import_role: - name: ansible.receptor.receptor + name: ansible.receptor.setup - name: Install ansible-runner pip: name: ansible-runner From fd10d83893d94b79cd8664209f6d5b83c11c6699 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 7 Sep 2022 12:39:32 -0700 Subject: [PATCH 42/68] Account for node state of 'unavailable' in the UI. --- awx/ui/src/components/StatusIcon/StatusIcon.js | 1 + awx/ui/src/components/StatusIcon/icons.js | 1 + awx/ui/src/components/StatusLabel/StatusLabel.js | 3 +++ awx/ui/src/screens/TopologyView/constants.js | 1 + awx/ui/src/screens/TopologyView/utils/helpers.js | 5 ++++- 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/awx/ui/src/components/StatusIcon/StatusIcon.js b/awx/ui/src/components/StatusIcon/StatusIcon.js index d63da75fd6..909e800256 100644 --- a/awx/ui/src/components/StatusIcon/StatusIcon.js +++ b/awx/ui/src/components/StatusIcon/StatusIcon.js @@ -27,6 +27,7 @@ const colors = { installed: blue, provisioning: gray, deprovisioning: gray, + unavailable: red, 'provision-fail': red, 'deprovision-fail': red, }; diff --git a/awx/ui/src/components/StatusIcon/icons.js b/awx/ui/src/components/StatusIcon/icons.js index 01eb1704ba..74b50a31ed 100644 --- a/awx/ui/src/components/StatusIcon/icons.js +++ b/awx/ui/src/components/StatusIcon/icons.js @@ -46,6 +46,7 @@ const icons = { installed: ClockIcon, provisioning: PlusCircleIcon, deprovisioning: MinusCircleIcon, + unavailable: ExclamationCircleIcon, 'provision-fail': ExclamationCircleIcon, 'deprovision-fail': ExclamationCircleIcon, }; diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.js b/awx/ui/src/components/StatusLabel/StatusLabel.js index dc78fd69e6..284e2c8d5f 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.js @@ -29,6 +29,7 @@ const colors = { installed: 'blue', provisioning: 'gray', deprovisioning: 'gray', + unavailable: 'red', 'provision-fail': 'red', 'deprovision-fail': 'red', }; @@ -57,6 +58,7 @@ export default function StatusLabel({ status, tooltipContent = '', children }) { installed: t`Installed`, provisioning: t`Provisioning`, deprovisioning: t`Deprovisioning`, + unavailable: t`Unavailable`, 'provision-fail': t`Provisioning fail`, 'deprovision-fail': t`Deprovisioning fail`, }; @@ -106,6 +108,7 @@ StatusLabel.propTypes = { 'installed', 'provisioning', 'deprovisioning', + 'unavailable', 'provision-fail', 'deprovision-fail', ]).isRequired, diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index efff753ae5..1748a94c9e 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -21,6 +21,7 @@ export const NODE_STATE_COLOR_KEY = { ready: '#3E8635', 'provision-fail': '#C9190B', 'deprovision-fail': '#C9190B', + unavailable: '#C9190B', installed: '#0066CC', provisioning: '#666', deprovisioning: '#666', diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index 02bff9c3dd..d2d875dc91 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -44,6 +44,7 @@ export function renderLabelIcons(nodeState) { const nodeLabelIconMapper = { ready: 'checkmark', installed: 'clock', + unavailable: 'exclaimation', 'provision-fail': 'exclaimation', 'deprovision-fail': 'exclaimation', provisioning: 'plus', @@ -60,6 +61,7 @@ export function renderIconPosition(nodeState, bbox) { const iconPositionMapper = { ready: `translate(${bbox.x - 15}, ${bbox.y + 3}), scale(0.02)`, installed: `translate(${bbox.x - 18}, ${bbox.y + 1}), scale(0.03)`, + unavailable: `translate(${bbox.x - 9}, ${bbox.y + 3}), scale(0.02)`, 'provision-fail': `translate(${bbox.x - 9}, ${bbox.y + 3}), scale(0.02)`, 'deprovision-fail': `translate(${bbox.x - 9}, ${ bbox.y + 3 @@ -128,7 +130,8 @@ export const generateRandomNodes = (n) => { 'installed', 'provision-fail', 'deprovision-fail', - ][getRandomInt(0, 5)]; + 'unavailable', + ][getRandomInt(0, 6)]; } for (let i = 0; i < n; i++) { const id = i + 1; From c1ba769b200aaa9199ea462ec7735898ce4ce63b Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 7 Sep 2022 12:28:18 -0700 Subject: [PATCH 43/68] Add enabled and disabled node states to legend. --- awx/ui/src/screens/TopologyView/Legend.js | 62 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index c57cba43da..57a5b147ae 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -102,7 +102,7 @@ function Legend() {
- {t`Status types`} + {t`Node state types`} @@ -175,6 +175,66 @@ function Legend() { {t`Error`} + + + + + + C + + + + {t`Enabled`} + + + + + + + C + + + + {t`Disabled`} + + + + {t`Link state types`} + + From d4b25058cd2b0be250ed9c7465a809dbe88ef00f Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 8 Sep 2022 09:48:34 -0700 Subject: [PATCH 44/68] Add update node logic; fix JSX formatting on SVG elements. --- awx/ui/src/screens/TopologyView/Legend.js | 39 ++++++++------- awx/ui/src/screens/TopologyView/MeshGraph.js | 52 +++++++++++++++----- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 57a5b147ae..d674ecd4cf 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -1,3 +1,4 @@ +/* eslint i18next/no-literal-string: "off" */ import React from 'react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; @@ -70,14 +71,14 @@ function Legend() { - + {t`Control node`} @@ -87,7 +88,7 @@ function Legend() { {t`Hybrid node`} @@ -95,7 +96,7 @@ function Legend() { {t`Hop node`} @@ -183,18 +184,18 @@ function Legend() { cx="10" cy="10" fill="transparent" - stroke-width="1px" + strokeWidth="1px" stroke="#ccc" - > + /> C @@ -210,19 +211,19 @@ function Legend() { cx="10" cy="10" fill="transparent" - stroke-dasharray="3" - stroke-width="1px" + strokeDasharray="5" + strokeWidth="1px" stroke="#ccc" - > + /> C diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index f520a75b69..9360580f70 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -41,6 +41,7 @@ const Loader = styled(ContentLoading)` background: white; `; function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { + const [storedNodes, setStoredNodes] = useState(null); const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [simulationProgress, setSimulationProgress] = useState(null); @@ -75,6 +76,42 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { fetchDetails(); }, [selectedNode, fetchDetails]); + function updateNodeSVG(nodes) { + if (nodes) { + d3.selectAll('[class*="id-"]') + .data(nodes) + .attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`)); + } + } + + useEffect(() => { + function handleResize() { + d3.select('.simulation-loader').style('visibility', 'visible'); + setSelectedNode(null); + setIsNodeSelected(false); + draw(); + } + window.addEventListener('resize', debounce(handleResize, 500)); + handleResize(); + return () => window.removeEventListener('resize', handleResize); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // update mesh when user toggles enabled/disabled slider + useEffect(() => { + if (instance?.id) { + const updatedNodes = storedNodes.map((n) => + n.id === instance.id ? { ...n, enabled: instance.enabled } : n + ); + setStoredNodes(updatedNodes); + } + }, [instance]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (storedNodes) { + updateNodeSVG(storedNodes); + } + }, [storedNodes]); + const draw = () => { let width; let height; @@ -124,6 +161,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { } function ended({ nodes, links }) { + setStoredNodes(nodes); // Remove loading screen d3.select('.simulation-loader').style('visibility', 'hidden'); setShowZoomControls(true); @@ -205,7 +243,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) .attr('fill', DEFAULT_NODE_COLOR) - .attr('stroke-dasharray', (d) => (d.enabled ? null : 3)) + .attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`)) .attr('stroke', DEFAULT_NODE_STROKE_COLOR); // node type labels @@ -341,18 +379,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { } }; - useEffect(() => { - function handleResize() { - d3.select('.simulation-loader').style('visibility', 'visible'); - setSelectedNode(null); - setIsNodeSelected(false); - draw(); - } - window.addEventListener('resize', debounce(handleResize, 500)); - handleResize(); - return () => window.removeEventListener('resize', handleResize); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - return (
{showLegend && } From 6619cc39f756cfcec31af22e56ad5dee8016bdec Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 12 Sep 2022 15:46:05 -0400 Subject: [PATCH 45/68] properly deprovisions instance --- awx/ui/src/api/models/Instances.js | 2 +- awx/ui/src/screens/Instances/InstanceList/InstanceList.js | 2 +- awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 21445ff02a..9434c94be3 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -27,7 +27,7 @@ class Instances extends Base { } deprovisionInstance(instanceId) { - return this.http.post(`${this.baseUrl}${instanceId}`, { + return this.http.patch(`${this.baseUrl}${instanceId}/`, { node_state: 'deprovisioning', }); } diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index 88d1d4041b..f73fad8c44 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -110,7 +110,7 @@ function InstanceList() { Promise.all( selected.map(({ id }) => InstancesAPI.deprovisionInstance(id)) ), - { fetchItems: fetchInstances } + { fetchItems: fetchInstances, qsConfig: QS_CONFIG } ); return ( diff --git a/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js index eee605c989..b6b1fb2986 100644 --- a/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js +++ b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js @@ -38,7 +38,7 @@ function RemoveInstanceButton({ itemsToRemove, onRemove, isK8s }) { const toggleModal = async (isOpen) => { setRemoveDetails(null); setIsLoading(true); - if (isOpen && itemsToRemove.length === 1) { + if (isOpen && itemsToRemove.length > 0) { const { results, error } = await getRelatedResourceDeleteCounts( relatedResourceDeleteRequests.instance(itemsToRemove[0]) ); @@ -85,7 +85,7 @@ function RemoveInstanceButton({ itemsToRemove, onRemove, isK8s }) { {removeDetails && Object.entries(removeDetails).map(([key, value]) => ( From 0e578534fa7af60e5ea409fd7e93c06838ebb988 Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Tue, 13 Sep 2022 10:15:26 -0400 Subject: [PATCH 46/68] Update the instance install bundle requirements.yml to point to the 0.1.0 release of ansible.receptor. --- awx/api/views/instance_install_bundle.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py index d470d5c8ca..e8155231d1 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -121,7 +121,10 @@ def generate_playbook(): def generate_requirements_yml(): return """--- collections: - - name: ansible.receptor + - name: ansible.receptor + source: https://github.com/ansible/receptor-collection/ + type: git + version: 0.1.0 """ From b4edfc24acc486f55c5c5923c0a427189840b520 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 6 Sep 2022 12:01:23 -0700 Subject: [PATCH 47/68] Add more helper unit tests. --- .../TopologyView/utils/helpers__RTL.test.js | 60 +++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js index 4b0ec8cd40..38a0c55931 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js @@ -3,11 +3,16 @@ import { renderLabelText, renderNodeType, renderNodeIcon, + renderLabelIcons, + renderIconPosition, + renderLinkState, redirectToDetailsPage, getHeight, getWidth, } from './helpers'; +import { ICONS } from '../constants'; + describe('renderStateColor', () => { test('returns correct node state color', () => { expect(renderStateColor('ready')).toBe('#3E8635'); @@ -26,13 +31,13 @@ describe('renderNodeType', () => { test('returns correct node type', () => { expect(renderNodeType('control')).toBe('C'); }); - test('returns empty string if state is not found', () => { + test('returns empty string if type is not found', () => { expect(renderNodeType('foo')).toBe(''); }); - test('returns empty string if state is null', () => { + test('returns empty string if type is null', () => { expect(renderNodeType(null)).toBe(''); }); - test('returns empty string if state is zero/integer', () => { + test('returns empty string if type is zero/integer', () => { expect(renderNodeType(0)).toBe(''); }); }); @@ -43,13 +48,58 @@ describe('renderNodeIcon', () => { test('returns empty string if state is not found', () => { expect(renderNodeIcon('foo')).toBe(''); }); - test('returns empty string if state is null', () => { + test('returns false if state is null', () => { expect(renderNodeIcon(null)).toBe(false); }); - test('returns empty string if state is zero/integer', () => { + test('returns false if state is zero/integer', () => { expect(renderNodeIcon(0)).toBe(false); }); }); +describe('renderLabelIcons', () => { + test('returns correct label icon', () => { + expect(renderLabelIcons('ready')).toBe(ICONS['checkmark']); + }); + test('returns empty string if state is not found', () => { + expect(renderLabelIcons('foo')).toBe(''); + }); + test('returns false if state is null', () => { + expect(renderLabelIcons(null)).toBe(false); + }); + test('returns false if state is zero/integer', () => { + expect(renderLabelIcons(0)).toBe(false); + }); +}); +describe('renderIconPosition', () => { + const bbox = { x: 400, y: 400, width: 10, height: 20 }; + test('returns correct label icon', () => { + expect(renderIconPosition('ready', bbox)).toBe( + `translate(${bbox.x - 15}, ${bbox.y + 3}), scale(0.02)` + ); + }); + test('returns empty string if state is not found', () => { + expect(renderIconPosition('foo', bbox)).toBe(''); + }); + test('returns false if state is null', () => { + expect(renderIconPosition(null)).toBe(false); + }); + test('returns false if state is zero/integer', () => { + expect(renderIconPosition(0)).toBe(false); + }); +}); +describe('renderLinkState', () => { + test('returns correct link state', () => { + expect(renderLinkState('adding')).toBe(3); + }); + test('returns null string if state is not found', () => { + expect(renderLinkState('foo')).toBe(null); + }); + test('returns null if state is null', () => { + expect(renderLinkState(null)).toBe(null); + }); + test('returns null if state is zero/integer', () => { + expect(renderLinkState(0)).toBe(null); + }); +}); describe('getWidth', () => { test('returns 700 if selector is null', () => { expect(getWidth(null)).toBe(700); From 532ad777a32f2fa64ed7d35067f16e2762246759 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 13 Sep 2022 09:43:49 -0400 Subject: [PATCH 48/68] Resolves peers list search bug --- awx/ui/src/api/models/Instances.js | 4 ++-- .../Instances/InstancePeers/InstancePeerList.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 9434c94be3..5552e85e69 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -18,8 +18,8 @@ class Instances extends Base { return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } - readPeers(instanceId) { - return this.http.get(`${this.baseUrl}${instanceId}/peers`); + readPeers(instanceId, params) { + return this.http.get(`${this.baseUrl}${instanceId}/peers/`, { params }); } readInstanceGroup(instanceId) { diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js index 649a78e0e0..d717cbcda0 100644 --- a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js @@ -7,8 +7,8 @@ import PaginatedTable, { HeaderRow, } from 'components/PaginatedTable'; import useRequest, { useDismissableError } from 'hooks/useRequest'; -import { getQSConfig } from 'util/qs'; -import { useParams } from 'react-router-dom'; +import { getQSConfig, parseQueryString } from 'util/qs'; +import { useLocation, useParams } from 'react-router-dom'; import DataListToolbar from 'components/DataListToolbar'; import { InstancesAPI } from 'api'; import useExpanded from 'hooks/useExpanded'; @@ -25,6 +25,7 @@ const QS_CONFIG = getQSConfig('peer', { }); function InstancePeerList() { + const location = useLocation(); const { id } = useParams(); const { isLoading, @@ -33,13 +34,14 @@ function InstancePeerList() { result: { peers, count, relatedSearchableKeys, searchableKeys }, } = useRequest( useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); const [ { data: { results, count: itemNumber }, }, actions, ] = await Promise.all([ - InstancesAPI.readPeers(id), + InstancesAPI.readPeers(id, params), InstancesAPI.readOptions(), ]); return { @@ -50,7 +52,7 @@ function InstancePeerList() { ), searchableKeys: getSearchableKeys(actions.data.actions?.GET), }; - }, [id]), + }, [id, location]), { peers: [], count: 0, @@ -59,7 +61,9 @@ function InstancePeerList() { } ); - useEffect(() => fetchPeers(), [fetchPeers, id]); + useEffect(() => { + fetchPeers(); + }, [fetchPeers]); const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = useSelected(peers.filter((i) => i.node_type !== 'hop')); From 6009d9816357a60f39bbf6b9de25c6e47dcc72c1 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 13 Sep 2022 12:27:36 -0700 Subject: [PATCH 49/68] Modify proxy config to allow UI to point to named sites. --- awx/ui/src/setupProxy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/src/setupProxy.js b/awx/ui/src/setupProxy.js index e072a4f82b..8001584ca1 100644 --- a/awx/ui/src/setupProxy.js +++ b/awx/ui/src/setupProxy.js @@ -8,6 +8,7 @@ module.exports = (app) => { target: TARGET, secure: false, ws: true, + changeOrigin: true, }) ); }; From 05109785169186e40df444e5f4c584523a9e0b03 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Mon, 12 Sep 2022 12:14:42 -0700 Subject: [PATCH 50/68] Use reusable HealthCheckAlert component. --- .../HealthCheckAlert/HealthCheckAlert.js | 26 +++++++++++++++++++ .../src/components/HealthCheckAlert/index.js | 1 + .../Instances/InstanceList/InstanceList.js | 22 ++++++++++++---- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js create mode 100644 awx/ui/src/components/HealthCheckAlert/index.js diff --git a/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js new file mode 100644 index 0000000000..d10f2c1362 --- /dev/null +++ b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { Alert, Button, AlertActionCloseButton } from '@patternfly/react-core'; + +function HealthCheckAlert({ onSetHealthCheckAlert }) { + return ( + onSetHealthCheckAlert(false)} /> + } + title={ + <> + {t`Health check request(s) submitted. Please wait and reload the page.`}{' '} + + + } + /> + ); +} + +export default HealthCheckAlert; diff --git a/awx/ui/src/components/HealthCheckAlert/index.js b/awx/ui/src/components/HealthCheckAlert/index.js new file mode 100644 index 0000000000..038b28f796 --- /dev/null +++ b/awx/ui/src/components/HealthCheckAlert/index.js @@ -0,0 +1 @@ +export { default } from './HealthCheckAlert'; diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index f73fad8c44..14f4202fc9 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { useLocation } from 'react-router-dom'; import 'styled-components/macro'; @@ -23,6 +23,7 @@ import useSelected from 'hooks/useSelected'; import { InstancesAPI, SettingsAPI } from 'api'; import { getQSConfig, parseQueryString } from 'util/qs'; import HealthCheckButton from 'components/HealthCheckButton'; +import HealthCheckAlert from 'components/HealthCheckAlert'; import InstanceListItem from './InstanceListItem'; import RemoveInstanceButton from '../Shared/RemoveInstanceButton'; @@ -35,6 +36,7 @@ const QS_CONFIG = getQSConfig('instance', { function InstanceList() { const location = useLocation(); const { me } = useConfig(); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const { result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s }, @@ -83,18 +85,23 @@ function InstanceList() { isLoading: isHealthCheckLoading, } = useRequest( useCallback(async () => { - await Promise.all( + const [...response] = await Promise.all( selected .filter(({ node_type }) => node_type !== 'hop') .map(({ id }) => InstancesAPI.healthCheck(id)) ); - fetchInstances(); - }, [selected, fetchInstances]) + if (response) { + setShowHealthCheckAlert(true); + } + + return response; + }, [selected]) ); const handleHealthCheck = async () => { await fetchHealthCheck(); clearSelected(); }; + const { error, dismissError } = useDismissableError(healthCheckError); const { expanded, isAllExpanded, handleExpand, expandAll } = @@ -115,6 +122,9 @@ function InstanceList() { return ( <> + {showHealthCheckAlert ? ( + + ) : null} handleSelect(instance)} + onSelect={() => { + handleSelect(instance); + }} isSelected={selected.some((row) => row.id === instance.id)} fetchInstances={fetchInstances} rowIndex={index} From 4a41098b24f3b4751f36cc21edef32a20f729f0c Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 13 Sep 2022 08:53:31 -0700 Subject: [PATCH 51/68] Add health check toast notification for Instance list and detail views. --- .../HealthCheckAlert/HealthCheckAlert.js | 12 +- .../InstanceDetails/InstanceDetails.js | 15 +- .../InstanceDetails/InstanceDetails.test.js | 52 --- .../InstanceGroup/Instances/InstanceList.js | 17 +- .../InstanceDetail/InstanceDetail.js | 369 +++++++++--------- .../InstanceDetail/InstanceDetail.test.js | 35 -- .../Instances/InstanceList/InstanceList.js | 3 +- 7 files changed, 226 insertions(+), 277 deletions(-) diff --git a/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js index d10f2c1362..5d3a77be3a 100644 --- a/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js +++ b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js @@ -1,7 +1,15 @@ import React from 'react'; import { t } from '@lingui/macro'; -import { Alert, Button, AlertActionCloseButton } from '@patternfly/react-core'; +import { + Alert as PFAlert, + Button, + AlertActionCloseButton, +} from '@patternfly/react-core'; +import styled from 'styled-components'; +const Alert = styled(PFAlert)` + z-index: 1; +`; function HealthCheckAlert({ onSetHealthCheckAlert }) { return ( window.location.reload(false)} + onClick={() => window.location.reload()} >{t`Reload`} } diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js index 43d9fe6eae..eb24f1f9aa 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js @@ -28,6 +28,7 @@ import RoutedTabs from 'components/RoutedTabs'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import { Detail, DetailList } from 'components/DetailList'; +import HealthCheckAlert from 'components/HealthCheckAlert'; import StatusLabel from 'components/StatusLabel'; import useRequest, { useDeleteItems, @@ -66,6 +67,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { const history = useHistory(); const [healthCheck, setHealthCheck] = useState({}); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const [forks, setForks] = useState(); const { @@ -79,7 +81,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { data: { results }, } = await InstanceGroupsAPI.readInstances(instanceGroup.id); let instanceDetails; - let healthCheckDetails; const isAssociated = results.some( ({ id: instId }) => instId === parseInt(instanceId, 10) ); @@ -92,7 +93,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { ]); instanceDetails = details; - healthCheckDetails = healthCheckData; + setHealthCheck(healthCheckData); } else { throw new Error( `This instance is not associated with this instance group` @@ -100,7 +101,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { } setBreadcrumb(instanceGroup, instanceDetails); - setHealthCheck(healthCheckDetails); setForks( computeForks( instanceDetails.mem_capacity, @@ -121,8 +121,12 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { request: fetchHealthCheck, } = useRequest( useCallback(async () => { - const { data } = await InstancesAPI.healthCheck(instanceId); + const { status } = await InstancesAPI.healthCheck(instanceId); + const { data } = await InstancesAPI.readHealthCheckDetail(instanceId); setHealthCheck(data); + if (status === 200) { + setShowHealthCheckAlert(true); + } }, [instanceId]) ); @@ -188,6 +192,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { return ( <> + {showHealthCheckAlert ? ( + + ) : null} ', () => { expect(InstancesAPI.readDetail).not.toBeCalled(); }); - test('Should make request for Health Check', async () => { - InstancesAPI.healthCheck.mockResolvedValue({ - data: { - uuid: '00000000-0000-0000-0000-000000000000', - hostname: 'awx_1', - version: '19.1.0', - last_health_check: '2021-09-15T18:02:07.270664Z', - errors: '', - cpu: 8, - memory: 6232231936, - cpu_capacity: 32, - mem_capacity: 38, - capacity: 38, - }, - }); - InstanceGroupsAPI.readInstances.mockResolvedValue({ - data: { - results: [ - { - id: 1, - }, - { - id: 2, - }, - ], - }, - }); - jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ - me: { is_superuser: true }, - })); - await act(async () => { - wrapper = mountWithContexts( - {}} - /> - ); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - expect( - wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') - ).toBe(false); - await act(async () => { - wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); - }); - expect(InstancesAPI.healthCheck).toBeCalledWith(1); - wrapper.update(); - expect( - wrapper.find("Detail[label='Last Health Check']").prop('value') - ).toBe('9/15/2021, 6:02:07 PM'); - }); - test('Should handle api error for health check', async () => { InstancesAPI.healthCheck.mockRejectedValue( new Error({ diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js index 332c4319e1..c42cbbda51 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js @@ -23,6 +23,7 @@ import useSelected from 'hooks/useSelected'; import { InstanceGroupsAPI, InstancesAPI } from 'api'; import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton'; +import HealthCheckAlert from 'components/HealthCheckAlert'; import InstanceListItem from './InstanceListItem'; const QS_CONFIG = getQSConfig('instance', { @@ -33,6 +34,7 @@ const QS_CONFIG = getQSConfig('instance', { function InstanceList({ instanceGroup }) { const [isModalOpen, setIsModalOpen] = useState(false); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const location = useLocation(); const { id: instanceGroupId } = useParams(); @@ -86,9 +88,15 @@ function InstanceList({ instanceGroup }) { isLoading: isHealthCheckLoading, } = useRequest( useCallback(async () => { - await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id))); - fetchInstances(); - }, [selected, fetchInstances]) + const [...response] = await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ id }) => InstancesAPI.healthCheck(id)) + ); + if (response) { + setShowHealthCheckAlert(true); + } + }, [selected]) ); const handleHealthCheck = async () => { @@ -171,6 +179,9 @@ function InstanceList({ instanceGroup }) { return ( <> + {showHealthCheckAlert ? ( + + ) : null} { - const { data } = await InstancesAPI.healthCheck(id); + const { status } = await InstancesAPI.healthCheck(id); + const { data } = await InstancesAPI.readHealthCheckDetail(id); setHealthCheck(data); + if (status === 200) { + setShowHealthCheckAlert(true); + } }, [id]) ); @@ -175,192 +181,197 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { const isHopNode = instance.node_type === 'hop'; return ( - - - - - ) : null - } - /> - - {!isHopNode && ( - <> - - - - - {instanceGroups && ( - ( - - {' '} - - ))} - isEmpty={instanceGroups.length === 0} - /> - )} - - {instance.related?.install_bundle && ( - - - - } - /> - )} - -
{t`CPU ${instance.cpu_capacity}`}
- -
- -
- -
-
{t`RAM ${instance.mem_capacity}`}
- - } - /> - - ) : ( - {t`Unavailable`} - ) - } - /> - - )} - {healthCheck?.errors && ( + <> + {showHealthCheckAlert ? ( + + ) : null} + + + - {healthCheck?.errors} - + instance.node_state ? ( + + ) : null } /> - )} - - {!isHopNode && ( - - {me.is_superuser && isK8s && instance.node_type === 'execution' && ( - + {!isHopNode && ( + <> + + + + + {instanceGroups && ( + ( + + {' '} + + ))} + isEmpty={instanceGroups.length === 0} + /> + )} + + {instance.related?.install_bundle && ( + + + + } + /> + )} + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+ + } + /> + + ) : ( + {t`Unavailable`} + ) + } + /> + + )} + {healthCheck?.errors && ( + + {healthCheck?.errors} + + } /> )} - - - - -
- )} +
+ {!isHopNode && ( + + {me.is_superuser && isK8s && instance.node_type === 'execution' && ( + + )} + + + + + + )} - {error && ( - - {updateInstanceError - ? t`Failed to update capacity adjustment.` - : t`Failed to disassociate one or more instances.`} - - - )} + {error && ( + + {updateInstanceError + ? t`Failed to update capacity adjustment.` + : t`Failed to disassociate one or more instances.`} + + + )} - {removeError && ( - - {t`Failed to remove one or more instances.`} - - - )} -
+ {removeError && ( + + {t`Failed to remove one or more instances.`} + + + )} +
+ ); } diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js index 019b43a3dd..cc038c6624 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js @@ -165,41 +165,6 @@ describe('', () => { expect(wrapper.find('InstanceToggle').length).toBe(1); }); - test('Should make request for Health Check', async () => { - InstancesAPI.healthCheck.mockResolvedValue({ - data: { - uuid: '00000000-0000-0000-0000-000000000000', - hostname: 'awx_1', - version: '19.1.0', - last_health_check: '2021-09-15T18:02:07.270664Z', - errors: '', - cpu: 8, - memory: 6232231936, - cpu_capacity: 32, - mem_capacity: 38, - capacity: 38, - }, - }); - jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ - me: { is_superuser: true }, - })); - await act(async () => { - wrapper = mountWithContexts( {}} />); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - expect( - wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') - ).toBe(false); - await act(async () => { - wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); - }); - expect(InstancesAPI.healthCheck).toBeCalledWith(1); - wrapper.update(); - expect( - wrapper.find("Detail[label='Last Health Check']").prop('value') - ).toBe('9/15/2021, 6:02:07 PM'); - }); - test('Should handle api error for health check', async () => { InstancesAPI.healthCheck.mockRejectedValue( new Error({ diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index 14f4202fc9..fdebb58833 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -93,10 +93,9 @@ function InstanceList() { if (response) { setShowHealthCheckAlert(true); } - - return response; }, [selected]) ); + const handleHealthCheck = async () => { await fetchHealthCheck(); clearSelected(); From 9c6aa930932804c8e36283e7687a4352e0718627 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Mon, 12 Sep 2022 18:27:21 -0700 Subject: [PATCH 52/68] Remove action items from Instance peers list. --- .../InstancePeers/InstancePeerList.js | 57 +------ .../InstancePeers/InstancePeerListItem.js | 156 +----------------- 2 files changed, 6 insertions(+), 207 deletions(-) diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js index d717cbcda0..a1d104d667 100644 --- a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js @@ -6,16 +6,12 @@ import PaginatedTable, { HeaderCell, HeaderRow, } from 'components/PaginatedTable'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; import { getQSConfig, parseQueryString } from 'util/qs'; import { useLocation, useParams } from 'react-router-dom'; +import useRequest from 'hooks/useRequest'; import DataListToolbar from 'components/DataListToolbar'; import { InstancesAPI } from 'api'; import useExpanded from 'hooks/useExpanded'; -import ErrorDetail from 'components/ErrorDetail'; -import useSelected from 'hooks/useSelected'; -import HealthCheckButton from 'components/HealthCheckButton'; -import AlertModal from 'components/AlertModal'; import InstancePeerListItem from './InstancePeerListItem'; const QS_CONFIG = getQSConfig('peer', { @@ -65,30 +61,6 @@ function InstancePeerList() { fetchPeers(); }, [fetchPeers]); - const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = - useSelected(peers.filter((i) => i.node_type !== 'hop')); - - const { - error: healthCheckError, - request: fetchHealthCheck, - isLoading: isHealthCheckLoading, - } = useRequest( - useCallback(async () => { - await Promise.all( - selected - .filter(({ node_type }) => node_type !== 'hop') - .map(({ instanceId }) => InstancesAPI.healthCheck(instanceId)) - ); - fetchPeers(); - }, [selected, fetchPeers]) - ); - const handleHealthCheck = async () => { - await fetchHealthCheck(); - clearSelected(); - }; - - const { error, dismissError } = useDismissableError(healthCheckError); - const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(peers); @@ -96,7 +68,7 @@ function InstancePeerList() { {t`Name`} {t`Status`} {t`Node Type`} - {t`Capacity Adjustment`} - {t`Used Capacity`} - {t`Actions`} } renderToolbar={(props) => ( , - ]} /> )} renderRow={(peer, index) => ( handleSelect(peer)} - isSelected={selected.some((row) => row.id === peer.id)} isExpanded={expanded.some((row) => row.id === peer.id)} onExpand={() => handleExpand(peer)} key={peer.id} peerInstance={peer} rowIndex={index} - fetchInstance={fetchPeers} /> )} /> - {error && ( - - {t`Failed to run a health check on one or more peers.`} - - - )} ); } diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js index bc3b5ad912..cce09300b0 100644 --- a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js @@ -1,104 +1,20 @@ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { Link } from 'react-router-dom'; -import { t, Plural } from '@lingui/macro'; -import styled from 'styled-components'; +import { t } from '@lingui/macro'; import 'styled-components/macro'; -import { - Progress, - ProgressMeasureLocation, - ProgressSize, - Slider, - Tooltip, -} from '@patternfly/react-core'; +import { Tooltip } from '@patternfly/react-core'; import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { formatDateString } from 'util/dates'; -import computeForks from 'util/computeForks'; -import { ActionsTd, ActionItem } from 'components/PaginatedTable'; -import InstanceToggle from 'components/InstanceToggle'; import StatusLabel from 'components/StatusLabel'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; -import useDebounce from 'hooks/useDebounce'; -import { InstancesAPI } from 'api'; -import { useConfig } from 'contexts/Config'; -import AlertModal from 'components/AlertModal'; -import ErrorDetail from 'components/ErrorDetail'; import { Detail, DetailList } from 'components/DetailList'; -const Unavailable = styled.span` - color: var(--pf-global--danger-color--200); -`; - -const SliderHolder = styled.div` - display: flex; - align-items: center; - justify-content: space-between; -`; - -const SliderForks = styled.div` - flex-grow: 1; - margin-right: 8px; - margin-left: 8px; - text-align: center; -`; - function InstancePeerListItem({ peerInstance, - fetchInstances, - isSelected, - onSelect, isExpanded, onExpand, rowIndex, }) { - const { me = {} } = useConfig(); - const [forks, setForks] = useState( - computeForks( - peerInstance.mem_capacity, - peerInstance.cpu_capacity, - peerInstance.capacity_adjustment - ) - ); const labelId = `check-action-${peerInstance.id}`; - - function usedCapacity(item) { - if (item.enabled) { - return ( - - ); - } - return {t`Unavailable`}; - } - - const { error: updateInstanceError, request: updateInstance } = useRequest( - useCallback( - async (values) => { - await InstancesAPI.update(peerInstance.id, values); - }, - [peerInstance] - ) - ); - - const { error: updateError, dismissError: dismissUpdateError } = - useDismissableError(updateInstanceError); - - const debounceUpdateInstance = useDebounce(updateInstance, 200); - - const handleChangeValue = (value) => { - const roundedValue = Math.round(value * 100) / 100; - setForks( - computeForks( - peerInstance.mem_capacity, - peerInstance.cpu_capacity, - roundedValue - ) - ); - debounceUpdateInstance({ capacity_adjustment: roundedValue }); - }; const isHopNode = peerInstance.node_type === 'hop'; return ( <> @@ -117,15 +33,7 @@ function InstancePeerListItem({ }} /> )} - + {peerInstance.hostname} @@ -149,51 +57,6 @@ function InstancePeerListItem({ {peerInstance.node_type} - {!isHopNode && ( - <> - - -
{t`CPU ${peerInstance.cpu_capacity}`}
- -
- -
- -
-
{t`RAM ${peerInstance.mem_capacity}`}
-
- - - - {usedCapacity(peerInstance)} - - - - - - - - - )} {!isHopNode && ( )} - {updateError && ( - - {t`Failed to update capacity adjustment.`} - - - )} ); } From e0c9013d9c57f6c6db937558a23360ef03154ec4 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 13 Sep 2022 23:53:58 -0400 Subject: [PATCH 53/68] Prevent altering certain fields on Instance - Prevents changing hostname, listener_port, or node_type for instances that already exist - API default node_type is execution - API default node_state is installed --- awx/api/serializers.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f17d5af1b0..a41b8cb47e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4884,12 +4884,12 @@ class InstanceSerializer(BaseSerializer): read_only_fields = ('ip_address', 'uuid', 'version') fields = ( 'id', + 'hostname', 'type', 'url', 'related', 'summary_fields', 'uuid', - 'hostname', 'created', 'modified', 'last_seen', @@ -4913,6 +4913,7 @@ class InstanceSerializer(BaseSerializer): 'ip_address', 'listener_port', ) + extra_kwargs = {'node_type': {'default': 'execution'}, 'node_state': {'default': 'installed'}} def get_related(self, obj): res = super(InstanceSerializer, self).get_related(obj) @@ -4974,6 +4975,18 @@ class InstanceSerializer(BaseSerializer): return value + def validate_hostname(self, value): + if self.instance and self.instance.hostname != value: + raise serializers.ValidationError("Cannot change hostname.") + + return value + + def validate_listener_port(self, value): + if self.instance and self.instance.listener_port != value: + raise serializers.ValidationError("Cannot change listener port.") + + return value + class InstanceHealthCheckSerializer(BaseSerializer): class Meta: From 301807466d03365af471a2f1d98b2bfd8ca2397b Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Thu, 15 Sep 2022 12:07:02 -0400 Subject: [PATCH 54/68] Only get receptor.conf lock in k8s environment - Writing to receptor.conf only takes place in K8S, so only get a lock if IS_K8S is true --- awx/main/tasks/receptor.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 916210c075..172d68b2b5 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -48,11 +48,22 @@ class ReceptorConnectionType(Enum): STREAMTLS = 2 -def get_receptor_sockfile(): - lock = FileLock(__RECEPTOR_CONF_LOCKFILE) - with lock: +def read_receptor_config(): + # for K8S deployments, getting a lock is necessary as another process + # may be re-writing the config at this time + if settings.IS_K8S: + lock = FileLock(__RECEPTOR_CONF_LOCKFILE) + with lock: + with open(__RECEPTOR_CONF, 'r') as f: + return yaml.safe_load(f) + else: with open(__RECEPTOR_CONF, 'r') as f: - data = yaml.safe_load(f) + return yaml.safe_load(f) + + +def get_receptor_sockfile(): + data = read_receptor_config() + for section in data: for entry_name, entry_data in section.items(): if entry_name == 'control-service': @@ -68,10 +79,7 @@ def get_tls_client(use_stream_tls=None): if not use_stream_tls: return None - lock = FileLock(__RECEPTOR_CONF_LOCKFILE) - with lock: - with open(__RECEPTOR_CONF, 'r') as f: - data = yaml.safe_load(f) + data = read_receptor_config() for section in data: for entry_name, entry_data in section.items(): if entry_name == 'tls-client': From 78cc9fb019a55b259769e78e120694413125e230 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Fri, 16 Sep 2022 14:03:38 -0700 Subject: [PATCH 55/68] Fix missing details message in Topology view. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 55 ++++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 9360580f70..5b6ca64562 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -47,7 +47,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { const [simulationProgress, setSimulationProgress] = useState(null); const history = useHistory(); const { - result: { instance, instanceGroups }, + result: { instance = {}, instanceGroups }, error: fetchError, isLoading, request: fetchDetails, @@ -68,12 +68,13 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { result: {}, } ); - const { error: fetchInstanceError, dismissError } = useDismissableError(fetchError); useEffect(() => { - fetchDetails(); + if (selectedNode) { + fetchDetails(); + } }, [selectedNode, fetchDetails]); function updateNodeSVG(nodes) { @@ -383,33 +384,31 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
{showLegend && } {instance && ( - <> - {fetchInstanceError && ( - - {t`Failed to update instance.`} - - - )} - - redirectToDetailsPage(selectedNode, history) - } - /> - + + redirectToDetailsPage(selectedNode, history) + } + /> )} + {fetchInstanceError && ( + + {t`Failed to get instance.`} + + + )}
); } From c153ac9d3ba458bb060e83cf2157955aaa5a2dd4 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 12 Sep 2022 11:11:01 -0400 Subject: [PATCH 56/68] Adds unit tests for RemoveInstanceButton --- awx/ui/package-lock.json | 21 +++ awx/ui/package.json | 1 + awx/ui/src/api/models/Instances.js | 1 + .../Shared/RemoveInstanceButton.test.js | 133 ++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index ff35c6549b..03c2654725 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -47,6 +47,7 @@ "@nteract/mockument": "^1.0.4", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "14.4.3", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", "babel-plugin-macros": "3.1.0", "enzyme": "^3.10.0", @@ -4514,6 +4515,19 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.4.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", + "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -25653,6 +25667,13 @@ "@types/react-dom": "<18.0.0" } }, + "@testing-library/user-event": { + "version": "14.4.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", + "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "dev": true, + "requires": {} + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", diff --git a/awx/ui/package.json b/awx/ui/package.json index cae5912ad5..4b244eebb4 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -47,6 +47,7 @@ "@nteract/mockument": "^1.0.4", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "14.4.3", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", "babel-plugin-macros": "3.1.0", "enzyme": "^3.10.0", diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 5552e85e69..388bb2eb4e 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -8,6 +8,7 @@ class Instances extends Base { this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this); this.healthCheck = this.healthCheck.bind(this); this.readInstanceGroup = this.readInstanceGroup.bind(this); + this.deprovisionInstance = this.deprovisionInstance.bind(this); } healthCheck(instanceId) { diff --git a/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js new file mode 100644 index 0000000000..3d2d467956 --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js @@ -0,0 +1,133 @@ +import React from 'react'; +import { within, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InstanceGroupsAPI } from 'api'; +import RemoveInstanceButton from './RemoveInstanceButton'; +import { I18nProvider } from '@lingui/react'; +import { i18n } from '@lingui/core'; +import { en } from 'make-plural/plurals'; +import english from '../../../../src/locales/en/messages'; + +jest.mock('api'); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'execution', + node_state: 'ready', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'control', + node_state: 'ready', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, +]; +describe('', () => { + test('Should open modal and deprovision node', async () => { + i18n.loadLocaleData({ en: { plurals: en } }); + i18n.load({ en: english }); + i18n.activate('en'); + InstanceGroupsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1 }], count: 1 }, + }); + const user = userEvent.setup(); + const onRemove = jest.fn(); + render( + + + + ); + + const button = screen.getByRole('button'); + await user.click(button); + await waitFor(() => screen.getByRole('dialog')); + const modal = screen.getByRole('dialog'); + const removeButton = within(modal).getByRole('button', { + name: 'Confirm remove', + }); + + await user.click(removeButton); + + await waitFor(() => expect(onRemove).toBeCalled()); + }); + + test('Should be disabled', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('button'); + await user.hover(button); + await waitFor(() => + screen.getByText('You do not have permission to remove instances:') + ); + }); + + test('Should handle error when fetching warning message details.', async () => { + InstanceGroupsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + const user = userEvent.setup(); + const onRemove = jest.fn(); + render( + + ); + + const button = screen.getByRole('button'); + await user.click(button); + await waitFor(() => screen.getByRole('dialog')); + screen.getByText('Error!'); + }); +}); From ada0d456540e3c57b140079f0edee90094570ba8 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Mon, 19 Sep 2022 18:09:12 -0400 Subject: [PATCH 57/68] put install bundle file in templates dir also enable Copr repo in the playbook Signed-off-by: Hao Liu --- .../install_receptor.yml | 18 ++++++ .../instance_install_bundle/inventory.yml | 28 +++++++++ .../instance_install_bundle/requirements.yml | 6 ++ awx/api/views/instance_install_bundle.py | 60 ++----------------- 4 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 awx/api/templates/instance_install_bundle/install_receptor.yml create mode 100644 awx/api/templates/instance_install_bundle/inventory.yml create mode 100644 awx/api/templates/instance_install_bundle/requirements.yml diff --git a/awx/api/templates/instance_install_bundle/install_receptor.yml b/awx/api/templates/instance_install_bundle/install_receptor.yml new file mode 100644 index 0000000000..0f0789df67 --- /dev/null +++ b/awx/api/templates/instance_install_bundle/install_receptor.yml @@ -0,0 +1,18 @@ +{% verbatim %} +--- +- hosts: all + become: yes + tasks: + - name: Create the receptor user + user: + name: "{{ receptor_user }}" + shell: /bin/bash + - name: Enable Copr repo for Receptor + command: dnf copr enable ansible-awx/receptor -y + - import_role: + name: ansible.receptor.setup + - name: Install ansible-runner + pip: + name: ansible-runner + executable: pip3.9 +{% endverbatim %} \ No newline at end of file diff --git a/awx/api/templates/instance_install_bundle/inventory.yml b/awx/api/templates/instance_install_bundle/inventory.yml new file mode 100644 index 0000000000..1124cae88e --- /dev/null +++ b/awx/api/templates/instance_install_bundle/inventory.yml @@ -0,0 +1,28 @@ +--- +all: + hosts: + remote-execution: + ansible_host: {{ instance.hostname }} + ansible_user: # user provided + ansible_ssh_private_key_file: ~/.ssh/id_rsa + receptor_verify: true + receptor_tls: true + receptor_work_commands: + ansible-runner: + command: ansible-runner + params: worker + allowruntimeparams: true + verifysignature: true + custom_worksign_public_keyfile: receptor/work-public-key.pem + custom_tls_certfile: receptor/tls/receptor.crt + custom_tls_keyfile: receptor/tls/receptor.key + custom_ca_certfile: receptor/tls/ca/receptor-ca.crt + receptor_user: awx + receptor_group: awx + receptor_protocol: 'tcp' + receptor_listener: true + receptor_port: {{ instance.listener_port }} + receptor_dependencies: + - podman + - crun + - python39-pip diff --git a/awx/api/templates/instance_install_bundle/requirements.yml b/awx/api/templates/instance_install_bundle/requirements.yml new file mode 100644 index 0000000000..9ed8b488b2 --- /dev/null +++ b/awx/api/templates/instance_install_bundle/requirements.yml @@ -0,0 +1,6 @@ +--- +collections: + - name: ansible.receptor + source: https://github.com/ansible/receptor-collection/ + type: git + version: 0.1.0 diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py index e8155231d1..d85240cea0 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -3,9 +3,9 @@ import datetime import io +import ipaddress import os import tarfile -import ipaddress import asn1 from awx.api import serializers @@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509 import DNSName, IPAddress, ObjectIdentifier, OtherName from cryptography.x509.oid import NameOID from django.http import HttpResponse +from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from rest_framework import status @@ -27,9 +28,8 @@ RECEPTOR_OID = "1.3.6.1.4.1.2312.19.1" # generate install bundle for the instance # install bundle directory structure # ├── install_receptor.yml (playbook) -# ├── inventory.ini +# ├── inventory.yml # ├── receptor -# │ ├── vars.yml # │ ├── tls # │ │ ├── ca # │ │ │ └── receptor-ca.crt @@ -101,63 +101,15 @@ class InstanceInstallBundle(GenericAPIView): def generate_playbook(): - return """--- -- hosts: all - become: yes - tasks: - - name: Create the receptor user - user: - name: "{{ receptor_user }}" - shell: /bin/bash - - import_role: - name: ansible.receptor.setup - - name: Install ansible-runner - pip: - name: ansible-runner - executable: pip3.9 -""" + return render_to_string("instance_install_bundle/install_receptor.yml") def generate_requirements_yml(): - return """--- -collections: - - name: ansible.receptor - source: https://github.com/ansible/receptor-collection/ - type: git - version: 0.1.0 -""" + return render_to_string("instance_install_bundle/requirements.yml") def generate_inventory_yml(instance_obj): - return f"""--- -all: - hosts: - remote-execution: - ansible_host: {instance_obj.hostname} - ansible_user: # user provided - ansible_ssh_private_key_file: ~/.ssh/id_rsa - receptor_verify: true - receptor_tls: true - receptor_work_commands: - ansible-runner: - command: ansible-runner - params: worker - allowruntimeparams: true - verifysignature: true - custom_worksign_public_keyfile: receptor/work-public-key.pem - custom_tls_certfile: receptor/tls/receptor.crt - custom_tls_keyfile: receptor/tls/receptor.key - custom_ca_certfile: receptor/tls/ca/receptor-ca.crt - receptor_user: awx - receptor_group: awx - receptor_protocol: 'tcp' - receptor_listener: true - receptor_port: {instance_obj.listener_port} - receptor_dependencies: - - podman - - crun - - python39-pip -""" + return render_to_string("instance_install_bundle/inventory.yml", context=dict(instance=instance_obj)) def generate_receptor_tls(instance_obj): From 4bf612851fcaabec51457f313403cbdfc8f3379f Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Tue, 20 Sep 2022 09:51:41 -0400 Subject: [PATCH 58/68] ignore template file from yamllint --- .yamllint | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.yamllint b/.yamllint index 7101b59ca1..fdfbfce43a 100644 --- a/.yamllint +++ b/.yamllint @@ -8,6 +8,8 @@ ignore: | awx/ui/test/e2e/tests/smoke-vars.yml awx/ui/node_modules tools/docker-compose/_sources + # django template files + awx/api/templates/instance_install_bundle/** extends: default From af8b5243a33461fe7385bdf51efa01f7a75f7854 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Tue, 20 Sep 2022 10:31:37 -0400 Subject: [PATCH 59/68] Update requirements.yml --- awx/api/templates/instance_install_bundle/requirements.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/templates/instance_install_bundle/requirements.yml b/awx/api/templates/instance_install_bundle/requirements.yml index 9ed8b488b2..392fee836b 100644 --- a/awx/api/templates/instance_install_bundle/requirements.yml +++ b/awx/api/templates/instance_install_bundle/requirements.yml @@ -3,4 +3,4 @@ collections: - name: ansible.receptor source: https://github.com/ansible/receptor-collection/ type: git - version: 0.1.0 + version: 0.1.1 From b879cbc2ece36bedfbef9987dc729c392a3a0682 Mon Sep 17 00:00:00 2001 From: Jeff Bradberry Date: Tue, 20 Sep 2022 09:38:38 -0400 Subject: [PATCH 60/68] Prevent any edits to hop nodes to retain the behavior that they had pre-mesh-scaling. --- awx/api/serializers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a41b8cb47e..a028c3a90b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4946,8 +4946,12 @@ class InstanceSerializer(BaseSerializer): return float("{0:.2f}".format(((float(obj.capacity) - float(obj.consumed_capacity)) / (float(obj.capacity))) * 100)) def validate(self, data): - if not self.instance and not settings.IS_K8S: - raise serializers.ValidationError("Can only create instances on Kubernetes or OpenShift.") + if self.instance: + if self.instance.node_type == Instance.Types.HOP: + raise serializers.ValidationError("Hop node instances may not be changed.") + else: + if not settings.IS_K8S: + raise serializers.ValidationError("Can only create instances on Kubernetes or OpenShift.") return data def validate_node_type(self, value): From 7d645c8ff6432bb4b4fdb703723b0c945bbcf505 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Mon, 19 Sep 2022 06:20:12 -0500 Subject: [PATCH 61/68] [collection] Add 'instance' module Signed-off-by: Rick Elrod --- .../plugins/module_utils/controller_api.py | 2 + awx_collection/plugins/modules/instance.py | 148 ++++++++++++++++++ awx_collection/test/awx/test_completeness.py | 1 - .../targets/instance/tasks/main.yml | 55 +++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 awx_collection/plugins/modules/instance.py create mode 100644 awx_collection/tests/integration/targets/instance/tasks/main.yml diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 567a753c8f..50d10f8104 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -903,6 +903,8 @@ class ControllerAPIModule(ControllerModule): item_name = existing_item['identifier'] elif item_type == 'credential_input_source': item_name = existing_item['id'] + elif item_type == 'instance': + item_name = existing_item['hostname'] else: item_name = existing_item['name'] item_id = existing_item['id'] diff --git a/awx_collection/plugins/modules/instance.py b/awx_collection/plugins/modules/instance.py new file mode 100644 index 0000000000..a20b6c831c --- /dev/null +++ b/awx_collection/plugins/modules/instance.py @@ -0,0 +1,148 @@ +#!/usr/bin/python +# coding: utf-8 -*- + + +# (c) 2022 Red Hat, Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: instance +author: "Rick Elrod (@relrod)" +version_added: "4.3.0" +short_description: create, update, or destroy Automation Platform Controller instances. +description: + - Create, update, or destroy Automation Platform Controller instances. See + U(https://www.ansible.com/tower) for an overview. +options: + hostname: + description: + - Hostname of this instance. + required: True + type: str + capacity_adjustment: + description: + - Capacity adjustment (0 <= capacity_adjustment <= 1) + required: False + type: float + enabled: + description: + - If true, the instance will be enabled and used. + required: False + type: bool + default: True + managed_by_policy: + description: + - Managed by policy + required: False + default: True + type: bool + node_type: + description: + - Role that this node plays in the mesh. + choices: + - control + - execution + - hybrid + - hop + required: False + type: str + default: execution + node_state: + description: + - Indicates the current life cycle stage of this instance. + choices: + - provisioning + - provision-fail + - installed + - ready + - unavailable + - deprovisioning + - deprovision-fail + required: False + default: installed + type: str + listener_port: + description: + - Port that Receptor will listen for incoming connections on. + required: False + default: 27199 + type: int +extends_documentation_fragment: awx.awx.auth +''' + +EXAMPLES = ''' +- name: Create an instance + awx.awx.instance: + hostname: my-instance.prod.example.com + capacity_adjustment: 0.4 + listener_port: 31337 + +- name: Deprovision the instance + awx.awx.instance: + hostname: my-instance.prod.example.com + node_state: deprovisioning +''' + +from ..module_utils.controller_api import ControllerAPIModule + + +def main(): + # Any additional arguments that are not fields of the item can be added here + argument_spec = dict( + hostname=dict(required=True), + capacity_adjustment=dict(type='float'), + enabled=dict(type='bool'), + managed_by_policy=dict(type='bool'), + node_type=dict(type='str', choices=['control', 'execution', 'hybrid', 'hop']), + node_state=dict(type='str', choices=['provisioning', 'provision-fail', 'installed', 'ready', 'unavailable', 'deprovisioning', 'deprovision-fail']), + listener_port=dict(type='int'), + ) + + # Create a module for ourselves + module = ControllerAPIModule(argument_spec=argument_spec) + + # Extract our parameters + hostname = module.params.get('hostname') + capacity_adjustment = module.params.get('capacity_adjustment') + enabled = module.params.get('enabled') + managed_by_policy = module.params.get('managed_by_policy') + node_type = module.params.get('node_type') + node_state = module.params.get('node_state') + listener_port = module.params.get('listener_port') + + # Attempt to look up an existing item based on the provided data + existing_item = module.get_one('instances', name_or_id=hostname) + + # Create the data that gets sent for create and update + new_fields = {'hostname': hostname} + if capacity_adjustment is not None: + new_fields['capacity_adjustment'] = capacity_adjustment + if enabled is not None: + new_fields['enabled'] = enabled + if managed_by_policy is not None: + new_fields['managed_by_policy'] = managed_by_policy + if node_type is not None: + new_fields['node_type'] = node_type + if node_state is not None: + new_fields['node_state'] = node_state + if listener_port is not None: + new_fields['listener_port'] = listener_port + + module.create_or_update_if_needed( + existing_item, + new_fields, + endpoint='instances', + item_type='instance', + ) + + +if __name__ == '__main__': + main() diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index 93ddd52fea..43e225e4b8 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -82,7 +82,6 @@ needs_development = ['inventory_script', 'instance'] needs_param_development = { 'host': ['instance_id'], 'workflow_approval': ['description', 'execution_environment'], - 'instances': ['capacity_adjustment', 'enabled', 'hostname', 'ip_address', 'managed_by_policy', 'node_state', 'node_type'], } # ----------------------------------------------------------------------------------------------------------- diff --git a/awx_collection/tests/integration/targets/instance/tasks/main.yml b/awx_collection/tests/integration/targets/instance/tasks/main.yml new file mode 100644 index 0000000000..4d5a596971 --- /dev/null +++ b/awx_collection/tests/integration/targets/instance/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Generate hostnames + set_fact: + hostname1: "AWX-Collection-tests-instance1.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + hostname2: "AWX-Collection-tests-instance2.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + hostname3: "AWX-Collection-tests-instance3.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + register: facts + +- name: Show hostnames + debug: + var: facts + +- block: + - name: Create an instance + awx.awx.instance: + hostname: "{{ item }}" + with_items: + - "{{ hostname1 }}" + - "{{ hostname2 }}" + register: result + + - assert: + that: + - result is changed + + - name: Create an instance with non-default config + awx.awx.instance: + hostname: "{{ hostname3 }}" + capacity_adjustment: 0.4 + listener_port: 31337 + register: result + + - assert: + that: + - result is changed + + - name: Update an instance + awx.awx.instance: + hostname: "{{ hostname1 }}" + capacity_adjustment: 0.7 + register: result + + - assert: + that: + - result is changed + + always: + - name: Deprovision the instances + awx.awx.instance: + hostname: "{{ item }}" + node_state: deprovisioning + with_items: + - "{{ hostname1 }}" + - "{{ hostname2 }}" + - "{{ hostname3 }}" From ba26909dc5e328ae1a80d52c054498a01c303c77 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 20 Sep 2022 12:39:32 -0500 Subject: [PATCH 62/68] Restrict node_state and node_type choices Signed-off-by: Rick Elrod --- awx_collection/plugins/modules/instance.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/awx_collection/plugins/modules/instance.py b/awx_collection/plugins/modules/instance.py index a20b6c831c..e8a7866eaa 100644 --- a/awx_collection/plugins/modules/instance.py +++ b/awx_collection/plugins/modules/instance.py @@ -48,10 +48,7 @@ options: description: - Role that this node plays in the mesh. choices: - - control - execution - - hybrid - - hop required: False type: str default: execution @@ -59,13 +56,8 @@ options: description: - Indicates the current life cycle stage of this instance. choices: - - provisioning - - provision-fail - - installed - - ready - - unavailable - deprovisioning - - deprovision-fail + - installed required: False default: installed type: str @@ -101,8 +93,8 @@ def main(): capacity_adjustment=dict(type='float'), enabled=dict(type='bool'), managed_by_policy=dict(type='bool'), - node_type=dict(type='str', choices=['control', 'execution', 'hybrid', 'hop']), - node_state=dict(type='str', choices=['provisioning', 'provision-fail', 'installed', 'ready', 'unavailable', 'deprovisioning', 'deprovision-fail']), + node_type=dict(type='str', choices=['execution']), + node_state=dict(type='str', choices=['deprovisioning', 'installed']), listener_port=dict(type='int'), ) From bf8ba6386061984d4e64556600828c59f6c3e446 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 20 Sep 2022 14:19:46 -0500 Subject: [PATCH 63/68] Add instance module to controller action group Signed-off-by: Rick Elrod --- awx_collection/meta/runtime.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/awx_collection/meta/runtime.yml b/awx_collection/meta/runtime.yml index 903c05ebf2..b23d5b87e2 100644 --- a/awx_collection/meta/runtime.yml +++ b/awx_collection/meta/runtime.yml @@ -15,6 +15,7 @@ action_groups: - group - host - import + - instance - instance_group - inventory - inventory_source From 01b41afa0fd50b3623861173cd3169945b491ea3 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Wed, 21 Sep 2022 10:59:42 -0400 Subject: [PATCH 64/68] includ template yml in sdist --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index ea77957b22..a3321f4fdc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,7 +3,7 @@ recursive-include awx *.po recursive-include awx *.mo recursive-include awx/static * recursive-include awx/templates *.html -recursive-include awx/api/templates *.md *.html +recursive-include awx/api/templates *.md *.html *.yml recursive-include awx/ui/build *.html recursive-include awx/ui/build * recursive-include awx/playbooks *.yml From 5b7a359c91b39bf25af36665331eeb9a9626c63c Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 19 Sep 2022 15:07:51 -0400 Subject: [PATCH 65/68] Add doc for adding execution node --- docs/execution_nodes.md | 78 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/execution_nodes.md diff --git a/docs/execution_nodes.md b/docs/execution_nodes.md new file mode 100644 index 0000000000..6c5504725e --- /dev/null +++ b/docs/execution_nodes.md @@ -0,0 +1,78 @@ +# Adding execution nodes to AWX + +Stand-alone execution nodes can be added to run alongside the Kubernetes deployment of AWX. These machines will not be a part of the AWX Kubernetes cluster. The control nodes running in the cluster will connect and submit work to these machines via Receptor. The machines be registered in AWX as type "execution" instances, meaning they will only be used to run AWX Jobs (i.e. they will not dispatch work or handle web requests as control nodes do). + +Below is an example of a single AWX pod connecting to two different execution nodes. For each execution node, the awx-ee container makes an outbound TCP connection to the machine via Receptor. + +``` + AWX POD + ┌──────────────┐ + │ │ + │ ┌──────────┐ │ +┌─────────────────┐ │ │ awx-task │ │ +│ execution node 1│◄────┐ │ ├──────────┤ │ +├─────────────────┤ ├────┼─┤ awx-ee │ │ +│ execution node 2│◄────┘ │ ├──────────┤ │ +└─────────────────┘ Receptor │ │ awx-web │ │ + TCP │ └──────────┘ │ + Peers │ │ + └──────────────┘ +``` + +Note, if the AWX deployment is scaled up, the new AWX pod will also make TCP connections to each execution node. + + +## Overview +Adding an execution instance involves a handful of steps: + +1. [Start a machine that is accessible from the k8s cluster (Red Hat family of operating systems are supported)](#start-machine) +2. [Create a new AWX Instance with `hostname` being the IP or DNS name of your remote machine.](#create-instance-in-awx) +3. [Download the install bundle for this newly created instance.](#download-the-install-bundle) +4. [Run the install bundle playbook against your remote machine.](#run-the-install-bundle-playbook) +5. [Wait for the instance to report a Ready state. Now jobs can run on that instance.](#wait-for-instance-to-be-ready) + + +### Start machine + +Bring a machine online with a compatible Red Hat family OS (e.g. RHEL 8 and 9). This machines needs a static IP, or a resolvable DNS hostname that the AWX cluster can access. The machine will also need an available open port to establish inbound TCP connections on (default is 27199). + +In general the more CPU cores and memory the machine has, the more jobs that can be scheduled to run on that machine at once. See https://docs.ansible.com/automation-controller/4.2.1/html/userguide/jobs.html#at-capacity-determination-and-job-impact for more information on capacity. + + +### Create instance in AWX + +Use the Instance page or `api/v2/instances` endpoint to add a new instance. +- `hostname` ("Name" in UI) is the IP address or DNS name of your machine. +- `node_type` is "execution" +- `node_state` is "installed" +- `listener_port` is an open port on the remote machine used to establish inbound TCP connections. Defaults to 27199. + + +### Download the install bundle + +On the Instance Details page, click Install Bundle and save the tar.gz file to your local computer and extract contents. Alternatively, make a GET request to `api/v2/instances/{id}/install_bundle` and save the binary output to a tar.gz file. + + +### Run the install bundle playbook + +In order for AWX to make proper TCP connections to the remote machine, a few files need to in place. These include TLS certificates and keys, a certificate authority, and a proper Receptor configuration file. To facilitate that these files will be in the right location on the remote machine, the install bundle includes an install_receptor.yml playbook. + +The playbook requires the Receptor collection which can be obtained via + +`ansible-galaxy collection install -r requirements.yml` + +Modify `inventory.yml`. Set the `ansible_user` and any other ansible variables that may be needed to run playbooks against the remote machine. + +`ansible-playbook -i inventory.yml install_receptor.py` to start installing Receptor on the remote machine. + +Note, the playbook will enable the [Copr ansible-awx/receptor repository](https://copr.fedorainfracloud.org/coprs/ansible-awx/receptor/) so that Receptor can be installed. + + +### Wait for instance to be Ready + +Wait a few minutes for the periodic AWX task to do a health check against the new instance. The instances endpoint or page should report "Ready" status for the instance. If so, jobs are now ready to run on this machine! + + +## Removing instances + +You can remove an instance by clicking "Remove" in the Instances page, or by setting the instance `node_state` to "deprovisioning" via the API. From c53228daf5f0e8162ab6df90273d45107a448241 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Wed, 21 Sep 2022 12:55:45 -0400 Subject: [PATCH 66/68] Set initial value node_type and node_state --- awx/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a028c3a90b..b90c32f750 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4913,7 +4913,7 @@ class InstanceSerializer(BaseSerializer): 'ip_address', 'listener_port', ) - extra_kwargs = {'node_type': {'default': 'execution'}, 'node_state': {'default': 'installed'}} + extra_kwargs = {'node_type': {'initial': 'execution'}, 'node_state': {'initial': 'installed'}} def get_related(self, obj): res = super(InstanceSerializer, self).get_related(obj) From 795569227a6a9fa14dfb9f73e6a7fe2239177b09 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Fri, 23 Sep 2022 11:50:04 -0400 Subject: [PATCH 67/68] Fix import ordering partially Signed-off-by: Hao Liu --- awx/api/views/__init__.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 128bc3cca1..ee9f1021b5 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -122,6 +122,22 @@ from awx.api.views.mixin import ( UnifiedJobDeletionMixin, NoTruncateMixin, ) +from awx.api.views.instance_install_bundle import InstanceInstallBundle # noqa +from awx.api.views.inventory import ( # noqa + InventoryList, + InventoryDetail, + InventoryUpdateEventsList, + InventoryList, + InventoryDetail, + InventoryActivityStreamList, + InventoryInstanceGroupsList, + InventoryAccessList, + InventoryObjectRolesList, + InventoryJobTemplateList, + InventoryLabelList, + InventoryCopy, +) +from awx.api.views.mesh_visualizer import MeshVisualizer # noqa from awx.api.views.organization import ( # noqa OrganizationList, OrganizationDetail, @@ -145,21 +161,6 @@ from awx.api.views.organization import ( # noqa OrganizationAccessList, OrganizationObjectRolesList, ) -from awx.api.views.inventory import ( # noqa - InventoryList, - InventoryDetail, - InventoryUpdateEventsList, - InventoryList, - InventoryDetail, - InventoryActivityStreamList, - InventoryInstanceGroupsList, - InventoryAccessList, - InventoryObjectRolesList, - InventoryJobTemplateList, - InventoryLabelList, - InventoryCopy, -) -from awx.api.views.mesh_visualizer import MeshVisualizer # noqa from awx.api.views.root import ( # noqa ApiRootView, ApiOAuthAuthorizationRootView, @@ -174,8 +175,6 @@ from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, Gitlab from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ -from awx.api.views.instance_install_bundle import InstanceInstallBundle # noqa - logger = logging.getLogger('awx.api.views') From 3ad7913353cdf14b2329340e6522b1f19b9ae183 Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Fri, 23 Sep 2022 12:12:27 -0400 Subject: [PATCH 68/68] Fix remove unnecessary comment --- awx/api/views/instance_install_bundle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py index d85240cea0..455da25ddf 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -47,7 +47,6 @@ class InstanceInstallBundle(GenericAPIView): def get(self, request, *args, **kwargs): instance_obj = self.get_object() - # if the instance is not a hop or execution node than return 400 if instance_obj.node_type not in ('execution',): return Response( data=dict(msg=_('Install bundle can only be generated for execution nodes.')),