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 000000000000..6fbc3dd12b05 --- /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 eeed06bc6016..f7388181f3a5 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -5,7 +5,7 @@ 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 47f121a58f1d..62626cd83bdf 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4859,7 +4859,7 @@ def validate(self, attrs): 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 Meta: 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 Meta: "enabled", "managed_by_policy", "node_type", + "node_state", ) def get_related(self, obj): @@ -4925,6 +4920,14 @@ def get_related(self, obj): 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 6d26ebfbb24e..078edb08c78f 100644 --- a/awx/main/management/commands/register_peers.py +++ b/awx/main/management/commands/register_peers.py @@ -27,7 +27,9 @@ def add_arguments(self, parser): ) 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 @@ def handle(self, **options): 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 @@ def handle(self, **options): 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 23acd15139a6..88e8384c431f 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -129,10 +129,13 @@ def register(self, uuid=None, hostname=None, ip_address=None, node_type='hybrid' # 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 @@ def register(self, uuid=None, hostname=None, ip_address=None, node_type='hybrid' # 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 @@ def register(self, uuid=None, hostname=None, ip_address=None, node_type='hybrid' 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 dfc1140a70cc..a318f36c544a 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -441,6 +441,7 @@ def get(self, request, *args, **kwargs): 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 f7388181f3a5..7de957d4d5db 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -242,15 +242,18 @@ def is_lost(self, ref_time=None): 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 @@ def save_health_data(self, version=None, cpu=0, memory=0, uuid=None, update_last 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 @@ def local_health_check(self): # 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 d4f067115e1c..e9f564b12528 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 e9f564b12528..f416bdd8258f 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 b84cdfcf8212..b9187c0e9cfe 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 f416bdd8258f..c2443b1a516d 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 62626cd83bdf..7ca485bd27fb 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 Meta: "managed_by_policy", "node_type", "node_state", + "ip_address", ) def get_related(self, obj): @@ -4923,6 +4925,7 @@ def get_related(self, obj): 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 @@ def get_percent_capacity_remaining(self, obj): 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 a318f36c544a..0b2a1a252e02 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -359,7 +359,7 @@ def get(self, request, format=None): 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 75e6bff29f47..93ddd52feaa7 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -78,10 +78,11 @@ # 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 7ca485bd27fb..48efd6a2046e 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 @@ def get_percent_capacity_remaining(self, obj): 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') - - 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 + raise serializers.ValidationError("Cannot change node type.") - 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 0b2a1a252e02..3c282f3e2cd3 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 e8deea8f365e..a11789ee8124 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,7 +579,7 @@ def can_unattach(self, obj, sub_obj, relationship, data=None): 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 000000000000..0fa6f1c630cb --- /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 000000000000..c6ddcff5bc0c --- /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 000000000000..6d706b39cf19 --- /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 0fa6f1c630cb..1c0e86400dcb 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 000000000000..e79b0471c839 --- /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 782fcdd18714..a50891bc68c6 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 a230fb9a6740..ca42498e417b 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 6d706b39cf19..4dff50df7771 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 000000000000..8a3b9898bf62 --- /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 4e899372919a..9e838f437726 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 d217078f6cae..e3ad1445ae60 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 11356d0e34a2..3d9669276554 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 860b699468dd..4b0ec8cd409e 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 7de957d4d5db..1e5264695811 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 45f262ebe60b..a0a125729dfb 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -642,10 +642,6 @@ def process_pending_tasks(self, pending_tasks): 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 33cfc30cd166..ff64f8ee6463 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -145,7 +145,7 @@ def build_execution_environment_params(self, instance, private_data_dir): """ 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 0350a96836be..0dda5b48add3 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -27,12 +27,17 @@ ) 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 @@ def kube_config(self): 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 c2443b1a516d..0c22051f5ab7 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -61,7 +61,7 @@ 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 @@ 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 000000000000..cf1ab25da034 --- /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 e0f707b8bc3f..3951faf06ccb 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 f05f27b807ad..143506c21ef5 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 e566a9707379..b6dd95aedb2e 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 2f7fc3cf4172..db4988b20779 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 48efd6a2046e..07c9b3aaf221 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4917,6 +4917,7 @@ def get_related(self, obj): 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 6c70e285c5f4..a9ef203384f5 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 @@ 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 3c282f3e2cd3..9991bcfe1c57 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -174,6 +174,7 @@ 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 000000000000..f3f017d14669 --- /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 07c9b3aaf221..25b389a77e74 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4917,7 +4917,8 @@ def get_related(self, obj): 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 0dda5b48add3..a5e2586f4d71 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 0c22051f5ab7..24dbd98b6e16 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 25b389a77e74..4f94175afd97 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4919,6 +4919,7 @@ def get_related(self, obj): 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 a9ef203384f5..8dad087b82c8 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 @@ 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 9991bcfe1c57..5d95e13b983d 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -384,6 +384,8 @@ def update(self, request, *args, **kwargs): 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 @@ def get_queryset(self): 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 a11789ee8124..665c8e1f8d5c 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,6 +579,7 @@ def can_unattach(self, obj, sub_obj, relationship, data=None): 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 ce385cfced35..6de551cf9ffd 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 07ee085c145f..460b809ec1a6 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 8efe4b55f6b3..535bb1ea393a 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 9338b7c8d249..9d3ae4786407 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 a50891bc68c6..ec3b59be9c22 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 000000000000..defa7c11d9eb --- /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 000000000000..1be96e838104 --- /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 | 155 ++++++++-- awx/ui/src/screens/TopologyView/MeshGraph.js | 82 +++++- awx/ui/src/screens/TopologyView/Tooltip.js | 285 ++++++++++++++++--- 3 files changed, 445 insertions(+), 77 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 5fe35beb5186..b292786a592a 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 b292786a592a..c57cba43da1b 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 f9d9496aec2b..a3289c57f8a8 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 ff75bfbcd2ed..63a650d96fe2 100644 --- a/awx/api/views/instance_install_bundle.py +++ b/awx/api/views/instance_install_bundle.py @@ -48,9 +48,9 @@ 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'): + 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 38695014bfae..d30694ed6cdd 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 25e26c636d24..65f3012587b8 100644 --- a/awxkit/awxkit/api/pages/page.py +++ b/awxkit/awxkit/api/pages/page.py @@ -154,6 +154,26 @@ def from_json(cls, raw, connection=None): 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 @@ def page_identity(self, response, request_json=None): 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 815f10ac9d1a..982e7c257399 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 7865527a2b08..a7803bca4eb1 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -460,41 +460,16 @@ def get(self, request, *args, **kwargs): 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 cd6313ecaa5a..9ecaece5debe 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -243,20 +243,21 @@ def is_lost(self, ref_time=None): 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 @@ def save_health_data(self, version=None, cpu=0, memory=0, uuid=None, update_last 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 4184d876aaf0..ec569d945f68 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 a7803bca4eb1..128bc3cca190 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 @@ def update(self, request, *args, **kwargs): 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 9ecaece5debe..38e8ac006896 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 3ec579844e8b..baf95dd4129d 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 f2449a1556f9..43d9fe6eaedb 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 c8d35ac10d90..9d3e081f1be7 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 a2bff6d281a2..019b43a3ddfe 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 baf95dd4129d..916210c07556 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -606,6 +606,7 @@ def kube_config(self): 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 63a650d96fe2..d470d5c8ca56 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 d63da75fd66c..909e8002565c 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 01eb1704ba2b..74b50a31ed9c 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 dc78fd69e6e2..284e2c8d5fa9 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 efff753ae521..1748a94c9e84 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 02bff9c3dd7e..d2d875dc91c0 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 c57cba43da1b..57a5b147ae50 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 57a5b147ae50..d674ecd4cf1d 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 f520a75b694a..9360580f700b 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 21445ff02aa6..9434c94be381 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 88d1d4041be7..f73fad8c448e 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 eee605c98936..b6b1fb298608 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 d470d5c8ca56..e8155231d1c2 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 4b0ec8cd409e..38a0c55931d6 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 9434c94be381..5552e85e691f 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 649a78e0e08e..d717cbcda075 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 e072a4f82b6f..8001584ca118 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 000000000000..d10f2c136252 --- /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 000000000000..038b28f79661 --- /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 f73fad8c448e..14f4202fc9f7 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 | 357 +++++++++--------- .../InstanceDetail/InstanceDetail.test.js | 35 -- .../Instances/InstanceList/InstanceList.js | 3 +- 7 files changed, 220 insertions(+), 271 deletions(-) diff --git a/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js index d10f2c136252..5d3a77be3a3e 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 43d9fe6eaedb..eb24f1f9aae7 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 332c4319e1ee..c42cbbda515d 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 && ( + <> + {showHealthCheckAlert ? ( + + ) : null} + + + + + ) : null + } + /> + + {!isHopNode && ( + <> ( - - {' '} - - ))} - isEmpty={instanceGroups.length === 0} + label={t`Policy Type`} + value={instance.managed_by_policy ? t`Auto` : t`Manual`} /> - )} - - {instance.related?.install_bundle && ( + + + + {instanceGroups && ( + ( + + {' '} + + ))} + isEmpty={instanceGroups.length === 0} + /> + )} + + {instance.related?.install_bundle && ( + + + + } + /> + )} - - + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+
} /> - )} - -
{t`CPU ${instance.cpu_capacity}`}
- -
- -
- -
-
{t`RAM ${instance.mem_capacity}`}
- - } - /> + ) : ( + {t`Unavailable`} + ) + } + /> + + )} + {healthCheck?.errors && ( - ) : ( - {t`Unavailable`} - ) + + {healthCheck?.errors} + } /> - - )} - {healthCheck?.errors && ( - - {healthCheck?.errors} - - } - /> - )} -
- {!isHopNode && ( - - {me.is_superuser && isK8s && instance.node_type === 'execution' && ( - )} - - - - - - )} +
+ {!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 019b43a3ddfe..cc038c66241b 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 14f4202fc9f7..fdebb5883307 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 d717cbcda075..a1d104d6672c 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 bc3b5ad9128e..cce09300b042 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 f17d5af1b041..a41b8cb47ed5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4884,12 +4884,12 @@ class Meta: 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 Meta: '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 @@ def validate_node_state(self, value): 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 916210c07556..172d68b2b5b7 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 9360580f700b..5b6ca6456249 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 ff35c6549bb3..03c26547252f 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 cae5912ad5b9..4b244eebb4b7 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 5552e85e691f..388bb2eb4e17 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 000000000000..3d2d467956fd --- /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 000000000000..0f0789df6750 --- /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 000000000000..1124cae88eea --- /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 000000000000..9ed8b488b2fd --- /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 e8155231d1c2..d85240cea080 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.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 @@ # 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 @@ def get(self, request, *args, **kwargs): 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 7101b59ca132..fdfbfce43af5 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 9ed8b488b2fd..392fee836b14 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 a41b8cb47ed5..a028c3a90b07 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4946,8 +4946,12 @@ def get_percent_capacity_remaining(self, obj): 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 567a753c8f67..50d10f81048a 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -903,6 +903,8 @@ def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=Tr 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 000000000000..a20b6c831cb9 --- /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 93ddd52feaa7..43e225e4b87b 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -82,7 +82,6 @@ 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 000000000000..4d5a59697147 --- /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 a20b6c831cb9..e8a7866eaa0e 100644 --- a/awx_collection/plugins/modules/instance.py +++ b/awx_collection/plugins/modules/instance.py @@ -48,10 +48,7 @@ description: - Role that this node plays in the mesh. choices: - - control - execution - - hybrid - - hop required: False type: str default: execution @@ -59,13 +56,8 @@ 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 903c05ebf22a..b23d5b87e23f 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 ea77957b2299..a3321f4fdced 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 000000000000..6c5504725e21 --- /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 a028c3a90b07..b90c32f7504b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4913,7 +4913,7 @@ class Meta: '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 128bc3cca190..ee9f1021b513 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -122,6 +122,22 @@ 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 @@ 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.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 d85240cea080..455da25ddfaa 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.')),