From a66d89295e9bb587959d8cc66fac5f03cac0519a Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sun, 1 Feb 2026 23:34:18 -0600 Subject: [PATCH 01/12] EE Builder initial --- awx/api/serializers.py | 127 ++++++++++- awx/api/urls/execution_environment_builder.py | 25 +++ .../execution_environment_builder_build.py | 25 +++ awx/api/urls/urls.py | 4 + awx/api/views/__init__.py | 116 ++++++++++ awx/api/views/root.py | 2 + awx/main/access.py | 92 ++++++++ awx/main/dispatch/worker/callback.py | 4 +- ...94_executionenvironmentbuilder_and_more.py | 119 ++++++++++ ...ecutionenvironmentbuilderbuild_and_more.py | 198 ++++++++++++++++ awx/main/models/__init__.py | 7 + awx/main/models/activity_stream.py | 1 + awx/main/models/events.py | 52 ++++- .../execution_environment_builder_builds.py | 122 ++++++++++ .../models/execution_environment_builders.py | 80 +++++++ awx/main/models/unified_jobs.py | 1 + awx/main/scheduler/dependency_graph.py | 9 +- awx/main/scheduler/task_manager.py | 4 + awx/main/tasks/callback.py | 4 + awx/main/tasks/jobs.py | 122 +++++++++- awx/main/utils/common.py | 2 +- awx/playbooks/build_ee.yml | 106 +++++++++ awx/ui/src/api/index.js | 6 + .../ExecutionEnvironmentBuilderBuilds.js | 15 ++ .../models/ExecutionEnvironmentBuilders.js | 31 +++ awx/ui/src/components/JobList/JobListItem.js | 1 + .../components/LaunchButton/LaunchButton.js | 5 + awx/ui/src/constants.js | 1 + awx/ui/src/routeConfig.js | 13 ++ .../ExecutionEnvironmentBuilder.js | 42 ++++ .../ExecutionEnvironmentBuilderAdd.js | 49 ++++ .../ExecutionEnvironmentBuilderAdd/index.js | 1 + .../ExecutionEnvironmentBuilderDetails.js | 150 +++++++++++++ .../index.js | 1 + .../ExecutionEnvironmentBuilderEdit.js | 56 +++++ .../ExecutionEnvironmentBuilderEdit/index.js | 1 + .../ExecutionEnvironmentBuilderList.js | 211 ++++++++++++++++++ .../ExecutionEnvironmentBuilderListItem.js | 153 +++++++++++++ .../ExecutionEnvironmentBuilderList/index.js | 1 + .../ExecutionEnvironmentBuilders.js | 61 +++++ .../ExecutionEnvironmentBuilder/index.js | 1 + .../shared/ExecutionEnvironmentBuilderForm.js | 156 +++++++++++++ .../ExecutionEnvironmentBuilderBuildDetail.js | 192 ++++++++++++++++ .../index.js | 1 + awx/ui/src/screens/Job/Job.js | 14 +- awx/ui/src/util/jobs.js | 2 + 46 files changed, 2367 insertions(+), 19 deletions(-) create mode 100644 awx/api/urls/execution_environment_builder.py create mode 100644 awx/api/urls/execution_environment_builder_build.py create mode 100644 awx/main/migrations/0194_executionenvironmentbuilder_and_more.py create mode 100644 awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py create mode 100644 awx/main/models/execution_environment_builder_builds.py create mode 100644 awx/main/models/execution_environment_builders.py create mode 100644 awx/playbooks/build_ee.yml create mode 100644 awx/ui/src/api/models/ExecutionEnvironmentBuilderBuilds.js create mode 100644 awx/ui/src/api/models/ExecutionEnvironmentBuilders.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilder.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/index.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/index.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/index.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/index.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js create mode 100644 awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.js create mode 100644 awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js create mode 100644 awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b84a80e7..22a69d42 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -56,6 +56,9 @@ CredentialInputSource, CredentialType, ExecutionEnvironment, + ExecutionEnvironmentBuilder, + ExecutionEnvironmentBuilderBuild, + ExecutionEnvironmentBuilderBuildEvent, Group, Host, HostMetric, @@ -373,6 +376,7 @@ def get_type_choices(self): 'workflow_job': _('Workflow Job'), 'workflow_job_template': _('Workflow Template'), 'job_template': _('Job Template'), + 'execution_environment_builder_build': _('EE Build'), } choices = [] for t in self.get_types(): @@ -798,7 +802,7 @@ class Meta: def get_types(self): if type(self) is UnifiedJobSerializer: - return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job', 'workflow_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job', 'workflow_job', 'execution_environment_builder_build'] else: return super(UnifiedJobSerializer, self).get_types() @@ -907,7 +911,7 @@ def get_field_names(self, declared_fields, info): def get_types(self): if type(self) is UnifiedJobListSerializer: - return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job', 'workflow_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job', 'workflow_job', 'execution_environment_builder_build'] else: return super(UnifiedJobListSerializer, self).get_types() @@ -928,6 +932,8 @@ def get_sub_serializer(self, obj): serializer_class = WorkflowJobListSerializer elif isinstance(obj, WorkflowApproval): serializer_class = WorkflowApprovalListSerializer + elif isinstance(obj, ExecutionEnvironmentBuilderBuild): + serializer_class = ExecutionEnvironmentBuilderBuildListSerializer return serializer_class def to_representation(self, obj): @@ -950,7 +956,7 @@ class Meta: def get_types(self): if type(self) is UnifiedJobStdoutSerializer: - return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job', 'execution_environment_builder_build'] else: return super(UnifiedJobStdoutSerializer, self).get_types() @@ -1652,6 +1658,105 @@ class Meta: fields = ('can_cancel',) +class ExecutionEnvironmentBuilderSerializer(BaseSerializer): + show_capabilities = ['edit', 'delete', 'copy'] + + class Meta: + model = ExecutionEnvironmentBuilder + fields = ( + '*', + 'organization', + 'image', + 'tag', + 'credential', + 'definition', + ) + + def get_related(self, obj): + res = super(ExecutionEnvironmentBuilderSerializer, self).get_related(obj) + res.update( + dict( + access_list=self.reverse('api:execution_environment_builder_access_list', kwargs={'pk': obj.pk}), + object_roles=self.reverse('api:execution_environment_builder_object_roles_list', kwargs={'pk': obj.pk}), + ) + ) + if obj.organization: + res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) + if obj.credential: + res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential.pk}) + return res + + +class ExecutionEnvironmentBuilderBuildSerializer(UnifiedJobSerializer): + type = serializers.SerializerMethodField() + execution_environment_builder = serializers.PrimaryKeyRelatedField( + queryset=ExecutionEnvironmentBuilder.objects.all(), + required=True, + ) + + class Meta: + model = ExecutionEnvironmentBuilderBuild + fields = ( + '*', + 'execution_environment_builder', + '-unified_job_template', + ) + + def get_type(self, obj): + return 'execution_environment_builder_build' + + +class ExecutionEnvironmentBuilderBuildDetailSerializer(ExecutionEnvironmentBuilderBuildSerializer): + class Meta: + model = ExecutionEnvironmentBuilderBuild + fields = ( + '*', + 'execution_environment_builder', + '-unified_job_template', + ) + + def get_summary_fields(self, obj): + data = super().get_summary_fields(obj) + if obj.execution_environment_builder: + builder_summary = { + 'id': obj.execution_environment_builder.id, + 'name': obj.execution_environment_builder.name, + 'image': obj.execution_environment_builder.image, + 'tag': obj.execution_environment_builder.tag, + } + if obj.execution_environment_builder.credential: + builder_summary['summary_fields'] = { + 'credential': { + 'id': obj.execution_environment_builder.credential.id, + 'name': obj.execution_environment_builder.credential.name, + 'kind': obj.execution_environment_builder.credential.kind, + } + } + data['execution_environment_builder'] = builder_summary + return data + + +class ExecutionEnvironmentBuilderBuildListSerializer(ExecutionEnvironmentBuilderBuildSerializer, UnifiedJobListSerializer): + type = serializers.SerializerMethodField() + + class Meta: + model = ExecutionEnvironmentBuilderBuild + fields = ('*', '-controller_node', '-unified_job_template') + + +class ExecutionEnvironmentBuilderBuildCancelSerializer(ExecutionEnvironmentBuilderBuildSerializer): + can_cancel = serializers.BooleanField(read_only=True) + + +class ExecutionEnvironmentBuilderBuildRelaunchSerializer(BaseSerializer): + class Meta: + model = ExecutionEnvironmentBuilderBuild + fields = () + + class Meta: + fields = ('can_cancel',) + + class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, value): return vars_validate_or_raise(value) @@ -4392,6 +4497,22 @@ def get_event_data(self, obj): return obj.event_data +class ExecutionEnvironmentBuilderBuildEventSerializer(JobEventSerializer): + stdout = serializers.SerializerMethodField() + + class Meta: + model = ExecutionEnvironmentBuilderBuildEvent + fields = ('*', '-name', '-description', '-job', '-job_id', '-parent_uuid', '-parent', '-host', 'execution_environment_builder_build') + + def get_related(self, obj): + res = super(JobEventSerializer, self).get_related(obj) + res['execution_environment_builder_build'] = self.reverse('api:execution_environment_builder_build_detail', kwargs={'pk': obj.execution_environment_builder_build_id}) + return res + + def get_stdout(self, obj): + return UriCleaner.remove_sensitive(obj.stdout) + + class AdHocCommandEventSerializer(BaseSerializer): event_display = serializers.CharField(source='get_event_display', read_only=True) diff --git a/awx/api/urls/execution_environment_builder.py b/awx/api/urls/execution_environment_builder.py new file mode 100644 index 00000000..d2ebbcbe --- /dev/null +++ b/awx/api/urls/execution_environment_builder.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023 Ctrl IQ, Inc. +# All Rights Reserved. + +from django.urls import path + +from awx.api.views import ( + ExecutionEnvironmentBuilderList, + ExecutionEnvironmentBuilderDetail, + ExecutionEnvironmentBuilderAccessList, + ExecutionEnvironmentBuilderObjectRolesList, + ExecutionEnvironmentBuilderCopy, + ExecutionEnvironmentBuilderLaunch, +) + + +urls = [ + path('', ExecutionEnvironmentBuilderList.as_view(), name='execution_environment_builder_list'), + path('/', ExecutionEnvironmentBuilderDetail.as_view(), name='execution_environment_builder_detail'), + path('/copy/', ExecutionEnvironmentBuilderCopy.as_view(), name='execution_environment_builder_copy'), + path('/launch/', ExecutionEnvironmentBuilderLaunch.as_view(), name='execution_environment_builder_launch'), + path('/access_list/', ExecutionEnvironmentBuilderAccessList.as_view(), name='execution_environment_builder_access_list'), + path('/object_roles/', ExecutionEnvironmentBuilderObjectRolesList.as_view(), name='execution_environment_builder_object_roles_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/execution_environment_builder_build.py b/awx/api/urls/execution_environment_builder_build.py new file mode 100644 index 00000000..80aa82fd --- /dev/null +++ b/awx/api/urls/execution_environment_builder_build.py @@ -0,0 +1,25 @@ +# Copyright (c) 2024 Ansible, Inc. +# All Rights Reserved. + +from django.urls import path + +from awx.api.views import ( + ExecutionEnvironmentBuilderBuildList, + ExecutionEnvironmentBuilderBuildDetail, + ExecutionEnvironmentBuilderBuildCancel, + ExecutionEnvironmentBuilderBuildRelaunch, + ExecutionEnvironmentBuilderBuildStdout, + ExecutionEnvironmentBuilderBuildEventsList, +) + + +urls = [ + path('', ExecutionEnvironmentBuilderBuildList.as_view(), name='execution_environment_builder_build_list'), + path('/', ExecutionEnvironmentBuilderBuildDetail.as_view(), name='execution_environment_builder_build_detail'), + path('/cancel/', ExecutionEnvironmentBuilderBuildCancel.as_view(), name='execution_environment_builder_build_cancel'), + path('/relaunch/', ExecutionEnvironmentBuilderBuildRelaunch.as_view(), name='execution_environment_builder_build_relaunch'), + path('/stdout/', ExecutionEnvironmentBuilderBuildStdout.as_view(), name='execution_environment_builder_build_stdout'), + path('/events/', ExecutionEnvironmentBuilderBuildEventsList.as_view(), name='execution_environment_builder_build_events_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 042acaba..ae941496 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -51,6 +51,8 @@ from .project_update import urls as project_update_urls from .inventory import urls as inventory_urls, constructed_inventory_urls from .execution_environments import urls as execution_environment_urls +from .execution_environment_builder import urls as execution_environment_builder_urls +from .execution_environment_builder_build import urls as execution_environment_builder_build_urls from .team import urls as team_urls from .host import urls as host_urls from .host_metric import urls as host_metric_urls @@ -118,6 +120,8 @@ path('organizations/', include(organization_urls)), path('users/', include(user_urls)), path('execution_environments/', include(execution_environment_urls)), + path('execution_environment_builders/', include(execution_environment_builder_urls)), + path('builds/', include(execution_environment_builder_build_urls)), path('projects/', include(project_urls)), path('project_updates/', include(project_update_urls)), path('teams/', include(team_urls)), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index eb3e6e5f..484bbf4b 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -875,6 +875,118 @@ class ExecutionEnvironmentActivityStreamList(SubListAPIView): filter_read_permission = False +class ExecutionEnvironmentBuilderList(ListCreateAPIView): + model = models.ExecutionEnvironmentBuilder + serializer_class = serializers.ExecutionEnvironmentBuilderSerializer + + +class ExecutionEnvironmentBuilderDetail(RetrieveUpdateDestroyAPIView): + model = models.ExecutionEnvironmentBuilder + serializer_class = serializers.ExecutionEnvironmentBuilderSerializer + + +class ExecutionEnvironmentBuilderAccessList(ResourceAccessList): + model = models.User + parent_model = models.ExecutionEnvironmentBuilder + + +class ExecutionEnvironmentBuilderObjectRolesList(SubListAPIView): + model = models.Role + serializer_class = serializers.RoleSerializer + parent_model = models.ExecutionEnvironmentBuilder + search_fields = ('role_field', 'content_type__model') + + def get_queryset(self): + parent = self.get_parent_object() + content_type = ContentType.objects.get_for_model(self.parent_model) + return models.Role.objects.filter(content_type=content_type, object_id=parent.pk) + + +class ExecutionEnvironmentBuilderCopy(CopyAPIView): + model = models.ExecutionEnvironmentBuilder + copy_return_serializer_class = serializers.ExecutionEnvironmentBuilderSerializer + + +class ExecutionEnvironmentBuilderLaunch(GenericAPIView): + model = models.ExecutionEnvironmentBuilder + serializer_class = serializers.EmptySerializer + + def get(self, request, *args, **kwargs): + return Response({}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + new_build = models.ExecutionEnvironmentBuilderBuild.objects.create( + execution_environment_builder=obj, + name=request.data.get('name', f'{obj.name} Build'), + ) + new_build.signal_start() + data = OrderedDict() + data['execution_environment_builder_build'] = new_build.id + return Response(data, status=status.HTTP_201_CREATED) + + +class ExecutionEnvironmentBuilderBuildList(ListCreateAPIView): + model = models.ExecutionEnvironmentBuilderBuild + serializer_class = serializers.ExecutionEnvironmentBuilderBuildListSerializer + + def perform_create(self, serializer): + obj = serializer.save() + obj.signal_start() + +class ExecutionEnvironmentBuilderBuildDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): + model = models.ExecutionEnvironmentBuilderBuild + serializer_class = serializers.ExecutionEnvironmentBuilderBuildDetailSerializer + + +class ExecutionEnvironmentBuilderBuildCancel(GenericCancelView): + model = models.ExecutionEnvironmentBuilderBuild + serializer_class = serializers.ExecutionEnvironmentBuilderBuildCancelSerializer + + +class ExecutionEnvironmentBuilderBuildRelaunch(GenericAPIView): + model = models.ExecutionEnvironmentBuilderBuild + serializer_class = serializers.ExecutionEnvironmentBuilderBuildRelaunchSerializer + obj_permission_type = 'start' + + def get(self, request, *args, **kwargs): + obj = self.get_object() + return Response({}) + + def post(self, request, *args, **kwargs): + from django.utils.timezone import now + obj = self.get_object() + # Create a new build with the same configuration as the original + builder = obj.execution_environment_builder + current_date = now().strftime('%Y-%m-%d %H:%M:%S') + new_build = models.ExecutionEnvironmentBuilderBuild.objects.create( + execution_environment_builder=builder, + name=f"{builder.name if builder else ''}", + launch_type='relaunch', + ) + new_build.signal_start() + return Response({'id': new_build.id}) + + +class ExecutionEnvironmentBuilderBuildEventsList(SubListAPIView): + model = models.ExecutionEnvironmentBuilderBuildEvent + serializer_class = serializers.ExecutionEnvironmentBuilderBuildEventSerializer + parent_model = models.ExecutionEnvironmentBuilderBuild + relationship = 'execution_environment_builder_build_events' + name = _('Execution Environment Builder Build Events List') + search_fields = ('stdout',) + pagination_class = UnifiedJobEventPagination + + def finalize_response(self, request, response, *args, **kwargs): + response['X-UI-Max-Events'] = settings.MAX_UI_JOB_EVENTS + return super(ExecutionEnvironmentBuilderBuildEventsList, self).finalize_response(request, response, *args, **kwargs) + + def get_queryset(self): + build = self.get_parent_object() + self.check_parent_access(build) + return build.get_event_queryset() + + class ProjectList(ListCreateAPIView): model = models.Project serializer_class = serializers.ProjectSerializer @@ -4304,6 +4416,10 @@ class AdHocCommandStdout(UnifiedJobStdout): model = models.AdHocCommand +class ExecutionEnvironmentBuilderBuildStdout(UnifiedJobStdout): + model = models.ExecutionEnvironmentBuilderBuild + + class NotificationTemplateList(ListCreateAPIView): model = models.NotificationTemplate serializer_class = serializers.NotificationTemplateSerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index f4d5a1c4..816d2f09 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -92,6 +92,8 @@ def get(self, request, format=None): data['organizations'] = reverse('api:organization_list', request=request) data['users'] = reverse('api:user_list', request=request) data['execution_environments'] = reverse('api:execution_environment_list', request=request) + data['execution_environment_builders'] = reverse('api:execution_environment_builder_list', request=request) + data['builds'] = reverse('api:execution_environment_builder_build_list', request=request) data['projects'] = reverse('api:project_list', request=request) data['project_updates'] = reverse('api:project_update_list', request=request) data['teams'] = reverse('api:team_list', request=request) diff --git a/awx/main/access.py b/awx/main/access.py index 30238e05..8456173e 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -36,6 +36,8 @@ CredentialType, CredentialInputSource, ExecutionEnvironment, + ExecutionEnvironmentBuilder, + ExecutionEnvironmentBuilderBuild, Group, Host, Instance, @@ -1414,6 +1416,96 @@ def can_delete(self, obj): return self.can_change(obj, None) +class ExecutionEnvironmentBuilderAccess(BaseAccess): + """ + I can see an execution environment builder when: + - I'm a superuser + - I'm a member of the same organization + I can create/change an execution environment builder when: + - I'm a superuser + """ + + model = ExecutionEnvironmentBuilder + select_related = ('organization',) + prefetch_related = ('organization__admin_role',) + + def filtered_queryset(self): + return ExecutionEnvironmentBuilder.objects.filter( + Q(organization__in=Organization.accessible_pk_qs(self.user, 'read_role')) | Q(organization__isnull=True) + ).distinct() + + @check_superuser + def can_add(self, data): + return True + + @check_superuser + def can_change(self, obj, data): + return True + + +class ExecutionEnvironmentBuilderBuildAccess(BaseAccess): + """ + I can see execution environment builder builds when I can see the builder. + I can change when I can change the builder. + I can delete when I can change/delete the builder. + """ + + model = ExecutionEnvironmentBuilderBuild + select_related = ( + 'created_by', + 'modified_by', + 'execution_environment_builder', + 'execution_environment_builder__organization', + ) + prefetch_related = ( + 'unified_job_template', + 'instance_group', + ) + + def filtered_queryset(self): + return self.model.objects.filter( + execution_environment_builder__in=ExecutionEnvironmentBuilder.accessible_pk_qs(self.user, 'read_role') + ) + + @check_superuser + @check_superuser + def can_cancel(self, obj): + if not obj: + return False + # Allow the user who created the build to cancel it + if self.user == obj.created_by: + return True + # Allow organization admin to cancel + if obj.execution_environment_builder and obj.execution_environment_builder.organization: + return self.user in obj.execution_environment_builder.organization.admin_role + # Allow users who can change the builder to cancel it + if obj.execution_environment_builder: + return self.user.can_access(ExecutionEnvironmentBuilder, 'change', obj.execution_environment_builder, None) + return False + + def can_start(self, obj, validate_license=True): + # for relaunching + try: + if obj and obj.execution_environment_builder: + if obj.execution_environment_builder.organization: + return self.user in obj.execution_environment_builder.organization.admin_role + # If no organization, allow the creator or superuser + return self.user == obj.created_by + except ObjectDoesNotExist: + pass + return False + + @check_superuser + def can_delete(self, obj): + # Allow the user who created the build to delete it + if self.user == obj.created_by: + return True + # Allow organization admin to delete + if obj.execution_environment_builder and obj.execution_environment_builder.organization: + return self.user in obj.execution_environment_builder.organization.admin_role + return False + + class ProjectAccess(NotificationAttachMixin, BaseAccess): """ I can see projects when: diff --git a/awx/main/dispatch/worker/callback.py b/awx/main/dispatch/worker/callback.py index 7394bbbe..39c40283 100644 --- a/awx/main/dispatch/worker/callback.py +++ b/awx/main/dispatch/worker/callback.py @@ -18,7 +18,7 @@ from awx.main.consumers import emit_channel_notification from awx.main.models import JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, SystemJobEvent, UnifiedJob from awx.main.constants import ACTIVE_STATES -from awx.main.models.events import emit_event_detail +from awx.main.models.events import emit_event_detail, ExecutionEnvironmentBuilderBuildEvent from awx.main.utils.profiling import AWXProfiler import awx.main.analytics.subsystem_metrics as s_metrics from .base import BaseWorker @@ -233,7 +233,7 @@ def perform_work(self, body): self.last_event = '' if not flush: job_identifier = 'unknown job' - for cls in (JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, SystemJobEvent): + for cls in (JobEvent, AdHocCommandEvent, ProjectUpdateEvent, InventoryUpdateEvent, ExecutionEnvironmentBuilderBuildEvent, SystemJobEvent): if cls.JOB_REFERENCE in body: job_identifier = body[cls.JOB_REFERENCE] break diff --git a/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py new file mode 100644 index 00000000..ca1dd9d4 --- /dev/null +++ b/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py @@ -0,0 +1,119 @@ +# Generated by Django 5.2.9 on 2026-01-26 07:16 + +import awx.main.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0193_alter_inventorysource_source_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExecutionEnvironmentBuilder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(blank=True, default='')), + ('name', models.CharField(max_length=512, unique=True)), + ( + 'image', + models.CharField( + blank=True, default='', help_text='The name for the built execution environment image', max_length=1024, verbose_name='Image Name' + ), + ), + ( + 'tag', + models.CharField( + blank=True, default='latest', help_text='The tag for the built execution environment image', max_length=1024, verbose_name='Image Tag' + ), + ), + ( + 'definition', + models.TextField(blank=True, default='', help_text='Ansible builder execution environment definition', verbose_name='Definition'), + ), + ( + 'admin_role', + awx.main.fields.ImplicitRoleField( + editable=False, + null='True', + on_delete=django.db.models.deletion.CASCADE, + parent_role=['organization.execution_environment_builder_admin_role', 'singleton:system_administrator'], + related_name='+', + to='main.role', + ), + ), + ( + 'created_by', + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%s(class)s_created+', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'credential', + models.ForeignKey( + blank=True, + default=None, + help_text='Container registry credential for pushing the built image', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%(class)ss', + to='main.credential', + ), + ), + ( + 'modified_by', + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%s(class)s_modified+', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'organization', + models.ForeignKey( + blank=True, + default=None, + help_text='The organization used to determine access to this execution environment builder.', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(class)ss', + to='main.organization', + ), + ), + ( + 'read_role', + awx.main.fields.ImplicitRoleField( + editable=False, + null='True', + on_delete=django.db.models.deletion.CASCADE, + parent_role=['organization.auditor_role', 'singleton:system_auditor', 'admin_role'], + related_name='+', + to='main.role', + ), + ), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.AddField( + model_name='activitystream', + name='execution_environment_builder', + field=models.ManyToManyField(blank=True, to='main.executionenvironmentbuilder'), + ), + ] diff --git a/awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py b/awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py new file mode 100644 index 00000000..2052aabb --- /dev/null +++ b/awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py @@ -0,0 +1,198 @@ +# Generated by Django 5.2.9 on 2026-01-26 21:04 + +import awx.main.fields +import awx.main.models.notifications +import django.db.models.deletion +from django.db import migrations, models, connection + + +def setup_event_partitioning(apps, schema_editor): + """Set up partitioning for ExecutionEnvironmentBuilderBuildEvent table""" + tblname = 'main_executionenvironmentbuilderbuildevent' + unpartitioned_tblname = f'_unpartitioned_{tblname}' + + with connection.cursor() as cursor: + # Check if table exists + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = %s + ) + """, [tblname]) + + if not cursor.fetchone()[0]: + # Table doesn't exist yet, it will be created by the CreateModel operation + return + + # Check if unpartitioned table already exists (from previous failed/rollback attempt) + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = %s + ) + """, [unpartitioned_tblname]) + + if cursor.fetchone()[0]: + # Unpartitioned table already exists, drop it and start fresh + cursor.execute(f'DROP TABLE IF EXISTS {unpartitioned_tblname}') + + # Mark existing table as unpartitioned + cursor.execute(f'ALTER TABLE {tblname} RENAME TO {unpartitioned_tblname}') + + # Create a temporary table to use as schema reference + cursor.execute(f'CREATE TABLE tmp_{tblname} (LIKE {unpartitioned_tblname} INCLUDING ALL)') + + # Drop primary key constraint + cursor.execute(f'ALTER TABLE tmp_{tblname} DROP CONSTRAINT tmp_{tblname}_pkey') + + # Create partitioned parent table + cursor.execute( + f'CREATE TABLE {tblname} ' + f'(LIKE tmp_{tblname} INCLUDING ALL) ' + f'PARTITION BY RANGE(job_created);' + ) + + cursor.execute(f'DROP TABLE tmp_{tblname}') + + # Recreate primary key constraint with partition key + cursor.execute(f'ALTER TABLE ONLY {tblname} ADD CONSTRAINT {tblname}_pkey_new PRIMARY KEY (id, job_created);') + + +def setup_event_partitioning_sqlite(apps, schema_editor): + # SQLite doesn't support partitioning, just pass + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0194_executionenvironmentbuilder_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ExecutionEnvironmentBuilderBuild', + fields=[ + ( + 'unifiedjob_ptr', + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='main.unifiedjob', + ), + ), + ( + 'execution_environment_builder', + models.ForeignKey( + editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='main.executionenvironmentbuilder' + ), + ), + ], + bases=('main.unifiedjob', awx.main.models.notifications.JobNotificationMixin), + ), + migrations.CreateModel( + name='ExecutionEnvironmentBuilderBuildEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'event', + models.CharField( + choices=[ + ('runner_on_failed', 'Host Failed'), + ('runner_on_start', 'Host Started'), + ('runner_on_ok', 'Host OK'), + ('runner_on_error', 'Host Failure'), + ('runner_on_skipped', 'Host Skipped'), + ('runner_on_unreachable', 'Host Unreachable'), + ('runner_on_no_hosts', 'No Hosts Remaining'), + ('runner_on_async_poll', 'Host Polling'), + ('runner_on_async_ok', 'Host Async OK'), + ('runner_on_async_failed', 'Host Async Failure'), + ('runner_item_on_ok', 'Item OK'), + ('runner_item_on_failed', 'Item Failed'), + ('runner_item_on_skipped', 'Item Skipped'), + ('runner_retry', 'Host Retry'), + ('runner_on_file_diff', 'File Difference'), + ('playbook_on_start', 'Playbook Started'), + ('playbook_on_notify', 'Running Handlers'), + ('playbook_on_include', 'Including File'), + ('playbook_on_no_hosts_matched', 'No Hosts Matched'), + ('playbook_on_no_hosts_remaining', 'No Hosts Remaining'), + ('playbook_on_task_start', 'Task Started'), + ('playbook_on_vars_prompt', 'Variables Prompted'), + ('playbook_on_setup', 'Gathering Facts'), + ('playbook_on_import_for_host', 'internal: on Import for Host'), + ('playbook_on_not_import_for_host', 'internal: on Not Import for Host'), + ('playbook_on_play_start', 'Play Started'), + ('playbook_on_stats', 'Playbook Complete'), + ('debug', 'Debug'), + ('verbose', 'Verbose'), + ('deprecated', 'Deprecated'), + ('warning', 'Warning'), + ('system_warning', 'System Warning'), + ('error', 'Error'), + ], + max_length=100, + ), + ), + ('event_data', awx.main.fields.JSONBlob(blank=True, default=dict)), + ('failed', models.BooleanField(default=False, editable=False)), + ('changed', models.BooleanField(default=False, editable=False)), + ('uuid', models.CharField(default='', editable=False, max_length=1024)), + ('playbook', models.CharField(default='', editable=False, max_length=1024)), + ('play', models.CharField(default='', editable=False, max_length=1024)), + ('role', models.CharField(default='', editable=False, max_length=1024)), + ('task', models.CharField(default='', editable=False, max_length=1024)), + ('counter', models.PositiveIntegerField(default=0, editable=False)), + ('stdout', models.TextField(default='', editable=False)), + ('verbosity', models.PositiveIntegerField(default=0, editable=False)), + ('start_line', models.PositiveIntegerField(default=0, editable=False)), + ('end_line', models.PositiveIntegerField(default=0, editable=False)), + ('created', models.DateTimeField(default=None, editable=False, null=True)), + ('modified', models.DateTimeField(db_index=True, default=None, editable=False)), + ('job_created', models.DateTimeField(editable=False, null=True)), + ('parent_uuid', models.CharField(default='', editable=False, max_length=1024)), + ( + 'execution_environment_builder_build', + models.ForeignKey( + db_constraint=False, + db_index=False, + editable=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name='execution_environment_builder_build_events', + to='main.executionenvironmentbuilderbuild', + ), + ), + ], + options={ + 'ordering': ('pk',), + }, + ), + migrations.CreateModel( + name='UnpartitionedExecutionEnvironmentBuilderBuildEvent', + fields=[], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('main.executionenvironmentbuilderbuildevent',), + ), + migrations.AddIndex( + model_name='executionenvironmentbuilderbuildevent', + index=models.Index(fields=['execution_environment_builder_build', 'job_created', 'event'], name='main_execut_executi_e9bdb1_idx'), + ), + migrations.AddIndex( + model_name='executionenvironmentbuilderbuildevent', + index=models.Index(fields=['execution_environment_builder_build', 'job_created', 'uuid'], name='main_execut_executi_083198_idx'), + ), + migrations.AddIndex( + model_name='executionenvironmentbuilderbuildevent', + index=models.Index(fields=['execution_environment_builder_build', 'job_created', 'counter'], name='main_execut_executi_03d2ab_idx'), + ), + migrations.RunPython(setup_event_partitioning, setup_event_partitioning_sqlite), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index fadff9fd..7b1a10ef 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -41,15 +41,21 @@ JobEvent, ProjectUpdateEvent, SystemJobEvent, + ExecutionEnvironmentBuilderBuildEvent, UnpartitionedAdHocCommandEvent, UnpartitionedInventoryUpdateEvent, UnpartitionedJobEvent, UnpartitionedProjectUpdateEvent, UnpartitionedSystemJobEvent, + UnpartitionedExecutionEnvironmentBuilderBuildEvent, ) from awx.main.models.ad_hoc_commands import AdHocCommand # noqa from awx.main.models.schedules import Schedule # noqa from awx.main.models.execution_environments import ExecutionEnvironment # noqa +from awx.main.models.execution_environment_builders import ExecutionEnvironmentBuilder # noqa +from awx.main.models.execution_environment_builder_builds import ( # noqa + ExecutionEnvironmentBuilderBuild, +) from awx.main.models.activity_stream import ActivityStream # noqa from awx.main.models.ha import ( # noqa Instance, @@ -267,6 +273,7 @@ def o_auth2_token_get_absolute_url(self, request=None): activity_stream_registrar.connect(Project) # activity_stream_registrar.connect(ProjectUpdate) activity_stream_registrar.connect(ExecutionEnvironment) +activity_stream_registrar.connect(ExecutionEnvironmentBuilder) activity_stream_registrar.connect(JobTemplate) activity_stream_registrar.connect(Job) activity_stream_registrar.connect(AdHocCommand) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index 2dccf315..454a4e76 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -74,6 +74,7 @@ class Meta: ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True) schedule = models.ManyToManyField("Schedule", blank=True) execution_environment = models.ManyToManyField("ExecutionEnvironment", blank=True) + execution_environment_builder = models.ManyToManyField("ExecutionEnvironmentBuilder", blank=True) notification_template = models.ManyToManyField("NotificationTemplate", blank=True) notification = models.ManyToManyField("Notification", blank=True) label = models.ManyToManyField("Label", blank=True) diff --git a/awx/main/models/events.py b/awx/main/models/events.py index e6ad8bef..0b57bdd5 100644 --- a/awx/main/models/events.py +++ b/awx/main/models/events.py @@ -29,7 +29,7 @@ logger = logging.getLogger('awx.main.models.events') -__all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent'] +__all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent', 'ExecutionEnvironmentBuilderBuildEvent'] def sanitize_event_keys(kwargs, valid_keys): @@ -71,6 +71,7 @@ def emit_event_detail(event): ProjectUpdateEvent: 'project_update_id', InventoryUpdateEvent: 'inventory_update_id', SystemJobEvent: 'system_job_id', + ExecutionEnvironmentBuilderBuildEvent: 'execution_environment_builder_build_id', }[cls] url = '' if isinstance(event, JobEvent): @@ -416,11 +417,11 @@ def create_from_data(cls, **kwargs): # Proceed with caution! # pk = None - for key in ('job_id', 'project_update_id'): + for key in ('job_id', 'project_update_id', 'ad_hoc_command_id', 'inventory_update_id', 'system_job_id', 'execution_environment_builder_build_id'): if key in kwargs: pk = key if pk is None: - # payload must contain either a job_id or a project_update_id + # payload must contain a job reference ID return # Convert the datetime for the job event's creation appropriately, @@ -964,3 +965,48 @@ class Meta: UnpartitionedSystemJobEvent._meta.db_table = '_unpartitioned_' + SystemJobEvent._meta.db_table # noqa + + +class ExecutionEnvironmentBuilderBuildEvent(BasePlaybookEvent): + VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['execution_environment_builder_build_id', 'workflow_job_id', 'job_created'] + JOB_REFERENCE = 'execution_environment_builder_build_id' + + objects = DeferJobCreatedManager() + + class Meta: + app_label = 'main' + ordering = ('pk',) + indexes = [ + models.Index(fields=['execution_environment_builder_build', 'job_created', 'event']), + models.Index(fields=['execution_environment_builder_build', 'job_created', 'uuid']), + models.Index(fields=['execution_environment_builder_build', 'job_created', 'counter']), + ] + + id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID') + execution_environment_builder_build = models.ForeignKey( + 'ExecutionEnvironmentBuilderBuild', + related_name='execution_environment_builder_build_events', + null=True, + on_delete=models.DO_NOTHING, + editable=False, + db_index=False, + db_constraint=False, + ) + parent_uuid = models.CharField( + max_length=1024, + default='', + editable=False, + ) + job_created = models.DateTimeField(null=True, editable=False) + + @property + def host_name(self): + return 'localhost' + + +class UnpartitionedExecutionEnvironmentBuilderBuildEvent(ExecutionEnvironmentBuilderBuildEvent): + class Meta: + proxy = True + + +UnpartitionedExecutionEnvironmentBuilderBuildEvent._meta.db_table = '_unpartitioned_' + ExecutionEnvironmentBuilderBuildEvent._meta.db_table # noqa diff --git a/awx/main/models/execution_environment_builder_builds.py b/awx/main/models/execution_environment_builder_builds.py new file mode 100644 index 00000000..df99e287 --- /dev/null +++ b/awx/main/models/execution_environment_builder_builds.py @@ -0,0 +1,122 @@ +# Copyright (c) 2024 Ansible, Inc. +# All Rights Reserved. + +import urllib.parse as urlparse + +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.contrib.contenttypes.models import ContentType + +from awx.main.models.unified_jobs import UnifiedJob +from awx.main.models.events import ExecutionEnvironmentBuilderBuildEvent, UnpartitionedExecutionEnvironmentBuilderBuildEvent +from awx.main.models.notifications import JobNotificationMixin + + +class ExecutionEnvironmentBuilderBuild(UnifiedJob, JobNotificationMixin): + """ + Internal job for tracking execution environment builder builds. + """ + + class Meta: + app_label = 'main' + + execution_environment_builder = models.ForeignKey( + 'ExecutionEnvironmentBuilder', + related_name='builds', + on_delete=models.CASCADE, + editable=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Initialize polymorphic_ctype_id if not set + if not self.polymorphic_ctype_id: + try: + ct = ContentType.objects.get_for_model(type(self)) + self.polymorphic_ctype_id = ct.id + except Exception: + pass + + def _set_default_dependencies_processed(self): + self.dependencies_processed = True + + def save(self, *args, **kwargs): + # Ensure polymorphic_ctype_id is set for proper polymorphic model handling + # This is needed because the polymorphic library needs this field to be set + # to properly identify the model subclass + if self.polymorphic_ctype_id is None: + ct = ContentType.objects.get_for_model(type(self)) + self.polymorphic_ctype_id = ct.id + return super().save(*args, **kwargs) + + def _get_parent_field_name(self): + return 'execution_environment_builder' + + def _get_parent_instance(self): + # ExecutionEnvironmentBuilder is not a UnifiedJobTemplate, so return None + # to keep unified_job_template field null + return None + + def get_absolute_url(self, request=None): + # Polymorphic model URL endpoint + from awx.api.versioning import reverse + return reverse('api:execution_environment_builder_build_detail', kwargs={'pk': self.pk}, request=request) + + @property + def job_type_name(self): + return 'build' + + def _update_parent_instance(self): + if not self.execution_environment_builder: + return # no parent instance to update + return super(ExecutionEnvironmentBuilderBuild, self)._update_parent_instance() + + @classmethod + def _get_task_class(cls): + from awx.main.tasks.jobs import RunExecutionEnvironmentBuilderBuild + + return RunExecutionEnvironmentBuilderBuild + + def _global_timeout_setting(self): + return 'DEFAULT_EXECUTION_ENVIRONMENT_BUILDER_TIMEOUT' + + def is_blocked_by(self, obj): + if type(obj) == ExecutionEnvironmentBuilderBuild: + if self.execution_environment_builder == obj.execution_environment_builder: + return True + return False + + def websocket_emit_data(self): + websocket_data = super(ExecutionEnvironmentBuilderBuild, self).websocket_emit_data() + websocket_data.update(dict(execution_environment_builder_id=self.execution_environment_builder.id)) + return websocket_data + + @property + def event_class(self): + if self.has_unpartitioned_events: + return UnpartitionedExecutionEnvironmentBuilderBuildEvent + return ExecutionEnvironmentBuilderBuildEvent + + def get_ui_url(self): + return urlparse.urljoin(settings.TOWER_URL_BASE, "/#/jobs/build/{}".format(self.pk)) + + ''' + JobNotificationMixin + ''' + + def get_notification_templates(self): + # ExecutionEnvironmentBuilder doesn't have notification templates, return empty + return [] + + def get_notification_friendly_name(self): + return "Execution Environment Builder Build" + + def notification_data(self): + data = super(ExecutionEnvironmentBuilderBuild, self).notification_data() + data.update( + dict( + execution_environment_builder=self.execution_environment_builder.name if self.execution_environment_builder else None, + ) + ) + return data diff --git a/awx/main/models/execution_environment_builders.py b/awx/main/models/execution_environment_builders.py new file mode 100644 index 00000000..b0c8c1f7 --- /dev/null +++ b/awx/main/models/execution_environment_builders.py @@ -0,0 +1,80 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from awx.api.versioning import reverse +from awx.main.models.base import CommonModel +from awx.main.fields import ImplicitRoleField +from awx.main.models.rbac import ( + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, + ROLE_SINGLETON_SYSTEM_AUDITOR, +) + + +__all__ = ['ExecutionEnvironmentBuilder'] + + +class ExecutionEnvironmentBuilder(CommonModel): + """ + A ExecutionEnvironmentBuilder represents a configuration for building + custom Execution Environments using ansible-builder. + """ + + class Meta: + ordering = ('id',) + + organization = models.ForeignKey( + 'Organization', + null=True, + default=None, + blank=True, + on_delete=models.CASCADE, + related_name='%(class)ss', + help_text=_('The organization used to determine access to this execution environment builder.'), + ) + image = models.CharField( + max_length=1024, + blank=True, + default='', + verbose_name=_('Image Name'), + help_text=_('The name for the built execution environment image'), + ) + tag = models.CharField( + max_length=1024, + blank=True, + default='latest', + verbose_name=_('Image Tag'), + help_text=_('The tag for the built execution environment image'), + ) + credential = models.ForeignKey( + 'Credential', + related_name='%(class)ss', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_('Container registry credential for pushing the built image'), + ) + definition = models.TextField( + blank=True, + default='', + verbose_name=_('Definition'), + help_text=_('Ansible builder execution environment definition'), + ) + + admin_role = ImplicitRoleField( + parent_role=[ + 'organization.execution_environment_builder_admin_role', + 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, + ] + ) + + read_role = ImplicitRoleField( + parent_role=[ + 'organization.auditor_role', + 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, + 'admin_role', + ] + ) + + def get_absolute_url(self, request=None): + return reverse('api:execution_environment_builder_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index e977ef93..b001d7f3 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1046,6 +1046,7 @@ def event_parent_key(self): 'main_projectupdate': 'project_update_id', 'main_inventoryupdate': 'inventory_update_id', 'main_systemjob': 'system_job_id', + 'main_executionenvironmentbuilderbuild': 'execution_environment_builder_build_id', }[tablename] @property diff --git a/awx/main/scheduler/dependency_graph.py b/awx/main/scheduler/dependency_graph.py index c0f2801f..fbb2a2d7 100644 --- a/awx/main/scheduler/dependency_graph.py +++ b/awx/main/scheduler/dependency_graph.py @@ -1,9 +1,10 @@ from awx.main.models import ( + AdHocCommand, + ExecutionEnvironmentBuilderBuild, Job, ProjectUpdate, InventoryUpdate, SystemJob, - AdHocCommand, WorkflowJob, ) @@ -124,6 +125,9 @@ def task_blocked_by(self, job): return self.ad_hoc_command_blocked_by(job) elif type(job) is WorkflowJob: return self.workflow_job_blocked_by(job) + elif type(job) is ExecutionEnvironmentBuilderBuild: + # ExecutionEnvironmentBuilderBuild jobs have no blocking logic + return None def add_job(self, job): if type(job) is ProjectUpdate: @@ -139,6 +143,9 @@ def add_job(self, job): self.mark_system_job(job) elif type(job) is AdHocCommand: self.mark_inventory_update(job) + elif type(job) is ExecutionEnvironmentBuilderBuild: + # ExecutionEnvironmentBuilderBuild jobs don't participate in blocking graph + pass def add_jobs(self, jobs): for j in jobs: diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 7e72d4cb..c4a8045c 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -22,6 +22,7 @@ # AWX from awx.main.dispatch.reaper import reap_job from awx.main.models import ( + ExecutionEnvironmentBuilderBuild, Instance, InventorySource, InventoryUpdate, @@ -383,6 +384,9 @@ def generate_dependencies(self, undeped_tasks): job_deps = self.gen_dep_for_job(task) elif type(task) is InventoryUpdate: job_deps = self.gen_dep_for_inventory_update(task) + elif type(task) is ExecutionEnvironmentBuilderBuild: + # ExecutionEnvironmentBuilderBuild jobs have no dependencies + job_deps = [] else: continue if job_deps: diff --git a/awx/main/tasks/callback.py b/awx/main/tasks/callback.py index 069bc408..118c8162 100644 --- a/awx/main/tasks/callback.py +++ b/awx/main/tasks/callback.py @@ -252,3 +252,7 @@ def __init__(self, *args, **kwargs): class RunnerCallbackForSystemJob(RunnerCallback): pass + + +class RunnerCallbackForExecutionEnvironmentBuilderBuild(RunnerCallback): + pass diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index d6dae280..dd24e845 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -50,11 +50,13 @@ ProjectUpdate, InventoryUpdate, SystemJob, + ExecutionEnvironmentBuilderBuild, JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent, + ExecutionEnvironmentBuilderBuildEvent, build_safe_env, ) from awx.main.tasks.callback import ( @@ -63,6 +65,7 @@ RunnerCallbackForInventoryUpdate, RunnerCallbackForProjectUpdate, RunnerCallbackForSystemJob, + RunnerCallbackForExecutionEnvironmentBuilderBuild, ) from awx.main.tasks.signals import with_signal_handling, signal_callback from awx.main.tasks.receptor import AWXReceptorJob @@ -155,6 +158,7 @@ def build_execution_environment_params(self, instance, private_data_dir): return {} image = instance.execution_environment.image + #image = "ghcr.io/ctrliq/ascender-ee:1b5b28d8c3d7fcae98a678b67d22c1b52d0dd152c8d1b924d3b8a9cb0907f71b" params = { "container_image": image, "process_isolation": True, @@ -651,16 +655,19 @@ def run(self, pk, **kwargs): # Field host_status_counts is used as a metric to check if event processing is finished # we send notifications if it is, if not, callback receiver will send them - if (self.instance.host_status_counts is not None) or (not self.runner_callback.wrapup_event_dispatched): + if self.instance and ((self.instance.host_status_counts is not None) or (not self.runner_callback.wrapup_event_dispatched)): self.instance.send_notification_templates('succeeded' if status == 'successful' else 'failed') try: - self.final_run_hook(self.instance, status, private_data_dir) + if self.instance: + self.final_run_hook(self.instance, status, private_data_dir) except Exception: - logger.exception('{} Final run hook errored.'.format(self.instance.log_format)) + if self.instance: + logger.exception('{} Final run hook errored.'.format(self.instance.log_format)) - self.instance.websocket_emit_status(status) - if status != 'successful': + if self.instance: + self.instance.websocket_emit_status(status) + if self.instance and status != 'successful': if status == 'canceled': raise AwxTaskError.TaskCancel(self.instance, rc) else: @@ -1919,3 +1926,108 @@ def build_playbook_path_relative_to_cwd(self, job, private_data_dir): def build_inventory(self, instance, private_data_dir): return None + + +@task(queue=get_task_queuename) +class RunExecutionEnvironmentBuilderBuild(BaseTask): + model = ExecutionEnvironmentBuilderBuild + event_model = ExecutionEnvironmentBuilderBuildEvent + callback_class = RunnerCallbackForExecutionEnvironmentBuilderBuild + + def build_private_data(self, builder_build, private_data_dir): + """ + Return credential data needed for this builder build. + """ + private_data = {'credentials': {}} + if builder_build.execution_environment_builder.credential: + credential = builder_build.execution_environment_builder.credential + if credential.has_input('ssh_key_data'): + private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='') + return private_data + + def build_passwords(self, builder_build, runtime_passwords): + """ + Build a dictionary of passwords for SSH private key unlock and registry auth. + """ + passwords = super(RunExecutionEnvironmentBuilderBuild, self).build_passwords(builder_build, runtime_passwords) + if builder_build.execution_environment_builder.credential: + passwords['registry_key_unlock'] = builder_build.execution_environment_builder.credential.get_input('ssh_key_unlock', default='') + passwords['registry_username'] = builder_build.execution_environment_builder.credential.get_input('username', default='') + passwords['registry_password'] = builder_build.execution_environment_builder.credential.get_input('password', default='') + return passwords + + def build_env(self, builder_build, private_data_dir, private_data_files=None): + """ + Build environment dictionary for ansible-playbook. + """ + env = super(RunExecutionEnvironmentBuilderBuild, self).build_env(builder_build, private_data_dir, private_data_files=private_data_files) + env['ANSIBLE_RETRY_FILES_ENABLED'] = str(False) + env['ANSIBLE_ASK_PASS'] = str(False) + env['ANSIBLE_BECOME_ASK_PASS'] = str(False) + env['DISPLAY'] = '' + env['TMP'] = settings.AWX_ISOLATION_BASE_PATH + env['EXECUTION_ENVIRONMENT_BUILDER_BUILD_ID'] = str(builder_build.pk) + return env + + def build_inventory(self, instance, private_data_dir): + return 'localhost,' + + def build_args(self, builder_build, private_data_dir, passwords): + """ + Build command line argument list for running ansible-playbook. + """ + args = [] + if getattr(settings, 'EXECUTION_ENVIRONMENT_BUILDER_BUILD_VVV', False): + args.append('-vvv') + return args + + def build_extra_vars_file(self, builder_build, private_data_dir): + extra_vars = {} + builder = builder_build.execution_environment_builder + + extra_vars.update( + { + 'execution_environment_builder_id': builder.pk, + 'execution_environment_builder_build_id': builder_build.pk, + 'execution_environment_name': builder.name, + 'execution_environment_image': builder.image, + 'execution_environment_tag': builder.tag, + 'execution_environment_definition': builder.definition, + } + ) + + if builder.credential: + extra_vars['registry_credential'] = { + 'url': builder.credential.get_input('host', default=''), + 'username': builder.credential.get_input('username', default=''), + 'password': builder.credential.get_input('password', default=''), + 'verify_ssl': builder.credential.get_input('verify_ssl', default=True) + } + + self._write_extra_vars_file(private_data_dir, extra_vars) + + def build_playbook_path_relative_to_cwd(self, builder_build, private_data_dir): + return os.path.join('build_ee.yml') + + def pre_run_hook(self, instance, private_data_dir): + super(RunExecutionEnvironmentBuilderBuild, self).pre_run_hook(instance, private_data_dir) + + def build_execution_environment_params(self, instance, private_data_dir): + """ + Return params structure to be executed by the container runtime. + For builder builds, we extend the base params to add security options. + """ + params = super(RunExecutionEnvironmentBuilderBuild, self).build_execution_environment_params(instance, private_data_dir) + # Add security options for container builds + if params and 'container_options' in params: + params['container_options'].extend(['--privileged']) + return params + + def build_project_dir(self, instance, private_data_dir): + # the build_ee playbook is not in a git repo, but uses a vendoring directory + # to be consistent with the ansible-runner model, + # that is moved into the runner project folder here + awx_playbooks = self.get_path_to('../../', 'playbooks') + import shutil + shutil.copytree(awx_playbooks, os.path.join(private_data_dir, 'project')) + diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 915314a3..a531487b 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -575,7 +575,7 @@ def get_capacity_type(uj): return None elif model_name.startswith('unified'): raise RuntimeError(f'Capacity type is undefined for {model_name} model') - elif model_name in ('projectupdate', 'systemjob', 'project', 'systemjobtemplate'): + elif model_name in ('projectupdate', 'systemjob', 'project', 'systemjobtemplate', 'executionenvironmentbuilderbuild'): return 'control' raise RuntimeError(f'Capacity type does not apply to {model_name} model') diff --git a/awx/playbooks/build_ee.yml b/awx/playbooks/build_ee.yml new file mode 100644 index 00000000..20fae416 --- /dev/null +++ b/awx/playbooks/build_ee.yml @@ -0,0 +1,106 @@ +- hosts: localhost + gather_facts: false + connection: local + name: Build the Execution Environment + tasks: + - name: Display EE build info + ansible.builtin.debug: + msg: "Building Execution Environment: {{ execution_environment_image }}:{{ execution_environment_tag }}" + + - name: Set execution_environment_definition fact + ansible.builtin.set_fact: + eed: "{{ execution_environment_definition | from_yaml }}" + + - name: Validate execution environment definition is provided + ansible.builtin.assert: + that: + - eed is defined + - eed | length > 0 + - eed.version is defined + - eed.version == 3 + - eed.images.base_image is defined + - eed.dependencies.ansible_core is defined + - eed.dependencies.ansible_runner is defined + - eed.dependencies.galaxy is defined + fail_msg: "Proper Execution environment v3 definition is required to build an execution environment." + + - name: Validate execution environment definition is provided + ansible.builtin.assert: + that: + - eed.additional_build_files is not defined + fail_msg: "Ascender EE builder does not support additional build files." + + - name: Ensure container config directories exists + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0700' + loop: + - ~/.docker + - ~/.config/containers + + - name: Create Registries.conf + ansible.builtin.copy: + dest: ~/.config/containers/registries.conf + content: | + short-name-mode="permissive" + unqualified-search-registries = ["quay.io", "docker.io"] + become: true + + - name: Check for ansible-builder command + ansible.builtin.command: command -v ansible-builder + register: ansible_builder_check + ignore_errors: true + failed_when: false + + - name: Install ansible-builder via pip if not present + ansible.builtin.pip: + name: ansible-builder + state: present + when: ansible_builder_check.rc != 0 + + - name: Check for buildah + ansible.builtin.command: command -v buildah + register: buildah_check + ignore_errors: true + failed_when: false + + - name: Install buildah if not installed + ansible.builtin.command: microdnf install -y buildah + when: buildah_check.rc != 0 + + - name: Create execution environment definition file + ansible.builtin.copy: + dest: ./execution-environment.yml + content: "{{ execution_environment_definition }}" + + - name: Create registry login file + ansible.builtin.copy: + dest: ~/.docker/config.json + content: | + { + "auths": { + "{{ registry_credential.url }}": { + "auth": "{{ (registry_credential.username + ":" + registry_credential.password) | b64encode }}" + } + } + } + no_log: true + when: registry_credential is defined + + - name: Run ansible-builder to create the EE context + ansible.builtin.command: ansible-builder create -v3 --context=./context + + - name: Run buildah to build the EE image + ansible.builtin.command: > + buildah + --storage-driver=vfs + --network=host + {{ '--tls-verify=false' if registry_credential is defined and registry_credential.verify_ssl==False else '' }} + bud + -t + "{{ (execution_environment_image + ':' + execution_environment_tag) | quote }}" + ./context + + # - pause: + # minutes: 300 diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js index 9c78db6e..2738e652 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -9,6 +9,8 @@ import Credentials from './models/Credentials'; import ConstructedInventories from './models/ConstructedInventories'; import Dashboard from './models/Dashboard'; import ExecutionEnvironments from './models/ExecutionEnvironments'; +import ExecutionEnvironmentBuilders from './models/ExecutionEnvironmentBuilders'; +import ExecutionEnvironmentBuilderBuilds from './models/ExecutionEnvironmentBuilderBuilds'; import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; @@ -60,6 +62,8 @@ const CredentialsAPI = new Credentials(); const ConstructedInventoriesAPI = new ConstructedInventories(); const DashboardAPI = new Dashboard(); const ExecutionEnvironmentsAPI = new ExecutionEnvironments(); +const ExecutionEnvironmentBuildersAPI = new ExecutionEnvironmentBuilders(); +const ExecutionEnvironmentBuilderBuildsAPI = new ExecutionEnvironmentBuilderBuilds(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); @@ -112,6 +116,8 @@ export { ConstructedInventoriesAPI, DashboardAPI, ExecutionEnvironmentsAPI, + ExecutionEnvironmentBuildersAPI, + ExecutionEnvironmentBuilderBuildsAPI, GroupsAPI, HostsAPI, InstanceGroupsAPI, diff --git a/awx/ui/src/api/models/ExecutionEnvironmentBuilderBuilds.js b/awx/ui/src/api/models/ExecutionEnvironmentBuilderBuilds.js new file mode 100644 index 00000000..996227db --- /dev/null +++ b/awx/ui/src/api/models/ExecutionEnvironmentBuilderBuilds.js @@ -0,0 +1,15 @@ +import Base from '../Base'; +import RunnableMixin from '../mixins/Runnable.mixin'; + +class ExecutionEnvironmentBuilderBuilds extends RunnableMixin(Base) { + constructor(http) { + super(http); + this.baseUrl = 'api/v2/builds/'; + } + + readStdout(id) { + return this.http.get(`${this.baseUrl}${id}/stdout/`); + } +} + +export default ExecutionEnvironmentBuilderBuilds; diff --git a/awx/ui/src/api/models/ExecutionEnvironmentBuilders.js b/awx/ui/src/api/models/ExecutionEnvironmentBuilders.js new file mode 100644 index 00000000..5c99d663 --- /dev/null +++ b/awx/ui/src/api/models/ExecutionEnvironmentBuilders.js @@ -0,0 +1,31 @@ +import Base from '../Base'; + +class ExecutionEnvironmentBuilders extends Base { + constructor(http) { + super(http); + this.baseUrl = 'api/v2/execution_environment_builders/'; + + this.readAccessList = this.readAccessList.bind(this); + this.readAccessOptions = this.readAccessOptions.bind(this); + this.copy = this.copy.bind(this); + this.launch = this.launch.bind(this); + } + + readAccessList(id, params) { + return this.http.get(`${this.baseUrl}${id}/access_list/`, { params }); + } + + readAccessOptions(id) { + return this.http.options(`${this.baseUrl}${id}/access_list/`); + } + + copy(id, data) { + return this.http.post(`${this.baseUrl}${id}/copy/`, data); + } + + launch(id, data) { + return this.http.post(`${this.baseUrl}${id}/launch/`, data); + } +} + +export default ExecutionEnvironmentBuilders; diff --git a/awx/ui/src/components/JobList/JobListItem.js b/awx/ui/src/components/JobList/JobListItem.js index 252dc2a7..56263e1e 100644 --- a/awx/ui/src/components/JobList/JobListItem.js +++ b/awx/ui/src/components/JobList/JobListItem.js @@ -49,6 +49,7 @@ function JobListItem({ ad_hoc_command: t`Command`, system_job: t`Management Job`, workflow_job: t`Workflow Job`, + execution_environment_builder_build: t`Execution Environment Build`, }; const { diff --git a/awx/ui/src/components/LaunchButton/LaunchButton.js b/awx/ui/src/components/LaunchButton/LaunchButton.js index e80397d2..28126492 100644 --- a/awx/ui/src/components/LaunchButton/LaunchButton.js +++ b/awx/ui/src/components/LaunchButton/LaunchButton.js @@ -10,6 +10,7 @@ import { ProjectsAPI, WorkflowJobsAPI, WorkflowJobTemplatesAPI, + ExecutionEnvironmentBuilderBuildsAPI, } from 'api'; import useToast, { AlertVariant } from 'hooks/useToast'; import AlertModal from '../AlertModal'; @@ -189,6 +190,8 @@ function LaunchButton({ resource, children }) { readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); } else if (resource.type === 'job') { readRelaunch = JobsAPI.readRelaunch(resource.id); + } else if (resource.type === 'execution_environment_builder_build') { + readRelaunch = ExecutionEnvironmentBuilderBuildsAPI.readRelaunch(resource.id); } try { @@ -210,6 +213,8 @@ function LaunchButton({ resource, children }) { relaunch = AdHocCommandsAPI.relaunch(resource.id); } else if (resource.type === 'job') { relaunch = JobsAPI.relaunch(resource.id, params || {}); + } else if (resource.type === 'execution_environment_builder_build') { + relaunch = ExecutionEnvironmentBuilderBuildsAPI.relaunch(resource.id, params || {}); } const { data: job } = await relaunch; if (isMounted.current) history.push(`/jobs/${job.id}/output`); diff --git a/awx/ui/src/constants.js b/awx/ui/src/constants.js index 348c8969..a33b8357 100644 --- a/awx/ui/src/constants.js +++ b/awx/ui/src/constants.js @@ -5,6 +5,7 @@ export const JOB_TYPE_URL_SEGMENTS = { inventory_update: 'inventory', ad_hoc_command: 'command', workflow_job: 'workflow', + execution_environment_builder_build: 'build', }; export const SESSION_TIMEOUT_KEY = 'awx-session-timeout'; diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js index 4b46e602..45323142 100644 --- a/awx/ui/src/routeConfig.js +++ b/awx/ui/src/routeConfig.js @@ -7,6 +7,7 @@ import CredentialTypes from 'screens/CredentialType'; import Credentials from 'screens/Credential'; import Dashboard from 'screens/Dashboard'; import ExecutionEnvironments from 'screens/ExecutionEnvironment'; +import ExecutionEnvironmentBuilder from 'screens/ExecutionEnvironmentBuilder'; import Hosts from 'screens/Host'; import Instances from 'screens/Instances'; import InstanceGroups from 'screens/InstanceGroup'; @@ -127,6 +128,17 @@ function getRouteConfig(userProfile = {}) { }, ], }, + { + groupTitle: Tools, + groupId: 'tools_group', + routes: [ + { + title: Execution Environment Builder, + path: '/execution_environment_builders', + screen: ExecutionEnvironmentBuilder, + }, + ], + }, { groupTitle: Administration, groupId: 'administration_group', @@ -211,6 +223,7 @@ function getRouteConfig(userProfile = {}) { deleteRoute('topology_view'); deleteRoute('instances'); deleteRoute('subscription_usage'); + if (!userProfile?.isExecutionEnvironmentAdmin) deleteRouteGroup('tools_group'); if (userProfile?.isOrgAdmin) return routeConfig; if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates'); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilder.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilder.js new file mode 100644 index 00000000..9f7ea9d5 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilder.js @@ -0,0 +1,42 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { Route, useRouteMatch, Switch } from 'react-router-dom'; +import useRequest from 'hooks/useRequest'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import ExecutionEnvironmentBuilderDetails from './ExecutionEnvironmentBuilderDetail'; +import ExecutionEnvironmentBuilderEdit from './ExecutionEnvironmentBuilderEdit'; + +function ExecutionEnvironmentBuilder({ setBreadcrumb }) { + const match = useRouteMatch(); + const [builder, setBuilder] = useState(null); + + const { request: fetchBuilder, isLoading } = useRequest( + useCallback(async () => { + const { data } = await ExecutionEnvironmentBuildersAPI.readDetail(match.params.id); + setBuilder(data); + setBreadcrumb(data); + return data; + }, [match.params.id, setBreadcrumb]) + ); + + useEffect(() => { + fetchBuilder(); + }, [match.params.id, fetchBuilder]); + + const handleBuilderUpdate = useCallback((updatedBuilder) => { + setBuilder(updatedBuilder); + setBreadcrumb(updatedBuilder); + }, [setBreadcrumb]); + + return ( + + + + + + + + + ); +} + +export default ExecutionEnvironmentBuilder; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js new file mode 100644 index 00000000..bdea41d0 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CardBody } from 'components/Card'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import ExecutionEnvironmentBuilderForm from '../shared/ExecutionEnvironmentBuilderForm'; + +function ExecutionEnvironmentBuilderAdd() { + const [formSubmitError, setFormSubmitError] = useState(null); + const history = useHistory(); + + const handleSubmit = async (values) => { + setFormSubmitError(null); + try { + const submitData = { + ...values, + credential: values.credential?.id || null, + }; + const { + data: { id }, + } = await ExecutionEnvironmentBuildersAPI.create(submitData); + history.push(`/execution_environment_builders/${id}`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + history.push('/execution_environment_builders'); + }; + + return ( + + + + + + + + ); +} + +export default ExecutionEnvironmentBuilderAdd; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/index.js new file mode 100644 index 00000000..33093792 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilderAdd'; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js new file mode 100644 index 00000000..a60c412b --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js @@ -0,0 +1,150 @@ +import React, { useState, useCallback } from 'react'; +import { useParams, Link, useHistory } from 'react-router-dom'; +import { Card, PageSection, Button } from '@patternfly/react-core'; +import { useLingui } from '@lingui/react/macro'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import { CardBody, CardActionsRow } from 'components/Card'; +import ContentLoading from 'components/ContentLoading'; +import { Detail, DetailList, UserDateDetail } from 'components/DetailList'; +import { VariablesDetail } from 'components/CodeEditor'; +import DeleteButton from 'components/DeleteButton'; +import AlertModal from 'components/AlertModal'; + +function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { + const { t } = useLingui(); + const { id } = useParams(); + const history = useHistory(); + const [isLaunchDisabled, setIsLaunchDisabled] = useState(false); + + const { + request: deleteBuilder, + isLoading: deleteLoading, + error: deleteError, + } = useRequest( + useCallback(async () => { + await ExecutionEnvironmentBuildersAPI.destroy(id); + history.push('/execution_environment_builders'); + }, [id, history]) + ); + + const launchBuilder = useCallback(async () => { + try { + setIsLaunchDisabled(true); + const response = await ExecutionEnvironmentBuildersAPI.launch(id, { + name: `${builder?.name}`, + }); + if (response.status === 201) { + history.push(`/jobs/build/${response.data.execution_environment_builder_build}`); + } + } catch (error) { + setIsLaunchDisabled(false); + } + }, [id, builder?.name, history]); + + const { error, dismissError } = useDismissableError(deleteError); + + if (isLoading) { + return ; + } + + if (!builder) { + return
{t`Execution Environment Builder not found`}
; + } + + return ( + + + + + + + + {builder.definition && ( + + )} + {builder.organization && ( + + {builder.organization.name} + + } + /> + )} + {builder.summary_fields?.credential && ( + + )} + + + + + + {builder.summary_fields?.user_capabilities?.edit && ( + + )} + {builder.summary_fields?.user_capabilities?.delete && ( + + {t`Delete`} + + )} + + {error && ( + + )} + + + + ); +} + +export default ExecutionEnvironmentBuilderDetails; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/index.js new file mode 100644 index 00000000..292c3f44 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilderDetails'; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js new file mode 100644 index 00000000..4ce8626b --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Card, PageSection } from '@patternfly/react-core'; +import { useLingui } from '@lingui/react/macro'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { CardBody } from 'components/Card'; +import ExecutionEnvironmentBuilderForm from '../shared/ExecutionEnvironmentBuilderForm'; + +function ExecutionEnvironmentBuilderEdit({ builder, onUpdate }) { + const history = useHistory(); + const { t } = useLingui(); + const [formSubmitError, setFormSubmitError] = useState(null); + + const handleSubmit = async (values) => { + setFormSubmitError(null); + try { + const submitData = { + ...values, + credential: values.credential?.id || null, + }; + const { data: updatedBuilder } = + await ExecutionEnvironmentBuildersAPI.update(builder.id, submitData); + if (onUpdate) { + onUpdate(updatedBuilder); + } + history.push(`/execution_environment_builders/${builder.id}`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + history.push(`/execution_environment_builders/${builder.id}`); + }; + + if (!builder) { + return
{t`Loading...`}
; + } + + return ( + + + + + + + + ); +} + +export default ExecutionEnvironmentBuilderEdit; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/index.js new file mode 100644 index 00000000..289f12c2 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilderEdit'; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js new file mode 100644 index 00000000..ca991865 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js @@ -0,0 +1,211 @@ +import React, { useEffect, useCallback } from 'react'; +import { useLocation, useRouteMatch } from 'react-router-dom'; +import { useLingui } from '@lingui/react/macro'; +import { Card, PageSection } from '@patternfly/react-core'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import useRequest, { useDeleteItems } from 'hooks/useRequest'; +import AlertModal from 'components/AlertModal'; +import DataListToolbar from 'components/DataListToolbar'; +import ErrorDetail from 'components/ErrorDetail'; +import PaginatedTable, { + HeaderRow, + HeaderCell, + ToolbarAddButton, + ToolbarDeleteButton, + getSearchableKeys, +} from 'components/PaginatedTable'; +import useSelected from 'hooks/useSelected'; +import useToast from 'hooks/useToast'; +import { getQSConfig, parseQueryString } from 'util/qs'; + +import ExecutionEnvironmentBuilderListItem from './ExecutionEnvironmentBuilderListItem'; + +const QS_CONFIG = getQSConfig('execution_environment_builder', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function ExecutionEnvironmentBuilderList() { + const { t } = useLingui(); + const location = useLocation(); + const match = useRouteMatch(); + const { addToast, Toast, toastProps } = useToast(); + + const { + result: { + results, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, + isLoading, + request: fetchBuilders, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, actionsResponse] = await Promise.all([ + ExecutionEnvironmentBuildersAPI.read(params), + ExecutionEnvironmentBuildersAPI.readOptions(), + ]); + return { + results: response.data.results, + itemCount: response.data.count, + actions: actionsResponse.data.actions, + relatedSearchableKeys: ( + actionsResponse?.data?.related_search_fields || [] + ).map((val) => val.slice(0, -8)), + searchableKeys: getSearchableKeys(actionsResponse.data.actions?.GET), + }; + }, [location]), + { + results: [], + itemCount: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchBuilders(); + }, [fetchBuilders]); + + const { selected, isAllSelected, handleSelectAll, clearSelected, handleSelect } = + useSelected(results); + + const { + isLoading: deleteLoading, + deletionError, + deleteItems: deleteBuilders, + clearDeletionError, + } = useDeleteItems( + useCallback(async () => { + await Promise.all( + selected.map(({ id }) => ExecutionEnvironmentBuildersAPI.destroy(id)) + ); + }, [selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchBuilders, + } + ); + + const handleDelete = async () => { + await deleteBuilders(); + clearSelected(); + }; + + const handleCopy = useCallback( + () => { + addToast({ + id: 'execution_environment_builder_copy_success', + title: t`Success!`, + variant: 'success', + description: t`Execution Environment Builder copied successfully`, + }); + }, + [addToast, t] + ); + + const canAdd = actions && actions.POST; + const deleteDetailsRequests = []; + + return ( + <> + + + + {t`Name`} + {t`Image`} + {t`Tag`} + {t`Actions`} + + } + renderToolbar={(props) => ( + , + ] + : []), + , + ]} + /> + )} + renderRow={(executionEnvironmentBuilder, index) => ( + row.id === executionEnvironmentBuilder.id + )} + onSelect={() => handleSelect(executionEnvironmentBuilder)} + onCopy={handleCopy} + rowIndex={index} + fetchExecutionEnvironmentBuilders={fetchBuilders} + /> + )} + /> + + + + {t`Failed to delete one or more execution environment builders`} + + + + + ); +} + +export default ExecutionEnvironmentBuilderList; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js new file mode 100644 index 00000000..e5a3b0e1 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js @@ -0,0 +1,153 @@ +import React, { useState, useCallback } from 'react'; +import { string, bool, func } from 'prop-types'; +import { useLingui } from '@lingui/react/macro'; +import { Link, useHistory } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; +import { PencilAltIcon, RocketIcon } from '@patternfly/react-icons'; + +import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable'; +import CopyButton from 'components/CopyButton'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { timeOfDay } from 'util/dates'; + +function ExecutionEnvironmentBuilderListItem({ + executionEnvironmentBuilder, + detailUrl, + isSelected, + onSelect, + onCopy, + rowIndex, + fetchExecutionEnvironmentBuilders, +}) { + const { t } = useLingui(); + const history = useHistory(); + const [isDisabled, setIsDisabled] = useState(false); + const [isLaunchDisabled, setIsLaunchDisabled] = useState(false); + + const copyExecutionEnvironmentBuilder = useCallback(async () => { + const response = await ExecutionEnvironmentBuildersAPI.copy( + executionEnvironmentBuilder.id, + { + name: `${executionEnvironmentBuilder.name} @ ${timeOfDay()}`, + } + ); + if (response.status === 201) { + onCopy(response.data.id); + } + await fetchExecutionEnvironmentBuilders(); + }, [ + executionEnvironmentBuilder.id, + executionEnvironmentBuilder.name, + fetchExecutionEnvironmentBuilders, + onCopy, + ]); + + const launchBuild = useCallback(async () => { + try { + setIsLaunchDisabled(true); + const response = await ExecutionEnvironmentBuildersAPI.launch( + executionEnvironmentBuilder.id, + { + name: `${executionEnvironmentBuilder.name}`, + } + ); + if (response.status === 201) { + history.push(`/jobs/build/${response.data.execution_environment_builder_build}`); + } + } catch (error) { + setIsLaunchDisabled(false); + } + }, [executionEnvironmentBuilder.id, executionEnvironmentBuilder.name, history]); + + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + + return ( + + + + + {executionEnvironmentBuilder.name} + + + + {executionEnvironmentBuilder.image} + + + {executionEnvironmentBuilder.tag} + + + + + + + + + + + + + + ); +} + +ExecutionEnvironmentBuilderListItem.propTypes = { + executionEnvironmentBuilder: string.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + onCopy: func.isRequired, + rowIndex: func.isRequired, + fetchExecutionEnvironmentBuilders: func.isRequired, +}; + +export default ExecutionEnvironmentBuilderListItem; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/index.js new file mode 100644 index 00000000..a29edb6d --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilderList'; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.js new file mode 100644 index 00000000..4c1053a2 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.js @@ -0,0 +1,61 @@ +import React, { useState, useCallback } from 'react'; +import { Route, withRouter, Switch } from 'react-router-dom'; +import { useLingui } from '@lingui/react/macro'; +import ScreenHeader from 'components/ScreenHeader/ScreenHeader'; +import PersistentFilters from 'components/PersistentFilters'; +import ExecutionEnvironmentBuildersList from './ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList'; +import ExecutionEnvironmentBuilderAdd from './ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd'; +import ExecutionEnvironmentBuilder from './ExecutionEnvironmentBuilder'; + +function ExecutionEnvironmentBuilders() { + const { t } = useLingui(); + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + '/execution_environment_builders': t`Execution Environment Builders`, + '/execution_environment_builders/add': t`Create New Execution Environment Builder`, + }); + + const buildBreadcrumbConfig = useCallback( + (builder, nested) => { + if (!builder) { + return; + } + const builderSchedulesPath = `/execution_environment_builders/${builder.id}/schedules`; + setBreadcrumbConfig({ + '/execution_environment_builders': t`Execution Environment Builders`, + '/execution_environment_builders/add': t`Create New Execution Environment Builder`, + [`/execution_environment_builders/${builder.id}`]: `${builder.name}`, + [`/execution_environment_builders/${builder.id}/edit`]: t`Edit Details`, + [`/execution_environment_builders/${builder.id}/details`]: t`Details`, + [`/execution_environment_builders/${builder.id}/access`]: t`Access`, + [`${builderSchedulesPath}`]: t`Schedules`, + [`${builderSchedulesPath}/add`]: t`Create New Schedule`, + [`${builderSchedulesPath}/${nested?.id}`]: `${nested?.name}`, + [`${builderSchedulesPath}/${nested?.id}/details`]: t`Schedule Details`, + [`${builderSchedulesPath}/${nested?.id}/edit`]: t`Edit Details`, + }); + }, + [t] + ); + + return ( + <> + + + + + + + + + + + + + + + + ); +} + +export { ExecutionEnvironmentBuilders as _ExecutionEnvironmentBuilders }; +export default withRouter(ExecutionEnvironmentBuilders); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js new file mode 100644 index 00000000..daf6ebf3 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilders'; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.js new file mode 100644 index 00000000..62598219 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.js @@ -0,0 +1,156 @@ +import React, { useCallback } from 'react'; +import { func, shape } from 'prop-types'; +import { Formik, useField, useFormikContext } from 'formik'; +import { useLingui } from '@lingui/react/macro'; +import { Form } from '@patternfly/react-core'; +import { VariablesField } from 'components/CodeEditor'; +import FormField, { FormSubmitError } from 'components/FormField'; +import FormActionGroup from 'components/FormActionGroup'; +import { FormColumnLayout, FormFullWidthLayout } from 'components/FormLayout'; +import { required } from 'util/validators'; +import CredentialLookup from 'components/Lookup/CredentialLookup'; + +function ExecutionEnvironmentBuilderFormFields() { + const { t } = useLingui(); + const [credentialField, credentialMeta, credentialHelpers] = + useField('credential'); + + const { setFieldValue } = useFormikContext(); + + const onCredentialChange = useCallback( + (value) => { + setFieldValue('credential', value); + }, + [setFieldValue] + ); + + return ( + <> + + + + credentialHelpers.setTouched()} + onChange={onCredentialChange} + value={credentialField.value} + /> + + + + + ); +} + +function ExecutionEnvironmentBuilderForm({ + executionEnvironmentBuilder = {}, + onSubmit, + onCancel, + submitError, + ...rest +}) { + const defaultDefinition = `--- +version: 3 + +images: + base_image: + name: 'registry.access.redhat.com/ubi9/python-311:latest' + +options: + package_manager_path: /usr/bin/dnf + +dependencies: + ansible_core: + package_pip: ansible-core>=2.16.14,<2.17 + ansible_runner: + package_pip: ansible-runner + galaxy: | + --- + collections: + - name: ansible.posix + - name: community.general + system: | + sshpass [platform:rpm] + python: | + requests>=2.25.1 + jinja2>=3.0.0 + jmespath>=1.0 + PyYAML>=6.0 + +additional_build_steps: + prepend_base: +`; + + const initialValues = { + name: executionEnvironmentBuilder.name || '', + image: executionEnvironmentBuilder.image || '', + tag: executionEnvironmentBuilder.tag || 'latest', + definition: executionEnvironmentBuilder.definition || defaultDefinition, + credential: executionEnvironmentBuilder.summary_fields?.credential || null, + }; + + return ( + onSubmit(values)} + > + {(formik) => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +ExecutionEnvironmentBuilderForm.propTypes = { + executionEnvironmentBuilder: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +ExecutionEnvironmentBuilderForm.defaultProps = { + executionEnvironmentBuilder: {}, + submitError: null, +}; + +export default ExecutionEnvironmentBuilderForm; diff --git a/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js new file mode 100644 index 00000000..b4235d0a --- /dev/null +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import styled from 'styled-components'; +import { useLingui } from '@lingui/react/macro'; + +import AlertModal from 'components/AlertModal'; +import { + DetailList, + Detail, + LaunchedByDetail, +} from 'components/DetailList'; +import { CardBody, CardActionsRow } from 'components/Card'; +import ChipGroup from 'components/ChipGroup'; +import CredentialChip from 'components/CredentialChip'; +import DeleteButton from 'components/DeleteButton'; +import ErrorDetail from 'components/ErrorDetail'; +import { LaunchButton } from 'components/LaunchButton'; +import StatusLabel from 'components/StatusLabel'; +import JobCancelButton from 'components/JobCancelButton'; +import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail'; +import { isJobRunning } from 'util/jobs'; +import { formatDateString } from 'util/dates'; +import { Job } from 'types'; +import { ExecutionEnvironmentBuilderBuildsAPI } from 'api'; + +const StatusDetailValue = styled.div` + align-items: center; + display: inline-grid; + grid-gap: 10px; + grid-template-columns: auto auto; +`; + +function ExecutionEnvironmentBuilderBuildDetail({ job }) { + const { t } = useLingui(); + const { + execution_environment_builder: builder, + execution_environment: executionEnvironment, + } = job.summary_fields; + const [errorMsg, setErrorMsg] = useState(); + const history = useHistory(); + + const credential = builder?.summary_fields?.credential; + + const deleteJob = async () => { + try { + await ExecutionEnvironmentBuilderBuildsAPI.destroy(job.id); + history.push('/jobs'); + } catch (err) { + setErrorMsg(err); + } + }; + + return ( + + + + + {validateReactNode(job.status) ? ( + + ) : ( + t`Unknown Status` + )} + {job?.job_explanation && job.job_explanation !== job.status + ? validateReactNode(job.job_explanation) + : null} + + } + /> + + + {job?.finished && ( + + )} + + + + + {credential && ( + + + + } + /> + )} + + + {job.summary_fields.user_capabilities.start && ( + + {({ handleRelaunch, isLaunching }) => ( + + )} + + )} + {isJobRunning(job.status) && + job?.summary_fields?.user_capabilities?.start && ( + + )} + {!isJobRunning(job.status) && + job?.summary_fields?.user_capabilities?.delete && ( + + {t`Delete`} + + )} + + {errorMsg && ( + setErrorMsg()} + title={t`Build Delete Error`} + > + + + )} + + ); +} + +ExecutionEnvironmentBuilderBuildDetail.propTypes = { + job: Job.isRequired, +}; + +export default ExecutionEnvironmentBuilderBuildDetail; + +function validateReactNode(value) { + if (value === null || value === undefined) return 'Unknown'; + if (typeof value === 'object') return JSON.stringify(value); + return value; +} diff --git a/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/index.js b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/index.js new file mode 100644 index 00000000..4f622e53 --- /dev/null +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/index.js @@ -0,0 +1 @@ +export { default } from './ExecutionEnvironmentBuilderBuildDetail'; diff --git a/awx/ui/src/screens/Job/Job.js b/awx/ui/src/screens/Job/Job.js index 23ee2a1f..bc584ffc 100644 --- a/awx/ui/src/screens/Job/Job.js +++ b/awx/ui/src/screens/Job/Job.js @@ -19,6 +19,7 @@ import useRequest from 'hooks/useRequest'; import { getJobModel } from 'util/jobs'; import WorkflowOutputNavigation from 'components/WorkflowOutputNavigation'; import JobDetail from './JobDetail'; +import ExecutionEnvironmentBuilderBuildDetail from './ExecutionEnvironmentBuilderBuildDetail'; import JobOutput from './JobOutput'; import { WorkflowOutput } from './WorkflowOutput'; import useWsJob from './useWsJob'; @@ -32,6 +33,7 @@ export const JOB_URL_SEGMENT_MAP = { inventory: 'inventory_update', command: 'ad_hoc_command', workflow: 'workflow_job', + build: 'execution_environment_builder_build', }; function Job({ setBreadcrumb }) { @@ -189,10 +191,14 @@ function Job({ setBreadcrumb }) { } path="/jobs/:typeSegment/:id/details" > - + {job.type === 'execution_environment_builder_build' ? ( + + ) : ( + + )} , {job.type === 'workflow_job' ? ( diff --git a/awx/ui/src/util/jobs.js b/awx/ui/src/util/jobs.js index 8319d6cb..5b6328e1 100644 --- a/awx/ui/src/util/jobs.js +++ b/awx/ui/src/util/jobs.js @@ -5,6 +5,7 @@ import { WorkflowJobsAPI, InventoryUpdatesAPI, AdHocCommandsAPI, + ExecutionEnvironmentBuilderBuildsAPI, } from 'api'; export function isJobRunning(status) { @@ -17,6 +18,7 @@ export function getJobModel(type) { if (type === 'project_update') return ProjectUpdatesAPI; if (type === 'system_job') return SystemJobsAPI; if (type === 'workflow_job') return WorkflowJobsAPI; + if (type === 'execution_environment_builder_build') return ExecutionEnvironmentBuilderBuildsAPI; return JobsAPI; } From 98eb5b0f1aee3930731fa81b665dbb7c451b0cb4 Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Mon, 2 Feb 2026 11:46:57 -0600 Subject: [PATCH 02/12] Update build_ee script --- awx/playbooks/build_ee.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/awx/playbooks/build_ee.yml b/awx/playbooks/build_ee.yml index 20fae416..78eed012 100644 --- a/awx/playbooks/build_ee.yml +++ b/awx/playbooks/build_ee.yml @@ -65,9 +65,29 @@ ignore_errors: true failed_when: false - - name: Install buildah if not installed + - name: Check for microdnf command + ansible.builtin.command: command -v microdnf + register: microdnf_check + ignore_errors: true + failed_when: false + + - name: Check for dnf command + ansible.builtin.command: command -v dnf + register: dnf_check + ignore_errors: true + failed_when: false + + - name: Install buildah via microdnf if not installed ansible.builtin.command: microdnf install -y buildah - when: buildah_check.rc != 0 + when: + - buildah_check.rc != 0 + - microdnf_check.rc == 0 + + - name: Install buildah via dnf if not installed + ansible.builtin.command: dnf install -y buildah + when: + - buildah_check.rc != 0 + - dnf_check.rc == 0 - name: Create execution environment definition file ansible.builtin.copy: From d2c12f69c7b33dc57feca616925ee55364ebfc43 Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sat, 28 Mar 2026 22:00:16 -0500 Subject: [PATCH 03/12] Fix migrations --- ...94_executionenvironmentbuilder_and_more.py | 119 ------------------ ...5_executionenvironmentbuilder_and_more.py} | 108 +++++++++++++++- 2 files changed, 106 insertions(+), 121 deletions(-) delete mode 100644 awx/main/migrations/0194_executionenvironmentbuilder_and_more.py rename awx/main/migrations/{0195_executionenvironmentbuilderbuild_and_more.py => 0195_executionenvironmentbuilder_and_more.py} (66%) diff --git a/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py deleted file mode 100644 index ca1dd9d4..00000000 --- a/awx/main/migrations/0194_executionenvironmentbuilder_and_more.py +++ /dev/null @@ -1,119 +0,0 @@ -# Generated by Django 5.2.9 on 2026-01-26 07:16 - -import awx.main.fields -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0193_alter_inventorysource_source_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ExecutionEnvironmentBuilder', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', models.DateTimeField(default=None, editable=False)), - ('modified', models.DateTimeField(default=None, editable=False)), - ('description', models.TextField(blank=True, default='')), - ('name', models.CharField(max_length=512, unique=True)), - ( - 'image', - models.CharField( - blank=True, default='', help_text='The name for the built execution environment image', max_length=1024, verbose_name='Image Name' - ), - ), - ( - 'tag', - models.CharField( - blank=True, default='latest', help_text='The tag for the built execution environment image', max_length=1024, verbose_name='Image Tag' - ), - ), - ( - 'definition', - models.TextField(blank=True, default='', help_text='Ansible builder execution environment definition', verbose_name='Definition'), - ), - ( - 'admin_role', - awx.main.fields.ImplicitRoleField( - editable=False, - null='True', - on_delete=django.db.models.deletion.CASCADE, - parent_role=['organization.execution_environment_builder_admin_role', 'singleton:system_administrator'], - related_name='+', - to='main.role', - ), - ), - ( - 'created_by', - models.ForeignKey( - default=None, - editable=False, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='%s(class)s_created+', - to=settings.AUTH_USER_MODEL, - ), - ), - ( - 'credential', - models.ForeignKey( - blank=True, - default=None, - help_text='Container registry credential for pushing the built image', - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='%(class)ss', - to='main.credential', - ), - ), - ( - 'modified_by', - models.ForeignKey( - default=None, - editable=False, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name='%s(class)s_modified+', - to=settings.AUTH_USER_MODEL, - ), - ), - ( - 'organization', - models.ForeignKey( - blank=True, - default=None, - help_text='The organization used to determine access to this execution environment builder.', - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name='%(class)ss', - to='main.organization', - ), - ), - ( - 'read_role', - awx.main.fields.ImplicitRoleField( - editable=False, - null='True', - on_delete=django.db.models.deletion.CASCADE, - parent_role=['organization.auditor_role', 'singleton:system_auditor', 'admin_role'], - related_name='+', - to='main.role', - ), - ), - ], - options={ - 'ordering': ('id',), - }, - ), - migrations.AddField( - model_name='activitystream', - name='execution_environment_builder', - field=models.ManyToManyField(blank=True, to='main.executionenvironmentbuilder'), - ), - ] diff --git a/awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py similarity index 66% rename from awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py rename to awx/main/migrations/0195_executionenvironmentbuilder_and_more.py index 2052aabb..c197e9de 100644 --- a/awx/main/migrations/0195_executionenvironmentbuilderbuild_and_more.py +++ b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py @@ -1,8 +1,9 @@ -# Generated by Django 5.2.9 on 2026-01-26 21:04 +# Generated by Django 5.2.9 on 2026-01-26 07:16 import awx.main.fields import awx.main.models.notifications import django.db.models.deletion +from django.conf import settings from django.db import migrations, models, connection @@ -66,10 +67,113 @@ def setup_event_partitioning_sqlite(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('main', '0194_executionenvironmentbuilder_and_more'), + ('main', '0194_add_github_app_credential'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='ExecutionEnvironmentBuilder', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=None, editable=False)), + ('modified', models.DateTimeField(default=None, editable=False)), + ('description', models.TextField(blank=True, default='')), + ('name', models.CharField(max_length=512, unique=True)), + ( + 'image', + models.CharField( + blank=True, default='', help_text='The name for the built execution environment image', max_length=1024, verbose_name='Image Name' + ), + ), + ( + 'tag', + models.CharField( + blank=True, default='latest', help_text='The tag for the built execution environment image', max_length=1024, verbose_name='Image Tag' + ), + ), + ( + 'definition', + models.TextField(blank=True, default='', help_text='Ansible builder execution environment definition', verbose_name='Definition'), + ), + ( + 'admin_role', + awx.main.fields.ImplicitRoleField( + editable=False, + null='True', + on_delete=django.db.models.deletion.CASCADE, + parent_role=['organization.execution_environment_builder_admin_role', 'singleton:system_administrator'], + related_name='+', + to='main.role', + ), + ), + ( + 'created_by', + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%s(class)s_created+', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'credential', + models.ForeignKey( + blank=True, + default=None, + help_text='Container registry credential for pushing the built image', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%(class)ss', + to='main.credential', + ), + ), + ( + 'modified_by', + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%s(class)s_modified+', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'organization', + models.ForeignKey( + blank=True, + default=None, + help_text='The organization used to determine access to this execution environment builder.', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(class)ss', + to='main.organization', + ), + ), + ( + 'read_role', + awx.main.fields.ImplicitRoleField( + editable=False, + null='True', + on_delete=django.db.models.deletion.CASCADE, + parent_role=['organization.auditor_role', 'singleton:system_auditor', 'admin_role'], + related_name='+', + to='main.role', + ), + ), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.AddField( + model_name='activitystream', + name='execution_environment_builder', + field=models.ManyToManyField(blank=True, to='main.executionenvironmentbuilder'), + ), migrations.CreateModel( name='ExecutionEnvironmentBuilderBuild', fields=[ From 9a36be76e44712445a7d5c284f0f802d655190d3 Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sat, 28 Mar 2026 22:23:39 -0500 Subject: [PATCH 04/12] Apply some fixes, start working on permissions --- awx/api/serializers.py | 3 --- awx/api/views/__init__.py | 1 + awx/main/access.py | 25 ++++++++++++++++--- ...95_executionenvironmentbuilder_and_more.py | 4 ++- .../models/execution_environment_builders.py | 2 +- awx/main/tasks/jobs.py | 1 - awx/playbooks/build_ee.yml | 1 - awx/ui/src/routeConfig.js | 2 +- .../ExecutionEnvironmentBuilderDetails.js | 6 ++--- .../ExecutionEnvironmentBuilderListItem.js | 7 +++--- awx/ui/src/types.js | 10 ++++++++ 11 files changed, 45 insertions(+), 17 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 22a69d42..94631ced 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1753,9 +1753,6 @@ class Meta: model = ExecutionEnvironmentBuilderBuild fields = () - class Meta: - fields = ('can_cancel',) - class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, value): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 484bbf4b..6dd0e5f7 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -910,6 +910,7 @@ class ExecutionEnvironmentBuilderCopy(CopyAPIView): class ExecutionEnvironmentBuilderLaunch(GenericAPIView): model = models.ExecutionEnvironmentBuilder serializer_class = serializers.EmptySerializer + obj_permission_type = 'start' def get(self, request, *args, **kwargs): return Response({}) diff --git a/awx/main/access.py b/awx/main/access.py index 8456173e..393fb56a 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1421,13 +1421,15 @@ class ExecutionEnvironmentBuilderAccess(BaseAccess): I can see an execution environment builder when: - I'm a superuser - I'm a member of the same organization + - it has no organization (global) I can create/change an execution environment builder when: - I'm a superuser + - I'm an execution environment admin for the organization """ model = ExecutionEnvironmentBuilder select_related = ('organization',) - prefetch_related = ('organization__admin_role',) + prefetch_related = ('organization__admin_role', 'organization__execution_environment_admin_role') def filtered_queryset(self): return ExecutionEnvironmentBuilder.objects.filter( @@ -1436,11 +1438,28 @@ def filtered_queryset(self): @check_superuser def can_add(self, data): - return True + if not data: + return Organization.accessible_objects(self.user, 'execution_environment_admin_role').exists() + return self.check_related('organization', Organization, data, mandatory=True, role_field='execution_environment_admin_role') @check_superuser def can_change(self, obj, data): - return True + if obj and obj.organization_id is None: + raise PermissionDenied + if self.user not in obj.organization.execution_environment_admin_role: + raise PermissionDenied + if data and 'organization' in data: + new_org = get_object_from_data('organization', Organization, data, obj=obj) + if not new_org or self.user not in new_org.execution_environment_admin_role: + return False + return self.check_related('organization', Organization, data, obj=obj, mandatory=True, role_field='execution_environment_admin_role') + + @check_superuser + def can_start(self, obj, validate_license=True): + return obj and self.user in obj.admin_role + + def can_delete(self, obj): + return self.can_change(obj, None) class ExecutionEnvironmentBuilderBuildAccess(BaseAccess): diff --git a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py index c197e9de..0937c66f 100644 --- a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py +++ b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py @@ -6,6 +6,8 @@ from django.conf import settings from django.db import migrations, models, connection +from ._sqlite_helper import dbawaremigrations + def setup_event_partitioning(apps, schema_editor): """Set up partitioning for ExecutionEnvironmentBuilderBuildEvent table""" @@ -298,5 +300,5 @@ class Migration(migrations.Migration): model_name='executionenvironmentbuilderbuildevent', index=models.Index(fields=['execution_environment_builder_build', 'job_created', 'counter'], name='main_execut_executi_03d2ab_idx'), ), - migrations.RunPython(setup_event_partitioning, setup_event_partitioning_sqlite), + dbawaremigrations.RunPython(setup_event_partitioning, sqlite_code=setup_event_partitioning_sqlite), ] diff --git a/awx/main/models/execution_environment_builders.py b/awx/main/models/execution_environment_builders.py index b0c8c1f7..c9d4b2d6 100644 --- a/awx/main/models/execution_environment_builders.py +++ b/awx/main/models/execution_environment_builders.py @@ -63,7 +63,7 @@ class Meta: admin_role = ImplicitRoleField( parent_role=[ - 'organization.execution_environment_builder_admin_role', + 'organization.execution_environment_admin_role', 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ] ) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index dd24e845..ac731920 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -158,7 +158,6 @@ def build_execution_environment_params(self, instance, private_data_dir): return {} image = instance.execution_environment.image - #image = "ghcr.io/ctrliq/ascender-ee:1b5b28d8c3d7fcae98a678b67d22c1b52d0dd152c8d1b924d3b8a9cb0907f71b" params = { "container_image": image, "process_isolation": True, diff --git a/awx/playbooks/build_ee.yml b/awx/playbooks/build_ee.yml index 78eed012..848a134e 100644 --- a/awx/playbooks/build_ee.yml +++ b/awx/playbooks/build_ee.yml @@ -45,7 +45,6 @@ content: | short-name-mode="permissive" unqualified-search-registries = ["quay.io", "docker.io"] - become: true - name: Check for ansible-builder command ansible.builtin.command: command -v ansible-builder diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js index 45323142..3dd6bdc0 100644 --- a/awx/ui/src/routeConfig.js +++ b/awx/ui/src/routeConfig.js @@ -223,7 +223,7 @@ function getRouteConfig(userProfile = {}) { deleteRoute('topology_view'); deleteRoute('instances'); deleteRoute('subscription_usage'); - if (!userProfile?.isExecutionEnvironmentAdmin) deleteRouteGroup('tools_group'); + if (!userProfile?.isExecEnvAdmin) deleteRouteGroup('tools_group'); if (userProfile?.isOrgAdmin) return routeConfig; if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates'); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js index a60c412b..80b4b806 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js @@ -78,12 +78,12 @@ function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { dataCy="builder-detail-definition" /> )} - {builder.organization && ( + {builder.summary_fields?.organization && ( - {builder.organization.name} + + {builder.summary_fields.organization.name} } /> diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js index e5a3b0e1..c8f8cca9 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js @@ -1,5 +1,6 @@ import React, { useState, useCallback } from 'react'; -import { string, bool, func } from 'prop-types'; +import { string, bool, func, number } from 'prop-types'; +import { ExecutionEnvironmentBuilder } from 'types'; import { useLingui } from '@lingui/react/macro'; import { Link, useHistory } from 'react-router-dom'; import { Button } from '@patternfly/react-core'; @@ -141,12 +142,12 @@ function ExecutionEnvironmentBuilderListItem({ } ExecutionEnvironmentBuilderListItem.propTypes = { - executionEnvironmentBuilder: string.isRequired, + executionEnvironmentBuilder: ExecutionEnvironmentBuilder.isRequired, detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, onCopy: func.isRequired, - rowIndex: func.isRequired, + rowIndex: number.isRequired, fetchExecutionEnvironmentBuilders: func.isRequired, }; diff --git a/awx/ui/src/types.js b/awx/ui/src/types.js index 0a097e3e..d3369993 100644 --- a/awx/ui/src/types.js +++ b/awx/ui/src/types.js @@ -276,6 +276,16 @@ export const SummaryFieldUser = shape({ last_name: string, }); +export const ExecutionEnvironmentBuilder = shape({ + id: number.isRequired, + name: string.isRequired, + image: string, + tag: string, + summary_fields: shape({ + user_capabilities: objectOf(bool), + }), +}); + export const Group = shape({ id: number.isRequired, type: oneOf(['group']), From 96b863689dbe7cd8d554eba15cbfff1526df4e0f Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sat, 28 Mar 2026 23:21:05 -0500 Subject: [PATCH 05/12] More fixes and add python tests --- awx/api/serializers.py | 3 + awx/main/access.py | 10 +- ...95_executionenvironmentbuilder_and_more.py | 2 +- .../api/test_execution_environment_builder.py | 457 ++++++++++++++++++ .../ExecutionEnvironmentBuilderAdd.js | 2 - .../ExecutionEnvironmentBuilderDetails.js | 5 +- 6 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 awx/main/tests/functional/api/test_execution_environment_builder.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 94631ced..0bffad0d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1747,6 +1747,9 @@ class Meta: class ExecutionEnvironmentBuilderBuildCancelSerializer(ExecutionEnvironmentBuilderBuildSerializer): can_cancel = serializers.BooleanField(read_only=True) + class Meta: + fields = ('can_cancel',) + class ExecutionEnvironmentBuilderBuildRelaunchSerializer(BaseSerializer): class Meta: diff --git a/awx/main/access.py b/awx/main/access.py index 393fb56a..2570522f 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1483,10 +1483,10 @@ class ExecutionEnvironmentBuilderBuildAccess(BaseAccess): def filtered_queryset(self): return self.model.objects.filter( - execution_environment_builder__in=ExecutionEnvironmentBuilder.accessible_pk_qs(self.user, 'read_role') + Q(execution_environment_builder__organization__in=Organization.accessible_pk_qs(self.user, 'read_role')) + | Q(execution_environment_builder__organization__isnull=True) ) - @check_superuser @check_superuser def can_cancel(self, obj): if not obj: @@ -1496,19 +1496,21 @@ def can_cancel(self, obj): return True # Allow organization admin to cancel if obj.execution_environment_builder and obj.execution_environment_builder.organization: - return self.user in obj.execution_environment_builder.organization.admin_role + if self.user in obj.execution_environment_builder.organization.admin_role: + return True # Allow users who can change the builder to cancel it if obj.execution_environment_builder: return self.user.can_access(ExecutionEnvironmentBuilder, 'change', obj.execution_environment_builder, None) return False + @check_superuser def can_start(self, obj, validate_license=True): # for relaunching try: if obj and obj.execution_environment_builder: if obj.execution_environment_builder.organization: return self.user in obj.execution_environment_builder.organization.admin_role - # If no organization, allow the creator or superuser + # If no organization, allow the creator return self.user == obj.created_by except ObjectDoesNotExist: pass diff --git a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py index 0937c66f..8d1442a9 100644 --- a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py +++ b/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py @@ -104,7 +104,7 @@ class Migration(migrations.Migration): editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, - parent_role=['organization.execution_environment_builder_admin_role', 'singleton:system_administrator'], + parent_role=['organization.execution_environment_admin_role', 'singleton:system_administrator'], related_name='+', to='main.role', ), diff --git a/awx/main/tests/functional/api/test_execution_environment_builder.py b/awx/main/tests/functional/api/test_execution_environment_builder.py new file mode 100644 index 00000000..1bd6105b --- /dev/null +++ b/awx/main/tests/functional/api/test_execution_environment_builder.py @@ -0,0 +1,457 @@ +# Python +import pytest +from unittest import mock + +# AWX +from awx.api.versioning import reverse +from awx.main.models import Organization, User +from awx.main.models.execution_environment_builders import ExecutionEnvironmentBuilder +from awx.main.models.execution_environment_builder_builds import ExecutionEnvironmentBuilderBuild + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def builder(organization): + return ExecutionEnvironmentBuilder.objects.create( + name='test-builder', + organization=organization, + image='quay.io/test/builder', + tag='latest', + ) + + +@pytest.fixture +def ee_admin(user, organization): + """User with execution_environment_admin_role on the org.""" + u = user('ee-admin', False) + organization.execution_environment_admin_role.members.add(u) + return u + + +@pytest.fixture +def builder_admin(user, builder): + """User with admin_role directly on the builder.""" + u = user('builder-admin', False) + builder.admin_role.members.add(u) + return u + + +@pytest.fixture +def build(builder, admin): + return ExecutionEnvironmentBuilderBuild.objects.create( + execution_environment_builder=builder, + name='test-build', + status='new', + created_by=admin, + ) + + +# --------------------------------------------------------------------------- +# Builder CRUD +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderList: + def test_admin_can_list(self, get, admin, builder): + url = reverse('api:execution_environment_builder_list') + r = get(url, admin, expect=200) + assert r.data['count'] >= 1 + + def test_ee_admin_can_list(self, get, ee_admin, builder): + url = reverse('api:execution_environment_builder_list') + r = get(url, ee_admin, expect=200) + assert r.data['count'] >= 1 + + def test_rando_sees_empty_list(self, get, rando, builder): + url = reverse('api:execution_environment_builder_list') + r = get(url, rando, expect=200) + assert r.data['count'] == 0 + + def test_org_auditor_can_see(self, get, org_auditor, builder): + """Org auditors have read_role through auditor_role → read_role chain.""" + url = reverse('api:execution_environment_builder_list') + r = get(url, org_auditor, expect=200) + assert r.data['count'] >= 1 + + def test_admin_can_create(self, post, admin, organization): + url = reverse('api:execution_environment_builder_list') + r = post(url, {'name': 'new-builder', 'organization': organization.pk, 'image': 'quay.io/new'}, admin, expect=201) + assert r.data['name'] == 'new-builder' + + def test_ee_admin_can_create(self, post, ee_admin, organization): + url = reverse('api:execution_environment_builder_list') + r = post(url, {'name': 'ee-admin-builder', 'organization': organization.pk, 'image': 'quay.io/new'}, ee_admin, expect=201) + assert r.data['name'] == 'ee-admin-builder' + + def test_rando_cannot_create(self, post, rando, organization): + url = reverse('api:execution_environment_builder_list') + post(url, {'name': 'bad-builder', 'organization': organization.pk}, rando, expect=403) + + def test_org_member_cannot_create(self, post, org_member, organization): + url = reverse('api:execution_environment_builder_list') + post(url, {'name': 'bad-builder', 'organization': organization.pk}, org_member, expect=403) + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderDetail: + def test_admin_can_read(self, get, admin, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + assert r.data['name'] == 'test-builder' + assert r.data['image'] == 'quay.io/test/builder' + + def test_rando_cannot_read(self, get, rando, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + get(url, rando, expect=403) + + def test_ee_admin_can_update(self, patch, ee_admin, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + r = patch(url, {'name': 'renamed-builder'}, ee_admin, expect=200) + assert r.data['name'] == 'renamed-builder' + + def test_rando_cannot_update(self, patch, rando, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + patch(url, {'name': 'renamed-builder'}, rando, expect=403) + + def test_org_member_cannot_update(self, patch, org_member, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + patch(url, {'name': 'renamed-builder'}, org_member, expect=403) + + def test_ee_admin_can_delete(self, delete, ee_admin, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + delete(url, ee_admin, expect=204) + assert not ExecutionEnvironmentBuilder.objects.filter(pk=builder.pk).exists() + + def test_rando_cannot_delete(self, delete, rando, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + delete(url, rando, expect=403) + + def test_summary_fields_include_organization(self, get, admin, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + assert 'organization' in r.data['summary_fields'] + assert r.data['summary_fields']['organization']['id'] == builder.organization.pk + + def test_related_links(self, get, admin, builder): + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + related = r.data.get('related', {}) + assert 'access_list' in related + assert 'object_roles' in related + + +# --------------------------------------------------------------------------- +# Builder Launch +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderLaunch: + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_admin_can_launch(self, mock_start, post, admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = post(url, {}, admin, expect=201) + assert 'execution_environment_builder_build' in r.data + build_id = r.data['execution_environment_builder_build'] + assert ExecutionEnvironmentBuilderBuild.objects.filter(pk=build_id).exists() + mock_start.assert_called_once() + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_builder_admin_can_launch(self, mock_start, post, builder_admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = post(url, {}, builder_admin, expect=201) + assert 'execution_environment_builder_build' in r.data + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_ee_admin_can_launch(self, mock_start, post, ee_admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = post(url, {}, ee_admin, expect=201) + assert 'execution_environment_builder_build' in r.data + + def test_rando_cannot_launch(self, post, rando, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + post(url, {}, rando, expect=403) + + def test_org_member_cannot_launch(self, post, org_member, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + post(url, {}, org_member, expect=403) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_launch_with_custom_name(self, mock_start, post, admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = post(url, {'name': 'Custom Build Name'}, admin, expect=201) + build_id = r.data['execution_environment_builder_build'] + build = ExecutionEnvironmentBuilderBuild.objects.get(pk=build_id) + assert build.name == 'Custom Build Name' + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_launch_default_name(self, mock_start, post, admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = post(url, {}, admin, expect=201) + build_id = r.data['execution_environment_builder_build'] + build = ExecutionEnvironmentBuilderBuild.objects.get(pk=build_id) + assert builder.name in build.name + + def test_get_launch_endpoint(self, get, admin, builder): + url = reverse('api:execution_environment_builder_launch', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + assert r.data == {} + + +# --------------------------------------------------------------------------- +# Build CRUD +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderBuildList: + def test_admin_can_list_builds(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_list') + r = get(url, admin, expect=200) + assert r.data['count'] >= 1 + + def test_rando_sees_empty_build_list(self, get, rando, build): + url = reverse('api:execution_environment_builder_build_list') + r = get(url, rando, expect=200) + assert r.data['count'] == 0 + + def test_ee_admin_can_list_builds(self, get, ee_admin, build): + url = reverse('api:execution_environment_builder_build_list') + r = get(url, ee_admin, expect=200) + assert r.data['count'] >= 1 + + def test_org_auditor_can_list_builds(self, get, org_auditor, build): + url = reverse('api:execution_environment_builder_build_list') + r = get(url, org_auditor, expect=200) + assert r.data['count'] >= 1 + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderBuildDetail: + def test_admin_can_read_build(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert r.data['name'] == 'test-build' + + def test_rando_cannot_read_build(self, get, rando, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + get(url, rando, expect=403) + + def test_admin_can_delete_build(self, delete, admin, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + delete(url, admin, expect=204) + + def test_rando_cannot_delete_build(self, delete, rando, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + delete(url, rando, expect=403) + + def test_build_detail_has_summary_fields(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert 'summary_fields' in r.data + + def test_org_auditor_can_read_build(self, get, org_auditor, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + r = get(url, org_auditor, expect=200) + assert r.data['name'] == 'test-build' + + def test_org_admin_can_delete_build(self, delete, org_admin, build): + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + delete(url, org_admin, expect=204) + + def test_creator_can_delete_build(self, delete, admin, build): + """Build was created_by=admin, so admin (as creator) can delete.""" + url = reverse('api:execution_environment_builder_build_detail', kwargs={'pk': build.pk}) + delete(url, admin, expect=204) + + +# --------------------------------------------------------------------------- +# Build Relaunch +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderBuildRelaunch: + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_admin_can_relaunch(self, mock_start, post, admin, build): + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + r = post(url, {}, admin, expect=200) + assert 'id' in r.data + new_build = ExecutionEnvironmentBuilderBuild.objects.get(pk=r.data['id']) + assert new_build.launch_type == 'relaunch' + assert new_build.execution_environment_builder == build.execution_environment_builder + mock_start.assert_called_once() + + def test_rando_cannot_relaunch(self, post, rando, build): + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + post(url, {}, rando, expect=403) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_org_admin_can_relaunch(self, mock_start, post, org_admin, build): + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + r = post(url, {}, org_admin, expect=200) + assert 'id' in r.data + new_build = ExecutionEnvironmentBuilderBuild.objects.get(pk=r.data['id']) + assert new_build.launch_type == 'relaunch' + + def test_ee_admin_cannot_relaunch(self, post, ee_admin, build): + """ee_admin has execution_environment_admin_role but not org admin_role.""" + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + post(url, {}, ee_admin, expect=403) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.signal_start', return_value=True) + def test_relaunch_uses_builder_name(self, mock_start, post, admin, build): + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + r = post(url, {}, admin, expect=200) + new_build = ExecutionEnvironmentBuilderBuild.objects.get(pk=r.data['id']) + assert build.execution_environment_builder.name in new_build.name + + def test_get_relaunch_endpoint(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_relaunch', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert r.data == {} + + +# --------------------------------------------------------------------------- +# Build Cancel +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderBuildCancel: + def test_admin_can_get_cancel_info(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_cancel', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert 'can_cancel' in r.data + + def test_rando_cannot_cancel(self, post, rando, build): + build.status = 'running' + build.save(update_fields=['status']) + url = reverse('api:execution_environment_builder_build_cancel', kwargs={'pk': build.pk}) + post(url, {}, rando, expect=403) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.cancel', return_value=True) + def test_org_admin_can_cancel(self, mock_cancel, post, org_admin, build): + build.status = 'running' + build.save(update_fields=['status']) + url = reverse('api:execution_environment_builder_build_cancel', kwargs={'pk': build.pk}) + post(url, {}, org_admin, expect=202) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.cancel', return_value=True) + def test_creator_can_cancel(self, mock_cancel, post, admin, build): + build.status = 'running' + build.save(update_fields=['status']) + url = reverse('api:execution_environment_builder_build_cancel', kwargs={'pk': build.pk}) + post(url, {}, admin, expect=202) + + @mock.patch('awx.main.models.unified_jobs.UnifiedJob.cancel', return_value=True) + def test_ee_admin_can_cancel(self, mock_cancel, post, ee_admin, build): + """ee_admin can change the builder, so can cancel its builds.""" + build.status = 'running' + build.save(update_fields=['status']) + url = reverse('api:execution_environment_builder_build_cancel', kwargs={'pk': build.pk}) + post(url, {}, ee_admin, expect=202) + + +# --------------------------------------------------------------------------- +# Events listing +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderBuildEvents: + def test_admin_can_list_events(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_events_list', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert r.data['count'] == 0 + + def test_rando_cannot_list_events(self, get, rando, build): + url = reverse('api:execution_environment_builder_build_events_list', kwargs={'pk': build.pk}) + get(url, rando, expect=403) + + def test_max_events_header(self, get, admin, build): + url = reverse('api:execution_environment_builder_build_events_list', kwargs={'pk': build.pk}) + r = get(url, admin, expect=200) + assert 'X-UI-Max-Events' in r + + +# --------------------------------------------------------------------------- +# Access list & object roles +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderAccessList: + def test_admin_can_view_access_list(self, get, admin, builder): + url = reverse('api:execution_environment_builder_access_list', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + assert 'count' in r.data + + def test_rando_cannot_view_access_list(self, get, rando, builder): + url = reverse('api:execution_environment_builder_access_list', kwargs={'pk': builder.pk}) + get(url, rando, expect=403) + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderObjectRoles: + def test_admin_can_view_object_roles(self, get, admin, builder): + url = reverse('api:execution_environment_builder_object_roles_list', kwargs={'pk': builder.pk}) + r = get(url, admin, expect=200) + assert r.data['count'] >= 1 # admin_role and read_role + + def test_rando_cannot_view_object_roles(self, get, rando, builder): + url = reverse('api:execution_environment_builder_object_roles_list', kwargs={'pk': builder.pk}) + get(url, rando, expect=403) + + +# --------------------------------------------------------------------------- +# Copy +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderCopy: + def test_admin_can_copy(self, post, admin, builder): + url = reverse('api:execution_environment_builder_copy', kwargs={'pk': builder.pk}) + r = post(url, {'name': 'test-builder copy'}, admin, expect=201) + assert r.data['id'] != builder.pk + assert 'test-builder' in r.data['name'] + + def test_rando_cannot_copy(self, post, rando, builder): + url = reverse('api:execution_environment_builder_copy', kwargs={'pk': builder.pk}) + post(url, {}, rando, expect=403) + + +# --------------------------------------------------------------------------- +# RBAC: org transfer +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderOrgTransfer: + def test_ee_admin_can_transfer_to_another_org(self, patch, user, builder, organization): + """EE admin of both orgs can transfer a builder.""" + other_org = Organization.objects.create(name='other-org') + u = user('dual-ee-admin', False) + organization.execution_environment_admin_role.members.add(u) + other_org.execution_environment_admin_role.members.add(u) + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + r = patch(url, {'organization': other_org.pk}, u, expect=200) + assert r.data['organization'] == other_org.pk + + def test_cannot_transfer_without_target_org_role(self, patch, ee_admin, builder): + """EE admin of source org only cannot transfer to another org.""" + other_org = Organization.objects.create(name='other-org') + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + patch(url, {'organization': other_org.pk}, ee_admin, expect=403) + + def test_ee_admin_cannot_transfer_without_target_role(self, patch, ee_admin, builder): + """EE admin of source org cannot transfer to an org where they lack the role.""" + other_org = Organization.objects.create(name='other-org') + url = reverse('api:execution_environment_builder_detail', kwargs={'pk': builder.pk}) + patch(url, {'organization': other_org.pk}, ee_admin, expect=403) diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js index bdea41d0..5a23eb7e 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js @@ -34,8 +34,6 @@ function ExecutionEnvironmentBuilderAdd() { + > + + )} From 5378ca24bd68e357715c9b7404d6af539595de36 Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sun, 29 Mar 2026 01:19:43 -0500 Subject: [PATCH 06/12] Add option to allow changing of container security flags --- awx/main/conf.py | 15 +++++++++++ awx/main/tasks/jobs.py | 11 ++++++-- .../screens/Setting/Jobs/JobsEdit/JobsEdit.js | 7 ++++++ .../JobsEdit/data.defaultJobSettings.json | 3 ++- .../shared/data.allSettingOptions.json | 25 +++++++++++++++++++ .../Setting/shared/data.allSettings.json | 1 + .../Setting/shared/data.jobSettings.json | 3 ++- 7 files changed, 61 insertions(+), 4 deletions(-) diff --git a/awx/main/conf.py b/awx/main/conf.py index e8cb2c1e..24472c00 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -915,6 +915,21 @@ category_slug='jobs', ) +register( + 'EXECUTION_ENVIRONMENT_BUILDER_CONTAINER_OPTIONS', + field_class=fields.StringListField, + label=_('EE Builder Container Options'), + default=[], + help_text=_( + "List of container runtime options to use when running EE builder builds. " + "When empty (the default), the builder runs with --privileged. " + "Set this to override with a tighter capability set, e.g. " + "['--cap-add=SYS_ADMIN', '--cap-add=MKNOD', '--device=/dev/fuse', '--security-opt=seccomp=unconfined']." + ), + category=('Jobs'), + category_slug='jobs', +) + register( 'RECEPTOR_RELEASE_WORK', field_class=fields.BooleanField, diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index ac731920..35d36409 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -2017,9 +2017,16 @@ def build_execution_environment_params(self, instance, private_data_dir): For builder builds, we extend the base params to add security options. """ params = super(RunExecutionEnvironmentBuilderBuild, self).build_execution_environment_params(instance, private_data_dir) - # Add security options for container builds + # Add security options for container builds. + # EXECUTION_ENVIRONMENT_BUILDER_CONTAINER_OPTIONS overrides the default + # --privileged flag, allowing admins to supply a tighter capability set + # if their container runtime / kernel supports it. if params and 'container_options' in params: - params['container_options'].extend(['--privileged']) + custom_options = getattr(settings, 'EXECUTION_ENVIRONMENT_BUILDER_CONTAINER_OPTIONS', None) + if custom_options: + params['container_options'].extend(custom_options) + else: + params['container_options'].extend(['--privileged']) return params def build_project_dir(self, instance, private_data_dir): diff --git a/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js b/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js index b4464a59..b433b908 100644 --- a/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js +++ b/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js @@ -90,6 +90,9 @@ function JobsEdit() { DEFAULT_CONTAINER_RUN_OPTIONS: formatJson( form.DEFAULT_CONTAINER_RUN_OPTIONS ), + EXECUTION_ENVIRONMENT_BUILDER_CONTAINER_OPTIONS: formatJson( + form.EXECUTION_ENVIRONMENT_BUILDER_CONTAINER_OPTIONS + ), }); }; @@ -228,6 +231,10 @@ function JobsEdit() { name="DEFAULT_CONTAINER_RUN_OPTIONS" config={jobs.DEFAULT_CONTAINER_RUN_OPTIONS} /> + Date: Sun, 29 Mar 2026 01:36:13 -0500 Subject: [PATCH 07/12] More fixes --- awx/api/serializers.py | 5 +---- awx/api/views/__init__.py | 2 -- .../functional/api/test_execution_environment_builder.py | 2 +- awx/playbooks/build_ee.yml | 6 +++--- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0bffad0d..5a544729 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1744,12 +1744,9 @@ class Meta: fields = ('*', '-controller_node', '-unified_job_template') -class ExecutionEnvironmentBuilderBuildCancelSerializer(ExecutionEnvironmentBuilderBuildSerializer): +class ExecutionEnvironmentBuilderBuildCancelSerializer(serializers.Serializer): can_cancel = serializers.BooleanField(read_only=True) - class Meta: - fields = ('can_cancel',) - class ExecutionEnvironmentBuilderBuildRelaunchSerializer(BaseSerializer): class Meta: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 6dd0e5f7..cd965231 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -955,11 +955,9 @@ def get(self, request, *args, **kwargs): return Response({}) def post(self, request, *args, **kwargs): - from django.utils.timezone import now obj = self.get_object() # Create a new build with the same configuration as the original builder = obj.execution_environment_builder - current_date = now().strftime('%Y-%m-%d %H:%M:%S') new_build = models.ExecutionEnvironmentBuilderBuild.objects.create( execution_environment_builder=builder, name=f"{builder.name if builder else ''}", diff --git a/awx/main/tests/functional/api/test_execution_environment_builder.py b/awx/main/tests/functional/api/test_execution_environment_builder.py index 1bd6105b..1b64aff4 100644 --- a/awx/main/tests/functional/api/test_execution_environment_builder.py +++ b/awx/main/tests/functional/api/test_execution_environment_builder.py @@ -4,7 +4,7 @@ # AWX from awx.api.versioning import reverse -from awx.main.models import Organization, User +from awx.main.models import Organization from awx.main.models.execution_environment_builders import ExecutionEnvironmentBuilder from awx.main.models.execution_environment_builder_builds import ExecutionEnvironmentBuilderBuild diff --git a/awx/playbooks/build_ee.yml b/awx/playbooks/build_ee.yml index 848a134e..93d533c3 100644 --- a/awx/playbooks/build_ee.yml +++ b/awx/playbooks/build_ee.yml @@ -78,13 +78,13 @@ - name: Install buildah via microdnf if not installed ansible.builtin.command: microdnf install -y buildah - when: + when: - buildah_check.rc != 0 - microdnf_check.rc == 0 - name: Install buildah via dnf if not installed ansible.builtin.command: dnf install -y buildah - when: + when: - buildah_check.rc != 0 - dnf_check.rc == 0 @@ -118,7 +118,7 @@ {{ '--tls-verify=false' if registry_credential is defined and registry_credential.verify_ssl==False else '' }} bud -t - "{{ (execution_environment_image + ':' + execution_environment_tag) | quote }}" + {{ (execution_environment_image + ':' + execution_environment_tag) | quote }} ./context # - pause: From 66353056a30e05d9be7b053e8b90a8d612d85218 Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sun, 29 Mar 2026 02:12:01 -0500 Subject: [PATCH 08/12] More fixes --- awx/api/serializers.py | 3 +- .../models/execution_environment_builders.py | 3 +- .../ExecutionEnvironmentBuilderDetails.js | 18 ++++++------ .../ExecutionEnvironmentBuilderListItem.js | 2 +- .../shared/ExecutionEnvironmentBuilderForm.js | 28 ++++++------------- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5a544729..7f1ddb4b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1659,7 +1659,8 @@ class Meta: class ExecutionEnvironmentBuilderSerializer(BaseSerializer): - show_capabilities = ['edit', 'delete', 'copy'] + show_capabilities = ['start', 'edit', 'delete', 'copy'] + capabilities_prefetch = ['admin'] class Meta: model = ExecutionEnvironmentBuilder diff --git a/awx/main/models/execution_environment_builders.py b/awx/main/models/execution_environment_builders.py index c9d4b2d6..d4854d4a 100644 --- a/awx/main/models/execution_environment_builders.py +++ b/awx/main/models/execution_environment_builders.py @@ -3,6 +3,7 @@ from awx.api.versioning import reverse from awx.main.models.base import CommonModel +from awx.main.models.mixins import ResourceMixin from awx.main.fields import ImplicitRoleField from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, @@ -13,7 +14,7 @@ __all__ = ['ExecutionEnvironmentBuilder'] -class ExecutionEnvironmentBuilder(CommonModel): +class ExecutionEnvironmentBuilder(CommonModel, ResourceMixin): """ A ExecutionEnvironmentBuilder represents a configuration for building custom Execution Environments using ansible-builder. diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js index b2f416f9..aa0ab660 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js @@ -107,14 +107,16 @@ function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { /> - + {builder.summary_fields?.user_capabilities?.start && ( + + )} {builder.summary_fields?.user_capabilities?.edit && ( diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.test.js new file mode 100644 index 00000000..be97fb65 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.test.js @@ -0,0 +1,281 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilderDetails from './ExecutionEnvironmentBuilderDetails'; + +jest.mock('../../../api'); + +const builder = { + id: 17, + type: 'execution_environment_builder', + url: '/api/v2/execution_environment_builders/17/', + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'latest', + definition: '---\nversion: 3\n', + created: '2024-09-17T20:14:15.408782Z', + modified: '2024-09-17T20:14:15.408802Z', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + copy: true, + start: true, + }, + credential: { + id: 4, + name: 'Container Registry', + }, + organization: { + id: 1, + name: 'Default', + }, + created_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + modified_by: { + id: 1, + username: 'admin', + first_name: '', + last_name: '', + }, + }, +}; + +describe('', () => { + let wrapper; + + test('should render details properly', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + + expect(wrapper.find('Detail[label="Name"]').prop('value')).toEqual( + builder.name + ); + expect(wrapper.find('Detail[label="Image"]').prop('value')).toEqual( + builder.image + ); + expect(wrapper.find('Detail[label="Tag"]').prop('value')).toEqual( + builder.tag + ); + expect(wrapper.find('VariablesDetail').length).toBe(1); + expect(wrapper.find('Detail[label="Credential"]').prop('value')).toEqual( + builder.summary_fields.credential.name + ); + + const dates = wrapper.find('UserDateDetail'); + expect(dates).toHaveLength(2); + expect(dates.at(0).prop('date')).toEqual(builder.created); + expect(dates.at(1).prop('date')).toEqual(builder.modified); + }); + + test('should render loading state', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('ContentLoading').length).toBe(1); + }); + + test('should render not found when builder is null and not loading', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.text()).toContain('not found'); + }); + + test('should show launch button for users with start permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + const launchButton = wrapper.find( + 'Button[ouiaId="builder-detail-launch-button"]' + ); + expect(launchButton.length).toBe(1); + expect(launchButton.text()).toEqual('Launch'); + }); + + test('should hide launch button for users without start permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect( + wrapper.find('Button[ouiaId="builder-detail-launch-button"]').length + ).toBe(0); + }); + + test('should show edit button for users with edit permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + const editButton = wrapper.find('Button').filterWhere( + (n) => n.text() === 'Edit' + ); + expect(editButton.length).toBe(1); + }); + + test('should hide edit button for users without edit permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + const editButtons = wrapper.find('Button').filterWhere( + (n) => n.text() === 'Edit' + ); + expect(editButtons.length).toBe(0); + }); + + test('should show delete button for users with delete permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('DeleteButton').length).toBe(1); + }); + + test('should hide delete button for users without delete permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('DeleteButton').length).toBe(0); + }); + + test('expected api call is made for delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/17/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + await act(async () => { + wrapper.find('DeleteButton').invoke('onConfirm')(); + }); + expect(ExecutionEnvironmentBuildersAPI.destroy).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/execution_environment_builders'); + }); + + test('should call launch api when launch button is clicked', async () => { + ExecutionEnvironmentBuildersAPI.launch.mockResolvedValue({ + status: 201, + data: { execution_environment_builder_build: 99 }, + }); + const history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/17/details'], + }); + + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + + await act(async () => { + wrapper + .find('Button[ouiaId="builder-detail-launch-button"]') + .simulate('click'); + }); + expect(ExecutionEnvironmentBuildersAPI.launch).toHaveBeenCalledWith(17, { + name: 'Test Builder', + }); + }); + + test('should render organization detail', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('Detail[label="Organization"]').length).toBe(1); + }); + + test('should not render organization detail when not present', async () => { + const builderWithoutOrg = { + ...builder, + summary_fields: { + ...builder.summary_fields, + organization: undefined, + }, + }; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('Detail[label="Organization"]').length).toBe(0); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js index 4ce8626b..f9778165 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js @@ -18,10 +18,9 @@ function ExecutionEnvironmentBuilderEdit({ builder, onUpdate }) { ...values, credential: values.credential?.id || null, }; - const { data: updatedBuilder } = - await ExecutionEnvironmentBuildersAPI.update(builder.id, submitData); + await ExecutionEnvironmentBuildersAPI.update(builder.id, submitData); if (onUpdate) { - onUpdate(updatedBuilder); + onUpdate(); } history.push(`/execution_environment_builders/${builder.id}`); } catch (error) { diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.test.js new file mode 100644 index 00000000..ab192ae0 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.test.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { ExecutionEnvironmentBuildersAPI, CredentialsAPI } from 'api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilderEdit from './ExecutionEnvironmentBuilderEdit'; + +jest.mock('../../../api'); + +const builderData = { + id: 42, + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'latest', + definition: '---\nversion: 3\n', + summary_fields: { + credential: { + id: 4, + name: 'Container Registry', + kind: 'registry', + }, + }, +}; + +describe('', () => { + let wrapper; + let history; + let onUpdate; + + beforeAll(async () => { + history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/42/edit'], + }); + onUpdate = jest.fn(); + CredentialsAPI.read.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} }, related_search_fields: [] }, + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('should render form', () => { + expect(wrapper.find('ExecutionEnvironmentBuilderForm').length).toBe(1); + }); + + test('handleSubmit should call the api and redirect to detail page', async () => { + const updatedValues = { + name: 'Updated Builder', + image: 'updated-ee', + tag: 'v2', + definition: '---\nversion: 3\n', + credential: { id: 4, name: 'Container Registry' }, + }; + + await act(async () => { + wrapper.find('ExecutionEnvironmentBuilderForm').invoke('onSubmit')( + updatedValues + ); + }); + wrapper.update(); + expect(ExecutionEnvironmentBuildersAPI.update).toHaveBeenCalledWith(42, { + ...updatedValues, + credential: 4, + }); + expect(onUpdate).toHaveBeenCalled(); + expect(history.location.pathname).toEqual( + '/execution_environment_builders/42' + ); + }); + + test('should navigate to detail page when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/execution_environment_builders/42' + ); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + ExecutionEnvironmentBuildersAPI.update.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper.find('ExecutionEnvironmentBuilderForm').invoke('onSubmit')({ + name: 'Test', + image: 'img', + tag: 'latest', + definition: '---\n', + credential: null, + }); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); + + test('should render loading when builder is null', async () => { + let loadingWrapper; + await act(async () => { + loadingWrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + expect(loadingWrapper.text()).toContain('Loading'); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.test.js new file mode 100644 index 00000000..a62072e1 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.test.js @@ -0,0 +1,207 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilderList from './ExecutionEnvironmentBuilderList'; + +jest.mock('../../../api/models/ExecutionEnvironmentBuilders'); + +const executionEnvironmentBuilders = { + data: { + results: [ + { + id: 1, + name: 'Builder One', + image: 'my-custom-ee', + tag: 'latest', + url: '/api/v2/execution_environment_builders/1/', + summary_fields: { + user_capabilities: { edit: true, delete: true, copy: true, start: true }, + }, + }, + { + id: 2, + name: 'Builder Two', + image: 'another-ee', + tag: 'v2', + url: '/api/v2/execution_environment_builders/2/', + summary_fields: { + user_capabilities: { edit: false, delete: true, copy: false, start: false }, + }, + }, + ], + count: 2, + }, +}; + +const options = { data: { actions: { POST: true } } }; + +describe('', () => { + let wrapper; + + beforeEach(() => { + ExecutionEnvironmentBuildersAPI.read.mockResolvedValue( + executionEnvironmentBuilders + ); + ExecutionEnvironmentBuildersAPI.readOptions.mockResolvedValue(options); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + }); + + test('should have data fetched and render 2 rows', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + + expect(wrapper.find('ExecutionEnvironmentBuilderListItem').length).toBe(2); + expect(ExecutionEnvironmentBuildersAPI.read).toHaveBeenCalled(); + expect(ExecutionEnvironmentBuildersAPI.readOptions).toHaveBeenCalled(); + }); + + test('should delete items successfully', async () => { + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + + await act(async () => { + wrapper + .find('ExecutionEnvironmentBuilderListItem') + .at(0) + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('ExecutionEnvironmentBuilderListItem') + .at(1) + .invoke('onSelect')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); + }); + + expect(ExecutionEnvironmentBuildersAPI.destroy).toHaveBeenCalledTimes(2); + }); + + test('should render deletion error modal', async () => { + ExecutionEnvironmentBuildersAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'DELETE', + url: '/api/v2/execution_environment_builders', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + + wrapper + .find('ExecutionEnvironmentBuilderListItem') + .at(0) + .find('input') + .simulate('change', 'a'); + wrapper.update(); + + expect( + wrapper + .find('ExecutionEnvironmentBuilderListItem') + .at(0) + .find('input') + .prop('checked') + ).toBe(true); + + await act(async () => + wrapper.find('Button[aria-label="Delete"]').prop('onClick')() + ); + wrapper.update(); + + await waitForElement( + wrapper, + 'Button[aria-label="confirm delete"]', + (el) => el.length > 0 + ); + await act(async () => + wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('should thrown content error', async () => { + ExecutionEnvironmentBuildersAPI.read.mockRejectedValue( + new Error({ + response: { + config: { + method: 'GET', + url: '/api/v2/execution_environment_builders', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + }); + + test('should not render add button', async () => { + ExecutionEnvironmentBuildersAPI.read.mockResolvedValue( + executionEnvironmentBuilders + ); + ExecutionEnvironmentBuildersAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement( + wrapper, + 'ExecutionEnvironmentBuilderList', + (el) => el.length > 0 + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.test.js new file mode 100644 index 00000000..9acc45e7 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.test.js @@ -0,0 +1,244 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilderListItem from './ExecutionEnvironmentBuilderListItem'; + +jest.mock('../../../api'); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + let wrapper; + const executionEnvironmentBuilder = { + id: 1, + name: 'Builder One', + image: 'my-custom-ee', + tag: 'latest', + summary_fields: { + user_capabilities: { edit: true, copy: true, delete: true, start: true }, + }, + }; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('ExecutionEnvironmentBuilderListItem').length).toBe(1); + }); + + test('should render the proper data', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('TdBreakWord').text()).toBe( + executionEnvironmentBuilder.name + ); + expect(wrapper.find('Td[dataLabel="Image"]').text()).toBe( + executionEnvironmentBuilder.image + ); + expect(wrapper.find('Td[dataLabel="Tag"]').text()).toBe( + executionEnvironmentBuilder.tag + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + expect(wrapper.find('RocketIcon').exists()).toBeTruthy(); + }); + + test('should call api to copy execution environment builder', async () => { + ExecutionEnvironmentBuildersAPI.copy.mockResolvedValue({ + status: 201, + data: { id: 2 }, + }); + + const onCopy = jest.fn(); + const fetchBuilders = jest.fn().mockResolvedValue({}); + + wrapper = mountWithContexts( + + + {}} + onCopy={onCopy} + rowIndex={0} + fetchExecutionEnvironmentBuilders={fetchBuilders} + /> + +
+ ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + expect(ExecutionEnvironmentBuildersAPI.copy).toHaveBeenCalled(); + }); + + test('should render proper alert modal on copy error', async () => { + ExecutionEnvironmentBuildersAPI.copy.mockRejectedValue(new Error()); + + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + + await act(async () => + wrapper.find('Button[aria-label="Copy"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('Modal').prop('isOpen')).toBe(true); + }); + + test('should not render copy button when user lacks copy permission', async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + expect(wrapper.find('CopyButton').length).toBe(0); + }); + + test('should not render edit button when user lacks edit permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); + + test('should not render launch button when user lacks start permission', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('RocketIcon').exists()).toBeFalsy(); + }); + + test('should call launch api when launch button is clicked', async () => { + ExecutionEnvironmentBuildersAPI.launch.mockResolvedValue({ + status: 201, + data: { execution_environment_builder_build: 99 }, + }); + + await act(async () => { + wrapper = mountWithContexts( + + + {}} + onCopy={() => {}} + rowIndex={0} + fetchExecutionEnvironmentBuilders={() => {}} + /> + +
+ ); + }); + + await act(async () => { + wrapper.find('Button[aria-label="Launch"]').simulate('click'); + }); + expect(ExecutionEnvironmentBuildersAPI.launch).toHaveBeenCalledWith( + 1, + { name: 'Builder One' } + ); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.test.js new file mode 100644 index 00000000..ae318056 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.test.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilders from './ExecutionEnvironmentBuilders'; + +describe('', () => { + let pageWrapper; + let pageSections; + + beforeEach(() => { + pageWrapper = mountWithContexts(); + pageSections = pageWrapper.find('PageSection'); + }); + + test('initially renders without crashing', () => { + expect(pageWrapper.length).toBe(1); + expect(pageSections.length).toBe(1); + expect(pageSections.first().props().variant).toBe('light'); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js new file mode 100644 index 00000000..fd4bd6c4 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { CredentialsAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import ExecutionEnvironmentBuilderForm from './ExecutionEnvironmentBuilderForm'; + +jest.mock('../../../api'); + +describe('', () => { + let wrapper; + let onCancel; + let onSubmit; + + const executionEnvironmentBuilder = { + id: 16, + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'v1', + definition: '---\nversion: 3\n', + summary_fields: { + credential: { + id: 4, + name: 'Container Registry', + kind: 'registry', + }, + }, + }; + + beforeEach(async () => { + onCancel = jest.fn(); + onSubmit = jest.fn(); + CredentialsAPI.read.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} }, related_search_fields: [] }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + + test('should display form fields properly', () => { + expect(wrapper.find('FormField[name="name"]').length).toBe(1); + expect(wrapper.find('FormField[name="image"]').length).toBe(1); + expect(wrapper.find('FormField[name="tag"]').length).toBe(1); + expect(wrapper.find('CredentialLookup').length).toBe(1); + expect(wrapper.find('VariablesField').length).toBe(1); + }); + + test('should call onSubmit when form submitted', async () => { + expect(onSubmit).not.toHaveBeenCalled(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + test('should update form values', async () => { + await act(async () => { + wrapper.find('input#eeb-name').simulate('change', { + target: { value: 'Updated Name', name: 'name' }, + }); + wrapper.find('input#eeb-image').simulate('change', { + target: { value: 'updated-image', name: 'image' }, + }); + wrapper.find('input#eeb-tag').simulate('change', { + target: { value: 'v2', name: 'tag' }, + }); + wrapper.find('CredentialLookup').invoke('onBlur')(); + wrapper.find('CredentialLookup').invoke('onChange')({ + id: 99, + name: 'New Credential', + }); + }); + + wrapper.update(); + expect(wrapper.find('input#eeb-name').prop('value')).toEqual( + 'Updated Name' + ); + expect(wrapper.find('input#eeb-image').prop('value')).toEqual( + 'updated-image' + ); + expect(wrapper.find('input#eeb-tag').prop('value')).toEqual('v2'); + expect(wrapper.find('CredentialLookup').prop('value')).toEqual({ + id: 99, + name: 'New Credential', + }); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(onCancel).toHaveBeenCalled(); + }); + + test('should render with default values for new builder', async () => { + let newWrapper; + await act(async () => { + newWrapper = mountWithContexts( + + ); + }); + expect(newWrapper.find('input#eeb-name').prop('value')).toEqual(''); + expect(newWrapper.find('input#eeb-tag').prop('value')).toEqual('latest'); + }); + + test('should show submit error when submitError prop is provided', async () => { + const submitError = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + let errorWrapper; + await act(async () => { + errorWrapper = mountWithContexts( + + ); + }); + expect(errorWrapper.find('FormSubmitError').length).toBe(1); + }); + + test('should populate credential from builder summary_fields', async () => { + expect(wrapper.find('CredentialLookup').prop('value')).toEqual({ + id: 4, + name: 'Container Registry', + kind: 'registry', + }); + }); +}); From 86f1b3db0ba2e6be7a4801e110d1895e1633133e Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Sun, 29 Mar 2026 12:27:38 -0500 Subject: [PATCH 10/12] Minor fixes --- awx/api/views/__init__.py | 6 +++++- awx/playbooks/build_ee.yml | 2 +- .../ExecutionEnvironmentBuilderDetails.js | 16 +++++++++++++++- .../ExecutionEnvironmentBuilderList.js | 3 ++- .../ExecutionEnvironmentBuilderListItem.js | 18 +++++++++++++++++- .../ExecutionEnvironmentBuilderForm.test.js | 3 +-- .../ExecutionEnvironmentBuilderBuildDetail.js | 6 +++--- 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index cd965231..22a38e41 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -920,6 +920,8 @@ def post(self, request, *args, **kwargs): new_build = models.ExecutionEnvironmentBuilderBuild.objects.create( execution_environment_builder=obj, name=request.data.get('name', f'{obj.name} Build'), + created_by=request.user, + modified_by=request.user, ) new_build.signal_start() data = OrderedDict() @@ -932,7 +934,7 @@ class ExecutionEnvironmentBuilderBuildList(ListCreateAPIView): serializer_class = serializers.ExecutionEnvironmentBuilderBuildListSerializer def perform_create(self, serializer): - obj = serializer.save() + obj = serializer.save(created_by=self.request.user, modified_by=self.request.user) obj.signal_start() class ExecutionEnvironmentBuilderBuildDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): @@ -962,6 +964,8 @@ def post(self, request, *args, **kwargs): execution_environment_builder=builder, name=f"{builder.name if builder else ''}", launch_type='relaunch', + created_by=request.user, + modified_by=request.user, ) new_build.signal_start() return Response({'id': new_build.id}) diff --git a/awx/playbooks/build_ee.yml b/awx/playbooks/build_ee.yml index 93d533c3..9e2404ce 100644 --- a/awx/playbooks/build_ee.yml +++ b/awx/playbooks/build_ee.yml @@ -24,7 +24,7 @@ - eed.dependencies.galaxy is defined fail_msg: "Proper Execution environment v3 definition is required to build an execution environment." - - name: Validate execution environment definition is provided + - name: Validate that no additional build files are included in the definition ansible.builtin.assert: that: - eed.additional_build_files is not defined diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js index 9926ff85..e5aac354 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js @@ -16,6 +16,7 @@ function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { const { t } = useLingui(); const history = useHistory(); const [isLaunchDisabled, setIsLaunchDisabled] = useState(false); + const [launchError, setLaunchError] = useState(null); const { request: deleteBuilder, @@ -37,7 +38,9 @@ function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { if (response.status === 201) { history.push(`/jobs/build/${response.data.execution_environment_builder_build}`); } - } catch (error) { + } catch (err) { + setLaunchError(err); + } finally { setIsLaunchDisabled(false); } }, [builder?.id, builder?.name, history]); @@ -145,6 +148,17 @@ function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { )} + {launchError && ( + setLaunchError(null)} + title={t`Error`} + variant="error" + > + {t`Failed to launch build.`} + + + )} diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js index ca991865..9dd092eb 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js @@ -40,6 +40,7 @@ function ExecutionEnvironmentBuilderList() { relatedSearchableKeys, searchableKeys, }, + error: contentError, isLoading, request: fetchBuilders, } = useRequest( @@ -118,7 +119,7 @@ function ExecutionEnvironmentBuilderList() { { const response = await ExecutionEnvironmentBuildersAPI.copy( @@ -56,7 +59,9 @@ function ExecutionEnvironmentBuilderListItem({ if (response.status === 201) { history.push(`/jobs/build/${response.data.execution_environment_builder_build}`); } - } catch (error) { + } catch (err) { + setLaunchError(err); + } finally { setIsLaunchDisabled(false); } }, [executionEnvironmentBuilder.id, executionEnvironmentBuilder.name, history]); @@ -137,6 +142,17 @@ function ExecutionEnvironmentBuilderListItem({ /> + {launchError && ( + setLaunchError(null)} + title={t`Error`} + variant="error" + > + {t`Failed to launch build.`} + + + )} ); } diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js index fd4bd6c4..36ab4133 100644 --- a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js @@ -2,8 +2,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { CredentialsAPI } from 'api'; import { - mountWithContexts, - waitForElement, + mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import ExecutionEnvironmentBuilderForm from './ExecutionEnvironmentBuilderForm'; diff --git a/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js index b4235d0a..3780f73e 100644 --- a/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js @@ -94,9 +94,9 @@ function ExecutionEnvironmentBuilderBuildDetail({ job }) { /> )} Date: Fri, 3 Apr 2026 11:25:00 -0500 Subject: [PATCH 11/12] Move migration --- ...and_more.py => 0196_executionenvironmentbuilder_and_more.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0195_executionenvironmentbuilder_and_more.py => 0196_executionenvironmentbuilder_and_more.py} (99%) diff --git a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0196_executionenvironmentbuilder_and_more.py similarity index 99% rename from awx/main/migrations/0195_executionenvironmentbuilder_and_more.py rename to awx/main/migrations/0196_executionenvironmentbuilder_and_more.py index 8d1442a9..a65bbe5b 100644 --- a/awx/main/migrations/0195_executionenvironmentbuilder_and_more.py +++ b/awx/main/migrations/0196_executionenvironmentbuilder_and_more.py @@ -69,7 +69,7 @@ def setup_event_partitioning_sqlite(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('main', '0194_add_github_app_credential'), + ('main', '0195_remove_custom_virtualenv'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] From 89eeffb5e00c23b869c934614a09468027bcc30c Mon Sep 17 00:00:00 2001 From: Jimmy Conner Date: Fri, 3 Apr 2026 12:35:48 -0500 Subject: [PATCH 12/12] Add more unit tests --- ...utionEnvironmentBuilderBuildDetail.test.js | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js diff --git a/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js new file mode 100644 index 00000000..816d0786 --- /dev/null +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js @@ -0,0 +1,298 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { ExecutionEnvironmentBuilderBuildsAPI } from 'api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ExecutionEnvironmentBuilderBuildDetail from './ExecutionEnvironmentBuilderBuildDetail'; + +jest.mock('../../../api'); + +const mockJob = { + id: 101, + name: 'Test Builder Build', + type: 'execution_environment_builder_build', + url: '/api/v2/builds/101/', + status: 'successful', + started: '2024-03-01T12:00:00.000000Z', + finished: '2024-03-01T12:05:00.000000Z', + job_explanation: '', + summary_fields: { + execution_environment_builder: { + id: 10, + name: 'My Builder', + image: 'quay.io/my-org/my-ee', + tag: 'latest', + summary_fields: { + credential: { + id: 5, + name: 'Registry Cred', + description: '', + kind: 'registry', + credential_type_id: 20, + }, + }, + }, + execution_environment: { + id: 1, + name: 'Default EE', + description: '', + image: 'quay.io/ansible/awx-ee', + }, + created_by: { + id: 1, + username: 'admin', + }, + user_capabilities: { + start: true, + delete: true, + }, + }, +}; + +describe('', () => { + let wrapper; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should display job details', () => { + wrapper = mountWithContexts( + + ); + + const idDetail = wrapper.find('Detail[dataCy="job-id"]'); + expect(idDetail).toHaveLength(1); + expect(idDetail.prop('value')).toBe(101); + + const statusDetail = wrapper.find('Detail[dataCy="job-status"]'); + expect(statusDetail).toHaveLength(1); + expect(statusDetail.find('StatusLabel')).toHaveLength(1); + + const startedDetail = wrapper.find('Detail[dataCy="job-started-date"]'); + expect(startedDetail).toHaveLength(1); + + const finishedDetail = wrapper.find('Detail[dataCy="job-finished-date"]'); + expect(finishedDetail).toHaveLength(1); + + const nameDetail = wrapper.find( + 'Detail[dataCy="execution-environment-name"]' + ); + expect(nameDetail).toHaveLength(1); + + const imageDetail = wrapper.find( + 'Detail[dataCy="execution-environment-image"]' + ); + expect(imageDetail).toHaveLength(1); + + const tagDetail = wrapper.find( + 'Detail[dataCy="execution-environment-tag"]' + ); + expect(tagDetail).toHaveLength(1); + }); + + test('should not display finished date when job has not finished', () => { + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('Detail[dataCy="job-finished-date"]') + ).toHaveLength(0); + }); + + test('should display credential chip when credential exists', () => { + wrapper = mountWithContexts( + + ); + const credentialDetail = wrapper.find( + 'Detail[dataCy="builder-build-credential"]' + ); + expect(credentialDetail).toHaveLength(1); + const chip = credentialDetail.find('CredentialChip'); + expect(chip).toHaveLength(1); + expect(chip.prop('credential')).toEqual( + mockJob.summary_fields.execution_environment_builder.summary_fields + .credential + ); + }); + + test('should not display credential when builder has no credential', () => { + const jobWithoutCred = { + ...mockJob, + summary_fields: { + ...mockJob.summary_fields, + execution_environment_builder: { + ...mockJob.summary_fields.execution_environment_builder, + summary_fields: {}, + }, + }, + }; + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('Detail[dataCy="builder-build-credential"]') + ).toHaveLength(0); + }); + + test('should show Relaunch button when user can start', () => { + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('Button[ouiaId="builder-build-detail-relaunch-button"]') + ).toHaveLength(1); + }); + + test('should hide Relaunch button when user cannot start', () => { + const jobNoStart = { + ...mockJob, + summary_fields: { + ...mockJob.summary_fields, + user_capabilities: { start: false, delete: true }, + }, + }; + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('Button[ouiaId="builder-build-detail-relaunch-button"]') + ).toHaveLength(0); + }); + + test('should show Delete button when job is completed and user can delete', () => { + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('DeleteButton[ouiaId="builder-build-detail-delete-button"]') + ).toHaveLength(1); + }); + + test('should hide Delete button when user cannot delete', () => { + const jobNoDelete = { + ...mockJob, + summary_fields: { + ...mockJob.summary_fields, + user_capabilities: { start: true, delete: false }, + }, + }; + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('DeleteButton[ouiaId="builder-build-detail-delete-button"]') + ).toHaveLength(0); + }); + + test('should hide Delete button when job is running', () => { + const runningJob = { + ...mockJob, + status: 'running', + }; + wrapper = mountWithContexts( + + ); + expect( + wrapper.find('DeleteButton[ouiaId="builder-build-detail-delete-button"]') + ).toHaveLength(0); + }); + + test('should show Cancel button when job is running and user can start', () => { + const runningJob = { + ...mockJob, + status: 'running', + }; + wrapper = mountWithContexts( + + ); + expect(wrapper.find('JobCancelButton')).toHaveLength(1); + }); + + test('should hide Cancel button when job is not running', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('JobCancelButton')).toHaveLength(0); + }); + + test('should hide Cancel button when user cannot start', () => { + const runningNoStart = { + ...mockJob, + status: 'pending', + summary_fields: { + ...mockJob.summary_fields, + user_capabilities: { start: false, delete: true }, + }, + }; + wrapper = mountWithContexts( + + ); + expect(wrapper.find('JobCancelButton')).toHaveLength(0); + }); + + test('should call API destroy and navigate to /jobs on successful delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/jobs/101/details'], + }); + ExecutionEnvironmentBuilderBuildsAPI.destroy.mockResolvedValue({}); + wrapper = mountWithContexts( + , + { context: { router: { history } } } + ); + + wrapper.find('button[aria-label="Delete"]').simulate('click'); + wrapper.update(); + + const modal = wrapper.find('Modal[aria-label="Alert modal"]'); + expect(modal).toHaveLength(1); + + await act(async () => { + modal.find('button[aria-label="Confirm Delete"]').simulate('click'); + }); + wrapper.update(); + + expect(ExecutionEnvironmentBuilderBuildsAPI.destroy).toHaveBeenCalledWith( + 101 + ); + expect(history.location.pathname).toBe('/jobs'); + }); + + test('should display error modal when delete fails', async () => { + ExecutionEnvironmentBuilderBuildsAPI.destroy.mockRejectedValue( + new Error('Delete failed') + ); + wrapper = mountWithContexts( + + ); + + wrapper.find('button[aria-label="Delete"]').simulate('click'); + wrapper.update(); + + const modal = wrapper.find('Modal[aria-label="Alert modal"]'); + expect(modal).toHaveLength(1); + + await act(async () => { + modal.find('button[aria-label="Confirm Delete"]').simulate('click'); + }); + wrapper.update(); + + expect(wrapper.find('ErrorDetail')).toHaveLength(1); + }); + + test('should display job_explanation in status when provided', () => { + const jobWithExplanation = { + ...mockJob, + status: 'failed', + job_explanation: 'Build timed out', + }; + wrapper = mountWithContexts( + + ); + const statusDetail = wrapper.find('Detail[dataCy="job-status"]'); + expect(statusDetail.prop('fullWidth')).toBe(true); + expect(statusDetail.text()).toContain('Build timed out'); + }); +});