mirror of
https://github.com/ansible/awx.git
synced 2026-02-05 03:24:50 -03:30
Compare commits
62 Commits
12640-Refa
...
11555-Calc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6ad47ca5 | ||
|
|
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 | ||
|
|
80a0842df1 | ||
|
|
2dd2931ab2 | ||
|
|
e83a4d7234 |
@@ -6,7 +6,6 @@ import inspect
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import urllib.parse
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -30,7 +29,7 @@ from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import views
|
||||
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
|
||||
|
||||
# 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.licensing import server_product_name
|
||||
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.metadata import SublistAttachDetatchMetadata, Metadata
|
||||
from awx.conf import settings_registry
|
||||
@@ -90,13 +89,9 @@ class LoggedLoginView(auth_views.LoginView):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
ret = super(LoggedLoginView, self).post(request, *args, **kwargs)
|
||||
current_user = getattr(request, 'user', None)
|
||||
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))))
|
||||
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'))
|
||||
|
||||
return ret
|
||||
@@ -253,7 +248,7 @@ class APIView(views.APIView):
|
||||
response['X-API-Query-Time'] = '%0.3fs' % sum(q_times)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.timezone import now
|
||||
from django.core.validators import RegexValidator, MaxLengthValidator
|
||||
|
||||
# Django REST Framework
|
||||
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.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField
|
||||
|
||||
# AWX Utils
|
||||
from awx.api.validators import HostnameRegexValidator
|
||||
|
||||
logger = logging.getLogger('awx.api.serializers')
|
||||
|
||||
# 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
|
||||
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
|
||||
if 'extra_data' in attrs and password_dict:
|
||||
@@ -4921,6 +4929,19 @@ class InstanceSerializer(BaseSerializer):
|
||||
extra_kwargs = {
|
||||
'node_type': {'initial': Instance.Types.EXECUTION, 'default': Instance.Types.EXECUTION},
|
||||
'node_state': {'initial': Instance.States.INSTALLED, 'default': Instance.States.INSTALLED},
|
||||
'hostname': {
|
||||
'validators': [
|
||||
MaxLengthValidator(limit_value=250),
|
||||
validators.UniqueValidator(queryset=Instance.objects.all()),
|
||||
RegexValidator(
|
||||
regex='^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$',
|
||||
flags=re.IGNORECASE,
|
||||
inverse_match=True,
|
||||
message="hostname cannot be localhost or 127.0.0.1",
|
||||
),
|
||||
HostnameRegexValidator(),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def get_related(self, obj):
|
||||
@@ -4991,6 +5012,10 @@ class InstanceSerializer(BaseSerializer):
|
||||
return 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:
|
||||
raise serializers.ValidationError("Cannot change hostname.")
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
receptor_user: awx
|
||||
receptor_group: awx
|
||||
receptor_verify: true
|
||||
receptor_tls: true
|
||||
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_keyfile: receptor/tls/receptor.key
|
||||
custom_ca_certfile: receptor/tls/ca/receptor-ca.crt
|
||||
receptor_user: awx
|
||||
receptor_group: awx
|
||||
receptor_protocol: 'tcp'
|
||||
receptor_listener: true
|
||||
receptor_port: {{ instance.listener_port }}
|
||||
receptor_dependencies:
|
||||
- podman
|
||||
- crun
|
||||
- python39-pip
|
||||
{% verbatim %}
|
||||
podman_user: "{{ receptor_user }}"
|
||||
podman_group: "{{ receptor_group }}"
|
||||
{% endverbatim %}
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
shell: /bin/bash
|
||||
- name: Enable Copr repo for Receptor
|
||||
command: dnf copr enable ansible-awx/receptor -y
|
||||
- import_role:
|
||||
name: ansible.receptor.podman
|
||||
- import_role:
|
||||
name: ansible.receptor.setup
|
||||
- name: Install ansible-runner
|
||||
pip:
|
||||
name: ansible-runner
|
||||
executable: pip3.9
|
||||
{% endverbatim %}
|
||||
{% endverbatim %}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
---
|
||||
collections:
|
||||
- name: ansible.receptor
|
||||
source: https://github.com/ansible/receptor-collection/
|
||||
type: git
|
||||
version: 0.1.1
|
||||
version: 1.1.0
|
||||
|
||||
@@ -9,9 +9,9 @@ from awx.api.views import (
|
||||
InstanceUnifiedJobsList,
|
||||
InstanceInstanceGroupsList,
|
||||
InstanceHealthCheck,
|
||||
InstanceInstallBundle,
|
||||
InstancePeersList,
|
||||
)
|
||||
from awx.api.views.instance_install_bundle import InstanceInstallBundle
|
||||
|
||||
|
||||
urls = [
|
||||
|
||||
@@ -3,26 +3,28 @@
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from awx.api.views import (
|
||||
from awx.api.views.inventory import (
|
||||
InventoryList,
|
||||
InventoryDetail,
|
||||
InventoryHostsList,
|
||||
InventoryGroupsList,
|
||||
InventoryRootGroupsList,
|
||||
InventoryVariableData,
|
||||
InventoryScriptView,
|
||||
InventoryTreeView,
|
||||
InventoryInventorySourcesList,
|
||||
InventoryInventorySourcesUpdate,
|
||||
InventoryActivityStreamList,
|
||||
InventoryJobTemplateList,
|
||||
InventoryAdHocCommandsList,
|
||||
InventoryAccessList,
|
||||
InventoryObjectRolesList,
|
||||
InventoryInstanceGroupsList,
|
||||
InventoryLabelList,
|
||||
InventoryCopy,
|
||||
)
|
||||
from awx.api.views import (
|
||||
InventoryHostsList,
|
||||
InventoryGroupsList,
|
||||
InventoryInventorySourcesList,
|
||||
InventoryInventorySourcesUpdate,
|
||||
InventoryAdHocCommandsList,
|
||||
InventoryRootGroupsList,
|
||||
InventoryScriptView,
|
||||
InventoryTreeView,
|
||||
InventoryVariableData,
|
||||
)
|
||||
|
||||
|
||||
urls = [
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from awx.api.views.inventory import (
|
||||
InventoryUpdateEventsList,
|
||||
)
|
||||
from awx.api.views import (
|
||||
InventoryUpdateList,
|
||||
InventoryUpdateDetail,
|
||||
@@ -10,7 +13,6 @@ from awx.api.views import (
|
||||
InventoryUpdateStdout,
|
||||
InventoryUpdateNotificationsList,
|
||||
InventoryUpdateCredentialsList,
|
||||
InventoryUpdateEventsList,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from oauthlib import oauth2
|
||||
from oauth2_provider import views
|
||||
|
||||
from awx.main.models import RefreshToken
|
||||
from awx.api.views import ApiOAuthAuthorizationRootView
|
||||
from awx.api.views.root import ApiOAuthAuthorizationRootView
|
||||
|
||||
|
||||
class TokenView(views.TokenView):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from awx.api.views import (
|
||||
from awx.api.views.organization import (
|
||||
OrganizationList,
|
||||
OrganizationDetail,
|
||||
OrganizationUsersList,
|
||||
@@ -14,7 +14,6 @@ from awx.api.views import (
|
||||
OrganizationJobTemplatesList,
|
||||
OrganizationWorkflowJobTemplatesList,
|
||||
OrganizationTeamsList,
|
||||
OrganizationCredentialList,
|
||||
OrganizationActivityStreamList,
|
||||
OrganizationNotificationTemplatesList,
|
||||
OrganizationNotificationTemplatesErrorList,
|
||||
@@ -25,8 +24,8 @@ from awx.api.views import (
|
||||
OrganizationGalaxyCredentialsList,
|
||||
OrganizationObjectRolesList,
|
||||
OrganizationAccessList,
|
||||
OrganizationApplicationList,
|
||||
)
|
||||
from awx.api.views import OrganizationCredentialList, OrganizationApplicationList
|
||||
|
||||
|
||||
urls = [
|
||||
|
||||
@@ -6,13 +6,15 @@ from django.urls import include, re_path
|
||||
|
||||
from awx import MODE
|
||||
from awx.api.generics import LoggedLoginView, LoggedLogoutView
|
||||
from awx.api.views import (
|
||||
from awx.api.views.root import (
|
||||
ApiRootView,
|
||||
ApiV2RootView,
|
||||
ApiV2PingView,
|
||||
ApiV2ConfigView,
|
||||
ApiV2SubscriptionView,
|
||||
ApiV2AttachView,
|
||||
)
|
||||
from awx.api.views import (
|
||||
AuthView,
|
||||
UserMeList,
|
||||
DashboardView,
|
||||
@@ -28,8 +30,8 @@ from awx.api.views import (
|
||||
OAuth2TokenList,
|
||||
ApplicationOAuth2TokenList,
|
||||
OAuth2ApplicationDetail,
|
||||
MeshVisualizer,
|
||||
)
|
||||
from awx.api.views.mesh_visualizer import MeshVisualizer
|
||||
|
||||
from awx.api.views.metrics import MetricsView
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from awx.api.views import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver
|
||||
from awx.api.views.webhooks import WebhookKeyView, GithubWebhookReceiver, GitlabWebhookReceiver
|
||||
|
||||
|
||||
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
|
||||
@@ -122,56 +122,6 @@ from awx.api.views.mixin import (
|
||||
UnifiedJobDeletionMixin,
|
||||
NoTruncateMixin,
|
||||
)
|
||||
from awx.api.views.instance_install_bundle import InstanceInstallBundle # noqa
|
||||
from awx.api.views.inventory import ( # noqa
|
||||
InventoryList,
|
||||
InventoryDetail,
|
||||
InventoryUpdateEventsList,
|
||||
InventoryList,
|
||||
InventoryDetail,
|
||||
InventoryActivityStreamList,
|
||||
InventoryInstanceGroupsList,
|
||||
InventoryAccessList,
|
||||
InventoryObjectRolesList,
|
||||
InventoryJobTemplateList,
|
||||
InventoryLabelList,
|
||||
InventoryCopy,
|
||||
)
|
||||
from awx.api.views.mesh_visualizer import MeshVisualizer # noqa
|
||||
from awx.api.views.organization import ( # noqa
|
||||
OrganizationList,
|
||||
OrganizationDetail,
|
||||
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.main.utils import set_environ
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ def generate_receptor_tls(instance_obj):
|
||||
.public_key(csr.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.datetime.utcnow())
|
||||
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=10))
|
||||
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
|
||||
.add_extension(
|
||||
csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value,
|
||||
critical=csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical,
|
||||
|
||||
@@ -166,11 +166,7 @@ class Metrics:
|
||||
elif settings.IS_TESTING():
|
||||
self.instance_name = "awx_testing"
|
||||
else:
|
||||
try:
|
||||
self.instance_name = Instance.objects.me().hostname
|
||||
except Exception as e:
|
||||
self.instance_name = settings.CLUSTER_HOST_ID
|
||||
logger.info(f'Instance {self.instance_name} seems to be unregistered, error: {e}')
|
||||
self.instance_name = Instance.objects.my_hostname()
|
||||
|
||||
# metric name, help_text
|
||||
METRICSLIST = [
|
||||
|
||||
@@ -16,12 +16,7 @@ def startup_reaping():
|
||||
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
|
||||
"""
|
||||
try:
|
||||
me = Instance.objects.me()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f'Local instance is not registered, not running startup reaper: {e}')
|
||||
return
|
||||
jobs = UnifiedJob.objects.filter(status='running', controller_node=me.hostname)
|
||||
jobs = UnifiedJob.objects.filter(status='running', controller_node=Instance.objects.my_hostname())
|
||||
job_ids = []
|
||||
for j in jobs:
|
||||
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:
|
||||
grace_period = settings.JOB_WAITING_GRACE_PERIOD + settings.TASK_MANAGER_TIMEOUT
|
||||
|
||||
me = instance
|
||||
if me is None:
|
||||
try:
|
||||
me = Instance.objects.me()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f'Local instance is not registered, not running reaper: {e}')
|
||||
return
|
||||
if instance is None:
|
||||
hostname = Instance.objects.my_hostname()
|
||||
else:
|
||||
hostname = instance.hostname
|
||||
if ref_time is None:
|
||||
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:
|
||||
jobs = jobs.exclude(celery_task_id__in=excluded_uuids)
|
||||
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.
|
||||
"""
|
||||
me = instance
|
||||
if me is None:
|
||||
try:
|
||||
me = Instance.objects.me()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f'Local instance is not registered, not running reaper: {e}')
|
||||
return
|
||||
if instance is None:
|
||||
hostname = Instance.objects.my_hostname()
|
||||
else:
|
||||
hostname = instance.hostname
|
||||
workflow_ctype_id = ContentType.objects.get_for_model(WorkflowJob).id
|
||||
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:
|
||||
jobs = jobs.exclude(celery_task_id__in=excluded_uuids)
|
||||
|
||||
@@ -53,7 +53,7 @@ class Command(BaseCommand):
|
||||
return lines
|
||||
|
||||
@classmethod
|
||||
def get_connection_status(cls, me, hostnames, data):
|
||||
def get_connection_status(cls, hostnames, data):
|
||||
host_stats = [('hostname', 'state', 'start time', 'duration (sec)')]
|
||||
for h in hostnames:
|
||||
connection_color = '91' # red
|
||||
@@ -78,7 +78,7 @@ class Command(BaseCommand):
|
||||
return host_stats
|
||||
|
||||
@classmethod
|
||||
def get_connection_stats(cls, me, hostnames, data):
|
||||
def get_connection_stats(cls, hostnames, data):
|
||||
host_stats = [('hostname', 'total', 'per minute')]
|
||||
for h in hostnames:
|
||||
h_safe = safe_name(h)
|
||||
@@ -119,8 +119,8 @@ class Command(BaseCommand):
|
||||
return
|
||||
|
||||
try:
|
||||
me = Instance.objects.me()
|
||||
logger.info('Active instance with hostname {} is registered.'.format(me.hostname))
|
||||
my_hostname = Instance.objects.my_hostname()
|
||||
logger.info('Active instance with hostname {} is registered.'.format(my_hostname))
|
||||
except RuntimeError as e:
|
||||
# the CLUSTER_HOST_ID in the task, and web instance must match and
|
||||
# ensure network connectivity between the task and web instance
|
||||
@@ -145,19 +145,19 @@ class Command(BaseCommand):
|
||||
else:
|
||||
data[family.name] = family.samples[0].value
|
||||
|
||||
me = Instance.objects.me()
|
||||
hostnames = [i.hostname for i in Instance.objects.exclude(hostname=me.hostname)]
|
||||
my_hostname = Instance.objects.my_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)
|
||||
|
||||
print(f'Broadcast websocket connection status from "{me.hostname}" to:')
|
||||
print(f'Broadcast websocket connection status from "{my_hostname}" to:')
|
||||
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)
|
||||
|
||||
print(f'\nBroadcast websocket connection stats from "{me.hostname}" to:')
|
||||
print(f'\nBroadcast websocket connection stats from "{my_hostname}" to:')
|
||||
print('\n'.join(lines))
|
||||
|
||||
return
|
||||
|
||||
@@ -99,9 +99,12 @@ class InstanceManager(models.Manager):
|
||||
instance or role.
|
||||
"""
|
||||
|
||||
def my_hostname(self):
|
||||
return settings.CLUSTER_HOST_ID
|
||||
|
||||
def me(self):
|
||||
"""Return the currently active instance."""
|
||||
node = self.filter(hostname=settings.CLUSTER_HOST_ID)
|
||||
node = self.filter(hostname=self.my_hostname())
|
||||
if node.exists():
|
||||
return node[0]
|
||||
raise RuntimeError("No instance found with the current cluster host id")
|
||||
|
||||
@@ -282,7 +282,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
return field['default']
|
||||
if 'default' in kwargs:
|
||||
return kwargs['default']
|
||||
raise AttributeError
|
||||
raise AttributeError(field_name)
|
||||
if field_name in self.inputs:
|
||||
return self.inputs[field_name]
|
||||
if 'default' in kwargs:
|
||||
|
||||
@@ -1305,6 +1305,8 @@ class UnifiedJob(
|
||||
status_data['instance_group_name'] = None
|
||||
elif status in ['successful', 'failed', 'canceled'] and self.finished:
|
||||
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['group_name'] = 'jobs'
|
||||
if getattr(self, 'unified_job_template_id', None):
|
||||
|
||||
@@ -700,7 +700,7 @@ class SourceControlMixin(BaseTask):
|
||||
|
||||
def spawn_project_sync(self, project, sync_needs, scm_branch=None):
|
||||
pu_ig = self.instance.instance_group
|
||||
pu_en = Instance.objects.me().hostname
|
||||
pu_en = Instance.objects.my_hostname()
|
||||
|
||||
sync_metafields = dict(
|
||||
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.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)
|
||||
|
||||
@@ -54,3 +56,33 @@ def test_health_check_usage(get, post, admin_user):
|
||||
get(url=url, user=admin_user, expect=200)
|
||||
r = post(url=url, user=admin_user, expect=200)
|
||||
assert r.data['msg'] == f"Health check is running for {instance.hostname}."
|
||||
|
||||
|
||||
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])
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@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.parametrize(
|
||||
'rrule, error',
|
||||
|
||||
@@ -5,7 +5,8 @@ from unittest import mock
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Copyright (c) 2017 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
import os
|
||||
import re
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
import json
|
||||
@@ -12,9 +13,13 @@ from unittest import mock
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
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 django.core.exceptions import ValidationError
|
||||
from django.utils.regex_helper import _lazy_re_compile
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'input_, output',
|
||||
@@ -194,3 +199,136 @@ def test_extract_ansible_vars():
|
||||
redacted, var_list = common.extract_ansible_vars(json.dumps(my_dict))
|
||||
assert var_list == set(['ansible_connetion_setting'])
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
host, path = hostpath.split(':', 1)
|
||||
else:
|
||||
host, path = hostpath.split(':', 1)
|
||||
# if not path.startswith('/') and not path.startswith('~/'):
|
||||
# path = '~/%s' % path
|
||||
# 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])
|
||||
else:
|
||||
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:
|
||||
netloc = u':'.join([netloc, str(parts.port)])
|
||||
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():
|
||||
Instance = apps.get_model('main', 'Instance')
|
||||
instances = (
|
||||
Instance.objects.exclude(hostname=Instance.objects.me().hostname)
|
||||
Instance.objects.exclude(hostname=Instance.objects.my_hostname())
|
||||
.exclude(node_type='execution')
|
||||
.exclude(node_type='hop')
|
||||
.order_by('hostname')
|
||||
@@ -47,7 +47,7 @@ def get_broadcast_hosts():
|
||||
|
||||
def get_local_host():
|
||||
Instance = apps.get_model('main', 'Instance')
|
||||
return Instance.objects.me().hostname
|
||||
return Instance.objects.my_hostname()
|
||||
|
||||
|
||||
class WebsocketTask:
|
||||
|
||||
@@ -11,9 +11,11 @@ import ldap
|
||||
# Django
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.signals import setting_changed
|
||||
from django.utils.encoding import force_str
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
# django-auth-ldap
|
||||
from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings
|
||||
@@ -327,31 +329,32 @@ class SAMLAuth(BaseSAMLAuth):
|
||||
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:
|
||||
return
|
||||
return None
|
||||
elif not opts:
|
||||
pass
|
||||
elif opts is True:
|
||||
should_add = True
|
||||
elif isinstance(opts, bool) and opts is True:
|
||||
return True
|
||||
else:
|
||||
if isinstance(opts, str):
|
||||
opts = [opts]
|
||||
# If any of the users groups matches any of the list options
|
||||
for group_dn in opts:
|
||||
if not isinstance(group_dn, str):
|
||||
continue
|
||||
if ldap_user._get_groups().is_member_of(group_dn):
|
||||
should_add = True
|
||||
if should_add:
|
||||
user.save()
|
||||
related.add(user)
|
||||
elif remove and user in related.all():
|
||||
user.save()
|
||||
related.remove(user)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@receiver(populate_user, dispatch_uid='populate-ldap-user')
|
||||
@@ -383,31 +386,73 @@ def on_populate_user(sender, **kwargs):
|
||||
force_user_update = True
|
||||
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', {})
|
||||
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', {})
|
||||
|
||||
# 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():
|
||||
if 'organization' not in team_opts:
|
||||
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)
|
||||
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()
|
||||
try:
|
||||
@@ -423,3 +468,62 @@ def on_populate_user(sender, **kwargs):
|
||||
if profile.ldap_dn != ldap_user.dn:
|
||||
profile.ldap_dn = ldap_user.dn
|
||||
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)
|
||||
|
||||
@@ -11,8 +11,6 @@ from django.http import HttpResponse
|
||||
from django.views.generic import View
|
||||
from django.views.generic.base import RedirectView
|
||||
from django.utils.encoding import smart_str
|
||||
from awx.api.serializers import UserSerializer
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger('awx.sso.views')
|
||||
@@ -42,9 +40,6 @@ class CompleteView(BaseRedirectView):
|
||||
if self.request.user and self.request.user.is_authenticated:
|
||||
logger.info(smart_str(u"User {} logged in".format(self.request.user.username)))
|
||||
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'))
|
||||
return response
|
||||
|
||||
|
||||
184
awx/ui/package-lock.json
generated
184
awx/ui/package-lock.json
generated
@@ -8,14 +8,14 @@
|
||||
"dependencies": {
|
||||
"@lingui/react": "3.14.0",
|
||||
"@patternfly/patternfly": "4.210.2",
|
||||
"@patternfly/react-core": "^4.221.3",
|
||||
"@patternfly/react-icons": "4.75.1",
|
||||
"@patternfly/react-table": "4.100.8",
|
||||
"@patternfly/react-core": "^4.239.0",
|
||||
"@patternfly/react-icons": "4.90.0",
|
||||
"@patternfly/react-table": "4.108.0",
|
||||
"ace-builds": "^1.10.1",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"axios": "0.27.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"d3": "7.4.4",
|
||||
"d3": "7.6.1",
|
||||
"dagre": "^0.8.4",
|
||||
"dompurify": "2.4.0",
|
||||
"formik": "2.2.9",
|
||||
@@ -31,7 +31,7 @@
|
||||
"react-router-dom": "^5.3.3",
|
||||
"react-virtualized": "^9.21.1",
|
||||
"rrule": "2.7.1",
|
||||
"styled-components": "5.3.5"
|
||||
"styled-components": "5.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.10",
|
||||
@@ -3752,13 +3752,13 @@
|
||||
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
|
||||
},
|
||||
"node_modules/@patternfly/react-core": {
|
||||
"version": "4.231.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
|
||||
"integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
|
||||
"version": "4.239.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz",
|
||||
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==",
|
||||
"dependencies": {
|
||||
"@patternfly/react-icons": "^4.82.8",
|
||||
"@patternfly/react-styles": "^4.81.8",
|
||||
"@patternfly/react-tokens": "^4.83.8",
|
||||
"@patternfly/react-icons": "^4.90.0",
|
||||
"@patternfly/react-styles": "^4.89.0",
|
||||
"@patternfly/react-tokens": "^4.91.0",
|
||||
"focus-trap": "6.9.2",
|
||||
"react-dropzone": "9.0.0",
|
||||
"tippy.js": "5.1.2",
|
||||
@@ -3769,43 +3769,34 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@patternfly/react-icons": {
|
||||
"version": "4.75.1",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
|
||||
"integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
|
||||
"version": "4.90.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz",
|
||||
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@patternfly/react-styles": {
|
||||
"version": "4.81.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
|
||||
"integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
|
||||
"version": "4.89.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz",
|
||||
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ=="
|
||||
},
|
||||
"node_modules/@patternfly/react-table": {
|
||||
"version": "4.100.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
|
||||
"integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
|
||||
"version": "4.108.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz",
|
||||
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==",
|
||||
"dependencies": {
|
||||
"@patternfly/react-core": "^4.231.8",
|
||||
"@patternfly/react-icons": "^4.82.8",
|
||||
"@patternfly/react-styles": "^4.81.8",
|
||||
"@patternfly/react-tokens": "^4.83.8",
|
||||
"@patternfly/react-core": "^4.239.0",
|
||||
"@patternfly/react-icons": "^4.90.0",
|
||||
"@patternfly/react-styles": "^4.89.0",
|
||||
"@patternfly/react-tokens": "^4.91.0",
|
||||
"lodash": "^4.17.19",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
@@ -3814,24 +3805,15 @@
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"node_modules/@patternfly/react-tokens": {
|
||||
"version": "4.83.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
|
||||
"integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
|
||||
"version": "4.91.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz",
|
||||
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw=="
|
||||
},
|
||||
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
|
||||
"version": "0.5.4",
|
||||
@@ -7482,16 +7464,16 @@
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
|
||||
"integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz",
|
||||
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
@@ -7522,9 +7504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
|
||||
"integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
|
||||
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
@@ -7575,11 +7557,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
|
||||
"integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz",
|
||||
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -20234,9 +20216,9 @@
|
||||
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
||||
},
|
||||
"node_modules/styled-components": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
|
||||
"integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz",
|
||||
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.0.0",
|
||||
@@ -25112,25 +25094,19 @@
|
||||
"integrity": "sha512-aZiW24Bxi6uVmk5RyNTp+6q6ThtlJZotNRJfWVeGuwu1UlbBuV4DFa1bpjA6jfTZpfEpX2YL5+R+4ZVSCFAVdw=="
|
||||
},
|
||||
"@patternfly/react-core": {
|
||||
"version": "4.231.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.231.8.tgz",
|
||||
"integrity": "sha512-2ClqlYCvSADppMfVfkUGIA/8XlO6jX8batoClXLxZDwqGoOfr61XyUgQ6SSlE4w60czoNeX4Nf6cfQKUH4RIKw==",
|
||||
"version": "4.239.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.239.0.tgz",
|
||||
"integrity": "sha512-6CmYABCJLUXTlzCk6C3WouMNZpS0BCT+aHU8CvYpFQ/NrpYp3MJaDsYbqgCRWV42rmIO5iXun/4WhXBJzJEoQg==",
|
||||
"requires": {
|
||||
"@patternfly/react-icons": "^4.82.8",
|
||||
"@patternfly/react-styles": "^4.81.8",
|
||||
"@patternfly/react-tokens": "^4.83.8",
|
||||
"@patternfly/react-icons": "^4.90.0",
|
||||
"@patternfly/react-styles": "^4.89.0",
|
||||
"@patternfly/react-tokens": "^4.91.0",
|
||||
"focus-trap": "6.9.2",
|
||||
"react-dropzone": "9.0.0",
|
||||
"tippy.js": "5.1.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
@@ -25139,35 +25115,29 @@
|
||||
}
|
||||
},
|
||||
"@patternfly/react-icons": {
|
||||
"version": "4.75.1",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.75.1.tgz",
|
||||
"integrity": "sha512-1ly8SVi/kcc0zkiViOjUd8D5BEr7GeqWGmDPuDSBtD60l1dYf3hZc44IWFVkRM/oHZML/musdrJkLfh4MDqX9w==",
|
||||
"version": "4.90.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.90.0.tgz",
|
||||
"integrity": "sha512-qEnQKbxbUgyiosiKSkeKEBwmhgJwWEqniIAFyoxj+kpzAdeu7ueWe5iBbqo06mvDOedecFiM5mIE1N0MXwk8Yw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@patternfly/react-styles": {
|
||||
"version": "4.81.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.81.8.tgz",
|
||||
"integrity": "sha512-Q5FiureSSCMIuz+KLMcEm1317TzbXcwmg2q5iNDRKyf/K+5CT6tJp0Wbtk3FlfRvzli4u/7YfXipahia5TL+tA=="
|
||||
"version": "4.89.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.89.0.tgz",
|
||||
"integrity": "sha512-SkT+qx3Xqu70T5s+i/AUT2hI2sKAPDX4ffeiJIUDu/oyWiFdk+/9DEivnLSyJMruroXXN33zKibvzb5rH7DKTQ=="
|
||||
},
|
||||
"@patternfly/react-table": {
|
||||
"version": "4.100.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.100.8.tgz",
|
||||
"integrity": "sha512-80XZCZzoYN9gsoufNdXUB/dk33SuWF9lUnOJs7ilezD6noTSD7ARqO1h532eaEPIbPBp4uIVkEUdfGSHd0HJtg==",
|
||||
"version": "4.108.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.108.0.tgz",
|
||||
"integrity": "sha512-EUvd3rlkE1UXobAm7L6JHgNE3TW8IYTaVwwH/px4Mkn5mBayDO6f+w6QM3OeoDQVZcXK6IYFe7QQaYd/vWIJCQ==",
|
||||
"requires": {
|
||||
"@patternfly/react-core": "^4.231.8",
|
||||
"@patternfly/react-icons": "^4.82.8",
|
||||
"@patternfly/react-styles": "^4.81.8",
|
||||
"@patternfly/react-tokens": "^4.83.8",
|
||||
"@patternfly/react-core": "^4.239.0",
|
||||
"@patternfly/react-icons": "^4.90.0",
|
||||
"@patternfly/react-styles": "^4.89.0",
|
||||
"@patternfly/react-tokens": "^4.91.0",
|
||||
"lodash": "^4.17.19",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
@@ -25176,9 +25146,9 @@
|
||||
}
|
||||
},
|
||||
"@patternfly/react-tokens": {
|
||||
"version": "4.83.8",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.83.8.tgz",
|
||||
"integrity": "sha512-Z/MHXNY8PQOuBFGUar2yzPVbz3BNJuhB+Dnk5RJcc/iIn3S+VlSru7g6v5jqoV/+a5wLqZtLGEBp8uhCZ7Xkig=="
|
||||
"version": "4.91.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.91.0.tgz",
|
||||
"integrity": "sha512-QeQCy8o8E/16fAr8mxqXIYRmpTsjCHJXi5p5jmgEDFmYMesN6Pqfv6N5D0FHb+CIaNOZWRps7GkWvlIMIE81sw=="
|
||||
},
|
||||
"@pmmmwh/react-refresh-webpack-plugin": {
|
||||
"version": "0.5.4",
|
||||
@@ -28082,16 +28052,16 @@
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||
},
|
||||
"d3": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.4.4.tgz",
|
||||
"integrity": "sha512-97FE+MYdAlV3R9P74+R3Uar7wUKkIFu89UWMjEaDhiJ9VxKvqaMxauImy8PC2DdBkdM2BxJOIoLxPrcZUyrKoQ==",
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.6.1.tgz",
|
||||
"integrity": "sha512-txMTdIHFbcpLx+8a0IFhZsbp+PfBBPt8yfbmukZTQFroKuFqIwqswF0qE5JXWefylaAVpSXFoKm3yP+jpNLFLw==",
|
||||
"requires": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
@@ -28119,9 +28089,9 @@
|
||||
}
|
||||
},
|
||||
"d3-array": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.1.1.tgz",
|
||||
"integrity": "sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
|
||||
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
|
||||
"requires": {
|
||||
"internmap": "1 - 2"
|
||||
}
|
||||
@@ -28157,11 +28127,11 @@
|
||||
"integrity": "sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw=="
|
||||
},
|
||||
"d3-contour": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-3.0.1.tgz",
|
||||
"integrity": "sha512-0Oc4D0KyhwhM7ZL0RMnfGycLN7hxHB8CMmwZ3+H26PWAG0ozNuYG5hXSDNgmP1SgJkQMrlG6cP20HoaSbvcJTQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.0.tgz",
|
||||
"integrity": "sha512-7aQo0QHUTu/Ko3cP9YK9yUTxtoDEiDGwnBHyLxG5M4vqlBkO/uixMRele3nfsfj6UXOcuReVpVXzAboGraYIJw==",
|
||||
"requires": {
|
||||
"d3-array": "2 - 3"
|
||||
"d3-array": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"d3-delaunay": {
|
||||
@@ -37705,9 +37675,9 @@
|
||||
"integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw=="
|
||||
},
|
||||
"styled-components": {
|
||||
"version": "5.3.5",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz",
|
||||
"integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==",
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.6.tgz",
|
||||
"integrity": "sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==",
|
||||
"requires": {
|
||||
"@babel/helper-module-imports": "^7.0.0",
|
||||
"@babel/traverse": "^7.4.5",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"dependencies": {
|
||||
"@lingui/react": "3.14.0",
|
||||
"@patternfly/patternfly": "4.210.2",
|
||||
"@patternfly/react-core": "^4.221.3",
|
||||
"@patternfly/react-icons": "4.75.1",
|
||||
"@patternfly/react-table": "4.100.8",
|
||||
"@patternfly/react-core": "^4.239.0",
|
||||
"@patternfly/react-icons": "4.90.0",
|
||||
"@patternfly/react-table": "4.108.0",
|
||||
"ace-builds": "^1.10.1",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"axios": "0.27.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"d3": "7.4.4",
|
||||
"d3": "7.6.1",
|
||||
"dagre": "^0.8.4",
|
||||
"dompurify": "2.4.0",
|
||||
"formik": "2.2.9",
|
||||
@@ -31,7 +31,7 @@
|
||||
"react-router-dom": "^5.3.3",
|
||||
"react-virtualized": "^9.21.1",
|
||||
"rrule": "2.7.1",
|
||||
"styled-components": "5.3.5"
|
||||
"styled-components": "5.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.10",
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Plural, t } from '@lingui/macro';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import { useKebabifiedMenu } from 'contexts/Kebabified';
|
||||
|
||||
function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
||||
function HealthCheckButton({
|
||||
isDisabled,
|
||||
onClick,
|
||||
selectedItems,
|
||||
healthCheckPending,
|
||||
}) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
|
||||
const selectedItemsCount = selectedItems.length;
|
||||
@@ -28,8 +33,10 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
||||
component="button"
|
||||
onClick={onClick}
|
||||
ouiaId="health-check"
|
||||
isLoading={healthCheckPending}
|
||||
spinnerAriaLabel={t`Running health check`}
|
||||
>
|
||||
{t`Run health check`}
|
||||
{healthCheckPending ? t`Running health check` : t`Run health check`}
|
||||
</DropdownItem>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -42,7 +49,11 @@ function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
|
||||
variant="secondary"
|
||||
ouiaId="health-check"
|
||||
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>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -107,6 +107,17 @@ function LaunchButton({ resource, children }) {
|
||||
jobPromise = JobsAPI.relaunch(resource.id, params || {});
|
||||
} else if (resource.type === 'workflow_job') {
|
||||
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;
|
||||
|
||||
@@ -129,7 +129,7 @@ function PromptModalForm({
|
||||
}}
|
||||
title={t`Launch | ${resource.name}`}
|
||||
description={
|
||||
resource.description.length > 512 ? (
|
||||
resource.description?.length > 512 ? (
|
||||
<ExpandableSection
|
||||
toggleText={
|
||||
showDescription ? t`Hide description` : t`Show description`
|
||||
|
||||
@@ -67,14 +67,14 @@ function ScheduleForm({
|
||||
if (schedule.id) {
|
||||
if (
|
||||
resource.type === 'job_template' &&
|
||||
launchConfig.ask_credential_on_launch
|
||||
launchConfig?.ask_credential_on_launch
|
||||
) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await SchedulesAPI.readCredentials(schedule.id);
|
||||
creds = results;
|
||||
}
|
||||
if (launchConfig.ask_labels_on_launch) {
|
||||
if (launchConfig?.ask_labels_on_launch) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await SchedulesAPI.readAllLabels(schedule.id);
|
||||
@@ -82,7 +82,7 @@ function ScheduleForm({
|
||||
}
|
||||
if (
|
||||
resource.type === 'job_template' &&
|
||||
launchConfig.ask_instance_groups_on_launch
|
||||
launchConfig?.ask_instance_groups_on_launch
|
||||
) {
|
||||
const {
|
||||
data: { results },
|
||||
@@ -91,7 +91,7 @@ function ScheduleForm({
|
||||
}
|
||||
} else {
|
||||
if (resource.type === 'job_template') {
|
||||
if (launchConfig.ask_labels_on_launch) {
|
||||
if (launchConfig?.ask_labels_on_launch) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await JobTemplatesAPI.readAllLabels(resource.id);
|
||||
@@ -100,7 +100,7 @@ function ScheduleForm({
|
||||
}
|
||||
if (
|
||||
resource.type === 'workflow_job_template' &&
|
||||
launchConfig.ask_labels_on_launch
|
||||
launchConfig?.ask_labels_on_launch
|
||||
) {
|
||||
const {
|
||||
data: { results },
|
||||
@@ -123,14 +123,7 @@ function ScheduleForm({
|
||||
zoneLinks: data.links,
|
||||
credentials: creds,
|
||||
};
|
||||
}, [
|
||||
schedule,
|
||||
resource.id,
|
||||
resource.type,
|
||||
launchConfig.ask_labels_on_launch,
|
||||
launchConfig.ask_instance_groups_on_launch,
|
||||
launchConfig.ask_credential_on_launch,
|
||||
]),
|
||||
}, [schedule, resource.id, resource.type, launchConfig]),
|
||||
{
|
||||
zonesOptions: [],
|
||||
zoneLinks: {},
|
||||
@@ -146,7 +139,7 @@ function ScheduleForm({
|
||||
const missingRequiredInventory = useCallback(() => {
|
||||
let missingInventory = false;
|
||||
if (
|
||||
launchConfig.inventory_needed_to_start &&
|
||||
launchConfig?.inventory_needed_to_start &&
|
||||
!schedule?.summary_fields?.inventory?.id
|
||||
) {
|
||||
missingInventory = true;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { CaretLeftIcon, OutlinedClockIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
@@ -23,6 +23,7 @@ import ErrorDetail from 'components/ErrorDetail';
|
||||
import DisassociateButton from 'components/DisassociateButton';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import RoutedTabs from 'components/RoutedTabs';
|
||||
import ContentError from 'components/ContentError';
|
||||
@@ -62,7 +63,7 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
||||
}
|
||||
|
||||
function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
const { me = {} } = useConfig();
|
||||
const config = useConfig();
|
||||
const { id, instanceId } = useParams();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -115,15 +116,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
}, [fetchDetails]);
|
||||
const {
|
||||
error: healthCheckError,
|
||||
isLoading: isRunningHealthCheck,
|
||||
request: fetchHealthCheck,
|
||||
} = useRequest(
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { status } = await InstancesAPI.healthCheck(instanceId);
|
||||
const { data } = await InstancesAPI.readHealthCheckDetail(instanceId);
|
||||
setHealthCheck(data);
|
||||
if (status === 200) {
|
||||
setShowHealthCheckAlert(true);
|
||||
}
|
||||
@@ -161,6 +156,18 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
|
||||
const formatHealthCheckTimeStamp = (last) => (
|
||||
<>
|
||||
{formatDateString(last)}
|
||||
{instance.health_check_pending ? (
|
||||
<>
|
||||
{' '}
|
||||
<OutlinedClockIcon />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(
|
||||
disassociateError || updateInstanceError || healthCheckError
|
||||
);
|
||||
@@ -189,6 +196,8 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
const isExecutionNode = instance.node_type === 'execution';
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
@@ -218,7 +227,22 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
|
||||
<Detail
|
||||
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
|
||||
@@ -237,7 +261,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
@@ -274,19 +298,25 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
)}
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
isLoading={isRunningHealthCheck}
|
||||
spinnerAriaLabel={t`Running health check`}
|
||||
>
|
||||
{t`Run health check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{me.is_superuser && instance.node_type !== 'control' && (
|
||||
{isExecutionNode && (
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={
|
||||
!config?.me?.is_superuser || instance.health_check_pending
|
||||
}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
isLoading={instance.health_check_pending}
|
||||
spinnerAriaLabel={t`Running health check`}
|
||||
>
|
||||
{instance.health_check_pending
|
||||
? t`Running health check`
|
||||
: t`Run health check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{config?.me?.is_superuser && instance.node_type !== 'control' && (
|
||||
<DisassociateButton
|
||||
verifyCannotDisassociate={instanceGroup.name === 'controlplane'}
|
||||
key="disassociate"
|
||||
|
||||
@@ -87,8 +87,9 @@ describe('<InstanceDetails/>', () => {
|
||||
mem_capacity: 38,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hybrid',
|
||||
node_type: 'execution',
|
||||
node_state: 'ready',
|
||||
health_check_pending: false,
|
||||
},
|
||||
});
|
||||
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
||||
@@ -347,6 +348,67 @@ describe('<InstanceDetails/>', () => {
|
||||
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 () => {
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@@ -35,6 +35,8 @@ const QS_CONFIG = getQSConfig('instance', {
|
||||
function InstanceList({ instanceGroup }) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
|
||||
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
|
||||
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
|
||||
const location = useLocation();
|
||||
const { id: instanceGroupId } = useParams();
|
||||
|
||||
@@ -56,6 +58,10 @@ function InstanceList({ instanceGroup }) {
|
||||
InstanceGroupsAPI.readInstances(instanceGroupId, params),
|
||||
InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
|
||||
]);
|
||||
const isPending = response.data.results.some(
|
||||
(i) => i.health_check_pending === true
|
||||
);
|
||||
setPendingHealthCheck(isPending);
|
||||
return {
|
||||
instances: response.data.results,
|
||||
count: response.data.count,
|
||||
@@ -90,7 +96,7 @@ function InstanceList({ instanceGroup }) {
|
||||
useCallback(async () => {
|
||||
const [...response] = await Promise.all(
|
||||
selected
|
||||
.filter(({ node_type }) => node_type !== 'hop')
|
||||
.filter(({ node_type }) => node_type === 'execution')
|
||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||
);
|
||||
if (response) {
|
||||
@@ -99,6 +105,18 @@ function InstanceList({ instanceGroup }) {
|
||||
}, [selected])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
selected.forEach((i) => {
|
||||
if (i.node_type === 'execution') {
|
||||
setCanRunHealthCheck(true);
|
||||
} else {
|
||||
setCanRunHealthCheck(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
await fetchHealthCheck();
|
||||
clearSelected();
|
||||
@@ -246,9 +264,10 @@ function InstanceList({ instanceGroup }) {
|
||||
isProtectedInstanceGroup={instanceGroup.name === 'controlplane'}
|
||||
/>,
|
||||
<HealthCheckButton
|
||||
isDisabled={!canAdd}
|
||||
isDisabled={!canAdd || !canRunHealthCheck}
|
||||
onClick={handleHealthCheck}
|
||||
selectedItems={selected}
|
||||
healthCheckPending={pendingHealthCheck}
|
||||
/>,
|
||||
]}
|
||||
emptyStateControls={
|
||||
@@ -263,7 +282,10 @@ function InstanceList({ instanceGroup }) {
|
||||
)}
|
||||
headerRow={
|
||||
<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="node_type">{t`Node Type`}</HeaderCell>
|
||||
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('<InstanceList/>', () => {
|
||||
await act(async () =>
|
||||
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 () => {
|
||||
InstancesAPI.healthCheck.mockRejectedValue(
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Slider,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { OutlinedClockIcon } from '@patternfly/react-icons';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
@@ -52,7 +54,7 @@ function InstanceListItem({
|
||||
fetchInstances,
|
||||
rowIndex,
|
||||
}) {
|
||||
const { me = {} } = useConfig();
|
||||
const config = useConfig();
|
||||
const { id } = useParams();
|
||||
const [forks, setForks] = useState(
|
||||
computeForks(
|
||||
@@ -100,6 +102,18 @@ function InstanceListItem({
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
|
||||
const formatHealthCheckTimeStamp = (last) => (
|
||||
<>
|
||||
{formatDateString(last)}
|
||||
{instance.health_check_pending ? (
|
||||
<>
|
||||
{' '}
|
||||
<OutlinedClockIcon />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
@@ -154,7 +168,7 @@ function InstanceListItem({
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
@@ -206,7 +220,22 @@ function InstanceListItem({
|
||||
<Detail
|
||||
data-cy="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>
|
||||
</ExpandableRowContent>
|
||||
|
||||
@@ -281,8 +281,8 @@ describe('<InstanceListItem/>', () => {
|
||||
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
|
||||
'Auto'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Detail[label="Last Health Check"]').prop('value')
|
||||
).toBe('9/15/2021, 6:02:07 PM');
|
||||
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
|
||||
'Last Health Check9/15/2021, 6:02:07 PM'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Slider,
|
||||
Label,
|
||||
} from '@patternfly/react-core';
|
||||
import { DownloadIcon } from '@patternfly/react-icons';
|
||||
import { DownloadIcon, OutlinedClockIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
@@ -23,6 +23,7 @@ import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
@@ -62,7 +63,8 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) {
|
||||
}
|
||||
|
||||
function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
const { me = {} } = useConfig();
|
||||
const config = useConfig();
|
||||
|
||||
const { id } = useParams();
|
||||
const [forks, setForks] = useState();
|
||||
const history = useHistory();
|
||||
@@ -85,8 +87,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
InstancesAPI.readDetail(id),
|
||||
InstancesAPI.readInstanceGroup(id),
|
||||
]);
|
||||
|
||||
if (details.node_type !== 'hop') {
|
||||
if (details.node_type === 'execution') {
|
||||
const { data: healthCheckData } =
|
||||
await InstancesAPI.readHealthCheckDetail(id);
|
||||
setHealthCheck(healthCheckData);
|
||||
@@ -115,15 +116,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
setBreadcrumb(instance);
|
||||
}
|
||||
}, [instance, setBreadcrumb]);
|
||||
const {
|
||||
error: healthCheckError,
|
||||
isLoading: isRunningHealthCheck,
|
||||
request: fetchHealthCheck,
|
||||
} = useRequest(
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { status } = await InstancesAPI.healthCheck(id);
|
||||
const { data } = await InstancesAPI.readHealthCheckDetail(id);
|
||||
setHealthCheck(data);
|
||||
if (status === 200) {
|
||||
setShowHealthCheckAlert(true);
|
||||
}
|
||||
@@ -149,6 +144,18 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
|
||||
const formatHealthCheckTimeStamp = (last) => (
|
||||
<>
|
||||
{formatDateString(last)}
|
||||
{instance.health_check_pending ? (
|
||||
<>
|
||||
{' '}
|
||||
<OutlinedClockIcon />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const buildLinkURL = (inst) =>
|
||||
inst.is_container_group
|
||||
? '/instance_groups/container_group/'
|
||||
@@ -179,6 +186,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
const isHopNode = instance.node_type === 'hop';
|
||||
const isExecutionNode = instance.node_type === 'execution';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -242,7 +250,22 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
<Detail
|
||||
label={t`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 && (
|
||||
<Detail
|
||||
@@ -280,7 +303,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
isDisabled={
|
||||
!config?.me?.is_superuser || !instance.enabled
|
||||
}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
@@ -324,7 +349,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
</DetailList>
|
||||
{!isHopNode && (
|
||||
<CardActionsRow>
|
||||
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
|
||||
{config?.me?.is_superuser && isK8s && isExecutionNode && (
|
||||
<RemoveInstanceButton
|
||||
dataCy="remove-instance-button"
|
||||
itemsToRemove={[instance]}
|
||||
@@ -332,18 +357,24 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
|
||||
onRemove={removeInstances}
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={!me.is_superuser || isRunningHealthCheck}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
isLoading={isRunningHealthCheck}
|
||||
spinnerAriaLabel={t`Running health check`}
|
||||
>
|
||||
{t`Run health check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{isExecutionNode && (
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={
|
||||
!config?.me?.is_superuser || instance.health_check_pending
|
||||
}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
isLoading={instance.health_check_pending}
|
||||
spinnerAriaLabel={t`Running health check`}
|
||||
>
|
||||
{instance.health_check_pending
|
||||
? t`Running health check`
|
||||
: t`Run health check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<InstanceToggle
|
||||
css="display: inline-flex;"
|
||||
fetchInstances={fetchDetails}
|
||||
|
||||
@@ -49,8 +49,9 @@ describe('<InstanceDetail/>', () => {
|
||||
mem_capacity: 38,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hybrid',
|
||||
node_type: 'execution',
|
||||
node_state: 'ready',
|
||||
health_check_pending: false,
|
||||
},
|
||||
});
|
||||
InstancesAPI.readInstanceGroup.mockResolvedValue({
|
||||
|
||||
@@ -37,6 +37,8 @@ function InstanceList() {
|
||||
const location = useLocation();
|
||||
const { me } = useConfig();
|
||||
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
|
||||
const [pendingHealthCheck, setPendingHealthCheck] = useState(false);
|
||||
const [canRunHealthCheck, setCanRunHealthCheck] = useState(true);
|
||||
|
||||
const {
|
||||
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s },
|
||||
@@ -51,6 +53,10 @@ function InstanceList() {
|
||||
InstancesAPI.readOptions(),
|
||||
SettingsAPI.readCategory('system'),
|
||||
]);
|
||||
const isPending = response.data.results.some(
|
||||
(i) => i.health_check_pending === true
|
||||
);
|
||||
setPendingHealthCheck(isPending);
|
||||
return {
|
||||
instances: response.data.results,
|
||||
isK8s: sysSettings.data.IS_K8S,
|
||||
@@ -87,7 +93,7 @@ function InstanceList() {
|
||||
useCallback(async () => {
|
||||
const [...response] = await Promise.all(
|
||||
selected
|
||||
.filter(({ node_type }) => node_type !== 'hop')
|
||||
.filter(({ node_type }) => node_type === 'execution')
|
||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||
);
|
||||
if (response) {
|
||||
@@ -96,6 +102,18 @@ function InstanceList() {
|
||||
}, [selected])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
selected.forEach((i) => {
|
||||
if (i.node_type === 'execution') {
|
||||
setCanRunHealthCheck(true);
|
||||
} else {
|
||||
setCanRunHealthCheck(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
await fetchHealthCheck();
|
||||
clearSelected();
|
||||
@@ -189,6 +207,8 @@ function InstanceList() {
|
||||
onClick={handleHealthCheck}
|
||||
key="healthCheck"
|
||||
selectedItems={selected}
|
||||
healthCheckPending={pendingHealthCheck}
|
||||
isDisabled={!canRunHealthCheck}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
@@ -196,7 +216,7 @@ function InstanceList() {
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||
<HeaderCell
|
||||
tooltip={t`Cannot run health check on hop nodes.`}
|
||||
tooltip={t`Health checks can only be run on execution nodes.`}
|
||||
sortKey="hostname"
|
||||
>{t`Name`}</HeaderCell>
|
||||
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
|
||||
|
||||
@@ -32,7 +32,7 @@ const instances = [
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'control',
|
||||
node_type: 'execution',
|
||||
node_state: 'ready',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
@@ -52,7 +52,7 @@ const instances = [
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'hybrid',
|
||||
node_type: 'execution',
|
||||
node_state: 'ready',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Slider,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { OutlinedClockIcon } from '@patternfly/react-icons';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import computeForks from 'util/computeForks';
|
||||
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
|
||||
@@ -52,7 +54,7 @@ function InstanceListItem({
|
||||
fetchInstances,
|
||||
rowIndex,
|
||||
}) {
|
||||
const { me = {} } = useConfig();
|
||||
const config = useConfig();
|
||||
const [forks, setForks] = useState(
|
||||
computeForks(
|
||||
instance.mem_capacity,
|
||||
@@ -98,7 +100,21 @@ function InstanceListItem({
|
||||
);
|
||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||
};
|
||||
|
||||
const formatHealthCheckTimeStamp = (last) => (
|
||||
<>
|
||||
{formatDateString(last)}
|
||||
{instance.health_check_pending ? (
|
||||
<>
|
||||
{' '}
|
||||
<OutlinedClockIcon />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const isHopNode = instance.node_type === 'hop';
|
||||
const isExecutionNode = instance.node_type === 'execution';
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
@@ -121,7 +137,7 @@ function InstanceListItem({
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disable: isHopNode,
|
||||
disable: !isExecutionNode,
|
||||
}}
|
||||
dataLabel={t`Selected`}
|
||||
/>
|
||||
@@ -164,7 +180,7 @@ function InstanceListItem({
|
||||
step={0.1}
|
||||
value={instance.capacity_adjustment}
|
||||
onChange={handleChangeValue}
|
||||
isDisabled={!me?.is_superuser || !instance.enabled}
|
||||
isDisabled={!config?.me?.is_superuser || !instance.enabled}
|
||||
data-cy="slider"
|
||||
/>
|
||||
</SliderForks>
|
||||
@@ -221,7 +237,22 @@ function InstanceListItem({
|
||||
<Detail
|
||||
data-cy="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>
|
||||
</ExpandableRowContent>
|
||||
|
||||
@@ -272,9 +272,9 @@ describe('<InstanceListItem/>', () => {
|
||||
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
|
||||
'Auto'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Detail[label="Last Health Check"]').prop('value')
|
||||
).toBe('9/15/2021, 6:02:07 PM');
|
||||
expect(wrapper.find('Detail[label="Last Health Check"]').text()).toBe(
|
||||
'Last Health Check9/15/2021, 6:02:07 PM'
|
||||
);
|
||||
});
|
||||
test('Hop should not render some things', async () => {
|
||||
const onSelect = jest.fn();
|
||||
|
||||
@@ -91,9 +91,7 @@ const SmartInventoryFormFields = ({ inventory }) => {
|
||||
id="variables"
|
||||
name="variables"
|
||||
label={t`Variables`}
|
||||
tooltip={t`Enter inventory variables using either JSON or YAML syntax.
|
||||
Use the radio button to toggle between the two. Refer to the
|
||||
Ansible Controller documentation for example syntax.`}
|
||||
tooltip={t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Controller documentation for example syntax.`}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from '@lingui/macro';
|
||||
import { bool, shape, func } from 'prop-types';
|
||||
import {
|
||||
@@ -41,18 +41,18 @@ const Wrapper = styled.div`
|
||||
flex-flow: row wrap;
|
||||
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) => {
|
||||
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}`;
|
||||
return Duration.fromObject({ ...duration }).toFormat('hh:mm:ss');
|
||||
};
|
||||
|
||||
const OUTPUT_NO_COUNT_JOB_TYPES = [
|
||||
@@ -62,6 +62,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
|
||||
];
|
||||
|
||||
const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
|
||||
const [activeJobElapsedTime, setActiveJobElapsedTime] = useState('00:00:00');
|
||||
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
||||
|
||||
const playCount = job?.playbook_counts?.play_count;
|
||||
@@ -76,6 +77,20 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
|
||||
: 0;
|
||||
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 (
|
||||
<Wrapper>
|
||||
{!hideCounts && (
|
||||
@@ -124,7 +139,13 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
|
||||
<BadgeGroup aria-label={t`Elapsed Time`}>
|
||||
<div>{t`Elapsed`}</div>
|
||||
<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>
|
||||
</BadgeGroup>
|
||||
{['pending', 'waiting', 'running'].includes(jobStatus) &&
|
||||
|
||||
@@ -180,8 +180,6 @@ function ManagementJob({ setBreadcrumb }) {
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
launchConfig={{}}
|
||||
surveyConfig={{}}
|
||||
/>
|
||||
</Route>
|
||||
) : null}
|
||||
|
||||
@@ -59,13 +59,14 @@ function PlaybookSelect({
|
||||
onToggle={setIsOpen}
|
||||
placeholderText={t`Select a playbook`}
|
||||
typeAheadAriaLabel={t`Select a playbook`}
|
||||
isCreatable={false}
|
||||
isCreatable
|
||||
createText=""
|
||||
onSelect={(event, value) => {
|
||||
setIsOpen(false);
|
||||
onChange(value);
|
||||
}}
|
||||
id="template-playbook"
|
||||
isValid={isValid}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
onBlur={onBlur}
|
||||
isDisabled={isLoading || isDisabled}
|
||||
maxHeight="1000%"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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 { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import PlaybookSelect from './PlaybookSelect';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('api');
|
||||
|
||||
describe('<PlaybookSelect />', () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<PlaybookSelect
|
||||
projectId={1}
|
||||
isValid
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
const { rerender } = render(
|
||||
<PlaybookSelect
|
||||
projectId={1}
|
||||
isValid
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PlaybookSelect
|
||||
projectId={15}
|
||||
isValid
|
||||
onChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
|
||||
});
|
||||
});
|
||||
|
||||
test('should trigger the onChange callback for the option selected from the list', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<PlaybookSelect
|
||||
projectId={1}
|
||||
isValid={true}
|
||||
onChange={mockCallback}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const selectToggleButton = container.querySelector(
|
||||
'button.pf-c-select__toggle-button'
|
||||
);
|
||||
fireEvent.click(selectToggleButton);
|
||||
// Select options are displayed
|
||||
expect(screen.getAllByRole('option').length).toBe(2);
|
||||
|
||||
fireEvent.click(screen.getByText('debug.yml'));
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith('debug.yml');
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow entering playbook file name manually', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<PlaybookSelect
|
||||
projectId={1}
|
||||
isValid={true}
|
||||
onChange={mockCallback}
|
||||
onError={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const input = container.querySelector('input.pf-c-form-control');
|
||||
expect(input).toBeVisible();
|
||||
fireEvent.change(input, { target: { value: 'foo.yml' } });
|
||||
});
|
||||
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(1);
|
||||
await act(async () => {
|
||||
wrapper.setProps({ projectId: 15 });
|
||||
});
|
||||
await waitFor(() => {
|
||||
// A new select option is displayed ("foo.yml")
|
||||
expect(
|
||||
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
|
||||
).toBeVisible();
|
||||
expect(screen.getAllByRole('option').length).toBe(1);
|
||||
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledTimes(2);
|
||||
expect(ProjectsAPI.readPlaybooks).toHaveBeenCalledWith(15);
|
||||
fireEvent.click(
|
||||
screen.getByText('"foo.yml"', { selector: '[role="option"]' })
|
||||
);
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith('foo.yml');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,9 +26,9 @@ const Wrapper = styled.div`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
padding: 0 10px;
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
overflow: scroll;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
`;
|
||||
const Button = styled(PFButton)`
|
||||
|
||||
@@ -40,8 +40,13 @@ const Loader = styled(ContentLoading)`
|
||||
width: 100%;
|
||||
background: white;
|
||||
`;
|
||||
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
||||
const [storedNodes, setStoredNodes] = useState(null);
|
||||
function MeshGraph({
|
||||
data,
|
||||
showLegend,
|
||||
zoom,
|
||||
setShowZoomControls,
|
||||
storedNodes,
|
||||
}) {
|
||||
const [isNodeSelected, setIsNodeSelected] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = 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
|
||||
useEffect(() => {
|
||||
if (instance?.id) {
|
||||
const updatedNodes = storedNodes.map((n) =>
|
||||
const updatedNodes = storedNodes.current.map((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
|
||||
|
||||
useEffect(() => {
|
||||
if (storedNodes) {
|
||||
updateNodeSVG(storedNodes);
|
||||
}
|
||||
}, [storedNodes]);
|
||||
|
||||
const draw = () => {
|
||||
let width;
|
||||
let height;
|
||||
@@ -137,6 +137,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
||||
const mesh = svg.append('g').attr('class', 'mesh');
|
||||
|
||||
const graph = data;
|
||||
if (storedNodes?.current) {
|
||||
graph.nodes = storedNodes.current;
|
||||
}
|
||||
|
||||
/* WEB WORKER */
|
||||
const worker = webWorker();
|
||||
@@ -162,7 +165,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
||||
}
|
||||
|
||||
function ended({ nodes, links }) {
|
||||
setStoredNodes(nodes);
|
||||
// Remove loading screen
|
||||
d3.select('.simulation-loader').style('visibility', 'hidden');
|
||||
setShowZoomControls(true);
|
||||
@@ -247,7 +249,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
||||
.attr('fill', DEFAULT_NODE_COLOR)
|
||||
.attr('stroke-dasharray', (d) => (d.enabled ? `1 0` : `5`))
|
||||
.attr('stroke', DEFAULT_NODE_STROKE_COLOR);
|
||||
|
||||
// node type labels
|
||||
node
|
||||
.append('text')
|
||||
|
||||
@@ -37,7 +37,7 @@ const Wrapper = styled.div`
|
||||
padding: 0 10px;
|
||||
width: 25%;
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
overflow: scroll;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
`;
|
||||
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 { PageSection, Card, CardBody } from '@patternfly/react-core';
|
||||
import ContentError from 'components/ContentError';
|
||||
@@ -10,6 +10,7 @@ import useZoom from './utils/useZoom';
|
||||
import { CHILDSELECTOR, PARENTSELECTOR } from './constants';
|
||||
|
||||
function TopologyView() {
|
||||
const storedNodes = useRef(null);
|
||||
const [showLegend, setShowLegend] = useState(true);
|
||||
const [showZoomControls, setShowZoomControls] = useState(false);
|
||||
const {
|
||||
@@ -20,6 +21,7 @@ function TopologyView() {
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await MeshAPI.read();
|
||||
storedNodes.current = data.nodes;
|
||||
return {
|
||||
meshData: data,
|
||||
};
|
||||
@@ -64,6 +66,7 @@ function TopologyView() {
|
||||
showLegend={showLegend}
|
||||
zoom={zoom}
|
||||
setShowZoomControls={setShowZoomControls}
|
||||
storedNodes={storedNodes}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
@@ -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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
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
|
||||
container_name: tools_postgres_1
|
||||
# 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:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
POSTGRES_USER: {{ pg_username }}
|
||||
|
||||
@@ -36,9 +36,18 @@ GRAFANA=true PROMETHEUS=true EXTRA_SOURCES_ANSIBLE_OPTS="-e scrape_interval=1 ad
|
||||
|
||||
We are configuring alerts in grafana using the provisioning files method. This feature is new in Grafana as of August, 2022. Documentation can be found: https://grafana.com/docs/grafana/latest/administration/provisioning/#alerting however it does not fully show all parameters to the config.
|
||||
|
||||
One way to understand how to build rules is to build them in the UI and use chrometools to inspect the payload as you save the rules. It appears that the "data" portion of the payload for each rule is the same syntax as needed in the provisioning file config. To reload the alerts without restarting the container, from within the container you can send a POST with `curl -X POST http://admin:admin@localhost:3000/api/admin/provisioning/alerting/reload`. Keep in mind the grafana container does not contain `curl`. You can install it with the command `apk add curl`.
|
||||
One way to understand how to build rules is to build them in the UI and use chrometools to inspect the payload as you save the rules. It appears that the "data" portion of the payload for each rule is the same syntax as needed in the provisioning file config. To reload the alerts without restarting the container, from your terminal you can send a POST with `curl -X POST http://admin:admin@localhost:3001/api/admin/provisioning/alerting/reload`.
|
||||
|
||||
Another way to export rules is explore the api.
|
||||
1. Get all the folders: `GET` to `/api/folders`
|
||||
2. Get the rules `GET` to `/api/ruler/grafana/api/v1/rules/{{ Folder }}`
|
||||
|
||||
You can do this via curl or in the web browser.
|
||||
|
||||
### Included Alerts
|
||||
|
||||
#### Alert if remaining capacity low and pending jobs exist
|
||||
|
||||
We want to know if jobs are in pending but we lack capacity in the cluster to run them. Our approach is to sum all remaining capacity in the cluster and compare it to the total capacity of the cluster. If less than 10% of our capacity is remaining and we have pending jobs, and this is true for more than 180s, we will fire the alert.
|
||||
|
||||
This alert is named "capacity_below_10_percent" and can be found in this directory in https://github.com/ansible/awx/blob/devel/tools/grafana/alerting/alerts.yml
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
apiVersion: 1
|
||||
groups:
|
||||
- folder: awx
|
||||
interval: 60s
|
||||
interval: 10s
|
||||
name: awx_rules
|
||||
orgId: 1
|
||||
exec_err_state: Alerting
|
||||
no_data_state: NoData
|
||||
rules:
|
||||
- condition: if_failures_too_high
|
||||
dashboardUid: awx
|
||||
- for: 5m
|
||||
noDataState: OK
|
||||
panelId: 2
|
||||
title: failure_rate_exceeded_20_percent
|
||||
uid: failure_rate_exceeded_20_percent
|
||||
condition: compare
|
||||
data:
|
||||
- refId: total_errors
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 600
|
||||
to: 0
|
||||
@@ -19,7 +25,7 @@ groups:
|
||||
editorMode: code
|
||||
expr: >-
|
||||
max(delta(awx_instance_status_total{instance="awx1:8013",
|
||||
status="failed|error"}[30m]))
|
||||
status=~"failed|error"}[30m]))
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
@@ -27,11 +33,11 @@ groups:
|
||||
range: true
|
||||
refId: total_errors
|
||||
- refId: max_errors
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: '-100'
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -60,7 +66,7 @@ groups:
|
||||
refId: max_errors
|
||||
type: reduce
|
||||
- refId: total_success
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 600
|
||||
to: 0
|
||||
@@ -80,11 +86,11 @@ groups:
|
||||
range: true
|
||||
refId: total_success
|
||||
- refId: max_success
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: '-100'
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -113,11 +119,11 @@ groups:
|
||||
refId: max_success
|
||||
type: reduce
|
||||
- refId: compare
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: '-100'
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -158,15 +164,19 @@ groups:
|
||||
maxDataPoints: 43200
|
||||
refId: compare
|
||||
type: math
|
||||
for: 30m
|
||||
- for: 60s
|
||||
noDataState: OK
|
||||
panelId: 2
|
||||
title: failure_rate_exceeded_20_percent
|
||||
uid: failure_rate_exceeded_20_percent
|
||||
- condition: if_redis_queue_too_large
|
||||
panelId: 1
|
||||
title: redis_queue_too_large_to_clear_in_2_min
|
||||
uid: redis_queue_too_large_to_clear_in_2_min
|
||||
condition: redis_queue_growing_faster_than_insertion_rate
|
||||
dashboardUid: awx
|
||||
data:
|
||||
- datasourceUid: awx_prometheus
|
||||
- refId: events_insertion_rate_per_second
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
to: 0
|
||||
datasourceUid: awx_prometheus
|
||||
model:
|
||||
editorMode: code
|
||||
expr: irate(callback_receiver_events_insert_db{node='awx_1'}[1m])
|
||||
@@ -177,11 +187,11 @@ groups:
|
||||
range: true
|
||||
refId: events_insertion_rate_per_second
|
||||
queryType: ""
|
||||
refId: events_insertion_rate_per_second
|
||||
- refId: mean_event_insertion_rate
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
from: 0
|
||||
to: 0
|
||||
- datasourceUid: -100
|
||||
datasourceUid: -100
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -208,11 +218,11 @@ groups:
|
||||
refId: mean_event_insertion_rate
|
||||
type: reduce
|
||||
queryType: ""
|
||||
refId: mean_event_insertion_rate
|
||||
- refId: redis_queue_size
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
from: 300
|
||||
to: 0
|
||||
- datasourceUid: awx_prometheus
|
||||
datasourceUid: awx_prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
@@ -226,11 +236,11 @@ groups:
|
||||
range: true
|
||||
refId: redis_queue_size
|
||||
queryType: ""
|
||||
refId: redis_queue_size
|
||||
- refId: last_redis_queue_size
|
||||
relativeTimeRange:
|
||||
from: 300
|
||||
to: 0
|
||||
- datasourceUid: -100
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: -100
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -257,11 +267,12 @@ groups:
|
||||
refId: last_redis_queue_size
|
||||
type: reduce
|
||||
queryType: ""
|
||||
refId: last_redis_queue_size
|
||||
- refId: redis_queue_growing_faster_than_insertion_rate
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
- datasourceUid: -100
|
||||
datasourceUid: -100
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
@@ -282,44 +293,35 @@ groups:
|
||||
name: Expression
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: '($last_redis_queue_size > ($mean_event_insertion_rate * 120))'
|
||||
expression: "($last_redis_queue_size > ($mean_event_insertion_rate * 120))"
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: redis_queue_growing_faster_than_insertion_rate
|
||||
type: math
|
||||
queryType: ""
|
||||
refId: redis_queue_growing_faster_than_insertion_rate
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
for: 60s
|
||||
- for: 60s
|
||||
noDataState: OK
|
||||
panelId: 1
|
||||
title: redis_queue_too_large_to_clear_in_2_min
|
||||
uid: redis_queue_too_large_to_clear_in_2_min
|
||||
- condition: if_capacity_is_too_low
|
||||
dashboardUid: awx
|
||||
no_data_state: OK
|
||||
exec_err_state: Error
|
||||
panelId: 3
|
||||
uid: capacity_below_10_percent
|
||||
title: capacity_below_10_percent
|
||||
condition: pending_jobs_and_capacity_compare
|
||||
data:
|
||||
- refId: remaining_capacity
|
||||
queryType: ''
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 1800
|
||||
from: 300
|
||||
to: 0
|
||||
datasourceUid: awx_prometheus
|
||||
model:
|
||||
editorMode: builder
|
||||
expr: awx_instance_remaining_capacity{instance="awx1:8013"}
|
||||
editorMode: code
|
||||
expr: sum(awx_instance_remaining_capacity)
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: true
|
||||
refId: remaining_capacity
|
||||
- refId: if_capacity_is_too_low
|
||||
queryType: ''
|
||||
- refId: last_remaining_capacity
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
@@ -328,14 +330,63 @@ groups:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 20
|
||||
- 0
|
||||
type: lt
|
||||
- 3
|
||||
type: outside_range
|
||||
operator:
|
||||
type: when
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- remaining_capacity
|
||||
- total_capacity
|
||||
reducer:
|
||||
params: []
|
||||
type: percent_diff
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: "-100"
|
||||
expression: remaining_capacity
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
reducer: last
|
||||
refId: last_remaining_capacity
|
||||
type: reduce
|
||||
- refId: total_capacity
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 600
|
||||
to: 0
|
||||
datasourceUid: awx_prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: awx_prometheus
|
||||
editorMode: code
|
||||
expr: sum(awx_instance_capacity{instance="awx1:8013"})
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: true
|
||||
refId: total_capacity
|
||||
- refId: last_total_capacity
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- capacity_below_10%
|
||||
reducer:
|
||||
params: []
|
||||
type: avg
|
||||
@@ -344,12 +395,142 @@ groups:
|
||||
name: Expression
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: remaining_capacity
|
||||
expression: total_capacity
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: if_capacity_is_too_low
|
||||
type: classic_conditions
|
||||
for: 30m
|
||||
title: if_capacity_is_too_low
|
||||
uid: if_capacity_is_too_low
|
||||
reducer: last
|
||||
refId: last_total_capacity
|
||||
type: reduce
|
||||
- refId: 10_percent_total_capacity
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- last_total_capacity
|
||||
reducer:
|
||||
params: []
|
||||
type: avg
|
||||
type: query
|
||||
datasource:
|
||||
name: Expression
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: "$last_total_capacity*.10"
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: 10_percent_total_capacity
|
||||
type: math
|
||||
- refId: pending_jobs
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 600
|
||||
to: 0
|
||||
datasourceUid: awx_prometheus
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: awx_prometheus
|
||||
editorMode: builder
|
||||
expr: awx_pending_jobs_total{instance="awx1:8013"}
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: true
|
||||
refId: pending_jobs
|
||||
- refId: last_pending_jobs
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- pending_jobs_and_capacity_compare
|
||||
reducer:
|
||||
params: []
|
||||
type: avg
|
||||
type: query
|
||||
datasource:
|
||||
name: Expression
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: pending_jobs
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
reducer: last
|
||||
refId: last_pending_jobs
|
||||
type: reduce
|
||||
- refId: pending_jobs_and_capacity_compare
|
||||
queryType: ""
|
||||
relativeTimeRange:
|
||||
from: 0
|
||||
to: 0
|
||||
datasourceUid: "-100"
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- 10_percent_total_capacity
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- pending_jobs
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
name: Expression
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression:
|
||||
"($10_percent_total_capacity > $last_remaining_capacity) && $last_pending_jobs
|
||||
> 1"
|
||||
hide: false
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
reducer: mean
|
||||
refId: pending_jobs_and_capacity_compare
|
||||
type: math
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user