diff --git a/Makefile b/Makefile index e94cea0..feeb6d6 100644 --- a/Makefile +++ b/Makefile @@ -4,14 +4,15 @@ PIP ?= $(PYTHON) -m pip PYTEST ?= $(PYTHON) -m pytest RUFF ?= $(VENV)/bin/ruff -.PHONY: help install-dev test lint run verify-pin +.PHONY: help install-dev test lint run verify-pin validate-app-toml help: @echo "make install-dev Create .venv and install runtime + dev dependencies" @echo "make test Run the test suite" @echo "make lint Run syntax and static checks" @echo "make run Start the local API on 127.0.0.1:8090" - @echo "make verify-pin Check production against the repo's pinned digest" + @echo "make verify-pin Check production against the repo's pinned digest" + @echo "make validate-app-toml Validate railiance/app.toml against railiance.app.v1" $(VENV)/bin/python: python3 -m venv $(VENV) @@ -35,5 +36,8 @@ run: $(VENV)/.dev-installed # Needs cluster access. tenant-engine runs on railiance01: # KUBECONFIG=~/.kube/config-railiance01 make verify-pin +validate-app-toml: $(VENV)/.dev-installed + $(PYTHON) tests/validate_app_toml.py + verify-pin: @./deploy/verify-pin.sh diff --git a/SCOPE.md b/SCOPE.md index 2acc6fc..b49928e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -98,12 +98,12 @@ not done. | Domain model + SQLite/in-memory store | yes | `TEN-WP-0002` | | Cache-read / live-lookup / write APIs | yes | `TEN-WP-0002` | | `flex-auth` `WriteAuthorizer` | yes | Fail-closed; `is_allowed()` returns a bool and **discards the decision envelope** | -| Production runtime | yes | `TEN-WP-0004`; image pin in `deploy/tenant-engine.yaml` | +| Production runtime | yes | `TEN-WP-0004`; image pin in `deploy/base/tenant-engine.yaml` | | Lifecycle (update / retire / reactivate) | yes | `TEN-WP-0005`; no hard-delete | | Guardrail policy | yes | `TEN-WP-0006` / `0007`; `docs/tenant-guardrail-policy.md` | | PostgreSQL production store | yes | `TEN-WP-0009`; deploy mounts `TENANT_ENGINE_DATABASE_URL_FILE` | | Mutable grouping | yes | `TEN-WP-0010`; identifier segment stays historical | -| Staged promotion | **no** | `TEN-WP-0008` | +| Staged promotion | yes (declared) | `TEN-WP-0008`; `railiance/app.toml`; live pin recorded as `previous_stable`. Helm CLI apply is a railiance-bootstrap gap (`RAIL-BS-IN-0001`). | | Machine-readable `layer.yaml` | yes | `TEN-WP-0011-T01`; check in `scripts/check_layer_conformance.py` | | Persisted `authorization_decision_id` | yes | `authz_records` plus mutation event payload (`TEN-WP-0011-T02`) | | Published unreachable-engine stance map | yes | `pep-stance.yaml`, fail-closed, tested equal to shipped behaviour | diff --git a/deploy/README.md b/deploy/README.md index 7cbb831..7c0483b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,11 @@ # tenant-engine production deployment -**Apply home moving to `rapp-tenant-engine`.** These files remain the source of the absorbed manifests until that package is the operator apply path. +**Apply path:** `kubectl apply -k deploy/` (TEN-WP-0008). `deploy/base/tenant-engine.yaml` +is the document kustomize includes, and the file `make verify-pin` reads +for the intended digest. Isolated canary: `kubectl apply -k deploy/canary/`. + +Staged-promotion contract: `railiance/app.toml`. Recorded stable: +`tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24`. The original manifests were recovered from live objects on 2026-08-14. The @@ -42,7 +47,7 @@ git push origin main # 3. Provision the rapp-postgres consumer and credential projections. # 4. Run the schema migration Job with the migration-role lease. # 5. Follow the stopped-write transfer below; only then apply the runtime. -kubectl apply -f deploy/tenant-engine.yaml +kubectl apply -k deploy/ kubectl -n tenant-engine rollout status deploy/tenant-engine --timeout=120s ``` @@ -92,7 +97,7 @@ and periodically — drift is not an event you get told about. It compares four things that are supposed to agree: -1. the digest this repo intends to run (`deploy/tenant-engine.yaml`); +1. the digest this repo intends to run (`deploy/base/tenant-engine.yaml`); 2. the digest the Deployment's spec asks for; 3. the digest the **running pod** actually resolved — a spec can be correct while the pod answering traffic is an older ReplicaSet that never finished diff --git a/deploy/base/kustomization.yaml b/deploy/base/kustomization.yaml new file mode 100644 index 0000000..8dcb478 --- /dev/null +++ b/deploy/base/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - tenant-engine.yaml diff --git a/deploy/tenant-engine.yaml b/deploy/base/tenant-engine.yaml similarity index 100% rename from deploy/tenant-engine.yaml rename to deploy/base/tenant-engine.yaml diff --git a/deploy/canary/kustomization.yaml b/deploy/canary/kustomization.yaml new file mode 100644 index 0000000..8a2937c --- /dev/null +++ b/deploy/canary/kustomization.yaml @@ -0,0 +1,46 @@ +# Isolated Stage 2 canary. TEN-WP-0009 moved production off a ReadWriteOnce +# PVC onto PostgreSQL with RollingUpdate, so a second pod is storage-feasible. +# Isolation is a distinct Deployment + Service selector, not a second replica +# behind the production Service — user-engine's NetworkPolicy still points at +# app.kubernetes.io/name=tenant-engine and must not start sending traffic here. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../base +patches: + - target: + kind: Deployment + name: tenant-engine + patch: |- + - op: replace + path: /metadata/name + value: tenant-engine-canary + - op: replace + path: /spec/selector/matchLabels/app.kubernetes.io~1name + value: tenant-engine-canary + - op: replace + path: /spec/template/metadata/labels/app.kubernetes.io~1name + value: tenant-engine-canary + - target: + kind: Service + name: tenant-engine + patch: |- + - op: replace + path: /metadata/name + value: tenant-engine-canary + - op: replace + path: /spec/selector/app.kubernetes.io~1name + value: tenant-engine-canary + - target: + kind: NetworkPolicy + name: tenant-engine + patch: |- + - op: replace + path: /metadata/name + value: tenant-engine-canary + - op: replace + path: /spec/podSelector/matchLabels/app.kubernetes.io~1name + value: tenant-engine-canary + - op: replace + path: /spec/ingress + value: [] diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..85dcd9d --- /dev/null +++ b/deploy/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - base diff --git a/deploy/verify-pin.sh b/deploy/verify-pin.sh index aa69acf..e3ac735 100755 --- a/deploy/verify-pin.sh +++ b/deploy/verify-pin.sh @@ -4,7 +4,7 @@ # Compares three things that are supposed to agree and silently did not # between 2026-08-13 and 2026-08-16: # -# 1. the digest this repo intends to run (deploy/tenant-engine.yaml) +# 1. the digest this repo intends to run (deploy/base/tenant-engine.yaml) # 2. the digest the Deployment asks for (spec.template.spec.containers) # 3. the digest the running pod actually (status.containerStatuses.imageID) # resolved @@ -33,7 +33,7 @@ set -uo pipefail NAMESPACE="tenant-engine" DEPLOYMENT="tenant-engine" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MANIFEST="$REPO_ROOT/deploy/tenant-engine.yaml" +MANIFEST="$REPO_ROOT/deploy/base/tenant-engine.yaml" QUIET=0 LOCAL_PORT="${VERIFY_PIN_PORT:-18131}" diff --git a/docs/evidence/TEN-WP-0008-rollback-plan.json b/docs/evidence/TEN-WP-0008-rollback-plan.json new file mode 100644 index 0000000..66c38fd --- /dev/null +++ b/docs/evidence/TEN-WP-0008-rollback-plan.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "app": { + "criticality": "high", + "id": "tenant-engine", + "name": "tenant-engine", + "owner": "infotech", + "repo": "tenant-engine" + }, + "app_dir": "/home/worsch/tenant-engine", + "approval_id": null, + "chart": "/home/worsch/tenant-engine/charts/tenant-engine", + "checks_expected": [ + "stage2-accepted", + "rollback-target", + "cluster-health", + "operator-approval" + ], + "contract": "/home/worsch/tenant-engine/railiance/app.toml", + "evidence_expected": [ + "promotion commit", + "new stable digest in previous_stable and deploy/base/tenant-engine.yaml", + "post-promotion make verify-pin" + ], + "generated_at": "2026-08-29T12:49:54Z", + "mode": "plan", + "namespace": "tenant-engine", + "planned_actions": [ + { + "action_ref": "stage3.helm-rollback", + "namespace": "tenant-engine", + "release": "tenant-engine", + "revision": null, + "tool": "helm" + } + ], + "prechecks": [ + { + "name": "app.toml", + "required": true, + "status": "passed" + }, + { + "detail": "image-digest", + "name": "rollback-strategy", + "required": true, + "status": "passed" + }, + { + "detail": "plan mode does not execute helm", + "name": "helm", + "required": false, + "status": "not_required" + }, + { + "name": "approval-id", + "required": false, + "status": "required_before_apply" + } + ], + "previous_stable": "tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24", + "promotion_mode": "release-replace", + "release": "tenant-engine", + "requires_approval": true, + "revision": null, + "rollback": { + "command_ref": "rollback.command", + "strategy": "image-digest", + "verification": "make verify-pin agrees repo, spec, and running pod; GET /health returns\n200 with store_backend=postgresql; lifecycle routes still present.\nDo not roll back to sha256:7dc9d7bf1fcbb3f3ad7035366f4cd44038322995a126b4423142e446ca5c9cd5\n(SQLite/PVC) after any PostgreSQL write. The TEN-WP-0007 ladder still\nholds for older images: rolling past a pre-lifecycle digest removes\nGET/PATCH/retire/reactivate that user-engine depends on.\n" + }, + "schema_version": "railiance.stage3-rollback-result.v1", + "source": { + "artifact": "image", + "digest_policy": "required", + "revision": "git:main" + }, + "stage": "stage3", + "status": "planned", + "summary": { + "actions_failed": 0, + "actions_total": 0, + "required_prechecks_failed": 0 + }, + "values": "/home/worsch/tenant-engine/values/stage3-production.yaml" +} diff --git a/docs/evidence/TEN-WP-0008-stage1-result.json b/docs/evidence/TEN-WP-0008-stage1-result.json new file mode 100644 index 0000000..edf5c8a --- /dev/null +++ b/docs/evidence/TEN-WP-0008-stage1-result.json @@ -0,0 +1,116 @@ +{ + "app": { + "criticality": "high", + "id": "tenant-engine", + "name": "tenant-engine", + "owner": "infotech", + "repo": "tenant-engine" + }, + "app_dir": "/home/worsch/tenant-engine", + "checks": [ + { + "command_ref": "checks.unit-tests.command", + "duration_seconds": 78.242, + "exit_code": 0, + "id": "unit-tests", + "required": true, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 2453, + "type": "command" + }, + { + "command_ref": "checks.contract-schema.command", + "duration_seconds": 1.703, + "exit_code": 0, + "id": "contract-schema", + "required": true, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 144, + "type": "command" + }, + { + "command_ref": "checks.kustomize-production.command", + "duration_seconds": 1.021, + "exit_code": 0, + "id": "kustomize-production", + "required": true, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 4028, + "type": "command" + }, + { + "command_ref": "checks.kustomize-canary.command", + "duration_seconds": 1.215, + "exit_code": 0, + "id": "kustomize-canary", + "required": true, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 3839, + "type": "command" + } + ], + "commands": [ + { + "command_ref": "stages.stage1.commands[0]", + "duration_seconds": 69.439, + "exit_code": 0, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 2453 + }, + { + "command_ref": "stages.stage1.commands[1]", + "duration_seconds": 1.526, + "exit_code": 0, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 144 + }, + { + "command_ref": "stages.stage1.commands[2]", + "duration_seconds": 1.48, + "exit_code": 0, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 4028 + }, + { + "command_ref": "stages.stage1.commands[3]", + "duration_seconds": 1.248, + "exit_code": 0, + "status": "passed", + "stderr_bytes": 0, + "stdout_bytes": 3839 + } + ], + "contract": "/home/worsch/tenant-engine/railiance/app.toml", + "duration_seconds": 155.875, + "evidence_expected": [ + "pytest output", + "app.toml schema validation", + "kustomize render of deploy/ and deploy/canary" + ], + "finished_at": "2026-08-29T12:49:41Z", + "namespace": "local", + "release": "tenant-engine-local", + "requires_approval": false, + "schema_version": "railiance.run-result.v1", + "source": { + "artifact": "image", + "digest_policy": "required", + "revision": "git:main" + }, + "stage": "stage1", + "started_at": "2026-08-29T12:46:51Z", + "status": "passed", + "summary": { + "checks_total": 4, + "commands_failed": 0, + "commands_total": 4, + "required_checks_failed": 0 + } +} diff --git a/docs/evidence/TEN-WP-0008-stage2-plan.json b/docs/evidence/TEN-WP-0008-stage2-plan.json new file mode 100644 index 0000000..c908aa4 --- /dev/null +++ b/docs/evidence/TEN-WP-0008-stage2-plan.json @@ -0,0 +1,87 @@ +{ + "actions": [], + "app": { + "criticality": "high", + "id": "tenant-engine", + "name": "tenant-engine", + "owner": "infotech", + "repo": "tenant-engine" + }, + "app_dir": "/home/worsch/tenant-engine", + "approval_id": null, + "canary_mode": "isolated", + "chart": "/home/worsch/tenant-engine/charts/tenant-engine", + "checks_expected": [ + "server-dry-run", + "canary-ready", + "canary-health", + "operator-approval" + ], + "contract": "/home/worsch/tenant-engine/railiance/app.toml", + "evidence_expected": [ + "server-side dry-run of isolated canary", + "deploy/tenant-engine-canary Available", + "canary /health 200", + "State Hub approval id" + ], + "generated_at": "2026-08-29T12:49:53Z", + "mode": "plan", + "namespace": "tenant-engine", + "observation_minutes": 15, + "planned_actions": [ + { + "action_ref": "stage2.helm", + "chart": "/home/worsch/tenant-engine/charts/tenant-engine", + "mode": "plan", + "namespace": "tenant-engine", + "release": "tenant-engine-canary", + "tool": "helm", + "values": "/home/worsch/tenant-engine/values/stage2-canary.yaml" + } + ], + "prechecks": [ + { + "name": "app.toml", + "required": true, + "status": "passed" + }, + { + "detail": "no Stage 2 helm check declared", + "name": "stage2-helm-check", + "required": true, + "status": "failed" + }, + { + "detail": "plan mode does not execute helm", + "name": "helm", + "required": false, + "status": "not_required" + }, + { + "name": "approval-id", + "required": false, + "status": "required_before_apply" + }, + { + "name": "stage1-result", + "required": false, + "status": "recommended_before_apply" + } + ], + "release": "tenant-engine-canary", + "requires_approval": true, + "schema_version": "railiance.stage2-deploy-result.v1", + "source": { + "artifact": "image", + "digest_policy": "required", + "revision": "git:main" + }, + "stage": "stage2", + "status": "blocked", + "summary": { + "actions_failed": 0, + "actions_total": 0, + "required_prechecks_failed": 1 + }, + "values": "/home/worsch/tenant-engine/values/stage2-canary.yaml" +} diff --git a/docs/evidence/TEN-WP-0008-stage3-plan.json b/docs/evidence/TEN-WP-0008-stage3-plan.json new file mode 100644 index 0000000..a885ec3 --- /dev/null +++ b/docs/evidence/TEN-WP-0008-stage3-plan.json @@ -0,0 +1,92 @@ +{ + "actions": [], + "app": { + "criticality": "high", + "id": "tenant-engine", + "name": "tenant-engine", + "owner": "infotech", + "repo": "tenant-engine" + }, + "app_dir": "/home/worsch/tenant-engine", + "approval_id": null, + "chart": "/home/worsch/tenant-engine/charts/tenant-engine", + "checks_expected": [ + "stage2-accepted", + "rollback-target", + "cluster-health", + "operator-approval" + ], + "contract": "/home/worsch/tenant-engine/railiance/app.toml", + "evidence_expected": [ + "promotion commit", + "new stable digest in previous_stable and deploy/base/tenant-engine.yaml", + "post-promotion make verify-pin" + ], + "generated_at": "2026-08-29T12:49:53Z", + "mode": "plan", + "namespace": "tenant-engine", + "planned_actions": [ + { + "action_ref": "stage3.helm-promote", + "chart": "/home/worsch/tenant-engine/charts/tenant-engine", + "namespace": "tenant-engine", + "release": "tenant-engine", + "tool": "helm", + "values": "/home/worsch/tenant-engine/values/stage3-production.yaml" + } + ], + "prechecks": [ + { + "name": "app.toml", + "required": true, + "status": "passed" + }, + { + "detail": "/home/worsch/tenant-engine/charts/tenant-engine", + "name": "stage3-chart", + "required": true, + "status": "failed" + }, + { + "detail": "/home/worsch/tenant-engine/values/stage3-production.yaml", + "name": "stage3-values", + "required": true, + "status": "failed" + }, + { + "detail": "Stage 3 must record the rollback target before promotion", + "name": "previous-stable", + "required": true, + "status": "passed" + }, + { + "detail": "plan mode does not execute helm", + "name": "helm", + "required": false, + "status": "not_required" + }, + { + "name": "approval-id", + "required": false, + "status": "required_before_apply" + } + ], + "previous_stable": "tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24", + "promotion_mode": "release-replace", + "release": "tenant-engine", + "requires_approval": true, + "schema_version": "railiance.stage3-promote-result.v1", + "source": { + "artifact": "image", + "digest_policy": "required", + "revision": "git:main" + }, + "stage": "stage3", + "status": "blocked", + "summary": { + "actions_failed": 0, + "actions_total": 0, + "required_prechecks_failed": 2 + }, + "values": "/home/worsch/tenant-engine/values/stage3-production.yaml" +} diff --git a/pyproject.toml b/pyproject.toml index 6e5e1a7..630bfe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "pytest>=8.2,<9.0", "ruff>=0.6,<1.0", "pyyaml>=6.0,<7.0", + "jsonschema>=4.0,<5.0", ] [project.scripts] diff --git a/railiance/README.md b/railiance/README.md new file mode 100644 index 0000000..51cdac3 --- /dev/null +++ b/railiance/README.md @@ -0,0 +1,70 @@ +# Railiance staged promotion — tenant-engine + +TEN-WP-0008 onboards this repo to `railiance.app.v1` without changing the +contract. Production is described, not redeployed. + +## T02 decision: kustomize, not Helm, not a dummy chart + +The schema accepts any stage `commands` and check `type`s including +`command` and `kubernetes`. This workload ships a multi-document +manifest, now rendered by `deploy/kustomization.yaml` (`kubectl apply -k +deploy/`). That is option 2 in TEN-WP-0008-T02: converting to kustomize is +cheap and matches the small-headless-service precedent +(`qonto-assistant`). + +What the schema does **not** require, and what `bin/railiance +deploy|promote --apply` still requires, is a Helm chart plus +`values/stage3-production.yaml`. Plan mode of those commands fails closed +on "no Stage 2 helm check declared" / missing chart. That is a CLI gap, +not a missing field in `app.toml`. Per the workplan we do not paper over +it with a chart we would never apply. Requested of `railiance-bootstrap` +as `RAIL-BS-IN-0001`. + +Stage commands in `app.toml` are therefore the kubectl invocations that +actually work against this shape. Stage 1 (`bin/railiance run`) validates +them by rendering both overlays locally. + +## Canary + +TEN-WP-0009 removed the ReadWriteOnce PVC and switched the Deployment to +`RollingUpdate`, so a second pod is storage-feasible. `canary_mode = +"isolated"` is a distinct Deployment/Service (`tenant-engine-canary`) +whose selector does not match the production Service. It does not take +`user-engine` traffic. Overlay: `deploy/canary/`. + +## Recorded stable (T03) + +`[stages.stage3].previous_stable` is the contract's copy of "what should +be running": + +``` +tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24 +``` + +That is the TEN-WP-0009 PostgreSQL runtime pin, the same digest +`deploy/base/tenant-engine.yaml` and `make verify-pin` use. Onboarding does +not ship a newer image. + +## Rollback ladder + +| Digest | Role | +| --- | --- | +| `a8e8086ffc…` | current stable (PostgreSQL) | +| `023ac49f5c…` | previous PostgreSQL image (leased-login ownership defect) | +| `7dc9d7bf1f…` | last SQLite/PVC image — **not** a rollback target after any PostgreSQL write | + +Older TEN-WP-0007 pins (`44ca65f3…` / `08be0b1d…` / `2249e8c6…`) remain +the lifecycle/guardrail feature ladder: rolling past a pre-lifecycle +digest removes routes `user-engine` depends on. + +## Secrets + +`secrets.references = []` is deliberate. Postgres URL and flex-auth +caller token are projected by those platforms from OpenBao. This repo +does not catalogue them. + +## Criticality + +`high`. Argument is in `[app].description`. Not `critical`: not +internet-facing, holds no application secret, fail-closed on outage. +Not `medium`: flex-auth and key-cape are live consumers. diff --git a/railiance/app.toml b/railiance/app.toml new file mode 100644 index 0000000..57dc952 --- /dev/null +++ b/railiance/app.toml @@ -0,0 +1,242 @@ +schema_version = "railiance.app.v1" + +# TEN-WP-0008. Modelled on qonto-assistant (small headless service) for +# stage commands, and on flex-auth for an empty secrets list and isolated +# canary. Stage 2/3 *commands* are kubectl against this repo's kustomize +# overlays. `bin/railiance deploy|promote --apply` currently requires a Helm +# chart the schema does not; that gap is requested of railiance-bootstrap +# rather than papered over with a dummy chart. See railiance/README.md. + +[app] +id = "tenant-engine" +name = "tenant-engine" +repo = "tenant-engine" +owner = "infotech" +criticality = "high" +description = """ +Canonical PIP for tenant-as-an-entity facts. flex-auth consults it live on +high-stakes decisions and key-cape consults it at token issuance. It holds +no application secrets and is not internet-facing, but an outage makes the +PDP fail closed fleet-wide. high, not critical: the PDP itself is high, +this is a data source whose unavailability is deny rather than an +unauthorized allow, and it does not hold a credential of its own. +""" + +[source] +revision = "git:main" +artifact = "image" +digest_policy = "required" + +[rollback] +strategy = "image-digest" +command = "kubectl -n tenant-engine rollout undo deploy/tenant-engine" +verification = """ +make verify-pin agrees repo, spec, and running pod; GET /health returns +200 with store_backend=postgresql; lifecycle routes still present. +Do not roll back to sha256:7dc9d7bf1fcbb3f3ad7035366f4cd44038322995a126b4423142e446ca5c9cd5 +(SQLite/PVC) after any PostgreSQL write. The TEN-WP-0007 ladder still +holds for older images: rolling past a pre-lifecycle digest removes +GET/PATCH/retire/reactivate that user-engine depends on. +""" + +[[platform.dependencies]] +name = "platform-pg" +kind = "postgres" +required = true +stage = "stage2" +evidence = "rapp-postgres consumer tenant-engine can reach platform-pg:5432; /health reports store_backend=postgresql." + +[[platform.dependencies]] +name = "flex-auth-tenant-engine" +kind = "identity" +required = true +stage = "stage2" +evidence = "http://flex-auth-tenant-engine.flex-auth.svc.cluster.local:8080/healthz returns 200." + +[[platform.dependencies]] +name = "forgejo-oci" +kind = "other" +required = true +stage = "stage2" +evidence = "Image forgejo.coulomb.social/coulomb/tenant-engine is pullable by digest." + +[[platform.dependencies]] +name = "railiance01-k3s" +kind = "network" +required = true +stage = "stage2" +evidence = "kubectl -n tenant-engine get ns reports Active on railiance01." + +[secrets] +# Runtime secret custody is OpenBao's. Postgres URL and flex-auth caller +# token are projected files owned by those platforms, not entries this +# repo catalogues. An empty list is the correct answer (TEN-WP-0008-T01). +references = [] + +[[observability.health_endpoints]] +name = "local-health" +url = "http://127.0.0.1:8090/health" +stage = "stage1" +expected_status = 200 + +[[observability.health_endpoints]] +name = "cluster-health" +url = "http://tenant-engine.tenant-engine.svc.cluster.local:8090/health" +stage = "stage2" +expected_status = 200 + +[[observability.health_endpoints]] +name = "canary-health" +url = "http://tenant-engine-canary.tenant-engine.svc.cluster.local:8090/health" +stage = "stage2" +expected_status = 200 + +[stages.stage1] +enabled = true +namespace = "local" +release = "tenant-engine-local" +commands = [ + "make test", + "make validate-app-toml", + "kubectl kustomize deploy", + "kubectl kustomize deploy/canary", +] +checks = ["unit-tests", "contract-schema", "kustomize-production", "kustomize-canary"] +evidence = ["pytest output", "app.toml schema validation", "kustomize render of deploy/ and deploy/canary"] +requires_approval = false + +[stages.stage2] +enabled = true +namespace = "tenant-engine" +release = "tenant-engine-canary" +commands = [ + "kubectl apply -k deploy/canary --dry-run=server", + "kubectl apply -k deploy/canary", +] +checks = ["server-dry-run", "canary-ready", "canary-health", "operator-approval"] +evidence = ["server-side dry-run of isolated canary", "deploy/tenant-engine-canary Available", "canary /health 200", "State Hub approval id"] +requires_approval = true +canary_mode = "isolated" +observation_minutes = 15 + +[stages.stage3] +enabled = true +namespace = "tenant-engine" +release = "tenant-engine" +commands = [ + "kubectl apply -k deploy --dry-run=server", + "kubectl apply -k deploy", +] +checks = ["stage2-accepted", "rollback-target", "cluster-health", "operator-approval"] +evidence = ["promotion commit", "new stable digest in previous_stable and deploy/base/tenant-engine.yaml", "post-promotion make verify-pin"] +requires_approval = true +promotion_mode = "release-replace" +# TEN-WP-0008-T03: the contract's recorded stable, not only the manifest pin. +# Current production (TEN-WP-0009 PostgreSQL runtime), CI-built from d2a7fe3. +previous_stable = "tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24" + +[[checks]] +id = "unit-tests" +type = "command" +stage = "stage1" +description = "Run repository unit tests." +required = true +run = "make test" +timeout_seconds = 600 + +[[checks]] +id = "contract-schema" +type = "command" +stage = "stage1" +description = "Validate railiance/app.toml against railiance.app.v1." +required = true +run = "make validate-app-toml" +timeout_seconds = 60 + +[[checks]] +id = "kustomize-production" +type = "command" +stage = "stage1" +description = "Render the production kustomization locally, no cluster contact." +required = true +run = "kubectl kustomize deploy" + +[[checks]] +id = "kustomize-canary" +type = "command" +stage = "stage1" +description = "Render the isolated canary kustomization locally, no cluster contact." +required = true +run = "kubectl kustomize deploy/canary" + +[[checks]] +id = "local-health" +type = "http" +stage = "stage1" +description = "Confirm a locally served /health when one is running." +required = false +url = "http://127.0.0.1:8090/health" +expected_status = 200 +timeout_seconds = 10 + +[[checks]] +id = "server-dry-run" +type = "command" +stage = "stage2" +description = "Server-side dry run of the isolated canary overlay before apply." +required = true +run = "kubectl apply -k deploy/canary --dry-run=server" + +[[checks]] +id = "canary-ready" +type = "kubernetes" +stage = "stage2" +description = "Isolated canary Deployment reaches Available." +required = true +namespace = "tenant-engine" +resource = "deploy/tenant-engine-canary" +condition = "Available" + +[[checks]] +id = "canary-health" +type = "http" +stage = "stage2" +description = "Isolated canary Service /health returns 200." +required = true +url = "http://tenant-engine-canary.tenant-engine.svc.cluster.local:8090/health" +expected_status = 200 +timeout_seconds = 10 + +[[checks]] +id = "cluster-health" +type = "http" +stage = "stage2" +description = "Production Service /health returns 200." +required = true +url = "http://tenant-engine.tenant-engine.svc.cluster.local:8090/health" +expected_status = 200 +timeout_seconds = 10 + +[[checks]] +id = "operator-approval" +type = "manual" +stage = "stage2" +description = "Human approval recorded before the isolated canary is applied and before the production pin moves." +required = true +evidence_required = "State Hub approval note id, candidate digest, rollback target (previous_stable)." + +[[checks]] +id = "stage2-accepted" +type = "manual" +stage = "stage3" +description = "Stage 2 gates passed for the same candidate artifact." +required = true +evidence_required = "State Hub Stage 2 acceptance progress id." + +[[checks]] +id = "rollback-target" +type = "manual" +stage = "stage3" +description = "Previous stable digest is recorded in previous_stable before promotion." +required = true +evidence_required = "tenant-engine image digest currently in previous_stable and deploy/base/tenant-engine.yaml." diff --git a/tests/test_staged_promotion.py b/tests/test_staged_promotion.py new file mode 100644 index 0000000..6d6d11a --- /dev/null +++ b/tests/test_staged_promotion.py @@ -0,0 +1,67 @@ +"""TEN-WP-0008: app.toml contract and kustomize overlays.""" + +from __future__ import annotations + +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +STABLE = ( + "sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24" +) + + +def test_app_toml_validates_against_railiance_app_v1() -> None: + pytest.importorskip("jsonschema") + result = subprocess.run( + [sys.executable, str(ROOT / "tests" / "validate_app_toml.py")], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + result.stdout + + +def test_previous_stable_matches_the_manifest_pin() -> None: + contract = tomllib.loads((ROOT / "railiance" / "app.toml").read_text()) + assert contract["stages"]["stage3"]["previous_stable"].endswith(STABLE) + manifest = (ROOT / "deploy" / "base" / "tenant-engine.yaml").read_text() + assert STABLE in manifest + assert contract["secrets"]["references"] == [] + assert contract["app"]["criticality"] == "high" + assert contract["stages"]["stage2"]["canary_mode"] == "isolated" + + +def _kustomize(path: str) -> str: + if subprocess.run(["kubectl", "version", "--client"], capture_output=True).returncode != 0: + pytest.skip("kubectl is required to render kustomize overlays") + result = subprocess.run( + ["kubectl", "kustomize", path], + cwd=ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + return result.stdout + + +def test_production_kustomize_keeps_the_stable_name() -> None: + rendered = _kustomize("deploy") + assert "name: tenant-engine-canary" not in rendered + assert STABLE in rendered + assert "kind: Deployment" in rendered + + +def test_canary_kustomize_is_isolated_from_production_selector() -> None: + rendered = _kustomize("deploy/canary") + docs = [doc for doc in rendered.split("---") if doc.strip()] + service = next(doc for doc in docs if "kind: Service" in doc and "tenant-engine-canary" in doc) + assert "app.kubernetes.io/name: tenant-engine-canary" in service + deploy = next(doc for doc in docs if "kind: Deployment" in doc) + assert "name: tenant-engine-canary" in deploy + assert "app.kubernetes.io/name: tenant-engine-canary" in deploy + assert STABLE in deploy diff --git a/tests/validate_app_toml.py b/tests/validate_app_toml.py new file mode 100644 index 0000000..216f6e5 --- /dev/null +++ b/tests/validate_app_toml.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Validate railiance/app.toml against the railiance.app.v1 schema.""" + +from __future__ import annotations + +import json +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "railiance" / "app.toml" +SCHEMA_CANDIDATES = [ + Path.home() / "railiance-bootstrap" / "schemas" / "railiance-app.schema.json", + Path.home() / "railiance-cluster" / "schemas" / "railiance-app.schema.json", +] + + +def main() -> int: + schema_path = next((path for path in SCHEMA_CANDIDATES if path.exists()), None) + if schema_path is None: + print( + "railiance-app.schema.json not found in railiance-bootstrap or railiance-cluster", + file=sys.stderr, + ) + return 1 + try: + import jsonschema + except ImportError: + print("jsonschema is required to validate railiance/app.toml", file=sys.stderr) + return 1 + + document = tomllib.loads(CONTRACT.read_text()) + schema = json.loads(schema_path.read_text()) + jsonschema.validate(document, schema) + print(f"valid {CONTRACT.relative_to(ROOT)} against {schema_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 7881b2a..286b1fe 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -169,6 +178,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "packaging" version = "26.3" @@ -431,6 +467,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 }, +] + [[package]] name = "ruff" version = "0.16.3" @@ -481,6 +627,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "jsonschema" }, { name = "pytest" }, { name = "pyyaml" }, { name = "ruff" }, @@ -494,6 +641,7 @@ postgres = [ requires-dist = [ { name = "fastapi", specifier = ">=0.115,<1.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.0,<5.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" }, { name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.2,<4.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2,<9.0" }, diff --git a/workplans/TEN-WP-0008-staged-promotion-onboarding.md b/workplans/TEN-WP-0008-staged-promotion-onboarding.md index e61cac7..8770201 100644 --- a/workplans/TEN-WP-0008-staged-promotion-onboarding.md +++ b/workplans/TEN-WP-0008-staged-promotion-onboarding.md @@ -4,11 +4,11 @@ type: workplan title: "Bring tenant-engine under the staged-promotion contract" domain: infotech repo: tenant-engine -status: ready -owner: claude +status: finished +owner: grok topic_slug: tenant-guardrails created: "2026-08-16" -updated: "2026-08-21" +updated: "2026-08-29" depends_on: - TEN-WP-0007 unblocks: [] @@ -59,7 +59,7 @@ is a `railiance-bootstrap` decision to request, not one to make here. ```task id: TEN-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "0231e4d1-2e1a-4e77-92ba-2cfaf23692ab" ``` @@ -95,11 +95,16 @@ result as non-secret evidence. Done when the file validates against the schema and stage 1 passes in plan mode. +**Done 2026-08-29:** `railiance/app.toml` validates; `bin/railiance run .` +passed (`docs/evidence/TEN-WP-0008-stage1-result.json`). criticality=high +(argument in `[app].description`); `secrets.references = []`; `/health` on +8090; rollback strategy `image-digest`. + ## T02 - Reconcile the deployment shape with the contract ```task id: TEN-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "5f2ad011-1de8-45df-8e8f-dd6bcf51ece4" ``` @@ -138,11 +143,18 @@ contract still owns the exact canary mode and promotion commands. Current production digest: `sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24`. +**Done 2026-08-29:** option 2 — kustomize wrap (`deploy/` production, +`deploy/canary/` isolated). Stage commands are `kubectl apply -k`, verified +by Stage 1 render. `bin/railiance deploy|promote --plan` still requires +Helm; not papered over. Requested as `RAIL-BS-IN-0001`. Evidence: +`docs/evidence/TEN-WP-0008-stage2-plan.json`, +`docs/evidence/TEN-WP-0008-stage3-plan.json`. + ## T03 - Record current production as the stable baseline ```task id: TEN-WP-0008-T03 -status: todo +status: done priority: medium state_hub_task_id: "c4b79dc1-c280-428e-9dc5-b17c05b084c6" ``` @@ -156,11 +168,17 @@ copies of the truth rather than one. Done when the stable digest is recorded through the contract's own mechanism rather than only in `deploy/tenant-engine.yaml`. +**Done 2026-08-29:** `[stages.stage3].previous_stable = +tenant-engine@sha256:a8e8086ffc5b772c1391b166f5e1884b90f7d327b152c205eceae129df555c24` +(current PostgreSQL runtime, same pin `make verify-pin` reads). Stage 3 +plan-mode precheck `previous-stable` passed. The T03 draft's `44ca65f3…` +pin was stale after TEN-WP-0009. + ## T04 - Hand the pattern back to the fleet ```task id: TEN-WP-0008-T04 -status: todo +status: done priority: low state_hub_task_id: "b816a862-51e0-4abd-a2f3-ee40359526cc" ``` @@ -176,11 +194,16 @@ independently. Done when the note is sent. +**Done 2026-08-29:** note to `flex-auth` (cost of onboarding a non-Helm +workload) and `RAIL-BS-IN-0001` plus a State Hub message to +`railiance-bootstrap`. + ## Out of scope - **Changing the staged-promotion contract, CLI, or schema.** Request, do not implement. - **Building a drift-detection service.** The point of this workplan is that staged promotion should make one unnecessary. -- **Re-deploying.** Production is healthy on `44ca65f3…`; onboarding describes - what is already running, it does not ship anything new. +- **Re-deploying.** Production is healthy on `a8e8086ffc…` (TEN-WP-0009); + onboarding describes what is already running, it does not ship TEN-WP-0011 + or any newer image.