From db33de51d30dcd530664fa5371a32a9ce8db9a8a Mon Sep 17 00:00:00 2001 From: Omar Nasser Date: Tue, 5 Aug 2025 12:07:46 +0300 Subject: [PATCH] Implement just in time devfile validation --- .../devfile_validate.mutation.graphql | 6 + .../workspaces/user/pages/create.vue | 154 ++++++++++++++---- .../frontend/workspaces/mock_data/index.js | 16 ++ .../workspaces/user/pages/create_spec.js | 95 ++++++++++- locale/gitlab.pot | 29 ++-- 5 files changed, 250 insertions(+), 50 deletions(-) create mode 100644 ee/app/assets/javascripts/workspaces/user/graphql/mutations/devfile_validate.mutation.graphql diff --git a/ee/app/assets/javascripts/workspaces/user/graphql/mutations/devfile_validate.mutation.graphql b/ee/app/assets/javascripts/workspaces/user/graphql/mutations/devfile_validate.mutation.graphql new file mode 100644 index 00000000000000..2f294aeb93017b --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/user/graphql/mutations/devfile_validate.mutation.graphql @@ -0,0 +1,6 @@ +mutation devfileValidate($input: DevfileValidateInput!) { + devfileValidate(input: $input) { + errors + valid + } +} diff --git a/ee/app/assets/javascripts/workspaces/user/pages/create.vue b/ee/app/assets/javascripts/workspaces/user/pages/create.vue index 0399f42310ac4a..f6e50fcd599476 100644 --- a/ee/app/assets/javascripts/workspaces/user/pages/create.vue +++ b/ee/app/assets/javascripts/workspaces/user/pages/create.vue @@ -13,16 +13,18 @@ import { } from '@gitlab/ui'; import { omit } from 'lodash'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__, n__, sprintf } from '~/locale'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue'; import { logError } from '~/lib/logger'; import { helpPagePath } from '~/helpers/help_page_helper'; import RefSelector from '~/ref/components/ref_selector.vue'; +import blobContentQuery from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; import GetProjectDetailsQuery from '../../common/components/get_project_details_query.vue'; import WorkspaceVariables from '../components/workspace_variables.vue'; import SearchProjectsListbox from '../components/search_projects_listbox.vue'; +import devfileValidateMutation from '../graphql/mutations/devfile_validate.mutation.graphql'; import workspaceCreateMutation from '../graphql/mutations/workspace_create.mutation.graphql'; import DevfileListbox from '../components/devfile_listbox.vue'; import DevfileHelpDrawer from '../components/devfile_help_drawer.vue'; @@ -42,33 +44,13 @@ export const i18n = { devfileProject: s__('Workspaces|Project'), projectReference: s__('Workspaces|Project reference'), devfile: s__('Workspaces|Devfile'), - devfileLocation: { - label: s__('Workspaces|Devfile location'), - title: s__('Workspaces|What is a devfile?'), - contentParagraph1: s__( - 'Workspaces|A devfile defines the development environment for a GitLab project. A workspace must have a valid devfile in the Git reference you use.', - ), - contentParagraph2: s__( - 'Workspaces|If your devfile is not in the root directory of your project, specify a relative path.', - ), - descriptionContent: s__( - "Workspaces|Provide a relative path if the devfile is not in the project's root directory.", - ), - labelDescriptionContent: s__("Workspaces|Defines the workspace's development environment."), - linkText: s__('Workspaces|Learn more.'), - }, - pathToDevfile: s__('Workspaces|Path to devfile'), agentId: s__('Workspaces|Cluster agent'), - maxHoursSuffix: __('hours'), }, invalidProjectAlert: { title: s__("Workspaces|You can't create a workspace for this project"), noAgentsContent: s__( 'Workspaces|No agents available to create workspaces. Please consult %{linkStart}Workspaces documentation%{linkEnd} for troubleshooting.', ), - noDevFileContent: s__( - 'Workspaces|To create a workspace, add a devfile to this project. A devfile is a configuration file for your workspace.', - ), }, submitButton: { create: s__('Workspaces|Create workspace'), @@ -120,6 +102,7 @@ export default { projectDetailsLoaded: false, error: '', selectedDevfilePath: null, + devfileValidateErrors: [], }; }, computed: { @@ -130,7 +113,12 @@ export default { return this.projectDetailsLoaded && this.emptyAgents; }, saveWorkspaceEnabled() { - return this.selectedProject && this.selectedAgent && this.selectedDevfilePath; + return ( + this.selectedProject && + this.selectedAgent && + this.selectedDevfilePath && + this.devfileValidateErrors.length === 0 + ); }, selectedProjectFullPath() { return this.selectedProject?.fullPath || this.$router.currentRoute.query?.project; @@ -144,6 +132,24 @@ export default { } return String(getIdFromGraphQLId(this.projectId)); }, + hasDevfileValidateErrors() { + return this.devfileValidateErrors.length > 0; + }, + hasExactlyOneDevfileValidateError() { + return this.devfileValidateErrors.length === 1; + }, + devfilePopoverErrorMessage() { + return sprintf( + n__( + 'Workspaces|Error processing the Devfile.', + 'Workspaces|%{count} errors processing the Devfile.', + this.devfileValidateErrors.length, + ), + { + count: this.devfileValidateErrors.length, + }, + ); + }, }, watch: { emptyAgents(newValue) { @@ -199,6 +205,42 @@ export default { return variable.valid === true; }); }, + async onDevfileSelected(path) { + if (path === null || path === DEFAULT_DEVFILE_OPTION) { + return; + } + + try { + const yaml = await this.fetchBlobContent( + this.selectedProjectFullPath, + path, + this.devfileRef, + ); + const result = await this.$apollo.mutate({ + mutation: devfileValidateMutation, + variables: { + input: { + devfileYaml: yaml, + }, + }, + }); + this.devfileValidateErrors = result.data.devfileValidate.errors; + } catch (error) { + logError(error); + this.error = s__('Workspaces|Encountered error while attempting to validate the Devfile.'); + } + }, + async fetchBlobContent(projectPath, devfilePath, devfileRef) { + const { data } = await this.$apollo.query({ + query: blobContentQuery, + variables: { + projectPath, + path: devfilePath, + ref: devfileRef, + }, + }); + return data?.project?.repository?.blobs?.nodes?.[0]?.rawBlob || null; + }, async createWorkspace() { if (!this.validateWorkspaceVariables()) { return; @@ -350,7 +392,7 @@ export default {
-

{{ $options.i18n.form.devfileLocation.contentParagraph1 }}

-

{{ $options.i18n.form.devfileLocation.contentParagraph2 }}

+

+ {{ + s__( + 'Workspaces|A devfile defines the development environment for a GitLab project. A workspace must have a valid devfile in the Git reference you use.', + ) + }} +

+

+ {{ + s__( + 'Workspaces|If your devfile is not in the root directory of your project, specify a relative path.', + ) + }} +

- {{ $options.i18n.form.devfileLocation.linkText }} + {{ s__('Workspaces|Learn more.') }}
+
+
+ {{ s__('Workspaces|Devfile is invalid.') }} + + {{ s__('Workspaces|Devfile Validation Rules.') }} + +
+ + + {{ s__('Workspaces|More Information.') }} + + + +
+ + +
+
+
{ }; let wrapper; let workspaceCreateMutationHandler; + let devfileValidateMutationHandler; + let fetchBlobContentQueryHandler; let mockApollo; const buildMockApollo = () => { workspaceCreateMutationHandler = jest.fn(); workspaceCreateMutationHandler.mockResolvedValue(WORKSPACE_CREATE_MUTATION_RESULT); - mockApollo = createMockApollo([[workspaceCreateMutation, workspaceCreateMutationHandler]]); + + devfileValidateMutationHandler = jest.fn(); + devfileValidateMutationHandler.mockResolvedValue(DEVFILE_VALIDATE_MUTATION_RESULT); + + fetchBlobContentQueryHandler = jest.fn(); + fetchBlobContentQueryHandler.mockResolvedValue(BLOB_CONTENT_QUERY_RESULT); + + mockApollo = createMockApollo([ + [workspaceCreateMutation, workspaceCreateMutationHandler], + [devfileValidateMutation, devfileValidateMutationHandler], + [blobContentQuery, fetchBlobContentQueryHandler], + ]); }; const readCachedWorkspaces = () => { @@ -156,10 +173,9 @@ describe('workspaces/user/pages/create.vue', () => { const findDevfileField = () => wrapper.findByTestId('devfile'); const findDevfileTitleText = () => findDevfileField().find('#devfile-selector-label').text(); const findDevfileHelpIcon = () => findDevfileField().findComponent(HelpIcon); - const findDevfilePopover = () => { - const field = findDevfileField(); - const popover = field.findComponent(GlPopover); - const popoverContent = popover.find('div.gl-flex.gl-flex-col').findAll('p'); + const findDevfileHelpPopover = () => { + const popover = wrapper.findByTestId('gl-devfile-help-popover'); + const popoverContent = popover.findAll('p'); return { popoverTextParagraph1: popoverContent.at(0).text(), @@ -170,6 +186,9 @@ describe('workspaces/user/pages/create.vue', () => { }; const findDevfileDropDown = () => findDevfileField().findComponent(DevfileListbox); const findDevfileHelpDrawer = () => findDevfileField().findComponent(DevfileHelpDrawer); + const selectDevfile = (devfilePath = '.devfile.yaml') => { + findDevfileDropDown().vm.$emit('input', devfilePath); + }; const emitGetProjectDetailsQueryResult = ({ clusterAgents = [], @@ -344,7 +363,7 @@ describe('workspaces/user/pages/create.vue', () => { }); it('renders popover', () => { - expect(findDevfilePopover()).toEqual({ + expect(findDevfileHelpPopover()).toEqual({ popoverTextParagraph1: 'A devfile defines the development environment for a GitLab project. A workspace must have a valid devfile in the Git reference you use.', popoverTextParagraph2: @@ -372,6 +391,68 @@ describe('workspaces/user/pages/create.vue', () => { }); }); + describe('when devfile validation is invalid', () => { + it('displays error popover with single error in devfile', async () => { + devfileValidateMutationHandler.mockReset(); + devfileValidateMutationHandler.mockResolvedValueOnce({ + data: { + devfileValidate: { + valid: false, + errors: ['Some devfile error message'], + }, + }, + }); + selectDevfile(); + await waitForPromises(); + + const popoverComponent = wrapper.findByTestId('gl-error-popover'); + expect(popoverComponent.exists()).toBe(true); + expect(popoverComponent.attributes('title')).toBe('Error processing the Devfile.'); + + const singleError = popoverComponent.findAll('p'); + expect(singleError.exists()).toBe(true); + expect(popoverComponent.findAll('li').exists()).toBe(false); + expect(singleError.at(0).text()).toBe('Some devfile error message'); + expect(findCreateWorkspaceButton().props().loading).toBe(false); + }); + + it('displays error popover with multiple errors in devfile', async () => { + devfileValidateMutationHandler.mockReset(); + devfileValidateMutationHandler.mockResolvedValueOnce({ + data: { + devfileValidate: { + valid: false, + errors: ['Some devfile error message', 'A different devfile error message'], + }, + }, + }); + selectDevfile(); + await waitForPromises(); + + const popoverComponent = wrapper.findByTestId('gl-error-popover'); + expect(popoverComponent.exists()).toBe(true); + expect(popoverComponent.attributes('title')).toBe('2 errors processing the Devfile.'); + + const multipleError = popoverComponent.findAll('li'); + expect(multipleError.exists()).toBe(true); + expect(popoverComponent.findAll('p').exists()).toBe(false); + expect(multipleError.at(0).text()).toEqual('Some devfile error message'); + expect(multipleError.at(1).text()).toEqual('A different devfile error message'); + expect(findCreateWorkspaceButton().props().loading).toBe(false); + }); + + it('alerts indicating system error while attempting to validate a devfile', async () => { + devfileValidateMutationHandler.mockReset(); + devfileValidateMutationHandler.mockRejectedValueOnce(new Error()); + selectDevfile(); + await waitForPromises(); + + expect(findCreateWorkspaceErrorGlAlert().text()).toContain( + 'Encountered error while attempting to validate the Devfile.', + ); + }); + }); + describe('when selecting a project again', () => { beforeEach(async () => { await selectProject({ nameWithNamespace: 'New Project', fullPath: 'new-project' }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 77e1869abfa267..9f1b9748d98b4d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -77627,21 +77627,29 @@ msgstr "" msgid "Workspaces|Creating" msgstr "" -msgid "Workspaces|Defines the workspace's development environment." +msgid "Workspaces|Devfile" msgstr "" -msgid "Workspaces|Devfile" +msgid "Workspaces|Devfile Validation Rules." msgstr "" -msgid "Workspaces|Devfile location" +msgid "Workspaces|Devfile is invalid." msgstr "" msgid "Workspaces|Each workspace is a secure cloud environment for your GitLab project. Quickly switch between projects, live preview, and compile code on any device." msgstr "" +msgid "Workspaces|Encountered error while attempting to validate the Devfile." +msgstr "" + msgid "Workspaces|Error" msgstr "" +msgid "Workspaces|Error processing the Devfile." +msgid_plural "Workspaces|%{count} errors processing the Devfile." +msgstr[0] "" +msgstr[1] "" + msgid "Workspaces|Failed" msgstr "" @@ -77687,6 +77695,9 @@ msgstr "" msgid "Workspaces|Learn more." msgstr "" +msgid "Workspaces|More Information." +msgstr "" + msgid "Workspaces|Name" msgstr "" @@ -77711,18 +77722,12 @@ msgstr "" msgid "Workspaces|Open workspace" msgstr "" -msgid "Workspaces|Path to devfile" -msgstr "" - msgid "Workspaces|Project" msgstr "" msgid "Workspaces|Project reference" msgstr "" -msgid "Workspaces|Provide a relative path if the devfile is not in the project's root directory." -msgstr "" - msgid "Workspaces|Restart" msgstr "" @@ -77774,9 +77779,6 @@ msgstr "" msgid "Workspaces|This group has no available agents. Select the %{strongStart}All agents%{strongEnd} tab and allow at least one agent." msgstr "" -msgid "Workspaces|To create a workspace, add a devfile to this project. A devfile is a configuration file for your workspace." -msgstr "" - msgid "Workspaces|Unable to complete request. Please try again." msgstr "" @@ -80282,9 +80284,6 @@ msgstr "" msgid "help" msgstr "" -msgid "hours" -msgstr "" - msgid "http:" msgstr "" -- GitLab