Skip to content

Commit d20a576

Browse files
feat: use v2 deployment read endpoints (#23)
* chore: update upstream OpenAPI spec 2026-05-07 * feat: use v2 deployment read endpoints Upstream RIG-Cluster shipped GET /v2/projects/{p}/deployments and GET /v2/projects/{p}/deployments/{d} (closes the structural problem flagged in RijksICTGilde/RIG-Cluster#51). The CLI no longer has to fuse /logs and /tasks to reconstruct deployment state. Client: - add ZadClient.list_deployments_v2 and get_deployment_v2 wrappers - list_deployments and describe_deployment now prefer the v2 endpoints and fall back to the legacy logs+tasks fusion on 404, so older Operations Manager deployments keep working - legacy code paths preserved as _list_deployments_legacy and _describe_deployment_legacy Models: - add DeploymentStatus and ErrorCategory string enums - add DeploymentDetail, DeploymentListResponse, DeploymentComponentDetail, StatusError pydantic models for the new response shapes Describe rendering: - show Status (color-coded), Revision (short SHA), Last sync - when Status indicates a problem, show an Errors table with Category/Resource/Message and a deduplicated explanation footer - hide the K8s Deployment column in the v2 path (the field doesn't exist there); legacy path still populates it Tests: - new respx tests for the v2 happy paths and the 404 fallback - existing legacy-path tests now mock a 404 on the v2 endpoint to exercise the same fallback code * fix: address review on PR #23 - describe_deployment now propagates a real 404 when the v2 list endpoint works but get-one 404s (deployment is genuinely missing). Previously the legacy fallback would silently return empty data. Probe by calling list_deployments_v2 to disambiguate "deployment missing" from "endpoint not registered on this upstream". - _status_color: Suspended joins the red tier alongside Degraded, Missing, OutOfSync per upstream's documented severity ordering. - backwards-compat baseline: register list_deployments_v2 and get_deployment_v2 as expected public methods. * fix: address second review on PR #23 - list_deployments now disambiguates "project not found" from "v2 endpoint not registered on this upstream", mirroring the describe_deployment logic. Probes via list_projects (available on every upstream version) before falling back to the legacy fusion. - "Last sync" label renamed to "Last sync attempt": per upstream spec, the timestamp is the most recent reconciliation attempt regardless of outcome, so a Degraded deployment with a recent failed sync no longer reads as if it last synced cleanly. - Move _status_color above describe (helpers before callers). * fix: only swallow 404 from list_projects probe The disambiguation probe in list_deployments was catching every ZadApiError from list_projects, including 401/403/5xx. An expired API key or transient server error would silently route to the legacy fusion path and surface a confusing error from /logs instead of the real cause. Now only 404 means "old upstream"; everything else propagates. * fix: extend describe_deployment 404 disambiguation describe_deployment now mirrors list_deployments: when both v2 endpoints 404, it probes list_projects to tell "old upstream" from "project does not exist on this upstream". The old code silently fell back to the legacy logs+tasks fusion in both cases, masking "Project not found" with empty-component results. Both callers go through a shared _project_exists helper which only swallows a 404 from list_projects (treating that as "even /projects is missing, fall back"); 401/403/5xx propagate so the user sees the real cause. New test: describe_deployment raises ZadApiError(404) with a clear "Project '<name>' not found" message when the project is missing. * fix: address third review on PR #23 Three findings: 1. (Significant) project_status was unconditionally overwriting v2-supplied URLs with empty dicts on modern upstreams that have no recent completed tasks. The task probe is now best-effort: only overrides v2 URLs when it actually finds something. 2. Validate v2 read endpoint responses through pydantic. list_deployments_v2 and get_deployment_v2 now run the response through DeploymentListResponse / DeploymentDetail and re-emit a dict. Upstream schema drift surfaces as a clear pydantic error instead of leaking malformed data downstream. 3. Tests: - new test for project_status preserving v2 URLs when /tasks is empty - new test for the "/projects also 404s" branch (very old upstream) * fix: v2 URLs are authoritative over task-history URLs The previous fix inverted the priority — task_urls or dep["urls"] gave task URLs priority when both were non-empty. v2 is authoritative when present, so prefer dep["urls"] (the v2-supplied dict) and fall back to the task probe only when v2 returned nothing (legacy upstreams or deployments with no public components). New test: when both v2 and task history carry URLs for the same deployment, the v2 URLs win. * fix: address fourth review on PR #23 Four findings: 1. (Significant) ValidationError from pydantic schema mismatch was not caught by @handle_api_errors and would surface as a raw traceback. New _parse_v2_response helper translates ValidationError to ZadApiError(502, "Unexpected API response shape: ...") so the existing error rendering path handles upstream schema drift cleanly. This becomes load-bearing as upstream adds new DeploymentStatus or ErrorCategory enum values without a breaking-change label. 2. (Minor) project_status URL merge now uses presence rather than truthiness. v2 returning {} legitimately means "no publish-on-web"; stale task URLs no longer leak through after the config is removed. Legacy rows lack the "urls" key so the test still picks them up. 3. (Minor) New test: describe_deployment propagates non-404 (401/etc) from the disambiguation probe, mirroring the list_deployments test. 4. (Minor) New parametrized test for _status_color covering every DeploymentStatus enum value plus the empty-string fallback. * fix: address fifth review on PR #23 - New CLI integration tests for deployment describe rendering, using typer's CliRunner with a stubbed ZadClient. Covers Healthy (no Errors table) and Degraded (Errors table with category, message, and explanation footer) so future Rich-markup or key-name regressions get caught at CI. - Trim multi-paragraph docstrings on _parse_v2_response, list_deployments_v2, get_deployment_v2, list_deployments, describe_deployment, _project_exists per CLAUDE.md (one-line docstrings only). Reasoning lives in the PR description. - _status_color now uses a dict keyed on DeploymentStatus enum members instead of bare string literals; if upstream renames a status the type system surfaces the dependency. * refactor: drop legacy logs+tasks fallback paths There is one production upstream and it has been upgraded to ship the v2 read endpoints, so the fallback chain no longer earns its keep. Removed: - _list_deployments_legacy and _describe_deployment_legacy (logs+tasks fusion to reconstruct deployment state) - _project_exists disambiguation probe and the layered 404 handling in list_deployments / describe_deployment - project_status's /tasks call to recover URLs (v2 supplies them directly on every deployment) - The optional `subdomain` plumbing and `k8s_deployment` column rendering, both vestigial after the legacy path is gone - All tests covering legacy fallback paths, probe behavior, and stale task-history URL handling resolve_namespace now uses get_deployment_v2 directly instead of walking list_deployments. describe_deployment, list_deployments, and project_status pass through v2 fields without `.get()` defaults: after pydantic validation the required fields are guaranteed present and the optional ones are explicitly None. Net change: ~150 lines of client code and ~250 lines of tests removed, no behavior change against the current upstream. * fix: coerce unknown enum values instead of failing validation Closed enums were brittle: an additive upstream change (a new ErrorCategory or DeploymentStatus value) would make pydantic reject the entire DeploymentListResponse, taking the whole `zad deployment list` for a project down until the CLI is updated. Upstream's own `Unknown` catch-all signals they expect callers to handle unknown values gracefully, not hard-fail. - StatusError now coerces unknown category strings to UNKNOWN via a before-validator. - DeploymentDetail does the same for status. - Tests cover both coercion paths plus the known-value passthrough. - Drop vestigial `k8s_deployment` keys from the describe rendering test fixtures (the column was removed when v2 became the only source). * fix: address final review on PR #23 - Restore `k8s_deployment` as a tombstone field in describe_deployment's component dicts. The v2 endpoint doesn't expose this field, but removing it from the public describe shape would break consumers reading `comp["k8s_deployment"]` from the JSON output. Per the backwards-compat policy, return-shape removals require a deprecation cycle; an empty-string tombstone is the additive-only path. - Replace the private `_value2member_map_` access in the enum coercion validators with a public set comprehension `{e.value for e in Enum}`. - Use a realistic 40-char SHA in the describe rendering test so the `[:12]` truncation is actually exercised. * fix: tighten _STATUS_COLORS type and exercise SHA truncation - _STATUS_COLORS is now annotated dict[DeploymentStatus, str], so type checkers flag a new enum value missing from the mapping instead of it silently falling through to "dim" at runtime. - The Degraded fixture's sync_revision was exactly 12 characters, so the [:12] truncation was a no-op there. Pad to 40 chars to match the Healthy fixture and actually verify truncation. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent cb27c51 commit d20a576

9 files changed

Lines changed: 810 additions & 191 deletions

File tree

api/upstream-openapi.json

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,121 @@
513513
]
514514
}
515515
},
516+
"/api/v2/projects/{project_name}/deployments": {
517+
"get": {
518+
"tags": [
519+
"v2",
520+
"v2",
521+
"deployments"
522+
],
523+
"summary": "List Deployments V2",
524+
"description": "List deployments in a project with components, images, and computed URLs.\n\nReturns only deployments targeting the current cluster.\n\nHeaders:\n X-API-Key: The API key for the project (required)",
525+
"operationId": "list_deployments_v2_api_v2_projects__project_name__deployments_get",
526+
"parameters": [
527+
{
528+
"name": "project_name",
529+
"in": "path",
530+
"required": true,
531+
"schema": {
532+
"type": "string",
533+
"title": "Project Name"
534+
}
535+
}
536+
],
537+
"responses": {
538+
"200": {
539+
"description": "Successful Response",
540+
"content": {
541+
"application/json": {
542+
"schema": {
543+
"$ref": "#/components/schemas/DeploymentListResponse"
544+
}
545+
}
546+
}
547+
},
548+
"404": {
549+
"description": "Not found"
550+
},
551+
"422": {
552+
"description": "Validation Error",
553+
"content": {
554+
"application/json": {
555+
"schema": {
556+
"$ref": "#/components/schemas/HTTPValidationError"
557+
}
558+
}
559+
}
560+
}
561+
},
562+
"security": [
563+
{
564+
"APIKeyHeader": []
565+
}
566+
]
567+
}
568+
},
569+
"/api/v2/projects/{project_name}/deployments/{deployment_name}": {
570+
"get": {
571+
"tags": [
572+
"v2",
573+
"v2",
574+
"deployments"
575+
],
576+
"summary": "Get Deployment V2",
577+
"description": "Get a single deployment with components, images, and computed URLs.\n\nReturns the current state of a deployment as defined in the project file,\nwith computed public URLs for components that have publish-on-web.\n\nHeaders:\n X-API-Key: The API key for the project (required)",
578+
"operationId": "get_deployment_v2_api_v2_projects__project_name__deployments__deployment_name__get",
579+
"parameters": [
580+
{
581+
"name": "project_name",
582+
"in": "path",
583+
"required": true,
584+
"schema": {
585+
"type": "string",
586+
"title": "Project Name"
587+
}
588+
},
589+
{
590+
"name": "deployment_name",
591+
"in": "path",
592+
"required": true,
593+
"schema": {
594+
"type": "string",
595+
"title": "Deployment Name"
596+
}
597+
}
598+
],
599+
"responses": {
600+
"200": {
601+
"description": "Successful Response",
602+
"content": {
603+
"application/json": {
604+
"schema": {
605+
"$ref": "#/components/schemas/DeploymentDetail"
606+
}
607+
}
608+
}
609+
},
610+
"404": {
611+
"description": "Not found"
612+
},
613+
"422": {
614+
"description": "Validation Error",
615+
"content": {
616+
"application/json": {
617+
"schema": {
618+
"$ref": "#/components/schemas/HTTPValidationError"
619+
}
620+
}
621+
}
622+
}
623+
},
624+
"security": [
625+
{
626+
"APIKeyHeader": []
627+
}
628+
]
629+
}
630+
},
516631
"/api/v2/projects/{project_name}/deployments/{deployment_name}/:clone-bucket": {
517632
"post": {
518633
"tags": [
@@ -6261,6 +6376,151 @@
62616376
"title": "DeploymentBackupResponse",
62626377
"description": "Response for combined deployment backup operations (PVCs, databases, buckets)."
62636378
},
6379+
"DeploymentComponentDetail": {
6380+
"properties": {
6381+
"reference": {
6382+
"type": "string",
6383+
"title": "Reference",
6384+
"description": "Component name reference"
6385+
},
6386+
"image": {
6387+
"type": "string",
6388+
"title": "Image",
6389+
"description": "Container image URL"
6390+
}
6391+
},
6392+
"type": "object",
6393+
"required": [
6394+
"reference",
6395+
"image"
6396+
],
6397+
"title": "DeploymentComponentDetail",
6398+
"description": "Component within a deployment, including image reference."
6399+
},
6400+
"DeploymentDetail": {
6401+
"properties": {
6402+
"name": {
6403+
"type": "string",
6404+
"title": "Name",
6405+
"description": "Deployment name"
6406+
},
6407+
"project": {
6408+
"type": "string",
6409+
"title": "Project",
6410+
"description": "Project name"
6411+
},
6412+
"cluster": {
6413+
"type": "string",
6414+
"title": "Cluster",
6415+
"description": "Target cluster"
6416+
},
6417+
"namespace": {
6418+
"type": "string",
6419+
"title": "Namespace",
6420+
"description": "Kubernetes namespace"
6421+
},
6422+
"subdomain": {
6423+
"anyOf": [
6424+
{
6425+
"type": "string"
6426+
},
6427+
{
6428+
"type": "null"
6429+
}
6430+
],
6431+
"title": "Subdomain",
6432+
"description": "DNS subdomain override"
6433+
},
6434+
"components": {
6435+
"items": {
6436+
"$ref": "#/components/schemas/DeploymentComponentDetail"
6437+
},
6438+
"type": "array",
6439+
"title": "Components",
6440+
"description": "Component references"
6441+
},
6442+
"urls": {
6443+
"additionalProperties": {
6444+
"type": "string"
6445+
},
6446+
"type": "object",
6447+
"title": "Urls",
6448+
"description": "Computed public URLs, keyed by component name"
6449+
},
6450+
"status": {
6451+
"$ref": "#/components/schemas/DeploymentStatus",
6452+
"description": "Overall deployment state. Always present; check value to render."
6453+
},
6454+
"sync_revision": {
6455+
"anyOf": [
6456+
{
6457+
"type": "string"
6458+
},
6459+
{
6460+
"type": "null"
6461+
}
6462+
],
6463+
"title": "Sync Revision",
6464+
"description": "Git revision (full SHA) the cluster last reconciled; null if never reconciled"
6465+
},
6466+
"last_synced_at": {
6467+
"anyOf": [
6468+
{
6469+
"type": "string"
6470+
},
6471+
{
6472+
"type": "null"
6473+
}
6474+
],
6475+
"title": "Last Synced At",
6476+
"description": "ISO timestamp of the last reconciliation attempt against git, regardless of outcome. Combine with status to know whether that attempt succeeded; for a Degraded deployment this can be the time of a failed sync, not a healthy one. Null if no reconciliation has ever happened."
6477+
},
6478+
"errors": {
6479+
"items": {
6480+
"$ref": "#/components/schemas/StatusError"
6481+
},
6482+
"type": "array",
6483+
"title": "Errors",
6484+
"description": "Cluster-side error entries; populated only when status indicates a problem (Degraded, OutOfSync, Suspended, Missing). Empty otherwise."
6485+
}
6486+
},
6487+
"type": "object",
6488+
"required": [
6489+
"name",
6490+
"project",
6491+
"cluster",
6492+
"namespace",
6493+
"status"
6494+
],
6495+
"title": "DeploymentDetail",
6496+
"description": "Full deployment state as returned by the GET endpoints."
6497+
},
6498+
"DeploymentListResponse": {
6499+
"properties": {
6500+
"project": {
6501+
"type": "string",
6502+
"title": "Project"
6503+
},
6504+
"cluster": {
6505+
"type": "string",
6506+
"title": "Cluster"
6507+
},
6508+
"deployments": {
6509+
"items": {
6510+
"$ref": "#/components/schemas/DeploymentDetail"
6511+
},
6512+
"type": "array",
6513+
"title": "Deployments"
6514+
}
6515+
},
6516+
"type": "object",
6517+
"required": [
6518+
"project",
6519+
"cluster"
6520+
],
6521+
"title": "DeploymentListResponse",
6522+
"description": "Response for GET /projects/{project_name}/deployments."
6523+
},
62646524
"DeploymentRestoreRequest": {
62656525
"properties": {
62666526
"resource_type": {
@@ -6412,6 +6672,36 @@
64126672
"status": "success"
64136673
}
64146674
},
6675+
"DeploymentStatus": {
6676+
"type": "string",
6677+
"enum": [
6678+
"Healthy",
6679+
"Degraded",
6680+
"Progressing",
6681+
"OutOfSync",
6682+
"Suspended",
6683+
"Missing",
6684+
"Pending",
6685+
"Unavailable",
6686+
"Unknown"
6687+
],
6688+
"title": "DeploymentStatus",
6689+
"description": "Overall deployment state.\n\nA single enum covering everything a caller wants to switch on. Argo's\ntwo orthogonal dimensions (sync, health) are collapsed using a\nworst-of-both priority: Degraded/Suspended/Missing > OutOfSync >\nProgressing > Healthy. Pending and Unavailable are *our* states for\n\"we have no data,\" distinct from Argo's own Unknown."
6690+
},
6691+
"ErrorCategory": {
6692+
"type": "string",
6693+
"enum": [
6694+
"ImagePull",
6695+
"CrashLoop",
6696+
"OutOfMemory",
6697+
"HealthCheck",
6698+
"SyncFailed",
6699+
"ComparisonError",
6700+
"Unknown"
6701+
],
6702+
"title": "ErrorCategory",
6703+
"description": "Programmatic categorization of a cluster error. Use ``message`` for the raw text.\n\nCategories are intentionally broader than literal Kubernetes reasons (e.g.\n``ImagePull`` covers ``ImagePullBackOff``, ``ErrImagePull``, manifest-unknown\npulls, etc.) so app-level categories can be added later without breaking\nconsumers tied to specific K8s state names."
6704+
},
64156705
"HTTPValidationError": {
64166706
"properties": {
64176707
"detail": {
@@ -7029,6 +7319,56 @@
70297319
"title": "SnapshotInfoModel",
70307320
"description": "Information about a Kopia snapshot."
70317321
},
7322+
"StatusError": {
7323+
"properties": {
7324+
"resource": {
7325+
"type": "string",
7326+
"title": "Resource",
7327+
"description": "Kind/name (e.g. 'Pod/frontend-abc') or 'Event/<obj>' for events"
7328+
},
7329+
"message": {
7330+
"type": "string",
7331+
"title": "Message",
7332+
"description": "Raw cluster message \u2014 for automation, regex matching, correlation"
7333+
},
7334+
"category": {
7335+
"$ref": "#/components/schemas/ErrorCategory",
7336+
"description": "Programmatic category for filtering, grouping, colorizing"
7337+
},
7338+
"explanation": {
7339+
"anyOf": [
7340+
{
7341+
"type": "string"
7342+
},
7343+
{
7344+
"type": "null"
7345+
}
7346+
],
7347+
"title": "Explanation",
7348+
"description": "Human-friendly explanation of the category and what to do next; null when the category has no canned guidance (e.g. Unknown)"
7349+
},
7350+
"timestamp": {
7351+
"anyOf": [
7352+
{
7353+
"type": "string"
7354+
},
7355+
{
7356+
"type": "null"
7357+
}
7358+
],
7359+
"title": "Timestamp",
7360+
"description": "ISO timestamp if known"
7361+
}
7362+
},
7363+
"type": "object",
7364+
"required": [
7365+
"resource",
7366+
"message",
7367+
"category"
7368+
],
7369+
"title": "StatusError",
7370+
"description": "A single error or warning entry surfaced from the cluster."
7371+
},
70327372
"StorageAction": {
70337373
"properties": {
70347374
"action": {

0 commit comments

Comments
 (0)