feat: manifest-driven pull — nested restore, restore-time rail, empty no-op (EGB-677 stage 1)
- pull with .secrets.json restores exactly the declared entries (nested paths get mkdir -p); stray store blobs are not restored - dotenv rail re-runs at restore time: unsafe entries warn+skip (pull never dies on one bad entry), missing blobs warn with a directed hint - empty manifest = warn no-op instead of a confusing 'not found' death - manifest-less projects keep the legacy glob pull verbatim
This commit is contained in:
parent
d7e1400487
commit
0049584d9b
2 changed files with 131 additions and 0 deletions
48
secrets
48
secrets
|
|
@ -1414,6 +1414,54 @@ cmd_pull() {
|
||||||
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
|
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Manifest-driven pull (EGB-677 stage 1) ──
|
||||||
|
# With a .secrets.json present, the manifest decides what restores and
|
||||||
|
# where (nested entries get their directories created). The dotenv rail
|
||||||
|
# runs again at restore time — warn+skip on pull, never die, so one bad
|
||||||
|
# entry can't block the rest of the restore.
|
||||||
|
local manifest="$PWD/$SECRETS_JSON_NAME"
|
||||||
|
if [ -e "$manifest" ]; then
|
||||||
|
_check_manifest_file "$manifest"
|
||||||
|
local declared n_external
|
||||||
|
declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
|
||||||
|
n_external=$(jq -r '.external // [] | length' "$manifest")
|
||||||
|
|
||||||
|
if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then
|
||||||
|
echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2
|
||||||
|
ensure_store_protections
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then
|
||||||
|
die "Project '$project' not found. Run: secrets list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count=0 rel
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[ -n "$rel" ] || continue
|
||||||
|
if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
|
||||||
|
echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local blob="$SECRETS_DIR/$project/${rel}.age"
|
||||||
|
if [ ! -f "$blob" ]; then
|
||||||
|
echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
|
||||||
|
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"
|
||||||
|
if [ ! -s "$target_dir/$rel" ]; then
|
||||||
|
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)"
|
||||||
|
fi
|
||||||
|
count=$((count + 1))
|
||||||
|
done <<< "$declared"
|
||||||
|
|
||||||
|
info "Decrypted $count file(s) into $target_dir"
|
||||||
|
pull_external_files "$PWD" "$project"
|
||||||
|
ensure_store_protections
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Legacy glob pull (manifest-less projects; unchanged) ──
|
||||||
# Check project exists
|
# Check project exists
|
||||||
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
||||||
die "Project '$project' not found. Run: secrets list"
|
die "Project '$project' not found. Run: secrets list"
|
||||||
|
|
|
||||||
|
|
@ -379,3 +379,86 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' >
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == *"superseded"* ]] || false
|
[[ "$output" == *"superseded"* ]] || false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── D: pull from manifest — nested restore, restore-time rail ─────────
|
||||||
|
|
||||||
|
@test "pull restores manifest-declared nested file (mkdir -p)" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir nestpull
|
||||||
|
mkdir -p packages/web
|
||||||
|
echo "K=v" > packages/web/.env.development
|
||||||
|
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
|
||||||
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
||||||
|
rm -rf packages
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f packages/web/.env.development ]
|
||||||
|
[ "$(cat packages/web/.env.development)" = "K=v" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pull with manifest restores only declared entries" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir onlydecl
|
||||||
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
||||||
|
# plant an undeclared stray blob in the store
|
||||||
|
local pubkey; pubkey=$(age-keygen -y "$SECRETS_DIR/key.txt")
|
||||||
|
echo "S=1" | age -r "$pubkey" -o "$SECRETS_DIR/onlydecl/.env.stray.age"
|
||||||
|
rm -f .env .env.staging
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f .env ]
|
||||||
|
[ ! -f .env.stray ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pull warns and skips an unsafe manifest entry, restores the rest" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir unsafepull
|
||||||
|
"$SECRETS_BIN" add .env >/dev/null
|
||||||
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
||||||
|
printf '{"version":2,"dotenv":[".env","../escape/.env"]}\n' > .secrets.json
|
||||||
|
rm -f .env
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"skipping unsafe"* ]] || false
|
||||||
|
[ -f .env ]
|
||||||
|
[ ! -f "$WORK_DIR/escape/.env" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pull on an empty manifest is a warn no-op" {
|
||||||
|
init_with_remote
|
||||||
|
mkdir -p "$WORK_DIR/emptypull"
|
||||||
|
cd "$WORK_DIR/emptypull"
|
||||||
|
printf '{"version":2,"dotenv":[]}\n' > .secrets.json
|
||||||
|
run "$SECRETS_BIN" pull emptypull
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"declares nothing"* ]] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "pull warns when a declared entry has no blob in the store" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir nopullblob
|
||||||
|
"$SECRETS_BIN" push >/dev/null 2>&1
|
||||||
|
jq '.dotenv += [".env.missing"]' .secrets.json > .secrets.json.tmp && mv .secrets.json.tmp .secrets.json
|
||||||
|
run "$SECRETS_BIN" pull
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *".env.missing"* ]] || false
|
||||||
|
[[ "$output" == *"no encrypted data"* ]] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "machine-2 flow: committed manifest + pull restores everything" {
|
||||||
|
init_with_remote
|
||||||
|
create_project_dir machine1
|
||||||
|
mkdir -p packages/api
|
||||||
|
echo "API=1" > packages/api/.dev.vars
|
||||||
|
"$SECRETS_BIN" add packages/api/.dev.vars >/dev/null
|
||||||
|
"$SECRETS_BIN" push m2proj >/dev/null 2>&1
|
||||||
|
# simulate machine 2: fresh dir, only the committed manifest present
|
||||||
|
mkdir -p "$WORK_DIR/machine2"
|
||||||
|
cp .secrets.json "$WORK_DIR/machine2/"
|
||||||
|
cd "$WORK_DIR/machine2"
|
||||||
|
run "$SECRETS_BIN" pull m2proj
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -f .env ]
|
||||||
|
[ -f packages/api/.dev.vars ]
|
||||||
|
[ "$(cat packages/api/.dev.vars)" = "API=1" ]
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue