Skip to content
Open

WIP #35

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/krci/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Package main is the entry point for the krci CLI.
package main

import (
Expand Down
76 changes: 76 additions & 0 deletions docs/json-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,79 @@ Common messages:
| Unknown pull request (404) | `pull request <id> not found` |
| Upstream 5xx / network | `portal returned HTTP 500: <cause>` |
| 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": "<apiserver-assigned name, e.g. foo-build-run-x9k2p>",
"status": "Pending|Running|Succeeded|Failed|Cancelled|Timeout",
"project": "<codebase or empty>",
"pr": "<pr number or empty>",
"author": "<git author or empty>",
"type": "<pipelinetype label or empty>",
"started": "<RFC3339 or empty>",
"duration": "<m+s or empty>"
}
}
```

### 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 '<name>' not found` | 3 |
| TriggerTemplate referenced but missing | `pipeline '<name>' references a TriggerTemplate that does not exist` | 3 |
| Malformed TriggerTemplate label | `platform rejected request: pipeline '<name>' 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: <cause>` | 2 |
| Duplicate / malformed `--param` / `--label` | `duplicate parameter '<k>'` / `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 |

34 changes: 34 additions & 0 deletions e2e/pipelinerun/test-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>' 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 <NAME>` 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 `<pipeline>-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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
20 changes: 14 additions & 6 deletions internal/cmdutil/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
34 changes: 34 additions & 0 deletions internal/cmdutil/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
36 changes: 36 additions & 0 deletions internal/portal/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Loading
Loading