Tarsnap Backup (gmktec)
What this does
Every night at 3AM, gmktec creates a tarsnap archive of the same raw, unpruned Minecraft world directory that feeds the ZFS-to-Tape Backup — a fully independent, encrypted offsite copy in addition to the local disk and tape copies. At 3:30AM, a retention script prunes old archives down to a grandfather-father-son schedule so the archive count doesn't grow forever.
Added 18 August 2026: the Fabric/Vanilla split. Like the rest of the pipeline, this branch now runs two independent create+rotate script pairs instead of one:
- Fabric (
tarsnap-create-fabric.sh+tarsnap-rotate-fabric.sh) — confirmed live, both dispatched from dedicated scripts in/usr/local/sbin/rather than gmktec's old inline/etc/crontabcommand.tarsnap-rotate-fabric.shalso picked up the calendar-accurate retention-window fix (see "Archive retention" and the script listing below). - Vanilla — a
tarsnap-rotate-vanilla.shexists on gmktec, but it's currently "just an unedited, renamed copy oftarsnap-rotate.sh" — i.e. it still uses the oldshockbyte-minecraft-archive prefix and pre-calendar-fix day-count approximation, not yet updated for the new naming or the retention fix. Whether a dedicatedtarsnap-create-vanilla.shexists alongside it hasn't been confirmed. See "What's fixed locally but not yet deployed" below.
Cron (/etc/crontab on gmktec)
0 3 * * * root /usr/local/sbin/tarsnap-create-fabric.sh
30 3 * * * root /usr/local/sbin/tarsnap-rotate-fabric.sh --commit
This replaced the old approach of running tarsnap -c directly as an inline crontab command — the actual tarsnap invocation now lives in tarsnap-create-fabric.sh itself (see the script listing below), matching how tarsnap-rotate-fabric.sh already worked as a dedicated script rather than an inline command.
Archives are named java-fabric-minecraft-conundra-YYYYMMDD-HH-MM — the -HH-MM suffix matters, since tarsnap-rotate-fabric.sh (below) parses the date back out of the archive name and needs a delimiter right after the 8-digit date to do it. (The still-unconverted vanilla side continues using the old shockbyte-minecraft-YYYYMMDD-HH-MM naming for now.)
Gotcha: tarsnap wouldn't fire from /etc/crontab
Looked like it ran fine — showed up in the cron log every night — but no archive ever appeared. Turned out nothing was actually wrong with the job: /etc/crontab entries mail their output locally by default, and with no MTA configured on that box, any real error just vanished into the void, silently, forever.
Fix: redirect output explicitly so failures are actually visible (now baked into the cron line above via >> /var/log/tarsnap-cron.log 2>&1).
Archive retention: why it was needed
Archives had been deleted manually/ad hoc with no systematic rotation. Besides being a general backup-hygiene gap, this was actively distorting tarsnap --print-stats readings — see Finding #1 below for how an unrelated investigation surfaced this.
tarsnap-rotate-fabric.sh
A grandfather-father-son (GFS) retention script, deployed to /usr/local/sbin/tarsnap-rotate-fabric.sh.
Retention policy (configurable at the top of the script):
KEEP_DAILY=14 # every archive kept for 14 days
KEEP_WEEKLY=8 # then one per ISO week for 8 more weeks
KEEP_MONTHLY=12 # then one per calendar month for 12 more months
KEEP_YEARLY=2 # then one per calendar year, for 2 more years (0 = no expiration)
How it works:
- Parses the date out of each archive name (
java-fabric-minecraft-conundra-YYYYMMDD-...), computes its age, and which year/week/month bucket it falls in. - For each bucket, only the earliest archive within that period is eligible to survive once the finer-grained window (daily/weekly) has passed.
- Archives with unparseable names are skipped and logged as a warning — never deleted, as a safety net.
- Defaults to dry-run — prints keep/delete decisions with reasons, deletes nothing, unless run with
--commit. - Logs every decision and action to
/var/log/tarsnap-rotate-fabric.log.
Deployment:
sudo cp tarsnap-rotate-fabric.sh /usr/local/sbin/
sudo chmod +x /usr/local/sbin/tarsnap-rotate-fabric.sh
sudo /usr/local/sbin/tarsnap-rotate-fabric.sh # dry run first, always
sudo /usr/local/sbin/tarsnap-rotate-fabric.sh --commit # actually delete
Fixed 9 August 2026, live as of the fabric split: retention windows (not the year/week/month bucketing itself) previously used fixed day-count approximations (30 days/month, 365 days/year), which could prune an archive a day or two earlier than a precise calendar-month/year calculation would. tarsnap-rotate-fabric.sh now computes exact MONTHLY_CUTOFF_EPOCH/YEARLY_CUTOFF_EPOCH values via BSD date -j -v-Nm/-v-Ny and compares archive epochs directly against those, instead of a fixed day count — see the script listing below.
What's fixed locally but not yet deployed
The reference copy of tarsnap-rotate-vanilla.sh in the project folder already has the same calendar-accurate cutoff fix applied, at my request. But the live tarsnap-rotate-vanilla.sh on gmktec is still an unedited, renamed copy of tarsnap-rotate.sh — old shockbyte-minecraft- prefix, old age <= keep_monthly*30-style day-count approximation, none of the fabric-side fixes. Deploy the fixed local copy (and settle on real Vanilla-side naming/paths — the current local copy is a straight rename, not yet verified against however Vanilla's tarsnap archives are actually intended to be named) before relying on it.
Investigation: does MCA Selector's chunk-pruning bloat tarsnap the way it bloats ZFS/tape?
This came up as follow-on work from the ZFS-to-Tape Backup project, after discovering that MCA Selector (run in backup-fabric.sh's de-chunking step) was causing massive daily ZFS-incremental churn — nearly as large as a full backup, out of an ~800MB total dataset. That raised the obvious question: is the same thing quietly inflating tarsnap's storage too?
Finding 1 — confirmed root cause on the ZFS side. MCA Selector prunes chunks by filtering on Minecraft's own InhabitedTime NBT field — it reads that value, it doesn't create or modify it. But deleting any chunk from a region file triggers a deFragment() step (confirmed via GitHub issue stack traces referencing MCAFile.java) that repacks remaining chunks to reclaim freed space. Kept chunks' raw byte data is copied verbatim (dos.write(data), confirmed via a GitHub PR snippet) — their actual NBT content is untouched — but the whole region file gets rebuilt as a new file, written to a temp file and atomically swapped in via Files.move. Net effect: any single deleted chunk anywhere in a region file forces the entire file to be rewritten from scratch, dragging every other unrelated chunk in that file along for the ride from any block-level backup system's perspective — even though the actual chunk content didn't change. Confirmed directly against real data: a zfs diff between two snapshots showed literally every changed file (584 of 584) as a matched delete+recreate pair, not an in-place modification.
Finding 2 — tarsnap is not affected the same way. Tarsnap uses content-defined chunking (a rolling-hash approach, confirmed via an academic paper analyzing its algorithm) rather than ZFS's fixed-offset block-level copy-on-write. That means tarsnap can recognize duplicate byte sequences even when they've been relocated within a file — exactly what MCA Selector's rewrite does — where ZFS cannot. Confirmed empirically with tarsnap --print-stats -f <archive> against real archives, all created from MCA-Selector-pruned data:
07-19: only ~150KB unique data (out of a ~1.07GB archive)07-20: only ~123KB unique data07-18: ~187MB unique data — initially puzzling, but explained by tarsnap's reference-counted dedup: an earlier archive that07-18shared chunks with had been manually deleted (ad hoc, no rotation policy in place at the time), making those chunks "unique" to07-18in retrospect — purely an artifact of deletion order, not evidence of anything wrong with that day's backup.
Conclusion at the time: no changes needed to the tarsnap branch of the backup workflow. MCA Selector still ran before every tarsnap upload as of this investigation — it still achieved its storage-cost-reduction goal on this side, since tarsnap's dedup already absorbs the "file looks entirely new" problem that hurt ZFS/tape. The decision to skip MCA Selector was made only for the ZFS-based tape pipeline on separate hardware, where the block-level rewrite penalty is real — see the ZFS-to-Tape Backup page.
Superseded since (caught during a supervisor review, 18 August 2026): that conclusion no longer describes what actually happens. The separate "DVD burn cadence & DVD-only pruning" redesign (5 August rewrite, carried forward through the Fabric/Vanilla split — see backup.sh & DVD Archiving) moved MCA Selector's chunk-pruning to run only against a disposable clone built for the weekly DVD burn, without carving out an exception for tarsnap. As a result, tarsnap-create-fabric.sh (below) archives /zroot/java-fabric-minecraft-conundra directly, and that dataset — the same raw rsync target the Mac fills every night — has never been pruned by MCA Selector in the current architecture. The dedup finding above is still technically correct (if pruning ever ran here again, tarsnap wouldn't suffer the ZFS-style bloat), but in practice no chunk of this investigation's original storage-savings goal for the tarsnap branch applies anymore, since there's nothing left to prune here.
tarsnap-create-fabric.sh
#!/bin/sh
#
# tarsnap-create-fabric.sh
#
set -eu
ARCHIVE_PREFIX="java-fabric-minecraft-conundra-"
LOG_FILE="/var/log/tarsnap-create-fabric.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"
}
log "=== tarsnap-create-fabric.sh start ==="
tarsnap -c -f $ARCHIVE_PREFIX`date +\%Y\%m\%d-\%H-\%M` /zroot/java-fabric-minecraft-conundra >> /var/log/tarsnap-create-fabric.log 2>&1
log "=== tarsnap-create-fabric.sh finished ==="
# ---------------------------------------------------------------------
# Example cron entry (run as root, daily at 3am :
# 0 3 * * * /usr/local/sbin/tarsnap-create-fabric.sh >> /var/log/tarsnap-create-fabric.log 2>&1
# ---------------------------------------------------------------------
tarsnap-rotate-fabric.sh
#!/bin/sh
#
# tarsnap-rotate-fabric.sh
#
# Grandfather-father-son retention for tarsnap archives named like:
# java-fabric-minecraft-conundra-YYYYMMDD-HH-MM
#
# Keeps:
# - every archive within KEEP_DAILY days (the "sons")
# - the earliest archive of each ISO week within KEEP_WEEKLY weeks (the "fathers")
# - the earliest archive of each calendar month within KEEP_MONTHLY months (the "grandfathers")
# - the earliest archive of each calendar year within KEEP_YEARLY years, or forever if KEEP_YEARLY=0
#
# SAFE BY DEFAULT: runs in dry-run mode (just prints what it would do) unless
# called with --commit. Always review a dry-run before committing, especially
# the first time or after changing the config below.
#
# Usage:
# ./tarsnap-rotate-fabric.sh # dry run - prints keep/delete decisions, deletes nothing
# ./tarsnap-rotate-fabric.sh --commit # actually deletes archives marked for deletion
set -eu
# ---------------------------------------------------------------------
# CONFIG - edit these for your retention policy
# ---------------------------------------------------------------------
ARCHIVE_PREFIX="java-fabric-minecraft-conundra-"
KEEP_DAILY=14 # days: keep every archive newer than this
KEEP_WEEKLY=8 # weeks: keep one archive/week (oldest in each ISO week) newer than this
KEEP_MONTHLY=12 # months: keep one archive/month (oldest in each calendar month) newer than this
KEEP_YEARLY=2 # years: keep one archive/year (oldest in each calendar year) newer than this; 0 = keep forever
LOG_FILE="/var/log/tarsnap-rotate-fabric.log"
MODE="dryrun"
if [ "${1:-}" = "--commit" ]; then
MODE="commit"
fi
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"
}
TODAY_EPOCH=$(date '+%s')
WORKFILE=$(mktemp)
trap 'rm -f "$WORKFILE"' EXIT
log "=== tarsnap-rotate-fabric.sh starting (mode=$MODE) ==="
# ---------------------------------------------------------------------
# Precise calendar-based cutoffs for the monthly/yearly retention windows.
# (Draft fix 2026-08-09: replaces the old "age <= keep_monthly*30" /
# "age <= keep_yearly*365" day-count approximations, which could prune an
# archive a day or two earlier than a true calendar month/year boundary
# would. Uses the same BSD `date -j` already relied on elsewhere in this
# script - no new dependency - to step back exactly N calendar
# months/years from today and compare archive epochs against that cutoff
# directly, instead of a fixed day count.)
# ---------------------------------------------------------------------
MONTHLY_CUTOFF_EPOCH=$(date -j -v-"${KEEP_MONTHLY}"m -f '%s' "$TODAY_EPOCH" '+%s')
if [ "$KEEP_YEARLY" -eq 0 ]; then
YEARLY_CUTOFF_EPOCH=0
else
YEARLY_CUTOFF_EPOCH=$(date -j -v-"${KEEP_YEARLY}"y -f '%s' "$TODAY_EPOCH" '+%s')
fi
# ---------------------------------------------------------------------
# Build a table: archive_name epoch age_days year_key week_key month_key
# ---------------------------------------------------------------------
tarsnap --list-archives | grep "^${ARCHIVE_PREFIX}" | while read -r ARCHIVE; do
DATEPART=$(echo "$ARCHIVE" | sed -E "s/^${ARCHIVE_PREFIX}([0-9]{8})-.*/\1/")
# Skip anything that doesn't parse as an 8-digit date - don't guess, don't touch it
case "$DATEPART" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
*)
log "WARNING: could not parse date from archive name '$ARCHIVE' - skipping (will not be deleted)"
continue
;;
esac
EPOCH=$(date -j -f '%Y%m%d' "$DATEPART" '+%s')
AGE_DAYS=$(( (TODAY_EPOCH - EPOCH) / 86400 ))
YEAR_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y')
WEEK_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y-%V')
MONTH_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y-%m')
echo "$ARCHIVE $EPOCH $AGE_DAYS $YEAR_KEY $WEEK_KEY $MONTH_KEY" >> "$WORKFILE"
done
if [ ! -s "$WORKFILE" ]; then
log "No archives matched prefix '$ARCHIVE_PREFIX' - nothing to do."
exit 0
fi
# ---------------------------------------------------------------------
# Classify each archive as KEEP or DELETE using awk (grouping/min logic,
# no date parsing needed here - that was already done above)
# ---------------------------------------------------------------------
DECISIONS=$(awk -v keep_daily="$KEEP_DAILY" \
-v keep_weekly="$KEEP_WEEKLY" \
-v keep_yearly="$KEEP_YEARLY" \
-v monthly_cutoff="$MONTHLY_CUTOFF_EPOCH" \
-v yearly_cutoff="$YEARLY_CUTOFF_EPOCH" '
{
name[NR] = $1; epoch[NR] = $2; age[NR] = $3
ykey[NR] = $4; wkey[NR] = $5; mkey[NR] = $6
n = NR
# Track the earliest (min epoch) archive seen so far for each bucket
if (!(ykey[NR] in y_min_epoch) || epoch[NR] < y_min_epoch[ykey[NR]]) {
y_min_epoch[ykey[NR]] = epoch[NR]; y_min_idx[ykey[NR]] = NR
}
if (!(wkey[NR] in w_min_epoch) || epoch[NR] < w_min_epoch[wkey[NR]]) {
w_min_epoch[wkey[NR]] = epoch[NR]; w_min_idx[wkey[NR]] = NR
}
if (!(mkey[NR] in m_min_epoch) || epoch[NR] < m_min_epoch[mkey[NR]]) {
m_min_epoch[mkey[NR]] = epoch[NR]; m_min_idx[mkey[NR]] = NR
}
}
END {
for (i = 1; i <= n; i++) {
keep = 0; reason = ""
if (age[i] <= keep_daily) { keep = 1; reason = "daily" }
else if (age[i] <= (keep_weekly * 7) && w_min_idx[wkey[i]] == i) { keep = 1; reason = "weekly" }
else if (epoch[i] >= monthly_cutoff && m_min_idx[mkey[i]] == i) { keep = 1; reason = "monthly" }
else if ((keep_yearly == 0 || epoch[i] >= yearly_cutoff) && y_min_idx[ykey[i]] == i) { keep = 1; reason = "yearly" }
if (keep) {
print "KEEP " name[i] " (" reason ", age=" age[i] "d)"
} else {
print "DELETE " name[i] " (age=" age[i] "d, no bucket match)"
}
}
}
' "$WORKFILE")
echo "$DECISIONS"
log "$(echo "$DECISIONS" | grep -c '^KEEP') archives to keep, $(echo "$DECISIONS" | grep -c '^DELETE') to delete"
if [ "$MODE" = "dryrun" ]; then
echo ""
echo "(dry run - nothing deleted. Re-run with --commit to actually delete the above.)"
log "Dry run complete. Re-run with --commit to apply."
exit 0
fi
# ---------------------------------------------------------------------
# Commit mode: actually delete
# ---------------------------------------------------------------------
echo "$DECISIONS" | grep '^DELETE' | awk '{print $2}' | while read -r ARCHIVE; do
log "Deleting archive: $ARCHIVE"
if tarsnap -d -f "$ARCHIVE" >>"$LOG_FILE" 2>&1; then
log "Successfully deleted: $ARCHIVE"
else
log "ERROR: failed to delete archive: $ARCHIVE"
fi
done
log "=== tarsnap-rotate-fabric.sh finished (mode=$MODE) ==="
# ---------------------------------------------------------------------
# Example cron entry (run as root, daily at 3:30am - after the 3am backup):
# 30 3 * * * /usr/local/sbin/tarsnap-rotate-fabric.sh --commit >> /var/log/tarsnap-rotate-fabric.log 2>&1
# ---------------------------------------------------------------------
tarsnap-rotate-vanilla.sh (local reference copy — has the fix, not yet deployed)
This is not what's currently running on gmktec. The live tarsnap-rotate-vanilla.sh is still an unedited, renamed copy of the old pre-fix tarsnap-rotate.sh (old shockbyte-minecraft- prefix, old day-count retention-window approximation). What's below is the local reference copy with the same calendar-cutoff fix ported over, for when I deploy it — note it still says tarsnap-rotate.sh internally in its log strings/comments (ARCHIVE_PREFIX="shockbyte-minecraft-", LOG_FILE="/var/log/tarsnap-rotate.log"), since it hasn't been re-pointed at Vanilla-specific naming yet either:
#!/bin/sh
#
# tarsnap-rotate.sh
#
# Grandfather-father-son retention for tarsnap archives named like:
# shockbyte-minecraft-YYYYMMDD-HH-MM
#
# Keeps:
# - every archive within KEEP_DAILY days (the "sons")
# - the earliest archive of each ISO week within KEEP_WEEKLY weeks (the "fathers")
# - the earliest archive of each calendar month within KEEP_MONTHLY months (the "grandfathers")
# - the earliest archive of each calendar year within KEEP_YEARLY years, or forever if KEEP_YEARLY=0
#
# SAFE BY DEFAULT: runs in dry-run mode (just prints what it would do) unless
# called with --commit. Always review a dry-run before committing, especially
# the first time or after changing the config below.
#
# Usage:
# ./tarsnap-rotate.sh # dry run - prints keep/delete decisions, deletes nothing
# ./tarsnap-rotate.sh --commit # actually deletes archives marked for deletion
set -eu
# ---------------------------------------------------------------------
# CONFIG - edit these for your retention policy
# ---------------------------------------------------------------------
ARCHIVE_PREFIX="shockbyte-minecraft-"
KEEP_DAILY=14 # days: keep every archive newer than this
KEEP_WEEKLY=8 # weeks: keep one archive/week (oldest in each ISO week) newer than this
KEEP_MONTHLY=12 # months: keep one archive/month (oldest in each calendar month) newer than this
KEEP_YEARLY=2 # years: keep one archive/year (oldest in each calendar year) newer than this; 0 = keep forever
LOG_FILE="/var/log/tarsnap-rotate.log"
MODE="dryrun"
if [ "${1:-}" = "--commit" ]; then
MODE="commit"
fi
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG_FILE"
}
TODAY_EPOCH=$(date '+%s')
WORKFILE=$(mktemp)
trap 'rm -f "$WORKFILE"' EXIT
log "=== tarsnap-rotate.sh starting (mode=$MODE) ==="
# ---------------------------------------------------------------------
# Precise calendar-based cutoffs for the monthly/yearly retention windows.
# (Draft fix 2026-08-09: replaces the old "age <= keep_monthly*30" /
# "age <= keep_yearly*365" day-count approximations, which could prune an
# archive a day or two earlier than a true calendar month/year boundary
# would. Uses the same BSD `date -j` already relied on elsewhere in this
# script - no new dependency - to step back exactly N calendar
# months/years from today and compare archive epochs against that cutoff
# directly, instead of a fixed day count.)
# ---------------------------------------------------------------------
MONTHLY_CUTOFF_EPOCH=$(date -j -v-"${KEEP_MONTHLY}"m -f '%s' "$TODAY_EPOCH" '+%s')
if [ "$KEEP_YEARLY" -eq 0 ]; then
YEARLY_CUTOFF_EPOCH=0
else
YEARLY_CUTOFF_EPOCH=$(date -j -v-"${KEEP_YEARLY}"y -f '%s' "$TODAY_EPOCH" '+%s')
fi
# ---------------------------------------------------------------------
# Build a table: archive_name epoch age_days year_key week_key month_key
# ---------------------------------------------------------------------
tarsnap --list-archives | grep "^${ARCHIVE_PREFIX}" | while read -r ARCHIVE; do
DATEPART=$(echo "$ARCHIVE" | sed -E "s/^${ARCHIVE_PREFIX}([0-9]{8})-.*/\1/")
# Skip anything that doesn't parse as an 8-digit date - don't guess, don't touch it
case "$DATEPART" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
*)
log "WARNING: could not parse date from archive name '$ARCHIVE' - skipping (will not be deleted)"
continue
;;
esac
EPOCH=$(date -j -f '%Y%m%d' "$DATEPART" '+%s')
AGE_DAYS=$(( (TODAY_EPOCH - EPOCH) / 86400 ))
YEAR_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y')
WEEK_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y-%V')
MONTH_KEY=$(date -j -f '%Y%m%d' "$DATEPART" '+%Y-%m')
echo "$ARCHIVE $EPOCH $AGE_DAYS $YEAR_KEY $WEEK_KEY $MONTH_KEY" >> "$WORKFILE"
done
if [ ! -s "$WORKFILE" ]; then
log "No archives matched prefix '$ARCHIVE_PREFIX' - nothing to do."
exit 0
fi
# ---------------------------------------------------------------------
# Classify each archive as KEEP or DELETE using awk (grouping/min logic,
# no date parsing needed here - that was already done above)
# ---------------------------------------------------------------------
DECISIONS=$(awk -v keep_daily="$KEEP_DAILY" \
-v keep_weekly="$KEEP_WEEKLY" \
-v keep_yearly="$KEEP_YEARLY" \
-v monthly_cutoff="$MONTHLY_CUTOFF_EPOCH" \
-v yearly_cutoff="$YEARLY_CUTOFF_EPOCH" '
{
name[NR] = $1; epoch[NR] = $2; age[NR] = $3
ykey[NR] = $4; wkey[NR] = $5; mkey[NR] = $6
n = NR
# Track the earliest (min epoch) archive seen so far for each bucket
if (!(ykey[NR] in y_min_epoch) || epoch[NR] < y_min_epoch[ykey[NR]]) {
y_min_epoch[ykey[NR]] = epoch[NR]; y_min_idx[ykey[NR]] = NR
}
if (!(wkey[NR] in w_min_epoch) || epoch[NR] < w_min_epoch[wkey[NR]]) {
w_min_epoch[wkey[NR]] = epoch[NR]; w_min_idx[wkey[NR]] = NR
}
if (!(mkey[NR] in m_min_epoch) || epoch[NR] < m_min_epoch[mkey[NR]]) {
m_min_epoch[mkey[NR]] = epoch[NR]; m_min_idx[mkey[NR]] = NR
}
}
END {
for (i = 1; i <= n; i++) {
keep = 0; reason = ""
if (age[i] <= keep_daily) { keep = 1; reason = "daily" }
else if (age[i] <= (keep_weekly * 7) && w_min_idx[wkey[i]] == i) { keep = 1; reason = "weekly" }
else if (epoch[i] >= monthly_cutoff && m_min_idx[mkey[i]] == i) { keep = 1; reason = "monthly" }
else if ((keep_yearly == 0 || epoch[i] >= yearly_cutoff) && y_min_idx[ykey[i]] == i) { keep = 1; reason = "yearly" }
if (keep) {
print "KEEP " name[i] " (" reason ", age=" age[i] "d)"
} else {
print "DELETE " name[i] " (age=" age[i] "d, no bucket match)"
}
}
}
' "$WORKFILE")
echo "$DECISIONS"
log "$(echo "$DECISIONS" | grep -c '^KEEP') archives to keep, $(echo "$DECISIONS" | grep -c '^DELETE') to delete"
if [ "$MODE" = "dryrun" ]; then
echo ""
echo "(dry run - nothing deleted. Re-run with --commit to actually delete the above.)"
log "Dry run complete. Re-run with --commit to apply."
exit 0
fi
# ---------------------------------------------------------------------
# Commit mode: actually delete
# ---------------------------------------------------------------------
echo "$DECISIONS" | grep '^DELETE' | awk '{print $2}' | while read -r ARCHIVE; do
log "Deleting archive: $ARCHIVE"
if tarsnap -d -f "$ARCHIVE" >>"$LOG_FILE" 2>&1; then
log "Successfully deleted: $ARCHIVE"
else
log "ERROR: failed to delete archive: $ARCHIVE"
fi
done
log "=== tarsnap-rotate.sh finished (mode=$MODE) ==="
# ---------------------------------------------------------------------
# Example cron entry (run as root, daily at 3:30am - after the 3am backup):
# 30 3 * * * /usr/local/sbin/tarsnap-rotate.sh --commit >> /var/log/tarsnap-rotate.log 2>&1
# ---------------------------------------------------------------------
Open items / next steps
- Deploy the fixed
tarsnap-rotate-vanilla.sh(calendar-accurate cutoffs) and decide on real Vanilla-side archive naming/paths — the current local copy is a straight rename of the old script, not yet adapted tojava-vanilla-minecraft-conundra-style naming. - Confirm whether a dedicated
tarsnap-create-vanilla.shexists on gmktec (mirroringtarsnap-create-fabric.sh) or whether Vanilla's tarsnap archive creation still needs to be built out. KEEP_YEARLY=2is the current setting on the fabric branch — worth revisiting once yearly archives start expiring under it, in case forever (0) turns out to be the better default after all.