fleetd #635 follow-up: refuse empty --set values, gitignore backups, preserve file mode
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 1m43s
CI / build (pull_request) Failing after 1m52s

Five fixes against PR #636, all verified by the lead's own review and reproduced here:

1. --set .a.b= (a forgotten value) is now refused outright instead of silently nulling the
   field — a null numeric config value falls back to its default rather than erroring, which
   widens capacity silently instead of failing loudly. A deliberate clear gets its own spelling,
   --set .a.b=null, which writes a literal YAML null via yq, never through strenv(). (criteria
   9, 10)

2. Backups move from beside fleetd.yaml to a dedicated fleetd/.config-backups/ directory,
   gitignored at the repo root (so it also covers scripts/test-config-edit.sh's own throwaway
   fixtures) and in fleetd/.gitignore, plus a fleetd.yaml.bak.* glob backstop for any stray
   backup written the old way. A backup of a file that must never be committed inherits that
   requirement. (criterion 11)

3. The live config's file mode now survives both an edit and a restore. mv from a mktemp
   candidate used to carry mktemp's 0600 onto the live path forever, and cp onto an existing
   file keeps the destination's mode, so a restore did not undo it either. (criterion 12)

4. A global CAND + single EXIT/INT/TERM trap prevents an uninstalled .config-edit.XXXXXX
   candidate from leaking if the script is interrupted mid-run. No acceptance criterion is
   gated on this — a reproducible leak could not be made to happen on demand — but it is cheap
   and obviously right.

5. Acceptance criterion 7's redaction check gained a positive control: it now asserts the
   output actually CONTAINS the redaction marker and the changed key, not only that it lacks
   the secret. The prior two assertions were negative-only and passed just as happily when the
   diff was never printed at all — confirmed by reproducing the lead's own mutation (deleting
   the redacted diff print on the edit path) and watching it survive the old test and get
   caught by the new one. (criterion 13)

All 13 acceptance criteria plus 3 extras pass in scripts/test-config-edit.sh. Criteria 9, 10,
11, 12 and 13 were each proven non-vacuous: criteria 9/10 by mutating the test's own expected
value and watching it fail by name, then reverting; criteria 11/12/13 by reverting or mutating
the corresponding fix in config-edit.sh and watching the matching criterion fail by name, then
restoring the fix and re-confirming a clean pass.
This commit is contained in:
Dai Ha
2026-10-01 18:07:12 +02:00
parent 0db6d31dc2
commit 4eb720029c
4 changed files with 265 additions and 18 deletions
+114 -15
View File
@@ -46,6 +46,13 @@
# scripts/config-edit.sh --dry-run --set <yq-path>=<value>
# scripts/config-edit.sh --restore
#
# `--set .a.b=` (an empty value — a forgotten typo) is REFUSED, not accepted as "clear the
# field": a null value falls back to its default rather than erroring, which is silent, not
# safe. To clear a key on purpose, write a literal null: `--set .a.b=null`. Every other value
# is always written as a YAML string (via yq's strenv(), never spliced into the expression), so
# there is currently no --set spelling for the literal three-character STRING "null" itself — use
# --from for that rare case.
#
# 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
@@ -77,6 +84,16 @@ DRY_RUN=0
SETS=()
FROM_FILE=""
# fleetd #635 follow-up — a signal (or any early exit while a candidate is still uninstalled) must
# not leave a `.config-edit.XXXXXX` file sitting beside the live config forever. CAND is global
# (never a function-local) on purpose: this ONE trap, set once, covers every path that ever
# creates a candidate — run_edit and dry_run_diff both assign it, and clear it back to "" once the
# file is consumed (installed, or explicitly removed), so a later, unrelated exit never retries a
# path that already served its purpose.
CAND=""
cleanup_candidate() { [ -n "$CAND" ] && rm -f "$CAND" 2>/dev/null; return 0; }
trap cleanup_candidate EXIT INT TERM
say() { printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok() { printf ' ok %s\n' "$*"; }
warn() { printf ' WARN %s\n' "$*"; }
@@ -114,7 +131,7 @@ while [ $# -gt 0 ]; do
--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 ;;
-h|--help) sed -n '3,70p' "$SELF"; exit 0 ;;
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
esac
done
@@ -272,18 +289,62 @@ default_wait_seconds() {
#
# Timestamped, never pruned — "keep backups" per the ticket. A pid suffix avoids a same-second
# collision between two invocations.
#
# fleetd #635 follow-up — lands under a DEDICATED, gitignored directory beside the config
# (<dir>/.config-backups/), never beside the config file itself. The whole reason fleetd.yaml is
# gitignored is that it must never be committed, and a backup of it inherits that requirement — a
# bare `fleetd.yaml.bak.*` next to a tracked directory is one `git add -A`/`git add .` away from
# committing the live config. A directory beats a glob on its own: the glob only protects today's
# naming, a location keeps working even if the naming changes later. (See .gitignore for the glob
# kept anyway, as a backstop for a stray backup written the old way.)
BACKUP_DIRNAME=".config-backups"
backup_dir_for() {
local src="$1"
printf '%s/%s' "$(dirname "$src")" "$BACKUP_DIRNAME"
}
backup_config() {
local src="$1" ts backup
local src="$1" ts backup dir base
dir="$(backup_dir_for "$src")"
mkdir -p "$dir" \
|| die "could not create the backup directory $dir — refusing to edit without a backup. The live config at $src was NOT touched."
ts="$(date -u +%Y%m%dT%H%M%S)Z"
backup="${src}.bak.${ts}.$$"
base="$(basename "$src")"
backup="${dir}/${base}.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
local cfg="$1" dir base
dir="$(backup_dir_for "$cfg")"
base="$(basename "$cfg")"
ls -t "${dir}/${base}".bak.* 2>/dev/null | head -1 || true
}
# ------------------------------------------------------------------------------------ the file mode
#
# fleetd #635 follow-up — `mv` from a mktemp candidate carries mktemp's 0600 onto the live path
# forever (measured: 644 -> 600 after one --set), and a restore does not undo it either, because
# `cp` onto an EXISTING file keeps the DESTINATION's mode, not the source's. Capture the live
# file's mode before anything touches it, and reapply it to whatever lands on that path
# afterwards — the candidate before install, and the config again after a restore — so an edit
# changes the file's CONTENT only, never its permissions. BSD `stat -f '%Lp'` first (matches this
# project's dev machine), GNU `stat -c '%a'` as the fallback. Prints nothing when the file does
# not exist yet, so apply_mode then does nothing and a first-ever edit falls back to the normal
# umask default rather than inventing a number.
file_mode() {
local file="$1"
[ -f "$file" ] || return 0
stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || true
}
apply_mode() {
local file="$1" mode="$2"
[ -n "$mode" ] || return 0
chmod "$mode" "$file" 2>/dev/null || true
}
# --------------------------------------------------------------------------- candidate builders
@@ -291,6 +352,23 @@ newest_backup() {
# 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).
# fleetd #635 follow-up — a forgotten value (`--set .a.b=`, a plausible typo) must never be
# accepted as "clear the field". `*=*` alone cannot tell "--set .a.b=" from "--set .a.b=7" apart
# — both contain an `=` — so the guard has to look at the VALUE, not the shape of the argument.
# An empty value refuses outright: nothing is installed, and the message names the likely cause
# AND the two ways to actually mean it (clear on purpose, or an intentional empty string via
# --from). Measured against the real daemon loader: a quoted empty string reads back as a null
# field (`quoted empty -> OK int=null`), and a null numeric field FALLS BACK TO ITS DEFAULT rather
# than erroring — so this is not a cosmetic nit, it is the one shape of edit that widens capacity
# silently instead of failing loudly, which is exactly what this script exists to catch.
#
# A deliberate clear needs its own spelling, because `""` and YAML `null` are NOT the same value
# to the loader (`""` is a valid empty String; `null` means absent, and an Integer field reads
# either the same way — null — but a String field would keep `""` as a real value). `--set
# .a.b=null` is that spelling: it writes a literal, unquoted `null` via yq, never the string
# "null" through strenv(). One consequence worth knowing: there is currently no --set spelling
# for the three-character STRING "null" itself (it collides with the clear spelling) — use
# --from for that rare case.
apply_set_pairs() {
local cand="$1" kv path value
shift
@@ -302,6 +380,17 @@ apply_set_pairs() {
path="${kv%%=*}"
path="${path#.}"
value="${kv#*=}"
if [ -z "$value" ]; then
die "--set '$kv' has an EMPTY value — refusing. Nothing was installed. A forgotten value
would NULL the field, and a null value falls back to its default rather than erroring —
silent, not safe. Did you mean --set .${path}=null to clear it on purpose, or --from a
file if you need a genuinely empty string?"
fi
if [ "$value" = "null" ]; then
yq eval -i ".${path} = null" "$cand" \
|| die "yq could not clear '$kv' — nothing was installed. The live config is unchanged."
continue
fi
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
@@ -340,10 +429,12 @@ restore_command_line() {
# 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
local backup="$1" mark2 orig_mode
orig_mode="$(file_mode "$CONFIG")"
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\""
apply_mode "$CONFIG" "$orig_mode"
ok "restored from $backup"
if wait_for_verdict "$LOG" "$mark2" "$WAIT_SECONDS"; then
case "$VERDICT_KIND" in
@@ -448,8 +539,9 @@ run_edit() {
local builder="$1"
[ -f "$CONFIG" ] || die "no config at $CONFIG — nothing to edit"
local mark
local mark orig_mode
mark="$(log_mark "$LOG")"
orig_mode="$(file_mode "$CONFIG")"
say "probe"
local port
@@ -467,25 +559,29 @@ run_edit() {
say "candidate"
local cand
cand="$(mktemp "$(dirname "$CONFIG")/.config-edit.XXXXXX")" \
CAND="$(mktemp "$(dirname "$CONFIG")/.config-edit.XXXXXX")" \
|| die "could not create a candidate temp file next to $CONFIG"
cand="$CAND"
if ! "$builder" "$cand"; then
rm -f "$cand"
rm -f "$cand"; 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"
rm -f "$cand"; CAND=""
die "candidate does not parse as valid YAML — nothing was installed. The live config at $CONFIG is unchanged."
fi
ok "candidate parses"
apply_mode "$cand" "$orig_mode"
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."
CAND=""
ok "installed: $CONFIG"
local rc=0
@@ -497,19 +593,20 @@ 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")" \
CAND="$(mktemp "$(dirname "$CONFIG")/.config-edit.XXXXXX")" \
|| die "could not create a candidate temp file next to $CONFIG"
cand="$CAND"
if ! "$builder" "$cand"; then
rm -f "$cand"
rm -f "$cand"; 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"
rm -f "$cand"; 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"
rm -f "$cand"; CAND=""
return 0
}
@@ -522,9 +619,11 @@ restore_mode() {
say "restore"
ok "restoring $backup onto $CONFIG"
local mark
local mark orig_mode
mark="$(log_mark "$LOG")"
orig_mode="$(file_mode "$CONFIG")"
cp "$backup" "$CONFIG" || die "could not copy $backup onto $CONFIG"
apply_mode "$CONFIG" "$orig_mode"
ok "installed: $CONFIG"
local rc=0