From d54c5934fff06336a2ebdb61d38532fe43f5460f Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Tue, 3 Oct 2023 14:32:39 -0400 Subject: [PATCH] Add support for inbound hop nodes --- awx/api/serializers.py | 14 +++++++ awx/api/urls/instance.py | 2 + awx/api/views/__init__.py | 13 +++++++ .../commands/add_receptor_address.py | 38 +++++++++++++++++++ awx/main/migrations/0188_inbound_ingress.py | 25 ++++++++++++ awx/main/models/__init__.py | 1 + awx/main/models/ha.py | 1 + awx/main/models/receptor_address.py | 29 ++++++++++++++ 8 files changed, 123 insertions(+) create mode 100644 awx/main/management/commands/add_receptor_address.py create mode 100644 awx/main/migrations/0188_inbound_ingress.py create mode 100644 awx/main/models/receptor_address.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 806e46cc85..9f42facf99 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -82,6 +82,7 @@ from awx.main.models import ( Project, ProjectUpdate, ProjectUpdateEvent, + ReceptorAddress, RefreshToken, Role, Schedule, @@ -5476,6 +5477,18 @@ class InstanceNodeSerializer(BaseSerializer): fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled') +class ReceptorAddressSerializer(BaseSerializer): + full_address = serializers.SerializerMethodField() + + class Meta: + model = ReceptorAddress + fields = ('id', 'address', 'port', 'protocol', 'websocket_path', 'is_internal', 'instance', 'full_address') + read_only = 'full_address' + + def get_full_address(self, obj): + return obj.get_full_address() + + class InstanceSerializer(BaseSerializer): show_capabilities = ['edit'] @@ -5544,6 +5557,7 @@ class InstanceSerializer(BaseSerializer): def get_related(self, obj): res = super(InstanceSerializer, self).get_related(obj) + res['receptor_addresses'] = self.reverse('api:receptor_addresses_list', kwargs={'pk': obj.pk}) 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 obj.node_type in [Instance.Types.EXECUTION, Instance.Types.HOP]: diff --git a/awx/api/urls/instance.py b/awx/api/urls/instance.py index 0d4df1df45..fbd29ac38e 100644 --- a/awx/api/urls/instance.py +++ b/awx/api/urls/instance.py @@ -10,6 +10,7 @@ from awx.api.views import ( InstanceInstanceGroupsList, InstanceHealthCheck, InstancePeersList, + ReceptorAddressesList, ) from awx.api.views.instance_install_bundle import InstanceInstallBundle @@ -21,6 +22,7 @@ urls = [ 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]+)/receptor_addresses/$', ReceptorAddressesList.as_view(), name='receptor_addresses_list'), re_path(r'^(?P[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'), ] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 80fc152bf4..714d8f9977 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -384,6 +384,19 @@ class InstancePeersList(SubListAPIView): relationship = 'peers' +class ReceptorAddressesList(ListCreateAPIView): + name = _("Receptor Addresses") + model = models.ReceptorAddress + serializer_class = serializers.ReceptorAddressSerializer + + def get_queryset(self): + return models.ReceptorAddress.objects.filter(instance=self.kwargs['pk']) + + def post(self, request, *args, **kwargs): + request.data.update({'instance': self.kwargs['pk']}) + return super().post(request, *args, **kwargs) + + class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): name = _("Instance's Instance Groups") model = models.InstanceGroup diff --git a/awx/main/management/commands/add_receptor_address.py b/awx/main/management/commands/add_receptor_address.py new file mode 100644 index 0000000000..2428b3dfdb --- /dev/null +++ b/awx/main/management/commands/add_receptor_address.py @@ -0,0 +1,38 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved + +import os + +from django.core.management.base import BaseCommand +from django.db import transaction + +from awx.main.models import Instance, ReceptorAddress + + +class Command(BaseCommand): + """ + Internal tower command. + Register receptor address to an already-registered instance. + """ + + help = "Add receptor address to an instance." + + def add_arguments(self, parser): + parser.add_argument('--hostname', dest='hostname', type=str, help="Hostname this address is added to") + parser.add_argument('--address', dest='address', type=str, help="Receptor address") + parser.add_argument('--port', dest='port', type=int, help="Receptor listener port") + parser.add_argument('--protocol', dest='protocol', type=str, default='tcp', choices=['tcp', 'ws'], help="Protocol of the backend connection") + parser.add_argument('--websocket_path', dest='websocket_path', type=str, default="", help="Path for websockets") + parser.add_argument( + '--is_internal', dest='is_internal', type=bool, default=False, help="If true, address only resolvable within the Kubernetes cluster" + ) + + def _add_address(self, **kwargs): + i = Instance.objects.get(hostname=kwargs.pop('hostname')) + kwargs['instance'] = i + ReceptorAddress.objects.create(**kwargs) + + @transaction.atomic + def handle(self, **options): + address_options = {k: options[k] for k in ('hostname', 'address', 'port', 'protocol', 'websocket_path', 'is_internal')} + self._add_address(**address_options) diff --git a/awx/main/migrations/0188_inbound_ingress.py b/awx/main/migrations/0188_inbound_ingress.py new file mode 100644 index 0000000000..3158aefd29 --- /dev/null +++ b/awx/main/migrations/0188_inbound_ingress.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.5 on 2023-10-03 18:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0187_hop_nodes'), + ] + + operations = [ + migrations.CreateModel( + name='ReceptorAddress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.CharField(max_length=255)), + ('port', models.IntegerField(null=True)), + ('protocol', models.CharField(max_length=10)), + ('websocket_path', models.CharField(blank=True, default='', max_length=255)), + ('is_internal', models.BooleanField(default=False)), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='receptor_addresses', to='main.instance')), + ], + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 2363543a9c..3148f44fb5 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -14,6 +14,7 @@ from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutM from awx.main.models.organization import Organization, Profile, Team, UserSessionMembership # noqa from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa from awx.main.models.projects import Project, ProjectUpdate # noqa +from awx.main.models.receptor_address import ReceptorAddress # noqa from awx.main.models.inventory import ( # noqa CustomInventoryScript, Group, diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 4792d4e4d4..5ec92e26f2 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -110,6 +110,7 @@ class Instance(HasPolicyEditsMixin, BaseModel): default="", max_length=50, ) + # Auto-fields, implementation is different from BaseModel created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) diff --git a/awx/main/models/receptor_address.py b/awx/main/models/receptor_address.py new file mode 100644 index 0000000000..2fac6af094 --- /dev/null +++ b/awx/main/models/receptor_address.py @@ -0,0 +1,29 @@ +from django.db import models + + +class ReceptorAddress(models.Model): + address = models.CharField(max_length=255) + port = models.IntegerField(null=True) + protocol = models.CharField(max_length=10) + websocket_path = models.CharField(max_length=255, default="", blank=True) + is_internal = models.BooleanField(default=False) + instance = models.ForeignKey( + 'Instance', + related_name='receptor_addresses', + on_delete=models.CASCADE, + ) + + def get_full_address(self): + scheme = "" + path = "" + port = "" + if self.protocol == "ws": + scheme = "wss://" + + if self.protocol == "ws" and self.websocket_path: + path = f"/{self.websocket_path}" + + if self.port: + port = f":{self.port}" + + return f"{scheme}{self.address}{port}{path}"