0db6d31dc2
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.
286 lines
11 KiB
Bash
Executable File
286 lines
11 KiB
Bash
Executable File
#!/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'
|