Persist the backup manifest inside the restic repo (2026-07 cross-cluster recover finding)
The cross-cluster no-file migration — backup create on cluster A against a
restic repo both clusters can reach, backup sync + backup export + recover
on cluster B, with no file ever copied host-to-host — restores the raw DATA
correctly but silently drops everything else a recovered stack needs to be
reachable: host_mappings (ingress), port_mappings, integration edges, resolved
variables, and the stack record. --override host becomes a no-op, recovered
stacks lose ingress entirely, and apps with a required plug fail install with
X400994PLGREQ. The root cause is a single architectural gap: the RICH
manifest is assembled in memory, after the restic snapshot already exists, and
is never written into the repo the snapshot lives in — only 4-5 short identity
tags are. This proposal decides how the rich manifest travels through the
repo so a foreign cluster can reconstruct it without ever touching cluster A’s
filesystem.
The gap (all refs verified at current main)
Section titled “The gap (all refs verified at current main)”WRITE — CreateBackup (internal/modules/backup/service.go:716) builds
the small tag set at :866-880 (backup_id, tenant, stack,
environment, and consistency_id when the backup is a group member — 4 or
5 keys, all short strings) and calls restic.CreateSnapshot(ctx, repo, req.Paths, tags, req.Exclude) at :884. CreateSnapshot
(internal/modules/backup/restic.go:202) turns tags into repeated --tag key=value CLI arguments (:209-212) — that is the entirety of what rides
the restic snapshot object. The RICH BackupManifest — App, AppVersion,
Runtime, Variables, PortMappings, HostMappings, Integrations,
StackRecord, VaultSecrets, DeploymentConfig — is assembled at
service.go:908-991, entirely AFTER CreateSnapshot returns, from a live
StackContext (s.resolveStack, :918) that has nothing to do with the
snapshot. It is marshalled to JSON at :993 and persisted ONLY as
row.Manifest in cluster A’s local backup table (completeBackup,
:995-1003). The restic object on disk never sees it.
READ #1 — SyncCache (service.go:1096-1235) is how cluster B learns a
snapshot exists at all: it calls restic.ListSnapshots (:1130) and, per
snapshot, runs the tags through parseTagMap (:1142, defined :1237-1250)
to pull stack/environment/tenant/backup_id/consistency_id into a
BackupCacheEntry (:1194-1207). BackupCacheEntry
(internal/modules/backup/types.go:827-840) and its ent-backed table
(ent/schema/backup_cache.go:26-41) have no manifest column at all — there is
nowhere for a richer payload to land even if SyncCache had one to store.
READ #2 — ExportBackupToFile (internal/modules/backup/actions.go:694)
is what materializes the LOCAL .bak cluster B’s own recover command reads
(recover.go:632-638 hard-requires req.File or req.FromBackup — a real
path on disk; LoadBackupManifest, recover.go:312, reads the manifest.json
AR member with a plain os.Open, no restic involved). ExportBackupToFile
ships row.Manifest verbatim (:730). For a NATIVE row Get resolves this
through loadOne against the backup table and gets the rich JSON. For a
cluster-B-synced FOREIGN backup, Get (service.go:325-379) finds no
backup-table row, falls through to getFromCache (:386-418), which
projects the cache row through cacheEntryToModel (:420-460). That function
manufactures a BackupManifest from exactly the columns BackupCacheEntry
has — ID, Timestamp, Version, TenantName, StackName, Environment,
Checksum, Consistency — and nothing else; its own doc comment says the
quiet part out loud: “the source cluster’s full manifest lives only in ITS
backup table.” This is the “cross-cluster no-file migration” path in its
entirety: backup export <id> on cluster B writes a LOCAL .bak sourced
live from the shared repo (register.go:960-999, Export) — the data bytes
are correct (DumpFile streams the real restic content), but the
manifest.json AR member is the eight-field synthesis above.
Consequences, traced to their exact mechanism. applyMappings
(restore.go:1165-1229) replays manifest.HostMappings/PortMappings; for
the bare synthesis both slices are nil, so the len(manifest.HostMappings) > 0 guard at :1192 is false, the delete-then-insert never runs, and the
overrideHostname call that would honor --override host=OLD:NEW
(:1221) is never reached — hence “silent no-op.” ComputeRecoverPlan
(recover.go:632-820) builds the stack dependency graph by walking
l.manifest.Integrations (:732-746); an empty slice means zero edges, so a
plug that must reconnect to a provider stack (app/plug_required.go:82,
X400994PLGREQ, app/errors.go:1053) never gets wired and install fails
loudly. Variables, StackRecord, and VaultSecrets are dropped the same
way — the recovered stack re-renders from catalogue defaults instead of the
captured render inputs.
One piece of prior art already sits, unused, in the Go port: types.go:787-790
documents ResticBackupManifest as “the manifest written under
.shc/manifest.json inside a restic snapshot” — a straight port of a Python
convention — and restic.go:490-511 (Restic.CreateManifest) builds the
struct in memory. Nothing ever calls it beyond its own declaration; the
write-into-the-snapshot half of the port was dropped. This proposal restores
that intent with the current, richer BackupManifest shape and closes the
loop on the read side, which the Python era apparently never needed to solve
in quite this form.
Options: where does the rich manifest ride
Section titled “Options: where does the rich manifest ride”(a) Tags only. Cram the marshalled BackupManifest JSON into one large
tag value. Rejected: restic tags are transported as individual --tag key=value CLI arguments (restic.go:209-212) and surfaced back as a flat
[]string per snapshot in restic snapshots --json — every consumer in this
codebase, and restic’s own tag UX, treats a tag as a short grep-able label
(today’s 4-5 keys are all ≤ ~40 characters). The rich manifest is unbounded —
Variables map[string]any, Integrations []IntegrationRecord,
HostMappings/PortMappings []map[string]any, and a base64’d
VaultSecrets.EncryptedBundle can each run to several KB per stack — and
JSON’s =, quotes, and newlines would need escaping through both the
daemon’s exec-argv construction (execResticCmd, restic.go:545) and
restic’s tag parser. Worse: SyncCache calls ListSnapshots for the WHOLE
repo on every backup sync (:1130); if the full manifest rode in a tag,
every sync would deserialize every stack’s entire captured state just to read
stack/environment — the exact cheap-index property tags exist for would
be destroyed.
(b) In-snapshot file only, drop the tags. Write manifest.json as a real
backed-up path, stop tagging identity. Rejected: SyncCache’s filtering
(tagMap["stack"]/["environment"]/["tenant"], :1147-1167) is O(1) per
snapshot from one restic snapshots --json call. Without tags, learning
stack/tenant for filtering would require a restic dump PER snapshot —
an O(n) restic-process fan-out on every sync, for a table that today does one
listing call. That regresses SyncCache from an index scan to a full-repo
walk.
Recommendation: (c) hybrid — keep the tags, add one, carry the payload in a file
Section titled “Recommendation: (c) hybrid — keep the tags, add one, carry the payload in a file”Leave the existing 4-5 identity tags exactly as they are (backward compatible
by construction — old snapshots already have precisely this and nothing
more). Add one new tag, manifest_path, whose value is the absolute path,
inside this snapshot, where the full manifest JSON lives. The tag stays a
short string (a filesystem path, tens of bytes) so the fast-index property of
SyncCache’s ListSnapshots scan is untouched; the unbounded payload —
Variables, Integrations, HostMappings, PortMappings, StackRecord,
VaultSecrets, DeploymentConfig — rides as an ordinary file inside the
snapshot, fetched with a targeted restic dump <snapshot> <manifest_path>
(the same primitive ExportBackupToFile already uses at :748 and the mount
subpackage’s restic source already wraps at restic.go:450) exactly once,
lazily, only for snapshots that declare they have one.
Exact convention. Stage the manifest at
<SHC_STATE_HOME>/.shc-manifest/<backup_id>.json — a reserved, hidden
top-level directory, a sibling of deployments/, never nested under any
stack’s deployment path. Pass that path as an EXTRA member of the paths
slice on the SAME restic.CreateSnapshot call that captures the app data
(restic backup already accepts multiple, unrelated absolute paths in one
invocation — nothing new is asked of restic here), so the data and the
manifest land in one atomic snapshot with one snapshot ID. Tag that snapshot
manifest_path=<SHC_STATE_HOME>/.shc-manifest/<backup_id>.json.
Why a tag-carried path instead of a fixed convention cluster B can
compute. The obvious alternative — derive the path purely from backup_id
(already tagged) without a second tag — fails across clusters with different
SHC_STATE_HOME layouts: cluster B has no way to know cluster A’s state-home
root, so it cannot reconstruct the absolute path cluster A actually backed
up. Carrying the resolved path AS a tag sidesteps that guessing game entirely
— cluster B never needs to know anything about cluster A’s filesystem layout,
it just reads the tag and dumps that exact string. This is the one piece of
information that genuinely belongs in a tag: not the payload, but the
payload’s address.
Sequencing change this forces in CreateBackup. Today the rich fields
are built AFTER CreateSnapshot (:908, needing snapshot.ID only for
manifest.Checksum). None of resolveStack, CaptureVariables,
CaptureHostMappings, CaptureIntegrations, CreateStackRecord, or
exportVaultBundle (orchestrator.go:210-230) actually depend on the
snapshot existing — they read the live StackContext and the vault exporter,
both independent of restic. So the manifest build moves BEFORE
CreateSnapshot: resolve the stack, assemble every field except Checksum
(left empty — the snapshot ID literally cannot be known before the snapshot
is taken), marshal, write to the staging path, append that path to the
paths slice passed into ONE CreateSnapshot call. manifest.Checksum is
filled in on the LOCAL row.Manifest copy after the snapshot returns, exactly
as today; the in-repo copy simply omits it — no consumer needs it, because
the reading cluster always trusts snap.ID from restic snapshots --json
(the same field SyncCache already uses for ResticSnapshotID, :1196),
never a self-reported value inside the file. One observable behavior change
falls out of the reordering: a --secrets-password vault-export failure
(service.go:960-970) is now caught BEFORE any data is captured, not after —
today’s code creates the data snapshot first and then ForgetSnapshots it on
a vault failure (:968); under this reordering there is nothing to forget.
Strictly an improvement (fewer wasted restic invocations on a failure that
was always going to abort the backup), but a real change in the sequence of
publisher events an operator might have scripted against.
Clean up the staging file after CreateSnapshot returns (success or
failure) — it is now durable inside the repo (or the whole backup failed and
nothing needs it). A crash between staging and the snapshot call leaves at
most one orphaned few-KB file per crashed attempt (fresh backup_id per
call, so it never recurs); not worth more than a comment today, a periodic
sweep of .shc-manifest/ is a cheap follow-up if it ever matters.
Restore-path safety: the manifest must never reach a live mount
Section titled “Restore-path safety: the manifest must never reach a live mount”RestoreSnapshot (restic.go:348-373) wraps `restic restore
- Structural.
.shc-manifest/<backup_id>.jsonis a sibling ofdeployments/, never inside it.replayRestoredDeployment(actions.go:180-) only copies the subtree whose path TAIL matchesdeployments/<tenant>/<stack>/<env>(:199-227) back onto the live mount — a file living outside that tree is restored into the scratch target (<state_home>/restore/<backup_id>/...) but is never part of what gets copied over the running deployment. This already prevents the worst case (the manifest landing inside a container’s bind mount) as a side effect of the existing replay logic. - Explicit, defense-in-depth. Don’t rely on (1) alone — a caller that
passes an explicit
TargetPath(rollback, the recover orchestrator) skips the replay step entirely and could restore straight onto a path a container reads. Bake a default exclude into the wrapper itself, not into caller-suppliedopts.Exclude:RestoreSnapshotalways appends--exclude '*/.shc-manifest/*'(a glob, so it strips the reserved directory regardless of whichSHC_STATE_HOMEroot prefixes it on this particular restore target — the same cross-cluster-portability reasoning as themanifest_pathtag) UNLESS the caller is the manifest-read path itself, which usesDumpFile/restic dump <snap> <manifest_path>directly and never goes throughRestoreSnapshotat all. The one place that legitimately needs the file reads it by exact path, not through a general restore; every general restore strips it by default.
ExportBackupToFile’s DumpFile(ctx, repo, snapshotID, "/") (:748, used to
build the .bak’s data.tar member) does not currently support exclude
filters (DumpFile has no filter argument, restic.go:450-465), so the
.shc-manifest entry will also appear inside data.tar, duplicating what the
.bak’s dedicated manifest.json AR member already carries
(writeBakArchive, actions.go:889-918). This is accepted as harmless
duplication (a few KB) rather than solved here — filtering dump would need
a new restic-wrapper method; not worth it for a redundant copy of data that’s
already present in the AR manifest slot.
Cache-side persistence: the ent schema delta
Section titled “Cache-side persistence: the ent schema delta”BackupCacheEntry needs a manifest column, mirroring exactly how the primary
backup table already does it (ent/schema/backup.go:57:
field.Text("manifest").Optional().Nillable()):
// ent/schema/backup_cache.go, Fields()field.Text("manifest").Optional().Nillable(),with a hand-written migration in the same style as the two most recent
column additions (migrations/20260713090000_deployment-proxied-ports.sql;
Atlas auto-diff is not usable in this repo, migrations are hand-written):
-- Add column "manifest" to table: "backup_cache"ALTER TABLE `backup_cache` ADD COLUMN `manifest` text NULL;BackupCacheEntry (types.go:827-840) gets a matching Manifest string \json:“manifest,omitempty”`field.SyncCache (:1194-1207) fetches the file at most once per backup_id: when the snapshot's tags carry manifest_pathAND the existing cache row (if any) has no manifest yet, callrestic.DumpFile(ctx, repo, snap.ID, tagMap[“manifest_path”]), store the raw JSON on entry.Manifest. Snapshots are immutable once sealed, so this is a genuine fetch-once-cache-forever — no re-dump on subsequent syncs. When manifest_pathis absent (old-format snapshot) or the dump fails (treat as non-fatal, same best-effort disposition as the existingpublishVaultExportWarning/X200062VLTXSKpattern,orchestrator.go:240-244), leave entry.Manifestempty and fall through to today's behavior. RewirecacheEntryToModel (service.go:420-460): when e.Manifest != "", unmarshal it and use it AS the BackupModel.Manifest, only falling back to the eight-field synthesis when it's empty — that one branch is what makes ExportBackupToFile` ship the real thing for a foreign backup.
CreateBackup’s own local upsertCacheEntry call (:1007-1028) does not
need to also populate backup_cache.manifest for correctness: Get
(:325-379) always prefers the native backup-table row (loadOne, :336)
over the cache projection, so the cache row’s manifest column only matters
once it is the ONLY record of a snapshot on a given cluster — exactly
SyncCache’s case. Populating it from CreateBackup too would be a harmless
symmetry nicety, not a requirement; leave it out to keep the change scoped to
the actual gap.
Secrets-inclusion policy
Section titled “Secrets-inclusion policy”No new secret material is created or exposed by this proposal. exportVaultBundle
(orchestrator.go:210-230) already runs unconditionally inside every
CreateBackup, today, producing a Fernet-encrypted bundle under either the
cluster’s vault master (default) or an explicit --secrets-password
(service.go:950-981); that sealed blob already sits in row.Manifest in
cluster A’s local DB. This proposal does not touch what gets sealed or how —
it only gives the ALREADY-sealed bytes a second storage location (the
in-repo manifest file) alongside the existing one (the local DB row). The
manifest JSON shape (BackupManifest) is identical in both places;
VaultSecrets.EncryptedBundle is []byte ciphertext by construction
(types.go:349-365 — its own comment already argues for inline-in-manifest
transport specifically because “the bundle is already encrypted… so it is
safe to embed in manifest.json”). Plaintext credentials never travel through
either the tag or the file; only ciphertext does, exactly as today’s .bak
export already ships ciphertext.
The one thing worth being explicit about: a MASTER-sealed bundle (the
default, no --secrets-password) is cryptographically useless to a foreign
cluster — cluster B’s vault master is a different key. That is not a new
problem this proposal introduces (the .bak file-export path has always
shipped whatever seal row.Manifest carries, master or password), but making
the manifest — sealed secrets included — ride EVERY shared-repo snapshot by
default, not just explicit --file exports, does mean more ciphertext
permanently at rest in a repo that may be read by operators of multiple
clusters. Recommendation: ship this unconditionally (matches the existing
unconditional local capture, and the “no backcompat, clean breaks” doctrine —
gating it behind a new flag would just be a second flag operators have to
remember alongside --secrets-password), but the operator-facing takeaway
belongs in the CLI docs for backup sync/recover: --secrets-password
at capture time is what makes vault secrets usable on the far side of a
cross-cluster migration; without it, secrets are present but permanently
inert ciphertext, exactly as today.
Backward compatibility
Section titled “Backward compatibility”Old snapshots (pre-this-change) have exactly the 4-5 identity tags and no
manifest_path. SyncCache seeing no manifest_path tag skips the dump
attempt entirely and produces today’s bare synthesis — zero behavior change,
zero errors, matching the manifest format’s existing stated convention of
tolerant additive fields with no strict version gate
(types.go:479-484, the HostBindSkips doc comment: “the manifest format
has no version gate / strict mode, its compat convention is tolerant
additive fields”). This proposal follows that same convention rather than
introducing a new one.
Migration / rollout
Section titled “Migration / rollout”No separate manifest_format version integer is needed. The presence or
absence of the manifest_path tag IS the format marker — it already answers
the one question that matters (“does this snapshot carry an in-repo manifest
file”), and it costs nothing extra to check since SyncCache already parses
every tag into a map. An old daemon binary reading a NEW-format snapshot
degrades gracefully for the same reason in reverse: parseTagMap
(:1237-1250) stores every tag key generically, an old SyncCache simply
never looks for manifest_path and ignores it — no crash, no special
handling required, today’s behavior exactly. If a future format change is
ever needed (a compressed or chunked manifest, say), it gets its own new tag
name (e.g. manifest_path_v2) rather than an integer gate, preserving this
same tolerant-additive pattern. No flag day, no dual-write period, no reader
that needs to understand two schemas at once — this is the cheapest possible
rollout precisely because the mechanism was chosen (tag-addressed file) to
make it so.
Test strategy
Section titled “Test strategy”Hermetic. Extend the existing fake-CommandRunner harness
(create_sync_test.go’s runnerOutput + newTestServiceWithRestic, and the
path-aware dump fixture pattern already proven at actions_test.go:306):
CreateBackup: assert therestic backupinvocation’s argv includes the staged.shc-manifest/<backup_id>.jsonpath alongsidereq.Paths, and that the tag list includesmanifest_path=....SyncCache: asnapshotsfixture whose tags includemanifest_path, a matching path-keyeddumpfixture returning a rich manifest JSON payload (realHostMappings/Integrations/Variables) — assertbackup_cache.manifestis populated and a secondSyncCachecall does NOT re-invokedumpfor the samebackup_id(fetch-once).Get/cacheEntryToModel: assert a cache row with a populatedmanifestcolumn round-trips throughExportBackupToFilewithHostMappings,Integrations, andStackRecordintact — this is the regression test for the actual bug (today this path always returns the eight-field synthesis).- Negative/backward-compat: a
snapshotsfixture with NOmanifest_pathtag still produces the bare synthesis unchanged — pins today’s behavior for old repos. - Restore-side:
RestoreSnapshot’s default--excludeargument is present on every call that isn’t the manifest-read path; a restore against a fake snapshot containing a.shc-manifest/entry does not extract it into the target directory.
Live acceptance. A real two-cluster proof against a shared restic backend
(S3/B2), in the spirit of this program’s existing cross-cluster scratch
proofs: cluster A installs a stack with a custom ingress hostname, a required
plug wired to a provider stack, and --secrets-password set; backup create
against the shared repo; cluster B (no filesystem access to cluster A) runs
backup sync then backup export <id> then recover -f <local.bak>. Green
= the recovered stack keeps its ingress hostname (or honors --override host), the plug reconnects without X400994PLGREQ, and
--secrets-password at capture + the matching --secrets-passwords at
recover successfully decrypt the vault bundle. Keep a matching run WITHOUT
--secrets-password as the regression twin, asserting the DEGRADED marker
surfaces (not a silent gap) and data restores intact.
Effort
Section titled “Effort”- Write-side reorder + staging + tag (
CreateBackup,restic.CreateSnapshotcallers): M — the reorder touches sequencing invariants (vault-export failure now aborts before data capture) more than it touches line count. - Restore-side default exclude (
RestoreSnapshot): S — one wrapper method, one constant. - Cache schema +
SyncCachefetch-once +cacheEntryToModelrewire: M — a real ent field + migration + the fetch-once bookkeeping, but each piece is small and independently testable. - Test suite (hermetic): M, mostly fixture data; live acceptance is S to script given the cross-cluster proof pattern already exists for other features.
- Total: one focused change, no flag day, no dual-schema reader — closer to the shape of the cache-schema-delta proposals already in this program than to a wire-format migration.
Non-goals
Section titled “Non-goals”- No change to the
.bak/.bkgAR archive format.manifest.jsonstays the canonical member name and position; this proposal only changes WHERE the bytes that fill it come from for a foreign backup, not the archive shape itself. - No fix for
ImportBackupFromFile’s data.tar re-snapshot asymmetry (actions.go:793-847snapshots the stageddata.tarFILE itself rather than its extracted tree) — a real, separate quirk in the file-transfer path, orthogonal to the shared-repo no-file path this proposal targets. - No
restic dumpexclude-filter support. The minordata.tarduplication of the manifest file (see Restore-path safety) is accepted, not solved. - No new CLI flag to opt in or out of in-repo manifest capture. Ships unconditionally, matching today’s unconditional local vault-export capture.
- No retroactive backfill of old snapshots. Existing repos stay data-only-restorable via the bare synthesis forever unless re-captured; no tool is proposed to walk an old repo and inject manifests after the fact.
- No integer manifest-format version field. Superseded by the
presence/absence of the
manifest_pathtag, per Migration/rollout above. - No change to how consistency-group members are tagged or grouped
(
consistency_id) — each member already gets its own independent snapshot viagrouping.go:271-275; this proposal rides that unchanged.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01Q4JcrwAZTK1EsKqD6xP7c3