fix(fleet): harden #791 upgrade rollback against find/reset failures
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Second- and third-round independent-review reliability fixes on the keep-mode
upgrade rollback path, plus accurate abort messaging. All fixed red-first with
self-verifying controls in the rollback gate.
Round 2 (blockers A/B, should-fix C):
- install.sh: `trap 'restore_snapshot; exit 1' ERR INT TERM` so an INT/TERM
mid-sync terminates instead of resuming past the interrupt and reporting
success (a bash signal handler that only returns does not terminate).
- manifest.{ts,sh}: reject a degenerate [framework] section whose entries are
all empty or bare-dot (`/`, `./`, `.`, `..`) — it passed the non-empty guard
yet yielded zero usable globs, silently resolving everything to operator.
Parity via a shared `[^/.]` usable-glob test; TS throws ManifestError.
- finalize.ts: classify the sync-abort message — a ManifestError is a pre-sync
validation abort ("no files were changed"); any other error may be partial.
Round 3 (blockers D1, D2):
- install.sh: enumerate framework files with a checked temp file (_scan_or_die)
instead of `< <(find …)` — process substitution discards find's exit status,
so an EACCES/I/O failure mid-scan would truncate the file list yet leave the
loop exiting 0, committing a partial upgrade as success (ERR trap never fires).
- install.sh: guard the `rm -rf; mkdir -p` target reset inside restore_snapshot
— a bare reset failing under set -e exits silently after partial deletion,
never printing the snapshot-recovery pointer. Now checked like the cp -a
restore: on failure it preserves the snapshot and tells the operator where.
Tests: rollback gate 14→28 (Parts C/D/E with disabled-guard controls);
new finalize-sync-abort.spec.ts (3). No secret value is ever emitted; snapshots
stay 0700. Gates green: typecheck, lint, format:check, full mosaic vitest 1094,
HARD GATE 193, rollback 28, migration 21.
Refs #791
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,11 @@ _manifest_compile() {
|
||||
local g
|
||||
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
||||
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
||||
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
|
||||
# short-circuit to rc 1, which would otherwise become this function's (and
|
||||
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
|
||||
# the last loop's exit status here.
|
||||
return 0
|
||||
}
|
||||
|
||||
# Load + compile the manifest. Rejects a malformed file the same way
|
||||
@@ -117,6 +122,13 @@ _manifest_compile() {
|
||||
manifest_load() {
|
||||
local file="${1:-}"
|
||||
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
|
||||
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
|
||||
# aborts on a raw redirection error with no explanation; downstream that reads
|
||||
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
|
||||
if [[ ! -r "$file" ]]; then
|
||||
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
|
||||
return 1
|
||||
fi
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
local section="" line
|
||||
@@ -139,7 +151,30 @@ manifest_load() {
|
||||
MANIFEST_OPERATOR+=("$line")
|
||||
fi
|
||||
done < "$file"
|
||||
# An empty or comment-only manifest defines NO framework-owned paths. Treating
|
||||
# that as valid would make every path resolve operator and an upgrade prune
|
||||
# nothing / write nothing — a silent no-op indistinguishable from success.
|
||||
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
|
||||
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
|
||||
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
|
||||
# matches no path — so a manifest whose only [framework] entries are degenerate
|
||||
# passes the count guard above but leaves the framework matcher empty: every
|
||||
# path resolves operator, the exact silent no-op we fail closed against. Require
|
||||
# at least one entry with a real (non-slash, non-dot) character. Mirrors
|
||||
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
|
||||
local _g _usable=0
|
||||
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
|
||||
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
|
||||
done
|
||||
if [[ "$_usable" -eq 0 ]]; then
|
||||
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
_manifest_compile
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
|
||||
@@ -196,7 +231,10 @@ manifest_subtree_roots() {
|
||||
# CLI dispatch — only when executed directly, never when sourced.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set -o pipefail
|
||||
manifest_load "${MANIFEST_FILE:-}"
|
||||
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
|
||||
# exit instead of continuing to resolve against empty compiled arrays — that is
|
||||
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
|
||||
manifest_load "${MANIFEST_FILE:-}" || exit 1
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
resolve) manifest_resolve "${2:?path required}" ;;
|
||||
|
||||
Reference in New Issue
Block a user