fix(cli): snapshot the blob volume unless blobs live in external S3 - #3184
Draft
larryro wants to merge 4 commits into
Draft
fix(cli): snapshot the blob volume unless blobs live in external S3#3184larryro wants to merge 4 commits into
larryro wants to merge 4 commits into
Conversation
`tale backup` and the pre-deploy snapshot never captured `object-store-data`: SNAPSHOT_VOLUMES predates the object store (blobs then lived in Convex `_storage` inside `convex-data`, which is snapshotted) and was not updated when #3107 moved blobs to MinIO. Every backup of a self-hosted deployment silently lacked its uploads. Add the blob volume to the snapshot set, decided the way the backend decides where blobs live: read `<org>/object-storage/connection.json` from the config volume and compare the `default` tree's endpoint with the bundled store's address (the bootstrap's own bundled-vs-repointed test). A deployment default repointed at external S3 skips the local volume and prints a one-line notice naming endpoint and bucket; organizations with their own bucket are named the same way. Anything short of a readable default connection captures the volume (fail-safe). The blob archive is as large as the store, so it gets its own timeout bound, shared with restore. The bundled endpoint the compose generators hardcode moves into one constant both sites read.
Restore already extracts every manifest volume the CLI snapshots, so the blob volume rides along once it is in SNAPSHOT_VOLUMES; the extract uses the same per-volume bound as the snapshot side. A snapshot taken before blobs were captured, or on a deployment whose blobs live in external S3, carries no blob archive: the listing marks it `without blobs`, the restore says so before asking for confirmation and leaves the blob volume untouched while restoring the rest.
Listing parses manifests with and without an `object-store-data` entry and orders them newest first; verification checks every `*.tar.gz.sha256` sidecar in the snapshot directory rather than a fixed list, so the blob archive is covered without a code change.
The backups page, the self-hosted overview and the Linux install hook described the blob store as deliberately outside the snapshot. State the shipped behaviour: `object-store-data` is captured with the other data volumes when the deployment default is the bundled store; a default repointed at external S3 and organizations with their own bucket are announced by the backup and are the operator's to back up; older snapshots restore without touching the blob volume.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Data loss:
tale backupnever captured uploaded filesBefore.
SNAPSHOT_VOLUMES(tools/cli/src/lib/backup/constants.ts) wasdb-data,convex-data,caddy-data,caddy-config. The blob store's volumeobject-store-data— every uploaded document, chat attachment, audio file and generated media of a self-hosted deployment — was in no snapshot. An operator who rantale backup, copied thebackupsvolume off-host as the docs instruct, and later rebuilt a host from it got their database, config and certificates back and lost every blob, with nothing in the CLI output hinting at it.After.
object-store-datais one of the snapshot volumes and is restored symmetrically. When the deployment's blobs live in an external S3 instead, the local volume is skipped andtale backupprints a one-line notice naming the endpoint and bucket; organizations that bring their own bucket are named the same way. Either way the omission is visible, never silent.Root cause
A regression by omission, not a design decision. The backup feature (#1863, 2026-06-11) predates the object store: blobs then lived in Convex
_storageinsideconvex-data, which is snapshotted. #3107 (2026-08-30) moved blobs to MinIO onobject-store-dataand did not update the snapshot set; the constant's own doc-comment still promised "every project volume that holds non-rederivable state". Today's docs alignment (#3154) then documented the gap as "deliberately skips" — describing the status quo, not a rationale.The LOCAL / BYO-S3 signal — reused, not invented
There is no deployment-level env switch in the CLI: the generated compose hardcodes
OBJECT_STORE_ENDPOINT: http://object-store:9000for both backend tiers and always ships theobject-storeservice. Where blobs live is decided by files the backend resolves (backend/lib/object-store.ts):<slug>/object-storage/connection.json(an org's own bucket) →default/object-storage/connection.json(the deployment default, seeded bydomains/object_storage/bootstrap.tsagainst the bundled store) → fail closed. The bootstrap tells the bundled store from an operator-repointed one by comparing the default connection'sendpointwith the bundled address.The CLI now reads exactly those files from the config volume (
convex-data, mounted atTALE_CONFIG_DIRin the backend tier) through the same pinned alpine helper the backup already uses, and applies the same endpoint comparison. The bundled address the compose generators hardcoded twice is hoisted intoBUNDLED_OBJECT_STORE_ENDPOINT(compose/generators/constants.ts) so the generators and the backup read one constant.object-storeBlobs live in external S3 (<endpoint>, bucket "<bucket>") — not in this snapshot: back that bucket up yourself; the local object-store-data volume is skipped.Blobs of N organization(s) with their own bucket (a, b) live in those buckets — not in this snapshot: back them up under your own contract.Per seam
fix(cli): snapshot the blob volume unless blobs live in external S3—constants.ts(BLOB_VOLUME,CONFIG_VOLUME, blob volume inSNAPSHOT_VOLUMES,archiveTimeoutSeconds()), newinspect-blob-store.ts,create-snapshot.ts(candidate set + notices), bundled-endpoint hoist.Tests (
create-snapshot.test.ts, +6): full local stack → manifest carriesobject-store-data, the inspection mountsconvex-dataread-only, the blob tar gets the wider bound (red on main: the snapshot set lacked the volume); external default → volume skipped, exactly one notice with endpoint + bucket; own-bucket orgs → captured, orgs named; inspection failure, unreadable default file, never-seeded store → captured.fix(cli): restore the blob archive and flag snapshots without one—restore.ts: extract uses the shared per-volume bound; a snapshot with no blob archive prints a notice before confirmation and leaves the volume untouched; the listing marks such snapshots· without blobs.Tests (
restore.test.ts, +3): manifest with blob archive → three wipe+extract runs, blob volume re-created, wider bound (red on main: the allow-list dropped it); older manifest → two runs, notice; listing marker.test(cli): cover listing and verifying snapshots with the blob archive— newlist-snapshots.test.ts(+4) andverify-snapshot.test.ts(+3). Rotation deletes whole snapshot directories (rm -rf /backup/<id>) and is archive-agnostic — verified by reading, nothing for a test to express.docs(cli): say which blobs a backup captures and which are yours— en/de/frself-hosted/operate/backups-and-restore.md(volume table row, the blob paragraph, pause duration, restore of older snapshots),self-hosted/overview.mdcaveat,self-hosted/install/linux-server.mdbackup hook. DE/FR authored natively, UI label matches the shippedDatenresidenz/Résidence des données.Trade-offs worth knowing
tar czfgzips blobs that are already compressed — wasted CPU, roughly 30–50 MB/s. Switching the blob archive to an uncompressed tar is a format change (archive name, verify glob, restore extract) and priced as its own PR; it does not change what is captured.Gates (observed)
bun testintools/cli: 327 pass / 0 fail (345 ran, pre-existing skips); baseline for the three touched files was 17 pass, now 33 (+16).bunx tsc --noEmit(afterbun run generatefor the gitignored embedded files): exit 0.bunx oxlint: exit 0.bun run --filter @tale/docs test: 30 files / 194 tests pass.shinspection script:sh -nclean; executed against a fake<slug>/object-storage/connection.jsontree under dash and busyboxsh(alpine's shell): rows parse, escaped\ninside JSON strings survive, empty tree exits 0.tale backup→tale restoreround-trip on a real stack is the one thing left to observe.Cross-class discoveries (not touched here)
generateDevComposeemits the backend tier withdepends_on: object-storebut defines noobject-storeservice — thetale devstack cannot satisfy that dependency.llm-gateway-data(the sandbox LLM gateway's Bifrost state) is also outside the snapshot set; whether it is rederivable is unassessed.