From a4a3cea0663c6c1752b6eb74a87ef569c3f33b7e Mon Sep 17 00:00:00 2001 From: Vladyslav Plishchenko <58120045+geekhubuser@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:36:21 +0300 Subject: [PATCH] EPMDEDP-17357: feat: Add auth status contract, Argo CD diagnostics, project versions - auth status exits 1 without a valid session and supports -o json. KRCI_TOKEN is validated by the portal; an expired JWT fails locally. - env get, project deployments, and deployment get/list report Argo CD conditions and operation, and Stage/CDPipeline detailedMessage. - New project versions lists image versions per branch from CodebaseImageStream. Signed-off-by: Sergiy Kulanov Co-authored-by: Sergiy Kulanov --- README.md | 4 +- docs/auth.md | 66 ++- docs/deployment.md | 23 ++ docs/env.md | 62 ++- docs/json-schemas.md | 104 ++++- docs/project.md | 122 +++++- e2e/auth/test-cases.md | 66 +++ e2e/env/test-cases.md | 1 + e2e/project/fixtures.env.example | 7 + e2e/project/test-cases.md | 34 ++ internal/auth/errors.go | 4 + internal/auth/oidc.go | 1 + internal/auth/provider.go | 77 +++- internal/auth/provider_test.go | 67 ++- internal/output/output.go | 6 + internal/output/output_test.go | 9 + internal/portal/config.go | 15 + internal/portal/config_test.go | 47 +++ internal/portal/deployment.go | 45 +- internal/portal/diagnostics_test.go | 297 ++++++++++++++ internal/portal/env.go | 81 +++- internal/portal/env_test.go | 63 ++- internal/portal/project_deployments.go | 3 + internal/portal/project_versions.go | 209 ++++++++++ internal/portal/project_versions_test.go | 297 ++++++++++++++ internal/portal/types.go | 170 +++++--- pkg/cmd/auth/status/status.go | 181 ++++++-- pkg/cmd/auth/status/status_test.go | 476 ++++++++++++++++++++++ pkg/cmd/deployment/get/get.go | 65 ++- pkg/cmd/deployment/get/get_test.go | 65 +++ pkg/cmd/env/get/get.go | 76 +++- pkg/cmd/env/get/get_test.go | 73 ++++ pkg/cmd/project/project.go | 2 + pkg/cmd/project/versions/versions.go | 205 ++++++++++ pkg/cmd/project/versions/versions_test.go | 208 ++++++++++ 35 files changed, 3071 insertions(+), 160 deletions(-) create mode 100644 e2e/auth/test-cases.md create mode 100644 internal/portal/diagnostics_test.go create mode 100644 internal/portal/project_versions.go create mode 100644 internal/portal/project_versions_test.go create mode 100644 pkg/cmd/auth/status/status_test.go create mode 100644 pkg/cmd/deployment/get/get_test.go create mode 100644 pkg/cmd/project/versions/versions.go create mode 100644 pkg/cmd/project/versions/versions_test.go diff --git a/README.md b/README.md index 72e8a22..17a7656 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ stable enough to treat `krci` as a first-class agent tool. | Area | What you get | Docs | |---------------|----------------------------------------------------------------------------------|-------------------------------------------| | Authentication | OIDC + PKCE browser flow, AES-256-GCM token storage, OS keyring integration | [`docs/auth.md`](docs/auth.md) | -| Projects | List and inspect projects, and build a project branch | [`docs/project.md`](docs/project.md) | +| Projects | List and inspect projects, list their built image versions, build a branch | [`docs/project.md`](docs/project.md) | | Deployments | Inspect deployments, their apps, environments, and promotion gates | [`docs/deployment.md`](docs/deployment.md)| | Environments | Inspect envs — deployed apps, infrastructure, gates, and health | [`docs/env.md`](docs/env.md) | | Pipeline runs | List, filter, stream logs, and diagnose failures across Tekton runs | [`docs/pipelinerun.md`](docs/pipelinerun.md) | @@ -117,7 +117,7 @@ start with the area you care about. ``` krci [--portal-url ] auth login | status | logout - project list | get | deployments | build + project list | get | deployments | versions | build deployment list | get pipelinerun list | get (also filters, --logs, --reason) sonar list | get | gate | issues diff --git a/docs/auth.md b/docs/auth.md index 1b01994..0cdaef4 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -49,13 +49,75 @@ Expires: 22 Apr 26 11:22 EEST (22h24m3s) Groups: admin, developers, viewers ``` -Exits non-zero when the token is missing or expired — handy for wrapping in a -shell guard: +### Exit codes + +| State | Exit | Message (stderr) | +|-----------------------------------------|------|----------------------------------------------| +| Valid session | `0` | — | +| No stored session | `1` | `not authenticated: run 'krci auth login'` | +| Session expired and refresh not possible| `1` | `session expired: run 'krci auth login'` | +| `KRCI_TOKEN` is a JWT past its `exp` | `1` | `KRCI_TOKEN has expired: supply a fresh token` | +| `KRCI_TOKEN` or unreadable claims, portal rejects the token | `1` | `not authenticated: the portal rejected the token` | +| `KRCI_TOKEN` or unreadable claims, portal unreachable | `1` | `verifying the token with the portal: ` | + +The stored session comes from `krci auth login` and is checked locally. +`KRCI_TOKEN`, and a stored token whose claims cannot be read, are checked with +one call to the portal through the configured portal URL, so the check needs +the same configuration as every portal command: portal URL, cluster name, and +namespace. An `http://` portal URL for local development works as it does for +the other commands. + +With `KRCI_TOKEN` set, `User`, `Groups`, and `Expires` come from its claims, +not from the stored session. When the portal accepts a token whose claims +cannot be read, such as an opaque OIDC access token, the command exits `0` and +prints `Status: Authenticated (unable to read user info)`; in JSON, +`data.user` is absent and `data.expiresAt` is `null`. + +The non-zero exit makes the command a shell guard: ```bash krci auth status >/dev/null 2>&1 || krci auth login ``` +### JSON output + +```bash +krci auth status -o json +``` + +```json +{ + "schemaVersion": "1", + "data": { + "authenticated": true, + "user": "user@example.com", + "name": "User Name", + "groups": ["admin", "developers", "viewers"], + "expiresAt": "2026-04-22T08:22:00Z" + } +} +``` + +`groups` is always an array (`[]` when the token carries no groups); +`expiresAt` is RFC3339 in UTC, `null` when the token has no expiry; `user` +and `name` are omitted when the stored token's claims cannot be read. + +Without a valid session the command still exits `1` and writes the error +envelope to stdout, so a script can branch on either signal: + +```json +{ + "schemaVersion": "1", + "error": { "message": "not authenticated: run 'krci auth login'" } +} +``` + +```bash +# Scripting — proceed only with a session that lasts another hour +krci auth status -o json | + jq -e '.data.expiresAt | fromdateiso8601 > (now + 3600)' >/dev/null || krci auth login +``` + ## `auth logout` ```bash diff --git a/docs/deployment.md b/docs/deployment.md index d8a075e..4b5b4d0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -59,6 +59,24 @@ Environment columns: - **PROMOTE GATES** — number and type of quality gates that must pass - **NAMESPACE** — target Kubernetes namespace +When the operator records `status.detailed_message` on the CDPipeline, a +`Message:` line follows `Available:`; stages with a message are listed under +a `Messages:` block below the table, `: ` per line. Both are +absent for healthy resources. + +``` +Status: failed +Available: false +Message: failed to create namespace my-pipeline-dev: quota exceeded + +Environments: +ORDER ENV DEPLOY MODE PROMOTE GATES NAMESPACE STATUS +0 dev Manual 1 manual my-pipeline-dev failed + +Messages: + dev: failed to create namespace my-pipeline-dev: quota exceeded +``` + ## JSON output ```bash @@ -91,6 +109,11 @@ krci deployment get my-pipeline -o json } ``` +`detailedMessage` appears on the deployment and on a stage only when the +resource carries `status.detailed_message` (`"status": "failed", +"detailedMessage": "failed to create namespace ...: quota exceeded"`); +`deployment list -o json` carries it on the same terms. + Agent workflow — list namespaces for a pipeline: ```bash diff --git a/docs/env.md b/docs/env.md index 508f1fa..4508511 100644 --- a/docs/env.md +++ b/docs/env.md @@ -133,12 +133,27 @@ The TTY view is layered top-to-bottom: 1. **Header** — `Environment / Deployment / Status / Description / Order`. Status uses `output.StatusColor` (`created` → green, `failed` → red, `in_progress` → yellow). + A `Message` line follows `Status` when the Stage reports + `status.detailed_message`, the operator's reason behind a `failed` status. 2. **Infrastructure** — indented block with the Stage's static placement. `Clean Pipeline: —` when no `cleanTemplate` is set. 3. **Quality Gates** — one bullet per gate; `autotests` gates show their autotest name and branch (e.g. `autotests: smoke-tests (branch: main)`). 4. **Projects sub-table** — column order: `PROJECT`, `STATUS`, `SYNC`, `VERSION`, `IMAGE_SHA`, `INGRESS`. Sorted by project name ascending. +5. **Conditions** — printed only when a project carries an Argo CD condition + or a sync operation that did not succeed: + + ``` + Conditions (2): + - foo: ComparisonError: Failed to load live state: failed to get cluster info for "https://k8s.example.com": dial tcp: i/o timeout + - foo: operation Error: ComparisonError: Failed to load target state + ``` + + This is where the reason behind an `unknown` or `degraded` status lives: + an unreachable target cluster, a chart that fails to render, a + `build/` revision that does not exist. Multi-line messages are + collapsed to one row. Projects sub-table semantics: @@ -181,6 +196,7 @@ krci env get my-pipeline prod -o json "deployment": "my-pipeline", "env": "prod", "status": "created", + "detailedMessage": null, "description": "Production environment", "order": 2, "infrastructure": { @@ -205,7 +221,34 @@ krci env get my-pipeline prod -o json "ingressUrls": ["https://foo.prod.example.com"], "argocdUrl": "/applications/my-pipeline-my-pipeline-prod-foo", "deployedAt": "2026-04-25T08:00:00Z", - "valuesOverride": false + "valuesOverride": false, + "conditions": [], + "operation": { + "phase": "Succeeded", + "message": "successfully synced (all tasks run)", + "startedAt": "2026-04-25T07:59:40Z", + "finishedAt": "2026-04-25T08:00:00Z" + } + }, + { + "name": "bar", + "status": "unknown", + "sync": "unknown", + "version": "2.0.1", + "imageTag": "2.0.1", + "imageDigest": null, + "ingressUrls": [], + "argocdUrl": "/applications/my-pipeline-my-pipeline-prod-bar", + "deployedAt": null, + "valuesOverride": false, + "conditions": [ + { + "type": "ComparisonError", + "message": "Failed to load live state: failed to get cluster info for \"https://k8s.example.com\": dial tcp: i/o timeout", + "lastTransitionTime": "2026-04-25T08:03:12Z" + } + ], + "operation": null }, { "name": "baz", @@ -217,7 +260,9 @@ krci env get my-pipeline prod -o json "ingressUrls": [], "argocdUrl": null, "deployedAt": null, - "valuesOverride": null + "valuesOverride": null, + "conditions": [], + "operation": null } ] } @@ -227,10 +272,18 @@ krci env get my-pipeline prod -o json Field absence rules: - `description`, `cleanPipeline` → `null` when the Stage spec omits them. +- `detailedMessage` → the Stage's `status.detailed_message`, `null` when the + operator reported none. - Per-project dynamic fields (`status`, `sync`, `version`, `imageTag`, `imageDigest`, `argocdUrl`, `deployedAt`, `valuesOverride`) → `null` for registered-but-not-deployed projects. `ingressUrls` is always an array, `[]` when none. +- `conditions[]` is always an array, `[]` when the Application has none; + each entry carries `type`, `message`, and `lastTransitionTime` (`null` + when Argo CD omits it). `operation` → `null` for + registered-but-not-deployed projects and for Applications never synced; + otherwise `phase`, `message`, `startedAt`, `finishedAt`, each of the last + three `null` when absent. - `qualityGates[]` is always an array (empty when none). - In `-o json`, `imageDigest` is the FULL `sha256:...`. The table view shortens it to 15 visible characters under `IMAGE_SHA`. @@ -246,6 +299,11 @@ krci env get my-pipeline prod -o json | krci env get my-pipeline prod -o json | jq -r '.data.projects[] | select(.name=="foo") | .ingressUrls[]' +# Why is a project unknown or degraded? Argo CD conditions and the last operation +krci env get my-pipeline prod -o json | + jq -r '.data.projects[] | .name as $n | (.conditions[] | "\($n): \(.type): \(.message)"), + (select(.operation != null and .operation.phase != "Succeeded") | "\($n): operation \(.operation.phase): \(.operation.message)")' + # Quality-gate names + branches krci env get my-pipeline prod -o json | jq -r '.data.qualityGates[] | "\(.type): \(.stepName) (\(.branchName // "no-branch"))"' diff --git a/docs/json-schemas.md b/docs/json-schemas.md index e288ba9..f554e33 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -345,6 +345,7 @@ single `No environments found.` line to stderr. "deployment": "my-pipeline", "env": "prod", "status": "created", + "detailedMessage": null, "description": "Production environment", "order": 2, "infrastructure": { @@ -373,7 +374,20 @@ single `No environments found.` line to stderr. "ingressUrls": ["https://foo.prod.example.com"], "argocdUrl": "/applications/my-pipeline-prod-foo", "deployedAt": "2026-04-25T08:00:00Z", - "valuesOverride": false + "valuesOverride": false, + "conditions": [ + { + "type": "ComparisonError", + "message": "Failed to load target state: unable to resolve 'build/1.2.0' to a commit SHA", + "lastTransitionTime": "2026-04-25T08:03:12Z" + } + ], + "operation": { + "phase": "Error", + "message": "ComparisonError: Failed to load target state", + "startedAt": "2026-04-25T08:03:00Z", + "finishedAt": "2026-04-25T08:03:12Z" + } } ] } @@ -383,10 +397,16 @@ single `No environments found.` line to stderr. Field absence rules: - `description`, `cleanPipeline` may be `null` when absent on the Stage spec. +- `detailedMessage` is the Stage's `status.detailed_message`, `null` when the + operator reported none. - Per-project dynamic fields (`status`, `sync`, `version`, `imageTag`, `imageDigest`, `argocdUrl`, `deployedAt`, `valuesOverride`) are `null` for projects registered in `CDPipeline.spec.applications` but without a matching `Application` resource. `ingressUrls` is always an array (`[]` when none). +- `conditions[]` is always an array (`[]` when none); `lastTransitionTime` is + `null` when Argo CD omits it. `operation` is `null` for + registered-but-not-deployed projects and for Applications never synced; + `message`, `startedAt`, `finishedAt` are `null` when absent. - In `-o json`, `imageDigest` carries the FULL `sha256:...` digest. The table view shortens it to `sha256:` plus the first 8 hex chars (15 visible chars) under the `IMAGE_SHA` column. @@ -420,7 +440,14 @@ Field absence rules: "triggerType": "Auto", "deployedAt": "2026-04-25T08:00:00Z", "ingressUrls": ["https://foo.dev.example.com"], - "argocdUrl": "/applications/my-pipeline-dev-foo" + "argocdUrl": "/applications/my-pipeline-dev-foo", + "conditions": [], + "operation": { + "phase": "Succeeded", + "message": "successfully synced (all tasks run)", + "startedAt": "2026-04-25T07:59:40Z", + "finishedAt": "2026-04-25T08:00:00Z" + } }, { "deployment": "other-pipe", @@ -436,7 +463,9 @@ Field absence rules: "triggerType": "Auto", "deployedAt": null, "ingressUrls": [], - "argocdUrl": null + "argocdUrl": null, + "conditions": [], + "operation": null } ] } @@ -448,8 +477,9 @@ Rules: - Rows are sorted by `deployment` ascending, then by `Stage.spec.order` ascending. - `deployed: false` rows still carry `cluster`, `namespace`, and `triggerType` from the Stage so the user sees the full footprint of the project even when - no `Application` resource exists yet. Dynamic fields are `null` and - `ingressUrls` is `[]`. + no `Application` resource exists yet. Dynamic fields are `null`, + `ingressUrls` and `conditions` are `[]`, `operation` is `null`. +- `conditions[]` and `operation` follow the `krci env get` rules above. - An empty result is success: `data.rows: []`, exit 0, with `No deployments found for project .` written to stderr in table mode. - "Project missing" and "project deployed nowhere" are indistinguishable from @@ -459,6 +489,70 @@ Rules: appear only on the first row). Hostnames are truncated to 50 visible chars; the OSC 8 hyperlink target keeps the full URL. JSON output is unaffected. +## `krci project versions ` + +```json +{ + "schemaVersion": "1", + "data": { + "project": "payments-api", + "streams": [ + { + "branch": "main", + "image": "registry.example.com/ns/payments-api", + "versions": [ + { "name": "0.1.0-SNAPSHOT.14", "created": "2026-09-08T06:12:40Z", "digest": "sha256:9f1c02ab..." }, + { "name": "0.1.0-SNAPSHOT.13", "created": "2026-09-05T11:47:02Z" } + ] + }, + { + "branch": "release/1.2", + "image": "registry.example.com/ns/payments-api", + "versions": [] + } + ] + } +} +``` + +Rules: + +- `streams[]` is always an array, one entry per `CodebaseImageStream` of the + project, sorted by `branch`; with `--branch` at most one entry. +- `branch` is the git branch name (`release/1.2`, not the operator's + `release-1-2-` resource name). +- `versions[]` is always an array (`[]` for a branch never built), newest + first by `created`. `digest` is omitted when the registry reported none; + in JSON it is the full `sha256:...`. +- An empty `streams` is success (exit `0`); an unknown project is an error + envelope with `project '' not found` and exit `1`. + +## `krci auth status` + +```json +{ + "schemaVersion": "1", + "data": { + "authenticated": true, + "user": "user@example.com", + "name": "User Name", + "groups": ["admin", "developers"], + "expiresAt": "2026-04-22T08:22:00Z" + } +} +``` + +Rules: + +- `authenticated` is always `true` in a success envelope: a missing or + expired session is an error (exit `1`) and produces the error envelope + below with `not authenticated: run 'krci auth login'` or + `session expired: run 'krci auth login'`. +- `groups` is always an array (`[]` when none). +- `expiresAt` is RFC3339 in UTC, `null` when the stored token has no expiry. +- `user` and `name` are omitted when the token claims cannot be decoded; the + session is still valid in that case. + ## Error envelope ```json diff --git a/docs/project.md b/docs/project.md index 3c4e70f..60c6f97 100644 --- a/docs/project.md +++ b/docs/project.md @@ -13,6 +13,7 @@ is currently deployed. | `project list` (`ls`) | List all projects | | `project get ` | Show a single project | | `project deployments ` | Every (deployment, env) row where the project is registered, with health/version | +| `project versions ` | The image versions the build pipeline has pushed, per branch | | `project build ` | Start the build pipeline of a project branch — the Portal's Build button | All accept `-o, --output` with `table` (default) or `json`; `build` also takes @@ -154,7 +155,14 @@ krci project deployments payments-api -o json "triggerType": "Auto", "deployedAt": "2026-04-25T08:00:00Z", "ingressUrls": ["https://payments-api.dev.example.com"], - "argocdUrl": "/applications/my-pipeline-dev-payments-api" + "argocdUrl": "/applications/my-pipeline-dev-payments-api", + "conditions": [], + "operation": { + "phase": "Succeeded", + "message": "successfully synced (all tasks run)", + "startedAt": "2026-04-25T07:59:40Z", + "finishedAt": "2026-04-25T08:00:00Z" + } }, { "deployment": "legacy", @@ -170,13 +178,23 @@ krci project deployments payments-api -o json "triggerType": "Auto", "deployedAt": null, "ingressUrls": [], - "argocdUrl": null + "argocdUrl": null, + "conditions": [], + "operation": null } ] } } ``` +`conditions[]` is always an array and lists the Argo CD `status.conditions` +of the row's Application (`type`, `message`, `lastTransitionTime`); this is +where the reason behind an `unknown` status lives. `operation` summarizes +the last sync (`phase`, `message`, `startedAt`, `finishedAt`) and is `null` +for `deployed: false` rows and for Applications never synced. The table view +does not show either; use `krci env get ` for the +Conditions block. + Empty result is success: `data.rows: []`, exit `0`, with `No deployments found for project .` written to stderr in table mode. "Project missing" and "project deployed nowhere" are intentionally @@ -206,6 +224,106 @@ krci project deployments payments-api -o json | (lowercase alphanumerics + hyphens, no dots, ≤ 253 chars). Invalid input fails with exit `1` before contacting the Portal. +## `project versions` + +Answers "which versions of my project exist, and what is the newest one?" — +the image tags the build pipeline pushed for each branch, read from the +project's `CodebaseImageStream` resources. A version listed here is what a +deployment can pick; a branch that has never been built shows `0`. + +```bash +krci project versions payments-api +``` + +``` +BRANCH VERSIONS LATEST CREATED IMAGE +feature/login 0 - - registry.example.com/ns/payments-api +main 14 0.1.0-SNAPSHOT.14 2026-09-08T06:12:40Z registry.example.com/ns/payments-api +release/1.2 3 1.2.0-SNAPSHOT.3 2026-08-21T14:03:11Z registry.example.com/ns/payments-api +``` + +Rows are sorted by branch name. `--branch` narrows the output to one git +branch and switches to one row per version, newest first: + +```bash +krci project versions payments-api --branch main +``` + +``` +VERSION CREATED DIGEST IMAGE +0.1.0-SNAPSHOT.14 2026-09-08T06:12:40Z sha256:9f1c02ab registry.example.com/ns/payments-api +0.1.0-SNAPSHOT.13 2026-09-05T11:47:02Z sha256:7b2e11cd registry.example.com/ns/payments-api +``` + +`--branch` takes the git branch name as it is (slashes included); the +mapping to the operator's branch resource name happens inside the command. +`DIGEST` is shortened like `IMAGE_SHA` elsewhere (`sha256:` + 8 hex chars); +`-o json` carries the full digest, and no `digest` at all when the registry +did not report one. + +### JSON envelope + +```bash +krci project versions payments-api -o json +``` + +```json +{ + "schemaVersion": "1", + "data": { + "project": "payments-api", + "streams": [ + { + "branch": "feature/login", + "image": "registry.example.com/ns/payments-api", + "versions": [] + }, + { + "branch": "main", + "image": "registry.example.com/ns/payments-api", + "versions": [ + { + "name": "0.1.0-SNAPSHOT.14", + "created": "2026-09-08T06:12:40Z", + "digest": "sha256:9f1c02ab..." + }, + { + "name": "0.1.0-SNAPSHOT.13", + "created": "2026-09-05T11:47:02Z" + } + ] + } + ] + } +} +``` + +`streams[]` is sorted by `branch`; `versions[]` is newest first and always an +array. With `--branch`, `streams[]` holds at most one entry. + +Empty result is success: `data.streams: []`, exit `0`, with +`No versions found for project .` written to stderr in table mode. +With `--branch`, the note `No versions found for branch of project +.` also covers a branch that exists but has never been built. An +unknown project is an error: exit `1` with `project '' not found`, so a +typo is not mistaken for a project that was never built. + +### Scripting examples + +```bash +# Newest version of the main branch — the value to deploy +krci project versions payments-api --branch main -o json | + jq -r '.data.streams[0].versions[0].name' + +# Branches that have never produced an image +krci project versions payments-api -o json | + jq -r '.data.streams[] | select(.versions | length == 0) | .branch' + +# Has version 1.2.0-SNAPSHOT.3 been built? (exit 0 when found) +krci project versions payments-api -o json | + jq -e '[.data.streams[].versions[].name] | index("1.2.0-SNAPSHOT.3")' >/dev/null +``` + ## `project build` Start the build pipeline of a project branch — exactly what the Portal's diff --git a/e2e/auth/test-cases.md b/e2e/auth/test-cases.md new file mode 100644 index 0000000..965638c --- /dev/null +++ b/e2e/auth/test-cases.md @@ -0,0 +1,66 @@ +# `krci auth` — e2e test cases + +Covers the `auth` command group: `login`, `status`, and `logout`. Source: +`pkg/cmd/auth/` and `docs/auth.md`. This file exercises **`status`** — `login` +needs a browser and `logout` destroys the session other rows depend on, so +both stay out of the parallel run. + +`krci auth status` reports the signed-in user and exits `1` without a valid +session, which makes it usable as a shell guard. `-o json` wraps the same +outcome in the `schemaVersion` envelope: `data.authenticated`, `data.user`, +`data.name`, `data.groups`, `data.expiresAt` on success, `error.message` on +failure. + +Every row is a self-contained contract a Haiku agent can execute. See +`../runner.md` for the agent brief and the **expect grammar** reference. + +## Placeholders resolved per run + +| Placeholder | Meaning | Example | +|------------------|----------------------------------------------------------------|-------------------------| +| `{{USER_EMAIL}}` | The e-mail of the account the current session belongs to. | `jane.doe@example.com` | + +The orchestrator fills these; the table never hard-codes them. + +--- + +## 1. Help & discovery (env: `offline`) + +| ID | Command | Env | Setup | Expect | +|----------|----------------------------|---------|-------|---------------------------------------------------------------------------------------------------------------------------| +| AU-H-01 | `krci auth --help` | offline | — | `exit=0; stdout~/Authentication commands/; stdout~/^\s+login\s/; stdout~/^\s+status\s/; stdout~/^\s+logout\s/` | +| AU-H-02 | `krci auth status --help` | offline | — | `exit=0; stdout~/Exits 1 when no session is stored/; stdout~/-o, --output string/; stdout~/krci auth status -o json/` | + +## 2. Argument validation (env: `offline`) + +| ID | Command | Env | Setup | Expect | +|----------|----------------------------------|---------|-------|-------------------------------------------------| +| AU-V-01 | `krci auth status -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| AU-V-02 | `krci auth status --unknown` | offline | — | `exit=1; stderr~/unknown flag: --unknown/` | +| AU-V-03 | `krci auth status extra` | offline | — | `exit=1; stderr~/unknown command "extra"/` | + +## 3. No session (env: `offline`) + +An empty `HOME` has no `~/.config/krci/tokens.enc`, so the command sees no +session without touching the real one. `KRCI_TOKEN` must be unset for these +rows. + +| ID | Command | Env | Setup | Expect | +|----------|----------------------------------------------------------------|---------|------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| AU-N-01 | `HOME=$(mktemp -d) krci auth status` | offline | `KRCI_TOKEN` unset | `exit=1; stderr~/not authenticated: run 'krci auth login'/; stdout_empty` | +| AU-N-02 | `HOME=$(mktemp -d) krci auth status -o json` | offline | `KRCI_TOKEN` unset | `exit=1; stdout_json.schemaVersion=1; stdout_json.error.message:exists; stdout~/not authenticated/; stdout!~/"data"/` | +| AU-N-03 | `HOME=$(mktemp -d) KRCI_TOKEN=eyJhbGciOiJub25lIn0.eyJleHAiOjF9.x krci auth status -o json` | offline | JWT with `exp` = 1 | `exit=1; stdout_json.error.message~/KRCI_TOKEN has expired/; stdout!~/"data"/` | +| AU-N-04 | `HOME=$(mktemp -d) KRCI_TOKEN=not-a-token KRCI_PORTAL_URL=http://127.0.0.1:1 KRCI_CLUSTER_NAME=c KRCI_NAMESPACE=ns krci auth status -o json` | offline | nothing listens on port 1 | `exit=1; stdout_json.error.message~/verifying the token with the portal/; stdout!~/"data"/` | +| AU-N-05 | `HOME=$(mktemp -d) KRCI_TOKEN=eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ImZvcmdlZEBleGFtcGxlLmNvbSIsImV4cCI6NDEwMjQ0NDgwMH0.x KRCI_PORTAL_URL=http://127.0.0.1:1 KRCI_CLUSTER_NAME=c KRCI_NAMESPACE=ns krci auth status -o json` | offline | unsigned JWT, `exp` in 2100; nothing listens on port 1 | `exit=1; stdout_json.error.message~/verifying the token with the portal/; stdout!~/"data"/` | + +## 4. Valid session (env: `auth`) + +Requires a session created with `krci auth login` beforehand. + +| ID | Command | Env | Setup | Expect | +|----------|----------------------------------|------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| AU-S-01 | `krci auth status` | auth | authenticated | `exit=0; stdout~/^User:\s+{{USER_EMAIL}}$/; stdout~/^Status:\s+Authenticated$/` | +| AU-S-02 | `krci auth status -o json` | auth | authenticated | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.authenticated=true; stdout_json.data.user={{USER_EMAIL}}; stdout_json.data.groups:exists` | +| AU-S-03 | `krci auth status -o json` | auth | authenticated | `exit=0; stdout_json.data.expiresAt:exists; stdout~/"expiresAt": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"/` | +| AU-S-04 | `KRCI_TOKEN=not-a-token krci auth status -o json` | auth | portal URL configured by the login | `exit=1; stdout_json.error.message~/the portal rejected the token/; stdout!~/"data"/` | +| AU-S-05 | `KRCI_TOKEN=eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ImZvcmdlZEBleGFtcGxlLmNvbSIsImV4cCI6NDEwMjQ0NDgwMH0.x krci auth status -o json` | auth | unsigned JWT, `exp` in 2100 | `exit=1; stdout_json.error.message~/the portal rejected the token/; stdout!~/"data"/` | diff --git a/e2e/env/test-cases.md b/e2e/env/test-cases.md index c2812f9..1c3d771 100644 --- a/e2e/env/test-cases.md +++ b/e2e/env/test-cases.md @@ -96,6 +96,7 @@ Verifies every documented field is present on at least one row. | ENV-G-02 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}} -o json` | portal | env exists | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.deployment={{DEPLOYMENT_OK}}; stdout_json.data.env={{ENV_OK}}; stdout_json.data.infrastructure:exists; stdout_json.data.qualityGates:exists; stdout_json.data.projects:exists` | | ENV-G-03 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}} -o json` | portal | env exists with >=1 project | `exit=0; stdout_json.data.projects.0.name:exists; stdout_json.data.projects.0.ingressUrls:exists` | | ENV-G-04 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}} -o json` | portal | env exists | `exit=0; stdout_json.data.infrastructure.cluster:exists; stdout_json.data.infrastructure.namespace:exists; stdout_json.data.infrastructure.triggerType:exists; stdout_json.data.infrastructure.deployPipeline:exists` | +| ENV-G-05 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}} -o json` | portal | env exists with >=1 project | `exit=0; stdout_json.data.detailedMessage:exists; stdout_json.data.projects.0.conditions:exists; stdout_json.data.projects.0.operation:exists` | ## 6. `get` — sub-table layout (env: `portal`) diff --git a/e2e/project/fixtures.env.example b/e2e/project/fixtures.env.example index 50b62c6..cab1a61 100644 --- a/e2e/project/fixtures.env.example +++ b/e2e/project/fixtures.env.example @@ -33,3 +33,10 @@ PROJECT_BUILD_BRANCH=main # A codebase whose CodebaseBranch status is not `created`. PROJECT_NOT_READY=test-dotnet-app + +# A codebase with at least one built branch (an image stream that carries tags). +# ./dist/krci project versions -o json | jq '[.data.streams[] | select(.versions | length > 0)] | length' +PROJECT_WITH_VERSIONS=payments-api + +# A git branch of PROJECT_WITH_VERSIONS that has been built at least once. +PROJECT_VERSIONS_BRANCH=main diff --git a/e2e/project/test-cases.md b/e2e/project/test-cases.md index 91e9a7c..df1be58 100644 --- a/e2e/project/test-cases.md +++ b/e2e/project/test-cases.md @@ -20,6 +20,10 @@ and the branch. `--branch` defaults to the project's default branch, the project-derived params are reserved, and `--dry-run` renders the manifest without creating anything. +`krci project versions ` lists the image versions the build +pipeline pushed, one row per branch (`PROJ-V-NN`); `--branch` lists every +version of one git branch, newest first. Read-only. + Every row is a self-contained contract a Haiku agent can execute. See `../runner.md` for the agent brief and the **expect grammar** reference. @@ -36,6 +40,8 @@ Every row is a self-contained contract a Haiku agent can execute. See | `{{PROJECT_BUILD}}` | A codebase with a build pipeline whose branch status is `created` (buildable). | `test-go-app` | | `{{PROJECT_BUILD_BRANCH}}` | The git branch of `{{PROJECT_BUILD}}` to build. | `main` | | `{{PROJECT_NOT_READY}}` | A codebase whose CodebaseBranch status is not `created`. | `test-dotnet-app` | +| `{{PROJECT_WITH_VERSIONS}}` | A codebase with at least one CodebaseImageStream that carries tags (a built branch). | `payments-api` | +| `{{PROJECT_VERSIONS_BRANCH}}` | A git branch of `{{PROJECT_WITH_VERSIONS}}` that has been built at least once. | `main` | The orchestrator fills these; the table never hard-codes them. @@ -78,6 +84,7 @@ code is 0 and the JSON envelope matches the documented shape | PROJ-D-P-01 | `krci project deployments {{PROJECT_DEPLOYED}}` | portal | project deployed somewhere | `exit=0; stdout~/^DEPLOYMENT/; stdout~/ENV/; stdout~/STATUS/; stdout~/SYNC/; stdout~/VERSION/; stdout~/IMAGE_SHA/; stdout~/CLUSTER/; stdout~/NAMESPACE/; stdout~/INGRESS/` | | PROJ-D-P-02 | `krci project deployments {{PROJECT_DEPLOYED}} -o json` | portal | project deployed somewhere | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.project={{PROJECT_DEPLOYED}}; stdout_json.data.rows:exists` | | PROJ-D-P-03 | `krci project deployments {{PROJECT_DEPLOYED}} -o json` | portal | project deployed in DEPLOYMENT_OK/ENV_OK | `exit=0; stdout_json.data.rows.0.deployment:exists; stdout_json.data.rows.0.env:exists; stdout_json.data.rows.0.deployed:exists; stdout_json.data.rows.0.cluster:exists; stdout_json.data.rows.0.namespace:exists; stdout_json.data.rows.0.triggerType:exists` | +| PROJ-D-P-04 | `krci project deployments {{PROJECT_DEPLOYED}} -o json` | portal | project deployed somewhere | `exit=0; stdout_json.data.rows.0.conditions:exists; stdout_json.data.rows.0.operation:exists` | ## 4. Empty results (env: `portal`) @@ -155,3 +162,30 @@ callers. > Rows in section 9 depend on each other and create a PipelineRun — the > orchestrator must run them serially, in order, **after** all other > sections. + +## 10. `project versions` (env: `offline`) + +| ID | Command | Env | Setup | Expect | +|------------|-------------------------------------------------------------|---------|-------|---------------------------------------------------------------------------------------------------------------------| +| PROJ-V-01 | `krci project versions --help` | offline | — | `exit=0; stdout~/^\s+krci project versions \[flags\]$/; stdout~/--branch string/; stdout~/-o, --output string/` | +| PROJ-V-02 | `krci project versions` | offline | — | `exit=1; stderr~/requires a project name/` | +| PROJ-V-03 | `krci project versions a b` | offline | — | `exit=1; stderr~/requires a project name/` | +| PROJ-V-04 | `krci project versions BAD_NAME` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 name/` | +| PROJ-V-05 | `krci project versions my-app -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| PROJ-V-06 | `krci project versions my-app --branch $(printf 'b%.0s' $(seq 254))` | offline | — | `exit=1; stderr~/--branch must be at most 253 characters/` | +| PROJ-V-07 | `krci project` | offline | — | `exit=0; stdout~/^\s+versions\s/` | + +## 11. `project versions` (env: `portal`) + +Read-only: every row lists existing image streams and creates nothing. + +| ID | Command | Env | Setup | Expect | +|------------|-------------------------------------------------------------------------------------------------------|--------|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| PROJ-V-08 | `krci project versions {{PROJECT_WITH_VERSIONS}}` | portal | project has a built branch | `exit=0; stdout~/^BRANCH/; stdout~/VERSIONS/; stdout~/LATEST/; stdout~/CREATED/; stdout~/IMAGE/; stdout~/{{PROJECT_VERSIONS_BRANCH}}/` | +| PROJ-V-09 | `krci project versions {{PROJECT_WITH_VERSIONS}} -o json` | portal | project has a built branch | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.project={{PROJECT_WITH_VERSIONS}}; stdout_json.data.streams:exists; stdout_json.data.streams.0.branch:exists; stdout_json.data.streams.0.image:exists; stdout_json.data.streams.0.versions:exists` | +| PROJ-V-10 | `krci project versions {{PROJECT_WITH_VERSIONS}} --branch {{PROJECT_VERSIONS_BRANCH}}` | portal | branch built at least once | `exit=0; stdout~/^VERSION/; stdout~/CREATED/; stdout~/DIGEST/; stdout~/IMAGE/` | +| PROJ-V-11 | `krci project versions {{PROJECT_WITH_VERSIONS}} --branch {{PROJECT_VERSIONS_BRANCH}} -o json` | portal | branch built at least once | `exit=0; stdout_json.data.streams:len=1; stdout_json.data.streams.0.branch={{PROJECT_VERSIONS_BRANCH}}; stdout_json.data.streams.0.versions.0.name:exists; stdout_json.data.streams.0.versions.0.created:exists` | +| PROJ-V-12 | `krci project versions {{PROJECT_WITH_VERSIONS}} --branch does-not-exist-branch-xyz` | portal | project exists, branch does not | `exit=0; stderr~/No versions found for branch does-not-exist-branch-xyz of project {{PROJECT_WITH_VERSIONS}}\./` | +| PROJ-V-13 | `krci project versions {{PROJECT_WITH_VERSIONS}} --branch does-not-exist-branch-xyz -o json` | portal | project exists, branch does not | `exit=0; stdout_json.data.streams:len=0` | +| PROJ-V-14 | `krci project versions {{PROJECT_MISSING}}` | portal | name does not exist | `exit=1; stderr~/project '{{PROJECT_MISSING}}' not found/` | +| PROJ-V-15 | `krci project versions {{PROJECT_MISSING}} -o json` | portal | name does not exist | `exit=1; stdout_json.schemaVersion=1; stdout_json.error.message:exists` | diff --git a/internal/auth/errors.go b/internal/auth/errors.go index bfd1416..fa8fe81 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -9,4 +9,8 @@ var ( ErrNotAuthenticated = errors.New("not authenticated: run 'krci auth login'") ErrTokenExpired = errors.New("token expired") ErrRefreshFailed = errors.New("token refresh failed") + + // ErrEnvTokenExpired is returned when KRCI_TOKEN is a JWT whose exp claim + // has passed. KRCI_TOKEN is never refreshed; the caller supplies a new one. + ErrEnvTokenExpired = errors.New("KRCI_TOKEN has expired: supply a fresh token") ) diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index cdf1e77..f172468 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -23,6 +23,7 @@ type UserInfo struct { Sub string `json:"sub"` Groups []string `json:"groups"` ExpiresAt time.Time `json:"-"` // set from token expiry, not from JWT claims + FromEnv bool `json:"-"` // true when the claims come from KRCI_TOKEN, not the stored session } // ValidateIssuerURL ensures the issuer URL is well-formed and uses HTTPS. diff --git a/internal/auth/provider.go b/internal/auth/provider.go index a07dd23..e90954d 100644 --- a/internal/auth/provider.go +++ b/internal/auth/provider.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" @@ -25,10 +26,14 @@ type TokenProvider interface { Login(ctx context.Context) error // Logout clears stored credentials. Logout() error - // UserInfo returns cached user claims from the stored ID token. + // UserInfo returns the claims of the token GetToken resolves: KRCI_TOKEN + // when set, the stored ID token otherwise. UserInfo() (*UserInfo, error) } +// envTokenVar names the environment variable that overrides the stored token. +const envTokenVar = "KRCI_TOKEN" + type tokenProvider struct { store token.Store cfg *config.Config @@ -44,8 +49,14 @@ func NewTokenProvider(store token.Store, cfg *config.Config) *tokenProvider { // GetToken returns a valid ID token for portal Bearer auth. // Precedence: KRCI_TOKEN env → cached ID token → refresh → error. +// KRCI_TOKEN is rejected only when it is a JWT with an exp claim in the past; +// an opaque token or one without exp is passed through for the portal to judge. func (p *tokenProvider) GetToken(ctx context.Context) (string, error) { - if t := os.Getenv("KRCI_TOKEN"); t != "" { + if t := os.Getenv(envTokenVar); t != "" { + if exp, ok := jwtExpiry(t); ok && !time.Now().Before(exp) { + return "", ErrEnvTokenExpired + } + return t, nil } @@ -102,8 +113,25 @@ func (p *tokenProvider) Logout() error { return p.store.Clear() } -// UserInfo returns user claims by decoding the stored ID token (unverified, display only). +// UserInfo returns user claims by decoding the token GetToken resolves +// (unverified, display only). For KRCI_TOKEN, FromEnv is true and ExpiresAt +// comes from its exp claim, zero when the token has none. func (p *tokenProvider) UserInfo() (*UserInfo, error) { + if t := os.Getenv(envTokenVar); t != "" { + info, err := decodeIDTokenClaims(t) + if err != nil { + return nil, err + } + + if exp, ok := jwtExpiry(t); ok { + info.ExpiresAt = exp + } + + info.FromEnv = true + + return info, nil + } + stored, err := p.store.Load() if err != nil { if errors.Is(err, token.ErrNoToken) { @@ -157,14 +185,9 @@ func (p *tokenProvider) refresh(ctx context.Context, stored *token.StoredToken) // decodeIDTokenClaims extracts claims from a JWT without verification (display only). func decodeIDTokenClaims(rawIDToken string) (*UserInfo, error) { - parts := strings.Split(rawIDToken, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid ID token format") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + payload, err := jwtPayload(rawIDToken) if err != nil { - return nil, fmt.Errorf("decoding ID token payload: %w", err) + return nil, err } var claims UserInfo @@ -174,3 +197,37 @@ func decodeIDTokenClaims(rawIDToken string) (*UserInfo, error) { return &claims, nil } + +// jwtExpiry returns the exp claim of a JWT, unverified. ok is false for a +// token that is not a JWT or carries no exp. +func jwtExpiry(rawToken string) (time.Time, bool) { + payload, err := jwtPayload(rawToken) + if err != nil { + return time.Time{}, false + } + + var claims struct { + Exp float64 `json:"exp"` + } + + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp <= 0 { + return time.Time{}, false + } + + return time.Unix(int64(claims.Exp), 0), true +} + +// jwtPayload returns the decoded payload segment of a JWT. +func jwtPayload(rawToken string) ([]byte, error) { + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid ID token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decoding ID token payload: %w", err) + } + + return payload, nil +} diff --git a/internal/auth/provider_test.go b/internal/auth/provider_test.go index 021b79e..5529699 100644 --- a/internal/auth/provider_test.go +++ b/internal/auth/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "errors" + "fmt" "testing" "time" @@ -14,9 +15,9 @@ import ( "github.com/KubeRocketCI/cli/internal/token" ) -// fakeJWT builds a test JWT from raw JSON header and payload (alg:none, no signature). -func fakeJWT(header, payload string) string { - h := base64.RawURLEncoding.EncodeToString([]byte(header)) +// fakeJWT builds an unsigned alg:none test JWT from a raw JSON payload. +func fakeJWT(payload string) string { + h := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) p := base64.RawURLEncoding.EncodeToString([]byte(payload)) return h + "." + p + "." @@ -69,6 +70,60 @@ func TestGetTokenEnvVarTakesPrecedence(t *testing.T) { assert.Equal(t, "env-token-value", tok) } +func TestGetTokenEnvVarExpired(t *testing.T) { + expired := time.Now().Add(-time.Minute).Unix() + t.Setenv("KRCI_TOKEN", fakeJWT(fmt.Sprintf(`{"sub":"ci","exp":%d}`, expired))) + + store := &mockStore{ + tok: &token.StoredToken{IDToken: "cached-id-token", ExpiresAt: time.Now().Add(time.Hour)}, + } + + tp := NewTokenProvider(store, &config.Config{}) + _, err := tp.GetToken(context.Background()) + require.ErrorIs(t, err, ErrEnvTokenExpired) +} + +func TestGetTokenEnvVarUnexpiredJWT(t *testing.T) { + valid := time.Now().Add(time.Hour).Unix() + envTok := fakeJWT(fmt.Sprintf(`{"sub":"ci","exp":%d}`, valid)) + t.Setenv("KRCI_TOKEN", envTok) + + tp := NewTokenProvider(&mockStore{}, &config.Config{}) + tok, err := tp.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, envTok, tok) +} + +func TestUserInfoFromEnvToken(t *testing.T) { + exp := time.Now().Add(time.Hour).Truncate(time.Second) + t.Setenv("KRCI_TOKEN", fakeJWT(fmt.Sprintf(`{"email":"ci@example.com","groups":["developers"],"exp":%d}`, exp.Unix()))) + + store := &mockStore{ + tok: &token.StoredToken{ + IDToken: fakeJWT(`{"email":"stored@example.com"}`), + ExpiresAt: time.Now().Add(2 * time.Hour), + }, + } + + tp := NewTokenProvider(store, &config.Config{}) + info, err := tp.UserInfo() + require.NoError(t, err) + + assert.Equal(t, "ci@example.com", info.Email) + assert.Equal(t, []string{"developers"}, info.Groups) + assert.True(t, exp.Equal(info.ExpiresAt), "ExpiresAt = %v, want %v", info.ExpiresAt, exp) + assert.True(t, info.FromEnv, "claims of KRCI_TOKEN must be marked FromEnv") +} + +func TestUserInfoFromOpaqueEnvToken(t *testing.T) { + t.Setenv("KRCI_TOKEN", "opaque-token") + + tp := NewTokenProvider(&mockStore{}, &config.Config{}) + _, err := tp.UserInfo() + require.Error(t, err) + assert.NotErrorIs(t, err, ErrNotAuthenticated) +} + func TestGetToken(t *testing.T) { t.Parallel() @@ -167,10 +222,7 @@ func TestUserInfoNotAuthenticated(t *testing.T) { func TestUserInfoDecodesIDToken(t *testing.T) { t.Parallel() - idToken := fakeJWT( - `{"alg":"none"}`, - `{"email":"test@example.com","name":"Test User","sub":"123","groups":["admin"]}`, - ) + idToken := fakeJWT(`{"email":"test@example.com","name":"Test User","sub":"123","groups":["admin"]}`) store := &mockStore{ tok: &token.StoredToken{ @@ -187,6 +239,7 @@ func TestUserInfoDecodesIDToken(t *testing.T) { assert.Equal(t, "Test User", info.Name) assert.Equal(t, []string{"admin"}, info.Groups) assert.False(t, info.ExpiresAt.IsZero(), "ExpiresAt should be set from stored token") + assert.False(t, info.FromEnv, "claims of the stored session must not be marked FromEnv") } func TestValidateIssuerURL(t *testing.T) { diff --git a/internal/output/output.go b/internal/output/output.go index 3ec475a..04e4929 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -143,6 +143,12 @@ func Truncate(s string, maxWidth int) string { return s[:maxWidth-3] + "..." } +// SingleLine collapses every whitespace run, newlines included, into one +// space so a multi-line operator message fits one display row. +func SingleLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + func Hyperlink(text, url string) string { if url == "" { return text diff --git a/internal/output/output_test.go b/internal/output/output_test.go index f610986..f76eed5 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -236,3 +236,12 @@ func TestPrintJSONErrorEnvelope_WrappedError(t *testing.T) { t.Errorf("error.message = %q, want %q", got.Error.Message, "x: y") } } + +func TestSingleLine(t *testing.T) { + t.Parallel() + + got := SingleLine(" rpc error:\n\tcode = Unknown desc = chart\r\nfailed \n") + if want := "rpc error: code = Unknown desc = chart failed"; got != want { + t.Errorf("SingleLine = %q, want %q", got, want) + } +} diff --git a/internal/portal/config.go b/internal/portal/config.go index 5b16e90..5e6c850 100644 --- a/internal/portal/config.go +++ b/internal/portal/config.go @@ -1,12 +1,15 @@ package portal import ( + "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" ) // ClusterConfig holds configuration returned by the authenticated config endpoint. @@ -103,6 +106,18 @@ func fetchClusterConfig(portalURL, token string) (*ClusterConfig, error) { return &cfg, nil } +// VerifySession calls the authenticated /rest/v1/config endpoint through +// client, so the portal validates the client's bearer token. ErrUnauthorized +// means the portal rejected the token. +func VerifySession(ctx context.Context, client *restapi.ClientWithResponses) error { + resp, err := client.ConfigGetWithResponse(ctx) + if err != nil { + return fmt.Errorf("requesting cluster config: %w", err) + } + + return checkResponse(resp.StatusCode(), resp.Body) +} + func validatePortalURL(portalURL string) error { u, err := url.Parse(portalURL) if err != nil || u.Scheme != "https" || u.Host == "" { diff --git a/internal/portal/config_test.go b/internal/portal/config_test.go index 389e6ca..aafa52f 100644 --- a/internal/portal/config_test.go +++ b/internal/portal/config_test.go @@ -1,12 +1,15 @@ package portal import ( + "context" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" ) func TestFetchOIDCConfig_RequiresHTTPS(t *testing.T) { @@ -182,3 +185,47 @@ func TestRestURL(t *testing.T) { assert.Equal(t, "https://portal.example.com/rest/v1/config", restURL("https://portal.example.com", "/v1/config")) } + +func TestVerifySession(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + wantErr error + }{ + {name: "accepted", status: http.StatusOK}, + {name: "rejected", status: http.StatusUnauthorized, wantErr: ErrUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(`{"clusterName":"in-cluster","defaultNamespace":"platform","sonarWebUrl":"","dependencyTrackWebUrl":""}`)) + })) + defer srv.Close() + + // httptest serves plain HTTP: the check follows the configured + // portal URL like every other portal call. + client, err := restapi.NewClientWithResponses(srv.URL + "/rest") + require.NoError(t, err) + + err = VerifySession(context.Background(), client) + assert.Equal(t, "/rest/v1/config", gotPath) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/portal/deployment.go b/internal/portal/deployment.go index f7d86a3..12af0b2 100644 --- a/internal/portal/deployment.go +++ b/internal/portal/deployment.go @@ -207,12 +207,13 @@ func mapDeployment(item k8sItem) Deployment { status := ptr.Deref(item.Status, nil) return Deployment{ - Name: item.Metadata.Name, - Namespace: ptr.Deref(item.Metadata.Namespace, ""), - Applications: stringSliceVal(spec, "applications"), - Description: stringVal(spec, "description"), - Status: stringVal(status, "status"), - Available: availableVal(status), + Name: item.Metadata.Name, + Namespace: ptr.Deref(item.Metadata.Namespace, ""), + Applications: stringSliceVal(spec, "applications"), + Description: stringVal(spec, "description"), + Status: stringVal(status, "status"), + DetailedMessage: stringVal(status, "detailed_message"), + Available: availableVal(status), } } @@ -221,12 +222,13 @@ func mapDeploymentDetail(meta restapi.K8sGet_200_Metadata, spec, status *map[str st := ptr.Deref(status, nil) return DeploymentDetail{ - Name: meta.Name, - Namespace: ptr.Deref(meta.Namespace, ""), - Applications: stringSliceVal(s, "applications"), - Description: stringVal(s, "description"), - Status: stringVal(st, "status"), - Available: availableVal(st), + Name: meta.Name, + Namespace: ptr.Deref(meta.Namespace, ""), + Applications: stringSliceVal(s, "applications"), + Description: stringVal(s, "description"), + Status: stringVal(st, "status"), + DetailedMessage: stringVal(st, "detailed_message"), + Available: availableVal(st), } } @@ -235,15 +237,16 @@ func mapStage(item k8sItem) Stage { status := ptr.Deref(item.Status, nil) return Stage{ - Name: stringVal(spec, "name"), - Order: int64Val(spec, "order"), - TriggerType: stringVal(spec, "triggerType"), - QualityGates: extractQualityGates(spec), - Namespace: stringVal(spec, "namespace"), - ClusterName: stringVal(spec, "clusterName"), - Description: stringVal(spec, "description"), - Status: stringVal(status, "status"), - Available: availableVal(status), + Name: stringVal(spec, "name"), + Order: int64Val(spec, "order"), + TriggerType: stringVal(spec, "triggerType"), + QualityGates: extractQualityGates(spec), + Namespace: stringVal(spec, "namespace"), + ClusterName: stringVal(spec, "clusterName"), + Description: stringVal(spec, "description"), + Status: stringVal(status, "status"), + DetailedMessage: stringVal(status, "detailed_message"), + Available: availableVal(status), } } diff --git a/internal/portal/diagnostics_test.go b/internal/portal/diagnostics_test.go new file mode 100644 index 0000000..9c505ef --- /dev/null +++ b/internal/portal/diagnostics_test.go @@ -0,0 +1,297 @@ +package portal + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" +) + +func cdPipelineGetJSONWithMessage(name string, applications []string, message string) string { + envelope := map[string]any{ + "apiVersion": "v1", + "kind": "CDPipeline", + "metadata": map[string]any{"name": name, "namespace": "ns"}, + "spec": map[string]any{"applications": stringsToAny(applications)}, + "status": statusWithMessage("created", message), + } + + return mustJSON(envelope) +} + +// newDeploymentServiceForTest wires up a DeploymentService against an httptest server. +func newDeploymentServiceForTest(t *testing.T, rec *envTestRecorder) (*DeploymentService, func()) { + t.Helper() + + url, closer := newEnvTestServer(t, rec) + + client, err := restapi.NewClientWithResponses(url + "/rest") + if err != nil { + closer() + t.Fatalf("new client: %v", err) + } + + return NewDeploymentService(client, "in-cluster", "ns"), closer +} + +func TestEnvService_Get_ArgoDiagnostics(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "my-pipeline-dev", deployment: "my-pipeline", env: "dev", cluster: "remote", namespace: "my-pipeline-dev", trigger: "Auto", order: 0, status: "failed", message: "namespace creation failed"}, + }), + "Application": applicationListJSON([]applicationStub{ + { + appName: "foo", pipeline: "my-pipeline", stage: "dev", health: "Unknown", sync: "Unknown", + conditions: []conditionStub{{condType: "ComparisonError", message: "Failed to load live state", since: "2026-09-21T10:00:00Z"}}, + opPhase: "Error", + opMessage: "cluster unreachable", + opFinishedAt: "2026-09-21T10:01:00Z", + }, + {appName: "bar", pipeline: "my-pipeline", stage: "dev", health: "Healthy", sync: "Synced", imageRepo: "registry/bar", imageTag: "2.0.1"}, + }), + }, + getByName: map[string]string{ + "my-pipeline": cdPipelineGetJSON("my-pipeline", []string{"foo", "bar", "baz"}), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + d, err := svc.Get(context.Background(), "my-pipeline", "dev") + if err != nil { + t.Fatalf("Get error: %v", err) + } + + if d.DetailedMessage == nil || *d.DetailedMessage != "namespace creation failed" { + t.Errorf("detailedMessage = %v, want the Stage status.detailed_message", d.DetailedMessage) + } + + byName := map[string]EnvProject{} + for _, p := range d.Projects { + byName[p.Name] = p + } + + foo := byName["foo"] + if len(foo.Conditions) != 1 { + t.Fatalf("foo.conditions = %+v, want 1 entry", foo.Conditions) + } + + if foo.Conditions[0].Type != "ComparisonError" || foo.Conditions[0].Message != "Failed to load live state" { + t.Errorf("foo.conditions[0] = %+v", foo.Conditions[0]) + } + + if foo.Conditions[0].LastTransitionTime == nil || *foo.Conditions[0].LastTransitionTime != "2026-09-21T10:00:00Z" { + t.Errorf("foo.conditions[0].lastTransitionTime = %v", foo.Conditions[0].LastTransitionTime) + } + + if foo.Operation == nil || foo.Operation.Phase != "Error" { + t.Fatalf("foo.operation = %+v, want phase Error", foo.Operation) + } + + if foo.Operation.Message == nil || *foo.Operation.Message != "cluster unreachable" { + t.Errorf("foo.operation.message = %v", foo.Operation.Message) + } + + if foo.DeployedAt == nil || *foo.DeployedAt != "2026-09-21T10:01:00Z" { + t.Errorf("foo.deployedAt = %v, want the operation finishedAt", foo.DeployedAt) + } + + for _, name := range []string{"bar", "baz"} { + p := byName[name] + if p.Conditions == nil || len(p.Conditions) != 0 { + t.Errorf("%s.conditions = %+v, want an empty array", name, p.Conditions) + } + + if p.Operation != nil { + t.Errorf("%s.operation = %+v, want null", name, p.Operation) + } + } + + raw, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + for _, want := range []string{`"conditions":[]`, `"operation":null`, `"detailedMessage":"namespace creation failed"`} { + if !strings.Contains(string(raw), want) { + t.Errorf("JSON missing %s: %s", want, raw) + } + } +} + +func TestEnvService_Get_NoDetailedMessage(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "billing-dev", deployment: "billing", env: "dev", cluster: "in-cluster", namespace: "billing-dev", trigger: "Auto", order: 0, status: "created"}, + }), + "Application": applicationListJSON(nil), + }, + getByName: map[string]string{ + "billing": cdPipelineGetJSON("billing", []string{"foo"}), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + d, err := svc.Get(context.Background(), "billing", "dev") + if err != nil { + t.Fatalf("Get error: %v", err) + } + + if d.DetailedMessage != nil { + t.Errorf("detailedMessage = %q, want nil without status.detailed_message", *d.DetailedMessage) + } + + raw, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + if !strings.Contains(string(raw), `"detailedMessage":null`) { + t.Errorf("JSON should carry detailedMessage:null, got %s", raw) + } +} + +func TestDeploymentByProjectService_List_ArgoDiagnostics(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Application": applicationListJSON([]applicationStub{ + { + appName: "foo", pipeline: "my-pipeline", stage: "dev", health: "Unknown", sync: "Unknown", + conditions: []conditionStub{{condType: "ComparisonError", message: "unable to resolve 'build/NaN' to a commit SHA"}}, + opPhase: "Error", + opMessage: "manifest generation failed", + }, + }), + "Stage": stageListJSON([]stageStub{ + {name: "my-pipeline-dev", deployment: "my-pipeline", env: "dev", cluster: "in-cluster", namespace: "my-pipeline-dev", trigger: "Auto", order: 0, status: "created"}, + {name: "my-pipeline-qa", deployment: "my-pipeline", env: "qa", cluster: "in-cluster", namespace: "my-pipeline-qa", trigger: "Manual", order: 1, status: "created"}, + }), + "CDPipeline": cdPipelineListJSON([]cdPipelineStub{ + {name: "my-pipeline", applications: []string{"foo"}}, + }), + }, + } + + svc, closer := newDeploymentByProjectServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background(), "foo") + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2: %+v", len(rows), rows) + } + + dev := rows[0] + if len(dev.Conditions) != 1 || dev.Conditions[0].Type != "ComparisonError" { + t.Errorf("dev.conditions = %+v, want the ComparisonError", dev.Conditions) + } + + if dev.Operation == nil || dev.Operation.Phase != "Error" || dev.Operation.Message == nil { + t.Errorf("dev.operation = %+v, want phase Error with a message", dev.Operation) + } + + qa := rows[1] + if qa.Conditions == nil || len(qa.Conditions) != 0 { + t.Errorf("qa.conditions = %+v, want an empty array on a not-deployed row", qa.Conditions) + } + + if qa.Operation != nil { + t.Errorf("qa.operation = %+v, want null on a not-deployed row", qa.Operation) + } +} + +func TestDeploymentService_Get_DetailedMessage(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "my-pipeline-dev", deployment: "my-pipeline", env: "dev", cluster: "in-cluster", namespace: "my-pipeline-dev", trigger: "Auto", order: 0, status: "failed", message: "quota exceeded"}, + {name: "my-pipeline-qa", deployment: "my-pipeline", env: "qa", cluster: "in-cluster", namespace: "my-pipeline-qa", trigger: "Manual", order: 1, status: "created"}, + }), + }, + getByName: map[string]string{ + "my-pipeline": cdPipelineGetJSONWithMessage("my-pipeline", []string{"foo"}, "gitops repository unreachable"), + }, + } + + svc, closer := newDeploymentServiceForTest(t, rec) + defer closer() + + d, err := svc.Get(context.Background(), "my-pipeline") + if err != nil { + t.Fatalf("Get error: %v", err) + } + + if d.DetailedMessage != "gitops repository unreachable" { + t.Errorf("detailedMessage = %q, want the CDPipeline status.detailed_message", d.DetailedMessage) + } + + if len(d.Stages) != 2 { + t.Fatalf("got %d stages, want 2", len(d.Stages)) + } + + if d.Stages[0].DetailedMessage != "quota exceeded" || d.Stages[1].DetailedMessage != "" { + t.Errorf("stage messages = %q / %q", d.Stages[0].DetailedMessage, d.Stages[1].DetailedMessage) + } + + raw, err := json.Marshal(d) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + if got := strings.Count(string(raw), `"detailedMessage"`); got != 2 { + t.Errorf("detailedMessage should appear twice (pipeline + dev stage), got %d in %s", got, raw) + } +} + +func TestDeploymentService_List_DetailedMessage(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "CDPipeline": cdPipelineListJSON([]cdPipelineStub{ + {name: "broken", applications: []string{"foo"}, message: "gitops repository unreachable"}, + {name: "fine", applications: []string{"bar"}}, + }), + "Stage": stageListJSON(nil), + }, + } + + svc, closer := newDeploymentServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2", len(rows)) + } + + if rows[0].DetailedMessage != "gitops repository unreachable" || rows[1].DetailedMessage != "" { + t.Errorf("row messages = %q / %q", rows[0].DetailedMessage, rows[1].DetailedMessage) + } +} diff --git a/internal/portal/env.go b/internal/portal/env.go index 7af63ca..73f1290 100644 --- a/internal/portal/env.go +++ b/internal/portal/env.go @@ -235,7 +235,7 @@ func buildEnvDetail(deployment string, stageItem k8sItem, registered []string, a projects := make([]EnvProject, 0, len(registered)) for _, name := range registered { - p := EnvProject{Name: name, IngressURLs: []string{}} + p := EnvProject{Name: name, IngressURLs: []string{}, Conditions: []AppCondition{}} if appItem, ok := appsByProject[name]; ok { fillEnvProjectFromApp(&p, appItem) @@ -248,15 +248,21 @@ func buildEnvDetail(deployment string, stageItem k8sItem, registered []string, a return projects[i].Name < projects[j].Name }) + var messagePtr *string + if msg := stringVal(status, "detailed_message"); msg != "" { + messagePtr = &msg + } + return EnvDetail{ - Deployment: deployment, - Env: stringVal(spec, "name"), - Status: stringVal(status, "status"), - Description: descPtr, - Order: stageOrder(spec), - Infrastructure: infra, - QualityGates: gates, - Projects: projects, + Deployment: deployment, + Env: stringVal(spec, "name"), + Status: stringVal(status, "status"), + DetailedMessage: messagePtr, + Description: descPtr, + Order: stageOrder(spec), + Infrastructure: infra, + QualityGates: gates, + Projects: projects, } } @@ -294,6 +300,8 @@ type appFields struct { IngressURLs []string ArgocdURL *string DeployedAt *string + Conditions []AppCondition + Operation *AppOperation } // extractAppFields shapes one Application's spec/status/labels into an @@ -344,6 +352,59 @@ func extractAppFields(item k8sItem) appFields { out.DeployedAt = &v } + out.Conditions = appConditions(status) + out.Operation = appOperation(status) + + return out +} + +// appConditions maps status.conditions[] of an Application to AppCondition +// entries. The result is never nil so JSON emits [] for an Application +// without conditions. +func appConditions(status map[string]any) []AppCondition { + raw := sliceVal(status, "conditions") + out := make([]AppCondition, 0, len(raw)) + + for _, entry := range raw { + m, ok := entry.(map[string]any) + if !ok { + continue + } + + c := AppCondition{Type: stringVal(m, "type"), Message: stringVal(m, "message")} + + if v := stringVal(m, "lastTransitionTime"); v != "" { + c.LastTransitionTime = &v + } + + out = append(out, c) + } + + return out +} + +// appOperation maps status.operationState of an Application to an +// AppOperation, nil when the Application has never been synced. +func appOperation(status map[string]any) *AppOperation { + op := deepGet(status, "operationState") + if len(op) == 0 { + return nil + } + + out := &AppOperation{Phase: stringVal(op, "phase")} + + if v := stringVal(op, "message"); v != "" { + out.Message = &v + } + + if v := stringVal(op, "startedAt"); v != "" { + out.StartedAt = &v + } + + if v := stringVal(op, "finishedAt"); v != "" { + out.FinishedAt = &v + } + return out } @@ -361,6 +422,8 @@ func fillEnvProjectFromApp(p *EnvProject, item k8sItem) { p.IngressURLs = f.IngressURLs p.ArgocdURL = f.ArgocdURL p.DeployedAt = f.DeployedAt + p.Conditions = f.Conditions + p.Operation = f.Operation spec := ptr.Deref(item.Spec, nil) _, hasSources := spec["sources"] diff --git a/internal/portal/env_test.go b/internal/portal/env_test.go index 664b2d5..9675f12 100644 --- a/internal/portal/env_test.go +++ b/internal/portal/env_test.go @@ -625,6 +625,7 @@ type stageStub struct { trigger string order int status string + message string // status.detailed_message, omitted when empty } func stageListJSON(stages []stageStub) string { @@ -660,7 +661,7 @@ func stageListJSON(stages []stageStub) string { "order": s.order, "qualityGates": []any{}, }, - Status: map[string]any{"status": s.status}, + Status: statusWithMessage(s.status, s.message), }) } @@ -684,6 +685,28 @@ type applicationStub struct { imageTag string imageDigest string // already in "sha256:..." format; appended to image entry externalURLs []string + conditions []conditionStub // status.conditions, omitted when empty + opPhase string // status.operationState.phase, omitted when empty + opMessage string // status.operationState.message + opFinishedAt string // status.operationState.finishedAt +} + +// conditionStub is one status.conditions entry of an Application stub. +type conditionStub struct { + condType string + message string + since string +} + +// statusWithMessage builds a Stage/CDPipeline status block, adding +// detailed_message only when message is set, like the operator does. +func statusWithMessage(status, message string) map[string]any { + out := map[string]any{"status": status} + if message != "" { + out["detailed_message"] = message + } + + return out } func applicationListJSON(apps []applicationStub) string { @@ -717,6 +740,35 @@ func applicationListJSON(apps []applicationStub) string { summary["images"] = stringsToAny([]string{a.imageRepo + ":" + a.imageTag + "@" + a.imageDigest}) } + status := map[string]any{ + "health": map[string]any{"status": a.health}, + "sync": map[string]any{"status": a.sync}, + "summary": summary, + } + + if len(a.conditions) > 0 { + conds := make([]any, 0, len(a.conditions)) + for _, c := range a.conditions { + entry := map[string]any{"type": c.condType, "message": c.message} + if c.since != "" { + entry["lastTransitionTime"] = c.since + } + conds = append(conds, entry) + } + status["conditions"] = conds + } + + if a.opPhase != "" { + op := map[string]any{"phase": a.opPhase} + if a.opMessage != "" { + op["message"] = a.opMessage + } + if a.opFinishedAt != "" { + op["finishedAt"] = a.opFinishedAt + } + status["operationState"] = op + } + items = append(items, app{ Metadata: metadata{ Name: a.pipeline + "-" + a.stage + "-" + a.appName, @@ -734,11 +786,7 @@ func applicationListJSON(apps []applicationStub) string { "targetRevision": "main", }, }, - Status: map[string]any{ - "health": map[string]any{"status": a.health}, - "sync": map[string]any{"status": a.sync}, - "summary": summary, - }, + Status: status, }) } @@ -763,6 +811,7 @@ func stringsToAny(s []string) []any { type cdPipelineStub struct { name string applications []string + message string // status.detailed_message, omitted when empty } func cdPipelineListJSON(items []cdPipelineStub) string { @@ -784,7 +833,7 @@ func cdPipelineListJSON(items []cdPipelineStub) string { Spec: map[string]any{ "applications": stringsToAny(it.applications), }, - Status: map[string]any{"status": "created"}, + Status: statusWithMessage("created", it.message), }) } diff --git a/internal/portal/project_deployments.go b/internal/portal/project_deployments.go index 3fe48c2..8908a94 100644 --- a/internal/portal/project_deployments.go +++ b/internal/portal/project_deployments.go @@ -187,6 +187,7 @@ func buildProjectDeploymentRow( Namespace: stringVal(spec, "namespace"), TriggerType: stringVal(spec, "triggerType"), IngressURLs: []string{}, + Conditions: []AppCondition{}, } if appItem, ok := appsByDeployAndEnv[deployEnvKey{deployment, row.Env}]; ok { @@ -210,6 +211,8 @@ func applyAppFieldsToRow(row *ProjectDeploymentRow, item k8sItem) { row.IngressURLs = f.IngressURLs row.ArgocdURL = f.ArgocdURL row.DeployedAt = f.DeployedAt + row.Conditions = f.Conditions + row.Operation = f.Operation } // indexStagesByDeployAndEnv groups Stages by parent CDPipeline diff --git a/internal/portal/project_versions.go b/internal/portal/project_versions.go new file mode 100644 index 0000000..9161ef1 --- /dev/null +++ b/internal/portal/project_versions.go @@ -0,0 +1,209 @@ +package portal + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "golang.org/x/sync/errgroup" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/internal/ptr" +) + +var ( + codebaseImageStreamResourceConfig = newK8sResourceConfig( + "v2.edp.epam.com", "v1", "CodebaseImageStream", "codebaseimagestream", "codebaseimagestreams") + codebaseBranchResourceConfig = newK8sResourceConfig( + "v2.edp.epam.com", "v1", "CodebaseBranch", "codebasebranch", "codebasebranches") +) + +// Labels the codebase-operator sets on the per-branch resources of a project. +const ( + labelCodebase = "app.edp.epam.com/codebase" + labelCodebaseBranch = "app.edp.epam.com/codebasebranch" +) + +// ProjectVersionsService backs `krci project versions `: the image +// versions the build pipeline pushed for each branch, read from the +// project's CodebaseImageStream resources. +type ProjectVersionsService struct { + client *restapi.ClientWithResponses + clusterName string + namespace string +} + +// NewProjectVersionsService creates a service for the given cluster and namespace. +func NewProjectVersionsService( + client *restapi.ClientWithResponses, + clusterName, namespace string, +) *ProjectVersionsService { + return &ProjectVersionsService{client: client, clusterName: clusterName, namespace: namespace} +} + +func (s *ProjectVersionsService) listBody( + rc restapi.K8sListJSONBody, + labels map[string]string, +) restapi.K8sListJSONRequestBody { + return buildK8sListBody(s.clusterName, s.namespace, rc, labels) +} + +// List returns one stream per CodebaseImageStream of project, sorted by git +// branch name, versions newest first. branch narrows the result to one git +// branch; "" returns every branch. Three concurrent calls: get the Codebase, +// so an unknown project fails instead of returning nothing; list the image +// streams by the codebase label; list the CodebaseBranches by the same label, +// which map a stream to its git branch name (a stream carries the branch +// resource name, e.g. my-app-release-2-27-781f0, not "release/2.27"). +func (s *ProjectVersionsService) List(ctx context.Context, project, branch string) ([]ProjectVersionStream, error) { + var streamResp, branchResp *restapi.K8sListResponse + + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + return s.checkProjectExists(gctx, project) + }) + + g.Go(func() error { + var err error + streamResp, err = s.client.K8sListWithResponse(gctx, + s.listBody(codebaseImageStreamResourceConfig, map[string]string{labelCodebase: project})) + if err != nil { + return fmt.Errorf("listing image streams for project %q: %w", project, err) + } + + return checkResponse(streamResp.StatusCode(), streamResp.Body) + }) + + g.Go(func() error { + var err error + branchResp, err = s.client.K8sListWithResponse(gctx, + s.listBody(codebaseBranchResourceConfig, map[string]string{labelCodebase: project})) + if err != nil { + return fmt.Errorf("listing branches for project %q: %w", project, err) + } + + return checkResponse(branchResp.StatusCode(), branchResp.Body) + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + var streamItems, branchItems []k8sItem + + if streamResp.JSON200 != nil { + streamItems = streamResp.JSON200.Items + } + + if branchResp.JSON200 != nil { + branchItems = branchResp.JSON200.Items + } + + return buildProjectVersionStreams(project, branch, streamItems, branchItems), nil +} + +// checkProjectExists resolves the Codebase so that an unknown project is an +// error rather than an empty result. +func (s *ProjectVersionsService) checkProjectExists(ctx context.Context, project string) error { + ns := s.namespace + + resp, err := s.client.K8sGetWithResponse(ctx, restapi.K8sGetJSONRequestBody{ + ClusterName: s.clusterName, + Namespace: &ns, + Name: project, + ResourceConfig: codebaseConfig.ResourceConfig, + }) + if err != nil { + return fmt.Errorf("getting project %q: %w", project, err) + } + + if err := checkResponse(resp.StatusCode(), resp.Body); err != nil { + if errors.Is(err, ErrNotFound) { + return newRichErr(fmt.Sprintf("project '%s' not found", project), ErrProjectNotFound) + } + + return fmt.Errorf("getting project %q: %w", project, err) + } + + return nil +} + +// buildProjectVersionStreams joins image streams to git branch names and +// shapes the result, sorted by branch. A stream whose branch resource is +// unknown falls back to the stream name without the "-" prefix. The +// result is never nil so JSON emits [] for a project without streams. +func buildProjectVersionStreams(project, branch string, streams, branches []k8sItem) []ProjectVersionStream { + branchNames := make(map[string]string, len(branches)) + + for _, item := range branches { + if name := stringVal(ptr.Deref(item.Spec, nil), "branchName"); name != "" { + branchNames[item.Metadata.Name] = name + } + } + + out := make([]ProjectVersionStream, 0, len(streams)) + + for _, item := range streams { + gitBranch := streamBranch(project, item, branchNames) + if branch != "" && gitBranch != branch { + continue + } + + spec := ptr.Deref(item.Spec, nil) + + out = append(out, ProjectVersionStream{ + Branch: gitBranch, + Image: stringVal(spec, "imageName"), + Versions: imageVersions(spec), + }) + } + + sort.SliceStable(out, func(i, j int) bool { + return out[i].Branch < out[j].Branch + }) + + return out +} + +// streamBranch resolves the git branch of an image stream through its +// codebasebranch label and the CodebaseBranch index. +func streamBranch(project string, item k8sItem, branchNames map[string]string) string { + labels := ptr.Deref(item.Metadata.Labels, nil) + + if name, ok := branchNames[labels[labelCodebaseBranch]]; ok { + return name + } + + return strings.TrimPrefix(item.Metadata.Name, project+"-") +} + +// imageVersions maps spec.tags[] to ImageVersion entries, newest first. The +// operator appends tags in build order and stamps each with its creation +// time, so the list is reversed and then ordered by created descending. +// Never nil. +func imageVersions(spec map[string]any) []ImageVersion { + raw := sliceVal(spec, "tags") + out := make([]ImageVersion, 0, len(raw)) + + for i := len(raw) - 1; i >= 0; i-- { + m, ok := raw[i].(map[string]any) + if !ok { + continue + } + + out = append(out, ImageVersion{ + Name: stringVal(m, "name"), + Created: stringVal(m, "created"), + Digest: stringVal(m, "digest"), + }) + } + + sort.SliceStable(out, func(i, j int) bool { + return out[i].Created > out[j].Created + }) + + return out +} diff --git a/internal/portal/project_versions_test.go b/internal/portal/project_versions_test.go new file mode 100644 index 0000000..ecc4aa5 --- /dev/null +++ b/internal/portal/project_versions_test.go @@ -0,0 +1,297 @@ +package portal + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" +) + +type tagStub struct { + name string + created string + digest string +} + +// imageStreamStub describes one CodebaseImageStream: the branch resource it +// belongs to (label, may be empty) and its tags (nil → spec.tags omitted). +type imageStreamStub struct { + name string + codebase string + branchRes string + image string + tags []tagStub +} + +func imageStreamListJSON(items []imageStreamStub) string { + type metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + } + + type stream struct { + Metadata metadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + } + + out := make([]stream, 0, len(items)) + for _, it := range items { + labels := map[string]string{"app.edp.epam.com/codebase": it.codebase} + if it.branchRes != "" { + labels["app.edp.epam.com/codebasebranch"] = it.branchRes + } + + spec := map[string]any{"codebase": it.codebase, "imageName": it.image} + + if it.tags != nil { + tags := make([]any, 0, len(it.tags)) + for _, tg := range it.tags { + entry := map[string]any{"name": tg.name, "created": tg.created} + if tg.digest != "" { + entry["digest"] = tg.digest + } + tags = append(tags, entry) + } + spec["tags"] = tags + } + + out = append(out, stream{ + Metadata: metadata{Name: it.name, Namespace: "ns", Labels: labels}, + Spec: spec, + Status: map[string]any{}, + }) + } + + return mustJSON(map[string]any{"apiVersion": "v1", "kind": "List", "metadata": map[string]any{}, "items": out}) +} + +type branchStub struct { + name string // CodebaseBranch resource name + codebase string + branch string // spec.branchName +} + +func codebaseBranchListJSON(items []branchStub) string { + type metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + } + + type cb struct { + Metadata metadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + } + + out := make([]cb, 0, len(items)) + for _, it := range items { + out = append(out, cb{ + Metadata: metadata{ + Name: it.name, + Namespace: "ns", + Labels: map[string]string{ + "app.edp.epam.com/codebase": it.codebase, + "app.edp.epam.com/codebaseName": it.codebase, + }, + }, + Spec: map[string]any{"branchName": it.branch, "codebaseName": it.codebase}, + Status: map[string]any{"status": "created"}, + }) + } + + return mustJSON(map[string]any{"apiVersion": "v1", "kind": "List", "metadata": map[string]any{}, "items": out}) +} + +func codebaseGetJSON(name string) string { + return mustJSON(map[string]any{ + "apiVersion": "v2.edp.epam.com/v1", + "kind": "Codebase", + "metadata": map[string]any{"name": name, "namespace": "ns"}, + "spec": map[string]any{"type": "application", "lang": "go", "buildTool": "go", "gitServer": "github"}, + "status": map[string]any{"status": "created", "available": true}, + }) +} + +func newProjectVersionsServiceForTest(t *testing.T, rec *envTestRecorder) (*ProjectVersionsService, func()) { + t.Helper() + + url, closer := newEnvTestServer(t, rec) + + client, err := restapi.NewClientWithResponses(url + "/rest") + if err != nil { + closer() + t.Fatalf("new client: %v", err) + } + + return NewProjectVersionsService(client, "in-cluster", "ns"), closer +} + +func versionsFixture(t *testing.T) *envTestRecorder { + t.Helper() + + return &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "CodebaseImageStream": imageStreamListJSON([]imageStreamStub{ + { + name: "my-app-release-2-27-781f0", codebase: "my-app", branchRes: "my-app-release-2-27-781f0", + image: "registry.example.com/ns/my-app", + tags: []tagStub{{name: "2.27.0-SNAPSHOT.1", created: "2026-09-01T10:00:00Z"}}, + }, + { + name: "my-app-main", codebase: "my-app", branchRes: "my-app-main", + image: "registry.example.com/ns/my-app", + tags: []tagStub{ + {name: "0.1.0-SNAPSHOT.1", created: "2026-08-30T09:00:00Z", digest: "sha256:aaaa1111bbbb2222"}, + {name: "0.1.0-SNAPSHOT.2", created: "2026-09-02T09:00:00Z", digest: "sha256:cccc3333dddd4444"}, + }, + }, + {name: "my-app-feature-x", codebase: "my-app", image: "registry.example.com/ns/my-app"}, + }), + "CodebaseBranch": codebaseBranchListJSON([]branchStub{ + {name: "my-app-main", codebase: "my-app", branch: "main"}, + {name: "my-app-release-2-27-781f0", codebase: "my-app", branch: "release/2.27"}, + }), + }, + getByName: map[string]string{"my-app": codebaseGetJSON("my-app")}, + } +} + +func TestProjectVersionsService_List_GroupsByBranch(t *testing.T) { + t.Parallel() + + rec := versionsFixture(t) + + svc, closer := newProjectVersionsServiceForTest(t, rec) + defer closer() + + streams, err := svc.List(context.Background(), "my-app", "") + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(streams) != 3 { + t.Fatalf("got %d streams, want 3: %+v", len(streams), streams) + } + + wantBranches := []string{"feature-x", "main", "release/2.27"} + for i, s := range streams { + if s.Branch != wantBranches[i] { + t.Errorf("streams[%d].branch = %q, want %q (sorted by branch)", i, s.Branch, wantBranches[i]) + } + + if s.Image != "registry.example.com/ns/my-app" { + t.Errorf("streams[%d].image = %q", i, s.Image) + } + } + + featureX := streams[0] + if featureX.Versions == nil || len(featureX.Versions) != 0 { + t.Errorf("a stream without tags must carry an empty versions array, got %+v", featureX.Versions) + } + + main := streams[1] + if len(main.Versions) != 2 || main.Versions[0].Name != "0.1.0-SNAPSHOT.2" || main.Versions[1].Name != "0.1.0-SNAPSHOT.1" { + t.Errorf("main versions must be newest first, got %+v", main.Versions) + } + + if main.Versions[0].Digest != "sha256:cccc3333dddd4444" || main.Versions[0].Created != "2026-09-02T09:00:00Z" { + t.Errorf("main.versions[0] = %+v", main.Versions[0]) + } + + for _, call := range rec.calls { + if call.Kind == "CodebaseImageStream" || call.Kind == "CodebaseBranch" { + if got := call.LabelSelectors["app.edp.epam.com/codebase"]; got != "my-app" { + t.Errorf("%s list must be scoped by the codebase label, got %v", call.Kind, call.LabelSelectors) + } + } + } + + raw, err := json.Marshal(ProjectVersionsPayload{Project: "my-app", Streams: streams}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + for _, want := range []string{`"streams":[`, `"branch":"feature-x"`, `"versions":[]`, `"digest":"sha256:cccc3333dddd4444"`} { + if !strings.Contains(string(raw), want) { + t.Errorf("JSON missing %s: %s", want, raw) + } + } +} + +func TestProjectVersionsService_List_BranchFilter(t *testing.T) { + t.Parallel() + + rec := versionsFixture(t) + + svc, closer := newProjectVersionsServiceForTest(t, rec) + defer closer() + + streams, err := svc.List(context.Background(), "my-app", "release/2.27") + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(streams) != 1 || streams[0].Branch != "release/2.27" || len(streams[0].Versions) != 1 { + t.Fatalf("branch filter should keep only release/2.27, got %+v", streams) + } + + none, err := svc.List(context.Background(), "my-app", "does-not-exist") + if err != nil { + t.Fatalf("List error: %v", err) + } + + if none == nil || len(none) != 0 { + t.Errorf("unknown branch should yield an empty array, got %+v", none) + } +} + +func TestProjectVersionsService_List_NoStreams(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{}, + getByName: map[string]string{"my-app": codebaseGetJSON("my-app")}, + } + + svc, closer := newProjectVersionsServiceForTest(t, rec) + defer closer() + + streams, err := svc.List(context.Background(), "my-app", "") + if err != nil { + t.Fatalf("List error: %v", err) + } + + if streams == nil || len(streams) != 0 { + t.Errorf("a project without image streams should yield an empty array, got %+v", streams) + } +} + +func TestProjectVersionsService_List_ProjectNotFound(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{t: t, listByKind: map[string]string{}, getByName: map[string]string{}} + + svc, closer := newProjectVersionsServiceForTest(t, rec) + defer closer() + + _, err := svc.List(context.Background(), "ghost", "") + if err == nil { + t.Fatal("expected an error for an unknown project") + } + + if !errors.Is(err, ErrProjectNotFound) { + t.Errorf("errors.Is(err, ErrProjectNotFound) = false, err = %v", err) + } + + if !strings.Contains(err.Error(), "project 'ghost' not found") { + t.Errorf("error = %q, want project 'ghost' not found", err.Error()) + } +} diff --git a/internal/portal/types.go b/internal/portal/types.go index fbe41d7..5284953 100644 --- a/internal/portal/types.go +++ b/internal/portal/types.go @@ -16,37 +16,42 @@ type Project struct { // Deployment represents a KubeRocketCI CDPipeline resource. type Deployment struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Applications []string `json:"applications"` - StageNames []string `json:"stages"` - Description string `json:"description,omitempty"` - Status string `json:"status"` - Available bool `json:"available"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Applications []string `json:"applications"` + StageNames []string `json:"stages"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + DetailedMessage string `json:"detailedMessage,omitempty"` + Available bool `json:"available"` } // DeploymentDetail represents a CDPipeline with its associated Stages. type DeploymentDetail struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Applications []string `json:"applications"` - Description string `json:"description,omitempty"` - Status string `json:"status"` - Available bool `json:"available"` - Stages []Stage `json:"stages"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Applications []string `json:"applications"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + DetailedMessage string `json:"detailedMessage,omitempty"` + Available bool `json:"available"` + Stages []Stage `json:"stages"` } // Stage represents a KubeRocketCI Stage resource belonging to a CDPipeline. +// DetailedMessage carries the operator's status.detailed_message, the reason +// behind a failed status, and is omitted when the resource has none. type Stage struct { - Name string `json:"name"` - Order int64 `json:"order"` - TriggerType string `json:"triggerType"` - QualityGates []QualityGate `json:"qualityGates"` - Namespace string `json:"namespace"` - ClusterName string `json:"clusterName,omitempty"` - Description string `json:"description,omitempty"` - Status string `json:"status"` - Available bool `json:"available"` + Name string `json:"name"` + Order int64 `json:"order"` + TriggerType string `json:"triggerType"` + QualityGates []QualityGate `json:"qualityGates"` + Namespace string `json:"namespace"` + ClusterName string `json:"clusterName,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + DetailedMessage string `json:"detailedMessage,omitempty"` + Available bool `json:"available"` } const ( @@ -69,6 +74,25 @@ const ( ArgoHealthUnknown ArgoHealthStatus = "unknown" ) +// AppCondition is one entry of an Argo CD Application's status.conditions: +// the reason a comparison, sync, or health evaluation did not complete, such +// as an unreachable target cluster or a chart that fails to render. +type AppCondition struct { + Type string `json:"type"` + Message string `json:"message"` + LastTransitionTime *string `json:"lastTransitionTime"` +} + +// AppOperation summarizes the last sync operation of an Argo CD Application +// (status.operationState). Phase is one of Argo CD's operation phases +// (Running, Succeeded, Failed, Error, Terminating). +type AppOperation struct { + Phase string `json:"phase"` + Message *string `json:"message"` + StartedAt *string `json:"startedAt"` + FinishedAt *string `json:"finishedAt"` +} + // QualityGate represents a quality gate step within a Stage. type QualityGate struct { Name string `json:"name"` @@ -95,15 +119,18 @@ type EnvListPayload struct { } // EnvDetail is the response for `krci env get `. +// DetailedMessage is the Stage's status.detailed_message, null when the +// operator reported none. type EnvDetail struct { - Deployment string `json:"deployment"` - Env string `json:"env"` - Status string `json:"status"` - Description *string `json:"description"` - Order int `json:"order"` - Infrastructure Infrastructure `json:"infrastructure"` - QualityGates []QualityGateDetail `json:"qualityGates"` - Projects []EnvProject `json:"projects"` + Deployment string `json:"deployment"` + Env string `json:"env"` + Status string `json:"status"` + DetailedMessage *string `json:"detailedMessage"` + Description *string `json:"description"` + Order int `json:"order"` + Infrastructure Infrastructure `json:"infrastructure"` + QualityGates []QualityGateDetail `json:"qualityGates"` + Projects []EnvProject `json:"projects"` } // Infrastructure carries the technical placement of an environment. @@ -124,36 +151,42 @@ type QualityGateDetail struct { BranchName *string `json:"branchName"` } -// EnvProject is one row in EnvDetail.Projects. +// EnvProject is one row in EnvDetail.Projects. Conditions is always an array +// and Operation is null until the Application has been synced once. type EnvProject struct { - Name string `json:"name"` - Status *string `json:"status"` - Sync *string `json:"sync"` - Version *string `json:"version"` - ImageTag *string `json:"imageTag"` - ImageDigest *string `json:"imageDigest"` - IngressURLs []string `json:"ingressUrls"` - ArgocdURL *string `json:"argocdUrl"` - DeployedAt *string `json:"deployedAt"` - ValuesOverride *bool `json:"valuesOverride"` + Name string `json:"name"` + Status *string `json:"status"` + Sync *string `json:"sync"` + Version *string `json:"version"` + ImageTag *string `json:"imageTag"` + ImageDigest *string `json:"imageDigest"` + IngressURLs []string `json:"ingressUrls"` + ArgocdURL *string `json:"argocdUrl"` + DeployedAt *string `json:"deployedAt"` + ValuesOverride *bool `json:"valuesOverride"` + Conditions []AppCondition `json:"conditions"` + Operation *AppOperation `json:"operation"` } // ProjectDeploymentRow is one row in `krci project deployments `. +// Conditions and Operation follow the EnvProject rules. type ProjectDeploymentRow struct { - Deployment string `json:"deployment"` - Env string `json:"env"` - Deployed bool `json:"deployed"` - Status *string `json:"status"` - Sync *string `json:"sync"` - Version *string `json:"version"` - ImageTag *string `json:"imageTag"` - ImageDigest *string `json:"imageDigest"` - Cluster string `json:"cluster"` - Namespace string `json:"namespace"` - TriggerType string `json:"triggerType"` - DeployedAt *string `json:"deployedAt"` - IngressURLs []string `json:"ingressUrls"` - ArgocdURL *string `json:"argocdUrl"` + Deployment string `json:"deployment"` + Env string `json:"env"` + Deployed bool `json:"deployed"` + Status *string `json:"status"` + Sync *string `json:"sync"` + Version *string `json:"version"` + ImageTag *string `json:"imageTag"` + ImageDigest *string `json:"imageDigest"` + Cluster string `json:"cluster"` + Namespace string `json:"namespace"` + TriggerType string `json:"triggerType"` + DeployedAt *string `json:"deployedAt"` + IngressURLs []string `json:"ingressUrls"` + ArgocdURL *string `json:"argocdUrl"` + Conditions []AppCondition `json:"conditions"` + Operation *AppOperation `json:"operation"` } // ProjectDeploymentsPayload is the envelope `data` block for @@ -162,3 +195,28 @@ type ProjectDeploymentsPayload struct { Project string `json:"project"` Rows []ProjectDeploymentRow `json:"rows"` } + +// ImageVersion is one tag of a CodebaseImageStream: a version the build +// pipeline pushed for a branch, with its creation time and, when the +// registry reported one, the image digest. +type ImageVersion struct { + Name string `json:"name"` + Created string `json:"created"` + Digest string `json:"digest,omitempty"` +} + +// ProjectVersionStream is one CodebaseImageStream of a project: the image +// repository of one git branch and its versions, newest first. Versions is +// always an array, empty for a branch that has never been built. +type ProjectVersionStream struct { + Branch string `json:"branch"` + Image string `json:"image"` + Versions []ImageVersion `json:"versions"` +} + +// ProjectVersionsPayload is the envelope `data` block for +// `krci project versions `. +type ProjectVersionsPayload struct { + Project string `json:"project"` + Streams []ProjectVersionStream `json:"streams"` +} diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index f4cb0fc..58dffdd 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -2,6 +2,7 @@ package status import ( + "context" "errors" "fmt" "io" @@ -14,71 +15,179 @@ import ( "github.com/KubeRocketCI/cli/internal/cmdutil" "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" ) +// SchemaVersion is the JSON envelope version emitted by `krci auth status -o json`. +const SchemaVersion = "1" + // StatusOptions holds all inputs for the status command. type StatusOptions struct { IO *iostreams.IOStreams TokenProvider func() (auth.TokenProvider, error) + RestClient func() (*restapi.ClientWithResponses, error) + OutputFormat string +} + +// StatusPayload is the envelope `data` block for `krci auth status -o json`. +type StatusPayload struct { + Authenticated bool `json:"authenticated"` + User string `json:"user,omitempty"` + Name string `json:"name,omitempty"` + Groups []string `json:"groups"` + ExpiresAt *string `json:"expiresAt"` } +// sessionError carries the user-facing message for a failed session check +// while errors.Is still matches the sentinel that caused it. +type sessionError struct { + msg string + cause error +} + +func (e *sessionError) Error() string { return e.msg } +func (e *sessionError) Unwrap() error { return e.cause } + // NewCmdStatus returns the "auth status" cobra.Command. // runF is the business logic function; pass nil to use the default statusRun. func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { opts := &StatusOptions{ IO: f.IOStreams, TokenProvider: f.TokenProvider, + RestClient: f.RestClient, } - return &cobra.Command{ - Use: "status", - Short: "Show authentication status", - Example: " krci auth status", + cmd := &cobra.Command{ + Use: "status", + Short: "Show authentication status", + Long: `Show the signed-in user, the session expiry, and the user's groups. + +Exits 1 when no session is stored or the session has expired, so the +command works as a shell guard. KRCI_TOKEN, and a stored token whose claims +cannot be read, are checked with one portal call and exit 1 when the portal +rejects the token or cannot be reached. With -o json the outcome is a schemaVersion +envelope: data.authenticated, data.user, data.name, data.groups, and +data.expiresAt on success; error.message on failure.`, + Example: ` krci auth status + + # Shell guard + krci auth status >/dev/null 2>&1 || krci auth login + + # JSON (for scripts and AI agents) + krci auth status -o json`, RunE: func(cmd *cobra.Command, _ []string) error { + if err := validateOutputFormat(opts.OutputFormat); err != nil { + return err + } + if runF != nil { return runF(opts) } - return statusRun(cmd, opts) + return statusRun(cmd.Context(), opts) }, } + + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", "Output format: table, json (default: table)") + + return cmd +} + +func validateOutputFormat(format string) error { + switch format { + case "", output.FormatTable, output.FormatJSON: + return nil + default: + return fmt.Errorf("unknown output format: %s (use 'json' or 'table')", format) + } } -func statusRun(cmd *cobra.Command, opts *StatusOptions) error { +func statusRun(ctx context.Context, opts *StatusOptions) error { tp, err := opts.TokenProvider() if err != nil { return err } - // GetToken is called for its error (to classify auth state: not-authenticated, - // expired, refresh-failed, or valid) and its side-effect (refreshing and - // persisting the token if expired). The token value itself is not needed. - _, tokenErr := tp.GetToken(cmd.Context()) + // GetToken classifies the auth state (not-authenticated, expired, + // refresh-failed, or valid) and refreshes and persists an expired token. + if _, tokenErr := tp.GetToken(ctx); tokenErr != nil { + return opts.fail(classifyTokenError(tokenErr)) + } info, infoErr := tp.UserInfo() - if tokenErr != nil { - if errors.Is(tokenErr, auth.ErrNotAuthenticated) { - _, _ = fmt.Fprintln(opts.IO.ErrOut, "Not authenticated. Run: krci auth login") - return nil - } + // The stored session comes from this CLI's own login and is judged + // locally. KRCI_TOKEN, or claims that cannot be read, prove nothing + // locally: the portal decides. + if infoErr == nil && !info.FromEnv { + return opts.render(info, true) + } - if errors.Is(tokenErr, auth.ErrRefreshFailed) || errors.Is(tokenErr, auth.ErrTokenExpired) { - if infoErr == nil { - _, _ = fmt.Fprintf(opts.IO.ErrOut, "User: %s\n", info.Email) - } + if err := opts.verifySession(ctx); err != nil { + return opts.fail(err) + } - _, _ = fmt.Fprintln(opts.IO.ErrOut, "Status: Session expired. Run: krci auth login") + if infoErr != nil { + return opts.render(&auth.UserInfo{}, false) + } - return nil + return opts.render(info, true) +} + +// verifySession asks the portal to validate the token through the same +// client every portal command uses, and returns the error the command exits +// with when the portal rejects the token or cannot be asked. +func (opts *StatusOptions) verifySession(ctx context.Context) error { + client, err := opts.RestClient() + if err != nil { + return err + } + + if err := portal.VerifySession(ctx, client); err != nil { + if errors.Is(err, portal.ErrUnauthorized) { + return &sessionError{msg: "not authenticated: the portal rejected the token", cause: err} } - return tokenErr + return fmt.Errorf("verifying the token with the portal: %w", err) } - if infoErr != nil { - _, _ = fmt.Fprintln(opts.IO.Out, "Status: Authenticated (unable to read user info)") - return nil + return nil +} + +// classifyTokenError turns the token provider's failure into the error the +// command exits with. A missing session keeps the auth sentinel message; an +// expired or unrefreshable session gets a message of its own. +func classifyTokenError(err error) error { + if errors.Is(err, auth.ErrTokenExpired) || errors.Is(err, auth.ErrRefreshFailed) { + return &sessionError{msg: "session expired: run 'krci auth login'", cause: err} + } + + return err +} + +// fail writes the error envelope to stdout under -o json, so scripting +// consumers get a structured error next to the exit-1 signal, then returns +// err for the root command to print and exit on. +func (opts *StatusOptions) fail(err error) error { + if output.ResolveFormat(opts.OutputFormat) == output.FormatJSON { + _ = output.PrintJSONErrorEnvelope(opts.IO.Out, SchemaVersion, err) + } + + return err +} + +// render prints the authenticated state. haveInfo is false when the token's +// claims could not be read and the portal accepted the token. +func (opts *StatusOptions) render(info *auth.UserInfo, haveInfo bool) error { + if output.ResolveFormat(opts.OutputFormat) == output.FormatJSON { + return output.PrintJSONEnvelope(opts.IO.Out, SchemaVersion, buildPayload(info)) + } + + if !haveInfo { + _, err := fmt.Fprintln(opts.IO.Out, "Status: Authenticated (unable to read user info)") + + return err } lines := []output.DetailLine{ @@ -119,3 +228,25 @@ func statusRun(cmd *cobra.Command, opts *StatusOptions) error { Plain: output.PrintPlainDetailLines, }) } + +// buildPayload shapes the JSON data block. Groups is always an array and +// expiresAt is RFC3339 in UTC, null when the token carries no expiry. +func buildPayload(info *auth.UserInfo) StatusPayload { + p := StatusPayload{ + Authenticated: true, + User: info.Email, + Name: info.Name, + Groups: []string{}, + } + + if len(info.Groups) > 0 { + p.Groups = info.Groups + } + + if !info.ExpiresAt.IsZero() { + s := info.ExpiresAt.UTC().Format(time.RFC3339) + p.ExpiresAt = &s + } + + return p +} diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go new file mode 100644 index 0000000..e110015 --- /dev/null +++ b/pkg/cmd/auth/status/status_test.go @@ -0,0 +1,476 @@ +package status + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/KubeRocketCI/cli/internal/auth" + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/pkg/cmd/internal/cmdtest" +) + +// mockTokenProvider implements auth.TokenProvider for testing. +type mockTokenProvider struct { + tokenErr error + info *auth.UserInfo + infoErr error +} + +func (m *mockTokenProvider) GetToken(_ context.Context) (string, error) { return "tok", m.tokenErr } +func (m *mockTokenProvider) Login(_ context.Context) error { return nil } +func (m *mockTokenProvider) Logout() error { return nil } +func (m *mockTokenProvider) UserInfo() (*auth.UserInfo, error) { return m.info, m.infoErr } + +// runStatus executes `auth status` against tp with the given argv and returns +// what the command wrote to the factory's stdout. +func runStatus(t *testing.T, tp auth.TokenProvider, args ...string) (stdout string, err error) { + t.Helper() + + f := cmdtest.NewFactory() + f.TokenProvider = func() (auth.TokenProvider, error) { return tp, nil } + + cmd := NewCmdStatus(f, nil) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + + return f.IOStreams.Out.(*bytes.Buffer).String(), err +} + +type envelope struct { + SchemaVersion string `json:"schemaVersion"` + Data *struct { + Authenticated bool `json:"authenticated"` + User string `json:"user"` + Name string `json:"name"` + Groups []string `json:"groups"` + ExpiresAt *string `json:"expiresAt"` + } `json:"data"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func parseEnvelope(t *testing.T, stdout string) envelope { + t.Helper() + + var env envelope + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\nstdout=%s", err, stdout) + } + + if env.SchemaVersion != SchemaVersion { + t.Errorf("schemaVersion = %q, want %q", env.SchemaVersion, SchemaVersion) + } + + return env +} + +func TestStatus_NotAuthenticated_ReturnsError(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{tokenErr: auth.ErrNotAuthenticated, infoErr: auth.ErrNotAuthenticated} + + stdout, err := runStatus(t, tp) + if err == nil { + t.Fatal("expected an error without a session") + } + + if !errors.Is(err, auth.ErrNotAuthenticated) { + t.Errorf("errors.Is(err, ErrNotAuthenticated) = false, err = %v", err) + } + + if !strings.Contains(err.Error(), "krci auth login") { + t.Errorf("error should tell the user to log in, got %q", err.Error()) + } + + if stdout != "" { + t.Errorf("stdout should be empty in table mode, got %q", stdout) + } +} + +func TestStatus_NotAuthenticated_JSONErrorEnvelope(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{tokenErr: auth.ErrNotAuthenticated, infoErr: auth.ErrNotAuthenticated} + + stdout, err := runStatus(t, tp, "-o", "json") + if err == nil { + t.Fatal("expected an error without a session") + } + + env := parseEnvelope(t, stdout) + if env.Error == nil { + t.Fatalf("expected an error envelope, got %s", stdout) + } + + if !strings.Contains(env.Error.Message, "not authenticated") { + t.Errorf("error.message = %q, want it to mention 'not authenticated'", env.Error.Message) + } + + if env.Data != nil { + t.Errorf("error envelope must not carry data, got %s", stdout) + } +} + +func TestStatus_ExpiredSession_ReturnsError(t *testing.T) { + t.Parallel() + + for _, cause := range []error{auth.ErrTokenExpired, auth.ErrRefreshFailed} { + tp := &mockTokenProvider{tokenErr: cause, info: &auth.UserInfo{Email: "user@example.com"}} + + stdout, err := runStatus(t, tp) + if err == nil { + t.Fatalf("cause %v: expected an error for an expired session", cause) + } + + if !errors.Is(err, cause) { + t.Errorf("cause %v: errors.Is on the cause = false, err = %v", cause, err) + } + + if !strings.Contains(err.Error(), "session expired") || !strings.Contains(err.Error(), "krci auth login") { + t.Errorf("cause %v: error = %q, want 'session expired' and the login hint", cause, err.Error()) + } + + if stdout != "" { + t.Errorf("cause %v: stdout should be empty in table mode, got %q", cause, stdout) + } + } +} + +func TestStatus_ExpiredEnvToken_KeepsItsMessage(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{tokenErr: auth.ErrEnvTokenExpired} + + stdout, err := runStatus(t, tp, "-o", "json") + if !errors.Is(err, auth.ErrEnvTokenExpired) { + t.Fatalf("err = %v, want ErrEnvTokenExpired", err) + } + + if strings.Contains(err.Error(), "krci auth login") { + t.Errorf("error = %q: an expired KRCI_TOKEN is not fixed by a login", err.Error()) + } + + env := parseEnvelope(t, stdout) + if env.Error == nil || env.Error.Message != auth.ErrEnvTokenExpired.Error() { + t.Errorf("error envelope = %+v, want message %q", env.Error, auth.ErrEnvTokenExpired.Error()) + } +} + +func TestStatus_Authenticated_JSON(t *testing.T) { + t.Parallel() + + expires := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second) + tp := &mockTokenProvider{info: &auth.UserInfo{ + Email: "user@example.com", + Name: "User Name", + Groups: []string{"admins", "developers"}, + ExpiresAt: expires, + }} + + stdout, err := runStatus(t, tp, "-o", "json") + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + env := parseEnvelope(t, stdout) + if env.Data == nil { + t.Fatalf("expected a data envelope, got %s", stdout) + } + + if !env.Data.Authenticated { + t.Error("data.authenticated = false, want true") + } + + if env.Data.User != "user@example.com" || env.Data.Name != "User Name" { + t.Errorf("data.user/name = %q/%q", env.Data.User, env.Data.Name) + } + + if len(env.Data.Groups) != 2 { + t.Errorf("data.groups = %v, want 2 entries", env.Data.Groups) + } + + if env.Data.ExpiresAt == nil { + t.Fatal("data.expiresAt = null, want RFC3339 timestamp") + } + + got, parseErr := time.Parse(time.RFC3339, *env.Data.ExpiresAt) + if parseErr != nil || !got.Equal(expires) { + t.Errorf("data.expiresAt = %q, want %s", *env.Data.ExpiresAt, expires.Format(time.RFC3339)) + } +} + +func TestStatus_Authenticated_Table(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{info: &auth.UserInfo{ + Email: "user@example.com", + Name: "User Name", + Groups: []string{"admins"}, + ExpiresAt: time.Now().Add(time.Hour), + }} + + stdout, err := runStatus(t, tp) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + for _, want := range []string{"User:", "user@example.com", "Status:", "Authenticated", "Groups:", "admins"} { + if !strings.Contains(stdout, want) { + t.Errorf("stdout missing %q:\n%s", want, stdout) + } + } +} + +// fakePortal answers the session check with status and records the calls. +type fakePortal struct { + status int + calls int + path string + authorization string + url string +} + +// start serves the fake portal over plain HTTP, the local-development setup, +// and returns a RestClient factory pointed at it. +func (p *fakePortal) start(t *testing.T) func() (*restapi.ClientWithResponses, error) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.calls++ + p.path = r.URL.Path + p.authorization = r.Header.Get("Authorization") + w.WriteHeader(p.status) + _, _ = w.Write([]byte(`{"clusterName":"in-cluster","defaultNamespace":"ns","sonarWebUrl":"","dependencyTrackWebUrl":""}`)) + })) + t.Cleanup(srv.Close) + + p.url = srv.URL + + return func() (*restapi.ClientWithResponses, error) { + return restapi.NewClientWithResponses(srv.URL + "/rest") + } +} + +// TestStatus_EnvToken_FactoryClientSendsBearer runs the check through the +// production Factory, so the bearer token reaches the portal the way every +// portal command sends it. +func TestStatus_EnvToken_FactoryClientSendsBearer(t *testing.T) { + t.Parallel() + + p := &fakePortal{status: http.StatusOK} + p.start(t) + + tp := &mockTokenProvider{info: &auth.UserInfo{Email: "ci@example.com", FromEnv: true}} + + f := cmdutil.New() + f.IOStreams = &iostreams.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + f.Config = func() (*config.Config, error) { + return &config.Config{PortalURL: p.url, ClusterName: "in-cluster", Namespace: "ns"}, nil + } + f.TokenProvider = func() (auth.TokenProvider, error) { return tp, nil } + + cmd := NewCmdStatus(f, nil) + cmd.SetArgs([]string{"-o", "json"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if p.calls != 1 || p.authorization != "Bearer tok" { + t.Errorf("portal saw %d call(s) with Authorization %q, want 1 with %q", p.calls, p.authorization, "Bearer tok") + } +} + +// runStatusWithPortal executes `auth status` with restClient standing in for +// the factory's portal client. +func runStatusWithPortal( + t *testing.T, tp auth.TokenProvider, restClient func() (*restapi.ClientWithResponses, error), args ...string, +) (stdout string, err error) { + t.Helper() + + f := cmdtest.NewFactory() + f.TokenProvider = func() (auth.TokenProvider, error) { return tp, nil } + f.RestClient = restClient + + cmd := NewCmdStatus(f, nil) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + + return f.IOStreams.Out.(*bytes.Buffer).String(), err +} + +func TestStatus_UnreadableClaims_PortalAccepts(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{infoErr: errors.New("invalid ID token format")} + p := &fakePortal{status: http.StatusOK} + + stdout, err := runStatusWithPortal(t, tp, p.start(t), "-o", "json") + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if p.calls != 1 || p.path != "/rest/v1/config" { + t.Errorf("portal check = %d call(s) to %q, want 1 to /rest/v1/config", p.calls, p.path) + } + + env := parseEnvelope(t, stdout) + if env.Data == nil || !env.Data.Authenticated { + t.Fatalf("expected data.authenticated=true, got %s", stdout) + } + + if env.Data.Groups == nil { + t.Errorf("data.groups should be [] not null: %s", stdout) + } + + if env.Data.ExpiresAt != nil { + t.Errorf("data.expiresAt should be null without user info: %s", stdout) + } +} + +func TestStatus_PortalRejects(t *testing.T) { + t.Parallel() + + cases := map[string]*mockTokenProvider{ + "unreadable claims": {infoErr: errors.New("invalid ID token format")}, + "readable KRCI_TOKEN claims": {info: &auth.UserInfo{Email: "forged@example.com", FromEnv: true}}, + } + + for name, tp := range cases { + for _, format := range []string{"table", "json"} { + p := &fakePortal{status: http.StatusUnauthorized} + + stdout, err := runStatusWithPortal(t, tp, p.start(t), "-o", format) + if !errors.Is(err, portal.ErrUnauthorized) { + t.Fatalf("%s/%s: err = %v, want it to wrap portal.ErrUnauthorized", name, format, err) + } + + if !strings.Contains(err.Error(), "the portal rejected the token") { + t.Errorf("%s/%s: error = %q, want 'the portal rejected the token'", name, format, err.Error()) + } + + if format == "table" { + if stdout != "" { + t.Errorf("%s/table: stdout should be empty, got %q", name, stdout) + } + + continue + } + + env := parseEnvelope(t, stdout) + if env.Error == nil || env.Data != nil { + t.Errorf("%s/json: want an error envelope without data, got %s", name, stdout) + } + } + } +} + +func TestStatus_EnvToken_PortalAccepts(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{info: &auth.UserInfo{Email: "ci@example.com", FromEnv: true}} + p := &fakePortal{status: http.StatusOK} + + stdout, err := runStatusWithPortal(t, tp, p.start(t), "-o", "json") + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if p.calls != 1 { + t.Errorf("portal check ran %d time(s) for KRCI_TOKEN, want 1", p.calls) + } + + env := parseEnvelope(t, stdout) + if env.Data == nil || !env.Data.Authenticated || env.Data.User != "ci@example.com" { + t.Errorf("want authenticated ci@example.com, got %s", stdout) + } +} + +func TestStatus_PortalUnreachable(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + + tp := &mockTokenProvider{infoErr: errors.New("invalid ID token format")} + restClient := func() (*restapi.ClientWithResponses, error) { + return restapi.NewClientWithResponses(url + "/rest") + } + + _, err := runStatusWithPortal(t, tp, restClient) + if err == nil { + t.Fatal("an unverifiable token must not report authenticated") + } + + if !strings.Contains(err.Error(), "verifying the token with the portal") { + t.Errorf("error = %q, want the verification context", err.Error()) + } +} + +func TestStatus_PortalNotConfigured(t *testing.T) { + t.Parallel() + + notSet := errors.New("portal URL not configured") + tp := &mockTokenProvider{infoErr: errors.New("invalid ID token format")} + restClient := func() (*restapi.ClientWithResponses, error) { return nil, notSet } + + stdout, err := runStatusWithPortal(t, tp, restClient, "-o", "json") + if !errors.Is(err, notSet) { + t.Fatalf("err = %v, want the RestClient error", err) + } + + if env := parseEnvelope(t, stdout); env.Error == nil { + t.Errorf("want an error envelope, got %s", stdout) + } +} + +func TestStatus_StoredSession_SkipsPortal(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{info: &auth.UserInfo{Email: "user@example.com"}} + p := &fakePortal{status: http.StatusUnauthorized} + + if _, err := runStatusWithPortal(t, tp, p.start(t), "-o", "json"); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if p.calls != 0 { + t.Errorf("portal check ran %d time(s) for a readable stored session", p.calls) + } +} + +func TestStatus_RejectsUnknownOutputFormat(t *testing.T) { + t.Parallel() + + tp := &mockTokenProvider{info: &auth.UserInfo{Email: "user@example.com"}} + + _, err := runStatus(t, tp, "-o", "yaml") + if err == nil { + t.Fatal("expected error for -o yaml") + } + + if !strings.Contains(err.Error(), "unknown output format") { + t.Errorf("error = %v, want 'unknown output format'", err) + } +} diff --git a/pkg/cmd/deployment/get/get.go b/pkg/cmd/deployment/get/get.go index c7f95bf..3b07bb0 100644 --- a/pkg/cmd/deployment/get/get.go +++ b/pkg/cmd/deployment/get/get.go @@ -116,6 +116,10 @@ func deploymentDetailLines(d *portal.DeploymentDetail, styled bool) []output.Det lines = append(lines, statusLine, availableLine) + if d.DetailedMessage != "" { + lines = append(lines, output.DetailLine{Label: "Message", Value: output.SingleLine(d.DetailedMessage)}) + } + return lines } @@ -147,24 +151,69 @@ func printStageSection(w io.Writer, stages []portal.Stage, styled bool) error { return err } + if err := printSectionHeading(w, styled, "Environments"); err != nil { + return err + } + + headers := []string{"ORDER", "ENV", "DEPLOY MODE", "PROMOTE GATES", "NAMESPACE", "STATUS"} + rows := stageRows(stages, styled) + + var err error if styled { - if _, err := fmt.Fprintln(w, output.LabelStyle.Render("Environments:")); err != nil { - return err - } + err = output.PrintStyledTable(w, headers, rows) } else { - if _, err := fmt.Fprintln(w, "Environments:"); err != nil { + err = output.PrintTable(w, headers, rows) + } + + if err != nil { + return err + } + + return printStageMessages(w, stages, styled) +} + +// printStageMessages lists the status message of every stage that carries +// one, so a failed environment explains itself below the table. +func printStageMessages(w io.Writer, stages []portal.Stage, styled bool) error { + lines := make([]string, 0, len(stages)) + + for _, s := range stages { + if s.DetailedMessage != "" { + lines = append(lines, fmt.Sprintf(" %s: %s", s.Name, output.SingleLine(s.DetailedMessage))) + } + } + + if len(lines) == 0 { + return nil + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := printSectionHeading(w, styled, "Messages"); err != nil { + return err + } + + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { return err } } - headers := []string{"ORDER", "ENV", "DEPLOY MODE", "PROMOTE GATES", "NAMESPACE", "STATUS"} - rows := stageRows(stages, styled) + return nil +} +// printSectionHeading writes ":", in LabelStyle when styled. +func printSectionHeading(w io.Writer, styled bool, text string) error { + heading := text + ":" if styled { - return output.PrintStyledTable(w, headers, rows) + heading = output.LabelStyle.Render(heading) } - return output.PrintTable(w, headers, rows) + _, err := fmt.Fprintln(w, heading) + + return err } // stageRows builds table rows from stages. When styled is true, status is colorized. diff --git a/pkg/cmd/deployment/get/get_test.go b/pkg/cmd/deployment/get/get_test.go new file mode 100644 index 0000000..646dfb5 --- /dev/null +++ b/pkg/cmd/deployment/get/get_test.go @@ -0,0 +1,65 @@ +package get + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal" +) + +func TestPrintPlainDeploymentDetail_Messages(t *testing.T) { + t.Parallel() + + d := &portal.DeploymentDetail{ + Name: "my-pipeline", + Namespace: "ns", + Applications: []string{"foo"}, + Status: "failed", + DetailedMessage: "gitops repository unreachable", + Stages: []portal.Stage{ + {Name: "dev", Order: 0, TriggerType: "Auto", Namespace: "ns-dev", Status: "failed", DetailedMessage: "quota exceeded"}, + {Name: "qa", Order: 1, TriggerType: "Manual", Namespace: "ns-qa", Status: "created"}, + }, + } + + var buf bytes.Buffer + if err := printPlainDeploymentDetail(&buf, d); err != nil { + t.Fatalf("printPlainDeploymentDetail error: %v", err) + } + + out := buf.String() + for _, want := range []string{"Message:", "gitops repository unreachable", "Messages:", " dev: quota exceeded"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + if strings.Contains(out, " qa:") { + t.Errorf("stages without a message must not be listed:\n%s", out) + } +} + +func TestPrintPlainDeploymentDetail_NoMessages(t *testing.T) { + t.Parallel() + + d := &portal.DeploymentDetail{ + Name: "my-pipeline", + Namespace: "ns", + Applications: []string{"foo"}, + Status: "created", + Available: true, + Stages: []portal.Stage{ + {Name: "dev", Order: 0, TriggerType: "Auto", Namespace: "ns-dev", Status: "created"}, + }, + } + + var buf bytes.Buffer + if err := printPlainDeploymentDetail(&buf, d); err != nil { + t.Fatalf("printPlainDeploymentDetail error: %v", err) + } + + if strings.Contains(buf.String(), "Message") { + t.Errorf("output should not mention messages without diagnostics:\n%s", buf.String()) + } +} diff --git a/pkg/cmd/env/get/get.go b/pkg/cmd/env/get/get.go index d5f9987..8882c68 100644 --- a/pkg/cmd/env/get/get.go +++ b/pkg/cmd/env/get/get.go @@ -164,7 +164,11 @@ func renderDetail(w io.Writer, isTTY bool, d *portal.EnvDetail) error { return err } - return renderProjects(w, isTTY, d.Projects) + if err := renderProjects(w, isTTY, d.Projects); err != nil { + return err + } + + return renderConditions(w, isTTY, d.Projects) } func buildHeaderPairs(d *portal.EnvDetail, isTTY bool) []labelValue { @@ -178,13 +182,77 @@ func buildHeaderPairs(d *portal.EnvDetail, isTTY bool) []labelValue { statusValue = output.StatusColor(d.Status) } - return []labelValue{ + pairs := []labelValue{ {Label: "Environment", Value: d.Env}, {Label: "Deployment", Value: d.Deployment}, {Label: "Status", Value: statusValue}, - {Label: "Description", Value: desc}, - {Label: "Order", Value: strconv.Itoa(d.Order)}, } + + if d.DetailedMessage != nil && *d.DetailedMessage != "" { + pairs = append(pairs, labelValue{Label: "Message", Value: output.SingleLine(*d.DetailedMessage)}) + } + + return append(pairs, + labelValue{Label: "Description", Value: desc}, + labelValue{Label: "Order", Value: strconv.Itoa(d.Order)}, + ) +} + +// renderConditions prints the Conditions block below the projects table: one +// line per Argo CD condition and one per sync operation that did not succeed, +// so the reason behind an unknown or degraded status is readable without +// opening Argo CD. Nothing is printed when no project carries a diagnostic. +func renderConditions(w io.Writer, isTTY bool, projects []portal.EnvProject) error { + lines := conditionLines(projects) + if len(lines) == 0 { + return nil + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := printSectionHeading(w, isTTY, fmt.Sprintf("Conditions (%d)", len(lines))); err != nil { + return err + } + + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + + return nil +} + +// conditionLines flattens the diagnostics of every project into display rows: +// +// " - : : " +// " - : operation : " +// +// The operation line appears only for a phase other than Succeeded. +func conditionLines(projects []portal.EnvProject) []string { + lines := make([]string, 0, len(projects)) + + for _, p := range projects { + for _, c := range p.Conditions { + lines = append(lines, fmt.Sprintf(" - %s: %s: %s", p.Name, c.Type, output.SingleLine(c.Message))) + } + + op := p.Operation + if op == nil || op.Phase == "" || op.Phase == "Succeeded" { + continue + } + + line := fmt.Sprintf(" - %s: operation %s", p.Name, op.Phase) + if op.Message != nil && *op.Message != "" { + line += ": " + output.SingleLine(*op.Message) + } + + lines = append(lines, line) + } + + return lines } func buildInfraPairs(i portal.Infrastructure) []labelValue { diff --git a/pkg/cmd/env/get/get_test.go b/pkg/cmd/env/get/get_test.go index 758d0f1..9a76818 100644 --- a/pkg/cmd/env/get/get_test.go +++ b/pkg/cmd/env/get/get_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/KubeRocketCI/cli/internal/portal" "github.com/KubeRocketCI/cli/pkg/cmd/internal/cmdtest" ) @@ -98,3 +99,75 @@ func TestGet_AcceptsValidArgs(t *testing.T) { t.Error("runF was not invoked") } } + +func TestRenderDetail_ConditionsBlock(t *testing.T) { + t.Parallel() + + msg := "namespace creation failed" + opMsg := "cluster unreachable" + unknown := "unknown" + healthy := "healthy" + + d := &portal.EnvDetail{ + Deployment: "my-pipeline", + Env: "dev", + Status: "failed", + DetailedMessage: &msg, + Infrastructure: portal.Infrastructure{Cluster: "remote", Namespace: "my-pipeline-dev", TriggerType: "Auto", DeployPipeline: "deploy"}, + QualityGates: []portal.QualityGateDetail{}, + Projects: []portal.EnvProject{ + {Name: "bar", Status: &healthy, Sync: &healthy, IngressURLs: []string{}, Conditions: []portal.AppCondition{}}, + { + Name: "foo", Status: &unknown, Sync: &unknown, IngressURLs: []string{}, + Conditions: []portal.AppCondition{{Type: "ComparisonError", Message: "Failed to load live state"}}, + Operation: &portal.AppOperation{Phase: "Error", Message: &opMsg}, + }, + }, + } + + var buf bytes.Buffer + if err := renderDetail(&buf, false, d); err != nil { + t.Fatalf("renderDetail error: %v", err) + } + + out := buf.String() + for _, want := range []string{ + "Message:", + "namespace creation failed", + "Conditions (2):", + " - foo: ComparisonError: Failed to load live state", + " - foo: operation Error: cluster unreachable", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestRenderDetail_NoConditionsBlock(t *testing.T) { + t.Parallel() + + healthy := "healthy" + d := &portal.EnvDetail{ + Deployment: "my-pipeline", + Env: "dev", + Status: "created", + Infrastructure: portal.Infrastructure{Cluster: "in-cluster", Namespace: "my-pipeline-dev", TriggerType: "Auto", DeployPipeline: "deploy"}, + QualityGates: []portal.QualityGateDetail{}, + Projects: []portal.EnvProject{ + {Name: "bar", Status: &healthy, Sync: &healthy, IngressURLs: []string{}, Conditions: []portal.AppCondition{}}, + }, + } + + var buf bytes.Buffer + if err := renderDetail(&buf, false, d); err != nil { + t.Fatalf("renderDetail error: %v", err) + } + + out := buf.String() + for _, absent := range []string{"Conditions", "Message:"} { + if strings.Contains(out, absent) { + t.Errorf("output should not contain %q without diagnostics:\n%s", absent, out) + } + } +} diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go index ed23533..4dd970d 100644 --- a/pkg/cmd/project/project.go +++ b/pkg/cmd/project/project.go @@ -9,6 +9,7 @@ import ( "github.com/KubeRocketCI/cli/pkg/cmd/project/deployments" "github.com/KubeRocketCI/cli/pkg/cmd/project/get" "github.com/KubeRocketCI/cli/pkg/cmd/project/list" + "github.com/KubeRocketCI/cli/pkg/cmd/project/versions" ) // NewCmdProject returns the "project" group cobra.Command with all subcommands attached. @@ -23,6 +24,7 @@ func NewCmdProject(f *cmdutil.Factory) *cobra.Command { list.NewCmdList(f, nil), get.NewCmdGet(f, nil), deployments.NewCmdDeployments(f, nil), + versions.NewCmdVersions(f, nil), build.NewCmdBuild(f, nil), ) diff --git a/pkg/cmd/project/versions/versions.go b/pkg/cmd/project/versions/versions.go new file mode 100644 index 0000000..1bf01cb --- /dev/null +++ b/pkg/cmd/project/versions/versions.go @@ -0,0 +1,205 @@ +// Package versions implements the "krci project versions " command. +package versions + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/internal/config" + "github.com/KubeRocketCI/cli/internal/iostreams" + "github.com/KubeRocketCI/cli/internal/output" + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/pkg/cmd/internal/discovery" +) + +// ListOptions holds all inputs for `krci project versions `. +type ListOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Config func() (*config.Config, error) + Project string + Branch string + OutputFormat string +} + +// NewCmdVersions returns the "project versions " cobra.Command. +// runF is the business-logic function; pass nil to use the default run. +func NewCmdVersions(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "versions ", + Short: "List the image versions a project has built, per branch", + Long: `List the image versions the build pipeline has pushed for a project: one +row per branch with the image repository, the number of versions, and the +newest one. These are the versions a deployment can pick. + +A version appears here once its build pipeline has finished; the list is +read from the project's CodebaseImageStream resources, so a branch that has +never been built shows 0 versions. + +--branch narrows the output to one git branch and lists every version of it, +newest first.`, + Args: cmdutil.ExactArgs(1, "a project name", + "to see available projects: krci project list"), + Example: ` # One row per branch + krci project versions my-app + + # Every version of one branch, newest first + krci project versions my-app --branch release/1.2 + + # JSON envelope + krci project versions my-app -o json + + # Scripting — the newest version of the main branch + krci project versions my-app --branch main -o json | + jq -r '.data.streams[0].versions[0].name'`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Project = args[0] + + if err := discovery.ValidateOutputFormat(opts.OutputFormat); err != nil { + return err + } + + if err := cmdutil.ValidateK8sName("", opts.Project); err != nil { + return err + } + + if len(opts.Branch) > portal.MaxBranchLength { + return fmt.Errorf("--branch must be at most %d characters", portal.MaxBranchLength) + } + + if runF != nil { + return runF(opts) + } + + return listRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVar(&opts.Branch, "branch", "", + "Git branch to list every version of (default: one row per branch)") + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func listRun(ctx context.Context, opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return discovery.HandleError(opts.IO, opts.OutputFormat, err) + } + + client, err := opts.RestClient() + if err != nil { + return discovery.HandleError(opts.IO, opts.OutputFormat, err) + } + + svc := portal.NewProjectVersionsService(client, cfg.ClusterName, cfg.Namespace) + + streams, err := svc.List(ctx, opts.Project, opts.Branch) + if err != nil { + return discovery.HandleError(opts.IO, opts.OutputFormat, err) + } + + payload := portal.ProjectVersionsPayload{ + Project: opts.Project, + Streams: streams, + } + + if err := discovery.Render(opts.IO, opts.OutputFormat, payload, func(w io.Writer, isTTY bool) error { + if opts.Branch != "" { + return renderVersionsTable(w, isTTY, streams) + } + + return renderStreamsTable(w, isTTY, streams) + }); err != nil { + return err + } + + if note := emptyNote(opts.Project, opts.Branch, streams); note != "" && opts.OutputFormat != output.FormatJSON { + if _, err := fmt.Fprintln(opts.IO.ErrOut, note); err != nil { + return err + } + } + + return nil +} + +// emptyNote is the stderr line for a table view with nothing to show: no +// stream at all, or, with --branch, a branch that exists but has never +// produced a version. Empty when the table has rows. +func emptyNote(project, branch string, streams []portal.ProjectVersionStream) string { + if branch == "" { + if len(streams) == 0 { + return fmt.Sprintf("No versions found for project %s.", project) + } + + return "" + } + + for _, s := range streams { + if len(s.Versions) > 0 { + return "" + } + } + + return fmt.Sprintf("No versions found for branch %s of project %s.", branch, project) +} + +// renderStreamsTable prints one row per branch with its newest version. +func renderStreamsTable(w io.Writer, isTTY bool, streams []portal.ProjectVersionStream) error { + headers := []string{"BRANCH", "VERSIONS", "LATEST", "CREATED", "IMAGE"} + rows := make([][]string, 0, len(streams)) + + for _, s := range streams { + latest, created := output.EmptyCell, output.EmptyCell + if len(s.Versions) > 0 { + latest = output.OrDash(s.Versions[0].Name) + created = output.OrDash(s.Versions[0].Created) + } + + rows = append(rows, []string{ + s.Branch, + strconv.Itoa(len(s.Versions)), + latest, + created, + output.OrDash(s.Image), + }) + } + + return discovery.PrintTable(w, isTTY, headers, rows) +} + +// renderVersionsTable prints one row per version, newest first, for the +// --branch view. +func renderVersionsTable(w io.Writer, isTTY bool, streams []portal.ProjectVersionStream) error { + headers := []string{"VERSION", "CREATED", "DIGEST", "IMAGE"} + + var rows [][]string + + for _, s := range streams { + for _, v := range s.Versions { + digest := v.Digest + rows = append(rows, []string{ + output.OrDash(v.Name), + output.OrDash(v.Created), + discovery.ShortDigestCell(&digest), + output.OrDash(s.Image), + }) + } + } + + return discovery.PrintTable(w, isTTY, headers, rows) +} diff --git a/pkg/cmd/project/versions/versions_test.go b/pkg/cmd/project/versions/versions_test.go new file mode 100644 index 0000000..4d5921b --- /dev/null +++ b/pkg/cmd/project/versions/versions_test.go @@ -0,0 +1,208 @@ +package versions + +import ( + "bytes" + "strings" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal" + "github.com/KubeRocketCI/cli/pkg/cmd/internal/cmdtest" +) + +var newFactory = cmdtest.NewFactory + +func TestVersions_RequiresExactlyOnePositional(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{{}, {"a", "b"}} { + cmd := NewCmdVersions(newFactory(), nil) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err == nil { + t.Errorf("expected error for args=%v", args) + } + } +} + +func TestVersions_RejectsInvalidProject(t *testing.T) { + t.Parallel() + + for _, p := range []string{"Bad_Name", "UPPER", strings.Repeat("a", 256)} { + cmd := NewCmdVersions(newFactory(), nil) + cmd.SetArgs([]string{p}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil { + t.Errorf("expected error for project=%q", p) + continue + } + + if !strings.Contains(err.Error(), "DNS-1123") { + t.Errorf("project=%q: expected DNS-1123 message, got %v", p, err) + } + } +} + +func TestVersions_AcceptsBranchWithSlash(t *testing.T) { + t.Parallel() + + called := false + + cmd := NewCmdVersions(newFactory(), func(opts *ListOptions) error { + called = true + + if opts.Project != "my-app" { + t.Errorf("Project = %q, want my-app", opts.Project) + } + + if opts.Branch != "release/2.27" { + t.Errorf("Branch = %q, want release/2.27", opts.Branch) + } + + return nil + }) + + cmd.SetArgs([]string{"my-app", "--branch", "release/2.27", "-o", "json"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if !called { + t.Error("runF was not invoked") + } +} + +func TestVersions_RejectsTooLongBranch(t *testing.T) { + t.Parallel() + + cmd := NewCmdVersions(newFactory(), nil) + cmd.SetArgs([]string{"my-app", "--branch", strings.Repeat("b", portal.MaxBranchLength+1)}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for an over-long branch") + } + + if !strings.Contains(err.Error(), "--branch must be at most") { + t.Errorf("error = %v, want the --branch bound", err) + } +} + +func TestVersions_RejectsUnknownOutputFormat(t *testing.T) { + t.Parallel() + + cmd := NewCmdVersions(newFactory(), nil) + cmd.SetArgs([]string{"my-app", "-o", "yaml"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for -o yaml") + } + + if !strings.Contains(err.Error(), "unknown output format") { + t.Errorf("error = %v, want 'unknown output format'", err) + } +} + +func sampleStreams() []portal.ProjectVersionStream { + return []portal.ProjectVersionStream{ + {Branch: "feature-x", Image: "registry.example.com/ns/my-app", Versions: []portal.ImageVersion{}}, + { + Branch: "main", Image: "registry.example.com/ns/my-app", + Versions: []portal.ImageVersion{ + {Name: "0.1.0-SNAPSHOT.2", Created: "2026-09-02T09:00:00Z", Digest: "sha256:cccc3333dddd4444"}, + {Name: "0.1.0-SNAPSHOT.1", Created: "2026-08-30T09:00:00Z"}, + }, + }, + } +} + +func TestRenderStreamsTable(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := renderStreamsTable(&buf, false, sampleStreams()); err != nil { + t.Fatalf("renderStreamsTable error: %v", err) + } + + out := buf.String() + for _, want := range []string{"BRANCH", "VERSIONS", "LATEST", "CREATED", "IMAGE", "main", "0.1.0-SNAPSHOT.2", "2026-09-02T09:00:00Z", "feature-x"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 3 { + t.Fatalf("want header + 2 rows, got %d lines:\n%s", len(lines), out) + } + + if !strings.Contains(lines[1], "feature-x") || !strings.Contains(lines[1], "0") || !strings.Contains(lines[1], "-") { + t.Errorf("a stream without versions shows 0 and dashes: %q", lines[1]) + } + + if strings.Contains(out, "0.1.0-SNAPSHOT.1") { + t.Errorf("the per-branch view lists only the newest version:\n%s", out) + } +} + +func TestEmptyNote(t *testing.T) { + t.Parallel() + + streams := sampleStreams() + + cases := []struct { + name string + branch string + streams []portal.ProjectVersionStream + want string + }{ + {name: "streams with versions", branch: "", streams: streams, want: ""}, + {name: "no streams", branch: "", streams: nil, want: "No versions found for project my-app."}, + {name: "branch with versions", branch: "main", streams: streams[1:], want: ""}, + {name: "branch never built", branch: "feature-x", streams: streams[:1], want: "No versions found for branch feature-x of project my-app."}, + {name: "unknown branch", branch: "ghost", streams: nil, want: "No versions found for branch ghost of project my-app."}, + } + + for _, tc := range cases { + if got := emptyNote("my-app", tc.branch, tc.streams); got != tc.want { + t.Errorf("%s: emptyNote = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestRenderVersionsTable(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := renderVersionsTable(&buf, false, sampleStreams()[1:]); err != nil { + t.Fatalf("renderVersionsTable error: %v", err) + } + + out := buf.String() + for _, want := range []string{"VERSION", "CREATED", "DIGEST", "IMAGE", "0.1.0-SNAPSHOT.2", "0.1.0-SNAPSHOT.1", "sha256:cccc333"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + if strings.Contains(out, "sha256:cccc3333dddd4444") { + t.Errorf("the table shortens digests:\n%s", out) + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[1], "0.1.0-SNAPSHOT.2") { + t.Errorf("want header + newest-first rows, got:\n%s", out) + } +}