Commit

Docs: R11 is built: how backups are cut and kept, the restore drill, and a real restore

ARTIFACTS.md gains the flow, the manifest, the meters, the drill and the steps of a restore into the store; SELF_HOSTING.md and the self-hosting guide name the g1t-backups bucket on MinIO, empty until the runner runs.

syntaqxcommitted Parent4b931fdBrowse files
3 files+139−70/3 viewed
+2−1
108108 | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. |
109109 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | the bundled MinIO, bucket `g1t-packages` | Where packages' files are kept: any S3-compatible store. Change the two keys before first start; MinIO is made with them. |
110110 | `S3_PUBLIC_ENDPOINT` | (none) | The store's address as clients reach it. When set, large layers are downloaded from it directly with a signed URL. |
111+| `BACKUP_S3_BUCKET` | `g1t-backups` | The bucket on the same store that nightly repository backups (a `git bundle` of each repository whose branches or tags changed) are kept in. The bundles are cut by g1t's runner, which this installation does not run yet, so the bucket stays empty for now: copy the volumes, as below. |
111112 | `STATUS_PORT` | `8788` | The port the status page is published on |
112113 | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. |
113114 | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. |
186187 | --- | --- |
187188 | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) |
188189 | `g1t_g1t-git` | Your repositories, one bare git repository each |
189−| `g1t_g1t-packages` | Container images' layers and other package files (MinIO) |
190+| `g1t_g1t-packages` | Container images' layers and other package files, and the `g1t-backups` bucket (MinIO) |
190191 | `g1t_g1t-secrets` | The key the site and the git store share |
191192
192193 To back up, stop g1t and copy the volumes:
+127−2
2828 128 MB Worker isolate that buffers each push body twice. Large pushes and imports fail late, without a
2929 message git can show.
3030 5. **No backup, no exit drill.** Cloudflare replicates data, but there is no SLA, no documented export
31− besides git itself, and the self-host git store is not a production fallback yet.
31+ besides git itself, and the self-host git store is not a production fallback yet. Nightly bundles
32+ to R2 and a restore drill are now built (R11, section 9); the fallback store is not (R12).
3233
3334 None of these blocks an invite-only launch. Items 1 and 2 must be answered before billing starts on
3435 2026-10-14, and the fork cleanup must ship before agent pull requests reach thousands a day.
350351
351352 Code in `services/repos` unless named; one migration,
352353 `migrations/0011_artifacts_meters_forks_health.sql` (new columns on `repos`, new tables
353−`artifacts_meters`, `operation_mapping`, `store_health`; additive, no backfill).
354+`artifacts_meters`, `operation_mapping`, `store_health`; additive, no backfill). R11 added
355+`migrations/0013_backups.sql` (a new table, `repo_backups`, and one `operation_mapping` row;
356+additive).
354357
355358 | # | Status | What |
356359 | --- | --- | --- |
364367 | R10 | Built in repos; work unchanged | `divergence` works out the target's side once per target head per isolate (`coalesce.rs`: the head under the refs version, then the history by hash, kept 60 s), and what the target changed between two trees once per pair (10 minutes). `readCommit` and logs by hash come from the cache. Work's fan-out (`after_push`, up to 100 pull requests) is unchanged: its 100 `divergence` calls now cost one walk of the target instead of 100. |
365368 | R7 | Groundwork | `shards.rs`: bindings named in `ARTIFACTS_NAMESPACES` (JSON, binding → namespace; `ARTIFACTS` → `g1t` always there), a repository's namespace kept in its `store` column as `<namespace>/<key>` (no prefix means the `ARTIFACTS` namespace, so every existing key reads the same), new repositories placed by `ARTIFACTS_NEW_REPOS` (comma-separated, spread by an FNV hash of the repository id; names not bound are skipped), forks always in their repository's namespace, `ARTIFACTS_EU_NAMESPACE` reserved for EU residency (no workspace setting yet). Works with only `ARTIFACTS` bound, as today. |
366369 | R8 | Built | `crates/runner/src/clone.rs`: every sandbox clones at `--depth=1` (a full g1t clone took 5.4 s, depth 1 took 3.8 s). Work that merges (catch-up, the merge queue, merge checks, a review's diff) deepens 50, 500, then 5000 commits until the two sides share one, and fetches everything only as the last resort (`share_history`). `G1T_CLONE_DEPTH` (0 or `full` for everything) and `G1T_CLONE_FILTER=blob:none` change it per runner. |
370+| R11 | Built; not yet deployed | Nightly `git bundle` backups to the `g1t-backups` R2 bucket, and a restore drill. Migration `0013_backups.sql` (`repo_backups`, and an `operation_mapping` row). See "R11: backups and the restore drill" below. |
367371
368372 ### R1: reading `scripts/ops/artifacts-usage.mjs`
369373
498502 Moving an existing repository between namespaces is not built (a clone and push, then a `store`
499503 update).
500504
505+### R11: backups and the restore drill
506+
507+Every repository whose refs moved is bundled once a night and kept outside the git store, so a
508+repository can be rebuilt without Artifacts. The flow is in `crates/contracts/src/backups.rs`;
509+the chain, the manifest and the record are in `services/repos/src/backups.rs`.
510+
511+1. **Queued.** At 02:53 UTC (`53 2 * * *` in `services/repos/wrangler.jsonc`) the repos service
512+ queues the repositories that are due, at most `BACKUPS_PER_NIGHT` (200), the longest since
513+ their last backup first. A repository is due when it has never been backed up, when its
514+ `refs_version` went past the one its last backup was cut at, or when a credential that can
515+ push was handed out (`refs_open_until`) after that backup's clone began: a push with such a
516+ credential does not move `refs_version`. Deleted repositories, retired working copies and
517+ pull request working copies (`pulls/…`, whose heads end up in their repository as
518+ `refs/pull/<id>/head`) are not backed up.
519+2. **Claimed.** The runner's five-minute sweep claims `BACKUPS_PER_SWEEP` (4) at a time, with at
520+ most `BACKUPS_RUNNING` (6) running (`claim_backups`), and starts a sandbox for each in
521+ `MODE=backup` (`crates/runner/src/backup.rs`). The sandbox is given the job's id and a token
522+ for it, nothing else; the repos service keeps only the token's hash. It has a 60-minute time
523+ cap. Its time is g1t's: it is not metered to the workspace.
524+3. **Cut.** The sandbox asks for its job (`POST api.g1t.sh/backups/{job}/spec`, the token in
525+ `x-g1t-backup-token`) and gets a read-only credential for the repository in the store (a
526+ `git_access`-style handout, 5 minutes), the bundle's kind, and the commits the last bundle
527+ ended at. It clones with `--mirror` (every ref, never shallow), writes those commits as refs
528+ of its own, and runs `git bundle create --all --not <them>`, then `git bundle verify`.
529+ When the clone has exactly the refs of the last backup, or git finds nothing new to bundle
530+ (a branch deleted, a ref moved to a commit already kept), no bundle is cut and only the refs
531+ are recorded.
532+4. **Sent.** The bundle goes in 32 MiB parts (`PUT /backups/{job}/parts/{n}`), which the API
533+ passes to the repos service and the repos service to an R2 multipart upload; then
534+ `POST /backups/{job}/complete` with every ref, the size, the SHA-256 and the parts. A failure
535+ is `POST /backups/{job}/fail`; a sandbox that dies is failed by the runner. A job is tried 3
536+ times a night; one running past 3 hours is queued again.
537+5. **Recorded.** The manifest gains the entry, and `repo_backups` the refs version the clone began
538+ at, so a push during the backup leaves the repository due the next night.
539+
540+Storage, through the `BlobStore` port in `crates/blobstore` (the adapters packages already used):
541+the `BACKUPS` binding (bucket `g1t-backups`) with `BACKUP_STORE=r2`; any S3-compatible store with
542+`BACKUP_STORE=s3` and `BACKUP_S3_BUCKET` (self-hosted: MinIO). Without either, backups are off and
543+the nightly cron does nothing.
544+
545+```text
546+backups/<repo id>/manifest.json
547+backups/<repo id>/20261006T025300Z-full.bundle
548+backups/<repo id>/20261007T025302Z-incr.bundle
549+```
550+
551+The manifest (version 1) lists `chain`, oldest first, and `previous`, the chain before it. Each
552+entry has `id`, `kind` (`full` or `incremental`), `key` (null when only refs moved),
553+`created_at`, `refs_version`, `refs` (every ref and `HEAD` once it is applied), `prerequisites`,
554+`size` and `sha256`. The first backup is full; the next ones are incremental, their
555+prerequisites the last entry's tips, until the chain holds `BACKUP_FULL_EVERY` (30) incremental
556+ones, when a full one starts a new chain. The chain before that is kept until the next full one
557+replaces it, so the oldest backup kept is about two chains old. Backups of purged repositories
558+are removed the night after (50 a night).
559+
560+Meters: the clone counts as `internal.git.info_refs` and `internal.git.backup_fetch` with the
561+bytes it read, on the repository (they show in `artifacts_usage` and
562+`scripts/ops/artifacts-usage.mjs`). `operation_mapping` has `internal.git.backup_fetch` at 1 for
563+`cost_operations` and 0 for `billable_operations`: an operation on g1t's bill, never on the
564+workspace's. The credential's `binding.create_token` is metered as before.
565+
566+**The restore drill** (read-only against production: SELECTs on `g1t-repos`, reads of
567+`g1t-backups` through Wrangler, `git ls-remote` of the live repository):
568+
569+```sh
570+node scripts/ops/backup-restore-drill.mjs # a repository unchanged since its last backup
571+node scripts/ops/backup-restore-drill.mjs --repo acme/rocket # this one
572+G1T_USER=you G1T_TOKEN=g1t_... node scripts/ops/backup-restore-drill.mjs --repo acme/private-thing
573+```
574+
575+It downloads the manifest and each bundle of the chain, checks each against its size and SHA-256,
576+`git bundle verify`s it, fetches it into a new bare repository without following tags, sets every
577+ref to what the last entry says (and removes the rest), points `HEAD` at the branch at its commit,
578+and runs `git fsck --connectivity-only`. Then it compares every ref with the manifest and with
579+`git ls-remote` of the live repository and prints each difference. Exit 0: every ref matches;
580+1: a difference; 2: it could not run (a bundle that does not match its manifest is this). Picked
581+at random, the repository is one whose refs have not moved since its last backup, so any
582+difference is the backup's. Run it after the first night, then monthly, and after any change to
583+`backups.rs` or `backup.rs`. `--bundles <dir>` reads a local copy of the bucket instead
584+(self-hosted: `mc mirror local/g1t-backups <dir>`), with `--repo-id` and `--live <url or path>`.
585+`npm run test:ops` runs it against bundles cut with git.
586+
587+**A real restore into the store**, as it can be done today:
588+
589+1. Run the drill for the repository with `--keep`. It prints where the restored copy is
590+ (`…/restored.git`). Go on only if every ref matches the manifest; differences from the live
591+ repository are what the restore is for.
592+2. Tell the workspace, and stop the repository's agents and merge queue for the time.
593+3. If its default branch is protected, turn protection off in the repository's settings for the
594+ push: a push that changes a protected branch is declined.
595+4. From the restored copy, push every ref as an owner, with an access token that has
596+ `code:write`:
597+
598+ ```sh
599+ cd /tmp/g1t-drill-…/restored.git
600+ git -c "http.extraHeader=Authorization: Basic $(printf 'you:g1t_...' | base64)" \
601+ push --force https://g1t.sh/acme/rocket.git 'refs/*:refs/*'
602+ ```
603+
604+ It goes through the git door like any push: size limits, push protection (pushes over 24 MiB
605+ per `LARGE_PUSHES`) and the audit log apply, and the refs version moves, so the next night
606+ backs the repository up again. `--force` rewinds refs that went wrong; refs the live
607+ repository has that the backup does not are left alone (`git push --mirror` would delete
608+ them).
609+5. Turn protection back on, and run the drill again: every ref now matches the live repository.
610+
611+When the repository is gone from the store itself (its key answers not found), there is no
612+operator call yet to make an empty repository under an existing row's key; that is part of R12.
613+
614+To deploy: make the bucket (`npx wrangler r2 bucket create g1t-backups`, a setup step of `repos`
615+in `deploy/stack.jsonc`), then migration 0013, then `g1t-repos` (the `BACKUPS` binding and the
616+new cron), `g1t-api` (the `/backups/` door), and `g1t-runner` (a new image: the `backup` mode).
617+Until the runner is out, queued backups wait; nothing fails. Set `BACKUPS_PER_SWEEP` to `0` on the
618+runner to stop starting them.
619+
620+What is not covered: a pull request's working copy while its pull request is open (its head is
621+kept in the repository only once the working copy is retired), and anything that is not a git
622+ref (issues, pull requests and the rest live in D1, which has its own Time Travel). Every backup
623+clones the whole repository, so a night reads each changed repository in full from the store;
624+incremental bundles save storage, not reads.
625+
501626 ### Deploy order and what to watch
502627
503628 1. Migration 0011 (the deploy tool applies migrations first). `forks_of` reads `retired_at`, so
+10−4
8484 | **Static Assets** | `apps/web` (Vite plugin build), `apps/docs`, `apps/sudo` (`run_worker_first`) | thin | workerd serves them. |
8585 | **`placement`, `observability`, routes, custom domains** | every `wrangler.jsonc` | config only | Dropped by `deploy/self-host/configs.mjs`. |
8686 | **`cf-ray`** | Used as an audit request id, with a fallback: `services/repos/src/run_access.rs:131`, `apps/api/src/audit.rs:37` | thin | Falls back already. |
87−| **R2** | `services/packages` (`BLOBS`: container layers and other package files, `src/store/r2.rs`), the API's Actions cache (`ACTIONS_CACHE`), the runner's downloads | thin | **S3-compatible storage**: the packages service's `BlobStore` port has an S3 adapter (`src/store/s3.rs`, SigV4 over fetch), run against MinIO in the compose file. |
87+| **R2** | `services/packages` (`BLOBS`: container layers and other package files), `services/repos` (`BACKUPS`: nightly backup bundles), the API's Actions cache (`ACTIONS_CACHE`), the runner's downloads | thin | **S3-compatible storage**: the `BlobStore` port in `crates/blobstore` has an R2 adapter and an S3 one (`s3.rs`, SigV4 over fetch); each service names its own bucket (`BLOB_STORE`/`S3_BUCKET` for packages, `BACKUP_STORE`/`BACKUP_S3_BUCKET` for backups), run against MinIO in the compose file. |
8888 | **Not used** | Hyperdrive, Workflows, Analytics Engine, Browser Rendering, Images, Turnstile, Secrets Store, `connect()`, HTMLRewriter, `request.cf` | — | — |
8989
9090 ### By service
102102 | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) |
103103 | `apps/status` | TS Worker | Email Sending, cron; bound only to billing | Runs in a process of its own (`status.sh`), so it stays up when the site does not |
104104 | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim |
105−| `services/repos` | Rust | **Artifacts**, Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store. Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only |
105+| `services/repos` | Rust | **Artifacts**, **R2** (`BACKUPS`), Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store, and backups to MinIO's `g1t-backups` bucket (`BACKUP_STORE=s3`). Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only. Its nightly cron queues backups, but bundles are cut by the runner, which is off in phase 1: none are made yet |
106106 | `services/work` | Rust | Queue consumer | Runs unchanged |
107107 | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to |
108108 | `services/projects` | TS | Queue consumer | Runs unchanged |
381381 a backfill a self-hoster's start can run.
382382 - **Backups.** Phase 1: stop, then tar the `g1t-data` and `g1t-git`
383383 volumes (documented in the guide). Phase 2: online backups with
384− `sqlite3 .backup` per database and `git bundle` or rsync of the bare
385− repositories, or Litestream for continuous replication.
384+ `sqlite3 .backup` per database, or Litestream for continuous
385+ replication. The repositories get hosted g1t's nightly bundles
386+ (docs/ARTIFACTS.md, R11) once the runner runs: the storage is already
387+ configured (`BACKUP_STORE=s3`, the `g1t-backups` bucket that
388+ `minio-setup` makes, `BACKUP_S3_BUCKET` to choose another), and the
389+ restore drill reads a copy of that bucket
390+ (`mc mirror local/g1t-backups ./copy`, then
391+ `node scripts/ops/backup-restore-drill.mjs --bundles ./copy --repo-id <id> --live <bare repository>`).
386392
387393 ## 3. Phase 1: what works today
388394