Skip to content

Commit a00d03c

Browse files
committed
WIP
Signed-off-by: Sergiy Kulanov <sergiy_kulanov@epam.com>
1 parent ecc4062 commit a00d03c

20 files changed

Lines changed: 2341 additions & 29 deletions

File tree

cmd/krci/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ func main() {
2424

2525
if err := root.NewCmdRoot(f, version, commit, date).Execute(); err != nil {
2626
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
27-
os.Exit(1)
27+
os.Exit(cmdutil.ExitCodeFor(err))
2828
}
2929
}

docs/json-schemas.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,3 +477,79 @@ Common messages:
477477
| Unknown pull request (404) | `pull request <id> not found` |
478478
| Upstream 5xx / network | `portal returned HTTP 500: <cause>` |
479479
| Invalid flag value | Flag-specific message (e.g. enum list) |
480+
481+
482+
## `krci pipelinerun start`
483+
484+
The start verb reuses the same column shape as `krci pipelinerun list`. Empty
485+
cells render as `-` in table mode and as `""` in JSON mode (matches list).
486+
487+
### Success envelope
488+
489+
```json
490+
{
491+
"schemaVersion": "1",
492+
"data": {
493+
"name": "<apiserver-assigned name, e.g. foo-build-run-x9k2p>",
494+
"status": "Pending|Running|Succeeded|Failed|Cancelled|Timeout",
495+
"project": "<codebase or empty>",
496+
"pr": "<pr number or empty>",
497+
"author": "<git author or empty>",
498+
"type": "<pipelinetype label or empty>",
499+
"started": "<RFC3339 or empty>",
500+
"duration": "<m+s or empty>"
501+
}
502+
}
503+
```
504+
505+
### Error envelope
506+
507+
```json
508+
{
509+
"schemaVersion": "1",
510+
"error": { "message": "pipeline 'ghost' not found" }
511+
}
512+
```
513+
514+
### Dry-run envelope (-o json)
515+
516+
`data` carries the rendered `PipelineRun` resource as a parsed JSON object —
517+
not a string. Default and `-o yaml` modes emit the same resource as YAML
518+
(suitable for piping to `kubectl apply -f -`).
519+
520+
```json
521+
{
522+
"schemaVersion": "1",
523+
"data": {
524+
"apiVersion": "tekton.dev/v1",
525+
"kind": "PipelineRun",
526+
"metadata": {
527+
"generateName": "foo-build-run-",
528+
"labels": { "app.edp.epam.com/codebase": "my-app" }
529+
},
530+
"spec": {
531+
"params": [ { "name": "git-revision", "value": "main" } ]
532+
}
533+
}
534+
}
535+
```
536+
537+
### Common messages
538+
539+
User-facing messages on the not-found path are synthesised CLI-side from a
540+
stable `error.reason` tag the Portal returns. The Portal deliberately does
541+
not put resource-identifying text in `error.message` (cluster-hardening
542+
policy applied uniformly to all REST routes), so the CLI builds the user
543+
message from the pipeline name it already has plus the reason it received.
544+
545+
| Condition | Message | Exit |
546+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---- |
547+
| Pipeline not found | `pipeline '<name>' not found` | 3 |
548+
| TriggerTemplate referenced but missing | `pipeline '<name>' references a TriggerTemplate that does not exist` | 3 |
549+
| Malformed TriggerTemplate label | `platform rejected request: pipeline '<name>' has malformed TriggerTemplate label` | 1 |
550+
| Tekton admission rejection (e.g. missing required param) | `platform rejected request: Bad Request` (Portal does not echo K8s admission detail) | 1 |
551+
| RBAC denied | `permission denied` | 1 |
552+
| Portal upstream 5xx | `upstream service unavailable: <cause>` | 2 |
553+
| Duplicate / malformed `--param` / `--label` | `duplicate parameter '<k>'` / `parameter must be key=value` / `label key must not be empty` | 1 |
554+
| `--dry-run` with `-o table` | `--dry-run cannot use -o table (use -o json or -o yaml)` | 1 |
555+

e2e/pipelinerun/test-cases.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,37 @@ Each of the following must be covered by ≥1 row above. Tick as you add.
191191
- [x] `get` nonexistent name
192192
- [x] JSON envelope field contract for `list` and `get`
193193
- [x] auth-required error path
194+
195+
### `pipelinerun start`
196+
197+
| ID | Class | Suite | Title | Run as | Expect |
198+
|----|-------|-------|-------|--------|--------|
199+
| 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` |
200+
| PR-S-DNS-1 | validation | offline | reject uppercase positional `Foo_Build` | offline | exit 1; stderr `must be a valid DNS-1123 name` |
201+
| PR-S-OUT-1 | validation | offline | reject `-o xml` | offline | exit 1; stderr `unknown output format` |
202+
| PR-S-DRY-MUTEX | validation | offline | reject `--dry-run -o table` | offline | exit 1; stderr `--dry-run cannot use -o table` |
203+
| PR-S-PARAM-DUP | validation | offline | reject `--param k=v1 --param k=v2` | offline | exit 1; stderr `duplicate parameter 'k'` |
204+
| PR-S-PARAM-EMPTY | validation | offline | reject `--param =value` | offline | exit 1; stderr `parameter key must not be empty` |
205+
| PR-S-PARAM-MAL | validation | offline | reject `--param keywithoutvalue` | offline | exit 1; stderr `parameter must be key=value` |
206+
| PR-S-LABEL-DUP | validation | offline | reject `--label k=v1 --label k=v2` | offline | exit 1; stderr `duplicate label 'k'` |
207+
| PR-S-PARAM-EQ | parser | offline | accept `--param token=abc=def==` (split on first `=`) | offline | exit 0 (capture); param `token=abc=def==` |
208+
| PR-S-PARAM-WS | parser | offline | accept `--param " k = v "` (whitespace trimmed) | offline | exit 0 (capture); param `k=v` |
209+
| PR-S-1 | happy | portal | start known pipeline (no params) | portal | exit 0; row in `NAME, STATUS, PROJECT, PR, AUTHOR, TYPE, STARTED, DURATION` |
210+
| PR-S-2 | happy | portal | start with `--param git-revision=main` | portal | exit 0; platform receives the param |
211+
| PR-S-3 | happy | portal | start with `-o json` | portal | exit 0; stdout JSON `{ "schemaVersion":"1", "data":{ "name":"...", … } }` |
212+
| 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 |
213+
| 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-` |
214+
| PR-S-DRY-JSON | dry-run | portal | start `--dry-run -o json` | portal | exit 0; stdout JSON envelope where `data` IS the PipelineRun resource |
215+
| PR-S-NOT-FOUND | error | portal | start `ghost` (no such Pipeline) | portal | **exit 3**; stderr `pipeline 'ghost' not found` |
216+
| 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` |
217+
| 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) |
218+
| PR-S-RBAC | error | portal | start as a user without `create` on `pipelineruns.tekton.dev` | portal | exit 1; stderr `permission denied` |
219+
| PR-S-5XX | error | portal | start with simulated upstream 5xx (toxiproxy or stub) | portal | **exit 2**; stderr `upstream service unavailable` |
220+
| PR-S-COL-EQ | regression | portal | header row of `start` matches header row of `list` byte-for-byte | portal | identical headers |
221+
| 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 |
222+
| PR-S-GENNAME | regression | portal | response `data.name` matches `<pipeline>-run-<5char-suffix>` apiserver pattern | portal | name shape conforms (`generateName` round-trip) |
223+
224+
#### Coverage notes
225+
226+
- **Validation rows** (`PR-S-DNS-1` through `PR-S-PARAM-WS`) run offline and only need a built `dist/krci`; no portal required.
227+
- **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.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/zalando/go-keyring v0.2.6
1717
golang.org/x/oauth2 v0.36.0
1818
golang.org/x/sync v0.20.0
19+
gopkg.in/yaml.v3 v3.0.1
1920
)
2021

2122
require (
@@ -53,5 +54,4 @@ require (
5354
golang.org/x/sys v0.42.0 // indirect
5455
golang.org/x/text v0.31.0 // indirect
5556
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
56-
gopkg.in/yaml.v3 v3.0.1 // indirect
5757
)

internal/cmdutil/exitcode.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmdutil
2+
3+
import "errors"
4+
5+
// ExitCoder is an error that carries an explicit OS exit code. main.go
6+
// inspects the chain via errors.As; absent matches default to exit 1.
7+
type ExitCoder interface {
8+
error
9+
ExitCode() int
10+
}
11+
12+
// exitCodeError associates an error with a specific exit code.
13+
type exitCodeError struct {
14+
cause error
15+
code int
16+
}
17+
18+
func (e *exitCodeError) Error() string { return e.cause.Error() }
19+
func (e *exitCodeError) Unwrap() error { return e.cause }
20+
func (e *exitCodeError) ExitCode() int { return e.code }
21+
22+
// WithExitCode wraps cause so main.go exits with the given code.
23+
// Returns nil when cause is nil.
24+
func WithExitCode(cause error, code int) error {
25+
if cause == nil {
26+
return nil
27+
}
28+
29+
return &exitCodeError{cause: cause, code: code}
30+
}
31+
32+
// ExitCodeFor walks the error chain and returns the first ExitCoder's code.
33+
// Falls back to 1 (the historic default) for any error without one.
34+
func ExitCodeFor(err error) int {
35+
if err == nil {
36+
return 0
37+
}
38+
39+
if ec, ok := errors.AsType[ExitCoder](err); ok {
40+
return ec.ExitCode()
41+
}
42+
43+
return 1
44+
}

internal/cmdutil/validate.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,19 @@ const DNS1123SubdomainMaxLength = 253
5454
func IsValidDNS1123Label(s string) bool {
5555
return dns1123LabelRegexp.MatchString(s)
5656
}
57+
58+
// DNS-1123 subdomain: lowercase alphanumerics, '-', and '.' (with each
59+
// dot-separated segment matching the label shape). 1..253 chars. Used for
60+
// Kubernetes resource names (Pipeline, PipelineRun, etc.) which can be up
61+
// to 253 chars even though label values are capped at 63.
62+
var dns1123SubdomainRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
63+
64+
// IsValidDNS1123Subdomain reports whether s matches the DNS-1123 subdomain
65+
// shape (max 253 chars).
66+
func IsValidDNS1123Subdomain(s string) bool {
67+
if len(s) == 0 || len(s) > DNS1123SubdomainMaxLength {
68+
return false
69+
}
70+
71+
return dns1123SubdomainRegexp.MatchString(s)
72+
}

internal/cmdutil/validate_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,37 @@ func TestIsValidDNS1123Label(t *testing.T) {
9898
})
9999
}
100100
}
101+
102+
func TestIsValidDNS1123Subdomain(t *testing.T) {
103+
t.Parallel()
104+
105+
tests := []struct {
106+
name string
107+
in string
108+
want bool
109+
}{
110+
{"single char", "a", true},
111+
{"label-style name", "payments-api", true},
112+
{"63 chars label shape", strings.Repeat("a", 63), true},
113+
{"200 chars with dots", strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 8), true},
114+
{"dotted segments", "a.b.c", true},
115+
{"segment with digits", "build-1.pipeline-2", true},
116+
117+
{"empty rejected", "", false},
118+
{"uppercase rejected", "UPPER", false},
119+
{"leading dash rejected", "-leading", false},
120+
{"trailing dash rejected", "trailing-", false},
121+
{"leading dot rejected", ".leading", false},
122+
{"trailing dot rejected", "trailing.", false},
123+
{"underscore rejected", "has_underscore", false},
124+
{"254 chars over limit", strings.Repeat("a", 254), false},
125+
}
126+
127+
for _, tc := range tests {
128+
t.Run(tc.name, func(t *testing.T) {
129+
if got := IsValidDNS1123Subdomain(tc.in); got != tc.want {
130+
t.Errorf("IsValidDNS1123Subdomain(%q) = %v, want %v", tc.in, got, tc.want)
131+
}
132+
})
133+
}
134+
}

internal/portal/errors.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,40 @@ var (
2020
// ErrEnvNotFound is returned when a Stage (env) lookup within a known
2121
// deployment fails. Wraps ErrNotFound similarly.
2222
ErrEnvNotFound = fmt.Errorf("environment %w", ErrNotFound)
23+
24+
// ErrPipelineNotFound is returned by `pipelinerun start` when the named
25+
// Tekton Pipeline does not exist. Wraps ErrNotFound for generic-not-found
26+
// handling.
27+
ErrPipelineNotFound = fmt.Errorf("pipeline %w", ErrNotFound)
28+
29+
// ErrTriggerTemplateNotFound is returned by `pipelinerun start` when the
30+
// Pipeline carries a TriggerTemplate label but the named TriggerTemplate
31+
// does not exist. Maps to exit 3.
32+
ErrTriggerTemplateNotFound = fmt.Errorf("trigger template %w", ErrNotFound)
33+
34+
// ErrPlatformReject is returned when the platform rejects the start
35+
// request (e.g. missing required Pipeline param). Maps to exit 1.
36+
ErrPlatformReject = errors.New("platform rejected request")
37+
38+
// ErrPermissionDenied is returned for HTTP 403 from the portal start
39+
// endpoint. Maps to exit 1; the message must not leak resource metadata.
40+
ErrPermissionDenied = errors.New("permission denied")
2341
)
42+
43+
// richNotFoundError carries a user-facing message while still matching
44+
// errors.Is(err, sentinel) via Unwrap. Use it when the platform supplies a
45+
// disambiguating message that should be shown verbatim instead of the bare
46+
// sentinel text.
47+
type richNotFoundError struct {
48+
msg string
49+
sentinel error
50+
}
51+
52+
func (e *richNotFoundError) Error() string { return e.msg }
53+
func (e *richNotFoundError) Unwrap() error { return e.sentinel }
54+
55+
// newNotFoundErr constructs a richNotFoundError. sentinel must be ErrNotFound
56+
// or wrap it so generic not-found callers continue to match.
57+
func newNotFoundErr(msg string, sentinel error) error {
58+
return &richNotFoundError{msg: msg, sentinel: sentinel}
59+
}

0 commit comments

Comments
 (0)