From efeb9c935c0218b897f7087b6fd29522977e5dae Mon Sep 17 00:00:00 2001 From: Sergiy Kulanov Date: Wed, 6 May 2026 15:48:38 +0300 Subject: [PATCH] EPMDEDP-16758: feat: Add krci pipelinerun start command Implement foundational verb for triggering Tekton pipelines directly from CLI with parameter and label overrides. Supports dry-run mode for manifest preview and multiple output formats (table/json/yaml). - Portal service wraps tRPC pipelineRun.start procedure with error mapping - CLI validates pipeline name, params, labels via Cobra Args validators - Server-assigned names via metadata.generateName; client reads resolved name - Comprehensive error disambiguation: pipeline-not-found, trigger-template-not-found - Unified richNotFoundError type for all portal service not-found scenarios - Includes e2e test cases and comprehensive unit test coverage Signed-off-by: Sergiy Kulanov --- cmd/krci/main.go | 1 - docs/json-schemas.md | 76 ++ e2e/pipelinerun/test-cases.md | 34 + go.mod | 2 +- internal/cmdutil/validate.go | 20 +- internal/cmdutil/validate_test.go | 34 + internal/portal/errors.go | 36 + internal/portal/openapi/spec.json | 715 ++++++++++++++---- internal/portal/restapi/api_gen.go | 394 +++++++++- internal/portal/sca.go | 20 +- internal/portal/start.go | 243 ++++++ internal/portal/start_test.go | 512 +++++++++++++ pkg/cmd/pipelinerun/internal/columns.go | 11 + pkg/cmd/pipelinerun/internal/validate.go | 105 +++ pkg/cmd/pipelinerun/internal/validate_test.go | 205 +++++ pkg/cmd/pipelinerun/list/list.go | 9 +- pkg/cmd/pipelinerun/pipelinerun.go | 3 +- pkg/cmd/pipelinerun/start/start.go | 238 ++++++ pkg/cmd/pipelinerun/start/start_test.go | 377 +++++++++ 19 files changed, 2796 insertions(+), 239 deletions(-) create mode 100644 internal/portal/start.go create mode 100644 internal/portal/start_test.go create mode 100644 pkg/cmd/pipelinerun/internal/columns.go create mode 100644 pkg/cmd/pipelinerun/internal/validate.go create mode 100644 pkg/cmd/pipelinerun/internal/validate_test.go create mode 100644 pkg/cmd/pipelinerun/start/start.go create mode 100644 pkg/cmd/pipelinerun/start/start_test.go 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..93a6572 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -477,3 +477,79 @@ 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. + +| Condition | Message | Exit | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---- | +| Pipeline not found | `pipeline '' not found` | 3 | +| TriggerTemplate referenced but missing | `pipeline '' references a TriggerTemplate that does not exist` | 3 | +| Malformed TriggerTemplate label | `platform rejected request: pipeline '' has malformed TriggerTemplate label` | 1 | +| Tekton admission rejection (e.g. missing required param) | `platform rejected request: Bad Request` (Portal does not echo K8s admission detail) | 1 | +| RBAC denied | `permission denied` | 1 | +| Portal upstream 5xx | `upstream service unavailable: ` | 2 | +| Duplicate / malformed `--param` / `--label` | `duplicate parameter ''` / `parameter must be key=value` / `label key must not be empty` | 1 | +| `--dry-run` with `-o table` | `--dry-run cannot use -o table (use -o json or -o yaml)` | 1 | + diff --git a/e2e/pipelinerun/test-cases.md b/e2e/pipelinerun/test-cases.md index aa057c3..a747b6c 100644 --- a/e2e/pipelinerun/test-cases.md +++ b/e2e/pipelinerun/test-cases.md @@ -191,3 +191,37 @@ 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` + +| ID | Class | Suite | Title | Run as | Expect | +|----|-------|-------|-------|--------|--------| +| PR-S-HELP | help | offline | `start --help` lists `--param`, `--label`, `--dry-run`, `-o` | offline | exit 0; help text mentions `escape hatch`, `start build`, `start review` | +| PR-S-DNS-1 | validation | offline | reject uppercase positional `Foo_Build` | offline | exit 1; stderr `must be a valid DNS-1123 name` | +| PR-S-OUT-1 | validation | offline | reject `-o xml` | offline | exit 1; stderr `unknown output format` | +| PR-S-DRY-MUTEX | validation | offline | reject `--dry-run -o table` | offline | exit 1; stderr `--dry-run cannot use -o table` | +| PR-S-PARAM-DUP | validation | offline | reject `--param k=v1 --param k=v2` | offline | exit 1; stderr `duplicate parameter 'k'` | +| PR-S-PARAM-EMPTY | validation | offline | reject `--param =value` | offline | exit 1; stderr `parameter key must not be empty` | +| PR-S-PARAM-MAL | validation | offline | reject `--param keywithoutvalue` | offline | exit 1; stderr `parameter must be key=value` | +| PR-S-LABEL-DUP | validation | offline | reject `--label k=v1 --label k=v2` | offline | exit 1; stderr `duplicate label 'k'` | +| PR-S-PARAM-EQ | parser | offline | accept `--param token=abc=def==` (split on first `=`) | offline | exit 0 (capture); param `token=abc=def==` | +| PR-S-PARAM-WS | parser | offline | accept `--param " k = v "` (whitespace trimmed) | offline | exit 0 (capture); param `k=v` | +| PR-S-1 | happy | portal | start known pipeline (no params) | portal | exit 0; row in `NAME, STATUS, PROJECT, PR, AUTHOR, TYPE, STARTED, DURATION` | +| PR-S-2 | happy | portal | start with `--param git-revision=main` | portal | exit 0; platform receives the param | +| PR-S-3 | happy | portal | start with `-o json` | portal | exit 0; stdout JSON `{ "schemaVersion":"1", "data":{ "name":"...", … } }` | +| PR-S-LABEL | happy | portal | start with `--label app.edp.epam.com/codebase=my-app`; immediate `run list --project my-app` | portal | new run discoverable in list | +| PR-S-DRY-YAML | dry-run | portal | start `--dry-run` (default → YAML manifest) | portal | exit 0; stdout valid YAML/JSON containing `metadata.generateName: foo-build-run-` | +| PR-S-DRY-JSON | dry-run | portal | start `--dry-run -o json` | portal | exit 0; stdout JSON envelope where `data` IS the PipelineRun resource | +| PR-S-NOT-FOUND | error | portal | start `ghost` (no such Pipeline) | portal | **exit 3**; stderr `pipeline 'ghost' not found` | +| PR-S-TT-MISSING | error | portal | start a Pipeline whose `triggertemplate` label points at a missing TT | portal | **exit 3**; stderr `pipeline '' references a TriggerTemplate that does not exist` | +| PR-S-PARAM-MISSING | error | portal | start a Pipeline that requires a param the user omitted | portal | exit 1; stderr `platform rejected request: Bad Request` (Portal hardening strips K8s admission detail) | +| PR-S-RBAC | error | portal | start as a user without `create` on `pipelineruns.tekton.dev` | portal | exit 1; stderr `permission denied` | +| PR-S-5XX | error | portal | start with simulated upstream 5xx (toxiproxy or stub) | portal | **exit 2**; stderr `upstream service unavailable` | +| PR-S-COL-EQ | regression | portal | header row of `start` matches header row of `list` byte-for-byte | portal | identical headers | +| PR-S-RACE | edge | portal | immediately after start, run `get ` before the controller registers it | portal | start exit 0 with stderr warning; get may briefly 404 | +| PR-S-GENNAME | regression | portal | response `data.name` matches `-run-<5char-suffix>` apiserver pattern | portal | 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 required. +- **Portal rows** require fixtures: a known Pipeline, a Pipeline with broken TT label (for `PR-S-TT-MISSING`), and a Pipeline declaring at least one required param (for `PR-S-PARAM-MISSING`). Add to `fixtures.env` per the existing pipelinerun pattern. 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/portal/errors.go b/internal/portal/errors.go index fb919c4..712cb6a 100644 --- a/internal/portal/errors.go +++ b/internal/portal/errors.go @@ -20,4 +20,40 @@ 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 } + +// newNotFoundErr constructs a richNotFoundError. sentinel must be ErrNotFound +// or wrap it so generic not-found callers continue to match. +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..7fa8232 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 }, @@ -682,9 +846,6 @@ "schema": { "type": "object", "properties": { - "taskName": { - "type": "string" - }, "taskRunName": { "type": "string" }, @@ -703,7 +864,6 @@ } }, "required": [ - "taskName", "taskRunName", "logs", "hasLogs", @@ -792,7 +952,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 +1040,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 +2198,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..97cee0b --- /dev/null +++ b/internal/portal/start.go @@ -0,0 +1,243 @@ +package portal + +import ( + "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 label of the Tekton Pipeline + 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 as a parsed object + // emitted when DryRun=true; nil on the live-create path. Transport already + // parses the JSON once at the wire boundary, so consumers don't pay a + // double-parse tax. The CLI converts the object to YAML for the default + // and `-o yaml` modes. + 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" +) + +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: + switch extractReason(body) { + case reasonMalformedTTLabel: + return fmt.Errorf("%w: pipeline '%s' has malformed TriggerTemplate label", + ErrPlatformReject, pipeline) + default: + // 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, fallbackMessage(body, "Bad Request")) + } + case http.StatusForbidden: + return ErrPermissionDenied + case http.StatusNotFound: + if extractReason(body) == 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 flows these + // through without attaching a stable reason tag (the K8s body + // has no `reason` field), so we discriminate purely on status + // code. 422 is the common "missing required Pipeline param" + // case; 408/409/429 are throttle/conflict/timeout from the API + // server. The static status phrase tells the user which class + // of reject occurred. + return fmt.Errorf("%w: %s", ErrPlatformReject, + fallbackMessage(body, http.StatusText(statusCode))) + case http.StatusBadGateway, http.StatusServiceUnavailable, + http.StatusInternalServerError, http.StatusGatewayTimeout: + return fmt.Errorf("%w: %s", ErrUpstreamUnavailable, truncateBody(body)) + } + + return checkResponse(statusCode, body) +} + +// extractReason reads error.reason from the Portal body; returns "" on parse failure. +func extractReason(body []byte) string { + var env struct { + Error struct { + Reason string `json:"reason"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &env); err != nil { + return "" + } + + return env.Error.Reason +} + +// fallbackMessage returns error.message from the Portal body, or the supplied +// default for codes with no stable reason tag. +func fallbackMessage(body []byte, fallback string) string { + var env struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + + if err := json.Unmarshal(body, &env); err != nil { + return fallback + } + + if env.Error.Message != "" { + return env.Error.Message + } + + if env.Message != "" { + return env.Message + } + + return fallback +} + +// 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 peek struct { + Kind string `json:"kind"` + } + + if err := json.Unmarshal(body, &peek); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start response: %w", err) + } + + switch peek.Kind { + case "created": + var v struct { + Row StartResult `json:"row"` + } + + if err := json.Unmarshal(body, &v); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start created row: %w", err) + } + + return &v.Row, nil + + case "dryRun": + var v struct { + Manifest map[string]any `json:"manifest"` + } + + if err := json.Unmarshal(body, &v); err != nil { + return nil, fmt.Errorf("decoding pipelinerun start dry-run manifest: %w", err) + } + + return &StartResult{DryRunManifest: v.Manifest}, nil + + default: + return nil, fmt.Errorf("unexpected pipelinerun start response kind: %q", peek.Kind) + } +} diff --git a/internal/portal/start_test.go b/internal/portal/start_test.go new file mode 100644 index 0000000..d7bbac3 --- /dev/null +++ b/internal/portal/start_test.go @@ -0,0 +1,512 @@ +package portal + +import ( + "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() + + // Tekton admission rejection (e.g. missing required Pipeline param) + // flows through the Portal's generic K8s error handler, which does NOT + // attach a stable reason tag. The CLI surfaces the static status phrase + // so the user gets the correct exit code (1) and a hint to inspect the + // Pipeline definition; the verbatim K8s message is not on the wire. + 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_MissingParam(t *testing.T) { + t.Parallel() + + // The most common admission failure on `start`: the Pipeline declares a + // required param the request didn't supply, so the K8s API server rejects + // the PipelineRun with 422. Portal's handleK8sError forwards the status + // without attaching a stable reason tag — the CLI keys off the status + // code alone and surfaces ErrPlatformReject (exit 1) plus the static + // HTTP phrase. The verbatim K8s admission message is not on the wire. + 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 TestExtractReason(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 := extractReason([]byte(tc.body)); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestFallbackMessage(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() + + got := fallbackMessage([]byte(tc.body), tc.fallback) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/pkg/cmd/pipelinerun/internal/columns.go b/pkg/cmd/pipelinerun/internal/columns.go new file mode 100644 index 0000000..de51334 --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/columns.go @@ -0,0 +1,11 @@ +// Package pipelineruninternal holds helpers shared by `krci pipelinerun` verbs +// (validation, shared column headers, key=value parsing). +package pipelineruninternal + +// Headers is the canonical column set for `krci pipelinerun list` output and +// for the single row emitted by `krci pipelinerun start`. +// +// list and start MUST emit byte-identical headers so users pivot directly +// between the two verbs. Inline duplicates would drift; this shared constant +// plus a header-equality regression test enforce parity. +var Headers = []string{"NAME", "STATUS", "PROJECT", "PR", "AUTHOR", "TYPE", "STARTED", "DURATION"} diff --git a/pkg/cmd/pipelinerun/internal/validate.go b/pkg/cmd/pipelinerun/internal/validate.go new file mode 100644 index 0000000..82e1888 --- /dev/null +++ b/pkg/cmd/pipelinerun/internal/validate.go @@ -0,0 +1,105 @@ +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" +) + +// FormatYAML is the dry-run-only output format. The shared output package +// owns FormatTable and FormatJSON; YAML is meaningful only for `pipelinerun +// start --dry-run`, so its constant lives here rather than in `output`. +const FormatYAML = "yaml" + +// 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 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..8c8013d 100644 --- a/pkg/cmd/pipelinerun/list/list.go +++ b/pkg/cmd/pipelinerun/list/list.go @@ -15,9 +15,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) @@ -138,7 +138,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 +148,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 +179,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 +213,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..51c4c45 --- /dev/null +++ b/pkg/cmd/pipelinerun/start/start.go @@ -0,0 +1,238 @@ +// Package start implements the "krci pipelinerun start" command. +package start + +import ( + "context" + "errors" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "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" +) + +const schemaVersion = "1" + +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 + + // Cached during Args validation so startRun does not re-parse. + 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, supplies all required params, and accepts the consequences +if anything is wrong. The CLI does NOT enumerate required parameters +client-side — missing required params are caught by the platform and surfaced +verbatim. + +For codebase-aware defaults, use 'krci pipelinerun start build '; +for PR-aware re-triggers, use 'krci pipelinerun start review '. + +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 +} + +// validate runs all input validation and caches parsed key=value lists. +// Called from RunE so it executes after PersistentPreRunE (which runs the +// shared --portal-url/flag-arg checks). +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 mapStartError(err) + } + + if opts.DryRun { + return renderDryRun(opts, result) + } + + if output.ResolveFormat(opts.OutputFormat) == output.FormatJSON { + return output.PrintJSONEnvelope(opts.IO.Out, schemaVersion, result) + } + + if err := renderRow(opts, result); err != nil { + return err + } + + // Controller race window: in table mode, warn that the new run may + // briefly 404 if queried immediately. JSON returned earlier; validate + // rejects yaml outside dry-run, so format here is always table. + 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(_ bool) ([]string, [][]string) { + row := []string{ + cellOrDash(result.Name), + cellOrDash(result.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 the manifest. Default and -o yaml produce YAML so the +// output pipes directly to `kubectl apply -f -`. -o json wraps in the +// schemaVersion envelope without string-in-string encoding. +func renderDryRun(opts *StartOptions, result *portal.StartResult) error { + if len(result.DryRunManifest) == 0 { + return fmt.Errorf("portal returned empty dry-run manifest") + } + + if output.ResolveFormat(opts.OutputFormat) == output.FormatJSON { + return output.PrintJSONEnvelope(opts.IO.Out, schemaVersion, result.DryRunManifest) + } + + yamlBytes, err := yaml.Marshal(result.DryRunManifest) + if err != nil { + return fmt.Errorf("converting dry-run manifest to yaml: %w", err) + } + + _, err = opts.IO.Out.Write(yamlBytes) + + return err +} + +func cellOrDash(s string) string { + if s == "" { + return "-" + } + + return s +} + +// mapStartError adds the auth-required remediation hint to ErrUnauthorized +// (matching list/get behaviour). All other portal errors propagate as-is and +// produce the same generic non-zero exit; only the stderr message differs. +func mapStartError(err error) error { + if errors.Is(err, portal.ErrUnauthorized) { + return cmdutil.ErrAuthRequired(err) + } + + return err +} diff --git a/pkg/cmd/pipelinerun/start/start_test.go b/pkg/cmd/pipelinerun/start/start_test.go new file mode 100644 index 0000000..539f4bf --- /dev/null +++ b/pkg/cmd/pipelinerun/start/start_test.go @@ -0,0 +1,377 @@ +package start + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/internal/cmdtest" +) + +func newFactory() *cmdutil.Factory { + return cmdtest.NewFactory() +} + +// runCmd executes the cobra command with the given argv (after `start ...`). +// On success returns the parsed StartOptions, otherwise the cobra error. +// The runF capture lets us assert post-validation state without actually +// 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) + } +} + +func TestMapStartError_AddsAuthHintForUnauthorized(t *testing.T) { + t.Parallel() + + mapped := mapStartError(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 TestMapStartError_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 := mapStartError(in); got != in { + t.Errorf("mapStartError(%v) should pass through unchanged, got %v", in, got) + } + } +} + +// 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{ + "escape hatch", + "start build", + "start review", + "--dry-run", + "--param", + "--label", + "-o json", + } { + if !strings.Contains(help, fragment) { + t.Errorf("help text missing %q", fragment) + } + } +}