diff --git a/cmd/krci/main.go b/cmd/krci/main.go index 417392e..8d3224c 100644 --- a/cmd/krci/main.go +++ b/cmd/krci/main.go @@ -1,4 +1,3 @@ -// Package main is the entry point for the krci CLI. package main import ( diff --git a/docs/json-schemas.md b/docs/json-schemas.md index 9e88118..e7db135 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -477,3 +477,81 @@ Common messages: | Unknown pull request (404) | `pull request not found` | | Upstream 5xx / network | `portal returned HTTP 500: ` | | Invalid flag value | Flag-specific message (e.g. enum list) | + + +## `krci pipelinerun start` + +The start verb reuses the same column shape as `krci pipelinerun list`. Empty +cells render as `-` in table mode and as `""` in JSON mode (matches list). + +### Success envelope + +```json +{ + "schemaVersion": "1", + "data": { + "name": "", + "status": "Pending|Running|Succeeded|Failed|Cancelled|Timeout", + "project": "", + "pr": "", + "author": "", + "type": "", + "started": "", + "duration": "" + } +} +``` + +### Error envelope + +```json +{ + "schemaVersion": "1", + "error": { "message": "pipeline 'ghost' not found" } +} +``` + +### Dry-run envelope (-o json) + +`data` carries the rendered `PipelineRun` resource as a parsed JSON object — +not a string. Default and `-o yaml` modes emit the same resource as YAML +(suitable for piping to `kubectl apply -f -`). + +```json +{ + "schemaVersion": "1", + "data": { + "apiVersion": "tekton.dev/v1", + "kind": "PipelineRun", + "metadata": { + "generateName": "foo-build-run-", + "labels": { "app.edp.epam.com/codebase": "my-app" } + }, + "spec": { + "params": [ { "name": "git-revision", "value": "main" } ] + } + } +} +``` + +### Common messages + +User-facing messages on the not-found path are synthesised CLI-side from a +stable `error.reason` tag the Portal returns. The Portal deliberately does +not put resource-identifying text in `error.message` (cluster-hardening +policy applied uniformly to all REST routes), so the CLI builds the user +message from the pipeline name it already has plus the reason it received. + +All errors exit `1` (per the global rule at the top of this document). + +| Condition | Message | +| ------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Pipeline not found | `pipeline '' not found` | +| TriggerTemplate referenced but missing | `pipeline '' references a TriggerTemplate that does not exist` | +| Malformed TriggerTemplate label | `platform rejected request: pipeline '' has malformed TriggerTemplate label` | +| Platform admission rejection (400/422) | `platform rejected request: ` (Portal does not echo K8s admission detail) | +| RBAC denied | `permission denied` | +| Portal upstream 5xx | `upstream service unavailable: ` | +| Duplicate / malformed `--param` / `--label` | `duplicate parameter ''` / `parameter must be key=value` / `label key must not be empty` | +| `--dry-run` with `-o table` | `--dry-run cannot use -o table (use -o json or -o yaml)` | + diff --git a/docs/pipelinerun.md b/docs/pipelinerun.md index 7c8bacb..a52a057 100644 --- a/docs/pipelinerun.md +++ b/docs/pipelinerun.md @@ -7,12 +7,15 @@ deploy, release). Also surfaces logs and a focused failure-diagnosis view. ## Subcommands -| Command | Purpose | -|-------------------------------|------------------------------------------| -| `pipelinerun list` (`ls`) | List and filter runs | -| `pipelinerun get ` | Inspect a specific run | +| Command | Purpose | +|-------------------------------|-----------------------------------------------| +| `pipelinerun list` (`ls`) | List and filter runs | +| `pipelinerun get ` | Inspect a specific run | +| `pipelinerun start ` | Create a new run from a Tekton pipeline name | -Both accept `-o, --output` (`table` | `json`), `--logs`, and `--reason`. +`list` and `get` accept `-o, --output` (`table` | `json`), `--logs`, and +`--reason`. `start` has its own flag set (`--param`, `--label`, `--dry-run`, +`-o`) — see below. ## `pipelinerun list` @@ -73,6 +76,80 @@ Project: keycloak-operator Add `--logs` for full logs or `--reason` for focused failure diagnosis. +## `pipelinerun start` + +Create a new run of a Tekton pipeline by name. The pipeline name is the +required argument; everything else is optional. The new run uses Kubernetes +`metadata.generateName`, so the apiserver assigns the random suffix and the +resolved name is read back and printed. + +```bash +krci pipelinerun start foo-build +``` + +``` +NAME STATUS PROJECT PR AUTHOR TYPE STARTED DURATION +foo-build-run-zhqvj Pending - - - build 2026-05-07T06:14:04Z - +``` + +### Flags + +| Flag | Description | +|----------------|------------------------------------------------------------------------------| +| `--param` | Pipeline parameter as `key=value` (repeatable; split on first `=`) | +| `--label` | Label to attach to the resulting PipelineRun as `key=value` (repeatable) | +| `--dry-run` | Render the would-be PipelineRun without creating it (needs `-o json`/`yaml`) | +| `-o, --output` | `table` (default), `json`, or `yaml` (yaml only with `--dry-run`) | + +> **Params without a default** are submitted with `value: ""` (or `[]` for +> arrays). Pass `--param k=v` for any values your pipeline actually needs. + +### Examples + +```bash +# Override a single param +krci pipelinerun start foo-build --param git-revision=develop + +# Multiple params plus a discoverability label +krci pipelinerun start foo-build --param k=v --param k2=v2 \ + --label app.edp.epam.com/codebase=my-app + +# Render the would-be PipelineRun without creating it +krci pipelinerun start foo-build --dry-run -o yaml + +# JSON output (for AI agents / scripting) +krci pipelinerun start foo-build -o json +``` + +### JSON output + +`start` uses a wrapped envelope (different from `list` / `get`): + +```json +{ + "schemaVersion": "1", + "data": { + "name": "foo-build-run-zhqvj", + "status": "Pending", + "project": "my-app", + "pr": "", + "author": "", + "type": "build", + "started": "2026-05-07T06:14:04Z", + "duration": "" + } +} +``` + +For `--dry-run`, `data` is the rendered PipelineRun manifest itself instead +of the result row. + +### Finding the run you just started + +```bash +krci pipelinerun list --project my-app +``` + ## Failure diagnosis (`--reason`) Works on both `list` (targets the most recent matching run) and `get`: @@ -100,7 +177,9 @@ Logs: sonar [sonar-scanner] ERROR: QUALITY GATE STATUS: FAILED ``` -## JSON output +## JSON output (`list` / `get`) + +`start` uses a different envelope — see the [`start` section](#pipelinerun-start) above. ```bash krci run list --project keycloak-operator -o json diff --git a/e2e/pipelinerun/fixtures.env.example b/e2e/pipelinerun/fixtures.env.example index aa8a121..8a6b8e4 100644 --- a/e2e/pipelinerun/fixtures.env.example +++ b/e2e/pipelinerun/fixtures.env.example @@ -2,14 +2,55 @@ # The /e2e command loads this file (if present) before running portal rows. # If a row references a placeholder that has no value, the row is SKIPped. # -# Discover good values with: -# ./dist/krci pipelinerun list -o json | jq '.pipelineRuns[0]' -# ./dist/krci pipelinerun list --status failed -o json | jq '.pipelineRuns[0]' +# All e2e rows are now self-contained: every value below either points at a +# Pipeline defined in fixtures/*.yaml, or at a label we attach to runs we +# create ourselves. There is no dependency on pre-existing cluster workload. +# +# Bootstrap once per cluster: +# kubectl apply -n -f source/cli/e2e/pipelinerun/fixtures/ +# +# Then run a few starts to populate RUN_NAME / FAILED_RUN_NAME (see below). + +# ----- pipelinerun start fixtures ----- +# Pipelines defined in fixtures/*.yaml. +PIPELINE_OK=krci-cli-e2e-noop +PIPELINE_REQUIRED_PARAM=krci-cli-e2e-required +PIPELINE_REQUIRED_PARAM_NAME=message +PIPELINE_BROKEN_TT=krci-cli-e2e-broken-tt +# Vanilla Tekton Pipeline with no KRCI labels at all — proves CLI start works +# regardless of the KubeRocketCI labelling convention (PR-S-BARE). +PIPELINE_BARE=krci-cli-e2e-bare +# Pipeline + TriggerTemplate fixture pair — exercises the TT branch of +# createPipelineRunDraftFromPipeline (placeholder resolution, label +# sanitization). The TT name is referenced via the Pipeline's +# app.edp.epam.com/triggertemplate label. +PIPELINE_WITH_TT=krci-cli-e2e-with-tt +TRIGGER_TEMPLATE=krci-cli-e2e-tt + +# Param accepted by PIPELINE_OK whose default is "main" — PR-S-2 overrides it. +PARAM_KNOWN_KEY=git-revision +PARAM_KNOWN_VALUE=develop -PROJECT=my-project -PR=123 -AUTHOR=john-doe +# Synthetic codebase label. Attached at start time via --label so the resulting +# PipelineRun is discoverable via `pipelinerun list --project krci-cli-e2e`. +PIPELINE_LABEL_KEY=app.edp.epam.com/codebase +PIPELINE_LABEL_VALUE=krci-cli-e2e + +# ----- pipelinerun list / get fixtures ----- +# Identifying values for runs created by the e2e fixtures themselves. The +# pipelinetype filter is the most useful selector since we own the value. +PROJECT=krci-cli-e2e +PR=1 +AUTHOR=krci-cli-e2e-bot BRANCH=main -RUN_NAME=build-my-project-main-abc12 -FAILED_RUN_NAME=review-my-project-main-xyz34 + +# Concrete run names. After a fresh apply, populate these with one successful +# and one failed run name from your namespace, e.g.: +# ./dist/krci pipelinerun start krci-cli-e2e-noop -o json \ +# | jq -r '.data.row.name' +# ./dist/krci pipelinerun start krci-cli-e2e-noop \ +# --param should-fail=true -o json | jq -r '.data.row.name' +RUN_NAME= +FAILED_RUN_NAME= + NONCE=test-999zzz diff --git a/e2e/pipelinerun/fixtures/README.md b/e2e/pipelinerun/fixtures/README.md new file mode 100644 index 0000000..4bd5554 --- /dev/null +++ b/e2e/pipelinerun/fixtures/README.md @@ -0,0 +1,125 @@ +# pipelinerun start — e2e fixtures + +Self-contained Tekton manifests used by the `krci pipelinerun start` e2e rows +in `../test-cases.md`. Files are the source of truth — apply with +`kubectl apply -f` and the cluster matches what's checked in (no drift). + +All pipelines run a single inline `taskSpec` that calls `busybox` and echoes +its inputs. None of them reach external systems, push artifacts, or write to +git. They are safe to start repeatedly. All resource names use a synthetic +`krci-cli-e2e-*` prefix and the manifests carry no organisation-specific +identifiers — drop them into any namespace on any KubeRocketCI cluster. + +## Bundle + +| File | Resource | Purpose | Test rows | +|---|---|---|---| +| `pipeline-noop.yaml` | Pipeline `krci-cli-e2e-noop` | Four params, all with defaults; `should-fail=true` makes the run exit non-zero. | `PR-S-1`, `PR-S-2`, `PR-S-3`, `PR-S-LABEL`, `PR-S-DRY-YAML`, `PR-S-DRY-JSON`, `PR-S-RACE`, `PR-S-GENNAME`, `PR-S-COL-EQ` | +| `pipeline-required.yaml` | Pipeline `krci-cli-e2e-required` | Declares a no-default param. Documents the portal's empty-string synthesis: omitting `--param message=...` produces `spec.params[0].value: ""`, **not** an admission rejection. The fixture's spec.description still references the old (incorrect) intent — left untouched to avoid drift; see `PR-S-PARAM-SYNTHESIZED` for the actual contract. | `PR-S-PARAM-SYNTHESIZED` | +| `pipeline-broken-tt.yaml` | Pipeline `krci-cli-e2e-broken-tt` | Carries an `app.edp.epam.com/triggertemplate` label pointing at a TT that does not exist. | `PR-S-TT-MISSING` | +| `pipeline-bare.yaml` | Pipeline `krci-cli-e2e-bare` | No KRCI labels at all. Proves the CLI can start any valid Tekton Pipeline regardless of the KubeRocketCI labelling convention. | `PR-S-BARE` | +| `pipeline-with-tt.yaml` | Pipeline `krci-cli-e2e-with-tt` | Paired with `trigger-template.yaml`. Exercises the TriggerTemplate branch of `createPipelineRunDraftFromPipeline` — placeholder resolution and label sanitization. | `PR-S-TT-OK`, `PR-S-TT-DRY` | +| `trigger-template.yaml` | TriggerTemplate `krci-cli-e2e-tt` | Resourcetemplate uses `$(tt.params.X)` placeholders that resolve against `pipeline-with-tt.yaml`'s param defaults. | (paired with above) | + +## Apply + +Substitute `` with whatever namespace you target (e.g. the one your +portal session points at): + +```sh +kubectl apply -n -f source/cli/e2e/pipelinerun/fixtures/ +``` + +Verify: + +```sh +kubectl -n get pipeline.tekton.dev,triggertemplate.triggers.tekton.dev \ + -l app.edp.epam.com/pipelinetype=tests +``` + +## Smoke test (after apply) + +```sh +# Dry-run — proves CLI <-> portal plumbing without creating a PipelineRun. +./dist/krci pipelinerun start krci-cli-e2e-noop --dry-run -o json | jq . + +# Real run with default params. +./dist/krci pipelinerun start krci-cli-e2e-noop -o json + +# Real run overriding a param. +./dist/krci pipelinerun start krci-cli-e2e-noop \ + --param git-revision=develop --param count=3 -o json + +# Deterministic failure path. +./dist/krci pipelinerun start krci-cli-e2e-noop --param should-fail=true -o json + +# No-default-param path: succeeds with synthesised empty value (exit 0). +# Demonstrates that "missing required param" is not a reachable failure mode. +./dist/krci pipelinerun start krci-cli-e2e-required -o json + +# TriggerTemplate-not-found path: synthesised "TT does not exist" message, exit 1. +./dist/krci pipelinerun start krci-cli-e2e-broken-tt + +# Bare Tekton Pipeline (no KRCI labels) — must still start. +./dist/krci pipelinerun start krci-cli-e2e-bare -o json + +# TriggerTemplate happy path — dry-run reveals resolved $(tt.params.X) values. +./dist/krci pipelinerun start krci-cli-e2e-with-tt --dry-run -o json | jq . + +# TriggerTemplate happy path — real run. +./dist/krci pipelinerun start krci-cli-e2e-with-tt -o json +``` + +## Cleanup + +The PipelineRun objects created by tests are subject to whatever GC the +cluster has configured (Tekton Results retention, operator pruning, etc.). +To remove the fixture resources themselves: + +```sh +kubectl delete -n -f source/cli/e2e/pipelinerun/fixtures/ +``` + +To purge accumulated runs (label selector picks up runs from any of the +fixture pipelines because every fixture sets `pipelinetype: tests` on either +the Pipeline or — via TT-resolved labels — the resulting PipelineRun): + +```sh +kubectl -n delete pipelinerun.tekton.dev \ + -l app.edp.epam.com/pipelinetype=tests +``` + +## Why these manifests look the way they do + +- **Labelled `pipelinetype: tests`.** Distinguishes these fixtures from + KubeRocketCI's real pipelines (build/review/deploy/clean/security/release). + Filter via `kubectl get pipeline.tekton.dev + -l app.edp.epam.com/pipelinetype=tests` or the same selector in the portal + UI. `tests` is already a valid value in the portal's `pipelineTypeEnum`. +- **`app.edp.epam.com/*` label keys are upstream KubeRocketCI definitions** + and cannot be renamed — the portal reads exactly those keys to drive + TriggerTemplate lookup, codebase filters, and pipelinetype filters. The + values used in this bundle (`tests`, `krci-cli-e2e`, `""`) are synthetic + and carry no organisation-specific data. +- **`triggertemplate` label is empty** on the `noop` and `required` fixtures. + The portal's Pipeline Zod schema requires both labels to be present, but + `getTriggerTemplateLabel` treats an empty string as "absent" and skips the + TT lookup — so `start` works without us having to also create a TT. + `pipeline-broken-tt.yaml` is the deliberate exception: its label points at + a TT that does not exist, which is exactly the failure mode it tests. +- **`pipeline-with-tt.yaml` + `trigger-template.yaml` are a pair.** The + Pipeline carries `triggertemplate: krci-cli-e2e-tt`; the matching TT's + `resourcetemplates[0]` uses `$(tt.params.X)` placeholders. The portal's + draft builder resolves those placeholders against the Pipeline's param + defaults (not the TT's), so the resulting PipelineRun reflects values + declared on the Pipeline side. This is the only fixture that exercises + `createPipelineRunDraftFromPipeline`'s TT branch end-to-end. +- **Inline `taskSpec`, not `taskRef`.** Decouples the fixtures from + cluster-installed Tasks/ClusterTasks — apply works on any cluster with + Tekton Pipelines installed. +- **Params passed via env, not interpolated into shell.** Tekton substitutes + `$(params.X)` as text before the shell runs. Routing through `env:` means + param values land in shell variables, where `"$VAR"` does not re-expand + command substitutions — so a hostile `--param message='$(reboot)'` is + echoed literally rather than executed. +- **Pinned image tag (`busybox:1.36`).** Reproducible across runs. diff --git a/e2e/pipelinerun/fixtures/pipeline-bare.yaml b/e2e/pipelinerun/fixtures/pipeline-bare.yaml new file mode 100644 index 0000000..19c3a81 --- /dev/null +++ b/e2e/pipelinerun/fixtures/pipeline-bare.yaml @@ -0,0 +1,36 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: krci-cli-e2e-bare + # No KRCI labels at all. Proves the CLI can start a vanilla Tekton Pipeline + # that does not follow the KubeRocketCI labelling convention. Drives + # PR-S-BARE — same getTriggerTemplateLabel return value as PIPELINE_OK, + # but reaches it via the "label key absent" branch rather than the + # "label key present, value empty" branch. +spec: + description: | + Test fixture for the krci CLI e2e suite. Carries zero KRCI labels — every + well-formed Tekton Pipeline must be startable, regardless of whether it + follows the KubeRocketCI labelling convention. + Source of truth: source/cli/e2e/pipelinerun/fixtures/pipeline-bare.yaml. + params: + - name: message + type: string + default: "hello from bare" + tasks: + - name: echo + params: + - name: message + value: $(params.message) + taskSpec: + params: + - name: message + steps: + - name: hello + image: docker.io/library/busybox:1.36 + env: + - name: MESSAGE + value: $(params.message) + script: | + #!/bin/sh + echo "$MESSAGE" diff --git a/e2e/pipelinerun/fixtures/pipeline-broken-tt.yaml b/e2e/pipelinerun/fixtures/pipeline-broken-tt.yaml new file mode 100644 index 0000000..b687f4a --- /dev/null +++ b/e2e/pipelinerun/fixtures/pipeline-broken-tt.yaml @@ -0,0 +1,37 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: krci-cli-e2e-broken-tt + labels: + app.edp.epam.com/pipelinetype: tests + # Points at a TriggerTemplate that does NOT exist. The portal start + # procedure looks the TT up before building the PipelineRun draft and + # returns trigger_template_not_found. Drives PR-S-TT-MISSING. + app.edp.epam.com/triggertemplate: krci-cli-e2e-tt-does-not-exist +spec: + description: | + Test fixture for the krci CLI e2e suite. Carries a TriggerTemplate label + pointing at a non-existent TT so that `pipelinerun start` exercises the + portal's TT-lookup error branch. + Source of truth: source/cli/e2e/pipelinerun/fixtures/pipeline-broken-tt.yaml. + params: + - name: message + type: string + default: "unreachable" + tasks: + - name: echo + params: + - name: message + value: $(params.message) + taskSpec: + params: + - name: message + steps: + - name: hello + image: docker.io/library/busybox:1.36 + env: + - name: MESSAGE + value: $(params.message) + script: | + #!/bin/sh + echo "$MESSAGE - this should never run; TT lookup fails first" diff --git a/e2e/pipelinerun/fixtures/pipeline-noop.yaml b/e2e/pipelinerun/fixtures/pipeline-noop.yaml new file mode 100644 index 0000000..79de347 --- /dev/null +++ b/e2e/pipelinerun/fixtures/pipeline-noop.yaml @@ -0,0 +1,74 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: krci-cli-e2e-noop + labels: + # Distinguishes test fixtures from real KubeRocketCI pipelines (build/review/deploy/...) + # so they can be filtered with -l app.edp.epam.com/pipelinetype=tests. + app.edp.epam.com/pipelinetype: tests + # Empty string is treated as "absent" by the portal's getTriggerTemplateLabel, + # so start skips TT lookup. Required by the portal's Pipeline Zod schema. + app.edp.epam.com/triggertemplate: "" +spec: + description: | + No-op test fixture for the krci CLI e2e suite (pipelinerun start). + Pure echo with controllable failure. Source of truth: + source/cli/e2e/pipelinerun/fixtures/pipeline-noop.yaml. + params: + - name: message + type: string + default: "hello" + description: Echoed verbatim in task logs. + - name: git-revision + type: string + default: "main" + description: Echoed alongside the message; matches a common build param name so PR-S-2 can override it. + - name: count + type: string + default: "1" + description: How many times to echo the message. + - name: should-fail + type: string + default: "false" + description: Set to "true" to make the run exit non-zero (deterministic failure for tests). + tasks: + - name: echo + params: + - name: message + value: $(params.message) + - name: revision + value: $(params.git-revision) + - name: count + value: $(params.count) + - name: should-fail + value: $(params.should-fail) + taskSpec: + params: + - name: message + - name: revision + - name: count + - name: should-fail + steps: + - name: hello + image: docker.io/library/busybox:1.36 + env: + - name: MESSAGE + value: $(params.message) + - name: REVISION + value: $(params.revision) + - name: COUNT + value: $(params.count) + - name: SHOULD_FAIL + value: $(params.should-fail) + script: | + #!/bin/sh + set -eu + i=1 + while [ "$i" -le "$COUNT" ]; do + echo "[$i] $MESSAGE @ $REVISION" + i=$((i + 1)) + done + if [ "$SHOULD_FAIL" = "true" ]; then + echo "SHOULD_FAIL=true -> exiting 1" + exit 1 + fi diff --git a/e2e/pipelinerun/fixtures/pipeline-required.yaml b/e2e/pipelinerun/fixtures/pipeline-required.yaml new file mode 100644 index 0000000..c7682ff --- /dev/null +++ b/e2e/pipelinerun/fixtures/pipeline-required.yaml @@ -0,0 +1,45 @@ +# IMPORTANT — read before editing. +# +# spec.description and params[0].description below still claim that omitting +# --param message=... triggers admission rejection. THAT IS NOT WHAT HAPPENS. +# The portal's createPipelineRunDraftFromPipeline synthesizes value: "" for +# every Pipeline-declared param without a default, so the apiserver always +# sees a complete spec.params list and admission always passes. The fixture +# now drives PR-S-PARAM-SYNTHESIZED, which asserts on the synthesized value +# instead. The applied descriptions are left untouched to avoid re-applying +# this manifest to the cluster — kubectl apply strips YAML # comments, so +# this block exists only in the source-of-truth file. +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: krci-cli-e2e-required + labels: + app.edp.epam.com/pipelinetype: tests + app.edp.epam.com/triggertemplate: "" +spec: + description: | + Test fixture for the krci CLI e2e suite. Declares one required param with + no default — submitting a PipelineRun without --param message=... is + rejected by the apiserver at admission time. Drives PR-S-PARAM-MISSING. + Source of truth: source/cli/e2e/pipelinerun/fixtures/pipeline-required.yaml. + params: + - name: message + type: string + description: Required — no default. Omitting --param message=... triggers an admission error. + tasks: + - name: echo + params: + - name: message + value: $(params.message) + taskSpec: + params: + - name: message + steps: + - name: hello + image: docker.io/library/busybox:1.36 + env: + - name: MESSAGE + value: $(params.message) + script: | + #!/bin/sh + echo "$MESSAGE" diff --git a/e2e/pipelinerun/fixtures/pipeline-with-tt.yaml b/e2e/pipelinerun/fixtures/pipeline-with-tt.yaml new file mode 100644 index 0000000..254b1e9 --- /dev/null +++ b/e2e/pipelinerun/fixtures/pipeline-with-tt.yaml @@ -0,0 +1,75 @@ +apiVersion: tekton.dev/v1 +kind: Pipeline +metadata: + name: krci-cli-e2e-with-tt + labels: + app.edp.epam.com/pipelinetype: tests + # References the TriggerTemplate defined in trigger-template.yaml. + # Drives the TT branch of createPipelineRunDraftFromPipeline. + app.edp.epam.com/triggertemplate: krci-cli-e2e-tt +spec: + description: | + Paired with TriggerTemplate krci-cli-e2e-tt (trigger-template.yaml). When + started via the CLI, the portal looks up the TT, clones its + resourcetemplate, then resolves every $(tt.params.X) placeholder using + the param defaults declared on THIS Pipeline (not the TT's spec.params). + Source of truth: source/cli/e2e/pipelinerun/fixtures/pipeline-with-tt.yaml. + params: + - name: message + type: string + default: "from-pipeline-default" + description: | + Resolved into the TT's $(tt.params.message) placeholder by Pass 1 + of createPipelineRunDraftFromPipeline. The resulting PipelineRun + carries this value in both spec.params and a seeded label, which + lets PR-S-TT-OK assert that the TT branch ran. + - name: git-revision + type: string + default: "main" + - name: count + type: string + default: "1" + - name: should-fail + type: string + default: "false" + tasks: + - name: echo + params: + - name: message + value: $(params.message) + - name: revision + value: $(params.git-revision) + - name: count + value: $(params.count) + - name: should-fail + value: $(params.should-fail) + taskSpec: + params: + - name: message + - name: revision + - name: count + - name: should-fail + steps: + - name: hello + image: docker.io/library/busybox:1.36 + env: + - name: MESSAGE + value: $(params.message) + - name: REVISION + value: $(params.revision) + - name: COUNT + value: $(params.count) + - name: SHOULD_FAIL + value: $(params.should-fail) + script: | + #!/bin/sh + set -eu + i=1 + while [ "$i" -le "$COUNT" ]; do + echo "[$i] $MESSAGE @ $REVISION (resolved via TriggerTemplate)" + i=$((i + 1)) + done + if [ "$SHOULD_FAIL" = "true" ]; then + echo "SHOULD_FAIL=true -> exiting 1" + exit 1 + fi diff --git a/e2e/pipelinerun/fixtures/trigger-template.yaml b/e2e/pipelinerun/fixtures/trigger-template.yaml new file mode 100644 index 0000000..251313f --- /dev/null +++ b/e2e/pipelinerun/fixtures/trigger-template.yaml @@ -0,0 +1,55 @@ +apiVersion: triggers.tekton.dev/v1beta1 +kind: TriggerTemplate +metadata: + name: krci-cli-e2e-tt + labels: + app.edp.epam.com/pipelinetype: tests +spec: + # TT-side param declarations exist for parity with real KubeRocketCI + # TriggerTemplates (and for any EventListener flows that may target this + # TT). NOTE: the portal's createPipelineRunDraftFromPipeline does NOT + # consume TT.spec.params — it resolves $(tt.params.X) placeholders in + # resourcetemplates against Pipeline.spec.params defaults instead. + params: + - name: message + default: "tt-spec-default" + description: Documentation-only; not used by CLI start. + - name: git-revision + default: "main" + description: Documentation-only; not used by CLI start. + resourcetemplates: + - apiVersion: tekton.dev/v1 + kind: PipelineRun + metadata: + # prepareStartDraft replaces this with metadata.generateName before + # the K8s create call, so the literal value here is irrelevant. + name: krci-cli-e2e-with-tt-from-tt + labels: + app.edp.epam.com/pipelinetype: tests + app.edp.epam.com/codebase: krci-cli-e2e + # Placeholder resolution into a label value is the most useful + # observable side-effect for tests: PR-S-TT-OK asserts that the + # produced PipelineRun carries + # test.krci-cli-e2e/seeded-message: from-pipeline-default + # which proves Pass 1 of the TT walker ran (the value comes from + # the Pipeline's param default, not from this TT's spec.params). + test.krci-cli-e2e/seeded-message: $(tt.params.message) + test.krci-cli-e2e/seeded-revision: $(tt.params.git-revision) + annotations: + test.krci-cli-e2e/source: tt + spec: + # Will be overwritten with the user-requested pipeline name by + # getPipelineRunFromTriggerTemplate before placeholder resolution. + pipelineRef: + name: krci-cli-e2e-with-tt + params: + - name: message + value: $(tt.params.message) + - name: git-revision + value: $(tt.params.git-revision) + # Pipeline declares these without TT-side counterparts, so the + # walker leaves them as literal strings. + - name: count + value: "1" + - name: should-fail + value: "false" diff --git a/e2e/pipelinerun/test-cases.md b/e2e/pipelinerun/test-cases.md index aa057c3..4aecfe4 100644 --- a/e2e/pipelinerun/test-cases.md +++ b/e2e/pipelinerun/test-cases.md @@ -9,16 +9,43 @@ Every row is a self-contained contract a Haiku agent can execute. See ## Placeholders resolved per run +The orchestrator loads `fixtures.env` and substitutes every `{{NAME}}` in the +tables below before dispatching agents. Rows whose placeholders are unset are +SKIPped, not failed. + +**Generic discovery placeholders** (used by `list` / `get` rows; populate from +your own portal): + | Placeholder | Meaning | Example | |--------------|---------------------------------------------------|-----------------------| -| `{{PROJECT}}` | An existing codebase name visible to the caller. | `keycloak-operator` | -| `{{PR}}` | A PR number with at least one run for PROJECT. | `336` | -| `{{AUTHOR}}` | An author with at least one run. | `jane-doe` | -| `{{BRANCH}}` | A source branch with at least one run. | `refactor-kc-client` | -| `{{RUN_NAME}}` | A real run name (copy from `list -o json`). | `build-…-m8z4m` | -| `{{FAILED_RUN_NAME}}` | A run whose status is Failed. | `review-…-a1b2c3` | - -The orchestrator fills these; the table never hard-codes them. +| `{{PROJECT}}` | A codebase name visible to the caller. | `krci-cli-e2e` | +| `{{PR}}` | A PR number with at least one run for PROJECT. | `1` | +| `{{AUTHOR}}` | An author with at least one run. | `krci-cli-e2e-bot` | +| `{{BRANCH}}` | A source branch with at least one run. | `main` | +| `{{RUN_NAME}}` | A real run name (copy from `list -o json`). | `krci-cli-e2e-noop-run-m8z4m` | +| `{{FAILED_RUN_NAME}}` | A run whose status is Failed. | `krci-cli-e2e-noop-run-a1b2c` | +| `{{NONCE}}` | Random suffix for "definitely-doesn't-exist" assertions. | `test-999zzz` | + +**Start-feature placeholders** (point at the manifests in `fixtures/*.yaml`; +apply once with `kubectl apply -n -f fixtures/`): + +> Note: pipelines whose params lack defaults are submitted with `value: ""` +> (or `[]` for arrays), so "missing required param" is not a reachable +> failure mode through this client — do not write rows expecting one. + +| Placeholder | Meaning | Example | +|--------------------------------------|----------------------------------------------------------|-------------------------------| +| `{{PIPELINE_OK}}` | Pipeline that runs cleanly with all-defaulted params. | `krci-cli-e2e-noop` | +| `{{PIPELINE_REQUIRED_PARAM}}` | Pipeline declaring a required param (no default). | `krci-cli-e2e-required` | +| `{{PIPELINE_REQUIRED_PARAM_NAME}}` | Name of that required param. | `message` | +| `{{PIPELINE_BROKEN_TT}}` | Pipeline whose TT label points at a missing TT. | `krci-cli-e2e-broken-tt` | +| `{{PIPELINE_BARE}}` | Tekton Pipeline carrying no KRCI labels at all. | `krci-cli-e2e-bare` | +| `{{PIPELINE_WITH_TT}}` | Pipeline paired with a real, resolvable TriggerTemplate. | `krci-cli-e2e-with-tt` | +| `{{TRIGGER_TEMPLATE}}` | The TT referenced by `{{PIPELINE_WITH_TT}}`. | `krci-cli-e2e-tt` | +| `{{PARAM_KNOWN_KEY}}` | Param accepted by `{{PIPELINE_OK}}`. | `git-revision` | +| `{{PARAM_KNOWN_VALUE}}` | Override value for `{{PARAM_KNOWN_KEY}}`. | `develop` | +| `{{PIPELINE_LABEL_KEY}}` | Codebase label key used by `--label`. | `app.edp.epam.com/codebase` | +| `{{PIPELINE_LABEL_VALUE}}` | Codebase label value used by `--label`. | `krci-cli-e2e` | --- @@ -191,3 +218,47 @@ Each of the following must be covered by ≥1 row above. Tick as you add. - [x] `get` nonexistent name - [x] JSON envelope field contract for `list` and `get` - [x] auth-required error path + +### `pipelinerun start` + +Source: `pkg/cmd/pipelinerun/start/start.go` and `internal/portal/start.go`. +Fixtures: `fixtures/*.yaml` (Pipelines + TriggerTemplate); see +`fixtures/README.md` for the apply procedure. + +| ID | Class | Run as | Title | Expect | +|---------------------|------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| PR-S-HELP | help | offline | `start --help` lists `--param`, `--label`, `--dry-run`, `-o` | exit 0; help text mentions `escape hatch` | +| PR-S-DNS-1 | validation | offline | reject uppercase positional `Foo_Build` | exit 1; stderr `must be a valid DNS-1123 name` | +| PR-S-OUT-1 | validation | offline | reject `-o xml` | exit 1; stderr `unknown output format` | +| PR-S-DRY-MUTEX | validation | offline | reject `--dry-run -o table` | exit 1; stderr `--dry-run cannot use -o table` | +| PR-S-PARAM-DUP | validation | offline | reject `--param k=v1 --param k=v2` | exit 1; stderr `duplicate parameter 'k'` | +| PR-S-PARAM-EMPTY | validation | offline | reject `--param =value` | exit 1; stderr `parameter key must not be empty` | +| PR-S-PARAM-MAL | validation | offline | reject `--param keywithoutvalue` | exit 1; stderr `parameter must be key=value` | +| PR-S-LABEL-DUP | validation | offline | reject `--label k=v1 --label k=v2` | exit 1; stderr `duplicate label 'k'` | +| PR-S-PARAM-EQ | parser | offline | accept `--param token=abc=def==` (split on first `=`) | exit 0 (capture); param `token=abc=def==` | +| PR-S-PARAM-WS | parser | offline | accept `--param " k = v "` (whitespace trimmed) | exit 0 (capture); param `k=v` | +| PR-S-1 | happy | portal | `start {{PIPELINE_OK}}` (no params) | exit 0; stdout row with columns `NAME, STATUS, PROJECT, PR, AUTHOR, TYPE, STARTED, DURATION` | +| PR-S-2 | happy | portal | `start {{PIPELINE_OK}} --param {{PARAM_KNOWN_KEY}}={{PARAM_KNOWN_VALUE}}` | exit 0; resulting PipelineRun's `spec.params` carries the override | +| PR-S-3 | happy | portal | `start {{PIPELINE_OK}} -o json` | exit 0; stdout `{"schemaVersion":"1","data":{"name":"...","status":"...",...}}` (StartResult fields, flat — no `row`) | +| PR-S-LABEL | happy | portal | `start {{PIPELINE_OK}} --label {{PIPELINE_LABEL_KEY}}={{PIPELINE_LABEL_VALUE}}`; then `pipelinerun list --project {{PIPELINE_LABEL_VALUE}}` | exit 0 from both; the new run is discoverable in the list output | +| PR-S-FAIL | happy | portal | `start {{PIPELINE_OK}} --param should-fail=true -o json` | exit 0; `data.name` populated. Run will eventually fail — useful as deterministic seed for `FAILED_RUN_NAME`. | +| PR-S-BARE | happy | portal | `start {{PIPELINE_BARE}}` (Pipeline carries zero KRCI labels) | exit 0; standard row; proves CLI does not require KubeRocketCI labelling convention | +| PR-S-TT-OK | happy | portal | `start {{PIPELINE_WITH_TT}} -o json`; then read back `kubectl get pipelinerun -o jsonpath='{.metadata.labels.test\.krci-cli-e2e/seeded-message}'` | exit 0; label value equals `from-pipeline-default` (proves TriggerTemplate placeholder resolution ran) | +| PR-S-DRY-YAML | dry-run | portal | `start {{PIPELINE_OK}} --dry-run` (default → YAML manifest) | exit 0; stdout valid YAML containing `metadata.generateName: {{PIPELINE_OK}}-run-` | +| PR-S-DRY-JSON | dry-run | portal | `start {{PIPELINE_OK}} --dry-run -o json` | exit 0; stdout `{"schemaVersion":"1","data":{...PipelineRun manifest...}}`; `data.metadata.generateName` starts with `{{PIPELINE_OK}}-run-` | +| PR-S-TT-DRY | dry-run | portal | `start {{PIPELINE_WITH_TT}} --dry-run -o json` | exit 0; `data.metadata.labels."test.krci-cli-e2e/seeded-message"` equals `from-pipeline-default` (resolved before K8s submission) | +| PR-S-NOT-FOUND | error | portal | `start ghost` (no such Pipeline) | exit 1; stderr `pipeline 'ghost' not found` | +| PR-S-TT-MISSING | error | portal | `start {{PIPELINE_BROKEN_TT}}` | exit 1; stderr `references a TriggerTemplate that does not exist` | +| PR-S-PARAM-SYNTHESIZED | regression | portal | `start {{PIPELINE_REQUIRED_PARAM}}` (omit `--param {{PIPELINE_REQUIRED_PARAM_NAME}}=...`); then read back `kubectl get pipelinerun -o jsonpath='{.spec.params[0].value}'` | exit 0 from start; the synthesized param value is the empty string. Documents the portal's "always submit a complete manifest" behavior — admission cannot reject for missing required params via this client. | +| PR-S-COL-EQ | regression | portal | header row of `start {{PIPELINE_OK}}` matches header row of `pipelinerun list` byte-for-byte | identical header strings | +| PR-S-RACE | edge | portal | immediately after `start {{PIPELINE_OK}}`, run `pipelinerun get ` | start exit 0 with stderr note `controller may briefly 404`; the subsequent `get` may briefly 404 | +| PR-S-GENNAME | regression | portal | `start {{PIPELINE_OK}} -o json`; assert `data.name` matches `^{{PIPELINE_OK}}-run-[a-z0-9]{5}$` | name shape conforms (`generateName` round-trip) | + +#### Coverage notes + +- **Validation rows** (`PR-S-DNS-1` through `PR-S-PARAM-WS`) run offline and only need a built `dist/krci` — no portal, no fixtures. +- **Portal rows** require the manifests in `fixtures/*.yaml` to be applied to the cluster, and the fixture-specific placeholders (`PIPELINE_OK`, `PIPELINE_REQUIRED_PARAM`, `PIPELINE_BROKEN_TT`, `PIPELINE_BARE`, `PIPELINE_WITH_TT`, etc.) to be set in `fixtures.env`. Apply with `kubectl apply -n -f fixtures/`; copy `fixtures.env.example` and fill in the values for your namespace. +- **Cluster-side assertions** (`PR-S-TT-OK`'s seeded-label check) require the agent to read the resulting PipelineRun back from K8s after `start` returns — the CLI does not surface labels in its output. Use `kubectl` against the same namespace the start used. +- **All `start` errors exit with code 1.** `cmd/krci/main.go` does not map error types to distinct codes; differentiation is via stderr text only. Earlier "exit 2" / "exit 3" rows have been corrected. +- **No "missing required param" admission rejection is reachable via this client.** The portal's `createPipelineRunDraftFromPipeline` synthesizes `value: ""` (or `[]` for arrays) for every Pipeline-declared param without a default, so the K8s apiserver always sees a complete `spec.params` list. PR-S-PARAM-SYNTHESIZED documents this property; we removed the earlier PR-S-PARAM-MISSING row that asserted the opposite. The CLI's 422 → `ErrPlatformReject` mapping is still exercised by other admission failure classes (resource quota, conflicting names, unrelated webhook rejections) — covered by `internal/portal/start_test.go`. +- **Out of scope for e2e, covered by unit tests:** `PR-S-RBAC` (HTTP 403 → `permission denied`) and `PR-S-5XX` (HTTP 502/503/500/504 → `upstream service unavailable`) require infrastructure (low-privilege user, fault-injecting proxy) that the self-contained fixture suite does not provision. The mappings live in `internal/portal/start.go:checkStartResponse` and are validated by `internal/portal/start_test.go`. diff --git a/go.mod b/go.mod index d7eba06..347c9c8 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/zalando/go-keyring v0.2.6 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -53,5 +54,4 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.31.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/cmdutil/validate.go b/internal/cmdutil/validate.go index 4de5994..23bf4b8 100644 --- a/internal/cmdutil/validate.go +++ b/internal/cmdutil/validate.go @@ -41,16 +41,24 @@ func ValidateStringFlags(cmd *cobra.Command) error { return nil } -// DNS-1123 label: lowercase alphanumerics and '-', must start and end with an -// alphanumeric, 1..63 chars. Used for Kubernetes namespaces, KubeRocketCI -// codebase names, and SonarQube projectKeys (which by Portal convention equal -// the codebase name). Single source of truth for the whole CLI. +// DNS-1123 label: lowercase alphanumerics and '-', 1..63 chars, start/end with +// alphanumeric. Single source of truth across the CLI — do not duplicate. var dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) -// DNS1123SubdomainMaxLength is the maximum length of a DNS-1123 subdomain. const DNS1123SubdomainMaxLength = 253 -// IsValidDNS1123Label reports whether s matches the DNS-1123 label shape. func IsValidDNS1123Label(s string) bool { return dns1123LabelRegexp.MatchString(s) } + +// DNS-1123 subdomain: dot-separated label segments, up to 253 chars. +// Kubernetes resource names use the subdomain shape — not the 63-char label cap. +var dns1123SubdomainRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +func IsValidDNS1123Subdomain(s string) bool { + if len(s) == 0 || len(s) > DNS1123SubdomainMaxLength { + return false + } + + return dns1123SubdomainRegexp.MatchString(s) +} diff --git a/internal/cmdutil/validate_test.go b/internal/cmdutil/validate_test.go index c8cc51c..be1f8a3 100644 --- a/internal/cmdutil/validate_test.go +++ b/internal/cmdutil/validate_test.go @@ -98,3 +98,37 @@ func TestIsValidDNS1123Label(t *testing.T) { }) } } + +func TestIsValidDNS1123Subdomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want bool + }{ + {"single char", "a", true}, + {"label-style name", "payments-api", true}, + {"63 chars label shape", strings.Repeat("a", 63), true}, + {"200 chars with dots", strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 8), true}, + {"dotted segments", "a.b.c", true}, + {"segment with digits", "build-1.pipeline-2", true}, + + {"empty rejected", "", false}, + {"uppercase rejected", "UPPER", false}, + {"leading dash rejected", "-leading", false}, + {"trailing dash rejected", "trailing-", false}, + {"leading dot rejected", ".leading", false}, + {"trailing dot rejected", "trailing.", false}, + {"underscore rejected", "has_underscore", false}, + {"254 chars over limit", strings.Repeat("a", 254), false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsValidDNS1123Subdomain(tc.in); got != tc.want { + t.Errorf("IsValidDNS1123Subdomain(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/output/output.go b/internal/output/output.go index 15f6221..d5d0d79 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -11,6 +11,7 @@ import ( "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/table" + "gopkg.in/yaml.v3" "github.com/KubeRocketCI/cli/internal/iostreams" "github.com/KubeRocketCI/cli/internal/portal" @@ -19,6 +20,7 @@ import ( const ( FormatTable = "table" FormatJSON = "json" + FormatYAML = "yaml" ) // ANSI color code used for borders and header accents. @@ -198,6 +200,15 @@ func PrintJSON(w io.Writer, v any) error { return enc.Encode(v) } +func PrintYAML(w io.Writer, v any) error { + enc := yaml.NewEncoder(w) + if err := enc.Encode(v); err != nil { + return err + } + + return enc.Close() +} + // JSONEnvelope wraps a success payload with the shared schemaVersion/data contract. // Used by commands that need a stable JSON output shape (e.g. `krci sonar *`). type JSONEnvelope struct { diff --git a/internal/portal/errors.go b/internal/portal/errors.go index fb919c4..968ead3 100644 --- a/internal/portal/errors.go +++ b/internal/portal/errors.go @@ -20,4 +20,41 @@ var ( // ErrEnvNotFound is returned when a Stage (env) lookup within a known // deployment fails. Wraps ErrNotFound similarly. ErrEnvNotFound = fmt.Errorf("environment %w", ErrNotFound) + + // ErrPipelineNotFound is returned by `pipelinerun start` when the named + // Tekton Pipeline does not exist. Wraps ErrNotFound for generic-not-found + // handling. + ErrPipelineNotFound = fmt.Errorf("pipeline %w", ErrNotFound) + + // ErrTriggerTemplateNotFound is returned by `pipelinerun start` when the + // Pipeline carries a TriggerTemplate label but the named TriggerTemplate + // does not exist. + ErrTriggerTemplateNotFound = fmt.Errorf("trigger template %w", ErrNotFound) + + // ErrPlatformReject is returned when the platform rejects the start + // request (e.g. missing required Pipeline param). + ErrPlatformReject = errors.New("platform rejected request") + + // ErrPermissionDenied is returned for HTTP 403 from the portal start + // endpoint. The message must not leak resource metadata. + ErrPermissionDenied = errors.New("permission denied") ) + +// richNotFoundError carries a user-facing message while still matching +// errors.Is(err, sentinel) via Unwrap. Use it when the platform supplies a +// disambiguating message that should be shown verbatim instead of the bare +// sentinel text. +type richNotFoundError struct { + msg string + sentinel error +} + +func (e *richNotFoundError) Error() string { return e.msg } +func (e *richNotFoundError) Unwrap() error { return e.sentinel } + +// sentinel must be ErrNotFound or a sentinel that wraps it (e.g. +// ErrPipelineNotFound) so generic not-found callers continue to match via +// errors.Is. +func newNotFoundErr(msg string, sentinel error) error { + return &richNotFoundError{msg: msg, sentinel: sentinel} +} diff --git a/internal/portal/openapi/spec.json b/internal/portal/openapi/spec.json index 38e4097..ef1e39d 100644 --- a/internal/portal/openapi/spec.json +++ b/internal/portal/openapi/spec.json @@ -514,6 +514,164 @@ } } }, + "/v1/pipelineruns/start": { + "post": { + "operationId": "pipelineRun-start", + "tags": [ + "pipelinerun" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 + }, + "pipeline": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 + }, + "params": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^([A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?)?$" + } + }, + "dryRun": { + "type": "boolean", + "default": false + } + }, + "required": [ + "namespace", + "pipeline" + ], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PipelineRunStartCreatedResponse" + }, + { + "$ref": "#/components/schemas/PipelineRunStartDryRunResponse" + } + ] + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "408": { + "description": "Kubernetes API request timed out", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.TIMEOUT" + } + } + } + }, + "409": { + "description": "Admission conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.CONFLICT" + } + } + } + }, + "422": { + "description": "Admission rejected the PipelineRun (e.g. missing required Pipeline param)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNPROCESSABLE_CONTENT" + } + } + } + }, + "429": { + "description": "Kubernetes API throttled the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.TOO_MANY_REQUESTS" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/v1/pipeline-runs/{resultUid}/logs": { "get": { "operationId": "tektonResults-getPipelineRunLogs", @@ -540,7 +698,9 @@ "name": "namespace", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 }, "required": true }, @@ -652,7 +812,9 @@ "name": "namespace", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 }, "required": true }, @@ -661,7 +823,9 @@ "name": "taskRunName", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 }, "required": true }, @@ -670,7 +834,8 @@ "name": "stepName", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*$" + "pattern": "^[a-z0-9][a-z0-9-]*$", + "maxLength": 63 } } ], @@ -682,9 +847,6 @@ "schema": { "type": "object", "properties": { - "taskName": { - "type": "string" - }, "taskRunName": { "type": "string" }, @@ -703,7 +865,6 @@ } }, "required": [ - "taskName", "taskRunName", "logs", "hasLogs", @@ -792,7 +953,9 @@ "name": "namespace", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 }, "required": true } @@ -878,7 +1041,9 @@ "name": "namespace", "schema": { "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$" + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 253 }, "required": true }, @@ -2034,213 +2199,430 @@ "schemas": { "error.INTERNAL_SERVER_ERROR": { "type": "object", + "title": "INTERNAL_SERVER_ERROR error envelope (500)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", + "required": [ + "error" + ], "properties": { - "message": { - "type": "string", - "description": "The error message", - "example": "Internal server error" - }, - "code": { - "type": "string", - "description": "The error code", - "example": "INTERNAL_SERVER_ERROR" - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "INTERNAL_SERVER_ERROR" }, - "required": [ - "message" - ] - }, - "description": "An array of issues that were responsible for the error", - "example": [] + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Internal Server Error" + } + } } }, + "example": { + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal Server Error" + } + } + }, + "error.UNAUTHORIZED": { + "type": "object", + "title": "UNAUTHORIZED error envelope (401)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", "required": [ - "message", - "code" + "error" ], - "title": "Internal server error error (500)", - "description": "The error information", + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "UNAUTHORIZED" + }, + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Unauthorized" + } + } + } + }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "Internal server error", - "issues": [] + "error": { + "code": "UNAUTHORIZED", + "message": "Unauthorized" + } } }, - "error.UNAUTHORIZED": { + "error.FORBIDDEN": { "type": "object", + "title": "FORBIDDEN error envelope (403)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", + "required": [ + "error" + ], "properties": { - "message": { - "type": "string", - "description": "The error message", - "example": "Authorization not provided" - }, - "code": { - "type": "string", - "description": "The error code", - "example": "UNAUTHORIZED" - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "FORBIDDEN" }, - "required": [ - "message" - ] - }, - "description": "An array of issues that were responsible for the error", - "example": [] + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Forbidden" + } + } } }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Forbidden" + } + } + }, + "error.BAD_REQUEST": { + "type": "object", + "title": "BAD_REQUEST error envelope (400)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", "required": [ - "message", - "code" + "error" ], - "title": "Authorization not provided error (401)", - "description": "The error information", + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "BAD_REQUEST" + }, + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Bad Request" + } + } + } + }, "example": { - "code": "UNAUTHORIZED", - "message": "Authorization not provided", - "issues": [] + "error": { + "code": "BAD_REQUEST", + "message": "Bad Request" + } } }, - "error.FORBIDDEN": { + "error.NOT_FOUND": { "type": "object", + "title": "NOT_FOUND error envelope (404)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", + "required": [ + "error" + ], "properties": { - "message": { - "type": "string", - "description": "The error message", - "example": "Insufficient access" - }, - "code": { - "type": "string", - "description": "The error code", - "example": "FORBIDDEN" - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "NOT_FOUND" }, - "required": [ - "message" - ] - }, - "description": "An array of issues that were responsible for the error", - "example": [] + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Not Found" + } + } } }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not Found" + } + } + }, + "error.TIMEOUT": { + "type": "object", + "title": "TIMEOUT error envelope (408)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", "required": [ - "message", - "code" + "error" ], - "title": "Insufficient access error (403)", - "description": "The error information", + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "TIMEOUT" + }, + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Request Timeout" + } + } + } + }, "example": { - "code": "FORBIDDEN", - "message": "Insufficient access", - "issues": [] + "error": { + "code": "TIMEOUT", + "message": "Request Timeout" + } } }, - "error.BAD_REQUEST": { + "error.CONFLICT": { "type": "object", + "title": "CONFLICT error envelope (409)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", + "required": [ + "error" + ], "properties": { - "message": { - "type": "string", - "description": "The error message", - "example": "Invalid input data" - }, - "code": { - "type": "string", - "description": "The error code", - "example": "BAD_REQUEST" - }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "CONFLICT" }, - "required": [ - "message" - ] - }, - "description": "An array of issues that were responsible for the error", - "example": [] + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Conflict" + } + } } }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Conflict" + } + } + }, + "error.UNPROCESSABLE_CONTENT": { + "type": "object", + "title": "UNPROCESSABLE_CONTENT error envelope (422)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", "required": [ - "message", - "code" + "error" ], - "title": "Invalid input data error (400)", - "description": "The error information", + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "UNPROCESSABLE_CONTENT" + }, + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Unprocessable Content" + } + } + } + }, "example": { - "code": "BAD_REQUEST", - "message": "Invalid input data", - "issues": [] + "error": { + "code": "UNPROCESSABLE_CONTENT", + "message": "Unprocessable Content" + } } }, - "error.NOT_FOUND": { + "error.TOO_MANY_REQUESTS": { "type": "object", + "title": "TOO_MANY_REQUESTS error envelope (429)", + "description": "REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message.", + "required": [ + "error" + ], "properties": { - "message": { - "type": "string", - "description": "The error message", - "example": "Not found" - }, - "code": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "tRPC error code (string literal)", + "example": "TOO_MANY_REQUESTS" + }, + "reason": { + "type": "string", + "description": "Stable machine-readable disambiguator (omitted when none applies)" + }, + "message": { + "type": "string", + "description": "Static HTTP status phrase", + "example": "Too Many Requests" + } + } + } + }, + "example": { + "error": { + "code": "TOO_MANY_REQUESTS", + "message": "Too Many Requests" + } + } + }, + "PipelineRunStartCreatedResponse": { + "type": "object", + "properties": { + "kind": { "type": "string", - "description": "The error code", - "example": "NOT_FOUND" + "enum": [ + "created" + ] }, - "issues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": { - "type": "string" - } + "row": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "required": [ - "message" - ] + "status": { + "type": "string" + }, + "project": { + "type": "string" + }, + "pr": { + "type": "string" + }, + "author": { + "type": "string" + }, + "type": { + "type": "string" + }, + "started": { + "type": "string" + }, + "duration": { + "type": "string" + } }, - "description": "An array of issues that were responsible for the error", - "example": [] + "required": [ + "name", + "status", + "project", + "pr", + "author", + "type", + "started", + "duration" + ], + "additionalProperties": false } }, "required": [ - "message", - "code" + "kind", + "row" ], - "title": "Not found error (404)", - "description": "The error information", - "example": { - "code": "NOT_FOUND", - "message": "Not found", - "issues": [] - } + "additionalProperties": false + }, + "PipelineRunStartDryRunResponse": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "dryRun" + ] + }, + "manifest": { + "type": "object", + "additionalProperties": {} + } + }, + "required": [ + "kind", + "manifest" + ], + "additionalProperties": false }, "TaskRunRecordsResponse": { "type": "object", diff --git a/internal/portal/restapi/api_gen.go b/internal/portal/restapi/api_gen.go index 8c974bb..3403b91 100644 --- a/internal/portal/restapi/api_gen.go +++ b/internal/portal/restapi/api_gen.go @@ -22,6 +22,16 @@ const ( BearerAuthScopes = "bearerAuth.Scopes" ) +// Defines values for PipelineRunStartCreatedResponseKind. +const ( + Created PipelineRunStartCreatedResponseKind = "created" +) + +// Defines values for PipelineRunStartDryRunResponseKind. +const ( + DryRun PipelineRunStartDryRunResponseKind = "dryRun" +) + // Defines values for SCAComponentsResponseStatus. const ( SCAComponentsResponseStatusNONE SCAComponentsResponseStatus = "NONE" @@ -149,6 +159,33 @@ const ( True SonarIssuesParamsAsc = "true" ) +// PipelineRunStartCreatedResponse defines model for PipelineRunStartCreatedResponse. +type PipelineRunStartCreatedResponse struct { + Kind PipelineRunStartCreatedResponseKind `json:"kind"` + Row struct { + Author string `json:"author"` + Duration string `json:"duration"` + Name string `json:"name"` + Pr string `json:"pr"` + Project string `json:"project"` + Started string `json:"started"` + Status string `json:"status"` + Type string `json:"type"` + } `json:"row"` +} + +// PipelineRunStartCreatedResponseKind defines model for PipelineRunStartCreatedResponse.Kind. +type PipelineRunStartCreatedResponseKind string + +// PipelineRunStartDryRunResponse defines model for PipelineRunStartDryRunResponse. +type PipelineRunStartDryRunResponse struct { + Kind PipelineRunStartDryRunResponseKind `json:"kind"` + Manifest map[string]interface{} `json:"manifest"` +} + +// PipelineRunStartDryRunResponseKind defines model for PipelineRunStartDryRunResponse.Kind. +type PipelineRunStartDryRunResponseKind string + // SCAComponent defines model for SCAComponent. type SCAComponent struct { Group *string `json:"group,omitempty"` @@ -501,74 +538,130 @@ type TaskRunStep struct { } `json:"waiting,omitempty"` } -// ErrorBADREQUEST The error information +// ErrorBADREQUEST REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. type ErrorBADREQUEST struct { - // Code The error code - Code string `json:"code"` + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` - // Issues An array of issues that were responsible for the error - Issues *[]struct { + // Message Static HTTP status phrase Message string `json:"message"` - } `json:"issues,omitempty"` - // Message The error message - Message string `json:"message"` + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` } -// ErrorFORBIDDEN The error information +// ErrorCONFLICT REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. +type ErrorCONFLICT struct { + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase + Message string `json:"message"` + + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` +} + +// ErrorFORBIDDEN REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. type ErrorFORBIDDEN struct { - // Code The error code - Code string `json:"code"` + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` - // Issues An array of issues that were responsible for the error - Issues *[]struct { + // Message Static HTTP status phrase Message string `json:"message"` - } `json:"issues,omitempty"` - // Message The error message - Message string `json:"message"` + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` } -// ErrorINTERNALSERVERERROR The error information +// ErrorINTERNALSERVERERROR REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. type ErrorINTERNALSERVERERROR struct { - // Code The error code - Code string `json:"code"` + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` - // Issues An array of issues that were responsible for the error - Issues *[]struct { + // Message Static HTTP status phrase Message string `json:"message"` - } `json:"issues,omitempty"` - // Message The error message - Message string `json:"message"` + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` } -// ErrorNOTFOUND The error information +// ErrorNOTFOUND REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. type ErrorNOTFOUND struct { - // Code The error code - Code string `json:"code"` + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase + Message string `json:"message"` - // Issues An array of issues that were responsible for the error - Issues *[]struct { + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` +} + +// ErrorTIMEOUT REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. +type ErrorTIMEOUT struct { + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase Message string `json:"message"` - } `json:"issues,omitempty"` - // Message The error message - Message string `json:"message"` + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` } -// ErrorUNAUTHORIZED The error information +// ErrorTOOMANYREQUESTS REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. +type ErrorTOOMANYREQUESTS struct { + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase + Message string `json:"message"` + + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` +} + +// ErrorUNAUTHORIZED REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. type ErrorUNAUTHORIZED struct { - // Code The error code - Code string `json:"code"` + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase + Message string `json:"message"` + + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` +} - // Issues An array of issues that were responsible for the error - Issues *[]struct { +// ErrorUNPROCESSABLECONTENT REST error envelope emitted by handleTRPCError. `message` is the static HTTP status phrase; `reason` (when present) is a stable machine-readable disambiguator that consumers should key off rather than parsing the human message. +type ErrorUNPROCESSABLECONTENT struct { + Error struct { + // Code tRPC error code (string literal) + Code string `json:"code"` + + // Message Static HTTP status phrase Message string `json:"message"` - } `json:"issues,omitempty"` - // Message The error message - Message string `json:"message"` + // Reason Stable machine-readable disambiguator (omitted when none applies) + Reason *string `json:"reason,omitempty"` + } `json:"error"` } // TektonResultsGetPipelineRunResultsParams defines parameters for TektonResultsGetPipelineRunResults. @@ -590,6 +683,15 @@ type TektonResultsGetTaskRunRecordsParams struct { Namespace string `form:"namespace" json:"namespace"` } +// PipelineRunStartJSONBody defines parameters for PipelineRunStart. +type PipelineRunStartJSONBody struct { + DryRun *bool `json:"dryRun,omitempty"` + Labels *map[string]string `json:"labels,omitempty"` + Namespace string `json:"namespace"` + Params *map[string]string `json:"params,omitempty"` + Pipeline string `json:"pipeline"` +} + // K8sGetJSONBody defines parameters for K8sGet. type K8sGetJSONBody struct { ClusterName string `json:"clusterName"` @@ -727,6 +829,9 @@ type TektonResultsGetTaskRunLogsParams struct { StepName *string `form:"stepName,omitempty" json:"stepName,omitempty"` } +// PipelineRunStartJSONRequestBody defines body for PipelineRunStart for application/json ContentType. +type PipelineRunStartJSONRequestBody PipelineRunStartJSONBody + // K8sGetJSONRequestBody defines body for K8sGet for application/json ContentType. type K8sGetJSONRequestBody K8sGetJSONBody @@ -821,6 +926,11 @@ type ClientInterface interface { // TektonResultsGetTaskRunRecords request TektonResultsGetTaskRunRecords(ctx context.Context, resultUid openapi_types.UUID, params *TektonResultsGetTaskRunRecordsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // PipelineRunStartWithBody request with any body + PipelineRunStartWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PipelineRunStart(ctx context.Context, body PipelineRunStartJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // K8sGetWithBody request with any body K8sGetWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -919,6 +1029,30 @@ func (c *Client) TektonResultsGetTaskRunRecords(ctx context.Context, resultUid o return c.Client.Do(req) } +func (c *Client) PipelineRunStartWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPipelineRunStartRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PipelineRunStart(ctx context.Context, body PipelineRunStartJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPipelineRunStartRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) K8sGetWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewK8sGetRequestWithBody(c.Server, contentType, body) if err != nil { @@ -1338,6 +1472,46 @@ func NewTektonResultsGetTaskRunRecordsRequest(server string, resultUid openapi_t return req, nil } +// NewPipelineRunStartRequest calls the generic PipelineRunStart builder with application/json body +func NewPipelineRunStartRequest(server string, body PipelineRunStartJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPipelineRunStartRequestWithBody(server, "application/json", bodyReader) +} + +// NewPipelineRunStartRequestWithBody generates requests for PipelineRunStart with any type of body +func NewPipelineRunStartRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/pipelineruns/start") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewK8sGetRequest calls the generic K8sGet builder with application/json body func NewK8sGetRequest(server string, body K8sGetJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -2420,6 +2594,11 @@ type ClientWithResponsesInterface interface { // TektonResultsGetTaskRunRecordsWithResponse request TektonResultsGetTaskRunRecordsWithResponse(ctx context.Context, resultUid openapi_types.UUID, params *TektonResultsGetTaskRunRecordsParams, reqEditors ...RequestEditorFn) (*TektonResultsGetTaskRunRecordsResponse, error) + // PipelineRunStartWithBodyWithResponse request with any body + PipelineRunStartWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PipelineRunStartResponse, error) + + PipelineRunStartWithResponse(ctx context.Context, body PipelineRunStartJSONRequestBody, reqEditors ...RequestEditorFn) (*PipelineRunStartResponse, error) + // K8sGetWithBodyWithResponse request with any body K8sGetWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*K8sGetResponse, error) @@ -2615,6 +2794,38 @@ func (r TektonResultsGetTaskRunRecordsResponse) StatusCode() int { return 0 } +type PipelineRunStartResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + union json.RawMessage + } + JSON400 *ErrorBADREQUEST + JSON401 *ErrorUNAUTHORIZED + JSON403 *ErrorFORBIDDEN + JSON408 *ErrorTIMEOUT + JSON409 *ErrorCONFLICT + JSON422 *ErrorUNPROCESSABLECONTENT + JSON429 *ErrorTOOMANYREQUESTS + JSON500 *ErrorINTERNALSERVERERROR +} + +// Status returns HTTPResponse.Status +func (r PipelineRunStartResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PipelineRunStartResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type K8sGetResponse struct { Body []byte HTTPResponse *http.Response @@ -2932,7 +3143,6 @@ type TektonResultsGetTaskRunLogsResponse struct { HasLogs bool `json:"hasLogs"` Logs string `json:"logs"` StepFiltered *bool `json:"stepFiltered,omitempty"` - TaskName string `json:"taskName"` TaskRunName string `json:"taskRunName"` } JSON400 *ErrorBADREQUEST @@ -3003,6 +3213,23 @@ func (c *ClientWithResponses) TektonResultsGetTaskRunRecordsWithResponse(ctx con return ParseTektonResultsGetTaskRunRecordsResponse(rsp) } +// PipelineRunStartWithBodyWithResponse request with arbitrary body returning *PipelineRunStartResponse +func (c *ClientWithResponses) PipelineRunStartWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PipelineRunStartResponse, error) { + rsp, err := c.PipelineRunStartWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePipelineRunStartResponse(rsp) +} + +func (c *ClientWithResponses) PipelineRunStartWithResponse(ctx context.Context, body PipelineRunStartJSONRequestBody, reqEditors ...RequestEditorFn) (*PipelineRunStartResponse, error) { + rsp, err := c.PipelineRunStart(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePipelineRunStartResponse(rsp) +} + // K8sGetWithBodyWithResponse request with arbitrary body returning *K8sGetResponse func (c *ClientWithResponses) K8sGetWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*K8sGetResponse, error) { rsp, err := c.K8sGetWithBody(ctx, contentType, body, reqEditors...) @@ -3408,6 +3635,90 @@ func ParseTektonResultsGetTaskRunRecordsResponse(rsp *http.Response) (*TektonRes return response, nil } +// ParsePipelineRunStartResponse parses an HTTP response from a PipelineRunStartWithResponse call +func ParsePipelineRunStartResponse(rsp *http.Response) (*PipelineRunStartResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PipelineRunStartResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + union json.RawMessage + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorBADREQUEST + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorUNAUTHORIZED + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorFORBIDDEN + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 408: + var dest ErrorTIMEOUT + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON408 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorCONFLICT + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorUNPROCESSABLECONTENT + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest ErrorTOOMANYREQUESTS + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorINTERNALSERVERERROR + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseK8sGetResponse parses an HTTP response from a K8sGetWithResponse call func ParseK8sGetResponse(rsp *http.Response) (*K8sGetResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4057,7 +4368,6 @@ func ParseTektonResultsGetTaskRunLogsResponse(rsp *http.Response) (*TektonResult HasLogs bool `json:"hasLogs"` Logs string `json:"logs"` StepFiltered *bool `json:"stepFiltered,omitempty"` - TaskName string `json:"taskName"` TaskRunName string `json:"taskRunName"` } if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/internal/portal/sca.go b/internal/portal/sca.go index 613d798..e88a686 100644 --- a/internal/portal/sca.go +++ b/internal/portal/sca.go @@ -188,15 +188,6 @@ func checkSCAResponse(statusCode int, body []byte) error { return checkResponse(statusCode, body) } -// scaNotFoundError wraps ErrNotFound with a user-facing message. Unlike a -// plain fmt.Errorf(...: %w, ErrNotFound), its Error() omits the sentinel -// "resource not found" suffix so the CLI emits only the rich disambiguation -// message. errors.Is(err, ErrNotFound) still matches via Unwrap. -type scaNotFoundError struct{ msg string } - -func (e *scaNotFoundError) Error() string { return e.msg } -func (e *scaNotFoundError) Unwrap() error { return ErrNotFound } - // scaBranchNotFoundErr decodes the 404 body returned by the resolveBranch // helper on the Portal side. The body distinguishes `codebase_not_found` vs // `default_branch_missing`; the CLI surfaces a matching human-readable message @@ -214,14 +205,15 @@ func scaBranchNotFoundErr(err error, body []byte, codebase, branch string) error bodyLower := strings.ToLower(string(body)) switch { case branch != "": - return &scaNotFoundError{msg: fmt.Sprintf("project %s not found", codebase)} + return newNotFoundErr(fmt.Sprintf("project %s not found", codebase), ErrNotFound) case strings.Contains(bodyLower, "default_branch_missing"): - return &scaNotFoundError{msg: fmt.Sprintf( - "project %s has no spec.defaultBranch configured — pass --branch explicitly", codebase)} + return newNotFoundErr(fmt.Sprintf( + "project %s has no spec.defaultBranch configured — pass --branch explicitly", codebase), + ErrNotFound) default: - return &scaNotFoundError{msg: fmt.Sprintf( + return newNotFoundErr(fmt.Sprintf( "project %s not found — use 'krci sca list --search=%s' to find projects known to Dep-Track", - codebase, codebase)} + codebase, codebase), ErrNotFound) } } diff --git a/internal/portal/start.go b/internal/portal/start.go new file mode 100644 index 0000000..6ff6616 --- /dev/null +++ b/internal/portal/start.go @@ -0,0 +1,226 @@ +package portal + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/internal/ptr" +) + +type StartInput struct { + Pipeline string // DNS-1123 subdomain name of the Tekton Pipeline (max 253 chars) + Params map[string]string // user-supplied parameter overrides (may be nil) + Labels map[string]string // labels to attach to metadata.labels (may be nil) + DryRun bool // true → render manifest without create +} + +// StartResult is the row shape returned by `pipelinerun start`. Mirrors the +// `pipelinerun list` columns exactly so users pivot directly between the two +// verbs. +type StartResult struct { + Name string `json:"name"` + Status string `json:"status"` + Project string `json:"project"` + PR string `json:"pr"` + Author string `json:"author"` + Type string `json:"type"` + Started string `json:"started"` + Duration string `json:"duration"` + + // DryRunManifest is the rendered PipelineRun resource emitted when + // DryRun=true; nil on the live-create path. + DryRunManifest map[string]any `json:"dryRunManifest,omitempty"` +} + +// Stable machine-readable error reasons surfaced by the Portal under +// `error.reason` (see `apps/server/src/config/openapi.ts handleTRPCError`). +// The Portal deliberately does not put resource-identifying text in +// `error.message` — `message` is always the static HTTP status phrase. The +// CLI must therefore key error mapping off `reason`, not the message. +// +// `reason=pipeline_not_found` and an absent reason both fall through to the +// default pipeline-not-found branch, so no constant is declared for it. +const ( + reasonTriggerTemplateNotFound = "trigger_template_not_found" + reasonMalformedTTLabel = "malformed_trigger_template_label" +) + +// Discriminator values for the start-response oneOf body. +const ( + startKindCreated = "created" + startKindDryRun = "dryRun" +) + +type PipelineRunStartService struct { + client *restapi.ClientWithResponses + namespace string +} + +func NewPipelineRunStartService(client *restapi.ClientWithResponses, namespace string) *PipelineRunStartService { + return &PipelineRunStartService{client: client, namespace: namespace} +} + +// Start calls `POST /rest/v1/pipelineruns/start`. +// +// Error mapping (Portal stable-reason contract): +// - 200: returns *StartResult +// - 400 reason=malformed_trigger_template_label: ErrPlatformReject (synthesised message) +// - 400 default: ErrPlatformReject (generic — Portal hardening strips K8s admission detail) +// - 401: ErrUnauthorized +// - 403: ErrPermissionDenied (no resource metadata leak) +// - 404 reason=trigger_template_not_found: ErrTriggerTemplateNotFound +// - 404 default (incl. reason=pipeline_not_found): ErrPipelineNotFound +// - 408/409/422/429: ErrPlatformReject (K8s admission classes; 422 covers +// the common "missing required Pipeline param" case) +// - 5xx: ErrUpstreamUnavailable +func (s *PipelineRunStartService) Start(ctx context.Context, in StartInput) (*StartResult, error) { + body := restapi.PipelineRunStartJSONRequestBody{ + Namespace: s.namespace, + Pipeline: in.Pipeline, + } + + if len(in.Params) > 0 { + body.Params = ptr.To(in.Params) + } + + if len(in.Labels) > 0 { + body.Labels = ptr.To(in.Labels) + } + + if in.DryRun { + body.DryRun = ptr.To(true) + } + + resp, err := s.client.PipelineRunStartWithResponse(ctx, body) + if err != nil { + return nil, fmt.Errorf("calling pipelinerun start: %w", err) + } + + if err := checkStartResponse(resp.StatusCode(), resp.Body, in.Pipeline); err != nil { + return nil, err + } + + return decodeStartBody(resp.Body) +} + +// checkStartResponse extends checkResponse with start-specific status mapping. +// Uses error.reason (not error.message) — Portal's handleTRPCError replaces +// message with the static HTTP phrase, stripping all resource names. +func checkStartResponse(statusCode int, body []byte, pipeline string) error { + switch statusCode { + case http.StatusBadRequest: + reason, message := parseErrorEnvelope(body) + + if reason == reasonMalformedTTLabel { + return fmt.Errorf("%w: pipeline '%s' has malformed TriggerTemplate label", + ErrPlatformReject, pipeline) + } + // Portal strips K8s admission messages. Surface the generic + // status phrase so the user knows to inspect the Pipeline + // definition for missing required params or other admission-time + // errors. + return fmt.Errorf("%w: %s", ErrPlatformReject, + cmp.Or(message, http.StatusText(http.StatusBadRequest))) + case http.StatusForbidden: + return ErrPermissionDenied + case http.StatusNotFound: + reason, _ := parseErrorEnvelope(body) + if reason == reasonTriggerTemplateNotFound { + return newNotFoundErr( + fmt.Sprintf("pipeline '%s' references a TriggerTemplate that does not exist", pipeline), + ErrTriggerTemplateNotFound, + ) + } + + // reason=pipeline_not_found OR reason absent (e.g. plain-text 404 + // from a misbehaving proxy): default to pipeline-not-found with a + // synthesised message. The pipeline name is known client-side, so we + // never need the Portal to echo it. + return newNotFoundErr( + fmt.Sprintf("pipeline '%s' not found", pipeline), + ErrPipelineNotFound, + ) + case http.StatusRequestTimeout, http.StatusConflict, + http.StatusUnprocessableEntity, http.StatusTooManyRequests: + // K8s admission rejection. Portal's handleK8sError forwards + // these without a stable reason tag, so we discriminate on + // status code alone and surface the static HTTP phrase. + _, message := parseErrorEnvelope(body) + return fmt.Errorf("%w: %s", ErrPlatformReject, + cmp.Or(message, http.StatusText(statusCode))) + case http.StatusBadGateway, http.StatusServiceUnavailable, + http.StatusInternalServerError, http.StatusGatewayTimeout: + return fmt.Errorf("%w: %s", ErrUpstreamUnavailable, truncateBody(body)) + } + + return checkResponse(statusCode, body) +} + +// parseErrorEnvelope reads error.reason and the user-facing message from a +// Portal error body. Returns ("", "") on parse failure. message prefers +// error.message, falling back to top-level message. +func parseErrorEnvelope(body []byte) (reason, message string) { + var env struct { + Error struct { + Reason string `json:"reason"` + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + + if err := json.Unmarshal(body, &env); err != nil { + return "", "" + } + + message = env.Error.Message + if message == "" { + message = env.Message + } + + return env.Error.Reason, message +} + +// decodeStartBody projects the discriminated-union 200 body into the flat +// StartResult. The portal procedure returns one of: +// +// {"kind":"created","row":{...row fields...}} +// {"kind":"dryRun","manifest":{...PipelineRun resource...}} +// +// oapi-codegen does not emit usable accessors for the `oneOf` (the union field +// is unexported), so we discriminate on `kind` against the raw response body. +func decodeStartBody(body []byte) (*StartResult, error) { + var env struct { + Kind string `json:"kind"` + Row json.RawMessage `json:"row"` + Manifest json.RawMessage `json:"manifest"` + } + + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start response: %w", err) + } + + switch env.Kind { + case startKindCreated: + var row StartResult + if err := json.Unmarshal(env.Row, &row); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start created row: %w", err) + } + + return &row, nil + + case startKindDryRun: + var manifest map[string]any + if err := json.Unmarshal(env.Manifest, &manifest); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start dry-run manifest: %w", err) + } + + return &StartResult{DryRunManifest: manifest}, nil + + default: + return nil, fmt.Errorf("unexpected pipelinerun start response kind: %q", env.Kind) + } +} diff --git a/internal/portal/start_test.go b/internal/portal/start_test.go new file mode 100644 index 0000000..1504a8b --- /dev/null +++ b/internal/portal/start_test.go @@ -0,0 +1,504 @@ +package portal + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +func newStartService(t *testing.T, handler http.HandlerFunc) (*PipelineRunStartService, func()) { + t.Helper() + + client, closer := newTestClient(t, handler) + + return NewPipelineRunStartService(client, "edp"), closer +} + +func TestStartService_Start_Success(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/v1/pipelineruns/start" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + + if r.Method != http.MethodPost { + t.Fatalf("unexpected method: %s", r.Method) + } + + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"pipeline":"foo-build"`) { + t.Fatalf("body missing pipeline: %s", body) + } + + if !strings.Contains(string(body), `"namespace":"edp"`) { + t.Fatalf("body missing namespace: %s", body) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + _, _ = w.Write([]byte(`{ + "kind": "created", + "row": { + "name": "foo-build-run-x9k2p", + "status": "Pending", + "project": "", + "pr": "", + "author": "", + "type": "build", + "started": "", + "duration": "" + } + }`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + got, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if err != nil { + t.Fatalf("Start: %v", err) + } + + if got.Name != "foo-build-run-x9k2p" { + t.Fatalf("name = %q", got.Name) + } + + if got.Status != "Pending" || got.Type != "build" { + t.Fatalf("unexpected fields: %+v", got) + } + + if len(got.DryRunManifest) != 0 { + t.Fatalf("dry-run manifest should be empty on live path: %v", got.DryRunManifest) + } +} + +func TestStartService_Start_ParamsAndLabels_InBody(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + var got struct { + Params map[string]string `json:"params"` + Labels map[string]string `json:"labels"` + } + + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Params["git-revision"] != "main" { + t.Errorf("params not forwarded: %+v", got.Params) + } + + if got.Labels["app.edp.epam.com/codebase"] != "my-app" { + t.Errorf("labels not forwarded: %+v", got.Labels) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "kind": "created", + "row": { + "name": "foo-build-run-abcde", + "status": "Pending", + "project": "my-app", + "pr": "", + "author": "", + "type": "build", + "started": "", + "duration": "" + } + }`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{ + Pipeline: "foo-build", + Params: map[string]string{"git-revision": "main"}, + Labels: map[string]string{"app.edp.epam.com/codebase": "my-app"}, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } +} + +func TestStartService_Start_DryRun(t *testing.T) { + t.Parallel() + + // Portal returns the rendered draft as a JSON object (procedures/start/index.ts); + // transport parses it at the wire boundary so the CLI receives a map. + manifest := map[string]any{ + "apiVersion": "tekton.dev/v1", + "kind": "PipelineRun", + "metadata": map[string]any{ + "generateName": "foo-build-run-", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + if !strings.Contains(string(body), `"dryRun":true`) { + t.Fatalf("dryRun not forwarded: %s", body) + } + + w.Header().Set("Content-Type", "application/json") + + payload := map[string]any{ + "kind": "dryRun", + "manifest": manifest, + } + + _ = json.NewEncoder(w).Encode(payload) + } + + svc, closer := newStartService(t, handler) + defer closer() + + got, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build", DryRun: true}) + if err != nil { + t.Fatalf("Start: %v", err) + } + + if got.DryRunManifest["kind"] != "PipelineRun" { + t.Fatalf("dry-run manifest kind mismatch: %v", got.DryRunManifest["kind"]) + } + + metadata, ok := got.DryRunManifest["metadata"].(map[string]any) + if !ok { + t.Fatalf("dry-run metadata not parsed as object: %T", got.DryRunManifest["metadata"]) + } + + if metadata["generateName"] != "foo-build-run-" { + t.Fatalf("dry-run generateName mismatch: %v", metadata["generateName"]) + } + + if got.Name != "" { + t.Fatalf("name should be empty on dry-run: %q", got.Name) + } +} + +func TestStartService_Start_PipelineNotFound(t *testing.T) { + t.Parallel() + + // Portal sends the static HTTP status phrase as `error.message` and + // communicates the failure mode via the stable `error.reason` tag. + // Asserts: never expect the pipeline name in `error.message`. + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"code":"NOT_FOUND","reason":"pipeline_not_found","message":"Not Found"}}`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "ghost"}) + if !errors.Is(err, ErrPipelineNotFound) { + t.Fatalf("want ErrPipelineNotFound, got %v", err) + } + + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound match too, got %v", err) + } + + // CLI synthesises the user-facing message from the pipeline name + // (which the caller already has) plus the reason tag. + want := `pipeline 'ghost' not found` + if err.Error() != want { + t.Fatalf("want exact synthesised message %q, got: %v", want, err) + } +} + +func TestStartService_Start_PipelineNotFound_NoReasonOnPlainText(t *testing.T) { + t.Parallel() + + // A plain-text 404 from a misbehaving proxy carries no reason tag. + // The CLI defaults to pipeline-not-found with the synthesised message — + // pipeline names always come from the caller, never the body. + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("plain text 404 from a misbehaving proxy")) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "ghost"}) + if !errors.Is(err, ErrPipelineNotFound) { + t.Fatalf("want ErrPipelineNotFound, got %v", err) + } + + if !strings.Contains(err.Error(), `pipeline 'ghost' not found`) { + t.Fatalf("expected synthesised message, got: %v", err) + } +} + +func TestStartService_Start_TriggerTemplateNotFound(t *testing.T) { + t.Parallel() + + // Trigger-template-not-found path. Portal communicates the failure via + // `reason`, not the message. The TriggerTemplate name is intentionally + // NOT echoed (Portal hardening policy) — the CLI's synthesised message + // therefore omits it. + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"code":"NOT_FOUND","reason":"trigger_template_not_found","message":"Not Found"}}`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrTriggerTemplateNotFound) { + t.Fatalf("want ErrTriggerTemplateNotFound, got %v", err) + } + + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound match too, got %v", err) + } + + want := `pipeline 'foo-build' references a TriggerTemplate that does not exist` + if err.Error() != want { + t.Fatalf("want exact synthesised message %q, got: %v", want, err) + } +} + +func TestStartService_Start_MalformedTriggerTemplateLabel_400(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"BAD_REQUEST","reason":"malformed_trigger_template_label","message":"Bad Request"}}`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrPlatformReject) { + t.Fatalf("want ErrPlatformReject, got %v", err) + } + + if !strings.Contains(err.Error(), "malformed TriggerTemplate label") { + t.Fatalf("expected synthesised malformed-label message, got: %v", err) + } +} + +func TestStartService_Start_PlatformReject_400_NoReason(t *testing.T) { + t.Parallel() + + // 400 with no reason tag → ErrPlatformReject + static HTTP phrase. + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"BAD_REQUEST","message":"Bad Request"}}`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrPlatformReject) { + t.Fatalf("want ErrPlatformReject, got %v", err) + } + + if !strings.Contains(err.Error(), "Bad Request") { + t.Fatalf("expected status phrase in error, got: %v", err) + } +} + +func TestStartService_Start_PlatformReject_422(t *testing.T) { + t.Parallel() + + // 422 from the apiserver → ErrPlatformReject + static HTTP phrase. + handler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":{"code":"UNPROCESSABLE_CONTENT","message":"Unprocessable Entity"}}`)) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrPlatformReject) { + t.Fatalf("want ErrPlatformReject, got %v", err) + } + + if !strings.Contains(err.Error(), "Unprocessable Entity") { + t.Fatalf("expected status phrase in error, got: %v", err) + } +} + +func TestStartService_Start_PlatformReject_AdmissionStatuses(t *testing.T) { + t.Parallel() + + // 408/409/429 follow the same pattern as 422: handleK8sError forwards + // these K8s admission classes without a stable reason tag, and the CLI + // surfaces ErrPlatformReject plus the static HTTP phrase. + cases := []struct { + code int + envCode string + }{ + {http.StatusRequestTimeout, "TIMEOUT"}, + {http.StatusConflict, "CONFLICT"}, + {http.StatusTooManyRequests, "TOO_MANY_REQUESTS"}, + } + + for _, tc := range cases { + t.Run(http.StatusText(tc.code), func(t *testing.T) { + t.Parallel() + + body := fmt.Sprintf(`{"error":{"code":%q,"message":%q}}`, tc.envCode, http.StatusText(tc.code)) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.code) + _, _ = w.Write([]byte(body)) + }) + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrPlatformReject) { + t.Fatalf("status %d: want ErrPlatformReject, got %v", tc.code, err) + } + + if !strings.Contains(err.Error(), http.StatusText(tc.code)) { + t.Fatalf("status %d: expected status phrase %q in error, got: %v", + tc.code, http.StatusText(tc.code), err) + } + }) + } +} + +func TestStartService_Start_RBAC_403(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("want ErrPermissionDenied, got %v", err) + } +} + +func TestStartService_Start_Unauthorized_401(t *testing.T) { + t.Parallel() + + handler := func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + } + + svc, closer := newStartService(t, handler) + defer closer() + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("want ErrUnauthorized, got %v", err) + } +} + +func TestStartService_Start_Upstream_5xx(t *testing.T) { + t.Parallel() + + for _, code := range []int{ + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } { + t.Run(http.StatusText(code), func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + _, _ = w.Write([]byte("portal exploded")) + }) + + client, closer := newTestClient(t, handler) + defer closer() + + svc := NewPipelineRunStartService(client, "edp") + + _, err := svc.Start(context.Background(), StartInput{Pipeline: "foo-build"}) + if !errors.Is(err, ErrUpstreamUnavailable) { + t.Fatalf("status %d: want ErrUpstreamUnavailable, got %v", code, err) + } + }) + } +} + +func TestParseErrorEnvelope_Reason(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + want string + }{ + {"with reason", `{"error":{"code":"NOT_FOUND","reason":"pipeline_not_found","message":"Not Found"}}`, "pipeline_not_found"}, + {"no reason field", `{"error":{"code":"NOT_FOUND","message":"Not Found"}}`, ""}, + {"empty body", "", ""}, + {"plain text body", "Not Found", ""}, + {"malformed JSON", `{not json`, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got, _ := parseErrorEnvelope([]byte(tc.body)); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestParseErrorEnvelope_Message(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + fallback string + want string + }{ + {"with nested message", `{"error":{"message":"boom"}}`, "fb", "boom"}, + {"no message", `{"error":{"code":"X"}}`, "fb", "fb"}, + {"empty message falls back", `{"error":{"message":""}}`, "fb", "fb"}, + {"empty body", "", "fb", "fb"}, + {"top-level message", `{"message":"top"}`, "fb", "top"}, + {"malformed JSON falls back", `{not json`, "fb", "fb"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, msg := parseErrorEnvelope([]byte(tc.body)) + if got := cmp.Or(msg, tc.fallback); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/pkg/cmd/pipelinerun/get/get.go b/pkg/cmd/pipelinerun/get/get.go index 0a02030..3612f32 100644 --- a/pkg/cmd/pipelinerun/get/get.go +++ b/pkg/cmd/pipelinerun/get/get.go @@ -15,6 +15,7 @@ import ( "github.com/KubeRocketCI/cli/internal/output" "github.com/KubeRocketCI/cli/internal/portal" "github.com/KubeRocketCI/cli/internal/portal/restapi" + pipelineruninternal "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/internal" ) // GetOptions holds all inputs for the pipelinerun get command. @@ -91,11 +92,7 @@ func getRun(ctx context.Context, opts *GetOptions) error { return fmt.Errorf("pipeline run %q not found", opts.Name) } - if errors.Is(err, portal.ErrUnauthorized) { - return cmdutil.ErrAuthRequired(err) - } - - return err + return pipelineruninternal.HandleAuthError(err) } if opts.IncludeReason { diff --git a/pkg/cmd/pipelinerun/internal/columns.go b/pkg/cmd/pipelinerun/internal/columns.go new file mode 100644 index 0000000..a41c98f --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/columns.go @@ -0,0 +1,12 @@ +// Package pipelineruninternal holds helpers shared by `krci pipelinerun` verbs +// (validation, shared column headers, key=value parsing). +package pipelineruninternal + +// SchemaVersion is the JSON envelope schema tag for pipelinerun command output. +// Matches the per-group pattern used by sonar, sca, and discovery. +const SchemaVersion = "1" + +// Headers is the shared column set for `pipelinerun list` and `pipelinerun +// start`. The two verbs must emit byte-identical headers; a header-equality +// regression test enforces parity. +var Headers = []string{"NAME", "STATUS", "PROJECT", "PR", "AUTHOR", "TYPE", "STARTED", "DURATION"} diff --git a/pkg/cmd/pipelinerun/internal/errors.go b/pkg/cmd/pipelinerun/internal/errors.go new file mode 100644 index 0000000..62c839d --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/errors.go @@ -0,0 +1,18 @@ +package pipelineruninternal + +import ( + "errors" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/portal" +) + +// HandleAuthError maps portal.ErrUnauthorized to the auth-required remediation +// hint; other errors pass through unchanged. +func HandleAuthError(err error) error { + if errors.Is(err, portal.ErrUnauthorized) { + return cmdutil.ErrAuthRequired(err) + } + + return err +} diff --git a/pkg/cmd/pipelinerun/internal/errors_test.go b/pkg/cmd/pipelinerun/internal/errors_test.go new file mode 100644 index 0000000..04f0dc2 --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/errors_test.go @@ -0,0 +1,42 @@ +package pipelineruninternal + +import ( + "errors" + "testing" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/portal" +) + +func TestHandleAuthError_AddsAuthHintForUnauthorized(t *testing.T) { + t.Parallel() + + mapped := HandleAuthError(portal.ErrUnauthorized) + if !errors.Is(mapped, portal.ErrUnauthorized) { + t.Fatalf("auth wrap must preserve errors.Is chain to ErrUnauthorized; got %v", mapped) + } + + want := cmdutil.ErrAuthRequired(portal.ErrUnauthorized).Error() + if mapped.Error() != want { + t.Errorf("auth-required message mismatch:\n got %q\n want %q", mapped.Error(), want) + } +} + +func TestHandleAuthError_PassesThroughOtherErrors(t *testing.T) { + t.Parallel() + + cases := []error{ + portal.ErrPipelineNotFound, + portal.ErrTriggerTemplateNotFound, + portal.ErrUpstreamUnavailable, + portal.ErrPlatformReject, + portal.ErrPermissionDenied, + errors.New("boom"), + } + + for _, in := range cases { + if got := HandleAuthError(in); got != in { + t.Errorf("HandleAuthError(%v) should pass through unchanged, got %v", in, got) + } + } +} diff --git a/pkg/cmd/pipelinerun/internal/validate.go b/pkg/cmd/pipelinerun/internal/validate.go new file mode 100644 index 0000000..c54cd50 --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/validate.go @@ -0,0 +1,100 @@ +package pipelineruninternal + +import ( + "fmt" + "strings" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/output" +) + +// Kinds for ParseKeyValueList error messages. +const ( + KindParameter = "parameter" + KindLabel = "label" +) + +// ValidatePipelineName returns an error when name does not match the DNS-1123 +// subdomain shape. Tekton Pipeline objects follow Kubernetes resource-name +// conventions, which allow up to 253 chars (subdomain shape), not the tighter +// 63-char label ceiling used for codebase/sonar names. +func ValidatePipelineName(name string) error { + if name == "" { + return fmt.Errorf(" must not be empty") + } + + if !cmdutil.IsValidDNS1123Subdomain(name) { + return fmt.Errorf( + " must be a valid DNS-1123 subdomain (max 253 chars, lowercase alphanumeric, '-' and '.')") + } + + return nil +} + +// ValidateOutputAndDryRun enforces the joint contract between -o and --dry-run. +// +// - "", "table", "json" → ok (table only when not dry-run). +// - "yaml" → ok only when --dry-run is set. +// - --dry-run + "table" → rejected (no row to render for a manifest). +// - any other -o value → rejected as unknown. +func ValidateOutputAndDryRun(format string, dryRun bool) error { + switch format { + case "", output.FormatJSON: + return nil + case output.FormatTable: + if dryRun { + return fmt.Errorf("--dry-run cannot use -o table (use -o json or -o yaml)") + } + + return nil + case output.FormatYAML: + if !dryRun { + return fmt.Errorf("-o yaml requires --dry-run") + } + + return nil + default: + return fmt.Errorf("unknown output format: %s (use 'json', 'yaml', or 'table')", format) + } +} + +// ParseKeyValueList parses a list of `--param key=value` (or `--label key=value`) +// strings into a map. +// +// Rules: +// - Split on the first `=` only (so values containing `=` are preserved). +// - Trim surrounding whitespace from key and value. +// - Empty key after trim → error. +// - Duplicate keys → error (no last-wins). +// - Malformed entry (no `=`) → error. +// +// kind is used in error messages and SHOULD be one of KindParameter or KindLabel. +func ParseKeyValueList(values []string, kind string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + out := make(map[string]string, len(values)) + + for _, raw := range values { + rawKey, rawValue, ok := strings.Cut(raw, "=") + if !ok { + return nil, fmt.Errorf("%s must be key=value", kind) + } + + key := strings.TrimSpace(rawKey) + value := strings.TrimSpace(rawValue) + + if key == "" { + return nil, fmt.Errorf("%s key must not be empty", kind) + } + + if _, exists := out[key]; exists { + return nil, fmt.Errorf("duplicate %s '%s'", kind, key) + } + + out[key] = value + } + + return out, nil +} diff --git a/pkg/cmd/pipelinerun/internal/validate_test.go b/pkg/cmd/pipelinerun/internal/validate_test.go new file mode 100644 index 0000000..a5253d3 --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/validate_test.go @@ -0,0 +1,205 @@ +package pipelineruninternal + +import ( + "strings" + "testing" +) + +func TestValidatePipelineName(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + wantErr string + }{ + {"valid", "foo-build", ""}, + {"valid digits", "build-1", ""}, + {"valid 64 chars", strings.Repeat("a", 64), ""}, + {"empty", "", "must not be empty"}, + {"uppercase", "Foo-Build", "valid DNS-1123 subdomain"}, + {"underscore", "foo_build", "valid DNS-1123 subdomain"}, + {"leading dash", "-foo", "valid DNS-1123 subdomain"}, + {"trailing dash", "foo-", "valid DNS-1123 subdomain"}, + {"too long", strings.Repeat("a", 254), "valid DNS-1123 subdomain"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := ValidatePipelineName(tc.input) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error to contain %q, got: %v", tc.wantErr, err) + } + }) + } +} + +func TestValidateOutputAndDryRun(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + format string + dryRun bool + wantSub string // empty = expect no error + }{ + // Live-create path. + {"empty default", "", false, ""}, + {"table", "table", false, ""}, + {"json", "json", false, ""}, + {"yaml without dry-run rejected", "yaml", false, "-o yaml requires --dry-run"}, + + // Dry-run path. + {"dry-run + empty default ok", "", true, ""}, + {"dry-run + json ok", "json", true, ""}, + {"dry-run + yaml ok", "yaml", true, ""}, + {"dry-run + table rejected", "table", true, "--dry-run cannot use -o table"}, + + // Unknown format always rejected. + {"unknown", "xml", false, "unknown output format"}, + {"unknown + dry-run", "xml", true, "unknown output format"}, + {"uppercase rejected", "JSON", false, "unknown output format"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := ValidateOutputAndDryRun(tc.format, tc.dryRun) + + if tc.wantSub == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSub) + } + + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("expected error to contain %q, got: %v", tc.wantSub, err) + } + }) + } +} + +func TestParseKeyValueList_HappyPaths(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in []string + kind string + want map[string]string + }{ + {"nil input", nil, KindParameter, nil}, + {"empty input", []string{}, KindParameter, nil}, + {"single", []string{"k=v"}, KindParameter, map[string]string{"k": "v"}}, + {"multiple order independent", []string{"a=1", "b=2"}, KindParameter, map[string]string{"a": "1", "b": "2"}}, + {"value contains equals", []string{"token=abc=def=="}, KindParameter, map[string]string{"token": "abc=def=="}}, + {"whitespace trimmed", []string{" k = v "}, KindParameter, map[string]string{"k": "v"}}, + {"comma value preserved", []string{"items=v1,v2"}, KindParameter, map[string]string{"items": "v1,v2"}}, + {"json value preserved", []string{`items=["v1","v2"]`}, KindParameter, map[string]string{"items": `["v1","v2"]`}}, + {"empty value allowed", []string{"k="}, KindParameter, map[string]string{"k": ""}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseKeyValueList(tc.in, tc.kind) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tc.want) { + t.Fatalf("length mismatch: got %d, want %d", len(got), len(tc.want)) + } + + for k, v := range tc.want { + if got[k] != v { + t.Fatalf("key %q: got %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestParseKeyValueList_ErrorPaths(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in []string + kind string + wantSub string + }{ + {"malformed", []string{"keywithoutvalue"}, KindParameter, "parameter must be key=value"}, + {"empty key", []string{"=value"}, KindParameter, "parameter key must not be empty"}, + {"whitespace empty key", []string{" =value"}, KindParameter, "parameter key must not be empty"}, + {"duplicate keys", []string{"k=v1", "k=v2"}, KindParameter, `duplicate parameter 'k'`}, + {"label kind word", []string{"keywithoutvalue"}, KindLabel, "label must be key=value"}, + {"label dup", []string{"k=v", "k=v2"}, KindLabel, `duplicate label 'k'`}, + {"label empty key", []string{"=v"}, KindLabel, "label key must not be empty"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseKeyValueList(tc.in, tc.kind) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSub) + } + + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("expected error to contain %q, got: %v", tc.wantSub, err) + } + }) + } +} + +func TestParseKeyValueList_NoLastWins(t *testing.T) { + t.Parallel() + + got, err := ParseKeyValueList([]string{"k=v1", "k=v2"}, KindParameter) + if err == nil { + t.Fatalf("expected duplicate-key error, got: %v", got) + } + + if !strings.Contains(err.Error(), `duplicate parameter 'k'`) { + t.Fatalf("expected duplicate-key error message, got: %v", err) + } +} + +func TestHeaders_Stable(t *testing.T) { + t.Parallel() + + want := []string{"NAME", "STATUS", "PROJECT", "PR", "AUTHOR", "TYPE", "STARTED", "DURATION"} + if len(Headers) != len(want) { + t.Fatalf("headers length: got %d, want %d", len(Headers), len(want)) + } + + for i, h := range want { + if Headers[i] != h { + t.Fatalf("Headers[%d]: got %q, want %q", i, Headers[i], h) + } + } +} diff --git a/pkg/cmd/pipelinerun/list/list.go b/pkg/cmd/pipelinerun/list/list.go index ca093d0..0a2845a 100644 --- a/pkg/cmd/pipelinerun/list/list.go +++ b/pkg/cmd/pipelinerun/list/list.go @@ -3,7 +3,6 @@ package list import ( "context" - "errors" "fmt" "charm.land/lipgloss/v2" @@ -15,9 +14,9 @@ import ( "github.com/KubeRocketCI/cli/internal/output" "github.com/KubeRocketCI/cli/internal/portal" "github.com/KubeRocketCI/cli/internal/portal/restapi" + pipelineruninternal "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/internal" ) -// ListOptions holds all inputs for the pipelinerun list command. type ListOptions struct { IO *iostreams.IOStreams RestClient func() (*restapi.ClientWithResponses, error) @@ -114,11 +113,7 @@ func listRun(ctx context.Context, opts *ListOptions) error { IncludeReason: opts.IncludeReason, }) if err != nil { - if errors.Is(err, portal.ErrUnauthorized) { - return cmdutil.ErrAuthRequired(err) - } - - return err + return pipelineruninternal.HandleAuthError(err) } if opts.IncludeReason { @@ -138,7 +133,6 @@ func listRun(ctx context.Context, opts *ListOptions) error { return err } - // --reason: pipeline info + task tree + failure details (no summary table). if opts.IncludeReason { if len(result.Tasks) > 0 { return output.RenderReason(opts.IO.Out, result) @@ -149,12 +143,10 @@ func listRun(ctx context.Context, opts *ListOptions) error { } } - // Summary table (default). if err := renderSummaryTable(opts, result); err != nil { return err } - // --logs: append logs for the most recent run. if result.Logs != "" { pr := result.PipelineRuns[0] header := fmt.Sprintf("Logs: %s (%s)", pr.Name, pr.Status) @@ -182,10 +174,8 @@ const ( colWidthAuthor = 14 ) -// renderSummaryTable renders the pipeline run list as a styled/plain table. func renderSummaryTable(opts *ListOptions, result *portal.PipelineRunListResult) error { return output.RenderList(opts.IO, opts.OutputFormat, result, func(isTTY bool) ([]string, [][]string) { - headers := []string{"NAME", "STATUS", "PROJECT", "PR", "AUTHOR", "TYPE", "STARTED", "DURATION"} rows := make([][]string, 0, len(result.PipelineRuns)) for _, pr := range result.PipelineRuns { @@ -218,6 +208,6 @@ func renderSummaryTable(opts *ListOptions, result *portal.PipelineRunListResult) }) } - return headers, rows + return pipelineruninternal.Headers, rows }) } diff --git a/pkg/cmd/pipelinerun/pipelinerun.go b/pkg/cmd/pipelinerun/pipelinerun.go index 54936c5..013afe5 100644 --- a/pkg/cmd/pipelinerun/pipelinerun.go +++ b/pkg/cmd/pipelinerun/pipelinerun.go @@ -7,9 +7,9 @@ import ( "github.com/KubeRocketCI/cli/internal/cmdutil" "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/get" "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/list" + "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/start" ) -// NewCmdPipelineRun returns the "pipelinerun" group cobra.Command with all subcommands attached. func NewCmdPipelineRun(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "pipelinerun", @@ -20,6 +20,7 @@ func NewCmdPipelineRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand( list.NewCmdList(f, nil), get.NewCmdGet(f, nil), + start.NewCmdStart(f, nil), ) return cmd diff --git a/pkg/cmd/pipelinerun/start/start.go b/pkg/cmd/pipelinerun/start/start.go new file mode 100644 index 0000000..0f92416 --- /dev/null +++ b/pkg/cmd/pipelinerun/start/start.go @@ -0,0 +1,214 @@ +// Package start implements the "krci pipelinerun start" command. +package start + +import ( + "context" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + pipelineruninternal "github.com/KubeRocketCI/cli/pkg/cmd/pipelinerun/internal" +) + +type StartOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Config func() (*config.Config, error) + Pipeline string + Params []string + Labels []string + OutputFormat string + DryRun bool + + parsedParams map[string]string + parsedLabels map[string]string +} + +// NewCmdStart returns the "pipelinerun start" cobra.Command. +// runF is the business logic function; pass nil to use the default startRun. +func NewCmdStart(f *cmdutil.Factory, runF func(*StartOptions) error) *cobra.Command { + opts := &StartOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "start ", + Short: "Start any Tekton pipeline by name", + Long: `Start a Tekton pipeline by name — the "expert escape hatch": the user +names the pipeline and supplies any params they want to override. + +Pipeline params without a default are submitted with an empty value +(strings as "", arrays as []). The CLI does not pre-validate required +params client-side — pass them with --param k=v if your pipeline needs them. + +The new run is created with Kubernetes 'metadata.generateName' so the +apiserver assigns the random suffix; the resolved name is read back and +returned in the output.`, + Args: cmdutil.ExactArgs(1, "a pipeline name", "to list available pipelines: krci pipelinerun list"), + Example: ` # Start a pipeline with no params + krci pipelinerun start foo-build + + # Start with a single param + krci pipelinerun start foo-build --param git-revision=main + + # Start with multiple params and a discoverability label + krci pipelinerun start foo-build --param k=v --param k2=v2 --label app.edp.epam.com/codebase=my-app + + # Render the would-be PipelineRun without creating it + krci pipelinerun start foo-build --param k=v --dry-run + + # Output as JSON (for AI agents / scripting) + krci pipelinerun start foo-build -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Pipeline = args[0] + + if err := opts.validate(); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return startRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringArrayVar(&opts.Params, "param", nil, + "Pipeline parameter as key=value (repeatable; split on first '=')") + cmd.Flags().StringArrayVar(&opts.Labels, "label", nil, + "Label to attach to the resulting PipelineRun as key=value (repeatable)") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, + "Render the would-be PipelineRun without creating it (requires -o json or -o yaml)") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json, yaml (yaml only with --dry-run)") + + return cmd +} + +func (opts *StartOptions) validate() error { + if err := pipelineruninternal.ValidatePipelineName(opts.Pipeline); err != nil { + return err + } + + if err := pipelineruninternal.ValidateOutputAndDryRun(opts.OutputFormat, opts.DryRun); err != nil { + return err + } + + params, err := pipelineruninternal.ParseKeyValueList(opts.Params, pipelineruninternal.KindParameter) + if err != nil { + return err + } + + opts.parsedParams = params + + labels, err := pipelineruninternal.ParseKeyValueList(opts.Labels, pipelineruninternal.KindLabel) + if err != nil { + return err + } + + opts.parsedLabels = labels + + return nil +} + +func startRun(ctx context.Context, opts *StartOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + client, err := opts.RestClient() + if err != nil { + return err + } + + svc := portal.NewPipelineRunStartService(client, cfg.Namespace) + + result, err := svc.Start(ctx, portal.StartInput{ + Pipeline: opts.Pipeline, + Params: opts.parsedParams, + Labels: opts.parsedLabels, + DryRun: opts.DryRun, + }) + if err != nil { + return pipelineruninternal.HandleAuthError(err) + } + + if opts.DryRun { + return renderDryRun(opts, result) + } + + if output.ResolveFormat(opts.OutputFormat) == output.FormatJSON { + return output.PrintJSONEnvelope(opts.IO.Out, pipelineruninternal.SchemaVersion, result) + } + + if err := renderRow(opts, result); err != nil { + return err + } + + // Controller race window: warn that the new run may briefly 404 if + // queried immediately, until labels reconcile. + if result.Name != "" { + msg := fmt.Sprintf( + "note: the controller may briefly 404 on 'krci pipelinerun get %s' until labels reconcile", + result.Name) + _, _ = lipgloss.Fprintln(opts.IO.ErrOut, output.DimStyle.Render(msg)) + } + + return nil +} + +func renderRow(opts *StartOptions, result *portal.StartResult) error { + return output.RenderList(opts.IO, opts.OutputFormat, result, func(isTTY bool) ([]string, [][]string) { + status := cellOrDash(result.Status) + if isTTY && result.Status != "" { + status = output.PipelineStatusColor(result.Status) + } + + row := []string{ + cellOrDash(result.Name), + status, + cellOrDash(result.Project), + cellOrDash(result.PR), + cellOrDash(result.Author), + cellOrDash(result.Type), + cellOrDash(result.Started), + cellOrDash(result.Duration), + } + + return pipelineruninternal.Headers, [][]string{row} + }) +} + +// renderDryRun emits YAML by default for direct use with `kubectl apply -f -`, +// or a schemaVersion-wrapped JSON envelope under -o json. ValidateOutputAndDryRun +// has already restricted opts.OutputFormat to "", "json", or "yaml" on this path. +func renderDryRun(opts *StartOptions, result *portal.StartResult) error { + if len(result.DryRunManifest) == 0 { + return fmt.Errorf("portal returned empty dry-run manifest") + } + + if opts.OutputFormat == output.FormatJSON { + return output.PrintJSONEnvelope(opts.IO.Out, pipelineruninternal.SchemaVersion, result.DryRunManifest) + } + + return output.PrintYAML(opts.IO.Out, result.DryRunManifest) +} + +func cellOrDash(s string) string { + if s == "" { + return "-" + } + + return s +} diff --git a/pkg/cmd/pipelinerun/start/start_test.go b/pkg/cmd/pipelinerun/start/start_test.go new file mode 100644 index 0000000..f65ae87 --- /dev/null +++ b/pkg/cmd/pipelinerun/start/start_test.go @@ -0,0 +1,335 @@ +package start + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/internal/cmdtest" +) + +var newFactory = cmdtest.NewFactory + +// runCmd executes the command with argv and returns the captured opts so +// tests can assert post-validation state without hitting the network. +func runCmd(t *testing.T, argv []string) (*StartOptions, error) { + t.Helper() + + var captured *StartOptions + + cmd := NewCmdStart(newFactory(), func(o *StartOptions) error { + captured = o + + return nil + }) + + cmd.SetArgs(argv) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + return captured, err + } + + return captured, nil +} + +func TestStart_RejectsMissingPositional(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{}) + if err == nil { + t.Fatal("expected error for missing positional") + } + + if !strings.Contains(err.Error(), "requires a pipeline name") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestStart_RejectsInvalidDNS1123(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"Foo_Build"}) + if err == nil || !strings.Contains(err.Error(), "DNS-1123") { + t.Fatalf("expected DNS-1123 error, got: %v", err) + } +} + +func TestStart_AcceptsValidName(t *testing.T) { + t.Parallel() + + opts, err := runCmd(t, []string{"foo-build"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opts == nil || opts.Pipeline != "foo-build" { + t.Fatalf("opts not captured properly: %+v", opts) + } +} + +func TestStart_RejectsUnknownOutputFormat(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "-o", "xml"}) + if err == nil || !strings.Contains(err.Error(), "unknown output format") { + t.Fatalf("expected unknown-format error, got: %v", err) + } +} + +func TestStart_RejectsDryRunPlusTable(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "--dry-run", "-o", "table"}) + if err == nil || !strings.Contains(err.Error(), "--dry-run cannot use -o table") { + t.Fatalf("expected dry-run+table mutex error, got: %v", err) + } +} + +func TestStart_RejectsYAMLWithoutDryRun(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "-o", "yaml"}) + if err == nil || !strings.Contains(err.Error(), "-o yaml requires --dry-run") { + t.Fatalf("expected yaml-without-dry-run error, got: %v", err) + } +} + +func TestStart_DryRunYAMLOutputFormat(t *testing.T) { + t.Parallel() + + opts, err := runCmd(t, []string{"foo-build", "--dry-run", "-o", "yaml"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !opts.DryRun { + t.Errorf("DryRun not set") + } + + if opts.OutputFormat != "yaml" { + t.Errorf("OutputFormat = %q", opts.OutputFormat) + } +} + +func TestStart_RejectsParamWithoutEquals(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "--param", "keywithoutvalue"}) + if err == nil || !strings.Contains(err.Error(), "parameter must be key=value") { + t.Fatalf("expected parser error, got: %v", err) + } +} + +func TestStart_RejectsParamEmptyKey(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "--param", "=value"}) + if err == nil || !strings.Contains(err.Error(), "parameter key must not be empty") { + t.Fatalf("expected empty-key error, got: %v", err) + } +} + +func TestStart_RejectsDuplicateParam(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "--param", "k=v1", "--param", "k=v2"}) + if err == nil || !strings.Contains(err.Error(), "duplicate parameter") { + t.Fatalf("expected duplicate-param error, got: %v", err) + } +} + +func TestStart_RejectsDuplicateLabel(t *testing.T) { + t.Parallel() + + _, err := runCmd(t, []string{"foo-build", "--label", "k=v1", "--label", "k=v2"}) + if err == nil || !strings.Contains(err.Error(), "duplicate label") { + t.Fatalf("expected duplicate-label error, got: %v", err) + } +} + +func TestStart_AcceptsParamValueWithEquals(t *testing.T) { + t.Parallel() + + opts, err := runCmd(t, []string{"foo-build", "--param", "token=abc=def=="}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opts.parsedParams["token"] != "abc=def==" { + t.Errorf("expected value preserved, got: %q", opts.parsedParams["token"]) + } +} + +func TestStart_AcceptsCommaSeparatedParam(t *testing.T) { + t.Parallel() + + opts, err := runCmd(t, []string{"foo-build", "--param", "items=v1,v2"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opts.parsedParams["items"] != "v1,v2" { + t.Errorf("comma value should be preserved verbatim, got: %q", opts.parsedParams["items"]) + } +} + +func TestStart_DoesNotExposeWaitFollowTimeout(t *testing.T) { + t.Parallel() + + cmd := NewCmdStart(newFactory(), nil) + for _, name := range []string{"wait", "follow", "timeout"} { + if cmd.Flags().Lookup(name) != nil { + t.Errorf("--%s flag must NOT be exposed (BR-wide non-goal)", name) + } + } +} + +func TestStart_AcceptsValidLabel(t *testing.T) { + t.Parallel() + + opts, err := runCmd(t, []string{"foo-build", "--label", "app.edp.epam.com/codebase=my-app"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opts.parsedLabels["app.edp.epam.com/codebase"] != "my-app" { + t.Errorf("label not parsed: %+v", opts.parsedLabels) + } +} + +// portalManifest is what the Portal procedure returns for `dryRun=true`: +// the rendered PipelineRun draft as a JSON object (parsed at the transport +// boundary). The CLI re-encodes per requested output format. +func portalManifest() map[string]any { + return map[string]any{ + "apiVersion": "tekton.dev/v1", + "kind": "PipelineRun", + "metadata": map[string]any{ + "generateName": "foo-build-run-", + "labels": map[string]any{"app.edp.epam.com/codebase": "my-app"}, + }, + "spec": map[string]any{ + "params": []any{map[string]any{"name": "git-revision", "value": "main"}}, + }, + } +} + +func newDryRunOpts(t *testing.T, format string) (*StartOptions, *bytes.Buffer) { + t.Helper() + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + + return &StartOptions{ + IO: &iostreams.IOStreams{Out: out, ErrOut: errOut}, + OutputFormat: format, + DryRun: true, + }, out +} + +func TestRenderDryRun_DefaultEmitsYAML(t *testing.T) { + t.Parallel() + + opts, out := newDryRunOpts(t, "") + + if err := renderDryRun(opts, &portal.StartResult{DryRunManifest: portalManifest()}); err != nil { + t.Fatalf("renderDryRun: %v", err) + } + + got := out.String() + + // YAML markers: bare keys, no quoted JSON braces wrapping the doc. + for _, want := range []string{ + "apiVersion: tekton.dev/v1\n", + "kind: PipelineRun\n", + "generateName: foo-build-run-\n", + } { + if !strings.Contains(got, want) { + t.Errorf("missing YAML fragment %q in output:\n%s", want, got) + } + } + + if strings.HasPrefix(strings.TrimSpace(got), "{") { + t.Errorf("default dry-run output should be YAML, got JSON-looking output:\n%s", got) + } +} + +func TestRenderDryRun_OutputYAMLEmitsYAML(t *testing.T) { + t.Parallel() + + opts, out := newDryRunOpts(t, "yaml") + + if err := renderDryRun(opts, &portal.StartResult{DryRunManifest: portalManifest()}); err != nil { + t.Fatalf("renderDryRun: %v", err) + } + + if !strings.Contains(out.String(), "apiVersion: tekton.dev/v1") { + t.Errorf("-o yaml output missing YAML markers:\n%s", out.String()) + } +} + +func TestRenderDryRun_OutputJSONEmbedsParsedObject(t *testing.T) { + t.Parallel() + + opts, out := newDryRunOpts(t, "json") + + if err := renderDryRun(opts, &portal.StartResult{DryRunManifest: portalManifest()}); err != nil { + t.Fatalf("renderDryRun: %v", err) + } + + var envelope struct { + SchemaVersion string `json:"schemaVersion"` + Data map[string]any `json:"data"` + } + + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatalf("envelope is not parseable JSON: %v\noutput: %s", err, out.String()) + } + + if envelope.SchemaVersion != "1" { + t.Errorf("schemaVersion = %q", envelope.SchemaVersion) + } + + // data must be a parsed object — NOT a string-wrapped manifest. + if envelope.Data["apiVersion"] != "tekton.dev/v1" { + t.Errorf("data.apiVersion = %v (expected parsed object, got: %#v)", envelope.Data["apiVersion"], envelope.Data) + } + + if envelope.Data["kind"] != "PipelineRun" { + t.Errorf("data.kind = %v", envelope.Data["kind"]) + } +} + +func TestRenderDryRun_RejectsEmptyManifest(t *testing.T) { + t.Parallel() + + opts, _ := newDryRunOpts(t, "") + + err := renderDryRun(opts, &portal.StartResult{DryRunManifest: nil}) + if err == nil { + t.Fatal("expected error on empty manifest") + } +} + +func TestStart_HelpHasExpectedExamples(t *testing.T) { + t.Parallel() + + cmd := NewCmdStart(newFactory(), nil) + help := cmd.Long + "\n" + cmd.Example + + for _, fragment := range []string{ + "--dry-run", + "--param", + "--label", + "-o json", + } { + if !strings.Contains(help, fragment) { + t.Errorf("help text missing %q", fragment) + } + } +}