From 0f682175f4851b86aebd801d7aaad5e0e67bf25b Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Thu, 20 Aug 2026 16:42:23 +0200 Subject: [PATCH] refactor(platform): call the shared deploy-check instead of a vendored copy platform/render-local.sh was a ~400-line copy of logic that also lived in actions/deploy-preview/run.sh, duplicated into every service repository. The copies drifted, and this one could not run at all: it passed a directory where --output names a file, and called artifact leak-scan and artifact validate-raw-manifests, neither of which the toolkit publishes. The logic now lives in @jorisjonkers-dev/deploy-check, which the deploy-preview action runs as well, so a local result and a CI result cannot disagree. What is left here is a wrapper that pulls the cluster context and invokes the pinned checker. The schema version and context ref are read out of this repository's own workflows rather than restated in the script. The previous copy hardcoded both and both went stale: the schema version by four minor releases, the context digest by two republications. There is now one place to change either. --- PLATFORM.md | 14 ++ platform/render-local.sh | 455 ++++++--------------------------------- 2 files changed, 83 insertions(+), 386 deletions(-) diff --git a/PLATFORM.md b/PLATFORM.md index cf53e72..9abfc43 100644 --- a/PLATFORM.md +++ b/PLATFORM.md @@ -6,6 +6,20 @@ ./platform/render-local.sh ``` +Requires `node`, plus either `oras` (to pull the cluster context by digest) or +`--context-dir` pointing at an already-pulled context package. Installing the +pinned toolkit reads `npm.pkg.github.com`, which needs a token even for public +packages: + +```bash +export GITHUB_TOKEN="$(gh auth token)" +``` + +The check runs `@jorisjonkers-dev/deploy-check`, the same code the +`deploy-preview` action runs, so a local result and a CI result agree. The +schema version and context ref are read from this repository's workflows rather +than restated in the script. + Review `out/scorecard.md` for any failures before committing. ## Full deployment flow diff --git a/platform/render-local.sh b/platform/render-local.sh index e090273..85f933a 100755 --- a/platform/render-local.sh +++ b/platform/render-local.sh @@ -1,401 +1,84 @@ #!/usr/bin/env bash -# render-local.sh — local CI-parity render for knowledge. +# render-local.sh -- run the same deployment checks CI runs, locally. # -# Mirrors what deploy-validate.yml / deploy-artifact.yml do in CI: -# validate -> render -> kubeconform -> leak-scan -> scorecard +# This was ~400 lines of bash duplicated into every service repository, and the +# copies drifted from the CI implementation they were meant to mirror. The logic +# now lives in @jorisjonkers-dev/deploy-check, which the deploy-preview action +# runs too, so a local result and a CI result cannot disagree. # -# Usage: -# ./platform/render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] +# The schema version and context ref are read out of this repository's own +# workflow rather than restated here. The previous copy hardcoded both and both +# went stale -- the schema version by four minor releases, the context digest by +# two republications. # -# Options: -# --diff compare rendered output against committed fixtures; exit 1 on drift -# --context-dir use a local context directory (bypasses digest requirement for local dev) -# --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard -# (dry mode: works without the npm package installed) +# Usage: +# ./platform/render-local.sh [--context-dir DIR] [-- ] # -# The scorecard functions are pure shell + jq so they stay testable without -# network access or the @jorisjonkers-dev/deploy-config-schema npm package. - +# Requires node, and either oras (to pull the context by digest) or +# --context-dir pointing at an already-pulled context package. Installing the +# pinned toolkit reads npm.pkg.github.com, so export GITHUB_TOKEN (or +# NODE_AUTH_TOKEN) first: export GITHUB_TOKEN="$(gh auth token)". set -euo pipefail +DEPLOY_CHECK_VERSION="0.15.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" -FLAG_DIFF=false -FLAG_SCORECARD_ONLY=false -CONTEXT_DIR="${CONTEXT_DIR:-}" -CONTEXT_REF="${CONTEXT_REF:-ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:9479bc22ae11183c0b68f257d2c1a21455be8c3cff602d3a491ea3ff31d01fe3}" -OUT_DIR="${OUT_DIR:-$REPO_ROOT/out}" -RESOLVED_VERSION="" -NPM_PROVENANCE_VERIFIED="${NPM_PROVENANCE_VERIFIED:-true}" - -ENVS=(production) -FRAGMENTS=( - kubernetes-workload-fragment - traefik-route-fragment - gatus-endpoint-fragment - edge-catalog-fragment - image-metadata-fragment -) - -log() { echo "[render-local] $*" >&2; } -warn() { echo "[render-local] WARNING: $*" >&2; } -fail() { - echo "ERROR: $*" >&2 - exit 1 -} - -usage() { - cat <<'USAGE' -Usage: render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] - -Options: - --diff compare rendered output against committed fixtures; exit 1 on drift - --context-dir use a local context directory (bypasses digest requirement for local dev) - --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard - -h, --help show this help -USAGE -} - -parse_args() { - while [ $# -gt 0 ]; do - case "$1" in - --diff) - FLAG_DIFF=true - shift - ;; - --context-dir) - [ $# -ge 2 ] || fail "--context-dir requires a PATH argument" - CONTEXT_DIR="$2" - shift 2 - ;; - --scorecard-only) - FLAG_SCORECARD_ONLY=true - shift - ;; - -h | --help) - usage - exit 0 - ;; - *) - usage >&2 - fail "unknown option: $1" - ;; - esac - done -} - -resolve_schema_version() { - if [ -n "${SCHEMA_VERSION:-}" ]; then - RESOLVED_VERSION="$SCHEMA_VERSION" - log "Using SCHEMA_VERSION from env: $RESOLVED_VERSION" - elif [ -f "$REPO_ROOT/.platform/deploy-version" ]; then - RESOLVED_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/.platform/deploy-version")" - log "Using schema version from .platform/deploy-version: $RESOLVED_VERSION" - else - RESOLVED_VERSION="0.20.0" - log "Using baked-in schema version: $RESOLVED_VERSION" - fi -} - -install_schema() { - log "Installing @jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" - npm install --global "@jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" \ - --registry https://npm.pkg.github.com \ - || fail "failed to install deploy-config-schema@$RESOLVED_VERSION" -} - -npm_audit_and_scorecard_init() { - if npm audit signatures --scope @jorisjonkers-dev >/dev/null 2>&1; then - NPM_PROVENANCE_VERIFIED="true" - else - NPM_PROVENANCE_VERIFIED="false" - warn "npm audit signatures failed; npm_signatures_verified=fail" - fi -} +fail() { echo "ERROR: $*" >&2; exit 1; } -require_digest_ref() { +CONTEXT_DIR="" +EXTRA=() +while [ $# -gt 0 ]; do case "$1" in - *@sha256:*) ;; - *) fail "E_CONTEXT_REF_NOT_PINNED: context ref must be digest-pinned (got: $1). Pass --context-dir for local dev." ;; + --context-dir) [ $# -ge 2 ] || fail "--context-dir requires a path"; CONTEXT_DIR="$2"; shift 2 ;; + --) shift; EXTRA=("$@"); break ;; + -h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) fail "unknown option: $1 (pass extra deploy-check flags after --)" ;; esac -} - -# Locate cluster-context-public.yml inside a context directory, as CI does. -find_cluster_context() { - local match - match=$(find "$1" -type f -name 'cluster-context-public.yml' 2>/dev/null | sort | head -1) - [ -n "$match" ] || fail "E_CONTEXT_FILE_MISSING: cluster-context-public.yml not found under $1" - printf '%s\n' "$match" -} - -pull_or_use_local_context() { - if [ -n "$CONTEXT_DIR" ]; then - CONTEXT_PKG_DIR="$CONTEXT_DIR" - warn "--context-dir bypasses OCI digest requirement. Not suitable for CI." - else - require_digest_ref "$CONTEXT_REF" - log "Pulling public cluster context: $CONTEXT_REF" - mkdir -p "$OUT_DIR/context-pkg" - oras pull "$CONTEXT_REF" --output "$OUT_DIR/context-pkg" \ - || fail "failed to pull context package $CONTEXT_REF" - CONTEXT_PKG_DIR="$OUT_DIR/context-pkg" - fi - CONTEXT_FILE="$(find_cluster_context "$CONTEXT_PKG_DIR")" -} - -render_all_fragments() { - local env fragment - for env in "${ENVS[@]}"; do - mkdir -p "$OUT_DIR/manifests/$env" "$OUT_DIR/metadata/$env" - for fragment in "${FRAGMENTS[@]}"; do - log "render $fragment ($env)" - deploy-config-schema render "$fragment" "$SCRIPT_DIR" \ - --env "$env" \ - --context "$CONTEXT_REF" \ - --context-dir "$CONTEXT_PKG_DIR" \ - --images "$SCRIPT_DIR/images.lock.json" \ - --output "$OUT_DIR/manifests/$env/$fragment.yaml" \ - || fail "render failed for $fragment ($env)" - done - deploy-config-schema artifact emit-kustomization-health \ - --deployment "$SCRIPT_DIR/deployment.yml" \ - --env "$env" \ - --image-digests "$SCRIPT_DIR/images.lock.json" \ - --out "$OUT_DIR/metadata/$env/kustomization-health.yml" \ - || fail "emit-kustomization-health failed ($env)" - done -} - -validate_rendered_output() { - local env - # A fragment is a schema document wrapping its payload, so kubeconform and - # kustomize -- both of which expect apiVersion/kind at the top level -- always - # fail when pointed at out/manifests. CI does not validate that shape at all; - # render correctness comes from the schema CLI. The applyable objects are what - # can be checked, so lift them first and validate those. - for env in "${ENVS[@]}"; do - log "emit-apply-bundle ($env)" - deploy-config-schema artifact emit-apply-bundle \ - --manifests "$OUT_DIR/manifests/$env" \ - --out "$OUT_DIR/apply/$env" \ - || fail "emit-apply-bundle failed for $env" - if command -v kubeconform >/dev/null 2>&1; then - log "kubeconform ($env)" - kubeconform -schema-location default -strict "$OUT_DIR/apply/$env" \ - || fail "kubeconform validation failed for $env" - else - warn "kubeconform not installed; skipping schema validation of $env" - fi - log "kustomize build dry-run ($env)" - kustomize build "$OUT_DIR/apply/$env" >/dev/null \ - || fail "kustomize build failed for $env" - done - if grep -rl 'kind: Secret' "$OUT_DIR/manifests/" 2>/dev/null | grep -q .; then - fail "E_FORBIDDEN_KIND: kind=Secret found in rendered manifests" - fi -} - -# The toolkit exposes only emit-apply-bundle, emit-contract and -# emit-kustomization-health under `artifact`; there is no -# validate-raw-manifests subcommand in any published version, so a -# rawManifests declaration cannot be checked locally. Say so rather than -# scoring an unexplained fail. -reject_unsupported_raw_manifests() { - grep -qE '^[[:space:]]*rawManifests:' <(deployment_source "$SCRIPT_DIR/deployment.yml") || return 0 - fail "E_RAW_MANIFESTS_UNSUPPORTED: deployment.yml declares rawManifests, but deploy-config-schema exposes no artifact validate-raw-manifests subcommand. CI cannot guard it either." -} - -emit_contract() { - deploy-config-schema artifact emit-contract \ - --artifact-name "knowledge" \ - --context-ref "$CONTEXT_REF" \ - --environments "production" \ - --images "$SCRIPT_DIR/images.lock.json" \ - --deployment "$SCRIPT_DIR/deployment.yml" \ - --context "$CONTEXT_FILE" \ - --provenance-verified "$NPM_PROVENANCE_VERIFIED" \ - --out "$OUT_DIR/artifact-contract.yaml" \ - || fail "emit-contract failed" -} - -deployment_source() { - sed 's/#.*$//' "$1" -} - -scorecard_images_pinned() { - local lock="$1" ref - [ -f "$lock" ] || { - echo "fail" - return - } - while IFS= read -r ref; do - case "$ref" in - *@sha256:*) ;; - *) - echo "fail" - return - ;; - esac - done < <(jq -r 'if type == "array" then map(.ref) else [.[]] end | .[]' "$lock") - echo "pass" -} - -scorecard_route_owner_authmode() { - local src="$1" - if ! grep -qE '^[[:space:]]*routes:' <<<"$src"; then - echo "not_applicable" - elif grep -qE '^[[:space:]]*owner:' <<<"$src" && grep -qE '^[[:space:]]*authMode:' <<<"$src"; then - echo "pass" - else - echo "fail" - fi -} - -scorecard_stateful_policy() { - local src="$1" - if ! grep -qE '^[[:space:]]*stateful:[[:space:]]*true' <<<"$src"; then - echo "not_applicable" - elif grep -qE '^[[:space:]]*migrationPolicy:' <<<"$src"; then - echo "pass" - else - echo "fail" - fi -} - -scorecard_raw_manifests() { - local src="$1" - if ! grep -qE '^[[:space:]]*rawManifests:' <<<"$src"; then - echo "not_applicable" - elif [ -f "$OUT_DIR/raw-manifests-guard.json" ]; then - echo "pass" - else - echo "fail" - fi -} - -compute_scorecard() { - local deployment="$1" lock="$2" - local src - src="$(deployment_source "$deployment")" - - local schema_pinned=fail context_pinned=fail health_declared=fail - local rollback=fail no_raw_secrets=pass npm_flag=fail - local workload_count health_count - - grep -qE '^[[:space:]]*schemaVersion:[[:space:]]*"?[0-9]' <<<"$src" && schema_pinned=pass - case "$CONTEXT_REF" in *@sha256:*) context_pinned=pass ;; esac - - workload_count="$(grep -cE '^[[:space:]]*-[[:space:]]*name:' <<<"$src" || true)" - health_count="$(grep -cE '^[[:space:]]*health:' <<<"$src" || true)" - if [ "$workload_count" -gt 0 ] && [ "$health_count" -ge "$workload_count" ]; then - health_declared=pass - fi - - if grep -qE '^[[:space:]]*rollbackTargetRetention:' <<<"$src" \ - && grep -qE '^[[:space:]]*acknowledged:[[:space:]]*true' <<<"$src"; then - rollback=pass - fi - - if [ -d "$SCRIPT_DIR/raw-manifests" ] \ - && grep -rl 'kind: Secret' "$SCRIPT_DIR/raw-manifests" 2>/dev/null | grep -q .; then - no_raw_secrets=fail - fi - if [ -d "$OUT_DIR/manifests" ] \ - && grep -rl 'kind: Secret' "$OUT_DIR/manifests" 2>/dev/null | grep -q .; then - no_raw_secrets=fail - fi - - [ "$NPM_PROVENANCE_VERIFIED" = "true" ] && npm_flag=pass - - mkdir -p "$OUT_DIR" - jq -n \ - --arg schema_pinned "$schema_pinned" \ - --arg context_pinned "$context_pinned" \ - --arg no_latest_images "$(scorecard_images_pinned "$lock")" \ - --arg health_declared "$health_declared" \ - --arg route_owner_authmode_declared "$(scorecard_route_owner_authmode "$src")" \ - --arg rollback_retention_acknowledged "$rollback" \ - --arg no_raw_secrets "$no_raw_secrets" \ - --arg stateful_policy_declared "$(scorecard_stateful_policy "$src")" \ - --arg raw_manifests_guarded "$(scorecard_raw_manifests "$src")" \ - --arg npm_signatures_verified "$npm_flag" \ - '{ - schema_pinned: $schema_pinned, - context_pinned: $context_pinned, - no_latest_images: $no_latest_images, - health_declared: $health_declared, - route_owner_authmode_declared: $route_owner_authmode_declared, - rollback_retention_acknowledged: $rollback_retention_acknowledged, - no_raw_secrets: $no_raw_secrets, - stateful_policy_declared: $stateful_policy_declared, - raw_manifests_guarded: $raw_manifests_guarded, - npm_signatures_verified: $npm_signatures_verified - }' > "$OUT_DIR/scorecard.json" -} - -write_scorecard_outputs() { - { - echo "# Deployment readiness scorecard — knowledge" - echo "" - echo "| Check | Status |" - echo "|-------|--------|" - jq -r 'to_entries[] | "| \(.key) | \(.value) |"' "$OUT_DIR/scorecard.json" - echo "" - echo "pass = ready · fail = blocks deployment · not_applicable = check does not apply" - } > "$OUT_DIR/scorecard.md" - cat "$OUT_DIR/scorecard.md" -} - -compare_against_fixtures() { - local drift - drift="$(diff -rq "$OUT_DIR" "$SCRIPT_DIR/fixtures" 2>&1 || true)" - if [ -n "$drift" ]; then - echo "ERROR: Rendered output differs from committed fixtures:" >&2 - echo "$drift" >&2 - echo "Run render-local.sh without --diff to regenerate fixtures, then commit." >&2 - exit 1 - fi - log "No drift detected between rendered output and committed fixtures." -} - -exit_based_on_scorecard() { - local fails - fails="$(jq '[to_entries[] | select(.value == "fail")] | length' "$OUT_DIR/scorecard.json")" - if [ "$fails" -gt 0 ]; then - echo "ERROR: Scorecard has $fails fail(s). See out/scorecard.md for details." >&2 - exit 1 - fi - log "All scorecard checks passed." -} - -main() { - parse_args "$@" - - if [ "$FLAG_SCORECARD_ONLY" = true ]; then - log "scorecard-only dry mode (npm package not required)" - compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" - write_scorecard_outputs - exit_based_on_scorecard - return - fi - - resolve_schema_version - install_schema - npm_audit_and_scorecard_init - pull_or_use_local_context - render_all_fragments - validate_rendered_output - reject_unsupported_raw_manifests - emit_contract - compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" - write_scorecard_outputs - if [ "$FLAG_DIFF" = true ]; then - compare_against_fixtures - fi - exit_based_on_scorecard -} +done + +# Single source of truth: whatever the workflows pin is what a local run uses. +value_from_workflows() { + local key="$1" + grep -rhoE "^[[:space:]]*${key}:[[:space:]]*[^[:space:]]+" .github/workflows/*.yml 2>/dev/null \ + | awk '{print $2}' | sort -u | head -1 +} + +SCHEMA_VERSION="$(value_from_workflows 'schema-version')" +CONTEXT_REF="$(value_from_workflows 'context-ref')" +[ -n "$SCHEMA_VERSION" ] || fail "no schema-version found in .github/workflows/*.yml" +[ -n "$CONTEXT_REF" ] || fail "no context-ref found in .github/workflows/*.yml" + +if [ -z "$CONTEXT_DIR" ]; then + command -v oras >/dev/null 2>&1 \ + || fail "oras is not installed; install it or pass --context-dir " + CONTEXT_DIR="$(mktemp -d)" + echo "[render-local] pulling context $CONTEXT_REF" >&2 + oras pull "$CONTEXT_REF" --output "$CONTEXT_DIR" >&2 \ + || fail "oras pull failed for $CONTEXT_REF" +fi -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - main "$@" +# GitHub Packages requires a token even for public packages, and npx would +# otherwise resolve the scope against registry.npmjs.org and 404. Build a +# throwaway npmrc rather than touching the developer's own. +TOKEN="${GITHUB_TOKEN:-${NODE_AUTH_TOKEN:-}}" +if [ -z "$TOKEN" ] && command -v gh >/dev/null 2>&1; then + TOKEN="$(gh auth token 2>/dev/null || true)" fi +[ -n "$TOKEN" ] || fail "no GitHub token: export GITHUB_TOKEN=\"\$(gh auth token)\" (npm.pkg.github.com requires auth even for public packages)" + +NPMRC="$(mktemp)" +trap 'rm -f "$NPMRC"' EXIT +printf '@jorisjonkers-dev:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$TOKEN" > "$NPMRC" +export npm_config_userconfig="$NPMRC" +# The toolkit install inside deploy-check reads this too. +export NODE_AUTH_TOKEN="$TOKEN" + +echo "[render-local] deploy-check ${DEPLOY_CHECK_VERSION}, toolkit ${SCHEMA_VERSION}" >&2 +exec npx --yes "@jorisjonkers-dev/deploy-check@${DEPLOY_CHECK_VERSION}" preview \ + --deploy-dir platform \ + --schema-version "$SCHEMA_VERSION" \ + --context-ref "$CONTEXT_REF" \ + --context-dir "$CONTEXT_DIR" \ + "${EXTRA[@]+"${EXTRA[@]}"}"