From 641813a3640b56140c22e659034a65e9e3950739 Mon Sep 17 00:00:00 2001 From: mbwhite Date: Thu, 18 Jan 2024 13:49:10 +0000 Subject: [PATCH] feat: pipelineRun support (partial) Signed-off-by: mbwhite --- TODO.md | 4 ++ src/errorbase.ts | 20 ++++++++ src/interfaces/common.ts | 3 +- src/lint.ts | 6 +++ src/rules.ts | 5 ++ src/rules/no-duplicate-param.ts | 22 ++++++++- src/rules/no-extra-param.ts | 6 +++ src/rules/no-missing-resource.ts | 25 ++++++++++ src/rules/no-missing-workspace.ts | 68 ++++++++++++++++++++------- src/rules/no-pipeline-missing-task.ts | 24 ++++++++++ src/rules/no-pipeline-task-cycle.ts | 39 +++++++++++---- src/rules/prefer-kebab-case.ts | 9 ++++ 12 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 TODO.md create mode 100644 src/errorbase.ts diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..976e16e --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +- TaskSpecs + - namespaces +- PipelineRun + - Reference pipelineref \ No newline at end of file diff --git a/src/errorbase.ts b/src/errorbase.ts new file mode 100644 index 0000000..0583520 --- /dev/null +++ b/src/errorbase.ts @@ -0,0 +1,20 @@ +export type ErrorNames = 'RULE_PARSE_ERROR'; + +export class ErrorBase extends Error { + name: string; + message: string; + cause: any; + + constructor(name: string, msg: string, cause?: any) { + super(); + this.name = name; + this.message = msg; + this.cause = cause; + } +} + +export class RuleError extends ErrorBase { + constructor(msg: string, rulename: string, data: string, cause?: any) { + super('RuleError', `${rulename} - ${msg} ${data}`, cause); + } +} diff --git a/src/interfaces/common.ts b/src/interfaces/common.ts index afd6ace..ed92910 100644 --- a/src/interfaces/common.ts +++ b/src/interfaces/common.ts @@ -43,6 +43,7 @@ interface Tekton { conditions?: ListResources; externaltasks?: ExternalResource[]; finally?: ListResources; + pipelineRuns?: ListResources; } // ref: https://dev.to/ankittanna/how-to-create-a-type-for-complex-json-object-in-typescript-d81 @@ -63,7 +64,7 @@ export interface RulesConfig { 'external-tasks': ExternalResource[]; } -export type RuleReportFn = (message: string, node, prop) => void; +export type RuleReportFn = (message: string, node, prop?) => void; export type RuleFn = (docs, tekton: Tekton, report: RuleReportFn) => void; // problem report definitions diff --git a/src/lint.ts b/src/lint.ts index 6a20021..66c4b69 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,5 +1,11 @@ #! /usr/bin/env node +/* + * SPDX-License-Identifier: Apache-2.0 + */ +import sourceMapSupport from 'source-map-support'; +sourceMapSupport.install(); + import yargs from 'yargs/yargs'; import * as fs from 'node:fs'; import * as path from 'node:path'; diff --git a/src/rules.ts b/src/rules.ts index 7323a6f..5019fa3 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -3,6 +3,7 @@ import rules from './rule-loader.js'; import { logger } from './logger.js'; import { Tekton } from './interfaces/common.js'; + const createReporter = (rule, config, reporter) => { const isError = config.rules[rule] && config.rules[rule] === 'error'; @@ -31,9 +32,13 @@ const parse = (docs): Tekton => { conditions: Object.fromEntries( docs.filter((item) => item.kind === 'Condition').map((item) => [item.metadata.name, item]), ), + pipelineRuns: Object.fromEntries( + docs.filter((item) => item.kind === 'PipelineRun').map((item) => [item.metadata.name, item]), + ), }; logger.info('Tekton: %o', tkn); + //fs.writeFileSync('tekton.json', JSON.stringify(tkn), 'utf-8'); return tkn; }; diff --git a/src/rules/no-duplicate-param.ts b/src/rules/no-duplicate-param.ts index 0c8b3dd..6a23499 100644 --- a/src/rules/no-duplicate-param.ts +++ b/src/rules/no-duplicate-param.ts @@ -17,7 +17,7 @@ function checkParams(params, report) { } export default (docs, tekton, report) => { - for (const t of ['triggerBindings', 'pipelines', 'tasks', 'triggerTemplates']) { + for (const t of ['triggerBindings', 'pipelines', 'tasks', 'triggerTemplates', 'pipelineRuns']) { for (const crd of Object.values(tekton[t])) { checkParams(getParams(crd.kind, crd.spec), report); } @@ -54,4 +54,24 @@ export default (docs, tekton, report) => { } } } + + for (const pipelineRun of Object.values(tekton.pipelineRuns)) { + try { + if (!pipelineRun.spec.pipelineSpec) continue; + + const tasks = [ + ...(pipelineRun.spec.pipelineSpec.tasks ? pipelineRun.spec.pipelineSpec.tasks : []), + ...(pipelineRun.spec.pipelineSpec.finally ? pipelineRun.spec.pipelineSpec.finally : []), + ]; + for (const task of tasks) { + checkParams(getParams('Task', task), report); + if (task.taskSpec) { + checkParams(getParams('Task', task.taskSpec), report); + } + } + } catch (e) { + console.log(pipelineRun.metadata.name); + throw e; + } + } }; diff --git a/src/rules/no-extra-param.ts b/src/rules/no-extra-param.ts index 84704be..58bca5b 100644 --- a/src/rules/no-extra-param.ts +++ b/src/rules/no-extra-param.ts @@ -109,4 +109,10 @@ export default (docs, tekton, report) => { } } } + + // for (const pr of Object.values(tekton.pipelineRuns)) { + // for (const task in pr.spec.pipelineSpec.tasks) { + // console.log(task); + // } + // } }; diff --git a/src/rules/no-missing-resource.ts b/src/rules/no-missing-resource.ts index fd053e8..f4d7654 100644 --- a/src/rules/no-missing-resource.ts +++ b/src/rules/no-missing-resource.ts @@ -62,6 +62,8 @@ export default (docs, tekton, report) => { } for (const pipeline of Object.values(tekton.pipelines)) { + if (!pipeline.spec) continue; + // include any finally tasks if they are present const tasks = [...pipeline.spec.tasks, ...(pipeline.spec.finally ? pipeline.spec.finally : [])]; for (const task of tasks) { @@ -78,4 +80,27 @@ export default (docs, tekton, report) => { } } } + + // --- + + for (const pipeline of Object.values(tekton.pipelineRuns)) { + if (!pipeline.spec || !pipeline.spec.pipelineSpec) continue; + const maintasks = pipeline.spec.pipelineSpec.tasks ? pipeline.spec.pipelineSpec.tasks : []; + const finallytasks = pipeline.spec.pipelineSpec.finally ? pipeline.spec.pipelineSpec.finally : []; + + const tasks = [...maintasks, ...finallytasks]; + for (const task of tasks) { + if (!task.taskRef) continue; + const name = task.taskRef.name; + + if (!tekton.tasks[name]) { + report( + `Pipeline '${pipeline.metadata.name}' references task '${name}' but the referenced task cannot be found. To fix this, include all the task definitions to the lint task for this pipeline.`, + task.taskRef, + 'name', + ); + continue; + } + } + } }; diff --git a/src/rules/no-missing-workspace.ts b/src/rules/no-missing-workspace.ts index 3ba09d2..4620e1a 100644 --- a/src/rules/no-missing-workspace.ts +++ b/src/rules/no-missing-workspace.ts @@ -1,4 +1,28 @@ -export default (docs, tekton, report) => { +import { RuleReportFn } from 'src/interfaces/common.js'; + +function checkTasks(tasks, parentWorkspaces, parentName, report) { + for (const task of tasks) { + if (!task.workspaces) continue; + for (const workspace of task.workspaces) { + let matchingWorkspace = false; + if (workspace.workspace) { + matchingWorkspace = parentWorkspaces.find(({ name }) => name === workspace.workspace); + } else { + // no workspace defined - which is strictly optional, so check the name field instead + matchingWorkspace = parentWorkspaces.find(({ name }) => name === workspace.name); + } + if (!matchingWorkspace) { + report( + `Pipeline '${parentName}' provides workspace '${workspace.workspace}' for '${workspace.name}' for Task '${task.name}', but '${workspace.workspace}' doesn't exists in '${parentName}'`, + workspace, + 'workspace', + ); + } + } + } +} + +export default (docs, tekton, report: RuleReportFn) => { for (const task of Object.values(tekton.tasks)) { if (!task.spec || !task.spec.workspaces) continue; const taskName = task.metadata.name; @@ -28,25 +52,35 @@ export default (docs, tekton, report) => { const pipelineWorkspaces = pipeline.spec.workspaces || []; // include any finally tasks if they are present const tasks = [...pipeline.spec.tasks, ...(pipeline.spec.finally ? pipeline.spec.finally : [])]; - for (const task of tasks) { - if (!task.workspaces) continue; - for (const workspace of task.workspaces) { - let matchingWorkspace = false; - if (workspace.workspace) { - matchingWorkspace = pipelineWorkspaces.find(({ name }) => name === workspace.workspace); - } else { - // no workspace defined - which is strictly optional, so check the name field instead - matchingWorkspace = pipelineWorkspaces.find(({ name }) => name === workspace.name); - } - if (!matchingWorkspace) { + checkTasks(tasks, pipelineWorkspaces, pipeline.metadata.name, report); + } + + for (const pipeline of Object.values(tekton.pipelineRuns)) { + if (!pipeline || !pipeline.spec || !pipeline.spec.pipelineSpec) continue; + const pipelineWorkspaces = pipeline.spec.pipelineSpec.workspaces || []; + // include any finally tasks if they are present + const tasks = [ + ...(pipeline.spec.pipelineSpec.tasks || []), + ...(pipeline.spec.pipelineSpec.finally ? pipeline.spec.pipelineSpec.finally : []), + ]; + checkTasks(tasks, pipelineWorkspaces, pipeline.metadata.name, report); + + // check the spec workspaces and the pieline spec + const runWorkspaces = pipeline.spec.workspaces; + const specWorkspaces = pipeline.spec.pipelineSpec.workspaces; + if (!specWorkspaces) continue; + + specWorkspaces + .filter((s) => !s) + .forEach((sws) => { + if (!runWorkspaces.find((ws) => (sws as any).name == ws.name)) { + console.log(`${sws.name} not found`); report( - `Pipeline '${pipeline.metadata.name}' provides workspace '${workspace.workspace}' for '${workspace.name}' for Task '${task.name}', but '${workspace.workspace}' doesn't exists in '${pipeline.metadata.name}'`, - workspace, - 'workspace', + `Pipeline run '${pipeline.metadata.name}' has a pipelineSpec with workspace '${sws.name}' that is not defined in the pipeline`, + sws, ); } - } - } + }); } for (const pipeline of Object.values(tekton.pipelines)) { diff --git a/src/rules/no-pipeline-missing-task.ts b/src/rules/no-pipeline-missing-task.ts index ac36c9a..804b33d 100644 --- a/src/rules/no-pipeline-missing-task.ts +++ b/src/rules/no-pipeline-missing-task.ts @@ -17,4 +17,28 @@ export default (docs, tekton, report) => { } } } + + // Run on the PipelineSpecs as well + for (const pipelineSpec of Object.values(tekton.pipelineRuns)) { + if (!pipelineSpec.spec || !pipelineSpec.spec.pipelineSpec) continue; + const taskRoot = pipelineSpec.spec.pipelineSpec.tasks; + if (taskRoot) { + for (const task of taskRoot) { + if (!task.runAfter) continue; + + for (const dependency of task.runAfter) { + const exists = taskRoot.some((task) => task.name === dependency); + const details = task.taskSpec ? 'defined in-line' : `referenced as '${task.taskRef.name}'`; + + if (!exists) { + report( + `Pipeline '${pipelineSpec.metadata.name}' uses task '${task.name}' (${details}), and it depends on '${dependency}', which doesn't exists (declared in runAfter)`, + task.runAfter, + task.runAfter.indexOf(dependency), + ); + } + } + } + } + } }; diff --git a/src/rules/no-pipeline-task-cycle.ts b/src/rules/no-pipeline-task-cycle.ts index ab84a8c..89e8be7 100644 --- a/src/rules/no-pipeline-task-cycle.ts +++ b/src/rules/no-pipeline-task-cycle.ts @@ -1,6 +1,10 @@ +// Supports Pipeline and PipelineRun + import pkg from 'graphlib'; const { alg, Graph } = pkg; +import { RuleError } from '../errorbase.js'; + const RESULT_PATTERN = '\\$\\(tasks\\.([^.]+)\\.results\\.[^.]*\\)'; const RESULT_REGEX_G = new RegExp(RESULT_PATTERN, 'g'); const RESULT_REGEX = new RegExp(RESULT_PATTERN); @@ -52,13 +56,13 @@ function resourceInputReferences(task) { })); } -function buildTaskGraph(pipeline, referenceCreators) { +function buildTaskGraph(name, tasks, referenceCreators) { const pipelineGraph = new Graph({ directed: true, multigraph: true }); - pipelineGraph.setGraph(pipeline.metadata.name); - if (!pipeline.spec.tasks || pipeline.spec.tasks.length === 0) { + pipelineGraph.setGraph(name); + if (!tasks || tasks.length === 0) { return pipelineGraph; } - for (const task of pipeline.spec.tasks) { + for (const task of tasks) { pipelineGraph.setNode(task.name, task); for (const referenceCreator of referenceCreators) { for (const { ref, type } of referenceCreator(task)) { @@ -72,11 +76,11 @@ function buildTaskGraph(pipeline, referenceCreators) { return pipelineGraph; } -function errorCyclesInPipeline(pipeline, referenceCreators, report) { - const pipelineTaskGraph = buildTaskGraph(pipeline, referenceCreators); +function errorCyclesInPipeline(name, tasks, referenceCreators, report) { + const pipelineTaskGraph = buildTaskGraph(name, tasks, referenceCreators); for (const cycle of alg.findCycles(pipelineTaskGraph)) { for (const taskNameInCycle of cycle) { - const taskInCycle = Object.values(pipeline.spec.tasks).find((task) => task.name === taskNameInCycle); + const taskInCycle = Object.values(tasks).find((task) => task.name === taskNameInCycle); report( `Cycle found in tasks (dependency graph): ${[...cycle, cycle[0]].join(' -> ')}`, taskInCycle, @@ -87,7 +91,24 @@ function errorCyclesInPipeline(pipeline, referenceCreators, report) { } export default (docs, tekton, report) => { - for (const pipeline of Object.values(tekton.pipelines)) { - errorCyclesInPipeline(pipeline, [runAfterReferences, paramsReferences, resourceInputReferences], report); + for (const pipeline of Object.values(tekton.pipelines)) { + try { + const name = pipeline.metadata.name; + const tasks = pipeline.spec.tasks; + errorCyclesInPipeline(name, tasks, [runAfterReferences, paramsReferences, resourceInputReferences], report); + } catch (e) { + throw new RuleError("Can't process", 'no-pipeline-task-cycle', pipeline.metadata.name); + } + } + + for (const pipeline of Object.values(tekton.pipelineRuns)) { + try { + if (!pipeline.spec.pipelineSpec) continue; + const name = pipeline.metadata.name; + const tasks = pipeline.spec.pipelineSpec.tasks; + errorCyclesInPipeline(name, tasks, [runAfterReferences, paramsReferences, resourceInputReferences], report); + } catch (e) { + throw new RuleError("Can't process", 'no-pipeline-task-cycle', pipeline.metadata.name); + } } }; diff --git a/src/rules/prefer-kebab-case.ts b/src/rules/prefer-kebab-case.ts index 9778029..6fc8b87 100644 --- a/src/rules/prefer-kebab-case.ts +++ b/src/rules/prefer-kebab-case.ts @@ -44,4 +44,13 @@ export default (docs, tekton, report) => { walk(pipeline.spec.tasks, ['spec', 'tasks'], naming(pipeline.metadata.name, 'params', report)); walk(pipeline.spec.finally, ['spec', 'finally'], naming(pipeline.metadata.name, 'params', report)); } + + for (const pipeline of Object.values(tekton.pipelineRuns)) { + walk(pipeline.spec.tasks, ['spec', 'pipelineSpec', 'tasks'], naming(pipeline.metadata.name, 'params', report)); + walk( + pipeline.spec.finally, + ['spec', 'pipelineSpec', 'finally'], + naming(pipeline.metadata.name, 'params', report), + ); + } };