diff --git a/.yamllint b/.yamllint index 7101b59ca1..fdfbfce43a 100644 --- a/.yamllint +++ b/.yamllint @@ -8,6 +8,8 @@ ignore: | awx/ui/test/e2e/tests/smoke-vars.yml awx/ui/node_modules tools/docker-compose/_sources + # django template files + awx/api/templates/instance_install_bundle/** extends: default diff --git a/MANIFEST.in b/MANIFEST.in index ea77957b22..a3321f4fdc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,7 +3,7 @@ recursive-include awx *.po recursive-include awx *.mo recursive-include awx/static * recursive-include awx/templates *.html -recursive-include awx/api/templates *.md *.html +recursive-include awx/api/templates *.md *.html *.yml recursive-include awx/ui/build *.html recursive-include awx/ui/build * recursive-include awx/playbooks *.yml diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 47f121a58f..b90c32f750 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4859,7 +4859,7 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria class InstanceLinkSerializer(BaseSerializer): class Meta: model = InstanceLink - fields = ('source', 'target') + fields = ('source', 'target', 'link_state') source = serializers.SlugRelatedField(slug_field="hostname", read_only=True) target = serializers.SlugRelatedField(slug_field="hostname", read_only=True) @@ -4868,63 +4868,74 @@ class InstanceLinkSerializer(BaseSerializer): class InstanceNodeSerializer(BaseSerializer): class Meta: model = Instance - fields = ('id', 'hostname', 'node_type', 'node_state') - - node_state = serializers.SerializerMethodField() - - def get_node_state(self, obj): - if not obj.enabled: - return "disabled" - return "error" if obj.errors else "healthy" + fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled') class InstanceSerializer(BaseSerializer): + show_capabilities = ['edit'] 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 = ('ip_address', 'uuid', 'version') fields = ( - "id", - "type", - "url", - "related", - "uuid", - "hostname", - "created", - "modified", - "last_seen", - "last_health_check", - "errors", + 'id', + 'hostname', + 'type', + 'url', + 'related', + 'summary_fields', + 'uuid', + '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", + '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', ) + extra_kwargs = {'node_type': {'initial': 'execution'}, 'node_state': {'initial': 'installed'}} 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}) + if settings.IS_K8S and obj.node_type in (Instance.Types.EXECUTION,): + 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}) return res + 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 + + return summary + def get_consumed_capacity(self, obj): return obj.consumed_capacity @@ -4934,10 +4945,51 @@ class InstanceSerializer(BaseSerializer): else: return float("{0:.2f}".format(((float(obj.capacity) - float(obj.consumed_capacity)) / (float(obj.capacity))) * 100)) - def validate(self, attrs): - if self.instance.node_type == 'hop': - raise serializers.ValidationError(_('Hop node instances may not be changed.')) - return attrs + def validate(self, data): + 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): + if not self.instance: + if value not in (Instance.Types.EXECUTION,): + raise serializers.ValidationError("Can only create execution nodes.") + else: + if self.instance.node_type != value: + raise serializers.ValidationError("Cannot change node type.") + + return value + + def validate_node_state(self, value): + if self.instance: + if value != self.instance.node_state: + if not settings.IS_K8S: + raise serializers.ValidationError("Can only change the state on Kubernetes or OpenShift.") + if value != Instance.States.DEPROVISIONING: + raise serializers.ValidationError("Can only change instances to the 'deprovisioning' state.") + if self.instance.node_type not in (Instance.Types.EXECUTION,): + raise serializers.ValidationError("Can only deprovision execution nodes.") + else: + if value and value != Instance.States.INSTALLED: + raise serializers.ValidationError("Can only create instances in the 'installed' state.") + + 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): diff --git a/awx/api/templates/instance_install_bundle/install_receptor.yml b/awx/api/templates/instance_install_bundle/install_receptor.yml new file mode 100644 index 0000000000..0f0789df67 --- /dev/null +++ b/awx/api/templates/instance_install_bundle/install_receptor.yml @@ -0,0 +1,18 @@ +{% verbatim %} +--- +- hosts: all + become: yes + tasks: + - name: Create the receptor user + user: + name: "{{ receptor_user }}" + shell: /bin/bash + - name: Enable Copr repo for Receptor + command: dnf copr enable ansible-awx/receptor -y + - import_role: + name: ansible.receptor.setup + - name: Install ansible-runner + pip: + name: ansible-runner + executable: pip3.9 +{% endverbatim %} \ No newline at end of file diff --git a/awx/api/templates/instance_install_bundle/inventory.yml b/awx/api/templates/instance_install_bundle/inventory.yml new file mode 100644 index 0000000000..1124cae88e --- /dev/null +++ b/awx/api/templates/instance_install_bundle/inventory.yml @@ -0,0 +1,28 @@ +--- +all: + hosts: + remote-execution: + ansible_host: {{ instance.hostname }} + ansible_user: # user provided + ansible_ssh_private_key_file: ~/.ssh/id_rsa + receptor_verify: true + receptor_tls: true + receptor_work_commands: + ansible-runner: + command: ansible-runner + params: worker + allowruntimeparams: true + verifysignature: true + custom_worksign_public_keyfile: receptor/work-public-key.pem + custom_tls_certfile: receptor/tls/receptor.crt + custom_tls_keyfile: receptor/tls/receptor.key + custom_ca_certfile: receptor/tls/ca/receptor-ca.crt + receptor_user: awx + receptor_group: awx + receptor_protocol: 'tcp' + receptor_listener: true + receptor_port: {{ instance.listener_port }} + receptor_dependencies: + - podman + - crun + - python39-pip diff --git a/awx/api/templates/instance_install_bundle/requirements.yml b/awx/api/templates/instance_install_bundle/requirements.yml new file mode 100644 index 0000000000..392fee836b --- /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.1 diff --git a/awx/api/urls/instance.py b/awx/api/urls/instance.py index 6c70e285c5..8dad087b82 100644 --- a/awx/api/urls/instance.py +++ b/awx/api/urls/instance.py @@ -3,7 +3,15 @@ from django.urls import re_path -from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck +from awx.api.views import ( + InstanceList, + InstanceDetail, + InstanceUnifiedJobsList, + InstanceInstanceGroupsList, + InstanceHealthCheck, + InstanceInstallBundle, + InstancePeersList, +) urls = [ @@ -12,6 +20,8 @@ urls = [ re_path(r'^(?P[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'), re_path(r'^(?P[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'), re_path(r'^(?P[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'), + re_path(r'^(?P[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'), + re_path(r'^(?P[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index dfc1140a70..ee9f1021b5 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -122,6 +122,22 @@ from awx.api.views.mixin import ( UnifiedJobDeletionMixin, NoTruncateMixin, ) +from awx.api.views.instance_install_bundle import InstanceInstallBundle # noqa +from awx.api.views.inventory import ( # noqa + InventoryList, + InventoryDetail, + InventoryUpdateEventsList, + InventoryList, + InventoryDetail, + InventoryActivityStreamList, + InventoryInstanceGroupsList, + InventoryAccessList, + InventoryObjectRolesList, + InventoryJobTemplateList, + InventoryLabelList, + InventoryCopy, +) +from awx.api.views.mesh_visualizer import MeshVisualizer # noqa from awx.api.views.organization import ( # noqa OrganizationList, OrganizationDetail, @@ -145,21 +161,6 @@ from awx.api.views.organization import ( # noqa OrganizationAccessList, OrganizationObjectRolesList, ) -from awx.api.views.inventory import ( # noqa - InventoryList, - InventoryDetail, - InventoryUpdateEventsList, - InventoryList, - InventoryDetail, - InventoryActivityStreamList, - InventoryInstanceGroupsList, - InventoryAccessList, - InventoryObjectRolesList, - InventoryJobTemplateList, - InventoryLabelList, - InventoryCopy, -) -from awx.api.views.mesh_visualizer import MeshVisualizer # noqa from awx.api.views.root import ( # noqa ApiRootView, ApiOAuthAuthorizationRootView, @@ -174,7 +175,6 @@ from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, Gitlab from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ - logger = logging.getLogger('awx.api.views') @@ -359,7 +359,7 @@ class DashboardJobsGraphView(APIView): return Response(dashboard_data) -class InstanceList(ListAPIView): +class InstanceList(ListCreateAPIView): name = _("Instances") model = models.Instance @@ -398,6 +398,17 @@ class InstanceUnifiedJobsList(SubListAPIView): return qs +class InstancePeersList(SubListAPIView): + + name = _("Instance Peers") + parent_model = models.Instance + model = models.Instance + serializer_class = serializers.InstanceSerializer + parent_access = 'read' + search_fields = {'hostname'} + relationship = 'peers' + + class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): name = _("Instance's Instance Groups") @@ -440,40 +451,16 @@ class InstanceHealthCheck(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - + # Note: hop nodes are already excluded by the get_queryset method if obj.node_type == 'execution': from awx.main.tasks.system import execution_node_health_check - runner_data = execution_node_health_check(obj.hostname) - obj.refresh_from_db() - data = self.get_serializer(data=request.data).to_representation(obj) - # Add in some extra unsaved fields - for extra_field in ('transmit_timing', 'run_timing'): - if extra_field in runner_data: - data[extra_field] = runner_data[extra_field] + execution_node_health_check.apply_async([obj.hostname]) else: from awx.main.tasks.system import cluster_node_health_check - if settings.CLUSTER_HOST_ID == obj.hostname: - cluster_node_health_check(obj.hostname) - else: - cluster_node_health_check.apply_async([obj.hostname], queue=obj.hostname) - start_time = time.time() - prior_check_time = obj.last_health_check - while time.time() - start_time < 50.0: - obj.refresh_from_db(fields=['last_health_check']) - if obj.last_health_check != prior_check_time: - break - if time.time() - start_time < 1.0: - time.sleep(0.1) - else: - time.sleep(1.0) - else: - obj.mark_offline(errors=_('Health check initiated by user determined this instance to be unresponsive')) - obj.refresh_from_db() - data = self.get_serializer(data=request.data).to_representation(obj) - - return Response(data, status=status.HTTP_200_OK) + cluster_node_health_check.apply_async([obj.hostname], queue=obj.hostname) + return Response(dict(msg=f"Health check is running for {obj.hostname}."), status=status.HTTP_200_OK) class InstanceGroupList(ListCreateAPIView): diff --git a/awx/api/views/instance_install_bundle.py b/awx/api/views/instance_install_bundle.py new file mode 100644 index 0000000000..455da25ddf --- /dev/null +++ b/awx/api/views/instance_install_bundle.py @@ -0,0 +1,187 @@ +# Copyright (c) 2018 Red Hat, Inc. +# All Rights Reserved. + +import datetime +import io +import ipaddress +import os +import tarfile + +import asn1 +from awx.api import serializers +from awx.api.generics import GenericAPIView, Response +from awx.api.permissions import IsSystemAdminOrAuditor +from awx.main import models +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509 import DNSName, IPAddress, ObjectIdentifier, OtherName +from cryptography.x509.oid import NameOID +from django.http import HttpResponse +from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ +from rest_framework import status + +# Red Hat has an OID namespace (RHANANA). Receptor has its own designation under that. +RECEPTOR_OID = "1.3.6.1.4.1.2312.19.1" + +# generate install bundle for the instance +# install bundle directory structure +# ├── install_receptor.yml (playbook) +# ├── inventory.yml +# ├── receptor +# │ ├── tls +# │ │ ├── ca +# │ │ │ └── receptor-ca.crt +# │ │ ├── receptor.crt +# │ │ └── receptor.key +# │ └── work-public-key.pem +# └── requirements.yml +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 instance_obj.node_type not in ('execution',): + return Response( + data=dict(msg=_('Install bundle can only be generated for execution nodes.')), + status=status.HTTP_400_BAD_REQUEST, + ) + + with io.BytesIO() as f: + with tarfile.open(fileobj=f, mode='w:gz') as tar: + # copy /etc/receptor/tls/ca/receptor-ca.crt to receptor/tls/ca in the tar file + tar.add( + os.path.realpath('/etc/receptor/tls/ca/receptor-ca.crt'), arcname=f"{instance_obj.hostname}_install_bundle/receptor/tls/ca/receptor-ca.crt" + ) + + # copy /etc/receptor/signing/work-public-key.pem to receptor/work-public-key.pem + tar.add('/etc/receptor/signing/work-public-key.pem', arcname=f"{instance_obj.hostname}_install_bundle/receptor/work-public-key.pem") + + # generate and write the receptor key to receptor/tls/receptor.key in the tar file + key, cert = generate_receptor_tls(instance_obj) + + key_tarinfo = tarfile.TarInfo(f"{instance_obj.hostname}_install_bundle/receptor/tls/receptor.key") + key_tarinfo.size = len(key) + tar.addfile(key_tarinfo, io.BytesIO(key)) + + cert_tarinfo = tarfile.TarInfo(f"{instance_obj.hostname}_install_bundle/receptor/tls/receptor.crt") + cert_tarinfo.size = len(cert) + tar.addfile(cert_tarinfo, io.BytesIO(cert)) + + # generate and write install_receptor.yml to the tar file + playbook = generate_playbook().encode('utf-8') + playbook_tarinfo = tarfile.TarInfo(f"{instance_obj.hostname}_install_bundle/install_receptor.yml") + playbook_tarinfo.size = len(playbook) + tar.addfile(playbook_tarinfo, io.BytesIO(playbook)) + + # generate and write inventory.yml to the tar file + inventory_yml = generate_inventory_yml(instance_obj).encode('utf-8') + inventory_yml_tarinfo = tarfile.TarInfo(f"{instance_obj.hostname}_install_bundle/inventory.yml") + inventory_yml_tarinfo.size = len(inventory_yml) + tar.addfile(inventory_yml_tarinfo, io.BytesIO(inventory_yml)) + + # generate and write requirements.yml to the tar file + requirements_yml = generate_requirements_yml().encode('utf-8') + requirements_yml_tarinfo = tarfile.TarInfo(f"{instance_obj.hostname}_install_bundle/requirements.yml") + requirements_yml_tarinfo.size = len(requirements_yml) + tar.addfile(requirements_yml_tarinfo, io.BytesIO(requirements_yml)) + + # respond with the tarfile + f.seek(0) + response = HttpResponse(f.read(), status=status.HTTP_200_OK) + response['Content-Disposition'] = f"attachment; filename={instance_obj.hostname}_install_bundle.tar.gz" + return response + + +def generate_playbook(): + return render_to_string("instance_install_bundle/install_receptor.yml") + + +def generate_requirements_yml(): + return render_to_string("instance_install_bundle/requirements.yml") + + +def generate_inventory_yml(instance_obj): + return render_to_string("instance_install_bundle/inventory.yml", context=dict(instance=instance_obj)) + + +def generate_receptor_tls(instance_obj): + # generate private key for the receptor + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + # encode receptor hostname to asn1 + hostname = instance_obj.hostname + encoder = asn1.Encoder() + encoder.start() + encoder.write(hostname.encode(), nr=asn1.Numbers.UTF8String) + hostname_asn1 = encoder.output() + + san_params = [ + DNSName(hostname), + OtherName(ObjectIdentifier(RECEPTOR_OID), hostname_asn1), + ] + + try: + san_params.append(IPAddress(ipaddress.IPv4Address(hostname))) + except ipaddress.AddressValueError: + pass + + # generate certificate for the receptor + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ] + ) + ) + .add_extension( + x509.SubjectAlternativeName(san_params), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + # sign csr with the receptor ca key from /etc/receptor/ca/receptor-ca.key + with open('/etc/receptor/tls/ca/receptor-ca.key', 'rb') as f: + ca_key = serialization.load_pem_private_key( + f.read(), + password=None, + ) + + with open('/etc/receptor/tls/ca/receptor-ca.crt', 'rb') as f: + ca_cert = x509.load_pem_x509_certificate(f.read()) + + cert = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(ca_cert.issuer) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10)) + .add_extension( + csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value, + critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical, + ) + .sign(ca_key, hashes.SHA256()) + ) + + key = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + cert = cert.public_bytes( + encoding=serialization.Encoding.PEM, + ) + + return key, cert diff --git a/awx/main/access.py b/awx/main/access.py index e8deea8f36..665c8e1f8d 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,7 +579,8 @@ class InstanceAccess(BaseAccess): return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data) def can_add(self, data): - return False + + return self.user.is_superuser def can_change(self, obj, data): return False diff --git a/awx/main/management/commands/register_peers.py b/awx/main/management/commands/register_peers.py index 6d26ebfbb2..078edb08c7 100644 --- a/awx/main/management/commands/register_peers.py +++ b/awx/main/management/commands/register_peers.py @@ -27,7 +27,9 @@ class Command(BaseCommand): ) def handle(self, **options): + # provides a mapping of hostname to Instance objects nodes = Instance.objects.in_bulk(field_name='hostname') + if options['source'] not in nodes: raise CommandError(f"Host {options['source']} is not a registered instance.") if not (options['peers'] or options['disconnect'] or options['exact'] is not None): @@ -57,7 +59,9 @@ class Command(BaseCommand): results = 0 for target in options['peers']: - _, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) + _, created = InstanceLink.objects.update_or_create( + source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED} + ) if created: results += 1 @@ -80,7 +84,9 @@ class Command(BaseCommand): links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True)) removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete() for target in peers - links: - _, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target]) + _, created = InstanceLink.objects.update_or_create( + source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED} + ) if created: additions += 1 diff --git a/awx/main/managers.py b/awx/main/managers.py index 23acd15139..88e8384c43 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -129,10 +129,13 @@ class InstanceManager(models.Manager): # if instance was not retrieved by uuid and hostname was, use the hostname instance = self.filter(hostname=hostname) + from awx.main.models import Instance + # Return existing instance if instance.exists(): instance = instance.first() # in the unusual occasion that there is more than one, only get one - update_fields = [] + instance.node_state = Instance.States.INSTALLED # Wait for it to show up on the mesh + update_fields = ['node_state'] # if instance was retrieved by uuid and hostname has changed, update hostname if instance.hostname != hostname: logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname)) @@ -141,6 +144,7 @@ class InstanceManager(models.Manager): # if any other fields are to be updated if instance.ip_address != ip_address: instance.ip_address = ip_address + update_fields.append('ip_address') if instance.node_type != node_type: instance.node_type = node_type update_fields.append('node_type') @@ -151,12 +155,12 @@ class InstanceManager(models.Manager): return (False, instance) # Create new instance, and fill in default values - create_defaults = dict(capacity=0) + create_defaults = {'node_state': Instance.States.INSTALLED, 'capacity': 0} if defaults is not None: create_defaults.update(defaults) uuid_option = {} if uuid is not None: - uuid_option = dict(uuid=uuid) + uuid_option = {'uuid': uuid} if node_type == 'execution' and 'version' not in create_defaults: create_defaults['version'] = RECEPTOR_PENDING instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option) diff --git a/awx/main/migrations/0170_node_and_link_state.py b/awx/main/migrations/0170_node_and_link_state.py new file mode 100644 index 0000000000..6fbc3dd12b --- /dev/null +++ b/awx/main/migrations/0170_node_and_link_state.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.13 on 2022-08-02 17:53 + +import django.core.validators +from django.db import migrations, models + + +def forwards(apps, schema_editor): + # All existing InstanceLink objects need to be in the state + # 'Established', which is the default, so nothing needs to be done + # for that. + + Instance = apps.get_model('main', 'Instance') + for instance in Instance.objects.all(): + instance.node_state = 'ready' if not instance.errors else 'unavailable' + instance.save(update_fields=['node_state']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0169_jt_prompt_everything_on_launch'), + ] + + operations = [ + migrations.AddField( + model_name='instance', + name='listener_port', + field=models.PositiveIntegerField( + blank=True, + default=27199, + help_text='Port that Receptor will listen for incoming connections on.', + validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], + ), + ), + migrations.AddField( + model_name='instance', + name='node_state', + field=models.CharField( + choices=[ + ('provisioning', 'Provisioning'), + ('provision-fail', 'Provisioning Failure'), + ('installed', 'Installed'), + ('ready', 'Ready'), + ('unavailable', 'Unavailable'), + ('deprovisioning', 'De-provisioning'), + ('deprovision-fail', 'De-provisioning Failure'), + ], + default='ready', + help_text='Indicates the current life cycle stage of this instance.', + max_length=16, + ), + ), + migrations.AddField( + model_name='instancelink', + name='link_state', + field=models.CharField( + choices=[('adding', 'Adding'), ('established', 'Established'), ('removing', 'Removing')], + default='established', + help_text='Indicates the current life cycle stage of this peer link.', + max_length=16, + ), + ), + migrations.AlterField( + model_name='instance', + name='node_type', + field=models.CharField( + choices=[ + ('control', 'Control plane node'), + ('execution', 'Execution plane node'), + ('hybrid', 'Controller and execution'), + ('hop', 'Message-passing node, no execution capability'), + ], + default='hybrid', + help_text='Role that this node plays in the mesh.', + max_length=16, + ), + ), + migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop), + ] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index eeed06bc60..38e8ac0068 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -5,7 +5,7 @@ from decimal import Decimal import logging import os -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, MaxValueValidator from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -59,6 +59,15 @@ class InstanceLink(BaseModel): source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+') target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers') + class States(models.TextChoices): + ADDING = 'adding', _('Adding') + ESTABLISHED = 'established', _('Established') + REMOVING = 'removing', _('Removing') + + link_state = models.CharField( + choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.") + ) + class Meta: unique_together = ('source', 'target') @@ -127,13 +136,33 @@ class Instance(HasPolicyEditsMixin, BaseModel): default=0, editable=False, ) - NODE_TYPE_CHOICES = [ - ("control", "Control plane node"), - ("execution", "Execution plane node"), - ("hybrid", "Controller and execution"), - ("hop", "Message-passing node, no execution capability"), - ] - node_type = models.CharField(default='hybrid', choices=NODE_TYPE_CHOICES, max_length=16) + + class Types(models.TextChoices): + CONTROL = 'control', _("Control plane node") + EXECUTION = 'execution', _("Execution plane node") + HYBRID = 'hybrid', _("Controller and execution") + HOP = 'hop', _("Message-passing node, no execution capability") + + node_type = models.CharField(default=Types.HYBRID, choices=Types.choices, max_length=16, help_text=_("Role that this node plays in the mesh.")) + + class States(models.TextChoices): + PROVISIONING = 'provisioning', _('Provisioning') + PROVISION_FAIL = 'provision-fail', _('Provisioning Failure') + INSTALLED = 'installed', _('Installed') + READY = 'ready', _('Ready') + UNAVAILABLE = 'unavailable', _('Unavailable') + DEPROVISIONING = 'deprovisioning', _('De-provisioning') + DEPROVISION_FAIL = 'deprovision-fail', _('De-provisioning Failure') + + node_state = models.CharField( + choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.") + ) + listener_port = models.PositiveIntegerField( + blank=True, + default=27199, + validators=[MinValueValidator(1), MaxValueValidator(65535)], + help_text=_("Port that Receptor will listen for incoming connections on."), + ) peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target')) @@ -213,18 +242,22 @@ class Instance(HasPolicyEditsMixin, BaseModel): return self.last_seen < ref_time - timedelta(seconds=grace_period) def mark_offline(self, update_last_seen=False, perform_save=True, errors=''): - if self.cpu_capacity == 0 and self.mem_capacity == 0 and self.capacity == 0 and self.errors == errors and (not update_last_seen): - return + 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() + update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors'] + if update_last_seen: + update_fields += ['last_seen'] if perform_save: - update_fields = ['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)""" @@ -278,8 +311,12 @@ class Instance(HasPolicyEditsMixin, BaseModel): if not errors: self.refresh_capacity_fields() self.errors = '' + if self.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED): + self.node_state = Instance.States.READY + update_fields.append('node_state') else: - self.mark_offline(perform_save=False, errors=errors) + 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 @@ -296,7 +333,7 @@ class Instance(HasPolicyEditsMixin, BaseModel): # playbook event data; we should consider this a zero capacity event redis.Redis.from_url(settings.BROKER_URL).ping() except redis.ConnectionError: - errors = _('Failed to connect ot Redis') + errors = _('Failed to connect to Redis') self.save_health_data(awx_application_version, get_cpu_count(), get_mem_in_bytes(), update_last_seen=True, errors=errors) @@ -388,6 +425,20 @@ 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): + 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 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: 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 + + # broadcast to all control instances to update their receptor configs + connection.on_commit(lambda: write_receptor_config.apply_async(queue='tower_broadcast_all')) + if created or instance.has_policy_changes(): schedule_policy_task() diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 45f262ebe6..a0a125729d 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -642,10 +642,6 @@ class TaskManager(TaskBase): found_acceptable_queue = True break - # TODO: remove this after we have confidence that OCP control nodes are reporting node_type=control - if settings.IS_K8S and task.capacity_type == 'execution': - logger.debug("Skipping group {}, task cannot run on control plane".format(instance_group.name)) - continue # at this point we know the instance group is NOT a container group # because if it was, it would have started the task and broke out of the loop. execution_instance = self.instance_groups.fit_task_to_most_remaining_capacity_instance( diff --git a/awx/main/scheduler/task_manager_models.py b/awx/main/scheduler/task_manager_models.py index b84cdfcf82..b9187c0e9c 100644 --- a/awx/main/scheduler/task_manager_models.py +++ b/awx/main/scheduler/task_manager_models.py @@ -37,7 +37,11 @@ class TaskManagerInstances: def __init__(self, active_tasks, instances=None, instance_fields=('node_type', 'capacity', 'hostname', 'enabled')): self.instances_by_hostname = dict() if instances is None: - instances = Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only(*instance_fields) + instances = ( + Instance.objects.filter(hostname__isnull=False, node_state=Instance.States.READY, enabled=True) + .exclude(node_type='hop') + .only('node_type', 'node_state', 'capacity', 'hostname', 'enabled') + ) for instance in instances: self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 33cfc30cd1..ff64f8ee64 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -145,7 +145,7 @@ class BaseTask(object): """ Return params structure to be executed by the container runtime """ - if settings.IS_K8S: + if settings.IS_K8S and instance.instance_group.is_container_group: return {} image = instance.execution_environment.image diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 0350a96836..172d68b2b5 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -27,12 +27,18 @@ from awx.main.utils.common import ( ) from awx.main.constants import MAX_ISOLATED_PATH_COLON_DELIMITER from awx.main.tasks.signals import signal_state, signal_callback, SignalExit +from awx.main.models import Instance, InstanceLink, UnifiedJob +from awx.main.dispatch import get_local_queuename +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') @@ -42,9 +48,22 @@ class ReceptorConnectionType(Enum): STREAMTLS = 2 +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: + return yaml.safe_load(f) + + def get_receptor_sockfile(): - 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 == 'control-service': @@ -60,8 +79,7 @@ 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) + data = read_receptor_config() for section in data: for entry_name, entry_data in section.items(): if entry_name == 'tls-client': @@ -78,12 +96,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): @@ -136,8 +167,7 @@ def run_until_complete(node, timing_data=None, **kwargs): kwargs.setdefault('payload', '') transmit_start = time.time() - sign_work = False if settings.IS_K8S else True - result = receptor_ctl.submit_work(worktype='ansible-runner', node=node, signwork=sign_work, **kwargs) + result = receptor_ctl.submit_work(worktype='ansible-runner', node=node, signwork=True, **kwargs) unit_id = result['unitid'] run_start = time.time() @@ -212,7 +242,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 @@ -283,10 +313,6 @@ class AWXReceptorJob: except Exception: logger.exception(f"Error releasing work unit {self.unit_id}.") - @property - def sign_work(self): - return False if settings.IS_K8S else True - def _run_internal(self, receptor_ctl): # Create a socketpair. Where the left side will be used for writing our payload # (private data dir, kwargs). The right side will be passed to Receptor for @@ -446,6 +472,10 @@ class AWXReceptorJob: return receptor_params + @property + def sign_work(self): + return True if self.work_type in ('ansible-runner', 'local') else False + @property def work_type(self): if self.task.instance.is_container_group_task: @@ -574,3 +604,105 @@ class AWXReceptorJob: else: config["clusters"][0]["cluster"]["insecure-skip-tls-verify"] = True return config + + +# TODO: receptor reload expects ordering within config items to be preserved +# if python dictionary is not preserving order properly, may need to find a +# solution. yaml.dump does not seem to work well with OrderedDict. below line may help +# yaml.add_representer(OrderedDict, lambda dumper, data: dumper.represent_mapping('tag:yaml.org,2002:map', data.items())) +# +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'}}, + { + '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(): + lock = FileLock(__RECEPTOR_CONF_LOCKFILE) + with lock: + receptor_config = list(RECEPTOR_CONFIG_STARTER) + + 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) + + # This needs to be outside of the lock because this function itself will acquire the lock. + receptor_ctl = get_receptor_ctl() + + attempts = 10 + for backoff in range(1, attempts + 1): + try: + receptor_ctl.simple_command("reload") + break + except ValueError: + logger.warning(f"Unable to reload Receptor configuration. {attempts-backoff} attempts left.") + time.sleep(backoff) + else: + raise RuntimeError("Receptor reload failed") + + links = InstanceLink.objects.filter(source=this_inst, target__in=instances, link_state=InstanceLink.States.ADDING) + links.update(link_state=InstanceLink.States.ESTABLISHED) + + +@task(queue=get_local_queuename) +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=( + 'running', + 'waiting', + ), + ) + while node_jobs.exists(): + time.sleep(60) + + # This will as a side effect also delete the InstanceLinks that are tied to it. + Instance.objects.filter(hostname=hostname).delete() + + # Update the receptor configs for all of the control-plane. + write_receptor_config.apply_async(queue='tower_broadcast_all') diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index d4f067115e..24dbd98b6e 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -61,7 +61,7 @@ from awx.main.utils.common import ( from awx.main.utils.external_logging import reconfigure_rsyslog from awx.main.utils.reload import stop_local_services from awx.main.utils.pglock import advisory_lock -from awx.main.tasks.receptor import get_receptor_ctl, worker_info, worker_cleanup, administrative_workunit_reaper +from awx.main.tasks.receptor import get_receptor_ctl, worker_info, worker_cleanup, administrative_workunit_reaper, write_receptor_config from awx.main.consumers import emit_channel_notification from awx.main import analytics from awx.conf import settings_registry @@ -81,6 +81,10 @@ Try upgrading OpenSSH or providing your private key in an different format. \ def dispatch_startup(): startup_logger = logging.getLogger('awx.main.tasks') + # TODO: Enable this on VM installs + if settings.IS_K8S: + write_receptor_config() + startup_logger.debug("Syncing Schedules") for sch in Schedule.objects.all(): try: @@ -122,7 +126,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.') @@ -349,9 +353,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 @@ -405,7 +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): + logger.warning(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}") + return data = worker_info(node) @@ -440,6 +453,7 @@ def inspect_execution_nodes(instance_list): nowtime = now() workers = mesh_status['Advertisements'] + for ad in workers: hostname = ad['NodeID'] @@ -453,9 +467,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 @@ -463,12 +475,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 and (not instance.is_lost(ref_time=nowtime)): + 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 @@ -487,7 +499,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 = [] @@ -549,11 +561,11 @@ 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() + 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)) diff --git a/awx/main/tests/functional/api/test_instance.py b/awx/main/tests/functional/api/test_instance.py index 4184d876aa..ec569d945f 100644 --- a/awx/main/tests/functional/api/test_instance.py +++ b/awx/main/tests/functional/api/test_instance.py @@ -1,16 +1,9 @@ import pytest -from unittest import mock - from awx.api.versioning import reverse from awx.main.models.activity_stream import ActivityStream from awx.main.models.ha import Instance -import redis - -# Django -from django.test.utils import override_settings - INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_capacity=6, mem_capacity=42) @@ -50,33 +43,14 @@ def test_enabled_sets_capacity(patch, admin_user): def test_auditor_user_health_check(get, post, system_auditor): instance = Instance.objects.create(**INSTANCE_KWARGS) url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - r = get(url=url, user=system_auditor, expect=200) - assert r.data['cpu_capacity'] == instance.cpu_capacity + get(url=url, user=system_auditor, expect=200) post(url=url, user=system_auditor, expect=403) @pytest.mark.django_db -def test_health_check_throws_error(post, admin_user): - instance = Instance.objects.create(node_type='execution', **INSTANCE_KWARGS) - url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - # we will simulate a receptor error, similar to this one - # https://github.com/ansible/receptor/blob/156e6e24a49fbf868734507f9943ac96208ed8f5/receptorctl/receptorctl/socket_interface.py#L204 - # related to issue https://github.com/ansible/tower/issues/5315 - with mock.patch('awx.main.tasks.receptor.run_until_complete', side_effect=RuntimeError('Remote error: foobar')): - post(url=url, user=admin_user, expect=200) - instance.refresh_from_db() - assert 'Remote error: foobar' in instance.errors - assert instance.capacity == 0 - - -@pytest.mark.django_db -@mock.patch.object(redis.client.Redis, 'ping', lambda self: True) def test_health_check_usage(get, post, admin_user): instance = Instance.objects.create(**INSTANCE_KWARGS) url = reverse('api:instance_health_check', kwargs={'pk': instance.pk}) - r = get(url=url, user=admin_user, expect=200) - assert r.data['cpu_capacity'] == instance.cpu_capacity - assert r.data['last_health_check'] is None - with override_settings(CLUSTER_HOST_ID=instance.hostname): # force direct call of cluster_node_health_check - r = post(url=url, user=admin_user, expect=200) - assert r.data['last_health_check'] is not None + get(url=url, user=admin_user, expect=200) + r = post(url=url, user=admin_user, expect=200) + assert r.data['msg'] == f"Health check is running for {instance.hostname}." diff --git a/awx/main/tests/functional/test_tasks.py b/awx/main/tests/functional/test_tasks.py index ce385cfced..6de551cf9f 100644 --- a/awx/main/tests/functional/test_tasks.py +++ b/awx/main/tests/functional/test_tasks.py @@ -19,12 +19,11 @@ def scm_revision_file(tmpdir_factory): @pytest.mark.django_db -@pytest.mark.parametrize('node_type', ('control', 'hybrid')) +@pytest.mark.parametrize('node_type', ('control. hybrid')) def test_no_worker_info_on_AWX_nodes(node_type): hostname = 'us-south-3-compute.invalid' Instance.objects.create(hostname=hostname, node_type=node_type) - with pytest.raises(RuntimeError): - execution_node_health_check(hostname) + assert execution_node_health_check(hostname) is None @pytest.fixture diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index ff35c6549b..03c2654725 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -47,6 +47,7 @@ "@nteract/mockument": "^1.0.4", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "14.4.3", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", "babel-plugin-macros": "3.1.0", "enzyme": "^3.10.0", @@ -4514,6 +4515,19 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.4.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", + "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -25653,6 +25667,13 @@ "@types/react-dom": "<18.0.0" } }, + "@testing-library/user-event": { + "version": "14.4.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.4.3.tgz", + "integrity": "sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==", + "dev": true, + "requires": {} + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", diff --git a/awx/ui/package.json b/awx/ui/package.json index cae5912ad5..4b244eebb4 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -47,6 +47,7 @@ "@nteract/mockument": "^1.0.4", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "14.4.3", "@wojtekmaj/enzyme-adapter-react-17": "0.6.5", "babel-plugin-macros": "3.1.0", "enzyme": "^3.10.0", diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 07ee085c14..388bb2eb4e 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -8,6 +8,7 @@ class Instances extends Base { this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this); this.healthCheck = this.healthCheck.bind(this); this.readInstanceGroup = this.readInstanceGroup.bind(this); + this.deprovisionInstance = this.deprovisionInstance.bind(this); } healthCheck(instanceId) { @@ -18,9 +19,19 @@ class Instances extends Base { return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } + readPeers(instanceId, params) { + return this.http.get(`${this.baseUrl}${instanceId}/peers/`, { params }); + } + readInstanceGroup(instanceId) { return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`); } + + deprovisionInstance(instanceId) { + return this.http.patch(`${this.baseUrl}${instanceId}/`, { + node_state: 'deprovisioning', + }); + } } export default Instances; diff --git a/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js new file mode 100644 index 0000000000..5d3a77be3a --- /dev/null +++ b/awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +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 ( + onSetHealthCheckAlert(false)} /> + } + title={ + <> + {t`Health check request(s) submitted. Please wait and reload the page.`}{' '} + + + } + /> + ); +} + +export default HealthCheckAlert; diff --git a/awx/ui/src/components/HealthCheckAlert/index.js b/awx/ui/src/components/HealthCheckAlert/index.js new file mode 100644 index 0000000000..038b28f796 --- /dev/null +++ b/awx/ui/src/components/HealthCheckAlert/index.js @@ -0,0 +1 @@ +export { default } from './HealthCheckAlert'; diff --git a/awx/ui/src/components/StatusIcon/StatusIcon.js b/awx/ui/src/components/StatusIcon/StatusIcon.js index 71149f8753..909e800256 100644 --- a/awx/ui/src/components/StatusIcon/StatusIcon.js +++ b/awx/ui/src/components/StatusIcon/StatusIcon.js @@ -22,6 +22,14 @@ const colors = { disabled: gray, canceled: orange, changed: orange, + /* Instance statuses */ + ready: green, + installed: blue, + provisioning: gray, + deprovisioning: gray, + unavailable: red, + 'provision-fail': red, + 'deprovision-fail': red, }; function StatusIcon({ status, ...props }) { diff --git a/awx/ui/src/components/StatusIcon/icons.js b/awx/ui/src/components/StatusIcon/icons.js index e91174661d..74b50a31ed 100644 --- a/awx/ui/src/components/StatusIcon/icons.js +++ b/awx/ui/src/components/StatusIcon/icons.js @@ -7,6 +7,7 @@ import { ClockIcon, MinusCircleIcon, InfoCircleIcon, + PlusCircleIcon, } from '@patternfly/react-icons'; const Spin = keyframes` @@ -40,5 +41,13 @@ const icons = { skipped: MinusCircleIcon, canceled: ExclamationTriangleIcon, changed: ExclamationTriangleIcon, + /* Instance statuses */ + ready: CheckCircleIcon, + installed: ClockIcon, + provisioning: PlusCircleIcon, + deprovisioning: MinusCircleIcon, + unavailable: ExclamationCircleIcon, + 'provision-fail': ExclamationCircleIcon, + 'deprovision-fail': ExclamationCircleIcon, }; export default icons; diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.js b/awx/ui/src/components/StatusLabel/StatusLabel.js index f7a91c8af2..284e2c8d5f 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.js @@ -24,6 +24,14 @@ const colors = { disabled: 'grey', canceled: 'orange', changed: 'orange', + /* Instance statuses */ + ready: 'green', + installed: 'blue', + provisioning: 'gray', + deprovisioning: 'gray', + unavailable: 'red', + 'provision-fail': 'red', + 'deprovision-fail': 'red', }; export default function StatusLabel({ status, tooltipContent = '', children }) { @@ -45,6 +53,14 @@ export default function StatusLabel({ status, tooltipContent = '', children }) { disabled: t`Disabled`, canceled: t`Canceled`, changed: t`Changed`, + /* Instance statuses */ + ready: t`Ready`, + installed: t`Installed`, + provisioning: t`Provisioning`, + deprovisioning: t`Deprovisioning`, + unavailable: t`Unavailable`, + 'provision-fail': t`Provisioning fail`, + 'deprovision-fail': t`Deprovisioning fail`, }; const label = upperCaseStatus[status] || status; const color = colors[status] || 'grey'; @@ -88,5 +104,12 @@ StatusLabel.propTypes = { 'disabled', 'canceled', 'changed', + 'ready', + 'installed', + 'provisioning', + 'deprovisioning', + 'unavailable', + 'provision-fail', + 'deprovision-fail', ]).isRequired, }; diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js index f2449a1556..eb24f1f9aa 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js @@ -28,6 +28,7 @@ import RoutedTabs from 'components/RoutedTabs'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import { Detail, DetailList } from 'components/DetailList'; +import HealthCheckAlert from 'components/HealthCheckAlert'; import StatusLabel from 'components/StatusLabel'; import useRequest, { useDeleteItems, @@ -66,6 +67,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { const history = useHistory(); const [healthCheck, setHealthCheck] = useState({}); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const [forks, setForks] = useState(); const { @@ -79,7 +81,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { data: { results }, } = await InstanceGroupsAPI.readInstances(instanceGroup.id); let instanceDetails; - let healthCheckDetails; const isAssociated = results.some( ({ id: instId }) => instId === parseInt(instanceId, 10) ); @@ -92,7 +93,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { ]); instanceDetails = details; - healthCheckDetails = healthCheckData; + setHealthCheck(healthCheckData); } else { throw new Error( `This instance is not associated with this instance group` @@ -100,7 +101,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { } setBreadcrumb(instanceGroup, instanceDetails); - setHealthCheck(healthCheckDetails); setForks( computeForks( instanceDetails.mem_capacity, @@ -115,15 +115,18 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { useEffect(() => { fetchDetails(); }, [fetchDetails]); - const { error: healthCheckError, isLoading: isRunningHealthCheck, 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]) ); @@ -148,7 +151,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { [instance] ) ); - const debounceUpdateInstance = useDebounce(updateInstance, 200); const handleChangeValue = (value) => { @@ -190,6 +192,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) { return ( <> + {showHealthCheckAlert ? ( + + ) : null} + instance.node_state ? ( + + ) : null } /> ', () => { enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', }, }); InstancesAPI.readHealthCheckDetail.mockResolvedValue({ @@ -297,58 +298,6 @@ describe('', () => { 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/InstanceGroupList/InstanceGroupList.js b/awx/ui/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.js index dfce293dbd..7868be6c14 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.js @@ -11,7 +11,6 @@ import useSelected from 'hooks/useSelected'; import PaginatedTable, { HeaderRow, HeaderCell, - ToolbarAddButton, ToolbarDeleteButton, getSearchableKeys, } from 'components/PaginatedTable'; @@ -27,11 +26,7 @@ const QS_CONFIG = getQSConfig('instance-group', { page_size: 20, }); -function InstanceGroupList({ - isKubernetes, - isSettingsRequestLoading, - settingsRequestError, -}) { +function InstanceGroupList() { const location = useLocation(); const match = useRouteMatch(); @@ -113,39 +108,32 @@ function InstanceGroupList({ const addContainerGroup = t`Add container group`; const addInstanceGroup = t`Add instance group`; - const addButton = - !isSettingsRequestLoading && !isKubernetes ? ( - - {addContainerGroup} - , - - {addInstanceGroup} - , - ]} - /> - ) : ( - - ); + const addButton = ( + + {addContainerGroup} + , + + {addInstanceGroup} + , + ]} + /> + ); const getDetailUrl = (item) => item.is_container_group @@ -159,10 +147,8 @@ function InstanceGroupList({ { - const { - data: { IS_K8S }, - } = await SettingsAPI.readCategory('all'); - return { - isKubernetes: IS_K8S, - }; - }, []), - { isKubernetes: false } - ); - useEffect(() => { - userCanReadSettings && settingsRequest(); - }, [settingsRequest, userCanReadSettings]); - const [breadcrumbConfig, setBreadcrumbConfig] = useState({ '/instance_groups': t`Instance Groups`, '/instance_groups/add': t`Create new instance group`, @@ -81,39 +52,25 @@ function InstanceGroups() { streamType={streamType} breadcrumbConfig={breadcrumbConfig} /> - {isSettingsRequestLoading ? ( - - - - - - ) : ( - - - - - - - - {!isKubernetes && ( - - - - )} - - - - - - - - - - )} + + + + + + + + + + + + + + + + + + + ); } diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js index 332c4319e1..c42cbbda51 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js @@ -23,6 +23,7 @@ import useSelected from 'hooks/useSelected'; import { InstanceGroupsAPI, InstancesAPI } from 'api'; import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton'; +import HealthCheckAlert from 'components/HealthCheckAlert'; import InstanceListItem from './InstanceListItem'; const QS_CONFIG = getQSConfig('instance', { @@ -33,6 +34,7 @@ const QS_CONFIG = getQSConfig('instance', { function InstanceList({ instanceGroup }) { const [isModalOpen, setIsModalOpen] = useState(false); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const location = useLocation(); const { id: instanceGroupId } = useParams(); @@ -86,9 +88,15 @@ function InstanceList({ instanceGroup }) { isLoading: isHealthCheckLoading, } = useRequest( useCallback(async () => { - await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id))); - fetchInstances(); - }, [selected, fetchInstances]) + const [...response] = await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ id }) => InstancesAPI.healthCheck(id)) + ); + if (response) { + setShowHealthCheckAlert(true); + } + }, [selected]) ); const handleHealthCheck = async () => { @@ -171,6 +179,9 @@ function InstanceList({ instanceGroup }) { return ( <> + {showHealthCheckAlert ? ( + + ) : null} } > - + {instance.node_type} diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js index 614e3ad9e0..334b6f07fe 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js @@ -44,6 +44,7 @@ const instance = [ enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', }, { id: 2, @@ -72,6 +73,7 @@ const instance = [ enabled: true, managed_by_policy: true, node_type: 'control', + node_state: 'ready', }, ]; diff --git a/awx/ui/src/screens/Instances/Instance.js b/awx/ui/src/screens/Instances/Instance.js index 8efe4b55f6..cd1169962b 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,34 @@ 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 { + isK8s: 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 ( @@ -32,8 +64,13 @@ function Instance({ setBreadcrumb }) { - + + {isK8s && ( + + + + )} {match.params.id && ( diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js new file mode 100644 index 0000000000..1c0e86400d --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js @@ -0,0 +1,39 @@ +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) => { + try { + const { + data: { id }, + } = await InstancesAPI.create(values); + + 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/InstanceAdd.test.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js new file mode 100644 index 0000000000..e79b0471c8 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { InstancesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import InstanceAdd from './InstanceAdd'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/instances'] }); + InstancesAPI.create.mockResolvedValue({ data: { id: 13 } }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('handleSubmit should call the api and redirect to details page', async () => { + await waitForElement(wrapper, 'isLoading', (el) => el.length === 0); + await act(async () => { + wrapper.find('InstanceForm').prop('handleSubmit')({ + name: 'new Foo', + node_type: 'hop', + }); + }); + expect(InstancesAPI.create).toHaveBeenCalledWith({ + name: 'new Foo', + node_type: 'hop', + }); + expect(history.location.pathname).toBe('/instances/13/details'); + }); + + test('handleCancel should return the user back to the instances list', async () => { + await waitForElement(wrapper, 'isLoading', (el) => el.length === 0); + await act(async () => { + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + }); + expect(history.location.pathname).toEqual('/instances'); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceAdd/index.js b/awx/ui/src/screens/Instances/InstanceAdd/index.js new file mode 100644 index 0000000000..c6ddcff5bc --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceAdd/index.js @@ -0,0 +1 @@ +export { default } from './InstanceAdd'; diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js index 9338b7c8d2..cd47a4c754 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, useHistory, useParams } from 'react-router-dom'; import { t, Plural } from '@lingui/macro'; import { Button, @@ -11,7 +11,9 @@ import { CodeBlockCode, Tooltip, Slider, + Label, } from '@patternfly/react-core'; +import { DownloadIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { useConfig } from 'contexts/Config'; @@ -26,7 +28,12 @@ import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import { Detail, DetailList } from 'components/DetailList'; import StatusLabel from 'components/StatusLabel'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; +import useRequest, { + useDeleteItems, + useDismissableError, +} from 'hooks/useRequest'; +import HealthCheckAlert from 'components/HealthCheckAlert'; +import RemoveInstanceButton from '../Shared/RemoveInstanceButton'; const Unavailable = styled.span` color: var(--pf-global--danger-color--200); @@ -54,21 +61,30 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) { ); } -function InstanceDetail({ setBreadcrumb }) { +function InstanceDetail({ setBreadcrumb, isK8s }) { const { me = {} } = useConfig(); const { id } = useParams(); const [forks, setForks] = useState(); - + const history = useHistory(); const [healthCheck, setHealthCheck] = useState({}); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const { 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 +99,12 @@ function InstanceDetail({ setBreadcrumb }) { details.capacity_adjustment ) ); - return details; + return { + instance: details, + instanceGroups: results, + }; }, [id]), - {} + { instance: {}, instanceGroups: [] } ); useEffect(() => { fetchDetails(); @@ -96,15 +115,18 @@ function InstanceDetail({ setBreadcrumb }) { setBreadcrumb(instance); } }, [instance, setBreadcrumb]); - const { error: healthCheckError, isLoading: isRunningHealthCheck, request: fetchHealthCheck, } = useRequest( useCallback(async () => { - 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]) ); @@ -127,135 +149,229 @@ 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 ); + const { + isLoading: isRemoveLoading, + deleteItems: removeInstances, + deletionError: removeError, + clearDeletionError, + } = useDeleteItems( + async () => { + await InstancesAPI.deprovisionInstance(instance.id); + history.push('/instances'); + }, + { + fetchItems: fetchDetails, + } + ); + if (contentError) { return ; } - if (isLoading) { + if (isLoading || isRemoveLoading) { return ; } const isHopNode = instance.node_type === 'hop'; + return ( - - - - - } - /> - - {!isHopNode && ( - <> - - - - - -
{t`CPU ${instance.cpu_capacity}`}
- -
- -
- -
-
{t`RAM ${instance.mem_capacity}`}
- - } - /> - - ) : ( - {t`Unavailable`} - ) - } - /> - - )} - {healthCheck?.errors && ( + <> + {showHealthCheckAlert ? ( + + ) : null} + + + - {healthCheck?.errors} - + instance.node_state ? ( + + ) : null } /> + + {!isHopNode && ( + <> + + + + + {instanceGroups && ( + ( + + {' '} + + ))} + isEmpty={instanceGroups.length === 0} + /> + )} + + {instance.related?.install_bundle && ( + + + + } + /> + )} + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+ + } + /> + + ) : ( + {t`Unavailable`} + ) + } + /> + + )} + {healthCheck?.errors && ( + + {healthCheck?.errors} + + } + /> + )} +
+ {!isHopNode && ( + + {me.is_superuser && isK8s && instance.node_type === 'execution' && ( + + )} + + + + + )} -
- {!isHopNode && ( - - - - - - - )} - {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.`} + + + )} +
+ ); } diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js index 72836e1792..cc038c6624 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js @@ -25,6 +25,7 @@ describe('', () => { InstancesAPI.readDetail.mockResolvedValue({ data: { + related: {}, id: 1, type: 'instance', url: '/api/v2/instances/1/', @@ -49,6 +50,17 @@ describe('', () => { enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', + }, + }); + InstancesAPI.readInstanceGroup.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Foo', + }, + ], }, }); InstancesAPI.readHealthCheckDetail.mockResolvedValue({ @@ -153,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 782fcdd187..fdebb58833 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'; @@ -10,15 +10,22 @@ import PaginatedTable, { HeaderRow, HeaderCell, getSearchableKeys, + ToolbarAddButton, } from 'components/PaginatedTable'; import AlertModal from 'components/AlertModal'; import ErrorDetail from 'components/ErrorDetail'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; +import { useConfig } from 'contexts/Config'; +import useRequest, { + useDismissableError, + useDeleteItems, +} 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 HealthCheckAlert from 'components/HealthCheckAlert'; import InstanceListItem from './InstanceListItem'; +import RemoveInstanceButton from '../Shared/RemoveInstanceButton'; const QS_CONFIG = getQSConfig('instance', { page: 1, @@ -28,21 +35,25 @@ const QS_CONFIG = getQSConfig('instance', { function InstanceList() { const location = useLocation(); + const { me } = useConfig(); + const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const { - result: { instances, count, relatedSearchableKeys, searchableKeys }, + result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s }, 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, + isK8s: sysSettings.data.IS_K8S, count: response.data.count, actions: responseActions.data.actions, relatedSearchableKeys: ( @@ -57,45 +68,69 @@ function InstanceList() { actions: {}, relatedSearchableKeys: [], searchableKeys: [], + isK8s: false, } ); - const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = - useSelected(instances.filter((i) => i.node_type !== 'hop')); - useEffect(() => { fetchInstances(); }, [fetchInstances]); + const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = + useSelected(instances.filter((i) => i.node_type !== 'hop')); + const { error: healthCheckError, request: fetchHealthCheck, 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); + } + }, [selected]) ); + const handleHealthCheck = async () => { await fetchHealthCheck(); clearSelected(); }; + const { error, dismissError } = useDismissableError(healthCheckError); const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(instances); + + const { + isLoading: isRemoveLoading, + deleteItems: handleRemoveInstances, + deletionError: removeError, + clearDeletionError, + } = useDeleteItems( + () => + Promise.all( + selected.map(({ id }) => InstancesAPI.deprovisionInstance(id)) + ), + { fetchItems: fetchInstances, qsConfig: QS_CONFIG } + ); + return ( <> + {showHealthCheckAlert ? ( + + ) : null} , + , + ] + : []), , ]} @@ -162,7 +213,9 @@ function InstanceList() { key={instance.id} value={instance.hostname} instance={instance} - onSelect={() => handleSelect(instance)} + onSelect={() => { + handleSelect(instance); + }} isSelected={selected.some((row) => row.id === instance.id)} fetchInstances={fetchInstances} rowIndex={index} @@ -182,6 +235,18 @@ function InstanceList() { )} + {removeError && ( + + {t`Failed to remove one or more instances.`} + + + )} ); } diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js index f8a3eca7d6..4542287b07 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js @@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils'; import { Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import { InstancesAPI } from 'api'; +import { InstancesAPI, SettingsAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -33,6 +33,7 @@ const instances = [ jobs_total: 68, cpu: 6, node_type: 'control', + node_state: 'ready', memory: 2087469056, cpu_capacity: 24, mem_capacity: 1, @@ -52,6 +53,7 @@ const instances = [ jobs_total: 68, cpu: 6, node_type: 'hybrid', + node_state: 'ready', memory: 2087469056, cpu_capacity: 24, mem_capacity: 1, @@ -71,6 +73,7 @@ const instances = [ jobs_total: 68, cpu: 6, node_type: 'execution', + node_state: 'ready', memory: 2087469056, cpu_capacity: 24, mem_capacity: 1, @@ -90,6 +93,7 @@ const instances = [ jobs_total: 68, cpu: 6, node_type: 'hop', + node_state: 'ready', memory: 2087469056, cpu_capacity: 24, mem_capacity: 1, @@ -111,6 +115,7 @@ describe('', () => { }, }); InstancesAPI.readOptions.mockResolvedValue(options); + SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: false } }); const history = createMemoryHistory({ initialEntries: ['/instances/1'], }); @@ -190,4 +195,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/InstanceList/InstanceListItem.js b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js index fc89bdd3fc..f01134f266 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js @@ -143,7 +143,7 @@ function InstanceListItem({ } > - + diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js index 10e8b2985a..7f5b8359b4 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js @@ -40,6 +40,7 @@ const instance = [ enabled: true, managed_by_policy: true, node_type: 'hybrid', + node_state: 'ready', }, { id: 2, @@ -64,6 +65,7 @@ const instance = [ enabled: true, managed_by_policy: true, node_type: 'hop', + node_state: 'ready', }, ]; diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js new file mode 100644 index 0000000000..a1d104d667 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js @@ -0,0 +1,123 @@ +import React, { useCallback, useEffect } from 'react'; +import { t } from '@lingui/macro'; +import { CardBody } from 'components/Card'; +import PaginatedTable, { + getSearchableKeys, + HeaderCell, + HeaderRow, +} from 'components/PaginatedTable'; +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 InstancePeerListItem from './InstancePeerListItem'; + +const QS_CONFIG = getQSConfig('peer', { + page: 1, + page_size: 20, + order_by: 'hostname', +}); + +function InstancePeerList() { + const location = useLocation(); + const { id } = useParams(); + const { + isLoading, + error: contentError, + request: fetchPeers, + 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, params), + 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, location]), + { + peers: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchPeers(); + }, [fetchPeers]); + + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(peers); + + return ( + + + {t`Name`} + {t`Status`} + {t`Node Type`} + + } + renderToolbar={(props) => ( + + )} + renderRow={(peer, index) => ( + row.id === peer.id)} + onExpand={() => handleExpand(peer)} + key={peer.id} + peerInstance={peer} + rowIndex={index} + /> + )} + /> + + ); +} + +export default InstancePeerList; diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js new file mode 100644 index 0000000000..cce09300b0 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import 'styled-components/macro'; +import { Tooltip } from '@patternfly/react-core'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import { formatDateString } from 'util/dates'; +import StatusLabel from 'components/StatusLabel'; +import { Detail, DetailList } from 'components/DetailList'; + +function InstancePeerListItem({ + peerInstance, + isExpanded, + onExpand, + rowIndex, +}) { + const labelId = `check-action-${peerInstance.id}`; + 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 && ( + + + + + + + + + + + + + + )} + + ); +} + +export default InstancePeerListItem; diff --git a/awx/ui/src/screens/Instances/InstancePeers/index.js b/awx/ui/src/screens/Instances/InstancePeers/index.js new file mode 100644 index 0000000000..1be96e8381 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/index.js @@ -0,0 +1 @@ +export { default } from './InstancePeerList'; diff --git a/awx/ui/src/screens/Instances/Instances.js b/awx/ui/src/screens/Instances/Instances.js index a230fb9a67..ca42498e41 100644 --- a/awx/ui/src/screens/Instances/Instances.js +++ b/awx/ui/src/screens/Instances/Instances.js @@ -6,10 +6,12 @@ import ScreenHeader from 'components/ScreenHeader'; import PersistentFilters from 'components/PersistentFilters'; import { InstanceList } from './InstanceList'; import Instance from './Instance'; +import InstanceAdd from './InstanceAdd'; function Instances() { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ '/instances': t`Instances`, + '/instances/add': t`Create new Instance`, }); const buildBreadcrumbConfig = useCallback((instance) => { @@ -27,6 +29,9 @@ function Instances() { <> + + + diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.js new file mode 100644 index 0000000000..8c57889f34 --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.js @@ -0,0 +1,118 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { Formik, useField } from 'formik'; +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'; + +function InstanceFormFields() { + const [enabled, , enabledHelpers] = useField('enabled'); + return ( + <> + + + + + + + } + > + { + enabledHelpers.setValue(!enabled.value); + }} + ouiaId="enable-instance-switch" + /> + + + ); +} + +function InstanceForm({ + instance = {}, + submitError, + handleCancel, + handleSubmit, +}) { + return ( + + { + handleSubmit(values); + }} + > + {(formik) => ( +
+ + + + + +
+ )} +
+
+ ); +} + +export default InstanceForm; diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.test.js new file mode 100644 index 0000000000..9e18a98283 --- /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', + }); + }); +}); diff --git a/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js new file mode 100644 index 0000000000..b6b1fb2986 --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js @@ -0,0 +1,198 @@ +import React, { useContext, useState, useEffect } from 'react'; +import { t, Plural } from '@lingui/macro'; +import { KebabifiedContext } from 'contexts/Kebabified'; +import { + getRelatedResourceDeleteCounts, + relatedResourceDeleteRequests, +} from 'util/getRelatedResourceDeleteDetails'; +import { + Button, + DropdownItem, + Tooltip, + Alert, + Badge, +} from '@patternfly/react-core'; +import AlertModal from 'components/AlertModal'; +import styled from 'styled-components'; +import ErrorDetail from 'components/ErrorDetail'; + +const WarningMessage = styled(Alert)` + margin-top: 10px; +`; + +const Label = styled.span` + && { + margin-right: 10px; + } +`; + +function RemoveInstanceButton({ itemsToRemove, onRemove, isK8s }) { + const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext); + const [removeMessageError, setRemoveMessageError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [removeDetails, setRemoveDetails] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const cannotRemove = (item) => item.node_type !== 'execution'; + + const toggleModal = async (isOpen) => { + setRemoveDetails(null); + setIsLoading(true); + if (isOpen && itemsToRemove.length > 0) { + const { results, error } = await getRelatedResourceDeleteCounts( + relatedResourceDeleteRequests.instance(itemsToRemove[0]) + ); + + if (error) { + setRemoveMessageError(error); + } else { + setRemoveDetails(results); + } + } + setIsModalOpen(isOpen); + setIsLoading(false); + }; + + const handleRemove = async () => { + await onRemove(); + toggleModal(false); + }; + useEffect(() => { + if (isKebabified) { + onKebabModalChange(isModalOpen); + } + }, [isKebabified, isModalOpen, onKebabModalChange]); + + const renderTooltip = () => { + const itemsUnableToremove = itemsToRemove + .filter(cannotRemove) + .map((item) => item.hostname) + .join(', '); + if (itemsToRemove.some(cannotRemove)) { + return t`You do not have permission to remove instances: ${itemsUnableToremove}`; + } + if (itemsToRemove.length) { + return t`Remove`; + } + return t`Select a row to remove`; + }; + + const isDisabled = + itemsToRemove.length === 0 || itemsToRemove.some(cannotRemove); + + const buildRemoveWarning = () => ( +
+ + {removeDetails && + Object.entries(removeDetails).map(([key, value]) => ( +
+ + {value} +
+ ))} +
+ ); + + if (removeMessageError) { + return ( + { + toggleModal(false); + setRemoveMessageError(); + }} + > + + + ); + } + return ( + <> + {isKebabified ? ( + + { + toggleModal(true); + }} + > + {t`Remove`} + + + ) : ( + +
+ +
+
+ )} + + {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/screens/Instances/Shared/RemoveInstanceButton.test.js b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js new file mode 100644 index 0000000000..3d2d467956 --- /dev/null +++ b/awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js @@ -0,0 +1,133 @@ +import React from 'react'; +import { within, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InstanceGroupsAPI } from 'api'; +import RemoveInstanceButton from './RemoveInstanceButton'; +import { I18nProvider } from '@lingui/react'; +import { i18n } from '@lingui/core'; +import { en } from 'make-plural/plurals'; +import english from '../../../../src/locales/en/messages'; + +jest.mock('api'); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'execution', + node_state: 'ready', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'control', + node_state: 'ready', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, +]; +describe('', () => { + test('Should open modal and deprovision node', async () => { + i18n.loadLocaleData({ en: { plurals: en } }); + i18n.load({ en: english }); + i18n.activate('en'); + InstanceGroupsAPI.read.mockResolvedValue({ + data: { results: [{ id: 1 }], count: 1 }, + }); + const user = userEvent.setup(); + const onRemove = jest.fn(); + render( + + + + ); + + const button = screen.getByRole('button'); + await user.click(button); + await waitFor(() => screen.getByRole('dialog')); + const modal = screen.getByRole('dialog'); + const removeButton = within(modal).getByRole('button', { + name: 'Confirm remove', + }); + + await user.click(removeButton); + + await waitFor(() => expect(onRemove).toBeCalled()); + }); + + test('Should be disabled', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('button'); + await user.hover(button); + await waitFor(() => + screen.getByText('You do not have permission to remove instances:') + ); + }); + + test('Should handle error when fetching warning message details.', async () => { + InstanceGroupsAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'get', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + const user = userEvent.setup(); + const onRemove = jest.fn(); + render( + + ); + + const button = screen.getByRole('button'); + await user.click(button); + await waitFor(() => screen.getByRole('dialog')); + screen.getByText('Error!'); + }); +}); diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 5fe35beb51..d674ecd4cf 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -1,3 +1,4 @@ +/* eslint i18next/no-literal-string: "off" */ import React from 'react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; @@ -14,8 +15,11 @@ import { } from '@patternfly/react-core'; import { - ExclamationIcon as PFExclamationIcon, - CheckIcon as PFCheckIcon, + ExclamationIcon, + CheckIcon, + OutlinedClockIcon, + PlusIcon, + MinusIcon, } 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,16 +71,14 @@ function Legend() { - + {t`Control node`} @@ -89,7 +88,7 @@ function Legend() { {t`Hybrid node`} @@ -97,42 +96,194 @@ function Legend() { {t`Hop node`} - {t`Status types`} + {t`Node state types`} - - - - {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`} + + +
+ )} )} diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index d217078f6c..1748a94c9e 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', + ready: '#3E8635', + 'provision-fail': '#C9190B', + 'deprovision-fail': '#C9190B', + unavailable: '#C9190B', + installed: '#0066CC', + provisioning: '#666', + deprovisioning: '#666', }; export const NODE_TYPE_SYMBOL_KEY = { @@ -32,3 +33,15 @@ 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', +}; diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index 11356d0e34..d2d875dc91 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 = { + ready: 'checkmark', + installed: 'clock', + unavailable: 'exclaimation', + '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 = { + 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 + }), 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', + 'provision-fail', + 'deprovision-fail', + 'unavailable', + ][getRandomInt(0, 6)]; } for (let i = 0; i < n; i++) { const id = i + 1; @@ -89,6 +142,7 @@ export const generateRandomNodes = (n) => { hostname: `node-${id}`, node_type: randomType, node_state: randomState, + enabled: Math.random() < 0.5, }; nodes.push(node); } diff --git a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js index 860b699468..38a0c55931 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers__RTL.test.js @@ -3,14 +3,19 @@ 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('healthy')).toBe('#3E8635'); + expect(renderStateColor('ready')).toBe('#3E8635'); }); test('returns empty string if state is not found', () => { expect(renderStateColor('foo')).toBe(''); @@ -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); @@ -68,10 +118,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(''); diff --git a/awx/ui/src/setupProxy.js b/awx/ui/src/setupProxy.js index e072a4f82b..8001584ca1 100644 --- a/awx/ui/src/setupProxy.js +++ b/awx/ui/src/setupProxy.js @@ -8,6 +8,7 @@ module.exports = (app) => { target: TARGET, secure: false, ws: true, + changeOrigin: true, }) ); }; diff --git a/awx/ui/src/util/getRelatedResourceDeleteDetails.js b/awx/ui/src/util/getRelatedResourceDeleteDetails.js index f9d9496aec..a3289c57f8 100644 --- a/awx/ui/src/util/getRelatedResourceDeleteDetails.js +++ b/awx/ui/src/util/getRelatedResourceDeleteDetails.js @@ -15,6 +15,7 @@ import { ExecutionEnvironmentsAPI, ApplicationsAPI, OrganizationsAPI, + InstanceGroupsAPI, } from 'api'; export async function getRelatedResourceDeleteCounts(requests) { @@ -274,4 +275,11 @@ export const relatedResourceDeleteRequests = { label: t`Templates`, }, ], + + instance: (selected) => [ + { + request: () => InstanceGroupsAPI.read({ instances: selected.id }), + label: t`Instance Groups`, + }, + ], }; diff --git a/awx_collection/meta/runtime.yml b/awx_collection/meta/runtime.yml index 903c05ebf2..b23d5b87e2 100644 --- a/awx_collection/meta/runtime.yml +++ b/awx_collection/meta/runtime.yml @@ -15,6 +15,7 @@ action_groups: - group - host - import + - instance - instance_group - inventory - inventory_source diff --git a/awx_collection/plugins/module_utils/controller_api.py b/awx_collection/plugins/module_utils/controller_api.py index 567a753c8f..50d10f8104 100644 --- a/awx_collection/plugins/module_utils/controller_api.py +++ b/awx_collection/plugins/module_utils/controller_api.py @@ -903,6 +903,8 @@ class ControllerAPIModule(ControllerModule): item_name = existing_item['identifier'] elif item_type == 'credential_input_source': item_name = existing_item['id'] + elif item_type == 'instance': + item_name = existing_item['hostname'] else: item_name = existing_item['name'] item_id = existing_item['id'] diff --git a/awx_collection/plugins/modules/instance.py b/awx_collection/plugins/modules/instance.py new file mode 100644 index 0000000000..e8a7866eaa --- /dev/null +++ b/awx_collection/plugins/modules/instance.py @@ -0,0 +1,140 @@ +#!/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: + - execution + required: False + type: str + default: execution + node_state: + description: + - Indicates the current life cycle stage of this instance. + choices: + - deprovisioning + - installed + 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=['execution']), + node_state=dict(type='str', choices=['deprovisioning', 'installed']), + 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 75e6bff29f..43e225e4b8 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -78,7 +78,7 @@ no_api_parameter_ok = { # When this tool was created we were not feature complete. Adding something in here indicates a module # that needs to be developed. If the module is found on the file system it will auto-detect that the # work is being done and will bypass this check. At some point this module should be removed from this list. -needs_development = ['inventory_script'] +needs_development = ['inventory_script', 'instance'] needs_param_development = { 'host': ['instance_id'], 'workflow_approval': ['description', 'execution_environment'], diff --git a/awx_collection/tests/integration/targets/instance/tasks/main.yml b/awx_collection/tests/integration/targets/instance/tasks/main.yml new file mode 100644 index 0000000000..4d5a596971 --- /dev/null +++ b/awx_collection/tests/integration/targets/instance/tasks/main.yml @@ -0,0 +1,55 @@ +--- +- name: Generate hostnames + set_fact: + hostname1: "AWX-Collection-tests-instance1.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + hostname2: "AWX-Collection-tests-instance2.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + hostname3: "AWX-Collection-tests-instance3.{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}.example.com" + register: facts + +- name: Show hostnames + debug: + var: facts + +- block: + - name: Create an instance + awx.awx.instance: + hostname: "{{ item }}" + with_items: + - "{{ hostname1 }}" + - "{{ hostname2 }}" + register: result + + - assert: + that: + - result is changed + + - name: Create an instance with non-default config + awx.awx.instance: + hostname: "{{ hostname3 }}" + capacity_adjustment: 0.4 + listener_port: 31337 + register: result + + - assert: + that: + - result is changed + + - name: Update an instance + awx.awx.instance: + hostname: "{{ hostname1 }}" + capacity_adjustment: 0.7 + register: result + + - assert: + that: + - result is changed + + always: + - name: Deprovision the instances + awx.awx.instance: + hostname: "{{ item }}" + node_state: deprovisioning + with_items: + - "{{ hostname1 }}" + - "{{ hostname2 }}" + - "{{ hostname3 }}" diff --git a/awxkit/awxkit/api/pages/instances.py b/awxkit/awxkit/api/pages/instances.py index 38695014bf..d30694ed6c 100644 --- a/awxkit/awxkit/api/pages/instances.py +++ b/awxkit/awxkit/api/pages/instances.py @@ -16,4 +16,15 @@ class Instances(page.PageList, Instance): pass -page.register_page([resources.instances, resources.related_instances], Instances) +page.register_page([resources.instances, resources.related_instances, resources.instance_peers], Instances) + + +class InstanceInstallBundle(page.Page): + def extract_data(self, response): + # The actual content of this response will be in the full set + # of bytes from response.content, which will be exposed via + # the Page.bytes interface. + return {} + + +page.register_page(resources.instance_install_bundle, InstanceInstallBundle) diff --git a/awxkit/awxkit/api/pages/page.py b/awxkit/awxkit/api/pages/page.py index 25e26c636d..65f3012587 100644 --- a/awxkit/awxkit/api/pages/page.py +++ b/awxkit/awxkit/api/pages/page.py @@ -154,6 +154,26 @@ class Page(object): resp.status_code = 200 return cls(r=resp, connection=connection) + @property + def bytes(self): + if self.r is None: + return b'' + return self.r.content + + def extract_data(self, response): + """Takes a `requests.Response` and returns a data dict.""" + try: + data = response.json() + except ValueError as e: # If there was no json to parse + data = {} + if response.text or response.status_code not in (200, 202, 204): + text = response.text + if len(text) > 1024: + text = text[:1024] + '... <<< Truncated >>> ...' + log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) + + return data + def page_identity(self, response, request_json=None): """Takes a `requests.Response` and returns a new __item_class__ instance if the request method is not a get, or returns @@ -171,16 +191,7 @@ class Page(object): else: ds = None - try: - data = response.json() - except ValueError as e: # If there was no json to parse - data = dict() - if response.text or response.status_code not in (200, 202, 204): - text = response.text - if len(text) > 1024: - text = text[:1024] + '... <<< Truncated >>> ...' - log.debug("Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) - + data = self.extract_data(response) exc_str = "%s (%s) received" % (http.responses[response.status_code], response.status_code) exception = exception_from_status_code(response.status_code) diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 815f10ac9d..982e7c2573 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -53,6 +53,8 @@ class Resources(object): _instance_group = r'instance_groups/\d+/' _instance_group_related_jobs = r'instance_groups/\d+/jobs/' _instance_groups = 'instance_groups/' + _instance_install_bundle = r'instances/\d+/install_bundle/' + _instance_peers = r'instances/\d+/peers/' _instance_related_jobs = r'instances/\d+/jobs/' _instances = 'instances/' _inventories = 'inventories/' diff --git a/docs/execution_nodes.md b/docs/execution_nodes.md new file mode 100644 index 0000000000..6c5504725e --- /dev/null +++ b/docs/execution_nodes.md @@ -0,0 +1,78 @@ +# Adding execution nodes to AWX + +Stand-alone execution nodes can be added to run alongside the Kubernetes deployment of AWX. These machines will not be a part of the AWX Kubernetes cluster. The control nodes running in the cluster will connect and submit work to these machines via Receptor. The machines be registered in AWX as type "execution" instances, meaning they will only be used to run AWX Jobs (i.e. they will not dispatch work or handle web requests as control nodes do). + +Below is an example of a single AWX pod connecting to two different execution nodes. For each execution node, the awx-ee container makes an outbound TCP connection to the machine via Receptor. + +``` + AWX POD + ┌──────────────┐ + │ │ + │ ┌──────────┐ │ +┌─────────────────┐ │ │ awx-task │ │ +│ execution node 1│◄────┐ │ ├──────────┤ │ +├─────────────────┤ ├────┼─┤ awx-ee │ │ +│ execution node 2│◄────┘ │ ├──────────┤ │ +└─────────────────┘ Receptor │ │ awx-web │ │ + TCP │ └──────────┘ │ + Peers │ │ + └──────────────┘ +``` + +Note, if the AWX deployment is scaled up, the new AWX pod will also make TCP connections to each execution node. + + +## Overview +Adding an execution instance involves a handful of steps: + +1. [Start a machine that is accessible from the k8s cluster (Red Hat family of operating systems are supported)](#start-machine) +2. [Create a new AWX Instance with `hostname` being the IP or DNS name of your remote machine.](#create-instance-in-awx) +3. [Download the install bundle for this newly created instance.](#download-the-install-bundle) +4. [Run the install bundle playbook against your remote machine.](#run-the-install-bundle-playbook) +5. [Wait for the instance to report a Ready state. Now jobs can run on that instance.](#wait-for-instance-to-be-ready) + + +### Start machine + +Bring a machine online with a compatible Red Hat family OS (e.g. RHEL 8 and 9). This machines needs a static IP, or a resolvable DNS hostname that the AWX cluster can access. The machine will also need an available open port to establish inbound TCP connections on (default is 27199). + +In general the more CPU cores and memory the machine has, the more jobs that can be scheduled to run on that machine at once. See https://docs.ansible.com/automation-controller/4.2.1/html/userguide/jobs.html#at-capacity-determination-and-job-impact for more information on capacity. + + +### Create instance in AWX + +Use the Instance page or `api/v2/instances` endpoint to add a new instance. +- `hostname` ("Name" in UI) is the IP address or DNS name of your machine. +- `node_type` is "execution" +- `node_state` is "installed" +- `listener_port` is an open port on the remote machine used to establish inbound TCP connections. Defaults to 27199. + + +### Download the install bundle + +On the Instance Details page, click Install Bundle and save the tar.gz file to your local computer and extract contents. Alternatively, make a GET request to `api/v2/instances/{id}/install_bundle` and save the binary output to a tar.gz file. + + +### Run the install bundle playbook + +In order for AWX to make proper TCP connections to the remote machine, a few files need to in place. These include TLS certificates and keys, a certificate authority, and a proper Receptor configuration file. To facilitate that these files will be in the right location on the remote machine, the install bundle includes an install_receptor.yml playbook. + +The playbook requires the Receptor collection which can be obtained via + +`ansible-galaxy collection install -r requirements.yml` + +Modify `inventory.yml`. Set the `ansible_user` and any other ansible variables that may be needed to run playbooks against the remote machine. + +`ansible-playbook -i inventory.yml install_receptor.py` to start installing Receptor on the remote machine. + +Note, the playbook will enable the [Copr ansible-awx/receptor repository](https://copr.fedorainfracloud.org/coprs/ansible-awx/receptor/) so that Receptor can be installed. + + +### Wait for instance to be Ready + +Wait a few minutes for the periodic AWX task to do a health check against the new instance. The instances endpoint or page should report "Ready" status for the instance. If so, jobs are now ready to run on this machine! + + +## Removing instances + +You can remove an instance by clicking "Remove" in the Instances page, or by setting the instance `node_state` to "deprovisioning" via the API. diff --git a/docs/licenses/asn1.txt b/docs/licenses/asn1.txt new file mode 100644 index 0000000000..d218d2316d --- /dev/null +++ b/docs/licenses/asn1.txt @@ -0,0 +1,7 @@ +Copyright (c) 2007-2021 the Python-ASN1 authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/docs/licenses/enum-compat.txt b/docs/licenses/enum-compat.txt new file mode 100644 index 0000000000..4a6d02cfc6 --- /dev/null +++ b/docs/licenses/enum-compat.txt @@ -0,0 +1,7 @@ +Copyright (c) 2014, Jakub Stasiak + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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 OR COPYRIGHT HOLDERS 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. diff --git a/docs/licenses/filelock.txt b/docs/licenses/filelock.txt new file mode 100644 index 0000000000..cf1ab25da0 --- /dev/null +++ b/docs/licenses/filelock.txt @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/requirements/requirements.in b/requirements/requirements.in index e0f707b8bc..00779e760c 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,6 +1,7 @@ aiohttp>=3.7.4 ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading asciichartpy +asn1 autobahn>=20.12.3 # CVE-2020-35678 azure-keyvault==1.1.0 # see UPGRADE BLOCKERs channels @@ -25,6 +26,7 @@ django-split-settings django-taggit djangorestframework==3.13.1 djangorestframework-yaml +filelock GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119 irc jinja2>=2.11.3 # CVE-2020-28493 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index f05f27b807..c407a3f5ee 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -15,6 +15,8 @@ asgiref==3.5.0 # channels-redis # daphne # django +asn1==2.6.0 + # via -r /awx_devel/requirements/requirements.in async-timeout==3.0.1 # via # aiohttp @@ -132,6 +134,10 @@ docutils==0.16 # via python-daemon ecdsa==0.18.0 # via python-jose +enum-compat==0.0.3 + # via asn1 +filelock==3.8.0 + # via -r /awx_devel/requirements/requirements.in # via # -r /awx_devel/requirements/requirements_git.txt # django-radius diff --git a/tools/docker-compose/ansible/roles/sources/tasks/main.yml b/tools/docker-compose/ansible/roles/sources/tasks/main.yml index e566a97073..b6dd95aedb 100644 --- a/tools/docker-compose/ansible/roles/sources/tasks/main.yml +++ b/tools/docker-compose/ansible/roles/sources/tasks/main.yml @@ -109,6 +109,20 @@ mode: '0600' with_sequence: start=1 end={{ control_plane_node_count }} +- name: Create Receptor Config Lock File + file: + path: "{{ sources_dest }}/receptor/receptor-awx-{{ item }}.conf.lock" + state: touch + mode: '0600' + with_sequence: start=1 end={{ control_plane_node_count }} + +- name: Render Receptor Config(s) for Control Plane + template: + src: "receptor-awx.conf.j2" + dest: "{{ sources_dest }}/receptor/receptor-awx-{{ item }}.conf" + mode: '0600' + with_sequence: start=1 end={{ control_plane_node_count }} + - name: Render Receptor Hop Config template: src: "receptor-hop.conf.j2" diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 index 2f7fc3cf41..db4988b207 100644 --- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 +++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 @@ -42,6 +42,7 @@ services: - "../../docker-compose/_sources/local_settings.py:/etc/tower/conf.d/local_settings.py" - "../../docker-compose/_sources/SECRET_KEY:/etc/tower/SECRET_KEY" - "../../docker-compose/_sources/receptor/receptor-awx-{{ loop.index }}.conf:/etc/receptor/receptor.conf" + - "../../docker-compose/_sources/receptor/receptor-awx-{{ loop.index }}.conf.lock:/etc/receptor/receptor.conf.lock" - "../../docker-compose/_sources/receptor/work_public_key.pem:/etc/receptor/work_public_key.pem" - "../../docker-compose/_sources/receptor/work_private_key.pem:/etc/receptor/work_private_key.pem" # - "../../docker-compose/_sources/certs:/etc/receptor/certs" # TODO: optionally generate certs