From 0db6d31dc291bb939b3ef735146988ec1e7654b0 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 1 Oct 2026 17:29:04 +0200 Subject: [PATCH] fleetd #635: add scripts/config-edit.sh, the one auditable way to edit fleetd.yaml Backs up, builds a candidate off the live file, parse-checks it with yq before install, installs atomically, then reads the daemon's own ConfigRef reload verdict back out of fleetd.out (marked from before the edit, so a stale line can never be mistaken for this edit's result). Four exit codes: 0 clean, 3 needs a restart, 4 refused (backup restored), 5 cannot tell (nothing restored, printed --restore command). Every diff is redacted. scripts/test-config-edit.sh drives it end to end against fixtures in a throwaway temp dir, with no daemon involved. --- scripts/config-edit.sh | 569 ++++++++++++++++++++++++++++++++++++ scripts/test-config-edit.sh | 285 ++++++++++++++++++ 2 files changed, 854 insertions(+) create mode 100755 scripts/config-edit.sh create mode 100755 scripts/test-config-edit.sh diff --git a/scripts/config-edit.sh b/scripts/config-edit.sh new file mode 100755 index 0000000..2c9ef22 --- /dev/null +++ b/scripts/config-edit.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash +# +# The one auditable way to edit the live fleetd.yaml. +# +# fleetd ticket #635 — why this exists at all: fleetd.yaml is gitignored and holds the live +# fleet's settings. A bad raw edit reaches a daemon that is already serving, so a direct `Edit` +# on it is refused by policy. This script is the allow-listed alternative, and it is not just +# convenience — it is the thing a raw file write can never give you: a backup, a parse check +# BEFORE the file is installed, and the daemon's own reload verdict read back afterwards. An +# edit to a live config is not finished when the bytes are written. It is finished when the +# daemon has said what it did with them. +# +# What the daemon says, and how this script finds it — measured against `ConfigRef.java` on +# fleetd commit 158a2a8, 2026-10-01: +# +# 1. `ConfigRef` re-reads fleetd.yaml only when the WATCHER sees the mtime move (every 10s by +# default — read the real interval out of the daemon's own startup line, "config watch: ... +# re-read when it changes (every Ns)"). So a verdict never appears before the next tick. +# 2. `ConfigRef.Outcome.summary()` logs exactly one of five strings (ConfigRef.java:371-391): +# config reload refused — +# config reload refused — these keys cannot change under a running daemon: . ... +# config reloaded +# config reloaded; these changes need a restart to take effect: +# config reloaded; partially live — +# A parse/validation failure logs a DIFFERENT line instead, before any summary ever runs +# (ConfigRef.java:425): "config reload from refused, keeping the running config: +# ". This script recognises both shapes of refusal. +# 3. The em dash in those strings is a real multi-byte character — match the stable prefix +# "config reload refused" (or "...refused, keeping the running config" for the parse-failure +# shape), never the dash itself. +# 4. A cold-key change (bind/herdrSocket/memberHerdrSocket/broker/auth) throws away the WHOLE +# reload — the running config keeps every old value, not only the cold one. +# 5. A deferred/split change IS applied (current.set(fresh) runs) — "needs a restart" is a +# SUCCESS with a follow-up, never a failure. +# +# Four outcomes, and they stay four (see the exit code table below). The one most likely to be +# gotten wrong is "cannot tell" (exit 5): the daemon may be down, or the watcher may be stalled, +# and folding that into either "refused" or "applied" is worse than never checking at all, +# because a caller then acts on a verdict nobody actually read. So exit 5 never restores — a +# visible, recoverable edit beats an invisible revert of a GOOD edit. +# +# Usage: +# scripts/config-edit.sh --check +# scripts/config-edit.sh --set = [--set ...] +# scripts/config-edit.sh --from +# scripts/config-edit.sh --dry-run --set = +# scripts/config-edit.sh --restore +# +# Overrides (so this is drivable with no daemon — see scripts/test-config-edit.sh): +# --config default: fleetd/fleetd.yaml +# --log default: fleetd/fleetd.out +# --wait-seconds default: 4x the watch interval this script reads out of --log (10 -> 40) +# +# Exit codes (the --check/--restore/usage-error paths are reported separately, see below): +# 0 applied; verdict read; clean +# 3 applied; verdict read; needs a restart (deferred or split keys named) +# 4 REFUSED by the daemon; backup restored (and the restore's own verdict reported if seen) +# 5 CANNOT TELL — no verdict line inside the wait window. Nothing is restored. +# +# Never prints a secret. fleetd.yaml keeps credentials out by indirection (broker.uriEnv, +# gitTokenEnv) but this script does not rely on that staying true: every diff it prints is piped +# through `redact`, which (a) blanks the userinfo of any `scheme://user:pass@host` and (b) masks +# the whole value on any line whose key looks like a credential. See `redact` below. + +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SELF="$REPO/scripts/config-edit.sh" + +CONFIG="$REPO/fleetd/fleetd.yaml" +LOG="$REPO/fleetd/fleetd.out" +WAIT_SECONDS_OVERRIDE="" +FALLBACK_PORT=8765 + +MODE="" +DRY_RUN=0 +SETS=() +FROM_FILE="" + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +ok() { printf ' ok %s\n' "$*"; } +warn() { printf ' WARN %s\n' "$*"; } +die() { printf '\n FAIL %s\n\n' "$*" >&2; exit 1; } + +set_mode() { + local new="$1" + if [ -n "$MODE" ] && [ "$MODE" != "$new" ]; then + die "cannot combine --$MODE and --$new in one invocation" + fi + MODE="$new" +} + +while [ $# -gt 0 ]; do + case "$1" in + --check) set_mode check; shift ;; + --restore) set_mode restore; shift ;; + --set) + [ $# -ge 2 ] || die "--set requires =" + set_mode set + SETS+=("$2") + shift 2 ;; + --from) + [ $# -ge 2 ] || die "--from requires a candidate file path" + set_mode from + FROM_FILE="$2" + shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --config) + [ $# -ge 2 ] || die "--config requires a path" + CONFIG="$2"; shift 2 ;; + --log) + [ $# -ge 2 ] || die "--log requires a path" + LOG="$2"; shift 2 ;; + --wait-seconds) + [ $# -ge 2 ] || die "--wait-seconds requires a number of seconds" + WAIT_SECONDS_OVERRIDE="$2"; shift 2 ;; + -h|--help) sed -n '3,63p' "$SELF"; exit 0 ;; + *) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; + esac +done + +[ -n "$MODE" ] || die "no action given — use --check, --set, --from, or --restore (see --help)" + +# ------------------------------------------------------------------------------------- redaction +# +# Two independent passes, applied to every diff this script ever prints: +# 1. `scheme://user:pass@host` -> `scheme://@host`, globally (the `g` flag matters — +# a line can carry more than one URI). +# 2. Any line whose key looks like TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|URI|_KEY, matched +# case-insensitively against the key text (uriEnv, gitTokenEnv, ... are camelCase, not +# SCREAMING_CASE) has its whole value blanked, diff marker and indentation kept so the shape +# of the change is still visible. Deliberately conservative: a false-positive redaction on an +# unrelated line costs nothing, an unredacted secret is a security defect (acceptance +# criterion 7). +redact() { + local line marker saved_nocasematch=0 + shopt -q nocasematch && saved_nocasematch=1 + shopt -s nocasematch + sed -E 's#://[^@]*@#://@#g' | while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ ^([-+\ ]?[[:space:]]*[A-Za-z0-9_.-]+:) ]]; then + marker="${BASH_REMATCH[1]}" + if [[ "$marker" =~ (TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|URI|_KEY) ]]; then + printf '%s \n' "$marker" + continue + fi + fi + printf '%s\n' "$line" + done + [ "$saved_nocasematch" = 1 ] || shopt -u nocasematch +} + +# ------------------------------------------------------------------------------------- the probe +# +# Probe the SOCKET, never `pgrep`/`ps -f` — both print argv, and argv holds `NAME=value`, making +# either a credential channel. The port comes from the config's own `bind.port`; 8765 is only a +# fallback when that key is absent or the file does not parse yet. +resolve_port() { + local file="$1" port + if [ -f "$file" ] && command -v yq >/dev/null 2>&1; then + port="$(yq eval '.bind.port' "$file" 2>/dev/null || true)" + else + port="" + fi + case "$port" in + ''|null) echo "$FALLBACK_PORT" ;; + *) echo "$port" ;; + esac +} + +daemon_listening() { + local port="$1" + if command -v nc >/dev/null 2>&1; then + nc -z -w1 127.0.0.1 "$port" 2>/dev/null + else + ( exec 3<>"/dev/tcp/127.0.0.1/$port" ) 2>/dev/null + fi +} + +# --------------------------------------------------------------------------------- the log marker +# +# Take the log's line count BEFORE touching anything. Every later read of "what did the daemon +# say" starts strictly after this mark, so a refusal from hours ago can never be mistaken for +# this edit's verdict. Same approach as scripts/redeploy-fleetd.sh's RESTART_MARK. +log_mark() { + local file="$1" + if [ -f "$file" ]; then + wc -l < "$file" 2>/dev/null || echo 0 + else + echo 0 + fi +} + +read_verdict_after_marker() { + local file="$1" mark="$2" + [ -f "$file" ] || return 0 + tail -n "+$((mark + 1))" "$file" 2>/dev/null || true +} + +# Classifies one log LINE. Echoes one of: refused | clean | needs-restart | none. Always +# succeeds (every branch ends in `echo`), so it is safe to call from inside `$( )`. +classify_verdict_line() { + local line="$1" + case "$line" in + *'config reload refused'*) echo refused ;; + *'config reload from '*'refused, keeping the running config'*) echo refused ;; + *'config reloaded'*) + case "$line" in + *'need a restart'*|*'partially live'*) echo needs-restart ;; + *) echo clean ;; + esac ;; + *) echo none ;; + esac + return 0 +} + +scan_region_for_verdict() { + local region="$1" line kind + [ -n "$region" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + kind="$(classify_verdict_line "$line")" + if [ "$kind" != "none" ]; then + VERDICT_KIND="$kind" + VERDICT_LINE="$line" + return 0 + fi + done <<< "$region" + return 1 +} + +# Sets VERDICT_KIND/VERDICT_LINE and returns 0 on the first verdict line found after $mark; +# returns 1 (VERDICT_KIND=none) if none appeared inside $wait_s seconds. Checks once before each +# sleep AND once more after the last sleep, the same boundary idiom +# scripts/redeploy-fleetd.sh's wait_for_daemon_exit/wait_for_new_pid already use. +wait_for_verdict() { + local log="$1" mark="$2" wait_s="$3" _i region + VERDICT_KIND="none" + VERDICT_LINE="" + for _i in $(seq "$wait_s"); do + region="$(read_verdict_after_marker "$log" "$mark")" + scan_region_for_verdict "$region" && return 0 + sleep 1 + done + region="$(read_verdict_after_marker "$log" "$mark")" + scan_region_for_verdict "$region" && return 0 + return 1 +} + +last_verdict_line() { + local file="$1" line out="" + [ -f "$file" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$(classify_verdict_line "$line")" != "none" ]; then + out="$line" + fi + done < "$file" + printf '%s' "$out" +} + +default_wait_seconds() { + local log="$1" interval="" + if [ -f "$log" ]; then + interval="$(grep -F 'config watch:' "$log" 2>/dev/null | tail -1 \ + | sed -E 's/.*\(every ([0-9]+)s\).*/\1/' || true)" + fi + case "$interval" in + ''|*[!0-9]*) interval=10 ;; + esac + echo $((interval * 4)) +} + +# ----------------------------------------------------------------------------------- the backup +# +# Timestamped, never pruned — "keep backups" per the ticket. A pid suffix avoids a same-second +# collision between two invocations. +backup_config() { + local src="$1" ts backup + ts="$(date -u +%Y%m%dT%H%M%S)Z" + backup="${src}.bak.${ts}.$$" + cp "$src" "$backup" \ + || die "could not create a backup at $backup — refusing to edit without one. The live config at $src was NOT touched." + printf '%s' "$backup" +} + +newest_backup() { + local cfg="$1" + ls -t "${cfg}".bak.* 2>/dev/null | head -1 || true +} + +# --------------------------------------------------------------------------- candidate builders +# +# Never edit the live file in place. Each builder fills $1 (a temp file already sitting in the +# SAME directory as the live config, so the later `mv` install is a rename, not a cross-device +# copy — see run_edit). +apply_set_pairs() { + local cand="$1" kv path value + shift + for kv in "$@"; do + case "$kv" in + *=*) : ;; + *) die "--set expects =, got: '$kv'" ;; + esac + path="${kv%%=*}" + path="${path#.}" + value="${kv#*=}" + CONFIG_EDIT_SET_VALUE="$value" yq eval -i ".${path} = strenv(CONFIG_EDIT_SET_VALUE)" "$cand" \ + || die "yq could not apply --set '$kv' — nothing was installed. The live config is unchanged." + done +} + +build_from_set() { + local cand="$1" + cp "$CONFIG" "$cand" + apply_set_pairs "$cand" "${SETS[@]}" +} + +build_from_file() { + local cand="$1" + [ -f "$FROM_FILE" ] || die "--from file not found: $FROM_FILE" + cp "$FROM_FILE" "$cand" +} + +parse_check() { + yq eval '.' "$1" >/dev/null 2>&1 +} + +install_candidate() { + local cand="$1" live="$2" + mv -f "$cand" "$live" +} + +# -------------------------------------------------------------------------------- the report path +# +# Prints the literal command the operator (or a test) can run to restore the backup by hand — the +# absolute path to THIS script plus the overrides actually in force, so it works from any cwd. +restore_command_line() { + printf '%q --restore --config %q --log %q --wait-seconds %q' "$SELF" "$CONFIG" "$LOG" "$WAIT_SECONDS" +} + +# State 4 only: restore the pre-edit backup, then wait for a SECOND verdict confirming the +# restore itself reloaded cleanly. Never claims a restore it did not observe — if the second wait +# also times out, it says so plainly rather than reporting "restored" as though confirmed. +restore_and_confirm() { + local backup="$1" mark2 + mark2="$(log_mark "$LOG")" + cp "$backup" "$CONFIG" \ + || die "could not restore $backup onto $CONFIG — the live config is left as the REFUSED edit. Fix this by hand immediately: cp \"$backup\" \"$CONFIG\"" + ok "restored from $backup" + if wait_for_verdict "$LOG" "$mark2" "$WAIT_SECONDS"; then + case "$VERDICT_KIND" in + refused) warn "the RESTORE was also refused by the daemon: $VERDICT_LINE" ;; + *) ok "restore confirmed: $VERDICT_LINE" ;; + esac + else + warn "the restore is on disk, but no confirming verdict line appeared within ${WAIT_SECONDS}s" + warn "cannot confirm the restore reloaded cleanly — check $LOG by hand" + fi + return 0 +} + +# The four-outcome decision. Echoed as a function so run_edit/restore_mode share one place that +# can return 0/3/4/5 — never duplicated, never re-worded between the two callers. +report_outcome() { + local mark="$1" backup="$2" kind line + + say "waiting for the daemon's verdict (up to ${WAIT_SECONDS}s)" + if wait_for_verdict "$LOG" "$mark" "$WAIT_SECONDS"; then + kind="$VERDICT_KIND"; line="$VERDICT_LINE" + else + kind="none" + fi + + case "$kind" in + clean) + ok "daemon verdict: $line" + say "result: applied cleanly" + return 0 ;; + needs-restart) + ok "daemon verdict: $line" + say "result: applied — a restart is needed for the change(s) named above" + return 3 ;; + refused) + warn "daemon verdict: $line" + say "result: REFUSED — restoring the backup" + restore_and_confirm "$backup" + return 4 ;; + none) + warn "no verdict line appeared within ${WAIT_SECONDS}s after $LOG line $mark" + warn "CANNOT TELL whether the daemon applied this edit, refused it, or is simply down." + warn "Nothing was restored — the edit is still on disk at $CONFIG." + echo + echo " backup: $backup" + echo " to restore it by hand:" + echo " $(restore_command_line)" + return 5 ;; + esac +} + +# ------------------------------------------------------------------------------------- the modes +check_mode() { + say "config-edit --check" + if [ -f "$CONFIG" ]; then + if parse_check "$CONFIG"; then + ok "config parses: $CONFIG" + else + warn "config does NOT parse as valid YAML: $CONFIG" + fi + else + warn "no config file at $CONFIG" + fi + + local port + port="$(resolve_port "$CONFIG")" + if daemon_listening "$port"; then + ok "daemon is listening on 127.0.0.1:$port" + else + warn "no daemon detected listening on 127.0.0.1:$port" + fi + + ok "watch interval assumed: $(( $(default_wait_seconds "$LOG") / 4 ))s (derives --wait-seconds default of $(default_wait_seconds "$LOG")s)" + + local verdict + verdict="$(last_verdict_line "$LOG")" + if [ -n "$verdict" ]; then + ok "last verdict in log: $verdict" + else + warn "no reload verdict line found in $LOG" + fi + + local backup + backup="$(newest_backup "$CONFIG")" + if [ -n "$backup" ]; then + ok "newest backup: $backup" + else + warn "no backups found for $CONFIG" + fi + + if command -v yq >/dev/null 2>&1; then + ok "yq: $(yq --version 2>&1)" + else + warn "yq not found on PATH" + fi + + return 0 +} + +# Shared by --set and --from: backup, build, parse-check, redacted diff, install, await verdict. +run_edit() { + local builder="$1" + [ -f "$CONFIG" ] || die "no config at $CONFIG — nothing to edit" + + local mark + mark="$(log_mark "$LOG")" + + say "probe" + local port + port="$(resolve_port "$CONFIG")" + if daemon_listening "$port"; then + ok "daemon appears to be listening on 127.0.0.1:$port" + else + warn "no daemon detected listening on 127.0.0.1:$port — a verdict may never appear" + fi + + say "backup" + local backup + backup="$(backup_config "$CONFIG")" + ok "backup: $backup" + + say "candidate" + local cand + cand="$(mktemp "$(dirname "$CONFIG")/.config-edit.XXXXXX")" \ + || die "could not create a candidate temp file next to $CONFIG" + if ! "$builder" "$cand"; then + rm -f "$cand" + die "could not build the candidate — nothing was installed. The live config at $CONFIG is unchanged." + fi + + if ! parse_check "$cand"; then + rm -f "$cand" + die "candidate does not parse as valid YAML — nothing was installed. The live config at $CONFIG is unchanged." + fi + ok "candidate parses" + + say "change (redacted)" + diff -u "$backup" "$cand" | redact || true + + say "install" + install_candidate "$cand" "$CONFIG" \ + || die "could not install the candidate onto $CONFIG — the live config was NOT changed. The validated candidate is sitting at $cand; investigate before retrying." + ok "installed: $CONFIG" + + local rc=0 + report_outcome "$mark" "$backup" || rc=$? + return "$rc" +} + +dry_run_diff() { + local builder="$1" + [ -f "$CONFIG" ] || die "no config at $CONFIG — nothing to diff against" + local cand + cand="$(mktemp "$(dirname "$CONFIG")/.config-edit.XXXXXX")" \ + || die "could not create a candidate temp file next to $CONFIG" + if ! "$builder" "$cand"; then + rm -f "$cand" + die "could not build the candidate — this was a --dry-run, nothing would have been installed either" + fi + if ! parse_check "$cand"; then + rm -f "$cand" + die "candidate does not parse as valid YAML — this was a --dry-run, nothing would have been installed either" + fi + say "dry run — diff (redacted), nothing installed" + diff -u "$CONFIG" "$cand" | redact || true + rm -f "$cand" + return 0 +} + +restore_mode() { + [ -f "$CONFIG" ] || die "no config at $CONFIG to restore onto" + local backup + backup="$(newest_backup "$CONFIG")" + [ -n "$backup" ] || die "no backup found matching ${CONFIG}.bak.* — nothing to restore" + [ -f "$backup" ] || die "backup candidate $backup vanished" + + say "restore" + ok "restoring $backup onto $CONFIG" + local mark + mark="$(log_mark "$LOG")" + cp "$backup" "$CONFIG" || die "could not copy $backup onto $CONFIG" + ok "installed: $CONFIG" + + local rc=0 + report_outcome "$mark" "$backup" || rc=$? + return "$rc" +} + +# -------------------------------------------------------------------------------------- dispatch + +if [ -n "$WAIT_SECONDS_OVERRIDE" ]; then + WAIT_SECONDS="$WAIT_SECONDS_OVERRIDE" +else + WAIT_SECONDS="$(default_wait_seconds "$LOG")" +fi + +RC=0 +case "$MODE" in + check) + check_mode || RC=$? + ;; + set) + [ "${#SETS[@]}" -gt 0 ] || die "--set requires at least one =" + if [ "$DRY_RUN" = 1 ]; then + dry_run_diff build_from_set || RC=$? + else + run_edit build_from_set || RC=$? + fi + ;; + from) + [ -n "$FROM_FILE" ] || die "--from requires a candidate file path" + if [ "$DRY_RUN" = 1 ]; then + dry_run_diff build_from_file || RC=$? + else + run_edit build_from_file || RC=$? + fi + ;; + restore) + restore_mode || RC=$? + ;; +esac + +exit "$RC" diff --git a/scripts/test-config-edit.sh b/scripts/test-config-edit.sh new file mode 100755 index 0000000..a6e5b4c --- /dev/null +++ b/scripts/test-config-edit.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# Self-contained checks for scripts/config-edit.sh — fleetd ticket #635. +# +# Drives the REAL config-edit.sh as a subprocess against a FIXTURE config and a FIXTURE log in a +# throwaway temp directory this file creates and removes. Never touches fleetd/fleetd.yaml or +# fleetd/fleetd.out, and never starts, stops, or contacts a daemon — there is no daemon here, so +# each test PLAYS the daemon: it starts config-edit.sh in the background (it is waiting on the +# log), appends the verdict line it wants, then collects the real exit code. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EDIT="$ROOT/scripts/config-edit.sh" +TMP="$(mktemp -d "$ROOT/.config-edit-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + return 1 +} + +assert_equals() { + local expected="$1" actual="$2" description="$3" + [ "$expected" = "$actual" ] || fail "$description: expected $expected, got $actual" +} + +assert_contains() { + local needle="$1" text="$2" description="$3" + printf '%s' "$text" | grep -qF -- "$needle" || fail "$description: missing [$needle]" +} + +assert_not_contains() { + local needle="$1" text="$2" description="$3" + if printf '%s' "$text" | grep -qF -- "$needle"; then + fail "$description: must NOT contain [$needle], but it does" + fi + return 0 +} + +# A fresh fixture pair per test: $1/fleetd.yaml (the config) and $1/fleetd.out (the log), plus a +# small wait-seconds budget so no test takes long. Returns the fixture dir via stdout. +new_fixture() { + local dir + dir="$(mktemp -d "$TMP/fixture.XXXXXX")" + cat > "$dir/fleetd.yaml" <<'YAML' +bind: + host: 127.0.0.1 + port: 19999 +broker: + uri: amqp://user:hunter2@host/vhost +profiles: + sonnet: + weight: 3 +YAML + : > "$dir/fleetd.out" + printf '%s' "$dir" +} + +# Runs config-edit.sh in the background against $dir's fixtures, with the given extra args, and +# a short --wait-seconds. Sets RUN_PID. Caller appends to $dir/fleetd.out (or not, for the +# silence test) and then calls collect_run to block for the exit code. +start_run() { + local dir="$1" wait_s="$2"; shift 2 + ( + # config-edit.sh deliberately exits 3/4/5 on several of these tests. This subshell inherits + # the parent's `set -e`, and without disabling it here the FIRST nonzero exit would kill the + # subshell before the `echo $? > rc` line ever ran — the real code would never reach the file. + set +e + "$EDIT" --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds "$wait_s" "$@" \ + > "$dir/stdout.log" 2>&1 + echo $? > "$dir/rc" + ) & + RUN_PID=$! +} + +collect_run() { + local dir="$1" + # wait echoes back the backgrounded subshell's own exit status (here, deliberately 3/4/5 on + # several tests) — under `set -e` a bare nonzero `wait` would abort this whole test script, so + # it is neutralized with `|| true`; the real code is read from $dir/rc right after. + wait "$RUN_PID" || true + RUN_OUTPUT="$(cat "$dir/stdout.log")" + RUN_RC="$(cat "$dir/rc")" +} + +# -------------------------------------------------------------- acceptance criterion 1: refusal +test_refusal_restores_byte_for_byte() { + local dir + dir="$(new_fixture)" + cp "$dir/fleetd.yaml" "$dir/pre-edit.yaml" + + start_run "$dir" 5 --set '.broker.uri=amqp://changed@host/x' + sleep 1 + printf 'config reload refused — these keys cannot change under a running daemon: broker. Restart fleetd to apply them.\n' >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 4 "$RUN_RC" "refusal exit code" + cmp -s "$dir/fleetd.yaml" "$dir/pre-edit.yaml" \ + || fail "refusal must restore the config byte for byte onto the pre-edit backup" +} + +# -------------------------------------------------------------- acceptance criterion 2: clean +test_clean_reload_keeps_the_edit() { + local dir + dir="$(new_fixture)" + + start_run "$dir" 5 --set '.profiles.sonnet.weight=7' + sleep 1 + printf 'config reloaded\n' >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 0 "$RUN_RC" "clean reload exit code" + assert_equals "7" "$(yq eval '.profiles.sonnet.weight' "$dir/fleetd.yaml")" "clean reload live value" +} + +# ----------------------------------------------------- acceptance criterion 3: deferred != clean +test_deferred_reload_is_told_apart_from_clean() { + local dir + dir="$(new_fixture)" + + start_run "$dir" 5 --set '.profiles.sonnet.weight=9' + sleep 1 + printf 'config reloaded; these changes need a restart to take effect: profiles\n' >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 3 "$RUN_RC" "deferred reload exit code" + [ "$RUN_RC" != 0 ] || fail "deferred reload must not report exit 0" + assert_contains "profiles" "$RUN_OUTPUT" "deferred reload names the key" + assert_contains "restart" "$RUN_OUTPUT" "deferred reload says a restart is needed" +} + +# -------------------------------------------------------------- acceptance criterion 4: silence +test_silence_is_its_own_answer() { + local dir + dir="$(new_fixture)" + + start_run "$dir" 2 --set '.profiles.sonnet.weight=11' + # Feed the log nothing. + collect_run "$dir" + + assert_equals 5 "$RUN_RC" "silence exit code" + assert_equals "11" "$(yq eval '.profiles.sonnet.weight' "$dir/fleetd.yaml")" "the edited value must still be on disk" + assert_contains '--restore' "$RUN_OUTPUT" "silence prints the --restore command" + + local backup restore_cmd + backup="$(ls -t "$dir"/fleetd.yaml.bak.* | head -1)" + [ -n "$backup" ] || fail "silence must still have taken a backup" + + restore_cmd="$(printf '%s\n' "$RUN_OUTPUT" | grep -F -- '--restore --config' | sed -E 's/^[[:space:]]*//')" + [ -n "$restore_cmd" ] || fail "could not find the printed --restore invocation in the output" + # Running this --restore invocation installs the backup, then itself waits for a confirming + # verdict that this fixture never feeds — so it legitimately exits 5 ("cannot tell") here, same + # as any edit with no daemon on the other end. Only a usage/internal error (1 or 2) is a real + # failure of the command itself; the actual assertion is the byte-for-byte cmp below. + local restore_rc=0 + eval "$restore_cmd" > "$dir/restore.log" 2>&1 || restore_rc=$? + case "$restore_rc" in + 0|3|4|5) : ;; + *) fail "the printed --restore command errored out (exit $restore_rc): $(cat "$dir/restore.log")" ;; + esac + + cmp -s "$dir/fleetd.yaml" "$backup" \ + || fail "running the printed --restore command must put the file back to the original backup" +} + +# --------------------------------------------------------- acceptance criterion 5: bad candidate +test_broken_candidate_never_reaches_live_path() { + local dir rc=0 + dir="$(new_fixture)" + printf 'foo: [unclosed\n' > "$dir/broken.yaml" + + "$EDIT" --from "$dir/broken.yaml" --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ + > "$dir/stdout.log" 2>&1 || rc=$? + + [ "$rc" -ne 0 ] || fail "a broken --from candidate must exit non-zero" + cmp -s "$dir/fleetd.yaml" <(new_fixture_yaml) \ + || fail "the broken candidate must never reach the live fixture config" +} +new_fixture_yaml() { + cat <<'YAML' +bind: + host: 127.0.0.1 + port: 19999 +broker: + uri: amqp://user:hunter2@host/vhost +profiles: + sonnet: + weight: 3 +YAML +} + +# -------------------------------------------------------------------- acceptance criterion 6 +test_marker_skips_lines_before_it() { + local dir + dir="$(new_fixture)" + printf 'config reload refused — something ancient\n' > "$dir/fleetd.out" + + start_run "$dir" 5 --set '.profiles.sonnet.weight=5' + sleep 1 + printf 'config reloaded\n' >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 0 "$RUN_RC" "a stale refusal before the marker must not be read as this edit's verdict" +} + +# ------------------------------------------------------------------- acceptance criterion 7 +test_redaction_holds() { + local dir + dir="$(new_fixture)" + + start_run "$dir" 5 --set '.profiles.sonnet.weight=4' + sleep 1 + printf 'config reloaded\n' >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 0 "$RUN_RC" "redaction-case reload exit code" + assert_not_contains "hunter2" "$RUN_OUTPUT" "full output must never contain the password" + assert_not_contains "user:" "$RUN_OUTPUT" "full output must never contain the userinfo" +} + +# dry-run must never touch the live file and must still redact. +test_dry_run_never_installs_and_redacts() { + local dir before + dir="$(new_fixture)" + before="$(cat "$dir/fleetd.yaml")" + + "$EDIT" --dry-run --set '.profiles.sonnet.weight=99' \ + --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ + > "$dir/stdout.log" 2>&1 + local rc=$? + RUN_OUTPUT="$(cat "$dir/stdout.log")" + + assert_equals 0 "$rc" "dry-run exit code" + assert_equals "$before" "$(cat "$dir/fleetd.yaml")" "dry-run must never write the live config" + assert_not_contains "hunter2" "$RUN_OUTPUT" "dry-run diff must also be redacted" + assert_contains "99" "$RUN_OUTPUT" "dry-run diff must show the candidate value" +} + +# --check is read-only and always exits 0, even against a dead "daemon". +test_check_is_read_only_and_exits_zero() { + local dir before rc=0 + dir="$(new_fixture)" + before="$(cat "$dir/fleetd.yaml")" + + "$EDIT" --check --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" \ + > "$dir/stdout.log" 2>&1 || rc=$? + + assert_equals 0 "$rc" "--check exit code" + assert_equals "$before" "$(cat "$dir/fleetd.yaml")" "--check must never modify the config" +} + +test_refusal_shape_from_parse_failure_wording_is_recognised() { + local dir + dir="$(new_fixture)" + + start_run "$dir" 5 --set '.profiles.sonnet.weight=6' + sleep 1 + printf 'config reload from %s refused, keeping the running config: boom\n' "$dir/fleetd.yaml" >> "$dir/fleetd.out" + collect_run "$dir" + + assert_equals 4 "$RUN_RC" "the parse-failure refusal shape must also exit 4, not be read as silence" +} + +echo "== acceptance criterion 1: refusal restores byte for byte ==" +test_refusal_restores_byte_for_byte +echo "== acceptance criterion 2: clean reload keeps the edit ==" +test_clean_reload_keeps_the_edit +echo "== acceptance criterion 3: deferred reload told apart from clean ==" +test_deferred_reload_is_told_apart_from_clean +echo "== acceptance criterion 4: silence is its own answer ==" +test_silence_is_its_own_answer +echo "== acceptance criterion 5: broken candidate never reaches the live path ==" +test_broken_candidate_never_reaches_live_path +echo "== acceptance criterion 6: the marker works ==" +test_marker_skips_lines_before_it +echo "== acceptance criterion 7: the redaction holds ==" +test_redaction_holds +echo "== extra: dry-run never installs, and redacts ==" +test_dry_run_never_installs_and_redacts +echo "== extra: --check is read-only and always exits 0 ==" +test_check_is_read_only_and_exits_zero +echo "== extra: the parse-failure refusal shape is also recognised ==" +test_refusal_shape_from_parse_failure_wording_is_recognised + +printf 'PASS: config-edit acceptance criteria\n'