From e9ecf124e36804ce494656e1705ab68e25c11755 Mon Sep 17 00:00:00 2001 From: Garrett Allen Date: Sun, 2 Aug 2026 21:44:07 +0000 Subject: [PATCH 1/2] fix(secrets): recover from a failed secrets-init instead of skipping silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the monitoring host, one step after the previous fix: $ make secrets-edit sops secrets/observability.sops.yaml sops metadata not found Two failures compounding. The *original* broken script redirected sops' stdout into the destination: sops --encrypt secrets/observability.example.yaml > secrets/observability.sops.yaml Shell redirection creates the file before the command runs, so when sops failed with "no matching creation rules found" it had already left a 0-byte secrets/observability.sops.yaml behind. The *fixed* script then tested only `[[ -f "${SECRETS_FILE}" ]]`, found the empty file, reported "already exists — leaving it alone", and did nothing. `make secrets-init` appeared to succeed while creating nothing, and the failure surfaced one step later as an unhelpful sops error. "The file exists" is not the same as "the secrets are set up". Three states now get three answers: * exists and contains a sops: metadata block -> genuinely done, left alone * exists, non-empty, not encrypted -> refuse and explain. Never delete: it could be real credentials someone wrote and has not yet encrypted. The error offers both `sops --encrypt --in-place` and the removal command. * absent, or an empty leftover -> remove the empty file (it carries no data) and create properly Verified against a clone rolled back to the broken script, so the reproduction is the real sequence rather than a synthetic one: * a 0-byte leftover is removed and replaced with a genuinely encrypted file * an already-encrypted file is byte-identical afterwards * a non-empty unencrypted file is refused and left fully intact, contents unchanged --- scripts/bootstrap.sh | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 66441d9..f77bc25 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -64,9 +64,37 @@ fi # --------------------------------------------------------------------------- # 3. encrypted secrets file # --------------------------------------------------------------------------- -if [[ -f "${SECRETS_FILE}" ]]; then - info "$(basename "${SECRETS_FILE}") already exists — leaving it alone" +# "The file exists" is not the same as "the secrets are set up". An earlier +# version of this script redirected sops' stdout into this path, and shell +# redirection creates the file before the command runs — so a failed encrypt +# left a 0-byte file behind. Treating that as "already done" made the script +# skip creation silently, and the next `make secrets-edit` failed with the +# unhelpful "sops metadata not found". +# +# Three distinct states, three different answers. +if [[ -f "${SECRETS_FILE}" ]] && grep -q '^sops:' "${SECRETS_FILE}" 2>/dev/null; then + info "$(basename "${SECRETS_FILE}") already exists and is encrypted — leaving it alone" + +elif [[ -s "${SECRETS_FILE}" ]]; then + # Non-empty but not SOPS-encrypted. Never delete this: it could be real + # credentials someone wrote by hand and has not encrypted yet. + die "${SECRETS_FILE} +exists but is not SOPS-encrypted. + +If it holds real values you want to keep, encrypt it in place: + sops --encrypt --in-place ${SECRETS_FILE} + +If it is junk from a failed run, remove it and re-run: + rm ${SECRETS_FILE} && make secrets-init" + else + # Absent, or an empty file left by a failed run. An empty file carries no + # data, so removing it is safe. + if [[ -e "${SECRETS_FILE}" ]]; then + warn "removing empty $(basename "${SECRETS_FILE}") left by a previous failed run" + rm -f "${SECRETS_FILE}" + fi + info "creating $(basename "${SECRETS_FILE}") from the template" # Copy to the destination name FIRST, then encrypt in place. From 7a9cc424b20828f1bc47ac6f83a394c50f5abcd4 Mon Sep 17 00:00:00 2001 From: Garrett Allen Date: Sun, 2 Aug 2026 21:49:03 +0000 Subject: [PATCH 2/2] fix(secrets): handle an unreadable secrets file, and quote pasteable paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback, both correct. An unreadable file was misreported, and the advice that followed was dangerous. `[[ -s FILE ]]` is a stat, not a read, so it succeeds on a file the process cannot open. A chmod 000 secrets file therefore fell through to the "not SOPS-encrypted" branch, whose remediation tells you to delete it — on a file whose contents were never inspected and which may be perfectly good ciphertext. Readability is now checked before anything tries to read, and the error says the state is unknown and to fix ownership rather than remove the file. The `grep` also no longer swallows stderr, so a genuine read error is visible. Demonstrated against a `sops:`-bearing file at mode 000, run as nobody: old: -> "not SOPS-encrypted" + advises: rm the file new: -> "not readable" (state unknown, do not delete) As root the branch cannot be exercised at all, since root reads anything — which is exactly why the earlier testing missed it. The paths printed in those messages are meant to be copied and pasted, so they are now %q-quoted and `rm` takes `--`. Unquoted, a stack name containing a space or a glob produced a command that acts on something else: raw: rm -- /home/u/my stack/secrets/obs*.sops.yaml -> [rm] [--] [/home/u/my] [stack/secrets/obs*.sops.yaml] quoted: rm -- /home/u/my\ stack/secrets/obs\*.sops.yaml -> [rm] [--] [/home/u/my stack/secrets/obs*.sops.yaml] The three previously covered states are unchanged and re-verified: an encrypted file is left byte-identical, an empty leftover is recovered, and a non-empty unencrypted file is refused with its contents intact. --- scripts/bootstrap.sh | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index f77bc25..0c576bb 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -71,21 +71,39 @@ fi # skip creation silently, and the next `make secrets-edit` failed with the # unhelpful "sops metadata not found". # -# Three distinct states, three different answers. -if [[ -f "${SECRETS_FILE}" ]] && grep -q '^sops:' "${SECRETS_FILE}" 2>/dev/null; then +# An unreadable file has to be ruled out before anything reads it. [[ -s ]] is a +# stat, not a read, so it succeeds on a file this process cannot open — without +# this check a chmod 000 file falls through to "not SOPS-encrypted" and the +# advice below tells you to delete what may be a perfectly good encrypted file. +if [[ -e "${SECRETS_FILE}" && ! -r "${SECRETS_FILE}" ]]; then + die "$(printf '%q' "${SECRETS_FILE}") +exists but is not readable by $(id -un). + +Its contents cannot be inspected, so its state is unknown — do not delete it. +Fix ownership or permissions first: + sudo chown $(id -un) $(printf '%q' "${SECRETS_FILE}") + chmod 600 $(printf '%q' "${SECRETS_FILE}")" +fi + +# Four distinct states, four different answers. +if [[ -f "${SECRETS_FILE}" ]] && grep -q '^sops:' "${SECRETS_FILE}"; then info "$(basename "${SECRETS_FILE}") already exists and is encrypted — leaving it alone" elif [[ -s "${SECRETS_FILE}" ]]; then # Non-empty but not SOPS-encrypted. Never delete this: it could be real # credentials someone wrote by hand and has not encrypted yet. - die "${SECRETS_FILE} + # + # The paths below are %q-quoted because they are meant to be copied and + # pasted, and a stack name containing a space or a glob character would + # otherwise produce a command that acts on something else entirely. + die "$(printf '%q' "${SECRETS_FILE}") exists but is not SOPS-encrypted. If it holds real values you want to keep, encrypt it in place: - sops --encrypt --in-place ${SECRETS_FILE} + sops --encrypt --in-place $(printf '%q' "${SECRETS_FILE}") If it is junk from a failed run, remove it and re-run: - rm ${SECRETS_FILE} && make secrets-init" + rm -- $(printf '%q' "${SECRETS_FILE}") && make secrets-init" else # Absent, or an empty file left by a failed run. An empty file carries no