homehub: implement + validate sync, tombstones, manifest, alerts

Tested end-to-end with two throwaway homes against a scratch hub root:
deletion propagation, new-machine join safety, edit-wins-over-delete,
reaping, forget, alert timing. Fixes found by those tests:
 - harvest deletions BEFORE the pull (pull used to undo every deletion)
 - gate attribution on $STATE/seen (a new machine would have tombstoned
   every hub file its peers had just uploaded)
 - active_path probed '+ /dir***' instead of '+ /dir/***' -> every tombstone
   looked out-of-scope, so deletions never applied
 - tombstone excludes must precede includes in the filter (first match wins)
 - rsync must get -e with the deploy key; harvest stderr must not be dropped
 - guard hub sync root that already holds unrelated data (178 G here)
 - scrub credentials out of repo remote URLs before publishing the manifest
This commit is contained in:
druppy committed 2026-10-24 16:36:40 +02:00
1 parent 78496330f3
commit be375fb312
4 files changed
+150 -45

No files matched your search

+25 -2
View File
@@ -1,6 +1,29 @@
# homehub — lean home-drive sync across 3–4 machines via Synology # homehub — lean home-drive sync across 3–4 machines via Synology
Status: **design, no code yet**. Open decisions in §13. Status: **implemented and tested on g9; no personal file data uploaded yet.** Decisions in §13,
operations in `README.md`.
### Verification status (2026-10-24)
| Behaviour | Method | Result |
|---|---|---|
| default-deny filter, shadow warnings | `homehub filter` on real `$HOME` | 35 include / 95 deny rules, no shadowing |
| pending set is genuinely personal | dry run on real `$HOME` | 571 files, 470 MB (docs, photos, dotfiles, pi sessions) |
| class B manifest, no byte-sync of repos | `git inventory` | 114 repos in 11 s, credentials scrubbed from 3 remotes |
| class C union merge + idempotency | synthetic 2-host export | 53 urls, re-merge stable |
| alert timing (silent → 24 h → once) | backdated `broken.auth` | silent at 0 m, single alert at 2 d |
| new machine must not tombstone the hub | 2 fake homes + scratch hub root | 5 never-seen hub files correctly ignored |
| deletion propagates, never resurrects | same | harvested pre-pull; peer applied it; no resurrection |
| **edit wins over a later delete** | same | re-pulled peer's newer copy, classified `EDITED-ELSEWHERE` |
| reaper preserves the last copy | `GRACE_DAYS=0` | hub copy moved to `.hub/prev/reaped/2026-10/…`, tombstone pruned |
| `forget` escape hatch | same | file travels again after forgetting it |
Not yet exercised: 3+ machine convergence, suspend/resume with `Persistent=true`,
real mtime-conflict resolution across skewed clocks, and the bookmark write-back
(v1 deliberately stops before overwriting the live file).
**Blocking step before any real sync:** `ssh-copy-id -i ~/.ssh/homehub_ed25519.pub nas.lue.dk`
— see §0 row on the passphrase/agent problem.
## 0. Ground truth this design was built against ## 0. Ground truth this design was built against
@@ -256,7 +279,7 @@ freshness monitor, not an error notifier.
- Transport: `sendmail` → your existing `mail.lue.dk`. Works with no GUI session, - Transport: `sendmail` → your existing `mail.lue.dk`. Works with no GUI session,
which `notify-send` does not. which `notify-send` does not.
## 11. Files to be created (this repo = the policy; ~200 lines of shell total) ## 11. Files to be created (~750 lines of bash in total — the comments are half of it)
``` ```
~/homehub/ # small git repo, hub-backed via a bare repo on the NAS ~/homehub/ # small git repo, hub-backed via a bare repo on the NAS
+1 -1
View File
@@ -5,7 +5,7 @@ is only the operational half.
``` ```
~/homehub/ ~/homehub/
bin/homehub the tool (~450 lines of bash + one python merge) bin/homehub the tool (~750 lines of bash + one python merge)
config machine-specific settings (per-machine, not synced) config machine-specific settings (per-machine, not synced)
rules/denylist DEFAULT DENY, emitted first, wins over everything rules/denylist DEFAULT DENY, emitted first, wins over everything
rules/sets.d/*.list named path-lists ("sets") rules/sets.d/*.list named path-lists ("sets")
+118 -36
View File
@@ -19,7 +19,9 @@ HH_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$HH_ROOT/config" . "$HH_ROOT/config"
HOST=$(hostname -s) # HH_HOST lets the deletion/convergence semantics be tested with two throwaway
# "machines" on one box; leave unset in normal use.
HOST=${HH_HOST:-$(hostname -s)}
HUB_URL="$HUB_HOST:$HUB_PATH" HUB_URL="$HUB_HOST:$HUB_PATH"
RULES=$HH_ROOT/rules RULES=$HH_ROOT/rules
STATE=${STATE_ROOT:?config: STATE_ROOT unset} STATE=${STATE_ROOT:?config: STATE_ROOT unset}
@@ -115,33 +117,36 @@ check_shadowing() { # $1 set name
done < <(awk '!/^[[:space:]]*#/ && NF' "$RULES/denylist" 2>/dev/null) done < <(awk '!/^[[:space:]]*#/ && NF' "$RULES/denylist" 2>/dev/null)
done < <(awk '!/^[[:space:]]*#/ && NF' "$f") done < <(awk '!/^[[:space:]]*#/ && NF' "$f")
} }
# base filter: deny first, then includes (+ ancestors), NO trailing default-deny # Filter composition. Order is load-bearing: rsync applies FIRST MATCH WINS, so
# 1. denylist (default-deny trees, wins over everything)
# 2. tombstone excludes (pull only — must precede the includes or they never fire)
# 3. includes (this host's sets + their traversable ancestors)
# 4. "- *" (default deny)
# Two variants: the push filter must NOT exclude tombstones, or a file you re-create
# after deleting it could never travel again (edits win, §3).
gen_filter() { gen_filter() {
local sets s local sets s
sets=$(sets_for_host) || return 1 sets=$(sets_for_host) || return 1
{
echo '- /.hub/***' # hub metadata can never leak into a home { echo '- /.hub/***' # hub metadata can never leak into a home
[ -r "$RULES/denylist" ] && rules_from "$RULES/denylist" '-' [ -r "$RULES/denylist" ] && rules_from "$RULES/denylist" '-'
for s in $sets; do } | awk '!seen[$0]++' > "$FILTER.deny" || return 1
{ for s in $sets; do
[ -r "$RULES/sets.d/$s.list" ] || { warn "unknown set '$s' (no rules/sets.d/$s.list)"; continue; } [ -r "$RULES/sets.d/$s.list" ] || { warn "unknown set '$s' (no rules/sets.d/$s.list)"; continue; }
{ rules_from "$RULES/sets.d/$s.list" '+' { rules_from "$RULES/sets.d/$s.list" '+'
awk '!/^[[:space:]]*#/ && NF' "$RULES/sets.d/$s.list" | while read -r l; do ancestors_of "$l"; done; } awk '!/^[[:space:]]*#/ && NF' "$RULES/sets.d/$s.list" | while read -r l; do ancestors_of "$l"; done; }
check_shadowing "$s" check_shadowing "$s"
done done
} | awk '!seen[$0]++' > "$FILTER.base.tmp" || return 1 } | awk '!seen[$0]++' > "$FILTER.incl" || return 1
mv "$FILTER.base.tmp" "$FILTER.base"
# push filter: default deny only awk -F'\t' 'NF>=2 { p=$2; sub(/^\//, "", p)
{ cat "$FILTER.base"; echo '- *'; } > "$FILTER.tmp" && mv "$FILTER.tmp" "$FILTER" if (p != "" && p !~ /^\.hub\// && p !~ /\.\./) { print "- /" p; print "- /" p "/***" } }' \
# pull filter: default deny, PLUS still-tombstoned paths excluded so a deleted "$STATE/tombstones.all" 2>/dev/null | awk '!seen[$0]++' > "$FILTER.tomb"
# file is not re-downloaded every cycle for 30 days only to be re-deleted.
# Tombstones never enter the push filter: a file re-created after the delete cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl" > "$FILTER.base"
# must still be able to travel (edits win — DESIGN.md §3). { cat "$FILTER.deny" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp" && mv "$FILTER.tmp" "$FILTER"
{ { cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp.pull" && mv "$FILTER.tmp.pull" "$FILTER.pull"
cat "$FILTER.base"
awk -F'\t' 'NF>=2 { p=$2; sub(/^\//, "", p); if (p != "" && p !~ /^\.hub\// && p !~/\.\./) {
print "- /" p; print "- /" p "/***" } }' "$STATE/tombstones.all" 2>/dev/null
echo '- *'
} | awk '!seen[$0]++' > "$FILTER.pull.tmp" && mv "$FILTER.pull.tmp" "$FILTER.pull"
} }
# --------------------------------------------------------------- rsync bits -- # --------------------------------------------------------------- rsync bits --
@@ -172,6 +177,20 @@ count_items() { grep -cE '^[<>]' "$1" 2>/dev/null || true; }
show_head() { grep -E '^[<>]' "$1" 2>/dev/null | head -n "${2:-10}"; } show_head() { grep -E '^[<>]' "$1" 2>/dev/null | head -n "${2:-10}"; }
# ----------------------------------------------------- deletions (tombstones) -- # ----------------------------------------------------- deletions (tombstones) --
# $STATE/seen = every path this machine has actually transferred (pushed or pulled).
# It is what makes "on the hub but not local" decidable: a file we transferred and
# then removed is OUR deletion and may propagate; a file only a peer ever uploaded is
# not. Without it, a newly added machine harvests every existing hub file as its own
# deletion and tombstones the whole hub on join. Built from rsync's own itemized
# output (no extra round trip); its failure mode is the safe one — a file that is not
# recognised is never deleted.
seen_update() {
{ [ -s "$STATE/seen" ] && cat "$STATE/seen"
awk '/^[<>]/ { sub(/^[^|]*\|/, ""); if (length($0)) print }' \
"$STATE/pull.out" "$STATE/push.out" 2>/dev/null
} | sed '/^\.$/d' | sort -u > "$STATE/seen.new" && mv "$STATE/seen.new" "$STATE/seen"
}
# Format on the hub: .hub/tombstones/<host> lines = epoch \t home-rel-path # Format on the hub: .hub/tombstones/<host> lines = epoch \t home-rel-path
hub_tombstones_all() { hub_tombstones_all() {
hub_script "'$HUB_PATH'" <<'EOS' hub_script "'$HUB_PATH'" <<'EOS'
@@ -188,8 +207,11 @@ EOS
active_path() { # $1 = path without leading slash active_path() { # $1 = path without leading slash
local p="/$1" local p="/$1"
while :; do while :; do
grep -qxF "+ $p***" "$FILTER" && return 0 # inside an included subtree # NOTE the slash before ***: the include rules are "+ /dir/***", so probing
grep -qxF "+ $p" "$FILTER" && return 0 # is itself an included file # "+ /dir***" would never match and every tombstone would look out-of-scope.
grep -qxF "+ $p/***" "$FILTER" && return 0 # inside an included subtree
grep -qxF "+ $p" "$FILTER" && return 0 # is itself an included file
grep -qxF "+ $p/" "$FILTER" && return 0 # an included ancestor dir
[ "${p%/*}" = "$p" ] && return 1 [ "${p%/*}" = "$p" ] && return 1
p=${p%/*} p=${p%/*}
[ -z "$p" ] && return 1 [ -z "$p" ] && return 1
@@ -218,20 +240,73 @@ apply_tombstones() { # deletions recorded by any machine, edit-wins (§3)
done < "$all" done < "$all"
v "tombstones: applied=$n edited-since-so-kept=$kept outside-sets=$out malformed=$bad" v "tombstones: applied=$n edited-since-so-kept=$kept outside-sets=$out malformed=$bad"
} }
harvest_tombstones() { # record what this machine deleted; runs only after a real pull harvest_tombstones() { # record what this machine deleted — BEFORE any pull, else
local new=$STATE/tomb.new merged=$STATE/tomb.merged n ep # the pull re-downloads the file and the deletion is silently lost forever.
run_rsync 1 --delete "$HOME/" "$HUB_URL/" 2>/dev/null | # A hub copy newer than our last successful sync was edited by someone else
awk -F'|' '$1 ~ /^\*/ { sub(/^\//, "", $2); print $2 }' | sort -u > "$new" # since we last looked, so it is an edit, not a deletion: leave it alone (§3).
local new=$STATE/tomb.new merged=$STATE/tomb.merged n ep last rc=0
# stderr is kept, never sent to /dev/null: an ssh / known_hosts / transport error
# here would otherwise look exactly like "nothing was deleted" and silently stop
# deletions propagating anywhere, forever.
run_rsync 1 "$FILTER" --delete "$HOME/" "$HUB_URL/" > "$STATE/harvest.out" 2> "$STATE/harvest.err" || rc=$?
if [ "$rc" != 0 ]; then
warn "deletion harvest failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/harvest.err" | tail -c 200)"
return 1
fi
awk -F'|' '$1 ~ /^\*/ { sub(/^\//, "", $2); print $2 }' "$STATE/harvest.out" | sort -u > "$new.raw"
# intersect with what we saw at the hub on the previous sync (first sync: no cache,
# so nothing can be claimed as a deletion — correct, nothing was ever ours to delete)
if [ -s "$STATE/seen" ]; then
comm -12 "$new.raw" "$STATE/seen" > "$new"
else
: > "$new"
v "nothing transferred by this machine yet: no deletions are attributable to it"
fi
local skipped; skipped=$(($(grep -c . "$new.raw" 2>/dev/null || true) - $(grep -c . "$new" 2>/dev/null || true)))
[ "${skipped:-0}" -gt 0 ] && v "ignored $skipped hub file(s) this machine never had (not our deletions)"
n=$(grep -c . "$new" 2>/dev/null || true) n=$(grep -c . "$new" 2>/dev/null || true)
[ "${n:-0}" = 0 ] && { v "no local deletions to record"; return 0; } if [ "${n:-0}" = 0 ]; then v "no local deletions to record"; return 0; fi
last=$(hub "date -r '$HUB_PATH/.hub/meta/markers/$HOST' +%s 2>/dev/null || echo 0")
local kept=$STATE/tomb.kept edited=$STATE/tomb.edited
# Candidates exist on the hub by definition (hub has it, we do not), so spool the
# list there and let the remote loop stat it. A heredoc cannot share stdin with a
# pipe, which is why this is two ssh calls rather than one.
hub "mkdir -p '$HUB_PATH/.hub/tmp' && cat > '$HUB_PATH/.hub/tmp/cand.$HOST'" < "$new" || return 1
hub_script "'$HUB_PATH' '${last:-0}' '$HOST'" > "$kept" <<'EOS'
root=$1; last=$2; me=$3
cf="$root/.hub/tmp/cand.$me"
[ -f "$cf" ] || exit 0
while IFS= read -r p; do
[ -n "$p" ] || continue
m=$(date -r "$root/$p" +%s 2>/dev/null || echo 0)
if [ "$last" != 0 ] && [ "$m" -gt "$last" ]; then
printf '%s\tEDITED-ELSEWHERE\n' "$p"
else
printf '%s\n' "$p"
fi
done < "$cf"
rm -f "$cf"
exit 0
EOS
grep -c 'EDITED-ELSEWHERE' "$kept" 2>/dev/null | tr -d ' ' > "$edited" || echo 0 > "$edited"
grep -v 'EDITED-ELSEWHERE' "$kept" > "$kept.paths" || true
if [ "$(grep -c . "$kept.paths" 2>/dev/null || true)" = 0 ]; then
v "no deletions recorded ($(cat "$edited") path(s) were edited elsewhere -> kept as edits)"; return 0
fi
ep=$(now) ep=$(now)
{ hub "cat '$HUB_PATH/.hub/tombstones/$HOST' 2>/dev/null" { hub "cat '$HUB_PATH/.hub/tombstones/$HOST' 2>/dev/null"
awk -v e="$ep" '{ print e "\t" $0 }' "$new" awk -v e="$ep" 'NF { print e "\t" $0 }' "$kept.paths"
} | awk -F'\t' 'NF>=2 { p=$2; if (!(p in e) || $1+0 < e[p]) e[p]=$1+0 } } | awk -F'\t' 'NF>=2 { p=$2; if (!(p in e) || $1+0 < e[p]) e[p]=$1+0 }
END { for (p in e) printf "%d\t%s\n", e[p], p }' | sort -t$'\t' -k2 > "$merged" END { for (p in e) printf "%d\t%s\n", e[p], p }' | sort -t$'\t' -k2 > "$merged"
hub "mkdir -p '$HUB_PATH/.hub/tombstones' && cat > '$HUB_PATH/.hub/tombstones/$HOST'" < "$merged" || hub "mkdir -p '$HUB_PATH/.hub/tombstones' && cat > '$HUB_PATH/.hub/tombstones/$HOST'" < "$merged" ||
{ warn "could not write tombstones"; return 1; } { warn "could not write tombstones"; return 1; }
v "recorded $n deletion(s) as tombstones on the hub" # fold into the local cache and refresh the pull filter so the pull cannot undo us
cat "$merged" >> "$STATE/tombstones.all"
sort -u -t$'\t' -k2,2 "$STATE/tombstones.all" -o "$STATE/tombstones.all"
gen_filter >/dev/null 2>&1 || true
v "recorded $(grep -c . "$kept.paths") deletion(s) as tombstones ($(cat "$edited") edited elsewhere, kept)"
} }
reap() { # stop resurrecting files deleted more than GRACE_DAYS ago (§5) reap() { # stop resurrecting files deleted more than GRACE_DAYS ago (§5)
hub_script "'$HUB_PATH' '${GRACE_DAYS:-30}'" <<'EOS' || true hub_script "'$HUB_PATH' '${GRACE_DAYS:-30}'" <<'EOS' || true
@@ -261,7 +336,7 @@ EOS
# $HOME on the first pull (and could be reaped by a tombstone). This guard exists # $HOME on the first pull (and could be reaped by a tombstone). This guard exists
# because /volume1/homes/bl really did hold 178 G of unrelated legacy data. # because /volume1/homes/bl really did hold 178 G of unrelated legacy data.
check_hub_stray() { check_hub_stray() {
[ -r "$FILTER.base" ] || return 0 [ -r "$FILTER.base" ] || return 1 # cannot judge -> do not block the sync
local allowed stray local allowed stray
allowed=$(printf '.hub\n@eaDir\n'; grep '^+ /' "$FILTER.base" | sed -E 's#^\+ /([^/]+).*#\1#; s#/$##' | sort -u) allowed=$(printf '.hub\n@eaDir\n'; grep '^+ /' "$FILTER.base" | sed -E 's#^\+ /([^/]+).*#\1#; s#/$##' | sort -u)
stray=$(hub "cd '$HUB_PATH' 2>/dev/null && ls -A" | grep -vxF -f <(printf '%s\n' "$allowed") || true) stray=$(hub "cd '$HUB_PATH' 2>/dev/null && ls -A" | grep -vxF -f <(printf '%s\n' "$allowed") || true)
@@ -290,18 +365,26 @@ cmd_sync() {
esac esac
clear_broken auth clear_broken auth
hub_setup_dirs hub_setup_dirs
if check_hub_stray; then
[ $exec = 1 ] && [ "${HH_FORCE:-0}" != 1 ] &&
die "refusing to sync into a hub root that is not ours. Fix HUB_PATH in config, or move that data away, or re-run with HH_FORCE=1 if you really mean it"
fi
# tombstones must be known BEFORE the filter is built: they shape the pull filter # tombstones must be known BEFORE the filter is built: they shape the pull filter
hub_tombstones_all > "$STATE/tombstones.all" || true hub_tombstones_all > "$STATE/tombstones.all" || true
gen_filter || return 1 gen_filter || return 1
# ...and the filter must exist before the hub root can be checked against it
if check_hub_stray; then
[ $exec = 1 ] && [ "${HH_FORCE:-0}" != 1 ] &&
die "refusing to sync into a hub root that is not ours. Fix HUB_PATH in config, move that data away, or re-run with HH_FORCE=1 if you really mean it"
fi
hr "homehub sync ($([ $exec = 1 ] && echo EXECUTE || echo 'dry run')) on $HOST" hr "homehub sync ($([ $exec = 1 ] && echo EXECUTE || echo 'dry run')) on $HOST"
v "sets: $sets" v "sets: $sets"
v "filter: $(grep -c . "$FILTER" || true) push, $(grep -c . "$FILTER.pull" || true) pull rules, $(grep -c . "$STATE/tombstones.all" 2>/dev/null || true) tombstoned" v "filter: $(grep -c . "$FILTER" || true) push, $(grep -c . "$FILTER.pull" || true) pull rules, $(grep -c . "$STATE/tombstones.all" 2>/dev/null || true) tombstoned"
[ $exec = 1 ] && touch_marker alive [ $exec = 1 ] && touch_marker alive
# 0. harvest BEFORE the pull: a deletion the pull would otherwise undo
if [ $exec = 1 ]; then
hr "0. harvest this machine's deletions (before the pull can undo them)"
harvest_tombstones || rc=1
gen_filter || return 1 # pull filter must know the fresh tombstones
fi
hr "1. pull $HUB_URL -> $HOME/" hr "1. pull $HUB_URL -> $HOME/"
pull_rsync "$dry" > "$STATE/pull.out" 2> "$STATE/pull.err" || rc=$? pull_rsync "$dry" > "$STATE/pull.out" 2> "$STATE/pull.err" || rc=$?
if [ $rc != 0 ]; then if [ $rc != 0 ]; then
@@ -314,10 +397,8 @@ cmd_sync() {
if [ $exec = 1 ]; then if [ $exec = 1 ]; then
hr "2. apply deletions recorded by any machine (edit-wins)" hr "2. apply deletions recorded by any machine (edit-wins)"
apply_tombstones apply_tombstones
hr "3. harvest this machine's deletions"
harvest_tombstones || rc=1
else else
hr "2./3. deletion handling skipped in dry run" hr "2. deletion handling skipped in dry run (harvest only runs with --exec)"
fi fi
hr "4. push $HOME/ -> $HUB_URL" hr "4. push $HOME/ -> $HUB_URL"
@@ -334,6 +415,7 @@ cmd_sync() {
touch_marker markers touch_marker markers
hub "rm -f '$HUB_PATH/.hub/meta/alerted/$HOST'" || true hub "rm -f '$HUB_PATH/.hub/meta/alerted/$HOST'" || true
clear_broken sync clear_broken sync
seen_update
elif [ $exec = 1 ]; then elif [ $exec = 1 ]; then
mark_broken sync "last sync failed with rsync rc=$rc (see $STATE/pull.err, $STATE/push.err)" mark_broken sync "last sync failed with rsync rc=$rc (see $STATE/pull.err, $STATE/push.err)"
fi fi
+6 -6
View File
@@ -9,7 +9,7 @@ HUB_HOST=nas.lue.dk
# years of NAS files into ~/, and a tombstone could reap unrelated files by name # years of NAS files into ~/, and a tombstone could reap unrelated files by name
# accident. The sync root is a dedicated subtree; .hub/ metadata lives inside it. # accident. The sync root is a dedicated subtree; .hub/ metadata lives inside it.
HUB_BASE=/volume1/homes/bl HUB_BASE=/volume1/homes/bl
HUB_PATH=$HUB_BASE/homehub HUB_PATH=${HUB_PATH:-$HUB_BASE/homehub}
# Dedicated, PASSPHRASE-LESS deploy key. Your id_rsa is passphrase-protected and a # Dedicated, PASSPHRASE-LESS deploy key. Your id_rsa is passphrase-protected and a
# systemd *user* unit has no SSH_AUTH_SOCK, so using ~/.ssh/id_rsa here would make # systemd *user* unit has no SSH_AUTH_SOCK, so using ~/.ssh/id_rsa here would make
@@ -30,11 +30,11 @@ STATE_ROOT=$HOME/.local/state/homehub
SHARED_ROOT=$HOME/.local/state/shared SHARED_ROOT=$HOME/.local/state/shared
# --- policy knobs ------------------------------------------------------------- # --- policy knobs -------------------------------------------------------------
GRACE_DAYS=30 # tombstone age before the hub copy is reaped (§5) GRACE_DAYS=${GRACE_DAYS:-30} # tombstone age before the hub copy is reaped (§5)
FRESH_MAX_AGE=86400 # alert if a live machine has not synced in 24 h FRESH_MAX_AGE=${FRESH_MAX_AGE:-86400} # alert if a live machine has not synced in 24 h
ALIVE_FRESH=10800 # "machine is on" = heartbeat younger than 3 h ALIVE_FRESH=${ALIVE_FRESH:-10800} # "machine is on" = heartbeat younger than 3 h
REALERT_AGE=604800 # at most one alert per host per week REALERT_AGE=${REALERT_AGE:-604800} # at most one alert per host per week
TOMB_LIMIT=100 # refuse to apply more deletions than this in one run TOMB_LIMIT=${TOMB_LIMIT:-100} # refuse to apply more deletions than this in one run
WARN_FILES=20000 # dry run warns above this many pending changes WARN_FILES=20000 # dry run warns above this many pending changes
# Where to look for git repos for the class B manifest (DESIGN.md §6). # Where to look for git repos for the class B manifest (DESIGN.md §6).