From 482da44f1e070d40fdb82e6c70bf22810adfefb5 Mon Sep 17 00:00:00 2001 From: Alan Greene Date: Thu, 16 Feb 2023 17:03:03 +0000 Subject: [PATCH] Add YAML mode for Create TaskRun Similar to Create PipelineRun, add a button to the Create TaskRun page allowing the user to switch to YAML mode for more advanced use cases not currently supported by the form-based UI. Also add the 'Edit and run' action on the TaskRuns page allowing users to create a new TaskRun based on an existing one. --- src/api/taskRuns.js | 109 ++++++++- src/api/taskRuns.test.js | 208 ++++++++++++++++++ .../CreatePipelineRun/CreatePipelineRun.js | 13 +- src/containers/CreateTaskRun/CreateTaskRun.js | 131 ++++++++++- src/containers/TaskRuns/TaskRuns.js | 16 ++ src/containers/YAMLEditor/YAMLEditor.js | 4 +- 6 files changed, 463 insertions(+), 18 deletions(-) diff --git a/src/api/taskRuns.js b/src/api/taskRuns.js index 9016b045dc..9187f6aedc 100644 --- a/src/api/taskRuns.js +++ b/src/api/taskRuns.js @@ -76,15 +76,15 @@ export function cancelTaskRun({ name, namespace }) { return patch(uri, payload); } -export function createTaskRun({ +export function getTaskRunPayload({ kind, labels, namespace, nodeSelector, params, - serviceAccount, taskName, - taskRunName = `${taskName}-run-${Date.now()}`, + taskRunName = `${taskName ? `${taskName}-run` : 'run'}-${Date.now()}`, + serviceAccount, timeout }) { const payload = { @@ -92,19 +92,17 @@ export function createTaskRun({ kind: 'TaskRun', metadata: { name: taskRunName, - namespace, - labels + namespace }, spec: { - params: [], taskRef: { name: taskName, kind: kind || 'Task' } } }; - if (nodeSelector) { - payload.spec.podTemplate = { nodeSelector }; + if (labels) { + payload.metadata.labels = labels; } if (params) { payload.spec.params = Object.keys(params).map(name => ({ @@ -112,16 +110,111 @@ export function createTaskRun({ value: params[name] })); } + if (nodeSelector) { + payload.spec.podTemplate = { nodeSelector }; + } if (serviceAccount) { payload.spec.serviceAccountName = serviceAccount; } if (timeout) { payload.spec.timeout = timeout; } + + return payload; +} + +export function createTaskRun({ + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + taskName, + taskRunName = `${taskName}-run-${Date.now()}`, + timeout +}) { + const payload = getTaskRunPayload({ + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + taskName, + taskRunName, + timeout + }); const uri = getTektonAPI('taskruns', { namespace }); return post(uri, payload).then(({ body }) => body); } +export function createTaskRunRaw({ namespace, payload }) { + const uri = getTektonAPI('taskruns', { namespace }); + return post(uri, payload).then(({ body }) => body); +} + +export function generateNewTaskRunPayload({ taskRun, rerun }) { + const { annotations, labels, name, namespace, generateName } = + taskRun.metadata; + + const payload = deepClone(taskRun); + payload.apiVersion = + payload.apiVersion || `tekton.dev/${getTektonPipelinesAPIVersion()}`; + payload.kind = payload.kind || 'TaskRun'; + + function getGenerateName() { + if (rerun) { + return getGenerateNamePrefixForRerun(name); + } + + return generateName || `${name}-`; + } + + payload.metadata = { + annotations: annotations || {}, + 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/')) { + delete payload.metadata.labels[label]; + } + }); + + /* + TODO: this is specific to PipelineRuns, check how TaskRuns are handled and update accordingly + + 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. + + 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 rerunTaskRun(taskRun) { const { annotations, labels, name, namespace } = taskRun.metadata; diff --git a/src/api/taskRuns.test.js b/src/api/taskRuns.test.js index 2bcc8ad8cb..5d89a897d7 100644 --- a/src/api/taskRuns.test.js +++ b/src/api/taskRuns.test.js @@ -11,6 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import yaml from 'js-yaml'; import * as API from './taskRuns'; import * as utils from './utils'; import * as comms from './comms'; @@ -158,6 +159,36 @@ describe('createTaskRun', () => { }); }); +it('createTaskRunRaw', () => { + const taskRunRaw = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + metadata: { name: 'test-task-run-name', namespace: 'test-namespace' }, + spec: { + taskSpec: { + steps: [ + { + image: 'busybox', + name: 'echo', + script: '#!/bin/ash\necho "Hello World!"\n' + } + ] + } + } + }; + jest + .spyOn(comms, 'post') + .mockImplementation((uri, body) => Promise.resolve(body)); + + return API.createTaskRunRaw({ + namespace: 'test-namespace', + payload: taskRunRaw + }).then(() => { + expect(comms.post).toHaveBeenCalled(); + expect(comms.post.mock.lastCall[1]).toEqual(taskRunRaw); + }); +}); + it('deleteTaskRun', () => { const name = 'foo'; const data = { fake: 'taskRun' }; @@ -269,3 +300,180 @@ it('rerunTaskRun', () => { expect(comms.post.mock.lastCall[1]).toEqual(rerun); }); }); + +describe('generateNewTaskRunPayload', () => { + it('rerun with minimum possible fields', () => { + const taskRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + metadata: { + name: 'test', + namespace: 'test-namespace' + }, + spec: { + taskRef: { + name: 'simple' + } + } + }; + const { namespace, payload } = API.generateNewTaskRunPayload({ + taskRun, + rerun: true + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: TaskRun +metadata: + annotations: {} + generateName: test-r- + labels: + dashboard.tekton.dev/rerunOf: test + namespace: test-namespace +spec: + taskRef: + name: simple +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); + }); + + it('rerun with all processed fields', () => { + const taskRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + 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/task': 'foo' + }, + uid: '111-233-33', + resourceVersion: 'aaaa' + }, + spec: { + taskRef: { + name: 'simple' + }, + params: [{ name: 'param-1' }, { name: 'param-2' }] + }, + status: { startTime: '0' } + }; + const { namespace, payload } = API.generateNewTaskRunPayload({ + taskRun, + rerun: true + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: TaskRun +metadata: + annotations: + keya: valuea + generateName: test-r- + labels: + key1: valuel + key2: value2 + dashboard.tekton.dev/rerunOf: test + namespace: test-namespace +spec: + taskRef: + name: simple + params: + - name: param-1 + - name: param-2 +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); + }); + + it('edit with minimum possible fields', () => { + const taskRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + metadata: { + name: 'test', + namespace: 'test-namespace' + }, + spec: { + taskRef: { + name: 'simple' + } + } + }; + const { namespace, payload } = API.generateNewTaskRunPayload({ + taskRun, + rerun: false + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: TaskRun +metadata: + annotations: {} + generateName: test- + labels: {} + namespace: test-namespace +spec: + taskRef: + name: simple +`; + expect(namespace).toEqual('test-namespace'); + expect(yaml.dump(payload)).toEqual(expected); + }); + + it('edit with all processed fields', () => { + const taskRun = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + metadata: { + annotations: { + keya: 'valuea', + 'kubectl.kubernetes.io/last-applied-configuration': + '{"apiVersion": "tekton.dev/v1beta1", "keya": "valuea"}' + }, + labels: { + key1: 'valuel', + key2: 'value2', + 'tekton.dev/task': 'foo', + 'tekton.dev/run': 'bar' + }, + name: 'test', + namespace: 'test-namespace', + uid: '111-233-33', + resourceVersion: 'aaaa' + }, + spec: { + taskRef: { + name: 'simple' + }, + params: [{ name: 'param-1' }, { name: 'param-2' }] + }, + status: { startTime: '0' } + }; + const { namespace, payload } = API.generateNewTaskRunPayload({ + taskRun, + rerun: false + }); + const expected = `apiVersion: tekton.dev/v1beta1 +kind: TaskRun +metadata: + annotations: + keya: valuea + generateName: test- + labels: + key1: valuel + key2: value2 + namespace: test-namespace +spec: + taskRef: + 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 f336fa2a49..f40395570c 100644 --- a/src/containers/CreatePipelineRun/CreatePipelineRun.js +++ b/src/containers/CreatePipelineRun/CreatePipelineRun.js @@ -469,10 +469,13 @@ function CreatePipelineRun() { }); payloadYaml = yaml.dump(payload); } - const loadingMessage = intl.formatMessage({ - id: 'dashboard.pipelineRun.loading', - defaultMessage: 'Loading PipelineRun…' - }); + const loadingMessage = intl.formatMessage( + { + id: 'dashboard.loading.resource', + defaultMessage: 'Loading {kind}…' + }, + { kind: 'PipelineRun' } + ); return ( {intl.formatMessage({ - id: 'dashboard.createPipelineRun.yamlModeButton', + id: 'dashboard.create.yamlModeButton', defaultMessage: 'YAML Mode' })} diff --git a/src/containers/CreateTaskRun/CreateTaskRun.js b/src/containers/CreateTaskRun/CreateTaskRun.js index f3b5719e87..65040d089a 100644 --- a/src/containers/CreateTaskRun/CreateTaskRun.js +++ b/src/containers/CreateTaskRun/CreateTaskRun.js @@ -15,6 +15,7 @@ limitations under the License. import React, { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; import keyBy from 'lodash.keyby'; +import yaml from 'js-yaml'; import { Button, Dropdown, @@ -37,9 +38,18 @@ import { ClusterTasksDropdown, NamespacesDropdown, ServiceAccountsDropdown, - TasksDropdown + TasksDropdown, + YAMLEditor } from '..'; -import { createTaskRun, useSelectedNamespace, useTaskByKind } from '../../api'; +import { + createTaskRun, + createTaskRunRaw, + generateNewTaskRunPayload, + getTaskRunPayload, + useSelectedNamespace, + useTaskByKind, + useTaskRun +} from '../../api'; import { isValidLabel } from '../../utils'; const clusterTaskItem = { id: 'clustertask', text: 'ClusterTask' }; @@ -90,6 +100,11 @@ function CreateTaskRun() { }; } + function getTaskRunName() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('taskRunName') || ''; + } + function getNamespace() { const urlSearchParams = new URLSearchParams(location.search); return ( @@ -98,6 +113,11 @@ function CreateTaskRun() { ); } + function isYAMLMode() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('mode') === 'yaml'; + } + const { kind: initialTaskKind, taskName: taskRefFromDetails } = getTaskDetails(); const [ @@ -141,8 +161,15 @@ function CreateTaskRun() { }) }); + function switchToYamlMode() { + const queryParams = new URLSearchParams(location.search); + queryParams.set('mode', 'yaml'); + const browserURL = location.pathname.concat(`?${queryParams.toString()}`); + navigate(browserURL); + } + function checkFormValidation() { - // Namespace, PipelineRef, and Params must all have values + // Namespace, taskRef, and Params must all have values const validNamespace = !!namespace; const validTaskRef = !!taskRef; @@ -275,6 +302,24 @@ function CreateTaskRun() { }); } + function handleCloseYAMLEditor() { + let url = urls.taskRuns.all(); + if (defaultNamespace && defaultNamespace !== ALL_NAMESPACES) { + url = urls.taskRuns.byNamespace({ namespace: defaultNamespace }); + } + navigate(url); + } + + function handleCreate({ resource }) { + const resourceNamespace = resource?.metadata?.namespace; + return createTaskRunRaw({ + namespace: resourceNamespace, + payload: resource + }).then(() => { + navigate(urls.taskRuns.byNamespace({ namespace: resourceNamespace })); + }); + } + function handleNamespaceChange({ selectedItem }) { const { text = '' } = selectedItem || {}; if (text !== namespace) { @@ -405,6 +450,74 @@ function CreateTaskRun() { }); } + if (isYAMLMode()) { + const externalTaskRunName = getTaskRunName(); + if (externalTaskRunName) { + const { data: taskRunObject, isLoading } = useTaskRun( + { + name: externalTaskRunName, + namespace: getNamespace() + }, + { disableWebSocket: true } + ); + let payloadYaml = null; + if (taskRunObject) { + const { payload } = generateNewTaskRunPayload({ + taskRun: taskRunObject, + rerun: false + }); + payloadYaml = yaml.dump(payload); + } + const loadingMessage = intl.formatMessage( + { + id: 'dashboard.loading.resource', + defaultMessage: 'Loading {kind}…' + }, + { kind: 'TaskRun' } + ); + + return ( + + ); + } + + const taskRun = getTaskRunPayload({ + kind, + labels: labels.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}), + namespace, + nodeSelector: nodeSelector.length + ? nodeSelector.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}) + : null, + params, + serviceAccount, + taskName: taskRef, + taskRunName: taskRunName || undefined, + timeout + }); + + return ( + + ); + } + return (
@@ -414,6 +527,18 @@ function CreateTaskRun() { defaultMessage: 'Create TaskRun' })} +
+ +
{taskError && ( diff --git a/src/containers/TaskRuns/TaskRuns.js b/src/containers/TaskRuns/TaskRuns.js index c9c4ed078d..f9d8b5c5da 100644 --- a/src/containers/TaskRuns/TaskRuns.js +++ b/src/containers/TaskRuns/TaskRuns.js @@ -165,6 +165,14 @@ function TaskRuns() { setCancelSelection(() => handleCancelSelection); } + function editAndRun(taskRun) { + navigate( + `${urls.taskRuns.create()}?mode=yaml&taskRunName=${ + taskRun.metadata.name + }&namespace=${taskRun.metadata.namespace}` + ); + } + function taskRunActions() { if (isReadOnly) { return []; @@ -178,6 +186,14 @@ function TaskRuns() { }), disable: resource => !!resource.metadata.labels?.['tekton.dev/pipeline'] }, + { + actionText: intl.formatMessage({ + id: 'dashboard.editAndRun.actionText', + defaultMessage: 'Edit and run' + }), + action: editAndRun, + disable: resource => !!resource.metadata.labels?.['tekton.dev/pipeline'] + }, { actionText: intl.formatMessage({ id: 'dashboard.cancelTaskRun.actionText', diff --git a/src/containers/YAMLEditor/YAMLEditor.js b/src/containers/YAMLEditor/YAMLEditor.js index bc76be7e1f..a81c20e005 100644 --- a/src/containers/YAMLEditor/YAMLEditor.js +++ b/src/containers/YAMLEditor/YAMLEditor.js @@ -58,7 +58,7 @@ export default function YAMLEditor({ return null; } - function validateEmptyYaml() { + function validateEmptyYAML() { if (!code) { return { valid: false, @@ -74,7 +74,7 @@ export default function YAMLEditor({ function handleSubmit(event) { event.preventDefault(); // Check form validation - let validationResult = validateEmptyYaml(); + let validationResult = validateEmptyYAML(); if (validationResult && !validationResult.valid) { setValidationErrorMessage(validationResult.message); return;