Reported symptoms
At 04:12 a firmware update bricks the local RAID controller on
rbdr-backup-01 and takes the system array with it. The host is rebuilt
from the configuration-management repository by 06:30. The scheduler
starts cleanly. The console comes up showing zero clients, zero jobs and
zero snapshots.
The storage is fine. df on the repository mount reports the same 4.1 TiB
used as last night’s shift handover recorded, and all sixty-one repository
directories are present. Each is named by the UUID the platform allocated
at client registration, and nothing on the mount says which business system
any UUID belongs to.
At 09:00 finance asks for last Tuesday’s copy of the orders system. The platform returns an empty result set, which on the bridge reads as the backups are gone. Two hypotheses follow: the repositories went with the array, or they survived but cannot be opened because the keys were in the catalogue.
Evidence provided
The first move is to leave the platform out of it and talk to a repository directly.
$ restic snapshotsID Time Host Tags Paths Size
-------------------------------------------------------------------------------
3fe43af4 2026-08-28 13:27:02 8211a08b55c3 daily /work/prod 60.000 MiB
3e349a12 2026-08-28 13:27:03 8211a08b55c3 daily /work/prod 60.000 MiB
-------------------------------------------------------------------------------
Timestamps shown in local time
2 snapshotsIdentifier, time, originating host, the job’s tag, the paths, the size: the columns the catalogue’s snapshot table held. The passphrase came from the secrets manager, which holds one entry per repository UUID.
$ restic restore 3fe43af4 --target /work/restorerestoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore
Summary: Restored 7 files/dirs (60.000 MiB) in 0:00
>>> exit code: 0
--- comparing the restored tree against the 09:00 checksums ---
./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: OK
>>> md5sum -c exit code: 0$ restic check --read-datausing temporary cache in /tmp/restic-check-cache-1847567736
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 2 / 2 snapshots
read all data
[0:00] 100.00% 7 / 7 packs
no errors were found
>>> exit code: 0A second repository, a Borg one, answers the same way.
$ borg list /work/repo day1 Fri, 2026-08-28 13:58:06 [fae87fac26949fa0ab2994a62819407fce5de3418d8d3bc23d4608fd466988c0]
day2 Fri, 2026-08-28 13:58:07 [85d3e533e094eb96663fd26a148b14737667bfc4251eea6b7144827ea2d279b8]
>>> exit code: 0$ borg check --verify-data /work/repo >>> exit code: 0Then the platform’s job definitions, recovered from the configuration-management repository, are searched for the one job whose absence the incident turns on.
$ ls /srv/rbdr-config/jobs | wc -l; grep -rlE 'rbdr-backup-01|rbdr_catalogue' /srv/rbdr-config/jobs /etc/cron.d /etc/systemd/system61
>>> grep exit code: 1 - no file matchedIllustrative output
The report that read 100 per cent for fourteen months came from this query:
SELECT c.hostname, j.name, max(r.finished_at) AS last_success
FROM jobs j
JOIN clients c ON c.id = j.client_id
LEFT JOIN runs r ON r.job_id = j.id AND r.status = 'ok'
GROUP BY c.hostname, j.name;
Work the evidence before reading on
- Two hypotheses were offered on the bridge. Which single command above eliminates the first, and why is a listing not enough to do it?
- The restore succeeded. What does that tell you about where the keys were, and which hypothesis does that retire?
- The snapshot listing has six columns. Which of them did the catalogue also hold, and which did only the catalogue hold?
- Read the SQL. Under what circumstances can that query emit a row for a system that has no backup job?
- Fourteen months of 100 per cent. What population was that a percentage of?
Root cause
The catalogue lived in a database that no job named
The platform kept its clients, jobs, retention classes, run history and the mapping from business system to repository UUID in a PostgreSQL database on the host’s local array. Every job it ran protected something else. No job named the platform host, no job named the database, and no dump was scheduled anywhere on the machine — the install guide’s Protecting the catalogue paragraph was never acted on. The repositories were on a separate mount and were never at risk. The only thing lost was the index.
The coverage inventory was keyed on the tool
Its rows come from the job table, joined out to the clients those jobs name, so every row it can emit begins life as a job that already exists. A system with no job contributes no row, and an absent row reads as nothing rather than as a gap. The platform’s own state was precisely such a system: it could never have appeared, not as a red row, not at all. The report was measuring the job table against itself — and it was stored in the catalogue, inside the thing it failed to protect.
Resolution
Mount the repository storage read-only and inventory it before anything else: the repositories are now the only surviving record of what was backed up.
Suspend all retention. restic forget, restic prune and borg prune
exist to destroy recovery points on purpose, and the platform no longer
knows which policy governs which repository.
Rebuild the index from the repositories themselves. restic documents a
global --json flag for scripting, which turns the listing above into
something a rebuild can consume:
REPOROOT=/srv/rbdr-repos
CATALOGUE=/srv/rbdr-rebuild/rbdr-catalogue.tsv
mkdir -p "$(dirname "$CATALOGUE")"
: > "$CATALOGUE"
for REPO in "$REPOROOT"/restic-*; do
export RESTIC_REPOSITORY="$REPO"
restic snapshots --json \
| jq -r --arg repo "$REPO" '.[] | [$repo, .short_id, .time, .hostname, (.tags // [] | join(",")), (.paths | join(","))] | @tsv' \
>> "$CATALOGUE"
done
for REPO in "$REPOROOT"/borg-*; do
printf '# %s\n' "$REPO" >> "$CATALOGUE"
borg list "$REPO" >> "$CATALOGUE"
done
Serve the 09:00 request directly from the repository the reconstruction identifies, without waiting for the platform to be well.
Recover from independent sources what the repositories cannot describe: the UUID mapping from the client registrations committed to the configuration-management repository, retention classes from the written policy, restore-test history from the ticket system. Then state plainly what is not recoverable — the run history and the coverage attestations — rather than letting a rebuilt database imply a provenance it lacks.
Last, create the job that did not exist: a scheduled pg_dump of the
catalogue, written where it can be found without the catalogue.
Verification
Reconcile the rebuilt catalogue against the asset inventory, not against the job table. Every UUID resolves to a named system or is recorded as unidentified.
Restore from a repository the rebuilt catalogue selected, into a scratch
target, and compare the tree against reference digests. In the capture,
Restored 7 files/dirs (60.000 MiB) at exit 0 and md5sum -c at exit 0
are read together; either alone proves less than it appears to.
Restore the new catalogue dump into a scratch database and query it before declaring the job to work; a dump that has never been restored is a file. Do it with the platform stopped, the only state in which it will ever be wanted.
Regenerate the coverage report from the asset inventory as its left side. The number of systems with no job must be a figure the report prints. On first run that figure is not zero, which is how you know it works.
Prevention
Treat the backup platform as a protected system with a named owner. Its catalogue, configuration and credentials are production state. No platform volunteers this: its view of the estate is what it was told to protect.
Store the catalogue dump where it can be found without the catalogue. A dump inside a repository whose UUID the catalogue must resolve is a circular dependency that fails at the moment it is wanted.
Build coverage from the asset inventory outward. Enumerate systems from a source the platform does not own, then left-join the jobs. A report that iterates jobs can only report 100 per cent, whatever is missing.
Keep the repositories self-describing. Set host, tags and paths at backup time so the listing carries them, as the capture’s does. That property is the only reason this incident was recoverable.
Keep the UUID to business-system mapping in version control, committed at registration, so it survives the platform that allocated it.
Rehearse the loss of the platform, not only the loss of a client. Restoring a file while the platform is healthy exercises none of the machinery this incident needed.