diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5505fb860..fb45167b7 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() @@ -1615,6 +1621,25 @@ def data(self): return ReturnList(ret, serializer=self) +class ProjectExecutionEnvironmentFilesSerializer(ProjectSerializer): + execution_environment_files = serializers.SerializerMethodField( + help_text=_('Array of ansible-builder execution environment definition files available within this project.') + ) + + class Meta: + model = Project + fields = ('execution_environment_files',) + + def get_execution_environment_files(self, obj): + return obj.execution_environment_files + + @property + def data(self): + ret = super(ProjectExecutionEnvironmentFilesSerializer, self).data + ret = ret.get('execution_environment_files', []) + return ReturnList(ret, serializer=self) + + class ProjectUpdateViewSerializer(ProjectSerializer): can_update = serializers.BooleanField(read_only=True) @@ -1673,6 +1698,140 @@ class Meta: fields = ('can_cancel',) +class ExecutionEnvironmentBuilderSerializer(BaseSerializer): + show_capabilities = ['start', 'edit', 'delete', 'copy'] + capabilities_prefetch = ['admin'] + + class Meta: + model = ExecutionEnvironmentBuilder + fields = ( + '*', + 'organization', + 'image', + 'tag', + 'credential', + 'project', + 'execution_environment_file', + ) + + 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}) + if obj.project: + res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk}) + return res + + def validate(self, attrs): + attrs = super(ExecutionEnvironmentBuilderSerializer, self).validate(attrs) + project = attrs.get('project', getattr(self.instance, 'project', None)) + ee_file = attrs.get('execution_environment_file', getattr(self.instance, 'execution_environment_file', None)) + if ee_file: + if not project: + raise serializers.ValidationError({'execution_environment_file': _('A project is required when specifying an execution environment file.')}) + self._validate_execution_environment_file(project, ee_file) + return attrs + + def _validate_execution_environment_file(self, project, ee_file): + import os + import yaml + + project_path = project.get_project_path() + if not project_path: + raise serializers.ValidationError( + {'execution_environment_file': _('The project has not been synced yet; unable to read the execution environment file.')} + ) + base = os.path.abspath(project_path) + full_path = os.path.normpath(os.path.join(base, ee_file)) + # Prevent path traversal outside of the project directory. + if full_path != base and not full_path.startswith(base + os.sep): + raise serializers.ValidationError({'execution_environment_file': _('Invalid execution environment file path.')}) + if not os.path.isfile(full_path): + raise serializers.ValidationError({'execution_environment_file': _('Execution environment file not found in project.')}) + try: + with open(full_path, 'r') as f: + data = yaml.safe_load(f.read()) + except Exception: + raise serializers.ValidationError({'execution_environment_file': _('Unable to parse the execution environment file as YAML.')}) + if not isinstance(data, dict) or data.get('version') != 3: + raise serializers.ValidationError({'execution_environment_file': _('The execution environment file must define "version: 3" at the top level.')}) + + +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(serializers.Serializer): + can_cancel = serializers.BooleanField(read_only=True) + + +class ExecutionEnvironmentBuilderBuildRelaunchSerializer(BaseSerializer): + class Meta: + model = ExecutionEnvironmentBuilderBuild + fields = () + + class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, value): return vars_validate_or_raise(value) @@ -4441,6 +4600,24 @@ 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 000000000..d2ebbcbeb --- /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 000000000..80aa82fd0 --- /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/project.py b/awx/api/urls/project.py index 56e82d49c..0fda164f4 100644 --- a/awx/api/urls/project.py +++ b/awx/api/urls/project.py @@ -8,6 +8,7 @@ ProjectDetail, ProjectPlaybooks, ProjectInventories, + ProjectExecutionEnvironmentFiles, ProjectScmInventorySources, ProjectTeamsList, ProjectUpdateView, @@ -27,6 +28,7 @@ path('/', ProjectDetail.as_view(), name='project_detail'), path('/playbooks/', ProjectPlaybooks.as_view(), name='project_playbooks'), path('/inventories/', ProjectInventories.as_view(), name='project_inventories'), + path('/execution_environment_files/', ProjectExecutionEnvironmentFiles.as_view(), name='project_execution_environment_files'), path('/scm_inventory_sources/', ProjectScmInventorySources.as_view(), name='project_scm_inventory_sources'), path('/teams/', ProjectTeamsList.as_view(), name='project_teams_list'), path('/update/', ProjectUpdateView.as_view(), name='project_update_view'), diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 2785b6fd9..4c7e28c80 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, federated_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 05515af58..ec919cb3d 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -874,6 +874,122 @@ 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 + obj_permission_type = 'start' + + 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'), + created_by=request.user, + modified_by=request.user, + ) + 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(created_by=self.request.user, modified_by=self.request.user) + 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): + self.get_object() + return Response({}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + # Create a new build with the same configuration as the original + builder = obj.execution_environment_builder + new_build = models.ExecutionEnvironmentBuilderBuild.objects.create( + 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}) + + +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 @@ -894,6 +1010,11 @@ class ProjectInventories(RetrieveAPIView): serializer_class = serializers.ProjectInventoriesSerializer +class ProjectExecutionEnvironmentFiles(RetrieveAPIView): + model = models.Project + serializer_class = serializers.ProjectExecutionEnvironmentFilesSerializer + + class ProjectTeamsList(ListAPIView): model = models.Team serializer_class = serializers.TeamSerializer @@ -4375,6 +4496,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 0ab9441d5..22d828896 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -94,6 +94,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 b0e534541..4bdc15378 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -36,6 +36,8 @@ CredentialType, CredentialInputSource, ExecutionEnvironment, + ExecutionEnvironmentBuilder, + ExecutionEnvironmentBuilderBuild, Group, Host, Instance, @@ -1412,6 +1414,121 @@ 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 + - 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', 'organization__execution_environment_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): + 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') and self.check_related( + 'project', Project, data, role_field='use_role' + ) + + @check_superuser + def can_change(self, obj, data): + 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' + ) and self.check_related('project', Project, data, obj=obj, role_field='use_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): + """ + 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( + Q(execution_environment_builder__organization__in=Organization.accessible_pk_qs(self.user, 'read_role')) + | Q(execution_environment_builder__organization__isnull=True) + ) + + @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: + 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 + 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/conf.py b/awx/main/conf.py index 33365fb56..277f1eec0 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/dispatch/worker/callback.py b/awx/main/dispatch/worker/callback.py index 7394bbbe0..39c402830 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/0201_executionenvironmentbuilder_and_more.py b/awx/main/migrations/0201_executionenvironmentbuilder_and_more.py new file mode 100644 index 000000000..7dad2107c --- /dev/null +++ b/awx/main/migrations/0201_executionenvironmentbuilder_and_more.py @@ -0,0 +1,324 @@ +# 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 + +from ._sqlite_helper import dbawaremigrations + + +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', '0200_add_list_ordering'), + 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' + ), + ), + ( + 'execution_environment_file', + models.CharField( + blank=True, + default='', + help_text='Path to the ansible-builder execution environment definition file within the project', + max_length=1024, + verbose_name='Execution Environment File', + ), + ), + ( + 'admin_role', + awx.main.fields.ImplicitRoleField( + editable=False, + null='True', + on_delete=django.db.models.deletion.CASCADE, + parent_role=['organization.execution_environment_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', + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + default=None, + help_text='The project that contains the execution environment definition file', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='%(class)ss', + to='main.project', + ), + ), + ( + '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=[ + ( + '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'), + ), + dbawaremigrations.RunPython(setup_event_partitioning, sqlite_code=setup_event_partitioning_sqlite), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 7c1f20da1..02d59aa38 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, @@ -265,6 +271,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 2dccf3158..454a4e760 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 3c4dd3e88..dcec532e4 100644 --- a/awx/main/models/events.py +++ b/awx/main/models/events.py @@ -28,7 +28,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): @@ -70,6 +70,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): @@ -415,11 +416,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, @@ -951,3 +952,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 000000000..df99e2870 --- /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 000000000..4c3fcc4c1 --- /dev/null +++ b/awx/main/models/execution_environment_builders.py @@ -0,0 +1,90 @@ +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.models.mixins import ResourceMixin +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, ResourceMixin): + """ + 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'), + ) + project = models.ForeignKey( + 'Project', + related_name='%(class)ss', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + help_text=_('The project that contains the execution environment definition file'), + ) + execution_environment_file = models.CharField( + max_length=1024, + blank=True, + default='', + verbose_name=_('Execution Environment File'), + help_text=_('Path to the ansible-builder execution environment definition file within the project'), + ) + + admin_role = ImplicitRoleField( + parent_role=[ + 'organization.execution_environment_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/projects.py b/awx/main/models/projects.py index cfa58366f..f9d7e8978 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -237,6 +237,26 @@ def inventories(self): break return sorted(results, key=lambda x: smart_str(x).lower()) + @property + def execution_environment_files(self): + """ + List of ansible-builder execution environment definition files found in + the project. These are files named ``execution-environment.yml`` (or + ``.yaml``), returned as paths relative to the project root. + """ + results = [] + project_path = self.get_project_path() + if project_path: + for dirpath, dirnames, filenames in os.walk(smart_str(project_path)): + if skip_directory(dirpath): + continue + for filename in filenames: + if filename in ('execution-environment.yml', 'execution-environment.yaml'): + full_path = os.path.join(dirpath, filename) + rel_path = os.path.relpath(full_path, project_path) + results.append(smart_str(rel_path)) + return sorted(results, key=lambda x: smart_str(x).lower()) + def get_lock_file(self): """ We want the project path in name only, we don't care if it exists or diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 3482c04ec..7f0e7975e 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1048,6 +1048,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 8f0aa7308..e5365d050 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, ) @@ -123,6 +124,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: @@ -138,6 +142,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 7936d0b57..53a1fdbfc 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, @@ -382,6 +383,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 069bc408c..118c8162c 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 5ddaff933..ce04039cb 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -49,11 +49,13 @@ ProjectUpdate, InventoryUpdate, SystemJob, + ExecutionEnvironmentBuilderBuild, JobEvent, ProjectUpdateEvent, InventoryUpdateEvent, AdHocCommandEvent, SystemJobEvent, + ExecutionEnvironmentBuilderBuildEvent, build_safe_env, ) from awx.main.tasks.callback import ( @@ -62,6 +64,7 @@ RunnerCallbackForInventoryUpdate, RunnerCallbackForProjectUpdate, RunnerCallbackForSystemJob, + RunnerCallbackForExecutionEnvironmentBuilderBuild, ) from awx.main.tasks.signals import with_signal_handling, signal_callback from awx.main.tasks.receptor import AWXReceptorJob @@ -652,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: @@ -1927,3 +1933,172 @@ 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(SourceControlMixin, 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_file': builder.execution_environment_file, + } + ) + + 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) + builder = instance.execution_environment_builder + if builder is None or builder.project is None: + error = _('Execution environment build could not start because it does not have a valid project.') + self.update_model(instance.pk, status='failed', job_explanation=error) + raise RuntimeError(error) + if not builder.execution_environment_file: + error = _('Execution environment build could not start because no execution environment file was selected.') + self.update_model(instance.pk, status='failed', job_explanation=error) + raise RuntimeError(error) + + 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. + # 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: + 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): + # Sync (if needed) and copy the builder's project into the runner + # project dir, just like a job template launch does. + builder = instance.execution_environment_builder + project = builder.project if builder else None + if project is None: + raise RuntimeError('Execution environment builder has no project configured.') + self._sync_and_copy_project(project, private_data_dir) + # The build_ee.yml playbook is not part of the user's project, so drop + # it alongside the copied project content so ansible-runner can run it. + awx_playbooks = self.get_path_to('../../', 'playbooks') + shutil.copy( + os.path.join(awx_playbooks, 'build_ee.yml'), + os.path.join(private_data_dir, 'project', 'build_ee.yml'), + ) + + def _sync_and_copy_project(self, project, private_data_dir): + # A self-contained variant of SourceControlMixin.sync_and_copy that does + # not write project_update/scm_revision onto the build instance (an + # ExecutionEnvironmentBuilderBuild has neither field). + lock_acquired = self.acquire_lock(project, self.instance.id) + if not lock_acquired: + self.instance.refresh_from_db() + if self.instance.cancel_flag: + return + raise RuntimeError(f'Could not acquire lock for project {project.id}, job was interrupted') + try: + failed_reason = project.get_reason_if_failed() + if failed_reason: + self.update_model(self.instance.pk, status='failed', job_explanation=failed_reason) + raise RuntimeError(failed_reason) + sync_needs = self.get_sync_needs(project) + if sync_needs: + local_project_sync = self.spawn_project_sync(project, sync_needs) + try: + # RunProjectUpdate copies project content into private_data_dir on success + sync_task = RunProjectUpdate(job_private_data_dir=private_data_dir) + sync_task.run(local_project_sync.id) + except Exception: + local_project_sync.refresh_from_db() + if local_project_sync.status != 'canceled': + self.update_model( + self.instance.pk, + status='failed', + job_explanation=( + 'Previous Task Failed: {"job_type": "project_update", ' + f'"job_name": "{local_project_sync.name}", "job_id": "{local_project_sync.id}"}}' + ), + ) + raise + self.instance.refresh_from_db() + else: + # Local tree is up to date, just copy it into the job folder + RunProjectUpdate.make_local_copy(project, private_data_dir) + finally: + self.release_lock(project) 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 000000000..fedf58abb --- /dev/null +++ b/awx/main/tests/functional/api/test_execution_environment_builder.py @@ -0,0 +1,572 @@ +# Python +import os +import pytest +from unittest import mock + +# AWX +from awx.api.versioning import reverse +from awx.main.models import Organization, Project +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) + + +# --------------------------------------------------------------------------- +# Project + execution environment file +# --------------------------------------------------------------------------- + + +VALID_EE_CONTENT = 'version: 3\nimages:\n base_image:\n name: quay.io/fedora:latest\n' + + +def _write_ee_file(directory, relpath, content): + full = os.path.join(directory, relpath) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, 'w') as f: + f.write(content) + return full + + +@pytest.mark.django_db +class TestExecutionEnvironmentBuilderProjectFile: + def test_project_execution_environment_files_endpoint(self, get, admin, project, tmp_path): + _write_ee_file(str(tmp_path), 'execution-environment.yml', VALID_EE_CONTENT) + _write_ee_file(str(tmp_path), 'nested/execution-environment.yaml', VALID_EE_CONTENT) + _write_ee_file(str(tmp_path), 'unrelated.yml', 'foo: bar\n') + url = reverse('api:project_execution_environment_files', kwargs={'pk': project.pk}) + with mock.patch.object(Project, 'get_project_path', return_value=str(tmp_path)): + r = get(url, admin, expect=200) + assert sorted(r.data) == ['execution-environment.yml', 'nested/execution-environment.yaml'] + + def test_create_with_valid_version_3_file(self, post, admin, organization, project, tmp_path): + _write_ee_file(str(tmp_path), 'execution-environment.yml', VALID_EE_CONTENT) + url = reverse('api:execution_environment_builder_list') + with mock.patch.object(Project, 'get_project_path', return_value=str(tmp_path)): + r = post( + url, + { + 'name': 'proj-builder', + 'organization': organization.pk, + 'project': project.pk, + 'execution_environment_file': 'execution-environment.yml', + }, + admin, + expect=201, + ) + assert r.data['project'] == project.pk + assert r.data['execution_environment_file'] == 'execution-environment.yml' + + def test_create_rejects_file_without_version_3(self, post, admin, organization, project, tmp_path): + _write_ee_file(str(tmp_path), 'execution-environment.yml', 'version: 2\nimages: {}\n') + url = reverse('api:execution_environment_builder_list') + with mock.patch.object(Project, 'get_project_path', return_value=str(tmp_path)): + r = post( + url, + { + 'name': 'bad-version-builder', + 'organization': organization.pk, + 'project': project.pk, + 'execution_environment_file': 'execution-environment.yml', + }, + admin, + expect=400, + ) + assert 'version' in str(r.data['execution_environment_file']).lower() + + def test_create_rejects_missing_file(self, post, admin, organization, project, tmp_path): + url = reverse('api:execution_environment_builder_list') + with mock.patch.object(Project, 'get_project_path', return_value=str(tmp_path)): + r = post( + url, + { + 'name': 'missing-file-builder', + 'organization': organization.pk, + 'project': project.pk, + 'execution_environment_file': 'does-not-exist.yml', + }, + admin, + expect=400, + ) + assert 'execution_environment_file' in r.data + + def test_execution_environment_file_requires_project(self, post, admin, organization): + url = reverse('api:execution_environment_builder_list') + r = post( + url, + { + 'name': 'no-project-builder', + 'organization': organization.pk, + 'execution_environment_file': 'execution-environment.yml', + }, + admin, + expect=400, + ) + assert 'execution_environment_file' in r.data + + def test_cannot_use_project_without_use_role(self, post, ee_admin, organization, project): + """An EE admin without use_role on the project cannot assign it.""" + url = reverse('api:execution_environment_builder_list') + post( + url, + {'name': 'unauthorized-project-builder', 'organization': organization.pk, 'project': project.pk}, + ee_admin, + expect=403, + ) + + def test_ee_admin_can_use_project_with_use_role(self, post, ee_admin, organization, project): + """Granting use_role on the project lets an EE admin assign it.""" + project.use_role.members.add(ee_admin) + url = reverse('api:execution_environment_builder_list') + r = post( + url, + {'name': 'authorized-project-builder', 'organization': organization.pk, 'project': project.pk}, + ee_admin, + expect=201, + ) + assert r.data['project'] == project.pk diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 4e26f3291..da1f2b6ff 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -585,7 +585,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 000000000..acc606d61 --- /dev/null +++ b/awx/playbooks/build_ee.yml @@ -0,0 +1,136 @@ +- 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 }} from {{ execution_environment_file }}" + + - name: Validate that an execution environment file was provided + ansible.builtin.assert: + that: + - execution_environment_file is defined + - execution_environment_file | length > 0 + fail_msg: "An execution environment file must be selected from the project." + + - name: Ensure the execution environment file exists in the project + ansible.builtin.stat: + path: "{{ execution_environment_file }}" + register: ee_file_stat + + - name: Fail if the execution environment file is missing + ansible.builtin.assert: + that: + - ee_file_stat.stat.exists + fail_msg: "The execution environment file '{{ execution_environment_file }}' was not found in the project." + + - name: Read the execution environment definition file + ansible.builtin.slurp: + src: "{{ execution_environment_file }}" + register: ee_file_raw + + - name: Parse the execution environment definition + ansible.builtin.set_fact: + eed: "{{ ee_file_raw.content | b64decode | from_yaml }}" + + - name: Validate execution environment definition is version 3 + ansible.builtin.assert: + that: + - eed is defined + - eed.version is defined + - eed.version == 3 + fail_msg: "The execution environment definition must define 'version: 3' at the top level." + + - 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"] + + - 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: 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 + - 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 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 + -f {{ execution_environment_file | quote }} + -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 591447aa9..dc38120a0 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -10,6 +10,8 @@ import ConstructedInventories from './models/ConstructedInventories'; import FederatedInventories from './models/FederatedInventories'; 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'; @@ -62,6 +64,8 @@ const ConstructedInventoriesAPI = new ConstructedInventories(); const FederatedInventoriesAPI = new FederatedInventories(); 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(); @@ -115,6 +119,8 @@ export { FederatedInventoriesAPI, 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 000000000..996227db0 --- /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 000000000..5c99d6635 --- /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/api/models/Projects.js b/awx/ui/src/api/models/Projects.js index 437da8cac..813165837 100644 --- a/awx/ui/src/api/models/Projects.js +++ b/awx/ui/src/api/models/Projects.js @@ -14,6 +14,8 @@ class Projects extends SchedulesMixin( this.readAccessOptions = this.readAccessOptions.bind(this); this.readInventories = this.readInventories.bind(this); this.readPlaybooks = this.readPlaybooks.bind(this); + this.readExecutionEnvironmentFiles = + this.readExecutionEnvironmentFiles.bind(this); this.readSync = this.readSync.bind(this); this.sync = this.sync.bind(this); this.createSchedule = this.createSchedule.bind(this); @@ -35,6 +37,10 @@ class Projects extends SchedulesMixin( return this.http.get(`${this.baseUrl}${id}/playbooks/`); } + readExecutionEnvironmentFiles(id) { + return this.http.get(`${this.baseUrl}${id}/execution_environment_files/`); + } + readSync(id) { return this.http.get(`${this.baseUrl}${id}/update/`); } diff --git a/awx/ui/src/components/JobList/JobListItem.js b/awx/ui/src/components/JobList/JobListItem.js index e6f11ce09..3a15ebfbc 100644 --- a/awx/ui/src/components/JobList/JobListItem.js +++ b/awx/ui/src/components/JobList/JobListItem.js @@ -61,6 +61,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 f0c1811ad..161c1a87e 100644 --- a/awx/ui/src/components/LaunchButton/LaunchButton.js +++ b/awx/ui/src/components/LaunchButton/LaunchButton.js @@ -9,6 +9,7 @@ import { ProjectsAPI, WorkflowJobsAPI, WorkflowJobTemplatesAPI, + ExecutionEnvironmentBuilderBuildsAPI, } from 'api'; import useToast, { AlertVariant } from 'hooks/useToast'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; @@ -192,6 +193,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 { @@ -213,6 +216,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) { diff --git a/awx/ui/src/constants.js b/awx/ui/src/constants.js index 348c8969f..a33b83570 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 4b46e6021..3dd6bdc02 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?.isExecEnvAdmin) 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 000000000..71e46b8d6 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilder.js @@ -0,0 +1,53 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { Route, Routes, useParams } from 'react-router'; +import useRequest from 'hooks/useRequest'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import ExecutionEnvironmentBuilderDetails from './ExecutionEnvironmentBuilderDetail'; +import ExecutionEnvironmentBuilderEdit from './ExecutionEnvironmentBuilderEdit'; + +function ExecutionEnvironmentBuilder({ setBreadcrumb }) { + const { id } = useParams(); + const [builder, setBuilder] = useState(null); + + const { request: fetchBuilder, isLoading } = useRequest( + useCallback(async () => { + const { data } = await ExecutionEnvironmentBuildersAPI.readDetail(id); + setBuilder(data); + setBreadcrumb(data); + return data; + }, [id, setBreadcrumb]) + ); + + useEffect(() => { + fetchBuilder(); + }, [id, fetchBuilder]); + + const handleBuilderUpdate = useCallback(() => { + fetchBuilder(); + }, [fetchBuilder]); + + 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 000000000..f9d14841b --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.js @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +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 navigate = useNavigate(); + + const handleSubmit = async (values) => { + setFormSubmitError(null); + try { + const submitData = { + ...values, + project: values.project?.id || null, + credential: values.credential?.id || null, + }; + const { + data: { id }, + } = await ExecutionEnvironmentBuildersAPI.create(submitData); + navigate(`/execution_environment_builders/${id}`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + navigate('/execution_environment_builders'); + }; + + return ( + + + + + + + + ); +} + +export default ExecutionEnvironmentBuilderAdd; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.test.js new file mode 100644 index 000000000..dcabd1553 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd.test.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { screen, waitFor } from '@testing-library/react'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; +import ExecutionEnvironmentBuilderAdd from './ExecutionEnvironmentBuilderAdd'; + +jest.mock('../../../api'); + +const submitValues = { + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'latest', + execution_environment_file: 'execution-environment.yml', + project: 7, +}; + +// The form has its own suite; stub it so we can drive the container's +// submit/cancel/error handling. +jest.mock('../shared/ExecutionEnvironmentBuilderForm', () => + function MockExecutionEnvironmentBuilderForm({ onSubmit, onCancel, submitError }) { + return ( +
+ {submitError ?
: null} + + + +
+ ); + } +); + +describe('', () => { + let history; + + const renderAdd = () => { + history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/add'], + }); + return renderWithContexts(, { + context: { router: { history } }, + }); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render form', () => { + renderAdd(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + test('handleSubmit should call the api and redirect to detail page', async () => { + ExecutionEnvironmentBuildersAPI.create.mockResolvedValue({ + data: { id: 42 }, + }); + const { user } = renderAdd(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.create).toHaveBeenCalledWith({ + ...submitValues, + credential: 4, + }) + ); + await waitFor(() => + expect(history.location.pathname).toBe('/execution_environment_builders/42') + ); + }); + + test('handleSubmit should send null credential when not provided', async () => { + ExecutionEnvironmentBuildersAPI.create.mockResolvedValue({ + data: { id: 42 }, + }); + const { user } = renderAdd(); + await user.click( + screen.getByRole('button', { name: 'Submit without credential' }) + ); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.create).toHaveBeenCalledWith({ + ...submitValues, + credential: null, + }) + ); + }); + + test('handleCancel should return the user back to the list', async () => { + const { user } = renderAdd(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(history.location.pathname).toEqual('/execution_environment_builders'); + }); + + test('failed form submission should show an error message', async () => { + ExecutionEnvironmentBuildersAPI.create.mockRejectedValue({ + response: { data: { detail: 'An error occurred' } }, + }); + const { user } = renderAdd(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(await screen.findByTestId('form-submit-error')).toBeInTheDocument(); + }); +}); 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 000000000..330937928 --- /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 000000000..92f2c6abc --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.js @@ -0,0 +1,178 @@ +import React, { useState, useCallback } from 'react'; +import { Link, useNavigate } from 'react-router'; +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 DeleteButton from 'components/DeleteButton'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; + +function ExecutionEnvironmentBuilderDetails({ builder, isLoading }) { + const { t } = useLingui(); + const navigate = useNavigate(); + const [isLaunchDisabled, setIsLaunchDisabled] = useState(false); + const [launchError, setLaunchError] = useState(null); + + const { + request: deleteBuilder, + isLoading: deleteLoading, + error: deleteError, + } = useRequest( + useCallback(async () => { + await ExecutionEnvironmentBuildersAPI.destroy(builder.id); + navigate('/execution_environment_builders'); + }, [builder, navigate]) + ); + + const launchBuilder = useCallback(async () => { + try { + setIsLaunchDisabled(true); + const response = await ExecutionEnvironmentBuildersAPI.launch(builder.id, { + name: `${builder?.name}`, + }); + if (response.status === 201) { + navigate(`/jobs/build/${response.data.execution_environment_builder_build}`); + } + } catch (err) { + setLaunchError(err); + } finally { + setIsLaunchDisabled(false); + } + }, [builder, navigate]); + + const { error, dismissError } = useDismissableError(deleteError); + + if (isLoading) { + return ; + } + + if (!builder) { + return
{t`Execution Environment Builder not found`}
; + } + + return ( + + + + + + + + {builder.summary_fields?.project && ( + + {builder.summary_fields.project.name} + + } + dataCy="builder-detail-project" + /> + )} + {builder.execution_environment_file && ( + + )} + {builder.summary_fields?.organization && ( + + {builder.summary_fields.organization.name} + + } + /> + )} + {builder.summary_fields?.credential && ( + + )} + + + + + {builder.summary_fields?.user_capabilities?.start && ( + + )} + {builder.summary_fields?.user_capabilities?.edit && ( + + )} + {builder.summary_fields?.user_capabilities?.delete && ( + + {t`Delete`} + + )} + + {error && ( + + + + )} + {launchError && ( + setLaunchError(null)} + title={t`Error`} + variant="error" + > + {t`Failed to launch build.`} + + + )} + + + + ); +} + +export default ExecutionEnvironmentBuilderDetails; 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 000000000..1a423fe1d --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderDetail/ExecutionEnvironmentBuilderDetails.test.js @@ -0,0 +1,232 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { + renderWithContexts, + assertDetail, +} from '../../../../testUtils/rtlContexts'; + +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', + execution_environment_file: 'execution-environment.yml', + 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, + }, + project: { + id: 7, + name: 'Demo Project', + }, + 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: '', + }, + }, +}; + +const withCapabilities = (capabilities) => ({ + ...builder, + summary_fields: { + ...builder.summary_fields, + user_capabilities: { + ...builder.summary_fields.user_capabilities, + ...capabilities, + }, + }, +}); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render details properly', async () => { + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + assertDetail('Name', builder.name); + assertDetail('Image', builder.image); + assertDetail('Tag', builder.tag); + assertDetail('Credential', builder.summary_fields.credential.name); + assertDetail('Project', builder.summary_fields.project.name); + assertDetail( + 'Execution environment file', + builder.execution_environment_file + ); + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Last Modified')).toBeInTheDocument(); + }); + + test('should render loading state', () => { + renderWithContexts( + + ); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + test('should render not found when builder is null and not loading', () => { + renderWithContexts( + + ); + expect(screen.getByText(/not found/)).toBeInTheDocument(); + }); + + test('should show launch button for users with start permission', async () => { + renderWithContexts( + + ); + expect( + await screen.findByRole('button', { name: 'Launch' }) + ).toBeInTheDocument(); + }); + + test('should hide launch button for users without start permission', async () => { + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + expect( + screen.queryByRole('button', { name: 'Launch' }) + ).not.toBeInTheDocument(); + }); + + test('should show edit button for users with edit permission', async () => { + renderWithContexts( + + ); + expect( + await screen.findByRole('link', { name: 'Edit' }) + ).toBeInTheDocument(); + }); + + test('should hide edit button for users without edit permission', async () => { + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + expect(screen.queryByRole('link', { name: 'Edit' })).not.toBeInTheDocument(); + }); + + test('should show delete button for users with delete permission', async () => { + renderWithContexts( + + ); + expect( + await screen.findByRole('button', { name: 'Delete' }) + ).toBeInTheDocument(); + }); + + test('should hide delete button for users without delete permission', async () => { + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + expect( + screen.queryByRole('button', { name: 'Delete' }) + ).not.toBeInTheDocument(); + }); + + test('expected api call is made for delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/17/details'], + }); + const { user } = renderWithContexts( + , + { + context: { router: { history } }, + } + ); + await user.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByLabelText('Confirm Delete')); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.destroy).toHaveBeenCalledTimes(1) + ); + await waitFor(() => + 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 { user } = renderWithContexts( + + ); + await user.click(await screen.findByRole('button', { name: 'Launch' })); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.launch).toHaveBeenCalledWith(17, { + name: 'Test Builder', + }) + ); + }); + + test('should render organization detail', async () => { + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + assertDetail('Organization', builder.summary_fields.organization.name); + }); + + test('should not render organization detail when not present', async () => { + const builderWithoutOrg = { + ...builder, + summary_fields: { + ...builder.summary_fields, + organization: undefined, + }, + }; + renderWithContexts( + + ); + await waitFor(() => expect(screen.getByText('Name')).toBeInTheDocument()); + expect(screen.queryByText('Organization')).not.toBeInTheDocument(); + }); +}); 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 000000000..292c3f446 --- /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 000000000..cb0a53b4a --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.js @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +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 navigate = useNavigate(); + const { t } = useLingui(); + const [formSubmitError, setFormSubmitError] = useState(null); + + const handleSubmit = async (values) => { + setFormSubmitError(null); + try { + const submitData = { + ...values, + project: values.project?.id || null, + credential: values.credential?.id || null, + }; + await ExecutionEnvironmentBuildersAPI.update(builder.id, submitData); + if (onUpdate) { + onUpdate(); + } + navigate(`/execution_environment_builders/${builder.id}`); + } catch (error) { + setFormSubmitError(error); + } + }; + + const handleCancel = () => { + navigate(`/execution_environment_builders/${builder.id}`); + }; + + if (!builder) { + return
{t`Loading...`}
; + } + + return ( + + + + + + + + ); +} + +export default ExecutionEnvironmentBuilderEdit; 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 000000000..b6915df3f --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderEdit/ExecutionEnvironmentBuilderEdit.test.js @@ -0,0 +1,135 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { screen, waitFor } from '@testing-library/react'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; + +import ExecutionEnvironmentBuilderEdit from './ExecutionEnvironmentBuilderEdit'; + +jest.mock('../../../api'); + +const builderData = { + id: 42, + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'latest', + execution_environment_file: 'execution-environment.yml', + summary_fields: { + project: { + id: 7, + name: 'Demo Project', + }, + credential: { + id: 4, + name: 'Container Registry', + kind: 'registry', + }, + }, +}; + +const updatedValues = { + name: 'Updated Builder', + image: 'updated-ee', + tag: 'v2', + execution_environment_file: 'nested/execution-environment.yml', + project: 7, + credential: { id: 4, name: 'Container Registry' }, +}; + +// The form has its own suite; stub it so we can drive the container's +// submit/cancel/error handling. +jest.mock('../shared/ExecutionEnvironmentBuilderForm', () => + function MockExecutionEnvironmentBuilderForm({ onSubmit, onCancel, submitError }) { + return ( +
+ {submitError ?
: null} + + +
+ ); + } +); + +describe('', () => { + let history; + let onUpdate; + + const renderEdit = (builder = builderData) => { + history = createMemoryHistory({ + initialEntries: ['/execution_environment_builders/42/edit'], + }); + onUpdate = jest.fn(); + return renderWithContexts( + , + { + context: { router: { history } }, + } + ); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render form', () => { + renderEdit(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + test('handleSubmit should call the api and redirect to detail page', async () => { + ExecutionEnvironmentBuildersAPI.update.mockResolvedValue({}); + const { user } = renderEdit(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.update).toHaveBeenCalledWith(42, { + ...updatedValues, + credential: 4, + }) + ); + expect(onUpdate).toHaveBeenCalled(); + await waitFor(() => + expect(history.location.pathname).toEqual( + '/execution_environment_builders/42' + ) + ); + }); + + test('should navigate to detail page when cancel is clicked', async () => { + const { user } = renderEdit(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(history.location.pathname).toEqual( + '/execution_environment_builders/42' + ); + }); + + test('failed form submission should show an error message', async () => { + ExecutionEnvironmentBuildersAPI.update.mockRejectedValue({ + response: { data: { detail: 'An error occurred' } }, + }); + const { user } = renderEdit(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(await screen.findByTestId('form-submit-error')).toBeInTheDocument(); + }); + + test('should render loading when builder is null', () => { + renderEdit(null); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); 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 000000000..289f12c27 --- /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 000000000..fbffefc5c --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.js @@ -0,0 +1,211 @@ +import React, { useEffect, useCallback } from 'react'; +import { useLocation } from 'react-router'; +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 { addToast, Toast, toastProps } = useToast(); + + const { + result: { + results, + itemCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, + error: contentError, + 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/ExecutionEnvironmentBuilderList.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.test.js new file mode 100644 index 000000000..3d56fdfc0 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; + +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('', () => { + beforeEach(() => { + ExecutionEnvironmentBuildersAPI.read.mockResolvedValue( + executionEnvironmentBuilders + ); + ExecutionEnvironmentBuildersAPI.readOptions.mockResolvedValue(options); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should have data fetched and render 2 rows', async () => { + renderWithContexts(); + expect(await screen.findByText('Builder One')).toBeInTheDocument(); + expect(screen.getByText('Builder Two')).toBeInTheDocument(); + expect(ExecutionEnvironmentBuildersAPI.read).toHaveBeenCalled(); + expect(ExecutionEnvironmentBuildersAPI.readOptions).toHaveBeenCalled(); + }); + + test('should delete items successfully', async () => { + const { user } = renderWithContexts(); + await screen.findByText('Builder One'); + + await user.click(screen.getByRole('checkbox', { name: 'Select row 0' })); + await user.click(screen.getByRole('checkbox', { name: 'Select row 1' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByLabelText('confirm delete')); + + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.destroy).toHaveBeenCalledTimes(2) + ); + }); + + test('should render deletion error modal', async () => { + ExecutionEnvironmentBuildersAPI.destroy.mockRejectedValue(new Error('nope')); + const { user } = renderWithContexts(); + await screen.findByText('Builder One'); + + await user.click(screen.getByRole('checkbox', { name: 'Select row 0' })); + await user.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByLabelText('confirm delete')); + + expect(await screen.findByLabelText('Deletion error')).toBeInTheDocument(); + }); + + test('should show a content error when the fetch fails', async () => { + ExecutionEnvironmentBuildersAPI.read.mockRejectedValue(new Error('nope')); + renderWithContexts(); + expect( + await screen.findByText('Something went wrong...') + ).toBeInTheDocument(); + }); + + test('should not render add button', async () => { + ExecutionEnvironmentBuildersAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + renderWithContexts(); + await screen.findByText('Builder One'); + expect(screen.queryByRole('link', { name: 'Add' })).not.toBeInTheDocument(); + }); +}); 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 000000000..774d421ea --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.js @@ -0,0 +1,169 @@ +import React, { useState, useCallback } from 'react'; +import { string, bool, func, number, object } from 'prop-types'; +import { useLingui } from '@lingui/react/macro'; +import { Link, useNavigate } from 'react-router'; +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 AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { timeOfDay } from 'util/dates'; + +function ExecutionEnvironmentBuilderListItem({ + executionEnvironmentBuilder, + detailUrl, + isSelected, + onSelect, + onCopy, + rowIndex, + fetchExecutionEnvironmentBuilders, +}) { + const { t } = useLingui(); + const navigate = useNavigate(); + const [isDisabled, setIsDisabled] = useState(false); + const [isLaunchDisabled, setIsLaunchDisabled] = useState(false); + const [launchError, setLaunchError] = useState(null); + + 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) { + navigate(`/jobs/build/${response.data.execution_environment_builder_build}`); + } + } catch (err) { + setLaunchError(err); + } finally { + setIsLaunchDisabled(false); + } + }, [executionEnvironmentBuilder.id, executionEnvironmentBuilder.name, navigate]); + + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + + return ( + + + + + {executionEnvironmentBuilder.name} + + + + {executionEnvironmentBuilder.image} + + + {executionEnvironmentBuilder.tag} + + + + + + + + + + + + + {launchError && ( + setLaunchError(null)} + title={t`Error`} + variant="error" + > + {t`Failed to launch build.`} + + + )} + + ); +} + +ExecutionEnvironmentBuilderListItem.propTypes = { + executionEnvironmentBuilder: object.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + onCopy: func.isRequired, + rowIndex: number.isRequired, + fetchExecutionEnvironmentBuilders: func.isRequired, +}; + +export default ExecutionEnvironmentBuilderListItem; 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 000000000..e9758e7c3 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderListItem.test.js @@ -0,0 +1,138 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; + +import { ExecutionEnvironmentBuildersAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; + +import ExecutionEnvironmentBuilderListItem from './ExecutionEnvironmentBuilderListItem'; + +jest.mock('../../../api'); + +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 }, + }, +}; + +const renderItem = (props = {}) => + renderWithContexts( + + + {}} + onCopy={jest.fn()} + rowIndex={0} + fetchExecutionEnvironmentBuilders={jest.fn().mockResolvedValue()} + {...props} + /> + +
+ ); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should mount successfully', () => { + renderItem(); + expect(screen.getByRole('row')).toBeInTheDocument(); + }); + + test('should render the proper data', () => { + renderItem(); + expect(screen.getByText(executionEnvironmentBuilder.name)).toBeInTheDocument(); + expect( + screen.getByText(executionEnvironmentBuilder.image) + ).toBeInTheDocument(); + expect(screen.getByText(executionEnvironmentBuilder.tag)).toBeInTheDocument(); + expect(screen.getByLabelText('Edit')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Launch' })).toBeInTheDocument(); + }); + + test('should call api to copy execution environment builder', async () => { + ExecutionEnvironmentBuildersAPI.copy.mockResolvedValue({ + status: 201, + data: { id: 2 }, + }); + const onCopy = jest.fn(); + const { user } = renderItem({ + onCopy, + fetchExecutionEnvironmentBuilders: jest.fn().mockResolvedValue({}), + }); + await user.click(screen.getByRole('button', { name: 'Copy' })); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.copy).toHaveBeenCalled() + ); + }); + + test('should render proper alert modal on copy error', async () => { + ExecutionEnvironmentBuildersAPI.copy.mockRejectedValue(new Error()); + const { user } = renderItem(); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect( + await screen.findByText('Failed to copy execution environment builder') + ).toBeInTheDocument(); + }); + + test('should not render copy button when user lacks copy permission', () => { + renderItem({ + executionEnvironmentBuilder: { + ...executionEnvironmentBuilder, + summary_fields: { + user_capabilities: { copy: false, edit: true, delete: true, start: true }, + }, + }, + }); + expect( + screen.queryByRole('button', { name: 'Copy' }) + ).not.toBeInTheDocument(); + }); + + test('should not render edit button when user lacks edit permission', () => { + renderItem({ + executionEnvironmentBuilder: { + ...executionEnvironmentBuilder, + summary_fields: { + user_capabilities: { edit: false, copy: true, delete: true, start: true }, + }, + }, + }); + expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument(); + }); + + test('should not render launch button when user lacks start permission', () => { + renderItem({ + executionEnvironmentBuilder: { + ...executionEnvironmentBuilder, + summary_fields: { + user_capabilities: { edit: true, copy: true, delete: true, start: false }, + }, + }, + }); + expect( + screen.queryByRole('button', { name: 'Launch' }) + ).not.toBeInTheDocument(); + }); + + test('should call launch api when launch button is clicked', async () => { + ExecutionEnvironmentBuildersAPI.launch.mockResolvedValue({ + status: 201, + data: { execution_environment_builder_build: 99 }, + }); + const { user } = renderItem(); + await user.click(screen.getByRole('button', { name: 'Launch' })); + await waitFor(() => + expect(ExecutionEnvironmentBuildersAPI.launch).toHaveBeenCalledWith(1, { + name: 'Builder One', + }) + ); + }); +}); 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 000000000..a29edb6dd --- /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 000000000..a9f120cb1 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.js @@ -0,0 +1,65 @@ +import React, { useState, useCallback } from 'react'; +import { Route, Routes } from 'react-router'; +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 ExecutionEnvironmentBuilders; 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 000000000..fe4c1b399 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/ExecutionEnvironmentBuilders.test.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { Routes, Route } from 'react-router'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; + +import ExecutionEnvironmentBuilders from './ExecutionEnvironmentBuilders'; + +jest.mock('../../api/models/ExecutionEnvironmentBuilders'); + +// Replace the routed children with markers so the assertions are purely about +// which branch of the v6 tree resolves for a given URL. +jest.mock( + './ExecutionEnvironmentBuilderList/ExecutionEnvironmentBuilderList', + () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => + ReactLib.createElement('div', null, 'ExecutionEnvironmentBuilderList'), + }; + } +); +jest.mock( + './ExecutionEnvironmentBuilderAdd/ExecutionEnvironmentBuilderAdd', + () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => + ReactLib.createElement('div', null, 'ExecutionEnvironmentBuilderAdd'), + }; + } +); +jest.mock('./ExecutionEnvironmentBuilder', () => { + const ReactLib = require('react'); + return { + __esModule: true, + default: () => + ReactLib.createElement('div', null, 'ExecutionEnvironmentBuilder detail'), + }; +}); + +function renderAt(path) { + const history = createMemoryHistory({ initialEntries: [path] }); + return renderWithContexts( + + } + /> + , + { + context: { router: { history } }, + } + ); +} + +describe('', () => { + test('renders the list at /execution_environment_builders', async () => { + renderAt('/execution_environment_builders'); + expect( + await screen.findByText('ExecutionEnvironmentBuilderList') + ).toBeInTheDocument(); + }); + + test('renders the add form at /execution_environment_builders/add', async () => { + renderAt('/execution_environment_builders/add'); + expect( + await screen.findByText('ExecutionEnvironmentBuilderAdd') + ).toBeInTheDocument(); + }); + + test('renders the detail view at /execution_environment_builders/:id', async () => { + renderAt('/execution_environment_builders/1'); + expect( + await screen.findByText('ExecutionEnvironmentBuilder detail') + ).toBeInTheDocument(); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/index.js new file mode 100644 index 000000000..daf6ebf35 --- /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 000000000..5643c37c1 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.js @@ -0,0 +1,176 @@ +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, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import FormField, { FormSubmitError } from 'components/FormField'; +import FormActionGroup from 'components/FormActionGroup'; +import { FormColumnLayout } from 'components/FormLayout'; +import { required } from 'util/validators'; +import CredentialLookup from 'components/Lookup/CredentialLookup'; +import ProjectLookup from 'components/Lookup/ProjectLookup'; +import ExecutionEnvironmentFileSelect from './ExecutionEnvironmentFileSelect'; + +function ExecutionEnvironmentBuilderFormFields() { + const { t } = useLingui(); + const [credentialField, credentialMeta, credentialHelpers] = + useField('credential'); + const [projectField, projectMeta, projectHelpers] = useField({ + name: 'project', + validate: required(null), + }); + const [eeFileField, eeFileMeta, eeFileHelpers] = useField({ + name: 'execution_environment_file', + validate: required(null), + }); + + const { setFieldValue, setFieldTouched } = useFormikContext(); + + const onCredentialChange = useCallback( + (value) => { + setFieldValue('credential', value); + }, + [setFieldValue] + ); + + const onProjectChange = useCallback( + (value) => { + setFieldValue('project', value); + setFieldValue('execution_environment_file', '', false); + setFieldTouched('execution_environment_file', false); + }, + [setFieldValue, setFieldTouched] + ); + + const onEEFileChange = useCallback( + (value) => { + setFieldValue('execution_environment_file', value); + setFieldTouched('execution_environment_file', true, false); + }, + [setFieldValue, setFieldTouched] + ); + + return ( + <> + + + + projectHelpers.setTouched()} + isValid={Boolean( + !projectMeta.touched || (!projectMeta.error && projectField.value) + )} + helperTextInvalid={projectMeta.error} + onChange={onProjectChange} + required + /> + + eeFileHelpers.setTouched()} + onChange={onEEFileChange} + /> + {eeFileMeta.error && ( + + + + {eeFileMeta.error} + + + + )} + + credentialHelpers.setTouched()} + onChange={onCredentialChange} + value={credentialField.value} + /> + + ); +} + +function ExecutionEnvironmentBuilderForm({ + executionEnvironmentBuilder = {}, + onSubmit, + onCancel, + submitError = null, + ...rest +}) { + const initialValues = { + name: executionEnvironmentBuilder.name || '', + image: executionEnvironmentBuilder.image || '', + tag: executionEnvironmentBuilder.tag || 'latest', + project: executionEnvironmentBuilder.summary_fields?.project || null, + execution_environment_file: + executionEnvironmentBuilder.execution_environment_file || '', + credential: executionEnvironmentBuilder.summary_fields?.credential || null, + }; + + return ( + onSubmit(values)} + > + {(formik) => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +ExecutionEnvironmentBuilderForm.propTypes = { + executionEnvironmentBuilder: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +export default ExecutionEnvironmentBuilderForm; 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 000000000..f513a9b65 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentBuilderForm.test.js @@ -0,0 +1,135 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { CredentialsAPI, ProjectsAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; + +import ExecutionEnvironmentBuilderForm from './ExecutionEnvironmentBuilderForm'; + +jest.mock('../../../api'); + +const executionEnvironmentBuilder = { + id: 16, + name: 'Test Builder', + image: 'my-custom-ee', + tag: 'v1', + execution_environment_file: 'execution-environment.yml', + summary_fields: { + project: { + id: 7, + name: 'Demo Project', + }, + credential: { + id: 4, + name: 'Container Registry', + kind: 'registry', + }, + }, +}; + +const renderForm = async (props = {}) => { + CredentialsAPI.read.mockResolvedValue({ + data: { results: [], count: 0 }, + }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} }, related_search_fields: [] }, + }); + ProjectsAPI.read.mockResolvedValue({ + data: { results: [{ id: 7, name: 'Demo Project' }], count: 1 }, + }); + ProjectsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} }, related_search_fields: [] }, + }); + ProjectsAPI.readExecutionEnvironmentFiles.mockResolvedValue({ + data: ['execution-environment.yml'], + }); + const onCancel = jest.fn(); + const onSubmit = jest.fn(); + const result = renderWithContexts( + + ); + // wait for the form to finish loading + await screen.findByRole('button', { name: 'Save' }); + return { ...result, onCancel, onSubmit }; +}; + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should display form fields properly', async () => { + const { container } = await renderForm(); + expect(container.querySelector('#eeb-name')).toBeInTheDocument(); + expect(container.querySelector('#eeb-image')).toBeInTheDocument(); + expect(container.querySelector('#eeb-tag')).toBeInTheDocument(); + expect(screen.getByText('Registry credential')).toBeInTheDocument(); + expect(screen.getByText('Project')).toBeInTheDocument(); + expect( + screen.getByText('Execution environment file') + ).toBeInTheDocument(); + }); + + test('should call onSubmit when form submitted', async () => { + const { user, onSubmit } = await renderForm(); + expect(onSubmit).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + test('should update form values', async () => { + const { user, container } = await renderForm(); + const nameField = container.querySelector('#eeb-name'); + await user.clear(nameField); + await user.type(nameField, 'Updated Name'); + expect(nameField).toHaveValue('Updated Name'); + + const imageField = container.querySelector('#eeb-image'); + await user.clear(imageField); + await user.type(imageField, 'updated-image'); + expect(imageField).toHaveValue('updated-image'); + + const tagField = container.querySelector('#eeb-tag'); + await user.clear(tagField); + await user.type(tagField, 'v2'); + expect(tagField).toHaveValue('v2'); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + const { user, onCancel } = await renderForm(); + expect(onCancel).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalled(); + }); + + test('should render with default values for new builder', async () => { + const { container } = await renderForm({ + executionEnvironmentBuilder: undefined, + }); + expect(container.querySelector('#eeb-name')).toHaveValue(''); + expect(container.querySelector('#eeb-tag')).toHaveValue('latest'); + }); + + test('should show submit error when submitError prop is provided', async () => { + const submitError = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + const { container } = await renderForm({ submitError }); + await waitFor(() => + expect( + container.querySelector('[data-ouia-component-id="form-submit-error-alert"]') + ).toBeInTheDocument() + ); + }); + + test('should populate credential from builder summary_fields', async () => { + await renderForm(); + expect(screen.getByDisplayValue('Container Registry')).toBeInTheDocument(); + }); +}); diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.js new file mode 100644 index 000000000..4e82d6b1f --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.js @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { + Button, + MenuToggle, + Select, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; +import { ProjectsAPI } from 'api'; +import useRequest from 'hooks/useRequest'; + +const noop = () => {}; + +function ExecutionEnvironmentFileSelect({ + projectId = null, + isValid = true, + selected, + onBlur, + onError = noop, + onChange = noop, +}) { + const { t } = useLingui(); + const [isDisabled, setIsDisabled] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [filterValue, setFilterValue] = useState(''); + const { + result: options, + request: fetchOptions, + isLoading, + error, + } = useRequest( + useCallback(async () => { + if (!projectId) { + return []; + } + const { data } = await ProjectsAPI.readExecutionEnvironmentFiles( + projectId + ); + + if (data.length === 1) { + onChange(data[0]); + } + return data; + }, [projectId, onChange]), + [] + ); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + useEffect(() => { + if (error) { + if (error.response?.status === 403) { + setIsDisabled(true); + } else { + onError(error); + } + } + }, [error, onError]); + + const filteredOptions = filterValue + ? options.filter((opt) => + opt.toLowerCase().includes(filterValue.toLowerCase()) + ) + : options; + + return ( + + ); +} + +export { ExecutionEnvironmentFileSelect as _ExecutionEnvironmentFileSelect }; +export default ExecutionEnvironmentFileSelect; diff --git a/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.test.js b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.test.js new file mode 100644 index 000000000..0c6d14216 --- /dev/null +++ b/awx/ui/src/screens/ExecutionEnvironmentBuilder/shared/ExecutionEnvironmentFileSelect.test.js @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@lingui/react'; +import { i18n } from '@lingui/core'; +import english from '../../../locales/en/messages'; +import { ProjectsAPI } from 'api'; +import ExecutionEnvironmentFileSelect from './ExecutionEnvironmentFileSelect'; + +i18n.load({ en: english.messages }); +i18n.activate('en'); + +const renderWithI18n = (component) => + render({component}); + +jest.mock('api'); + +describe('', () => { + beforeEach(() => { + ProjectsAPI.readExecutionEnvironmentFiles.mockReturnValue({ + data: ['execution-environment.yml', 'nested/execution-environment.yaml'], + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should not fetch when no project is provided', async () => { + renderWithI18n( + {}} + onError={() => {}} + /> + ); + await waitFor(() => + expect(ProjectsAPI.readExecutionEnvironmentFiles).not.toHaveBeenCalled() + ); + }); + + test('should reload files when the project value changes', async () => { + const { rerender } = renderWithI18n( + {}} + onError={() => {}} + /> + ); + + await waitFor(() => + expect(ProjectsAPI.readExecutionEnvironmentFiles).toHaveBeenCalledWith(1) + ); + + rerender( + + {}} + onError={() => {}} + /> + + ); + + await waitFor(() => { + expect(ProjectsAPI.readExecutionEnvironmentFiles).toHaveBeenCalledTimes(2); + expect(ProjectsAPI.readExecutionEnvironmentFiles).toHaveBeenCalledWith(15); + }); + }); + + test('should trigger onChange for the selected option', async () => { + const mockCallback = jest.fn(); + renderWithI18n( + {}} + /> + ); + + await waitFor(() => + expect(ProjectsAPI.readExecutionEnvironmentFiles).toHaveBeenCalledWith(1) + ); + + const input = screen.getByRole('textbox', { + name: 'Select an execution environment file', + }); + fireEvent.click(input); + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBe(2); + }); + + fireEvent.click(screen.getByText('execution-environment.yml')); + expect(mockCallback).toHaveBeenCalledWith('execution-environment.yml'); + }); + + test('should auto-select when only one file is available', async () => { + ProjectsAPI.readExecutionEnvironmentFiles.mockReturnValue({ + data: ['execution-environment.yml'], + }); + const mockCallback = jest.fn(); + renderWithI18n( + {}} + /> + ); + + await waitFor(() => + expect(mockCallback).toHaveBeenCalledWith('execution-environment.yml') + ); + }); +}); 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 000000000..fed6268e8 --- /dev/null +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.js @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +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 { object } from 'prop-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 navigate = useNavigate(); + + const credential = builder?.summary_fields?.credential; + + const deleteJob = async () => { + try { + await ExecutionEnvironmentBuilderBuildsAPI.destroy(job.id); + navigate('/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: object.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/ExecutionEnvironmentBuilderBuildDetail.test.js b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js new file mode 100644 index 000000000..46d87c415 --- /dev/null +++ b/awx/ui/src/screens/Job/ExecutionEnvironmentBuilderBuildDetail/ExecutionEnvironmentBuilderBuildDetail.test.js @@ -0,0 +1,249 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { ExecutionEnvironmentBuilderBuildsAPI } from 'api'; +import { + renderWithContexts, + assertDetail, +} from '../../../../testUtils/rtlContexts'; +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('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should display job details', () => { + renderWithContexts( + + ); + assertDetail('Job ID', '101'); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Started')).toBeInTheDocument(); + expect(screen.getByText('Finished')).toBeInTheDocument(); + assertDetail('Environment Name', 'My Builder'); + assertDetail('Image', 'quay.io/my-org/my-ee'); + assertDetail('Tag', 'latest'); + }); + + test('should not display finished date when job has not finished', () => { + renderWithContexts( + + ); + expect(screen.queryByText('Finished')).not.toBeInTheDocument(); + }); + + test('should display credential chip when credential exists', () => { + renderWithContexts( + + ); + expect(screen.getByText('Credential')).toBeInTheDocument(); + expect(screen.getByText('Registry Cred')).toBeInTheDocument(); + }); + + 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: {}, + }, + }, + }; + renderWithContexts( + + ); + expect(screen.queryByText('Credential')).not.toBeInTheDocument(); + }); + + test('should show Relaunch button when user can start', () => { + renderWithContexts( + + ); + expect( + screen.getByRole('button', { name: 'Relaunch' }) + ).toBeInTheDocument(); + }); + + test('should hide Relaunch button when user cannot start', () => { + const jobNoStart = { + ...mockJob, + summary_fields: { + ...mockJob.summary_fields, + user_capabilities: { start: false, delete: true }, + }, + }; + renderWithContexts( + + ); + expect( + screen.queryByRole('button', { name: 'Relaunch' }) + ).not.toBeInTheDocument(); + }); + + test('should show Delete button when job is completed and user can delete', () => { + renderWithContexts( + + ); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + test('should hide Delete button when user cannot delete', () => { + const jobNoDelete = { + ...mockJob, + summary_fields: { + ...mockJob.summary_fields, + user_capabilities: { start: true, delete: false }, + }, + }; + renderWithContexts( + + ); + expect( + screen.queryByRole('button', { name: 'Delete' }) + ).not.toBeInTheDocument(); + }); + + test('should hide Delete button when job is running', () => { + const runningJob = { ...mockJob, status: 'running' }; + renderWithContexts( + + ); + expect( + screen.queryByRole('button', { name: 'Delete' }) + ).not.toBeInTheDocument(); + }); + + test('should show Cancel button when job is running and user can start', () => { + const runningJob = { ...mockJob, status: 'running' }; + renderWithContexts( + + ); + expect( + screen.getByRole('button', { name: 'Cancel Build' }) + ).toBeInTheDocument(); + }); + + test('should hide Cancel button when job is not running', () => { + renderWithContexts( + + ); + expect( + screen.queryByRole('button', { name: 'Cancel Build' }) + ).not.toBeInTheDocument(); + }); + + 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 }, + }, + }; + renderWithContexts( + + ); + expect( + screen.queryByRole('button', { name: 'Cancel Build' }) + ).not.toBeInTheDocument(); + }); + + test('should call API destroy and navigate to /jobs on successful delete', async () => { + const history = createMemoryHistory({ + initialEntries: ['/jobs/101/details'], + }); + ExecutionEnvironmentBuilderBuildsAPI.destroy.mockResolvedValue({}); + const { user } = renderWithContexts( + , + { context: { router: { history } } } + ); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByLabelText('Confirm Delete')); + + await waitFor(() => + expect(ExecutionEnvironmentBuilderBuildsAPI.destroy).toHaveBeenCalledWith( + 101 + ) + ); + await waitFor(() => expect(history.location.pathname).toBe('/jobs')); + }); + + test('should display error modal when delete fails', async () => { + ExecutionEnvironmentBuilderBuildsAPI.destroy.mockRejectedValue( + new Error('Delete failed') + ); + const { user } = renderWithContexts( + + ); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + fireEvent.click(await screen.findByLabelText('Confirm Delete')); + + expect( + await screen.findByText('Build Delete Error') + ).toBeInTheDocument(); + }); + + test('should display job_explanation in status when provided', () => { + const jobWithExplanation = { + ...mockJob, + status: 'failed', + job_explanation: 'Build timed out', + }; + renderWithContexts( + + ); + expect(screen.getByText('Build timed out')).toBeInTheDocument(); + }); +}); 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 000000000..4f622e53b --- /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 de9ad0bdc..a6015803b 100644 --- a/awx/ui/src/screens/Job/Job.js +++ b/awx/ui/src/screens/Job/Job.js @@ -17,6 +17,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'; @@ -36,6 +37,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 }) { @@ -190,10 +192,14 @@ function Job({ setBreadcrumb }) { + job.type === 'execution_environment_builder_build' ? ( + + ) : ( + + ) } /> )} diff --git a/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js b/awx/ui/src/screens/Setting/Jobs/JobsEdit/JobsEdit.js index f44f99fbc..1ffbd8d15 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} /> +