|
| 1 | +#!/bin/bash |
| 2 | + |
| 3 | +# Tests the result-accumulator lifecycle in the run-rad-commands composite |
| 4 | +# action: the outcome written by the EXIT trap must never claim success for a |
| 5 | +# run that exited before it completed. |
| 6 | +# |
| 7 | +# Any exit before the promotion bypasses the failure branches that assign |
| 8 | +# `command_failed` - but the EXIT trap still fires and writes the accumulator as |
| 9 | +# it stands. GitHub runs `shell: bash` steps as `bash --noprofile --norc -eo |
| 10 | +# pipefail {0}`, so the reachable case is an `errexit` abort; a step timeout or |
| 11 | +# a cancellation behaves the same way. The accumulator must therefore be |
| 12 | +# pessimistic: seeded to a non-success value and promoted to `succeeded` only |
| 13 | +# after the command loop has actually completed. |
| 14 | + |
| 15 | +set -euo pipefail |
| 16 | + |
| 17 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 18 | +readonly SCRIPT_DIR |
| 19 | +# Overridable so the suite can be pointed at a mutated copy of the action to |
| 20 | +# confirm the static assertions below actually bite. |
| 21 | +ACTION_FILE="${ACTION_FILE:-${SCRIPT_DIR}/action.yml}" |
| 22 | +readonly ACTION_FILE |
| 23 | +readonly STEP_NAME="Run rad commands" |
| 24 | +# The step sources this before the accumulator, and `cleanup` calls into it, so |
| 25 | +# the extracted prologue needs it too. Sourced for real rather than stubbed, so |
| 26 | +# a change to those helpers is exercised here instead of hidden behind a stub. |
| 27 | +readonly PROGRESS_LIB="${SCRIPT_DIR}/../deploy-progress/progress.sh" |
| 28 | +# The value the accumulator is seeded to, and the value the promotion is allowed |
| 29 | +# to promote *from*. Asserted exactly rather than as "anything non-success": |
| 30 | +# publish-deploy-status, the design note, and the promotion guard all name it. |
| 31 | +readonly SEED_OUTCOME="interrupted" |
| 32 | +readonly SEED_EXIT="1" |
| 33 | +TEST_ROOT="$(mktemp -d)" |
| 34 | +readonly TEST_ROOT |
| 35 | +trap 'rm -rf "${TEST_ROOT}"' EXIT |
| 36 | + |
| 37 | +readonly PROLOGUE="${TEST_ROOT}/prologue.sh" |
| 38 | +readonly RESULT_FILE="${TEST_ROOT}/rad-commands-result.json" |
| 39 | +readonly READY_FILE="${TEST_ROOT}/ready" |
| 40 | +readonly VICTIM_LOG="${TEST_ROOT}/victim.log" |
| 41 | + |
| 42 | +fail() { |
| 43 | + echo "FAIL: $*" >&2 |
| 44 | + exit 1 |
| 45 | +} |
| 46 | + |
| 47 | +if ! command -v jq >/dev/null 2>&1; then |
| 48 | + fail "jq is required; run 'make install-jq'" |
| 49 | +fi |
| 50 | +if ! command -v python3 >/dev/null 2>&1; then |
| 51 | + fail "python3 is required to parse action.yml" |
| 52 | +fi |
| 53 | +[[ -f "${PROGRESS_LIB}" ]] || |
| 54 | + fail "expected the deploy-progress helpers at ${PROGRESS_LIB}; the accumulator's EXIT trap calls into them" |
| 55 | + |
| 56 | +# Extract the accumulator prologue verbatim from the action - the seed, the |
| 57 | +# write_result writer, and the EXIT trap - so the test exercises the shipped |
| 58 | +# code rather than a copy of it that can drift. Scoped to the "Run rad |
| 59 | +# commands" step so an unrelated step gaining the same anchor lines cannot |
| 60 | +# silently redirect the extraction. |
| 61 | +extract_prologue() { |
| 62 | + python3 - "${ACTION_FILE}" "${PROLOGUE}" "${STEP_NAME}" <<'PYTHON' |
| 63 | +import re |
| 64 | +import sys |
| 65 | +
|
| 66 | +action_file, out_file, step_name = sys.argv[1], sys.argv[2], sys.argv[3] |
| 67 | +lines = open(action_file, encoding="utf-8").read().splitlines() |
| 68 | +
|
| 69 | +steps = [i for i, l in enumerate(lines) |
| 70 | + if re.match(r"^\s*-\s+name:\s+%s\s*$" % re.escape(step_name), l)] |
| 71 | +if len(steps) != 1: |
| 72 | + sys.exit("expected exactly one '%s' step, found %d" % (step_name, len(steps))) |
| 73 | +
|
| 74 | +# Bound the search at the next step so the prologue can only come from this one. |
| 75 | +start_line = steps[0] |
| 76 | +step_indent = len(lines[start_line]) - len(lines[start_line].lstrip(" ")) |
| 77 | +end = len(lines) |
| 78 | +for i, line in enumerate(lines[start_line + 1:], start=start_line + 1): |
| 79 | + if not line.strip(): |
| 80 | + continue |
| 81 | + indent = len(line) - len(line.lstrip(" ")) |
| 82 | + if indent <= step_indent and line.lstrip().startswith("- "): |
| 83 | + end = i |
| 84 | + break |
| 85 | +step = lines[start_line:end] |
| 86 | +
|
| 87 | +seeds = [i for i, l in enumerate(step) if l.strip().startswith("RESULT_FILE=")] |
| 88 | +if len(seeds) != 1: |
| 89 | + sys.exit("expected exactly one RESULT_FILE assignment in the step, " |
| 90 | + "found %d" % len(seeds)) |
| 91 | +
|
| 92 | +base = len(step[seeds[0]]) - len(step[seeds[0]].lstrip(" ")) |
| 93 | +body = [] |
| 94 | +for line in step[seeds[0]:]: |
| 95 | + body.append(line[base:] if line.strip() else "") |
| 96 | + # The trap's handler has been renamed before (write_result -> cleanup), so |
| 97 | + # match any handler rather than pinning to one name. |
| 98 | + if re.match(r"^trap\s+\w+\s+EXIT$", line.strip()): |
| 99 | + break |
| 100 | +else: |
| 101 | + sys.exit("did not find a 'trap <handler> EXIT' after the accumulator seed") |
| 102 | +
|
| 103 | +open(out_file, "w", encoding="utf-8").write("\n".join(body) + "\n") |
| 104 | +PYTHON |
| 105 | + |
| 106 | + grep -qE '^trap[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+EXIT$' "${PROLOGUE}" || |
| 107 | + fail "failed to extract the accumulator prologue from action.yml" |
| 108 | + |
| 109 | + # Keep the artifact - and the directory holding it - inside the sandbox |
| 110 | + # instead of the runner's /tmp/radius-output. |
| 111 | + sed -i -E \ |
| 112 | + -e "s|^RESULT_FILE=.*|RESULT_FILE=${RESULT_FILE}|" \ |
| 113 | + -e "s|^mkdir -p /tmp/radius-output\$|mkdir -p ${TEST_ROOT}|" \ |
| 114 | + "${PROLOGUE}" |
| 115 | + grep -q "^RESULT_FILE=${RESULT_FILE}\$" "${PROLOGUE}" || |
| 116 | + fail "failed to redirect RESULT_FILE into the test sandbox" |
| 117 | + if grep -q '/tmp/radius-output' "${PROLOGUE}"; then |
| 118 | + fail "the extracted prologue still writes to /tmp/radius-output" |
| 119 | + fi |
| 120 | +} |
| 121 | + |
| 122 | +result_outcome() { |
| 123 | + jq -r '.outcome' "${RESULT_FILE}" |
| 124 | +} |
| 125 | + |
| 126 | +result_exit_code() { |
| 127 | + jq -r '.exitCode' "${RESULT_FILE}" |
| 128 | +} |
| 129 | + |
| 130 | +# Locate a line in action.yml. Fails with a useful message rather than handing |
| 131 | +# an empty operand to a later comparison if the pattern has been renamed away. |
| 132 | +action_line() { |
| 133 | + local pattern="$1" picker="$2" line |
| 134 | + line="$(grep -n -e "${pattern}" "${ACTION_FILE}" | "${picker}" -1 | cut -d: -f1)" || |
| 135 | + fail "no line in action.yml matches: ${pattern}" |
| 136 | + [[ -n "${line}" ]] || fail "no line in action.yml matches: ${pattern}" |
| 137 | + printf '%s' "${line}" |
| 138 | +} |
| 139 | + |
| 140 | +extract_prologue |
| 141 | + |
| 142 | +# --------------------------------------------------------------------------- |
| 143 | +# A run that exits mid-command must not publish success. |
| 144 | +# |
| 145 | +# Models an abnormal exit faithfully: the prologue runs, a command is in flight, |
| 146 | +# and the shell dies without any failure branch executing - only the EXIT trap |
| 147 | +# gets to speak. SIGTERM stands in for the whole class (step timeout, job |
| 148 | +# cancellation); the `errexit` case is covered separately below. The subshell |
| 149 | +# uses the same options GitHub gives a `shell: bash` step (`-e -o pipefail`). |
| 150 | +# --------------------------------------------------------------------------- |
| 151 | +# The victim's own output is not asserted on, so send it to a log rather than |
| 152 | +# inheriting this script's stdout. SIGTERM reaps the subshell but not the |
| 153 | +# `sleep` it is waiting on, and an orphan holding the pipe open would stall any |
| 154 | +# caller that captures our output until the sleep expires. |
| 155 | +# RUNNER_TEMP keeps the progress helpers' scratch files inside the sandbox. |
| 156 | +ENVIRONMENT="aks-dev" RUNNER_TEMP="${TEST_ROOT}" bash -eo pipefail -c " |
| 157 | + source '${PROGRESS_LIB}' |
| 158 | + source '${PROLOGUE}' |
| 159 | + touch '${READY_FILE}' |
| 160 | + # Stands in for a long-running \`rad deploy\`. |
| 161 | + sleep 60 |
| 162 | +" >"${VICTIM_LOG}" 2>&1 & |
| 163 | +victim=$! |
| 164 | + |
| 165 | +# Kill only once the trap is installed, so the test cannot race ahead of the |
| 166 | +# code it is exercising and mistake a never-started run for a killed one. |
| 167 | +for _ in $(seq 1 100); do |
| 168 | + [[ -f "${READY_FILE}" ]] && break |
| 169 | + sleep 0.1 |
| 170 | +done |
| 171 | +[[ -f "${READY_FILE}" ]] || |
| 172 | + fail "the extracted prologue never finished installing the EXIT trap$(printf '\n%s' "$(cat "${VICTIM_LOG}" 2>/dev/null)")" |
| 173 | + |
| 174 | +# The result file is written by the trap, not eagerly. If that ever changes, |
| 175 | +# the assertions below would be testing a file the kill had no part in. |
| 176 | +if [[ -f "${RESULT_FILE}" ]]; then |
| 177 | + fail "the result file must be written by the EXIT trap, not eagerly" |
| 178 | +fi |
| 179 | + |
| 180 | +kill -TERM "${victim}" 2>/dev/null || true |
| 181 | +wait "${victim}" 2>/dev/null || true |
| 182 | + |
| 183 | +[[ -f "${RESULT_FILE}" ]] || |
| 184 | + fail "expected the EXIT trap to write a result file on termination" |
| 185 | + |
| 186 | +# Assert the seed exactly. `interrupted` is not an arbitrary non-success value: |
| 187 | +# publish-deploy-status maps it to `failed` through its `*)` arm, it must not be |
| 188 | +# `unknown` (which that action reserves for a missing result file and maps to |
| 189 | +# the neutral `in_progress`), and the promotion in action.yml is guarded to fire |
| 190 | +# only from this exact value. |
| 191 | +outcome="$(result_outcome)" |
| 192 | +[[ "${outcome}" == "${SEED_OUTCOME}" ]] || |
| 193 | + fail "a killed run published '${outcome}'; expected the seed '${SEED_OUTCOME}'" |
| 194 | +exit_code="$(result_exit_code)" |
| 195 | +[[ "${exit_code}" == "${SEED_EXIT}" ]] || |
| 196 | + fail "a killed run published exitCode '${exit_code}'; expected '${SEED_EXIT}'" |
| 197 | + |
| 198 | +# --------------------------------------------------------------------------- |
| 199 | +# An `errexit` abort must not publish success either. |
| 200 | +# |
| 201 | +# This is the reachable case today: GitHub runs `shell: bash` steps as |
| 202 | +# `bash --noprofile --norc -eo pipefail {0}`, so any unguarded non-zero command |
| 203 | +# ends the step mid-run. No failure branch assigns an outcome, and the trap |
| 204 | +# still fires. |
| 205 | +# --------------------------------------------------------------------------- |
| 206 | +rm -f "${RESULT_FILE}" |
| 207 | +ENVIRONMENT="aks-dev" RUNNER_TEMP="${TEST_ROOT}" bash -eo pipefail -c " |
| 208 | + source '${PROGRESS_LIB}' |
| 209 | + source '${PROLOGUE}' |
| 210 | + # Stands in for any unguarded command that returns non-zero. |
| 211 | + false |
| 212 | + # Unreachable under errexit; present so a broken -e would be visible. |
| 213 | + OVERALL_OUTCOME=\"succeeded\" |
| 214 | +" && fail "the errexit run exited 0; the shell must abort on the failed command" |
| 215 | + |
| 216 | +[[ -f "${RESULT_FILE}" ]] || |
| 217 | + fail "expected the EXIT trap to write a result file on an errexit abort" |
| 218 | +outcome="$(result_outcome)" |
| 219 | +[[ "${outcome}" == "${SEED_OUTCOME}" ]] || |
| 220 | + fail "an errexit abort published '${outcome}'; expected the seed '${SEED_OUTCOME}'" |
| 221 | +exit_code="$(result_exit_code)" |
| 222 | +[[ "${exit_code}" == "${SEED_EXIT}" ]] || |
| 223 | + fail "an errexit abort published exitCode '${exit_code}'; expected '${SEED_EXIT}'" |
| 224 | + |
| 225 | +# The seed must be the value the prologue actually assigns, and only that value, |
| 226 | +# so the assertions above cannot drift from the shipped code. |
| 227 | +seed_assignments="$(grep -c "^OVERALL_OUTCOME=\"${SEED_OUTCOME}\"$" "${PROLOGUE}" || true)" |
| 228 | +[[ "${seed_assignments}" == "1" ]] || |
| 229 | + fail "expected exactly one OVERALL_OUTCOME=\"${SEED_OUTCOME}\" seed in the prologue, found ${seed_assignments}" |
| 230 | + |
| 231 | +# --------------------------------------------------------------------------- |
| 232 | +# Success must be earned, not seeded - and the promotion must be guarded. |
| 233 | +# |
| 234 | +# The failure branches all exit before the promotion today, but the promotion |
| 235 | +# does not get to depend on that: it is guarded so it can only ever fire from |
| 236 | +# the seed. Assert the guard rather than trying to prove reachability by |
| 237 | +# scanning the source, which indentation cannot establish in shell. |
| 238 | +# --------------------------------------------------------------------------- |
| 239 | +# `grep -c` exits 1 on zero matches, which under `set -e` would abort silently - |
| 240 | +# report the count instead so a removed promotion names itself. |
| 241 | +success_assignments="$(grep -c '^[[:space:]]*OVERALL_OUTCOME="succeeded"$' "${ACTION_FILE}" || true)" |
| 242 | +[[ "${success_assignments}" == "1" ]] || |
| 243 | + fail "expected exactly one OVERALL_OUTCOME=\"succeeded\" assignment, found ${success_assignments}" |
| 244 | + |
| 245 | +seed_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME=' head)" |
| 246 | +success_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME="succeeded"$' head)" |
| 247 | +last_failure_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME="command_failed"$' tail)" |
| 248 | +[[ "${success_line}" != "${seed_line}" ]] || |
| 249 | + fail "OVERALL_OUTCOME is seeded to 'succeeded'; an interrupted run would inherit it" |
| 250 | +[[ "${success_line}" -gt "${last_failure_line}" ]] || |
| 251 | + fail "OVERALL_OUTCOME=\"succeeded\" must be assigned after the command loop" |
| 252 | + |
| 253 | +# The promotion fires only from the seed, so an outcome recorded by a failure |
| 254 | +# path that did not exit survives instead of being overwritten. |
| 255 | +guard_line="$(action_line "^[[:space:]]*if \[ \"\$OVERALL_OUTCOME\" = \"${SEED_OUTCOME}\" \]; then\$" tail)" |
| 256 | +[[ "$((success_line - guard_line))" == "1" ]] || |
| 257 | + fail "OVERALL_OUTCOME=\"succeeded\" (line ${success_line}) must be guarded by the seed test on the line above it (found the guard on line ${guard_line})" |
| 258 | + |
| 259 | +# Exit on the accumulator, so the step result cannot disagree with the artifact. |
| 260 | +# shellcheck disable=SC2016 # matching the literal text `exit "$OVERALL_EXIT"` |
| 261 | +exit_line="$(action_line '^[[:space:]]*exit "\$OVERALL_EXIT"$' tail)" |
| 262 | +[[ "${exit_line}" -gt "${success_line}" ]] || |
| 263 | + fail "the run block must end with 'exit \"\$OVERALL_EXIT\"' after the promotion" |
| 264 | + |
| 265 | +echo "run-rad-commands command outcome tests passed" |
0 commit comments