Merge pull request #12744 from ansible/feature-mesh-scaling

[feature] Ability to add execution nodes at runtime
This commit is contained in:
Hao Liu 2022-09-26 10:59:46 -04:00 committed by GitHub
commit 9c2185c68f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3381 additions and 680 deletions

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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 %}

View File

@ -0,0 +1,28 @@
---
all:
hosts:
remote-execution:
ansible_host: {{ instance.hostname }}
ansible_user: <username> # 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

View File

@ -0,0 +1,6 @@
---
collections:
- name: ansible.receptor
source: https://github.com/ansible/receptor-collection/
type: git
version: 0.1.1

View File

@ -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<pk>[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'),
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'),
re_path(r'^(?P<pk>[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'),
re_path(r'^(?P<pk>[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'),
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
]
__all__ = ['urls']

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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),
]

View File

@ -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()

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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))

View File

@ -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}."

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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 (
<Alert
variant="default"
actionClose={
<AlertActionCloseButton onClose={() => onSetHealthCheckAlert(false)} />
}
title={
<>
{t`Health check request(s) submitted. Please wait and reload the page.`}{' '}
<Button
variant="link"
isInline
onClick={() => window.location.reload()}
>{t`Reload`}</Button>
</>
}
/>
);
}
export default HealthCheckAlert;

View File

@ -0,0 +1 @@
export { default } from './HealthCheckAlert';

View File

@ -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 }) {

View File

@ -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;

View File

@ -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,
};

View File

@ -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 (
<>
<RoutedTabs tabsArray={tabsArray} />
{showHealthCheckAlert ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<CardBody>
<DetailList gutter="sm">
<Detail
@ -200,7 +205,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
<Detail
label={t`Status`}
value={
<StatusLabel status={healthCheck?.errors ? 'error' : 'healthy'} />
instance.node_state ? (
<StatusLabel status={instance.node_state} />
) : null
}
/>
<Detail

View File

@ -88,6 +88,7 @@ describe('<InstanceDetails/>', () => {
enabled: true,
managed_by_policy: true,
node_type: 'hybrid',
node_state: 'ready',
},
});
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
@ -297,58 +298,6 @@ describe('<InstanceDetails/>', () => {
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(
<InstanceDetails
instanceGroup={instanceGroup}
setBreadcrumb={() => {}}
/>
);
});
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({

View File

@ -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 ? (
<AddDropDownButton
ouiaId="add-instance-group-button"
key="add"
dropdownItems={[
<DropdownItem
ouiaId="add-container-group-item"
to="/instance_groups/container_group/add"
component={Link}
key={addContainerGroup}
aria-label={addContainerGroup}
>
{addContainerGroup}
</DropdownItem>,
<DropdownItem
ouiaId="add-instance-group-item"
to="/instance_groups/add"
component={Link}
key={addInstanceGroup}
aria-label={addInstanceGroup}
>
{addInstanceGroup}
</DropdownItem>,
]}
/>
) : (
<ToolbarAddButton
key="add"
ouiaId="add-container-group-button"
linkTo={`${match.url}/container_group/add`}
/>
);
const addButton = (
<AddDropDownButton
ouiaId="add-instance-group-button"
key="add"
dropdownItems={[
<DropdownItem
ouiaId="add-container-group-item"
to="/instance_groups/container_group/add"
component={Link}
key={addContainerGroup}
aria-label={addContainerGroup}
>
{addContainerGroup}
</DropdownItem>,
<DropdownItem
ouiaId="add-instance-group-item"
to="/instance_groups/add"
component={Link}
key={addInstanceGroup}
aria-label={addInstanceGroup}
>
{addInstanceGroup}
</DropdownItem>,
]}
/>
);
const getDetailUrl = (item) =>
item.is_container_group
@ -159,10 +147,8 @@ function InstanceGroupList({
<PageSection>
<Card>
<PaginatedTable
contentError={contentError || settingsRequestError}
hasContentLoading={
isLoading || deleteLoading || isSettingsRequestLoading
}
contentError={contentError}
hasContentLoading={isLoading || deleteLoading}
items={instanceGroups}
itemCount={instanceGroupsCount}
pluralizedItemName={pluralizedItemName}

View File

@ -1,14 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { t } from '@lingui/macro';
import { Route, Switch, useLocation } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
import { useUserProfile } from 'contexts/Config';
import useRequest from 'hooks/useRequest';
import { SettingsAPI } from 'api';
import ScreenHeader from 'components/ScreenHeader';
import ContentLoading from 'components/ContentLoading';
import PersistentFilters from 'components/PersistentFilters';
import InstanceGroupAdd from './InstanceGroupAdd';
import InstanceGroupList from './InstanceGroupList';
@ -18,29 +12,6 @@ import ContainerGroup from './ContainerGroup';
function InstanceGroups() {
const { pathname } = useLocation();
const { isSuperUser, isSystemAuditor } = useUserProfile();
const userCanReadSettings = isSuperUser || isSystemAuditor;
const {
request: settingsRequest,
isLoading: isSettingsRequestLoading,
error: settingsRequestError,
result: { isKubernetes },
} = useRequest(
useCallback(async () => {
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 ? (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
) : (
<Switch>
<Route path="/instance_groups/container_group/add">
<ContainerGroupAdd />
</Route>
<Route path="/instance_groups/container_group/:id">
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
{!isKubernetes && (
<Route path="/instance_groups/add">
<InstanceGroupAdd />
</Route>
)}
<Route path="/instance_groups/:id">
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instance_groups">
<PersistentFilters pageKey="instanceGroups">
<InstanceGroupList
isKubernetes={isKubernetes}
isSettingsRequestLoading={isSettingsRequestLoading}
settingsRequestError={settingsRequestError}
/>
</PersistentFilters>
</Route>
</Switch>
)}
<Switch>
<Route path="/instance_groups/container_group/add">
<ContainerGroupAdd />
</Route>
<Route path="/instance_groups/container_group/:id">
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instance_groups/add">
<InstanceGroupAdd />
</Route>
<Route path="/instance_groups/:id">
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instance_groups">
<PersistentFilters pageKey="instanceGroups">
<InstanceGroupList />
</PersistentFilters>
</Route>
</Switch>
</>
);
}

View File

@ -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 ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<PaginatedTable
contentError={contentError}
hasContentLoading={

View File

@ -42,6 +42,7 @@ const instances = [
jobs_total: 68,
cpu: 6,
node_type: 'control',
node_state: 'ready',
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
@ -69,6 +70,7 @@ const instances = [
jobs_total: 68,
cpu: 6,
node_type: 'hybrid',
node_state: 'ready',
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
@ -96,6 +98,7 @@ const instances = [
jobs_total: 68,
cpu: 6,
node_type: 'execution',
node_state: 'ready',
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,

View File

@ -136,7 +136,7 @@ function InstanceListItem({
</div>
}
>
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
<StatusLabel status={instance.node_state} />
</Tooltip>
</Td>
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>

View File

@ -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',
},
];

View File

@ -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 <ContentLoading />;
}
if (error) {
return <ContentError />;
}
return (
<PageSection>
<Card>
@ -32,8 +64,13 @@ function Instance({ setBreadcrumb }) {
<Switch>
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
<Route path="/instances/:id/details" key="details">
<InstanceDetail setBreadcrumb={setBreadcrumb} />
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
</Route>
{isK8s && (
<Route path="/instances/:id/peers" key="peers">
<InstancePeerList setBreadcrumb={setBreadcrumb} />
</Route>
)}
<Route path="*" key="not-found">
<ContentError isNotFound>
{match.params.id && (

View File

@ -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 (
<PageSection>
<Card>
<InstanceForm
submitError={formError}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
</Card>
</PageSection>
);
}
export default InstanceAdd;

View File

@ -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('<InstanceAdd />', () => {
let wrapper;
let history;
beforeEach(async () => {
history = createMemoryHistory({ initialEntries: ['/instances'] });
InstancesAPI.create.mockResolvedValue({ data: { id: 13 } });
await act(async () => {
wrapper = mountWithContexts(<InstanceAdd />, {
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');
});
});

View File

@ -0,0 +1 @@
export { default } from './InstanceAdd';

View File

@ -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 <ContentError error={contentError} />;
}
if (isLoading) {
if (isLoading || isRemoveLoading) {
return <ContentLoading />;
}
const isHopNode = instance.node_type === 'hop';
return (
<CardBody>
<DetailList gutter="sm">
<Detail
label={t`Host Name`}
value={instance.hostname}
dataCy="instance-detail-name"
/>
<Detail
label={t`Status`}
value={
<StatusLabel status={healthCheck?.errors ? 'error' : 'healthy'} />
}
/>
<Detail label={t`Node Type`} value={instance.node_type} />
{!isHopNode && (
<>
<Detail
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
<Detail
label={t`Last Health Check`}
value={formatDateString(healthCheck?.last_health_check)}
/>
<Detail
label={t`Capacity Adjustment`}
value={
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural value={forks} one="# fork" other="# forks" />
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instance.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
</SliderHolder>
}
/>
<Detail
label={t`Used Capacity`}
value={
instance.enabled ? (
<Progress
title={t`Used capacity`}
value={Math.round(
100 - instance.percent_capacity_remaining
)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
aria-label={t`Used capacity`}
/>
) : (
<Unavailable>{t`Unavailable`}</Unavailable>
)
}
/>
</>
)}
{healthCheck?.errors && (
<>
{showHealthCheckAlert ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<CardBody>
<DetailList gutter="sm">
<Detail
fullWidth
label={t`Errors`}
label={t`Host Name`}
value={instance.hostname}
dataCy="instance-detail-name"
/>
<Detail
label={t`Status`}
value={
<CodeBlock>
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
</CodeBlock>
instance.node_state ? (
<StatusLabel status={instance.node_state} />
) : null
}
/>
<Detail label={t`Node Type`} value={instance.node_type} />
{!isHopNode && (
<>
<Detail
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail label={t`Host`} value={instance.ip_address} />
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
helpText={t`The Instance Groups to which this instance belongs.`}
value={instanceGroups.map((ig) => (
<React.Fragment key={ig.id}>
<Label
color="blue"
isTruncated
render={({ className, content, componentRef }) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
className={className}
innerRef={componentRef}
>
{content}
</Link>
)}
>
{ig.name}
</Label>{' '}
</React.Fragment>
))}
isEmpty={instanceGroups.length === 0}
/>
)}
<Detail
label={t`Last Health Check`}
value={formatDateString(healthCheck?.last_health_check)}
/>
{instance.related?.install_bundle && (
<Detail
label={t`Install Bundle`}
value={
<Tooltip content={t`Click to download bundle`}>
<Button
component="a"
isSmall
href={`${instance.related?.install_bundle}`}
target="_blank"
variant="secondary"
>
<DownloadIcon />
</Button>
</Tooltip>
}
/>
)}
<Detail
label={t`Capacity Adjustment`}
value={
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural value={forks} one="# fork" other="# forks" />
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instance.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
</SliderHolder>
}
/>
<Detail
label={t`Used Capacity`}
value={
instance.enabled ? (
<Progress
title={t`Used capacity`}
value={Math.round(
100 - instance.percent_capacity_remaining
)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
aria-label={t`Used capacity`}
/>
) : (
<Unavailable>{t`Unavailable`}</Unavailable>
)
}
/>
</>
)}
{healthCheck?.errors && (
<Detail
fullWidth
label={t`Errors`}
value={
<CodeBlock>
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
</CodeBlock>
}
/>
)}
</DetailList>
{!isHopNode && (
<CardActionsRow>
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
<RemoveInstanceButton
itemsToRemove={[instance]}
isK8s={isK8s}
onRemove={removeInstances}
/>
)}
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
/>
</CardActionsRow>
)}
</DetailList>
{!isHopNode && (
<CardActionsRow>
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
/>
</CardActionsRow>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={t`Error!`}
variant="error"
>
{updateInstanceError
? t`Failed to update capacity adjustment.`
: t`Failed to disassociate one or more instances.`}
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={t`Error!`}
variant="error"
>
{updateInstanceError
? t`Failed to update capacity adjustment.`
: t`Failed to disassociate one or more instances.`}
<ErrorDetail error={error} />
</AlertModal>
)}
{removeError && (
<AlertModal
isOpen={removeError}
variant="error"
aria-label={t`Removal Error`}
title={t`Error!`}
onClose={clearDeletionError}
>
{t`Failed to remove one or more instances.`}
<ErrorDetail error={removeError} />
</AlertModal>
)}
</CardBody>
</>
);
}

View File

@ -25,6 +25,7 @@ describe('<InstanceDetail/>', () => {
InstancesAPI.readDetail.mockResolvedValue({
data: {
related: {},
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
@ -49,6 +50,17 @@ describe('<InstanceDetail/>', () => {
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('<InstanceDetail/>', () => {
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(<InstanceDetail setBreadcrumb={() => {}} />);
});
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({

View File

@ -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 ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<PageSection>
<Card>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isHealthCheckLoading}
contentError={contentError || removeError}
hasContentLoading={
isLoading || isHealthCheckLoading || isRemoveLoading
}
items={instances}
itemCount={count}
pluralizedItemName={t`Instances`}
@ -135,8 +170,24 @@ function InstanceList() {
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(isK8s && me.is_superuser
? [
<ToolbarAddButton
ouiaId="instances-add-button"
key="add"
linkTo="/instances/add"
/>,
<RemoveInstanceButton
itemsToRemove={selected}
isK8s={isK8s}
key="remove"
onRemove={handleRemoveInstances}
/>,
]
: []),
<HealthCheckButton
onClick={handleHealthCheck}
key="healthCheck"
selectedItems={selected}
/>,
]}
@ -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() {
<ErrorDetail error={error} />
</AlertModal>
)}
{removeError && (
<AlertModal
isOpen={removeError}
variant="error"
aria-label={t`Removal Error`}
title={t`Error!`}
onClose={clearDeletionError}
>
{t`Failed to remove one or more instances.`}
<ErrorDetail error={removeError} />
</AlertModal>
)}
</>
);
}

View File

@ -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('<InstanceList/>', () => {
},
});
InstancesAPI.readOptions.mockResolvedValue(options);
SettingsAPI.readCategory.mockResolvedValue({ data: { IS_K8S: false } });
const history = createMemoryHistory({
initialEntries: ['/instances/1'],
});
@ -190,4 +195,52 @@ describe('<InstanceList/>', () => {
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(
<Route path="/instances/:id">
<InstanceList />
</Route>,
{
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
);
});
});

View File

@ -143,7 +143,7 @@ function InstanceListItem({
</div>
}
>
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
<StatusLabel status={instance.node_state} />
</Tooltip>
</Td>

View File

@ -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',
},
];

View File

@ -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 (
<CardBody>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading}
items={peers}
itemCount={count}
pluralizedItemName={t`Peers`}
qsConfig={QS_CONFIG}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: t`Name`,
key: 'hostname__icontains',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: t`Name`,
key: 'hostname',
},
]}
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell
tooltip={t`Cannot run health check on hop nodes.`}
sortKey="hostname"
>{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
</HeaderRow>
}
renderToolbar={(props) => (
<DataListToolbar
{...props}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
/>
)}
renderRow={(peer, index) => (
<InstancePeerListItem
isExpanded={expanded.some((row) => row.id === peer.id)}
onExpand={() => handleExpand(peer)}
key={peer.id}
peerInstance={peer}
rowIndex={index}
/>
)}
/>
</CardBody>
);
}
export default InstancePeerList;

View File

@ -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 (
<>
<Tr
id={`peerInstance-row-${peerInstance.id}`}
ouiaId={`peerInstance-row-${peerInstance.id}`}
>
{isHopNode ? (
<Td />
) : (
<Td
expand={{
rowIndex,
isExpanded,
onToggle: onExpand,
}}
/>
)}
<Td />
<Td id={labelId} dataLabel={t`Name`}>
<Link to={`/instances/${peerInstance.id}/details`}>
<b>{peerInstance.hostname}</b>
</Link>
</Td>
<Td dataLabel={t`Status`}>
<Tooltip
content={
<div>
{t`Last Health Check`}
&nbsp;
{formatDateString(
peerInstance.last_health_check ?? peerInstance.last_seen
)}
</div>
}
>
<StatusLabel status={peerInstance.node_state} />
</Tooltip>
</Td>
<Td dataLabel={t`Node Type`}>{peerInstance.node_type}</Td>
</Tr>
{!isHopNode && (
<Tr
ouiaId={`peerInstance-row-${peerInstance.id}-expanded`}
isExpanded={isExpanded}
>
<Td colSpan={2} />
<Td colSpan={7}>
<ExpandableRowContent>
<DetailList>
<Detail
data-cy="running-jobs"
value={peerInstance.jobs_running}
label={t`Running Jobs`}
/>
<Detail
data-cy="total-jobs"
value={peerInstance.jobs_total}
label={t`Total Jobs`}
/>
<Detail
data-cy="policy-type"
label={t`Policy Type`}
value={peerInstance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail
data-cy="last-health-check"
label={t`Last Health Check`}
value={formatDateString(peerInstance.last_health_check)}
/>
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
)}
</>
);
}
export default InstancePeerListItem;

View File

@ -0,0 +1 @@
export { default } from './InstancePeerList';

View File

@ -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() {
<>
<ScreenHeader streamType="instance" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/instances/add">
<InstanceAdd />
</Route>
<Route path="/instances/:id">
<Instance setBreadcrumb={buildBreadcrumbConfig} />
</Route>

View File

@ -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 (
<>
<FormField
id="name"
label={t`Name`}
name="hostname"
type="text"
validate={required(null)}
isRequired
/>
<FormField
id="instance-description"
label={t`Description`}
name="description"
type="text"
/>
<FormField
id="instance-state"
label={t`Instance State`}
name="node_state"
type="text"
isDisabled
/>
<FormField
id="instance-port"
label={t`Listener Port`}
name="listener_port"
type="number"
isRequired
/>
<FormField
id="instance-type"
label={t`Instance Type`}
name="node_type"
type="text"
isDisabled
/>
<FormGroup
label={t`Enable Instance`}
aria-label={t`Enable Instance`}
labelIcon={
<Popover
content={t`If enabled, the instance will be ready to accept work.`}
/>
}
>
<Switch
css="display: inline-flex;"
id="enabled"
label={t`Enabled`}
labelOff={t`Disabled`}
isChecked={enabled.value}
onChange={() => {
enabledHelpers.setValue(!enabled.value);
}}
ouiaId="enable-instance-switch"
/>
</FormGroup>
</>
);
}
function InstanceForm({
instance = {},
submitError,
handleCancel,
handleSubmit,
}) {
return (
<CardBody>
<Formik
initialValues={{
hostname: '',
description: '',
node_type: 'execution',
node_state: 'installed',
listener_port: 27199,
enabled: true,
}}
onSubmit={(values) => {
handleSubmit(values);
}}
>
{(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InstanceFormFields instance={instance} />
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
</CardBody>
);
}
export default InstanceForm;

View File

@ -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('<InstanceForm />', () => {
let wrapper;
let handleCancel;
let handleSubmit;
beforeAll(async () => {
handleCancel = jest.fn();
handleSubmit = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<InstanceForm
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={null}
/>
);
});
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',
});
});
});

View File

@ -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 = () => (
<div>
<Plural
value={itemsToRemove.length}
one="This intance is currently being used by other resources. Are you sure you want to delete it?"
other="Deprovisioning these instances could impact other resources that rely on them. Are you sure you want to delete anyway?"
/>
{removeDetails &&
Object.entries(removeDetails).map(([key, value]) => (
<div key={key} aria-label={`${key}: ${value}`}>
<Label>{key}</Label>
<Badge>{value}</Badge>
</div>
))}
</div>
);
if (removeMessageError) {
return (
<AlertModal
isOpen={removeMessageError}
title={t`Error!`}
onClose={() => {
toggleModal(false);
setRemoveMessageError();
}}
>
<ErrorDetail error={removeMessageError} />
</AlertModal>
);
}
return (
<>
{isKebabified ? (
<Tooltip content={renderTooltip()} position="top">
<DropdownItem
key="add"
isDisabled={isDisabled || !isK8s}
isLoading={isLoading}
ouiaId="remove-button"
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
component="button"
onClick={() => {
toggleModal(true);
}}
>
{t`Remove`}
</DropdownItem>
</Tooltip>
) : (
<Tooltip content={renderTooltip()} position="top">
<div>
<Button
variant="secondary"
isLoading={isLoading}
ouiaId="remove-button"
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
onClick={() => toggleModal(true)}
isDisabled={isDisabled || !isK8s}
>
{t`Remove`}
</Button>
</div>
</Tooltip>
)}
{isModalOpen && (
<AlertModal
variant="danger"
title={t`Remove Instances`}
isOpen={isModalOpen}
onClose={() => toggleModal(false)}
actions={[
<Button
ouiaId="remove-modal-confirm"
key="remove"
variant="danger"
aria-label={t`Confirm remove`}
onClick={handleRemove}
>
{t`Remove`}
</Button>,
<Button
ouiaId="remove-cancel"
key="cancel"
variant="link"
aria-label={t`cancel remove`}
onClick={() => {
toggleModal(false);
}}
>
{t`Cancel`}
</Button>,
]}
>
<div>{t`This action will remove the following instances:`}</div>
{itemsToRemove.map((item) => (
<span key={item.id} id={`item-to-be-removed-${item.id}`}>
<strong>{item.hostname}</strong>
<br />
</span>
))}
{removeDetails && (
<WarningMessage
variant="warning"
isInline
title={buildRemoveWarning()}
/>
)}
</AlertModal>
)}
</>
);
}
export default RemoveInstanceButton;

View File

@ -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('<RemoveInstanceButtton />', () => {
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(
<I18nProvider i18n={i18n}>
<RemoveInstanceButton
isK8s={true}
itemsToRemove={[instances[0]]}
onRemove={onRemove}
/>
</I18nProvider>
);
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(
<RemoveInstanceButton
isK8s={true}
itemsToRemove={[instances[1]]}
onRemove={jest.fn()}
/>
);
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(
<RemoveInstanceButton
isK8s={true}
itemsToRemove={[instances[0]]}
onRemove={onRemove}
/>
);
const button = screen.getByRole('button');
await user.click(button);
await waitFor(() => screen.getByRole('dialog'));
screen.getByText('Error!');
});
});

View File

@ -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() {
<DescriptionList isHorizontal isFluid>
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="primary" isSmall>
{t`C`}
</Button>
<Button isSmall>C</Button>
</DescriptionListTerm>
<DescriptionListDescription>{t`Control node`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="primary" isSmall>
{t`Ex`}
Ex
</Button>
</DescriptionListTerm>
<DescriptionListDescription>
@ -89,7 +88,7 @@ function Legend() {
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="primary" isSmall>
{t`Hy`}
Hy
</Button>
</DescriptionListTerm>
<DescriptionListDescription>{t`Hybrid node`}</DescriptionListDescription>
@ -97,42 +96,194 @@ function Legend() {
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="primary" isSmall>
{t`h`}
h
</Button>
</DescriptionListTerm>
<DescriptionListDescription>{t`Hop node`}</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
<TextContent>
<Text component={TextVariants.small}>{t`Status types`}</Text>
<Text component={TextVariants.small}>{t`Node state types`}</Text>
</TextContent>
<DescriptionList isHorizontal isFluid>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={<CheckIcon />}
icon={
<CheckIcon
style={{ fill: 'white', marginLeft: '2px', marginTop: '3px' }}
/>
}
isSmall
style={{ border: '1px solid gray', backgroundColor: '#3E8635' }}
style={{ backgroundColor: '#3E8635' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Healthy`}</DescriptionListDescription>
<DescriptionListDescription>{t`Ready`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="danger" icon={<ExclamationIcon />} isSmall />
<Button
icon={
<OutlinedClockIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#0066CC' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Installed`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<PlusIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#6A6E73' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Provisioning`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<MinusIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#6A6E73' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Deprovisioning`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<ExclamationIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#C9190B' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Error`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
isSmall
style={{ border: '1px solid gray', backgroundColor: '#e6e6e6' }}
/>
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<circle
r="9"
cx="10"
cy="10"
fill="transparent"
strokeWidth="1px"
stroke="#ccc"
/>
<text
x="10"
y="10"
textAnchor="middle"
dominantBaseline="central"
fill="black"
fontSize="11px"
fontFamily="inherit"
fontWeight="400"
>
C
</text>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Enabled`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg">
<circle
r="9"
cx="10"
cy="10"
fill="transparent"
strokeDasharray="5"
strokeWidth="1px"
stroke="#ccc"
/>
<text
x="10"
y="10"
textAnchor="middle"
dominantBaseline="central"
fill="black"
fontSize="11px"
fontFamily="inherit"
fontWeight="400"
>
C
</text>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Disabled`}</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
<TextContent>
<Text component={TextVariants.small}>{t`Link state types`}</Text>
</TextContent>
<DescriptionList isHorizontal isFluid>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
<line
x1="0"
y1="9"
x2="20"
y2="9"
stroke="#666"
strokeWidth="4"
/>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Established`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
<line
x1="0"
y1="9"
x2="20"
y2="9"
stroke="#666"
strokeWidth="4"
strokeDasharray="6"
/>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Adding`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
<line
x1="0"
y1="9"
x2="20"
y2="9"
stroke="#C9190B"
strokeWidth="4"
strokeDasharray="6"
/>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Removing`}</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</Wrapper>
);
}

View File

@ -1,8 +1,13 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import debounce from 'util/debounce';
import * as d3 from 'd3';
import { InstancesAPI } from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import Legend from './Legend';
import Tooltip from './Tooltip';
import ContentLoading from './ContentLoading';
@ -11,6 +16,9 @@ import {
renderLabelText,
renderNodeType,
renderNodeIcon,
renderLinkState,
renderLabelIcons,
renderIconPosition,
redirectToDetailsPage,
getHeight,
getWidth,
@ -20,7 +28,8 @@ import {
DEFAULT_RADIUS,
DEFAULT_NODE_COLOR,
DEFAULT_NODE_HIGHLIGHT_COLOR,
DEFAULT_NODE_LABEL_TEXT_COLOR,
DEFAULT_NODE_SYMBOL_TEXT_COLOR,
DEFAULT_NODE_STROKE_COLOR,
DEFAULT_FONT_SIZE,
SELECTOR,
} from './constants';
@ -32,11 +41,77 @@ const Loader = styled(ContentLoading)`
background: white;
`;
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const [storedNodes, setStoredNodes] = useState(null);
const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null);
const [nodeDetail, setNodeDetail] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null);
const history = useHistory();
const {
result: { instance = {}, instanceGroups },
error: fetchError,
isLoading,
request: fetchDetails,
} = useRequest(
useCallback(async () => {
const { data: instanceData } = await InstancesAPI.readDetail(
selectedNode.id
);
const { data: instanceGroupsData } = await InstancesAPI.readInstanceGroup(
selectedNode.id
);
return {
instance: instanceData,
instanceGroups: instanceGroupsData,
};
}, [selectedNode]),
{
result: {},
}
);
const { error: fetchInstanceError, dismissError } =
useDismissableError(fetchError);
useEffect(() => {
if (selectedNode) {
fetchDetails();
}
}, [selectedNode, fetchDetails]);
function updateNodeSVG(nodes) {
if (nodes) {
d3.selectAll('[class*="id-"]')
.data(nodes)
.attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`));
}
}
useEffect(() => {
function handleResize() {
d3.select('.simulation-loader').style('visibility', 'visible');
setSelectedNode(null);
setIsNodeSelected(false);
draw();
}
window.addEventListener('resize', debounce(handleResize, 500));
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// update mesh when user toggles enabled/disabled slider
useEffect(() => {
if (instance?.id) {
const updatedNodes = storedNodes.map((n) =>
n.id === instance.id ? { ...n, enabled: instance.enabled } : n
);
setStoredNodes(updatedNodes);
}
}, [instance]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (storedNodes) {
updateNodeSVG(storedNodes);
}
}, [storedNodes]);
const draw = () => {
let width;
@ -87,6 +162,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
}
function ended({ nodes, links }) {
setStoredNodes(nodes);
// Remove loading screen
d3.select('.simulation-loader').style('visibility', 'hidden');
setShowZoomControls(true);
@ -95,6 +171,24 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.forceSimulation(nodes)
.force('center', d3.forceCenter(width / 2, height / 2));
simulation.tick();
// build the arrow.
mesh
.append('defs')
.selectAll('marker')
.data(['end', 'end-active'])
.join('marker')
.attr('id', String)
.attr('viewBox', '0 -5 10 10')
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5');
mesh.select('#end').attr('refX', 23).attr('fill', '#ccc');
mesh.select('#end-active').attr('refX', 18).attr('fill', '#0066CC');
// Add links
mesh
.append('g')
@ -108,11 +202,15 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y)
.attr('marker-end', 'url(#end)')
.attr('class', (_, i) => `link-${i}`)
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
.style('fill', 'none')
.style('stroke', '#ccc')
.style('stroke', (d) =>
d.link_state === 'removing' ? '#C9190B' : '#CCC'
)
.style('stroke-width', '2px')
.style('stroke-dasharray', (d) => renderLinkState(d.link_state))
.attr('pointer-events', 'none')
.on('mouseover', function showPointer() {
d3.select(this).transition().style('cursor', 'pointer');
@ -134,7 +232,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
deselectSiblings(d);
})
.on('click', (_, d) => {
setNodeDetail(d);
highlightSelected(d);
});
@ -147,7 +244,8 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.attr('class', (d) => d.node_type)
.attr('class', (d) => `id-${d.id}`)
.attr('fill', DEFAULT_NODE_COLOR)
.attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR);
.attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`))
.attr('stroke', DEFAULT_NODE_STROKE_COLOR);
// node type labels
node
@ -157,41 +255,65 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.attr('y', (d) => d.y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
.attr('fill', DEFAULT_NODE_SYMBOL_TEXT_COLOR);
// node hostname labels
const hostNames = node.append('g');
hostNames
const placeholder = node.append('g').attr('class', 'placeholder');
placeholder
.append('text')
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('x', (d) => d.x)
.attr('y', (d) => d.y + 40)
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('class', 'placeholder')
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('fill', 'black')
.attr('font-size', '18px')
.attr('text-anchor', 'middle')
.each(function calculateLabelWidth() {
// eslint-disable-next-line react/no-this-in-sfc
const bbox = this.getBBox();
// eslint-disable-next-line react/no-this-in-sfc
d3.select(this.parentNode)
.append('rect')
.attr('x', bbox.x)
.attr('y', bbox.y)
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('rx', 8)
.attr('ry', 8)
.style('fill', (d) => renderStateColor(d.node_state));
.append('path')
.attr('d', (d) => renderLabelIcons(d.node_state))
.attr('transform', (d) => renderIconPosition(d.node_state, bbox))
.style('fill', 'black');
});
svg.selectAll('text.placeholder').remove();
placeholder.each(function calculateLabelWidth() {
// eslint-disable-next-line react/no-this-in-sfc
const bbox = this.getBBox();
// eslint-disable-next-line react/no-this-in-sfc
d3.select(this.parentNode)
.append('rect')
.attr('x', (d) => d.x - bbox.width / 2)
.attr('y', bbox.y + 5)
.attr('width', bbox.width)
.attr('height', bbox.height)
.attr('rx', 8)
.attr('ry', 8)
.style('fill', (d) => renderStateColor(d.node_state));
});
const hostNames = node.append('g');
hostNames
.append('text')
.attr('x', (d) => d.x)
.attr('y', (d) => d.y + 38)
.text((d) => renderLabelText(d.node_state, d.hostname))
.attr('x', (d) => d.x + 6)
.attr('y', (d) => d.y + 42)
.attr('fill', 'white')
.attr('font-size', DEFAULT_FONT_SIZE)
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
.attr('text-anchor', 'middle');
.attr('text-anchor', 'middle')
.each(function calculateLabelWidth() {
// eslint-disable-next-line react/no-this-in-sfc
const bbox = this.getBBox();
// eslint-disable-next-line react/no-this-in-sfc
d3.select(this.parentNode)
.append('path')
.attr('class', (d) => `icon-${d.node_state}`)
.attr('d', (d) => renderLabelIcons(d.node_state))
.attr('transform', (d) => renderIconPosition(d.node_state, bbox))
.attr('fill', 'white');
});
svg.selectAll('g.placeholder').remove();
svg.call(zoom);
@ -208,7 +330,8 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.selectAll(`.link-${s.index}`)
.transition()
.style('stroke', '#0066CC')
.style('stroke-width', '3px');
.style('stroke-width', '3px')
.attr('marker-end', 'url(#end-active)');
});
}
@ -222,55 +345,70 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
svg
.selectAll(`.link-${s.index}`)
.transition()
.style('stroke', '#ccc')
.style('stroke-width', '2px');
.duration(50)
.style('stroke', (d) =>
d.link_state === 'removing' ? '#C9190B' : '#CCC'
)
.style('stroke-width', '2px')
.attr('marker-end', 'url(#end)');
});
}
function highlightSelected(n) {
if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
// toggle rings
svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
svg
.select(`circle.id-${n.id}`)
.attr('stroke', '#ccc')
.attr('stroke-width', null);
// show default empty state of tooltip
setIsNodeSelected(false);
setSelectedNode(null);
return;
}
svg.selectAll('circle').attr('stroke-width', null);
svg
.selectAll('circle')
.attr('stroke', '#ccc')
.attr('stroke-width', null);
svg
.select(`circle.id-${n.id}`)
.attr('stroke-width', '5px')
.attr('stroke', '#D2D2D2');
.attr('stroke', '#0066CC');
setIsNodeSelected(true);
setSelectedNode(n);
}
}
};
useEffect(() => {
function handleResize() {
d3.select('.simulation-loader').style('visibility', 'visible');
setSelectedNode(null);
setIsNodeSelected(false);
draw();
}
window.addEventListener('resize', debounce(handleResize, 500));
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div id="chart" style={{ position: 'relative', height: '100%' }}>
{showLegend && <Legend />}
<Tooltip
isNodeSelected={isNodeSelected}
renderNodeIcon={renderNodeIcon(selectedNode)}
nodeDetail={nodeDetail}
redirectToDetailsPage={() =>
redirectToDetailsPage(selectedNode, history)
}
/>
{instance && (
<Tooltip
isNodeSelected={isNodeSelected}
renderNodeIcon={renderNodeIcon(selectedNode)}
selectedNode={selectedNode}
fetchInstance={fetchDetails}
instanceGroups={instanceGroups}
instanceDetail={instance}
isLoading={isLoading}
redirectToDetailsPage={() =>
redirectToDetailsPage(selectedNode, history)
}
/>
)}
<Loader className="simulation-loader" progress={simulationProgress} />
{fetchInstanceError && (
<AlertModal
variant="error"
title={t`Error!`}
isOpen
onClose={dismissError}
>
{t`Failed to get instance.`}
<ErrorDetail error={fetchInstanceError} />
</AlertModal>
)}
</div>
);
}

View File

@ -1,6 +1,12 @@
import React from 'react';
import { t } from '@lingui/macro';
import React, { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import styled from 'styled-components';
import { useConfig } from 'contexts/Config';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import useDebounce from 'hooks/useDebounce';
import { InstancesAPI } from 'api';
import computeForks from 'util/computeForks';
import {
Button as PFButton,
DescriptionList as PFDescriptionList,
@ -8,26 +14,42 @@ import {
DescriptionListGroup as PFDescriptionListGroup,
DescriptionListDescription,
Divider,
Progress,
ProgressMeasureLocation,
ProgressSize,
Slider,
TextContent,
Text as PFText,
TextVariants,
Label,
} from '@patternfly/react-core';
import { DownloadIcon } from '@patternfly/react-icons';
import ContentLoading from 'components/ContentLoading';
import InstanceToggle from 'components/InstanceToggle';
import StatusLabel from 'components/StatusLabel';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import { formatDateString } from 'util/dates';
const Wrapper = styled.div`
position: absolute;
top: -20px;
right: 0;
padding: 10px;
width: 20%;
width: 25%;
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: 30px;
height: 30px;
border-radius: 15px;
padding: 0;
font-size: 16px;
background-color: white;
border: 1px solid #ccc;
color: black;
}
`;
const DescriptionList = styled(PFDescriptionList)`
gap: 0;
@ -39,12 +61,120 @@ const DescriptionListGroup = styled(PFDescriptionListGroup)`
const Text = styled(PFText)`
margin: 10px 0 5px;
`;
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
`;
const SliderHolder = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const SliderForks = styled.div`
flex-grow: 1;
margin-right: 8px;
margin-left: 8px;
text-align: center;
`;
const buildLinkURL = (inst) =>
inst.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
function renderInstanceGroups(instanceGroups) {
return instanceGroups.map((ig) => (
<React.Fragment key={ig.id}>
<Label
color="blue"
isTruncated
render={({ className, content, componentRef }) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
className={className}
innerRef={componentRef}
>
{content}
</Link>
)}
>
{ig.name}
</Label>{' '}
</React.Fragment>
));
}
function usedCapacity(instance) {
if (instance.enabled) {
return (
<Progress
value={Math.round(100 - instance.percent_capacity_remaining)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
title={t`Used capacity`}
/>
);
}
return <Unavailable>{t`Unavailable`}</Unavailable>;
}
function Tooltip({
fetchInstance,
isNodeSelected,
renderNodeIcon,
nodeDetail,
instanceDetail,
instanceGroups,
isLoading,
redirectToDetailsPage,
}) {
const { me = {} } = useConfig();
const [forks, setForks] = useState(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
instanceDetail.capacity_adjustment
)
);
const { error: updateInstanceError, request: updateInstance } = useRequest(
useCallback(
async (values) => {
await InstancesAPI.update(instanceDetail.id, values);
},
[instanceDetail]
)
);
const debounceUpdateInstance = useDebounce(updateInstance, 100);
const { error: updateError, dismissError: dismissUpdateError } =
useDismissableError(updateInstanceError);
const handleChangeValue = (value) => {
const roundedValue = Math.round(value * 100) / 100;
setForks(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
roundedValue
)
);
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
useEffect(() => {
setForks(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
instanceDetail.capacity_adjustment
)
);
}, [instanceDetail]);
return (
<Wrapper className="tooltip" data-cy="tooltip">
{isNodeSelected === false ? (
@ -62,6 +192,17 @@ function Tooltip({
</TextContent>
) : (
<>
{updateError && (
<AlertModal
variant="error"
title={t`Error!`}
isOpen
onClose={dismissUpdateError}
>
{t`Failed to update instance.`}
<ErrorDetail error={updateError} />
</AlertModal>
)}
<TextContent>
<Text
component={TextVariants.small}
@ -71,36 +212,131 @@ function Tooltip({
</Text>
<Divider component="div" />
</TextContent>
<DescriptionList isHorizontal isFluid>
<DescriptionListGroup>
<DescriptionListTerm>
<Button variant="primary" isSmall>
{renderNodeIcon}
</Button>
</DescriptionListTerm>
<DescriptionListDescription>
<PFButton
variant="link"
isInline
onClick={redirectToDetailsPage}
>
{nodeDetail.hostname}
</PFButton>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Type`}</DescriptionListTerm>
<DescriptionListDescription>
{nodeDetail.node_type} {t`node`}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Status`}</DescriptionListTerm>
<DescriptionListDescription>
<StatusLabel status={nodeDetail.node_state} />
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
{isLoading && <ContentLoading />}
{!isLoading && (
<DescriptionList>
<DescriptionListGroup>
<DescriptionListDescription>
<Button>{renderNodeIcon}</Button>{' '}
<PFButton
variant="link"
isInline
onClick={redirectToDetailsPage}
>
{instanceDetail.hostname}
</PFButton>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Instance status`}</DescriptionListTerm>
<DescriptionListDescription>
<StatusLabel status={instanceDetail.node_state} />
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Instance type`}</DescriptionListTerm>
<DescriptionListDescription>
{instanceDetail.node_type}
</DescriptionListDescription>
</DescriptionListGroup>
{instanceDetail.related?.install_bundle && (
<DescriptionListGroup>
<DescriptionListTerm>{t`Download bundle`}</DescriptionListTerm>
<DescriptionListDescription>
<a href={`${instanceDetail.related.install_bundle}`}>
<PFButton
ouiaId="job-output-download-button"
variant="plain"
aria-label={t`Download Bundle`}
>
<DownloadIcon />
</PFButton>
</a>
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceDetail.ip_address && (
<DescriptionListGroup>
<DescriptionListTerm>{t`IP address`}</DescriptionListTerm>
<DescriptionListDescription>
{instanceDetail.ip_address}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceGroups && (
<DescriptionListGroup>
<DescriptionListTerm>{t`Instance groups`}</DescriptionListTerm>
<DescriptionListDescription>
{renderInstanceGroups(instanceGroups.results)}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceDetail.node_type !== 'hop' && (
<>
<DescriptionListGroup>
<DescriptionListTerm>{t`Forks`}</DescriptionListTerm>
<DescriptionListDescription>
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instanceDetail.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural
value={forks}
one="# fork"
other="# forks"
/>
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instanceDetail.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={
!me?.is_superuser || !instanceDetail.enabled
}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instanceDetail.mem_capacity}`}</div>
</SliderHolder>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Capacity`}</DescriptionListTerm>
<DescriptionListDescription>
{usedCapacity(instanceDetail)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListDescription>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstance}
instance={instanceDetail}
/>
</DescriptionListDescription>
</DescriptionListGroup>
</>
)}
<DescriptionListGroup>
<DescriptionListTerm>{t`Last modified`}</DescriptionListTerm>
<DescriptionListDescription>
{formatDateString(instanceDetail.modified)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Last seen`}</DescriptionListTerm>
<DescriptionListDescription>
{instanceDetail.last_seen
? formatDateString(instanceDetail.last_seen)
: `not found`}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
)}
</>
)}
</Wrapper>

View File

@ -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',
};

View File

@ -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);
}

View File

@ -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('');

View File

@ -8,6 +8,7 @@ module.exports = (app) => {
target: TARGET,
secure: false,
ws: true,
changeOrigin: true,
})
);
};

View File

@ -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`,
},
],
};

View File

@ -15,6 +15,7 @@ action_groups:
- group
- host
- import
- instance
- instance_group
- inventory
- inventory_source

View File

@ -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']

View File

@ -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()

View File

@ -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'],

View File

@ -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 }}"

View File

@ -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)

View File

@ -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)

View File

@ -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/'

78
docs/execution_nodes.md Normal file
View File

@ -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.

7
docs/licenses/asn1.txt Normal file
View File

@ -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.

View File

@ -0,0 +1,7 @@
Copyright (c) 2014, Jakub Stasiak <jakub@stasiak.at>
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.

View File

@ -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 <http://unlicense.org>

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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