diff --git a/actions/deploy-artifact/install-tooling.sh b/actions/deploy-artifact/install-tooling.sh index bff1033..0922fb6 100755 --- a/actions/deploy-artifact/install-tooling.sh +++ b/actions/deploy-artifact/install-tooling.sh @@ -96,13 +96,36 @@ install_yq() { } main() { + local only="" + while [[ $# -gt 0 ]]; do + case "$1" in + --only) + only="${2:?--only requires a comma-separated tool list}" + shift 2 + ;; + *) + fail "unknown argument: $1 (usage: install-tooling.sh [--only oras,yq,...])" + ;; + esac + done + + local -a tools=(oras cosign syft kubeconform kustomize yq) + if [[ -n "$only" ]]; then + IFS=',' read -ra tools <<< "$only" + fi + printf '::group::Installing deployment tooling\n' - install_oras - install_cosign - install_syft - install_kubeconform - install_kustomize - install_yq + for tool in "${tools[@]}"; do + case "$tool" in + oras) install_oras ;; + cosign) install_cosign ;; + syft) install_syft ;; + kubeconform) install_kubeconform ;; + kustomize) install_kustomize ;; + yq) install_yq ;; + *) fail "unknown tool: ${tool} (valid: oras,cosign,syft,kubeconform,kustomize,yq)" ;; + esac + done printf '::endgroup::\n' } diff --git a/actions/deploy-artifact/run.sh b/actions/deploy-artifact/run.sh index 8ad9aa3..5bd4d63 100755 --- a/actions/deploy-artifact/run.sh +++ b/actions/deploy-artifact/run.sh @@ -17,6 +17,28 @@ warn() { printf '::warning::%s\n' "$*" >&2 } +# find_cluster_context : locate cluster-context-public.yml inside a +# pulled context package tree. The published artifact carries it under +# context/public/, but the layout is discovered rather than hardcoded so a +# layout change fails loud in one place. Prefers a context/public/ match, +# falls back to the first match anywhere in the tree. Prints the path; +# returns 1 if no match exists. +find_cluster_context() { + local root="$1" + local matches + matches=$(find "$root" -type f -name 'cluster-context-public.yml' 2>/dev/null | sort || true) + if [[ -z "$matches" ]]; then + return 1 + fi + local preferred + preferred=$(printf '%s\n' "$matches" | grep '/context/public/' | head -1 || true) + if [[ -n "$preferred" ]]; then + printf '%s' "$preferred" + else + printf '%s' "$(printf '%s\n' "$matches" | head -1)" + fi +} + emit_gate_summary() { local gate="$1" local check_name="$2" @@ -133,7 +155,7 @@ main() { fail "E_IMAGE_LOCK_MISSING: expected at ${image_lock_path} (was image-lock-artifact set?)" fi - # (5) Pull context package via oras + # (5) Pull context package via oras (once; reused across all envs and subcommands) mkdir -p context-pkg if ! oras pull "$context_ref" --output context-pkg/ 2>&1; then emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ @@ -141,8 +163,15 @@ main() { fail "E_CONTEXT_PULL_FAILED: oras pull ${context_ref} failed" fi - # (6) Validate pulled context - deploy-config-schema validate context-pkg/cluster-context-public.yml + # (6) Locate cluster-context-public.yml in the pulled tree (usually under + # context/public/) and validate it; fail loud if it is absent. + local context_file + if ! context_file=$(find_cluster_context context-pkg); then + emit_gate_summary "deploy-artifact" "Deploy Artifact" "fail" \ + "context-file-missing" "none" + fail "E_CONTEXT_FILE_MISSING: cluster-context-public.yml not found in pulled context package ${context_ref}; pulled files: $(find context-pkg -type f 2>/dev/null | tr '\n' ' ')" + fi + deploy-config-schema validate "$context_file" # (7) Process environments: CRLF strip, trim, validate name, dedupe local envs_raw @@ -171,7 +200,11 @@ main() { fail "E_NO_VALID_ENVIRONMENTS" fi - # (8) Render 5 fragments per env + emit kustomization health + # (8) Render 5 fragments per env + emit kustomization health. + # CLI: render --env --images + # --context --context-path [--output ] + # The context package was pulled once in step (5) and the context file + # discovered in step (6); pass via --context --context-path. for env in "${envs[@]}"; do mkdir -p "out/manifests/${env}" "out/metadata/${env}" for fragment in \ @@ -181,10 +214,10 @@ main() { edge-catalog-fragment \ image-metadata-fragment do - deploy-config-schema render "$fragment" "$deploy_dir/deployment.yml" \ + deploy-config-schema render "$fragment" "$deploy_dir" \ --env "$env" \ - --context context-pkg/cluster-context-public.yml \ - --context-ref "$context_ref" \ + --context "$context_ref" \ + --context-path "$context_file" \ --images "$image_lock_path" \ --output "out/manifests/${env}" done @@ -238,16 +271,28 @@ main() { } fi - # (13) Emit artifact contract (includes SC-9 render hash) + # (13) Emit artifact contract (includes SC-9 render hash). + # CLI: artifact emit-contract + # --artifact-name + # --environments + # --images + # --context-ref + # --deployment + # --context + # --out + # [--provenance-verified true|false] + # [--output-root ] local envs_joined envs_joined="$(printf '%s,' "${envs[@]}" | sed 's/,$//')" deploy-config-schema artifact emit-contract \ --artifact-name "$artifact_name" \ - --schema-version "$schema_version" \ - --context-ref "$context_ref" \ --environments "$envs_joined" \ --images "$image_lock_path" \ + --context-ref "$context_ref" \ + --deployment "$deploy_dir/deployment.yml" \ + --context "$context_file" \ --provenance-verified "$provenance_verified" \ + --output-root out \ --out out/artifact-contract.yaml # (14) Export render-hash to GITHUB_OUTPUT (correction #8: also declared in outputs block) @@ -259,4 +304,8 @@ main() { printf 'render-hash=%s\n' "$render_hash" >> "${GITHUB_OUTPUT:-/dev/null}" } -main "$@" +# Allow sourcing for unit tests of the helpers (find_cluster_context, ...); +# execute main only when invoked directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/actions/deploy-preview/action.yml b/actions/deploy-preview/action.yml index 9d01507..4cecdff 100644 --- a/actions/deploy-preview/action.yml +++ b/actions/deploy-preview/action.yml @@ -31,6 +31,12 @@ inputs: runs: using: composite steps: + # The preview pulls the cluster context package by digest; oras and yq are + # required. Reuse the pinned installer from the deploy-artifact action. + - name: Install deployment tooling (oras, yq) + shell: bash + run: bash "${{ github.action_path }}/../deploy-artifact/install-tooling.sh" --only oras,yq + - name: Render and preview deployment shell: bash env: diff --git a/actions/deploy-preview/run.sh b/actions/deploy-preview/run.sh index df34881..2f5adb9 100755 --- a/actions/deploy-preview/run.sh +++ b/actions/deploy-preview/run.sh @@ -17,6 +17,28 @@ warn() { printf '::warning::%s\n' "$*" >&2 } +# find_cluster_context : locate cluster-context-public.yml inside a +# pulled context package tree. The published artifact carries it under +# context/public/, but the layout is discovered rather than hardcoded so a +# layout change fails loud in one place. Prefers a context/public/ match, +# falls back to the first match anywhere in the tree. Prints the path; +# returns 1 if no match exists. +find_cluster_context() { + local root="$1" + local matches + matches=$(find "$root" -type f -name 'cluster-context-public.yml' 2>/dev/null | sort || true) + if [[ -z "$matches" ]]; then + return 1 + fi + local preferred + preferred=$(printf '%s\n' "$matches" | grep '/context/public/' | head -1 || true) + if [[ -n "$preferred" ]]; then + printf '%s' "$preferred" + else + printf '%s' "$(printf '%s\n' "$matches" | head -1)" + fi +} + emit_gate_summary() { local gate="$1" local check_name="$2" @@ -70,6 +92,13 @@ install_schema_cli() { # Scorecard computation (SC-11) # --------------------------------------------------------------------------- +# count_lines : count non-empty lines in a string without || echo 0 pitfall. +# grep -c already returns 0 when no matches; the || true prevents set -e from +# firing on exit code 1 (no match), and the result is a single clean integer. +count_lines() { + printf '%s' "$1" | grep -c '^.' || true +} + compute_scorecard() { local deployment_yml="$1" local contract_yaml="$2" @@ -110,9 +139,13 @@ compute_scorecard() { fi if [[ "$route_owner_authmode_declared" == "pass" ]]; then if yq '.spec.workloads[].routes[].authMode // ""' "$deployment_yml" 2>/dev/null | grep -q '^$'; then + local authmode_out + authmode_out=$(yq '.spec.workloads[].routeDefaults.authMode // ""' "$deployment_yml" \ + 2>/dev/null || true) local has_defaults=0 - has_defaults=$(yq '.spec.workloads[].routeDefaults.authMode // ""' "$deployment_yml" \ - 2>/dev/null | grep -c '^[a-z]' || echo 0) + has_defaults=$(count_lines "$authmode_out") + # count_lines counts all lines; re-filter to non-empty alpha values + has_defaults=$(printf '%s' "$authmode_out" | grep -c '^[a-z]' || true) if [[ "$has_defaults" -eq 0 ]]; then route_owner_authmode_declared="fail" fi @@ -122,13 +155,16 @@ compute_scorecard() { # rollback_retention_acknowledged local rollback_retention_acknowledged="fail" + local ack_out + ack_out=$(yq '.spec.workloads[].rollbackTargetRetention.acknowledged' "$deployment_yml" \ + 2>/dev/null || true) local ack=0 - ack=$(yq '.spec.workloads[].rollbackTargetRetention.acknowledged' "$deployment_yml" \ - 2>/dev/null | grep -c 'true' || echo 0) + ack=$(printf '%s' "$ack_out" | grep -c 'true' || true) local days=0 days=$(yq '.spec.workloads[].rollbackTargetRetention.minimumDays' "$deployment_yml" \ - 2>/dev/null | sort -n | tail -1 || echo 0) - if [[ "$ack" -gt 0 && "${days:-0}" -ge 90 ]]; then + 2>/dev/null | sort -n | tail -1 || true) + days="${days:-0}" + if [[ "$ack" -gt 0 && "${days}" -ge 90 ]]; then rollback_retention_acknowledged="pass" fi @@ -140,9 +176,11 @@ compute_scorecard() { # stateful_policy_declared: not_applicable if no stateful workloads local stateful_policy_declared="not_applicable" + local stateful_out + stateful_out=$(yq '.spec.workloads[] | select(.stateful == true) | .name' "$deployment_yml" \ + 2>/dev/null || true) local has_stateful=0 - has_stateful=$(yq '.spec.workloads[] | select(.stateful == true) | .name' "$deployment_yml" \ - 2>/dev/null | grep -c '.' || echo 0) + has_stateful=$(printf '%s' "$stateful_out" | grep -c '.' || true) if [[ "$has_stateful" -gt 0 ]]; then stateful_policy_declared="pass" if yq '.spec.workloads[] | select(.stateful == true) | .migrationPolicy // ""' \ @@ -153,9 +191,11 @@ compute_scorecard() { # raw_manifests_guarded: not_applicable if no rawManifests.enabled workloads local raw_manifests_guarded="not_applicable" + local raw_out + raw_out=$(yq '.spec.workloads[] | select(.rawManifests.enabled == true) | .name' \ + "$deployment_yml" 2>/dev/null || true) local has_raw=0 - has_raw=$(yq '.spec.workloads[] | select(.rawManifests.enabled == true) | .name' \ - "$deployment_yml" 2>/dev/null | grep -c '.' || echo 0) + has_raw=$(printf '%s' "$raw_out" | grep -c '.' || true) if [[ "$has_raw" -gt 0 ]]; then raw_manifests_guarded="pass" if [[ ! -f out/raw-manifests-guard.json ]]; then @@ -355,38 +395,119 @@ main() { mkdir -p out/manifests/preview - # (1) Render all 5 fragments in validate-only mode - for fragment in \ - kubernetes-workload-fragment \ - traefik-route-fragment \ - gatus-endpoint-fragment \ - edge-catalog-fragment \ - image-metadata-fragment - do - deploy-config-schema render "$fragment" "${deploy_dir}/deployment.yml" \ - --env "$environments" \ - --context-ref "$context_ref" \ - --images "$image_lock_path" \ - --output out/manifests/preview \ - 2>&1 || true - # validate-only: failures are surfaced in scorecard, not fatal here + # (0) Pull the context package once via oras. Service repos never hold the + # cluster context — the action fetches it by digest. The CLI never pulls: + # --context records the digest ref, --context-path reads a local file. + local context_root="${RUNNER_TEMP:-/tmp}/deploy-preview-context" + rm -rf "$context_root" + mkdir -p "$context_root" + if ! oras pull "$context_ref" --output "$context_root" 2>&1; then + emit_gate_summary "deploy-validate" "Deploy Validate" "fail" \ + "context-pull-failed" "none" + fail "E_CONTEXT_PULL_FAILED: oras pull ${context_ref} failed; rendering impossible" + fi + + # Locate cluster-context-public.yml in the pulled tree (usually under + # context/public/); fail loud with the tree listing if it is absent. + local context_file + if ! context_file=$(find_cluster_context "$context_root"); then + emit_gate_summary "deploy-validate" "Deploy Validate" "fail" \ + "context-file-missing" "none" + fail "E_CONTEXT_FILE_MISSING: cluster-context-public.yml not found in pulled context package ${context_ref}; pulled files: $(find "$context_root" -type f 2>/dev/null | tr '\n' ' ')" + fi + + # (1) Render all 5 fragments per environment. + # The CLI takes a single --env value; loop over environments. + # Pass the pulled context file via --context + --context-path + # so the recorded ref stays the digest-pinned one. + # Render failures are captured and surfaced in the scorecard; the action + # exits nonzero at the end if any fragment failed (rendering impossible). + local IFS_save="$IFS" + IFS=',' read -ra preview_envs <<< "${environments}" + IFS="$IFS_save" + + # Track per-env render failures for scorecard + declare -A render_failures=() + + for env in "${preview_envs[@]}"; do + env="$(printf '%s' "$env" | xargs 2>/dev/null || printf '%s' "$env")" + [[ -z "$env" ]] && continue + mkdir -p "out/manifests/preview/${env}" + for fragment in \ + kubernetes-workload-fragment \ + traefik-route-fragment \ + gatus-endpoint-fragment \ + edge-catalog-fragment \ + image-metadata-fragment + do + local render_stderr_file + render_stderr_file=$(mktemp) + local render_exit=0 + deploy-config-schema render "$fragment" "$deploy_dir" \ + --env "$env" \ + --context "$context_ref" \ + --context-path "$context_file" \ + --images "$image_lock_path" \ + --output "out/manifests/preview/${env}" \ + 2>"$render_stderr_file" || render_exit=$? + + if [[ "$render_exit" -ne 0 ]]; then + local diag + diag=$(cat "$render_stderr_file") + warn "render ${fragment} env=${env} failed (exit=${render_exit}): ${diag}" + render_failures["${env}/${fragment}"]="${diag}" + fi + rm -f "$render_stderr_file" + done done - # (2) Emit artifact contract (preview mode; provenance_verified=false) + # (2) Emit artifact contract (preview mode; provenance_verified=false). + # The CLI requires --deployment, --context (cluster-context.yml path), + # --context-ref (digest ref), --environments (comma-separated), --images, + # --artifact-name, and --out. + local emit_stderr_file + emit_stderr_file=$(mktemp) + local emit_exit=0 deploy-config-schema artifact emit-contract \ --artifact-name preview \ - --schema-version "$schema_version" \ - --context-ref "$context_ref" \ --environments "$environments" \ --images "$image_lock_path" \ + --context-ref "$context_ref" \ + --deployment "${deploy_dir}/deployment.yml" \ + --context "$context_file" \ --provenance-verified false \ --out out/artifact-contract.yaml \ - 2>&1 || true + 2>"$emit_stderr_file" || emit_exit=$? - # (3) Compute SC-11 scorecard + local emit_diag="" + if [[ "$emit_exit" -ne 0 ]]; then + emit_diag=$(cat "$emit_stderr_file") + warn "artifact emit-contract failed (exit=${emit_exit}): ${emit_diag}" + fi + rm -f "$emit_stderr_file" + + # (3) Compute SC-11 scorecard. + # If emit-contract failed the contract file may not exist; scorecard handles missing files. local scorecard scorecard=$(compute_scorecard "${deploy_dir}/deployment.yml" out/artifact-contract.yaml) + # If rendering was impossible (emit-contract failed), mark scorecard fields fail. + if [[ "$emit_exit" -ne 0 ]]; then + scorecard=$(printf '%s' "$scorecard" \ + | jq \ + --arg diag "${emit_diag}" \ + '.context_pinned = "fail" | .no_latest_images = "fail"') + fi + + # If any render failed, mark no_raw_secrets fail with diagnostic + if [[ "${#render_failures[@]}" -gt 0 ]]; then + scorecard=$(printf '%s' "$scorecard" \ + | jq '.no_raw_secrets = "fail"') + for key in "${!render_failures[@]}"; do + warn "render failure [${key}]: ${render_failures[$key]}" + done + fi + # (4) Build deploy-preview summary markdown render_preview_summary "$scorecard" > deploy-preview-summary.md @@ -410,9 +531,19 @@ main() { 2>/dev/null || echo "fail") emit_gate_summary "deploy-validate" "Deploy Validate" "$overall" "scorecard-evaluated" "none" + # Exit nonzero when rendering is impossible (emit-contract failed) — the PR + # check should fail, not silently pass with a broken scorecard. + if [[ "$emit_exit" -ne 0 ]]; then + fail "E_EMIT_CONTRACT_FAILED: artifact emit-contract returned ${emit_exit}; rendering impossible" + fi + if [[ "$overall" != "pass" ]]; then exit 1 fi } -main "$@" +# Allow sourcing for unit tests of the helpers (find_cluster_context, ...); +# execute main only when invoked directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/tests/fixtures/deploy-artifact/cli-interface-0.16.0.json b/tests/fixtures/deploy-artifact/cli-interface-0.16.0.json new file mode 100644 index 0000000..7461a6d --- /dev/null +++ b/tests/fixtures/deploy-artifact/cli-interface-0.16.0.json @@ -0,0 +1,51 @@ +{ + "version": "0.16.0", + "description": "Recorded CLI argument shapes for deploy-config-schema 0.16.0. These are derived directly from src/deployment/commands.ts and src/cli.ts in the deploy-config-schema repository at tag v0.16.0. Any change to the flags used in actions/deploy-artifact/run.sh or actions/deploy-preview/run.sh must be reflected here first.", + "subcommands": { + "render_fragment": { + "synopsis": "render --env --images (--context-dir | --context --context-path ) [--output ]", + "positionals": ["fragment-id", "deploy-dir"], + "required_flags": ["--env", "--images"], + "exclusive_groups": [ + ["--context-dir"], + ["--context", "--context-path"] + ], + "optional_flags": ["--output"], + "notes": [ + "The second positional is the deploy directory (e.g. 'deploy'), NOT the deployment.yml file.", + "The CLI routes to runFragmentRender when the first positional resolves to a fragment adapter.", + "context must be passed either as --context-dir (local dir containing cluster-context-public.yml) or as --context plus --context-path ." + ] + }, + "artifact_emit_contract": { + "synopsis": "artifact emit-contract --artifact-name --environments --images --context-ref --deployment --context --out [--provenance-verified true|false] [--output-root ]", + "required_flags": [ + "--artifact-name", + "--environments", + "--images", + "--context-ref", + "--deployment", + "--context", + "--out" + ], + "optional_flags": ["--provenance-verified", "--output-root"], + "absent_flags": ["--schema-version"], + "notes": [ + "--schema-version does NOT exist in this subcommand; do not pass it.", + "--deployment is required (path to deployment.yml).", + "--context is required (path to the pulled cluster-context-public.yml file).", + "--context-ref is required (the OCI digest ref string, e.g. ghcr.io/...@sha256:...).", + "--environments takes a comma-separated string (e.g. 'production,staging').", + "The subcommand is 'artifact emit-contract' (two words after deploy-config-schema)." + ] + }, + "artifact_emit_kustomization_health": { + "synopsis": "artifact emit-kustomization-health --deployment --env --image-digests --out ", + "required_flags": ["--deployment", "--env", "--image-digests", "--out"], + "optional_flags": [], + "notes": [ + "Takes a single --env per invocation; call once per environment." + ] + } + } +} diff --git a/tests/test_deploy_actions_cli_drift.py b/tests/test_deploy_actions_cli_drift.py new file mode 100644 index 0000000..0d21122 --- /dev/null +++ b/tests/test_deploy_actions_cli_drift.py @@ -0,0 +1,702 @@ +""" +Regression tests for deploy-config-schema 0.16.0 CLI interface drift. + +Test groups: + T-CLI1: Recorded-interface tests — parse run.sh invocations and compare the + flag sets used against the checked-in interface spec + (tests/fixtures/deploy-artifact/cli-interface-0.16.0.json). + T-CLI2: Count-parsing helper — verify the count_lines pattern (grep -c with + || true instead of || echo 0) does not produce a two-line value. + T-CLI3: deploy-preview positional args — second positional must be the + deploy-dir, not the deployment.yml path. + T-CLI4: deploy-artifact positional args — same constraint. + T-CLI5: Silent || true removal — render and emit-contract in deploy-preview + must not use unconditional `|| true`; failures must be captured. + T-CLI6: Context package handling — both actions pull the context by digest + via oras (service repos never hold the context), discover + cluster-context-public.yml in the pulled tree via the + find_cluster_context helper, and pass the digest ref to --context + with the pulled file as --context-path. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +DEPLOY_PREVIEW_RUN = ROOT / "actions/deploy-preview/run.sh" +DEPLOY_ARTIFACT_RUN = ROOT / "actions/deploy-artifact/run.sh" +CLI_INTERFACE_SPEC = ( + ROOT / "tests/fixtures/deploy-artifact/cli-interface-0.16.0.json" +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def read_script(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _load_spec() -> dict: + with CLI_INTERFACE_SPEC.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def _extract_render_fragment_invocations(text: str) -> list[str]: + """ + Return lines (with continuations joined) that contain + 'deploy-config-schema render ' patterns. + + The fragment-id may be a literal like 'kubernetes-workload-fragment' or a + shell variable like '"$fragment"' (loop variable iterating over the list). + """ + # Join line continuations so each logical invocation is one string + joined = text.replace("\\\n", " ") + lines = [] + for line in joined.splitlines(): + stripped = line.strip() + # Matches both literal fragment ids and loop variables + if re.search( + r'deploy-config-schema\s+render\s+(\S+-fragment|"\$\w+"|\'?\$\w+)', + stripped, + ): + lines.append(stripped) + return lines + + +def _extract_emit_contract_invocations(text: str) -> list[str]: + """Return logical lines that contain 'artifact emit-contract'.""" + joined = text.replace("\\\n", " ") + lines = [] + for line in joined.splitlines(): + stripped = line.strip() + if "artifact emit-contract" in stripped and "deploy-config-schema" in stripped: + lines.append(stripped) + return lines + + +def _flags_in_invocation(invocation: str) -> set[str]: + """Extract all --flag tokens from an invocation string.""" + return set(re.findall(r"--[a-z][a-z0-9-]*", invocation)) + + +# --------------------------------------------------------------------------- +# T-CLI1: Recorded-interface tests against the spec fixture +# --------------------------------------------------------------------------- + + +class CliInterfaceSpecTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.spec = _load_spec() + cls.preview_text = read_script(DEPLOY_PREVIEW_RUN) + cls.artifact_text = read_script(DEPLOY_ARTIFACT_RUN) + + def test_spec_fixture_exists_and_has_version(self) -> None: + self.assertIn("version", self.spec) + self.assertEqual(self.spec["version"], "0.16.0") + + def test_spec_has_render_fragment_subcommand(self) -> None: + self.assertIn("render_fragment", self.spec["subcommands"]) + + def test_spec_has_emit_contract_subcommand(self) -> None: + self.assertIn("artifact_emit_contract", self.spec["subcommands"]) + + # ---- render fragment: required flags must appear in each run.sh ---- + + def _required_render_flags(self) -> set[str]: + return set(self.spec["subcommands"]["render_fragment"]["required_flags"]) + + def test_deploy_preview_render_has_required_flags(self) -> None: + invocations = _extract_render_fragment_invocations(self.preview_text) + self.assertGreater( + len(invocations), + 0, + "deploy-preview/run.sh must contain at least one render invocation", + ) + required = self._required_render_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + missing = required - flags + self.assertFalse( + missing, + f"deploy-preview render invocation missing required flags {missing}: {inv!r}", + ) + + def test_deploy_artifact_render_has_required_flags(self) -> None: + invocations = _extract_render_fragment_invocations(self.artifact_text) + self.assertGreater( + len(invocations), + 0, + "deploy-artifact/run.sh must contain at least one render invocation", + ) + required = self._required_render_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + missing = required - flags + self.assertFalse( + missing, + f"deploy-artifact render invocation missing required flags {missing}: {inv!r}", + ) + + # ---- emit-contract: required flags must appear; absent flags must not ---- + + def _required_emit_flags(self) -> set[str]: + return set(self.spec["subcommands"]["artifact_emit_contract"]["required_flags"]) + + def _absent_emit_flags(self) -> set[str]: + return set(self.spec["subcommands"]["artifact_emit_contract"].get("absent_flags", [])) + + def test_deploy_preview_emit_contract_has_required_flags(self) -> None: + invocations = _extract_emit_contract_invocations(self.preview_text) + self.assertGreater( + len(invocations), + 0, + "deploy-preview/run.sh must contain at least one artifact emit-contract invocation", + ) + required = self._required_emit_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + missing = required - flags + self.assertFalse( + missing, + f"deploy-preview emit-contract invocation missing required flags {missing}: {inv!r}", + ) + + def test_deploy_artifact_emit_contract_has_required_flags(self) -> None: + invocations = _extract_emit_contract_invocations(self.artifact_text) + self.assertGreater( + len(invocations), + 0, + "deploy-artifact/run.sh must contain at least one artifact emit-contract invocation", + ) + required = self._required_emit_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + missing = required - flags + self.assertFalse( + missing, + f"deploy-artifact emit-contract invocation missing required flags {missing}: {inv!r}", + ) + + def test_deploy_preview_emit_contract_no_absent_flags(self) -> None: + """--schema-version must not appear in emit-contract invocations.""" + invocations = _extract_emit_contract_invocations(self.preview_text) + absent = self._absent_emit_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + present_absent = absent & flags + self.assertFalse( + present_absent, + f"deploy-preview emit-contract has forbidden flag(s) {present_absent}: {inv!r}", + ) + + def test_deploy_artifact_emit_contract_no_absent_flags(self) -> None: + """--schema-version must not appear in emit-contract invocations.""" + invocations = _extract_emit_contract_invocations(self.artifact_text) + absent = self._absent_emit_flags() + for inv in invocations: + flags = _flags_in_invocation(inv) + present_absent = absent & flags + self.assertFalse( + present_absent, + f"deploy-artifact emit-contract has forbidden flag(s) {present_absent}: {inv!r}", + ) + + +# --------------------------------------------------------------------------- +# T-CLI2: Count-parsing helper — grep -c || true vs || echo 0 +# --------------------------------------------------------------------------- + + +class CountParsingHelperTest(unittest.TestCase): + """ + Verify that the scripts use `|| true` (not `|| echo 0`) after grep -c so + that the result is always a single integer (not a two-line value). + + grep -c already prints 0 when no lines match; `|| echo 0` appends a second + line when grep exits 1, making the result "0\n0" which breaks [[ arithmetic. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.preview_text = read_script(DEPLOY_PREVIEW_RUN) + cls.artifact_text = read_script(DEPLOY_ARTIFACT_RUN) + + def _find_grep_c_or_echo_zero(self, text: str) -> list[str]: + """Return lines that have `grep -c ... || echo 0` (the broken pattern).""" + bad_lines = [] + for line in text.splitlines(): + if re.search(r"grep\s+(-[^ ]*c[^ ]*).*\|\|\s*echo\s+0", line) or re.search( + r"grep\s+-c\b.*\|\|\s*echo\s+0", line + ): + bad_lines.append(line.strip()) + return bad_lines + + def test_deploy_preview_no_grep_c_or_echo_zero(self) -> None: + bad = self._find_grep_c_or_echo_zero(self.preview_text) + self.assertFalse( + bad, + f"deploy-preview/run.sh has `grep -c ... || echo 0` (broken two-line pattern) on lines: {bad}", + ) + + def test_deploy_artifact_no_grep_c_or_echo_zero(self) -> None: + bad = self._find_grep_c_or_echo_zero(self.artifact_text) + self.assertFalse( + bad, + f"deploy-artifact/run.sh has `grep -c ... || echo 0` (broken two-line pattern) on lines: {bad}", + ) + + def test_count_lines_helper_via_bash(self) -> None: + """ + Shell-level check: simulate the fixed pattern in bash and assert the + result is exactly one line containing a single integer. + """ + script = r""" +set -euo pipefail +# Simulate: no lines matching — grep -c exits 1; || true prevents abort. +result=$(printf '' | grep -c '^.' || true) +# result must be a single token (no newline inside) +lines=$(printf '%s' "$result" | wc -l | tr -d ' ') +if [[ "$lines" -ne 0 ]]; then + echo "FAIL: result has ${lines} embedded newlines" >&2 + exit 1 +fi +# Must be numeric +if ! [[ "$result" =~ ^[0-9]+$ ]]; then + echo "FAIL: result is not a plain integer: '${result}'" >&2 + exit 1 +fi +echo "ok: result=${result}" +""" + result = subprocess.run( + ["bash", "-c", script], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertEqual( + result.returncode, + 0, + f"bash count_lines simulation failed: {result.stderr}", + ) + self.assertIn("ok:", result.stdout) + + def test_broken_or_echo_zero_produces_two_lines(self) -> None: + """ + Confirm that `grep -c ... || echo 0` IS the broken pattern by + demonstrating it produces two lines when grep exits 1. + The test documents WHY we forbid it. + """ + script = r""" +# This is the BROKEN pattern: grep -c exits 1 (no match), then echo 0 fires. +result=$(printf '' | grep -c '^.' || echo 0) +line_count=$(printf '%s\n' "$result" | wc -l | tr -d ' ') +# We expect 2 lines ("0" from grep-c then "0" from echo) +printf '%s' "$line_count" +""" + result = subprocess.run( + ["bash", "-c", script], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + # Two-line output means the broken pattern is confirmed + self.assertEqual( + result.stdout.strip(), + "2", + "Expected two-line output from broken || echo 0 pattern (this test documents the bug)", + ) + + +# --------------------------------------------------------------------------- +# T-CLI3: deploy-preview positional arg — deploy-dir, not deployment.yml +# --------------------------------------------------------------------------- + + +class DeployPreviewPositionalArgTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.text = read_script(DEPLOY_PREVIEW_RUN) + + def test_render_second_positional_is_deploy_dir_not_deployment_yml(self) -> None: + """ + The CLI requires: render + The deploy-dir is passed as-is; the CLI appends /deployment.yml internally. + Callers must NOT pass '${deploy_dir}/deployment.yml' as the second positional. + """ + invocations = _extract_render_fragment_invocations(self.text) + self.assertGreater(len(invocations), 0, "No render fragment invocations found") + for inv in invocations: + self.assertNotIn( + "deployment.yml", + inv, + f"deploy-preview render invocation passes deployment.yml as positional " + f"(should pass the deploy-dir only): {inv!r}", + ) + + def test_render_uses_context_and_context_path_flags(self) -> None: + """ + The preview pulls the context package via oras and passes it to render + with --context + --context-path so the + recorded ref stays the digest-pinned one. Service repos never hold the + context file, so --context-dir must not be used (it records a + local:// ref instead of the digest-pinned one). + """ + invocations = _extract_render_fragment_invocations(self.text) + for inv in invocations: + flags = _flags_in_invocation(inv) + self.assertIn( + "--context", + flags, + f"deploy-preview render invocation missing --context flag: {inv!r}", + ) + self.assertIn( + "--context-path", + flags, + f"deploy-preview render invocation missing --context-path flag: {inv!r}", + ) + self.assertNotIn( + "--context-dir", + flags, + f"deploy-preview render must not use --context-dir (loses the digest ref): {inv!r}", + ) + + +# --------------------------------------------------------------------------- +# T-CLI4: deploy-artifact positional arg — deploy-dir, not deployment.yml +# --------------------------------------------------------------------------- + + +class DeployArtifactPositionalArgTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.text = read_script(DEPLOY_ARTIFACT_RUN) + + def test_render_second_positional_is_deploy_dir_not_deployment_yml(self) -> None: + invocations = _extract_render_fragment_invocations(self.text) + self.assertGreater(len(invocations), 0, "No render fragment invocations found") + for inv in invocations: + self.assertNotIn( + "deployment.yml", + inv, + f"deploy-artifact render invocation passes deployment.yml as positional " + f"(should pass the deploy-dir only): {inv!r}", + ) + + def test_render_uses_context_and_context_path_flags(self) -> None: + """ + In deploy-artifact, the context package is pulled once via oras and + passed to render with --context + --context-path . + """ + invocations = _extract_render_fragment_invocations(self.text) + for inv in invocations: + flags = _flags_in_invocation(inv) + self.assertIn( + "--context", + flags, + f"deploy-artifact render invocation missing --context flag: {inv!r}", + ) + self.assertIn( + "--context-path", + flags, + f"deploy-artifact render invocation missing --context-path flag: {inv!r}", + ) + + +# --------------------------------------------------------------------------- +# T-CLI5: Silent || true removal — render/emit-contract failures must be captured +# --------------------------------------------------------------------------- + + +class SilentFailureRemovalTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.preview_text = read_script(DEPLOY_PREVIEW_RUN) + + def _render_fragment_lines_with_or_true(self, text: str) -> list[str]: + """ + Return lines that are PART OF a render-fragment invocation AND also + have an unconditional `|| true` appended (the silent-failure pattern). + """ + # Join continuations so each invocation is one logical line + joined = text.replace("\\\n", " ") + bad = [] + for line in joined.splitlines(): + stripped = line.strip() + if re.search(r"deploy-config-schema\s+render\s+\S+-fragment", stripped): + if re.search(r"\|\|\s*true\s*$", stripped): + bad.append(stripped) + return bad + + def _emit_contract_lines_with_or_true(self, text: str) -> list[str]: + joined = text.replace("\\\n", " ") + bad = [] + for line in joined.splitlines(): + stripped = line.strip() + if "artifact emit-contract" in stripped and "deploy-config-schema" in stripped: + if re.search(r"\|\|\s*true\s*$", stripped): + bad.append(stripped) + return bad + + def test_deploy_preview_render_not_silently_suppressed(self) -> None: + """ + render invocations must NOT use unconditional `|| true`. + The fixed script captures exit status and surfaces it in the scorecard. + """ + bad = self._render_fragment_lines_with_or_true(self.preview_text) + self.assertFalse( + bad, + f"deploy-preview/run.sh silently suppresses render failures with '|| true': {bad}", + ) + + def test_deploy_preview_emit_contract_not_silently_suppressed(self) -> None: + """ + artifact emit-contract must NOT use unconditional `|| true`. + The fixed script captures exit status and exits nonzero when rendering is impossible. + """ + bad = self._emit_contract_lines_with_or_true(self.preview_text) + self.assertFalse( + bad, + f"deploy-preview/run.sh silently suppresses emit-contract failure with '|| true': {bad}", + ) + + def test_deploy_preview_captures_render_exit_status(self) -> None: + """ + The script must track render exit status (render_exit or similar variable). + """ + self.assertIn( + "render_exit", + self.preview_text, + "deploy-preview/run.sh must capture render exit status in a variable", + ) + + def test_deploy_preview_captures_emit_exit_status(self) -> None: + """ + The script must track emit-contract exit status. + """ + self.assertIn( + "emit_exit", + self.preview_text, + "deploy-preview/run.sh must capture emit-contract exit status in a variable", + ) + + def test_deploy_preview_exits_nonzero_on_emit_failure(self) -> None: + """ + When emit-contract fails the action must exit nonzero (E_EMIT_CONTRACT_FAILED). + """ + self.assertIn( + "E_EMIT_CONTRACT_FAILED", + self.preview_text, + "deploy-preview/run.sh must exit nonzero with E_EMIT_CONTRACT_FAILED when rendering is impossible", + ) + + +# --------------------------------------------------------------------------- +# T-CLI6: Context package handling — oras pull + pulled-layout discovery +# --------------------------------------------------------------------------- + + +DEPLOY_PREVIEW_ACTION = ROOT / "actions/deploy-preview/action.yml" + + +class ContextPackagePullTest(unittest.TestCase): + """ + Service repos never hold the cluster context; the ACTION fetches it by + digest. The CLI never pulls (--context records the ref, --context-path + reads a local file), so each action must oras-pull once and pass the + discovered file path. + """ + + @classmethod + def setUpClass(cls) -> None: + cls.preview_text = read_script(DEPLOY_PREVIEW_RUN) + cls.artifact_text = read_script(DEPLOY_ARTIFACT_RUN) + cls.preview_action = DEPLOY_PREVIEW_ACTION.read_text(encoding="utf-8") + + def test_deploy_preview_pulls_context_via_oras(self) -> None: + self.assertRegex( + self.preview_text, + r"oras pull\s+\"\$context_ref\"", + "deploy-preview/run.sh must oras pull the context package by digest ref", + ) + + def test_deploy_preview_fails_loud_on_pull_failure(self) -> None: + self.assertIn( + "E_CONTEXT_PULL_FAILED", + self.preview_text, + "deploy-preview/run.sh must fail loud when the oras pull fails", + ) + + def test_deploy_preview_fails_loud_when_context_file_missing(self) -> None: + self.assertIn( + "E_CONTEXT_FILE_MISSING", + self.preview_text, + "deploy-preview/run.sh must fail loud when cluster-context-public.yml is absent from the pulled tree", + ) + + def test_deploy_artifact_fails_loud_when_context_file_missing(self) -> None: + self.assertIn( + "E_CONTEXT_FILE_MISSING", + self.artifact_text, + "deploy-artifact/run.sh must fail loud when cluster-context-public.yml is absent from the pulled tree", + ) + + def test_scripts_do_not_hardcode_pulled_context_root_path(self) -> None: + """ + The pulled layout carries cluster-context-public.yml under + context/public/; scripts must discover the file rather than hardcode + /cluster-context-public.yml. + """ + for name, text in ( + ("deploy-preview", self.preview_text), + ("deploy-artifact", self.artifact_text), + ): + self.assertNotRegex( + text, + r"context-pkg/cluster-context-public\.yml", + f"{name}/run.sh hardcodes the pulled context file at the pull root; " + "use find_cluster_context instead", + ) + + def test_scripts_define_discovery_helper(self) -> None: + for name, text in ( + ("deploy-preview", self.preview_text), + ("deploy-artifact", self.artifact_text), + ): + self.assertIn( + "find_cluster_context()", + text, + f"{name}/run.sh must define the find_cluster_context discovery helper", + ) + + def test_preview_action_installs_oras(self) -> None: + """ + The preview action must install oras (reusing the pinned deploy-artifact + installer) before run.sh executes, since ubuntu-latest runners do not + ship oras. + """ + self.assertIn( + "install-tooling.sh", + self.preview_action, + "deploy-preview/action.yml must run install-tooling.sh before run.sh", + ) + self.assertIn( + "oras", + self.preview_action, + "deploy-preview/action.yml tooling step must include oras", + ) + + +class FindClusterContextHelperTest(unittest.TestCase): + """ + Behavioral tests for the find_cluster_context helper, executed against the + real run.sh files (sourced; main is guarded behind BASH_SOURCE check). + """ + + def _run_helper(self, script: Path, layout: list[str]) -> subprocess.CompletedProcess[str]: + """ + Create a temp pulled-tree containing the given relative file paths and + invoke find_cluster_context from the sourced script against it. + """ + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "pulled" + root.mkdir() + for rel in layout: + target = root / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("apiVersion: deployment.jorisjonkers.dev/cluster-context\n", encoding="utf-8") + bash = ( + f'source "{script}" && find_cluster_context "{root}"' + ) + return subprocess.run( + ["bash", "-c", bash], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + def test_sourcing_run_sh_does_not_execute_main(self) -> None: + """The BASH_SOURCE guard must prevent main from running when sourced.""" + for script in (DEPLOY_PREVIEW_RUN, DEPLOY_ARTIFACT_RUN): + result = subprocess.run( + ["bash", "-c", f'source "{script}" && echo SOURCED_OK'], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + # No CONTEXT_REF etc. in env: if main ran, it would fail on + # the :? parameter expansions instead of printing SOURCED_OK. + env={"PATH": "/usr/bin:/bin"}, + ) + self.assertEqual( + result.returncode, + 0, + f"sourcing {script} must not execute main: {result.stderr}", + ) + self.assertIn("SOURCED_OK", result.stdout) + + def test_finds_file_under_context_public(self) -> None: + """The published layout carries the file under context/public/.""" + for script in (DEPLOY_PREVIEW_RUN, DEPLOY_ARTIFACT_RUN): + result = self._run_helper(script, ["context/public/cluster-context-public.yml"]) + self.assertEqual(result.returncode, 0, f"{script}: {result.stderr}") + self.assertTrue( + result.stdout.endswith("context/public/cluster-context-public.yml"), + f"{script}: unexpected path: {result.stdout!r}", + ) + + def test_prefers_context_public_over_other_matches(self) -> None: + """When multiple matches exist, the context/public/ one wins.""" + for script in (DEPLOY_PREVIEW_RUN, DEPLOY_ARTIFACT_RUN): + result = self._run_helper( + script, + [ + "cluster-context-public.yml", + "context/public/cluster-context-public.yml", + "other/cluster-context-public.yml", + ], + ) + self.assertEqual(result.returncode, 0, f"{script}: {result.stderr}") + self.assertIn( + "context/public/cluster-context-public.yml", + result.stdout, + f"{script}: must prefer the context/public/ match: {result.stdout!r}", + ) + + def test_falls_back_to_any_match_when_no_context_public(self) -> None: + """A root-level or differently nested file is still found.""" + for script in (DEPLOY_PREVIEW_RUN, DEPLOY_ARTIFACT_RUN): + result = self._run_helper(script, ["cluster-context-public.yml"]) + self.assertEqual(result.returncode, 0, f"{script}: {result.stderr}") + self.assertTrue( + result.stdout.endswith("cluster-context-public.yml"), + f"{script}: unexpected path: {result.stdout!r}", + ) + + def test_returns_nonzero_when_absent(self) -> None: + """No match anywhere in the tree -> nonzero so callers can fail loud.""" + for script in (DEPLOY_PREVIEW_RUN, DEPLOY_ARTIFACT_RUN): + result = self._run_helper(script, ["context/public/other-file.yml"]) + self.assertNotEqual( + result.returncode, + 0, + f"{script}: find_cluster_context must return nonzero when the file is absent", + ) + self.assertEqual(result.stdout, "", f"{script}: no path must be printed on miss") + + +if __name__ == "__main__": + unittest.main()