demo/lib_demo.sh488 of 488 statements covered (100.00%).
coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.
| 1 | 12 | #!/usr/bin/env bash |
| 2 | # ───────────────────────────────────────────────────────────────────────────── | |
| 3 | # Shared library for GCO demo scripts | |
| 4 | # ───────────────────────────────────────────────────────────────────────────── | |
| 5 | # Sourced by live_demo.sh and record_demo.sh. Also sourced by BATS tests | |
| 6 | # so the tests exercise the real functions, not duplicated copies. | |
| 7 | # | |
| 8 | # Usage: | |
| 9 | # source demo/lib_demo.sh | |
| 10 | # | |
| 11 | # shellcheck disable=SC2034 # Variables are used by sourcing scripts | |
| 12 | # ───────────────────────────────────────────────────────────────────────────── | |
| 13 | ||
| 14 | # ── Colors & Formatting ───────────────────────────────────────────────────── | |
| 15 | # Uses tput for portability. Falls back to empty strings when there's no | |
| 16 | # terminal or tput isn't available. | |
| 17 | ||
| 18 | 71 | setup_colors() { |
| 19 | 255 | if [ -t 1 ] && command -v tput &>/dev/null && [ "${TERM:-dumb}" != "dumb" ]; then |
| 20 | 2 | BOLD=$(tput bold) |
| 21 | 2 | DIM=$(tput dim) |
| 22 | 2 | RESET=$(tput sgr0) |
| 23 | 2 | CYAN=$(tput setaf 6) |
| 24 | 2 | GREEN=$(tput setaf 2) |
| 25 | 2 | YELLOW=$(tput setaf 3) |
| 26 | 2 | MAGENTA=$(tput setaf 5) |
| 27 | 2 | BLUE=$(tput setaf 4) |
| 28 | 2 | WHITE=$(tput setaf 7) |
| 29 | 2 | RED=$(tput setaf 1) |
| 30 | 2 | BG_BLUE=$(tput setab 4) |
| 31 | else | |
| 32 | 1157 | BOLD="" DIM="" RESET="" CYAN="" GREEN="" YELLOW="" |
| 33 | 976 | MAGENTA="" BLUE="" WHITE="" RED="" BG_BLUE="" |
| 34 | fi | |
| 35 | } | |
| 36 | ||
| 37 | # ── Pause Durations ───────────────────────────────────────────────────────── | |
| 38 | # GCO_DEMO_FAST=1 shortens pauses for rehearsal or recording. | |
| 39 | ||
| 40 | 2 | setup_pauses() { |
| 41 | 41 | PAUSE_SHORT="${GCO_DEMO_FAST:+1}" |
| 42 | 41 | PAUSE_SHORT="${PAUSE_SHORT:-3}" |
| 43 | 41 | PAUSE_LONG="${GCO_DEMO_FAST:+2}" |
| 44 | 41 | PAUSE_LONG="${PAUSE_LONG:-5}" |
| 45 | } | |
| 46 | ||
| 47 | # ── Display Helpers ────────────────────────────────────────────────────────── | |
| 48 | ||
| 49 | 1 | banner() { |
| 50 | # Use terminal width if available, otherwise default to 72. | |
| 51 | # This ensures the banner fills the recording frame nicely. | |
| 52 | 71 | local width |
| 53 | 142 | width=$(tput cols 2>/dev/null || echo "72") |
| 54 | # Cap at 120 to avoid absurdly wide banners on ultrawide terminals | |
| 55 | 108 | if [ "$width" -gt 120 ]; then width=120; fi |
| 56 | 71 | local text="$1" |
| 57 | 71 | local text_len=${#text} |
| 58 | 71 | local pad_left=$(( (width - text_len) / 2 )) |
| 59 | 71 | local pad_right=$(( width - text_len - pad_left )) |
| 60 | 71 | echo "" |
| 61 | 71 | printf "%s%s%s%*s%s\n" "$BG_BLUE" "$WHITE" "$BOLD" "$width" "" "$RESET" |
| 62 | 71 | printf "%s%s%s%*s%s%*s%s\n" "$BG_BLUE" "$WHITE" "$BOLD" "$pad_left" "" "$text" "$pad_right" "" "$RESET" |
| 63 | 71 | printf "%s%s%s%*s%s\n" "$BG_BLUE" "$WHITE" "$BOLD" "$width" "" "$RESET" |
| 64 | 71 | echo "" |
| 65 | } | |
| 66 | ||
| 67 | 1 | section_header() { |
| 68 | 160 | local num="$1" |
| 69 | 160 | local title="$2" |
| 70 | 160 | local color="${3:-$CYAN}" |
| 71 | # Build a divider line that fills the terminal width (capped at 120) | |
| 72 | 160 | local width |
| 73 | 320 | width=$(tput cols 2>/dev/null || echo "72") |
| 74 | 281 | if [ "$width" -gt 120 ]; then width=120; fi |
| 75 | 160 | local divider |
| 76 | 480 | divider=$(printf '%*s' "$width" '' | tr ' ' '━') |
| 77 | 160 | echo "" |
| 78 | 160 | echo "${color}${BOLD}${divider}${RESET}" |
| 79 | 160 | echo "${color}${BOLD} [$num] $title${RESET}" |
| 80 | 160 | echo "${color}${BOLD}${divider}${RESET}" |
| 81 | 160 | echo "" |
| 82 | } | |
| 83 | ||
| 84 | 1252 | narrate() { echo " ${DIM}$1${RESET}"; } |
| 85 | 491 | highlight() { echo " ${YELLOW}${BOLD}▸ $1${RESET}"; } |
| 86 | 260 | success() { echo " ${GREEN}${BOLD}✓ $1${RESET}"; } |
| 87 | 56 | warn() { echo " ${RED}${BOLD}⚠ $1${RESET}"; } |
| 88 | 679 | spacer() { echo ""; } |
| 89 | ||
| 90 | 2 | feature_status() { |
| 91 | 252 | local value="$1" |
| 92 | 252 | if [ "$value" = "true" ]; then |
| 93 | 83 | echo "${GREEN}enabled${RESET}" |
| 94 | else | |
| 95 | 169 | echo "${DIM}disabled${RESET}" |
| 96 | fi | |
| 97 | } | |
| 98 | ||
| 99 | run_cmd() { | |
| 100 | 473 | echo "" |
| 101 | 473 | echo " ${MAGENTA}\$ ${WHITE}${BOLD}$1${RESET}" |
| 102 | 473 | echo " ${DIM}────────────────────────────────────────────────────────────${RESET}" |
| 103 | 1441 | eval "$1" 2>&1 | sed 's/^/ /' |
| 104 | 473 | local exit_code=${PIPESTATUS[0]} |
| 105 | 473 | echo " ${DIM}────────────────────────────────────────────────────────────${RESET}" |
| 106 | 473 | if [ "$exit_code" -ne 0 ]; then |
| 107 | 19 | warn "Command exited with code $exit_code" |
| 108 | fi | |
| 109 | 473 | return "$exit_code" |
| 110 | } | |
| 111 | ||
| 112 | # wait_for_inference_generation <endpoint> [attempts] [delay_seconds] | |
| 113 | # | |
| 114 | # Kubernetes readiness proves only the pod-local health endpoint. Before the | |
| 115 | # visible demo prompt, send a tiny real completion through the same unpinned | |
| 116 | # global API route narrated by live_demo.sh. This deliberately retries at the | |
| 117 | # workflow level: the generic client never replays POST automatically. | |
| 118 | wait_for_inference_generation() { | |
| 119 | 21 | local endpoint="$1" |
| 120 | 21 | local attempts="${2:-4}" |
| 121 | 21 | local delay_seconds="${3:-10}" |
| 122 | 21 | local attempt |
| 123 | ||
| 124 | 48 | for attempt in $(seq 1 "$attempts"); do |
| 125 | 27 | if gco inference invoke "$endpoint" \ |
| 126 | -p 'Reply with ready.' --max-tokens 1 >/dev/null 2>&1; then | |
| 127 | 19 | return 0 |
| 128 | fi | |
| 129 | 8 | if [ "$attempt" -lt "$attempts" ]; then |
| 130 | 6 | narrate "The global inference route is still converging; retrying in ${delay_seconds}s..." |
| 131 | 6 | sleep "$delay_seconds" |
| 132 | fi | |
| 133 | done | |
| 134 | 2 | return 1 |
| 135 | } | |
| 136 | ||
| 137 | # cleanup_inference_endpoint <endpoint> | |
| 138 | # | |
| 139 | # Best-effort fallback for ambiguous deployment failures and abnormal exits. | |
| 140 | # Preserve the caller's original status, but never hide a possible GPU leak. | |
| 141 | cleanup_inference_endpoint() { | |
| 142 | 12 | local endpoint="$1" |
| 143 | 12 | if gco inference delete "$endpoint" -y >/dev/null 2>&1; then |
| 144 | 9 | return 0 |
| 145 | fi | |
| 146 | 3 | echo "WARNING: inference endpoint '${endpoint}' may still be running." >&2 |
| 147 | 3 | echo "Run: gco inference delete ${endpoint} -y" >&2 |
| 148 | 3 | return 1 |
| 149 | } | |
| 150 | ||
| 151 | # report_inference_lifecycle_result <invoke_ok> <delete_ok> | |
| 152 | # | |
| 153 | # Print the full-lifecycle claim only when generation and normal cleanup both | |
| 154 | # succeeded. Callers propagate a nonzero result to the recorder. | |
| 155 | report_inference_lifecycle_result() { | |
| 156 | 23 | local invoke_ok="$1" |
| 157 | 23 | local delete_ok="$2" |
| 158 | 23 | if [ "$invoke_ok" -ne 1 ]; then |
| 159 | 3 | warn "Inference generation failed; refusing to publish a false-success recording." |
| 160 | 3 | return 1 |
| 161 | fi | |
| 162 | 20 | if [ "$delete_ok" -ne 1 ]; then |
| 163 | 2 | warn "Inference cleanup failed; refusing to publish an incomplete lifecycle recording." |
| 164 | 2 | return 1 |
| 165 | fi | |
| 166 | 18 | success "Endpoint deployed, invoked, and torn down — full lifecycle." |
| 167 | } | |
| 168 | ||
| 169 | # report_feature_result <submitted_ok> <feature label> <success claim> | |
| 170 | # | |
| 171 | # The same fail-closed rule as report_inference_lifecycle_result, applied to the | |
| 172 | # optional feature sections. Their commands are individually forgiving — | |
| 173 | # `submit-direct ... || true` keeps a presentation moving, `wait_for_job` always | |
| 174 | # returns 0, and the kubectl reads end in `|| echo '(pod scheduling...)'` — so | |
| 175 | # without this the section printed a green claim about FSx throughput or Valkey | |
| 176 | # caching even when nothing was ever submitted. In a published recording an | |
| 177 | # unearned claim is worse than a missing section. | |
| 178 | # | |
| 179 | # A feature can be absent for two reasons that look identical here: it was named | |
| 180 | # in GCO_DEMO_ENABLE but never deployed, or it is deployed and genuinely broken. | |
| 181 | # Both make the claim false, so both fail. | |
| 182 | # | |
| 183 | # Returns nonzero on failure; guarded recordings propagate that and publish | |
| 184 | # nothing. Live presentations degrade to a warning and keep going. | |
| 185 | report_feature_result() { | |
| 186 | 47 | local submitted_ok="$1" |
| 187 | 47 | local label="$2" |
| 188 | 47 | local claim="$3" |
| 189 | 47 | if [ "$submitted_ok" -eq 1 ]; then |
| 190 | 32 | success "$claim" |
| 191 | 32 | return 0 |
| 192 | fi | |
| 193 | 15 | warn "${label} did not run: its workload could not be submitted." |
| 194 | 15 | narrate "Verify ${label} is actually deployed — a section enabled through" |
| 195 | 15 | narrate "GCO_DEMO_ENABLE still needs the matching 'deploy-all --enable'." |
| 196 | 15 | if [ "${GCO_DEMO_GUARDED_RECORDING:-}" = "1" ]; then |
| 197 | 8 | warn "Refusing to publish a recording that claims an unproven feature." |
| 198 | 8 | return 1 |
| 199 | fi | |
| 200 | 7 | return 0 |
| 201 | } | |
| 202 | ||
| 203 | pause_for_audience() { | |
| 204 | 181 | if [ "${GCO_DEMO_NONINTERACTIVE:-}" = "1" ]; then |
| 205 | 178 | sleep 1 |
| 206 | 178 | return |
| 207 | fi | |
| 208 | 3 | echo "" |
| 209 | 3 | echo " ${DIM}Press Enter to continue...${RESET}" |
| 210 | 3 | read -r |
| 211 | } | |
| 212 | ||
| 213 | countdown() { | |
| 214 | 79 | local msg="$1" |
| 215 | 79 | local secs="$2" |
| 216 | 1000 | for i in $(seq "$secs" -1 1); do |
| 217 | 921 | printf "\r %s%s %d...%s" "$DIM" "$msg" "$i" "$RESET" |
| 218 | 921 | sleep 1 |
| 219 | done | |
| 220 | 79 | printf "\r %s%-60s%s\n" "$DIM" "$msg done." "$RESET" |
| 221 | } | |
| 222 | ||
| 223 | # wait_for_job <job-name> <namespace> [timeout_seconds] | |
| 224 | # | |
| 225 | # Waits for a Kubernetes Job to reach the ``complete`` condition, showing a | |
| 226 | # live-updating progress indicator until it succeeds, fails, or times out. | |
| 227 | # Designed for the live demo where we want the audience to actually see the | |
| 228 | # job's final logs — a fixed-duration ``sleep`` used to fall short when | |
| 229 | # image pulls or node provisioning pushed completion past the window. | |
| 230 | # | |
| 231 | # Arguments: | |
| 232 | # job-name Name of the ``batch/v1`` Job resource. | |
| 233 | # namespace Namespace containing the job. | |
| 234 | # timeout_seconds Optional wall-clock budget (default: 240). This is a | |
| 235 | # deadline, not a target — if the job finishes sooner | |
| 236 | # we return immediately. The budget deliberately does | |
| 237 | # not shrink in ``GCO_DEMO_FAST=1`` mode: that flag is | |
| 238 | # for narration pauses, not real work. | |
| 239 | # | |
| 240 | # The helper *always* returns 0, even on timeout or failure. Callers are | |
| 241 | # running under ``set -euo pipefail`` and we don't want a slow job to kill | |
| 242 | # the entire recording mid-demo — the next ``kubectl logs`` / ``kubectl | |
| 243 | # get`` call will surface the state naturally. On timeout we print the pod | |
| 244 | # status so the next narration makes sense instead of showing a blank log | |
| 245 | # block. | |
| 246 | wait_for_job() { | |
| 247 | 55 | local job="$1" |
| 248 | 55 | local ns="$2" |
| 249 | 55 | local budget="${3:-240}" |
| 250 | # NOTE: GCO_DEMO_FAST is for narration pauses, not for real work. Jobs | |
| 251 | # still need as long as they need. If the caller explicitly passes a | |
| 252 | # smaller budget via $3, that wins. | |
| 253 | ||
| 254 | 55 | local start=$SECONDS |
| 255 | 55 | local deadline=$((start + budget)) |
| 256 | ||
| 257 | # First tick: the Job resource itself may not have appeared in the API | |
| 258 | # yet (submit-direct returns before the apply is persisted across the | |
| 259 | # control plane on a cold cluster). Spin briefly until it does. | |
| 260 | 56 | while [ "$SECONDS" -lt "$deadline" ]; do |
| 261 | 56 | if kubectl get "job/${job}" -n "$ns" >/dev/null 2>&1; then |
| 262 | 55 | break |
| 263 | fi | |
| 264 | 1 | printf "\r %sWaiting for job/%s to register...%s" "$DIM" "$job" "$RESET" |
| 265 | 1 | sleep 1 |
| 266 | done | |
| 267 | ||
| 268 | # Use kubectl's own wait primitive for the remainder of the budget. It | |
| 269 | # returns immediately once the condition is met, so this is both faster | |
| 270 | # than polling and more accurate than a fixed sleep. | |
| 271 | 55 | local remaining=$((deadline - SECONDS)) |
| 272 | 57 | if [ "$remaining" -lt 5 ]; then remaining=5; fi |
| 273 | ||
| 274 | 55 | printf "\r %sWaiting for job/%s to complete (up to %ds)...%s\n" \ |
| 275 | "$DIM" "$job" "$remaining" "$RESET" | |
| 276 | ||
| 277 | 55 | if kubectl wait --for=condition=complete "job/${job}" \ |
| 278 | -n "$ns" --timeout="${remaining}s" >/dev/null 2>&1; then | |
| 279 | 54 | local elapsed=$((SECONDS - start)) |
| 280 | 54 | printf " %s${GREEN}${BOLD}✓${RESET} %sjob/%s completed in %ds%s\n" \ |
| 281 | "" "$DIM" "$job" "$elapsed" "$RESET" | |
| 282 | 54 | return 0 |
| 283 | fi | |
| 284 | ||
| 285 | # Timed out or job failed — show what the pod is doing so the audience | |
| 286 | # sees meaningful context before we hit ``kubectl logs`` on a non-ready | |
| 287 | # pod. We always return 0 so ``set -e`` callers don't die on a slow job. | |
| 288 | 1 | printf " %s${YELLOW}${BOLD}!${RESET} %sjob/%s still running after %ds — showing latest pod status%s\n" \ |
| 289 | "" "$DIM" "$job" "$budget" "$RESET" | |
| 290 | 1 | kubectl get pods -n "$ns" \ |
| 291 | 1 | -l "job-name=${job}" --no-headers 2>/dev/null | sed 's/^/ /' || true |
| 292 | 1 | return 0 |
| 293 | } | |
| 294 | ||
| 295 | # ── Feature Detection ──────────────────────────────────────────────────────── | |
| 296 | # Reads cdk.json and sets global variables for each feature flag. | |
| 297 | # Requires jq and CDK_JSON to be set. | |
| 298 | ||
| 299 | # demo_feature_forced <name> | |
| 300 | # | |
| 301 | # True when GCO_DEMO_ENABLE names this feature or chart. The variable carries | |
| 302 | # the same comma-separated names as `gco stacks deploy-all --enable`, which is | |
| 303 | # the whole point: the recorders derive both values from one knob, so a session | |
| 304 | # can never deploy a feature and then skip demonstrating it (or narrate a | |
| 305 | # feature it never deployed). | |
| 306 | # | |
| 307 | # GCO ships every optional add-on disabled in cdk.json because each carries | |
| 308 | # recurring cost. Recording a full-topology demo therefore needs a run-scoped | |
| 309 | # override rather than a committed config change — see docs/CUSTOMIZATION.md | |
| 310 | # (Run-scoped enablement overrides). | |
| 311 | # | |
| 312 | # Names with no demo section (keda, cert_manager, ...) are accepted and simply | |
| 313 | # have no effect here; they still reach the deploy. A typo is caught by the | |
| 314 | # deploy itself, which validates `--enable` against the canonical name sets in | |
| 315 | # gco/enablement_overrides.py before making any AWS call. | |
| 316 | 76 | demo_feature_forced() { |
| 317 | 421 | local wanted="$1" |
| 318 | 421 | local requested |
| 319 | 1263 | requested=$(printf '%s' "${GCO_DEMO_ENABLE:-}" | tr -d '[:space:]') |
| 320 | 421 | case ",${requested}," in |
| 321 | 27 | *",${wanted},"*) return 0 ;; |
| 322 | 394 | *) return 1 ;; |
| 323 | esac | |
| 324 | } | |
| 325 | ||
| 326 | # verify_enablement_overrides <repo_root> | |
| 327 | # | |
| 328 | # Validates GCO_DEMO_ENABLE against the canonical name sets in | |
| 329 | # gco/enablement_overrides.py, which is the same authority `gco stacks | |
| 330 | # deploy-all --enable` validates against. | |
| 331 | # | |
| 332 | # The deploy and destroy recorders get this for free because the CLI rejects an | |
| 333 | # unknown --enable name before any AWS call. The live-demo recorder does not: | |
| 334 | # it only *reads* the variable for section detection, so an unnoticed typo | |
| 335 | # would silently skip the very section the operator set out to record — after | |
| 336 | # the deploy already ran. Checking here keeps that failure loud and early. | |
| 337 | # | |
| 338 | # No-op when unset or empty, so unguarded default recordings are unaffected. | |
| 339 | verify_enablement_overrides() { | |
| 340 | 41 | local repo_root="$1" |
| 341 | 41 | local requested="${GCO_DEMO_ENABLE:-}" |
| 342 | 41 | if [ -z "$requested" ]; then |
| 343 | 29 | return 0 |
| 344 | fi | |
| 345 | # Distinguish "cannot check" from "check failed", so a broken interpreter is | |
| 346 | # not reported to the operator as an invalid feature name. | |
| 347 | 12 | if ! command -v python3 >/dev/null 2>&1; then |
| 348 | 4 | echo "python3 is required to validate GCO_DEMO_ENABLE." >&2 |
| 349 | 4 | return 2 |
| 350 | fi | |
| 351 | # The Python body is deliberately flush-left: it lives inside a quoted | |
| 352 | # shell string, so any indentation would reach the interpreter verbatim and | |
| 353 | # raise IndentationError. Errors are reported as one line rather than a | |
| 354 | # traceback, because this surfaces inside preflight output. | |
| 355 | ( | |
| 356 | 8 | cd "$repo_root" || exit 1 |
| 357 | 8 | python3 -c ' |
| 358 | import sys | |
| 359 | ||
| 360 | from gco.enablement_overrides import EnablementOverrideError, route_enablement_overrides | |
| 361 | ||
| 362 | try: | |
| 363 | route_enablement_overrides(sys.argv[1:]) | |
| 364 | except EnablementOverrideError as exc: | |
| 365 | sys.exit(str(exc)) | |
| 366 | ' "$requested" | |
| 367 | ) | |
| 368 | } | |
| 369 | ||
| 370 | 9 | detect_features() { |
| 371 | 51 | local cdk="${1:-cdk.json}" |
| 372 | 102 | VOLCANO_ENABLED=$(jq -r '.context.helm.volcano.enabled // false' "$cdk") |
| 373 | 102 | KUEUE_ENABLED=$(jq -r '.context.helm.kueue.enabled // false' "$cdk") |
| 374 | 102 | YUNIKORN_ENABLED=$(jq -r '.context.helm.yunikorn.enabled // false' "$cdk") |
| 375 | 102 | SLURM_ENABLED=$(jq -r '.context.helm.slurm.enabled // false' "$cdk") |
| 376 | 102 | FSX_ENABLED=$(jq -r '.context.fsx_lustre.enabled // false' "$cdk") |
| 377 | 102 | VALKEY_ENABLED=$(jq -r '.context.valkey.enabled // false' "$cdk") |
| 378 | 102 | AURORA_PGVECTOR_ENABLED=$(jq -r '.context.aurora_pgvector.enabled // false' "$cdk") |
| 379 | 102 | VECTOR_STORE_ENABLED=$(jq -r '.context.vector_store.enabled // false' "$cdk") |
| 380 | ||
| 381 | # Overrides are one-way, matching the CDK context semantics: they can only | |
| 382 | # turn a feature on, never off. A feature an operator disabled stays | |
| 383 | # disabled unless it is named explicitly. | |
| 384 | 51 | if demo_feature_forced volcano; then VOLCANO_ENABLED=true; fi |
| 385 | 51 | if demo_feature_forced kueue; then KUEUE_ENABLED=true; fi |
| 386 | 53 | if demo_feature_forced yunikorn; then YUNIKORN_ENABLED=true; fi |
| 387 | 53 | if demo_feature_forced slurm; then SLURM_ENABLED=true; fi |
| 388 | 55 | if demo_feature_forced fsx_lustre; then FSX_ENABLED=true; fi |
| 389 | 56 | if demo_feature_forced valkey; then VALKEY_ENABLED=true; fi |
| 390 | 53 | if demo_feature_forced aurora_pgvector; then AURORA_PGVECTOR_ENABLED=true; fi |
| 391 | 55 | if demo_feature_forced vector_store; then VECTOR_STORE_ENABLED=true; fi |
| 392 | } | |
| 393 | ||
| 394 | 2 | detect_region() { |
| 395 | 110 | local cdk="${1:-cdk.json}" |
| 396 | 213 | REGION="${GCO_DEMO_REGION:-$(jq -r '.context.deployment_regions.regional[0] // "us-east-1"' "$cdk")}" |
| 397 | } | |
| 398 | ||
| 399 | 1 | detect_endpoint_access() { |
| 400 | 35 | local cdk="${1:-cdk.json}" |
| 401 | 70 | ENDPOINT_ACCESS=$(jq -r '.context.eks_cluster.endpoint_access // "PRIVATE"' "$cdk") |
| 402 | } | |
| 403 | ||
| 404 | # ── Section Counter ────────────────────────────────────────────────────────── | |
| 405 | ||
| 406 | # Section counter — can't use $(next_section) because command substitution | |
| 407 | # runs in a subshell and the counter increment is lost. Instead we increment | |
| 408 | # inline and use the variable directly. | |
| 409 | 472 | SECTION=0 |
| 410 | ||
| 411 | # ── ARN Helpers (shared with setup-cluster-access.sh) ──────────────────────── | |
| 412 | ||
| 413 | # Checks if an ARN is an assumed-role ARN. | |
| 414 | 6 | is_assumed_role() { |
| 415 | 14 | [[ "$1" == *":assumed-role/"* ]] |
| 416 | } | |
| 417 | ||
| 418 | # Extracts the role name from an assumed-role ARN. | |
| 419 | # Input: arn:aws:sts::123456789012:assumed-role/MyRole/session-name | |
| 420 | # Output: MyRole | |
| 421 | 4 | extract_role_name() { |
| 422 | 18 | echo "$1" | sed 's/.*:assumed-role\/\([^\/]*\)\/.*/\1/' |
| 423 | } | |
| 424 | ||
| 425 | # Reconstructs an IAM role ARN from an assumed-role ARN and account ID. | |
| 426 | # Input: role_name, account_id | |
| 427 | # Output: arn:aws:iam::123456789012:role/MyRole | |
| 428 | 2 | build_role_arn() { |
| 429 | 5 | local role_name="$1" |
| 430 | 5 | local account_id="$2" |
| 431 | 5 | echo "arn:aws:iam::${account_id}:role/${role_name}" |
| 432 | } | |
| 433 | ||
| 434 | # ── Recording Helpers ──────────────────────────────────────────────────────── | |
| 435 | ||
| 436 | # Default font family used when rendering .cast files to GIFs with agg. | |
| 437 | # | |
| 438 | # agg's text renderer (resvg/usvg) is first-family-wins — it does not do | |
| 439 | # per-glyph fallback down the family list like a GUI text engine would. So | |
| 440 | # Menlo is kept first because it covers the characters our scripts emit | |
| 441 | # (box-drawing, arrows, geometric shapes, and the dingbats ✓ ✗ ⚠ ▸). Any | |
| 442 | # codepoint Menlo doesn't have (typically color-emoji pictographs from CDK | |
| 443 | # output like ✨ and ✅, or the information symbol ℹ) would otherwise fall | |
| 444 | # through to ``.LastResort`` and render as a tofu box. | |
| 445 | # | |
| 446 | # We fix that upstream instead of with more font fallbacks: every cast file | |
| 447 | # runs through ``strip_emoji_from_cast`` before ``render_gif``, which maps | |
| 448 | # the known tofu-triggering codepoints to safe monochrome equivalents. | |
| 449 | # After that pass, Menlo covers every character in the cast and agg never | |
| 450 | # needs to fall back. | |
| 451 | # | |
| 452 | # Override via the DEMO_FONT_FAMILY environment variable if you need to | |
| 453 | # skip this substitution and use a font that has real coverage of those | |
| 454 | # codepoints (e.g. a full Unicode monospace font). | |
| 455 | 472 | DEMO_FONT_FAMILY_DEFAULT="Menlo,Monaco,Courier New" |
| 456 | ||
| 457 | # verify_recording_git_state <repo_root> [allowed_dirty_path ...] | |
| 458 | # | |
| 459 | # When GCO_EXPECTED_GIT_SHA is set, verifies that the checkout is exactly that | |
| 460 | # full commit and rejects every dirty/untracked path except the explicitly | |
| 461 | # supplied recorder outputs. The output allowlist lets a destroy recording | |
| 462 | # follow a deploy recording before the four generated artifacts are committed, | |
| 463 | # while still proving that the source tree matches the CI-green commit. | |
| 464 | verify_recording_git_state() { | |
| 465 | 42 | local repo_root="$1" |
| 466 | 42 | shift |
| 467 | 42 | local expected="${GCO_EXPECTED_GIT_SHA:-}" |
| 468 | 42 | if [ -z "$expected" ]; then |
| 469 | 1 | return 0 |
| 470 | fi | |
| 471 | 81 | if [ "${#expected}" -ne 40 ] || [[ "$expected" == *[!0-9a-fA-F]* ]]; then |
| 472 | 2 | echo "GCO_EXPECTED_GIT_SHA must be a full 40-character hexadecimal SHA." >&2 |
| 473 | 2 | return 1 |
| 474 | fi | |
| 475 | ||
| 476 | 39 | local actual |
| 477 | 78 | if ! actual=$(git -C "$repo_root" rev-parse HEAD 2>/dev/null); then |
| 478 | 2 | echo "Unable to resolve git HEAD in $repo_root." >&2 |
| 479 | 2 | return 1 |
| 480 | fi | |
| 481 | 37 | local expected_normalized actual_normalized |
| 482 | 111 | expected_normalized=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') |
| 483 | 111 | actual_normalized=$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]') |
| 484 | 37 | if [ "$actual_normalized" != "$expected_normalized" ]; then |
| 485 | 1 | echo "Git HEAD does not match GCO_EXPECTED_GIT_SHA." >&2 |
| 486 | 1 | return 1 |
| 487 | fi | |
| 488 | ||
| 489 | 36 | local dirty |
| 490 | 72 | if ! dirty=$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all); then |
| 491 | 1 | echo "Unable to inspect git worktree state in $repo_root." >&2 |
| 492 | 1 | return 1 |
| 493 | fi | |
| 494 | ||
| 495 | 35 | local line path candidate is_allowed rename_source rename_destination |
| 496 | 35 | local unexpected="" |
| 497 | 146 | while IFS= read -r line; do |
| 498 | 69 | [ -n "$line" ] || continue |
| 499 | 7 | path="${line:3}" |
| 500 | 7 | rename_source="" |
| 501 | 7 | rename_destination="$path" |
| 502 | 7 | case "$path" in |
| 503 | *" -> "*) | |
| 504 | 1 | rename_source="${path%% -> *}" |
| 505 | 1 | rename_destination="${path##* -> }" |
| 506 | ;; | |
| 507 | esac | |
| 508 | # Porcelain-v1 renders renames as ``source -> destination``. Both | |
| 509 | # paths must be allowlisted: accepting only the destination would let | |
| 510 | # a recorder delete or move arbitrary tracked source files. | |
| 511 | 14 | for path in "$rename_source" "$rename_destination"; do |
| 512 | 20 | [ -n "$path" ] || continue |
| 513 | 8 | is_allowed=0 |
| 514 | 24 | for candidate in "$@"; do |
| 515 | 24 | if [ "$path" = "$candidate" ]; then |
| 516 | 5 | is_allowed=1 |
| 517 | 5 | break |
| 518 | fi | |
| 519 | done | |
| 520 | 8 | if [ "$is_allowed" -ne 1 ]; then |
| 521 | 3 | unexpected="${unexpected}${unexpected:+, }${path}" |
| 522 | fi | |
| 523 | done | |
| 524 | done <<< "$dirty" | |
| 525 | ||
| 526 | 35 | if [ -n "$unexpected" ]; then |
| 527 | 3 | echo "Unexpected dirty paths for guarded recording: $unexpected" >&2 |
| 528 | 3 | return 1 |
| 529 | fi | |
| 530 | } | |
| 531 | ||
| 532 | # verify_recording_aws_account | |
| 533 | # | |
| 534 | # When GCO_EXPECTED_ACCOUNT_ID is set, resolves the active caller through STS | |
| 535 | # and requires an exact match before a recorder can mutate infrastructure. | |
| 536 | verify_recording_aws_account() { | |
| 537 | 36 | local expected="${GCO_EXPECTED_ACCOUNT_ID:-}" |
| 538 | 36 | if [ -z "$expected" ]; then |
| 539 | 1 | return 0 |
| 540 | fi | |
| 541 | 35 | if ! [[ "$expected" =~ ^[0-9]{12}$ ]]; then |
| 542 | 1 | echo "GCO_EXPECTED_ACCOUNT_ID must contain exactly 12 digits." >&2 |
| 543 | 1 | return 1 |
| 544 | fi | |
| 545 | ||
| 546 | 34 | local actual |
| 547 | 68 | if ! actual=$(aws sts get-caller-identity --query Account --output text 2>/dev/null); then |
| 548 | 1 | echo "Unable to resolve the active AWS account through STS." >&2 |
| 549 | 1 | return 1 |
| 550 | fi | |
| 551 | 33 | if [ "$actual" != "$expected" ]; then |
| 552 | 1 | echo "Active AWS account does not match GCO_EXPECTED_ACCOUNT_ID." >&2 |
| 553 | 1 | return 1 |
| 554 | fi | |
| 555 | } | |
| 556 | ||
| 557 | # verify_legacy_live_recording_authorization <repo_root> | |
| 558 | # | |
| 559 | # Fail-closed gate for the three publishable legacy recordings. A caller must | |
| 560 | # explicitly acknowledge live mutations and bind the session to one reviewed | |
| 561 | # commit and one authorized AWS account. The six generated legacy artifacts | |
| 562 | # may be dirty so deploy, live-demo, and destroy can be captured sequentially | |
| 563 | # from the same checkout; every source or Autopilot path must remain clean. | |
| 564 | verify_legacy_live_recording_authorization() { | |
| 565 | 41 | local repo_root="$1" |
| 566 | 41 | if [ "${GCO_RECORDING_LIVE:-}" != "1" ]; then |
| 567 | 4 | echo "Set GCO_RECORDING_LIVE=1 to acknowledge live AWS/Kubernetes mutations." >&2 |
| 568 | 4 | return 1 |
| 569 | fi | |
| 570 | 37 | if [ -z "${GCO_EXPECTED_GIT_SHA:-}" ]; then |
| 571 | 1 | echo "GCO_EXPECTED_GIT_SHA is required for a live legacy recording." >&2 |
| 572 | 1 | return 1 |
| 573 | fi | |
| 574 | 36 | if [ -z "${GCO_EXPECTED_ACCOUNT_ID:-}" ]; then |
| 575 | 1 | echo "GCO_EXPECTED_ACCOUNT_ID is required for a live legacy recording." >&2 |
| 576 | 1 | return 1 |
| 577 | fi | |
| 578 | 35 | verify_recording_git_state "$repo_root" \ |
| 579 | "demo/deploy.cast" "demo/deploy.gif" \ | |
| 580 | "demo/live_demo.cast" "demo/live_demo.gif" \ | |
| 581 | 3 | "demo/destroy.cast" "demo/destroy.gif" || return 1 |
| 582 | 32 | verify_recording_aws_account || return 1 |
| 583 | } | |
| 584 | ||
| 585 | # verify_recording_kube_context <cluster-name> <region> | |
| 586 | # | |
| 587 | # Bind the active kubectl context to the EKS cluster resolved through the same | |
| 588 | # AWS identity that passed the account guard. Exact endpoint comparison prevents | |
| 589 | # namespace-wide cleanup from reaching an unrelated but otherwise healthy | |
| 590 | # cluster in another account or context. | |
| 591 | verify_recording_kube_context() { | |
| 592 | 45 | local cluster_name="$1" |
| 593 | 45 | local region="$2" |
| 594 | 45 | local expected_endpoint current_endpoint |
| 595 | 90 | if ! expected_endpoint=$(aws eks describe-cluster \ |
| 596 | --name "$cluster_name" \ | |
| 597 | --region "$region" \ | |
| 598 | --query 'cluster.endpoint' \ | |
| 599 | --output text 2>/dev/null); then | |
| 600 | 1 | echo "Unable to resolve the expected EKS endpoint for recording." >&2 |
| 601 | 1 | return 1 |
| 602 | fi | |
| 603 | 88 | if ! current_endpoint=$(kubectl config view --minify \ |
| 604 | -o 'jsonpath={.clusters[0].cluster.server}' 2>/dev/null); then | |
| 605 | 1 | echo "Unable to resolve the active kubectl server." >&2 |
| 606 | 1 | return 1 |
| 607 | fi | |
| 608 | 43 | expected_endpoint="${expected_endpoint%/}" |
| 609 | 43 | current_endpoint="${current_endpoint%/}" |
| 610 | 86 | if [ -z "$expected_endpoint" ] || [ "$expected_endpoint" = "None" ] || \ |
| 611 | [ "$current_endpoint" != "$expected_endpoint" ]; then | |
| 612 | 5 | echo "Active kubectl context does not match the authorized GCO EKS cluster." >&2 |
| 613 | 5 | return 1 |
| 614 | fi | |
| 615 | } | |
| 616 | ||
| 617 | # A fixed hard-link beneath Git's common directory serializes all legacy | |
| 618 | # recorders across linked worktrees without dirtying any checkout. Each process | |
| 619 | # prepares a private owner file before atomically linking it into the fixed lock | |
| 620 | # path. Registering both paths first lets handled signals clean up safely before, | |
| 621 | # during, or immediately after acquisition without touching another owner. A | |
| 622 | # SIGKILL can still leave the lock fail-closed for operator inspection. | |
| 623 | 472 | LEGACY_RECORDING_LOCK_FILE="" |
| 624 | 472 | LEGACY_RECORDING_LOCK_OWNER_FILE="" |
| 625 | ||
| 626 | 4 | acquire_legacy_recording_lock() { |
| 627 | 52 | local repo_root="$1" |
| 628 | 52 | local git_common |
| 629 | 104 | if ! git_common=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null); then |
| 630 | 1 | echo "Unable to resolve the Git common directory for recording lock." >&2 |
| 631 | 1 | return 1 |
| 632 | fi | |
| 633 | 51 | case "$git_common" in |
| 634 | /*) ;; | |
| 635 | 51 | *) git_common="${repo_root}/${git_common}" ;; |
| 636 | esac | |
| 637 | 152 | if ! git_common=$(cd "$git_common" 2>/dev/null && pwd -P); then |
| 638 | 1 | echo "Unable to canonicalize the Git common directory for recording lock." >&2 |
| 639 | 1 | return 1 |
| 640 | fi | |
| 641 | ||
| 642 | 50 | local lock_file="${git_common}/gco-legacy-recording.lock" |
| 643 | 50 | local owner_file="${lock_file}.owner.${BASHPID:-$$}.${RANDOM}" |
| 644 | 50 | LEGACY_RECORDING_LOCK_FILE="$lock_file" |
| 645 | 50 | LEGACY_RECORDING_LOCK_OWNER_FILE="$owner_file" |
| 646 | ||
| 647 | 150 | if ! (umask 077; set -o noclobber; printf 'pid=%s\nrepo=%s\n' \ |
| 648 | "$$" "$repo_root" > "$owner_file") 2>/dev/null; then | |
| 649 | 3 | LEGACY_RECORDING_LOCK_FILE="" |
| 650 | 3 | LEGACY_RECORDING_LOCK_OWNER_FILE="" |
| 651 | 3 | echo "Unable to create recording lock owner file: ${owner_file}." >&2 |
| 652 | 3 | return 1 |
| 653 | fi | |
| 654 | 47 | if ! ln "$owner_file" "$lock_file" 2>/dev/null; then |
| 655 | 1 | rm -f -- "$owner_file" || true |
| 656 | 1 | LEGACY_RECORDING_LOCK_FILE="" |
| 657 | 1 | LEGACY_RECORDING_LOCK_OWNER_FILE="" |
| 658 | 1 | echo "Another legacy demo recorder holds ${lock_file}." >&2 |
| 659 | 1 | return 1 |
| 660 | fi | |
| 661 | } | |
| 662 | ||
| 663 | 1 | release_legacy_recording_lock() { |
| 664 | 66 | local lock_file="${LEGACY_RECORDING_LOCK_FILE:-}" |
| 665 | 66 | local owner_file="${LEGACY_RECORDING_LOCK_OWNER_FILE:-}" |
| 666 | 110 | if [ -z "$lock_file" ] || [ -z "$owner_file" ]; then |
| 667 | 22 | return 0 |
| 668 | fi | |
| 669 | ||
| 670 | 85 | if [ -e "$lock_file" ] && [ -e "$owner_file" ] && \ |
| 671 | [ "$owner_file" -ef "$lock_file" ]; then | |
| 672 | 41 | if ! rm -f -- "$lock_file"; then |
| 673 | 4 | echo "Unable to release legacy recording lock: ${lock_file}" >&2 |
| 674 | 4 | return 1 |
| 675 | fi | |
| 676 | fi | |
| 677 | 80 | if [ -e "$owner_file" ] && ! rm -f -- "$owner_file"; then |
| 678 | 1 | echo "Unable to remove recording lock owner file: ${owner_file}" >&2 |
| 679 | 1 | return 1 |
| 680 | fi | |
| 681 | 39 | LEGACY_RECORDING_LOCK_FILE="" |
| 682 | 39 | LEGACY_RECORDING_LOCK_OWNER_FILE="" |
| 683 | } | |
| 684 | ||
| 685 | # sanitize_cast <cast_file> | |
| 686 | # | |
| 687 | # Redacts AWS account IDs and temporary/long-lived AWS access-key IDs from an | |
| 688 | # asciinema recording. Account-ID-shaped values become 000000000000 and | |
| 689 | # AKIA/ASIA access-key IDs become REDACTED_AWS_ACCESS_KEY_ID. Operates in place. | |
| 690 | # | |
| 691 | # The account heuristic is intentionally broad: unrelated standalone 12-digit | |
| 692 | # values are also redacted. Over-redaction is safer than allowing an account ID | |
| 693 | # into a committed cast or the GIF rendered from it. | |
| 694 | # | |
| 695 | # Use SKIP_SANITIZE=1 only to debug a local recording. Bypassed artifacts must | |
| 696 | # never be committed or distributed. | |
| 697 | 7 | sanitize_cast() { |
| 698 | 46 | local cast_file="$1" |
| 699 | 46 | if [ "${SKIP_SANITIZE:-}" = "1" ]; then |
| 700 | 2 | return |
| 701 | fi | |
| 702 | 44 | if [ ! -f "$cast_file" ]; then |
| 703 | 1 | return |
| 704 | fi | |
| 705 | ||
| 706 | 43 | python3 - "$cast_file" <<'PYEOF' |
| 707 | import json | |
| 708 | import re | |
| 709 | import sys | |
| 710 | from pathlib import Path | |
| 711 | ||
| 712 | ACCOUNT_ID = re.compile(r"(?<![0-9])[0-9]{12}(?![0-9])") | |
| 713 | ACCESS_KEY_ID = re.compile(r"(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])") | |
| 714 | PATTERNS = ( | |
| 715 | (ACCOUNT_ID, "000000000000"), | |
| 716 | (ACCESS_KEY_ID, "REDACTED_AWS_ACCESS_KEY_ID"), | |
| 717 | ) | |
| 718 | ||
| 719 | ||
| 720 | def redactions(text): | |
| 721 | edits = [ | |
| 722 | (match.start(), match.end(), replacement) | |
| 723 | for pattern, replacement in PATTERNS | |
| 724 | for match in pattern.finditer(text) | |
| 725 | ] | |
| 726 | edits.sort(key=lambda edit: (edit[0], edit[1])) | |
| 727 | return edits | |
| 728 | ||
| 729 | ||
| 730 | def redact_text(text): | |
| 731 | edits = redactions(text) | |
| 732 | parts = [] | |
| 733 | cursor = 0 | |
| 734 | for start, end, replacement in edits: | |
| 735 | if start < cursor: | |
| 736 | continue | |
| 737 | parts.extend((text[cursor:start], replacement)) | |
| 738 | cursor = end | |
| 739 | parts.append(text[cursor:]) | |
| 740 | return "".join(parts) | |
| 741 | ||
| 742 | ||
| 743 | def redact_value(value): | |
| 744 | if isinstance(value, str): | |
| 745 | return redact_text(value) | |
| 746 | if isinstance(value, list): | |
| 747 | return [redact_value(item) for item in value] | |
| 748 | if isinstance(value, dict): | |
| 749 | return {key: redact_value(item) for key, item in value.items()} | |
| 750 | return value | |
| 751 | ||
| 752 | ||
| 753 | def mapped_offset(position, edits): | |
| 754 | source_cursor = 0 | |
| 755 | output_cursor = 0 | |
| 756 | for start, end, replacement in edits: | |
| 757 | if position <= start: | |
| 758 | return output_cursor + position - source_cursor | |
| 759 | output_cursor += start - source_cursor | |
| 760 | if position < end: | |
| 761 | # Assign a replacement spanning event boundaries to the event in | |
| 762 | # which the sensitive value began; later fragments become empty. | |
| 763 | return output_cursor + len(replacement) | |
| 764 | output_cursor += len(replacement) | |
| 765 | source_cursor = end | |
| 766 | return output_cursor + position - source_cursor | |
| 767 | ||
| 768 | ||
| 769 | path = Path(sys.argv[1]) | |
| 770 | documents = [] | |
| 771 | for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): | |
| 772 | if not line.strip(): | |
| 773 | raise ValueError(f"blank line {line_number} is not valid cast NDJSON") | |
| 774 | documents.append(json.loads(line)) | |
| 775 | ||
| 776 | output_events = [] | |
| 777 | for index, document in enumerate(documents): | |
| 778 | if isinstance(document, list) and len(document) >= 3 and document[1] == "o": | |
| 779 | if not isinstance(document[2], str): | |
| 780 | raise ValueError(f"output event {index + 1} has a non-string payload") | |
| 781 | output_events.append(document) | |
| 782 | else: | |
| 783 | documents[index] = redact_value(document) | |
| 784 | ||
| 785 | # Redact each payload first so a complete identifier cannot be hidden by | |
| 786 | # adjacent event content that turns it into one longer alphanumeric run. | |
| 787 | for event in output_events: | |
| 788 | event[2] = redact_text(event[2]) | |
| 789 | ||
| 790 | # Then redact the concatenated rendered terminal so identifiers split by | |
| 791 | # asciinema's event boundaries cannot evade either pattern. | |
| 792 | source = "".join(event[2] for event in output_events) | |
| 793 | edits = redactions(source) | |
| 794 | rendered = redact_text(source) | |
| 795 | offset = 0 | |
| 796 | for event in output_events: | |
| 797 | start = offset | |
| 798 | offset += len(event[2]) | |
| 799 | event[2] = rendered[mapped_offset(start, edits):mapped_offset(offset, edits)] | |
| 800 | ||
| 801 | serialized = "\n".join( | |
| 802 | json.dumps(document, ensure_ascii=False, separators=(",", ":")) | |
| 803 | for document in documents | |
| 804 | ) | |
| 805 | path.write_text(serialized + ("\n" if documents else ""), encoding="utf-8") | |
| 806 | PYEOF | |
| 807 | 43 | } |
| 808 | 43 | |
| 809 | 43 | # verify_cast_sanitized <cast_file> |
| 810 | 43 | # |
| 811 | 43 | # Independently verifies the sanitizer's postcondition without printing the |
| 812 | 43 | # matched values. The all-zero account placeholder is allowed; every other |
| 813 | 43 | # standalone 12-digit value and every AKIA/ASIA access-key ID fails closed. |
| 814 | 2 | verify_cast_sanitized() { |
| 815 | 36 | local cast_file="$1" |
| 816 | 36 | if [ "${SKIP_SANITIZE:-}" = "1" ]; then |
| 817 | 43 | return |
| 818 | 43 | fi |
| 819 | 36 | if [ ! -f "$cast_file" ]; then |
| 820 | 43 | echo "Cannot verify missing cast file: $cast_file" >&2 |
| 821 | 43 | return 1 |
| 822 | 43 | fi |
| 823 | 43 | |
| 824 | 43 | python3 - "$cast_file" <<'PYEOF' |
| 825 | import json | |
| 826 | import re | |
| 827 | import sys | |
| 828 | from pathlib import Path | |
| 829 | ||
| 830 | ACCOUNT_ID = re.compile(r"(?<![0-9])[0-9]{12}(?![0-9])") | |
| 831 | ACCESS_KEY_ID = re.compile(r"(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])") | |
| 832 | ||
| 833 | ||
| 834 | def string_values(value): | |
| 835 | if isinstance(value, str): | |
| 836 | yield value | |
| 837 | elif isinstance(value, list): | |
| 838 | for item in value: | |
| 839 | yield from string_values(item) | |
| 840 | elif isinstance(value, dict): | |
| 841 | for item in value.values(): | |
| 842 | yield from string_values(item) | |
| 843 | ||
| 844 | ||
| 845 | documents = [] | |
| 846 | path = Path(sys.argv[1]) | |
| 847 | for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): | |
| 848 | if not line.strip(): | |
| 849 | raise ValueError(f"blank line {line_number} is not valid cast NDJSON") | |
| 850 | documents.append(json.loads(line)) | |
| 851 | ||
| 852 | output_payloads = [] | |
| 853 | non_output_strings = [] | |
| 854 | for index, document in enumerate(documents): | |
| 855 | if isinstance(document, list) and len(document) >= 3 and document[1] == "o": | |
| 856 | if not isinstance(document[2], str): | |
| 857 | raise ValueError(f"output event {index + 1} has a non-string payload") | |
| 858 | output_payloads.append(document[2]) | |
| 859 | non_output_strings.extend(string_values(document[:2])) | |
| 860 | non_output_strings.extend(string_values(document[3:])) | |
| 861 | else: | |
| 862 | non_output_strings.extend(string_values(document)) | |
| 863 | ||
| 864 | # Verify decoded content, not raw JSON serialization. This independently | |
| 865 | # reconstructs the rendered output stream and catches identifiers split across | |
| 866 | # adjacent output events while still checking header/non-output string fields. | |
| 867 | texts = [*output_payloads, "".join(output_payloads), *non_output_strings] | |
| 868 | account_ids = [ | |
| 869 | match.group(0) | |
| 870 | for text in texts | |
| 871 | for match in ACCOUNT_ID.finditer(text) | |
| 872 | if match.group(0) != "000000000000" | |
| 873 | ] | |
| 874 | access_key_ids = [ | |
| 875 | match.group(0) | |
| 876 | for text in texts | |
| 877 | for match in ACCESS_KEY_ID.finditer(text) | |
| 878 | ] | |
| 879 | if account_ids or access_key_ids: | |
| 880 | print( | |
| 881 | "Cast sanitization verification failed: " | |
| 882 | f"{len(account_ids)} account-ID pattern(s), " | |
| 883 | f"{len(access_key_ids)} access-key-ID pattern(s) remain.", | |
| 884 | file=sys.stderr, | |
| 885 | ) | |
| 886 | raise SystemExit(1) | |
| 887 | PYEOF | |
| 888 | 43 | } |
| 889 | 43 | |
| 890 | 43 | # strip_emoji_from_cast <cast_file> |
| 891 | 43 | # |
| 892 | 43 | # Rewrites tofu-triggering Unicode codepoints in a .cast file to ASCII or |
| 893 | 43 | # to monochrome glyphs Menlo can render, so agg never falls back to |
| 894 | 43 | # ``.LastResort`` during GIF conversion. |
| 895 | 43 | # |
| 896 | 43 | # Background: agg uses resvg/usvg, a pure-vector text renderer. When the |
| 897 | 43 | # first font in the family list can't render a glyph, usvg falls back to |
| 898 | 43 | # ``.LastResort`` (the system tofu font) rather than iterating the family |
| 899 | 43 | # list. Color emoji fonts like Apple Color Emoji don't help because they're |
| 900 | 43 | # bitmap (sbix/COLR) fonts, which usvg cannot use. |
| 901 | 43 | # |
| 902 | 43 | # This helper runs in-place with Python 3 for portable Unicode handling. |
| 903 | 43 | # The substitutions: |
| 904 | 43 | # ℹ (INFORMATION SOURCE, U+2139) → i Menlo has no glyph |
| 905 | 43 | # ✅ (WHITE HEAVY CHECK MARK, U+2705) → ✓ Menlo has ✓, not ✅ |
| 906 | 43 | # ✨ (SPARKLES, U+2728) → * Menlo has no glyph |
| 907 | 43 | # 📦 (PACKAGE, U+1F4E6) → [pkg] Menlo has no glyph |
| 908 | 43 | # 🚀 (ROCKET, U+1F680) → >> Menlo has no glyph |
| 909 | 43 | # |
| 910 | 43 | # Use SKIP_EMOJI_STRIP=1 to bypass (useful when you're confident your font |
| 911 | 43 | # chain renders everything correctly and don't want the substitutions). |
| 912 | 8 | strip_emoji_from_cast() { |
| 913 | 48 | local cast_file="$1" |
| 914 | 48 | if [ "${SKIP_EMOJI_STRIP:-}" = "1" ]; then |
| 915 | 2 | return |
| 916 | 43 | fi |
| 917 | 46 | if [ ! -f "$cast_file" ]; then |
| 918 | 1 | return |
| 919 | 43 | fi |
| 920 | 43 | # Python handles Unicode character substitution cleanly across GNU and |
| 921 | 43 | # BSD sed variants, and lets us express the character set as a readable |
| 922 | 43 | # translation table rather than cramming UTF-8 byte sequences into a |
| 923 | 43 | # fragile sed one-liner. |
| 924 | 45 | python3 - "$cast_file" <<'PYEOF' |
| 925 | import sys | |
| 926 | from pathlib import Path | |
| 927 | ||
| 928 | # Single-character substitutions (str.translate with the ord key). | |
| 929 | SINGLE = { | |
| 930 | 0x2139: "i", # ℹ INFORMATION SOURCE → i | |
| 931 | 0x2705: "\u2713", # ✅ WHITE HEAVY CHECK MARK → ✓ (monochrome check, in Menlo) | |
| 932 | 0x2728: "*", # ✨ SPARKLES → * | |
| 933 | } | |
| 934 | ||
| 935 | # Multi-character substitutions applied after the translate pass. | |
| 936 | MULTI = { | |
| 937 | "\U0001F4E6": "[pkg]", # 📦 PACKAGE | |
| 938 | "\U0001F680": ">>", # 🚀 ROCKET | |
| 939 | } | |
| 940 | ||
| 941 | path = Path(sys.argv[1]) | |
| 942 | text = path.read_text(encoding="utf-8") | |
| 943 | text = text.translate(SINGLE) | |
| 944 | for src, dst in MULTI.items(): | |
| 945 | text = text.replace(src, dst) | |
| 946 | path.write_text(text, encoding="utf-8") | |
| 947 | PYEOF | |
| 948 | } | |
| 949 | ||
| 950 | # render_gif <cast_file> <gif_file> <speed> <theme> <cols> <rows> | |
| 951 | # | |
| 952 | # Converts an asciinema .cast file to an animated GIF using agg with the | |
| 953 | # shared font-family fallback chain. Centralised here so all three | |
| 954 | # record scripts render consistent-looking output. | |
| 955 | # | |
| 956 | # The DEMO_FONT_FAMILY env var overrides the default fallback list. | |
| 957 | 4 | render_gif() { |
| 958 | 30 | local cast_file="$1" |
| 959 | 30 | local gif_file="$2" |
| 960 | 30 | local speed="$3" |
| 961 | 30 | local theme="$4" |
| 962 | 30 | local cols="$5" |
| 963 | 30 | local rows="$6" |
| 964 | 30 | local font_family="${DEMO_FONT_FAMILY:-$DEMO_FONT_FAMILY_DEFAULT}" |
| 965 | ||
| 966 | 30 | agg \ |
| 967 | --speed "$speed" \ | |
| 968 | --theme "$theme" \ | |
| 969 | --font-family "$font_family" \ | |
| 970 | --font-size 14 \ | |
| 971 | --cols "$cols" \ | |
| 972 | --rows "$rows" \ | |
| 973 | "$cast_file" \ | |
| 974 | "$gif_file" | |
| 975 | } | |
| 976 | ||
| 977 | # ── Recording Publication Transaction ─────────────────────────────────────── | |
| 978 | # | |
| 979 | # A cast and its GIF cannot be switched with one POSIX rename. These globals | |
| 980 | # describe the narrow interval in which one file may have been switched but the | |
| 981 | # other has not. Recorder EXIT/signal cleanup calls rollback_recording_publication | |
| 982 | # so every handled failure restores the complete previous pair (or removes both | |
| 983 | # newly-created outputs when no previous pair existed). | |
| 984 | 472 | RECORDING_PUBLICATION_IN_PROGRESS=0 |
| 985 | 472 | RECORDING_PUBLICATION_COMPLETE=0 |
| 986 | 472 | RECORDING_PUBLICATION_STAGE_DIR="" |
| 987 | 472 | RECORDING_PUBLICATION_CAST_FILE="" |
| 988 | 472 | RECORDING_PUBLICATION_GIF_FILE="" |
| 989 | 472 | RECORDING_PUBLICATION_CAST_BACKUP="" |
| 990 | 472 | RECORDING_PUBLICATION_GIF_BACKUP="" |
| 991 | 472 | RECORDING_PUBLICATION_HAD_CAST=0 |
| 992 | 472 | RECORDING_PUBLICATION_HAD_GIF=0 |
| 993 | ||
| 994 | # recording_publication_restore_file <backup> <final> <restore-staging-path> | |
| 995 | # | |
| 996 | # Copies the preserved artifact to another same-filesystem staging path before | |
| 997 | # renaming it over the final. The original backup remains available if this | |
| 998 | # restoration attempt is interrupted or fails and the EXIT trap retries it. | |
| 999 | recording_publication_restore_file() { | |
| 1000 | 25 | local backup_file="$1" |
| 1001 | 25 | local final_file="$2" |
| 1002 | 25 | local restore_file="$3" |
| 1003 | ||
| 1004 | 25 | rm -f "$restore_file" |
| 1005 | 25 | if ! cp -p "$backup_file" "$restore_file"; then |
| 1006 | 9 | return 1 |
| 1007 | fi | |
| 1008 | 16 | if ! mv -f "$restore_file" "$final_file"; then |
| 1009 | 7 | return 1 |
| 1010 | fi | |
| 1011 | } | |
| 1012 | ||
| 1013 | # rollback_recording_publication | |
| 1014 | # | |
| 1015 | # Restores both artifacts represented by the active publication transaction. | |
| 1016 | # Backups are copied rather than consumed so an EXIT cleanup can retry after a | |
| 1017 | # partial restoration failure. The transaction remains active until both final | |
| 1018 | # paths match their pre-publication state. | |
| 1019 | rollback_recording_publication() { | |
| 1020 | 67 | if [ "${RECORDING_PUBLICATION_IN_PROGRESS:-0}" != "1" ]; then |
| 1021 | 52 | return 0 |
| 1022 | fi | |
| 1023 | ||
| 1024 | 15 | RECORDING_PUBLICATION_COMPLETE=0 |
| 1025 | 15 | local rollback_status=0 |
| 1026 | ||
| 1027 | 15 | if [ "$RECORDING_PUBLICATION_HAD_CAST" = "1" ]; then |
| 1028 | 13 | if ! recording_publication_restore_file \ |
| 1029 | "$RECORDING_PUBLICATION_CAST_BACKUP" \ | |
| 1030 | "$RECORDING_PUBLICATION_CAST_FILE" \ | |
| 1031 | "${RECORDING_PUBLICATION_STAGE_DIR}/.restore-cast"; then | |
| 1032 | 9 | rollback_status=1 |
| 1033 | fi | |
| 1034 | 2 | elif ! rm -f "$RECORDING_PUBLICATION_CAST_FILE"; then |
| 1035 | 1 | rollback_status=1 |
| 1036 | fi | |
| 1037 | ||
| 1038 | 15 | if [ "$RECORDING_PUBLICATION_HAD_GIF" = "1" ]; then |
| 1039 | 12 | if ! recording_publication_restore_file \ |
| 1040 | "$RECORDING_PUBLICATION_GIF_BACKUP" \ | |
| 1041 | "$RECORDING_PUBLICATION_GIF_FILE" \ | |
| 1042 | "${RECORDING_PUBLICATION_STAGE_DIR}/.restore-gif"; then | |
| 1043 | 7 | rollback_status=1 |
| 1044 | fi | |
| 1045 | 3 | elif ! rm -f "$RECORDING_PUBLICATION_GIF_FILE"; then |
| 1046 | 1 | rollback_status=1 |
| 1047 | fi | |
| 1048 | ||
| 1049 | 15 | if [ "$rollback_status" -eq 0 ]; then |
| 1050 | 4 | RECORDING_PUBLICATION_IN_PROGRESS=0 |
| 1051 | fi | |
| 1052 | 15 | return "$rollback_status" |
| 1053 | } | |
| 1054 | ||
| 1055 | # publish_recording_artifacts <staged-cast> <staged-gif-or-empty> <final-cast> <final-gif> | |
| 1056 | # | |
| 1057 | # Publishes a prepared cast/GIF pair with rollback across the two individually | |
| 1058 | # atomic same-filesystem renames. An empty staged GIF means the final GIF must | |
| 1059 | # be absent (SKIP_GIF mode). Existing finals are preserved before either final | |
| 1060 | # is changed. On any ordinary command failure this helper rolls both paths back; | |
| 1061 | # recorder EXIT/HUP/INT/TERM handling covers interruption between commands. | |
| 1062 | 2 | publish_recording_artifacts() { |
| 1063 | 45 | local staged_cast="$1" |
| 1064 | 45 | local staged_gif="$2" |
| 1065 | 45 | local final_cast="$3" |
| 1066 | 45 | local final_gif="$4" |
| 1067 | ||
| 1068 | 45 | if [ "${RECORDING_PUBLICATION_IN_PROGRESS:-0}" = "1" ]; then |
| 1069 | 1 | echo "A recording publication transaction is already active." >&2 |
| 1070 | 1 | return 1 |
| 1071 | fi | |
| 1072 | 44 | if [ ! -f "$staged_cast" ]; then |
| 1073 | 1 | echo "Cannot publish missing staged cast: $staged_cast" >&2 |
| 1074 | 1 | return 1 |
| 1075 | fi | |
| 1076 | 73 | if [ -n "$staged_gif" ] && [ ! -f "$staged_gif" ]; then |
| 1077 | 1 | echo "Cannot publish missing staged GIF: $staged_gif" >&2 |
| 1078 | 1 | return 1 |
| 1079 | fi | |
| 1080 | 84 | if [ -d "$final_cast" ] || [ -d "$final_gif" ]; then |
| 1081 | 1 | echo "Recording publication destinations must be files." >&2 |
| 1082 | 1 | return 1 |
| 1083 | fi | |
| 1084 | ||
| 1085 | 41 | local stage_dir |
| 1086 | 41 | local cast_backup |
| 1087 | 41 | local gif_backup |
| 1088 | 41 | local had_cast=0 |
| 1089 | 41 | local had_gif=0 |
| 1090 | 82 | stage_dir=$(dirname "$staged_cast") |
| 1091 | 41 | cast_backup="${stage_dir}/.previous-cast" |
| 1092 | 41 | gif_backup="${stage_dir}/.previous-gif" |
| 1093 | 41 | rm -f "$cast_backup" "$gif_backup" \ |
| 1094 | "${stage_dir}/.restore-cast" "${stage_dir}/.restore-gif" | |
| 1095 | ||
| 1096 | # Do not mark the transaction active until both required backups are fully | |
| 1097 | # copied. An interruption during this phase leaves the final paths intact. | |
| 1098 | 41 | if [ -e "$final_cast" ]; then |
| 1099 | 39 | if ! cp -p "$final_cast" "$cast_backup"; then |
| 1100 | 1 | return 1 |
| 1101 | fi | |
| 1102 | 38 | had_cast=1 |
| 1103 | fi | |
| 1104 | 40 | if [ -e "$final_gif" ]; then |
| 1105 | 37 | if ! cp -p "$final_gif" "$gif_backup"; then |
| 1106 | 1 | return 1 |
| 1107 | fi | |
| 1108 | 36 | had_gif=1 |
| 1109 | fi | |
| 1110 | ||
| 1111 | 39 | RECORDING_PUBLICATION_STAGE_DIR="$stage_dir" |
| 1112 | 39 | RECORDING_PUBLICATION_CAST_FILE="$final_cast" |
| 1113 | 39 | RECORDING_PUBLICATION_GIF_FILE="$final_gif" |
| 1114 | 39 | RECORDING_PUBLICATION_CAST_BACKUP="$cast_backup" |
| 1115 | 39 | RECORDING_PUBLICATION_GIF_BACKUP="$gif_backup" |
| 1116 | 39 | RECORDING_PUBLICATION_HAD_CAST="$had_cast" |
| 1117 | 39 | RECORDING_PUBLICATION_HAD_GIF="$had_gif" |
| 1118 | 39 | RECORDING_PUBLICATION_COMPLETE=0 |
| 1119 | 39 | RECORDING_PUBLICATION_IN_PROGRESS=1 |
| 1120 | ||
| 1121 | 39 | local publication_status |
| 1122 | 39 | if mv -f "$staged_cast" "$final_cast"; then |
| 1123 | 37 | : |
| 1124 | else | |
| 1125 | 2 | publication_status=$? |
| 1126 | 2 | if ! rollback_recording_publication; then |
| 1127 | 1 | echo "Recording publication failed and rollback could not complete." >&2 |
| 1128 | 1 | return 1 |
| 1129 | fi | |
| 1130 | 1 | return "$publication_status" |
| 1131 | fi | |
| 1132 | ||
| 1133 | 37 | if [ -n "$staged_gif" ]; then |
| 1134 | 26 | if mv -f "$staged_gif" "$final_gif"; then |
| 1135 | 18 | : |
| 1136 | else | |
| 1137 | 8 | publication_status=$? |
| 1138 | 8 | if ! rollback_recording_publication; then |
| 1139 | 6 | echo "Recording publication failed and rollback could not complete." >&2 |
| 1140 | 6 | return 1 |
| 1141 | fi | |
| 1142 | 2 | return "$publication_status" |
| 1143 | fi | |
| 1144 | 11 | elif rm -f "$final_gif"; then |
| 1145 | 9 | : |
| 1146 | else | |
| 1147 | 2 | publication_status=$? |
| 1148 | 2 | if ! rollback_recording_publication; then |
| 1149 | 1 | echo "Recording publication failed and rollback could not complete." >&2 |
| 1150 | 1 | return 1 |
| 1151 | fi | |
| 1152 | 1 | return "$publication_status" |
| 1153 | fi | |
| 1154 | ||
| 1155 | # Both final-path operations succeeded. If a handled signal arrives before | |
| 1156 | # IN_PROGRESS is cleared, the active transaction safely restores both old | |
| 1157 | # paths; after it is cleared, the new pair is already internally consistent. | |
| 1158 | 27 | RECORDING_PUBLICATION_COMPLETE=1 |
| 1159 | 27 | RECORDING_PUBLICATION_IN_PROGRESS=0 |
| 1160 | } |