From 76af10d623d028bc5c8f803796e5fae38905a8a1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 26 Oct 2026 08:16:32 +0100 Subject: [PATCH 1/3] feat(tenants): prepare verified source upgrades --- .github/workflows/ci.yaml | 3 + docs/TENANTS.md | 18 ++ docs/adr/canonical-only-source-bootstrap.md | 24 +++ .../hetzner/apps/business-site/tenant.yaml | 2 +- scripts/bootstrap-tenant-source.sh | 90 +++++++- scripts/canonical-tenant-source-bindings.json | 50 ++++- scripts/canonical-tenant-source.lib.sh | 69 +++++- ...ate-publish-workflow-approved-revisions.sh | 7 +- .../tests/test-canonical-bootstrap-output.sh | 4 +- .../test-canonical-constructor-attribution.sh | 2 +- .../tests/test-canonical-generator-output.sh | 2 +- .../tests/test-canonical-source-upgrade.sh | 198 ++++++++++++++++++ 12 files changed, 449 insertions(+), 20 deletions(-) create mode 100755 scripts/tests/test-canonical-source-upgrade.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d3cb2d3110..8fad96b438 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -560,6 +560,7 @@ jobs: - 'scripts/canonical-tenant-source.lib.sh' - 'scripts/canonical-tenant-source-bindings.json' - 'scripts/tests/test-canonical-source-bootstrap.sh' + - 'scripts/tests/test-canonical-source-upgrade.sh' - 'scripts/tests/test-canonical-constructor-attribution.sh' - 'scripts/tests/test-canonical-generator-output.sh' - 'scripts/tests/test-canonical-bootstrap-output.sh' @@ -1178,6 +1179,8 @@ jobs: bash scripts/tests/test-bootstrap-tenant-source.sh shellcheck -x scripts/canonical-tenant-source.lib.sh scripts/tests/test-canonical-source-bootstrap.sh scripts/tests/test-canonical-bootstrap-output.sh bash scripts/tests/test-canonical-source-bootstrap.sh + shellcheck -x scripts/tests/test-canonical-source-upgrade.sh + bash scripts/tests/test-canonical-source-upgrade.sh shellcheck -x scripts/tests/test-canonical-constructor-attribution.sh bash scripts/tests/test-canonical-constructor-attribution.sh shellcheck -x scripts/tests/test-canonical-generator-output.sh diff --git a/docs/TENANTS.md b/docs/TENANTS.md index adf42ebbf5..70a827354f 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -196,6 +196,24 @@ and inventory, and writes the candidate only after all checks pass. The existing `--consumer business-site --verify-only` mode remains evidence-only. Review the candidate and registration together; generating a file is not deployed acceptance. +For a later source-only upgrade, use +`scripts/bootstrap-tenant-source.sh --prepare-canonical-upgrade --tag --source-sha --digest --output /absolute/new-candidate-directory` +with that same read-only context. The existing source must already be Ready and +SourceVerified with its admitted digest and exact publisher constraint. The tool +verifies the proposed publication, checks the source again, and generates two +reviewable files in a new private directory. It never updates the checkout or +cluster. Copy those generated files to their matching repository paths for review; +do not hand-edit the generated admission or change its disabled application flags. + +The `published-upgrade` record retains the previous binding and a hashed source +observation separately from the proposed publication. Discovery projects the new +desired digest; regeneration preserves the last actual observation while the +previous source remains verified. It records the replacement only after Flux has +verified the new digest at its current generation, then removes the transitional +record. A proposed version is never an applied version. Missing, ambiguous, +recreated, stale or weakened previous sources fail preparation or regeneration; +an upgrade never restores first-publication authority or enables an application. + Before first verification, complete inventory may show the exact source absent or pending without an artifact. Regeneration emits no applied row for that state. Once the uniquely identified source is Ready and SourceVerified at its current diff --git a/docs/adr/canonical-only-source-bootstrap.md b/docs/adr/canonical-only-source-bootstrap.md index 93726edb3b..4b24a27d3f 100644 --- a/docs/adr/canonical-only-source-bootstrap.md +++ b/docs/adr/canonical-only-source-bootstrap.md @@ -46,6 +46,30 @@ It never erases the observation or restores bootstrap authority. Conflicting record/observation states fail closed. Historical observation is not current readiness, and a failed read is not evidence of absence. +## Source-only upgrades + +An existing source cannot use first-admission bootstrap again. An explicitly +prepared `published-upgrade` binds a separately verified replacement publication +and retains the previous binding plus a hashed, current source observation. +Preparation validates complete discovery and the exact source verification +constraint, observes the source twice, and rechecks the declaration, constructor +and admission before creating a new private output directory. It never changes +the checkout or cluster and never expands the approved publisher set. + +Raw and rendered discovery project the proposed digest. Until it is applied, +regeneration accepts only the previously verified source with its recorded UID +hash and generation, and preserves the previous observation row or its absence. +It never labels the proposal as applied. Current-generation Ready/SourceVerified +evidence for the proposed digest permits ordinary regeneration to replace the +transitional record with `published-binding` and its actual observation row. +Missing or inconsistent evidence never restores bootstrap authority. Both +application flags, external DNS and SOPS remain disabled throughout this path. + +The maintained preparation command generates the admission and Tenant changes +together for normal review. A failed output reservation cannot overwrite an +existing directory or final symlink. Interrupted partial output is not a valid +preparation result; only a successful complete invocation may be copied for review. + ## Delivery boundary Source registration uses the normal reviewed production pipeline and fixed diff --git a/k8s/providers/hetzner/apps/business-site/tenant.yaml b/k8s/providers/hetzner/apps/business-site/tenant.yaml index b8e9666f24..e4c06c2378 100644 --- a/k8s/providers/hetzner/apps/business-site/tenant.yaml +++ b/k8s/providers/hetzner/apps/business-site/tenant.yaml @@ -11,6 +11,6 @@ spec: sops: false webApp: false source: - digest: sha256:fa22e5796a39872feb8c75b5d56e2c6d68ff289230e2c5762f0bbfa0b30d6744 + digest: sha256:2f43bf60c87e373ab99f9fc08f187287d9526b3f1b3212b6234e2ae80b9c5e82 publisherRevision: 467f07582436a26b62cf4aa756423512f85ba0c0 reconcileApplication: false diff --git a/scripts/bootstrap-tenant-source.sh b/scripts/bootstrap-tenant-source.sh index 735eb06dfe..c169ace272 100755 --- a/scripts/bootstrap-tenant-source.sh +++ b/scripts/bootstrap-tenant-source.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Verify one first publication without claiming that Flux has applied it. -# Approval bootstrap is scoped to Data Products; business-site has evidence-only mode. +# Verify publications and prepare bounded source-only admission/upgrade candidates. +# Generation never claims that Flux has applied the proposed publication. set -euo pipefail BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/generate-publish-workflow-approved-revisions.sh @@ -94,7 +94,93 @@ canonical_bootstrap_admission() ( echo 'canonical source admission candidate generated; review required; application reconciliation remains disabled' ) +canonical_upgrade_preparation() ( + set -euo pipefail + trap - EXIT + local upgrade_root="$REPO_ROOT" upgrade_output='' tag='' source_sha='' digest='' seen=':' option + local original record receipt first_snapshot second_snapshot today uid_hash snapshot_hash prepared_root prior_node + while [ "$#" -gt 0 ]; do + option="$1" + case "$seen" in *":$option:"*) return 2 ;; esac + seen="$seen$option:" + [ "$#" -ge 2 ] || return 2 + case "$option" in + --source-root) upgrade_root="$2" ;; + --output) upgrade_output="$2" ;; + --tag) tag="$2" ;; + --source-sha) source_sha="$2" ;; + --digest) digest="$2" ;; + *) return 2 ;; + esac + shift 2 + done + [[ "$tag" =~ ^v[0-9]+[.][0-9]+[.][0-9]+$ ]] && is_sha "$source_sha" && is_digest "$digest" || return 2 + [ -n "${PUBLISH_KUBE_CONTEXT:-}" ] && [[ "$upgrade_output" = /* ]] || return 2 + local output_parent output_name upgrade_tenant graph_path + output_parent="$(dirname "$upgrade_output")"; output_name="$(basename "$upgrade_output")" + [[ "$output_name" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || return 2 + output_parent="$(cd "$output_parent" && pwd -P)" || return 2 + upgrade_output="$output_parent/$output_name" + [ ! -e "$upgrade_output" ] && [ ! -L "$upgrade_output" ] || return 1 + upgrade_root="$(cd "$upgrade_root" && pwd -P)" || return 2 + upgrade_tenant=k8s/providers/hetzner/apps/business-site/tenant.yaml + graph_path=k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml + [ -z "${CANONICAL_TENANT_BINDINGS_FILE:-}" ] || return 2 + original="$(canonical_tenant_bindings "$upgrade_root")" || return 1 + record="$(printf '%s\n' "$original" | jq -cer '.bindings|select(length==1)|.[0]|select(.kind!="published-upgrade")')" || return 1 + canonical_observation_consistent "$record" "${APPROVED_IDENTITIES_FILE:-$upgrade_root/scripts/publish-workflow-approved-revisions.identities.tsv}" || return 1 + prior_node="$(yq -o=json -I=0 '.' "$upgrade_root/$upgrade_tenant" | jq -ces 'select(length==1)|.[0]')" || return 1 + canonical_tenant_projection "$upgrade_root" "$prior_node" >/dev/null || return 1 + discover_consumers "$upgrade_root" >/dev/null || return 1 + prepared_root="$(mktemp -d "$output_parent/.canonical-upgrade.XXXXXX")" || return 1 + trap 'rm -rf "$prepared_root"' EXIT + kubectl --context "$PUBLISH_KUBE_CONTEXT" get ocirepositories.source.toolkit.fluxcd.io -A -o json >"$prepared_root/first-inventory.json" || return 1 + first_snapshot="$(canonical_source_observation "$record" "$prepared_root/first-inventory.json")" || return 1 + receipt="$(business_site_manifest_evidence --consumer business-site --verify-only --tag "$tag" --source-sha "$source_sha" --digest "$digest")" || return 1 + today="${APPROVED_REVISIONS_OBSERVED_ON:-$(date -u +%Y-%m-%d)}" + is_date "$today" || return 1 + uid_hash="$(printf '%s' "$(printf '%s\n' "$first_snapshot" | jq -r .metadata.uid)" | shasum -a 256 | awk '{print $1}')" || return 1 + snapshot_hash="$(printf '%s\n' "$first_snapshot" | shasum -a 256 | awk '{print $1}')" || return 1 + mkdir -p "$prepared_root/scripts" "$prepared_root/$(dirname "$upgrade_tenant")" "$prepared_root/$(dirname "$graph_path")" + printf '%s\n' "$receipt" | jq -es --argjson old "$record" --arg tag "$tag" --arg source "$source_sha" \ + --arg digest "$digest" --arg date "$today" --arg uid "$uid_hash" --arg snapshot "$snapshot_hash" --argjson observed "$first_snapshot" ' + select(length==1)|.[0]|select(.schemaVersion==1 and .kind=="manifest-publication-evidence" and + .consumer=="business-site" and .deploymentAuthorized==false and .gitTag==$tag and .sourceSHA==$source and .digest==$digest) | + . as $evidence | {schemaVersion:1,bindings:[($old|.kind="published-upgrade"| + .previousBinding=$old|.previousObservation={uidSha256:$uid,generation:$observed.metadata.generation,snapshotSha256:$snapshot,observedOn:$date}| + .sourceIdentity.digest=$digest|.publication={gitTag:$tag,sourceSHA:$source,publisherIdentity:$evidence.publisherIdentity, + callerWorkflow:$evidence.callerWorkflow,event:$evidence.event,workflowRunId:$evidence.workflowRunId, + workflowRunAttempt:$evidence.workflowRunAttempt,verifiedOn:$date})]}' >"$prepared_root/scripts/canonical-tenant-source-bindings.json" || return 1 + cp "$upgrade_root/$graph_path" "$prepared_root/$graph_path" + cp "$upgrade_root/scripts/publish-workflow-canonical-approvals.tsv" "$prepared_root/scripts/" + cp "${APPROVED_IDENTITIES_FILE:-$upgrade_root/scripts/publish-workflow-approved-revisions.identities.tsv}" "$prepared_root/scripts/" + UPGRADE_PREPARED_DIGEST="$digest" yq '.spec.source.digest=strenv(UPGRADE_PREPARED_DIGEST)' "$upgrade_root/$upgrade_tenant" >"$prepared_root/$upgrade_tenant" || return 1 + # The same strict reader and raw discovery used by CI must accept generated output. + APPROVED_IDENTITIES_FILE="$prepared_root/scripts/publish-workflow-approved-revisions.identities.tsv" \ + CANONICAL_APPROVALS_FILE="$prepared_root/scripts/publish-workflow-canonical-approvals.tsv" \ + discover_consumers "$prepared_root" >/dev/null || return 1 + kubectl --context "$PUBLISH_KUBE_CONTEXT" get ocirepositories.source.toolkit.fluxcd.io -A -o json >"$prepared_root/second-inventory.json" || return 1 + second_snapshot="$(canonical_source_observation "$record" "$prepared_root/second-inventory.json")" || return 1 + [ "$first_snapshot" = "$second_snapshot" ] || return 1 + [ "$(canonical_tenant_bindings "$upgrade_root")" = "$original" ] || return 1 + [ "$(yq -o=json -I=0 '.' "$upgrade_root/$upgrade_tenant" | jq -ces 'select(length==1)|.[0]')" = "$prior_node" ] || return 1 + canonical_tenant_projection "$upgrade_root" "$prior_node" >/dev/null || return 1 + discover_consumers "$upgrade_root" >/dev/null || return 1 + # Reserve a new private output directory only after every input has been validated. + # Exclusive links cannot overwrite a conflicting file or follow its final symlink. + (umask 077; mkdir "$upgrade_output") || return 1 + mkdir -p "$upgrade_output/scripts" "$upgrade_output/$(dirname "$upgrade_tenant")" || return 1 + link "$prepared_root/scripts/canonical-tenant-source-bindings.json" "$upgrade_output/scripts/canonical-tenant-source-bindings.json" || return 1 + link "$prepared_root/$upgrade_tenant" "$upgrade_output/$upgrade_tenant" || return 1 + printf 'Canonical source upgrade prepared for review; existing observations and disabled application flags preserved.\n' +) + bootstrap_tenant_source() ( + if [ "${1:-}" = --prepare-canonical-upgrade ]; then + shift + canonical_upgrade_preparation "$@" + return + fi if [ "${1:-}" = --admit-canonical-source ]; then shift canonical_bootstrap_admission "$@" diff --git a/scripts/canonical-tenant-source-bindings.json b/scripts/canonical-tenant-source-bindings.json index eb0574c584..0183ee7b89 100644 --- a/scripts/canonical-tenant-source-bindings.json +++ b/scripts/canonical-tenant-source-bindings.json @@ -5,7 +5,7 @@ "consumer": "business-site", "consumerRepository": "devantler-tech/business-site", "workflow": "publish-app", - "kind": "published-bootstrap", + "kind": "published-upgrade", "sourcePath": "k8s/providers/hetzner/apps/business-site/tenant.yaml", "constructorSpecSha256": "16939512acb4d4dccb1ebe11caa3f48fe49503c8a9a8a4c44aafd328a5a45fdb", "tenantIdentity": { @@ -20,17 +20,55 @@ "namespace": "business-site", "name": "business-site", "artifactURL": "oci://ghcr.io/devantler-tech/business-site/manifests", - "digest": "sha256:fa22e5796a39872feb8c75b5d56e2c6d68ff289230e2c5762f0bbfa0b30d6744" + "digest": "sha256:2f43bf60c87e373ab99f9fc08f187287d9526b3f1b3212b6234e2ae80b9c5e82" }, "publication": { - "gitTag": "v1.0.3", - "sourceSHA": "498248e86e49024b72f3249b518e4dd1cef0104b", + "gitTag": "v2.0.0", + "sourceSHA": "2eca5f823676adde374556848cf8bf86eb139804", "publisherIdentity": "devantler-tech/.github/.github/workflows/publish-app.yaml@467f07582436a26b62cf4aa756423512f85ba0c0", "callerWorkflow": ".github/workflows/publish-runtime.yaml", "event": "release", - "workflowRunId": 39921694859, + "workflowRunId": 40247127226, "workflowRunAttempt": 1, - "verifiedOn": "2026-10-25" + "verifiedOn": "2026-10-26" + }, + "previousBinding": { + "consumer": "business-site", + "consumerRepository": "devantler-tech/business-site", + "workflow": "publish-app", + "kind": "published-bootstrap", + "sourcePath": "k8s/providers/hetzner/apps/business-site/tenant.yaml", + "constructorSpecSha256": "16939512acb4d4dccb1ebe11caa3f48fe49503c8a9a8a4c44aafd328a5a45fdb", + "tenantIdentity": { + "apiVersion": "kro.run/v1alpha1", + "kind": "Tenant", + "namespace": "flux-system", + "name": "business-site" + }, + "sourceIdentity": { + "apiVersion": "source.toolkit.fluxcd.io/v1", + "kind": "OCIRepository", + "namespace": "business-site", + "name": "business-site", + "artifactURL": "oci://ghcr.io/devantler-tech/business-site/manifests", + "digest": "sha256:fa22e5796a39872feb8c75b5d56e2c6d68ff289230e2c5762f0bbfa0b30d6744" + }, + "publication": { + "gitTag": "v1.0.3", + "sourceSHA": "498248e86e49024b72f3249b518e4dd1cef0104b", + "publisherIdentity": "devantler-tech/.github/.github/workflows/publish-app.yaml@467f07582436a26b62cf4aa756423512f85ba0c0", + "callerWorkflow": ".github/workflows/publish-runtime.yaml", + "event": "release", + "workflowRunId": 39921694859, + "workflowRunAttempt": 1, + "verifiedOn": "2026-10-25" + } + }, + "previousObservation": { + "uidSha256": "38aff5c8aafbd89fc51dbbe131d27c9a3fb0e78108a20779d0e7a5680a6cc2d2", + "generation": 1, + "snapshotSha256": "610500b54e61b2d3e33cc2db548971e04816e05231e2df50dd6f255ddd92600e", + "observedOn": "2026-10-26" } } ] diff --git a/scripts/canonical-tenant-source.lib.sh b/scripts/canonical-tenant-source.lib.sh index 4c3e8614ae..814e526d4c 100644 --- a/scripts/canonical-tenant-source.lib.sh +++ b/scripts/canonical-tenant-source.lib.sh @@ -18,10 +18,7 @@ canonical_tenant_bindings() ( def sha: type=="string" and test("^[0-9a-f]{40}$"); def digest: type=="string" and test("^sha256:[0-9a-f]{64}$"); def positive: type=="number" and .>0 and .<=999999999999999 and floor==.; - select(length==1) | .[0] | - select(type=="object" and keys==["bindings","schemaVersion"] and .schemaVersion==1) | - select(.bindings|type=="array" and length<=1) | - select(all(.bindings[]; + def plain_binding: type=="object" and keys==["constructorSpecSha256","consumer","consumerRepository","kind","publication","sourceIdentity","sourcePath","tenantIdentity","workflow"] and (.constructorSpecSha256|type=="string" and test("^[0-9a-f]{64}$")) and .consumer=="business-site" and .consumerRepository=="devantler-tech/business-site" and @@ -37,8 +34,26 @@ canonical_tenant_bindings() ( (.gitTag|type=="string" and test("^v[0-9]+[.][0-9]+[.][0-9]+$")) and (.sourceSHA|sha) and .publisherIdentity=="devantler-tech/.github/.github/workflows/publish-app.yaml@467f07582436a26b62cf4aa756423512f85ba0c0" and (.verifiedOn|type=="string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and - (.workflowRunId|positive) and (.workflowRunAttempt|positive)) - )) + (.workflowRunId|positive) and (.workflowRunAttempt|positive)); + def upgrade_binding: + type=="object" and + keys==["constructorSpecSha256","consumer","consumerRepository","kind","previousBinding","previousObservation","publication","sourceIdentity","sourcePath","tenantIdentity","workflow"] and + .kind=="published-upgrade" and + ((del(.previousBinding,.previousObservation)|.kind="published-binding")|plain_binding) and + (.previousBinding|plain_binding) and + (.previousObservation|type=="object" and keys==["generation","observedOn","snapshotSha256","uidSha256"] and + (.generation|positive) and (.observedOn|type=="string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + all(.snapshotSha256,.uidSha256; type=="string" and test("^[0-9a-f]{64}$"))) and + (. as $candidate | .previousBinding as $old | + ($candidate|del(.previousBinding,.previousObservation,.publication,.sourceIdentity,.kind))== + ($old|del(.publication,.sourceIdentity,.kind)) and + ($candidate.sourceIdentity|del(.digest))==($old.sourceIdentity|del(.digest)) and + $candidate.sourceIdentity.digest!=$old.sourceIdentity.digest and + $candidate.publication.publisherIdentity==$old.publication.publisherIdentity); + select(length==1) | .[0] | + select(type=="object" and keys==["bindings","schemaVersion"] and .schemaVersion==1) | + select(.bindings|type=="array" and length<=1) | + select(all(.bindings[]; plain_binding or upgrade_binding)) ' "$file" ) @@ -192,12 +207,50 @@ canonical_publication_verify() ( }' >/dev/null ) +# A preparation snapshot is current source verification, never application readiness. +canonical_source_observation() ( + set -euo pipefail + trap - EXIT + local observation_record="$1" observation_inventory="$2" observation_pin + [ "$(canonical_source_inventory_state "$observation_record" "$observation_inventory")" = observed ] || return 1 + observation_pin="$(printf '%s\n' "$observation_record" | jq -er '.publication.publisherIdentity|split("@")[1]')" || return 1 + jq -cSe --argjson binding "$observation_record" --arg pin "$observation_pin" ' + def text: type=="string" and length>0 and length<=512 and (test("[[:cntrl:]]")|not); + [.items[]|select(.metadata.namespace==$binding.sourceIdentity.namespace and .metadata.name==$binding.sourceIdentity.name)] | + select(length==1)|.[0]| + select((.metadata.uid|text) and (.metadata.resourceVersion|text) and + .spec.verify=={provider:"cosign",matchOIDCIdentity:[{ + issuer:"^https://token\\.actions\\.githubusercontent\\.com$", + subject:("^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-app\\.yaml@"+$pin+"$")}]}) | + {metadata:{uid:.metadata.uid,resourceVersion:.metadata.resourceVersion,generation:.metadata.generation}, + spec:{url:.spec.url,ref:.spec.ref,verify:.spec.verify},status:{observedGeneration:.status.observedGeneration, + revision:.status.artifact.revision,conditions:([.status.conditions[]|select(.type=="Ready" or .type=="SourceVerified")| + {type,status,observedGeneration}]|sort_by(.type))}}' "$observation_inventory" +) + # A complete cluster listing distinguishes first publication from real source # verification. An existing observed binding can never regress to bootstrap. canonical_source_inventory_state() ( set -euo pipefail trap - EXIT local state_record="$1" state_inventory="$2" + if [ "$(printf '%s\n' "$state_record" | jq -r .kind)" = published-upgrade ]; then + local next_record prior_record next_state prior_snapshot prior_uid prior_generation + next_record="$(printf '%s\n' "$state_record" | jq -c 'del(.previousBinding,.previousObservation)|.kind="published-binding"')" || return 1 + if next_state="$(canonical_source_inventory_state "$next_record" "$state_inventory")"; then + [ "$next_state" = observed ] || return 1 + printf 'observed\n' + return + fi + prior_record="$(printf '%s\n' "$state_record" | jq -c .previousBinding)" || return 1 + prior_snapshot="$(canonical_source_observation "$prior_record" "$state_inventory")" || return 1 + prior_uid="$(printf '%s\n' "$prior_snapshot" | jq -er .metadata.uid)" || return 1 + prior_generation="$(printf '%s\n' "$prior_snapshot" | jq -er .metadata.generation)" || return 1 + [ "$(printf '%s' "$prior_uid" | shasum -a 256 | awk '{print $1}')" = "$(printf '%s\n' "$state_record" | jq -r .previousObservation.uidSha256)" ] || return 1 + [ "$prior_generation" = "$(printf '%s\n' "$state_record" | jq -r .previousObservation.generation)" ] || return 1 + printf 'previous\n' + return + fi jq -cers --argjson binding "$state_record" ' def text: type=="string" and length>0 and length<=512 and (test("[[:cntrl:]]")|not); def generation: type=="number" and .>0 and floor==.; @@ -246,6 +299,10 @@ canonical_observation_consistent() ( set -euo pipefail trap - EXIT local consistency_record="$1" identities="$2" expected actual + if [ "$(printf '%s\n' "$consistency_record" | jq -r .kind)" = published-upgrade ]; then + canonical_observation_consistent "$(printf '%s\n' "$consistency_record" | jq -c .previousBinding)" "$identities" + return + fi [ -f "$identities" ] && [ ! -L "$identities" ] || return 1 [ "$(head -n1 "$identities")" = $'consumer\tworkflow\tapplied_tag\tapplied_digest\tapplied_publisher_identity\tmain_publisher_identity\tobserved_on' ] || return 1 actual="$(awk -F '\t' '$1=="business-site" {if(NF!=7) exit 1; print}' "$identities")" || return 1 diff --git a/scripts/generate-publish-workflow-approved-revisions.sh b/scripts/generate-publish-workflow-approved-revisions.sh index fff2a4c253..3980e83125 100755 --- a/scripts/generate-publish-workflow-approved-revisions.sh +++ b/scripts/generate-publish-workflow-approved-revisions.sh @@ -438,6 +438,11 @@ main() { canonical_pin="$(default_pin_resolver "$repo" "$workflow")" || exit 1 [ "$canonical_pin" = "$(printf '%s\n' "$canonical_record" | jq -r .publication.publisherIdentity)" ] || { refuse "$repo: current publisher is outside the pinned admission"; exit 1; } case "$source_state" in + previous) + # Keep the last actual row, including absence before first observation. + canonical_previous="$(awk -F '\t' -v consumer="$repo" '$1==consumer {print}' "$IDENTITIES_OUTPUT")" + if [ -n "$canonical_previous" ]; then identities_rows="${identities_rows}${canonical_previous}"$'\n'; fi + ;; absent | pending) [ "$(printf '%s\n' "$canonical_record" | jq -r .kind)" = published-bootstrap ] || { refuse "$repo: observed publication cannot regress to bootstrap"; exit 1; } ;; @@ -449,7 +454,7 @@ main() { canonical_date="${canonical_previous##*$'\t'}" fi identities_rows="${identities_rows}${canonical_tuple}"$'\t'"${canonical_date}"$'\n' - canonical_document="$(printf '%s\n' "$canonical_document" | jq --arg consumer "$repo" '.bindings |= map(if .consumer==$consumer then .kind="published-binding" else . end)')" + canonical_document="$(printf '%s\n' "$canonical_document" | jq --arg consumer "$repo" '.bindings |= map(if .consumer==$consumer then .kind="published-binding"|del(.previousBinding,.previousObservation) else . end)')" ;; *) refuse "$repo: unknown canonical source state"; exit 1 ;; esac diff --git a/scripts/tests/test-canonical-bootstrap-output.sh b/scripts/tests/test-canonical-bootstrap-output.sh index 1a42e7b504..9445d2af93 100755 --- a/scripts/tests/test-canonical-bootstrap-output.sh +++ b/scripts/tests/test-canonical-bootstrap-output.sh @@ -33,8 +33,8 @@ fixture_output="$fixture/scripts/canonical-tenant-source-bindings.json" run_admission() { PUBLISH_KUBE_CONTEXT=fixture CANONICAL_APPROVALS_FILE="$fixture/scripts/publish-workflow-canonical-approvals.tsv" \ bootstrap_tenant_source --admit-canonical-source --source-root "$fixture" \ - --tag v1.0.3 --source-sha 498248e86e49024b72f3249b518e4dd1cef0104b \ - --digest sha256:fa22e5796a39872feb8c75b5d56e2c6d68ff289230e2c5762f0bbfa0b30d6744 + --tag "$(jq -r .gitTag "$test_work/receipt.json")" --source-sha "$(jq -r .sourceSHA "$test_work/receipt.json")" \ + --digest "$(jq -r .digest "$test_work/receipt.json")" } printf '{"schemaVersion":1,"bindings":[]}\n' >"$fixture_output" if run_admission >"$test_work/result" 2>&1; then echo 'empty registry reported an unchanged admission' >&2; exit 1; fi diff --git a/scripts/tests/test-canonical-constructor-attribution.sh b/scripts/tests/test-canonical-constructor-attribution.sh index 3614c57059..e8d1d8b24e 100755 --- a/scripts/tests/test-canonical-constructor-attribution.sh +++ b/scripts/tests/test-canonical-constructor-attribution.sh @@ -10,7 +10,7 @@ tenant=k8s/providers/hetzner/apps/business-site/tenant.yaml mkdir -p "$tree/$(dirname "$graph")" "$tree/$(dirname "$tenant")" "$tree/scripts" cp "$attribution_repo_root/$graph" "$tree/$graph" cp "$attribution_repo_root/$tenant" "$tree/$tenant" -jq '.bindings[0].kind="published-bootstrap"' "$attribution_repo_root/scripts/canonical-tenant-source-bindings.json" >"$tree/scripts/canonical-tenant-source-bindings.json" +jq '.bindings |= map(del(.previousBinding,.previousObservation)|.kind="published-bootstrap")' "$attribution_repo_root/scripts/canonical-tenant-source-bindings.json" >"$tree/scripts/canonical-tenant-source-bindings.json" cp "$attribution_repo_root/scripts/publish-workflow-canonical-approvals.tsv" "$tree/scripts/" awk -F '\t' '$1!="business-site"' "$attribution_repo_root/scripts/publish-workflow-approved-revisions.identities.tsv" >"$tree/scripts/publish-workflow-approved-revisions.identities.tsv" discover() ( diff --git a/scripts/tests/test-canonical-generator-output.sh b/scripts/tests/test-canonical-generator-output.sh index a264970bd9..fe75102d78 100755 --- a/scripts/tests/test-canonical-generator-output.sh +++ b/scripts/tests/test-canonical-generator-output.sh @@ -9,7 +9,7 @@ mkdir -p "$fixture/scripts" "$fixture/talos/cluster" cp -R "$generator_test_root/k8s" "$fixture/k8s" cp "$generator_test_root/talos/cluster/verify-first-party-images.yaml" "$fixture/talos/cluster/" cp "$generator_test_root/scripts/publish-workflow-canonical-approvals.tsv" "$fixture/scripts/" -jq '.bindings[0].kind="published-bootstrap"' "$generator_test_root/scripts/canonical-tenant-source-bindings.json" >"$fixture/scripts/canonical-tenant-source-bindings.json" +jq '.bindings |= map(del(.previousBinding,.previousObservation)|.kind="published-bootstrap")' "$generator_test_root/scripts/canonical-tenant-source-bindings.json" >"$fixture/scripts/canonical-tenant-source-bindings.json" cp "$generator_test_root/scripts/publish-workflow-approved-revisions.tsv" "$fixture/scripts/" awk -F '\t' '$1!="business-site"' "$generator_test_root/scripts/publish-workflow-approved-revisions.identities.tsv" >"$fixture/scripts/publish-workflow-approved-revisions.identities.tsv" cp "$generator_test_root/scripts/publish-workflow-bootstrap-publications.json" "$fixture/scripts/" diff --git a/scripts/tests/test-canonical-source-upgrade.sh b/scripts/tests/test-canonical-source-upgrade.sh new file mode 100755 index 0000000000..2749ce1ce6 --- /dev/null +++ b/scripts/tests/test-canonical-source-upgrade.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# Exercise real upgrade preparation, discovery and regeneration with provider adapters only. +set -euo pipefail +test_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +upgrade_test_work="$(mktemp -d)" +trap 'rm -rf "$upgrade_test_work"' EXIT +tree="$upgrade_test_work/tree" +mkdir -p "$tree/scripts" +cp -R "$test_root/k8s" "$tree/k8s" +cp "$test_root/ksail.prod.yaml" "$tree/ksail.prod.yaml" +cp "$test_root/scripts/"*.tsv "$tree/scripts/" +cp "$test_root/scripts/canonical-tenant-source-bindings.json" "$tree/scripts/" +cp "$test_root/scripts/publish-workflow-bootstrap-publications.json" "$tree/scripts/" +mkdir -p "$tree/talos/cluster" +cp "$test_root/talos/cluster/verify-first-party-images.yaml" "$tree/talos/cluster/" +binding_file="$tree/scripts/canonical-tenant-source-bindings.json" +tenant_path=k8s/providers/hetzner/apps/business-site/tenant.yaml +jq '.bindings |= map((.previousBinding // .)|.kind="published-bootstrap"| + .sourceIdentity.digest="sha256:fa22e5796a39872feb8c75b5d56e2c6d68ff289230e2c5762f0bbfa0b30d6744"| + .publication.gitTag="v1.0.3"|.publication.sourceSHA="498248e86e49024b72f3249b518e4dd1cef0104b"| + .publication.workflowRunId=39921694859|.publication.workflowRunAttempt=1)' "$binding_file" >"$upgrade_test_work/seed.json" +mv "$upgrade_test_work/seed.json" "$binding_file" +UPGRADE_SEED_DIGEST="$(jq -r '.bindings[0].sourceIdentity.digest' "$binding_file")" \ + yq -i '.spec.source.digest=strenv(UPGRADE_SEED_DIGEST)' "$tree/$tenant_path" +awk -F '\t' '$1!="business-site"' "$tree/scripts/publish-workflow-approved-revisions.identities.tsv" >"$upgrade_test_work/seed-identities.tsv" +mv "$upgrade_test_work/seed-identities.tsv" "$tree/scripts/publish-workflow-approved-revisions.identities.tsv" +cp "$binding_file" "$upgrade_test_work/original-binding.json" +cp "$tree/$tenant_path" "$upgrade_test_work/original-tenant.yaml" +export PUBLISH_CONSUMER_ROOT="$tree" APPROVED_REVISIONS_FILE="$tree/scripts/publish-workflow-approved-revisions.tsv" +export APPROVED_IDENTITIES_FILE="$tree/scripts/publish-workflow-approved-revisions.identities.tsv" +export CANONICAL_APPROVALS_FILE="$tree/scripts/publish-workflow-canonical-approvals.tsv" +export PUBLISH_BOOTSTRAP_FILE="$tree/scripts/publish-workflow-bootstrap-publications.json" PUBLISH_KUBE_CONTEXT=fixture +# shellcheck source=scripts/bootstrap-tenant-source.sh +source "$test_root/scripts/bootstrap-tenant-source.sh" +old_record="$(jq -c '.bindings[0]' "$binding_file")" +next_digest="sha256:$(printf '%064d' 8)" +next_source="$(printf '%040d' 7)" +next_tag=v2.0.0 +jq --arg digest "$next_digest" --arg source "$next_source" ' + .bindings[0] | {schemaVersion:1,kind:"manifest-publication-evidence",consumer, + deploymentAuthorized:false,gitTag:"v2.0.0",sourceSHA:$source,digest:$digest, + publisherIdentity:.publication.publisherIdentity,callerWorkflow:.publication.callerWorkflow, + event:.publication.event,workflowRunId:12345,workflowRunAttempt:1}' "$binding_file" >"$upgrade_test_work/receipt.json" +# The old live source is independently populated from the committed admission. +jq -n --argjson binding "$old_record" '{apiVersion:"v1",kind:"List",metadata:{},items:[{ + apiVersion:$binding.sourceIdentity.apiVersion,kind:$binding.sourceIdentity.kind, + metadata:{namespace:$binding.sourceIdentity.namespace,name:$binding.sourceIdentity.name, + uid:"fictional-source-uid",resourceVersion:"1234",generation:2}, + spec:{url:$binding.sourceIdentity.artifactURL,ref:{digest:$binding.sourceIdentity.digest}, + verify:{provider:"cosign",matchOIDCIdentity:[{issuer:"^https://token\\.actions\\.githubusercontent\\.com$", + subject:"^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-app\\.yaml@467f07582436a26b62cf4aa756423512f85ba0c0$"}]}}, + status:{observedGeneration:2,artifact:{revision:$binding.sourceIdentity.digest},conditions:[ + {type:"Ready",status:"True",observedGeneration:2},{type:"SourceVerified",status:"True",observedGeneration:2}]}}]}' >"$upgrade_test_work/inventory.json" +cp "$upgrade_test_work/inventory.json" "$upgrade_test_work/original-inventory.json" +business_site_manifest_evidence() { cat "$upgrade_test_work/receipt.json"; } +kubectl() { + if [ -n "${input_race:-}" ]; then + if [ -e "$upgrade_test_work/read-once" ]; then + case "$input_race" in + registry) + jq '.bindings[0].publication.workflowRunAttempt=2' "$binding_file" >"$upgrade_test_work/raced.json" + mv "$upgrade_test_work/raced.json" "$binding_file" ;; + tenant) yq -i '.spec.webApp=true' "$tree/$tenant_path" ;; + constructor) yq -i '.spec.schema.group="unapproved.example"' "$tree/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml" ;; + output) mkdir "$upgrade_test_work/candidate"; printf 'keep\n' >"$upgrade_test_work/candidate/keep" ;; + esac + else touch "$upgrade_test_work/read-once"; fi + fi + if [ "${observation_race:-0}" = 1 ]; then + # A changed resourceVersion is enough to invalidate the preparation snapshot. + if [ -e "$upgrade_test_work/read-once" ]; then jq '.items[0].metadata.resourceVersion="changed"' "$upgrade_test_work/inventory.json"; return; fi + touch "$upgrade_test_work/read-once" + fi + cat "$upgrade_test_work/inventory.json" +} +prepare() { + PUBLISH_KUBE_CONTEXT=fixture CANONICAL_APPROVALS_FILE="$tree/scripts/publish-workflow-canonical-approvals.tsv" \ + bootstrap_tenant_source --prepare-canonical-upgrade --source-root "$tree" --output "$upgrade_test_work/candidate" \ + --tag "$next_tag" --source-sha "$next_source" --digest "$next_digest" +} +prepare >"$upgrade_test_work/result" 2>"$upgrade_test_work/error" || { cat "$upgrade_test_work/error" >&2; echo 'real upgrade preparation failed' >&2; exit 1; } +candidate_record="$upgrade_test_work/candidate/scripts/canonical-tenant-source-bindings.json" +test -f "$candidate_record" +test -f "$upgrade_test_work/candidate/$tenant_path" +cmp "$binding_file" "$upgrade_test_work/original-binding.json" +cmp "$tree/$tenant_path" "$upgrade_test_work/original-tenant.yaml" +jq -e --arg digest "$next_digest" --argjson old "$old_record" ' + .bindings[0] | .kind=="published-upgrade" and .sourceIdentity.digest==$digest and + .previousBinding==$old and .previousObservation.generation==2 and + (.previousObservation.uidSha256|test("^[0-9a-f]{64}$"))' "$candidate_record" >/dev/null +test "$(yq '.spec.source.digest' "$upgrade_test_work/candidate/$tenant_path")" = "$next_digest" +test "$(yq '.spec.webApp or .spec.externalDns or .spec.sops or .spec.source.reconcileApplication' "$upgrade_test_work/candidate/$tenant_path")" = false +if prepare >"$upgrade_test_work/result" 2>&1; then echo 'existing candidate directory overwritten' >&2; exit 1; fi +cp -R "$upgrade_test_work/candidate" "$upgrade_test_work/good-candidate" +rm -rf "$upgrade_test_work/candidate" +prepare >"$upgrade_test_work/result" +diff -r "$upgrade_test_work/good-candidate" "$upgrade_test_work/candidate" +rm -rf "$upgrade_test_work/candidate" +mkdir "$upgrade_test_work/redirect" +ln -s "$upgrade_test_work/redirect" "$upgrade_test_work/candidate" +if prepare >"$upgrade_test_work/result" 2>&1; then echo 'candidate symlink followed' >&2; exit 1; fi +test -z "$(find "$upgrade_test_work/redirect" -mindepth 1 -print)" +rm "$upgrade_test_work/candidate" +observation_race=1 +if prepare >"$upgrade_test_work/result" 2>&1; then echo 'stale observation accepted' >&2; exit 1; fi +test ! -e "$upgrade_test_work/candidate" +observation_race=0 +graph_path=k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml +cp "$tree/$graph_path" "$upgrade_test_work/original-graph.yaml" +for input_race in registry tenant constructor output; do + rm -f "$upgrade_test_work/read-once" + if prepare >"$upgrade_test_work/result" 2>&1; then echo "changed $input_race input accepted" >&2; exit 1; fi + if [ "$input_race" = output ]; then + test "$(find "$upgrade_test_work/candidate" -type f | wc -l | tr -d ' ')" = 1 + grep -qx keep "$upgrade_test_work/candidate/keep" + rm -rf "$upgrade_test_work/candidate" + else test ! -e "$upgrade_test_work/candidate"; fi + cp "$upgrade_test_work/original-binding.json" "$binding_file" + cp "$upgrade_test_work/original-tenant.yaml" "$tree/$tenant_path" + cp "$upgrade_test_work/original-graph.yaml" "$tree/$graph_path" +done +input_race='' +for mutation in \ + '.items += .items' \ + '.metadata.continue="next-page"' \ + '.items[0].metadata.uid=""' \ + '.items[0].status.conditions[1].status="False"' \ + '.items[0].status.observedGeneration=1' \ + '.items[0].spec.verify.matchOIDCIdentity[0].subject=".*"' \ + '.items[0].spec.url="oci://ghcr.io/other/repository"' \ + '.items[0].spec.ref.digest="sha256:bad"'; do + jq "$mutation" "$upgrade_test_work/original-inventory.json" >"$upgrade_test_work/inventory.json" + if prepare >"$upgrade_test_work/result" 2>&1; then echo "unsafe observed source accepted: $mutation" >&2; exit 1; fi + test ! -e "$upgrade_test_work/candidate" +done +cp "$upgrade_test_work/original-inventory.json" "$upgrade_test_work/inventory.json" +cp "$upgrade_test_work/receipt.json" "$upgrade_test_work/good-receipt.json" +for mutation in '.digest="sha256:bad"' '.sourceSHA=("9"*40)' '.gitTag="v3.0.0"' '.publisherIdentity="unapproved"' '.deploymentAuthorized=true' '.consumer="other"' '.event="push"' '.callerWorkflow=".github/workflows/other.yaml"'; do + jq "$mutation" "$upgrade_test_work/good-receipt.json" >"$upgrade_test_work/receipt.json" + if prepare >"$upgrade_test_work/result" 2>&1; then echo "wrong candidate publication accepted: $mutation" >&2; exit 1; fi + test ! -e "$upgrade_test_work/candidate" +done +cp "$upgrade_test_work/good-receipt.json" "$upgrade_test_work/receipt.json" +prepare >"$upgrade_test_work/result" +cp "$candidate_record" "$binding_file" +cp "$upgrade_test_work/candidate/$tenant_path" "$tree/$tenant_path" +discover_consumers "$tree" with-identity >"$upgrade_test_work/consumers.tsv" +grep -q "digest:$next_digest" "$upgrade_test_work/consumers.tsv" +APPROVED_REVISIONS_FILE="$tree/scripts/publish-workflow-approved-revisions.tsv" \ + CANONICAL_APPROVALS_FILE="$tree/scripts/publish-workflow-canonical-approvals.tsv" \ + PUBLISH_BOOTSTRAP_FILE="$tree/scripts/publish-workflow-bootstrap-publications.json" \ + PUBLISH_CONSUMER_ROOT="$tree" APPROVED_REVISIONS_ENFORCE=1 \ + bash "$test_root/scripts/guard-consumer-discovery-conservation.sh" "$tree" >"$upgrade_test_work/conservation" +record="$(jq -c '.bindings[0]' "$binding_file")" +test "$(canonical_source_inventory_state "$record" "$upgrade_test_work/inventory.json")" = previous +canonical_observation_consistent "$record" "$tree/scripts/publish-workflow-approved-revisions.identities.tsv" +# The actual generator must preserve old observations while the candidate is unapplied. +default_observer() { + if [ "$1" = data-product-controller ]; then + jq -r '.publications[]|select(.consumer=="data-product-controller")|[(.gitTag|ltrimstr("v")),.digest,.signerSHA]|@tsv' "$PUBLISH_BOOTSTRAP_FILE" + else awk -F '\t' -v c="$1" '$1==c {print $3 "\t" $4 "\t" $5}' "$APPROVED_REVISIONS_FILE"; fi +} +default_pin_resolver() { + if [ "$1" = business-site ]; then printf '%s\n' "$record" | jq -r .publication.publisherIdentity + elif [ "$1" = data-product-controller ]; then jq -r '.publications[]|select(.consumer=="data-product-controller")|.mainPinSHA' "$PUBLISH_BOOTSTRAP_FILE" + else awk -F '\t' -v c="$1" '$1==c {print $6}' "$APPROVED_REVISIONS_FILE"; fi +} +canonical_publication_verify() { :; } +main >"$upgrade_test_work/generator-pending" +test "$(jq -r '.bindings[0].kind' "$binding_file")" = published-upgrade +test "$(awk -F '\t' '$1=="business-site" {n++} END {print n+0}' "$APPROVED_IDENTITIES_FILE")" = 0 +jq --arg digest "$next_digest" '.items[0].spec.ref.digest=$digest | .items[0].status.artifact.revision=$digest | + .items[0].metadata.generation=3 | .items[0].status.observedGeneration=3 | .items[0].status.conditions[].observedGeneration=3' \ + "$upgrade_test_work/original-inventory.json" >"$upgrade_test_work/inventory.json" +main >"$upgrade_test_work/generator-observed" +test "$(jq -r '.bindings[0].kind' "$binding_file")" = published-binding +jq -e '.bindings[0]|has("previousBinding")|not' "$binding_file" >/dev/null +awk -F '\t' -v digest="$next_digest" '$1=="business-site" {n++; if($3!="2.0.0" || $4!=digest) exit 1} END {exit n!=1}' "$APPROVED_IDENTITIES_FILE" +cp "$binding_file" "$upgrade_test_work/applied.json" +cp "$APPROVED_IDENTITIES_FILE" "$upgrade_test_work/applied-identities.tsv" +main >"$upgrade_test_work/generator-repeat" +cmp "$upgrade_test_work/applied.json" "$binding_file" +cmp "$upgrade_test_work/applied-identities.tsv" "$APPROVED_IDENTITIES_FILE" +# A subsequent transition must also preserve a real existing applied row. +next_tag=v2.1.0 +next_digest="sha256:$(printf '%064d' 9)" +next_source="$(printf '%040d' 8)" +jq --arg tag "$next_tag" --arg digest "$next_digest" --arg source "$next_source" \ + '.gitTag=$tag|.digest=$digest|.sourceSHA=$source' "$upgrade_test_work/good-receipt.json" >"$upgrade_test_work/receipt.json" +rm -rf "$upgrade_test_work/candidate" +prepare >"$upgrade_test_work/result" +jq -e '.bindings[0].previousBinding.kind=="published-binding"' "$candidate_record" >/dev/null +cp "$candidate_record" "$binding_file" +cp "$upgrade_test_work/candidate/$tenant_path" "$tree/$tenant_path" +record="$(jq -c '.bindings[0]' "$binding_file")" +main >"$upgrade_test_work/generator-second-pending" +cmp "$upgrade_test_work/applied-identities.tsv" "$APPROVED_IDENTITIES_FILE" +printf 'Canonical upgrade: distinct proposed/observed states, portable candidate, real discovery/regeneration and refusal controls passed.\n' From 6433a5f7e8b60631e36828a81704da790a204a0e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 26 Oct 2026 09:09:06 +0100 Subject: [PATCH 2/3] fix(tenants): record the reviewed source upgrade fingerprints --- scripts/rgd-template-static-scan-baseline.tsv | 2 +- scripts/validate-eks-ci-role-policy/approved-surface.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rgd-template-static-scan-baseline.tsv b/scripts/rgd-template-static-scan-baseline.tsv index 236f7837bc..dfc0690bee 100644 --- a/scripts/rgd-template-static-scan-baseline.tsv +++ b/scripts/rgd-template-static-scan-baseline.tsv @@ -40,4 +40,4 @@ 1 k8s/bases/infrastructure/resource-graph-definitions/webapp/resource-graph-definition.yaml.resources/service.yaml KSV-0040 LOW CAUSE-SHA256:e59d76fdca878919309a3203c94916d7258f052f5ac4012683c70a6307f16e07 1 k8s/providers/docker/apps/tenant-ascoachingogvaner.yaml INSTANCE-CONTENT SHA256 7e65481a62deb24c89dca2e5f83969e308e7604e770d97edb15e86ecc948f22c 1 k8s/providers/docker/apps/web-app-wedding-app.yaml INSTANCE-CONTENT SHA256 3d42027c7e549014cad1e717876a6eb72e0a6641f3bf181f7a0e1a70d94306d6 -1 k8s/providers/hetzner/apps/business-site/tenant.yaml INSTANCE-CONTENT SHA256 30edb1a22004f47b034429ad7e64ad24717f3612e9bde324609ac46daeb7b3fd +1 k8s/providers/hetzner/apps/business-site/tenant.yaml INSTANCE-CONTENT SHA256 c33939d19c4a174116ebbcbc3f26fcaae6893873c6c72f32a60a816986bf8792 diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index de46105307..b3b7110966 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -106,7 +106,7 @@ iam.aws.m.upbound.io/v1beta1|Role|aws|eks-ci c40205b3f48d193e8be1a39abda738865b9 kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 24ef73925e497cac2805d326deedd7ff0f2f418520e2908cd5bd16e3ae4d4109 -kro.run/v1alpha1|Tenant|flux-system|business-site 71ab4be37780b6877d5c2676bf1e918f149b2c19a957aeb687d2acef1a99509b +kro.run/v1alpha1|Tenant|flux-system|business-site fb88b22b7924c9f1657617e5174d15e266543c731c80f43635d6ca2e31d37236 kustomize.toolkit.fluxcd.io/v1|Kustomization|ascoachingogvaner|ascoachingogvaner ac00ca29c04de8d2c30a045d583570536a97fc305a1b06c4300da018ecdfb864 From 826cf9d7c4e41ad27f3e8d643dddc424a4eb8d34 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 26 Oct 2026 10:35:53 +0100 Subject: [PATCH 3/3] fix(tenants): verify the replacement publisher before recording adoption --- scripts/canonical-tenant-source.lib.sh | 5 ++- .../tests/test-canonical-source-upgrade.sh | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/scripts/canonical-tenant-source.lib.sh b/scripts/canonical-tenant-source.lib.sh index 814e526d4c..cc90cbec9b 100644 --- a/scripts/canonical-tenant-source.lib.sh +++ b/scripts/canonical-tenant-source.lib.sh @@ -235,10 +235,9 @@ canonical_source_inventory_state() ( trap - EXIT local state_record="$1" state_inventory="$2" if [ "$(printf '%s\n' "$state_record" | jq -r .kind)" = published-upgrade ]; then - local next_record prior_record next_state prior_snapshot prior_uid prior_generation + local next_record prior_record prior_snapshot prior_uid prior_generation next_record="$(printf '%s\n' "$state_record" | jq -c 'del(.previousBinding,.previousObservation)|.kind="published-binding"')" || return 1 - if next_state="$(canonical_source_inventory_state "$next_record" "$state_inventory")"; then - [ "$next_state" = observed ] || return 1 + if canonical_source_observation "$next_record" "$state_inventory" >/dev/null; then printf 'observed\n' return fi diff --git a/scripts/tests/test-canonical-source-upgrade.sh b/scripts/tests/test-canonical-source-upgrade.sh index 2749ce1ce6..dc4c8b3dc0 100755 --- a/scripts/tests/test-canonical-source-upgrade.sh +++ b/scripts/tests/test-canonical-source-upgrade.sh @@ -172,6 +172,41 @@ test "$(awk -F '\t' '$1=="business-site" {n++} END {print n+0}' "$APPROVED_IDENT jq --arg digest "$next_digest" '.items[0].spec.ref.digest=$digest | .items[0].status.artifact.revision=$digest | .items[0].metadata.generation=3 | .items[0].status.observedGeneration=3 | .items[0].status.conditions[].observedGeneration=3' \ "$upgrade_test_work/original-inventory.json" >"$upgrade_test_work/inventory.json" +cp "$upgrade_test_work/inventory.json" "$upgrade_test_work/replacement-ready.json" +cp "$binding_file" "$upgrade_test_work/pending-binding.json" +cp "$APPROVED_REVISIONS_FILE" "$upgrade_test_work/pending-revisions.tsv" +cp "$APPROVED_IDENTITIES_FILE" "$upgrade_test_work/pending-identities.tsv" +cp "$PUBLISH_BOOTSTRAP_FILE" "$upgrade_test_work/pending-bootstrap.json" +# Ready/SourceVerified conditions do not establish which publisher policy was used. +# Exercise the actual generator, preserving the transitional record and all outputs +# on every refusal; reset fixture outputs so each pre-repair control is independent. +unsafe_replacements=0 +for mutation in \ + '.items[0].spec.verify.matchOIDCIdentity[0].issuer=".*"' \ + '.items[0].spec.verify.matchOIDCIdentity[0].subject=".*"' \ + '.items[0].spec.verify.provider="other"' \ + 'del(.items[0].spec.verify)' \ + '.items[0].spec.verify.matchOIDCIdentity += [{issuer:".*",subject:".*"}]' \ + '.items[0].spec.verify.matchOIDCIdentity[0].subject="unapproved-publisher"' \ + 'del(.items[0].metadata.uid)' \ + 'del(.items[0].metadata.resourceVersion)'; do + jq "$mutation" "$upgrade_test_work/replacement-ready.json" >"$upgrade_test_work/inventory.json" + if (main) >"$upgrade_test_work/replacement-result" 2>&1; then + printf 'unsafe replacement accepted: %s\n' "$mutation" >&2 + unsafe_replacements=$((unsafe_replacements + 1)) + else + cmp "$upgrade_test_work/pending-binding.json" "$binding_file" + cmp "$upgrade_test_work/pending-revisions.tsv" "$APPROVED_REVISIONS_FILE" + cmp "$upgrade_test_work/pending-identities.tsv" "$APPROVED_IDENTITIES_FILE" + cmp "$upgrade_test_work/pending-bootstrap.json" "$PUBLISH_BOOTSTRAP_FILE" + fi + cp "$upgrade_test_work/pending-binding.json" "$binding_file" + cp "$upgrade_test_work/pending-revisions.tsv" "$APPROVED_REVISIONS_FILE" + cp "$upgrade_test_work/pending-identities.tsv" "$APPROVED_IDENTITIES_FILE" + cp "$upgrade_test_work/pending-bootstrap.json" "$PUBLISH_BOOTSTRAP_FILE" +done +[ "$unsafe_replacements" -eq 0 ] || { printf '%s unsafe replacement controls admitted\n' "$unsafe_replacements" >&2; exit 1; } +cp "$upgrade_test_work/replacement-ready.json" "$upgrade_test_work/inventory.json" main >"$upgrade_test_work/generator-observed" test "$(jq -r '.bindings[0].kind' "$binding_file")" = published-binding jq -e '.bindings[0]|has("previousBinding")|not' "$binding_file" >/dev/null