diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70e8b37b99..0953a16727 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
@@ -43,7 +43,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
@@ -91,7 +91,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
@@ -115,7 +115,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
@@ -139,7 +139,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
@@ -163,7 +163,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${{ env.BRANCH }} || :
- name: Build image
run: |
diff --git a/.github/workflows/upload_schema.yml b/.github/workflows/upload_schema.yml
index 4d90f96a66..3b73e8c956 100644
--- a/.github/workflows/upload_schema.yml
+++ b/.github/workflows/upload_schema.yml
@@ -19,7 +19,7 @@ jobs:
- name: Pre-pull image to warm build cache
run: |
- docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/}
+ docker pull ghcr.io/${{ github.repository_owner }}/awx_devel:${GITHUB_REF##*/} || :
- name: Build image
run: |
diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index c3e2a427f9..e8df13ab3e 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -5003,6 +5003,7 @@ class ActivityStreamSerializer(BaseSerializer):
('credential_type', ('id', 'name', 'description', 'kind', 'managed')),
('ad_hoc_command', ('id', 'name', 'status', 'limit')),
('workflow_approval', ('id', 'name', 'unified_job_id')),
+ ('instance', ('id', 'hostname')),
]
return field_list
diff --git a/awx/main/exceptions.py b/awx/main/exceptions.py
index 6a9bb7ece4..2cd9a44418 100644
--- a/awx/main/exceptions.py
+++ b/awx/main/exceptions.py
@@ -36,3 +36,7 @@ class PostRunError(Exception):
self.status = status
self.tb = tb
super(PostRunError, self).__init__(msg)
+
+
+class ReceptorNodeNotFound(RuntimeError):
+ pass
diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py
index 8f524c1f05..a3c6acdab3 100644
--- a/awx/main/management/commands/inventory_import.py
+++ b/awx/main/management/commands/inventory_import.py
@@ -76,7 +76,10 @@ class AnsibleInventoryLoader(object):
bargs.extend(['-v', '{0}:{0}:Z'.format(self.source)])
for key, value in STANDARD_INVENTORY_UPDATE_ENV.items():
bargs.extend(['-e', '{0}={1}'.format(key, value)])
- bargs.extend([get_default_execution_environment().image])
+ ee = get_default_execution_environment()
+
+ bargs.extend([ee.image])
+
bargs.extend(['ansible-inventory', '-i', self.source])
bargs.extend(['--playbook-dir', functioning_dir(self.source)])
if self.verbosity:
@@ -111,9 +114,7 @@ class AnsibleInventoryLoader(object):
def load(self):
base_args = self.get_base_args()
-
logger.info('Reading Ansible inventory source: %s', self.source)
-
return self.command_to_json(base_args)
@@ -138,7 +139,7 @@ class Command(BaseCommand):
type=str,
default=None,
metavar='v',
- help='host variable used to ' 'set/clear enabled flag when host is online/offline, may ' 'be specified as "foo.bar" to traverse nested dicts.',
+ help='host variable used to set/clear enabled flag when host is online/offline, may be specified as "foo.bar" to traverse nested dicts.',
)
parser.add_argument(
'--enabled-value',
@@ -146,7 +147,7 @@ class Command(BaseCommand):
type=str,
default=None,
metavar='v',
- help='value of host variable ' 'specified by --enabled-var that indicates host is ' 'enabled/online.',
+ help='value of host variable specified by --enabled-var that indicates host is enabled/online.',
)
parser.add_argument(
'--group-filter',
@@ -154,7 +155,7 @@ class Command(BaseCommand):
type=str,
default=None,
metavar='regex',
- help='regular expression ' 'to filter group name(s); only matches are imported.',
+ help='regular expression to filter group name(s); only matches are imported.',
)
parser.add_argument(
'--host-filter',
@@ -162,14 +163,14 @@ class Command(BaseCommand):
type=str,
default=None,
metavar='regex',
- help='regular expression ' 'to filter host name(s); only matches are imported.',
+ help='regular expression to filter host name(s); only matches are imported.',
)
parser.add_argument(
'--exclude-empty-groups',
dest='exclude_empty_groups',
action='store_true',
default=False,
- help='when set, ' 'exclude all groups that have no child groups, hosts, or ' 'variables.',
+ help='when set, exclude all groups that have no child groups, hosts, or variables.',
)
parser.add_argument(
'--instance-id-var',
@@ -177,7 +178,7 @@ class Command(BaseCommand):
type=str,
default=None,
metavar='v',
- help='host variable that ' 'specifies the unique, immutable instance ID, may be ' 'specified as "foo.bar" to traverse nested dicts.',
+ help='host variable that specifies the unique, immutable instance ID, may be specified as "foo.bar" to traverse nested dicts.',
)
def set_logging_level(self, verbosity):
@@ -1017,4 +1018,4 @@ class Command(BaseCommand):
if settings.SQL_DEBUG:
queries_this_import = connection.queries[queries_before:]
sqltime = sum(float(x['time']) for x in queries_this_import)
- logger.warning('Inventory import required %d queries ' 'taking %0.3fs', len(queries_this_import), sqltime)
+ logger.warning('Inventory import required %d queries taking %0.3fs', len(queries_this_import), sqltime)
diff --git a/awx/main/management/commands/list_instances.py b/awx/main/management/commands/list_instances.py
index 7568f0b45c..bef9034774 100644
--- a/awx/main/management/commands/list_instances.py
+++ b/awx/main/management/commands/list_instances.py
@@ -47,7 +47,7 @@ class Command(BaseCommand):
color = '\033[90m[DISABLED] '
if no_color:
color = ''
- fmt = '\t' + color + '{0.hostname} capacity={0.capacity} version={1}'
+ fmt = '\t' + color + '{0.hostname} capacity={0.capacity} node_type={0.node_type} version={1}'
if x.capacity:
fmt += ' heartbeat="{0.modified:%Y-%m-%d %H:%M:%S}"'
print((fmt + '\033[0m').format(x, x.version or '?'))
diff --git a/awx/main/management/commands/register_queue.py b/awx/main/management/commands/register_queue.py
index 9c05020545..2fa931c88b 100644
--- a/awx/main/management/commands/register_queue.py
+++ b/awx/main/management/commands/register_queue.py
@@ -36,7 +36,7 @@ class RegisterQueue:
ig.policy_instance_minimum = self.instance_min
changed = True
- if self.is_container_group:
+ if self.is_container_group and (ig.is_container_group != self.is_container_group):
ig.is_container_group = self.is_container_group
changed = True
diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py
index 0fab2cd4f6..f439a692fb 100644
--- a/awx/main/models/__init__.py
+++ b/awx/main/models/__init__.py
@@ -201,6 +201,8 @@ activity_stream_registrar.connect(Organization)
activity_stream_registrar.connect(Inventory)
activity_stream_registrar.connect(Host)
activity_stream_registrar.connect(Group)
+activity_stream_registrar.connect(Instance)
+activity_stream_registrar.connect(InstanceGroup)
activity_stream_registrar.connect(InventorySource)
# activity_stream_registrar.connect(InventoryUpdate)
activity_stream_registrar.connect(Credential)
diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py
index 1e9c6d983a..ea9f7d8d0e 100644
--- a/awx/main/models/ha.py
+++ b/awx/main/models/ha.py
@@ -162,7 +162,9 @@ class Instance(HasPolicyEditsMixin, BaseModel):
returns a dict that is passed to the python interface for the runner method corresponding to that command
any kwargs will override that key=value combination in the returned dict
"""
- vargs = dict(file_pattern='/tmp/{}*'.format(JOB_FOLDER_PREFIX % '*'))
+ vargs = dict()
+ if settings.AWX_CLEANUP_PATHS:
+ vargs['file_pattern'] = '/tmp/{}*'.format(JOB_FOLDER_PREFIX % '*')
vargs.update(kwargs)
if 'exclude_strings' not in vargs and vargs.get('file_pattern'):
active_pks = list(UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting')).values_list('pk', flat=True))
diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py
index 2cb2fd28af..671daf104d 100644
--- a/awx/main/models/unified_jobs.py
+++ b/awx/main/models/unified_jobs.py
@@ -1497,7 +1497,12 @@ class UnifiedJob(
return False
def log_lifecycle(self, state, blocked_by=None):
- extra = {'type': self._meta.model_name, 'task_id': self.id, 'state': state}
+ extra = {
+ 'type': self._meta.model_name,
+ 'task_id': self.id,
+ 'state': state,
+ 'work_unit_id': self.work_unit_id,
+ }
if self.unified_job_template:
extra["template_name"] = self.unified_job_template.name
if state == "blocked" and blocked_by:
@@ -1506,6 +1511,11 @@ class UnifiedJob(
extra["blocked_by"] = blocked_by_msg
else:
msg = f"{self._meta.model_name}-{self.id} {state.replace('_', ' ')}"
+
+ if state == "controller_node_chosen":
+ extra["controller_node"] = self.controller_node or "NOT_SET"
+ elif state == "execution_node_chosen":
+ extra["execution_node"] = self.execution_node or "NOT_SET"
logger_job_lifecycle.debug(msg, extra=extra)
@property
diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py
index 2944562723..ff48c5267c 100644
--- a/awx/main/scheduler/task_manager.py
+++ b/awx/main/scheduler/task_manager.py
@@ -291,6 +291,7 @@ class TaskManager:
# act as the controller for k8s API interaction
try:
task.controller_node = Instance.choose_online_control_plane_node()
+ task.log_lifecycle("controller_node_chosen")
except IndexError:
logger.warning("No control plane nodes available to run containerized job {}".format(task.log_format))
return
@@ -298,19 +299,23 @@ class TaskManager:
# project updates and system jobs don't *actually* run in pods, so
# just pick *any* non-containerized host and use it as the execution node
task.execution_node = Instance.choose_online_control_plane_node()
+ task.log_lifecycle("execution_node_chosen")
logger.debug('Submitting containerized {} to queue {}.'.format(task.log_format, task.execution_node))
else:
task.instance_group = rampart_group
task.execution_node = instance.hostname
+ task.log_lifecycle("execution_node_chosen")
if instance.node_type == 'execution':
try:
task.controller_node = Instance.choose_online_control_plane_node()
+ task.log_lifecycle("controller_node_chosen")
except IndexError:
logger.warning("No control plane nodes available to manage {}".format(task.log_format))
return
else:
# control plane nodes will manage jobs locally for performance and resilience
task.controller_node = task.execution_node
+ task.log_lifecycle("controller_node_chosen")
logger.debug('Submitting job {} to queue {} controlled by {}.'.format(task.log_format, task.execution_node, task.controller_node))
with disable_activity_stream():
task.celery_task_id = str(uuid.uuid4())
diff --git a/awx/main/signals.py b/awx/main/signals.py
index 5caf7b45a8..8dde65342d 100644
--- a/awx/main/signals.py
+++ b/awx/main/signals.py
@@ -34,7 +34,6 @@ from awx.main.models import (
ExecutionEnvironment,
Group,
Host,
- InstanceGroup,
Inventory,
InventorySource,
Job,
@@ -377,6 +376,7 @@ def model_serializer_mapping():
models.Inventory: serializers.InventorySerializer,
models.Host: serializers.HostSerializer,
models.Group: serializers.GroupSerializer,
+ models.Instance: serializers.InstanceSerializer,
models.InstanceGroup: serializers.InstanceGroupSerializer,
models.InventorySource: serializers.InventorySourceSerializer,
models.Credential: serializers.CredentialSerializer,
@@ -675,9 +675,3 @@ def create_access_token_user_if_missing(sender, **kwargs):
post_save.disconnect(create_access_token_user_if_missing, sender=OAuth2AccessToken)
obj.save()
post_save.connect(create_access_token_user_if_missing, sender=OAuth2AccessToken)
-
-
-# Connect the Instance Group to Activity Stream receivers.
-post_save.connect(activity_stream_create, sender=InstanceGroup, dispatch_uid=str(InstanceGroup) + "_create")
-pre_save.connect(activity_stream_update, sender=InstanceGroup, dispatch_uid=str(InstanceGroup) + "_update")
-pre_delete.connect(activity_stream_delete, sender=InstanceGroup, dispatch_uid=str(InstanceGroup) + "_delete")
diff --git a/awx/main/tasks.py b/awx/main/tasks.py
index 42fb01d253..93ab6ccd81 100644
--- a/awx/main/tasks.py
+++ b/awx/main/tasks.py
@@ -85,7 +85,7 @@ from awx.main.models import (
build_safe_env,
)
from awx.main.constants import ACTIVE_STATES
-from awx.main.exceptions import AwxTaskError, PostRunError
+from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound
from awx.main.queue import CallbackQueueDispatcher
from awx.main.dispatch.publish import task
from awx.main.dispatch import get_local_queuename, reaper
@@ -108,7 +108,7 @@ from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
from awx.main.utils.reload import stop_local_services
from awx.main.utils.pglock import advisory_lock
from awx.main.utils.handlers import SpecialInventoryHandler
-from awx.main.utils.receptor import get_receptor_ctl, worker_info, get_conn_type, get_tls_client, worker_cleanup
+from awx.main.utils.receptor import get_receptor_ctl, worker_info, get_conn_type, get_tls_client, worker_cleanup, administrative_workunit_reaper
from awx.main.consumers import emit_channel_notification
from awx.main import analytics
from awx.conf import settings_registry
@@ -191,6 +191,8 @@ def inform_cluster_of_shutdown():
@task(queue=get_local_queuename)
def apply_cluster_membership_policies():
+ from awx.main.signals import disable_activity_stream
+
started_waiting = time.time()
with advisory_lock('cluster_policy_lock', wait=True):
lock_time = time.time() - started_waiting
@@ -282,18 +284,19 @@ def apply_cluster_membership_policies():
# On a differential basis, apply instances to groups
with transaction.atomic():
- for g in actual_groups:
- if g.obj.is_container_group:
- logger.debug('Skipping containerized group {} for policy calculation'.format(g.obj.name))
- continue
- instances_to_add = set(g.instances) - set(g.prior_instances)
- instances_to_remove = set(g.prior_instances) - set(g.instances)
- if instances_to_add:
- logger.debug('Adding instances {} to group {}'.format(list(instances_to_add), g.obj.name))
- g.obj.instances.add(*instances_to_add)
- if instances_to_remove:
- logger.debug('Removing instances {} from group {}'.format(list(instances_to_remove), g.obj.name))
- g.obj.instances.remove(*instances_to_remove)
+ with disable_activity_stream():
+ for g in actual_groups:
+ if g.obj.is_container_group:
+ logger.debug('Skipping containerized group {} for policy calculation'.format(g.obj.name))
+ continue
+ instances_to_add = set(g.instances) - set(g.prior_instances)
+ instances_to_remove = set(g.prior_instances) - set(g.instances)
+ if instances_to_add:
+ logger.debug('Adding instances {} to group {}'.format(list(instances_to_add), g.obj.name))
+ g.obj.instances.add(*instances_to_add)
+ if instances_to_remove:
+ logger.debug('Removing instances {} from group {}'.format(list(instances_to_remove), g.obj.name))
+ g.obj.instances.remove(*instances_to_remove)
logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
@@ -397,20 +400,22 @@ def _cleanup_images_and_files(**kwargs):
return
this_inst = Instance.objects.me()
runner_cleanup_kwargs = this_inst.get_cleanup_task_kwargs(**kwargs)
- stdout = ''
- with StringIO() as buffer:
- with redirect_stdout(buffer):
- ansible_runner.cleanup.run_cleanup(runner_cleanup_kwargs)
- stdout = buffer.getvalue()
- if '(changed: True)' in stdout:
- logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}')
+ if runner_cleanup_kwargs:
+ stdout = ''
+ with StringIO() as buffer:
+ with redirect_stdout(buffer):
+ ansible_runner.cleanup.run_cleanup(runner_cleanup_kwargs)
+ stdout = buffer.getvalue()
+ if '(changed: True)' in stdout:
+ logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}')
# if we are the first instance alphabetically, then run cleanup on execution nodes
checker_instance = Instance.objects.filter(node_type__in=['hybrid', 'control'], enabled=True, capacity__gt=0).order_by('-hostname').first()
if checker_instance and this_inst.hostname == checker_instance.hostname:
- logger.info(f'Running execution node cleanup with kwargs {kwargs}')
for inst in Instance.objects.filter(node_type='execution', enabled=True, capacity__gt=0):
runner_cleanup_kwargs = inst.get_cleanup_task_kwargs(**kwargs)
+ if not runner_cleanup_kwargs:
+ continue
try:
stdout = worker_cleanup(inst.hostname, runner_cleanup_kwargs)
if '(changed: True)' in stdout:
@@ -532,7 +537,7 @@ def inspect_execution_nodes(instance_list):
# check
logger.warn(f'Execution node attempting to rejoin as instance {hostname}.')
execution_node_health_check.apply_async([hostname])
- elif instance.capacity == 0:
+ elif instance.capacity == 0 and instance.enabled:
# nodes with proven connection but need remediation run health checks are reduced frequency
if not instance.last_health_check or (nowtime - instance.last_health_check).total_seconds() >= settings.EXECUTION_NODE_REMEDIATION_CHECKS:
# Periodically re-run the health check of errored nodes, in case someone fixed it
@@ -649,6 +654,8 @@ def awx_receptor_workunit_reaper():
receptor_ctl.simple_command(f"work cancel {job.work_unit_id}")
receptor_ctl.simple_command(f"work release {job.work_unit_id}")
+ administrative_workunit_reaper(receptor_work_list)
+
@task(queue=get_local_queuename)
def awx_k8s_reaper():
@@ -1542,6 +1549,8 @@ class BaseTask(object):
# ensure failure notification sends even if playbook_on_stats event is not triggered
handle_success_and_failure_notifications.apply_async([self.instance.job.id])
+ except ReceptorNodeNotFound as exc:
+ extra_update_fields['job_explanation'] = str(exc)
except Exception:
# this could catch programming or file system errors
extra_update_fields['result_traceback'] = traceback.format_exc()
@@ -1905,6 +1914,7 @@ class RunJob(BaseTask):
status='running',
instance_group=pu_ig,
execution_node=pu_en,
+ controller_node=pu_en,
celery_task_id=job.celery_task_id,
)
if branch_override:
@@ -1913,6 +1923,8 @@ class RunJob(BaseTask):
if 'update_' not in sync_metafields['job_tags']:
sync_metafields['scm_revision'] = job_revision
local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields)
+ local_project_sync.log_lifecycle("controller_node_chosen")
+ local_project_sync.log_lifecycle("execution_node_chosen")
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
# save the associated job before calling run() so that a
# cancel() call on the job can cancel the project update
@@ -2205,10 +2217,13 @@ class RunProjectUpdate(BaseTask):
status='running',
instance_group=instance_group,
execution_node=project_update.execution_node,
+ controller_node=project_update.execution_node,
source_project_update=project_update,
celery_task_id=project_update.celery_task_id,
)
)
+ local_inv_update.log_lifecycle("controller_node_chosen")
+ local_inv_update.log_lifecycle("execution_node_chosen")
try:
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
inv_update_class().run(local_inv_update.id)
@@ -2656,10 +2671,13 @@ class RunInventoryUpdate(BaseTask):
job_tags=','.join(sync_needs),
status='running',
execution_node=Instance.objects.me().hostname,
+ controller_node=Instance.objects.me().hostname,
instance_group=inventory_update.instance_group,
celery_task_id=inventory_update.celery_task_id,
)
)
+ local_project_sync.log_lifecycle("controller_node_chosen")
+ local_project_sync.log_lifecycle("execution_node_chosen")
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
# associate the inventory update before calling run() so that a
# cancel() call on the inventory update can cancel the project update
@@ -3062,10 +3080,10 @@ class AWXReceptorJob:
finally:
# Make sure to always release the work unit if we established it
if self.unit_id is not None and settings.RECEPTOR_RELEASE_WORK:
- receptor_ctl.simple_command(f"work release {self.unit_id}")
- # If an error occured without the job itself failing, it could be a broken instance
- if self.work_type == 'ansible-runner' and ((res is None) or (getattr(res, 'rc', None) is None)):
- execution_node_health_check(self.task.instance.execution_node)
+ try:
+ receptor_ctl.simple_command(f"work release {self.unit_id}")
+ except Exception:
+ logger.exception(f"Error releasing work unit {self.unit_id}.")
@property
def sign_work(self):
@@ -3089,7 +3107,21 @@ class AWXReceptorJob:
_kw['tlsclient'] = get_tls_client(use_stream_tls)
result = receptor_ctl.submit_work(worktype=self.work_type, payload=sockout.makefile('rb'), params=self.receptor_params, signwork=self.sign_work, **_kw)
self.unit_id = result['unitid']
+ # Update the job with the work unit in-memory so that the log_lifecycle
+ # will print out the work unit that is to be associated with the job in the database
+ # via the update_model() call.
+ # We want to log the work_unit_id as early as possible. A failure can happen in between
+ # when we start the job in receptor and when we associate the job <-> work_unit_id.
+ # In that case, there will be work running in receptor and Controller will not know
+ # which Job it is associated with.
+ # We do not programatically handle this case. Ideally, we would handle this with a reaper case.
+ # The two distinct job lifecycle log events below allow for us to at least detect when this
+ # edge case occurs. If the lifecycle event work_unit_id_received occurs without the
+ # work_unit_id_assigned event then this case may have occured.
+ self.task.instance.work_unit_id = result['unitid'] # Set work_unit_id in-memory only
+ self.task.instance.log_lifecycle("work_unit_id_received")
self.task.update_model(self.task.instance.pk, work_unit_id=result['unitid'])
+ self.task.instance.log_lifecycle("work_unit_id_assigned")
sockin.close()
sockout.close()
@@ -3118,9 +3150,14 @@ class AWXReceptorJob:
resultsock.shutdown(socket.SHUT_RDWR)
resultfile.close()
elif res.status == 'error':
- unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
- detail = unit_status['Detail']
- state_name = unit_status['StateName']
+ try:
+ unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
+ detail = unit_status.get('Detail', None)
+ state_name = unit_status.get('StateName', None)
+ except Exception:
+ detail = ''
+ state_name = ''
+ logger.exception(f'An error was encountered while getting status for work unit {self.unit_id}')
if 'exceeded quota' in detail:
logger.warn(detail)
@@ -3137,11 +3174,19 @@ class AWXReceptorJob:
try:
resultsock = receptor_ctl.get_work_results(self.unit_id, return_sockfile=True)
lines = resultsock.readlines()
- self.task.instance.result_traceback = b"".join(lines).decode()
- self.task.instance.save(update_fields=['result_traceback'])
+ receptor_output = b"".join(lines).decode()
+ if receptor_output:
+ self.task.instance.result_traceback = receptor_output
+ self.task.instance.save(update_fields=['result_traceback'])
+ elif detail:
+ self.task.instance.result_traceback = detail
+ self.task.instance.save(update_fields=['result_traceback'])
+ else:
+ logger.warn(f'No result details or output from {self.task.instance.log_format}, status:\n{unit_status}')
except Exception:
raise RuntimeError(detail)
+ time.sleep(3)
return res
# Spawned in a thread so Receptor can start reading before we finish writing, we
@@ -3184,7 +3229,7 @@ class AWXReceptorJob:
receptor_params["secret_kube_config"] = kubeconfig_yaml
else:
private_data_dir = self.runner_params['private_data_dir']
- if self.work_type == 'ansible-runner':
+ if self.work_type == 'ansible-runner' and settings.AWX_CLEANUP_PATHS:
# on execution nodes, we rely on the private data dir being deleted
cli_params = f"--private-data-dir={private_data_dir} --delete"
else:
diff --git a/awx/main/tests/functional/api/test_instance.py b/awx/main/tests/functional/api/test_instance.py
index b94b860b01..c65cea0c01 100644
--- a/awx/main/tests/functional/api/test_instance.py
+++ b/awx/main/tests/functional/api/test_instance.py
@@ -3,6 +3,7 @@ import pytest
from unittest import mock
from awx.api.versioning import reverse
+from awx.main.models.activity_stream import ActivityStream
from awx.main.models.ha import Instance
import redis
@@ -17,6 +18,7 @@ INSTANCE_KWARGS = dict(hostname='example-host', cpu=6, memory=36000000000, cpu_c
@pytest.mark.django_db
def test_disabled_zeros_capacity(patch, admin_user):
instance = Instance.objects.create(**INSTANCE_KWARGS)
+ assert ActivityStream.objects.filter(instance=instance).count() == 1
url = reverse('api:instance_detail', kwargs={'pk': instance.pk})
@@ -25,12 +27,14 @@ def test_disabled_zeros_capacity(patch, admin_user):
instance.refresh_from_db()
assert instance.capacity == 0
+ assert ActivityStream.objects.filter(instance=instance).count() == 2
@pytest.mark.django_db
def test_enabled_sets_capacity(patch, admin_user):
instance = Instance.objects.create(enabled=False, capacity=0, **INSTANCE_KWARGS)
assert instance.capacity == 0
+ assert ActivityStream.objects.filter(instance=instance).count() == 1
url = reverse('api:instance_detail', kwargs={'pk': instance.pk})
@@ -39,6 +43,7 @@ def test_enabled_sets_capacity(patch, admin_user):
instance.refresh_from_db()
assert instance.capacity > 0
+ assert ActivityStream.objects.filter(instance=instance).count() == 2
@pytest.mark.django_db
@@ -50,6 +55,20 @@ def test_auditor_user_health_check(get, post, system_auditor):
post(url=url, user=system_auditor, expect=403)
+@pytest.mark.django_db
+def test_health_check_throws_error(post, admin_user):
+ instance = Instance.objects.create(node_type='execution', **INSTANCE_KWARGS)
+ url = reverse('api:instance_health_check', kwargs={'pk': instance.pk})
+ # we will simulate a receptor error, similar to this one
+ # https://github.com/ansible/receptor/blob/156e6e24a49fbf868734507f9943ac96208ed8f5/receptorctl/receptorctl/socket_interface.py#L204
+ # related to issue https://github.com/ansible/tower/issues/5315
+ with mock.patch('awx.main.utils.receptor.run_until_complete', side_effect=RuntimeError('Remote error: foobar')):
+ post(url=url, user=admin_user, expect=200)
+ instance.refresh_from_db()
+ assert 'Remote error: foobar' in instance.errors
+ assert instance.capacity == 0
+
+
@pytest.mark.django_db
@mock.patch.object(redis.client.Redis, 'ping', lambda self: True)
def test_health_check_usage(get, post, admin_user):
diff --git a/awx/main/tests/functional/api/test_instance_group.py b/awx/main/tests/functional/api/test_instance_group.py
index 5a787b6607..97b8abcff2 100644
--- a/awx/main/tests/functional/api/test_instance_group.py
+++ b/awx/main/tests/functional/api/test_instance_group.py
@@ -4,6 +4,7 @@ import pytest
from awx.api.versioning import reverse
from awx.main.models import (
+ ActivityStream,
Instance,
InstanceGroup,
ProjectUpdate,
@@ -213,9 +214,23 @@ def test_containerized_group_default_fields(instance_group, kube_credential):
def test_instance_attach_to_instance_group(post, instance_group, node_type_instance, admin, node_type):
instance = node_type_instance(hostname=node_type, node_type=node_type)
+ count = ActivityStream.objects.count()
+
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
post(url, {'associate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
+ new_activity = ActivityStream.objects.all()[count:]
+ if node_type != 'control':
+ assert len(new_activity) == 2 # the second is an update of the instance group policy
+ new_activity = new_activity[0]
+ assert new_activity.operation == 'associate'
+ assert new_activity.object1 == 'instance_group'
+ assert new_activity.object2 == 'instance'
+ assert new_activity.instance.first() == instance
+ assert new_activity.instance_group.first() == instance_group
+ else:
+ assert not new_activity
+
@pytest.mark.django_db
@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution'])
@@ -223,18 +238,46 @@ def test_instance_unattach_from_instance_group(post, instance_group, node_type_i
instance = node_type_instance(hostname=node_type, node_type=node_type)
instance_group.instances.add(instance)
+ count = ActivityStream.objects.count()
+
url = reverse(f'api:instance_group_instance_list', kwargs={'pk': instance_group.pk})
post(url, {'disassociate': True, 'id': instance.id}, admin, expect=204 if node_type != 'control' else 400)
+ new_activity = ActivityStream.objects.all()[count:]
+ if node_type != 'control':
+ assert len(new_activity) == 1
+ new_activity = new_activity[0]
+ assert new_activity.operation == 'disassociate'
+ assert new_activity.object1 == 'instance_group'
+ assert new_activity.object2 == 'instance'
+ assert new_activity.instance.first() == instance
+ assert new_activity.instance_group.first() == instance_group
+ else:
+ assert not new_activity
+
@pytest.mark.django_db
@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution'])
def test_instance_group_attach_to_instance(post, instance_group, node_type_instance, admin, node_type):
instance = node_type_instance(hostname=node_type, node_type=node_type)
+ count = ActivityStream.objects.count()
+
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
post(url, {'associate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
+ new_activity = ActivityStream.objects.all()[count:]
+ if node_type != 'control':
+ assert len(new_activity) == 2 # the second is an update of the instance group policy
+ new_activity = new_activity[0]
+ assert new_activity.operation == 'associate'
+ assert new_activity.object1 == 'instance'
+ assert new_activity.object2 == 'instance_group'
+ assert new_activity.instance.first() == instance
+ assert new_activity.instance_group.first() == instance_group
+ else:
+ assert not new_activity
+
@pytest.mark.django_db
@pytest.mark.parametrize('node_type', ['control', 'hybrid', 'execution'])
@@ -242,5 +285,19 @@ def test_instance_group_unattach_from_instance(post, instance_group, node_type_i
instance = node_type_instance(hostname=node_type, node_type=node_type)
instance_group.instances.add(instance)
+ count = ActivityStream.objects.count()
+
url = reverse(f'api:instance_instance_groups_list', kwargs={'pk': instance.pk})
post(url, {'disassociate': True, 'id': instance_group.id}, admin, expect=204 if node_type != 'control' else 400)
+
+ new_activity = ActivityStream.objects.all()[count:]
+ if node_type != 'control':
+ assert len(new_activity) == 1
+ new_activity = new_activity[0]
+ assert new_activity.operation == 'disassociate'
+ assert new_activity.object1 == 'instance'
+ assert new_activity.object2 == 'instance_group'
+ assert new_activity.instance.first() == instance
+ assert new_activity.instance_group.first() == instance_group
+ else:
+ assert not new_activity
diff --git a/awx/main/tests/functional/commands/test_register_queue.py b/awx/main/tests/functional/commands/test_register_queue.py
new file mode 100644
index 0000000000..aaa9910911
--- /dev/null
+++ b/awx/main/tests/functional/commands/test_register_queue.py
@@ -0,0 +1,26 @@
+from io import StringIO
+from contextlib import redirect_stdout
+
+import pytest
+
+from awx.main.management.commands.register_queue import RegisterQueue
+from awx.main.models.ha import InstanceGroup
+
+
+@pytest.mark.django_db
+def test_openshift_idempotence():
+ def perform_register():
+ with StringIO() as buffer:
+ with redirect_stdout(buffer):
+ RegisterQueue('default', 100, 0, [], is_container_group=True).register()
+ return buffer.getvalue()
+
+ assert '(changed: True)' in perform_register()
+ assert '(changed: True)' not in perform_register()
+ assert '(changed: True)' not in perform_register()
+
+ ig = InstanceGroup.objects.get(name='default')
+ assert ig.policy_instance_percentage == 100
+ assert ig.policy_instance_minimum == 0
+ assert ig.policy_instance_list == []
+ assert ig.is_container_group is True
diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py
index bc6c3e8c51..f8ae40b540 100644
--- a/awx/main/tests/functional/models/test_activity_stream.py
+++ b/awx/main/tests/functional/models/test_activity_stream.py
@@ -170,7 +170,7 @@ def test_activity_stream_actor(admin_user):
@pytest.mark.django_db
-def test_annon_user_action():
+def test_anon_user_action():
with mock.patch('awx.main.signals.get_current_user') as u_mock:
u_mock.return_value = AnonymousUser()
inv = Inventory.objects.create(name='ainventory')
diff --git a/awx/main/tests/functional/test_instances.py b/awx/main/tests/functional/test_instances.py
index a0a06b4ae5..21a17ff2b5 100644
--- a/awx/main/tests/functional/test_instances.py
+++ b/awx/main/tests/functional/test_instances.py
@@ -2,6 +2,7 @@ import pytest
from unittest import mock
from awx.main.models import AdHocCommand, InventoryUpdate, JobTemplate, ProjectUpdate
+from awx.main.models.activity_stream import ActivityStream
from awx.main.models.ha import Instance, InstanceGroup
from awx.main.tasks import apply_cluster_membership_policies
from awx.api.versioning import reverse
@@ -72,6 +73,7 @@ def test_instance_dup(org_admin, organization, project, instance_factory, instan
i1 = instance_factory("i1")
i2 = instance_factory("i2")
i3 = instance_factory("i3")
+
ig_all = instance_group_factory("all", instances=[i1, i2, i3])
ig_dup = instance_group_factory("duplicates", instances=[i1])
project.organization.instance_groups.add(ig_all, ig_dup)
@@ -83,7 +85,7 @@ def test_instance_dup(org_admin, organization, project, instance_factory, instan
api_num_instances_oa = list(list_response2.data.items())[0][1]
assert actual_num_instances == api_num_instances_auditor
- # Note: The org_admin will not see the default 'tower' node (instance fixture) because it is not in it's group, as expected
+ # Note: The org_admin will not see the default 'tower' node (instance fixture) because it is not in its group, as expected
assert api_num_instances_oa == (actual_num_instances - 1)
@@ -94,7 +96,13 @@ def test_policy_instance_few_instances(instance_factory, instance_group_factory)
ig_2 = instance_group_factory("ig2", percentage=25)
ig_3 = instance_group_factory("ig3", percentage=25)
ig_4 = instance_group_factory("ig4", percentage=25)
+
+ count = ActivityStream.objects.count()
+
apply_cluster_membership_policies()
+ # running apply_cluster_membership_policies shouldn't spam the activity stream
+ assert ActivityStream.objects.count() == count
+
assert len(ig_1.instances.all()) == 1
assert i1 in ig_1.instances.all()
assert len(ig_2.instances.all()) == 1
@@ -103,8 +111,12 @@ def test_policy_instance_few_instances(instance_factory, instance_group_factory)
assert i1 in ig_3.instances.all()
assert len(ig_4.instances.all()) == 1
assert i1 in ig_4.instances.all()
+
i2 = instance_factory("i2")
+ count += 1
apply_cluster_membership_policies()
+ assert ActivityStream.objects.count() == count
+
assert len(ig_1.instances.all()) == 1
assert i1 in ig_1.instances.all()
assert len(ig_2.instances.all()) == 1
diff --git a/awx/main/utils/receptor.py b/awx/main/utils/receptor.py
index b92b57c46a..e1961ca905 100644
--- a/awx/main/utils/receptor.py
+++ b/awx/main/utils/receptor.py
@@ -1,16 +1,21 @@
import logging
import yaml
import time
+from enum import Enum, unique
from receptorctl.socket_interface import ReceptorControl
+
+from awx.main.exceptions import ReceptorNodeNotFound
+
from django.conf import settings
-from enum import Enum, unique
logger = logging.getLogger('awx.main.utils.receptor')
__RECEPTOR_CONF = '/etc/receptor/receptor.conf'
+RECEPTOR_ACTIVE_STATES = ('Pending', 'Running')
+
@unique
class ReceptorConnectionType(Enum):
@@ -60,6 +65,35 @@ def get_conn_type(node_name, receptor_ctl):
for node in all_nodes:
if node.get('NodeID') == node_name:
return ReceptorConnectionType(node.get('ConnType'))
+ raise ReceptorNodeNotFound(f'Instance {node_name} is not in the receptor mesh')
+
+
+def administrative_workunit_reaper(work_list=None):
+ """
+ This releases completed work units that were spawned by actions inside of this module
+ specifically, this should catch any completed work unit left by
+ - worker_info
+ - worker_cleanup
+ These should ordinarily be released when the method finishes, but this is a
+ cleanup of last-resort, in case something went awry
+ """
+ receptor_ctl = get_receptor_ctl()
+ if work_list is None:
+ work_list = receptor_ctl.simple_command("work list")
+
+ for unit_id, work_data in work_list.items():
+ extra_data = work_data.get('ExtraData')
+ if (extra_data is None) or (extra_data.get('RemoteWorkType') != 'ansible-runner'):
+ continue # if this is not ansible-runner work, we do not want to touch it
+ params = extra_data.get('RemoteParams', {}).get('params')
+ if not params:
+ continue
+ if not (params == '--worker-info' or params.startswith('cleanup')):
+ continue # if this is not a cleanup or health check, we do not want to touch it
+ if work_data.get('StateName') in RECEPTOR_ACTIVE_STATES:
+ continue # do not want to touch active work units
+ logger.info(f'Reaping orphaned work unit {unit_id} with params {params}')
+ receptor_ctl.simple_command(f"work release {unit_id}")
class RemoteJobError(RuntimeError):
@@ -95,7 +129,7 @@ def run_until_complete(node, timing_data=None, **kwargs):
while run_timing < 20.0:
status = receptor_ctl.simple_command(f'work status {unit_id}')
state_name = status.get('StateName')
- if state_name not in ('Pending', 'Running'):
+ if state_name not in RECEPTOR_ACTIVE_STATES:
break
run_timing = time.time() - run_start
time.sleep(0.5)
@@ -110,9 +144,10 @@ def run_until_complete(node, timing_data=None, **kwargs):
finally:
- res = receptor_ctl.simple_command(f"work release {unit_id}")
- if res != {'released': unit_id}:
- logger.warn(f'Could not confirm release of receptor work unit id {unit_id} from {node}, data: {res}')
+ if settings.RECEPTOR_RELEASE_WORK:
+ res = receptor_ctl.simple_command(f"work release {unit_id}")
+ if res != {'released': unit_id}:
+ logger.warn(f'Could not confirm release of receptor work unit id {unit_id} from {node}, data: {res}')
receptor_ctl.close()
@@ -153,6 +188,9 @@ def worker_info(node_name, work_type='ansible-runner'):
else:
error_list.append(details)
+ except (ReceptorNodeNotFound, RuntimeError) as exc:
+ error_list.append(str(exc))
+
# If we have a connection error, missing keys would be trivial consequence of that
if not data['errors']:
# see tasks.py usage of keys
diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py
index 3f949abeca..0b5bd0b4b6 100644
--- a/awx/settings/defaults.py
+++ b/awx/settings/defaults.py
@@ -68,7 +68,6 @@ DATABASES = {
# the K8S cluster where awx itself is running)
IS_K8S = False
-RECEPTOR_RELEASE_WORK = True
AWX_CONTAINER_GROUP_K8S_API_TIMEOUT = 10
AWX_CONTAINER_GROUP_DEFAULT_NAMESPACE = os.getenv('MY_POD_NAMESPACE', 'default')
# Timeout when waiting for pod to enter running state. If the pod is still in pending state , it will be terminated. Valid time units are "s", "m", "h". Example : "5m" , "10s".
@@ -426,7 +425,7 @@ os.environ.setdefault('DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:9013-9199')
# heartbeat period can factor into some forms of logic, so it is maintained as a setting here
CLUSTER_NODE_HEARTBEAT_PERIOD = 60
RECEPTOR_SERVICE_ADVERTISEMENT_PERIOD = 60 # https://github.com/ansible/receptor/blob/aa1d589e154d8a0cb99a220aff8f98faf2273be6/pkg/netceptor/netceptor.go#L34
-EXECUTION_NODE_REMEDIATION_CHECKS = 60 * 10 # once every 10 minutes check if an execution node errors have been resolved
+EXECUTION_NODE_REMEDIATION_CHECKS = 60 * 30 # once every 30 minutes check if an execution node errors have been resolved
BROKER_URL = 'unix:///var/run/redis/redis.sock'
CELERYBEAT_SCHEDULE = {
@@ -931,6 +930,9 @@ AWX_CALLBACK_PROFILE = False
# Delete temporary directories created to store playbook run-time
AWX_CLEANUP_PATHS = True
+# Delete completed work units in receptor
+RECEPTOR_RELEASE_WORK = True
+
MIDDLEWARE = [
'django_guid.middleware.GuidMiddleware',
'awx.main.middleware.TimingMiddleware',
diff --git a/awx/ui/src/App.js b/awx/ui/src/App.js
index ab0211bd38..674dec8b07 100644
--- a/awx/ui/src/App.js
+++ b/awx/ui/src/App.js
@@ -98,8 +98,13 @@ const AuthorizedRoutes = ({ routeConfig }) => {
);
};
-const ProtectedRoute = ({ children, ...rest }) => {
- const { authRedirectTo, setAuthRedirectTo } = useSession();
+export function ProtectedRoute({ children, ...rest }) {
+ const {
+ authRedirectTo,
+ setAuthRedirectTo,
+ loginRedirectOverride,
+ isUserBeingLoggedOut,
+ } = useSession();
const location = useLocation();
useEffect(() => {
@@ -120,8 +125,16 @@ const ProtectedRoute = ({ children, ...rest }) => {
);
}
+ if (
+ loginRedirectOverride &&
+ !window.location.href.includes('/login') &&
+ !isUserBeingLoggedOut
+ ) {
+ window.location.replace(loginRedirectOverride);
+ return null;
+ }
return ;
-};
+}
function App() {
const history = useHistory();
diff --git a/awx/ui/src/App.test.js b/awx/ui/src/App.test.js
index a8b842714a..edcf60ebb7 100644
--- a/awx/ui/src/App.test.js
+++ b/awx/ui/src/App.test.js
@@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { RootAPI } from 'api';
import * as SessionContext from 'contexts/Session';
import { mountWithContexts } from '../testUtils/enzymeHelpers';
-import App from './App';
+import App, { ProtectedRoute } from './App';
jest.mock('./api');
@@ -20,6 +20,8 @@ describe('', () => {
const contextValues = {
setAuthRedirectTo: jest.fn(),
isSessionExpired: false,
+ isUserBeingLoggedOut: false,
+ loginRedirectOverride: null,
};
jest
.spyOn(SessionContext, 'useSession')
@@ -32,4 +34,36 @@ describe('', () => {
expect(wrapper.length).toBe(1);
jest.clearAllMocks();
});
+
+ test('redirect to login override', async () => {
+ const { location } = window;
+ delete window.location;
+ window.location = {
+ replace: jest.fn(),
+ href: '/',
+ };
+
+ expect(window.location.replace).not.toHaveBeenCalled();
+
+ const contextValues = {
+ setAuthRedirectTo: jest.fn(),
+ isSessionExpired: false,
+ isUserBeingLoggedOut: false,
+ loginRedirectOverride: '/sso/test',
+ };
+ jest
+ .spyOn(SessionContext, 'useSession')
+ .mockImplementation(() => contextValues);
+
+ await act(async () => {
+ mountWithContexts(
+
+ foo
+
+ );
+ });
+
+ expect(window.location.replace).toHaveBeenCalled();
+ window.location = location;
+ });
});
diff --git a/awx/ui/src/components/AssociateModal/AssociateModal.js b/awx/ui/src/components/AssociateModal/AssociateModal.js
index abfc2293ac..38335020d2 100644
--- a/awx/ui/src/components/AssociateModal/AssociateModal.js
+++ b/awx/ui/src/components/AssociateModal/AssociateModal.js
@@ -18,10 +18,7 @@ const QS_CONFIG = (order_by = 'name') =>
function AssociateModal({
header = t`Items`,
- columns = [
- { key: 'hostname', name: t`Name` },
- { key: 'node_type', name: t`Node Type` },
- ],
+ columns = [],
title = t`Select Items`,
onClose,
onAssociate,
diff --git a/awx/ui/src/components/LaunchPrompt/steps/useSurveyStep.js b/awx/ui/src/components/LaunchPrompt/steps/useSurveyStep.js
index 19484747fa..a19bc46a57 100644
--- a/awx/ui/src/components/LaunchPrompt/steps/useSurveyStep.js
+++ b/awx/ui/src/components/LaunchPrompt/steps/useSurveyStep.js
@@ -128,9 +128,9 @@ function checkForError(launchConfig, surveyConfig, values) {
hasError = true;
}
}
- if (isNumeric && (value || value === 0)) {
+ if (isNumeric) {
if (
- (value < question.min || value > question.max) &&
+ (value < question.min || value > question.max || value === '') &&
question.required
) {
hasError = true;
diff --git a/awx/ui/src/contexts/Session.js b/awx/ui/src/contexts/Session.js
index f6db1e3be3..1f76826abf 100644
--- a/awx/ui/src/contexts/Session.js
+++ b/awx/ui/src/contexts/Session.js
@@ -5,10 +5,11 @@ import React, {
useRef,
useCallback,
} from 'react';
-import { useHistory } from 'react-router-dom';
+import { useHistory, Redirect } from 'react-router-dom';
import { DateTime } from 'luxon';
import { RootAPI, MeAPI } from 'api';
import { isAuthenticated } from 'util/auth';
+import useRequest from 'hooks/useRequest';
import { SESSION_TIMEOUT_KEY } from '../constants';
// The maximum supported timeout for setTimeout(), in milliseconds,
@@ -72,8 +73,31 @@ function SessionProvider({ children }) {
const [sessionTimeout, setSessionTimeout] = useStorage(SESSION_TIMEOUT_KEY);
const [sessionCountdown, setSessionCountdown] = useState(0);
const [authRedirectTo, setAuthRedirectTo] = useState('/');
+ const [isUserBeingLoggedOut, setIsUserBeingLoggedOut] = useState(false);
+
+ const {
+ request: fetchLoginRedirectOverride,
+ result: { loginRedirectOverride },
+ isLoading,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await RootAPI.read();
+ return {
+ loginRedirectOverride: data?.login_redirect_override,
+ };
+ }, []),
+ {
+ loginRedirectOverride: null,
+ isLoading: true,
+ }
+ );
+
+ useEffect(() => {
+ fetchLoginRedirectOverride();
+ }, [fetchLoginRedirectOverride]);
const logout = useCallback(async () => {
+ setIsUserBeingLoggedOut(true);
if (!isSessionExpired.current) {
setAuthRedirectTo('/logout');
}
@@ -82,14 +106,13 @@ function SessionProvider({ children }) {
setSessionCountdown(0);
clearTimeout(sessionTimeoutId.current);
clearInterval(sessionIntervalId.current);
+ return ;
}, [setSessionTimeout, setSessionCountdown]);
useEffect(() => {
if (!isAuthenticated(document.cookie)) {
- history.replace('/login');
return () => {};
}
-
const calcRemaining = () => {
if (sessionTimeout) {
return Math.max(
@@ -140,9 +163,15 @@ function SessionProvider({ children }) {
clearInterval(sessionIntervalId.current);
}, []);
+ if (isLoading) {
+ return null;
+ }
+
return (
{t`Notification Templates`}
+ {t`Instances`}
{t`Instance Groups`}
diff --git a/awx/ui/src/screens/InstanceGroup/InstanceGroups.js b/awx/ui/src/screens/InstanceGroup/InstanceGroups.js
index 3f92aa6abe..c4ca94541e 100644
--- a/awx/ui/src/screens/InstanceGroup/InstanceGroups.js
+++ b/awx/ui/src/screens/InstanceGroup/InstanceGroups.js
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
-import { Route, Switch } from 'react-router-dom';
+import { Route, Switch, useLocation } from 'react-router-dom';
import useRequest from 'hooks/useRequest';
import { SettingsAPI } from 'api';
@@ -14,6 +14,7 @@ import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup';
function InstanceGroups() {
+ const { pathname } = useLocation();
const {
request: settingsRequest,
isLoading: isSettingsRequestLoading,
@@ -62,10 +63,14 @@ function InstanceGroups() {
});
}, []);
+ const streamType = pathname.includes('instances')
+ ? 'instance'
+ : 'instance_group';
+
return (
<>
diff --git a/awx/ui/src/screens/InstanceGroup/InstanceGroups.test.js b/awx/ui/src/screens/InstanceGroup/InstanceGroups.test.js
index 1a310dbe1b..fdd53c6e8f 100644
--- a/awx/ui/src/screens/InstanceGroup/InstanceGroups.test.js
+++ b/awx/ui/src/screens/InstanceGroup/InstanceGroups.test.js
@@ -1,10 +1,20 @@
import React from 'react';
import { shallow } from 'enzyme';
-
+import { InstanceGroupsAPI } from 'api';
import InstanceGroups from './InstanceGroups';
+const mockUseLocationValue = {
+ pathname: '',
+};
+jest.mock('api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useLocation: () => mockUseLocationValue,
+}));
describe('', () => {
test('should set breadcrumbs', () => {
+ mockUseLocationValue.pathname = '/instance_groups';
+
const wrapper = shallow();
const header = wrapper.find('ScreenHeader');
@@ -15,4 +25,17 @@ describe('', () => {
'/instance_groups/container_group/add': 'Create new container group',
});
});
+ test('should set breadcrumbs', async () => {
+ mockUseLocationValue.pathname = '/instance_groups/1/instances';
+ InstanceGroupsAPI.readInstances.mockResolvedValue({
+ data: { results: [{ hostname: 'EC2', id: 1 }] },
+ });
+ InstanceGroupsAPI.readInstanceOptions.mockResolvedValue({
+ data: { actions: {} },
+ });
+
+ const wrapper = shallow();
+
+ expect(wrapper.find('ScreenHeader').prop('streamType')).toEqual('instance');
+ });
});
diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js
index f87c6cae62..946bc81d2e 100644
--- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js
+++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js
@@ -147,7 +147,10 @@ function InstanceList() {
const fetchInstancesToAssociate = useCallback(
(params) =>
InstancesAPI.read(
- mergeParams(params, { not__rampart_groups__id: instanceGroupId })
+ mergeParams(params, {
+ ...{ not__rampart_groups__id: instanceGroupId },
+ ...{ not__node_type: 'control' },
+ })
),
[instanceGroupId]
);
@@ -280,6 +283,10 @@ function InstanceList() {
title={t`Select Instances`}
optionsRequest={readInstancesOptions}
displayKey="hostname"
+ columns={[
+ { key: 'hostname', name: t`Name` },
+ { key: 'node_type', name: t`Node Type` },
+ ]}
/>
)}
{error && (
diff --git a/awx/ui/src/screens/Login/Login.js b/awx/ui/src/screens/Login/Login.js
index bbd333524d..ede201c4e7 100644
--- a/awx/ui/src/screens/Login/Login.js
+++ b/awx/ui/src/screens/Login/Login.js
@@ -47,18 +47,12 @@ function AWXLogin({ alt, isAuthenticated }) {
isLoading: isCustomLoginInfoLoading,
error: customLoginInfoError,
request: fetchCustomLoginInfo,
- result: {
- brandName,
- logo,
- loginInfo,
- socialAuthOptions,
- loginRedirectOverride,
- },
+ result: { brandName, logo, loginInfo, socialAuthOptions },
} = useRequest(
useCallback(async () => {
const [
{
- data: { custom_logo, custom_login_info, login_redirect_override },
+ data: { custom_logo, custom_login_info },
},
{
data: { BRAND_NAME },
@@ -78,7 +72,6 @@ function AWXLogin({ alt, isAuthenticated }) {
logo: logoSrc,
loginInfo: custom_login_info,
socialAuthOptions: authData,
- loginRedirectOverride: login_redirect_override,
};
}, []),
{
@@ -118,10 +111,6 @@ function AWXLogin({ alt, isAuthenticated }) {
if (isCustomLoginInfoLoading) {
return null;
}
- if (!isAuthenticated(document.cookie) && loginRedirectOverride) {
- window.location.replace(loginRedirectOverride);
- return null;
- }
if (isAuthenticated(document.cookie)) {
return ;
}
diff --git a/awx/ui/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.js b/awx/ui/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.js
index cf728fc27d..bc3eadd1a1 100644
--- a/awx/ui/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.js
+++ b/awx/ui/src/screens/Setting/AzureAD/AzureADEdit/AzureADEdit.js
@@ -94,10 +94,9 @@ function AzureADEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.js b/awx/ui/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.js
index 3991192ba2..256f144459 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.test.js
@@ -147,8 +147,8 @@ describe('', () => {
);
assertDetail(wrapper, 'GitHub OAuth2 Key', 'mock github key');
assertDetail(wrapper, 'GitHub OAuth2 Secret', 'Encrypted');
- assertVariableDetail(wrapper, 'GitHub OAuth2 Organization Map', '{}');
- assertVariableDetail(wrapper, 'GitHub OAuth2 Team Map', '{}');
+ assertVariableDetail(wrapper, 'GitHub OAuth2 Organization Map', 'null');
+ assertVariableDetail(wrapper, 'GitHub OAuth2 Team Map', 'null');
});
test('should hide edit button from non-superusers', async () => {
@@ -226,12 +226,12 @@ describe('', () => {
assertVariableDetail(
wrapper,
'GitHub Organization OAuth2 Organization Map',
- '{}'
+ 'null'
);
assertVariableDetail(
wrapper,
'GitHub Organization OAuth2 Team Map',
- '{}'
+ 'null'
);
});
});
@@ -333,9 +333,13 @@ describe('', () => {
assertVariableDetail(
wrapper,
'GitHub Enterprise OAuth2 Organization Map',
- '{}'
+ 'null'
+ );
+ assertVariableDetail(
+ wrapper,
+ 'GitHub Enterprise OAuth2 Team Map',
+ 'null'
);
- assertVariableDetail(wrapper, 'GitHub Enterprise OAuth2 Team Map', '{}');
});
});
@@ -398,12 +402,12 @@ describe('', () => {
assertVariableDetail(
wrapper,
'GitHub Enterprise Organization OAuth2 Organization Map',
- '{}'
+ 'null'
);
assertVariableDetail(
wrapper,
'GitHub Enterprise Organization OAuth2 Team Map',
- '{}'
+ 'null'
);
});
});
@@ -463,12 +467,12 @@ describe('', () => {
assertVariableDetail(
wrapper,
'GitHub Enterprise Team OAuth2 Organization Map',
- '{}'
+ 'null'
);
assertVariableDetail(
wrapper,
'GitHub Enterprise Team OAuth2 Team Map',
- '{}'
+ 'null'
);
});
});
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.js
index 107752bfc6..e936e1f224 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.js
@@ -92,10 +92,9 @@ function GitHubEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.js
index 02b8d5c434..4fa7d941b3 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.js
@@ -94,10 +94,9 @@ function GitHubEnterpriseEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.js
index 7179fbfa24..0f334e8365 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseEdit/GitHubEnterpriseEdit.test.js
@@ -133,7 +133,7 @@ describe('', () => {
SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET: '',
- SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP: null,
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP: {
Default: {
users: false,
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.js
index d914c46755..0f856558f5 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.js
@@ -94,10 +94,9 @@ function GitHubEnterpriseOrgEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.js
index 84dc3989dd..b6e55487c8 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseOrgEdit/GitHubEnterpriseOrgEdit.test.js
@@ -146,7 +146,7 @@ describe('', () => {
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME: '',
- SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP: null,
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP: {
Default: {
users: false,
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.js
index 6f638e7e16..8ba8dd176e 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.js
@@ -94,10 +94,9 @@ function GitHubEnterpriseTeamEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.js b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.js
index 6e460e246a..e54c14c1cd 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubEnterpriseTeamEdit/GitHubEnterpriseTeamEdit.test.js
@@ -140,7 +140,7 @@ describe('', () => {
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET: '',
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID: '',
- SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP: null,
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP: {
Default: {
users: false,
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.js
index c2e8f83cd6..3b1beab537 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.js
@@ -94,10 +94,9 @@ function GitHubOrgEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.js b/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.js
index 777eb698af..f8f99b3d25 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.js
@@ -122,7 +122,7 @@ describe('', () => {
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org',
- SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: {
Default: {
users: false,
diff --git a/awx/ui/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.js b/awx/ui/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.js
index b4506c667b..d12d6baae7 100644
--- a/awx/ui/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.js
+++ b/awx/ui/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.js
@@ -94,10 +94,9 @@ function GitHubTeamEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.js b/awx/ui/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.js
index 8da9e8f583..3fa679beeb 100644
--- a/awx/ui/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.js
+++ b/awx/ui/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.js
@@ -100,10 +100,9 @@ function GoogleOAuth2Edit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js b/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js
index 1e6d8cef73..fec8d6cdb8 100644
--- a/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js
+++ b/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js
@@ -103,10 +103,9 @@ function JobsEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.js b/awx/ui/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.js
index 22929f65b9..22e00db54e 100644
--- a/awx/ui/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.js
+++ b/awx/ui/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.js
@@ -146,10 +146,9 @@ function LDAPEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.js b/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.js
index 6fb9f6c919..f19807e842 100644
--- a/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.js
+++ b/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.js
@@ -164,10 +164,9 @@ function MiscAuthenticationEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.test.js b/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.test.js
index b3cbd31db2..cf00ea5716 100644
--- a/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.test.js
+++ b/awx/ui/src/screens/Setting/MiscAuthentication/MiscAuthenticationEdit/MiscAuthenticationEdit.test.js
@@ -29,9 +29,9 @@ const authenticationData = {
'awx.sso.backends.TACACSPlusBackend',
'awx.main.backends.AWXModelBackend',
],
- SOCIAL_AUTH_ORGANIZATION_MAP: {},
- SOCIAL_AUTH_TEAM_MAP: {},
- SOCIAL_AUTH_USER_FIELDS: [],
+ SOCIAL_AUTH_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_TEAM_MAP: null,
+ SOCIAL_AUTH_USER_FIELDS: null,
};
describe('', () => {
diff --git a/awx/ui/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.js b/awx/ui/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.js
index 46db9eda90..17202503a2 100644
--- a/awx/ui/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.js
+++ b/awx/ui/src/screens/Setting/SAML/SAMLEdit/SAMLEdit.js
@@ -112,10 +112,9 @@ function SAMLEdit() {
const initialValues = (fields) =>
Object.keys(fields).reduce((acc, key) => {
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
- const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
acc[key] = fields[key].value
? JSON.stringify(fields[key].value, null, 2)
- : emptyDefault;
+ : null;
} else {
acc[key] = fields[key].value ?? '';
}
diff --git a/awx/ui/src/screens/Setting/shared/SettingDetail.js b/awx/ui/src/screens/Setting/shared/SettingDetail.js
index dca91d0cef..c133bfbe06 100644
--- a/awx/ui/src/screens/Setting/shared/SettingDetail.js
+++ b/awx/ui/src/screens/Setting/shared/SettingDetail.js
@@ -5,7 +5,7 @@ import { Detail } from 'components/DetailList';
import CodeDetail from 'components/DetailList/CodeDetail';
function sortObj(obj) {
- if (typeof obj !== 'object' || Array.isArray(obj)) {
+ if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
return obj;
}
const sorted = {};
@@ -30,7 +30,7 @@ export default ({ helpText, id, label, type, unit = '', value }) => {
label={label}
mode="javascript"
rows={4}
- value={JSON.stringify(sortObj(value || {}), undefined, 2)}
+ value={JSON.stringify(sortObj(value), undefined, 2)}
/>
);
break;
@@ -42,7 +42,7 @@ export default ({ helpText, id, label, type, unit = '', value }) => {
label={label}
mode="javascript"
rows={4}
- value={JSON.stringify(value || [], undefined, 2)}
+ value={JSON.stringify(value, undefined, 2)}
/>
);
break;
diff --git a/awx/ui/src/screens/Setting/shared/SharedFields.js b/awx/ui/src/screens/Setting/shared/SharedFields.js
index 887547186b..d273737f50 100644
--- a/awx/ui/src/screens/Setting/shared/SharedFields.js
+++ b/awx/ui/src/screens/Setting/shared/SharedFields.js
@@ -22,7 +22,7 @@ import CodeEditor from 'components/CodeEditor';
import { PasswordInput } from 'components/FormField';
import { FormFullWidthLayout } from 'components/FormLayout';
import Popover from 'components/Popover';
-import { combine, integer, minMaxValue, required, url } from 'util/validators';
+import { combine, minMaxValue, required, url, number } from 'util/validators';
import AlertModal from 'components/AlertModal';
import RevertButton from './RevertButton';
@@ -365,12 +365,11 @@ const InputField = ({ name, config, type = 'text', isRequired = false }) => {
const validators = [
...(isRequired ? [required(null)] : []),
...(type === 'url' ? [url()] : []),
- ...(type === 'number'
- ? [integer(), minMaxValue(min_value, max_value)]
- : []),
+ ...(type === 'number' ? [number(), minMaxValue(min_value, max_value)] : []),
];
const [field, meta] = useField({ name, validate: combine(validators) });
const isValid = !(meta.touched && meta.error);
+
return config ? (
{
validated={isValid ? 'default' : 'error'}
>
{
const [field, meta, helpers] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
- const emptyDefault = config?.type === 'list' ? '[]' : '{}';
- const defaultRevertValue = config?.default
- ? JSON.stringify(config.default, null, 2)
- : emptyDefault;
+ const defaultRevertValue =
+ config?.default !== null ? JSON.stringify(config.default, null, 2) : null;
return config ? (
@@ -458,7 +456,12 @@ const ObjectField = ({ name, config, isRequired = false }) => {
>
{
diff --git a/tools/docker-compose/ansible/roles/sources/tasks/main.yml b/tools/docker-compose/ansible/roles/sources/tasks/main.yml
index b6df968ed7..7b442aa3bc 100644
--- a/tools/docker-compose/ansible/roles/sources/tasks/main.yml
+++ b/tools/docker-compose/ansible/roles/sources/tasks/main.yml
@@ -113,4 +113,5 @@
src: "receptor-worker.conf.j2"
dest: "{{ sources_dest }}/receptor/receptor-worker-{{ item }}.conf"
mode: '0600'
- with_sequence: start=1 end={{ execution_node_count }}
+ with_sequence: start=1 end={{ execution_node_count if execution_node_count | int > 0 else 1}}
+ when: execution_node_count | int > 0
diff --git a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2 b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2
index d623c82ef1..d89a733ed1 100644
--- a/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2
+++ b/tools/docker-compose/ansible/roles/sources/templates/docker-compose.yml.j2
@@ -111,7 +111,7 @@ services:
- "../../docker-compose/_sources/receptor/receptor-hop.conf:/etc/receptor/receptor.conf"
{% for i in range(execution_node_count|int) -%}
receptor-{{ loop.index }}:
- image: quay.io/awx/awx_devel:devel
+ image: "{{ awx_image }}:{{ awx_image_tag }}"
user: "{{ ansible_user_uid }}"
container_name: tools_receptor_{{ loop.index }}
hostname: receptor-{{ loop.index }}