From 16ca97b2776789f730cc7049a2d6533612faaccd Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 23:25:19 +0200 Subject: [PATCH] Complete renamed image source and verify bounded consumer migration readiness Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- .forgejo/workflows/image.yaml | 25 +++- Containerfile | 4 +- Containerfile.lifecycle | 9 +- decisions/decisions.md | 29 ++++ .../2026-10-11-runtime-source/validation.json | 42 ++++++ docs/runtime-rename-inventory.md | 29 ++++ internal/adapters/topaz/integration_test.go | 8 +- tests/stage1.sh | 1 + tests/test_runtime_consumer_migration.py | 69 +++++++++ tests/test_runtime_publication.py | 53 +++++++ tools/publish-runtime-images.sh | 37 +++++ tools/verify_runtime_consumer_migration.py | 139 ++++++++++++++++++ ...EX-WP-0036-runtime-rename-access-engine.md | 29 +++- 13 files changed, 455 insertions(+), 19 deletions(-) create mode 100644 docs/evidence/2026-10-11-runtime-source/validation.json create mode 100644 tests/test_runtime_consumer_migration.py create mode 100644 tests/test_runtime_publication.py create mode 100644 tools/publish-runtime-images.sh create mode 100644 tools/verify_runtime_consumer_migration.py diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml index 29aa359..3ce0b14 100644 --- a/.forgejo/workflows/image.yaml +++ b/.forgejo/workflows/image.yaml @@ -22,7 +22,14 @@ on: - "examples/**" - "go.mod" - "go.sum" + - "tools/publish-runtime-images.sh" workflow_dispatch: + inputs: + publish_runtime_rename: + description: "Publish both packages after the exact new public-package scope is accepted" + type: boolean + required: false + default: false env: REGISTRY: forgejo.coulomb.social @@ -39,7 +46,8 @@ jobs: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu - REF="${GITHUB_SHA:-main}" + REF="${GITHUB_SHA:?full source revision required}" + [[ "$REF" =~ ^[a-f0-9]{40}$ ]] || exit 2 SHORT="${REF:0:7}" mkdir -p buildctx "${HOME}/bin" wget -qO /tmp/repo.tar.gz \ @@ -49,11 +57,13 @@ jobs: | tar xz --strip-components=1 -C "${HOME}/bin" docker/docker export PATH="${HOME}/bin:${PATH}" echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin - IMAGE="${REGISTRY}/${IMAGE_NAME}" - docker build -f buildctx/Containerfile -t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" buildctx - docker push "${IMAGE}:latest" - docker push "${IMAGE}:main-${SHORT}" - echo "pushed ${IMAGE}:latest and ${IMAGE}:main-${SHORT}" + # Pushes keep the already admitted legacy package. Paired publication + # is an explicit owner dispatch after the daily red-batch disposition. + MODE=legacy + if [ "${{ github.event_name }}" = workflow_dispatch ] && [ "${{ inputs.publish_runtime_rename }}" = true ]; then + MODE=paired + fi + bash buildctx/tools/publish-runtime-images.sh "$REF" "$MODE" buildctx - name: Report immutable digest run: | @@ -61,5 +71,6 @@ jobs: export PATH="${HOME}/bin:${PATH}" IMAGE="${REGISTRY}/${IMAGE_NAME}" SHORT="${GITHUB_SHA:0:7}" - # Deployments pin by digest, never by tag -- print it for the rollout step. + # Legacy package is available on ordinary push; paired digest verification + # runs in the publisher before an owner can switch any pin. docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:main-${SHORT}" diff --git a/Containerfile b/Containerfile index 8c36542..f42a559 100644 --- a/Containerfile +++ b/Containerfile @@ -9,7 +9,9 @@ FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c COPY --from=build /out/access-engine /usr/local/bin/access-engine # Retired name: prints the rename and exits 2 (FLEX-DEC-2026-020). COPY --from=build /out/flex-auth /usr/local/bin/flex-auth +COPY examples /opt/access-engine/examples +# Existing pinned releases still pass these paths during the side-by-side migration. COPY examples /opt/flex-auth/examples EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/access-engine"] -CMD ["serve", "--addr", "0.0.0.0:8080", "--registry", "/opt/flex-auth/examples/tenant-engine/registry_snapshot.json", "--policy", "/opt/flex-auth/examples/tenant-engine/policy_package.md"] +CMD ["serve", "--addr", "0.0.0.0:8080", "--registry", "/opt/access-engine/examples/tenant-engine/registry_snapshot.json", "--policy", "/opt/access-engine/examples/tenant-engine/policy_package.md"] diff --git a/Containerfile.lifecycle b/Containerfile.lifecycle index d510d4d..ff7c159 100644 --- a/Containerfile.lifecycle +++ b/Containerfile.lifecycle @@ -4,11 +4,14 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/flex-auth ./cmd/flex-auth +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/access-engine ./cmd/access-engine && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/flex-auth ./cmd/flex-auth FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab +COPY --from=build /out/access-engine /usr/local/bin/access-engine COPY --from=build /out/flex-auth /usr/local/bin/flex-auth +COPY examples /opt/access-engine/examples +# Existing pinned releases still pass these paths during the side-by-side migration. COPY examples /opt/flex-auth/examples EXPOSE 8080 -ENTRYPOINT ["/usr/local/bin/flex-auth"] -CMD ["serve", "--addr", "0.0.0.0:8080", "--registry", "/opt/flex-auth/examples/tenant-engine/registry_snapshot.json", "--policy", "/opt/flex-auth/examples/tenant-engine/policy_package.md"] +ENTRYPOINT ["/usr/local/bin/access-engine"] +CMD ["serve", "--addr", "0.0.0.0:8080", "--registry", "/opt/access-engine/examples/tenant-engine/registry_snapshot.json", "--policy", "/opt/access-engine/examples/tenant-engine/policy_package.md"] diff --git a/decisions/decisions.md b/decisions/decisions.md index ee9b93e..080a7d8 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -2145,3 +2145,32 @@ alias would keep the old name in use, and the rename exists to end that. `/opt/flex-auth` image paths, the caller audience, Kubernetes names and the image package. They stay with `FLEX-WP-0036` T03 to T05 and their own order (`FLEX-DEC-2026-015`). + +--- + +## FLEX-DEC-2026-021 — Runtime target names and bounded side-by-side migration + +**Date:** 2026-10-11 +**Status:** proposed; exact scope in the single daily red batch +**Workplan:** `FLEX-WP-0036-T01` +**Supersedes on acceptance:** runtime rows of `FLEX-DEC-2026-013`. + +The proposed namespace and chart are `access-engine`; pin release, Service, +Deployment and reviewer ServiceAccount names are `access-engine-`, preserving +the nine existing pin suffixes. DNS is +`access-engine-.access-engine.svc.cluster.local:8080`. The caller audience is +`access-engine`, with exact legacy `flex-auth` acceptance during migration. The +engine's own resource-system coordinate is `access-engine`; protected consumers +retain their existing identities and grants. No PDP-self resource lane is created. + +The binary ruling remains the accepted FLEX-DEC-2026-020: `access-engine` is +the real binary and `flex-auth` is an exit-2 rename stub. Canonical image paths +are `/opt/access-engine/examples`; legacy paths remain during consumer migration. +The image is published as `coulomb/access-engine` and as `coulomb/flex-auth` from +one build, independently verifying equal registry manifest digests. New public +package publication and the nine new TokenReview subjects remain unexecuted +until the exact batch disposition; this source record does not approve them. + +Switch consumers individually only after predecessor proofs and side-by-side +allow/deny checks. Remove old egress/grants only after bounded old-twin traffic +and soak evidence; rollback retains the original pins and consumer access. diff --git a/docs/evidence/2026-10-11-runtime-source/validation.json b/docs/evidence/2026-10-11-runtime-source/validation.json new file mode 100644 index 0000000..c70583f --- /dev/null +++ b/docs/evidence/2026-10-11-runtime-source/validation.json @@ -0,0 +1,42 @@ +{ + "schema": "access-runtime-source-validation/v1", + "task": "FLEX-WP-0036-T04", + "source_sha256": { + "Containerfile": "85094ed869bbcd24a8b1fadff46038c891fef2d2b2f20b2689a59004b94d19a9", + "Containerfile.lifecycle": "248a36de72c49513dac478367863610a54a1499c22f5505a1cc08a0565eb050d", + ".forgejo/workflows/image.yaml": "af7768f4c223b9151060bf1c61d2bc22a09dfe5ad52349409545135660108e27", + "tools/publish-runtime-images.sh": "796f2d402a5d118879f45f47e762004602c612ccf783fc6c60e51ee7f16bca5a", + "internal/adapters/topaz/integration_test.go": "4b506ff3d27f8ad36f4a7d3d2a263e959be32d14857321cfcfd54c9899c702e6" + }, + "local_image_tag": "custodian-access-runtime-t04:20261011", + "local_build_passed": true, + "policy_path_checks": [ + { + "path": "/opt/access-engine/examples/tenant-engine/policy_package.md", + "exit": 0, + "valid": true, + "checks": 75 + }, + { + "path": "/opt/flex-auth/examples/tenant-engine/policy_package.md", + "exit": 0, + "valid": true, + "checks": 75 + } + ], + "legacy_stub_exit": 2, + "legacy_stub_rename_message": true, + "publisher_failure_and_scope_tests": 5, + "go_suite_passed": true, + "registry_publication_proven": false, + "native_rollout": false, + "preparation_failures": [ + { + "stage": "local validation invocation", + "exit": 64, + "reason": "incorrect registry/policy flags for validate command", + "recovery": "used documented -kind policy -file; both paths passed" + } + ], + "oci_index_handling": "Registry verification uses independent pull and exact repository RepoDigest; retained build provenance/index. No Buildx plugin dependency in publisher." +} diff --git a/docs/runtime-rename-inventory.md b/docs/runtime-rename-inventory.md index 7ca4dd7..7ac56f9 100644 --- a/docs/runtime-rename-inventory.md +++ b/docs/runtime-rename-inventory.md @@ -106,3 +106,32 @@ Live pins: `flex-auth-informed-decision-lifecycle`, `-sitting`, `-t03`. Requested after T01 fixes the target names, so each owner gets one message with the exact new URL and audience: `ops-warden`, `secrets-engine`, `user-engine` with `net-kingdom`, `tenant-engine`, and `informed-decision`. + + +## Deterministic consumer readiness check (2026-10-11, T06) + +Run `python3 tools/verify_runtime_consumer_migration.py --estate-root /home/worsch`. +The class parses only the bounded public inventory source paths as YAML/JSON, +TOML or Python literal strings, excludes comments and records file SHA256s. +It emits exact old/new pin origins, source-owner handoff responsibilities and +source migration phases. It never marks native readiness or closes a task. +Unsigned completion flags, source URL strings and a missing log are not live +proof. Operator configuration, ops-bridge ClusterIP tunnels and Secrets CLI +runtime selection remain owner evidence requirements; no private operator +configuration or credentials are read by this tool. + +The 2026-10-11 re-derivation found remaining owner source work for ops-warden, +secrets-engine, user-engine with net-kingdom, tenant-engine and informed-decision. +No new endpoint appeared in these bounded files. The five owner handoffs are +prepared by the report, not sent or claimed acknowledged. Source owners first +retain old egress while adding the new namespace, then change one consumer's +endpoint and projected audience after T02/T05 and ordered live prerequisites. +Each new pin needs authenticated allow/deny and exact deployed revision/digest +correspondence; old-twin logs need an explicit observation interval and +no-traffic/soak evidence before old egress removal or T07 retirement. + +The earlier `flex-auth-policy-check` catalogue example is a policy routing lane, +not a secret-fetch package lane. Its naming migration does not authorize adding +PDP-self resource systems to packages that govern other protected consumers. +T05's nine new TokenReview ServiceAccount grants are a separate red daily-batch +item. This report does not authorize those grants or the consumer changes. diff --git a/internal/adapters/topaz/integration_test.go b/internal/adapters/topaz/integration_test.go index 26da344..8d8db28 100644 --- a/internal/adapters/topaz/integration_test.go +++ b/internal/adapters/topaz/integration_test.go @@ -8,10 +8,12 @@ import ( ) func TestTopazDockerComposeExample(t *testing.T) { - if os.Getenv("FLEX_AUTH_RUN_TOPAZ_INTEGRATION") == "1" { - t.Skip("FLEX_AUTH_RUN_TOPAZ_INTEGRATION was renamed: set ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION=1 (FLEX-WP-0036)") + enabled := os.Getenv("ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION") + if enabled == "" && os.Getenv("FLEX_AUTH_RUN_TOPAZ_INTEGRATION") == "1" { + t.Log("FLEX_AUTH_RUN_TOPAZ_INTEGRATION is deprecated; use ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION=1 (FLEX-WP-0036)") + enabled = "1" } - if os.Getenv("ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION") != "1" { + if enabled != "1" { t.Skip("set ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION=1 to run the examples/topaz docker-compose integration") } diff --git a/tests/stage1.sh b/tests/stage1.sh index cec1ee2..4e3bd57 100755 --- a/tests/stage1.sh +++ b/tests/stage1.sh @@ -8,6 +8,7 @@ python3 tests/validate_app_toml.py python3 tests/test_outbox_chart.py python3 tools/gen_runtime_twins.py --check python3 tests/test_runtime_twins.py +python3 tests/test_runtime_consumer_migration.py for values in values/stage1.yaml values/stage2-canary.yaml values/stage3-production.yaml \ values/user-engine.yaml values/tenant-engine.yaml values/ops-warden.yaml; do diff --git a/tests/test_runtime_consumer_migration.py b/tests/test_runtime_consumer_migration.py new file mode 100644 index 0000000..0dfb72e --- /dev/null +++ b/tests/test_runtime_consumer_migration.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("migration", ROOT / "tools/verify_runtime_consumer_migration.py") +migration = importlib.util.module_from_spec(spec); sys.modules[spec.name] = migration; spec.loader.exec_module(migration) + +class MigrationTests(unittest.TestCase): + def classify(self, text, network=True): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); (root / "owner").mkdir(); (root / "owner/runtime.yaml").write_text(text) + consumer = migration.Consumer("owner", ("owner",), ("example",), ("owner/runtime.yaml",), network) + return migration.MigrationReadiness(root).classify(consumer) + + def test_ordering_and_no_native_admission(self): + old = "url: http://flex-auth-example.flex-auth.svc.cluster.local:8080\naudience: flex-auth\nkind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: flex-auth}}}]}]}\n" + new = "new: http://access-engine-example.access-engine.svc.cluster.local:8080\naudience: access-engine\nkind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: access-engine}}}]}]}\n" + self.assertEqual(self.classify(old)["phase"], "NEEDS_OWNER_SOURCE_WORK") + self.assertEqual(self.classify(old+"---\n"+new)["phase"], "SOURCE_DUAL_PREPARATION_REVIEW_REQUIRED") + switched = self.classify(new + "native_ready: true\nT05_verified: true\n") + self.assertEqual(switched["phase"], "SOURCE_SWITCHED_NATIVE_PROOF_REQUIRED") + self.assertFalse(switched["native_ready"]) + bad_order = self.classify(old.replace("kind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: flex-auth}}}]}]}\n", "") + "---\n" + new) + self.assertEqual(bad_order["phase"], "NEEDS_OWNER_SOURCE_WORK") + self.assertTrue(any("unsafe migration order" in r for r in bad_order["reasons"])) + + def test_exact_hosts_and_ports(self): + for origin in ("http://access-engine-example.access-engine.svc.cluster.local.evil:8080", "http://access-engine-example.access-engine.svc.cluster.local:9000", "http://access-engine-example.access-engine.svc.cluster.local:bad", "http://[malformed-template", "http://flex-auth-example.flex-auth.svc.cluster.local:8080"): + with self.subTest(origin=origin): + got = self.classify("url: " + origin+"\naudience: access-engine\nkind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: access-engine}}}]}]}\n") + self.assertEqual(got["new_endpoint_pins"], []) + self.assertEqual(got["phase"], "NEEDS_OWNER_SOURCE_WORK") + + def test_unrelated_namespace_label_is_not_egress(self): + got = self.classify("url: http://access-engine-example.access-engine.svc.cluster.local:8080\naudience: access-engine\nmetadata: {labels: {kubernetes.io/metadata.name: access-engine}}\n") + self.assertFalse(got["egress_namespace"]["new"]) + self.assertEqual(got["phase"], "NEEDS_OWNER_SOURCE_WORK") + + def test_comments_cannot_supply_migration_facts(self): + got = self.classify("# url: http://access-engine-example.access-engine.svc.cluster.local:8080\n# audience: access-engine\n# kind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: access-engine}}}]}]}\n") + self.assertEqual(got["new_endpoint_pins"], []) + self.assertFalse(got["audience"]["new"]) + self.assertFalse(got["egress_namespace"]["new"]) + + def test_env_names_are_not_runtime_audiences(self): + got = self.classify("url: http://access-engine-example.access-engine.svc.cluster.local:8080\nOWNER_ACCESS_ENGINE_URL: x\nmount: /var/run/secrets/access-engine-caller/token\nkind: NetworkPolicy\nspec: {egress: [{to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: access-engine}}}]}]}\n") + self.assertFalse(got["audience"]["new"]) + self.assertEqual(got["phase"], "NEEDS_OWNER_SOURCE_WORK") + + def test_missing_and_symlink_sources_refuse(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp); c=migration.Consumer("owner", ("owner",), ("example",), ("runtime.yaml",), True) + got=migration.MigrationReadiness(root).classify(c) + self.assertEqual(got["sources"][0]["state"], "missing") + self.assertFalse(got["native_ready"]) + (root/"runtime.yaml").symlink_to("/etc/hosts") + with self.assertRaises(ValueError): migration.MigrationReadiness(root).classify(c) + + def test_deterministic_output_and_no_source_values_leak(self): + got=self.classify("secret: TEST_ONLY_MUST_NOT_EMIT\nurl: http://flex-auth-example.flex-auth.svc:8080\naudience: flex-auth\n") + self.assertNotIn("TEST_ONLY_MUST_NOT_EMIT", str(got)) + self.assertEqual(len(got["sources"][0]["sha256"]), 64) + self.assertEqual(got["old_endpoint_pins"], ["example"]) + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_publication.py b/tests/test_runtime_publication.py new file mode 100644 index 0000000..04dbcea --- /dev/null +++ b/tests/test_runtime_publication.py @@ -0,0 +1,53 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +class PublicationTests(unittest.TestCase): + def run_publisher(self, mode='paired', revision='a' * 40, mismatch=False, array=False): + with tempfile.TemporaryDirectory() as folder: + base=Path(folder); log=base/'commands.jsonl' + docker=base/'docker' + docker.write_text('''#!/usr/bin/env python3 +import json,os,sys +with open(os.environ['COMMAND_LOG'],'a') as f:f.write(json.dumps(sys.argv[1:])+'\\n') +if sys.argv[1:3]==['image','inspect']: + digest='sha256:'+('b' if os.environ.get('MISMATCH')=='1' and '/access-engine:' in sys.argv[-1] else 'a')*64 + payload=[sys.argv[-1].split(':main-')[0]+'@'+digest] + print(json.dumps({'invalid':'response'} if os.environ.get('ARRAY')=='1' else payload)) +''');docker.chmod(0o755) + result=subprocess.run(['bash',str(ROOT/'tools/publish-runtime-images.sh'),revision,mode,'.'],capture_output=True,text=True,env={**os.environ,'PATH':str(base)+':'+os.environ['PATH'],'COMMAND_LOG':str(log),'MISMATCH':str(int(mismatch)),'ARRAY':str(int(array))}) + calls=[json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + return result,calls + + def test_paired_builds_once_and_verifies_both_registry_digests(self): + result,calls=self.run_publisher();self.assertEqual(result.returncode,0,result.stderr) + self.assertEqual(sum(c[0]=='build' for c in calls),1) + pushes=[c[1] for c in calls if c[0]=='push'] + self.assertEqual(set(pushes),{f'forgejo.coulomb.social/coulomb/{name}:{tag}' for name in ('access-engine','flex-auth') for tag in ('latest','main-aaaaaaa')}) + self.assertEqual(sum(c[:2]==['image','inspect'] for c in calls),2) + self.assertEqual(sum(c[0]=='pull' for c in calls),2) + self.assertIn('manifest_digest=sha256:',result.stdout) + self.assertFalse(any(c[0]=='login' for c in calls)) + + def test_normal_push_cannot_publish_new_package(self): + result,calls=self.run_publisher('legacy');self.assertEqual(result.returncode,0) + self.assertFalse(any('coulomb/access-engine' in arg for call in calls for arg in call)) + + def test_different_registry_digest_refuses_success(self): + result,calls=self.run_publisher(mismatch=True);self.assertNotEqual(result.returncode,0) + self.assertNotIn('verified_repositories=',result.stdout) + + def test_unexpected_inspection_response_refuses_success(self): + result,_=self.run_publisher(array=True);self.assertNotEqual(result.returncode,0) + + def test_invalid_mode_or_mutable_revision_stops_before_docker(self): + for mode,revision in [('other','a'*40),('paired','main'),('paired','a'*7)]: + with self.subTest(mode=mode,revision=revision): + result,calls=self.run_publisher(mode,revision);self.assertNotEqual(result.returncode,0);self.assertEqual(calls,[]) + +if __name__=='__main__':unittest.main() diff --git a/tools/publish-runtime-images.sh b/tools/publish-runtime-images.sh new file mode 100644 index 0000000..c9337e0 --- /dev/null +++ b/tools/publish-runtime-images.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Uses the enclosing Forge runner's existing registry login. No credential reads. +set -euo pipefail +revision=${1:?full source revision required} +mode=${2:-legacy} +context=${3:-.} +[[ "$revision" =~ ^[a-f0-9]{40}$ ]] || { echo 'full immutable source revision required' >&2; exit 2; } +[[ "$mode" == legacy || "$mode" == paired ]] || { echo 'unknown publication mode' >&2; exit 2; } +short=${revision:0:7} +registry=forgejo.coulomb.social +legacy="$registry/coulomb/flex-auth" +renamed="$registry/coulomb/access-engine" +docker build -f "$context/Containerfile" --label "org.opencontainers.image.revision=$revision" -t "$legacy:main-$short" "$context" +images=("$legacy") +if [[ "$mode" == paired ]]; then + docker tag "$legacy:main-$short" "$renamed:main-$short" + images+=("$renamed") +fi +for image in "${images[@]}"; do + docker tag "$image:main-$short" "$image:latest" + docker push "$image:main-$short" + docker push "$image:latest" +done +# Pull each published immutable tag through the runner's existing registry login. +# RepoDigests then records the registry's top-level digest, including OCI indexes +# and attestations; comparing a local image/config ID would not prove publication. +digests=() +for image in "${images[@]}"; do + docker pull "$image:main-$short" + raw=$(docker image inspect --format '{{json .RepoDigests}}' "$image:main-$short") + digest=$(printf '%s' "$raw" | python3 -c 'import json,re,sys; x=json.load(sys.stdin); assert isinstance(x,list); matches=[v.removeprefix(sys.argv[1]+"@") for v in x if isinstance(v,str) and v.startswith(sys.argv[1]+"@")]; assert len(matches)==1 and re.fullmatch(r"sha256:[a-f0-9]{64}",matches[0]); print(matches[0])' "$image") + digests+=("$digest") +done +if [[ "$mode" == paired && "${digests[0]}" != "${digests[1]}" ]]; then + echo 'published registry manifest digests differ' >&2; exit 1 +fi +printf 'publication mode=%s revision=%s manifest_digest=%s verified_repositories=%s\n' "$mode" "$revision" "${digests[0]}" "${#images[@]}" diff --git a/tools/verify_runtime_consumer_migration.py b/tools/verify_runtime_consumer_migration.py new file mode 100644 index 0000000..695dd4f --- /dev/null +++ b/tools/verify_runtime_consumer_migration.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Deterministic T06 source classification. No credentials, network or mutation. + +This reports source preparation, NEVER native readiness or workplan closure. +Owner-authenticated live allow/deny, predecessor and no-traffic/soak evidence +must be independently collected and reviewed by the migration coordinator. +""" +from dataclasses import dataclass +from pathlib import Path +import argparse +import ast +import tomllib +import yaml +import hashlib +import json +import re +from urllib.parse import urlparse + +@dataclass(frozen=True) +class Consumer: + owner: str + repos: tuple + pins: tuple + paths: tuple + network_policy: bool + +CONSUMERS = ( + Consumer("ops-warden", ("ops-warden", "ops-bridge"), ("ops-warden", "ops-warden-fetch"), ("ops-warden/examples/warden.production.example.yaml",), False), + Consumer("secrets-engine", ("secrets-engine",), ("secrets-engine",), ("secrets-engine/tools/exercise_approval_identity.py",), False), + Consumer("user-engine", ("user-engine", "net-kingdom"), ("user-engine", "application-readback"), ("net-kingdom/sso-mfa/k8s/user-engine/runtime.yaml",), True), + Consumer("tenant-engine", ("tenant-engine",), ("tenant-engine",), ("tenant-engine/deploy/base/tenant-engine.yaml", "tenant-engine/railiance/app.toml"), True), + Consumer("informed-decision", ("informed-decision",), ("informed-decision-lifecycle", "informed-decision-sitting", "informed-decision-t03"), ("informed-decision/deploy/sitting-admission.json", "informed-decision/deploy/t03-admission.json", "informed-decision/deploy/lifecycle/admission.candidate.json", "informed-decision/deploy/lifecycle/service.candidate.json", "informed-decision/tools/render_deployment.py"), True), +) + +class MigrationReadiness: + def __init__(self, estate_root): + self.root = Path(estate_root).resolve() + + def classify(self, consumer): + sources = []; texts = []; documents = [] + for relative in consumer.paths: + path = self.root / relative + # Never follow a redirected public path into a secret/private tree. + if path.is_symlink() or (path.exists() and not path.resolve().is_relative_to(self.root)): + raise ValueError("public source path escapes bounded estate root") + if not path.is_file(): + sources.append({"path": relative, "state": "missing"}); continue + raw = path.read_bytes(); text = raw.decode() + source = {"path": relative, "state": "present", "sha256": hashlib.sha256(raw).hexdigest()} + try: + if path.suffix == ".py": + # Literal configuration strings only, never execute source. + values = [n.value for n in ast.walk(ast.parse(text)) if isinstance(n, ast.Constant) and isinstance(n.value, str)] + texts.extend(values) + for value in values: + try: + documents.extend(doc for doc in yaml.safe_load_all(value) if isinstance(doc, (dict, list))) + except yaml.YAMLError: + pass + elif path.suffix == ".toml": + doc = tomllib.loads(text); documents.append(doc) + texts.append(json.dumps(doc, sort_keys=True)) + else: + # JSON is a YAML subset; parse documents to exclude comments. + docs = [doc for doc in yaml.safe_load_all(text) if doc is not None] + documents.extend(docs) + texts.extend(json.dumps(doc, sort_keys=True) for doc in docs) + except (ValueError, SyntaxError, yaml.YAMLError): + source["state"] = "invalid" + sources.append(source) + text = "\n".join(texts) + old_urls = []; new_urls = [] + endpoints = [] + for pin in consumer.pins: + old = f"flex-auth-{pin}.flex-auth.svc" + new = f"access-engine-{pin}.access-engine.svc" + observed = [] + for value in re.findall(r"https?://[^\s\"'<>]+", text): + try: + observed.append(urlparse(value.rstrip(".,;"))) + except ValueError: + continue # templates/malformed URLs are not endpoint evidence + def has_endpoint(host): + for value in observed: + try: + if value.hostname in (host, host + ".cluster.local") and value.port == 8080: + return True + except ValueError: + continue + return False + if has_endpoint(old): old_urls.append(pin) + if has_endpoint(new): new_urls.append(pin) + endpoints.append({"pin": pin, "old_origin": f"http://{old}.cluster.local:8080", "new_origin": f"http://{new}.cluster.local:8080", "audience": "access-engine"}) + # Only runtime audience and namespace-selector literals count. Env names, + # caller mount paths, prose and historical package names do not. + audiences = set(); namespaces = set() + def collect(value): + if isinstance(value, dict): + audience = value.get("audience") + if isinstance(audience, str): audiences.add(audience) + if value.get("kind") == "NetworkPolicy": + spec = value.get("spec", {}) + for rule in spec.get("egress", []) if isinstance(spec, dict) else []: + if not isinstance(rule, dict): continue + for peer in rule.get("to", []): + if not isinstance(peer, dict): continue + selector = peer.get("namespaceSelector", {}) + labels = selector.get("matchLabels", {}) if isinstance(selector, dict) else {} + if isinstance(labels, dict) and isinstance(labels.get("kubernetes.io/metadata.name"), str): + namespaces.add(labels["kubernetes.io/metadata.name"]) + for child in value.values(): collect(child) + elif isinstance(value, list): + for child in value: collect(child) + for doc in documents: collect(doc) + audience_old = "flex-auth" in audiences + audience_new = "access-engine" in audiences + egress_old = "flex-auth" in namespaces + egress_new = "access-engine" in namespaces + reasons = [] + if any(s["state"] != "present" for s in sources): reasons.append("inventory source files missing/invalid; owner must reconcile") + if len(new_urls) != len(consumer.pins): reasons.append("not every new pin endpoint appears in bounded public source") + if not audience_new: reasons.append("new projected-token audience not observed in public source") + if consumer.network_policy and not egress_new: reasons.append("new namespace-selector egress not observed; add before switching") + if old_urls and consumer.network_policy and egress_new and not egress_old: reasons.append("old endpoint remains but old namespace-selector removed; unsafe migration order") + if reasons: phase = "NEEDS_OWNER_SOURCE_WORK" + elif old_urls or audience_old: phase = "SOURCE_DUAL_PREPARATION_REVIEW_REQUIRED" + else: phase = "SOURCE_SWITCHED_NATIVE_PROOF_REQUIRED" + return {"owner": consumer.owner, "source_owners": list(consumer.repos), "phase": phase, "native_ready": False, "sources": sources, "old_endpoint_pins": old_urls, "new_endpoint_pins": new_urls, "audience": {"old": audience_old, "new": audience_new}, "egress_namespace": {"old": egress_old, "new": egress_new}, "reasons": reasons, "handoff": {"endpoints": endpoints, "order": ["verify T02/T05 and ordered predecessor live proof", "owner adds new egress while retaining old", "owner switches URL and projected audience for one consumer", "prove live allow and deny against every new pin", "observe old-twin logs with bounded no-traffic/soak evidence", "owner removes old egress only after evidence review"], "required_live_evidence": ["owner identity and exact deployed revision", "old/new image digest and package correspondence", "authenticated allow and deny exact decision bindings", "old-twin log observation interval and no-traffic results; silence/source strings alone are insufficient"], "special": "ops-warden operator config and ops-bridge ClusterIP tunnels require owner evidence; no new self resource.system grant may be inferred from policy routing lane" if consumer.owner == "ops-warden" else "CLI/operator runtime config requires owner evidence" if consumer.owner == "secrets-engine" else "public source does not establish live deployment"}} + + def report(self): + return {"schema": "runtime-consumer-migration-source-v1", "workplan": "FLEX-WP-0036", "task": "FLEX-WP-0036-T06", "scope": "deterministic public-source readiness classification, no native admission", "dependency_state": "T02/T05 and ordered live proof require coordinator verification; this tool cannot assert completion", "consumers": [self.classify(c) for c in CONSUMERS]} + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--estate-root", type=Path, default=Path(__file__).resolve().parents[2]) + args = parser.parse_args() + print(json.dumps(MigrationReadiness(args.estate_root).report(), indent=2, sort_keys=True)) + +if __name__ == "__main__": main() diff --git a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md index 07e4d4c..c11d42b 100644 --- a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md +++ b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md @@ -65,7 +65,9 @@ written. ```task id: FLEX-WP-0036-T01 -status: progress +status: wait +needs_human: true +intervention_note: "Exact new runtime principals/naming are included in the single daily red batch with the nine TokenReview grants; no per-item question." priority: high state_hub_task_id: "4f9a796d-832f-5936-8a34-eb980f6e1b57" ``` @@ -82,6 +84,8 @@ Gate: decision accepted by the founder. `flex-auth` is a stub that prints the rename and exits 2. The other target names in the table still need the founder's acceptance. +2026-10-11: target names are captured as proposed FLEX-DEC-2026-021. The new reviewer subjects are the scope-expansion component and are already in the single daily red batch. Binary disposition FLEX-DEC-2026-020 remains accepted; no founder acceptance of this broader table is fabricated. + ## Inventory every consumer reference and open handoffs ```task @@ -115,9 +119,9 @@ current source must now come from `./cmd/access-engine`. ```task id: FLEX-WP-0036-T03 -status: progress +status: wait priority: high -depends_on: [FLEX-WP-0036-T01] +depends_on: [FLEX-WP-0036-T01, FLEX-WP-0036-T04, RPF-WP-0044-T07] state_hub_task_id: "487525e5-c89e-586e-8e9b-54ac6da0804e" ``` @@ -144,11 +148,15 @@ PDP-self resource lane in these pin packages; the old inventory's fetch package. No new self-lane or caller grant is created to fill that absence. T03 remains progress until the new image and ordered live checks are proven. +2026-10-11: source compatibility is tested and locally built. The next native step waits for the accepted runtime table, published exact artifact and the ordered Argo ownership proof (RPF-WP-0044-T07). No new founder rollout question; the source/public-package scope lives in the existing single batch. + ## Rename the source ```task id: FLEX-WP-0036-T04 -status: progress +status: wait +needs_human: true +intervention_note: "New public coulomb/access-engine image package publication is included in the single daily red batch. Local source/build checks pass; paired publication has not run." priority: medium depends_on: [FLEX-WP-0036-T01] state_hub_task_id: "ff7fd369-326f-5044-aaa4-96c5f45c80e9" @@ -177,6 +185,8 @@ which are immutable on a `helm upgrade`, so it ships with the new releases. Also the image package and the `/opt/flex-auth` paths, which every live pin's args reference. +2026-10-11: source now includes canonical `/opt/access-engine/examples` and retains the legacy paths for existing pins. The lifecycle Containerfile starts the renamed engine rather than the retired stub. The old Topaz integration variable remains usable for one release with a warning; an explicit new value takes precedence. CI builds once, publishes to the already admitted legacy package on normal push, and has an explicit paired-publication dispatch for the new package. `tools/publish-runtime-images.sh` independently compares both registry manifest digests; a local image ID or equal config alone is not publication proof. The new public package is a daily red-batch item; no paired dispatch has run. T04 remains progress until accepted publication resolves both repositories to the same immutable digest. + ## Stand up the access-engine namespace next to flex-auth ```task @@ -219,7 +229,7 @@ native action occurred. T03/T04 and ordered predecessor gates remain unchanged. ```task id: FLEX-WP-0036-T06 -status: todo +status: wait priority: medium depends_on: [FLEX-WP-0036-T02, FLEX-WP-0036-T05] state_hub_task_id: "f0470e5c-3ea7-506c-b8bb-3ade0b57ff88" @@ -233,6 +243,15 @@ allow and a live deny against the new pin, and no traffic left on the twin Gate: every inventory row closed with owner evidence. +2026-10-11 native dependency wait qualified once on existing T02/T05 and +ordered predecessor live proof; no consumer switch executed. Actual source work +adds a deterministic `MigrationReadiness` classifier and public-source +re-derivation/owner handoff report. All five owners still require source work; +operator and tunnel custody remains with their owners. The verifier refuses to +turn comments, spoofed hosts, unsigned readiness flags or source-only changes +into native readiness. Owner-authenticated live allow/deny and timed old-twin +no-traffic/soak evidence remain necessary; this task is not closed by the report. + ## Retire the flex-auth runtime ```task