diff --git a/backup-to-drive.sh b/backup-to-drive.sh index 3c3acc7..f4f4a6f 100755 --- a/backup-to-drive.sh +++ b/backup-to-drive.sh @@ -150,11 +150,24 @@ fi # out. That is backwards: the whole value of the off-site copy is that it outlives the NAS's # 30-day window. Each file is ~260 KB, so a decade of them is under a gigabyte. If it ever # genuinely needs trimming, do it by hand, and never below what the NAS still holds. -cd "$DRIVE_DIR" || die "cannot enter $DRIVE_DIR" -TOTAL="$(ls -1 checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')" -OLDEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | head -1)" -NEWEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | tail -1)" -say "✓ done — $TOTAL copies off-site, ${OLDEST:-none} → ${NEWEST:-none}" +# Reporting and freshness. +# +# This deliberately does NOT rely on listing the Drive folder. Under launchd, macOS lets a +# named path be stat'd but blocks enumerating ~/Library/CloudStorage, so a glob quietly +# expands to nothing — which on 2026-09-24 made this script report "0 copies off-site" and +# email an alert while 57 files sat there. Checking explicit filenames works in both +# contexts, so freshness is derived that way and enumeration is treated as a bonus. +NEWEST="" +for i in $(seq 0 60); do + d="$(date -v-${i}d +%Y-%m-%d 2>/dev/null)" || break + if [ -s "$DRIVE_DIR/checklists-$d.json" ]; then NEWEST="checklists-$d.json"; NEWEST_DATE="$d"; AGE_DAYS=$i; break; fi +done + +# Enumeration is allowed when run by hand; when it is blocked the count is unknown rather +# than zero, and an unknown count is never treated as a missing backup. +TOTAL="$(ls -1 "$DRIVE_DIR"/checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')" +if [ "$TOTAL" = "0" ]; then TOTAL="unknown (cannot list the folder from here)"; fi +say "✓ done — $TOTAL copies off-site, newest ${NEWEST:-none}" # Some files were fetched but were not usable. The run "succeeded" in the sense that it did # not crash, which is exactly the kind of success that hides a broken backup. @@ -164,24 +177,19 @@ if [ "${FAILED:-0}" -gt 0 ]; then were skipped. See the log for which ones." fi -# The staleness check is the one that would have caught both of the incidents in the header -# comment. Every failure path above assumes this script RAN; if the Mac is asleep, or the -# LaunchAgent is unloaded, nothing above ever fires. This at least turns a quietly ageing -# off-site copy into mail the next time it does run. +# The check that would have caught both of the incidents in the header comment. Every +# failure path above assumes this script RAN; if the Mac is asleep or the LaunchAgent is +# unloaded, nothing fires at all. if [ -z "$NEWEST" ]; then - alert "no off-site copies at all" "$DRIVE_DIR contains no checklists-.json files." + alert "no recent off-site copy" \ + "No checklists-.json for any of the last 60 days was found in +$DRIVE_DIR +Either copying has stopped, or this machine cannot read that folder." +elif [ "$AGE_DAYS" -gt "$STALE_DAYS" ]; then + alert "off-site copy is $AGE_DAYS days old" \ + "Newest file off-site is $NEWEST ($AGE_DAYS days old); more than $STALE_DAYS days +means the chain from the NAS to Drive is not working, even though this run did +not error." else - NEWEST_DATE="$(printf '%s' "$NEWEST" | sed -E 's/^checklists-([0-9]{4}-[0-9]{2}-[0-9]{2})\.json$/\1/')" - NEWEST_EPOCH="$(date -j -f "%Y-%m-%d" "$NEWEST_DATE" +%s 2>/dev/null || echo 0)" - if [ "$NEWEST_EPOCH" -gt 0 ]; then - AGE_DAYS=$(( ( $(date +%s) - NEWEST_EPOCH ) / 86400 )) - if [ "$AGE_DAYS" -gt "$STALE_DAYS" ]; then - alert "off-site copy is $AGE_DAYS days old" \ - "Newest file off-site is $NEWEST ($AGE_DAYS days old); anything more than -$STALE_DAYS days means the chain from the NAS to Drive is not working, even though -this run did not error." - else - say "freshness OK — newest off-site copy is $AGE_DAYS day(s) old" - fi - fi + say "freshness OK — newest off-site copy is $AGE_DAYS day(s) old" fi