mirror of
https://github.com/ansible/awx.git
synced 2026-03-29 14:55:09 -02:30
Compare commits
1 Commits
12824-Inst
...
12640-Refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87c091eea |
@@ -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
|
||||||
@@ -13,7 +14,7 @@ from django.contrib.auth import views as auth_views
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.db import connection, transaction
|
from django.db import connection
|
||||||
from django.db.models.fields.related import OneToOneRel
|
from django.db.models.fields.related import OneToOneRel
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -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
|
||||||
@@ -64,7 +65,6 @@ __all__ = [
|
|||||||
'ParentMixin',
|
'ParentMixin',
|
||||||
'SubListAttachDetachAPIView',
|
'SubListAttachDetachAPIView',
|
||||||
'CopyAPIView',
|
'CopyAPIView',
|
||||||
'GenericCancelView',
|
|
||||||
'BaseUsersList',
|
'BaseUsersList',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -90,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
|
||||||
@@ -249,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
|
||||||
|
|
||||||
@@ -986,23 +990,6 @@ class CopyAPIView(GenericAPIView):
|
|||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
class GenericCancelView(RetrieveAPIView):
|
|
||||||
# In subclass set model, serializer_class
|
|
||||||
obj_permission_type = 'cancel'
|
|
||||||
|
|
||||||
@transaction.non_atomic_requests
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super(GenericCancelView, self).dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
obj = self.get_object()
|
|
||||||
if obj.can_cancel:
|
|
||||||
obj.cancel()
|
|
||||||
return Response(status=status.HTTP_202_ACCEPTED)
|
|
||||||
else:
|
|
||||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseUsersList(SubListCreateAttachDetachAPIView):
|
class BaseUsersList(SubListCreateAttachDetachAPIView):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
ret = super(BaseUsersList, self).post(request, *args, **kwargs)
|
ret = super(BaseUsersList, self).post(request, *args, **kwargs)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ __all__ = [
|
|||||||
'InventoryInventorySourcesUpdatePermission',
|
'InventoryInventorySourcesUpdatePermission',
|
||||||
'UserPermission',
|
'UserPermission',
|
||||||
'IsSystemAdminOrAuditor',
|
'IsSystemAdminOrAuditor',
|
||||||
|
'InstanceGroupTowerPermission',
|
||||||
'WorkflowApprovalPermission',
|
'WorkflowApprovalPermission',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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=r'^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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
|
|||||||
@@ -9,12 +9,10 @@
|
|||||||
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
|
||||||
pip:
|
pip:
|
||||||
name: ansible-runner
|
name: ansible-runner
|
||||||
executable: pip3.9
|
executable: pip3.9
|
||||||
{% endverbatim %}
|
{% endverbatim %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -69,7 +69,6 @@ from awx.api.generics import (
|
|||||||
APIView,
|
APIView,
|
||||||
BaseUsersList,
|
BaseUsersList,
|
||||||
CopyAPIView,
|
CopyAPIView,
|
||||||
GenericCancelView,
|
|
||||||
GenericAPIView,
|
GenericAPIView,
|
||||||
ListAPIView,
|
ListAPIView,
|
||||||
ListCreateAPIView,
|
ListCreateAPIView,
|
||||||
@@ -123,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
|
||||||
|
|
||||||
@@ -977,11 +1026,20 @@ class SystemJobEventsList(SubListAPIView):
|
|||||||
return job.get_event_queryset()
|
return job.get_event_queryset()
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateCancel(GenericCancelView):
|
class ProjectUpdateCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.ProjectUpdate
|
model = models.ProjectUpdate
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.ProjectUpdateCancelSerializer
|
serializer_class = serializers.ProjectUpdateCancelSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if obj.can_cancel:
|
||||||
|
obj.cancel()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateNotificationsList(SubListAPIView):
|
class ProjectUpdateNotificationsList(SubListAPIView):
|
||||||
|
|
||||||
@@ -2254,11 +2312,20 @@ class InventoryUpdateCredentialsList(SubListAPIView):
|
|||||||
relationship = 'credentials'
|
relationship = 'credentials'
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdateCancel(GenericCancelView):
|
class InventoryUpdateCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.InventoryUpdate
|
model = models.InventoryUpdate
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.InventoryUpdateCancelSerializer
|
serializer_class = serializers.InventoryUpdateCancelSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if obj.can_cancel:
|
||||||
|
obj.cancel()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdateNotificationsList(SubListAPIView):
|
class InventoryUpdateNotificationsList(SubListAPIView):
|
||||||
|
|
||||||
@@ -3033,7 +3100,8 @@ class WorkflowJobNodeChildrenBaseList(SubListAPIView):
|
|||||||
search_fields = ('unified_job_template__name', 'unified_job_template__description')
|
search_fields = ('unified_job_template__name', 'unified_job_template__description')
|
||||||
|
|
||||||
#
|
#
|
||||||
# Limit the set of WorkflowJobNodes to the related nodes of specified by self.relationship
|
# Limit the set of WorkflowJobeNodes to the related nodes of specified by
|
||||||
|
#'relationship'
|
||||||
#
|
#
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
parent = self.get_parent_object()
|
parent = self.get_parent_object()
|
||||||
@@ -3335,15 +3403,20 @@ class WorkflowJobWorkflowNodesList(SubListAPIView):
|
|||||||
return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id')
|
return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id')
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobCancel(GenericCancelView):
|
class WorkflowJobCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.WorkflowJob
|
model = models.WorkflowJob
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.WorkflowJobCancelSerializer
|
serializer_class = serializers.WorkflowJobCancelSerializer
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
r = super().post(request, *args, **kwargs)
|
obj = self.get_object()
|
||||||
ScheduleWorkflowManager().schedule()
|
if obj.can_cancel:
|
||||||
return r
|
obj.cancel()
|
||||||
|
ScheduleWorkflowManager().schedule()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobNotificationsList(SubListAPIView):
|
class WorkflowJobNotificationsList(SubListAPIView):
|
||||||
@@ -3499,11 +3572,20 @@ class JobActivityStreamList(SubListAPIView):
|
|||||||
search_fields = ('changes',)
|
search_fields = ('changes',)
|
||||||
|
|
||||||
|
|
||||||
class JobCancel(GenericCancelView):
|
class JobCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.Job
|
model = models.Job
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.JobCancelSerializer
|
serializer_class = serializers.JobCancelSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if obj.can_cancel:
|
||||||
|
obj.cancel()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class JobRelaunch(RetrieveAPIView):
|
class JobRelaunch(RetrieveAPIView):
|
||||||
|
|
||||||
@@ -3974,11 +4056,20 @@ class AdHocCommandDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
|
|||||||
serializer_class = serializers.AdHocCommandDetailSerializer
|
serializer_class = serializers.AdHocCommandDetailSerializer
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandCancel(GenericCancelView):
|
class AdHocCommandCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.AdHocCommand
|
model = models.AdHocCommand
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.AdHocCommandCancelSerializer
|
serializer_class = serializers.AdHocCommandCancelSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if obj.can_cancel:
|
||||||
|
obj.cancel()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandRelaunch(GenericAPIView):
|
class AdHocCommandRelaunch(GenericAPIView):
|
||||||
|
|
||||||
@@ -4113,11 +4204,20 @@ class SystemJobDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
|
|||||||
serializer_class = serializers.SystemJobSerializer
|
serializer_class = serializers.SystemJobSerializer
|
||||||
|
|
||||||
|
|
||||||
class SystemJobCancel(GenericCancelView):
|
class SystemJobCancel(RetrieveAPIView):
|
||||||
|
|
||||||
model = models.SystemJob
|
model = models.SystemJob
|
||||||
|
obj_permission_type = 'cancel'
|
||||||
serializer_class = serializers.SystemJobCancelSerializer
|
serializer_class = serializers.SystemJobCancelSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
if obj.can_cancel:
|
||||||
|
obj.cancel()
|
||||||
|
return Response(status=status.HTTP_202_ACCEPTED)
|
||||||
|
else:
|
||||||
|
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class SystemJobNotificationsList(SubListAPIView):
|
class SystemJobNotificationsList(SubListAPIView):
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -993,6 +993,9 @@ class HostAccess(BaseAccess):
|
|||||||
if data and 'name' in data:
|
if data and 'name' in data:
|
||||||
self.check_license(add_host_name=data['name'])
|
self.check_license(add_host_name=data['name'])
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory}, add_host_name=data['name'])
|
||||||
|
|
||||||
# Checks for admin or change permission on inventory, controls whether
|
# Checks for admin or change permission on inventory, controls whether
|
||||||
# the user can edit variable data.
|
# the user can edit variable data.
|
||||||
return obj and self.user in obj.inventory.admin_role
|
return obj and self.user in obj.inventory.admin_role
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import uuid
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import connection
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from awx.main.dispatch import get_local_queuename
|
from awx.main.dispatch import get_local_queuename
|
||||||
@@ -50,10 +49,7 @@ class Control(object):
|
|||||||
reply_queue = Control.generate_reply_queue_name()
|
reply_queue = Control.generate_reply_queue_name()
|
||||||
self.result = None
|
self.result = None
|
||||||
|
|
||||||
if not connection.get_autocommit():
|
with pg_bus_conn(new_connection=True) as conn:
|
||||||
raise RuntimeError('Control-with-reply messages can only be done in autocommit mode')
|
|
||||||
|
|
||||||
with pg_bus_conn() as conn:
|
|
||||||
conn.listen(reply_queue)
|
conn.listen(reply_queue)
|
||||||
send_data = {'control': command, 'reply_to': reply_queue}
|
send_data = {'control': command, 'reply_to': reply_queue}
|
||||||
if extra_data:
|
if extra_data:
|
||||||
|
|||||||
@@ -387,8 +387,6 @@ class AutoscalePool(WorkerPool):
|
|||||||
reaper.reap_job(j, 'failed')
|
reaper.reap_job(j, 'failed')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('failed to reap job UUID {}'.format(w.current_task['uuid']))
|
logger.exception('failed to reap job UUID {}'.format(w.current_task['uuid']))
|
||||||
else:
|
|
||||||
logger.warning(f'Worker was told to quit but has not, pid={w.pid}')
|
|
||||||
orphaned.extend(w.orphaned_tasks)
|
orphaned.extend(w.orphaned_tasks)
|
||||||
self.workers.remove(w)
|
self.workers.remove(w)
|
||||||
elif w.idle and len(self.workers) > self.min_workers:
|
elif w.idle and len(self.workers) > self.min_workers:
|
||||||
@@ -452,6 +450,9 @@ class AutoscalePool(WorkerPool):
|
|||||||
try:
|
try:
|
||||||
if isinstance(body, dict) and body.get('bind_kwargs'):
|
if isinstance(body, dict) and body.get('bind_kwargs'):
|
||||||
self.add_bind_kwargs(body)
|
self.add_bind_kwargs(body)
|
||||||
|
# when the cluster heartbeat occurs, clean up internally
|
||||||
|
if isinstance(body, dict) and 'cluster_node_heartbeat' in body['task']:
|
||||||
|
self.cleanup()
|
||||||
if self.should_grow:
|
if self.should_grow:
|
||||||
self.up()
|
self.up()
|
||||||
# we don't care about "preferred queue" round robin distribution, just
|
# we don't care about "preferred queue" round robin distribution, just
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class AWXConsumerBase(object):
|
|||||||
queue = 0
|
queue = 0
|
||||||
self.pool.write(queue, body)
|
self.pool.write(queue, body)
|
||||||
self.total_messages += 1
|
self.total_messages += 1
|
||||||
|
self.record_statistics()
|
||||||
|
|
||||||
@log_excess_runtime(logger)
|
@log_excess_runtime(logger)
|
||||||
def record_statistics(self):
|
def record_statistics(self):
|
||||||
@@ -155,16 +156,6 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
# if no successful loops have ran since startup, then we should fail right away
|
# if no successful loops have ran since startup, then we should fail right away
|
||||||
self.pg_is_down = True # set so that we fail if we get database errors on startup
|
self.pg_is_down = True # set so that we fail if we get database errors on startup
|
||||||
self.pg_down_time = time.time() - self.pg_max_wait # allow no grace period
|
self.pg_down_time = time.time() - self.pg_max_wait # allow no grace period
|
||||||
self.last_cleanup = time.time()
|
|
||||||
|
|
||||||
def run_periodic_tasks(self):
|
|
||||||
self.record_statistics() # maintains time buffer in method
|
|
||||||
|
|
||||||
if time.time() - self.last_cleanup > 60: # same as cluster_node_heartbeat
|
|
||||||
# NOTE: if we run out of database connections, it is important to still run cleanup
|
|
||||||
# so that we scale down workers and free up connections
|
|
||||||
self.pool.cleanup()
|
|
||||||
self.last_cleanup = time.time()
|
|
||||||
|
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
super(AWXConsumerPG, self).run(*args, **kwargs)
|
super(AWXConsumerPG, self).run(*args, **kwargs)
|
||||||
@@ -180,10 +171,8 @@ class AWXConsumerPG(AWXConsumerBase):
|
|||||||
if init is False:
|
if init is False:
|
||||||
self.worker.on_start()
|
self.worker.on_start()
|
||||||
init = True
|
init = True
|
||||||
for e in conn.events(yield_timeouts=True):
|
for e in conn.events():
|
||||||
if e is not None:
|
self.process_task(json.loads(e.payload))
|
||||||
self.process_task(json.loads(e.payload))
|
|
||||||
self.run_periodic_tasks()
|
|
||||||
self.pg_is_down = False
|
self.pg_is_down = False
|
||||||
if self.should_stop:
|
if self.should_stop:
|
||||||
return
|
return
|
||||||
@@ -240,8 +229,6 @@ class BaseWorker(object):
|
|||||||
# so we can establish a new connection
|
# so we can establish a new connection
|
||||||
conn.close_if_unusable_or_obsolete()
|
conn.close_if_unusable_or_obsolete()
|
||||||
self.perform_work(body, *args)
|
self.perform_work(body, *args)
|
||||||
except Exception:
|
|
||||||
logger.exception(f'Unhandled exception in perform_work in worker pid={os.getpid()}')
|
|
||||||
finally:
|
finally:
|
||||||
if 'uuid' in body:
|
if 'uuid' in body:
|
||||||
uuid = body['uuid']
|
uuid = body['uuid']
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
|||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f'''
|
f'''
|
||||||
SELECT
|
SELECT
|
||||||
b.id, b.job_id, b.host_name, b.created - a.created delta,
|
b.id, b.job_id, b.host_name, b.created - a.created delta,
|
||||||
b.task task,
|
b.task task,
|
||||||
b.event_data::json->'task_action' task_action,
|
b.event_data::json->'task_action' task_action,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.utils.timezone import now
|
|||||||
|
|
||||||
logger = logging.getLogger('awx.main.migrations')
|
logger = logging.getLogger('awx.main.migrations')
|
||||||
|
|
||||||
__all__ = ['create_clearsessions_jt', 'create_cleartokens_jt']
|
__all__ = ['create_collection_jt', 'create_clearsessions_jt', 'create_cleartokens_jt']
|
||||||
|
|
||||||
'''
|
'''
|
||||||
These methods are called by migrations to create various system job templates
|
These methods are called by migrations to create various system job templates
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def migrate_galaxy_settings(apps, schema_editor):
|
|||||||
credential_type=galaxy_type,
|
credential_type=galaxy_type,
|
||||||
inputs={'url': 'https://galaxy.ansible.com/'},
|
inputs={'url': 'https://galaxy.ansible.com/'},
|
||||||
)
|
)
|
||||||
except Exception:
|
except:
|
||||||
# Needed for new migrations, tests
|
# Needed for new migrations, tests
|
||||||
public_galaxy_credential = Credential(
|
public_galaxy_credential = Credential(
|
||||||
created=now(), modified=now(), name='Ansible Galaxy', managed=True, credential_type=galaxy_type, inputs={'url': 'https://galaxy.ansible.com/'}
|
created=now(), modified=now(), name='Ansible Galaxy', managed=True, credential_type=galaxy_type, inputs={'url': 'https://galaxy.ansible.com/'}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
|
|||||||
#
|
#
|
||||||
|
|
||||||
# Find the DTSTART rule or raise an error, its usually the first rule but that is not strictly enforced
|
# Find the DTSTART rule or raise an error, its usually the first rule but that is not strictly enforced
|
||||||
start_date_rule = re.sub(r'^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule)
|
start_date_rule = re.sub('^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule)
|
||||||
if not start_date_rule:
|
if not start_date_rule:
|
||||||
raise ValueError('A DTSTART field needs to be in the rrule')
|
raise ValueError('A DTSTART field needs to be in the rrule')
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -1467,23 +1465,23 @@ class UnifiedJob(
|
|||||||
self.job_explanation = job_explanation
|
self.job_explanation = job_explanation
|
||||||
cancel_fields.append('job_explanation')
|
cancel_fields.append('job_explanation')
|
||||||
|
|
||||||
# Important to save here before sending cancel signal to dispatcher to cancel because
|
|
||||||
# the job control process will use the cancel_flag to distinguish a shutdown from a cancel
|
|
||||||
self.save(update_fields=cancel_fields)
|
|
||||||
|
|
||||||
controller_notified = False
|
controller_notified = False
|
||||||
if self.celery_task_id:
|
if self.celery_task_id:
|
||||||
controller_notified = self.cancel_dispatcher_process()
|
controller_notified = self.cancel_dispatcher_process()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Avoid race condition where we have stale model from pending state but job has already started,
|
||||||
|
# its checking signal but not cancel_flag, so re-send signal after this database commit
|
||||||
|
connection.on_commit(self.fallback_cancel)
|
||||||
|
|
||||||
# If a SIGTERM signal was sent to the control process, and acked by the dispatcher
|
# If a SIGTERM signal was sent to the control process, and acked by the dispatcher
|
||||||
# then we want to let its own cleanup change status, otherwise change status now
|
# then we want to let its own cleanup change status, otherwise change status now
|
||||||
if not controller_notified:
|
if not controller_notified:
|
||||||
if self.status != 'canceled':
|
if self.status != 'canceled':
|
||||||
self.status = 'canceled'
|
self.status = 'canceled'
|
||||||
self.save(update_fields=['status'])
|
cancel_fields.append('status')
|
||||||
# Avoid race condition where we have stale model from pending state but job has already started,
|
|
||||||
# its checking signal but not cancel_flag, so re-send signal after updating cancel fields
|
self.save(update_fields=cancel_fields)
|
||||||
self.fallback_cancel()
|
|
||||||
|
|
||||||
return self.cancel_flag
|
return self.cancel_flag
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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])
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ def test_instance_attach_to_instance_group(post, instance_group, node_type_insta
|
|||||||
|
|
||||||
count = ActivityStream.objects.count()
|
count = ActivityStream.objects.count()
|
||||||
|
|
||||||
url = reverse('api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
||||||
post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
|
post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
|
||||||
|
|
||||||
new_activity = ActivityStream.objects.all()[count:]
|
new_activity = ActivityStream.objects.all()[count:]
|
||||||
@@ -240,7 +240,7 @@ def test_instance_unattach_from_instance_group(post, instance_group, node_type_i
|
|||||||
|
|
||||||
count = ActivityStream.objects.count()
|
count = ActivityStream.objects.count()
|
||||||
|
|
||||||
url = reverse('api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
||||||
post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
|
post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
|
||||||
|
|
||||||
new_activity = ActivityStream.objects.all()[count:]
|
new_activity = ActivityStream.objects.all()[count:]
|
||||||
@@ -263,7 +263,7 @@ def test_instance_group_attach_to_instance(post, instance_group, node_type_insta
|
|||||||
|
|
||||||
count = ActivityStream.objects.count()
|
count = ActivityStream.objects.count()
|
||||||
|
|
||||||
url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
||||||
post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
|
post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
|
||||||
|
|
||||||
new_activity = ActivityStream.objects.all()[count:]
|
new_activity = ActivityStream.objects.all()[count:]
|
||||||
@@ -287,7 +287,7 @@ def test_instance_group_unattach_from_instance(post, instance_group, node_type_i
|
|||||||
|
|
||||||
count = ActivityStream.objects.count()
|
count = ActivityStream.objects.count()
|
||||||
|
|
||||||
url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
||||||
post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
|
post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
|
||||||
|
|
||||||
new_activity = ActivityStream.objects.all()[count:]
|
new_activity = ActivityStream.objects.all()[count:]
|
||||||
@@ -314,4 +314,4 @@ def test_cannot_remove_controlplane_hybrid_instances(post, controlplane_instance
|
|||||||
|
|
||||||
url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
||||||
r = post(url, {'disassociate': True, 'id': controlplane_instance_group.id}, admin_user, expect=400)
|
r = post(url, {'disassociate': True, 'id': controlplane_instance_group.id}, admin_user, expect=400)
|
||||||
assert 'Cannot disassociate hybrid instance' in str(r.data)
|
assert f'Cannot disassociate hybrid instance' in str(r.data)
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -147,19 +123,19 @@ def test_survey_password_default(post, patch, admin_user, project, inventory, su
|
|||||||
("DTSTART:20030925T104941Z RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z", "RRULE may not contain both COUNT and UNTIL"), # noqa
|
("DTSTART:20030925T104941Z RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z", "RRULE may not contain both COUNT and UNTIL"), # noqa
|
||||||
("DTSTART:20300308T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "COUNT > 999 is unsupported"), # noqa
|
("DTSTART:20300308T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "COUNT > 999 is unsupported"), # noqa
|
||||||
# Individual rule test with multiple rules
|
# Individual rule test with multiple rules
|
||||||
# Bad Rule: RRULE:NONSENSE
|
## Bad Rule: RRULE:NONSENSE
|
||||||
("DTSTART:20300308T050000Z RRULE:NONSENSE RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU", "INTERVAL required in rrule"),
|
("DTSTART:20300308T050000Z RRULE:NONSENSE RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU", "INTERVAL required in rrule"),
|
||||||
# Bad Rule: RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO
|
## Bad Rule: RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO
|
||||||
(
|
(
|
||||||
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO",
|
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO",
|
||||||
"BYDAY with numeric prefix not supported",
|
"BYDAY with numeric prefix not supported",
|
||||||
), # noqa
|
), # noqa
|
||||||
# Bad Rule: RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z
|
## Bad Rule: RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z
|
||||||
(
|
(
|
||||||
"DTSTART:20030925T104941Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z",
|
"DTSTART:20030925T104941Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z",
|
||||||
"RRULE may not contain both COUNT and UNTIL",
|
"RRULE may not contain both COUNT and UNTIL",
|
||||||
), # noqa
|
), # noqa
|
||||||
# Bad Rule: RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000
|
## Bad Rule: RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000
|
||||||
(
|
(
|
||||||
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000",
|
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000",
|
||||||
"COUNT > 999 is unsupported",
|
"COUNT > 999 is unsupported",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ class TestApiRootView:
|
|||||||
endpoints = [
|
endpoints = [
|
||||||
'ping',
|
'ping',
|
||||||
'config',
|
'config',
|
||||||
# 'settings',
|
#'settings',
|
||||||
'me',
|
'me',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'organizations',
|
'organizations',
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ def test_cancel(unified_job):
|
|||||||
# Some more thought may want to go into only emitting canceled if/when the job record
|
# Some more thought may want to go into only emitting canceled if/when the job record
|
||||||
# status is changed to canceled. Unlike, currently, where it's emitted unconditionally.
|
# status is changed to canceled. Unlike, currently, where it's emitted unconditionally.
|
||||||
unified_job.websocket_emit_status.assert_called_with("canceled")
|
unified_job.websocket_emit_status.assert_called_with("canceled")
|
||||||
assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
|
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status'])
|
||||||
((), {'update_fields': ['cancel_flag', 'start_args']}),
|
|
||||||
((), {'update_fields': ['status']}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_job_explanation(unified_job):
|
def test_cancel_job_explanation(unified_job):
|
||||||
@@ -63,10 +60,7 @@ def test_cancel_job_explanation(unified_job):
|
|||||||
unified_job.cancel(job_explanation=job_explanation)
|
unified_job.cancel(job_explanation=job_explanation)
|
||||||
|
|
||||||
assert unified_job.job_explanation == job_explanation
|
assert unified_job.job_explanation == job_explanation
|
||||||
assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
|
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'job_explanation', 'status'])
|
||||||
((), {'update_fields': ['cancel_flag', 'start_args', 'job_explanation']}),
|
|
||||||
((), {'update_fields': ['status']}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_organization_copy_to_jobs():
|
def test_organization_copy_to_jobs():
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -264,15 +264,9 @@ 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
|
||||||
# if path.startswith('/'):
|
# if path.startswith('/'):
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __metaclass__ = type
|
|||||||
import gnupg
|
import gnupg
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from ansible.module_utils.basic import *
|
||||||
from ansible.plugins.action import ActionBase
|
from ansible.plugins.action import ActionBase
|
||||||
from ansible.utils.display import Display
|
from ansible.utils.display import Display
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from ansible_sign.checksum import (
|
|||||||
InvalidChecksumLine,
|
InvalidChecksumLine,
|
||||||
)
|
)
|
||||||
from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer
|
from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer
|
||||||
from ansible_sign.signing import GPGVerifier
|
from ansible_sign.signing import *
|
||||||
|
|
||||||
display = Display()
|
display = Display()
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ REST_FRAMEWORK = {
|
|||||||
# For swagger schema generation
|
# For swagger schema generation
|
||||||
# see https://github.com/encode/django-rest-framework/pull/6532
|
# see https://github.com/encode/django-rest-framework/pull/6532
|
||||||
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema',
|
||||||
# 'URL_FORMAT_OVERRIDE': None,
|
#'URL_FORMAT_OVERRIDE': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = (
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
|||||||
@@ -101,5 +101,5 @@ except IOError:
|
|||||||
# The below runs AFTER all of the custom settings are imported.
|
# The below runs AFTER all of the custom settings are imported.
|
||||||
|
|
||||||
DATABASES.setdefault('default', dict()).setdefault('OPTIONS', dict()).setdefault(
|
DATABASES.setdefault('default', dict()).setdefault('OPTIONS', dict()).setdefault(
|
||||||
'application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63] # NOQA
|
'application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63]
|
||||||
) # noqa
|
) # noqa
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT = _(
|
|||||||
'''\
|
'''\
|
||||||
Mapping to organization admins/users from social auth accounts. This setting
|
Mapping to organization admins/users from social auth accounts. This setting
|
||||||
controls which users are placed into which organizations based on their
|
controls which users are placed into which organizations based on their
|
||||||
username and email address. Configuration details are available in the
|
username and email address. Configuration details are available in the
|
||||||
documentation.\
|
documentation.\
|
||||||
'''
|
'''
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ _values_to_change = ['is_superuser_value', 'is_superuser_role', 'is_system_audit
|
|||||||
|
|
||||||
def _get_setting():
|
def _get_setting():
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute('SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
cursor.execute(f'SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row == None:
|
if row == None:
|
||||||
return {}
|
return {}
|
||||||
@@ -24,7 +24,7 @@ def _get_setting():
|
|||||||
|
|
||||||
def _set_setting(value):
|
def _set_setting(value):
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute('UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
cursor.execute(f'UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
||||||
|
|
||||||
|
|
||||||
def forwards(app, schema_editor):
|
def forwards(app, schema_editor):
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ class TestSAMLAttr:
|
|||||||
'PersonImmutableID': [],
|
'PersonImmutableID': [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# 'social': <UserSocialAuth: cmeyers@redhat.com>,
|
#'social': <UserSocialAuth: cmeyers@redhat.com>,
|
||||||
'social': None,
|
'social': None,
|
||||||
# 'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>,
|
#'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>,
|
||||||
'strategy': None,
|
'strategy': None,
|
||||||
'new_association': False,
|
'new_association': False,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
184
awx/ui/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { arrayOf, bool, number, shape, string } from 'prop-types';
|
|
||||||
|
|
||||||
import { Label, LabelGroup } from '@patternfly/react-core';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
function InstanceGroupLabels({ labels, isLinkable }) {
|
|
||||||
const buildLinkURL = (isContainerGroup) =>
|
|
||||||
isContainerGroup
|
|
||||||
? '/instance_groups/container_group/'
|
|
||||||
: '/instance_groups/';
|
|
||||||
return (
|
|
||||||
<LabelGroup numLabels={5}>
|
|
||||||
{labels.map(({ id, name, is_container_group }) =>
|
|
||||||
isLinkable ? (
|
|
||||||
<Label
|
|
||||||
color="blue"
|
|
||||||
key={id}
|
|
||||||
render={({ className, content, componentRef }) => (
|
|
||||||
<Link
|
|
||||||
className={className}
|
|
||||||
innerRef={componentRef}
|
|
||||||
to={`${buildLinkURL(is_container_group)}${id}/details`}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Label>
|
|
||||||
) : (
|
|
||||||
<Label color="blue" key={id}>
|
|
||||||
{name}
|
|
||||||
</Label>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</LabelGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InstanceGroupLabels.propTypes = {
|
|
||||||
labels: arrayOf(shape({ id: number.isRequired, name: string.isRequired }))
|
|
||||||
.isRequired,
|
|
||||||
isLinkable: bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
InstanceGroupLabels.defaultProps = { isLinkable: false };
|
|
||||||
|
|
||||||
export default InstanceGroupLabels;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from './InstanceGroupLabels';
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Chip, Divider, Title } from '@patternfly/react-core';
|
import { Chip, Divider, Title } from '@patternfly/react-core';
|
||||||
import { toTitleCase } from 'util/strings';
|
import { toTitleCase } from 'util/strings';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import CredentialChip from '../CredentialChip';
|
import CredentialChip from '../CredentialChip';
|
||||||
import ChipGroup from '../ChipGroup';
|
import ChipGroup from '../ChipGroup';
|
||||||
import { DetailList, Detail, UserDateDetail } from '../DetailList';
|
import { DetailList, Detail, UserDateDetail } from '../DetailList';
|
||||||
@@ -228,7 +227,21 @@ function PromptDetail({
|
|||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
rows={4}
|
rows={4}
|
||||||
value={
|
value={
|
||||||
<InstanceGroupLabels labels={overrides.instance_groups} />
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={overrides.instance_groups.length}
|
||||||
|
ouiaId="prompt-instance-groups-chips"
|
||||||
|
>
|
||||||
|
{overrides.instance_groups.map((instance_group) => (
|
||||||
|
<Chip
|
||||||
|
key={instance_group.id}
|
||||||
|
ouiaId={`instance-group-${instance_group.id}-chip`}
|
||||||
|
isReadOnly
|
||||||
|
>
|
||||||
|
{instance_group.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
|
|||||||
import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api';
|
import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api';
|
||||||
import { parseVariableField, jsonToYaml } from 'util/yaml';
|
import { parseVariableField, jsonToYaml } from 'util/yaml';
|
||||||
import { useConfig } from 'contexts/Config';
|
import { useConfig } from 'contexts/Config';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import parseRuleObj from '../shared/parseRuleObj';
|
import parseRuleObj from '../shared/parseRuleObj';
|
||||||
import FrequencyDetails from './FrequencyDetails';
|
import FrequencyDetails from './FrequencyDetails';
|
||||||
import AlertModal from '../../AlertModal';
|
import AlertModal from '../../AlertModal';
|
||||||
@@ -28,6 +27,11 @@ import { VariablesDetail } from '../../CodeEditor';
|
|||||||
import { VERBOSITY } from '../../VerbositySelectField';
|
import { VERBOSITY } from '../../VerbositySelectField';
|
||||||
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
|
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
|
||||||
|
|
||||||
|
const buildLinkURL = (instance) =>
|
||||||
|
instance.is_container_group
|
||||||
|
? '/instance_groups/container_group/'
|
||||||
|
: '/instance_groups/';
|
||||||
|
|
||||||
const PromptDivider = styled(Divider)`
|
const PromptDivider = styled(Divider)`
|
||||||
margin-top: var(--pf-global--spacer--lg);
|
margin-top: var(--pf-global--spacer--lg);
|
||||||
margin-bottom: var(--pf-global--spacer--lg);
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
@@ -494,7 +498,26 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
value={
|
value={
|
||||||
<InstanceGroupLabels labels={instanceGroups} isLinkable />
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups.map((ig) => (
|
||||||
|
<Link
|
||||||
|
to={`${buildLinkURL(ig)}${ig.id}/details`}
|
||||||
|
key={ig.id}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
isReadOnly
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
}
|
}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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={!me.is_superuser || isRunningHealthCheck}
|
||||||
isDisabled={
|
variant="primary"
|
||||||
!config?.me?.is_superuser || instance.health_check_pending
|
ouiaId="health-check-button"
|
||||||
}
|
onClick={fetchHealthCheck}
|
||||||
variant="primary"
|
isLoading={isRunningHealthCheck}
|
||||||
ouiaId="health-check-button"
|
spinnerAriaLabel={t`Running health check`}
|
||||||
onClick={fetchHealthCheck}
|
>
|
||||||
isLoading={instance.health_check_pending}
|
{t`Run health check`}
|
||||||
spinnerAriaLabel={t`Running health check`}
|
</Button>
|
||||||
>
|
</Tooltip>
|
||||||
{instance.health_check_pending
|
{me.is_superuser && instance.node_type !== 'control' && (
|
||||||
? t`Running health check`
|
|
||||||
: t`Run health check`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{config?.me?.is_superuser && instance.node_type !== 'control' && (
|
|
||||||
<DisassociateButton
|
<DisassociateButton
|
||||||
verifyCannotDisassociate={instanceGroup.name === 'controlplane'}
|
verifyCannotDisassociate={instanceGroup.name === 'controlplane'}
|
||||||
key="disassociate"
|
key="disassociate"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
import { Link, useHistory, useParams } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
CodeBlockCode,
|
CodeBlockCode,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Slider,
|
Slider,
|
||||||
|
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';
|
||||||
@@ -22,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';
|
||||||
@@ -33,7 +33,6 @@ import useRequest, {
|
|||||||
useDismissableError,
|
useDismissableError,
|
||||||
} from 'hooks/useRequest';
|
} from 'hooks/useRequest';
|
||||||
import HealthCheckAlert from 'components/HealthCheckAlert';
|
import HealthCheckAlert from 'components/HealthCheckAlert';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
|
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
|
||||||
|
|
||||||
const Unavailable = styled.span`
|
const Unavailable = styled.span`
|
||||||
@@ -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,17 +149,10 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatHealthCheckTimeStamp = (last) => (
|
const buildLinkURL = (inst) =>
|
||||||
<>
|
inst.is_container_group
|
||||||
{formatDateString(last)}
|
? '/instance_groups/container_group/'
|
||||||
{instance.health_check_pending ? (
|
: '/instance_groups/';
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<OutlinedClockIcon />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(
|
const { error, dismissError } = useDismissableError(
|
||||||
updateInstanceError || healthCheckError
|
updateInstanceError || healthCheckError
|
||||||
@@ -181,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 (
|
||||||
<>
|
<>
|
||||||
@@ -220,31 +217,32 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
dataCy="instance-groups"
|
dataCy="instance-groups"
|
||||||
helpText={t`The Instance Groups to which this instance belongs.`}
|
helpText={t`The Instance Groups to which this instance belongs.`}
|
||||||
value={
|
value={instanceGroups.map((ig) => (
|
||||||
<InstanceGroupLabels labels={instanceGroups} isLinkable />
|
<React.Fragment key={ig.id}>
|
||||||
}
|
<Label
|
||||||
|
color="blue"
|
||||||
|
isTruncated
|
||||||
|
render={({ className, content, componentRef }) => (
|
||||||
|
<Link
|
||||||
|
to={`${buildLinkURL(ig)}${ig.id}/details`}
|
||||||
|
className={className}
|
||||||
|
innerRef={componentRef}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Label>{' '}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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
|
||||||
@@ -282,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>
|
||||||
@@ -328,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]}
|
||||||
@@ -336,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={!me.is_superuser || isRunningHealthCheck}
|
||||||
isDisabled={
|
variant="primary"
|
||||||
!config?.me?.is_superuser || instance.health_check_pending
|
ouiaId="health-check-button"
|
||||||
}
|
onClick={fetchHealthCheck}
|
||||||
variant="primary"
|
isLoading={isRunningHealthCheck}
|
||||||
ouiaId="health-check-button"
|
spinnerAriaLabel={t`Running health check`}
|
||||||
onClick={fetchHealthCheck}
|
>
|
||||||
isLoading={instance.health_check_pending}
|
{t`Run health check`}
|
||||||
spinnerAriaLabel={t`Running health check`}
|
</Button>
|
||||||
>
|
</Tooltip>
|
||||||
{instance.health_check_pending
|
|
||||||
? t`Running health check`
|
|
||||||
: t`Run health check`}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<InstanceToggle
|
<InstanceToggle
|
||||||
css="display: inline-flex;"
|
css="display: inline-flex;"
|
||||||
fetchInstances={fetchDetails}
|
fetchInstances={fetchDetails}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { InventoriesAPI } from 'api';
|
|||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import { Inventory } from 'types';
|
import { Inventory } from 'types';
|
||||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import getHelpText from '../shared/Inventory.helptext';
|
import getHelpText from '../shared/Inventory.helptext';
|
||||||
|
|
||||||
function InventoryDetail({ inventory }) {
|
function InventoryDetail({ inventory }) {
|
||||||
@@ -106,7 +105,23 @@ function InventoryDetail({ inventory }) {
|
|||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups?.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups?.map((ig) => (
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
isReadOnly
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,8 +131,9 @@ describe('<InventoryDetail />', () => {
|
|||||||
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
|
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
|
||||||
mockInventory.id
|
mockInventory.id
|
||||||
);
|
);
|
||||||
const label = wrapper.find('Label').at(0);
|
const chip = wrapper.find('Chip').at(0);
|
||||||
expect(label.prop('children')).toEqual('Foo');
|
expect(chip.prop('isReadOnly')).toEqual(true);
|
||||||
|
expect(chip.prop('children')).toEqual('Foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not load instance groups', async () => {
|
test('should not load instance groups', async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
|
|||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button, Label } from '@patternfly/react-core';
|
import { Button, Chip, Label } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { Inventory } from 'types';
|
import { Inventory } from 'types';
|
||||||
import { InventoriesAPI, UnifiedJobsAPI } from 'api';
|
import { InventoriesAPI, UnifiedJobsAPI } from 'api';
|
||||||
@@ -10,6 +10,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
|
|||||||
|
|
||||||
import AlertModal from 'components/AlertModal';
|
import AlertModal from 'components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from 'components/Card';
|
import { CardBody, CardActionsRow } from 'components/Card';
|
||||||
|
import ChipGroup from 'components/ChipGroup';
|
||||||
import { VariablesDetail } from 'components/CodeEditor';
|
import { VariablesDetail } from 'components/CodeEditor';
|
||||||
import ContentError from 'components/ContentError';
|
import ContentError from 'components/ContentError';
|
||||||
import ContentLoading from 'components/ContentLoading';
|
import ContentLoading from 'components/ContentLoading';
|
||||||
@@ -17,7 +18,6 @@ import DeleteButton from 'components/DeleteButton';
|
|||||||
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
|
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
|
||||||
import ErrorDetail from 'components/ErrorDetail';
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
import Sparkline from 'components/Sparkline';
|
import Sparkline from 'components/Sparkline';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
|
|
||||||
function SmartInventoryDetail({ inventory }) {
|
function SmartInventoryDetail({ inventory }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -120,7 +120,23 @@ function SmartInventoryDetail({ inventory }) {
|
|||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance groups`}
|
label={t`Instance groups`}
|
||||||
value={<InstanceGroupLabels labels={instanceGroups} />}
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups.map((ig) => (
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
isReadOnly
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
<VariablesDetail
|
<VariablesDetail
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ const processCodeEditorValue = (value) => {
|
|||||||
codeEditorValue = '';
|
codeEditorValue = '';
|
||||||
} else if (typeof value === 'string') {
|
} else if (typeof value === 'string') {
|
||||||
codeEditorValue = encode(value);
|
codeEditorValue = encode(value);
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
codeEditorValue = encode(value.join(' '));
|
|
||||||
} else {
|
} else {
|
||||||
codeEditorValue = value;
|
codeEditorValue = value;
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,7 @@ const getStdOutValue = (hostEvent) => {
|
|||||||
) {
|
) {
|
||||||
stdOut = res.results.join('\n');
|
stdOut = res.results.join('\n');
|
||||||
} else if (res?.stdout) {
|
} else if (res?.stdout) {
|
||||||
stdOut = Array.isArray(res.stdout) ? res.stdout.join(' ') : res.stdout;
|
stdOut = res.stdout;
|
||||||
}
|
}
|
||||||
return stdOut;
|
return stdOut;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,60 +52,6 @@ const hostEvent = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
Some libraries return a list of string in stdout
|
|
||||||
Example: https://github.com/ansible-collections/cisco.ios/blob/main/plugins/modules/ios_command.py#L124-L128
|
|
||||||
*/
|
|
||||||
const hostEventWithArray = {
|
|
||||||
changed: true,
|
|
||||||
event: 'runner_on_ok',
|
|
||||||
event_data: {
|
|
||||||
host: 'foo',
|
|
||||||
play: 'all',
|
|
||||||
playbook: 'run_command.yml',
|
|
||||||
res: {
|
|
||||||
ansible_loop_var: 'item',
|
|
||||||
changed: true,
|
|
||||||
item: '1',
|
|
||||||
msg: 'This is a debug message: 1',
|
|
||||||
stdout: [
|
|
||||||
' total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023',
|
|
||||||
],
|
|
||||||
stderr: 'problems',
|
|
||||||
cmd: ['free', '-m'],
|
|
||||||
stderr_lines: [],
|
|
||||||
stdout_lines: [
|
|
||||||
' total used free shared buff/cache available',
|
|
||||||
'Mem: 7973 3005 960 30 4007 4582',
|
|
||||||
'Swap: 1023 0 1023',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
task: 'command',
|
|
||||||
task_action: 'command',
|
|
||||||
},
|
|
||||||
event_display: 'Host OK',
|
|
||||||
event_level: 3,
|
|
||||||
failed: false,
|
|
||||||
host: 1,
|
|
||||||
host_name: 'foo',
|
|
||||||
id: 123,
|
|
||||||
job: 4,
|
|
||||||
play: 'all',
|
|
||||||
playbook: 'run_command.yml',
|
|
||||||
stdout: `stdout: "[0;33mchanged: [localhost] => {"changed": true, "cmd": ["free", "-m"], "delta": "0:00:01.479609", "end": "2019-09-10 14:21:45.469533", "rc": 0, "start": "2019-09-10 14:21:43.989924", "stderr": "", "stderr_lines": [], "stdout": " total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023", "stdout_lines": [" total used free shared buff/cache available", "Mem: 7973 3005 960 30 4007 4582", "Swap: 1023 0 1023"]}[0m"
|
|
||||||
`,
|
|
||||||
task: 'command',
|
|
||||||
type: 'job_event',
|
|
||||||
url: '/api/v2/job_events/123/',
|
|
||||||
summary_fields: {
|
|
||||||
host: {
|
|
||||||
id: 1,
|
|
||||||
name: 'foo',
|
|
||||||
description: 'Bar',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/* eslint-disable no-useless-escape */
|
/* eslint-disable no-useless-escape */
|
||||||
const jsonValue = `{
|
const jsonValue = `{
|
||||||
\"ansible_loop_var\": \"item\",
|
\"ansible_loop_var\": \"item\",
|
||||||
@@ -335,25 +281,4 @@ describe('HostEventModal', () => {
|
|||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
expect(codeEditor.prop('readOnly')).toBe(true);
|
||||||
expect(codeEditor.prop('value')).toEqual('baz\nbar');
|
expect(codeEditor.prop('value')).toEqual('baz\nbar');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display Standard Out array stdout content', () => {
|
|
||||||
const wrapper = shallow(
|
|
||||||
<HostEventModal
|
|
||||||
hostEvent={hostEventWithArray}
|
|
||||||
onClose={() => {}}
|
|
||||||
isOpen
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
|
|
||||||
handleTabClick(null, 2);
|
|
||||||
wrapper.update();
|
|
||||||
|
|
||||||
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
|
|
||||||
expect(codeEditor.prop('mode')).toBe('javascript');
|
|
||||||
expect(codeEditor.prop('readOnly')).toBe(true);
|
|
||||||
expect(codeEditor.prop('value')).toEqual(
|
|
||||||
hostEventWithArray.event_data.res.stdout.join(' ')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||||||
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
|
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { Button, Chip } from '@patternfly/react-core';
|
||||||
import { OrganizationsAPI } from 'api';
|
import { OrganizationsAPI } from 'api';
|
||||||
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
|
import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
|
||||||
import { CardBody, CardActionsRow } from 'components/Card';
|
import { CardBody, CardActionsRow } from 'components/Card';
|
||||||
@@ -16,7 +16,6 @@ import ErrorDetail from 'components/ErrorDetail';
|
|||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
import { useConfig } from 'contexts/Config';
|
import { useConfig } from 'contexts/Config';
|
||||||
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
|
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function OrganizationDetail({ organization }) {
|
function OrganizationDetail({ organization }) {
|
||||||
@@ -80,6 +79,11 @@ function OrganizationDetail({ organization }) {
|
|||||||
return <ContentError error={contentError} />;
|
return <ContentError error={contentError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildLinkURL = (instance) =>
|
||||||
|
instance.is_container_group
|
||||||
|
? '/instance_groups/container_group/'
|
||||||
|
: '/instance_groups/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
@@ -122,7 +126,25 @@ function OrganizationDetail({ organization }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
helpText={t`The Instance Groups for this Organization to run on.`}
|
helpText={t`The Instance Groups for this Organization to run on.`}
|
||||||
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups.map((ig) => (
|
||||||
|
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
isReadOnly
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe('<OrganizationDetail />', () => {
|
|||||||
await waitForElement(component, 'ContentLoading', (el) => el.length === 0);
|
await waitForElement(component, 'ContentLoading', (el) => el.length === 0);
|
||||||
expect(
|
expect(
|
||||||
component
|
component
|
||||||
.find('Label')
|
.find('Chip')
|
||||||
.findWhere((el) => el.text() === 'One')
|
.findWhere((el) => el.text() === 'One')
|
||||||
.exists()
|
.exists()
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(() => {
|
const {
|
||||||
async function fetchData() {
|
result: { scmTypeOptions, credentialTypeIds },
|
||||||
try {
|
error: contentError,
|
||||||
const credentialResponse = fetchCredentials(summary_fields.credential);
|
isLoading,
|
||||||
const signatureValidationCredentialResponse = fetchCredentials(
|
request: fetchData,
|
||||||
summary_fields.signature_validation_credential
|
} = useRequest(
|
||||||
);
|
useCallback(async () => {
|
||||||
const {
|
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],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] = await Promise.all([
|
||||||
|
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();
|
||||||
}, [
|
}, [fetchData]);
|
||||||
summary_fields.credential,
|
|
||||||
summary_fields.signature_validation_credential,
|
|
||||||
]);
|
|
||||||
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
|
|||||||
import useBrandName from 'hooks/useBrandName';
|
import useBrandName from 'hooks/useBrandName';
|
||||||
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
|
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
|
||||||
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
|
||||||
import InstanceGroupLabels from 'components/InstanceGroupLabels';
|
|
||||||
import getHelpText from '../shared/JobTemplate.helptext';
|
import getHelpText from '../shared/JobTemplate.helptext';
|
||||||
|
|
||||||
function JobTemplateDetail({ template }) {
|
function JobTemplateDetail({ template }) {
|
||||||
@@ -168,6 +167,11 @@ function JobTemplateDetail({ template }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildLinkURL = (instance) =>
|
||||||
|
instance.is_container_group
|
||||||
|
? '/instance_groups/container_group/'
|
||||||
|
: '/instance_groups/';
|
||||||
|
|
||||||
if (instanceGroupsError) {
|
if (instanceGroupsError) {
|
||||||
return <ContentError error={instanceGroupsError} />;
|
return <ContentError error={instanceGroupsError} />;
|
||||||
}
|
}
|
||||||
@@ -418,7 +422,25 @@ function JobTemplateDetail({ template }) {
|
|||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
dataCy="jt-detail-instance-groups"
|
dataCy="jt-detail-instance-groups"
|
||||||
helpText={helpText.instanceGroups}
|
helpText={helpText.instanceGroups}
|
||||||
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups.map((ig) => (
|
||||||
|
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
isReadOnly
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
{job_tags && (
|
{job_tags && (
|
||||||
|
|||||||
@@ -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%"
|
||||||
|
|||||||
@@ -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,90 +18,24 @@ 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;
|
||||||
<PlaybookSelect
|
await act(async () => {
|
||||||
projectId={1}
|
wrapper = mountWithContexts(
|
||||||
isValid
|
<PlaybookSelect
|
||||||
onChange={() => {}}
|
projectId={1}
|
||||||
onError={() => {}}
|
isValid
|
||||||
/>
|
onChange={() => {}}
|
||||||
);
|
onError={() => {}}
|
||||||
|
/>
|
||||||
await waitFor(() => {
|
|
||||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<PlaybookSelect
|
|
||||||
projectId={15}
|
|
||||||
isValid
|
|
||||||
onChange={() => {}}
|
|
||||||
onError={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
|
|
||||||
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(() => {
|
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
|
||||||
// A new select option is displayed ("foo.yml")
|
await act(async () => {
|
||||||
expect(
|
wrapper.setProps({ projectId: 15 });
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
|
||||||
|
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)`
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ options:
|
|||||||
signature_validation_credential:
|
signature_validation_credential:
|
||||||
description:
|
description:
|
||||||
- Name of the credential to use for signature validation.
|
- Name of the credential to use for signature validation.
|
||||||
- If signature validation credential is provided, signature validation will be enabled.
|
- If signature validation credential is provided, signature validation will be enabled.
|
||||||
type: str
|
type: str
|
||||||
|
|
||||||
extends_documentation_fragment: awx.awx.auth
|
extends_documentation_fragment: awx.awx.auth
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
|
|||||||
# Lookup Job Template ID
|
# Lookup Job Template ID
|
||||||
if workflow_node['unified_job_template']['name']:
|
if workflow_node['unified_job_template']['name']:
|
||||||
if workflow_node['unified_job_template']['type'] is None:
|
if workflow_node['unified_job_template']['type'] is None:
|
||||||
module.fail_json(msg='Could not find unified job template type in workflow_nodes {0}'.format(workflow_node))
|
module.fail_json(msg='Could not find unified job template type in workflow_nodes {1}'.format(workflow_node))
|
||||||
search_fields['type'] = workflow_node['unified_job_template']['type']
|
search_fields['type'] = workflow_node['unified_job_template']['type']
|
||||||
if workflow_node['unified_job_template']['type'] == 'inventory_source':
|
if workflow_node['unified_job_template']['type'] == 'inventory_source':
|
||||||
if 'inventory' in workflow_node['unified_job_template']:
|
if 'inventory' in workflow_node['unified_job_template']:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -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 }}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user