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.
This commit is contained in:
Executable
+569
@@ -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 — <error message>
|
||||
# config reload refused — these keys cannot change under a running daemon: <keys>. ...
|
||||
# config reloaded
|
||||
# config reloaded; these changes need a restart to take effect: <keys>
|
||||
# config reloaded; partially live — <key: detail | ...>
|
||||
# A parse/validation failure logs a DIFFERENT line instead, before any summary ever runs
|
||||
# (ConfigRef.java:425): "config reload from <path> refused, keeping the running config:
|
||||
# <message>". 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 <yq-path>=<value> [--set ...]
|
||||
# scripts/config-edit.sh --from <candidate.yaml>
|
||||
# scripts/config-edit.sh --dry-run --set <yq-path>=<value>
|
||||
# scripts/config-edit.sh --restore
|
||||
#
|
||||
# Overrides (so this is drivable with no daemon — see scripts/test-config-edit.sh):
|
||||
# --config <path> default: fleetd/fleetd.yaml
|
||||
# --log <path> default: fleetd/fleetd.out
|
||||
# --wait-seconds <n> 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 <yq-path>=<value>"
|
||||
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://<redacted>@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#://[^@]*@#://<redacted>@#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 <redacted>\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 <yq-path>=<value>, 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 <yq-path>=<value>"
|
||||
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"
|
||||
Executable
+285
@@ -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'
|
||||
Reference in New Issue
Block a user