Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Corey
d87c091eea Refactors the project form 2022-10-05 15:43:01 -04:00
72 changed files with 1642 additions and 2992 deletions

View File

@@ -6,6 +6,7 @@ import inspect
import logging import logging
import time import time
import uuid import uuid
import urllib.parse
# Django # Django
from django.conf import settings from django.conf import settings
@@ -29,7 +30,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework import views from rest_framework import views
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.renderers import StaticHTMLRenderer from rest_framework.renderers import StaticHTMLRenderer, JSONRenderer
from rest_framework.negotiation import DefaultContentNegotiation from rest_framework.negotiation import DefaultContentNegotiation
# AWX # AWX
@@ -40,7 +41,7 @@ from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd,
from awx.main.utils.db import get_all_field_names from awx.main.utils.db import get_all_field_names
from awx.main.utils.licensing import server_product_name from awx.main.utils.licensing import server_product_name
from awx.main.views import ApiErrorView from awx.main.views import ApiErrorView
from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer, UserSerializer
from awx.api.versioning import URLPathVersioning from awx.api.versioning import URLPathVersioning
from awx.api.metadata import SublistAttachDetatchMetadata, Metadata from awx.api.metadata import SublistAttachDetatchMetadata, Metadata
from awx.conf import settings_registry from awx.conf import settings_registry
@@ -89,9 +90,13 @@ class LoggedLoginView(auth_views.LoginView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
ret = super(LoggedLoginView, self).post(request, *args, **kwargs) ret = super(LoggedLoginView, self).post(request, *args, **kwargs)
current_user = getattr(request, 'user', None)
if request.user.is_authenticated: if request.user.is_authenticated:
logger.info(smart_str(u"User {} logged in from {}".format(self.request.user.username, request.META.get('REMOTE_ADDR', None)))) logger.info(smart_str(u"User {} logged in from {}".format(self.request.user.username, request.META.get('REMOTE_ADDR', None))))
ret.set_cookie('userLoggedIn', 'true') ret.set_cookie('userLoggedIn', 'true')
current_user = UserSerializer(self.request.user)
current_user = smart_str(JSONRenderer().render(current_user.data))
current_user = urllib.parse.quote('%s' % current_user, '')
ret.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid')) ret.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid'))
return ret return ret
@@ -248,7 +253,7 @@ class APIView(views.APIView):
response['X-API-Query-Time'] = '%0.3fs' % sum(q_times) response['X-API-Query-Time'] = '%0.3fs' % sum(q_times)
if getattr(self, 'deprecated', False): if getattr(self, 'deprecated', False):
response['Warning'] = '299 awx "This resource has been deprecated and will be removed in a future release."' response['Warning'] = '299 awx "This resource has been deprecated and will be removed in a future release."' # noqa
return response return response

View File

@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.text import capfirst from django.utils.text import capfirst
from django.utils.timezone import now from django.utils.timezone import now
from django.core.validators import RegexValidator, MaxLengthValidator
# Django REST Framework # Django REST Framework
from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.exceptions import ValidationError, PermissionDenied
@@ -121,9 +120,6 @@ from awx.main.validators import vars_validate_or_raise
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField
# AWX Utils
from awx.api.validators import HostnameRegexValidator
logger = logging.getLogger('awx.api.serializers') logger = logging.getLogger('awx.api.serializers')
# Fields that should be summarized regardless of object type. # Fields that should be summarized regardless of object type.
@@ -3750,11 +3746,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer):
# Build unsaved version of this config, use it to detect prompts errors # Build unsaved version of this config, use it to detect prompts errors
mock_obj = self._build_mock_obj(attrs) mock_obj = self._build_mock_obj(attrs)
if set(list(ujt.get_ask_mapping().keys()) + ['extra_data']) & set(attrs.keys()):
accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict()) accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict())
else:
# Only perform validation of prompts if prompts fields are provided
errors = {}
# Remove all unprocessed $encrypted$ strings, indicating default usage # Remove all unprocessed $encrypted$ strings, indicating default usage
if 'extra_data' in attrs and password_dict: if 'extra_data' in attrs and password_dict:
@@ -4929,19 +4921,6 @@ class InstanceSerializer(BaseSerializer):
extra_kwargs = { extra_kwargs = {
'node_type': {'initial': Instance.Types.EXECUTION, 'default': Instance.Types.EXECUTION}, 'node_type': {'initial': Instance.Types.EXECUTION, 'default': Instance.Types.EXECUTION},
'node_state': {'initial': Instance.States.INSTALLED, 'default': Instance.States.INSTALLED}, 'node_state': {'initial': Instance.States.INSTALLED, 'default': Instance.States.INSTALLED},
'hostname': {
'validators': [
MaxLengthValidator(limit_value=250),
validators.UniqueValidator(queryset=Instance.objects.all()),
RegexValidator(
regex='^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$',
flags=re.IGNORECASE,
inverse_match=True,
message="hostname cannot be localhost or 127.0.0.1",
),
HostnameRegexValidator(),
],
},
} }
def get_related(self, obj): def get_related(self, obj):
@@ -5012,10 +4991,6 @@ class InstanceSerializer(BaseSerializer):
return value return value
def validate_hostname(self, value): def validate_hostname(self, value):
"""
- Hostname cannot be "localhost" - but can be something like localhost.domain
- Cannot change the hostname of an-already instantiated & initialized Instance object
"""
if self.instance and self.instance.hostname != value: if self.instance and self.instance.hostname != value:
raise serializers.ValidationError("Cannot change hostname.") raise serializers.ValidationError("Cannot change hostname.")

View File

@@ -1,5 +1,3 @@
receptor_user: awx
receptor_group: awx
receptor_verify: true receptor_verify: true
receptor_tls: true receptor_tls: true
receptor_work_commands: receptor_work_commands:
@@ -12,12 +10,12 @@ custom_worksign_public_keyfile: receptor/work-public-key.pem
custom_tls_certfile: receptor/tls/receptor.crt custom_tls_certfile: receptor/tls/receptor.crt
custom_tls_keyfile: receptor/tls/receptor.key custom_tls_keyfile: receptor/tls/receptor.key
custom_ca_certfile: receptor/tls/ca/receptor-ca.crt custom_ca_certfile: receptor/tls/ca/receptor-ca.crt
receptor_user: awx
receptor_group: awx
receptor_protocol: 'tcp' receptor_protocol: 'tcp'
receptor_listener: true receptor_listener: true
receptor_port: {{ instance.listener_port }} receptor_port: {{ instance.listener_port }}
receptor_dependencies: receptor_dependencies:
- podman
- crun
- python39-pip - python39-pip
{% verbatim %}
podman_user: "{{ receptor_user }}"
podman_group: "{{ receptor_group }}"
{% endverbatim %}

View File

@@ -9,8 +9,6 @@
shell: /bin/bash shell: /bin/bash
- name: Enable Copr repo for Receptor - name: Enable Copr repo for Receptor
command: dnf copr enable ansible-awx/receptor -y command: dnf copr enable ansible-awx/receptor -y
- import_role:
name: ansible.receptor.podman
- import_role: - import_role:
name: ansible.receptor.setup name: ansible.receptor.setup
- name: Install ansible-runner - name: Install ansible-runner

View File

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

View File

@@ -9,9 +9,9 @@ from awx.api.views import (
InstanceUnifiedJobsList, InstanceUnifiedJobsList,
InstanceInstanceGroupsList, InstanceInstanceGroupsList,
InstanceHealthCheck, InstanceHealthCheck,
InstanceInstallBundle,
InstancePeersList, InstancePeersList,
) )
from awx.api.views.instance_install_bundle import InstanceInstallBundle
urls = [ urls = [

View File

@@ -3,28 +3,26 @@
from django.urls import re_path from django.urls import re_path
from awx.api.views.inventory import ( from awx.api.views import (
InventoryList, InventoryList,
InventoryDetail, InventoryDetail,
InventoryHostsList,
InventoryGroupsList,
InventoryRootGroupsList,
InventoryVariableData,
InventoryScriptView,
InventoryTreeView,
InventoryInventorySourcesList,
InventoryInventorySourcesUpdate,
InventoryActivityStreamList, InventoryActivityStreamList,
InventoryJobTemplateList, InventoryJobTemplateList,
InventoryAdHocCommandsList,
InventoryAccessList, InventoryAccessList,
InventoryObjectRolesList, InventoryObjectRolesList,
InventoryInstanceGroupsList, InventoryInstanceGroupsList,
InventoryLabelList, InventoryLabelList,
InventoryCopy, InventoryCopy,
) )
from awx.api.views import (
InventoryHostsList,
InventoryGroupsList,
InventoryInventorySourcesList,
InventoryInventorySourcesUpdate,
InventoryAdHocCommandsList,
InventoryRootGroupsList,
InventoryScriptView,
InventoryTreeView,
InventoryVariableData,
)
urls = [ urls = [

View File

@@ -3,9 +3,6 @@
from django.urls import re_path from django.urls import re_path
from awx.api.views.inventory import (
InventoryUpdateEventsList,
)
from awx.api.views import ( from awx.api.views import (
InventoryUpdateList, InventoryUpdateList,
InventoryUpdateDetail, InventoryUpdateDetail,
@@ -13,6 +10,7 @@ from awx.api.views import (
InventoryUpdateStdout, InventoryUpdateStdout,
InventoryUpdateNotificationsList, InventoryUpdateNotificationsList,
InventoryUpdateCredentialsList, InventoryUpdateCredentialsList,
InventoryUpdateEventsList,
) )

View File

@@ -10,7 +10,7 @@ from oauthlib import oauth2
from oauth2_provider import views from oauth2_provider import views
from awx.main.models import RefreshToken from awx.main.models import RefreshToken
from awx.api.views.root import ApiOAuthAuthorizationRootView from awx.api.views import ApiOAuthAuthorizationRootView
class TokenView(views.TokenView): class TokenView(views.TokenView):

View File

@@ -3,7 +3,7 @@
from django.urls import re_path from django.urls import re_path
from awx.api.views.organization import ( from awx.api.views import (
OrganizationList, OrganizationList,
OrganizationDetail, OrganizationDetail,
OrganizationUsersList, OrganizationUsersList,
@@ -14,6 +14,7 @@ from awx.api.views.organization import (
OrganizationJobTemplatesList, OrganizationJobTemplatesList,
OrganizationWorkflowJobTemplatesList, OrganizationWorkflowJobTemplatesList,
OrganizationTeamsList, OrganizationTeamsList,
OrganizationCredentialList,
OrganizationActivityStreamList, OrganizationActivityStreamList,
OrganizationNotificationTemplatesList, OrganizationNotificationTemplatesList,
OrganizationNotificationTemplatesErrorList, OrganizationNotificationTemplatesErrorList,
@@ -24,8 +25,8 @@ from awx.api.views.organization import (
OrganizationGalaxyCredentialsList, OrganizationGalaxyCredentialsList,
OrganizationObjectRolesList, OrganizationObjectRolesList,
OrganizationAccessList, OrganizationAccessList,
OrganizationApplicationList,
) )
from awx.api.views import OrganizationCredentialList, OrganizationApplicationList
urls = [ urls = [

View File

@@ -6,15 +6,13 @@ from django.urls import include, re_path
from awx import MODE from awx import MODE
from awx.api.generics import LoggedLoginView, LoggedLogoutView from awx.api.generics import LoggedLoginView, LoggedLogoutView
from awx.api.views.root import ( from awx.api.views import (
ApiRootView, ApiRootView,
ApiV2RootView, ApiV2RootView,
ApiV2PingView, ApiV2PingView,
ApiV2ConfigView, ApiV2ConfigView,
ApiV2SubscriptionView, ApiV2SubscriptionView,
ApiV2AttachView, ApiV2AttachView,
)
from awx.api.views import (
AuthView, AuthView,
UserMeList, UserMeList,
DashboardView, DashboardView,
@@ -30,8 +28,8 @@ from awx.api.views import (
OAuth2TokenList, OAuth2TokenList,
ApplicationOAuth2TokenList, ApplicationOAuth2TokenList,
OAuth2ApplicationDetail, OAuth2ApplicationDetail,
MeshVisualizer,
) )
from awx.api.views.mesh_visualizer import MeshVisualizer
from awx.api.views.metrics import MetricsView from awx.api.views.metrics import MetricsView

View File

@@ -1,6 +1,6 @@
from django.urls import re_path from django.urls import re_path
from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver from awx.api.views import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver
urlpatterns = [ urlpatterns = [

View File

@@ -1,55 +0,0 @@
import re
from django.core.validators import RegexValidator, validate_ipv46_address
from django.core.exceptions import ValidationError
class HostnameRegexValidator(RegexValidator):
"""
Fully validates a domain name that is compliant with norms in Linux/RHEL
- Cannot start with a hyphen
- Cannot begin with, or end with a "."
- Cannot contain any whitespaces
- Entire hostname is max 255 chars (including dots)
- Each domain/label is between 1 and 63 characters, except top level domain, which must be at least 2 characters
- Supports ipv4, ipv6, simple hostnames and FQDNs
- Follows RFC 9210 (modern RFC 1123, 1178) requirements
Accepts an IP Address or Hostname as the argument
"""
regex = '^[a-z0-9][-a-z0-9]*$|^([a-z0-9][-a-z0-9]{0,62}[.])*[a-z0-9][-a-z0-9]{1,62}$'
flags = re.IGNORECASE
def __call__(self, value):
regex_matches, err = self.__validate(value)
invalid_input = regex_matches if self.inverse_match else not regex_matches
if invalid_input:
if err is None:
err = ValidationError(self.message, code=self.code, params={"value": value})
raise err
def __str__(self):
return f"regex={self.regex}, message={self.message}, code={self.code}, inverse_match={self.inverse_match}, flags={self.flags}"
def __validate(self, value):
if ' ' in value:
return False, ValidationError("whitespaces in hostnames are illegal")
"""
If we have an IP address, try and validate it.
"""
try:
validate_ipv46_address(value)
return True, None
except ValidationError:
pass
"""
By this point in the code, we probably have a simple hostname, FQDN or a strange hostname like "192.localhost.domain.101"
"""
if not self.regex.match(value):
return False, ValidationError(f"illegal characters detected in hostname={value}. Please verify.")
return True, None

View File

@@ -122,6 +122,56 @@ from awx.api.views.mixin import (
UnifiedJobDeletionMixin, UnifiedJobDeletionMixin,
NoTruncateMixin, 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,
OrganizationInventoriesList,
OrganizationUsersList,
OrganizationAdminsList,
OrganizationExecutionEnvironmentsList,
OrganizationProjectsList,
OrganizationJobTemplatesList,
OrganizationWorkflowJobTemplatesList,
OrganizationTeamsList,
OrganizationActivityStreamList,
OrganizationNotificationTemplatesList,
OrganizationNotificationTemplatesAnyList,
OrganizationNotificationTemplatesErrorList,
OrganizationNotificationTemplatesStartedList,
OrganizationNotificationTemplatesSuccessList,
OrganizationNotificationTemplatesApprovalList,
OrganizationInstanceGroupsList,
OrganizationGalaxyCredentialsList,
OrganizationAccessList,
OrganizationObjectRolesList,
)
from awx.api.views.root import ( # noqa
ApiRootView,
ApiOAuthAuthorizationRootView,
ApiVersionRootView,
ApiV2RootView,
ApiV2PingView,
ApiV2ConfigView,
ApiV2SubscriptionView,
ApiV2AttachView,
)
from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver # noqa
from awx.api.pagination import UnifiedJobEventPagination from awx.api.pagination import UnifiedJobEventPagination
from awx.main.utils import set_environ from awx.main.utils import set_environ

View File

@@ -178,7 +178,7 @@ def generate_receptor_tls(instance_obj):
.public_key(csr.public_key()) .public_key(csr.public_key())
.serial_number(x509.random_serial_number()) .serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow()) .not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10))
.add_extension( .add_extension(
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value, csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value,
critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical, critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical,

View File

@@ -166,7 +166,11 @@ class Metrics:
elif settings.IS_TESTING(): elif settings.IS_TESTING():
self.instance_name = "awx_testing" self.instance_name = "awx_testing"
else: else:
self.instance_name = Instance.objects.my_hostname() try:
self.instance_name = Instance.objects.me().hostname
except Exception as e:
self.instance_name = settings.CLUSTER_HOST_ID
logger.info(f'Instance {self.instance_name} seems to be unregistered, error: {e}')
# metric name, help_text # metric name, help_text
METRICSLIST = [ METRICSLIST = [

View File

@@ -16,7 +16,12 @@ def startup_reaping():
If this particular instance is starting, then we know that any running jobs are invalid If this particular instance is starting, then we know that any running jobs are invalid
so we will reap those jobs as a special action here so we will reap those jobs as a special action here
""" """
jobs = UnifiedJob.objects.filter(status='running', controller_node=Instance.objects.my_hostname()) try:
me = Instance.objects.me()
except RuntimeError as e:
logger.warning(f'Local instance is not registered, not running startup reaper: {e}')
return
jobs = UnifiedJob.objects.filter(status='running', controller_node=me.hostname)
job_ids = [] job_ids = []
for j in jobs: for j in jobs:
job_ids.append(j.id) job_ids.append(j.id)
@@ -57,13 +62,16 @@ def reap_waiting(instance=None, status='failed', job_explanation=None, grace_per
if grace_period is None: if grace_period is None:
grace_period = settings.JOB_WAITING_GRACE_PERIOD + settings.TASK_MANAGER_TIMEOUT grace_period = settings.JOB_WAITING_GRACE_PERIOD + settings.TASK_MANAGER_TIMEOUT
if instance is None: me = instance
hostname = Instance.objects.my_hostname() if me is None:
else: try:
hostname = instance.hostname me = Instance.objects.me()
except RuntimeError as e:
logger.warning(f'Local instance is not registered, not running reaper: {e}')
return
if ref_time is None: if ref_time is None:
ref_time = tz_now() ref_time = tz_now()
jobs = UnifiedJob.objects.filter(status='waiting', modified__lte=ref_time - timedelta(seconds=grace_period), controller_node=hostname) jobs = UnifiedJob.objects.filter(status='waiting', modified__lte=ref_time - timedelta(seconds=grace_period), controller_node=me.hostname)
if excluded_uuids: if excluded_uuids:
jobs = jobs.exclude(celery_task_id__in=excluded_uuids) jobs = jobs.exclude(celery_task_id__in=excluded_uuids)
for j in jobs: for j in jobs:
@@ -74,13 +82,16 @@ def reap(instance=None, status='failed', job_explanation=None, excluded_uuids=No
""" """
Reap all jobs in running for this instance. Reap all jobs in running for this instance.
""" """
if instance is None: me = instance
hostname = Instance.objects.my_hostname() if me is None:
else: try:
hostname = instance.hostname me = Instance.objects.me()
except RuntimeError as e:
logger.warning(f'Local instance is not registered, not running reaper: {e}')
return
workflow_ctype_id = ContentType.objects.get_for_model(WorkflowJob).id workflow_ctype_id = ContentType.objects.get_for_model(WorkflowJob).id
jobs = UnifiedJob.objects.filter( jobs = UnifiedJob.objects.filter(
Q(status='running') & (Q(execution_node=hostname) | Q(controller_node=hostname)) & ~Q(polymorphic_ctype_id=workflow_ctype_id) Q(status='running') & (Q(execution_node=me.hostname) | Q(controller_node=me.hostname)) & ~Q(polymorphic_ctype_id=workflow_ctype_id)
) )
if excluded_uuids: if excluded_uuids:
jobs = jobs.exclude(celery_task_id__in=excluded_uuids) jobs = jobs.exclude(celery_task_id__in=excluded_uuids)

View File

@@ -53,7 +53,7 @@ class Command(BaseCommand):
return lines return lines
@classmethod @classmethod
def get_connection_status(cls, hostnames, data): def get_connection_status(cls, me, hostnames, data):
host_stats = [('hostname', 'state', 'start time', 'duration (sec)')] host_stats = [('hostname', 'state', 'start time', 'duration (sec)')]
for h in hostnames: for h in hostnames:
connection_color = '91' # red connection_color = '91' # red
@@ -78,7 +78,7 @@ class Command(BaseCommand):
return host_stats return host_stats
@classmethod @classmethod
def get_connection_stats(cls, hostnames, data): def get_connection_stats(cls, me, hostnames, data):
host_stats = [('hostname', 'total', 'per minute')] host_stats = [('hostname', 'total', 'per minute')]
for h in hostnames: for h in hostnames:
h_safe = safe_name(h) h_safe = safe_name(h)
@@ -119,8 +119,8 @@ class Command(BaseCommand):
return return
try: try:
my_hostname = Instance.objects.my_hostname() me = Instance.objects.me()
logger.info('Active instance with hostname {} is registered.'.format(my_hostname)) logger.info('Active instance with hostname {} is registered.'.format(me.hostname))
except RuntimeError as e: except RuntimeError as e:
# the CLUSTER_HOST_ID in the task, and web instance must match and # the CLUSTER_HOST_ID in the task, and web instance must match and
# ensure network connectivity between the task and web instance # ensure network connectivity between the task and web instance
@@ -145,19 +145,19 @@ class Command(BaseCommand):
else: else:
data[family.name] = family.samples[0].value data[family.name] = family.samples[0].value
my_hostname = Instance.objects.my_hostname() me = Instance.objects.me()
hostnames = [i.hostname for i in Instance.objects.exclude(hostname=my_hostname)] hostnames = [i.hostname for i in Instance.objects.exclude(hostname=me.hostname)]
host_stats = Command.get_connection_status(hostnames, data) host_stats = Command.get_connection_status(me, hostnames, data)
lines = Command._format_lines(host_stats) lines = Command._format_lines(host_stats)
print(f'Broadcast websocket connection status from "{my_hostname}" to:') print(f'Broadcast websocket connection status from "{me.hostname}" to:')
print('\n'.join(lines)) print('\n'.join(lines))
host_stats = Command.get_connection_stats(hostnames, data) host_stats = Command.get_connection_stats(me, hostnames, data)
lines = Command._format_lines(host_stats) lines = Command._format_lines(host_stats)
print(f'\nBroadcast websocket connection stats from "{my_hostname}" to:') print(f'\nBroadcast websocket connection stats from "{me.hostname}" to:')
print('\n'.join(lines)) print('\n'.join(lines))
return return

View File

@@ -99,12 +99,9 @@ class InstanceManager(models.Manager):
instance or role. instance or role.
""" """
def my_hostname(self):
return settings.CLUSTER_HOST_ID
def me(self): def me(self):
"""Return the currently active instance.""" """Return the currently active instance."""
node = self.filter(hostname=self.my_hostname()) node = self.filter(hostname=settings.CLUSTER_HOST_ID)
if node.exists(): if node.exists():
return node[0] return node[0]
raise RuntimeError("No instance found with the current cluster host id") raise RuntimeError("No instance found with the current cluster host id")

View File

@@ -282,7 +282,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
return field['default'] return field['default']
if 'default' in kwargs: if 'default' in kwargs:
return kwargs['default'] return kwargs['default']
raise AttributeError(field_name) raise AttributeError
if field_name in self.inputs: if field_name in self.inputs:
return self.inputs[field_name] return self.inputs[field_name]
if 'default' in kwargs: if 'default' in kwargs:

View File

@@ -1305,8 +1305,6 @@ class UnifiedJob(
status_data['instance_group_name'] = None status_data['instance_group_name'] = None
elif status in ['successful', 'failed', 'canceled'] and self.finished: elif status in ['successful', 'failed', 'canceled'] and self.finished:
status_data['finished'] = datetime.datetime.strftime(self.finished, "%Y-%m-%dT%H:%M:%S.%fZ") status_data['finished'] = datetime.datetime.strftime(self.finished, "%Y-%m-%dT%H:%M:%S.%fZ")
elif status == 'running':
status_data['started'] = datetime.datetime.strftime(self.finished, "%Y-%m-%dT%H:%M:%S.%fZ")
status_data.update(self.websocket_emit_data()) status_data.update(self.websocket_emit_data())
status_data['group_name'] = 'jobs' status_data['group_name'] = 'jobs'
if getattr(self, 'unified_job_template_id', None): if getattr(self, 'unified_job_template_id', None):

View File

@@ -700,7 +700,7 @@ class SourceControlMixin(BaseTask):
def spawn_project_sync(self, project, sync_needs, scm_branch=None): def spawn_project_sync(self, project, sync_needs, scm_branch=None):
pu_ig = self.instance.instance_group pu_ig = self.instance.instance_group
pu_en = Instance.objects.my_hostname() pu_en = Instance.objects.me().hostname
sync_metafields = dict( sync_metafields = dict(
launch_type="sync", launch_type="sync",

View File

@@ -4,8 +4,6 @@ from awx.api.versioning import reverse
from awx.main.models.activity_stream import ActivityStream from awx.main.models.activity_stream import ActivityStream
from awx.main.models.ha import Instance from awx.main.models.ha import Instance
from django.test.utils import override_settings
INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_capacity=6, mem_capacity=42) INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_capacity=6, mem_capacity=42)
@@ -56,33 +54,3 @@ def test_health_check_usage(get, post, admin_user):
get(url=url, user=admin_user, expect=200) get(url=url, user=admin_user, expect=200)
r = post(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}." assert r.data['msg'] == f"Health check is running for {instance.hostname}."
def test_custom_hostname_regex(post, admin_user):
url = reverse('api:instance_list')
with override_settings(IS_K8S=True):
for value in [
("foo.bar.baz", 201),
("f.bar.bz", 201),
("foo.bar.b", 400),
("a.b.c", 400),
("localhost", 400),
("127.0.0.1", 400),
("192.168.56.101", 201),
("2001:0db8:85a3:0000:0000:8a2e:0370:7334", 201),
("foobar", 201),
("--yoooo", 400),
("$3$@foobar@#($!@#*$", 400),
("999.999.999.999", 201),
("0000:0000:0000:0000:0000:0000:0000:0001", 400),
("whitespaces are bad for hostnames", 400),
("0:0:0:0:0:0:0:1", 400),
("192.localhost.domain.101", 201),
("F@$%(@#$H%^(I@#^HCTQEWRFG", 400),
]:
data = {
"hostname": value[0],
"node_type": "execution",
"node_state": "installed",
}
post(url=url, user=admin_user, data=data, expect=value[1])

View File

@@ -105,30 +105,6 @@ def test_encrypted_survey_answer(post, patch, admin_user, project, inventory, su
assert decrypt_value(get_encryption_key('value', pk=None), schedule.extra_data['var1']) == 'bar' assert decrypt_value(get_encryption_key('value', pk=None), schedule.extra_data['var1']) == 'bar'
@pytest.mark.django_db
def test_survey_password_default(post, patch, admin_user, project, inventory, survey_spec_factory):
job_template = JobTemplate.objects.create(
name='test-jt',
project=project,
playbook='helloworld.yml',
inventory=inventory,
ask_variables_on_launch=False,
survey_enabled=True,
survey_spec=survey_spec_factory([{'variable': 'var1', 'question_name': 'Q1', 'type': 'password', 'required': True, 'default': 'foobar'}]),
)
# test removal of $encrypted$
url = reverse('api:job_template_schedules_list', kwargs={'pk': job_template.id})
r = post(url, {'name': 'test sch', 'rrule': RRULE_EXAMPLE, 'extra_data': '{"var1": "$encrypted$"}'}, admin_user, expect=201)
schedule = Schedule.objects.get(pk=r.data['id'])
assert schedule.extra_data == {}
assert schedule.enabled is True
# test an unrelated change
patch(schedule.get_absolute_url(), data={'enabled': False}, user=admin_user, expect=200)
patch(schedule.get_absolute_url(), data={'enabled': True}, user=admin_user, expect=200)
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @pytest.mark.parametrize(
'rrule, error', 'rrule, error',

View File

@@ -5,8 +5,7 @@ from unittest import mock
from collections import namedtuple from collections import namedtuple
from awx.api.views.root import ApiVersionRootView from awx.api.views import ApiVersionRootView, JobTemplateLabelList, InventoryInventorySourcesUpdate, JobTemplateSurveySpec
from awx.api.views import JobTemplateLabelList, InventoryInventorySourcesUpdate, JobTemplateSurveySpec
from awx.main.views import handle_error from awx.main.views import handle_error

View File

@@ -3,7 +3,6 @@
# Copyright (c) 2017 Ansible, Inc. # Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import os import os
import re
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
import json import json
@@ -13,13 +12,9 @@ from unittest import mock
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from awx.main.utils import common from awx.main.utils import common
from awx.api.validators import HostnameRegexValidator
from awx.main.models import Job, AdHocCommand, InventoryUpdate, ProjectUpdate, SystemJob, WorkflowJob, Inventory, JobTemplate, UnifiedJobTemplate, UnifiedJob from awx.main.models import Job, AdHocCommand, InventoryUpdate, ProjectUpdate, SystemJob, WorkflowJob, Inventory, JobTemplate, UnifiedJobTemplate, UnifiedJob
from django.core.exceptions import ValidationError
from django.utils.regex_helper import _lazy_re_compile
@pytest.mark.parametrize( @pytest.mark.parametrize(
'input_, output', 'input_, output',
@@ -199,136 +194,3 @@ def test_extract_ansible_vars():
redacted, var_list = common.extract_ansible_vars(json.dumps(my_dict)) redacted, var_list = common.extract_ansible_vars(json.dumps(my_dict))
assert var_list == set(['ansible_connetion_setting']) assert var_list == set(['ansible_connetion_setting'])
assert redacted == {"foobar": "baz"} assert redacted == {"foobar": "baz"}
@pytest.mark.parametrize(
'scm_type, url, username, password, check_special_cases, scp_format, expected',
[
# General/random cases
('git', '', True, True, True, False, ''),
('git', 'git://example.com/foo.git', True, True, True, False, 'git://example.com/foo.git'),
('git', 'http://example.com/foo.git', True, True, True, False, 'http://example.com/foo.git'),
('git', 'example.com:bar.git', True, True, True, False, 'git+ssh://example.com/bar.git'),
('git', 'user@example.com:bar.git', True, True, True, False, 'git+ssh://user@example.com/bar.git'),
('git', '127.0.0.1:bar.git', True, True, True, False, 'git+ssh://127.0.0.1/bar.git'),
('git', 'git+ssh://127.0.0.1/bar.git', True, True, True, True, '127.0.0.1:bar.git'),
('git', 'ssh://127.0.0.1:22/bar.git', True, True, True, False, 'ssh://127.0.0.1:22/bar.git'),
('git', 'ssh://root@127.0.0.1:22/bar.git', True, True, True, False, 'ssh://root@127.0.0.1:22/bar.git'),
('git', 'some/path', True, True, True, False, 'file:///some/path'),
('git', '/some/path', True, True, True, False, 'file:///some/path'),
# Invalid URLs - ensure we error properly
('cvs', 'anything', True, True, True, False, ValueError('Unsupported SCM type "cvs"')),
('svn', 'anything-without-colon-slash-slash', True, True, True, False, ValueError('Invalid svn URL')),
('git', 'http://example.com:123invalidport/foo.git', True, True, True, False, ValueError('Invalid git URL')),
('git', 'git+ssh://127.0.0.1/bar.git', True, True, True, False, ValueError('Unsupported git URL')),
('git', 'git@example.com:3000:/git/repo.git', True, True, True, False, ValueError('Invalid git URL')),
('insights', 'git://example.com/foo.git', True, True, True, False, ValueError('Unsupported insights URL')),
('svn', 'file://example/path', True, True, True, False, ValueError('Unsupported host "example" for file:// URL')),
('svn', 'svn:///example', True, True, True, False, ValueError('Host is required for svn URL')),
# Username/password cases
('git', 'https://example@example.com/bar.git', False, True, True, False, 'https://example.com/bar.git'),
('git', 'https://example@example.com/bar.git', 'user', True, True, False, 'https://user@example.com/bar.git'),
('git', 'https://example@example.com/bar.git', 'user:pw', True, True, False, 'https://user%3Apw@example.com/bar.git'),
('git', 'https://example@example.com/bar.git', False, 'pw', True, False, 'https://example.com/bar.git'),
('git', 'https://some:example@example.com/bar.git', True, False, True, False, 'https://some@example.com/bar.git'),
('git', 'https://some:example@example.com/bar.git', False, False, True, False, 'https://example.com/bar.git'),
('git', 'https://example.com/bar.git', 'user', 'pw', True, False, 'https://user:pw@example.com/bar.git'),
('git', 'https://example@example.com/bar.git', False, 'something', True, False, 'https://example.com/bar.git'),
# Special github/bitbucket cases
('git', 'notgit@github.com:ansible/awx.git', True, True, True, False, ValueError('Username must be "git" for SSH access to github.com.')),
(
'git',
'notgit@bitbucket.org:does-not-exist/example.git',
True,
True,
True,
False,
ValueError('Username must be "git" for SSH access to bitbucket.org.'),
),
(
'git',
'notgit@altssh.bitbucket.org:does-not-exist/example.git',
True,
True,
True,
False,
ValueError('Username must be "git" for SSH access to altssh.bitbucket.org.'),
),
('git', 'git:password@github.com:ansible/awx.git', True, True, True, False, 'git+ssh://git@github.com/ansible/awx.git'),
# Disabling the special handling should not raise an error
('git', 'notgit@github.com:ansible/awx.git', True, True, False, False, 'git+ssh://notgit@github.com/ansible/awx.git'),
('git', 'notgit@bitbucket.org:does-not-exist/example.git', True, True, False, False, 'git+ssh://notgit@bitbucket.org/does-not-exist/example.git'),
(
'git',
'notgit@altssh.bitbucket.org:does-not-exist/example.git',
True,
True,
False,
False,
'git+ssh://notgit@altssh.bitbucket.org/does-not-exist/example.git',
),
# awx#12992 - IPv6
('git', 'http://[fd00:1234:2345:6789::11]:3000/foo.git', True, True, True, False, 'http://[fd00:1234:2345:6789::11]:3000/foo.git'),
('git', 'http://foo:bar@[fd00:1234:2345:6789::11]:3000/foo.git', True, True, True, False, 'http://foo:bar@[fd00:1234:2345:6789::11]:3000/foo.git'),
('git', 'example@[fd00:1234:2345:6789::11]:example/foo.git', True, True, True, False, 'git+ssh://example@[fd00:1234:2345:6789::11]/example/foo.git'),
],
)
def test_update_scm_url(scm_type, url, username, password, check_special_cases, scp_format, expected):
if isinstance(expected, Exception):
with pytest.raises(type(expected)) as excinfo:
common.update_scm_url(scm_type, url, username, password, check_special_cases, scp_format)
assert str(excinfo.value) == str(expected)
else:
assert common.update_scm_url(scm_type, url, username, password, check_special_cases, scp_format) == expected
class TestHostnameRegexValidator:
@pytest.fixture
def regex_expr(self):
return '^[a-z0-9][-a-z0-9]*$|^([a-z0-9][-a-z0-9]{0,62}[.])*[a-z0-9][-a-z0-9]{1,62}$'
@pytest.fixture
def re_flags(self):
return re.IGNORECASE
@pytest.fixture
def custom_err_message(self):
return "foobar"
def test_hostame_regex_validator_constructor_with_args(self, regex_expr, re_flags, custom_err_message):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, message=custom_err_message)
assert h.regex == _lazy_re_compile(regex_expr, re_flags)
assert h.message == 'foobar'
assert h.code == 'invalid'
assert h.inverse_match == False
assert h.flags == re_flags
def test_hostame_regex_validator_default_constructor(self, regex_expr, re_flags):
h = HostnameRegexValidator()
assert h.regex == _lazy_re_compile(regex_expr, re_flags)
assert h.message == 'Enter a valid value.'
assert h.code == 'invalid'
assert h.inverse_match == False
assert h.flags == re_flags
def test_good_call(self, regex_expr, re_flags):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags)
assert (h("192.168.56.101"), None)
def test_bad_call(self, regex_expr, re_flags):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags)
try:
h("@#$%)$#(TUFAS_DG")
except ValidationError as e:
assert e.message is not None
def test_good_call_with_inverse(self, regex_expr, re_flags, inverse_match=True):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match)
try:
h("1.2.3.4")
except ValidationError as e:
assert e.message is not None
def test_bad_call_with_inverse(self, regex_expr, re_flags, inverse_match=True):
h = HostnameRegexValidator(regex=regex_expr, flags=re_flags, inverse_match=inverse_match)
assert (h("@#$%)$#(TUFAS_DG"), None)

View File

@@ -264,14 +264,8 @@ def update_scm_url(scm_type, url, username=True, password=True, check_special_ca
userpass, hostpath = url.split('@', 1) userpass, hostpath = url.split('@', 1)
else: else:
userpass, hostpath = '', url userpass, hostpath = '', url
# Handle IPv6 here. In this case, we might have hostpath of: if hostpath.count(':') > 1:
# [fd00:1234:2345:6789::11]:example/foo.git
if hostpath.startswith('[') and ']:' in hostpath:
host, path = hostpath.split(']:', 1)
host = host + ']'
elif hostpath.count(':') > 1:
raise ValueError(_('Invalid %s URL') % scm_type) raise ValueError(_('Invalid %s URL') % scm_type)
else:
host, path = hostpath.split(':', 1) host, path = hostpath.split(':', 1)
# if not path.startswith('/') and not path.startswith('~/'): # if not path.startswith('/') and not path.startswith('~/'):
# path = '~/%s' % path # path = '~/%s' % path
@@ -331,11 +325,7 @@ def update_scm_url(scm_type, url, username=True, password=True, check_special_ca
netloc = u':'.join([urllib.parse.quote(x, safe='') for x in (netloc_username, netloc_password) if x]) netloc = u':'.join([urllib.parse.quote(x, safe='') for x in (netloc_username, netloc_password) if x])
else: else:
netloc = u'' netloc = u''
# urllib.parse strips brackets from IPv6 addresses, so we need to add them back in netloc = u'@'.join(filter(None, [netloc, parts.hostname]))
hostname = parts.hostname
if hostname and ':' in hostname and '[' in url and ']' in url:
hostname = f'[{hostname}]'
netloc = u'@'.join(filter(None, [netloc, hostname]))
if parts.port: if parts.port:
netloc = u':'.join([netloc, str(parts.port)]) netloc = u':'.join([netloc, str(parts.port)])
new_url = urllib.parse.urlunsplit([parts.scheme, netloc, parts.path, parts.query, parts.fragment]) new_url = urllib.parse.urlunsplit([parts.scheme, netloc, parts.path, parts.query, parts.fragment])

View File

@@ -35,7 +35,7 @@ def unwrap_broadcast_msg(payload: dict):
def get_broadcast_hosts(): def get_broadcast_hosts():
Instance = apps.get_model('main', 'Instance') Instance = apps.get_model('main', 'Instance')
instances = ( instances = (
Instance.objects.exclude(hostname=Instance.objects.my_hostname()) Instance.objects.exclude(hostname=Instance.objects.me().hostname)
.exclude(node_type='execution') .exclude(node_type='execution')
.exclude(node_type='hop') .exclude(node_type='hop')
.order_by('hostname') .order_by('hostname')
@@ -47,7 +47,7 @@ def get_broadcast_hosts():
def get_local_host(): def get_local_host():
Instance = apps.get_model('main', 'Instance') Instance = apps.get_model('main', 'Instance')
return Instance.objects.my_hostname() return Instance.objects.me().hostname
class WebsocketTask: class WebsocketTask:

View File

@@ -11,11 +11,9 @@ import ldap
# Django # Django
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.core.signals import setting_changed from django.core.signals import setting_changed
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.db.utils import IntegrityError
# django-auth-ldap # django-auth-ldap
from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings
@@ -329,32 +327,31 @@ class SAMLAuth(BaseSAMLAuth):
return super(SAMLAuth, self).get_user(user_id) return super(SAMLAuth, self).get_user(user_id)
def _update_m2m_from_groups(ldap_user, opts, remove=True): def _update_m2m_from_groups(user, ldap_user, related, opts, remove=True):
""" """
Hepler function to evaluate the LDAP team/org options to determine if LDAP user should Hepler function to update m2m relationship based on LDAP group membership.
be a member of the team/org based on their ldap group dns.
Returns:
True - User should be added
False - User should be removed
None - Users membership should not be changed
""" """
should_add = False
if opts is None: if opts is None:
return None return
elif not opts: elif not opts:
pass pass
elif isinstance(opts, bool) and opts is True: elif opts is True:
return True should_add = True
else: else:
if isinstance(opts, str): if isinstance(opts, str):
opts = [opts] opts = [opts]
# If any of the users groups matches any of the list options
for group_dn in opts: for group_dn in opts:
if not isinstance(group_dn, str): if not isinstance(group_dn, str):
continue continue
if ldap_user._get_groups().is_member_of(group_dn): if ldap_user._get_groups().is_member_of(group_dn):
return True should_add = True
return False if should_add:
user.save()
related.add(user)
elif remove and user in related.all():
user.save()
related.remove(user)
@receiver(populate_user, dispatch_uid='populate-ldap-user') @receiver(populate_user, dispatch_uid='populate-ldap-user')
@@ -386,73 +383,31 @@ def on_populate_user(sender, **kwargs):
force_user_update = True force_user_update = True
logger.warning('LDAP user {} has {} > max {} characters'.format(user.username, field, max_len)) logger.warning('LDAP user {} has {} > max {} characters'.format(user.username, field, max_len))
# Update organization membership based on group memberships.
org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {}) org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {})
team_map = getattr(backend.settings, 'TEAM_MAP', {})
# Move this junk into save of the settings for performance later, there is no need to do that here
# with maybe the exception of someone defining this in settings before the server is started?
# ==============================================================================================================
# Get all of the IDs and names of orgs in the DB and create any new org defined in LDAP that does not exist in the DB
existing_orgs = {}
for (org_id, org_name) in Organization.objects.all().values_list('id', 'name'):
existing_orgs[org_name] = org_id
# Create any orgs (if needed) for all entries in the org and team maps
for org_name in set(list(org_map.keys()) + [item.get('organization', None) for item in team_map.values()]):
if org_name and org_name not in existing_orgs:
logger.info("LDAP adapter is creating org {}".format(org_name))
try:
new_org = Organization.objects.create(name=org_name)
except IntegrityError:
# Another thread must have created this org before we did so now we need to get it
new_org = Organization.objects.get(name=org_name)
# Add the org name to the existing orgs since we created it and we may need it to build the teams below
existing_orgs[org_name] = new_org.id
# Do the same for teams
existing_team_names = list(Team.objects.all().values_list('name', flat=True))
for team_name, team_opts in team_map.items():
if not team_opts.get('organization', None):
# You can't save the LDAP config in the UI w/o an org (or '' or null as the org) so if we somehow got this condition its an error
logger.error("Team named {} in LDAP team map settings is invalid due to missing organization".format(team_name))
continue
if team_name not in existing_team_names:
try:
Team.objects.create(name=team_name, organization_id=existing_orgs[team_opts['organization']])
except IntegrityError:
# If another process got here before us that is ok because we don't need the ID from this team or anything
pass
# End move some day
# ==============================================================================================================
# Compute in memory what the state is of the different LDAP orgs
org_roles_and_ldap_attributes = {'admin_role': 'admins', 'auditor_role': 'auditors', 'member_role': 'users'}
desired_org_states = {}
for org_name, org_opts in org_map.items(): for org_name, org_opts in org_map.items():
org, created = Organization.objects.get_or_create(name=org_name)
remove = bool(org_opts.get('remove', True)) remove = bool(org_opts.get('remove', True))
desired_org_states[org_name] = {} admins_opts = org_opts.get('admins', None)
for org_role_name in org_roles_and_ldap_attributes.keys(): remove_admins = bool(org_opts.get('remove_admins', remove))
ldap_name = org_roles_and_ldap_attributes[org_role_name] _update_m2m_from_groups(user, ldap_user, org.admin_role.members, admins_opts, remove_admins)
opts = org_opts.get(ldap_name, None) auditors_opts = org_opts.get('auditors', None)
remove = bool(org_opts.get('remove_{}'.format(ldap_name), remove)) remove_auditors = bool(org_opts.get('remove_auditors', remove))
desired_org_states[org_name][org_role_name] = _update_m2m_from_groups(ldap_user, opts, remove) _update_m2m_from_groups(user, ldap_user, org.auditor_role.members, auditors_opts, remove_auditors)
users_opts = org_opts.get('users', None)
remove_users = bool(org_opts.get('remove_users', remove))
_update_m2m_from_groups(user, ldap_user, org.member_role.members, users_opts, remove_users)
# If everything returned None (because there was no configuration) we can remove this org from our map # Update team membership based on group memberships.
# This will prevent us from loading the org in the next query team_map = getattr(backend.settings, 'TEAM_MAP', {})
if all(desired_org_states[org_name][org_role_name] is None for org_role_name in org_roles_and_ldap_attributes.keys()):
del desired_org_states[org_name]
# Compute in memory what the state is of the different LDAP teams
desired_team_states = {}
for team_name, team_opts in team_map.items(): for team_name, team_opts in team_map.items():
if 'organization' not in team_opts: if 'organization' not in team_opts:
continue continue
org, created = Organization.objects.get_or_create(name=team_opts['organization'])
team, created = Team.objects.get_or_create(name=team_name, organization=org)
users_opts = team_opts.get('users', None) users_opts = team_opts.get('users', None)
remove = bool(team_opts.get('remove', True)) remove = bool(team_opts.get('remove', True))
state = _update_m2m_from_groups(ldap_user, users_opts, remove) _update_m2m_from_groups(user, ldap_user, team.member_role.members, users_opts, remove)
if state is not None:
desired_team_states[team_name] = {'member_role': state}
# Check if user.profile is available, otherwise force user.save() # Check if user.profile is available, otherwise force user.save()
try: try:
@@ -468,62 +423,3 @@ def on_populate_user(sender, **kwargs):
if profile.ldap_dn != ldap_user.dn: if profile.ldap_dn != ldap_user.dn:
profile.ldap_dn = ldap_user.dn profile.ldap_dn = ldap_user.dn
profile.save() profile.save()
reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, 'LDAP')
def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, source):
from awx.main.models import Organization, Team
content_types = []
reconcile_items = []
if desired_org_states:
content_types.append(ContentType.objects.get_for_model(Organization))
reconcile_items.append(('organization', desired_org_states, Organization))
if desired_team_states:
content_types.append(ContentType.objects.get_for_model(Team))
reconcile_items.append(('team', desired_team_states, Team))
if not content_types:
# If both desired states were empty we can simply return because there is nothing to reconcile
return
# users_roles is a flat set of IDs
users_roles = set(user.roles.filter(content_type__in=content_types).values_list('pk', flat=True))
for object_type, desired_states, model in reconcile_items:
# Get all of the roles in the desired states for efficient DB extraction
roles = []
for sub_dict in desired_states.values():
for role_name in sub_dict:
if sub_dict[role_name] is None:
continue
if role_name not in roles:
roles.append(role_name)
# Get a set of named tuples for the org/team name plus all of the roles we got above
model_roles = model.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True)
for row in model_roles:
for role_name in roles:
desired_state = desired_states.get(row.name, {})
if desired_state[role_name] is None:
# The mapping was not defined for this [org/team]/role so we can just pass
pass
# If somehow the auth adapter knows about an items role but that role is not defined in the DB we are going to print a pretty error
# This is your classic safety net that we should never hit; but here you are reading this comment... good luck and Godspeed.
role_id = getattr(row, role_name, None)
if role_id is None:
logger.error("{} adapter wanted to manage role {} of {} {} but that role is not defined".format(source, role_name, object_type, row.name))
continue
if desired_state[role_name]:
# The desired state was the user mapped into the object_type, if the user was not mapped in map them in
if role_id not in users_roles:
logger.debug("{} adapter adding user {} to {} {} as {}".format(source, user.username, object_type, row.name, role_name))
user.roles.add(role_id)
else:
# The desired state was the user was not mapped into the org, if the user has the permission remove it
if role_id in users_roles:
logger.debug("{} adapter removing user {} permission of {} from {} {}".format(source, user.username, role_name, object_type, row.name))
user.roles.remove(role_id)

View File

@@ -11,6 +11,8 @@ from django.http import HttpResponse
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
from awx.api.serializers import UserSerializer
from rest_framework.renderers import JSONRenderer
from django.conf import settings from django.conf import settings
logger = logging.getLogger('awx.sso.views') logger = logging.getLogger('awx.sso.views')
@@ -40,6 +42,9 @@ class CompleteView(BaseRedirectView):
if self.request.user and self.request.user.is_authenticated: if self.request.user and self.request.user.is_authenticated:
logger.info(smart_str(u"User {} logged in".format(self.request.user.username))) logger.info(smart_str(u"User {} logged in".format(self.request.user.username)))
response.set_cookie('userLoggedIn', 'true') response.set_cookie('userLoggedIn', 'true')
current_user = UserSerializer(self.request.user)
current_user = smart_str(JSONRenderer().render(current_user.data))
current_user = urllib.parse.quote('%s' % current_user, '')
response.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid')) response.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid'))
return response return response

184
awx/ui/package-lock.json generated
View File

@@ -8,14 +8,14 @@
"dependencies": { "dependencies": {
"@lingui/react": "3.14.0", "@lingui/react": "3.14.0",
"@patternfly/patternfly": "4.210.2", "@patternfly/patternfly": "4.210.2",
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.221.3",
"@patternfly/react-icons": "4.90.0", "@patternfly/react-icons": "4.75.1",
"@patternfly/react-table": "4.108.0", "@patternfly/react-table": "4.100.8",
"ace-builds": "^1.10.1", "ace-builds": "^1.10.1",
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",
"axios": "0.27.2", "axios": "0.27.2",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"d3": "7.6.1", "d3": "7.4.4",
"dagre": "^0.8.4", "dagre": "^0.8.4",
"dompurify": "2.4.0", "dompurify": "2.4.0",
"formik": "2.2.9", "formik": "2.2.9",
@@ -31,7 +31,7 @@
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"react-virtualized": "^9.21.1", "react-virtualized": "^9.21.1",
"rrule": "2.7.1", "rrule": "2.7.1",
"styled-components": "5.3.6" "styled-components": "5.3.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.16.10", "@babel/core": "^7.16.10",
@@ -3752,13 +3752,13 @@
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw==" "integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
}, },
"node_modules/@patternfly/react-core": { "node_modules/@patternfly/react-core": {
"version": "4.239.0", "version": "4.231.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==", "integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
"dependencies": { "dependencies": {
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.82.8",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.81.8",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.83.8",
"focus-trap": "6.9.2", "focus-trap": "6.9.2",
"react-dropzone": "9.0.0", "react-dropzone": "9.0.0",
"tippy.js": "5.1.2", "tippy.js": "5.1.2",
@@ -3769,34 +3769,43 @@
"react-dom": "^16.8.0 || ^17.0.0" "react-dom": "^16.8.0 || ^17.0.0"
} }
}, },
"node_modules/@patternfly/react-core/node_modules/@patternfly/react-icons": {
"version": "4.82.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.82.8.tgz",
"integrity": "sha512-cKixprTiMLZRe/+kmdZ5suvYb9ly9p1f/HjlcNiWBfsiA8ZDEPmxJnVdend/YsafelC8YC9QGcQf97ay5PNhcw==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
"react-dom": "^16.8.0 || ^17.0.0"
}
},
"node_modules/@patternfly/react-core/node_modules/tslib": { "node_modules/@patternfly/react-core/node_modules/tslib": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}, },
"node_modules/@patternfly/react-icons": { "node_modules/@patternfly/react-icons": {
"version": "4.90.0", "version": "4.75.1",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==", "integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0", "react": "^16.8.0 || ^17.0.0",
"react-dom": "^16.8.0 || ^17.0.0" "react-dom": "^16.8.0 || ^17.0.0"
} }
}, },
"node_modules/@patternfly/react-styles": { "node_modules/@patternfly/react-styles": {
"version": "4.89.0", "version": "4.81.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ==" "integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
}, },
"node_modules/@patternfly/react-table": { "node_modules/@patternfly/react-table": {
"version": "4.108.0", "version": "4.100.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==", "integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
"dependencies": { "dependencies": {
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.231.8",
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.82.8",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.81.8",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.83.8",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
@@ -3805,15 +3814,24 @@
"react-dom": "^16.8.0 || ^17.0.0" "react-dom": "^16.8.0 || ^17.0.0"
} }
}, },
"node_modules/@patternfly/react-table/node_modules/@patternfly/react-icons": {
"version": "4.82.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.82.8.tgz",
"integrity": "sha512-cKixprTiMLZRe/+kmdZ5suvYb9ly9p1f/HjlcNiWBfsiA8ZDEPmxJnVdend/YsafelC8YC9QGcQf97ay5PNhcw==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
"react-dom": "^16.8.0 || ^17.0.0"
}
},
"node_modules/@patternfly/react-table/node_modules/tslib": { "node_modules/@patternfly/react-table/node_modules/tslib": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==" "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
}, },
"node_modules/@patternfly/react-tokens": { "node_modules/@patternfly/react-tokens": {
"version": "4.91.0", "version": "4.83.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw==" "integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
}, },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": { "node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.4", "version": "0.5.4",
@@ -7464,16 +7482,16 @@
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
}, },
"node_modules/d3": { "node_modules/d3": {
"version": "7.6.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz", "resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==", "integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
"dependencies": { "dependencies": {
"d3-array": "3", "d3-array": "3",
"d3-axis": "3", "d3-axis": "3",
"d3-brush": "3", "d3-brush": "3",
"d3-chord": "3", "d3-chord": "3",
"d3-color": "3", "d3-color": "3",
"d3-contour": "4", "d3-contour": "3",
"d3-delaunay": "6", "d3-delaunay": "6",
"d3-dispatch": "3", "d3-dispatch": "3",
"d3-drag": "3", "d3-drag": "3",
@@ -7504,9 +7522,9 @@
} }
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.0", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==", "integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
"dependencies": { "dependencies": {
"internmap": "1 - 2" "internmap": "1 - 2"
}, },
@@ -7557,11 +7575,11 @@
} }
}, },
"node_modules/d3-contour": { "node_modules/d3-contour": {
"version": "4.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==", "integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
"dependencies": { "dependencies": {
"d3-array": "^3.2.0" "d3-array": "2 - 3"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -20216,9 +20234,9 @@
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==" "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
}, },
"node_modules/styled-components": { "node_modules/styled-components": {
"version": "5.3.6", "version": "5.3.5",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==", "integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@babel/helper-module-imports": "^7.0.0", "@babel/helper-module-imports": "^7.0.0",
@@ -25094,19 +25112,25 @@
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw==" "integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
}, },
"@patternfly/react-core": { "@patternfly/react-core": {
"version": "4.239.0", "version": "4.231.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==", "integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
"requires": { "requires": {
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.82.8",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.81.8",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.83.8",
"focus-trap": "6.9.2", "focus-trap": "6.9.2",
"react-dropzone": "9.0.0", "react-dropzone": "9.0.0",
"tippy.js": "5.1.2", "tippy.js": "5.1.2",
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"@patternfly/react-icons": {
"version": "4.82.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.82.8.tgz",
"integrity": "sha512-cKixprTiMLZRe/+kmdZ5suvYb9ly9p1f/HjlcNiWBfsiA8ZDEPmxJnVdend/YsafelC8YC9QGcQf97ay5PNhcw==",
"requires": {}
},
"tslib": { "tslib": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
@@ -25115,29 +25139,35 @@
} }
}, },
"@patternfly/react-icons": { "@patternfly/react-icons": {
"version": "4.90.0", "version": "4.75.1",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==", "integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
"requires": {} "requires": {}
}, },
"@patternfly/react-styles": { "@patternfly/react-styles": {
"version": "4.89.0", "version": "4.81.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ==" "integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
}, },
"@patternfly/react-table": { "@patternfly/react-table": {
"version": "4.108.0", "version": "4.100.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==", "integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
"requires": { "requires": {
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.231.8",
"@patternfly/react-icons": "^4.90.0", "@patternfly/react-icons": "^4.82.8",
"@patternfly/react-styles": "^4.89.0", "@patternfly/react-styles": "^4.81.8",
"@patternfly/react-tokens": "^4.91.0", "@patternfly/react-tokens": "^4.83.8",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"@patternfly/react-icons": {
"version": "4.82.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.82.8.tgz",
"integrity": "sha512-cKixprTiMLZRe/+kmdZ5suvYb9ly9p1f/HjlcNiWBfsiA8ZDEPmxJnVdend/YsafelC8YC9QGcQf97ay5PNhcw==",
"requires": {}
},
"tslib": { "tslib": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
@@ -25146,9 +25176,9 @@
} }
}, },
"@patternfly/react-tokens": { "@patternfly/react-tokens": {
"version": "4.91.0", "version": "4.83.8",
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw==" "integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
}, },
"@pmmmwh/react-refresh-webpack-plugin": { "@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.4", "version": "0.5.4",
@@ -28052,16 +28082,16 @@
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
}, },
"d3": { "d3": {
"version": "7.6.1", "version": "7.4.4",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz", "resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==", "integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
"requires": { "requires": {
"d3-array": "3", "d3-array": "3",
"d3-axis": "3", "d3-axis": "3",
"d3-brush": "3", "d3-brush": "3",
"d3-chord": "3", "d3-chord": "3",
"d3-color": "3", "d3-color": "3",
"d3-contour": "4", "d3-contour": "3",
"d3-delaunay": "6", "d3-delaunay": "6",
"d3-dispatch": "3", "d3-dispatch": "3",
"d3-drag": "3", "d3-drag": "3",
@@ -28089,9 +28119,9 @@
} }
}, },
"d3-array": { "d3-array": {
"version": "3.2.0", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==", "integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
"requires": { "requires": {
"internmap": "1 - 2" "internmap": "1 - 2"
} }
@@ -28127,11 +28157,11 @@
"integrity": "sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw==" "integrity": "sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw=="
}, },
"d3-contour": { "d3-contour": {
"version": "4.0.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==", "integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
"requires": { "requires": {
"d3-array": "^3.2.0" "d3-array": "2 - 3"
} }
}, },
"d3-delaunay": { "d3-delaunay": {
@@ -37675,9 +37705,9 @@
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==" "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
}, },
"styled-components": { "styled-components": {
"version": "5.3.6", "version": "5.3.5",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==", "integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
"requires": { "requires": {
"@babel/helper-module-imports": "^7.0.0", "@babel/helper-module-imports": "^7.0.0",
"@babel/traverse": "^7.4.5", "@babel/traverse": "^7.4.5",

View File

@@ -8,14 +8,14 @@
"dependencies": { "dependencies": {
"@lingui/react": "3.14.0", "@lingui/react": "3.14.0",
"@patternfly/patternfly": "4.210.2", "@patternfly/patternfly": "4.210.2",
"@patternfly/react-core": "^4.239.0", "@patternfly/react-core": "^4.221.3",
"@patternfly/react-icons": "4.90.0", "@patternfly/react-icons": "4.75.1",
"@patternfly/react-table": "4.108.0", "@patternfly/react-table": "4.100.8",
"ace-builds": "^1.10.1", "ace-builds": "^1.10.1",
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",
"axios": "0.27.2", "axios": "0.27.2",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"d3": "7.6.1", "d3": "7.4.4",
"dagre": "^0.8.4", "dagre": "^0.8.4",
"dompurify": "2.4.0", "dompurify": "2.4.0",
"formik": "2.2.9", "formik": "2.2.9",
@@ -31,7 +31,7 @@
"react-router-dom": "^5.3.3", "react-router-dom": "^5.3.3",
"react-virtualized": "^9.21.1", "react-virtualized": "^9.21.1",
"rrule": "2.7.1", "rrule": "2.7.1",
"styled-components": "5.3.6" "styled-components": "5.3.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.16.10", "@babel/core": "^7.16.10",

View File

@@ -3,12 +3,7 @@ import { Plural, t } from '@lingui/macro';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { useKebabifiedMenu } from 'contexts/Kebabified'; import { useKebabifiedMenu } from 'contexts/Kebabified';
function HealthCheckButton({ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
isDisabled,
onClick,
selectedItems,
healthCheckPending,
}) {
const { isKebabified } = useKebabifiedMenu(); const { isKebabified } = useKebabifiedMenu();
const selectedItemsCount = selectedItems.length; const selectedItemsCount = selectedItems.length;
@@ -33,10 +28,8 @@ function HealthCheckButton({
component="button" component="button"
onClick={onClick} onClick={onClick}
ouiaId="health-check" ouiaId="health-check"
isLoading={healthCheckPending}
spinnerAriaLabel={t`Running health check`}
> >
{healthCheckPending ? t`Running health check` : t`Run health check`} {t`Run health check`}
</DropdownItem> </DropdownItem>
</Tooltip> </Tooltip>
); );
@@ -49,11 +42,7 @@ function HealthCheckButton({
variant="secondary" variant="secondary"
ouiaId="health-check" ouiaId="health-check"
onClick={onClick} onClick={onClick}
isLoading={healthCheckPending} >{t`Run health check`}</Button>
spinnerAriaLabel={t`Running health check`}
>
{healthCheckPending ? t`Running health check` : t`Run health check`}
</Button>
</div> </div>
</Tooltip> </Tooltip>
); );

View File

@@ -107,17 +107,6 @@ function LaunchButton({ resource, children }) {
jobPromise = JobsAPI.relaunch(resource.id, params || {}); jobPromise = JobsAPI.relaunch(resource.id, params || {});
} else if (resource.type === 'workflow_job') { } else if (resource.type === 'workflow_job') {
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {}); jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
} else if (resource.type === 'ad_hoc_command') {
if (params?.credential_passwords) {
// The api expects the passwords at the top level of the object instead of nested
// in credential_passwords like the other relaunch endpoints
Object.keys(params.credential_passwords).forEach((key) => {
params[key] = params.credential_passwords[key];
});
delete params.credential_passwords;
}
jobPromise = AdHocCommandsAPI.relaunch(resource.id, params || {});
} }
const { data: job } = await jobPromise; const { data: job } = await jobPromise;

View File

@@ -129,7 +129,7 @@ function PromptModalForm({
}} }}
title={t`Launch | ${resource.name}`} title={t`Launch | ${resource.name}`}
description={ description={
resource.description?.length > 512 ? ( resource.description.length > 512 ? (
<ExpandableSection <ExpandableSection
toggleText={ toggleText={
showDescription ? t`Hide description` : t`Show description` showDescription ? t`Hide description` : t`Show description`

View File

@@ -67,14 +67,14 @@ function ScheduleForm({
if (schedule.id) { if (schedule.id) {
if ( if (
resource.type === 'job_template' && resource.type === 'job_template' &&
launchConfig?.ask_credential_on_launch launchConfig.ask_credential_on_launch
) { ) {
const { const {
data: { results }, data: { results },
} = await SchedulesAPI.readCredentials(schedule.id); } = await SchedulesAPI.readCredentials(schedule.id);
creds = results; creds = results;
} }
if (launchConfig?.ask_labels_on_launch) { if (launchConfig.ask_labels_on_launch) {
const { const {
data: { results }, data: { results },
} = await SchedulesAPI.readAllLabels(schedule.id); } = await SchedulesAPI.readAllLabels(schedule.id);
@@ -82,7 +82,7 @@ function ScheduleForm({
} }
if ( if (
resource.type === 'job_template' && resource.type === 'job_template' &&
launchConfig?.ask_instance_groups_on_launch launchConfig.ask_instance_groups_on_launch
) { ) {
const { const {
data: { results }, data: { results },
@@ -91,7 +91,7 @@ function ScheduleForm({
} }
} else { } else {
if (resource.type === 'job_template') { if (resource.type === 'job_template') {
if (launchConfig?.ask_labels_on_launch) { if (launchConfig.ask_labels_on_launch) {
const { const {
data: { results }, data: { results },
} = await JobTemplatesAPI.readAllLabels(resource.id); } = await JobTemplatesAPI.readAllLabels(resource.id);
@@ -100,7 +100,7 @@ function ScheduleForm({
} }
if ( if (
resource.type === 'workflow_job_template' && resource.type === 'workflow_job_template' &&
launchConfig?.ask_labels_on_launch launchConfig.ask_labels_on_launch
) { ) {
const { const {
data: { results }, data: { results },
@@ -123,7 +123,14 @@ function ScheduleForm({
zoneLinks: data.links, zoneLinks: data.links,
credentials: creds, credentials: creds,
}; };
}, [schedule, resource.id, resource.type, launchConfig]), }, [
schedule,
resource.id,
resource.type,
launchConfig.ask_labels_on_launch,
launchConfig.ask_instance_groups_on_launch,
launchConfig.ask_credential_on_launch,
]),
{ {
zonesOptions: [], zonesOptions: [],
zoneLinks: {}, zoneLinks: {},
@@ -139,7 +146,7 @@ function ScheduleForm({
const missingRequiredInventory = useCallback(() => { const missingRequiredInventory = useCallback(() => {
let missingInventory = false; let missingInventory = false;
if ( if (
launchConfig?.inventory_needed_to_start && launchConfig.inventory_needed_to_start &&
!schedule?.summary_fields?.inventory?.id !schedule?.summary_fields?.inventory?.id
) { ) {
missingInventory = true; missingInventory = true;

View File

@@ -12,7 +12,7 @@ import {
Tooltip, Tooltip,
Slider, Slider,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { CaretLeftIcon, OutlinedClockIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
@@ -23,7 +23,6 @@ import ErrorDetail from 'components/ErrorDetail';
import DisassociateButton from 'components/DisassociateButton'; import DisassociateButton from 'components/DisassociateButton';
import InstanceToggle from 'components/InstanceToggle'; import InstanceToggle from 'components/InstanceToggle';
import { CardBody, CardActionsRow } from 'components/Card'; import { CardBody, CardActionsRow } from 'components/Card';
import getDocsBaseUrl from 'util/getDocsBaseUrl';
import { formatDateString } from 'util/dates'; import { formatDateString } from 'util/dates';
import RoutedTabs from 'components/RoutedTabs'; import RoutedTabs from 'components/RoutedTabs';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
@@ -63,7 +62,7 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
} }
function InstanceDetails({ setBreadcrumb, instanceGroup }) { function InstanceDetails({ setBreadcrumb, instanceGroup }) {
const config = useConfig(); const { me = {} } = useConfig();
const { id, instanceId } = useParams(); const { id, instanceId } = useParams();
const history = useHistory(); const history = useHistory();
@@ -116,9 +115,15 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
useEffect(() => { useEffect(() => {
fetchDetails(); fetchDetails();
}, [fetchDetails]); }, [fetchDetails]);
const { error: healthCheckError, request: fetchHealthCheck } = useRequest( const {
error: healthCheckError,
isLoading: isRunningHealthCheck,
request: fetchHealthCheck,
} = useRequest(
useCallback(async () => { useCallback(async () => {
const { status } = await InstancesAPI.healthCheck(instanceId); const { status } = await InstancesAPI.healthCheck(instanceId);
const { data } = await InstancesAPI.readHealthCheckDetail(instanceId);
setHealthCheck(data);
if (status === 200) { if (status === 200) {
setShowHealthCheckAlert(true); setShowHealthCheckAlert(true);
} }
@@ -156,18 +161,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
debounceUpdateInstance({ capacity_adjustment: roundedValue }); debounceUpdateInstance({ capacity_adjustment: roundedValue });
}; };
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const { error, dismissError } = useDismissableError( const { error, dismissError } = useDismissableError(
disassociateError || updateInstanceError || healthCheckError disassociateError || updateInstanceError || healthCheckError
); );
@@ -196,8 +189,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
return <ContentLoading />; return <ContentLoading />;
} }
const isExecutionNode = instance.node_type === 'execution';
return ( return (
<> <>
<RoutedTabs tabsArray={tabsArray} /> <RoutedTabs tabsArray={tabsArray} />
@@ -227,22 +218,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
<Detail label={t`Total Jobs`} value={instance.jobs_total} /> <Detail label={t`Total Jobs`} value={instance.jobs_total} />
<Detail <Detail
label={t`Last Health Check`} label={t`Last Health Check`}
helpText={ value={formatDateString(healthCheck?.last_health_check)}
<>
{t`Health checks are asynchronous tasks. See the`}{' '}
<a
href={`${getDocsBaseUrl(
config
)}/html/administration/instances.html#health-check`}
target="_blank"
rel="noopener noreferrer"
>
{t`documentation`}
</a>{' '}
{t`for more info.`}
</>
}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/> />
<Detail label={t`Node Type`} value={instance.node_type} /> <Detail label={t`Node Type`} value={instance.node_type} />
<Detail <Detail
@@ -261,7 +237,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
step={0.1} step={0.1}
value={instance.capacity_adjustment} value={instance.capacity_adjustment}
onChange={handleChangeValue} onChange={handleChangeValue}
isDisabled={!config?.me?.is_superuser || !instance.enabled} isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider" data-cy="slider"
/> />
</SliderForks> </SliderForks>
@@ -298,25 +274,19 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
)} )}
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}> <Tooltip content={t`Run a health check on the instance`}>
<Button <Button
isDisabled={ isDisabled={!me.is_superuser || isRunningHealthCheck}
!config?.me?.is_superuser || instance.health_check_pending
}
variant="primary" variant="primary"
ouiaId="health-check-button" ouiaId="health-check-button"
onClick={fetchHealthCheck} onClick={fetchHealthCheck}
isLoading={instance.health_check_pending} isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`} spinnerAriaLabel={t`Running health check`}
> >
{instance.health_check_pending {t`Run health check`}
? t`Running health check`
: t`Run health check`}
</Button> </Button>
</Tooltip> </Tooltip>
)} {me.is_superuser && instance.node_type !== 'control' && (
{config?.me?.is_superuser && instance.node_type !== 'control' && (
<DisassociateButton <DisassociateButton
verifyCannotDisassociate={instanceGroup.name === 'controlplane'} verifyCannotDisassociate={instanceGroup.name === 'controlplane'}
key="disassociate" key="disassociate"

View File

@@ -87,9 +87,8 @@ describe('<InstanceDetails/>', () => {
mem_capacity: 38, mem_capacity: 38,
enabled: true, enabled: true,
managed_by_policy: true, managed_by_policy: true,
node_type: 'execution', node_type: 'hybrid',
node_state: 'ready', node_state: 'ready',
health_check_pending: false,
}, },
}); });
InstancesAPI.readHealthCheckDetail.mockResolvedValue({ InstancesAPI.readHealthCheckDetail.mockResolvedValue({
@@ -348,67 +347,6 @@ describe('<InstanceDetails/>', () => {
expect(wrapper.find('ErrorDetail')).toHaveLength(1); expect(wrapper.find('ErrorDetail')).toHaveLength(1);
}); });
test.each([
[1, 'hybrid', 0],
[2, 'hop', 0],
[3, 'control', 0],
])(
'hide health check button for non-execution type nodes',
async (a, b, expected) => {
InstancesAPI.readDetail.mockResolvedValue({
data: {
id: a,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
named_url: '/api/v2/instances/awx_1/',
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
health_check: '/api/v2/instances/1/health_check/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx_1',
created: '2021-09-08T17:10:34.484569Z',
modified: '2021-09-09T13:55:44.219900Z',
last_seen: '2021-09-09T20:20:31.623148Z',
last_health_check: '2021-09-09T20:20:31.623148Z',
errors: '',
capacity_adjustment: '1.00',
version: '19.1.0',
capacity: 38,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 0,
cpu: 8,
memory: 6232231936,
cpu_capacity: 32,
mem_capacity: 38,
enabled: true,
managed_by_policy: true,
node_type: b,
node_state: 'ready',
health_check_pending: false,
},
});
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']")).toHaveLength(
expected
);
}
);
test('Should call disassociate', async () => { test('Should call disassociate', async () => {
InstanceGroupsAPI.readInstances.mockResolvedValue({ InstanceGroupsAPI.readInstances.mockResolvedValue({
data: { data: {

View File

@@ -35,8 +35,6 @@ const QS_CONFIG = getQSConfig('instance', {
function InstanceList({ instanceGroup }) { function InstanceList({ instanceGroup }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
const location = useLocation(); const location = useLocation();
const { id: instanceGroupId } = useParams(); const { id: instanceGroupId } = useParams();
@@ -58,10 +56,6 @@ function InstanceList({ instanceGroup }) {
InstanceGroupsAPI.readInstances(instanceGroupId, params), InstanceGroupsAPI.readInstances(instanceGroupId, params),
InstanceGroupsAPI.readInstanceOptions(instanceGroupId), InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
]); ]);
const isPending = response.data.results.some(
(i) => i.health_check_pending === true
);
setPendingHealthCheck(isPending);
return { return {
instances: response.data.results, instances: response.data.results,
count: response.data.count, count: response.data.count,
@@ -96,7 +90,7 @@ function InstanceList({ instanceGroup }) {
useCallback(async () => { useCallback(async () => {
const [...response] = await Promise.all( const [...response] = await Promise.all(
selected selected
.filter(({ node_type }) => node_type === 'execution') .filter(({ node_type }) => node_type !== 'hop')
.map(({ id }) => InstancesAPI.healthCheck(id)) .map(({ id }) => InstancesAPI.healthCheck(id))
); );
if (response) { if (response) {
@@ -105,18 +99,6 @@ function InstanceList({ instanceGroup }) {
}, [selected]) }, [selected])
); );
useEffect(() => {
if (selected) {
selected.forEach((i) => {
if (i.node_type === 'execution') {
setCanRunHealthCheck(true);
} else {
setCanRunHealthCheck(false);
}
});
}
}, [selected]);
const handleHealthCheck = async () => { const handleHealthCheck = async () => {
await fetchHealthCheck(); await fetchHealthCheck();
clearSelected(); clearSelected();
@@ -264,10 +246,9 @@ function InstanceList({ instanceGroup }) {
isProtectedInstanceGroup={instanceGroup.name === 'controlplane'} isProtectedInstanceGroup={instanceGroup.name === 'controlplane'}
/>, />,
<HealthCheckButton <HealthCheckButton
isDisabled={!canAdd || !canRunHealthCheck} isDisabled={!canAdd}
onClick={handleHealthCheck} onClick={handleHealthCheck}
selectedItems={selected} selectedItems={selected}
healthCheckPending={pendingHealthCheck}
/>, />,
]} ]}
emptyStateControls={ emptyStateControls={
@@ -282,10 +263,7 @@ function InstanceList({ instanceGroup }) {
)} )}
headerRow={ headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable> <HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell <HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
tooltip={t`Health checks can only be run on execution nodes.`}
sortKey="hostname"
>{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell> <HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell> <HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell> <HeaderCell>{t`Capacity Adjustment`}</HeaderCell>

View File

@@ -172,7 +172,7 @@ describe('<InstanceList/>', () => {
await act(async () => await act(async () =>
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')() wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
); );
expect(InstancesAPI.healthCheck).toBeCalledTimes(1); expect(InstancesAPI.healthCheck).toBeCalledTimes(3);
}); });
test('should render health check error', async () => { test('should render health check error', async () => {
InstancesAPI.healthCheck.mockRejectedValue( InstancesAPI.healthCheck.mockRejectedValue(

View File

@@ -11,9 +11,7 @@ import {
Slider, Slider,
Tooltip, Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { OutlinedClockIcon } from '@patternfly/react-icons';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import getDocsBaseUrl from 'util/getDocsBaseUrl';
import { formatDateString } from 'util/dates'; import { formatDateString } from 'util/dates';
import { ActionsTd, ActionItem } from 'components/PaginatedTable'; import { ActionsTd, ActionItem } from 'components/PaginatedTable';
import InstanceToggle from 'components/InstanceToggle'; import InstanceToggle from 'components/InstanceToggle';
@@ -54,7 +52,7 @@ function InstanceListItem({
fetchInstances, fetchInstances,
rowIndex, rowIndex,
}) { }) {
const config = useConfig(); const { me = {} } = useConfig();
const { id } = useParams(); const { id } = useParams();
const [forks, setForks] = useState( const [forks, setForks] = useState(
computeForks( computeForks(
@@ -102,18 +100,6 @@ function InstanceListItem({
debounceUpdateInstance({ capacity_adjustment: roundedValue }); debounceUpdateInstance({ capacity_adjustment: roundedValue });
}; };
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
return ( return (
<> <>
<Tr <Tr
@@ -168,7 +154,7 @@ function InstanceListItem({
step={0.1} step={0.1}
value={instance.capacity_adjustment} value={instance.capacity_adjustment}
onChange={handleChangeValue} onChange={handleChangeValue}
isDisabled={!config?.me?.is_superuser || !instance.enabled} isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider" data-cy="slider"
/> />
</SliderForks> </SliderForks>
@@ -220,22 +206,7 @@ function InstanceListItem({
<Detail <Detail
data-cy="last-health-check" data-cy="last-health-check"
label={t`Last Health Check`} label={t`Last Health Check`}
helpText={ value={formatDateString(instance.last_health_check)}
<>
{t`Health checks are asynchronous tasks. See the`}{' '}
<a
href={`${getDocsBaseUrl(
config
)}/html/administration/instances.html#health-check`}
target="_blank"
rel="noopener noreferrer"
>
{t`documentation`}
</a>{' '}
{t`for more info.`}
</>
}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/> />
</DetailList> </DetailList>
</ExpandableRowContent> </ExpandableRowContent>

View File

@@ -281,8 +281,8 @@ describe('<InstanceListItem/>', () => {
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe( expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
'Auto' 'Auto'
); );
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe( expect(
'Last Health Check9/15/2021, 6:02:07 PM' wrapper.find('Detail[label="Last Health Check"]').prop('value')
); ).toBe('9/15/2021, 6:02:07 PM');
}); });
}); });

View File

@@ -13,7 +13,7 @@ import {
Slider, Slider,
Label, Label,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { DownloadIcon, OutlinedClockIcon } from '@patternfly/react-icons'; import { DownloadIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
@@ -23,7 +23,6 @@ import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail'; import ErrorDetail from 'components/ErrorDetail';
import InstanceToggle from 'components/InstanceToggle'; import InstanceToggle from 'components/InstanceToggle';
import { CardBody, CardActionsRow } from 'components/Card'; import { CardBody, CardActionsRow } from 'components/Card';
import getDocsBaseUrl from 'util/getDocsBaseUrl';
import { formatDateString } from 'util/dates'; import { formatDateString } from 'util/dates';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading'; import ContentLoading from 'components/ContentLoading';
@@ -63,8 +62,7 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
} }
function InstanceDetail({ setBreadcrumb, isK8s }) { function InstanceDetail({ setBreadcrumb, isK8s }) {
const config = useConfig(); const { me = {} } = useConfig();
const { id } = useParams(); const { id } = useParams();
const [forks, setForks] = useState(); const [forks, setForks] = useState();
const history = useHistory(); const history = useHistory();
@@ -87,7 +85,8 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
InstancesAPI.readDetail(id), InstancesAPI.readDetail(id),
InstancesAPI.readInstanceGroup(id), InstancesAPI.readInstanceGroup(id),
]); ]);
if (details.node_type === 'execution') {
if (details.node_type !== 'hop') {
const { data: healthCheckData } = const { data: healthCheckData } =
await InstancesAPI.readHealthCheckDetail(id); await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(healthCheckData); setHealthCheck(healthCheckData);
@@ -116,9 +115,15 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
setBreadcrumb(instance); setBreadcrumb(instance);
} }
}, [instance, setBreadcrumb]); }, [instance, setBreadcrumb]);
const { error: healthCheckError, request: fetchHealthCheck } = useRequest( const {
error: healthCheckError,
isLoading: isRunningHealthCheck,
request: fetchHealthCheck,
} = useRequest(
useCallback(async () => { useCallback(async () => {
const { status } = await InstancesAPI.healthCheck(id); const { status } = await InstancesAPI.healthCheck(id);
const { data } = await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(data);
if (status === 200) { if (status === 200) {
setShowHealthCheckAlert(true); setShowHealthCheckAlert(true);
} }
@@ -144,18 +149,6 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
debounceUpdateInstance({ capacity_adjustment: roundedValue }); debounceUpdateInstance({ capacity_adjustment: roundedValue });
}; };
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const buildLinkURL = (inst) => const buildLinkURL = (inst) =>
inst.is_container_group inst.is_container_group
? '/instance_groups/container_group/' ? '/instance_groups/container_group/'
@@ -186,7 +179,6 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
return <ContentLoading />; return <ContentLoading />;
} }
const isHopNode = instance.node_type === 'hop'; const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return ( return (
<> <>
@@ -250,22 +242,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
<Detail <Detail
label={t`Last Health Check`} label={t`Last Health Check`}
dataCy="last-health-check" dataCy="last-health-check"
helpText={ value={formatDateString(healthCheck?.last_health_check)}
<>
{t`Health checks are asynchronous tasks. See the`}{' '}
<a
href={`${getDocsBaseUrl(
config
)}/html/administration/instances.html#health-check`}
target="_blank"
rel="noopener noreferrer"
>
{t`documentation`}
</a>{' '}
{t`for more info.`}
</>
}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/> />
{instance.related?.install_bundle && ( {instance.related?.install_bundle && (
<Detail <Detail
@@ -303,9 +280,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
step={0.1} step={0.1}
value={instance.capacity_adjustment} value={instance.capacity_adjustment}
onChange={handleChangeValue} onChange={handleChangeValue}
isDisabled={ isDisabled={!me?.is_superuser || !instance.enabled}
!config?.me?.is_superuser || !instance.enabled
}
data-cy="slider" data-cy="slider"
/> />
</SliderForks> </SliderForks>
@@ -349,7 +324,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
</DetailList> </DetailList>
{!isHopNode && ( {!isHopNode && (
<CardActionsRow> <CardActionsRow>
{config?.me?.is_superuser && isK8s && isExecutionNode && ( {me.is_superuser && isK8s && instance.node_type === 'execution' && (
<RemoveInstanceButton <RemoveInstanceButton
dataCy="remove-instance-button" dataCy="remove-instance-button"
itemsToRemove={[instance]} itemsToRemove={[instance]}
@@ -357,24 +332,18 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
onRemove={removeInstances} onRemove={removeInstances}
/> />
)} )}
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}> <Tooltip content={t`Run a health check on the instance`}>
<Button <Button
isDisabled={ isDisabled={!me.is_superuser || isRunningHealthCheck}
!config?.me?.is_superuser || instance.health_check_pending
}
variant="primary" variant="primary"
ouiaId="health-check-button" ouiaId="health-check-button"
onClick={fetchHealthCheck} onClick={fetchHealthCheck}
isLoading={instance.health_check_pending} isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`} spinnerAriaLabel={t`Running health check`}
> >
{instance.health_check_pending {t`Run health check`}
? t`Running health check`
: t`Run health check`}
</Button> </Button>
</Tooltip> </Tooltip>
)}
<InstanceToggle <InstanceToggle
css="display: inline-flex;" css="display: inline-flex;"
fetchInstances={fetchDetails} fetchInstances={fetchDetails}

View File

@@ -49,9 +49,8 @@ describe('<InstanceDetail/>', () => {
mem_capacity: 38, mem_capacity: 38,
enabled: true, enabled: true,
managed_by_policy: true, managed_by_policy: true,
node_type: 'execution', node_type: 'hybrid',
node_state: 'ready', node_state: 'ready',
health_check_pending: false,
}, },
}); });
InstancesAPI.readInstanceGroup.mockResolvedValue({ InstancesAPI.readInstanceGroup.mockResolvedValue({

View File

@@ -37,8 +37,6 @@ function InstanceList() {
const location = useLocation(); const location = useLocation();
const { me } = useConfig(); const { me } = useConfig();
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false); const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
const { const {
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s }, result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s },
@@ -53,10 +51,6 @@ function InstanceList() {
InstancesAPI.readOptions(), InstancesAPI.readOptions(),
SettingsAPI.readCategory('system'), SettingsAPI.readCategory('system'),
]); ]);
const isPending = response.data.results.some(
(i) => i.health_check_pending === true
);
setPendingHealthCheck(isPending);
return { return {
instances: response.data.results, instances: response.data.results,
isK8s: sysSettings.data.IS_K8S, isK8s: sysSettings.data.IS_K8S,
@@ -93,7 +87,7 @@ function InstanceList() {
useCallback(async () => { useCallback(async () => {
const [...response] = await Promise.all( const [...response] = await Promise.all(
selected selected
.filter(({ node_type }) => node_type === 'execution') .filter(({ node_type }) => node_type !== 'hop')
.map(({ id }) => InstancesAPI.healthCheck(id)) .map(({ id }) => InstancesAPI.healthCheck(id))
); );
if (response) { if (response) {
@@ -102,18 +96,6 @@ function InstanceList() {
}, [selected]) }, [selected])
); );
useEffect(() => {
if (selected) {
selected.forEach((i) => {
if (i.node_type === 'execution') {
setCanRunHealthCheck(true);
} else {
setCanRunHealthCheck(false);
}
});
}
}, [selected]);
const handleHealthCheck = async () => { const handleHealthCheck = async () => {
await fetchHealthCheck(); await fetchHealthCheck();
clearSelected(); clearSelected();
@@ -207,8 +189,6 @@ function InstanceList() {
onClick={handleHealthCheck} onClick={handleHealthCheck}
key="healthCheck" key="healthCheck"
selectedItems={selected} selectedItems={selected}
healthCheckPending={pendingHealthCheck}
isDisabled={!canRunHealthCheck}
/>, />,
]} ]}
/> />
@@ -216,7 +196,7 @@ function InstanceList() {
headerRow={ headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable> <HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell <HeaderCell
tooltip={t`Health checks can only be run on execution nodes.`} tooltip={t`Cannot run health check on hop nodes.`}
sortKey="hostname" sortKey="hostname"
>{t`Name`}</HeaderCell> >{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell> <HeaderCell sortKey="errors">{t`Status`}</HeaderCell>

View File

@@ -32,7 +32,7 @@ const instances = [
jobs_running: 0, jobs_running: 0,
jobs_total: 68, jobs_total: 68,
cpu: 6, cpu: 6,
node_type: 'execution', node_type: 'control',
node_state: 'ready', node_state: 'ready',
memory: 2087469056, memory: 2087469056,
cpu_capacity: 24, cpu_capacity: 24,
@@ -52,7 +52,7 @@ const instances = [
jobs_running: 0, jobs_running: 0,
jobs_total: 68, jobs_total: 68,
cpu: 6, cpu: 6,
node_type: 'execution', node_type: 'hybrid',
node_state: 'ready', node_state: 'ready',
memory: 2087469056, memory: 2087469056,
cpu_capacity: 24, cpu_capacity: 24,

View File

@@ -11,9 +11,7 @@ import {
Slider, Slider,
Tooltip, Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { OutlinedClockIcon } from '@patternfly/react-icons';
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
import getDocsBaseUrl from 'util/getDocsBaseUrl';
import { formatDateString } from 'util/dates'; import { formatDateString } from 'util/dates';
import computeForks from 'util/computeForks'; import computeForks from 'util/computeForks';
import { ActionsTd, ActionItem } from 'components/PaginatedTable'; import { ActionsTd, ActionItem } from 'components/PaginatedTable';
@@ -54,7 +52,7 @@ function InstanceListItem({
fetchInstances, fetchInstances,
rowIndex, rowIndex,
}) { }) {
const config = useConfig(); const { me = {} } = useConfig();
const [forks, setForks] = useState( const [forks, setForks] = useState(
computeForks( computeForks(
instance.mem_capacity, instance.mem_capacity,
@@ -100,21 +98,7 @@ function InstanceListItem({
); );
debounceUpdateInstance({ capacity_adjustment: roundedValue }); debounceUpdateInstance({ capacity_adjustment: roundedValue });
}; };
const formatHealthCheckTimeStamp = (last) => (
<>
{formatDateString(last)}
{instance.health_check_pending ? (
<>
{' '}
<OutlinedClockIcon />
</>
) : null}
</>
);
const isHopNode = instance.node_type === 'hop'; const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return ( return (
<> <>
<Tr <Tr
@@ -137,7 +121,7 @@ function InstanceListItem({
rowIndex, rowIndex,
isSelected, isSelected,
onSelect, onSelect,
disable: !isExecutionNode, disable: isHopNode,
}} }}
dataLabel={t`Selected`} dataLabel={t`Selected`}
/> />
@@ -180,7 +164,7 @@ function InstanceListItem({
step={0.1} step={0.1}
value={instance.capacity_adjustment} value={instance.capacity_adjustment}
onChange={handleChangeValue} onChange={handleChangeValue}
isDisabled={!config?.me?.is_superuser || !instance.enabled} isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider" data-cy="slider"
/> />
</SliderForks> </SliderForks>
@@ -237,22 +221,7 @@ function InstanceListItem({
<Detail <Detail
data-cy="last-health-check" data-cy="last-health-check"
label={t`Last Health Check`} label={t`Last Health Check`}
helpText={ value={formatDateString(instance.last_health_check)}
<>
{t`Health checks are asynchronous tasks. See the`}{' '}
<a
href={`${getDocsBaseUrl(
config
)}/html/administration/instances.html#health-check`}
target="_blank"
rel="noopener noreferrer"
>
{t`documentation`}
</a>{' '}
{t`for more info.`}
</>
}
value={formatHealthCheckTimeStamp(instance.last_health_check)}
/> />
</DetailList> </DetailList>
</ExpandableRowContent> </ExpandableRowContent>

View File

@@ -272,9 +272,9 @@ describe('<InstanceListItem/>', () => {
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe( expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
'Auto' 'Auto'
); );
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe( expect(
'Last Health Check9/15/2021, 6:02:07 PM' wrapper.find('Detail[label="Last Health Check"]').prop('value')
); ).toBe('9/15/2021, 6:02:07 PM');
}); });
test('Hop should not render some things', async () => { test('Hop should not render some things', async () => {
const onSelect = jest.fn(); const onSelect = jest.fn();

View File

@@ -91,7 +91,9 @@ const SmartInventoryFormFields = ({ inventory }) => {
id="variables" id="variables"
name="variables" name="variables"
label={t`Variables`} label={t`Variables`}
tooltip={t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Controller documentation for example syntax.`} tooltip={t`Enter inventory variables using either JSON or YAML syntax.
Use the radio button to toggle between the two. Refer to the
Ansible Controller documentation for example syntax.`}
/> />
</FormFullWidthLayout> </FormFullWidthLayout>
</> </>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { DateTime, Duration } from 'luxon';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { bool, shape, func } from 'prop-types'; import { bool, shape, func } from 'prop-types';
import { import {
@@ -41,18 +41,18 @@ const Wrapper = styled.div`
flex-flow: row wrap; flex-flow: row wrap;
font-size: 14px; font-size: 14px;
`; `;
const calculateElapsed = (started) => {
const now = DateTime.now();
const duration = now
.diff(DateTime.fromISO(`${started}`), [
'milliseconds',
'seconds',
'minutes',
'hours',
])
.toObject();
return Duration.fromObject({ ...duration }).toFormat('hh:mm:ss'); const toHHMMSS = (elapsed) => {
const sec_num = parseInt(elapsed, 10);
const hours = Math.floor(sec_num / 3600);
const minutes = Math.floor(sec_num / 60) % 60;
const seconds = sec_num % 60;
const stampHours = hours < 10 ? `0${hours}` : hours;
const stampMinutes = minutes < 10 ? `0${minutes}` : minutes;
const stampSeconds = seconds < 10 ? `0${seconds}` : seconds;
return `${stampHours}:${stampMinutes}:${stampSeconds}`;
}; };
const OUTPUT_NO_COUNT_JOB_TYPES = [ const OUTPUT_NO_COUNT_JOB_TYPES = [
@@ -62,7 +62,6 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
]; ];
const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => { const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
const [activeJobElapsedTime, setActiveJobElapsedTime] = useState('00:00:00');
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type); const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count; const playCount = job?.playbook_counts?.play_count;
@@ -77,20 +76,6 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
: 0; : 0;
const { me } = useConfig(); const { me } = useConfig();
useEffect(() => {
let secTimer;
if (job.finished) {
return () => clearInterval(secTimer);
}
secTimer = setInterval(() => {
const elapsedTime = calculateElapsed(job.started);
setActiveJobElapsedTime(elapsedTime);
}, 1000);
return () => clearInterval(secTimer);
}, [job.started, job.finished]);
return ( return (
<Wrapper> <Wrapper>
{!hideCounts && ( {!hideCounts && (
@@ -139,13 +124,7 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
<BadgeGroup aria-label={t`Elapsed Time`}> <BadgeGroup aria-label={t`Elapsed Time`}>
<div>{t`Elapsed`}</div> <div>{t`Elapsed`}</div>
<Tooltip content={t`Elapsed time that the job ran`}> <Tooltip content={t`Elapsed time that the job ran`}>
<Badge isRead> <Badge isRead>{toHHMMSS(job.elapsed)}</Badge>
{job.finished
? Duration.fromObject({ seconds: job.elapsed }).toFormat(
'hh:mm:ss'
)
: activeJobElapsedTime}
</Badge>
</Tooltip> </Tooltip>
</BadgeGroup> </BadgeGroup>
{['pending', 'waiting', 'running'].includes(jobStatus) && {['pending', 'waiting', 'running'].includes(jobStatus) &&

View File

@@ -180,6 +180,8 @@ function ManagementJob({ setBreadcrumb }) {
loadSchedules={loadSchedules} loadSchedules={loadSchedules}
loadScheduleOptions={loadScheduleOptions} loadScheduleOptions={loadScheduleOptions}
setBreadcrumb={setBreadcrumb} setBreadcrumb={setBreadcrumb}
launchConfig={{}}
surveyConfig={{}}
/> />
</Route> </Route>
) : null} ) : null}

View File

@@ -9,7 +9,7 @@ function ProjectEdit({ project }) {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();
const handleSubmit = async (values) => { const handleSubmit = async ({ ...values }) => {
if (values.scm_type === 'manual') { if (values.scm_type === 'manual') {
values.scm_type = ''; values.scm_type = '';
} }

View File

@@ -31,6 +31,7 @@ describe('<ProjectEdit />', () => {
summary_fields: { summary_fields: {
credential: { credential: {
id: 100, id: 100,
name: 'insights',
credential_type_id: 5, credential_type_id: 5,
kind: 'insights', kind: 'insights',
}, },

View File

@@ -1,7 +1,6 @@
/* eslint no-nested-ternary: 0 */ /* eslint no-nested-ternary: 0 */
import React, { useCallback, useState, useEffect } from 'react'; import React, { useCallback, useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { Form, FormGroup, Title } from '@patternfly/react-core'; import { Form, FormGroup, Title } from '@patternfly/react-core';
@@ -17,6 +16,7 @@ import ExecutionEnvironmentLookup from 'components/Lookup/ExecutionEnvironmentLo
import { CredentialTypesAPI, ProjectsAPI } from 'api'; import { CredentialTypesAPI, ProjectsAPI } from 'api';
import { required } from 'util/validators'; import { required } from 'util/validators';
import { FormColumnLayout, SubFormLayout } from 'components/FormLayout'; import { FormColumnLayout, SubFormLayout } from 'components/FormLayout';
import useRequest from 'hooks/useRequest';
import getProjectHelpText from './Project.helptext'; import getProjectHelpText from './Project.helptext';
import { import {
GitSubForm, GitSubForm,
@@ -26,67 +26,12 @@ import {
ManualSubForm, ManualSubForm,
} from './ProjectSubForms'; } from './ProjectSubForms';
const fetchCredentials = async (credential) => {
const [
{
data: {
results: [scmCredentialType],
},
},
{
data: {
results: [insightsCredentialType],
},
},
{
data: {
results: [cryptographyCredentialType],
},
},
] = await Promise.all([
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
CredentialTypesAPI.read({ kind: 'cryptography' }),
]);
if (!credential) {
return {
scm: { typeId: scmCredentialType.id },
insights: { typeId: insightsCredentialType.id },
cryptography: { typeId: cryptographyCredentialType.id },
};
}
const { credential_type_id } = credential;
return {
scm: {
typeId: scmCredentialType.id,
value: credential_type_id === scmCredentialType.id ? credential : null,
},
insights: {
typeId: insightsCredentialType.id,
value:
credential_type_id === insightsCredentialType.id ? credential : null,
},
cryptography: {
typeId: cryptographyCredentialType.id,
value:
credential_type_id === cryptographyCredentialType.id
? credential
: null,
},
};
};
function ProjectFormFields({ function ProjectFormFields({
project, project,
project_base_dir, project_base_dir,
project_local_paths, project_local_paths,
formik, credentialTypeIds,
setCredentials,
setSignatureValidationCredentials,
credentials,
signatureValidationCredentials,
scmTypeOptions, scmTypeOptions,
setScmSubFormState, setScmSubFormState,
scmSubFormState, scmSubFormState,
@@ -96,8 +41,7 @@ function ProjectFormFields({
scm_url: '', scm_url: '',
scm_branch: '', scm_branch: '',
scm_refspec: '', scm_refspec: '',
credential: '', credential: null,
signature_validation_credential: '',
scm_clean: false, scm_clean: false,
scm_delete_on_update: false, scm_delete_on_update: false,
scm_track_submodules: false, scm_track_submodules: false,
@@ -105,7 +49,8 @@ function ProjectFormFields({
allow_override: false, allow_override: false,
scm_update_cache_timeout: 0, scm_update_cache_timeout: 0,
}; };
const { setFieldValue, setFieldTouched } = useFormikContext(); const { setFieldValue, setFieldTouched, values, initialValues } =
useFormikContext();
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({ const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
name: 'scm_type', name: 'scm_type',
@@ -121,11 +66,11 @@ function ProjectFormFields({
] = useField('default_environment'); ] = useField('default_environment');
/* Save current scm subform field values to state */ /* Save current scm subform field values to state */
const saveSubFormState = (form) => { const saveSubFormState = () => {
const currentScmFormFields = { ...scmFormFields }; const currentScmFormFields = { ...scmFormFields };
Object.keys(currentScmFormFields).forEach((label) => { Object.keys(currentScmFormFields).forEach((label) => {
currentScmFormFields[label] = form.values[label]; currentScmFormFields[label] = values[label];
}); });
setScmSubFormState(currentScmFormFields); setScmSubFormState(currentScmFormFields);
@@ -137,58 +82,27 @@ function ProjectFormFields({
* If scm type is === the initial scm type value, * If scm type is === the initial scm type value,
* reset scm subform field values to scmSubFormState. * reset scm subform field values to scmSubFormState.
*/ */
const resetScmTypeFields = (value, form) => { const resetScmTypeFields = (value) => {
if (form.values.scm_type === form.initialValues.scm_type) { if (values.scm_type === initialValues.scm_type) {
saveSubFormState(formik); saveSubFormState();
} }
Object.keys(scmFormFields).forEach((label) => { Object.keys(scmFormFields).forEach((label) => {
if (value === form.initialValues.scm_type) { if (value === initialValues.scm_type) {
form.setFieldValue(label, scmSubFormState[label]); setFieldValue(label, scmSubFormState[label]);
} else { } else {
form.setFieldValue(label, scmFormFields[label]); setFieldValue(label, scmFormFields[label]);
} }
form.setFieldTouched(label, false); setFieldTouched(label, false);
}); });
}; };
const handleCredentialSelection = useCallback(
(type, value) => {
setCredentials({
...credentials,
[type]: {
...credentials[type],
value,
},
});
},
[credentials, setCredentials]
);
const handleSignatureValidationCredentialSelection = useCallback(
(type, value) => {
setSignatureValidationCredentials({
...signatureValidationCredentials,
[type]: {
...signatureValidationCredentials[type],
value,
},
});
},
[signatureValidationCredentials, setSignatureValidationCredentials]
);
const handleSignatureValidationCredentialChange = useCallback( const handleSignatureValidationCredentialChange = useCallback(
(value) => { (cred) => {
handleSignatureValidationCredentialSelection('cryptography', value); setFieldValue('signature_validation_credential', cred);
setFieldValue('signature_validation_credential', value);
setFieldTouched('signature_validation_credential', true, false); setFieldTouched('signature_validation_credential', true, false);
}, },
[ [setFieldTouched, setFieldValue]
handleSignatureValidationCredentialSelection,
setFieldValue,
setFieldTouched,
]
); );
const handleOrganizationUpdate = useCallback( const handleOrganizationUpdate = useCallback(
@@ -281,18 +195,18 @@ function ProjectFormFields({
]} ]}
onChange={(event, value) => { onChange={(event, value) => {
scmTypeHelpers.setValue(value); scmTypeHelpers.setValue(value);
resetScmTypeFields(value, formik); resetScmTypeFields(value);
}} }}
/> />
</FormGroup> </FormGroup>
<CredentialLookup <CredentialLookup
credentialTypeId={signatureValidationCredentials.cryptography.typeId} credentialTypeId={credentialTypeIds?.cryptography}
label={t`Content Signature Validation Credential`} label={t`Content Signature Validation Credential`}
onChange={handleSignatureValidationCredentialChange} onChange={handleSignatureValidationCredentialChange}
value={signatureValidationCredentials.cryptography.value} value={values.signature_validation_credential}
tooltip={projectHelpText.signatureValidation} tooltip={projectHelpText.signatureValidation}
/> />
{formik.values.scm_type !== '' && ( {values.scm_type !== '' && (
<SubFormLayout> <SubFormLayout>
<Title size="md" headingLevel="h4"> <Title size="md" headingLevel="h4">
{t`Type Details`} {t`Type Details`}
@@ -302,43 +216,39 @@ function ProjectFormFields({
{ {
manual: ( manual: (
<ManualSubForm <ManualSubForm
localPath={formik.initialValues.local_path} localPath={initialValues.local_path}
project_base_dir={project_base_dir} project_base_dir={project_base_dir}
project_local_paths={project_local_paths} project_local_paths={project_local_paths}
/> />
), ),
git: ( git: (
<GitSubForm <GitSubForm
credential={credentials.scm} credentialTypeId={credentialTypeIds.scm}
onCredentialSelection={handleCredentialSelection} scmUpdateOnLaunch={values.scm_update_on_launch}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
svn: ( svn: (
<SvnSubForm <SvnSubForm
credential={credentials.scm} credentialTypeId={credentialTypeIds.scm}
onCredentialSelection={handleCredentialSelection} scmUpdateOnLaunch={values.scm_update_on_launch}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
archive: ( archive: (
<ArchiveSubForm <ArchiveSubForm
credential={credentials.scm} credentialTypeId={credentialTypeIds.scm}
onCredentialSelection={handleCredentialSelection} scmUpdateOnLaunch={values.scm_update_on_launch}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
/> />
), ),
insights: ( insights: (
<InsightsSubForm <InsightsSubForm
credential={credentials.insights} credentialTypeId={credentialTypeIds.insights}
onCredentialSelection={handleCredentialSelection} scmUpdateOnLaunch={values.scm_update_on_launch}
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
autoPopulateCredential={ autoPopulateCredential={
!project?.id || project?.scm_type !== 'insights' !project?.id || project?.scm_type !== 'insights'
} }
/> />
), ),
}[formik.values.scm_type] }[values.scm_type]
} }
</FormColumnLayout> </FormColumnLayout>
</SubFormLayout> </SubFormLayout>
@@ -348,16 +258,13 @@ function ProjectFormFields({
} }
function ProjectForm({ project, submitError, ...props }) { function ProjectForm({ project, submitError, ...props }) {
const { handleCancel, handleSubmit } = props; const { handleCancel, handleSubmit } = props;
const { summary_fields = {} } = project;
const { project_base_dir, project_local_paths } = useConfig(); const { project_base_dir, project_local_paths } = useConfig();
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [scmSubFormState, setScmSubFormState] = useState({ const [scmSubFormState, setScmSubFormState] = useState({
scm_url: '', scm_url: '',
scm_branch: '', scm_branch: '',
scm_refspec: '', scm_refspec: '',
credential: '', credential: null,
signature_validation_credential: '',
scm_clean: false, scm_clean: false,
scm_delete_on_update: false, scm_delete_on_update: false,
scm_track_submodules: false, scm_track_submodules: false,
@@ -365,27 +272,16 @@ function ProjectForm({ project, submitError, ...props }) {
allow_override: false, allow_override: false,
scm_update_cache_timeout: 0, scm_update_cache_timeout: 0,
}); });
const [scmTypeOptions, setScmTypeOptions] = useState(null);
const [credentials, setCredentials] = useState({
scm: { typeId: null, value: null },
insights: { typeId: null, value: null },
cryptography: { typeId: null, value: null },
});
const [signatureValidationCredentials, setSignatureValidationCredentials] =
useState({
scm: { typeId: null, value: null },
insights: { typeId: null, value: null },
cryptography: { typeId: null, value: null },
});
useEffect(() => {
async function fetchData() {
try {
const credentialResponse = fetchCredentials(summary_fields.credential);
const signatureValidationCredentialResponse = fetchCredentials(
summary_fields.signature_validation_credential
);
const { const {
result: { scmTypeOptions, credentialTypeIds },
error: contentError,
isLoading,
request: fetchData,
} = useRequest(
useCallback(async () => {
const [
{
data: { data: {
actions: { actions: {
GET: { GET: {
@@ -393,25 +289,47 @@ function ProjectForm({ project, submitError, ...props }) {
}, },
}, },
}, },
} = await ProjectsAPI.readOptions(); },
{
setCredentials(await credentialResponse); data: {
setSignatureValidationCredentials( results: [scmCredentialType],
await signatureValidationCredentialResponse },
); },
setScmTypeOptions(choices); {
} catch (error) { data: {
setContentError(error); results: [insightsCredentialType],
} finally { },
setIsLoading(false); },
} {
} data: {
results: [cryptographyCredentialType],
fetchData(); },
}, [ },
summary_fields.credential, ] = await Promise.all([
summary_fields.signature_validation_credential, ProjectsAPI.readOptions(),
CredentialTypesAPI.read({ kind: 'scm' }),
CredentialTypesAPI.read({ name: 'Insights' }),
CredentialTypesAPI.read({ kind: 'cryptography' }),
]); ]);
return {
scmTypeOptions: choices,
credentialTypeIds: {
scm: scmCredentialType.id,
insights: insightsCredentialType.id,
cryptography: cryptographyCredentialType.id,
},
};
}, []),
{
scmTypeOptions: [],
credentialTypeIds: { scm: '', insights: '', cryptography: '' },
isLoading: true,
}
);
useEffect(() => {
fetchData();
}, [fetchData]);
if (isLoading) { if (isLoading) {
return <ContentLoading />; return <ContentLoading />;
@@ -426,11 +344,11 @@ function ProjectForm({ project, submitError, ...props }) {
initialValues={{ initialValues={{
allow_override: project.allow_override || false, allow_override: project.allow_override || false,
base_dir: project_base_dir || '', base_dir: project_base_dir || '',
credential: project.credential || '', credential: project?.summary_fields?.credential || null,
description: project.description || '', description: project.description || '',
local_path: project.local_path || '', local_path: project.local_path || '',
name: project.name || '', name: project.name || '',
organization: project.summary_fields?.organization || null, organization: project?.summary_fields?.organization || null,
scm_branch: project.scm_branch || '', scm_branch: project.scm_branch || '',
scm_clean: project.scm_clean || false, scm_clean: project.scm_clean || false,
scm_delete_on_update: project.scm_delete_on_update || false, scm_delete_on_update: project.scm_delete_on_update || false,
@@ -446,7 +364,7 @@ function ProjectForm({ project, submitError, ...props }) {
scm_update_on_launch: project.scm_update_on_launch || false, scm_update_on_launch: project.scm_update_on_launch || false,
scm_url: project.scm_url || '', scm_url: project.scm_url || '',
signature_validation_credential: signature_validation_credential:
project.signature_validation_credential || '', project?.summary_fields?.signature_validation_credential || null,
default_environment: default_environment:
project.summary_fields?.default_environment || null, project.summary_fields?.default_environment || null,
}} }}
@@ -459,13 +377,7 @@ function ProjectForm({ project, submitError, ...props }) {
project={project} project={project}
project_base_dir={project_base_dir} project_base_dir={project_base_dir}
project_local_paths={project_local_paths} project_local_paths={project_local_paths}
formik={formik} credentialTypeIds={credentialTypeIds}
setCredentials={setCredentials}
setSignatureValidationCredentials={
setSignatureValidationCredentials
}
credentials={credentials}
signatureValidationCredentials={signatureValidationCredentials}
scmTypeOptions={scmTypeOptions} scmTypeOptions={scmTypeOptions}
setScmSubFormState={setScmSubFormState} setScmSubFormState={setScmSubFormState}
scmSubFormState={scmSubFormState} scmSubFormState={scmSubFormState}

View File

@@ -132,8 +132,7 @@ describe('<ProjectForm />', () => {
<ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} /> <ProjectForm handleSubmit={jest.fn()} handleCancel={jest.fn()} />
); );
}); });
waitForElement(wrapper, 'ProjectForm', (el) => el.length === 1);
expect(wrapper.find('ProjectForm').length).toBe(1);
}); });
test('new form displays primary form fields', async () => { test('new form displays primary form fields', async () => {
@@ -149,6 +148,10 @@ describe('<ProjectForm />', () => {
expect(wrapper.find('FormGroup[label="Source Control Type"]').length).toBe( expect(wrapper.find('FormGroup[label="Source Control Type"]').length).toBe(
1 1
); );
expect(
wrapper.find('FormGroup[label="Content Signature Validation Credential"]')
.length
).toBe(1);
expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe( expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe(
0 0
); );
@@ -163,7 +166,7 @@ describe('<ProjectForm />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await act(async () => { await act(async () => {
await wrapper.find('AnsibleSelect[id="scm_type"]').invoke('onChange')( wrapper.find('AnsibleSelect[id="scm_type"]').invoke('onChange')(
null, null,
'git' 'git'
); );
@@ -178,17 +181,9 @@ describe('<ProjectForm />', () => {
expect( expect(
wrapper.find('FormGroup[label="Source Control Refspec"]').length wrapper.find('FormGroup[label="Source Control Refspec"]').length
).toBe(1); ).toBe(1);
expect(
wrapper.find('FormGroup[label="Content Signature Validation Credential"]')
.length
).toBe(1);
expect( expect(
wrapper.find('FormGroup[label="Source Control Credential"]').length wrapper.find('FormGroup[label="Source Control Credential"]').length
).toBe(1); ).toBe(1);
expect(
wrapper.find('FormGroup[label="Content Signature Validation Credential"]')
.length
).toBe(1);
expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1);
}); });

View File

@@ -8,19 +8,12 @@ import {
ScmTypeOptions, ScmTypeOptions,
} from './SharedFields'; } from './SharedFields';
const ArchiveSubForm = ({ const ArchiveSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
credential,
onCredentialSelection,
scmUpdateOnLaunch,
}) => {
const projectHelpText = getProjectHelpText(); const projectHelpText = getProjectHelpText();
return ( return (
<> <>
<UrlFormField tooltip={projectHelpText.archiveUrl} /> <UrlFormField tooltip={projectHelpText.archiveUrl} />
<ScmCredentialFormField <ScmCredentialFormField credentialTypeId={credentialTypeId} />
credential={credential}
onCredentialSelection={onCredentialSelection}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>
); );

View File

@@ -13,11 +13,7 @@ import {
} from './SharedFields'; } from './SharedFields';
import getProjectHelpStrings from '../Project.helptext'; import getProjectHelpStrings from '../Project.helptext';
const GitSubForm = ({ const GitSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
credential,
onCredentialSelection,
scmUpdateOnLaunch,
}) => {
const docsURL = `${getDocsBaseUrl( const docsURL = `${getDocsBaseUrl(
useConfig() useConfig()
)}/html/userguide/projects.html#manage-playbooks-using-source-control`; )}/html/userguide/projects.html#manage-playbooks-using-source-control`;
@@ -35,10 +31,7 @@ const GitSubForm = ({
tooltipMaxWidth="400px" tooltipMaxWidth="400px"
tooltip={projectHelpStrings.sourceControlRefspec(docsURL)} tooltip={projectHelpStrings.sourceControlRefspec(docsURL)}
/> />
<ScmCredentialFormField <ScmCredentialFormField credentialTypeId={credentialTypeId} />
credential={credential}
onCredentialSelection={onCredentialSelection}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>
); );

View File

@@ -1,39 +1,37 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField, useFormikContext } from 'formik'; import { useFormikContext, useField } from 'formik';
import CredentialLookup from 'components/Lookup/CredentialLookup'; import CredentialLookup from 'components/Lookup/CredentialLookup';
import { required } from 'util/validators'; import { required } from 'util/validators';
import { ScmTypeOptions } from './SharedFields'; import { ScmTypeOptions } from './SharedFields';
const InsightsSubForm = ({ const InsightsSubForm = ({
credential, credentialTypeId,
onCredentialSelection,
scmUpdateOnLaunch, scmUpdateOnLaunch,
autoPopulateCredential, autoPopulateCredential,
}) => { }) => {
const { setFieldValue, setFieldTouched } = useFormikContext(); const { setFieldValue, setFieldTouched } = useFormikContext();
const [, credMeta, credHelpers] = useField('credential'); const [credField, credMeta, credHelpers] = useField('credential');
const onCredentialChange = useCallback( const onCredentialChange = useCallback(
(value) => { (value) => {
onCredentialSelection('insights', value);
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false); setFieldTouched('credential', true, false);
}, },
[onCredentialSelection, setFieldValue, setFieldTouched] [setFieldValue, setFieldTouched]
); );
return ( return (
<> <>
<CredentialLookup <CredentialLookup
credentialTypeId={credential.typeId} credentialTypeId={credentialTypeId}
label={t`Insights Credential`} label={t`Insights Credential`}
helperTextInvalid={credMeta.error} helperTextInvalid={credMeta.error}
isValid={!credMeta.touched || !credMeta.error} isValid={!credMeta.touched || !credMeta.error}
onBlur={() => credHelpers.setTouched()} onBlur={() => credHelpers.setTouched()}
onChange={onCredentialChange} onChange={onCredentialChange}
value={credential.value} value={credField.value}
required required
autoPopulate={autoPopulateCredential} autoPopulate={autoPopulateCredential}
validate={required(t`Select a value for this field`)} validate={required(t`Select a value for this field`)}

View File

@@ -35,27 +35,23 @@ export const BranchFormField = ({ label }) => {
); );
}; };
export const ScmCredentialFormField = ({ export const ScmCredentialFormField = ({ credentialTypeId }) => {
credential, const { setFieldValue, setFieldTouched, values } = useFormikContext();
onCredentialSelection,
}) => {
const { setFieldValue, setFieldTouched } = useFormikContext();
const onCredentialChange = useCallback( const handleChange = useCallback(
(value) => { (value) => {
onCredentialSelection('scm', value);
setFieldValue('credential', value); setFieldValue('credential', value);
setFieldTouched('credential', true, false); setFieldTouched('credential', true, false);
}, },
[onCredentialSelection, setFieldValue, setFieldTouched] [setFieldValue, setFieldTouched]
); );
return ( return (
<CredentialLookup <CredentialLookup
credentialTypeId={credential.typeId} credentialTypeId={credentialTypeId}
label={t`Source Control Credential`} label={t`Source Control Credential`}
value={credential.value} value={values.credential}
onChange={onCredentialChange} onChange={handleChange}
/> />
); );
}; };

View File

@@ -10,20 +10,13 @@ import {
ScmTypeOptions, ScmTypeOptions,
} from './SharedFields'; } from './SharedFields';
const SvnSubForm = ({ const SvnSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
credential,
onCredentialSelection,
scmUpdateOnLaunch,
}) => {
const projectHelpStrings = getProjectHelpStrings(); const projectHelpStrings = getProjectHelpStrings();
return ( return (
<> <>
<UrlFormField tooltip={projectHelpStrings.svnSourceControlUrl} /> <UrlFormField tooltip={projectHelpStrings.svnSourceControlUrl} />
<BranchFormField label={t`Revision #`} /> <BranchFormField label={t`Revision #`} />
<ScmCredentialFormField <ScmCredentialFormField credentialTypeId={credentialTypeId} />
credential={credential}
onCredentialSelection={onCredentialSelection}
/>
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} /> <ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
</> </>
); );

View File

@@ -59,14 +59,13 @@ function PlaybookSelect({
onToggle={setIsOpen} onToggle={setIsOpen}
placeholderText={t`Select a playbook`} placeholderText={t`Select a playbook`}
typeAheadAriaLabel={t`Select a playbook`} typeAheadAriaLabel={t`Select a playbook`}
isCreatable isCreatable={false}
createText=""
onSelect={(event, value) => { onSelect={(event, value) => {
setIsOpen(false); setIsOpen(false);
onChange(value); onChange(value);
}} }}
id="template-playbook" id="template-playbook"
validated={isValid ? 'default' : 'error'} isValid={isValid}
onBlur={onBlur} onBlur={onBlur}
isDisabled={isLoading || isDisabled} isDisabled={isLoading || isDisabled}
maxHeight="1000%" maxHeight="1000%"

View File

@@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils';
import '@testing-library/jest-dom';
import { ProjectsAPI } from 'api'; import { ProjectsAPI } from 'api';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import PlaybookSelect from './PlaybookSelect'; import PlaybookSelect from './PlaybookSelect';
jest.mock('api'); jest.mock('../../../api');
describe('<PlaybookSelect />', () => { describe('<PlaybookSelect />', () => {
beforeEach(() => { beforeEach(() => {
ProjectsAPI.readPlaybooks.mockReturnValue({ ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml', 'test.yml'], data: ['debug.yml'],
}); });
}); });
@@ -18,7 +18,9 @@ describe('<PlaybookSelect />', () => {
}); });
test('should reload playbooks when project value changes', async () => { test('should reload playbooks when project value changes', async () => {
const { rerender } = render( let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<PlaybookSelect <PlaybookSelect
projectId={1} projectId={1}
isValid isValid
@@ -26,82 +28,14 @@ describe('<PlaybookSelect />', () => {
onError={() => {}} onError={() => {}}
/> />
); );
await waitFor(() => {
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
}); });
rerender( expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
<PlaybookSelect await act(async () => {
projectId={15} wrapper.setProps({ projectId: 15 });
isValid });
onChange={() => {}}
onError={() => {}}
/>
);
await waitFor(() => {
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2); expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15); expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
}); });
}); });
test('should trigger the onChange callback for the option selected from the list', async () => {
const mockCallback = jest.fn();
const { container } = render(
<PlaybookSelect
projectId={1}
isValid={true}
onChange={mockCallback}
onError={() => {}}
/>
);
await waitFor(() => {
const selectToggleButton = container.querySelector(
'button.pf-c-select__toggle-button'
);
fireEvent.click(selectToggleButton);
// Select options are displayed
expect(screen.getAllByRole('option').length).toBe(2);
fireEvent.click(screen.getByText('debug.yml'));
expect(mockCallback).toHaveBeenCalledWith('debug.yml');
});
});
test('should allow entering playbook file name manually', async () => {
const mockCallback = jest.fn();
const { container } = render(
<PlaybookSelect
projectId={1}
isValid={true}
onChange={mockCallback}
onError={() => {}}
/>
);
await waitFor(() => {
const input = container.querySelector('input.pf-c-form-control');
expect(input).toBeVisible();
fireEvent.change(input, { target: { value: 'foo.yml' } });
});
await waitFor(() => {
// A new select option is displayed ("foo.yml")
expect(
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
).toBeVisible();
expect(screen.getAllByRole('option').length).toBe(1);
fireEvent.click(
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
);
expect(mockCallback).toHaveBeenCalledWith('foo.yml');
});
});
});

View File

@@ -26,9 +26,9 @@ const Wrapper = styled.div`
position: absolute; position: absolute;
left: 0; left: 0;
padding: 0 10px; padding: 0 10px;
min-width: 150px; width: 150px;
background-color: rgba(255, 255, 255, 0.85); background-color: rgba(255, 255, 255, 0.85);
overflow: auto; overflow: scroll;
height: 100%; height: 100%;
`; `;
const Button = styled(PFButton)` const Button = styled(PFButton)`

View File

@@ -40,13 +40,8 @@ const Loader = styled(ContentLoading)`
width: 100%; width: 100%;
background: white; background: white;
`; `;
function MeshGraph({ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
data, const [storedNodes, setStoredNodes] = useState(null);
showLegend,
zoom,
setShowZoomControls,
storedNodes,
}) {
const [isNodeSelected, setIsNodeSelected] = useState(false); const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null); const [simulationProgress, setSimulationProgress] = useState(null);
@@ -105,14 +100,19 @@ function MeshGraph({
// update mesh when user toggles enabled/disabled slider // update mesh when user toggles enabled/disabled slider
useEffect(() => { useEffect(() => {
if (instance?.id) { if (instance?.id) {
const updatedNodes = storedNodes.current.map((n) => const updatedNodes = storedNodes.map((n) =>
n.id === instance.id ? { ...n, enabled: instance.enabled } : n n.id === instance.id ? { ...n, enabled: instance.enabled } : n
); );
storedNodes.current = updatedNodes; setStoredNodes(updatedNodes);
updateNodeSVG(storedNodes.current);
} }
}, [instance]); // eslint-disable-line react-hooks/exhaustive-deps }, [instance]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (storedNodes) {
updateNodeSVG(storedNodes);
}
}, [storedNodes]);
const draw = () => { const draw = () => {
let width; let width;
let height; let height;
@@ -137,9 +137,6 @@ function MeshGraph({
const mesh = svg.append('g').attr('class', 'mesh'); const mesh = svg.append('g').attr('class', 'mesh');
const graph = data; const graph = data;
if (storedNodes?.current) {
graph.nodes = storedNodes.current;
}
/* WEB WORKER */ /* WEB WORKER */
const worker = webWorker(); const worker = webWorker();
@@ -165,6 +162,7 @@ function MeshGraph({
} }
function ended({ nodes, links }) { function ended({ nodes, links }) {
setStoredNodes(nodes);
// Remove loading screen // Remove loading screen
d3.select('.simulation-loader').style('visibility', 'hidden'); d3.select('.simulation-loader').style('visibility', 'hidden');
setShowZoomControls(true); setShowZoomControls(true);
@@ -249,6 +247,7 @@ function MeshGraph({
.attr('fill', DEFAULT_NODE_COLOR) .attr('fill', DEFAULT_NODE_COLOR)
.attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`)) .attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`))
.attr('stroke', DEFAULT_NODE_STROKE_COLOR); .attr('stroke', DEFAULT_NODE_STROKE_COLOR);
// node type labels // node type labels
node node
.append('text') .append('text')

View File

@@ -37,7 +37,7 @@ const Wrapper = styled.div`
padding: 0 10px; padding: 0 10px;
width: 25%; width: 25%;
background-color: rgba(255, 255, 255, 0.85); background-color: rgba(255, 255, 255, 0.85);
overflow: auto; overflow: scroll;
height: 100%; height: 100%;
`; `;
const Button = styled(PFButton)` const Button = styled(PFButton)`

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback, useState, useRef } from 'react'; import React, { useEffect, useCallback, useState } from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card, CardBody } from '@patternfly/react-core'; import { PageSection, Card, CardBody } from '@patternfly/react-core';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
@@ -10,7 +10,6 @@ import useZoom from './utils/useZoom';
import { CHILDSELECTOR, PARENTSELECTOR } from './constants'; import { CHILDSELECTOR, PARENTSELECTOR } from './constants';
function TopologyView() { function TopologyView() {
const storedNodes = useRef(null);
const [showLegend, setShowLegend] = useState(true); const [showLegend, setShowLegend] = useState(true);
const [showZoomControls, setShowZoomControls] = useState(false); const [showZoomControls, setShowZoomControls] = useState(false);
const { const {
@@ -21,7 +20,6 @@ function TopologyView() {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await MeshAPI.read(); const { data } = await MeshAPI.read();
storedNodes.current = data.nodes;
return { return {
meshData: data, meshData: data,
}; };
@@ -66,7 +64,6 @@ function TopologyView() {
showLegend={showLegend} showLegend={showLegend}
zoom={zoom} zoom={zoom}
setShowZoomControls={setShowZoomControls} setShowZoomControls={setShowZoomControls}
storedNodes={storedNodes}
/> />
)} )}
</CardBody> </CardBody>

View File

@@ -63,7 +63,7 @@ The playbook requires the Receptor collection which can be obtained via
Modify `inventory.yml`. Set the `ansible_user` and any other ansible variables that may be needed to run playbooks against the remote machine. 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.yml` to start installing Receptor on 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. 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.

View File

@@ -1,108 +0,0 @@
# Project Signing and Verification
Project signing and verification allows project maintainers to sign their
project directory files with GPG and verify them at project-update time in
AWX/Controller.
## Signing
Signing is provided by a CLI tool and library called
[`ansible-sign`](https://github.com/ansible/ansible-sign) which makes use of
`python-gnupg` to ultimately shell out to GPG to do signing. Currently the only
supported end-user use of this tool is as a CLI utility, but it does provide a
somewhat clean API as well for internal use, and we use this in our verification
process in AWX. More on that below.
`ansible-sign` expects a `MANIFEST.in` file (written in valid `distlib.manifest`
format familiar to most Python project maintainers) which lists the files that
should be included and excluded from the signing process.
Internally, there is a concept of "differs", and a differ is what allows us to
know if files have been added or removed along with which files we should care
about signing and verifying. Currently only one is shipped and supported and
that is the `DistlibManifestChecksumFileExistenceDiffer` which uses
`distlib.manifest` to allow our `MANIFEST.in` machinery to work.
At a broad implementation level, `ansible-sign` works like this when it is asked
to sign a project:
* First, it will ask `distlib.manifest` to read in the `MANIFEST.in` file and
resolve the entries in it to actual file paths. It processes the directives
one line at a time. We skip lines starting with `#` along with blank lines. We
also always implicitly include `MANIFEST.in` itself.
* Once all of the `(recursive-)include`-ed files are resolved, it will iterate
through all of them and calculate sha256sums for all of them. (It does this by
reading chunks of the file at a time, to avoid reading entire potentially
large files into memory).
* Now we have a dictionary of 'file path -> checksum', and we can write it out
to a file. We store the file in `.ansible-sign/sha256sum.txt`. This is called
the "checksum manifest file" and it has one line per file. It is in standard
GNU Coreutils `sha256sum` format.
* Once the checksum manifest is written, we sign it. Signing is modular-ish
(like the "differ" concept) though only GPG is currently supported and
implemented. GPG signing uses the `GPGSigner` class which internally uses the
`python-gnupg` library, which itself shells out to `gpg` to sign the
file. `GPGSigner` takes parameters such as the passphrase, GnuPG home
directory, private key to use, and so on. By default `gpg` will use the first
available private signing key found in the user's default keyring. It will
write out the detached signature to `.ansible-sign/sha256sum.txt.sig`.
* We do some sanity checking such as ensuring that we get a `0` return code
(indicating success) back from `gpg`.
## Verifying
On the AWX side, we have a `GPG Public Key` credential type that ships with
AWX. This credential type allows the user to paste in a public GPG key, which
should correspond to the private key used to sign the content. The validity and
"realness" of this key is not currently checked.
Once a `GPG Public Key` credential has been created, it can be attached to the
project (this is just a normal FK relationship). If the project has such a
credential associated with it, content verification will be enabled. Otherwise,
it will be skipped.
Project verification happens only during project update, _not_ during Job
launch. There is an action plugin in
`awx/playbooks/action_plugins/verify_project.py` which uses `ansible-sign` as a
library for doing verification. The implementation is similar to the
`ansible-sign project gpg-verify` subcommand; they both use the same library
calls internally. If the API changes, both places will need to be updated.
Verifying reverts the general signing process described above:
* First we ensure a few files exist (the signature file, the manifest file, and
`MANIFEST.in`).
* We once again use `python-gnupg` (via our `GPGVerifier` class this time) and
ask it to validate the detached signature. It will check it against keys in
our public keyring unless we give it another keyring to use instead. (On the
CLI we can do this with `--keyring`; on the AWX/Controller side, we get a
fresh keyring every time the EE spawns, so we import the public key from the
credential and just let it check against the default keyring).
* Once the key is imported, we can use it to verify if the signature corresponds
to the checksum manifest. `gpg` does this for us (we use `python-gnupg`'s
`verify_file` method), but effectively it is checking for:
1. Does the key match up with something known/trusted in our keyring?
2. Does the signature correspond to the checksum manifest? (In other words,
has the checksum manifest been modified?)
* After `gpg` tells us everything is okay, the checksum manifest can then be
used as a "source of truth" for everything else. Our next step is to parse
checksum manifest file (this is `ChecksumFile#parse`). We'll ultimately have a
dictionary of `file path -> checksum` after this.
* We then call `ChecksumFile#verify` which internally does a few things:
1. It will call the differ to parse `MANIFEST.in` again, via
`ChecksumFile#diff`. We inject an implicit `global-include *` at the top so
that we catch any files that have been added to the project as
well. Ultimately `ChecksumFile#diff` will call the differ's
`compare_filelist` method which takes a list of files (those listed in the
checksum manifest parsed by `ChecksumFile#parse` a few steps up) and
compares them against all the files in the project (captured by
`global-include *`). It returns a dict and groups the results into `added`
and `removed` keys.
2. Check the result from above. If there are any files listed in `added` or
`removed`, we throw `ChecksumMismatch` and bail out early.
3. Otherwise, no files have been added or removed from the project. In this
case, we can iterate all the files in the project and take a new checksum
hash of all of them.
4. Once we have those, compare those against the parsed manifest file's
checksums. If there are checksum mismatches, accumulate a list of them and
raise `ChecksumMismatch`.

View File

@@ -180,7 +180,7 @@ services:
image: postgres:12 image: postgres:12
container_name: tools_postgres_1 container_name: tools_postgres_1
# additional logging settings for postgres can be found https://www.postgresql.org/docs/current/runtime-config-logging.html # additional logging settings for postgres can be found https://www.postgresql.org/docs/current/runtime-config-logging.html
command: postgres -c log_destination=stderr -c log_min_messages=info -c log_min_duration_statement={{ pg_log_min_duration_statement|default(1000) }} -c max_connections={{ pg_max_connections|default(1024) }} command: postgres -c log_destination=stderr -c log_min_messages=info -c log_min_duration_statement={{ pg_log_min_duration_statement|default(1000) }}
environment: environment:
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_USER: {{ pg_username }} POSTGRES_USER: {{ pg_username }}

View File

@@ -36,18 +36,9 @@ GRAFANA=true PROMETHEUS=true EXTRA_SOURCES_ANSIBLE_OPTS="-e scrape_interval=1 ad
We are configuring alerts in grafana using the provisioning files method. This feature is new in Grafana as of August, 2022. Documentation can be found: https://grafana.com/docs/grafana/latest/administration/provisioning/#alerting however it does not fully show all parameters to the config. We are configuring alerts in grafana using the provisioning files method. This feature is new in Grafana as of August, 2022. Documentation can be found: https://grafana.com/docs/grafana/latest/administration/provisioning/#alerting however it does not fully show all parameters to the config.
One way to understand how to build rules is to build them in the UI and use chrometools to inspect the payload as you save the rules. It appears that the "data" portion of the payload for each rule is the same syntax as needed in the provisioning file config. To reload the alerts without restarting the container, from your terminal you can send a POST with `curl -X POST http://admin:admin@localhost:3001/api/admin/provisioning/alerting/reload`. One way to understand how to build rules is to build them in the UI and use chrometools to inspect the payload as you save the rules. It appears that the "data" portion of the payload for each rule is the same syntax as needed in the provisioning file config. To reload the alerts without restarting the container, from within the container you can send a POST with `curl -X POST http://admin:admin@localhost:3000/api/admin/provisioning/alerting/reload`. Keep in mind the grafana container does not contain `curl`. You can install it with the command `apk add curl`.
Another way to export rules is explore the api. Another way to export rules is explore the api.
1. Get all the folders: `GET` to `/api/folders` 1. Get all the folders: `GET` to `/api/folders`
2. Get the rules `GET` to `/api/ruler/grafana/api/v1/rules/{{ Folder }}` 2. Get the rules `GET` to `/api/ruler/grafana/api/v1/rules/{{ Folder }}`
You can do this via curl or in the web browser.
### Included Alerts
#### Alert if remaining capacity low and pending jobs exist
We want to know if jobs are in pending but we lack capacity in the cluster to run them. Our approach is to sum all remaining capacity in the cluster and compare it to the total capacity of the cluster. If less than 10% of our capacity is remaining and we have pending jobs, and this is true for more than 180s, we will fire the alert.
This alert is named "capacity_below_10_percent" and can be found in this directory in https://github.com/ansible/awx/blob/devel/tools/grafana/alerting/alerts.yml

View File

@@ -2,21 +2,15 @@
apiVersion: 1 apiVersion: 1
groups: groups:
- folder: awx - folder: awx
interval: 10s interval: 60s
name: awx_rules name: awx_rules
orgId: 1 orgId: 1
exec_err_state: Alerting
no_data_state: NoData
rules: rules:
- for: 5m - condition: if_failures_too_high
noDataState: OK dashboardUid: awx
panelId: 2
title: failure_rate_exceeded_20_percent
uid: failure_rate_exceeded_20_percent
condition: compare
data: data:
- refId: total_errors - refId: total_errors
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 600 from: 600
to: 0 to: 0
@@ -25,7 +19,7 @@ groups:
editorMode: code editorMode: code
expr: >- expr: >-
max(delta(awx_instance_status_total{instance="awx1:8013", max(delta(awx_instance_status_total{instance="awx1:8013",
status=~"failed|error"}[30m])) status="failed|error"}[30m]))
hide: false hide: false
intervalMs: 1000 intervalMs: 1000
legendFormat: __auto legendFormat: __auto
@@ -33,11 +27,11 @@ groups:
range: true range: true
refId: total_errors refId: total_errors
- refId: max_errors - refId: max_errors
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 0 from: 0
to: 0 to: 0
datasourceUid: "-100" datasourceUid: '-100'
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -66,7 +60,7 @@ groups:
refId: max_errors refId: max_errors
type: reduce type: reduce
- refId: total_success - refId: total_success
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 600 from: 600
to: 0 to: 0
@@ -86,11 +80,11 @@ groups:
range: true range: true
refId: total_success refId: total_success
- refId: max_success - refId: max_success
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 0 from: 0
to: 0 to: 0
datasourceUid: "-100" datasourceUid: '-100'
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -119,11 +113,11 @@ groups:
refId: max_success refId: max_success
type: reduce type: reduce
- refId: compare - refId: compare
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 0 from: 0
to: 0 to: 0
datasourceUid: "-100" datasourceUid: '-100'
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -164,19 +158,15 @@ groups:
maxDataPoints: 43200 maxDataPoints: 43200
refId: compare refId: compare
type: math type: math
- for: 60s for: 30m
noDataState: OK noDataState: OK
panelId: 1 panelId: 2
title: redis_queue_too_large_to_clear_in_2_min title: failure_rate_exceeded_20_percent
uid: redis_queue_too_large_to_clear_in_2_min uid: failure_rate_exceeded_20_percent
condition: redis_queue_growing_faster_than_insertion_rate - condition: if_redis_queue_too_large
dashboardUid: awx dashboardUid: awx
data: data:
- refId: events_insertion_rate_per_second - datasourceUid: awx_prometheus
relativeTimeRange:
from: 300
to: 0
datasourceUid: awx_prometheus
model: model:
editorMode: code editorMode: code
expr: irate(callback_receiver_events_insert_db{node='awx_1'}[1m]) expr: irate(callback_receiver_events_insert_db{node='awx_1'}[1m])
@@ -187,11 +177,11 @@ groups:
range: true range: true
refId: events_insertion_rate_per_second refId: events_insertion_rate_per_second
queryType: "" queryType: ""
- refId: mean_event_insertion_rate refId: events_insertion_rate_per_second
relativeTimeRange: relativeTimeRange:
from: 0 from: 300
to: 0 to: 0
datasourceUid: -100 - datasourceUid: -100
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -218,11 +208,11 @@ groups:
refId: mean_event_insertion_rate refId: mean_event_insertion_rate
type: reduce type: reduce
queryType: "" queryType: ""
- refId: redis_queue_size refId: mean_event_insertion_rate
relativeTimeRange: relativeTimeRange:
from: 300 from: 0
to: 0 to: 0
datasourceUid: awx_prometheus - datasourceUid: awx_prometheus
model: model:
datasource: datasource:
type: prometheus type: prometheus
@@ -236,11 +226,11 @@ groups:
range: true range: true
refId: redis_queue_size refId: redis_queue_size
queryType: "" queryType: ""
- refId: last_redis_queue_size refId: redis_queue_size
relativeTimeRange: relativeTimeRange:
from: 0 from: 300
to: 0 to: 0
datasourceUid: -100 - datasourceUid: -100
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -267,12 +257,11 @@ groups:
refId: last_redis_queue_size refId: last_redis_queue_size
type: reduce type: reduce
queryType: "" queryType: ""
- refId: redis_queue_growing_faster_than_insertion_rate refId: last_redis_queue_size
queryType: ""
relativeTimeRange: relativeTimeRange:
from: 0 from: 0
to: 0 to: 0
datasourceUid: -100 - datasourceUid: -100
model: model:
conditions: conditions:
- evaluator: - evaluator:
@@ -293,35 +282,44 @@ groups:
name: Expression name: Expression
type: __expr__ type: __expr__
uid: __expr__ uid: __expr__
expression: "($last_redis_queue_size > ($mean_event_insertion_rate * 120))" expression: '($last_redis_queue_size > ($mean_event_insertion_rate * 120))'
hide: false hide: false
intervalMs: 1000 intervalMs: 1000
maxDataPoints: 43200 maxDataPoints: 43200
refId: redis_queue_growing_faster_than_insertion_rate
type: math type: math
- for: 60s queryType: ""
refId: redis_queue_growing_faster_than_insertion_rate
relativeTimeRange:
from: 0
to: 0
for: 60s
noDataState: OK noDataState: OK
panelId: 3 panelId: 1
uid: capacity_below_10_percent title: redis_queue_too_large_to_clear_in_2_min
title: capacity_below_10_percent uid: redis_queue_too_large_to_clear_in_2_min
condition: pending_jobs_and_capacity_compare - condition: if_capacity_is_too_low
dashboardUid: awx
no_data_state: OK
exec_err_state: Error
data: data:
- refId: remaining_capacity - refId: remaining_capacity
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 300 from: 1800
to: 0 to: 0
datasourceUid: awx_prometheus datasourceUid: awx_prometheus
model: model:
editorMode: code editorMode: builder
expr: sum(awx_instance_remaining_capacity) expr: awx_instance_remaining_capacity{instance="awx1:8013"}
hide: false hide: false
intervalMs: 1000 intervalMs: 1000
legendFormat: __auto legendFormat: __auto
maxDataPoints: 43200 maxDataPoints: 43200
range: true range: true
refId: remaining_capacity refId: remaining_capacity
- refId: last_remaining_capacity - refId: if_capacity_is_too_low
queryType: "" queryType: ''
relativeTimeRange: relativeTimeRange:
from: 0 from: 0
to: 0 to: 0
@@ -330,207 +328,28 @@ groups:
conditions: conditions:
- evaluator: - evaluator:
params: params:
- 3 - 20
type: outside_range - 0
type: lt
operator: operator:
type: and type: when
query: query:
params: params:
- total_capacity - remaining_capacity
reducer: reducer:
params: [] params: []
type: percent_diff type: avg
type: query type: query
datasource: datasource:
name: Expression
type: __expr__ type: __expr__
uid: "-100" uid: __expr__
expression: remaining_capacity expression: remaining_capacity
hide: false hide: false
intervalMs: 1000 intervalMs: 1000
maxDataPoints: 43200 maxDataPoints: 43200
reducer: last refId: if_capacity_is_too_low
refId: last_remaining_capacity type: classic_conditions
type: reduce for: 30m
- refId: total_capacity title: if_capacity_is_too_low
queryType: "" uid: if_capacity_is_too_low
relativeTimeRange:
from: 600
to: 0
datasourceUid: awx_prometheus
model:
datasource:
type: prometheus
uid: awx_prometheus
editorMode: code
expr: sum(awx_instance_capacity{instance="awx1:8013"})
hide: false
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: true
refId: total_capacity
- refId: last_total_capacity
queryType: ""
relativeTimeRange:
from: 0
to: 0
datasourceUid: "-100"
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params:
- capacity_below_10%
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: total_capacity
hide: false
intervalMs: 1000
maxDataPoints: 43200
reducer: last
refId: last_total_capacity
type: reduce
- refId: 10_percent_total_capacity
queryType: ""
relativeTimeRange:
from: 0
to: 0
datasourceUid: "-100"
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params:
- last_total_capacity
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: "$last_total_capacity*.10"
hide: false
intervalMs: 1000
maxDataPoints: 43200
refId: 10_percent_total_capacity
type: math
- refId: pending_jobs
queryType: ""
relativeTimeRange:
from: 600
to: 0
datasourceUid: awx_prometheus
model:
datasource:
type: prometheus
uid: awx_prometheus
editorMode: builder
expr: awx_pending_jobs_total{instance="awx1:8013"}
hide: false
intervalMs: 1000
legendFormat: __auto
maxDataPoints: 43200
range: true
refId: pending_jobs
- refId: last_pending_jobs
queryType: ""
relativeTimeRange:
from: 0
to: 0
datasourceUid: "-100"
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params:
- pending_jobs_and_capacity_compare
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: pending_jobs
hide: false
intervalMs: 1000
maxDataPoints: 43200
reducer: last
refId: last_pending_jobs
type: reduce
- refId: pending_jobs_and_capacity_compare
queryType: ""
relativeTimeRange:
from: 0
to: 0
datasourceUid: "-100"
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params:
- 10_percent_total_capacity
reducer:
params: []
type: last
type: query
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params:
- pending_jobs
reducer:
params: []
type: last
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression:
"($10_percent_total_capacity > $last_remaining_capacity) && $last_pending_jobs
> 1"
hide: false
intervalMs: 1000
maxDataPoints: 43200
reducer: mean
refId: pending_jobs_and_capacity_compare
type: math

File diff suppressed because it is too large Load Diff