From d385e75189c7142aab3fafd24fd9befca5f1f653 Mon Sep 17 00:00:00 2001 From: Marniks7 <9289172+marniks7@users.noreply.github.com> Date: Sun, 11 Dec 2022 21:39:17 -0500 Subject: [PATCH] Add new action for PipelineRun - Edit and run --- .../cypress/e2e/run/actions-pipelinerun.cy.js | 81 ++++++++ src/api/pipelineRuns.js | 52 ++++-- src/api/pipelineRuns.test.js | 176 ++++++++++++++++++ .../CreatePipelineRun/CreatePipelineRun.js | 39 +++- .../CreatePipelineRun.test.js | 111 ++++++++++- .../CreatePipelineRun/YAMLEditor.js | 49 +++-- .../CreatePipelineRun/YAMLEditor.test.js | 10 + src/containers/PipelineRun/PipelineRun.js | 15 ++ src/containers/PipelineRuns/PipelineRuns.js | 15 ++ src/nls/messages_de.json | 2 + src/nls/messages_en.json | 2 + src/nls/messages_es.json | 2 + src/nls/messages_fr.json | 2 + src/nls/messages_it.json | 2 + src/nls/messages_ja.json | 2 + src/nls/messages_ko.json | 2 + src/nls/messages_pt.json | 2 + src/nls/messages_zh-Hans.json | 2 + src/nls/messages_zh-Hant.json | 2 + 19 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 packages/e2e/cypress/e2e/run/actions-pipelinerun.cy.js diff --git a/packages/e2e/cypress/e2e/run/actions-pipelinerun.cy.js b/packages/e2e/cypress/e2e/run/actions-pipelinerun.cy.js new file mode 100644 index 0000000000..ef0f785a94 --- /dev/null +++ b/packages/e2e/cypress/e2e/run/actions-pipelinerun.cy.js @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const namespace = 'e2e-actions'; +describe('Edit and run Pipeline Run', () => { + before(() => { + cy.exec('kubectl version --client'); + cy.exec(`kubectl create namespace ${namespace} || true`); + }); + + after(() => { + cy.exec(`kubectl delete namespace ${namespace} || true`); + }); + + it('should create pipelinerun on edit and run', function () { + // given pipeline + const uniqueNumber = Date.now(); + const pipelineName = `sp-${uniqueNumber}`; + const pipeline = `apiVersion: tekton.dev/v1beta1 +kind: Pipeline +metadata: + name: ${pipelineName} + namespace: ${namespace} +spec: + tasks: + - name: hello + taskSpec: + steps: + - name: echo + image: busybox + script: | + #!/bin/ash + echo "Hello World!" + `; + cy.exec(`echo "${pipeline}" | kubectl apply -f -`); + // when create first pipeline run + cy.visit( + `/#/pipelineruns/create?namespace=${namespace}&pipelineName=${pipelineName}` + ); + cy.get('[id=create-pipelinerun--namespaces-dropdown]').should( + 'have.value', + namespace + ); + cy.get('[id=create-pipelinerun--pipelines-dropdown]').should( + 'have.value', + pipelineName + ); + cy.contains('button', 'Create').click(); + + cy.contains('h1', 'PipelineRuns'); + + cy.get( + `td:has(.bx--link[title*=${pipelineName}-run]) + td:has(.tkn--status[data-reason=Succeeded])`, + { timeout: 15000 } + ).should('have.length', 1); + + // when edit and run to create second pipeline run + cy.contains('a', `${pipelineName}-run`).click(); + cy.contains('button', 'Actions').click(); + cy.contains('button', 'Edit and run').click(); + cy.get('.cm-content').contains(`name: ${pipelineName}`); + cy.contains('button', 'Create').click(); + cy.contains('h1', 'PipelineRuns'); + + // then + cy.get( + `td:has(.bx--link[title*=${pipelineName}-run]) + td:has(.tkn--status[data-reason=Succeeded])`, + { timeout: 15000 } + ).should('have.length', 2); + }); +}); diff --git a/src/api/pipelineRuns.js b/src/api/pipelineRuns.js index bf7d0db77c..301a13581d 100644 --- a/src/api/pipelineRuns.js +++ b/src/api/pipelineRuns.js @@ -170,22 +170,32 @@ export function createPipelineRun({ return post(uri, payload).then(({ body }) => body); } -export function rerunPipelineRun(pipelineRun) { - const { annotations, labels, name, namespace } = pipelineRun.metadata; +export function generateNewPipelineRunPayload({ pipelineRun, rerun }) { + const { annotations, labels, name, namespace, generateName } = + pipelineRun.metadata; const payload = deepClone(pipelineRun); payload.apiVersion = payload.apiVersion || `tekton.dev/${getTektonPipelinesAPIVersion()}`; payload.kind = payload.kind || 'PipelineRun'; + + function getGenerateName() { + if (rerun) { + return getGenerateNamePrefixForRerun(name); + } + + return generateName || `${name}-`; + } + payload.metadata = { annotations: annotations || {}, - generateName: getGenerateNamePrefixForRerun(name), - labels: { - ...labels, - 'dashboard.tekton.dev/rerunOf': name - }, + generateName: getGenerateName(), + labels: labels || {}, namespace }; + if (rerun) { + payload.metadata.labels['dashboard.tekton.dev/rerunOf'] = name; + } Object.keys(payload.metadata.labels).forEach(label => { if (label.startsWith('tekton.dev/')) { @@ -194,24 +204,36 @@ export function rerunPipelineRun(pipelineRun) { }); /* - This is used by Tekton Pipelines as part of the conversion between v1beta1 - and v1 resources. Creating a run with this in place prevents it from actually - executing and instead adopts the status of the original TaskRuns. + This is used by Tekton Pipelines as part of the conversion between v1beta1 + and v1 resources. Creating a run with this in place prevents it from actually + executing and instead adopts the status of the original TaskRuns. - Ideally we would just delete all `tekton.dev/*` annotations as we do with labels but - `tekton.dev/v1beta1Resources` is required for pipelines that use PipelineResources, - and there may be other similar annotations that are still required. + Ideally we would just delete all `tekton.dev/*` annotations as we do with labels but + `tekton.dev/v1beta1Resources` is required for pipelines that use PipelineResources, + and there may be other similar annotations that are still required. - When v1beta1 has been fully removed from Tekton Pipelines we can revisit this - and remove all remaining `tekton.dev/*` annotations. + When v1beta1 has been fully removed from Tekton Pipelines we can revisit this + and remove all remaining `tekton.dev/*` annotations. */ delete payload.metadata.annotations['tekton.dev/v1beta1TaskRuns']; delete payload.metadata.annotations[ 'kubectl.kubernetes.io/last-applied-configuration' ]; + Object.keys(payload.metadata).forEach( + i => payload.metadata[i] === undefined && delete payload.metadata[i] + ); delete payload.status; + delete payload.spec?.status; + return { namespace, payload }; +} + +export function rerunPipelineRun(pipelineRun) { + const { namespace, payload } = generateNewPipelineRunPayload({ + pipelineRun, + rerun: true + }); const uri = getTektonAPI('pipelineruns', { namespace }); return post(uri, payload).then(({ body }) => body); diff --git a/src/api/pipelineRuns.test.js b/src/api/pipelineRuns.test.js index 1be357ea6c..163daa839c 100644 --- a/src/api/pipelineRuns.test.js +++ b/src/api/pipelineRuns.test.js @@ -11,9 +11,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +import yaml from 'js-yaml'; import * as API from './pipelineRuns'; import * as utils from './utils'; import { rest, server } from '../../config_frontend/msw'; +import { generateNewPipelineRunPayload } from './pipelineRuns'; it('cancelPipelineRun', () => { const name = 'foo'; @@ -326,3 +328,177 @@ it('startPipelineRun', () => { } ); }); + +it('rerun. generate new pipeline run. minimum', () => { + const pipelineRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + name: 'test', + namespace: 'test-namespace' + }, + spec: { + pipelineRef: { + name: 'simple' + } + } + }; + const { namespace, payload } = generateNewPipelineRunPayload({ + pipelineRun, + rerun: true + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + annotations: {} + generateName: test-r- + labels: + dashboard.tekton.dev/rerunOf: test + namespace: test-namespace +spec: + pipelineRef: + name: simple +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); +}); + +it('rerun. generate new pipeline run. maximum', () => { + const pipelineRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + name: 'test', + namespace: 'test-namespace', + annotations: { + keya: 'valuea', + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion": "tekton.dev/v1beta1", "keya": "valuea"}' + }, + labels: { + key1: 'valuel', + key2: 'value2', + 'tekton.dev/pipeline': 'foo' + }, + uid: '111-233-33', + resourceVersion: 'aaaa' + }, + spec: { + pipelineRef: { + name: 'simple' + }, + params: [{ name: 'param-1' }, { name: 'param-2' }] + }, + status: { startTime: '0' } + }; + const { namespace, payload } = generateNewPipelineRunPayload({ + pipelineRun, + rerun: true + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + annotations: + keya: valuea + generateName: test-r- + labels: + key1: valuel + key2: value2 + dashboard.tekton.dev/rerunOf: test + namespace: test-namespace +spec: + pipelineRef: + name: simple + params: + - name: param-1 + - name: param-2 +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); +}); + +it('edit. generate new pipeline run. minimum', () => { + const pipelineRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + name: 'test', + namespace: 'test-namespace' + }, + spec: { + pipelineRef: { + name: 'simple' + } + } + }; + const { namespace, payload } = generateNewPipelineRunPayload({ + pipelineRun, + rerun: false + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + annotations: {} + generateName: test- + labels: {} + namespace: test-namespace +spec: + pipelineRef: + name: simple +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); +}); +it('edit. generate new pipeline run. maximum', () => { + const pipelineRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + annotations: { + keya: 'valuea', + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion": "tekton.dev/v1beta1", "keya": "valuea"}' + }, + labels: { + key1: 'valuel', + key2: 'value2', + 'tekton.dev/pipeline': 'foo', + 'tekton.dev/run': 'bar' + }, + name: 'test', + namespace: 'test-namespace', + uid: '111-233-33', + resourceVersion: 'aaaa' + }, + spec: { + pipelineRef: { + name: 'simple' + }, + params: [{ name: 'param-1' }, { name: 'param-2' }] + }, + status: { startTime: '0' } + }; + const { namespace, payload } = generateNewPipelineRunPayload({ + pipelineRun, + rerun: false + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + annotations: + keya: valuea + generateName: test- + labels: + key1: valuel + key2: value2 + namespace: test-namespace +spec: + pipelineRef: + name: simple + params: + - name: param-1 + - name: param-2 +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); +}); diff --git a/src/containers/CreatePipelineRun/CreatePipelineRun.js b/src/containers/CreatePipelineRun/CreatePipelineRun.js index 3ca70e9fef..2e0693c6c5 100644 --- a/src/containers/CreatePipelineRun/CreatePipelineRun.js +++ b/src/containers/CreatePipelineRun/CreatePipelineRun.js @@ -40,8 +40,10 @@ import { } from '..'; import { createPipelineRun, + generateNewPipelineRunPayload, getPipelineRunPayload, usePipeline, + usePipelineRun, useSelectedNamespace } from '../../api'; import { isValidLabel } from '../../utils'; @@ -56,7 +58,7 @@ const initialState = { nodeSelector: [], params: {}, paramSpecs: [], - pendingPipelineStatus: '', + pipelinePendingStatus: '', pipelineError: false, pipelineRef: '', pipelineRunName: '', @@ -91,6 +93,11 @@ function CreatePipelineRun() { return urlSearchParams.get('pipelineName') || ''; } + function getPipelineRunName() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('pipelineRunName') || ''; + } + function getNamespace() { const urlSearchParams = new URLSearchParams(location.search); return ( @@ -426,6 +433,36 @@ function CreatePipelineRun() { } if (isYAMLMode()) { + const externalPipelineRunName = getPipelineRunName(); + if (externalPipelineRunName) { + const { data: pipelineRunObject, isLoading } = usePipelineRun( + { + name: externalPipelineRunName, + namespace: getNamespace() + }, + { disableWebSocket: true } + ); + let payloadYaml = null; + if (pipelineRunObject) { + const { payload } = generateNewPipelineRunPayload({ + pipelineRun: pipelineRunObject, + rerun: false + }); + payloadYaml = yaml.dump(payload); + } + const loadingMessage = intl.formatMessage({ + id: 'dashboard.pipelineRun.loading', + defaultMessage: 'Loading PipelineRun…' + }); + + return ( + + ); + } const pipelineRun = getPipelineRunPayload({ labels: labels.reduce((acc, { key, value }) => { acc[key] = value; diff --git a/src/containers/CreatePipelineRun/CreatePipelineRun.test.js b/src/containers/CreatePipelineRun/CreatePipelineRun.test.js index 272da4a9a4..70d44931d9 100644 --- a/src/containers/CreatePipelineRun/CreatePipelineRun.test.js +++ b/src/containers/CreatePipelineRun/CreatePipelineRun.test.js @@ -12,7 +12,7 @@ limitations under the License. */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import { renderWithRouter } from '../../utils/test'; @@ -23,6 +23,7 @@ import * as PipelineRunsAPI from '../../api/pipelineRuns'; import * as PipelinesAPI from '../../api/pipelines'; import * as ServiceAccountsAPI from '../../api/serviceAccounts'; +const submitButton = allByText => allByText('Create')[0]; const pipelines = [ { metadata: { @@ -110,14 +111,6 @@ describe('CreatePipelineRun', () => { expect(queryByDisplayValue(/bar/i)).toBeFalsy(); }); - it('renders yaml mode', () => { - const { getByRole } = renderWithRouter(, { - path: '/create', - route: '/create?mode=yaml' - }); - expect(getByRole(/textbox/)).toBeTruthy(); - }); - it('handles onClose event', () => { jest.spyOn(window.history, 'pushState'); const { getByText } = renderWithRouter(); @@ -133,3 +126,103 @@ describe('CreatePipelineRun', () => { expect(window.history.pushState).toHaveBeenCalledTimes(2); }); }); + +const pipelineRunRawGenerateName = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + annotations: {}, + generateName: 'test-pipeline-run-name-', + labels: {}, + namespace: 'test-namespace' + }, + spec: { + pipelineSpec: { + tasks: [ + { + name: 'hello', + taskSpec: { + steps: [ + { + image: 'busybox', + name: 'echo', + script: '#!/bin/ash\necho "Hello World!"\n' + } + ] + } + } + ] + } + } +}; + +const expectedPipelineRun = `apiVersion: tekton.dev/v1beta1 +kind: PipelineRun +metadata: + name: run-1111111111 + namespace: test-namespace + labels: {} +spec: + pipelineRef: + name: '' + status: ''`; +const expectedPipelineRunOneLine = expectedPipelineRun.replace(/\r?\n|\r/g, ''); +const findNameRegexp = /name: run-\S+/; +describe('CreatePipelineRun yaml mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(window.history, 'pushState'); + }); + + it('renders yaml mode with namespace', async () => { + jest + .spyOn(PipelineRunsAPI, 'createPipelineRunRaw') + .mockImplementation(() => Promise.resolve({ data: {} })); + jest + .spyOn(PipelineRunsAPI, 'usePipelineRun') + .mockImplementation(() => ({ data: pipelineRunRawGenerateName })); + + const { getByRole } = renderWithRouter(, { + path: '/create', + route: '/create?mode=yaml&namespace=test-namespace' + }); + + expect(getByRole(/textbox/)).toBeTruthy(); + let actual = getByRole(/textbox/).textContent; + actual = actual.replace(findNameRegexp, 'name: run-1111111111'); + expect(actual.trim()).toEqual(expectedPipelineRunOneLine); + }); + + it('handle submit. yaml mode with pipelinerun and namespace', async () => { + jest + .spyOn(PipelineRunsAPI, 'createPipelineRunRaw') + .mockImplementation(() => Promise.resolve({ data: {} })); + jest + .spyOn(PipelineRunsAPI, 'usePipelineRun') + .mockImplementation(() => ({ data: pipelineRunRawGenerateName })); + + const { queryAllByText } = renderWithRouter(, { + path: '/create', + route: + '/create?mode=yaml&pipelineRunName=test-pipeline-run-name&namespace=test-namespace' + }); + + expect(submitButton(queryAllByText)).toBeTruthy(); + + fireEvent.click(submitButton(queryAllByText)); + + await waitFor(() => { + expect(PipelineRunsAPI.createPipelineRunRaw).toHaveBeenCalledTimes(1); + }); + expect(PipelineRunsAPI.createPipelineRunRaw).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'test-namespace', + payload: pipelineRunRawGenerateName + }) + ); + await waitFor(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/containers/CreatePipelineRun/YAMLEditor.js b/src/containers/CreatePipelineRun/YAMLEditor.js index 88f481da43..e5cfbca5d1 100644 --- a/src/containers/CreatePipelineRun/YAMLEditor.js +++ b/src/containers/CreatePipelineRun/YAMLEditor.js @@ -16,17 +16,22 @@ import { Button, Form, FormGroup, - InlineNotification + InlineNotification, + Loading } from 'carbon-components-react'; import yaml from 'js-yaml'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { StreamLanguage } from '@codemirror/language'; import { yaml as codeMirrorYAML } from '@codemirror/legacy-modes/mode/yaml'; import { useNavigate } from 'react-router-dom-v5-compat'; import { createPipelineRunRaw, useSelectedNamespace } from '../../api'; -export function CreateYAMLEditor({ code: initialCode = '' }) { +export function CreateYAMLEditor({ + code: initialCode, + loading = false, + loadingMessage = '' +}) { const intl = useIntl(); const navigate = useNavigate(); const { selectedNamespace } = useSelectedNamespace(); @@ -39,6 +44,15 @@ export function CreateYAMLEditor({ code: initialCode = '' }) { validationErrorMessage: '' }); + useEffect(() => { + if (!loading) { + setState(state => ({ + ...state, + code: initialCode + })); + } + }, [loading]); + function validateNamespace(obj) { if (!obj?.metadata?.namespace) { return { @@ -176,14 +190,25 @@ export function CreateYAMLEditor({ code: initialCode = '' }) { lowContrast /> )} - - + + <> + {loading && ( +
+ + {loadingMessage} +
+ )} + {!loading && ( + + )} +