mirror of
https://github.com/ansible/awx.git
synced 2026-03-28 22:35:08 -02:30
Compare commits
80 Commits
12640-Refa
...
12824-Inst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea8b1a754 | ||
|
|
95dba81a9d | ||
|
|
4b308d313a | ||
|
|
d80db763bc | ||
|
|
ddd09461fb | ||
|
|
6d192927ae | ||
|
|
e655e1dbc2 | ||
|
|
e41f20320a | ||
|
|
192f45bbd0 | ||
|
|
e013d25e2d | ||
|
|
8a6ad47ca5 | ||
|
|
cba780a8f8 | ||
|
|
3fc67dc76c | ||
|
|
6f85aef5fe | ||
|
|
4d9b8400da | ||
|
|
eeb9d61488 | ||
|
|
234ce529fc | ||
|
|
4f36943b47 | ||
|
|
25737ba7c6 | ||
|
|
7127d18072 | ||
|
|
e5c834383c | ||
|
|
b9c9800210 | ||
|
|
c94dc08cf3 | ||
|
|
a0594c8948 | ||
|
|
ab5ea46006 | ||
|
|
6b471e468c | ||
|
|
50614b961e | ||
|
|
a2be320605 | ||
|
|
8a959e9586 | ||
|
|
1db189c7ee | ||
|
|
39c2fcd8c2 | ||
|
|
da857ea334 | ||
|
|
d50c97ae22 | ||
|
|
0f150aa3b3 | ||
|
|
cdb51a75b8 | ||
|
|
22b6ae6903 | ||
|
|
871175f97f | ||
|
|
e6497be200 | ||
|
|
3b9333be9f | ||
|
|
04b814cfd8 | ||
|
|
bb2e5cba0a | ||
|
|
42a4e9f10f | ||
|
|
882d2fdbe8 | ||
|
|
0d69d40859 | ||
|
|
2e38bbcbcd | ||
|
|
6f741b909a | ||
|
|
bbb00e0674 | ||
|
|
560b952dd6 | ||
|
|
62c773e912 | ||
|
|
fd38c926b2 | ||
|
|
7a8874b947 | ||
|
|
150c55c72a | ||
|
|
417ac3b88c | ||
|
|
9e0d1a678c | ||
|
|
1a766c09e7 | ||
|
|
7849c0fb1e | ||
|
|
35a7e43f22 | ||
|
|
47a6a73fc5 | ||
|
|
805091cfc1 | ||
|
|
8d05e339ae | ||
|
|
8472e3a26d | ||
|
|
174121cdbe | ||
|
|
385a2eabce | ||
|
|
a64467c5a6 | ||
|
|
58772d79c7 | ||
|
|
235ed2f0d0 | ||
|
|
03eaeac459 | ||
|
|
a4fba37222 | ||
|
|
3a09522d3e | ||
|
|
b5db710c8b | ||
|
|
b964905c80 | ||
|
|
37717ce3d5 | ||
|
|
e7c75f3510 | ||
|
|
d3eb2c1975 | ||
|
|
5551874352 | ||
|
|
80a0842df1 | ||
|
|
2dd2931ab2 | ||
|
|
e83a4d7234 | ||
|
|
8e2003a36b | ||
|
|
4f52343cd9 |
@@ -6,7 +6,6 @@ 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
|
||||||
@@ -14,7 +13,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
|
from django.db import connection, transaction
|
||||||
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
|
||||||
@@ -30,7 +29,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, JSONRenderer
|
from rest_framework.renderers import StaticHTMLRenderer
|
||||||
from rest_framework.negotiation import DefaultContentNegotiation
|
from rest_framework.negotiation import DefaultContentNegotiation
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
@@ -41,7 +40,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, UserSerializer
|
from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer
|
||||||
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
|
||||||
@@ -65,6 +64,7 @@ __all__ = [
|
|||||||
'ParentMixin',
|
'ParentMixin',
|
||||||
'SubListAttachDetachAPIView',
|
'SubListAttachDetachAPIView',
|
||||||
'CopyAPIView',
|
'CopyAPIView',
|
||||||
|
'GenericCancelView',
|
||||||
'BaseUsersList',
|
'BaseUsersList',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -90,13 +90,9 @@ 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
|
||||||
@@ -253,7 +249,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."' # noqa
|
response['Warning'] = '299 awx "This resource has been deprecated and will be removed in a future release."'
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -990,6 +986,23 @@ 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,7 +24,6 @@ __all__ = [
|
|||||||
'InventoryInventorySourcesUpdatePermission',
|
'InventoryInventorySourcesUpdatePermission',
|
||||||
'UserPermission',
|
'UserPermission',
|
||||||
'IsSystemAdminOrAuditor',
|
'IsSystemAdminOrAuditor',
|
||||||
'InstanceGroupTowerPermission',
|
|
||||||
'WorkflowApprovalPermission',
|
'WorkflowApprovalPermission',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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
|
||||||
@@ -120,6 +121,9 @@ 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.
|
||||||
@@ -3746,7 +3750,11 @@ 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)
|
||||||
accepted, rejected, errors = ujt._accept_or_ignore_job_kwargs(_exclude_errors=self.exclude_errors, **mock_obj.prompts_dict())
|
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())
|
||||||
|
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:
|
||||||
@@ -4921,6 +4929,19 @@ 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):
|
||||||
@@ -4991,6 +5012,10 @@ 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,3 +1,5 @@
|
|||||||
|
receptor_user: awx
|
||||||
|
receptor_group: awx
|
||||||
receptor_verify: true
|
receptor_verify: true
|
||||||
receptor_tls: true
|
receptor_tls: true
|
||||||
receptor_work_commands:
|
receptor_work_commands:
|
||||||
@@ -10,12 +12,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,10 +9,12 @@
|
|||||||
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,6 +1,4 @@
|
|||||||
---
|
---
|
||||||
collections:
|
collections:
|
||||||
- name: ansible.receptor
|
- name: ansible.receptor
|
||||||
source: https://github.com/ansible/receptor-collection/
|
version: 1.1.0
|
||||||
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,26 +3,28 @@
|
|||||||
|
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from awx.api.views import (
|
from awx.api.views.inventory 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,6 +3,9 @@
|
|||||||
|
|
||||||
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,
|
||||||
@@ -10,7 +13,6 @@ 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 import ApiOAuthAuthorizationRootView
|
from awx.api.views.root 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 import (
|
from awx.api.views.organization import (
|
||||||
OrganizationList,
|
OrganizationList,
|
||||||
OrganizationDetail,
|
OrganizationDetail,
|
||||||
OrganizationUsersList,
|
OrganizationUsersList,
|
||||||
@@ -14,7 +14,6 @@ from awx.api.views import (
|
|||||||
OrganizationJobTemplatesList,
|
OrganizationJobTemplatesList,
|
||||||
OrganizationWorkflowJobTemplatesList,
|
OrganizationWorkflowJobTemplatesList,
|
||||||
OrganizationTeamsList,
|
OrganizationTeamsList,
|
||||||
OrganizationCredentialList,
|
|
||||||
OrganizationActivityStreamList,
|
OrganizationActivityStreamList,
|
||||||
OrganizationNotificationTemplatesList,
|
OrganizationNotificationTemplatesList,
|
||||||
OrganizationNotificationTemplatesErrorList,
|
OrganizationNotificationTemplatesErrorList,
|
||||||
@@ -25,8 +24,8 @@ from awx.api.views import (
|
|||||||
OrganizationGalaxyCredentialsList,
|
OrganizationGalaxyCredentialsList,
|
||||||
OrganizationObjectRolesList,
|
OrganizationObjectRolesList,
|
||||||
OrganizationAccessList,
|
OrganizationAccessList,
|
||||||
OrganizationApplicationList,
|
|
||||||
)
|
)
|
||||||
|
from awx.api.views import OrganizationCredentialList, OrganizationApplicationList
|
||||||
|
|
||||||
|
|
||||||
urls = [
|
urls = [
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ 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 import (
|
from awx.api.views.root import (
|
||||||
ApiRootView,
|
ApiRootView,
|
||||||
ApiV2RootView,
|
ApiV2RootView,
|
||||||
ApiV2PingView,
|
ApiV2PingView,
|
||||||
ApiV2ConfigView,
|
ApiV2ConfigView,
|
||||||
ApiV2SubscriptionView,
|
ApiV2SubscriptionView,
|
||||||
ApiV2AttachView,
|
ApiV2AttachView,
|
||||||
|
)
|
||||||
|
from awx.api.views import (
|
||||||
AuthView,
|
AuthView,
|
||||||
UserMeList,
|
UserMeList,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
@@ -28,8 +30,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 import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver
|
from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
55
awx/api/validators.py
Normal file
55
awx/api/validators.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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,6 +69,7 @@ from awx.api.generics import (
|
|||||||
APIView,
|
APIView,
|
||||||
BaseUsersList,
|
BaseUsersList,
|
||||||
CopyAPIView,
|
CopyAPIView,
|
||||||
|
GenericCancelView,
|
||||||
GenericAPIView,
|
GenericAPIView,
|
||||||
ListAPIView,
|
ListAPIView,
|
||||||
ListCreateAPIView,
|
ListCreateAPIView,
|
||||||
@@ -122,56 +123,6 @@ 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
|
||||||
|
|
||||||
@@ -1026,20 +977,11 @@ class SystemJobEventsList(SubListAPIView):
|
|||||||
return job.get_event_queryset()
|
return job.get_event_queryset()
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateCancel(RetrieveAPIView):
|
class ProjectUpdateCancel(GenericCancelView):
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
@@ -2312,20 +2254,11 @@ class InventoryUpdateCredentialsList(SubListAPIView):
|
|||||||
relationship = 'credentials'
|
relationship = 'credentials'
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdateCancel(RetrieveAPIView):
|
class InventoryUpdateCancel(GenericCancelView):
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
@@ -3100,8 +3033,7 @@ 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 WorkflowJobeNodes to the related nodes of specified by
|
# Limit the set of WorkflowJobNodes to the related nodes of specified by self.relationship
|
||||||
#'relationship'
|
|
||||||
#
|
#
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
parent = self.get_parent_object()
|
parent = self.get_parent_object()
|
||||||
@@ -3403,20 +3335,15 @@ class WorkflowJobWorkflowNodesList(SubListAPIView):
|
|||||||
return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id')
|
return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id')
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobCancel(RetrieveAPIView):
|
class WorkflowJobCancel(GenericCancelView):
|
||||||
|
|
||||||
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):
|
||||||
obj = self.get_object()
|
r = super().post(request, *args, **kwargs)
|
||||||
if obj.can_cancel:
|
ScheduleWorkflowManager().schedule()
|
||||||
obj.cancel()
|
return r
|
||||||
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):
|
||||||
@@ -3572,20 +3499,11 @@ class JobActivityStreamList(SubListAPIView):
|
|||||||
search_fields = ('changes',)
|
search_fields = ('changes',)
|
||||||
|
|
||||||
|
|
||||||
class JobCancel(RetrieveAPIView):
|
class JobCancel(GenericCancelView):
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
@@ -4056,20 +3974,11 @@ class AdHocCommandDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
|
|||||||
serializer_class = serializers.AdHocCommandDetailSerializer
|
serializer_class = serializers.AdHocCommandDetailSerializer
|
||||||
|
|
||||||
|
|
||||||
class AdHocCommandCancel(RetrieveAPIView):
|
class AdHocCommandCancel(GenericCancelView):
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
@@ -4204,20 +4113,11 @@ class SystemJobDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
|
|||||||
serializer_class = serializers.SystemJobSerializer
|
serializer_class = serializers.SystemJobSerializer
|
||||||
|
|
||||||
|
|
||||||
class SystemJobCancel(RetrieveAPIView):
|
class SystemJobCancel(GenericCancelView):
|
||||||
|
|
||||||
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=10))
|
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
|
||||||
.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,9 +993,6 @@ 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,11 +166,7 @@ class Metrics:
|
|||||||
elif settings.IS_TESTING():
|
elif settings.IS_TESTING():
|
||||||
self.instance_name = "awx_testing"
|
self.instance_name = "awx_testing"
|
||||||
else:
|
else:
|
||||||
try:
|
self.instance_name = Instance.objects.my_hostname()
|
||||||
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,6 +3,7 @@ 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
|
||||||
@@ -49,7 +50,10 @@ class Control(object):
|
|||||||
reply_queue = Control.generate_reply_queue_name()
|
reply_queue = Control.generate_reply_queue_name()
|
||||||
self.result = None
|
self.result = None
|
||||||
|
|
||||||
with pg_bus_conn(new_connection=True) as conn:
|
if not connection.get_autocommit():
|
||||||
|
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,6 +387,8 @@ 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:
|
||||||
@@ -450,9 +452,6 @@ 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,12 +16,7 @@ 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
|
||||||
"""
|
"""
|
||||||
try:
|
jobs = UnifiedJob.objects.filter(status='running', controller_node=Instance.objects.my_hostname())
|
||||||
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)
|
||||||
@@ -62,16 +57,13 @@ 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
|
||||||
|
|
||||||
me = instance
|
if instance is None:
|
||||||
if me is None:
|
hostname = Instance.objects.my_hostname()
|
||||||
try:
|
else:
|
||||||
me = Instance.objects.me()
|
hostname = instance.hostname
|
||||||
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=me.hostname)
|
jobs = UnifiedJob.objects.filter(status='waiting', modified__lte=ref_time - timedelta(seconds=grace_period), controller_node=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:
|
||||||
@@ -82,16 +74,13 @@ 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.
|
||||||
"""
|
"""
|
||||||
me = instance
|
if instance is None:
|
||||||
if me is None:
|
hostname = Instance.objects.my_hostname()
|
||||||
try:
|
else:
|
||||||
me = Instance.objects.me()
|
hostname = instance.hostname
|
||||||
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=me.hostname) | Q(controller_node=me.hostname)) & ~Q(polymorphic_ctype_id=workflow_ctype_id)
|
Q(status='running') & (Q(execution_node=hostname) | Q(controller_node=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,7 +114,6 @@ 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):
|
||||||
@@ -156,6 +155,16 @@ 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)
|
||||||
@@ -171,8 +180,10 @@ 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():
|
for e in conn.events(yield_timeouts=True):
|
||||||
self.process_task(json.loads(e.payload))
|
if e is not None:
|
||||||
|
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
|
||||||
@@ -229,6 +240,8 @@ 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, me, hostnames, data):
|
def get_connection_status(cls, 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, me, hostnames, data):
|
def get_connection_stats(cls, 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:
|
||||||
me = Instance.objects.me()
|
my_hostname = Instance.objects.my_hostname()
|
||||||
logger.info('Active instance with hostname {} is registered.'.format(me.hostname))
|
logger.info('Active instance with hostname {} is registered.'.format(my_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
|
||||||
|
|
||||||
me = Instance.objects.me()
|
my_hostname = Instance.objects.my_hostname()
|
||||||
hostnames = [i.hostname for i in Instance.objects.exclude(hostname=me.hostname)]
|
hostnames = [i.hostname for i in Instance.objects.exclude(hostname=my_hostname)]
|
||||||
|
|
||||||
host_stats = Command.get_connection_status(me, hostnames, data)
|
host_stats = Command.get_connection_status(hostnames, data)
|
||||||
lines = Command._format_lines(host_stats)
|
lines = Command._format_lines(host_stats)
|
||||||
|
|
||||||
print(f'Broadcast websocket connection status from "{me.hostname}" to:')
|
print(f'Broadcast websocket connection status from "{my_hostname}" to:')
|
||||||
print('\n'.join(lines))
|
print('\n'.join(lines))
|
||||||
|
|
||||||
host_stats = Command.get_connection_stats(me, hostnames, data)
|
host_stats = Command.get_connection_stats(hostnames, data)
|
||||||
lines = Command._format_lines(host_stats)
|
lines = Command._format_lines(host_stats)
|
||||||
|
|
||||||
print(f'\nBroadcast websocket connection stats from "{me.hostname}" to:')
|
print(f'\nBroadcast websocket connection stats from "{my_hostname}" to:')
|
||||||
print('\n'.join(lines))
|
print('\n'.join(lines))
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -99,9 +99,12 @@ 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=settings.CLUSTER_HOST_ID)
|
node = self.filter(hostname=self.my_hostname())
|
||||||
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_collection_jt', 'create_clearsessions_jt', 'create_cleartokens_jt']
|
__all__ = ['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:
|
except Exception:
|
||||||
# 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
|
raise AttributeError(field_name)
|
||||||
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('^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule)
|
start_date_rule = re.sub(r'^.*(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,6 +1305,8 @@ 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):
|
||||||
@@ -1465,23 +1467,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'
|
||||||
cancel_fields.append('status')
|
self.save(update_fields=['status'])
|
||||||
|
# Avoid race condition where we have stale model from pending state but job has already started,
|
||||||
self.save(update_fields=cancel_fields)
|
# its checking signal but not cancel_flag, so re-send signal after updating 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.me().hostname
|
pu_en = Instance.objects.my_hostname()
|
||||||
|
|
||||||
sync_metafields = dict(
|
sync_metafields = dict(
|
||||||
launch_type="sync",
|
launch_type="sync",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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)
|
||||||
|
|
||||||
@@ -54,3 +56,33 @@ 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(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
url = reverse('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(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
|
url = reverse('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(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
url = reverse('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(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
|
url = reverse('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 f'Cannot disassociate hybrid instance' in str(r.data)
|
assert 'Cannot disassociate hybrid instance' in str(r.data)
|
||||||
|
|||||||
@@ -105,6 +105,30 @@ 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',
|
||||||
@@ -123,19 +147,19 @@ def test_encrypted_survey_answer(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,7 +5,8 @@ from unittest import mock
|
|||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from awx.api.views import ApiVersionRootView, JobTemplateLabelList, InventoryInventorySourcesUpdate, JobTemplateSurveySpec
|
from awx.api.views.root import ApiVersionRootView
|
||||||
|
from awx.api.views import JobTemplateLabelList, InventoryInventorySourcesUpdate, JobTemplateSurveySpec
|
||||||
|
|
||||||
from awx.main.views import handle_error
|
from awx.main.views import handle_error
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ class TestApiRootView:
|
|||||||
endpoints = [
|
endpoints = [
|
||||||
'ping',
|
'ping',
|
||||||
'config',
|
'config',
|
||||||
#'settings',
|
# 'settings',
|
||||||
'me',
|
'me',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'organizations',
|
'organizations',
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ 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")
|
||||||
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status'])
|
assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
|
||||||
|
((), {'update_fields': ['cancel_flag', 'start_args']}),
|
||||||
|
((), {'update_fields': ['status']}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_job_explanation(unified_job):
|
def test_cancel_job_explanation(unified_job):
|
||||||
@@ -60,7 +63,10 @@ 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
|
||||||
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'job_explanation', 'status'])
|
assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
|
||||||
|
((), {'update_fields': ['cancel_flag', 'start_args', 'job_explanation']}),
|
||||||
|
((), {'update_fields': ['status']}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_organization_copy_to_jobs():
|
def test_organization_copy_to_jobs():
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# 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
|
||||||
@@ -12,9 +13,13 @@ 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',
|
||||||
@@ -194,3 +199,136 @@ 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,9 +264,15 @@ 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
|
||||||
if hostpath.count(':') > 1:
|
# Handle IPv6 here. In this case, we might have hostpath of:
|
||||||
|
# [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)
|
||||||
host, path = hostpath.split(':', 1)
|
else:
|
||||||
|
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('/'):
|
||||||
@@ -325,7 +331,11 @@ 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''
|
||||||
netloc = u'@'.join(filter(None, [netloc, parts.hostname]))
|
# urllib.parse strips brackets from IPv6 addresses, so we need to add them back in
|
||||||
|
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.me().hostname)
|
Instance.objects.exclude(hostname=Instance.objects.my_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.me().hostname
|
return Instance.objects.my_hostname()
|
||||||
|
|
||||||
|
|
||||||
class WebsocketTask:
|
class WebsocketTask:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ __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
|
||||||
|
|
||||||
@@ -15,7 +14,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 *
|
from ansible_sign.signing import GPGVerifier
|
||||||
|
|
||||||
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]
|
'application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63] # NOQA
|
||||||
) # noqa
|
) # noqa
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ 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
|
||||||
@@ -327,31 +329,32 @@ class SAMLAuth(BaseSAMLAuth):
|
|||||||
return super(SAMLAuth, self).get_user(user_id)
|
return super(SAMLAuth, self).get_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
def _update_m2m_from_groups(user, ldap_user, related, opts, remove=True):
|
def _update_m2m_from_groups(ldap_user, opts, remove=True):
|
||||||
"""
|
"""
|
||||||
Hepler function to update m2m relationship based on LDAP group membership.
|
Hepler function to evaluate the LDAP team/org options to determine if LDAP user should
|
||||||
|
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
|
return None
|
||||||
elif not opts:
|
elif not opts:
|
||||||
pass
|
pass
|
||||||
elif opts is True:
|
elif isinstance(opts, bool) and opts is True:
|
||||||
should_add = True
|
return 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):
|
||||||
should_add = True
|
return True
|
||||||
if should_add:
|
return False
|
||||||
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')
|
||||||
@@ -383,31 +386,73 @@ 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', {})
|
||||||
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))
|
|
||||||
admins_opts = org_opts.get('admins', None)
|
|
||||||
remove_admins = bool(org_opts.get('remove_admins', remove))
|
|
||||||
_update_m2m_from_groups(user, ldap_user, org.admin_role.members, admins_opts, remove_admins)
|
|
||||||
auditors_opts = org_opts.get('auditors', None)
|
|
||||||
remove_auditors = bool(org_opts.get('remove_auditors', 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)
|
|
||||||
|
|
||||||
# Update team membership based on group memberships.
|
|
||||||
team_map = getattr(backend.settings, 'TEAM_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():
|
||||||
|
remove = bool(org_opts.get('remove', True))
|
||||||
|
desired_org_states[org_name] = {}
|
||||||
|
for org_role_name in org_roles_and_ldap_attributes.keys():
|
||||||
|
ldap_name = org_roles_and_ldap_attributes[org_role_name]
|
||||||
|
opts = org_opts.get(ldap_name, None)
|
||||||
|
remove = bool(org_opts.get('remove_{}'.format(ldap_name), remove))
|
||||||
|
desired_org_states[org_name][org_role_name] = _update_m2m_from_groups(ldap_user, opts, remove)
|
||||||
|
|
||||||
|
# If everything returned None (because there was no configuration) we can remove this org from our map
|
||||||
|
# This will prevent us from loading the org in the next query
|
||||||
|
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))
|
||||||
_update_m2m_from_groups(user, ldap_user, team.member_role.members, users_opts, remove)
|
state = _update_m2m_from_groups(ldap_user, 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:
|
||||||
@@ -423,3 +468,62 @@ 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(f'SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
cursor.execute('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(f'UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
|
cursor.execute('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,8 +11,6 @@ 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')
|
||||||
@@ -42,9 +40,6 @@ 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.221.3",
|
"@patternfly/react-core": "^4.239.0",
|
||||||
"@patternfly/react-icons": "4.75.1",
|
"@patternfly/react-icons": "4.90.0",
|
||||||
"@patternfly/react-table": "4.100.8",
|
"@patternfly/react-table": "4.108.0",
|
||||||
"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.4.4",
|
"d3": "7.6.1",
|
||||||
"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.5"
|
"styled-components": "5.3.6"
|
||||||
},
|
},
|
||||||
"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.231.8",
|
"version": "4.239.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz",
|
||||||
"integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
|
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@patternfly/react-icons": "^4.82.8",
|
"@patternfly/react-icons": "^4.90.0",
|
||||||
"@patternfly/react-styles": "^4.81.8",
|
"@patternfly/react-styles": "^4.89.0",
|
||||||
"@patternfly/react-tokens": "^4.83.8",
|
"@patternfly/react-tokens": "^4.91.0",
|
||||||
"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,43 +3769,34 @@
|
|||||||
"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.75.1",
|
"version": "4.90.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz",
|
||||||
"integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
|
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==",
|
||||||
"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.81.8",
|
"version": "4.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz",
|
||||||
"integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
|
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@patternfly/react-table": {
|
"node_modules/@patternfly/react-table": {
|
||||||
"version": "4.100.8",
|
"version": "4.108.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz",
|
||||||
"integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
|
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@patternfly/react-core": "^4.231.8",
|
"@patternfly/react-core": "^4.239.0",
|
||||||
"@patternfly/react-icons": "^4.82.8",
|
"@patternfly/react-icons": "^4.90.0",
|
||||||
"@patternfly/react-styles": "^4.81.8",
|
"@patternfly/react-styles": "^4.89.0",
|
||||||
"@patternfly/react-tokens": "^4.83.8",
|
"@patternfly/react-tokens": "^4.91.0",
|
||||||
"lodash": "^4.17.19",
|
"lodash": "^4.17.19",
|
||||||
"tslib": "^2.0.0"
|
"tslib": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -3814,24 +3805,15 @@
|
|||||||
"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.83.8",
|
"version": "4.91.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz",
|
||||||
"integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
|
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw=="
|
||||||
},
|
},
|
||||||
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
|
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
@@ -7482,16 +7464,16 @@
|
|||||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
"node_modules/d3": {
|
||||||
"version": "7.4.4",
|
"version": "7.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz",
|
||||||
"integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
|
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==",
|
||||||
"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": "3",
|
"d3-contour": "4",
|
||||||
"d3-delaunay": "6",
|
"d3-delaunay": "6",
|
||||||
"d3-dispatch": "3",
|
"d3-dispatch": "3",
|
||||||
"d3-drag": "3",
|
"d3-drag": "3",
|
||||||
@@ -7522,9 +7504,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.1.1",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
|
||||||
"integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
|
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"internmap": "1 - 2"
|
"internmap": "1 - 2"
|
||||||
},
|
},
|
||||||
@@ -7575,11 +7557,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-contour": {
|
"node_modules/d3-contour": {
|
||||||
"version": "3.0.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz",
|
||||||
"integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
|
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "2 - 3"
|
"d3-array": "^3.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -20234,9 +20216,9 @@
|
|||||||
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
||||||
},
|
},
|
||||||
"node_modules/styled-components": {
|
"node_modules/styled-components": {
|
||||||
"version": "5.3.5",
|
"version": "5.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz",
|
||||||
"integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
|
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.0.0",
|
"@babel/helper-module-imports": "^7.0.0",
|
||||||
@@ -25112,25 +25094,19 @@
|
|||||||
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
|
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
|
||||||
},
|
},
|
||||||
"@patternfly/react-core": {
|
"@patternfly/react-core": {
|
||||||
"version": "4.231.8",
|
"version": "4.239.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz",
|
||||||
"integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
|
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@patternfly/react-icons": "^4.82.8",
|
"@patternfly/react-icons": "^4.90.0",
|
||||||
"@patternfly/react-styles": "^4.81.8",
|
"@patternfly/react-styles": "^4.89.0",
|
||||||
"@patternfly/react-tokens": "^4.83.8",
|
"@patternfly/react-tokens": "^4.91.0",
|
||||||
"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",
|
||||||
@@ -25139,35 +25115,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@patternfly/react-icons": {
|
"@patternfly/react-icons": {
|
||||||
"version": "4.75.1",
|
"version": "4.90.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz",
|
||||||
"integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
|
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"@patternfly/react-styles": {
|
"@patternfly/react-styles": {
|
||||||
"version": "4.81.8",
|
"version": "4.89.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz",
|
||||||
"integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
|
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ=="
|
||||||
},
|
},
|
||||||
"@patternfly/react-table": {
|
"@patternfly/react-table": {
|
||||||
"version": "4.100.8",
|
"version": "4.108.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz",
|
||||||
"integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
|
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@patternfly/react-core": "^4.231.8",
|
"@patternfly/react-core": "^4.239.0",
|
||||||
"@patternfly/react-icons": "^4.82.8",
|
"@patternfly/react-icons": "^4.90.0",
|
||||||
"@patternfly/react-styles": "^4.81.8",
|
"@patternfly/react-styles": "^4.89.0",
|
||||||
"@patternfly/react-tokens": "^4.83.8",
|
"@patternfly/react-tokens": "^4.91.0",
|
||||||
"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",
|
||||||
@@ -25176,9 +25146,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@patternfly/react-tokens": {
|
"@patternfly/react-tokens": {
|
||||||
"version": "4.83.8",
|
"version": "4.91.0",
|
||||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
|
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz",
|
||||||
"integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
|
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw=="
|
||||||
},
|
},
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": {
|
"@pmmmwh/react-refresh-webpack-plugin": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
@@ -28082,16 +28052,16 @@
|
|||||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||||
},
|
},
|
||||||
"d3": {
|
"d3": {
|
||||||
"version": "7.4.4",
|
"version": "7.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz",
|
||||||
"integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
|
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==",
|
||||||
"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": "3",
|
"d3-contour": "4",
|
||||||
"d3-delaunay": "6",
|
"d3-delaunay": "6",
|
||||||
"d3-dispatch": "3",
|
"d3-dispatch": "3",
|
||||||
"d3-drag": "3",
|
"d3-drag": "3",
|
||||||
@@ -28119,9 +28089,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-array": {
|
"d3-array": {
|
||||||
"version": "3.1.1",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
|
||||||
"integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
|
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"internmap": "1 - 2"
|
"internmap": "1 - 2"
|
||||||
}
|
}
|
||||||
@@ -28157,11 +28127,11 @@
|
|||||||
"integrity": "sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw=="
|
"integrity": "sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw=="
|
||||||
},
|
},
|
||||||
"d3-contour": {
|
"d3-contour": {
|
||||||
"version": "3.0.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz",
|
||||||
"integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
|
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-array": "2 - 3"
|
"d3-array": "^3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-delaunay": {
|
"d3-delaunay": {
|
||||||
@@ -37705,9 +37675,9 @@
|
|||||||
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
||||||
},
|
},
|
||||||
"styled-components": {
|
"styled-components": {
|
||||||
"version": "5.3.5",
|
"version": "5.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz",
|
||||||
"integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
|
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==",
|
||||||
"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.221.3",
|
"@patternfly/react-core": "^4.239.0",
|
||||||
"@patternfly/react-icons": "4.75.1",
|
"@patternfly/react-icons": "4.90.0",
|
||||||
"@patternfly/react-table": "4.100.8",
|
"@patternfly/react-table": "4.108.0",
|
||||||
"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.4.4",
|
"d3": "7.6.1",
|
||||||
"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.5"
|
"styled-components": "5.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.16.10",
|
"@babel/core": "^7.16.10",
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ 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({ isDisabled, onClick, selectedItems }) {
|
function HealthCheckButton({
|
||||||
|
isDisabled,
|
||||||
|
onClick,
|
||||||
|
selectedItems,
|
||||||
|
healthCheckPending,
|
||||||
|
}) {
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
const selectedItemsCount = selectedItems.length;
|
const selectedItemsCount = selectedItems.length;
|
||||||
@@ -28,8 +33,10 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
|||||||
component="button"
|
component="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
ouiaId="health-check"
|
ouiaId="health-check"
|
||||||
|
isLoading={healthCheckPending}
|
||||||
|
spinnerAriaLabel={t`Running health check`}
|
||||||
>
|
>
|
||||||
{t`Run health check`}
|
{healthCheckPending ? t`Running health check` : t`Run health check`}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -42,7 +49,11 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
ouiaId="health-check"
|
ouiaId="health-check"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>{t`Run health check`}</Button>
|
isLoading={healthCheckPending}
|
||||||
|
spinnerAriaLabel={t`Running health check`}
|
||||||
|
>
|
||||||
|
{healthCheckPending ? t`Running health check` : t`Run health check`}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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
awx/ui/src/components/InstanceGroupLabels/index.js
Normal file
1
awx/ui/src/components/InstanceGroupLabels/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InstanceGroupLabels';
|
||||||
@@ -107,6 +107,17 @@ 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,6 +6,7 @@ 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';
|
||||||
@@ -227,21 +228,7 @@ function PromptDetail({
|
|||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
rows={4}
|
rows={4}
|
||||||
value={
|
value={
|
||||||
<ChipGroup
|
<InstanceGroupLabels labels={overrides.instance_groups} />
|
||||||
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,6 +10,7 @@ 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';
|
||||||
@@ -27,11 +28,6 @@ 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);
|
||||||
@@ -498,26 +494,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
|
|||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance Groups`}
|
label={t`Instance Groups`}
|
||||||
value={
|
value={
|
||||||
<ChipGroup
|
<InstanceGroupLabels labels={instanceGroups} isLinkable />
|
||||||
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,14 +123,7 @@ 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: {},
|
||||||
@@ -146,7 +139,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 } from '@patternfly/react-icons';
|
import { CaretLeftIcon, OutlinedClockIcon } 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,6 +23,7 @@ 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';
|
||||||
@@ -62,7 +63,7 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||||
const { me = {} } = useConfig();
|
const config = useConfig();
|
||||||
const { id, instanceId } = useParams();
|
const { id, instanceId } = useParams();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -115,15 +116,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDetails();
|
fetchDetails();
|
||||||
}, [fetchDetails]);
|
}, [fetchDetails]);
|
||||||
const {
|
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -161,6 +156,18 @@ 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
|
||||||
);
|
);
|
||||||
@@ -189,6 +196,8 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
|||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isExecutionNode = instance.node_type === 'execution';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoutedTabs tabsArray={tabsArray} />
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
@@ -218,7 +227,22 @@ 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`}
|
||||||
value={formatDateString(healthCheck?.last_health_check)}
|
helpText={
|
||||||
|
<>
|
||||||
|
{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
|
||||||
@@ -237,7 +261,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={instance.capacity_adjustment}
|
value={instance.capacity_adjustment}
|
||||||
onChange={handleChangeValue}
|
onChange={handleChangeValue}
|
||||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||||
data-cy="slider"
|
data-cy="slider"
|
||||||
/>
|
/>
|
||||||
</SliderForks>
|
</SliderForks>
|
||||||
@@ -274,19 +298,25 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
|||||||
)}
|
)}
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
<Tooltip content={t`Run a health check on the instance`}>
|
{isExecutionNode && (
|
||||||
<Button
|
<Tooltip content={t`Run a health check on the instance`}>
|
||||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
<Button
|
||||||
variant="primary"
|
isDisabled={
|
||||||
ouiaId="health-check-button"
|
!config?.me?.is_superuser || instance.health_check_pending
|
||||||
onClick={fetchHealthCheck}
|
}
|
||||||
isLoading={isRunningHealthCheck}
|
variant="primary"
|
||||||
spinnerAriaLabel={t`Running health check`}
|
ouiaId="health-check-button"
|
||||||
>
|
onClick={fetchHealthCheck}
|
||||||
{t`Run health check`}
|
isLoading={instance.health_check_pending}
|
||||||
</Button>
|
spinnerAriaLabel={t`Running health check`}
|
||||||
</Tooltip>
|
>
|
||||||
{me.is_superuser && instance.node_type !== 'control' && (
|
{instance.health_check_pending
|
||||||
|
? 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,8 +87,9 @@ describe('<InstanceDetails/>', () => {
|
|||||||
mem_capacity: 38,
|
mem_capacity: 38,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
managed_by_policy: true,
|
managed_by_policy: true,
|
||||||
node_type: 'hybrid',
|
node_type: 'execution',
|
||||||
node_state: 'ready',
|
node_state: 'ready',
|
||||||
|
health_check_pending: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
||||||
@@ -347,6 +348,67 @@ 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,6 +35,8 @@ 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();
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ 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,
|
||||||
@@ -90,7 +96,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 !== 'hop')
|
.filter(({ node_type }) => node_type === 'execution')
|
||||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||||
);
|
);
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -99,6 +105,18 @@ 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();
|
||||||
@@ -246,9 +264,10 @@ function InstanceList({ instanceGroup }) {
|
|||||||
isProtectedInstanceGroup={instanceGroup.name === 'controlplane'}
|
isProtectedInstanceGroup={instanceGroup.name === 'controlplane'}
|
||||||
/>,
|
/>,
|
||||||
<HealthCheckButton
|
<HealthCheckButton
|
||||||
isDisabled={!canAdd}
|
isDisabled={!canAdd || !canRunHealthCheck}
|
||||||
onClick={handleHealthCheck}
|
onClick={handleHealthCheck}
|
||||||
selectedItems={selected}
|
selectedItems={selected}
|
||||||
|
healthCheckPending={pendingHealthCheck}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
emptyStateControls={
|
emptyStateControls={
|
||||||
@@ -263,7 +282,10 @@ function InstanceList({ instanceGroup }) {
|
|||||||
)}
|
)}
|
||||||
headerRow={
|
headerRow={
|
||||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||||
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
|
<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(3);
|
expect(InstancesAPI.healthCheck).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
test('should render health check error', async () => {
|
test('should render health check error', async () => {
|
||||||
InstancesAPI.healthCheck.mockRejectedValue(
|
InstancesAPI.healthCheck.mockRejectedValue(
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ 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';
|
||||||
@@ -52,7 +54,7 @@ function InstanceListItem({
|
|||||||
fetchInstances,
|
fetchInstances,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
}) {
|
}) {
|
||||||
const { me = {} } = useConfig();
|
const config = useConfig();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [forks, setForks] = useState(
|
const [forks, setForks] = useState(
|
||||||
computeForks(
|
computeForks(
|
||||||
@@ -100,6 +102,18 @@ 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
|
||||||
@@ -154,7 +168,7 @@ function InstanceListItem({
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={instance.capacity_adjustment}
|
value={instance.capacity_adjustment}
|
||||||
onChange={handleChangeValue}
|
onChange={handleChangeValue}
|
||||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||||
data-cy="slider"
|
data-cy="slider"
|
||||||
/>
|
/>
|
||||||
</SliderForks>
|
</SliderForks>
|
||||||
@@ -206,7 +220,22 @@ function InstanceListItem({
|
|||||||
<Detail
|
<Detail
|
||||||
data-cy="last-health-check"
|
data-cy="last-health-check"
|
||||||
label={t`Last Health Check`}
|
label={t`Last Health Check`}
|
||||||
value={formatDateString(instance.last_health_check)}
|
helpText={
|
||||||
|
<>
|
||||||
|
{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(
|
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
|
||||||
wrapper.find('Detail[label="Last Health Check"]').prop('value')
|
'Last Health Check9/15/2021, 6:02:07 PM'
|
||||||
).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 { Link, useHistory, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
CodeBlockCode,
|
CodeBlockCode,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Slider,
|
Slider,
|
||||||
Label,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { DownloadIcon } from '@patternfly/react-icons';
|
import { DownloadIcon, OutlinedClockIcon } 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,6 +22,7 @@ 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,6 +33,7 @@ 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`
|
||||||
@@ -62,7 +63,8 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function InstanceDetail({ setBreadcrumb, isK8s }) {
|
function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||||
const { me = {} } = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [forks, setForks] = useState();
|
const [forks, setForks] = useState();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -85,8 +87,7 @@ 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);
|
||||||
@@ -115,15 +116,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
setBreadcrumb(instance);
|
setBreadcrumb(instance);
|
||||||
}
|
}
|
||||||
}, [instance, setBreadcrumb]);
|
}, [instance, setBreadcrumb]);
|
||||||
const {
|
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -149,10 +144,17 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildLinkURL = (inst) =>
|
const formatHealthCheckTimeStamp = (last) => (
|
||||||
inst.is_container_group
|
<>
|
||||||
? '/instance_groups/container_group/'
|
{formatDateString(last)}
|
||||||
: '/instance_groups/';
|
{instance.health_check_pending ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<OutlinedClockIcon />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(
|
const { error, dismissError } = useDismissableError(
|
||||||
updateInstanceError || healthCheckError
|
updateInstanceError || healthCheckError
|
||||||
@@ -179,6 +181,7 @@ 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 (
|
||||||
<>
|
<>
|
||||||
@@ -217,32 +220,31 @@ 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={instanceGroups.map((ig) => (
|
value={
|
||||||
<React.Fragment key={ig.id}>
|
<InstanceGroupLabels labels={instanceGroups} isLinkable />
|
||||||
<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"
|
||||||
value={formatDateString(healthCheck?.last_health_check)}
|
helpText={
|
||||||
|
<>
|
||||||
|
{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
|
||||||
@@ -280,7 +282,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={instance.capacity_adjustment}
|
value={instance.capacity_adjustment}
|
||||||
onChange={handleChangeValue}
|
onChange={handleChangeValue}
|
||||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
isDisabled={
|
||||||
|
!config?.me?.is_superuser || !instance.enabled
|
||||||
|
}
|
||||||
data-cy="slider"
|
data-cy="slider"
|
||||||
/>
|
/>
|
||||||
</SliderForks>
|
</SliderForks>
|
||||||
@@ -324,7 +328,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
</DetailList>
|
</DetailList>
|
||||||
{!isHopNode && (
|
{!isHopNode && (
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
|
{config?.me?.is_superuser && isK8s && isExecutionNode && (
|
||||||
<RemoveInstanceButton
|
<RemoveInstanceButton
|
||||||
dataCy="remove-instance-button"
|
dataCy="remove-instance-button"
|
||||||
itemsToRemove={[instance]}
|
itemsToRemove={[instance]}
|
||||||
@@ -332,18 +336,24 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
|||||||
onRemove={removeInstances}
|
onRemove={removeInstances}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={t`Run a health check on the instance`}>
|
{isExecutionNode && (
|
||||||
<Button
|
<Tooltip content={t`Run a health check on the instance`}>
|
||||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
<Button
|
||||||
variant="primary"
|
isDisabled={
|
||||||
ouiaId="health-check-button"
|
!config?.me?.is_superuser || instance.health_check_pending
|
||||||
onClick={fetchHealthCheck}
|
}
|
||||||
isLoading={isRunningHealthCheck}
|
variant="primary"
|
||||||
spinnerAriaLabel={t`Running health check`}
|
ouiaId="health-check-button"
|
||||||
>
|
onClick={fetchHealthCheck}
|
||||||
{t`Run health check`}
|
isLoading={instance.health_check_pending}
|
||||||
</Button>
|
spinnerAriaLabel={t`Running health check`}
|
||||||
</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,8 +49,9 @@ describe('<InstanceDetail/>', () => {
|
|||||||
mem_capacity: 38,
|
mem_capacity: 38,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
managed_by_policy: true,
|
managed_by_policy: true,
|
||||||
node_type: 'hybrid',
|
node_type: 'execution',
|
||||||
node_state: 'ready',
|
node_state: 'ready',
|
||||||
|
health_check_pending: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
InstancesAPI.readInstanceGroup.mockResolvedValue({
|
InstancesAPI.readInstanceGroup.mockResolvedValue({
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ 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 },
|
||||||
@@ -51,6 +53,10 @@ 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,
|
||||||
@@ -87,7 +93,7 @@ function InstanceList() {
|
|||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const [...response] = await Promise.all(
|
const [...response] = await Promise.all(
|
||||||
selected
|
selected
|
||||||
.filter(({ node_type }) => node_type !== 'hop')
|
.filter(({ node_type }) => node_type === 'execution')
|
||||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||||
);
|
);
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -96,6 +102,18 @@ 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();
|
||||||
@@ -189,6 +207,8 @@ function InstanceList() {
|
|||||||
onClick={handleHealthCheck}
|
onClick={handleHealthCheck}
|
||||||
key="healthCheck"
|
key="healthCheck"
|
||||||
selectedItems={selected}
|
selectedItems={selected}
|
||||||
|
healthCheckPending={pendingHealthCheck}
|
||||||
|
isDisabled={!canRunHealthCheck}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -196,7 +216,7 @@ function InstanceList() {
|
|||||||
headerRow={
|
headerRow={
|
||||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||||
<HeaderCell
|
<HeaderCell
|
||||||
tooltip={t`Cannot run health check on hop nodes.`}
|
tooltip={t`Health checks can only be run on execution 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: 'control',
|
node_type: 'execution',
|
||||||
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: 'hybrid',
|
node_type: 'execution',
|
||||||
node_state: 'ready',
|
node_state: 'ready',
|
||||||
memory: 2087469056,
|
memory: 2087469056,
|
||||||
cpu_capacity: 24,
|
cpu_capacity: 24,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ 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';
|
||||||
@@ -52,7 +54,7 @@ function InstanceListItem({
|
|||||||
fetchInstances,
|
fetchInstances,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
}) {
|
}) {
|
||||||
const { me = {} } = useConfig();
|
const config = useConfig();
|
||||||
const [forks, setForks] = useState(
|
const [forks, setForks] = useState(
|
||||||
computeForks(
|
computeForks(
|
||||||
instance.mem_capacity,
|
instance.mem_capacity,
|
||||||
@@ -98,7 +100,21 @@ 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
|
||||||
@@ -121,7 +137,7 @@ function InstanceListItem({
|
|||||||
rowIndex,
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
disable: isHopNode,
|
disable: !isExecutionNode,
|
||||||
}}
|
}}
|
||||||
dataLabel={t`Selected`}
|
dataLabel={t`Selected`}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +180,7 @@ function InstanceListItem({
|
|||||||
step={0.1}
|
step={0.1}
|
||||||
value={instance.capacity_adjustment}
|
value={instance.capacity_adjustment}
|
||||||
onChange={handleChangeValue}
|
onChange={handleChangeValue}
|
||||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||||
data-cy="slider"
|
data-cy="slider"
|
||||||
/>
|
/>
|
||||||
</SliderForks>
|
</SliderForks>
|
||||||
@@ -221,7 +237,22 @@ function InstanceListItem({
|
|||||||
<Detail
|
<Detail
|
||||||
data-cy="last-health-check"
|
data-cy="last-health-check"
|
||||||
label={t`Last Health Check`}
|
label={t`Last Health Check`}
|
||||||
value={formatDateString(instance.last_health_check)}
|
helpText={
|
||||||
|
<>
|
||||||
|
{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(
|
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
|
||||||
wrapper.find('Detail[label="Last Health Check"]').prop('value')
|
'Last Health Check9/15/2021, 6:02:07 PM'
|
||||||
).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,6 +23,7 @@ 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 }) {
|
||||||
@@ -105,23 +106,7 @@ function InventoryDetail({ inventory }) {
|
|||||||
<Detail
|
<Detail
|
||||||
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) => (
|
|
||||||
<Chip
|
|
||||||
key={ig.id}
|
|
||||||
isReadOnly
|
|
||||||
ouiaId={`instance-group-${ig.id}-chip`}
|
|
||||||
>
|
|
||||||
{ig.name}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
}
|
|
||||||
isEmpty={instanceGroups.length === 0}
|
isEmpty={instanceGroups.length === 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,9 +131,8 @@ describe('<InventoryDetail />', () => {
|
|||||||
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
|
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
|
||||||
mockInventory.id
|
mockInventory.id
|
||||||
);
|
);
|
||||||
const chip = wrapper.find('Chip').at(0);
|
const label = wrapper.find('Label').at(0);
|
||||||
expect(chip.prop('isReadOnly')).toEqual(true);
|
expect(label.prop('children')).toEqual('Foo');
|
||||||
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, Chip, Label } from '@patternfly/react-core';
|
import { Button, Label } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { Inventory } from 'types';
|
import { Inventory } from 'types';
|
||||||
import { InventoriesAPI, UnifiedJobsAPI } from 'api';
|
import { InventoriesAPI, UnifiedJobsAPI } from 'api';
|
||||||
@@ -10,7 +10,6 @@ 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';
|
||||||
@@ -18,6 +17,7 @@ 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,23 +120,7 @@ function SmartInventoryDetail({ inventory }) {
|
|||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={t`Instance groups`}
|
label={t`Instance groups`}
|
||||||
value={
|
value={<InstanceGroupLabels labels={instanceGroups} />}
|
||||||
<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,9 +91,7 @@ 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.
|
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.`}
|
||||||
Use the radio button to toggle between the two. Refer to the
|
|
||||||
Ansible Controller documentation for example syntax.`}
|
|
||||||
/>
|
/>
|
||||||
</FormFullWidthLayout>
|
</FormFullWidthLayout>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,7 @@ const getStdOutValue = (hostEvent) => {
|
|||||||
) {
|
) {
|
||||||
stdOut = res.results.join('\n');
|
stdOut = res.results.join('\n');
|
||||||
} else if (res?.stdout) {
|
} else if (res?.stdout) {
|
||||||
stdOut = res.stdout;
|
stdOut = Array.isArray(res.stdout) ? res.stdout.join(' ') : res.stdout;
|
||||||
}
|
}
|
||||||
return stdOut;
|
return stdOut;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,60 @@ 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\",
|
||||||
@@ -281,4 +335,25 @@ 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 from 'react';
|
import React, { useEffect, useState } 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();
|
||||||
|
|
||||||
const toHHMMSS = (elapsed) => {
|
return Duration.fromObject({ ...duration }).toFormat('hh:mm:ss');
|
||||||
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,6 +62,7 @@ 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;
|
||||||
@@ -76,6 +77,20 @@ 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 && (
|
||||||
@@ -124,7 +139,13 @@ 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>{toHHMMSS(job.elapsed)}</Badge>
|
<Badge isRead>
|
||||||
|
{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,8 +180,6 @@ 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, Chip } from '@patternfly/react-core';
|
import { Button } 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,6 +16,7 @@ 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 }) {
|
||||||
@@ -79,11 +80,6 @@ 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>
|
||||||
@@ -126,25 +122,7 @@ 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={
|
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}
|
|
||||||
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('Chip')
|
.find('Label')
|
||||||
.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,7 +31,6 @@ 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,6 +1,7 @@
|
|||||||
/* 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';
|
||||||
@@ -16,7 +17,6 @@ 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,12 +26,67 @@ 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,
|
||||||
credentialTypeIds,
|
formik,
|
||||||
|
setCredentials,
|
||||||
|
setSignatureValidationCredentials,
|
||||||
|
credentials,
|
||||||
|
signatureValidationCredentials,
|
||||||
scmTypeOptions,
|
scmTypeOptions,
|
||||||
setScmSubFormState,
|
setScmSubFormState,
|
||||||
scmSubFormState,
|
scmSubFormState,
|
||||||
@@ -41,7 +96,8 @@ function ProjectFormFields({
|
|||||||
scm_url: '',
|
scm_url: '',
|
||||||
scm_branch: '',
|
scm_branch: '',
|
||||||
scm_refspec: '',
|
scm_refspec: '',
|
||||||
credential: null,
|
credential: '',
|
||||||
|
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,
|
||||||
@@ -49,8 +105,7 @@ function ProjectFormFields({
|
|||||||
allow_override: false,
|
allow_override: false,
|
||||||
scm_update_cache_timeout: 0,
|
scm_update_cache_timeout: 0,
|
||||||
};
|
};
|
||||||
const { setFieldValue, setFieldTouched, values, initialValues } =
|
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||||
useFormikContext();
|
|
||||||
|
|
||||||
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
|
const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({
|
||||||
name: 'scm_type',
|
name: 'scm_type',
|
||||||
@@ -66,11 +121,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 = () => {
|
const saveSubFormState = (form) => {
|
||||||
const currentScmFormFields = { ...scmFormFields };
|
const currentScmFormFields = { ...scmFormFields };
|
||||||
|
|
||||||
Object.keys(currentScmFormFields).forEach((label) => {
|
Object.keys(currentScmFormFields).forEach((label) => {
|
||||||
currentScmFormFields[label] = values[label];
|
currentScmFormFields[label] = form.values[label];
|
||||||
});
|
});
|
||||||
|
|
||||||
setScmSubFormState(currentScmFormFields);
|
setScmSubFormState(currentScmFormFields);
|
||||||
@@ -82,27 +137,58 @@ 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) => {
|
const resetScmTypeFields = (value, form) => {
|
||||||
if (values.scm_type === initialValues.scm_type) {
|
if (form.values.scm_type === form.initialValues.scm_type) {
|
||||||
saveSubFormState();
|
saveSubFormState(formik);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.keys(scmFormFields).forEach((label) => {
|
Object.keys(scmFormFields).forEach((label) => {
|
||||||
if (value === initialValues.scm_type) {
|
if (value === form.initialValues.scm_type) {
|
||||||
setFieldValue(label, scmSubFormState[label]);
|
form.setFieldValue(label, scmSubFormState[label]);
|
||||||
} else {
|
} else {
|
||||||
setFieldValue(label, scmFormFields[label]);
|
form.setFieldValue(label, scmFormFields[label]);
|
||||||
}
|
}
|
||||||
setFieldTouched(label, false);
|
form.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(
|
||||||
(cred) => {
|
(value) => {
|
||||||
setFieldValue('signature_validation_credential', cred);
|
handleSignatureValidationCredentialSelection('cryptography', value);
|
||||||
|
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(
|
||||||
@@ -195,18 +281,18 @@ function ProjectFormFields({
|
|||||||
]}
|
]}
|
||||||
onChange={(event, value) => {
|
onChange={(event, value) => {
|
||||||
scmTypeHelpers.setValue(value);
|
scmTypeHelpers.setValue(value);
|
||||||
resetScmTypeFields(value);
|
resetScmTypeFields(value, formik);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
credentialTypeId={credentialTypeIds?.cryptography}
|
credentialTypeId={signatureValidationCredentials.cryptography.typeId}
|
||||||
label={t`Content Signature Validation Credential`}
|
label={t`Content Signature Validation Credential`}
|
||||||
onChange={handleSignatureValidationCredentialChange}
|
onChange={handleSignatureValidationCredentialChange}
|
||||||
value={values.signature_validation_credential}
|
value={signatureValidationCredentials.cryptography.value}
|
||||||
tooltip={projectHelpText.signatureValidation}
|
tooltip={projectHelpText.signatureValidation}
|
||||||
/>
|
/>
|
||||||
{values.scm_type !== '' && (
|
{formik.values.scm_type !== '' && (
|
||||||
<SubFormLayout>
|
<SubFormLayout>
|
||||||
<Title size="md" headingLevel="h4">
|
<Title size="md" headingLevel="h4">
|
||||||
{t`Type Details`}
|
{t`Type Details`}
|
||||||
@@ -216,39 +302,43 @@ function ProjectFormFields({
|
|||||||
{
|
{
|
||||||
manual: (
|
manual: (
|
||||||
<ManualSubForm
|
<ManualSubForm
|
||||||
localPath={initialValues.local_path}
|
localPath={formik.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
|
||||||
credentialTypeId={credentialTypeIds.scm}
|
credential={credentials.scm}
|
||||||
scmUpdateOnLaunch={values.scm_update_on_launch}
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
svn: (
|
svn: (
|
||||||
<SvnSubForm
|
<SvnSubForm
|
||||||
credentialTypeId={credentialTypeIds.scm}
|
credential={credentials.scm}
|
||||||
scmUpdateOnLaunch={values.scm_update_on_launch}
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
archive: (
|
archive: (
|
||||||
<ArchiveSubForm
|
<ArchiveSubForm
|
||||||
credentialTypeId={credentialTypeIds.scm}
|
credential={credentials.scm}
|
||||||
scmUpdateOnLaunch={values.scm_update_on_launch}
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
insights: (
|
insights: (
|
||||||
<InsightsSubForm
|
<InsightsSubForm
|
||||||
credentialTypeId={credentialTypeIds.insights}
|
credential={credentials.insights}
|
||||||
scmUpdateOnLaunch={values.scm_update_on_launch}
|
onCredentialSelection={handleCredentialSelection}
|
||||||
|
scmUpdateOnLaunch={formik.values.scm_update_on_launch}
|
||||||
autoPopulateCredential={
|
autoPopulateCredential={
|
||||||
!project?.id || project?.scm_type !== 'insights'
|
!project?.id || project?.scm_type !== 'insights'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}[values.scm_type]
|
}[formik.values.scm_type]
|
||||||
}
|
}
|
||||||
</FormColumnLayout>
|
</FormColumnLayout>
|
||||||
</SubFormLayout>
|
</SubFormLayout>
|
||||||
@@ -258,13 +348,16 @@ 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: null,
|
credential: '',
|
||||||
|
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,
|
||||||
@@ -272,16 +365,27 @@ 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 },
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
result: { scmTypeOptions, credentialTypeIds },
|
async function fetchData() {
|
||||||
error: contentError,
|
try {
|
||||||
isLoading,
|
const credentialResponse = fetchCredentials(summary_fields.credential);
|
||||||
request: fetchData,
|
const signatureValidationCredentialResponse = fetchCredentials(
|
||||||
} = useRequest(
|
summary_fields.signature_validation_credential
|
||||||
useCallback(async () => {
|
);
|
||||||
const [
|
const {
|
||||||
{
|
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
GET: {
|
GET: {
|
||||||
@@ -289,47 +393,25 @@ function ProjectForm({ project, submitError, ...props }) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
} = await ProjectsAPI.readOptions();
|
||||||
{
|
|
||||||
data: {
|
setCredentials(await credentialResponse);
|
||||||
results: [scmCredentialType],
|
setSignatureValidationCredentials(
|
||||||
},
|
await signatureValidationCredentialResponse
|
||||||
},
|
);
|
||||||
{
|
setScmTypeOptions(choices);
|
||||||
data: {
|
} catch (error) {
|
||||||
results: [insightsCredentialType],
|
setContentError(error);
|
||||||
},
|
} 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 />;
|
||||||
@@ -344,11 +426,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?.summary_fields?.credential || null,
|
credential: project.credential || '',
|
||||||
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,
|
||||||
@@ -364,7 +446,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?.summary_fields?.signature_validation_credential || null,
|
project.signature_validation_credential || '',
|
||||||
default_environment:
|
default_environment:
|
||||||
project.summary_fields?.default_environment || null,
|
project.summary_fields?.default_environment || null,
|
||||||
}}
|
}}
|
||||||
@@ -377,7 +459,13 @@ 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}
|
||||||
credentialTypeIds={credentialTypeIds}
|
formik={formik}
|
||||||
|
setCredentials={setCredentials}
|
||||||
|
setSignatureValidationCredentials={
|
||||||
|
setSignatureValidationCredentials
|
||||||
|
}
|
||||||
|
credentials={credentials}
|
||||||
|
signatureValidationCredentials={signatureValidationCredentials}
|
||||||
scmTypeOptions={scmTypeOptions}
|
scmTypeOptions={scmTypeOptions}
|
||||||
setScmSubFormState={setScmSubFormState}
|
setScmSubFormState={setScmSubFormState}
|
||||||
scmSubFormState={scmSubFormState}
|
scmSubFormState={scmSubFormState}
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ 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 () => {
|
||||||
@@ -148,10 +149,6 @@ 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
|
||||||
);
|
);
|
||||||
@@ -166,7 +163,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 () => {
|
||||||
wrapper.find('AnsibleSelect[id="scm_type"]').invoke('onChange')(
|
await wrapper.find('AnsibleSelect[id="scm_type"]').invoke('onChange')(
|
||||||
null,
|
null,
|
||||||
'git'
|
'git'
|
||||||
);
|
);
|
||||||
@@ -181,9 +178,17 @@ 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,12 +8,19 @@ import {
|
|||||||
ScmTypeOptions,
|
ScmTypeOptions,
|
||||||
} from './SharedFields';
|
} from './SharedFields';
|
||||||
|
|
||||||
const ArchiveSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
|
const ArchiveSubForm = ({
|
||||||
|
credential,
|
||||||
|
onCredentialSelection,
|
||||||
|
scmUpdateOnLaunch,
|
||||||
|
}) => {
|
||||||
const projectHelpText = getProjectHelpText();
|
const projectHelpText = getProjectHelpText();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UrlFormField tooltip={projectHelpText.archiveUrl} />
|
<UrlFormField tooltip={projectHelpText.archiveUrl} />
|
||||||
<ScmCredentialFormField credentialTypeId={credentialTypeId} />
|
<ScmCredentialFormField
|
||||||
|
credential={credential}
|
||||||
|
onCredentialSelection={onCredentialSelection}
|
||||||
|
/>
|
||||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
} from './SharedFields';
|
} from './SharedFields';
|
||||||
import getProjectHelpStrings from '../Project.helptext';
|
import getProjectHelpStrings from '../Project.helptext';
|
||||||
|
|
||||||
const GitSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
|
const GitSubForm = ({
|
||||||
|
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`;
|
||||||
@@ -31,7 +35,10 @@ const GitSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
|
|||||||
tooltipMaxWidth="400px"
|
tooltipMaxWidth="400px"
|
||||||
tooltip={projectHelpStrings.sourceControlRefspec(docsURL)}
|
tooltip={projectHelpStrings.sourceControlRefspec(docsURL)}
|
||||||
/>
|
/>
|
||||||
<ScmCredentialFormField credentialTypeId={credentialTypeId} />
|
<ScmCredentialFormField
|
||||||
|
credential={credential}
|
||||||
|
onCredentialSelection={onCredentialSelection}
|
||||||
|
/>
|
||||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useFormikContext, useField } from 'formik';
|
import { useField, useFormikContext } 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 = ({
|
||||||
credentialTypeId,
|
credential,
|
||||||
|
onCredentialSelection,
|
||||||
scmUpdateOnLaunch,
|
scmUpdateOnLaunch,
|
||||||
autoPopulateCredential,
|
autoPopulateCredential,
|
||||||
}) => {
|
}) => {
|
||||||
const { setFieldValue, setFieldTouched } = useFormikContext();
|
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||||
const [credField, credMeta, credHelpers] = useField('credential');
|
const [, 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);
|
||||||
},
|
},
|
||||||
[setFieldValue, setFieldTouched]
|
[onCredentialSelection, setFieldValue, setFieldTouched]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
credentialTypeId={credentialTypeId}
|
credentialTypeId={credential.typeId}
|
||||||
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={credField.value}
|
value={credential.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,23 +35,27 @@ export const BranchFormField = ({ label }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScmCredentialFormField = ({ credentialTypeId }) => {
|
export const ScmCredentialFormField = ({
|
||||||
const { setFieldValue, setFieldTouched, values } = useFormikContext();
|
credential,
|
||||||
|
onCredentialSelection,
|
||||||
|
}) => {
|
||||||
|
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const onCredentialChange = useCallback(
|
||||||
(value) => {
|
(value) => {
|
||||||
|
onCredentialSelection('scm', value);
|
||||||
setFieldValue('credential', value);
|
setFieldValue('credential', value);
|
||||||
setFieldTouched('credential', true, false);
|
setFieldTouched('credential', true, false);
|
||||||
},
|
},
|
||||||
[setFieldValue, setFieldTouched]
|
[onCredentialSelection, setFieldValue, setFieldTouched]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
credentialTypeId={credentialTypeId}
|
credentialTypeId={credential.typeId}
|
||||||
label={t`Source Control Credential`}
|
label={t`Source Control Credential`}
|
||||||
value={values.credential}
|
value={credential.value}
|
||||||
onChange={handleChange}
|
onChange={onCredentialChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ import {
|
|||||||
ScmTypeOptions,
|
ScmTypeOptions,
|
||||||
} from './SharedFields';
|
} from './SharedFields';
|
||||||
|
|
||||||
const SvnSubForm = ({ credentialTypeId, scmUpdateOnLaunch }) => {
|
const SvnSubForm = ({
|
||||||
|
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 credentialTypeId={credentialTypeId} />
|
<ScmCredentialFormField
|
||||||
|
credential={credential}
|
||||||
|
onCredentialSelection={onCredentialSelection}
|
||||||
|
/>
|
||||||
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
<ScmTypeOptions scmUpdateOnLaunch={scmUpdateOnLaunch} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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 }) {
|
||||||
@@ -167,11 +168,6 @@ 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} />;
|
||||||
}
|
}
|
||||||
@@ -422,25 +418,7 @@ 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={
|
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}
|
||||||
/>
|
/>
|
||||||
{job_tags && (
|
{job_tags && (
|
||||||
|
|||||||
@@ -59,13 +59,14 @@ 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={false}
|
isCreatable
|
||||||
|
createText=""
|
||||||
onSelect={(event, value) => {
|
onSelect={(event, value) => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onChange(value);
|
onChange(value);
|
||||||
}}
|
}}
|
||||||
id="template-playbook"
|
id="template-playbook"
|
||||||
isValid={isValid}
|
validated={isValid ? 'default' : 'error'}
|
||||||
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 { act } from 'react-dom/test-utils';
|
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||||
|
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'],
|
data: ['debug.yml', 'test.yml'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,24 +18,90 @@ describe('<PlaybookSelect />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should reload playbooks when project value changes', async () => {
|
test('should reload playbooks when project value changes', async () => {
|
||||||
let wrapper;
|
const { rerender } = render(
|
||||||
await act(async () => {
|
<PlaybookSelect
|
||||||
wrapper = mountWithContexts(
|
projectId={1}
|
||||||
<PlaybookSelect
|
isValid
|
||||||
projectId={1}
|
onChange={() => {}}
|
||||||
isValid
|
onError={() => {}}
|
||||||
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' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
|
await waitFor(() => {
|
||||||
await act(async () => {
|
// A new select option is displayed ("foo.yml")
|
||||||
wrapper.setProps({ projectId: 15 });
|
expect(
|
||||||
});
|
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getAllByRole('option').length).toBe(1);
|
||||||
|
|
||||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
|
fireEvent.click(
|
||||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
|
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledWith('foo.yml');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const Wrapper = styled.div`
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
width: 150px;
|
min-width: 150px;
|
||||||
background-color: rgba(255, 255, 255, 0.85);
|
background-color: rgba(255, 255, 255, 0.85);
|
||||||
overflow: scroll;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
const Button = styled(PFButton)`
|
const Button = styled(PFButton)`
|
||||||
|
|||||||
@@ -40,8 +40,13 @@ const Loader = styled(ContentLoading)`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
background: white;
|
background: white;
|
||||||
`;
|
`;
|
||||||
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
function MeshGraph({
|
||||||
const [storedNodes, setStoredNodes] = useState(null);
|
data,
|
||||||
|
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);
|
||||||
@@ -100,19 +105,14 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
// 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.map((n) =>
|
const updatedNodes = storedNodes.current.map((n) =>
|
||||||
n.id === instance.id ? { ...n, enabled: instance.enabled } : n
|
n.id === instance.id ? { ...n, enabled: instance.enabled } : n
|
||||||
);
|
);
|
||||||
setStoredNodes(updatedNodes);
|
storedNodes.current = 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,6 +137,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
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();
|
||||||
@@ -162,7 +165,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -247,7 +249,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
.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: scroll;
|
overflow: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
const Button = styled(PFButton)`
|
const Button = styled(PFButton)`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useCallback, useState } from 'react';
|
import React, { useEffect, useCallback, useState, useRef } 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,6 +10,7 @@ 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 {
|
||||||
@@ -20,6 +21,7 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -64,6 +66,7 @@ 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 {1}'.format(workflow_node))
|
module.fail_json(msg='Could not find unified job template type in workflow_nodes {0}'.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.py` to start installing Receptor on the remote machine.
|
`ansible-playbook -i inventory.yml install_receptor.yml` 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.
|
||||||
|
|
||||||
|
|||||||
108
docs/project_signing.md
Normal file
108
docs/project_signing.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# 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) }}
|
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) }}
|
||||||
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