Compare commits

...

18 Commits

Author SHA1 Message Date
Alex Corey
bea8b1a754 Adds an Instance Group component that renders IGs as a PF Label 2022-10-26 13:36:14 -04:00
Alex Corey
95dba81a9d Merge pull request #13110 from AlexSCorey/fixBrokenHostEventModalTest
Fixes a broken Host event modal unit test
2022-10-26 11:04:03 -04:00
Alex Corey
4b308d313a Fixes a broken Host event modal unit test 2022-10-25 15:12:47 -04:00
Alex Corey
d80db763bc Merge pull request #12942 from Tioborto/fix/ui-host-vent-modal
fix: UI host event modal when stdout is an array
2022-10-25 10:12:02 -04:00
Seth Foster
ddd09461fb Merge pull request #13093 from fosterseth/nginx_static_location
Make nginx conf consistent with settings.STATIC_ROOT
2022-10-24 11:02:17 -04:00
Seth Foster
6d192927ae Make nginx conf consistent with settings.STATIC_ROOT 2022-10-21 23:10:06 -04:00
Sarah Akus
e655e1dbc2 Merge pull request #13068 from AlexSCorey/11555-CalculateElapsedTimeonJob
Allows job output to calculate elapsed time
2022-10-21 11:51:47 -04:00
Sarabraj Singh
e41f20320a removed hostname check when editing hostname on existing host (#13057) 2022-10-21 10:28:40 -03:00
Alan Rominger
192f45bbd0 Make canceling view non-atomic to fix 500 errors with job bursts (#13072)
* Make canceling view non-atomic to fix 500 errors with job bursts

* Update test calls for cancel method changes
2022-10-20 15:02:54 -04:00
Alan Rominger
e013d25e2d Merge pull request #13073 from AlanCoding/max_conn_deadlock
Fix dispatcher connection deadlock w scheduler and cleanup
2022-10-19 14:06:12 -04:00
Alan Rominger
cba780a8f8 Fix dispatcher connection deadlock w scheduler and cleanup 2022-10-19 12:12:15 -04:00
Alan Rominger
3fc67dc76c Merge pull request #13081 from AlanCoding/raw_string
Use raw string to satisfy linter rules
2022-10-19 12:08:02 -04:00
Alan Rominger
6f85aef5fe Use raw string to satisfy linter rules 2022-10-19 11:07:43 -04:00
Alan Rominger
4d9b8400da Merge pull request #12887 from AlanCoding/more_rules
[tech debt] Add new flake8 rules to do some meaningful corrections
2022-10-18 20:27:00 -04:00
Alan Rominger
d3eb2c1975 Add new flak8 rules to do some meaningful corrections 2022-09-27 20:36:42 -04:00
Alexandre Bortoluzzi
5551874352 fix: HostEventModel test 2022-09-27 10:23:14 +02:00
Alexandre Bortoluzzi
8e2003a36b chore:add comment in test 2022-09-22 16:23:23 +02:00
Alexandre Bortoluzzi
4f52343cd9 fix: host modal stdout when stdout is an array 2022-09-22 16:03:11 +02:00
40 changed files with 246 additions and 268 deletions

View File

@@ -13,7 +13,7 @@ from django.contrib.auth import views as auth_views
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db import connection from django.db import connection, transaction
from django.db.models.fields.related import OneToOneRel from django.db.models.fields.related import OneToOneRel
from django.http import QueryDict from django.http import QueryDict
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@@ -64,6 +64,7 @@ __all__ = [
'ParentMixin', 'ParentMixin',
'SubListAttachDetachAPIView', 'SubListAttachDetachAPIView',
'CopyAPIView', 'CopyAPIView',
'GenericCancelView',
'BaseUsersList', 'BaseUsersList',
] ]
@@ -985,6 +986,23 @@ class CopyAPIView(GenericAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class GenericCancelView(RetrieveAPIView):
# In subclass set model, serializer_class
obj_permission_type = 'cancel'
@transaction.non_atomic_requests
def dispatch(self, *args, **kwargs):
return super(GenericCancelView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class BaseUsersList(SubListCreateAttachDetachAPIView): class BaseUsersList(SubListCreateAttachDetachAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
ret = super(BaseUsersList, self).post(request, *args, **kwargs) ret = super(BaseUsersList, self).post(request, *args, **kwargs)

View File

@@ -24,7 +24,6 @@ __all__ = [
'InventoryInventorySourcesUpdatePermission', 'InventoryInventorySourcesUpdatePermission',
'UserPermission', 'UserPermission',
'IsSystemAdminOrAuditor', 'IsSystemAdminOrAuditor',
'InstanceGroupTowerPermission',
'WorkflowApprovalPermission', 'WorkflowApprovalPermission',
] ]

View File

@@ -4934,7 +4934,7 @@ class InstanceSerializer(BaseSerializer):
MaxLengthValidator(limit_value=250), MaxLengthValidator(limit_value=250),
validators.UniqueValidator(queryset=Instance.objects.all()), validators.UniqueValidator(queryset=Instance.objects.all()),
RegexValidator( RegexValidator(
regex='^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$', regex=r'^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$',
flags=re.IGNORECASE, flags=re.IGNORECASE,
inverse_match=True, inverse_match=True,
message="hostname cannot be localhost or 127.0.0.1", message="hostname cannot be localhost or 127.0.0.1",

View File

@@ -69,6 +69,7 @@ from awx.api.generics import (
APIView, APIView,
BaseUsersList, BaseUsersList,
CopyAPIView, CopyAPIView,
GenericCancelView,
GenericAPIView, GenericAPIView,
ListAPIView, ListAPIView,
ListCreateAPIView, ListCreateAPIView,
@@ -976,20 +977,11 @@ class SystemJobEventsList(SubListAPIView):
return job.get_event_queryset() return job.get_event_queryset()
class ProjectUpdateCancel(RetrieveAPIView): class ProjectUpdateCancel(GenericCancelView):
model = models.ProjectUpdate model = models.ProjectUpdate
obj_permission_type = 'cancel'
serializer_class = serializers.ProjectUpdateCancelSerializer serializer_class = serializers.ProjectUpdateCancelSerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class ProjectUpdateNotificationsList(SubListAPIView): class ProjectUpdateNotificationsList(SubListAPIView):
@@ -2262,20 +2254,11 @@ class InventoryUpdateCredentialsList(SubListAPIView):
relationship = 'credentials' relationship = 'credentials'
class InventoryUpdateCancel(RetrieveAPIView): class InventoryUpdateCancel(GenericCancelView):
model = models.InventoryUpdate model = models.InventoryUpdate
obj_permission_type = 'cancel'
serializer_class = serializers.InventoryUpdateCancelSerializer serializer_class = serializers.InventoryUpdateCancelSerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class InventoryUpdateNotificationsList(SubListAPIView): class InventoryUpdateNotificationsList(SubListAPIView):
@@ -3050,8 +3033,7 @@ class WorkflowJobNodeChildrenBaseList(SubListAPIView):
search_fields = ('unified_job_template__name', 'unified_job_template__description') search_fields = ('unified_job_template__name', 'unified_job_template__description')
# #
# Limit the set of WorkflowJobeNodes to the related nodes of specified by # Limit the set of WorkflowJobNodes to the related nodes of specified by self.relationship
#'relationship'
# #
def get_queryset(self): def get_queryset(self):
parent = self.get_parent_object() parent = self.get_parent_object()
@@ -3353,20 +3335,15 @@ class WorkflowJobWorkflowNodesList(SubListAPIView):
return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id') return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id')
class WorkflowJobCancel(RetrieveAPIView): class WorkflowJobCancel(GenericCancelView):
model = models.WorkflowJob model = models.WorkflowJob
obj_permission_type = 'cancel'
serializer_class = serializers.WorkflowJobCancelSerializer serializer_class = serializers.WorkflowJobCancelSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object() r = super().post(request, *args, **kwargs)
if obj.can_cancel: ScheduleWorkflowManager().schedule()
obj.cancel() return r
ScheduleWorkflowManager().schedule()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class WorkflowJobNotificationsList(SubListAPIView): class WorkflowJobNotificationsList(SubListAPIView):
@@ -3522,20 +3499,11 @@ class JobActivityStreamList(SubListAPIView):
search_fields = ('changes',) search_fields = ('changes',)
class JobCancel(RetrieveAPIView): class JobCancel(GenericCancelView):
model = models.Job model = models.Job
obj_permission_type = 'cancel'
serializer_class = serializers.JobCancelSerializer serializer_class = serializers.JobCancelSerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class JobRelaunch(RetrieveAPIView): class JobRelaunch(RetrieveAPIView):
@@ -4006,20 +3974,11 @@ class AdHocCommandDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
serializer_class = serializers.AdHocCommandDetailSerializer serializer_class = serializers.AdHocCommandDetailSerializer
class AdHocCommandCancel(RetrieveAPIView): class AdHocCommandCancel(GenericCancelView):
model = models.AdHocCommand model = models.AdHocCommand
obj_permission_type = 'cancel'
serializer_class = serializers.AdHocCommandCancelSerializer serializer_class = serializers.AdHocCommandCancelSerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class AdHocCommandRelaunch(GenericAPIView): class AdHocCommandRelaunch(GenericAPIView):
@@ -4154,20 +4113,11 @@ class SystemJobDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
serializer_class = serializers.SystemJobSerializer serializer_class = serializers.SystemJobSerializer
class SystemJobCancel(RetrieveAPIView): class SystemJobCancel(GenericCancelView):
model = models.SystemJob model = models.SystemJob
obj_permission_type = 'cancel'
serializer_class = serializers.SystemJobCancelSerializer serializer_class = serializers.SystemJobCancelSerializer
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.can_cancel:
obj.cancel()
return Response(status=status.HTTP_202_ACCEPTED)
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class SystemJobNotificationsList(SubListAPIView): class SystemJobNotificationsList(SubListAPIView):

View File

@@ -993,9 +993,6 @@ class HostAccess(BaseAccess):
if data and 'name' in data: if data and 'name' in data:
self.check_license(add_host_name=data['name']) self.check_license(add_host_name=data['name'])
# Check the per-org limit
self.check_org_host_limit({'inventory': obj.inventory}, add_host_name=data['name'])
# Checks for admin or change permission on inventory, controls whether # Checks for admin or change permission on inventory, controls whether
# the user can edit variable data. # the user can edit variable data.
return obj and self.user in obj.inventory.admin_role return obj and self.user in obj.inventory.admin_role

View File

@@ -3,6 +3,7 @@ import uuid
import json import json
from django.conf import settings from django.conf import settings
from django.db import connection
import redis import redis
from awx.main.dispatch import get_local_queuename from awx.main.dispatch import get_local_queuename
@@ -49,7 +50,10 @@ class Control(object):
reply_queue = Control.generate_reply_queue_name() reply_queue = Control.generate_reply_queue_name()
self.result = None self.result = None
with pg_bus_conn(new_connection=True) as conn: if not connection.get_autocommit():
raise RuntimeError('Control-with-reply messages can only be done in autocommit mode')
with pg_bus_conn() as conn:
conn.listen(reply_queue) conn.listen(reply_queue)
send_data = {'control': command, 'reply_to': reply_queue} send_data = {'control': command, 'reply_to': reply_queue}
if extra_data: if extra_data:

View File

@@ -387,6 +387,8 @@ class AutoscalePool(WorkerPool):
reaper.reap_job(j, 'failed') reaper.reap_job(j, 'failed')
except Exception: except Exception:
logger.exception('failed to reap job UUID {}'.format(w.current_task['uuid'])) logger.exception('failed to reap job UUID {}'.format(w.current_task['uuid']))
else:
logger.warning(f'Worker was told to quit but has not, pid={w.pid}')
orphaned.extend(w.orphaned_tasks) orphaned.extend(w.orphaned_tasks)
self.workers.remove(w) self.workers.remove(w)
elif w.idle and len(self.workers) > self.min_workers: elif w.idle and len(self.workers) > self.min_workers:
@@ -450,9 +452,6 @@ class AutoscalePool(WorkerPool):
try: try:
if isinstance(body, dict) and body.get('bind_kwargs'): if isinstance(body, dict) and body.get('bind_kwargs'):
self.add_bind_kwargs(body) self.add_bind_kwargs(body)
# when the cluster heartbeat occurs, clean up internally
if isinstance(body, dict) and 'cluster_node_heartbeat' in body['task']:
self.cleanup()
if self.should_grow: if self.should_grow:
self.up() self.up()
# we don't care about "preferred queue" round robin distribution, just # we don't care about "preferred queue" round robin distribution, just

View File

@@ -114,7 +114,6 @@ class AWXConsumerBase(object):
queue = 0 queue = 0
self.pool.write(queue, body) self.pool.write(queue, body)
self.total_messages += 1 self.total_messages += 1
self.record_statistics()
@log_excess_runtime(logger) @log_excess_runtime(logger)
def record_statistics(self): def record_statistics(self):
@@ -156,6 +155,16 @@ class AWXConsumerPG(AWXConsumerBase):
# if no successful loops have ran since startup, then we should fail right away # if no successful loops have ran since startup, then we should fail right away
self.pg_is_down = True # set so that we fail if we get database errors on startup self.pg_is_down = True # set so that we fail if we get database errors on startup
self.pg_down_time = time.time() - self.pg_max_wait # allow no grace period self.pg_down_time = time.time() - self.pg_max_wait # allow no grace period
self.last_cleanup = time.time()
def run_periodic_tasks(self):
self.record_statistics() # maintains time buffer in method
if time.time() - self.last_cleanup > 60: # same as cluster_node_heartbeat
# NOTE: if we run out of database connections, it is important to still run cleanup
# so that we scale down workers and free up connections
self.pool.cleanup()
self.last_cleanup = time.time()
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
super(AWXConsumerPG, self).run(*args, **kwargs) super(AWXConsumerPG, self).run(*args, **kwargs)
@@ -171,8 +180,10 @@ class AWXConsumerPG(AWXConsumerBase):
if init is False: if init is False:
self.worker.on_start() self.worker.on_start()
init = True init = True
for e in conn.events(): for e in conn.events(yield_timeouts=True):
self.process_task(json.loads(e.payload)) if e is not None:
self.process_task(json.loads(e.payload))
self.run_periodic_tasks()
self.pg_is_down = False self.pg_is_down = False
if self.should_stop: if self.should_stop:
return return
@@ -229,6 +240,8 @@ class BaseWorker(object):
# so we can establish a new connection # so we can establish a new connection
conn.close_if_unusable_or_obsolete() conn.close_if_unusable_or_obsolete()
self.perform_work(body, *args) self.perform_work(body, *args)
except Exception:
logger.exception(f'Unhandled exception in perform_work in worker pid={os.getpid()}')
finally: finally:
if 'uuid' in body: if 'uuid' in body:
uuid = body['uuid'] uuid = body['uuid']

View File

@@ -25,7 +25,7 @@ class Command(BaseCommand):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute( cursor.execute(
f''' f'''
SELECT SELECT
b.id, b.job_id, b.host_name, b.created - a.created delta, b.id, b.job_id, b.host_name, b.created - a.created delta,
b.task task, b.task task,
b.event_data::json->'task_action' task_action, b.event_data::json->'task_action' task_action,

View File

@@ -4,7 +4,7 @@ from django.utils.timezone import now
logger = logging.getLogger('awx.main.migrations') logger = logging.getLogger('awx.main.migrations')
__all__ = ['create_collection_jt', 'create_clearsessions_jt', 'create_cleartokens_jt'] __all__ = ['create_clearsessions_jt', 'create_cleartokens_jt']
''' '''
These methods are called by migrations to create various system job templates These methods are called by migrations to create various system job templates

View File

@@ -44,7 +44,7 @@ def migrate_galaxy_settings(apps, schema_editor):
credential_type=galaxy_type, credential_type=galaxy_type,
inputs={'url': 'https://galaxy.ansible.com/'}, inputs={'url': 'https://galaxy.ansible.com/'},
) )
except: except Exception:
# Needed for new migrations, tests # Needed for new migrations, tests
public_galaxy_credential = Credential( public_galaxy_credential = Credential(
created=now(), modified=now(), name='Ansible Galaxy', managed=True, credential_type=galaxy_type, inputs={'url': 'https://galaxy.ansible.com/'} created=now(), modified=now(), name='Ansible Galaxy', managed=True, credential_type=galaxy_type, inputs={'url': 'https://galaxy.ansible.com/'}

View File

@@ -153,7 +153,7 @@ class Schedule(PrimordialModel, LaunchTimeConfig):
# #
# Find the DTSTART rule or raise an error, its usually the first rule but that is not strictly enforced # Find the DTSTART rule or raise an error, its usually the first rule but that is not strictly enforced
start_date_rule = re.sub('^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule) start_date_rule = re.sub(r'^.*(DTSTART[^\s]+)\s.*$', r'\1', rrule)
if not start_date_rule: if not start_date_rule:
raise ValueError('A DTSTART field needs to be in the rrule') raise ValueError('A DTSTART field needs to be in the rrule')

View File

@@ -1467,23 +1467,23 @@ class UnifiedJob(
self.job_explanation = job_explanation self.job_explanation = job_explanation
cancel_fields.append('job_explanation') cancel_fields.append('job_explanation')
# Important to save here before sending cancel signal to dispatcher to cancel because
# the job control process will use the cancel_flag to distinguish a shutdown from a cancel
self.save(update_fields=cancel_fields)
controller_notified = False controller_notified = False
if self.celery_task_id: if self.celery_task_id:
controller_notified = self.cancel_dispatcher_process() controller_notified = self.cancel_dispatcher_process()
else:
# Avoid race condition where we have stale model from pending state but job has already started,
# its checking signal but not cancel_flag, so re-send signal after this database commit
connection.on_commit(self.fallback_cancel)
# If a SIGTERM signal was sent to the control process, and acked by the dispatcher # If a SIGTERM signal was sent to the control process, and acked by the dispatcher
# then we want to let its own cleanup change status, otherwise change status now # then we want to let its own cleanup change status, otherwise change status now
if not controller_notified: if not controller_notified:
if self.status != 'canceled': if self.status != 'canceled':
self.status = 'canceled' self.status = 'canceled'
cancel_fields.append('status') self.save(update_fields=['status'])
# Avoid race condition where we have stale model from pending state but job has already started,
self.save(update_fields=cancel_fields) # its checking signal but not cancel_flag, so re-send signal after updating cancel fields
self.fallback_cancel()
return self.cancel_flag return self.cancel_flag

View File

@@ -216,7 +216,7 @@ def test_instance_attach_to_instance_group(post, instance_group, node_type_insta
count = ActivityStream.objects.count() count = ActivityStream.objects.count()
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk}) url = reverse('api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400) post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
new_activity = ActivityStream.objects.all()[count:] new_activity = ActivityStream.objects.all()[count:]
@@ -240,7 +240,7 @@ def test_instance_unattach_from_instance_group(post, instance_group, node_type_i
count = ActivityStream.objects.count() count = ActivityStream.objects.count()
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk}) url = reverse('api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400) post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
new_activity = ActivityStream.objects.all()[count:] new_activity = ActivityStream.objects.all()[count:]
@@ -263,7 +263,7 @@ def test_instance_group_attach_to_instance(post, instance_group, node_type_insta
count = ActivityStream.objects.count() count = ActivityStream.objects.count()
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk}) url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400) post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
new_activity = ActivityStream.objects.all()[count:] new_activity = ActivityStream.objects.all()[count:]
@@ -287,7 +287,7 @@ def test_instance_group_unattach_from_instance(post, instance_group, node_type_i
count = ActivityStream.objects.count() count = ActivityStream.objects.count()
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk}) url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400) post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
new_activity = ActivityStream.objects.all()[count:] new_activity = ActivityStream.objects.all()[count:]
@@ -314,4 +314,4 @@ def test_cannot_remove_controlplane_hybrid_instances(post, controlplane_instance
url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk}) url = reverse('api:instance_instance_groups_list', kwargs={'pk': instance.pk})
r = post(url, {'disassociate': True, 'id': controlplane_instance_group.id}, admin_user, expect=400) r = post(url, {'disassociate': True, 'id': controlplane_instance_group.id}, admin_user, expect=400)
assert f'Cannot disassociate hybrid instance' in str(r.data) assert 'Cannot disassociate hybrid instance' in str(r.data)

View File

@@ -147,19 +147,19 @@ def test_survey_password_default(post, patch, admin_user, project, inventory, su
("DTSTART:20030925T104941Z RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z", "RRULE may not contain both COUNT and UNTIL"), # noqa ("DTSTART:20030925T104941Z RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z", "RRULE may not contain both COUNT and UNTIL"), # noqa
("DTSTART:20300308T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "COUNT > 999 is unsupported"), # noqa ("DTSTART:20300308T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "COUNT > 999 is unsupported"), # noqa
# Individual rule test with multiple rules # Individual rule test with multiple rules
## Bad Rule: RRULE:NONSENSE # Bad Rule: RRULE:NONSENSE
("DTSTART:20300308T050000Z RRULE:NONSENSE RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU", "INTERVAL required in rrule"), ("DTSTART:20300308T050000Z RRULE:NONSENSE RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU", "INTERVAL required in rrule"),
## Bad Rule: RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO # Bad Rule: RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO
( (
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO", "DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=5MO",
"BYDAY with numeric prefix not supported", "BYDAY with numeric prefix not supported",
), # noqa ), # noqa
## Bad Rule: RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z # Bad Rule: RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z
( (
"DTSTART:20030925T104941Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z", "DTSTART:20030925T104941Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=10;COUNT=500;UNTIL=20040925T104941Z",
"RRULE may not contain both COUNT and UNTIL", "RRULE may not contain both COUNT and UNTIL",
), # noqa ), # noqa
## Bad Rule: RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000 # Bad Rule: RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000
( (
"DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000", "DTSTART:20300308T050000Z RRULE:INTERVAL=1;FREQ=DAILY EXRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2000",
"COUNT > 999 is unsupported", "COUNT > 999 is unsupported",

View File

@@ -24,7 +24,7 @@ class TestApiRootView:
endpoints = [ endpoints = [
'ping', 'ping',
'config', 'config',
#'settings', # 'settings',
'me', 'me',
'dashboard', 'dashboard',
'organizations', 'organizations',

View File

@@ -50,7 +50,10 @@ def test_cancel(unified_job):
# Some more thought may want to go into only emitting canceled if/when the job record # Some more thought may want to go into only emitting canceled if/when the job record
# status is changed to canceled. Unlike, currently, where it's emitted unconditionally. # status is changed to canceled. Unlike, currently, where it's emitted unconditionally.
unified_job.websocket_emit_status.assert_called_with("canceled") unified_job.websocket_emit_status.assert_called_with("canceled")
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status']) assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
((), {'update_fields': ['cancel_flag', 'start_args']}),
((), {'update_fields': ['status']}),
]
def test_cancel_job_explanation(unified_job): def test_cancel_job_explanation(unified_job):
@@ -60,7 +63,10 @@ def test_cancel_job_explanation(unified_job):
unified_job.cancel(job_explanation=job_explanation) unified_job.cancel(job_explanation=job_explanation)
assert unified_job.job_explanation == job_explanation assert unified_job.job_explanation == job_explanation
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'job_explanation', 'status']) assert [(args, kwargs) for args, kwargs in unified_job.save.call_args_list] == [
((), {'update_fields': ['cancel_flag', 'start_args', 'job_explanation']}),
((), {'update_fields': ['status']}),
]
def test_organization_copy_to_jobs(): def test_organization_copy_to_jobs():

View File

@@ -5,7 +5,6 @@ __metaclass__ = type
import gnupg import gnupg
import os import os
import tempfile import tempfile
from ansible.module_utils.basic import *
from ansible.plugins.action import ActionBase from ansible.plugins.action import ActionBase
from ansible.utils.display import Display from ansible.utils.display import Display
@@ -15,7 +14,7 @@ from ansible_sign.checksum import (
InvalidChecksumLine, InvalidChecksumLine,
) )
from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer from ansible_sign.checksum.differ import DistlibManifestChecksumFileExistenceDiffer
from ansible_sign.signing import * from ansible_sign.signing import GPGVerifier
display = Display() display = Display()

View File

@@ -360,7 +360,7 @@ REST_FRAMEWORK = {
# For swagger schema generation # For swagger schema generation
# see https://github.com/encode/django-rest-framework/pull/6532 # see https://github.com/encode/django-rest-framework/pull/6532
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema',
#'URL_FORMAT_OVERRIDE': None, # 'URL_FORMAT_OVERRIDE': None,
} }
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (

View File

@@ -101,5 +101,5 @@ except IOError:
# The below runs AFTER all of the custom settings are imported. # The below runs AFTER all of the custom settings are imported.
DATABASES.setdefault('default', dict()).setdefault('OPTIONS', dict()).setdefault( DATABASES.setdefault('default', dict()).setdefault('OPTIONS', dict()).setdefault(
'application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63] 'application_name', f'{CLUSTER_HOST_ID}-{os.getpid()}-{" ".join(sys.argv)}'[:63] # NOQA
) # noqa ) # noqa

View File

@@ -53,7 +53,7 @@ SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT = _(
'''\ '''\
Mapping to organization admins/users from social auth accounts. This setting Mapping to organization admins/users from social auth accounts. This setting
controls which users are placed into which organizations based on their controls which users are placed into which organizations based on their
username and email address. Configuration details are available in the username and email address. Configuration details are available in the
documentation.\ documentation.\
''' '''
) )

View File

@@ -6,7 +6,7 @@ _values_to_change = ['is_superuser_value', 'is_superuser_role', 'is_system_audit
def _get_setting(): def _get_setting():
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(f'SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR']) cursor.execute('SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
row = cursor.fetchone() row = cursor.fetchone()
if row == None: if row == None:
return {} return {}
@@ -24,7 +24,7 @@ def _get_setting():
def _set_setting(value): def _set_setting(value):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(f'UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR']) cursor.execute('UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR'])
def forwards(app, schema_editor): def forwards(app, schema_editor):

View File

@@ -163,9 +163,9 @@ class TestSAMLAttr:
'PersonImmutableID': [], 'PersonImmutableID': [],
}, },
}, },
#'social': <UserSocialAuth: cmeyers@redhat.com>, # 'social': <UserSocialAuth: cmeyers@redhat.com>,
'social': None, 'social': None,
#'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>, # 'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>,
'strategy': None, 'strategy': None,
'new_association': False, 'new_association': False,
} }

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { arrayOf, bool, number, shape, string } from 'prop-types';
import { Label, LabelGroup } from '@patternfly/react-core';
import { Link } from 'react-router-dom';
function InstanceGroupLabels({ labels, isLinkable }) {
const buildLinkURL = (isContainerGroup) =>
isContainerGroup
? '/instance_groups/container_group/'
: '/instance_groups/';
return (
<LabelGroup numLabels={5}>
{labels.map(({ id, name, is_container_group }) =>
isLinkable ? (
<Label
color="blue"
key={id}
render={({ className, content, componentRef }) => (
<Link
className={className}
innerRef={componentRef}
to={`${buildLinkURL(is_container_group)}${id}/details`}
>
{content}
</Link>
)}
>
{name}
</Label>
) : (
<Label color="blue" key={id}>
{name}
</Label>
)
)}
</LabelGroup>
);
}
InstanceGroupLabels.propTypes = {
labels: arrayOf(shape({ id: number.isRequired, name: string.isRequired }))
.isRequired,
isLinkable: bool,
};
InstanceGroupLabels.defaultProps = { isLinkable: false };
export default InstanceGroupLabels;

View File

@@ -0,0 +1 @@
export { default } from './InstanceGroupLabels';

View File

@@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { Chip, Divider, Title } from '@patternfly/react-core'; import { Chip, Divider, Title } from '@patternfly/react-core';
import { toTitleCase } from 'util/strings'; import { toTitleCase } from 'util/strings';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import CredentialChip from '../CredentialChip'; import CredentialChip from '../CredentialChip';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
import { DetailList, Detail, UserDateDetail } from '../DetailList'; import { DetailList, Detail, UserDateDetail } from '../DetailList';
@@ -227,21 +228,7 @@ function PromptDetail({
label={t`Instance Groups`} label={t`Instance Groups`}
rows={4} rows={4}
value={ value={
<ChipGroup <InstanceGroupLabels labels={overrides.instance_groups} />
numChips={5}
totalChips={overrides.instance_groups.length}
ouiaId="prompt-instance-groups-chips"
>
{overrides.instance_groups.map((instance_group) => (
<Chip
key={instance_group.id}
ouiaId={`instance-group-${instance_group.id}-chip`}
isReadOnly
>
{instance_group.name}
</Chip>
))}
</ChipGroup>
} }
/> />
)} )}

View File

@@ -10,6 +10,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api'; import { JobTemplatesAPI, SchedulesAPI, WorkflowJobTemplatesAPI } from 'api';
import { parseVariableField, jsonToYaml } from 'util/yaml'; import { parseVariableField, jsonToYaml } from 'util/yaml';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import parseRuleObj from '../shared/parseRuleObj'; import parseRuleObj from '../shared/parseRuleObj';
import FrequencyDetails from './FrequencyDetails'; import FrequencyDetails from './FrequencyDetails';
import AlertModal from '../../AlertModal'; import AlertModal from '../../AlertModal';
@@ -27,11 +28,6 @@ import { VariablesDetail } from '../../CodeEditor';
import { VERBOSITY } from '../../VerbositySelectField'; import { VERBOSITY } from '../../VerbositySelectField';
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext'; import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
const buildLinkURL = (instance) =>
instance.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
const PromptDivider = styled(Divider)` const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg); margin-top: var(--pf-global--spacer--lg);
margin-bottom: var(--pf-global--spacer--lg); margin-bottom: var(--pf-global--spacer--lg);
@@ -498,26 +494,7 @@ function ScheduleDetail({ hasDaysToKeepField, schedule, surveyConfig }) {
fullWidth fullWidth
label={t`Instance Groups`} label={t`Instance Groups`}
value={ value={
<ChipGroup <InstanceGroupLabels labels={instanceGroups} isLinkable />
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
key={ig.id}
>
<Chip
key={ig.id}
ouiaId={`instance-group-${ig.id}-chip`}
isReadOnly
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
} }
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { t, Plural } from '@lingui/macro'; import { t, Plural } from '@lingui/macro';
import { import {
Button, Button,
@@ -11,7 +11,6 @@ import {
CodeBlockCode, CodeBlockCode,
Tooltip, Tooltip,
Slider, Slider,
Label,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { DownloadIcon, OutlinedClockIcon } from '@patternfly/react-icons'; import { DownloadIcon, OutlinedClockIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -34,6 +33,7 @@ import useRequest, {
useDismissableError, useDismissableError,
} from 'hooks/useRequest'; } from 'hooks/useRequest';
import HealthCheckAlert from 'components/HealthCheckAlert'; import HealthCheckAlert from 'components/HealthCheckAlert';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import RemoveInstanceButton from '../Shared/RemoveInstanceButton'; import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
const Unavailable = styled.span` const Unavailable = styled.span`
@@ -156,11 +156,6 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
</> </>
); );
const buildLinkURL = (inst) =>
inst.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
const { error, dismissError } = useDismissableError( const { error, dismissError } = useDismissableError(
updateInstanceError || healthCheckError updateInstanceError || healthCheckError
); );
@@ -225,25 +220,9 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
label={t`Instance Groups`} label={t`Instance Groups`}
dataCy="instance-groups" dataCy="instance-groups"
helpText={t`The Instance Groups to which this instance belongs.`} helpText={t`The Instance Groups to which this instance belongs.`}
value={instanceGroups.map((ig) => ( value={
<React.Fragment key={ig.id}> <InstanceGroupLabels labels={instanceGroups} isLinkable />
<Label }
color="blue"
isTruncated
render={({ className, content, componentRef }) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
className={className}
innerRef={componentRef}
>
{content}
</Link>
)}
>
{ig.name}
</Label>{' '}
</React.Fragment>
))}
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />
)} )}

View File

@@ -23,6 +23,7 @@ import { InventoriesAPI } from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest'; import useRequest, { useDismissableError } from 'hooks/useRequest';
import { Inventory } from 'types'; import { Inventory } from 'types';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import getHelpText from '../shared/Inventory.helptext'; import getHelpText from '../shared/Inventory.helptext';
function InventoryDetail({ inventory }) { function InventoryDetail({ inventory }) {
@@ -105,23 +106,7 @@ function InventoryDetail({ inventory }) {
<Detail <Detail
fullWidth fullWidth
label={t`Instance Groups`} label={t`Instance Groups`}
value={ value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
<ChipGroup
numChips={5}
totalChips={instanceGroups?.length}
ouiaId="instance-group-chips"
>
{instanceGroups?.map((ig) => (
<Chip
key={ig.id}
isReadOnly
ouiaId={`instance-group-${ig.id}-chip`}
>
{ig.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />
)} )}

View File

@@ -131,9 +131,8 @@ describe('<InventoryDetail />', () => {
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith( expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
mockInventory.id mockInventory.id
); );
const chip = wrapper.find('Chip').at(0); const label = wrapper.find('Label').at(0);
expect(chip.prop('isReadOnly')).toEqual(true); expect(label.prop('children')).toEqual('Foo');
expect(chip.prop('children')).toEqual('Foo');
}); });
test('should not load instance groups', async () => { test('should not load instance groups', async () => {

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Chip, Label } from '@patternfly/react-core'; import { Button, Label } from '@patternfly/react-core';
import { Inventory } from 'types'; import { Inventory } from 'types';
import { InventoriesAPI, UnifiedJobsAPI } from 'api'; import { InventoriesAPI, UnifiedJobsAPI } from 'api';
@@ -10,7 +10,6 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal'; import AlertModal from 'components/AlertModal';
import { CardBody, CardActionsRow } from 'components/Card'; import { CardBody, CardActionsRow } from 'components/Card';
import ChipGroup from 'components/ChipGroup';
import { VariablesDetail } from 'components/CodeEditor'; import { VariablesDetail } from 'components/CodeEditor';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading'; import ContentLoading from 'components/ContentLoading';
@@ -18,6 +17,7 @@ import DeleteButton from 'components/DeleteButton';
import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
import ErrorDetail from 'components/ErrorDetail'; import ErrorDetail from 'components/ErrorDetail';
import Sparkline from 'components/Sparkline'; import Sparkline from 'components/Sparkline';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
function SmartInventoryDetail({ inventory }) { function SmartInventoryDetail({ inventory }) {
const history = useHistory(); const history = useHistory();
@@ -120,23 +120,7 @@ function SmartInventoryDetail({ inventory }) {
<Detail <Detail
fullWidth fullWidth
label={t`Instance groups`} label={t`Instance groups`}
value={ value={<InstanceGroupLabels labels={instanceGroups} />}
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Chip
key={ig.id}
isReadOnly
ouiaId={`instance-group-${ig.id}-chip`}
>
{ig.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />
<VariablesDetail <VariablesDetail

View File

@@ -40,6 +40,8 @@ const processCodeEditorValue = (value) => {
codeEditorValue = ''; codeEditorValue = '';
} else if (typeof value === 'string') { } else if (typeof value === 'string') {
codeEditorValue = encode(value); codeEditorValue = encode(value);
} else if (Array.isArray(value)) {
codeEditorValue = encode(value.join(' '));
} else { } else {
codeEditorValue = value; codeEditorValue = value;
} }
@@ -60,7 +62,7 @@ const getStdOutValue = (hostEvent) => {
) { ) {
stdOut = res.results.join('\n'); stdOut = res.results.join('\n');
} else if (res?.stdout) { } else if (res?.stdout) {
stdOut = res.stdout; stdOut = Array.isArray(res.stdout) ? res.stdout.join(' ') : res.stdout;
} }
return stdOut; return stdOut;
}; };

View File

@@ -52,6 +52,60 @@ const hostEvent = {
}, },
}; };
/*
Some libraries return a list of string in stdout
Example: https://github.com/ansible-collections/cisco.ios/blob/main/plugins/modules/ios_command.py#L124-L128
*/
const hostEventWithArray = {
changed: true,
event: 'runner_on_ok',
event_data: {
host: 'foo',
play: 'all',
playbook: 'run_command.yml',
res: {
ansible_loop_var: 'item',
changed: true,
item: '1',
msg: 'This is a debug message: 1',
stdout: [
' total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023',
],
stderr: 'problems',
cmd: ['free', '-m'],
stderr_lines: [],
stdout_lines: [
' total used free shared buff/cache available',
'Mem: 7973 3005 960 30 4007 4582',
'Swap: 1023 0 1023',
],
},
task: 'command',
task_action: 'command',
},
event_display: 'Host OK',
event_level: 3,
failed: false,
host: 1,
host_name: 'foo',
id: 123,
job: 4,
play: 'all',
playbook: 'run_command.yml',
stdout: `stdout: "changed: [localhost] => {"changed": true, "cmd": ["free", "-m"], "delta": "0:00:01.479609", "end": "2019-09-10 14:21:45.469533", "rc": 0, "start": "2019-09-10 14:21:43.989924", "stderr": "", "stderr_lines": [], "stdout": " total used free shared buff/cache available\nMem: 7973 3005 960 30 4007 4582\nSwap: 1023 0 1023", "stdout_lines": [" total used free shared buff/cache available", "Mem: 7973 3005 960 30 4007 4582", "Swap: 1023 0 1023"]}"
`,
task: 'command',
type: 'job_event',
url: '/api/v2/job_events/123/',
summary_fields: {
host: {
id: 1,
name: 'foo',
description: 'Bar',
},
},
};
/* eslint-disable no-useless-escape */ /* eslint-disable no-useless-escape */
const jsonValue = `{ const jsonValue = `{
\"ansible_loop_var\": \"item\", \"ansible_loop_var\": \"item\",
@@ -281,4 +335,25 @@ describe('HostEventModal', () => {
expect(codeEditor.prop('readOnly')).toBe(true); expect(codeEditor.prop('readOnly')).toBe(true);
expect(codeEditor.prop('value')).toEqual('baz\nbar'); expect(codeEditor.prop('value')).toEqual('baz\nbar');
}); });
test('should display Standard Out array stdout content', () => {
const wrapper = shallow(
<HostEventModal
hostEvent={hostEventWithArray}
onClose={() => {}}
isOpen
/>
);
const handleTabClick = wrapper.find('Tabs').prop('onSelect');
handleTabClick(null, 2);
wrapper.update();
const codeEditor = wrapper.find('Tab[eventKey=2] CodeEditor');
expect(codeEditor.prop('mode')).toBe('javascript');
expect(codeEditor.prop('readOnly')).toBe(true);
expect(codeEditor.prop('value')).toEqual(
hostEventWithArray.event_data.res.stdout.join(' ')
);
});
}); });

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { Link, useHistory, useRouteMatch } from 'react-router-dom'; import { Link, useHistory, useRouteMatch } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Button, Chip } from '@patternfly/react-core'; import { Button } from '@patternfly/react-core';
import { OrganizationsAPI } from 'api'; import { OrganizationsAPI } from 'api';
import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; import { DetailList, Detail, UserDateDetail } from 'components/DetailList';
import { CardBody, CardActionsRow } from 'components/Card'; import { CardBody, CardActionsRow } from 'components/Card';
@@ -16,6 +16,7 @@ import ErrorDetail from 'components/ErrorDetail';
import useRequest, { useDismissableError } from 'hooks/useRequest'; import useRequest, { useDismissableError } from 'hooks/useRequest';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
function OrganizationDetail({ organization }) { function OrganizationDetail({ organization }) {
@@ -79,11 +80,6 @@ function OrganizationDetail({ organization }) {
return <ContentError error={contentError} />; return <ContentError error={contentError} />;
} }
const buildLinkURL = (instance) =>
instance.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
return ( return (
<CardBody> <CardBody>
<DetailList> <DetailList>
@@ -126,25 +122,7 @@ function OrganizationDetail({ organization }) {
fullWidth fullWidth
label={t`Instance Groups`} label={t`Instance Groups`}
helpText={t`The Instance Groups for this Organization to run on.`} helpText={t`The Instance Groups for this Organization to run on.`}
value={ value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
<Chip
key={ig.id}
isReadOnly
ouiaId={`instance-group-${ig.id}-chip`}
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />
)} )}

View File

@@ -90,7 +90,7 @@ describe('<OrganizationDetail />', () => {
await waitForElement(component, 'ContentLoading', (el) => el.length === 0); await waitForElement(component, 'ContentLoading', (el) => el.length === 0);
expect( expect(
component component
.find('Chip') .find('Label')
.findWhere((el) => el.text() === 'One') .findWhere((el) => el.text() === 'One')
.exists() .exists()
).toBe(true); ).toBe(true);

View File

@@ -34,6 +34,7 @@ import useRequest, { useDismissableError } from 'hooks/useRequest';
import useBrandName from 'hooks/useBrandName'; import useBrandName from 'hooks/useBrandName';
import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail';
import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails';
import InstanceGroupLabels from 'components/InstanceGroupLabels';
import getHelpText from '../shared/JobTemplate.helptext'; import getHelpText from '../shared/JobTemplate.helptext';
function JobTemplateDetail({ template }) { function JobTemplateDetail({ template }) {
@@ -167,11 +168,6 @@ function JobTemplateDetail({ template }) {
); );
}; };
const buildLinkURL = (instance) =>
instance.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
if (instanceGroupsError) { if (instanceGroupsError) {
return <ContentError error={instanceGroupsError} />; return <ContentError error={instanceGroupsError} />;
} }
@@ -422,25 +418,7 @@ function JobTemplateDetail({ template }) {
label={t`Instance Groups`} label={t`Instance Groups`}
dataCy="jt-detail-instance-groups" dataCy="jt-detail-instance-groups"
helpText={helpText.instanceGroups} helpText={helpText.instanceGroups}
value={ value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
<Chip
key={ig.id}
ouiaId={`instance-group-${ig.id}-chip`}
isReadOnly
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0} isEmpty={instanceGroups.length === 0}
/> />
{job_tags && ( {job_tags && (

View File

@@ -172,7 +172,7 @@ options:
signature_validation_credential: signature_validation_credential:
description: description:
- Name of the credential to use for signature validation. - Name of the credential to use for signature validation.
- If signature validation credential is provided, signature validation will be enabled. - If signature validation credential is provided, signature validation will be enabled.
type: str type: str
extends_documentation_fragment: awx.awx.auth extends_documentation_fragment: awx.awx.auth

View File

@@ -514,7 +514,7 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
# Lookup Job Template ID # Lookup Job Template ID
if workflow_node['unified_job_template']['name']: if workflow_node['unified_job_template']['name']:
if workflow_node['unified_job_template']['type'] is None: if workflow_node['unified_job_template']['type'] is None:
module.fail_json(msg='Could not find unified job template type in workflow_nodes {1}'.format(workflow_node)) module.fail_json(msg='Could not find unified job template type in workflow_nodes {0}'.format(workflow_node))
search_fields['type'] = workflow_node['unified_job_template']['type'] search_fields['type'] = workflow_node['unified_job_template']['type']
if workflow_node['unified_job_template']['type'] == 'inventory_source': if workflow_node['unified_job_template']['type'] == 'inventory_source':
if 'inventory' in workflow_node['unified_job_template']: if 'inventory' in workflow_node['unified_job_template']:

View File

@@ -85,8 +85,7 @@ server {
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
location /static/ { location /static/ {
root /awx_devel; alias /var/lib/awx/public/static/;
try_files /awx/ui/$uri /awx/$uri /awx/public/$uri =404;
access_log off; access_log off;
sendfile off; sendfile off;
} }

View File

@@ -16,5 +16,5 @@ commands =
yamllint -s . yamllint -s .
[flake8] [flake8]
select = F401,F402,F821,F823,F841,F811 select = F401,F402,F821,F823,F841,F811,E265,E266,F541,W605,E722,F822,F523,W291,F405
exclude = awx/ui/node_modules,awx/ui/node_modules,env,awx_collection_build exclude = awx/ui/node_modules,awx/ui/node_modules,env,awx_collection_build