mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #12744 from ansible/feature-mesh-scaling
[feature] Ability to add execution nodes at runtime
This commit is contained in:
commit
9c2185c68f
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 %}
|
||||
28
awx/api/templates/instance_install_bundle/inventory.yml
Normal file
28
awx/api/templates/instance_install_bundle/inventory.yml
Normal 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
|
||||
@ -0,0 +1,6 @@
|
||||
---
|
||||
collections:
|
||||
- name: ansible.receptor
|
||||
source: https://github.com/ansible/receptor-collection/
|
||||
type: git
|
||||
version: 0.1.1
|
||||
@ -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']
|
||||
|
||||
@ -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):
|
||||
|
||||
187
awx/api/views/instance_install_bundle.py
Normal file
187
awx/api/views/instance_install_bundle.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
79
awx/main/migrations/0170_node_and_link_state.py
Normal file
79
awx/main/migrations/0170_node_and_link_state.py
Normal 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),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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}."
|
||||
|
||||
@ -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
|
||||
|
||||
21
awx/ui/package-lock.json
generated
21
awx/ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
34
awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js
Normal file
34
awx/ui/src/components/HealthCheckAlert/HealthCheckAlert.js
Normal 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;
|
||||
1
awx/ui/src/components/HealthCheckAlert/index.js
Normal file
1
awx/ui/src/components/HealthCheckAlert/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './HealthCheckAlert';
|
||||
@ -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 }) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
39
awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js
Normal file
39
awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.js
Normal 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;
|
||||
53
awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js
Normal file
53
awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
1
awx/ui/src/screens/Instances/InstanceAdd/index.js
Normal file
1
awx/ui/src/screens/Instances/InstanceAdd/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InstanceAdd';
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -143,7 +143,7 @@ function InstanceListItem({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
|
||||
<StatusLabel status={instance.node_state} />
|
||||
</Tooltip>
|
||||
</Td>
|
||||
|
||||
|
||||
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
123
awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js
Normal file
123
awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js
Normal 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;
|
||||
@ -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`}
|
||||
|
||||
{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;
|
||||
1
awx/ui/src/screens/Instances/InstancePeers/index.js
Normal file
1
awx/ui/src/screens/Instances/InstancePeers/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './InstancePeerList';
|
||||
@ -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>
|
||||
|
||||
118
awx/ui/src/screens/Instances/Shared/InstanceForm.js
Normal file
118
awx/ui/src/screens/Instances/Shared/InstanceForm.js
Normal 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;
|
||||
98
awx/ui/src/screens/Instances/Shared/InstanceForm.test.js
Normal file
98
awx/ui/src/screens/Instances/Shared/InstanceForm.test.js
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
198
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js
Normal file
198
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.js
Normal 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;
|
||||
133
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js
Normal file
133
awx/ui/src/screens/Instances/Shared/RemoveInstanceButton.test.js
Normal 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!');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -8,6 +8,7 @@ module.exports = (app) => {
|
||||
target: TARGET,
|
||||
secure: false,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@ -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`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ action_groups:
|
||||
- group
|
||||
- host
|
||||
- import
|
||||
- instance
|
||||
- instance_group
|
||||
- inventory
|
||||
- inventory_source
|
||||
|
||||
@ -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']
|
||||
|
||||
140
awx_collection/plugins/modules/instance.py
Normal file
140
awx_collection/plugins/modules/instance.py
Normal 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()
|
||||
@ -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'],
|
||||
|
||||
@ -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 }}"
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
78
docs/execution_nodes.md
Normal 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
7
docs/licenses/asn1.txt
Normal 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.
|
||||
7
docs/licenses/enum-compat.txt
Normal file
7
docs/licenses/enum-compat.txt
Normal 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.
|
||||
24
docs/licenses/filelock.txt
Normal file
24
docs/licenses/filelock.txt
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user