diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000..23f7408 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,280 @@ +# `krci env` — Environments (Stages) + +Inspect KRCI environments — the `Stage` resources surfaced as +"environments" in the Portal — without leaving the terminal. Lists every +stage in the configured namespace or shows full detail for one +(deployment, env) pair, including infrastructure, quality gates, and the +projects deployed there with health, sync, version, image digest, and +ingress URLs. + +**Alias:** `e` + +## Subcommands + +| Command | Purpose | +|---------------------------------------------|------------------------------------------------------------------------| +| `env list` (`ls`) | List every environment in the namespace, optionally filtered | +| `env get ` | Full detail for one environment, including the projects deployed there | + +Both accept `-o, --output` with `table` (default) or `json`. + +`` everywhere refers to `Stage.spec.name` (the short user-facing +identifier `dev`, `stage`, `prod`, `qa`), not the compound K8s resource +name. + +## `env list` + +```bash +krci env list +``` + +``` +DEPLOYMENT ENV CLUSTER NAMESPACE TRIGGER STATUS +my-pipeline dev in-cluster my-pipeline-dev Auto created +my-pipeline stage in-cluster my-pipeline-stage Manual created +my-pipeline prod in-cluster my-pipeline-prod Manual created +other-pipe dev in-cluster other-pipe-dev Auto in_progress +``` + +Sort order: `deployment` ascending, then `Stage.spec.order` ascending. + +### Filters + +```bash +# Only one deployment +krci env list --deployment my-pipeline + +# Only one cluster +krci env list --cluster in-cluster + +# Combined +krci env list --deployment my-pipeline --cluster in-cluster +``` + +| Flag | Purpose | +|-----------------|---------------------------------------------------------------| +| `--deployment` | Filter by parent CDPipeline name (DNS-1123 label) | +| `--cluster` | Filter by `Stage.spec.clusterName` (DNS-1123 label) | +| `-o, --output` | `table` (default) or `json` | + +Empty result is success: `data.stages: []`, exit `0`, with +`No environments found.` to stderr in table mode. + +### JSON envelope + +```bash +krci env list -o json +``` + +```json +{ + "schemaVersion": "1", + "data": { + "stages": [ + { + "deployment": "my-pipeline", + "env": "dev", + "cluster": "in-cluster", + "namespace": "my-pipeline-dev", + "triggerType": "Auto", + "status": "created", + "order": 0 + } + ] + } +} +``` + +Scripting — group environments by status: + +```bash +krci env list -o json | jq -r '.data.stages[] | "\(.deployment)/\(.env): \(.status)"' +``` + +## `env get` + +```bash +krci env get my-pipeline prod +``` + +``` +Environment: prod +Deployment: my-pipeline +Status: created +Description: Production environment +Order: 2 + +Infrastructure: + Cluster: in-cluster + Namespace: my-pipeline-prod + Trigger Type: Manual + Deploy Pipeline: deploy-with-helm + Clean Pipeline: clean-helm + +Quality Gates (2): + - manual: stage-approval + - autotests: smoke-tests (branch: main) + +Projects (3): +PROJECT STATUS SYNC VERSION IMAGE_SHA INGRESS +foo healthy synced 1.2.0 sha256:abc12345 foo.prod.example.com +bar healthy synced 2.0.1 sha256:def34567 bar.prod.example.com + bar-admin.prod.example.com +baz - - - - - +``` + +`` and `` are **both positional and both required**. Both +must be DNS-1123 labels (lowercase alphanumerics + hyphens, ≤ 63 chars). +Invalid input fails with exit `1` before contacting the Portal. + +### Output blocks + +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). +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. + +Projects sub-table semantics: + +- **STATUS** — ArgoCD health: `healthy` (green), `degraded` / `missing` (red), + `progressing` (blue, same color as a running pipeline run); `-` when the + project is registered in `CDPipeline.spec.applications` but no `Application` + exists yet +- **VERSION** — helm `image.tag` parameter (or `targetRevision` last segment + when helm is absent) +- **IMAGE_SHA** — short content digest (`sha256:` + first 8 hex chars = + 15 visible chars). Full digest under `imageDigest` in `-o json` +- **INGRESS** — hostnames from `status.summary.externalURLs`. Multiple URLs + stack across visual rows (the project name appears only on the first row); + hostnames are truncated at 50 chars but the OSC 8 hyperlink target keeps + the full URL — `cmd-click` opens it + +### Not-found errors + +```bash +$ krci env get nope dev +Error: deployment "nope" not found + +$ krci env get my-pipeline wat +Error: environment "wat" not found in deployment "my-pipeline" +``` + +Both exit `1`. Under `-o json` the message is also written to stdout as +`{"schemaVersion":"1","error":{"message":"…"}}`. + +### JSON envelope + +```bash +krci env get my-pipeline prod -o json +``` + +```json +{ + "schemaVersion": "1", + "data": { + "deployment": "my-pipeline", + "env": "prod", + "status": "created", + "description": "Production environment", + "order": 2, + "infrastructure": { + "cluster": "in-cluster", + "namespace": "my-pipeline-prod", + "triggerType": "Manual", + "deployPipeline": "deploy-with-helm", + "cleanPipeline": "clean-helm" + }, + "qualityGates": [ + { "type": "manual", "stepName": "stage-approval", "autotestName": null, "branchName": null }, + { "type": "autotests", "stepName": "smoke", "autotestName": "smoke-tests", "branchName": "main" } + ], + "projects": [ + { + "name": "foo", + "status": "healthy", + "sync": "synced", + "version": "1.2.0", + "imageTag": "1.2.0", + "imageDigest": "sha256:abc12345...", + "ingressUrls": ["https://foo.prod.example.com"], + "argocdUrl": "/applications/my-pipeline-my-pipeline-prod-foo", + "deployedAt": "2026-04-25T08:00:00Z", + "valuesOverride": false + }, + { + "name": "baz", + "status": null, + "sync": null, + "version": null, + "imageTag": null, + "imageDigest": null, + "ingressUrls": [], + "argocdUrl": null, + "deployedAt": null, + "valuesOverride": null + } + ] + } +} +``` + +Field absence rules: + +- `description`, `cleanPipeline` → `null` when the Stage spec omits them. +- 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. +- `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`. + +### Scripting examples + +```bash +# Projects that aren't healthy in this env +krci env get my-pipeline prod -o json | + jq -r '.data.projects[] | select(.status != "healthy" and .status != null) | "\(.name): \(.status)"' + +# All ingress URLs for one project in this env +krci env get my-pipeline prod -o json | + jq -r '.data.projects[] | select(.name=="foo") | .ingressUrls[]' + +# Quality-gate names + branches +krci env get my-pipeline prod -o json | + jq -r '.data.qualityGates[] | "\(.type): \(.stepName) (\(.branchName // "no-branch"))"' +``` + +## Status colors (TTY only) + +| Value | Color | Same as | +|-----------------|---------|---------------------| +| `healthy` | green | — | +| `degraded` | red | — | +| `missing` | red | — | +| `progressing` | blue | running pipelinerun | +| anything else (`suspended`, `unknown`, …) | unstyled | — | + +## Typical workflows + +```bash +# Where do I have stages, and what state are they in? +krci env list + +# Drill into one environment +krci env get my-pipeline prod + +# Quick "what's deployed in dev?" loop, JSON-friendly +krci env list --deployment my-pipeline -o json | + jq -r '.data.stages[] | "\(.env): \(.status)"' + +# Pre-deploy sanity check — every project healthy in prod? +krci env get my-pipeline prod -o json | + jq -e 'all(.data.projects[]; .status == "healthy" or .status == null)' >/dev/null +``` diff --git a/docs/json-schemas.md b/docs/json-schemas.md index 4504e6e..9e88118 100644 --- a/docs/json-schemas.md +++ b/docs/json-schemas.md @@ -311,6 +311,154 @@ scripts should branch on `.data.truncated` rather than counting rows. Severity enum: `CRITICAL | HIGH | MEDIUM | LOW | INFO | UNASSIGNED`. +## `krci env list` + +```json +{ + "schemaVersion": "1", + "data": { + "stages": [ + { + "deployment": "my-pipeline", + "env": "dev", + "cluster": "in-cluster", + "namespace": "my-pipeline-dev", + "triggerType": "Auto", + "status": "created", + "order": 0 + } + ] + } +} +``` + +Rows are sorted by `deployment` ascending, then by `Stage.spec.order` ascending. +Empty result returns `data.stages: []` and exit 0; the table view writes a +single `No environments found.` line to stderr. + +## `krci env get ` + +```json +{ + "schemaVersion": "1", + "data": { + "deployment": "my-pipeline", + "env": "prod", + "status": "created", + "description": "Production environment", + "order": 2, + "infrastructure": { + "cluster": "in-cluster", + "namespace": "my-pipeline-prod", + "triggerType": "Manual", + "deployPipeline": "deploy-with-helm", + "cleanPipeline": "clean-helm" + }, + "qualityGates": [ + { + "type": "manual", + "stepName": "stage-approval", + "autotestName": null, + "branchName": null + } + ], + "projects": [ + { + "name": "foo", + "status": "healthy", + "sync": "synced", + "version": "1.2.0", + "imageTag": "1.2.0", + "imageDigest": "sha256:abc12345...", + "ingressUrls": ["https://foo.prod.example.com"], + "argocdUrl": "/applications/my-pipeline-prod-foo", + "deployedAt": "2026-04-25T08:00:00Z", + "valuesOverride": false + } + ] + } +} +``` + +Field absence rules: + +- `description`, `cleanPipeline` may be `null` when absent on the Stage spec. +- 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). +- 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. +- `qualityGates[]` is always an array (empty when none). +- `projects[]` is sorted by name ascending. +- In table mode, the `INGRESS` column stacks one URL per visual row when a + project carries multiple ingresses (project name appears only on the first + row); each hostname is truncated to 50 chars with a trailing `...` when + longer, but the OSC 8 hyperlink target retains the full URL. JSON output is + unaffected by this rendering — `ingressUrls` is always the full array. + +## `krci project deployments ` + +```json +{ + "schemaVersion": "1", + "data": { + "project": "foo", + "rows": [ + { + "deployment": "my-pipeline", + "env": "dev", + "deployed": true, + "status": "healthy", + "sync": "synced", + "version": "1.2.3", + "imageTag": "1.2.3", + "imageDigest": "sha256:abc12345...", + "cluster": "in-cluster", + "namespace": "my-pipeline-dev", + "triggerType": "Auto", + "deployedAt": "2026-04-25T08:00:00Z", + "ingressUrls": ["https://foo.dev.example.com"], + "argocdUrl": "/applications/my-pipeline-dev-foo" + }, + { + "deployment": "other-pipe", + "env": "dev", + "deployed": false, + "status": null, + "sync": null, + "version": null, + "imageTag": null, + "imageDigest": null, + "cluster": "in-cluster", + "namespace": "other-pipe-dev", + "triggerType": "Auto", + "deployedAt": null, + "ingressUrls": [], + "argocdUrl": null + } + ] + } +} +``` + +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 `[]`. +- 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 + the user's perspective: both return `data.rows: []` with exit 0. +- In table mode, the `INGRESS` column stacks one URL per visual row when a + (deployment, env) row carries multiple ingresses (deployment/env/etc. + 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. + ## Error envelope ```json diff --git a/docs/project.md b/docs/project.md index 7be576b..6876f8c 100644 --- a/docs/project.md +++ b/docs/project.md @@ -1,18 +1,20 @@ # `krci project` — Codebase resources Inspect the `Codebase` resources registered in the Portal — applications, -libraries, autotests, and infrastructure repos. +libraries, autotests, and infrastructure repos — and trace where each one +is currently deployed. **Alias:** `proj` ## Subcommands -| Command | Purpose | -|--------------------------|-------------------------| -| `project list` (`ls`) | List all projects | -| `project get ` | Show a single project | +| Command | Purpose | +|--------------------------------------|--------------------------------------------------------------------------------------| +| `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 | -Both accept `-o, --output` with `table` (default) or `json`. +All accept `-o, --output` with `table` (default) or `json`. ## `project list` @@ -43,13 +45,13 @@ krci project get keycloak-operator ``` Name: keycloak-operator -Namespace: edp-delivery +Namespace: team-a Type: application Language: other Build Tool: go Framework: keycloak-operator Git Server: github -Git URL: https://github.com/epam/edp-keycloak-operator +Git URL: https://github.com/example-org/keycloak-operator Status: created Available: true ``` @@ -59,13 +61,13 @@ JSON envelope (full output): ```json { "name": "keycloak-operator", - "namespace": "edp-delivery", + "namespace": "team-a", "type": "application", "language": "other", "buildTool": "go", "framework": "keycloak-operator", "gitServer": "github", - "gitUrl": "https://github.com/epam/edp-keycloak-operator", + "gitUrl": "https://github.com/example-org/keycloak-operator", "status": "created", "available": true } @@ -76,3 +78,128 @@ Pull a single field from an agent workflow: ```bash krci project get keycloak-operator -o json | jq -r '.gitUrl' ``` + +## `project deployments` + +Answers "where is my project deployed, and at what version?" — one row per +(deployment, env) pair where the project is registered, with current +health, sync, version, image digest, cluster, namespace, and ingress URLs. + +```bash +krci project deployments payments-api +``` + +``` +DEPLOYMENT ENV STATUS SYNC VERSION IMAGE_SHA CLUSTER NAMESPACE INGRESS +my-pipeline dev healthy synced 1.2.3 sha256:abc12345 cluster-a my-pipeline-dev payments-api.dev.example.com +my-pipeline stage healthy synced 1.2.3 sha256:abc12345 cluster-a my-pipeline-stage payments-api.stage.example.com +my-pipeline prod progressing synced 1.2.3 sha256:abc12345 cluster-a my-pipeline-prod payments-api.prod.example.com +other-pipe dev degraded outofsync 1.2.0 sha256:def34567 cluster-b other-pipe-dev payments-api.dev2.example.com +legacy dev - - - - cluster-a legacy-dev - +``` + +Columns (in order, `INGRESS` is always last): + +- **DEPLOYMENT / ENV** — the (CDPipeline, Stage) pair +- **STATUS** — ArgoCD health: `healthy` (green), `degraded` / `missing` (red), + `progressing` (blue, same color as a running pipeline run), `-` when the project is + registered but no `Application` exists yet +- **SYNC** — ArgoCD sync state (`synced`, `outofsync`, `unknown`); `-` when not deployed +- **VERSION** — derived from the Argo Application's helm `image.tag` parameter (or + `targetRevision` when no helm parameter is set) +- **IMAGE_SHA** — short content digest (`sha256:` + first 8 hex chars = 15 visible chars) + matched from `status.summary.images`. Full digest in `-o json` +- **CLUSTER / NAMESPACE / TRIGGER TYPE** — Stage's static placement, populated even on + `deployed: false` rows so you see the full footprint +- **INGRESS** — hostnames from the Application's `status.summary.externalURLs`. Multiple + URLs stack across visual rows (project name appears only on the first row); hostnames + are truncated at 50 chars but the OSC 8 hyperlink target keeps the full URL — + `cmd-click` opens it + +Sort order: `deployment` ascending, then `Stage.spec.order` ascending. + +### "Registered but not deployed" rows + +When a project is listed in `CDPipeline.spec.applications` but no +`Application` resource exists yet, the row is emitted with `-` placeholders +in the dynamic columns (table) or `null` in JSON, and `deployed: false`. +`cluster`, `namespace`, and `triggerType` still come through from the Stage +so you see exactly where the project will land once deployed. + +### JSON envelope + +```bash +krci project deployments payments-api -o json +``` + +```json +{ + "schemaVersion": "1", + "data": { + "project": "payments-api", + "rows": [ + { + "deployment": "my-pipeline", + "env": "dev", + "deployed": true, + "status": "healthy", + "sync": "synced", + "version": "1.2.3", + "imageTag": "1.2.3", + "imageDigest": "sha256:abc12345...", + "cluster": "cluster-a", + "namespace": "my-pipeline-dev", + "triggerType": "Auto", + "deployedAt": "2026-04-25T08:00:00Z", + "ingressUrls": ["https://payments-api.dev.example.com"], + "argocdUrl": "/applications/my-pipeline-dev-payments-api" + }, + { + "deployment": "legacy", + "env": "dev", + "deployed": false, + "status": null, + "sync": null, + "version": null, + "imageTag": null, + "imageDigest": null, + "cluster": "cluster-a", + "namespace": "legacy-dev", + "triggerType": "Auto", + "deployedAt": null, + "ingressUrls": [], + "argocdUrl": null + } + ] + } +} +``` + +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 +indistinguishable — both return empty rows with exit 0. + +### Scripting examples + +```bash +# Every deployed instance of the project, with version +krci project deployments payments-api -o json | + jq -r '.data.rows[] | select(.deployed) | "\(.deployment)/\(.env): \(.version)"' + +# Find degraded environments +krci project deployments payments-api -o json | + jq -r '.data.rows[] | select(.status=="degraded") | "\(.deployment)/\(.env)"' + +# Registered-but-not-deployed pairs (where the project is expected but missing) +krci project deployments payments-api -o json | + jq -r '.data.rows[] | select(.deployed==false) | "\(.deployment)/\(.env) on \(.cluster)"' + +# Count deployed vs registered-only +krci project deployments payments-api -o json | + jq '.data.rows | group_by(.deployed) | map({deployed: .[0].deployed, count: length})' +``` + +`` is positional, required, single, and must be a DNS-1123 label +(lowercase alphanumerics + hyphens, ≤ 63 chars). Invalid input fails with +exit `1` before contacting the Portal. diff --git a/e2e/env/fixtures.env.example b/e2e/env/fixtures.env.example new file mode 100644 index 0000000..e5d102a --- /dev/null +++ b/e2e/env/fixtures.env.example @@ -0,0 +1,30 @@ +# Copy to fixtures.env and fill in values from your own portal. +# The /e2e command loads this file (if present) before running portal rows. +# If a row references a placeholder that has no value, the row is SKIPped. +# +# Discover good values with: +# ./dist/krci env list -o json | jq '.data.stages[0]' +# ./dist/krci env list -o json | jq -r '.data.stages[].deployment' | sort -u +# ./dist/krci env list --deployment -o json | jq -r '.data.stages[].env' +# ./dist/krci env get -o json | jq '.data.projects[] | select((.ingressUrls | length) > 1)' + +# A deployment (CDPipeline) with at least one Stage in the configured namespace. +DEPLOYMENT_OK=my-pipeline + +# A Stage.spec.name that exists under DEPLOYMENT_OK (e.g. dev / stage / prod / qa). +ENV_OK=dev + +# An env on DEPLOYMENT_OK whose Projects sub-table includes a project with >=2 ingress URLs. +ENV_MULTI_INGRESS=dev + +# A deployment name that does NOT exist in the cluster. +DEPLOYMENT_MISSING=does-not-exist-xyz + +# An env name that does NOT exist on DEPLOYMENT_OK. +ENV_MISSING=nope + +# A cluster name that at least one Stage references (Stage.spec.clusterName). +CLUSTER_OK=cluster-a + +# A project (Codebase) registered in DEPLOYMENT_OK.spec.applications with a real Application. +PROJECT_DEPLOYED=payments-api diff --git a/e2e/env/test-cases.md b/e2e/env/test-cases.md new file mode 100644 index 0000000..9601557 --- /dev/null +++ b/e2e/env/test-cases.md @@ -0,0 +1,123 @@ +# `krci env` — e2e test cases + +Covers the `env` command group (alias `e`) and its two subcommands `list` +(alias `ls`) and `get`. Source: `pkg/cmd/env/`, +`pkg/cmd/internal/discovery/`, and `docs/json-schemas.md`. + +The `env` group surfaces KRCI `Stage` resources as user-facing +"environments". `list` returns every stage in the configured namespace +(optionally filtered by parent deployment / cluster). `get` takes two +positionals (` `) and returns the full detail block plus +a per-project sub-table with health/sync/version/image-digest/ingress. + +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 | +|------------------------|------------------------------------------------------------------------------------------------------|-----------------| +| `{{DEPLOYMENT_OK}}` | A deployment (CDPipeline) that exists and has at least one Stage in the configured namespace. | `tekton` | +| `{{ENV_OK}}` | A `Stage.spec.name` that exists under `{{DEPLOYMENT_OK}}` (e.g. `dev`, `stage`, `prod`, `qa`). | `dev` | +| `{{ENV_MULTI_INGRESS}}` | An env on `{{DEPLOYMENT_OK}}` whose Projects sub-table includes a project with **>=2 ingress URLs**. | `dev` | +| `{{DEPLOYMENT_MISSING}}` | A deployment name that does NOT exist in the cluster. | `does-not-exist`| +| `{{ENV_MISSING}}` | An env name that does NOT exist on `{{DEPLOYMENT_OK}}`. | `nope` | +| `{{CLUSTER_OK}}` | A cluster name that at least one Stage references (`Stage.spec.clusterName`). | `in-cluster` | +| `{{PROJECT_DEPLOYED}}` | A project (Codebase) registered in `{{DEPLOYMENT_OK}}.spec.applications` with a real Application. | `krci-portal` | + +The orchestrator fills these; the table never hard-codes them. + +--- + +## 1. Help & discovery (env: `offline`) + +Fast, idempotent, no portal — these are the first line of defence. + +| ID | Command | Env | Setup | Expect | +|----------|----------------------------------|---------|-------|-----------------------------------------------------------------------------------------------------------------------------------------| +| ENV-H-01 | `krci env --help` | offline | — | `exit=0; stdout~/Inspect KRCI environments/; stdout~/^Aliases:$/; stdout~/^\s+env, e$/; stdout~/^\s+list\s/; stdout~/^\s+get\s/` | +| ENV-H-02 | `krci e --help` | offline | — | `exit=0; stdout~/Inspect KRCI environments/` | +| ENV-H-03 | `krci env list --help` | offline | — | `exit=0; stdout~/--deployment string/; stdout~/--cluster string/; stdout~/-o, --output string/; stdout~/^Aliases:$/; stdout~/^\s+list, ls$/` | +| ENV-H-04 | `krci env ls --help` | offline | — | `exit=0; stdout~/List environments/` | +| ENV-H-05 | `krci env get --help` | offline | — | `exit=0; stdout~/^Usage:$/; stdout~/^\s+krci env get \[flags\]$/; stdout~/-o, --output string/; stdout!~/--pr/` | +| ENV-H-06 | `krci env` | offline | — | `exit=0; stdout~/^Available Commands:$/; stdout~/^\s+list\s/; stdout~/^\s+get\s/` | + +## 2. Argument validation (env: `offline`) + +Wrong shape of invocation must fail fast with a helpful message and a +non-zero exit. Catches DNS-1123, output-format, and arg-count regressions. + +| ID | Command | Env | Setup | Expect | +|----------|------------------------------------------------------|---------|-------|-------------------------------------------------------------------------| +| ENV-V-01 | `krci env get` | offline | — | `exit=1; stderr~/requires a deployment and an env/` | +| ENV-V-02 | `krci env get only-one` | offline | — | `exit=1; stderr~/requires a deployment and an env/` | +| ENV-V-03 | `krci env get a b c` | offline | — | `exit=1; stderr~/requires a deployment and an env/` | +| ENV-V-04 | `krci env get BAD_NAME prod` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 label/` | +| ENV-V-05 | `krci env get my-pipeline Bad_Env` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 label/` | +| ENV-V-06 | `krci env list --deployment Bad_Name` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 label/` | +| ENV-V-07 | `krci env list --cluster Bad_Cluster` | offline | — | `exit=1; stderr~/--cluster must be a valid DNS-1123 label/` | +| ENV-V-08 | `krci env list -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| ENV-V-09 | `krci env get my-pipeline prod -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| ENV-V-10 | `krci env list --unknown-flag` | offline | — | `exit=1; stderr~/unknown flag: --unknown-flag/` | +| ENV-V-11 | `krci env get --unknown-flag a b` | offline | — | `exit=1; stderr~/unknown flag: --unknown-flag/` | +| ENV-V-12 | `krci env list stray-positional` | offline | — | `exit=1; stderr~/unknown command "stray-positional"/` | +| ENV-V-13 | `krci env list --deployment --cluster x` | offline | — | `exit=1; stderr~/flag needs an argument: --deployment/` | +| ENV-V-14 | `krci env list --cluster --deployment x` | offline | — | `exit=1; stderr~/flag needs an argument: --cluster/` | + +## 3. `list` — happy paths (env: `portal`) + +Requires `krci auth status` → Authenticated. Each row asserts the exit +code is 0 and the JSON envelope matches the documented shape (`schemaVersion=1`, +`data.stages[]`). + +| ID | Command | Env | Setup | Expect | +|----------|---------------------------------------------------------------------------|--------|----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| ENV-L-01 | `krci env list` | portal | any stage exists | `exit=0; stdout~/^DEPLOYMENT/; stdout~/ENV/; stdout~/CLUSTER/; stdout~/NAMESPACE/; stdout~/TRIGGER/; stdout~/STATUS/` | +| ENV-L-02 | `krci env list -o json` | portal | any stage exists | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.stages:exists` | +| ENV-L-03 | `krci env list --deployment {{DEPLOYMENT_OK}} -o json` | portal | DEPLOYMENT_OK has stages | `exit=0; stdout_json.data.stages.0.deployment={{DEPLOYMENT_OK}}` | +| ENV-L-04 | `krci env list --cluster {{CLUSTER_OK}} -o json` | portal | CLUSTER_OK has stages | `exit=0; stdout_json.data.stages.0.cluster={{CLUSTER_OK}}` | +| ENV-L-05 | `krci env list --deployment {{DEPLOYMENT_OK}} --cluster {{CLUSTER_OK}} -o json` | portal | both filters match a stage | `exit=0; stdout_json.data.stages.0.deployment={{DEPLOYMENT_OK}}; stdout_json.data.stages.0.cluster={{CLUSTER_OK}}` | +| ENV-L-06 | `krci env list --deployment {{DEPLOYMENT_MISSING}} -o json` | portal | no matching stages | `exit=0; stdout_json.data.stages:len=0` | +| ENV-L-07 | `krci env ls -o json` | portal | any stage exists | `exit=0; stdout_json.data.stages:exists` | + +## 4. `list` — JSON envelope shape (env: `portal`) + +Verifies every documented field is present on at least one row. + +| ID | Command | Env | Setup | Expect | +|----------|--------------------------|--------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ENV-J-01 | `krci env list -o json` | portal | any stage exists | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.stages.0.deployment:exists; stdout_json.data.stages.0.env:exists; stdout_json.data.stages.0.cluster:exists; stdout_json.data.stages.0.namespace:exists; stdout_json.data.stages.0.triggerType:exists; stdout_json.data.stages.0.status:exists; stdout_json.data.stages.0.order:exists` | + +## 5. `get` — happy paths (env: `portal`) + +| ID | Command | Env | Setup | Expect | +|----------|---------------------------------------------------------------|--------|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| ENV-G-01 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}}` | portal | env exists | `exit=0; stdout~/^Environment:/; stdout~/^Deployment:/; stdout~/^Status:/; stdout~/^Infrastructure/; stdout~/^Quality Gates \([0-9]+\)/; stdout~/^Projects \([0-9]+\)/` | +| 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` | + +## 6. `get` — sub-table layout (env: `portal`) + +Verifies the Projects sub-table column order matches `M4`, the +`IMAGE_SHA` column header is in place, and multi-ingress projects stack +across rows. + +| ID | Command | Env | Setup | Expect | +|----------|---------------------------------------------------------------|--------|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------| +| ENV-T-01 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}}` | portal | env has projects | `exit=0; stdout~/PROJECT/; stdout~/STATUS/; stdout~/SYNC/; stdout~/VERSION/; stdout~/IMAGE_SHA/; stdout~/INGRESS/; stdout!~/^IMAGE\s/` | +| ENV-T-02 | `krci env get {{DEPLOYMENT_OK}} {{ENV_MULTI_INGRESS}} -o json` | portal | project with >=2 ingress URLs | `exit=0; stdout_json.data.projects.0.ingressUrls:exists` | +| ENV-T-03 | `krci env get {{DEPLOYMENT_OK}} {{ENV_OK}} -o json` | portal | project deployed | `exit=0; stdout_json.data.projects.0.status:exists` | + +## 7. Not-found errors (env: `portal`) + +`get` is detail-style: a missing deployment OR env is exit `1` with a +typed message. Under `-o json` the error envelope is on stdout. + +| ID | Command | Env | Setup | Expect | +|----------|--------------------------------------------------------------------|--------|------------------------------------|---------------------------------------------------------------------------------------------------------------------| +| ENV-E-01 | `krci env get {{DEPLOYMENT_MISSING}} {{ENV_OK}}` | portal | deployment absent | `exit=1; stderr~/deployment "{{DEPLOYMENT_MISSING}}" not found/` | +| ENV-E-02 | `krci env get {{DEPLOYMENT_OK}} {{ENV_MISSING}}` | portal | env absent on deployment | `exit=1; stderr~/environment "{{ENV_MISSING}}" not found in deployment "{{DEPLOYMENT_OK}}"/` | +| ENV-E-03 | `krci env get {{DEPLOYMENT_MISSING}} {{ENV_OK}} -o json` | portal | — | `exit=1; stdout_json.schemaVersion=1; stdout_json.error.message:exists` | +| ENV-E-04 | `krci env get {{DEPLOYMENT_OK}} {{ENV_MISSING}} -o json` | portal | — | `exit=1; stdout_json.error.message:exists` | +| ENV-E-05 | `krci env list --deployment {{DEPLOYMENT_MISSING}}` | portal | no matching stages | `exit=0; stderr~/No environments found\./` | diff --git a/e2e/project/fixtures.env.example b/e2e/project/fixtures.env.example new file mode 100644 index 0000000..1628847 --- /dev/null +++ b/e2e/project/fixtures.env.example @@ -0,0 +1,26 @@ +# Copy to fixtures.env and fill in values from your own portal. +# The /e2e command loads this file (if present) before running portal rows. +# If a row references a placeholder that has no value, the row is SKIPped. +# +# Discover good values with: +# ./dist/krci project list -o json | jq -r '.[].name' | head +# ./dist/krci project deployments -o json | jq '.data.rows | map(select(.deployed==true)) | length' +# ./dist/krci project deployments -o json | jq '.data.rows | map(select(.deployed==false))' + +# A codebase listed in at least one CDPipeline.spec.applications AND with a real Application. +PROJECT_DEPLOYED=payments-api + +# A codebase listed in CDPipeline.spec.applications but with no matching Application. +PROJECT_REGISTERED=pending-svc + +# A real codebase not listed in any CDPipeline (returns empty rows, exit 0). +PROJECT_NOWHERE=infra-gitops + +# A name that does not exist anywhere in the cluster. +PROJECT_MISSING=does-not-exist-xyz + +# A deployment that registers PROJECT_DEPLOYED in spec.applications. +DEPLOYMENT_OK=my-pipeline + +# An env on DEPLOYMENT_OK where PROJECT_DEPLOYED has a deployed Application. +ENV_OK=dev diff --git a/e2e/project/test-cases.md b/e2e/project/test-cases.md new file mode 100644 index 0000000..f299e75 --- /dev/null +++ b/e2e/project/test-cases.md @@ -0,0 +1,103 @@ +# `krci project` — e2e test cases + +Covers the `project` command group (alias `proj`). Source: +`pkg/cmd/project/`. This file presently exercises the **`deployments`** +verb only — `list` and `get` are out of scope here and may be added in a +future iteration. Row IDs use a verb-segmented prefix +(`PROJ-D-NN` = project / deployments / NN) so siblings can co-exist. + +`krci project deployments ` returns every (deployment, env) row +in the configured namespace where the given project is registered, with +current health/sync/version/image-digest/cluster/namespace/ingress. +Rows for stages where the project is registered (`CDPipeline.spec.applications`) +but no `Application` exists yet are emitted with `-` placeholders (table) +or `null` values (JSON, with `deployed: false`). + +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 | +|------------------------|---------------------------------------------------------------------------------------------|-----------------| +| `{{PROJECT_DEPLOYED}}` | A codebase listed in at least one `CDPipeline.spec.applications` AND with a real Application. | `krci-portal` | +| `{{PROJECT_REGISTERED}}` | A codebase listed in `CDPipeline.spec.applications` but with no matching Application. | `pending-svc` | +| `{{PROJECT_NOWHERE}}` | A real codebase not listed in any CDPipeline (returns empty rows, exit 0). | `infra-gitops` | +| `{{PROJECT_MISSING}}` | A name that does not exist anywhere in the cluster. | `does-not-exist`| +| `{{DEPLOYMENT_OK}}` | A deployment that registers `{{PROJECT_DEPLOYED}}` in `spec.applications`. | `krci-portal` | +| `{{ENV_OK}}` | An env on `{{DEPLOYMENT_OK}}` where `{{PROJECT_DEPLOYED}}` has a deployed Application. | `dev` | + +The orchestrator fills these; the table never hard-codes them. + +--- + +## 1. Help & discovery (env: `offline`) + +Fast, idempotent, no portal — these are the first line of defence. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------|---------|-------|----------------------------------------------------------------------------------------------------------------------------------| +| PROJ-D-H-01 | `krci project --help` | offline | — | `exit=0; stdout~/Manage projects \(Codebases\)/; stdout~/^Aliases:$/; stdout~/^\s+project, proj$/; stdout~/^\s+deployments\s/` | +| PROJ-D-H-02 | `krci proj --help` | offline | — | `exit=0; stdout~/Manage projects \(Codebases\)/` | +| PROJ-D-H-03 | `krci project deployments --help` | offline | — | `exit=0; stdout~/^Usage:$/; stdout~/^\s+krci project deployments \[flags\]$/; stdout~/-o, --output string/` | +| PROJ-D-H-04 | `krci project` | offline | — | `exit=0; stdout~/^Available Commands:$/; stdout~/^\s+deployments\s/; stdout~/^\s+get\s/; stdout~/^\s+list\s/` | + +## 2. Argument validation (env: `offline`) + +Wrong shape of invocation must fail fast with a helpful message and a +non-zero exit. Catches DNS-1123, output-format, and arg-count regressions. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------|---------|-------|-------------------------------------------------------------------------| +| PROJ-D-V-01 | `krci project deployments` | offline | — | `exit=1; stderr~/requires a project \(codebase\) name/` | +| PROJ-D-V-02 | `krci project deployments a b` | offline | — | `exit=1; stderr~/requires a project \(codebase\) name/` | +| PROJ-D-V-03 | `krci project deployments BAD_NAME` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 label/` | +| PROJ-D-V-04 | `krci project deployments UPPER` | offline | — | `exit=1; stderr~/ must be a valid DNS-1123 label/` | +| PROJ-D-V-05 | `krci project deployments my-app -o yaml` | offline | — | `exit=1; stderr~/unknown output format/` | +| PROJ-D-V-06 | `krci project deployments my-app --unknown-flag` | offline | — | `exit=1; stderr~/unknown flag: --unknown-flag/` | +| PROJ-D-V-07 | `krci project deployments -o` | offline | — | `exit=1; stderr~/flag needs an argument/` | + +## 3. Happy paths (env: `portal`) + +Requires `krci auth status` → Authenticated. Each row asserts the exit +code is 0 and the JSON envelope matches the documented shape +(`schemaVersion=1`, `data.project`, `data.rows[]`). + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------------------|--------|------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| 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` | + +## 4. Empty results (env: `portal`) + +"Project missing" and "project deployed nowhere" are indistinguishable +from the user's perspective: both exit 0 with `data.rows: []`. + +| ID | Command | Env | Setup | Expect | +|------------|---------------------------------------------------------------|--------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| PROJ-D-N-01 | `krci project deployments {{PROJECT_NOWHERE}}` | portal | codebase exists, no CDPipeline references it | `exit=0; stderr~/No deployments found for project {{PROJECT_NOWHERE}}\./` | +| PROJ-D-N-02 | `krci project deployments {{PROJECT_NOWHERE}} -o json` | portal | — | `exit=0; stdout_json.schemaVersion=1; stdout_json.data.project={{PROJECT_NOWHERE}}; stdout_json.data.rows:len=0` | +| PROJ-D-N-03 | `krci project deployments {{PROJECT_MISSING}}` | portal | name does not exist | `exit=0; stderr~/No deployments found for project {{PROJECT_MISSING}}\./` | +| PROJ-D-N-04 | `krci project deployments {{PROJECT_MISSING}} -o json` | portal | — | `exit=0; stdout_json.data.rows:len=0` | + +## 5. Registered-but-not-deployed rows (env: `portal`) + +Stages where the project is in `CDPipeline.spec.applications` but no +`Application` resource exists yet must still appear with `deployed:false`, +null dynamic fields, and `cluster`/`namespace`/`triggerType` populated +from the Stage. Skip when no such pair exists. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------------------|--------|------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| PROJ-D-R-01 | `krci project deployments {{PROJECT_REGISTERED}} -o json` | portal | project registered without Application | `exit=0; stdout_json.data.rows.0.deployed=false; stdout_json.data.rows.0.cluster:exists; stdout_json.data.rows.0.namespace:exists; stdout_json.data.rows.0.triggerType:exists` | + +## 6. Sub-table layout (env: `portal`) + +Verifies the table column order matches `M6`, the `IMAGE_SHA` header is +in place, and multi-ingress rows stack across visual rows. + +| ID | Command | Env | Setup | Expect | +|------------|------------------------------------------------------------------|--------|------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| PROJ-D-T-01 | `krci project deployments {{PROJECT_DEPLOYED}}` | portal | project deployed somewhere | `exit=0; stdout~/IMAGE_SHA/; stdout!~/^IMAGE\s/` | +| PROJ-D-T-02 | `krci project deployments {{PROJECT_DEPLOYED}} -o json` | portal | project has multiple deployed rows | `exit=0; stdout_json.data.rows.0.ingressUrls:exists` | diff --git a/internal/output/output.go b/internal/output/output.go index 682f3ab..15f6221 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -143,6 +143,12 @@ func GreenText(s string) string { return greenStyle.Render(s) } // YellowText returns s rendered in yellow. func YellowText(s string) string { return yellowStyle.Render(s) } +// RedText returns s rendered in red. +func RedText(s string) string { return redStyle.Render(s) } + +// BlueText returns s rendered in blue. +func BlueText(s string) string { return blueStyle.Render(s) } + // PrintStyledTable renders a lipgloss table with colored headers, alternating row colors, and no borders. // Uses lipgloss for ANSI-aware column width calculation. func PrintStyledTable(w io.Writer, headers []string, rows [][]string) error { diff --git a/internal/portal/deployment.go b/internal/portal/deployment.go index 7849de4..f7d86a3 100644 --- a/internal/portal/deployment.go +++ b/internal/portal/deployment.go @@ -11,49 +11,74 @@ import ( "github.com/KubeRocketCI/cli/internal/ptr" ) -var cdPipelineResourceConfig = restapi.K8sListJSONBody{ - ResourceConfig: struct { - ApiVersion string `json:"apiVersion"` - ClusterScoped *bool `json:"clusterScoped,omitempty"` - Group string `json:"group"` - Kind string `json:"kind"` - Labels *map[string]string `json:"labels,omitempty"` - PluralName string `json:"pluralName"` - SingularName string `json:"singularName"` - Version string `json:"version"` - }{ - ApiVersion: "v2.edp.epam.com/v1", - Kind: "CDPipeline", - Group: "v2.edp.epam.com", - Version: "v1", - SingularName: "cdpipeline", - PluralName: "cdpipelines", - }, +// newK8sResourceConfig assembles a K8sListJSONBody whose ResourceConfig +// identifies a single CRD by its group/version/kind triple. Centralizes the +// anonymous-struct shape from the generated restapi package so each new kind +// (CDPipeline, Stage, Application, …) reduces to one line. +func newK8sResourceConfig(group, version, kind, singular, plural string) restapi.K8sListJSONBody { + return restapi.K8sListJSONBody{ + ResourceConfig: struct { + ApiVersion string `json:"apiVersion"` + ClusterScoped *bool `json:"clusterScoped,omitempty"` + Group string `json:"group"` + Kind string `json:"kind"` + Labels *map[string]string `json:"labels,omitempty"` + PluralName string `json:"pluralName"` + SingularName string `json:"singularName"` + Version string `json:"version"` + }{ + ApiVersion: group + "/" + version, + Group: group, + Version: version, + Kind: kind, + SingularName: singular, + PluralName: plural, + }, + } } -var stageResourceConfig = restapi.K8sListJSONBody{ - ResourceConfig: struct { - ApiVersion string `json:"apiVersion"` - ClusterScoped *bool `json:"clusterScoped,omitempty"` - Group string `json:"group"` - Kind string `json:"kind"` - Labels *map[string]string `json:"labels,omitempty"` - PluralName string `json:"pluralName"` - SingularName string `json:"singularName"` - Version string `json:"version"` - }{ - ApiVersion: "v2.edp.epam.com/v1", - Kind: "Stage", - Group: "v2.edp.epam.com", - Version: "v1", - SingularName: "stage", - PluralName: "stages", - }, -} +var ( + cdPipelineResourceConfig = newK8sResourceConfig( + "v2.edp.epam.com", "v1", "CDPipeline", "cdpipeline", "cdpipelines") + stageResourceConfig = newK8sResourceConfig( + "v2.edp.epam.com", "v1", "Stage", "stage", "stages") + applicationResourceConfig = newK8sResourceConfig( + "argoproj.io", "v1alpha1", "Application", "application", "applications") +) + +// Canonical KRCI / ArgoCD label keys used by the deployment-discovery surface. +const ( + labelAppName = "app.edp.epam.com/app-name" + labelCDPipelineName = "app.edp.epam.com/cdPipelineName" + labelStage = "app.edp.epam.com/stage" + labelPipeline = "app.edp.epam.com/pipeline" +) // k8sItem is the type of items returned by K8sList. type k8sItem = restapi.K8sList_200_Items_Item +// buildK8sListBody assembles a K8sListJSONRequestBody from the given cluster, +// namespace, resource config, and optional label selector. Labels are omitted +// (kept nil) when empty. +func buildK8sListBody( + clusterName, namespace string, + rc restapi.K8sListJSONBody, + labels map[string]string, +) restapi.K8sListJSONRequestBody { + ns := namespace + body := restapi.K8sListJSONRequestBody{ + ClusterName: clusterName, + Namespace: &ns, + ResourceConfig: rc.ResourceConfig, + } + + if len(labels) > 0 { + body.Labels = &labels + } + + return body +} + // DeploymentService provides access to CDPipeline and Stage resources via the portal REST API. type DeploymentService struct { client *restapi.ClientWithResponses @@ -67,14 +92,7 @@ func NewDeploymentService(client *restapi.ClientWithResponses, clusterName, name } func (s *DeploymentService) k8sList(ctx context.Context, rc restapi.K8sListJSONBody) (*restapi.K8sListResponse, error) { - ns := s.namespace - body := restapi.K8sListJSONRequestBody{ - ClusterName: s.clusterName, - Namespace: &ns, - ResourceConfig: rc.ResourceConfig, - } - - return s.client.K8sListWithResponse(ctx, body) + return s.client.K8sListWithResponse(ctx, buildK8sListBody(s.clusterName, s.namespace, rc, nil)) } // List returns all CDPipelines with their stage names ordered by spec.order. @@ -354,3 +372,69 @@ func int64Val(m map[string]any, key string) int64 { return 0 } } + +// stageOrder returns the integer Stage.spec.order. Defaults to 0 when missing +// or non-numeric. +func stageOrder(spec map[string]any) int { + return int(int64Val(spec, "order")) +} + +// findSliceItem returns the first map within slice whose entry at key equals +// val, or nil when no match exists. +func findSliceItem(slice []any, key, val string) map[string]any { + for _, raw := range slice { + m, ok := raw.(map[string]any) + if !ok { + continue + } + + if stringVal(m, key) == val { + return m + } + } + + return nil +} + +// deepGet walks a nested *map[string]any tree by key. Returns nil if any +// intermediate key is missing or a non-map value blocks the path. Defensive +// against the schemaless `*map[string]any` shape returned by k8s.list. +func deepGet(m map[string]any, keys ...string) map[string]any { + cur := m + for _, k := range keys { + if cur == nil { + return nil + } + + raw, ok := cur[k] + if !ok { + return nil + } + + next, ok := raw.(map[string]any) + if !ok { + return nil + } + + cur = next + } + + return cur +} + +// sliceVal returns the slice at m[key] as []any, or nil when missing or not a +// slice. +func sliceVal(m map[string]any, key string) []any { + if m == nil { + return nil + } + + raw, ok := m[key] + if !ok { + return nil + } + + items, _ := raw.([]any) + + return items +} diff --git a/internal/portal/env.go b/internal/portal/env.go new file mode 100644 index 0000000..7af63ca --- /dev/null +++ b/internal/portal/env.go @@ -0,0 +1,544 @@ +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" +) + +// EnvListFilters bounds an `env list` query. +type EnvListFilters struct { + // Deployment is the parent CDPipeline name to filter by, or "" for all. + Deployment string + // Cluster is the post-mapping cluster filter, or "" for all. + Cluster string +} + +// EnvService surfaces KRCI Stages as user-facing "environments". +type EnvService struct { + client *restapi.ClientWithResponses + clusterName string + namespace string +} + +// NewEnvService creates an EnvService for the given cluster and namespace. +func NewEnvService(client *restapi.ClientWithResponses, clusterName, namespace string) *EnvService { + return &EnvService{client: client, clusterName: clusterName, namespace: namespace} +} + +func (s *EnvService) listBody(rc restapi.K8sListJSONBody, labels map[string]string) restapi.K8sListJSONRequestBody { + return buildK8sListBody(s.clusterName, s.namespace, rc, labels) +} + +// List returns every KRCI Stage in the configured namespace, optionally +// filtered by parent deployment and/or cluster. The returned slice is always +// non-nil so callers can JSON-marshal it as `[]` rather than `null`. +func (s *EnvService) List(ctx context.Context, f EnvListFilters) ([]EnvSummary, error) { + var labels map[string]string + if f.Deployment != "" { + labels = map[string]string{labelCDPipelineName: f.Deployment} + } + + resp, err := s.client.K8sListWithResponse(ctx, s.listBody(stageResourceConfig, labels)) + if err != nil { + return nil, fmt.Errorf("listing stages: %w", err) + } + + if err := checkResponse(resp.StatusCode(), resp.Body); err != nil { + return nil, fmt.Errorf("listing stages: %w", err) + } + + if resp.JSON200 == nil { + return []EnvSummary{}, nil + } + + rows := make([]EnvSummary, 0, len(resp.JSON200.Items)) + for _, item := range resp.JSON200.Items { + row := mapEnvSummary(item) + + if f.Cluster != "" && row.Cluster != f.Cluster { + continue + } + + rows = append(rows, row) + } + + sortEnvSummaries(rows) + + return rows, nil +} + +// Get returns the full detail of one stage. Fans out three k8s calls in +// parallel: list Stages (to resolve env-name → metadata.name), get parent +// CDPipeline (to enumerate registered apps), list Applications scoped to the +// (pipeline, stage) pair. The Application label `app.edp.epam.com/stage` +// carries `stage.spec.name` (the short identifier "dev"/"stage"/"prod"), not +// the compound resource name. +func (s *EnvService) Get(ctx context.Context, deployment, env string) (*EnvDetail, error) { + var ( + stageResp *restapi.K8sListResponse + pipelineResp *restapi.K8sGetResponse + appResp *restapi.K8sListResponse + ) + + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + stageResp, err = s.client.K8sListWithResponse(gctx, s.listBody(stageResourceConfig, map[string]string{ + labelCDPipelineName: deployment, + })) + if err != nil { + return fmt.Errorf("listing stages for deployment %q: %w", deployment, err) + } + + return checkResponse(stageResp.StatusCode(), stageResp.Body) + }) + + g.Go(func() error { + ns := s.namespace + body := restapi.K8sGetJSONRequestBody{ + ClusterName: s.clusterName, + Namespace: &ns, + Name: deployment, + ResourceConfig: cdPipelineResourceConfig.ResourceConfig, + } + + var err error + pipelineResp, err = s.client.K8sGetWithResponse(gctx, body) + if err != nil { + return fmt.Errorf("getting deployment %q: %w", deployment, err) + } + + if err := checkResponse(pipelineResp.StatusCode(), pipelineResp.Body); err != nil { + if errors.Is(err, ErrNotFound) { + return ErrDeploymentNotFound + } + return err + } + + return nil + }) + + g.Go(func() error { + var err error + appResp, err = s.client.K8sListWithResponse(gctx, s.listBody(applicationResourceConfig, map[string]string{ + labelPipeline: deployment, + labelStage: env, + })) + if err != nil { + return fmt.Errorf("listing applications for env %q in deployment %q: %w", env, deployment, err) + } + + return checkResponse(appResp.StatusCode(), appResp.Body) + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + var stageItem *k8sItem + if stageResp.JSON200 != nil { + for i := range stageResp.JSON200.Items { + it := stageResp.JSON200.Items[i] + spec := ptr.Deref(it.Spec, nil) + if stringVal(spec, "name") == env { + stageItem = &it + break + } + } + } + + if stageItem == nil { + return nil, ErrEnvNotFound + } + + var registered []string + if pipelineResp.JSON200 != nil { + registered = stringSliceVal(ptr.Deref(pipelineResp.JSON200.Spec, nil), "applications") + } + + var appItems []k8sItem + if appResp.JSON200 != nil { + appItems = appResp.JSON200.Items + } + + detail := buildEnvDetail(deployment, *stageItem, registered, appItems) + + return &detail, nil +} + +// mapEnvSummary projects one Stage k8s item to an EnvSummary row. +func mapEnvSummary(item k8sItem) EnvSummary { + spec := ptr.Deref(item.Spec, nil) + status := ptr.Deref(item.Status, nil) + + return EnvSummary{ + Deployment: stringVal(spec, "cdPipeline"), + Env: stringVal(spec, "name"), + Cluster: stringVal(spec, "clusterName"), + Namespace: stringVal(spec, "namespace"), + TriggerType: stringVal(spec, "triggerType"), + Status: stringVal(status, "status"), + Order: stageOrder(spec), + } +} + +// sortEnvSummaries orders rows by deployment name then by Stage.spec.order. +func sortEnvSummaries(rows []EnvSummary) { + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Deployment != rows[j].Deployment { + return rows[i].Deployment < rows[j].Deployment + } + + return rows[i].Order < rows[j].Order + }) +} + +// buildEnvDetail composes EnvDetail from the parent Stage item, the registered +// project list (CDPipeline.spec.applications), and the Application list scoped +// to this (deployment, env) pair. +func buildEnvDetail(deployment string, stageItem k8sItem, registered []string, apps []k8sItem) EnvDetail { + spec := ptr.Deref(stageItem.Spec, nil) + status := ptr.Deref(stageItem.Status, nil) + + desc := stringVal(spec, "description") + var descPtr *string + if desc != "" { + descPtr = &desc + } + + cleanTpl := stringVal(spec, "cleanTemplate") + var cleanPtr *string + if cleanTpl != "" { + cleanPtr = &cleanTpl + } + + infra := Infrastructure{ + Cluster: stringVal(spec, "clusterName"), + Namespace: stringVal(spec, "namespace"), + TriggerType: stringVal(spec, "triggerType"), + DeployPipeline: stringVal(spec, "triggerTemplate"), + CleanPipeline: cleanPtr, + } + + gates := mapQualityGateDetails(spec) + + appsByProject := indexAppsByProjectName(apps) + + projects := make([]EnvProject, 0, len(registered)) + for _, name := range registered { + p := EnvProject{Name: name, IngressURLs: []string{}} + + if appItem, ok := appsByProject[name]; ok { + fillEnvProjectFromApp(&p, appItem) + } + + projects = append(projects, p) + } + + sort.SliceStable(projects, func(i, j int) bool { + return projects[i].Name < projects[j].Name + }) + + return EnvDetail{ + Deployment: deployment, + Env: stringVal(spec, "name"), + Status: stringVal(status, "status"), + Description: descPtr, + Order: stageOrder(spec), + Infrastructure: infra, + QualityGates: gates, + Projects: projects, + } +} + +// indexAppsByProjectName indexes ArgoCD Application items by the +// `app.edp.epam.com/app-name` label so the env-detail builder can look up +// state for each registered project. +func indexAppsByProjectName(items []k8sItem) map[string]k8sItem { + out := make(map[string]k8sItem, len(items)) + + for _, item := range items { + labels := ptr.Deref(item.Metadata.Labels, nil) + if labels == nil { + continue + } + + name, ok := labels[labelAppName] + if !ok || name == "" { + continue + } + + out[name] = item + } + + return out +} + +// appFields carries the dynamic fields extracted from one ArgoCD Application +// item, shared by EnvProject and ProjectDeploymentRow. +type appFields struct { + Status *string + Sync *string + Version *string + ImageTag *string + ImageDigest *string + IngressURLs []string + ArgocdURL *string + DeployedAt *string +} + +// extractAppFields shapes one Application's spec/status/labels into an +// appFields value. Single source of truth for fields shared by EnvProject and +// ProjectDeploymentRow; consumer-specific derivations (e.g. ValuesOverride for +// EnvProject) live alongside their consumer. +func extractAppFields(item k8sItem) appFields { + spec := ptr.Deref(item.Spec, nil) + status := ptr.Deref(item.Status, nil) + labels := ptr.Deref(item.Metadata.Labels, nil) + summary := deepGet(status, "summary") + + out := appFields{IngressURLs: []string{}} + + if v := stringVal(deepGet(status, "health"), "status"); v != "" { + l := strings.ToLower(v) + out.Status = &l + } + + if v := stringVal(deepGet(status, "sync"), "status"); v != "" { + l := strings.ToLower(v) + out.Sync = &l + } + + tag, repo := helmImageFields(spec) + + if v, ok := deployedVersion(spec, tag); ok { + out.Version = &v + } + + if tag != "" { + out.ImageTag = &tag + } + + if v, ok := imageDigest(repo, summary); ok { + out.ImageDigest = &v + } + + if urls := stringSliceVal(summary, "externalURLs"); urls != nil { + out.IngressURLs = urls + } + + if u, ok := argocdURL(labels); ok { + out.ArgocdURL = &u + } + + if v := stringVal(deepGet(status, "operationState"), "finishedAt"); v != "" { + out.DeployedAt = &v + } + + return out +} + +// fillEnvProjectFromApp populates an EnvProject from one Application item. +// ValuesOverride is EnvProject-only (no analogue on ProjectDeploymentRow), so +// it is derived here rather than in the shared extractAppFields. +func fillEnvProjectFromApp(p *EnvProject, item k8sItem) { + f := extractAppFields(item) + + p.Status = f.Status + p.Sync = f.Sync + p.Version = f.Version + p.ImageTag = f.ImageTag + p.ImageDigest = f.ImageDigest + p.IngressURLs = f.IngressURLs + p.ArgocdURL = f.ArgocdURL + p.DeployedAt = f.DeployedAt + + spec := ptr.Deref(item.Spec, nil) + _, hasSources := spec["sources"] + p.ValuesOverride = &hasSources +} + +// mapQualityGateDetails projects Stage.spec.qualityGates into the QualityGateDetail +// shape used by `env get`. Empty input returns an empty (non-nil) slice so the +// JSON envelope emits `[]` rather than `null`. +func mapQualityGateDetails(spec map[string]any) []QualityGateDetail { + raw := sliceVal(spec, "qualityGates") + + gates := make([]QualityGateDetail, 0, len(raw)) + + for _, entry := range raw { + m, ok := entry.(map[string]any) + if !ok { + continue + } + + var autotestPtr, branchPtr *string + if v := stringVal(m, "autotestName"); v != "" { + autotestPtr = &v + } + + if v := stringVal(m, "branchName"); v != "" { + branchPtr = &v + } + + gates = append(gates, QualityGateDetail{ + Type: stringVal(m, "qualityGateType"), + StepName: stringVal(m, "stepName"), + AutotestName: autotestPtr, + BranchName: branchPtr, + }) + } + + return gates +} + +// deployedVersion derives the user-facing "deployed version" string from the +// Application spec. Prefers the pre-extracted helm `image.tag` parameter +// (from helmImageFields), then falls back to the first source's +// targetRevision (last path segment). +func deployedVersion(spec map[string]any, tag string) (string, bool) { + if tag != "" { + return tag, true + } + + if sources := sliceVal(spec, "sources"); len(sources) > 0 { + if first, ok := sources[0].(map[string]any); ok { + if rev := stringVal(first, "targetRevision"); rev != "" { + return lastPathSegment(rev), true + } + } + } + + if rev := stringVal(deepGet(spec, "source"), "targetRevision"); rev != "" { + return lastPathSegment(rev), true + } + + return "", false +} + +// helmImageFields walks `spec.sources[*].helm.parameters` and `spec.source. +// helm.parameters` once, extracting `image.tag` and `image.repository` in a +// single pass. Returns the first non-empty values found per key. +func helmImageFields(spec map[string]any) (tag, repo string) { + scan := func(helm map[string]any) { + if helm == nil { + return + } + + if tag == "" { + if v, ok := paramValue(helm, "image.tag"); ok { + tag = v + } + } + + if repo == "" { + if v, ok := paramValue(helm, "image.repository"); ok { + repo = v + } + } + } + + if sources := sliceVal(spec, "sources"); len(sources) > 0 { + for _, raw := range sources { + src, ok := raw.(map[string]any) + if !ok { + continue + } + scan(deepGet(src, "helm")) + if tag != "" && repo != "" { + break + } + } + + if tag != "" && repo != "" { + return tag, repo + } + } + + scan(deepGet(spec, "source", "helm")) + + return tag, repo +} + +// imageDigest matches the first entry of `status.summary.images[]` whose +// repository (substring before the digest separator) matches the helm +// `image.repository` parameter. Returns the full `sha256:...` tail and +// ok=true. +func imageDigest(repo string, summary map[string]any) (string, bool) { + if repo == "" { + return "", false + } + + images := stringSliceVal(summary, "images") + for _, img := range images { + // Argo summary entries look like ":@sha256:" or just + // "@sha256:". Anchor the prefix match on a separator so + // "registry/foo" does not falsely match "registry/foo-bar:…". + if !strings.HasPrefix(img, repo) { + continue + } + + rest := img[len(repo):] + if rest == "" || (rest[0] != ':' && rest[0] != '@') { + continue + } + + if idx := strings.Index(rest, "@"); idx >= 0 && idx+1 < len(rest) { + return rest[idx+1:], true + } + } + + return "", false +} + +func lastPathSegment(s string) string { + if idx := strings.LastIndex(s, "/"); idx >= 0 { + return s[idx+1:] + } + return s +} + +// paramValue looks up a parameter by name in a helm spec +// (`helm.parameters[name == ].value`). Returns the value + ok flag. +func paramValue(helm map[string]any, name string) (string, bool) { + params := sliceVal(helm, "parameters") + if params == nil { + return "", false + } + + m := findSliceItem(params, "name", name) + if m == nil { + return "", false + } + + if v := stringVal(m, "value"); v != "" { + return v, true + } + + return "", false +} + +// argocdURL constructs the relative path to the Argo Application detail page. +// Returns ("", false) when any of the three required label values are absent, +// so callers can leave the field null instead of emitting a broken URL. +func argocdURL(labels map[string]string) (string, bool) { + pipeline, ok1 := labels[labelPipeline] + stage, ok2 := labels[labelStage] + app, ok3 := labels[labelAppName] + + if !ok1 || !ok2 || !ok3 || pipeline == "" || stage == "" || app == "" { + return "", false + } + + // Consumers compose this with their own base URL. + return fmt.Sprintf("/applications/%s-%s-%s", pipeline, stage, app), true +} diff --git a/internal/portal/env_test.go b/internal/portal/env_test.go new file mode 100644 index 0000000..664b2d5 --- /dev/null +++ b/internal/portal/env_test.go @@ -0,0 +1,811 @@ +package portal + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" +) + +// envTestRecorder routes /rest/v1/resources/list calls to a list of typed +// responses keyed by Kind, plus optional /rest/v1/resources/get for the +// CDPipeline parent fetch in EnvService.Get. +// +//nolint:unused // false positives for fields populated through composite literals +type envTestRecorder struct { + t *testing.T + listByKind map[string]string // Kind → JSON response + getByName map[string]string // metadata.name → JSON response (for CDPipeline get) + statusByKind map[string]int // optional override per kind + + mu sync.Mutex + calls []envTestCall +} + +type envTestCall struct { + Path string + Kind string + LabelSelectors map[string]string +} + +func (r *envTestRecorder) handler(w http.ResponseWriter, req *http.Request) { + r.t.Helper() + + body, err := io.ReadAll(req.Body) + if err != nil { + r.t.Fatalf("read body: %v", err) + } + + var parsed struct { + ResourceConfig struct { + Kind string `json:"kind"` + } `json:"resourceConfig"` + Labels *map[string]string `json:"labels,omitempty"` + Name string `json:"name,omitempty"` + } + + if err := json.Unmarshal(body, &parsed); err != nil { + r.t.Fatalf("unmarshal body: %v\nbody=%s", err, string(body)) + } + + call := envTestCall{Path: req.URL.Path, Kind: parsed.ResourceConfig.Kind} + if parsed.Labels != nil { + call.LabelSelectors = *parsed.Labels + } + r.mu.Lock() + r.calls = append(r.calls, call) + r.mu.Unlock() + + if status, ok := r.statusByKind[parsed.ResourceConfig.Kind]; ok && status != 0 { + w.WriteHeader(status) + return + } + + w.Header().Set("Content-Type", "application/json") + + if strings.HasSuffix(req.URL.Path, "/v1/resources/get") { + body, ok := r.getByName[parsed.Name] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(body)) + return + } + + body2, ok := r.listByKind[parsed.ResourceConfig.Kind] + if !ok { + _, _ = w.Write([]byte(`{"apiVersion":"v1","kind":"List","items":[],"metadata":{}}`)) + return + } + _, _ = w.Write([]byte(body2)) +} + +func newEnvTestServer(t *testing.T, rec *envTestRecorder) (string, func()) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(rec.handler)) + + return srv.URL, srv.Close +} + +// newEnvServiceForTest wires up an EnvService against an httptest server. +func newEnvServiceForTest(t *testing.T, rec *envTestRecorder) (*EnvService, 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 NewEnvService(client, "in-cluster", "ns"), closer +} + +// newDeploymentByProjectServiceForTest wires up a DeploymentByProjectService. +func newDeploymentByProjectServiceForTest(t *testing.T, rec *envTestRecorder) (*DeploymentByProjectService, 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 NewDeploymentByProjectService(client, "in-cluster", "ns"), closer +} + +func TestEnvService_List_Default(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "my-pipeline-prod", deployment: "my-pipeline", env: "prod", cluster: "in-cluster", namespace: "my-pipeline-prod", trigger: "Manual", order: 2, status: "created"}, + {name: "my-pipeline-dev", deployment: "my-pipeline", env: "dev", cluster: "in-cluster", namespace: "my-pipeline-dev", trigger: "Auto", order: 0, status: "created"}, + {name: "other-pipe-dev", deployment: "other-pipe", env: "dev", cluster: "in-cluster", namespace: "other-pipe-dev", trigger: "Auto", order: 0, status: "in_progress"}, + {name: "my-pipeline-stage", deployment: "my-pipeline", env: "stage", cluster: "in-cluster", namespace: "my-pipeline-stage", trigger: "Manual", order: 1, status: "created"}, + }), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background(), EnvListFilters{}) + if err != nil { + t.Fatalf("List error: %v", err) + } + + want := []EnvSummary{ + {Deployment: "my-pipeline", Env: "dev", Cluster: "in-cluster", Namespace: "my-pipeline-dev", TriggerType: "Auto", Status: "created", Order: 0}, + {Deployment: "my-pipeline", Env: "stage", Cluster: "in-cluster", Namespace: "my-pipeline-stage", TriggerType: "Manual", Status: "created", Order: 1}, + {Deployment: "my-pipeline", Env: "prod", Cluster: "in-cluster", Namespace: "my-pipeline-prod", TriggerType: "Manual", Status: "created", Order: 2}, + {Deployment: "other-pipe", Env: "dev", Cluster: "in-cluster", Namespace: "other-pipe-dev", TriggerType: "Auto", Status: "in_progress", Order: 0}, + } + + if len(rows) != len(want) { + t.Fatalf("got %d rows, want %d: %+v", len(rows), len(want), rows) + } + + for i, row := range rows { + if row != want[i] { + t.Errorf("row[%d] = %+v, want %+v", i, row, want[i]) + } + } +} + +func TestEnvService_List_FilteredByDeployment(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: "created"}, + }), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background(), EnvListFilters{Deployment: "my-pipeline"}) + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1: %+v", len(rows), rows) + } + + if len(rec.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(rec.calls)) + } + + if got := rec.calls[0].LabelSelectors[labelCDPipelineName]; got != "my-pipeline" { + t.Errorf("label %s = %q, want %q", labelCDPipelineName, got, "my-pipeline") + } +} + +func TestEnvService_List_ClusterFilter(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "a", deployment: "p", env: "dev", cluster: "in-cluster", order: 0, status: "created"}, + {name: "b", deployment: "p", env: "stage", cluster: "remote", order: 1, status: "created"}, + }), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background(), EnvListFilters{Cluster: "remote"}) + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(rows) != 1 || rows[0].Env != "stage" { + t.Fatalf("expected 1 remote row, got %+v", rows) + } +} + +func TestEnvService_List_Empty(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{t: t, listByKind: map[string]string{}} + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + rows, err := svc.List(context.Background(), EnvListFilters{}) + if err != nil { + t.Fatalf("List error: %v", err) + } + + if len(rows) != 0 { + t.Errorf("expected empty rows, got %+v", rows) + } +} + +func TestEnvService_List_Unauthorized(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{t: t, statusByKind: map[string]int{"Stage": http.StatusUnauthorized}} + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + _, err := svc.List(context.Background(), EnvListFilters{}) + if !errors.Is(err, ErrUnauthorized) { + t.Errorf("expected ErrUnauthorized, got %v", err) + } +} + +func TestEnvService_Get_DeploymentNotFound(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{"Stage": stageListJSON(nil)}, + getByName: map[string]string{}, // empty → 404 + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + _, err := svc.Get(context.Background(), "nope", "dev") + if !errors.Is(err, ErrDeploymentNotFound) { + t.Errorf("expected ErrDeploymentNotFound, got %v", err) + } + + if !errors.Is(err, ErrNotFound) { + t.Errorf("ErrDeploymentNotFound must wrap ErrNotFound, got %v", err) + } +} + +func TestEnvService_Get_EnvNotFound(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", order: 0}, + }), + }, + getByName: map[string]string{ + "my-pipeline": cdPipelineGetJSON("my-pipeline", []string{"foo"}), + }, + } + + svc, closer := newEnvServiceForTest(t, rec) + defer closer() + + _, err := svc.Get(context.Background(), "my-pipeline", "wat") + if !errors.Is(err, ErrEnvNotFound) { + t.Errorf("expected ErrEnvNotFound, got %v", err) + } + + if !errors.Is(err, ErrNotFound) { + t.Errorf("ErrEnvNotFound must wrap ErrNotFound, got %v", err) + } +} + +func TestEnvService_Get_ProjectsRegisteredButNotDeployed(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Stage": stageListJSON([]stageStub{ + {name: "my-pipeline-prod", deployment: "my-pipeline", env: "prod", cluster: "in-cluster", namespace: "my-pipeline-prod", trigger: "Manual", order: 2, status: "created"}, + }), + "Application": applicationListJSON([]applicationStub{ + {appName: "foo", pipeline: "my-pipeline", stage: "prod", health: "Healthy", sync: "Synced", imageRepo: "registry/foo", imageTag: "1.2.0", imageDigest: "sha256:abc12345abc12345"}, + {appName: "bar", pipeline: "my-pipeline", stage: "prod", health: "Healthy", sync: "Synced", imageRepo: "registry/bar", imageTag: "2.0.1", externalURLs: []string{"https://bar.prod.example.com"}}, + }), + }, + 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", "prod") + if err != nil { + t.Fatalf("Get error: %v", err) + } + + if got := d.Env; got != "prod" { + t.Errorf("env = %q, want %q", got, "prod") + } + + if len(d.Projects) != 3 { + t.Fatalf("got %d projects, want 3: %+v", len(d.Projects), d.Projects) + } + + wantNames := []string{"bar", "baz", "foo"} + for i, p := range d.Projects { + if p.Name != wantNames[i] { + t.Errorf("projects[%d].name = %q, want %q (sort by name asc)", i, p.Name, wantNames[i]) + } + } + + // baz is registered but not deployed + for _, p := range d.Projects { + if p.Name == "baz" { + if p.Status != nil || p.Sync != nil || p.Version != nil || p.ImageDigest != nil { + t.Errorf("baz dynamic fields should be nil: %+v", p) + } + if len(p.IngressURLs) != 0 { + t.Errorf("baz ingressUrls should be empty, got %v", p.IngressURLs) + } + } + } + + // foo deployed: check fields + for _, p := range d.Projects { + if p.Name == "foo" { + if p.Status == nil || *p.Status != "healthy" { + t.Errorf("foo.status = %v, want pointer to 'healthy'", p.Status) + } + if p.Sync == nil || *p.Sync != "synced" { + t.Errorf("foo.sync = %v, want pointer to 'synced'", p.Sync) + } + if p.Version == nil || *p.Version != "1.2.0" { + t.Errorf("foo.version = %v, want '1.2.0'", p.Version) + } + if p.ImageDigest == nil || !strings.HasPrefix(*p.ImageDigest, "sha256:") { + t.Errorf("foo.imageDigest = %v", p.ImageDigest) + } + } + } +} + +func TestDeploymentByProjectService_List_Mockup6(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Application": applicationListJSON([]applicationStub{ + {appName: "foo", pipeline: "my-pipeline", stage: "dev", health: "Healthy", sync: "Synced", imageRepo: "registry/foo", imageTag: "1.2.3", imageDigest: "sha256:abc12345xx", externalURLs: []string{"https://foo.dev.example.com"}}, + {appName: "foo", pipeline: "my-pipeline", stage: "stage", health: "Healthy", sync: "Synced", imageRepo: "registry/foo", imageTag: "1.2.3", imageDigest: "sha256:abc12345xx", externalURLs: []string{"https://foo.stage.example.com"}}, + {appName: "foo", pipeline: "my-pipeline", stage: "prod", health: "Degraded", sync: "OutOfSync", imageRepo: "registry/foo", imageTag: "1.2.0", imageDigest: "sha256:def34567xx", externalURLs: []string{"https://foo.prod.example.com", "https://foo-admin.prod.example.com"}}, + }), + "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-stage", deployment: "my-pipeline", env: "stage", cluster: "in-cluster", namespace: "my-pipeline-stage", trigger: "Manual", order: 1, status: "created"}, + {name: "my-pipeline-prod", deployment: "my-pipeline", env: "prod", cluster: "in-cluster", namespace: "my-pipeline-prod", trigger: "Manual", order: 2, status: "created"}, + {name: "other-pipe-dev", deployment: "other-pipe", env: "dev", cluster: "in-cluster", namespace: "other-pipe-dev", trigger: "Auto", order: 0, status: "created"}, + }), + "CDPipeline": cdPipelineListJSON([]cdPipelineStub{ + {name: "my-pipeline", applications: []string{"foo", "bar"}}, + {name: "other-pipe", 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) != 4 { + t.Fatalf("got %d rows, want 4: %+v", len(rows), rows) + } + + // Sort: my-pipeline (dev,stage,prod) then other-pipe (dev) + want := []struct { + deployment string + env string + deployed bool + }{ + {"my-pipeline", "dev", true}, + {"my-pipeline", "stage", true}, + {"my-pipeline", "prod", true}, + {"other-pipe", "dev", false}, + } + + for i, row := range rows { + if row.Deployment != want[i].deployment { + t.Errorf("rows[%d].deployment = %q, want %q", i, row.Deployment, want[i].deployment) + } + if row.Env != want[i].env { + t.Errorf("rows[%d].env = %q, want %q", i, row.Env, want[i].env) + } + if row.Deployed != want[i].deployed { + t.Errorf("rows[%d].deployed = %v, want %v", i, row.Deployed, want[i].deployed) + } + } + + // other-pipe/dev row carries Stage statics even when not deployed + last := rows[3] + if last.Cluster == "" || last.Namespace == "" || last.TriggerType == "" { + t.Errorf("not-deployed row must still carry cluster/namespace/triggerType: %+v", last) + } + if last.Status != nil || last.Sync != nil || last.Version != nil { + t.Errorf("not-deployed row dynamic fields must be nil: %+v", last) + } + if len(last.IngressURLs) != 0 { + t.Errorf("not-deployed row ingressUrls must be empty, got %v", last.IngressURLs) + } + + // JSON round-trip — preserves the schema shape. + payload := ProjectDeploymentsPayload{Project: "foo", Rows: rows} + if _, err := json.Marshal(payload); err != nil { + t.Errorf("JSON marshal failed: %v", err) + } +} + +func TestDeploymentByProjectService_List_NoPipeline(t *testing.T) { + t.Parallel() + + rec := &envTestRecorder{ + t: t, + listByKind: map[string]string{ + "Application": applicationListJSON(nil), + "Stage": stageListJSON(nil), + "CDPipeline": cdPipelineListJSON(nil), + }, + } + + 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) != 0 { + t.Errorf("expected zero rows, got %+v", rows) + } +} + +func TestDeployedVersion_HelmSingleSource_ImageTagParam(t *testing.T) { + t.Parallel() + + spec := map[string]any{ + "source": map[string]any{ + "helm": map[string]any{ + "parameters": []any{ + map[string]any{"name": "image.tag", "value": "1.2.3"}, + }, + }, + "targetRevision": "v9.9.9", + }, + } + + tag, _ := helmImageFields(spec) + got, ok := deployedVersion(spec, tag) + if !ok || got != "1.2.3" { + t.Errorf("deployedVersion = (%q,%v), want ('1.2.3',true)", got, ok) + } +} + +func TestDeployedVersion_HelmSingleSource_TargetRevision(t *testing.T) { + t.Parallel() + + spec := map[string]any{ + "source": map[string]any{ + "helm": map[string]any{"parameters": []any{}}, + "targetRevision": "refs/tags/v1.0.0", + }, + } + + tag, _ := helmImageFields(spec) + got, ok := deployedVersion(spec, tag) + if !ok || got != "v1.0.0" { + t.Errorf("deployedVersion = (%q,%v), want ('v1.0.0',true)", got, ok) + } +} + +func TestDeployedVersion_MultiSource_HelmParam(t *testing.T) { + t.Parallel() + + spec := map[string]any{ + "sources": []any{ + map[string]any{"targetRevision": "main"}, + map[string]any{ + "helm": map[string]any{ + "parameters": []any{ + map[string]any{"name": "image.tag", "value": "v2.0.0"}, + }, + }, + }, + }, + } + + tag, _ := helmImageFields(spec) + got, ok := deployedVersion(spec, tag) + if !ok || got != "v2.0.0" { + t.Errorf("deployedVersion = (%q,%v), want ('v2.0.0',true)", got, ok) + } +} + +func TestDeployedVersion_NoMatch(t *testing.T) { + t.Parallel() + + spec := map[string]any{} + + tag, _ := helmImageFields(spec) + if v, ok := deployedVersion(spec, tag); ok { + t.Errorf("deployedVersion = %q, want empty", v) + } +} + +func TestImageDigest_AnchoredPrefix(t *testing.T) { + t.Parallel() + + // summary.images contains a same-prefix repo first; the matcher must + // skip it and pick the exact-repo entry. + summary := map[string]any{ + "images": []any{ + "registry/foo-bar:1.0@sha256:wrong", + "registry/foo:1.2.3@sha256:right", + }, + } + + got, ok := imageDigest("registry/foo", summary) + if !ok || got != "sha256:right" { + t.Errorf("imageDigest = (%q,%v), want ('sha256:right',true)", got, ok) + } + + // No exact match → not found, even though a similarly-named repo exists. + summaryOnlyOther := map[string]any{ + "images": []any{"registry/foo-bar:1.0@sha256:wrong"}, + } + + if got, ok := imageDigest("registry/foo", summaryOnlyOther); ok { + t.Errorf("imageDigest = %q, want empty (no exact-repo match)", got) + } +} + +func TestFindSliceItem(t *testing.T) { + t.Parallel() + + slice := []any{ + map[string]any{"name": "image.repository", "value": "registry/foo"}, + map[string]any{"name": "image.tag", "value": "1.2.3"}, + } + + if got := findSliceItem(slice, "name", "image.tag"); got == nil || got["value"] != "1.2.3" { + t.Errorf("findSliceItem(image.tag) = %v", got) + } + + if got := findSliceItem(slice, "name", "missing"); got != nil { + t.Errorf("findSliceItem(missing) = %v, want nil", got) + } +} + +// --- helpers --- + +// mustJSON marshals v or panics on failure. The test fixtures only build +// deterministic primitive trees so a marshal error is a programmer mistake, +// not test data; surface it loudly instead of returning an empty string. +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + panic("test fixture marshal: " + err.Error()) + } + + return string(b) +} + +// stageStub describes the minimal Stage spec/status used by the test stubs. +type stageStub struct { + name string + deployment string + env string + cluster string + namespace string + trigger string + order int + status string +} + +func stageListJSON(stages []stageStub) string { + type metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + } + + type stage struct { + Metadata metadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + } + + items := make([]stage, 0, len(stages)) + for _, s := range stages { + items = append(items, stage{ + Metadata: metadata{ + Name: s.name, + Namespace: "ns", + Labels: map[string]string{ + "app.edp.epam.com/cdPipelineName": s.deployment, + }, + }, + Spec: map[string]any{ + "name": s.env, + "cdPipeline": s.deployment, + "clusterName": s.cluster, + "namespace": s.namespace, + "triggerType": s.trigger, + "triggerTemplate": "deploy", + "order": s.order, + "qualityGates": []any{}, + }, + Status: map[string]any{"status": s.status}, + }) + } + + envelope := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "metadata": map[string]any{}, + "items": items, + } + + return mustJSON(envelope) +} + +type applicationStub struct { + appName string + pipeline string + stage string + health string + sync string + imageRepo string + imageTag string + imageDigest string // already in "sha256:..." format; appended to image entry + externalURLs []string +} + +func applicationListJSON(apps []applicationStub) string { + type metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + } + + type app struct { + Metadata metadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + } + + items := make([]app, 0, len(apps)) + for _, a := range apps { + params := []any{} + if a.imageRepo != "" { + params = append(params, map[string]any{"name": "image.repository", "value": a.imageRepo}) + } + if a.imageTag != "" { + params = append(params, map[string]any{"name": "image.tag", "value": a.imageTag}) + } + + summary := map[string]any{} + if len(a.externalURLs) > 0 { + summary["externalURLs"] = stringsToAny(a.externalURLs) + } + if a.imageDigest != "" { + summary["images"] = stringsToAny([]string{a.imageRepo + ":" + a.imageTag + "@" + a.imageDigest}) + } + + items = append(items, app{ + Metadata: metadata{ + Name: a.pipeline + "-" + a.stage + "-" + a.appName, + Namespace: "ns", + Labels: map[string]string{ + "app.edp.epam.com/app-name": a.appName, + "app.edp.epam.com/cdPipelineName": a.pipeline, + "app.edp.epam.com/pipeline": a.pipeline, + "app.edp.epam.com/stage": a.stage, + }, + }, + Spec: map[string]any{ + "source": map[string]any{ + "helm": map[string]any{"parameters": params}, + "targetRevision": "main", + }, + }, + Status: map[string]any{ + "health": map[string]any{"status": a.health}, + "sync": map[string]any{"status": a.sync}, + "summary": summary, + }, + }) + } + + envelope := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "metadata": map[string]any{}, + "items": items, + } + + return mustJSON(envelope) +} + +func stringsToAny(s []string) []any { + out := make([]any, 0, len(s)) + for _, v := range s { + out = append(out, v) + } + return out +} + +type cdPipelineStub struct { + name string + applications []string +} + +func cdPipelineListJSON(items []cdPipelineStub) string { + type metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } + + type pipe struct { + Metadata metadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + } + + out := make([]pipe, 0, len(items)) + for _, it := range items { + out = append(out, pipe{ + Metadata: metadata{Name: it.name, Namespace: "ns"}, + Spec: map[string]any{ + "applications": stringsToAny(it.applications), + }, + Status: map[string]any{"status": "created"}, + }) + } + + envelope := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "metadata": map[string]any{}, + "items": out, + } + + return mustJSON(envelope) +} + +func cdPipelineGetJSON(name string, applications []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": map[string]any{"status": "created"}, + } + + return mustJSON(envelope) +} diff --git a/internal/portal/errors.go b/internal/portal/errors.go index 493f5b7..fb919c4 100644 --- a/internal/portal/errors.go +++ b/internal/portal/errors.go @@ -2,6 +2,7 @@ package portal import ( "errors" + "fmt" ) // Sentinel errors for portal API failures. @@ -10,4 +11,13 @@ var ( ErrNotFound = errors.New("resource not found") ErrHTTPSRequired = errors.New("portal URL must use HTTPS") ErrUpstreamUnavailable = errors.New("upstream service unavailable") + + // ErrDeploymentNotFound is returned when a CDPipeline (deployment) + // look-up fails. Wraps ErrNotFound so callers using errors.Is for + // generic not-found handling still match. + ErrDeploymentNotFound = fmt.Errorf("deployment %w", ErrNotFound) + + // ErrEnvNotFound is returned when a Stage (env) lookup within a known + // deployment fails. Wraps ErrNotFound similarly. + ErrEnvNotFound = fmt.Errorf("environment %w", ErrNotFound) ) diff --git a/internal/portal/project_deployments.go b/internal/portal/project_deployments.go new file mode 100644 index 0000000..3fe48c2 --- /dev/null +++ b/internal/portal/project_deployments.go @@ -0,0 +1,256 @@ +package portal + +import ( + "context" + "fmt" + "slices" + "sort" + + "golang.org/x/sync/errgroup" + + "github.com/KubeRocketCI/cli/internal/portal/restapi" + "github.com/KubeRocketCI/cli/internal/ptr" +) + +// DeploymentByProjectService backs `krci project deployments `. It +// fans out three concurrent k8s.list calls (Applications scoped to the +// project, Stages, CDPipelines) then joins them client-side. +type DeploymentByProjectService struct { + client *restapi.ClientWithResponses + clusterName string + namespace string +} + +// NewDeploymentByProjectService creates a service for the given cluster and +// namespace. +func NewDeploymentByProjectService( + client *restapi.ClientWithResponses, + clusterName, namespace string, +) *DeploymentByProjectService { + return &DeploymentByProjectService{client: client, clusterName: clusterName, namespace: namespace} +} + +func (s *DeploymentByProjectService) listBody( + rc restapi.K8sListJSONBody, + labels map[string]string, +) restapi.K8sListJSONRequestBody { + return buildK8sListBody(s.clusterName, s.namespace, rc, labels) +} + +// List returns one row per (deployment, env) pair where project is registered +// in the matching CDPipeline's spec.applications. Rows for stages without a +// matching Application carry deployed=false plus null dynamic fields (still +// surfacing static cluster/namespace/triggerType from the Stage so the user +// sees the full footprint). Stages and CDPipelines are fetched unscoped because +// the project-membership filter lives in CDPipeline.spec.applications, which +// has no equivalent label on Stage; the join is performed client-side. +func (s *DeploymentByProjectService) List(ctx context.Context, project string) ([]ProjectDeploymentRow, error) { + var ( + appResp *restapi.K8sListResponse + stageResp *restapi.K8sListResponse + pipelineResp *restapi.K8sListResponse + ) + + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + body := s.listBody(applicationResourceConfig, map[string]string{labelAppName: project}) + + var err error + appResp, err = s.client.K8sListWithResponse(gctx, body) + if err != nil { + return fmt.Errorf("listing applications for project %q: %w", project, err) + } + + return checkResponse(appResp.StatusCode(), appResp.Body) + }) + + g.Go(func() error { + var err error + stageResp, err = s.client.K8sListWithResponse(gctx, s.listBody(stageResourceConfig, nil)) + if err != nil { + return fmt.Errorf("listing stages: %w", err) + } + + return checkResponse(stageResp.StatusCode(), stageResp.Body) + }) + + g.Go(func() error { + var err error + pipelineResp, err = s.client.K8sListWithResponse(gctx, s.listBody(cdPipelineResourceConfig, nil)) + if err != nil { + return fmt.Errorf("listing cdpipelines: %w", err) + } + + return checkResponse(pipelineResp.StatusCode(), pipelineResp.Body) + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + var ( + appItems []k8sItem + stageItems []k8sItem + pipelineItems []k8sItem + ) + + if appResp.JSON200 != nil { + appItems = appResp.JSON200.Items + } + + if stageResp.JSON200 != nil { + stageItems = stageResp.JSON200.Items + } + + if pipelineResp.JSON200 != nil { + pipelineItems = pipelineResp.JSON200.Items + } + + rows := buildProjectDeploymentRows(project, appItems, stageItems, pipelineItems) + + return rows, nil +} + +// buildProjectDeploymentRows enumerates expected (deployment, env) pairs from +// each CDPipeline whose spec.applications contains project, joins each pair +// to its Stage (for cluster/namespace/triggerType/order) and Application +// (for health/sync/version), and emits rows sorted by deployment asc then +// Stage.spec.order asc. +func buildProjectDeploymentRows( + project string, + apps, stages, pipelines []k8sItem, +) []ProjectDeploymentRow { + stagesByDeployAndEnv := indexStagesByDeployAndEnv(stages) + appsByDeployAndEnv := indexAppsByDeployAndEnv(apps) + + type sortable struct { + row ProjectDeploymentRow + order int + } + + indexed := make([]sortable, 0, len(stages)) + + for _, pipelineItem := range pipelines { + spec := ptr.Deref(pipelineItem.Spec, nil) + + registered := stringSliceVal(spec, "applications") + if !slices.Contains(registered, project) { + continue + } + + deploymentName := pipelineItem.Metadata.Name + + for _, stageItem := range stagesByDeployAndEnv[deploymentName] { + stageSpec := ptr.Deref(stageItem.Spec, nil) + indexed = append(indexed, sortable{ + row: buildProjectDeploymentRow(deploymentName, stageSpec, appsByDeployAndEnv), + order: stageOrder(stageSpec), + }) + } + } + + sort.SliceStable(indexed, func(i, j int) bool { + if indexed[i].row.Deployment != indexed[j].row.Deployment { + return indexed[i].row.Deployment < indexed[j].row.Deployment + } + + return indexed[i].order < indexed[j].order + }) + + rows := make([]ProjectDeploymentRow, len(indexed)) + for i, s := range indexed { + rows[i] = s.row + } + + return rows +} + +// deployEnvKey is the composite (deployment, env) lookup key used by the +// stage-order map and the Application index. +type deployEnvKey struct { + Deployment string + Env string +} + +// buildProjectDeploymentRow shapes one row from a Stage spec + the +// Application index keyed by (deployment, env). +func buildProjectDeploymentRow( + deployment string, + spec map[string]any, + appsByDeployAndEnv map[deployEnvKey]k8sItem, +) ProjectDeploymentRow { + row := ProjectDeploymentRow{ + Deployment: deployment, + Env: stringVal(spec, "name"), + Cluster: stringVal(spec, "clusterName"), + Namespace: stringVal(spec, "namespace"), + TriggerType: stringVal(spec, "triggerType"), + IngressURLs: []string{}, + } + + if appItem, ok := appsByDeployAndEnv[deployEnvKey{deployment, row.Env}]; ok { + row.Deployed = true + applyAppFieldsToRow(&row, appItem) + } + + return row +} + +// applyAppFieldsToRow sets the dynamic Application-derived fields on a +// ProjectDeploymentRow from one Application item. +func applyAppFieldsToRow(row *ProjectDeploymentRow, item k8sItem) { + f := extractAppFields(item) + + row.Status = f.Status + row.Sync = f.Sync + row.Version = f.Version + row.ImageTag = f.ImageTag + row.ImageDigest = f.ImageDigest + row.IngressURLs = f.IngressURLs + row.ArgocdURL = f.ArgocdURL + row.DeployedAt = f.DeployedAt +} + +// indexStagesByDeployAndEnv groups Stages by parent CDPipeline +// (spec.cdPipeline). Bucket order is irrelevant; the final row sort in +// buildProjectDeploymentRows is the authoritative ordering. +func indexStagesByDeployAndEnv(items []k8sItem) map[string][]k8sItem { + out := make(map[string][]k8sItem) + + for _, item := range items { + spec := ptr.Deref(item.Spec, nil) + parent := stringVal(spec, "cdPipeline") + if parent == "" { + continue + } + + out[parent] = append(out[parent], item) + } + + return out +} + +// indexAppsByDeployAndEnv indexes Applications by the (pipeline, stage) label +// pair. The `app.edp.epam.com/stage` label carries Stage.spec.name (the short +// env identifier "dev"/"stage"/"prod"). +func indexAppsByDeployAndEnv(items []k8sItem) map[deployEnvKey]k8sItem { + out := make(map[deployEnvKey]k8sItem, len(items)) + + for _, item := range items { + labels := ptr.Deref(item.Metadata.Labels, nil) + if labels == nil { + continue + } + + pipeline := labels[labelPipeline] + stage := labels[labelStage] + if pipeline == "" || stage == "" { + continue + } + + out[deployEnvKey{pipeline, stage}] = item + } + + return out +} diff --git a/internal/portal/types.go b/internal/portal/types.go index 572ba19..fbe41d7 100644 --- a/internal/portal/types.go +++ b/internal/portal/types.go @@ -54,8 +54,111 @@ const ( QualityGateTypeManual = "manual" ) +// ArgoHealthStatus is one of ArgoCD's documented health-status values for an +// Application's `status.health.status` field. The CLI surfaces these in +// lowercase across `env get` and `project deployments`; renderers use the +// constants below to dispatch coloring without raw-string switches. +type ArgoHealthStatus string + +const ( + ArgoHealthHealthy ArgoHealthStatus = "healthy" + ArgoHealthDegraded ArgoHealthStatus = "degraded" + ArgoHealthMissing ArgoHealthStatus = "missing" + ArgoHealthProgressing ArgoHealthStatus = "progressing" + ArgoHealthSuspended ArgoHealthStatus = "suspended" + ArgoHealthUnknown ArgoHealthStatus = "unknown" +) + // QualityGate represents a quality gate step within a Stage. type QualityGate struct { Name string `json:"name"` Type string `json:"type"` } + +// EnvSummary is one row in `krci env list`. The JSON envelope emits this +// slice under the wire-key "stages" (see EnvListPayload.Stages) to match the +// upstream Stage K8s resource; the Go-side name "EnvSummary" reflects the +// CLI's user-facing "environment" abstraction. +type EnvSummary struct { + Deployment string `json:"deployment"` + Env string `json:"env"` + Cluster string `json:"cluster"` + Namespace string `json:"namespace"` + TriggerType string `json:"triggerType"` + Status string `json:"status"` + Order int `json:"order"` +} + +// EnvListPayload is the envelope `data` block for `krci env list`. +type EnvListPayload struct { + Stages []EnvSummary `json:"stages"` +} + +// EnvDetail is the response for `krci env get `. +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"` +} + +// Infrastructure carries the technical placement of an environment. +type Infrastructure struct { + Cluster string `json:"cluster"` + Namespace string `json:"namespace"` + TriggerType string `json:"triggerType"` + DeployPipeline string `json:"deployPipeline"` + CleanPipeline *string `json:"cleanPipeline"` +} + +// QualityGateDetail extends QualityGate with autotest + branch metadata +// surfaced in `krci env get`. +type QualityGateDetail struct { + Type string `json:"type"` + StepName string `json:"stepName"` + AutotestName *string `json:"autotestName"` + BranchName *string `json:"branchName"` +} + +// EnvProject is one row in EnvDetail.Projects. +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"` +} + +// ProjectDeploymentRow is one row in `krci project deployments `. +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"` +} + +// ProjectDeploymentsPayload is the envelope `data` block for +// `krci project deployments `. +type ProjectDeploymentsPayload struct { + Project string `json:"project"` + Rows []ProjectDeploymentRow `json:"rows"` +} diff --git a/pkg/cmd/env/env.go b/pkg/cmd/env/env.go new file mode 100644 index 0000000..d5e977f --- /dev/null +++ b/pkg/cmd/env/env.go @@ -0,0 +1,31 @@ +// Package env implements the "krci env" command group. +package env + +import ( + "github.com/spf13/cobra" + + "github.com/KubeRocketCI/cli/internal/cmdutil" + "github.com/KubeRocketCI/cli/pkg/cmd/env/get" + "github.com/KubeRocketCI/cli/pkg/cmd/env/list" +) + +// NewCmdEnv returns the "env" group cobra.Command with all subcommands attached. +func NewCmdEnv(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Aliases: []string{"e"}, + Short: "Inspect KRCI environments (Stages)", + Long: `Inspect KRCI environments — the Stage resources surfaced as +"environments" in the Portal — without leaving the terminal. Lists every +stage in the configured namespace or shows full detail for one (deployment, +env) pair, including infrastructure, quality gates, and the projects +deployed there with health, sync, version, image, and ingress URLs.`, + } + + cmd.AddCommand( + list.NewCmdList(f, nil), + get.NewCmdGet(f, nil), + ) + + return cmd +} diff --git a/pkg/cmd/env/get/get.go b/pkg/cmd/env/get/get.go new file mode 100644 index 0000000..dfbec63 --- /dev/null +++ b/pkg/cmd/env/get/get.go @@ -0,0 +1,326 @@ +// Package get implements the "krci env get" command. +package get + +import ( + "context" + "errors" + "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" +) + +// GetOptions holds all inputs for `krci env get `. +type GetOptions struct { + IO *iostreams.IOStreams + RestClient func() (*restapi.ClientWithResponses, error) + Config func() (*config.Config, error) + Deployment string + Env string + OutputFormat string +} + +// NewCmdGet returns the "env get" cobra.Command. +// runF is the business-logic function; pass nil to use the default getRun. +func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + RestClient: f.RestClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "get ", + Short: "Show full detail for one environment", + Long: `Show the full detail of one (deployment, env) pair: infrastructure +(cluster, namespace, trigger type, deploy/clean pipelines), quality gates, +and the projects deployed there with health, sync, version, image digest, +ingress URLs, ArgoCD link, and last-deployed timestamp. + + is the parent CDPipeline name. is Stage.spec.name (the +short user-facing identifier such as "dev", "stage", "prod").`, + Args: cmdutil.ExactArgs(2, + "a deployment and an env (e.g. krci env get my-pipeline prod)", + "to see available environments: krci env list"), + Example: ` # Default + krci env get my-pipeline prod + + # JSON envelope (full image digest, full ingress URLs) + krci env get my-pipeline prod -o json + + # Scripting — list all degraded projects in this env + krci env get my-pipeline prod -o json | + jq -r '.data.projects[] | select(.status=="degraded") | .name'`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Deployment = args[0] + opts.Env = args[1] + + if err := discovery.ValidateOutputFormat(opts.OutputFormat); err != nil { + return err + } + + if err := discovery.ValidateDeployment(opts.Deployment); err != nil { + return err + } + + if err := discovery.ValidateEnv(opts.Env); err != nil { + return err + } + + if runF != nil { + return runF(opts) + } + + return getRun(cmd.Context(), opts) + }, + } + + cmd.Flags().StringVarP(&opts.OutputFormat, "output", "o", "", + "Output format: table, json (default: table)") + + return cmd +} + +func getRun(ctx context.Context, opts *GetOptions) 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.NewEnvService(client, cfg.ClusterName, cfg.Namespace) + + detail, err := svc.Get(ctx, opts.Deployment, opts.Env) + if err != nil { + return discovery.HandleError(opts.IO, opts.OutputFormat, mapNotFound(err, opts.Deployment, opts.Env)) + } + + return discovery.Render(opts.IO, opts.OutputFormat, detail, func(w io.Writer, isTTY bool) error { + return renderDetail(w, isTTY, detail) + }) +} + +// mapNotFound rewrites the typed not-found sentinels from EnvService.Get into +// the precise user-facing message the spec requires (M4 scenarios). Other +// errors pass through unchanged. +func mapNotFound(err error, deployment, env string) error { + switch { + case errors.Is(err, portal.ErrEnvNotFound): + return fmt.Errorf("environment %q not found in deployment %q", env, deployment) + case errors.Is(err, portal.ErrDeploymentNotFound): + return fmt.Errorf("deployment %q not found", deployment) + default: + return err + } +} + +func renderDetail(w io.Writer, isTTY bool, d *portal.EnvDetail) error { + headerPairs := buildHeaderPairs(d, isTTY) + infraPairs := buildInfraPairs(d.Infrastructure) + + // Shared label-column width so the header and infrastructure blocks line + // up across the section break (e.g. "Description" and "Deploy Pipeline" + // padded to the same column). + labelWidth := maxLabelWidth(headerPairs, infraPairs) + + if err := printAligned(w, isTTY, headerPairs, "", labelWidth); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := printSectionHeading(w, isTTY, "Infrastructure"); err != nil { + return err + } + + if err := printAligned(w, isTTY, infraPairs, " ", labelWidth); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + if err := renderQualityGates(w, isTTY, d.QualityGates); err != nil { + return err + } + + if _, err := fmt.Fprintln(w); err != nil { + return err + } + + return renderProjects(w, isTTY, d.Projects) +} + +func buildHeaderPairs(d *portal.EnvDetail, isTTY bool) []labelValue { + desc := "—" + if d.Description != nil { + desc = *d.Description + } + + statusValue := d.Status + if isTTY { + statusValue = output.StatusColor(d.Status) + } + + return []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)}, + } +} + +func buildInfraPairs(i portal.Infrastructure) []labelValue { + clean := "—" + if i.CleanPipeline != nil { + clean = *i.CleanPipeline + } + + return []labelValue{ + {Label: "Cluster", Value: i.Cluster}, + {Label: "Namespace", Value: i.Namespace}, + {Label: "Trigger Type", Value: i.TriggerType}, + {Label: "Deploy Pipeline", Value: i.DeployPipeline}, + {Label: "Clean Pipeline", Value: clean}, + } +} + +func maxLabelWidth(groups ...[]labelValue) int { + m := 0 + for _, g := range groups { + for _, p := range g { + if len(p.Label) > m { + m = len(p.Label) + } + } + } + return m +} + +func renderQualityGates(w io.Writer, isTTY bool, gates []portal.QualityGateDetail) error { + if err := printSectionHeading(w, isTTY, fmt.Sprintf("Quality Gates (%d)", len(gates))); err != nil { + return err + } + + if len(gates) == 0 { + _, err := fmt.Fprintln(w, " (none)") + return err + } + + for _, g := range gates { + if _, err := fmt.Fprintln(w, formatQualityGateLine(g)); err != nil { + return err + } + } + + return nil +} + +// formatQualityGateLine returns the per-gate display row used by `env get`: +// +// " - : [ (branch: )]" +// +// AutotestName, when present, replaces stepName as the value (matches the +// portal "autotests: smoke-tests (branch: main)" rendering). +func formatQualityGateLine(g portal.QualityGateDetail) string { + value := g.StepName + if g.AutotestName != nil && *g.AutotestName != "" { + value = *g.AutotestName + } + + line := fmt.Sprintf(" - %s: %s", g.Type, value) + + if g.BranchName != nil && *g.BranchName != "" { + line += fmt.Sprintf(" (branch: %s)", *g.BranchName) + } + + return line +} + +func renderProjects(w io.Writer, isTTY bool, projects []portal.EnvProject) error { + if err := printSectionHeading(w, isTTY, fmt.Sprintf("Projects (%d)", len(projects))); err != nil { + return err + } + + if len(projects) == 0 { + _, err := fmt.Fprintln(w, " (none)") + return err + } + + headers := []string{"PROJECT", "STATUS", "SYNC", "VERSION", "IMAGE_SHA", "INGRESS"} + rows := make([][]string, 0, len(projects)) + + for _, p := range projects { + cells := []string{ + p.Name, + discovery.OptStatusCell(p.Status, isTTY), + discovery.OptCell(p.Sync), + discovery.OptCell(p.Version), + discovery.ShortDigestCell(p.ImageDigest), + } + rows = append(rows, discovery.ExpandIngressRows(cells, discovery.IngressLines(p.IngressURLs, isTTY))...) + } + + return discovery.PrintTable(w, isTTY, headers, rows) +} + +// printSectionHeading writes a section heading: lipgloss-styled when isTTY, +// ":" otherwise. Centralizes the styled/plain branching that every +// detail section shares. +func printSectionHeading(w io.Writer, isTTY bool, text string) error { + if isTTY { + _, err := fmt.Fprintln(w, output.HeaderStyle.Render(text)) + return err + } + + _, err := fmt.Fprintln(w, text+":") + return err +} + +// labelValue is one aligned "label: value" row in the detail block. +type labelValue struct { + Label string + Value string +} + +// envLabelStyle reuses the shared LabelStyle but lets printAligned control +// the column width dynamically via its own padding logic. +var envLabelStyle = output.LabelStyle.UnsetWidth() + +// printAligned prints "