OXYGENHUB

docs: backups per site, organisation, user and repository, designed (refs #169) 6dbd999ad794

Hourly backups that a user and an organisation set, to a GCS bucket
reached keylessly (the hub's OIDC issuer, sub backup:OWNER, exchanged
through workload identity federation, so no key exists) or a directory
the site allows; content-addressed objects sent once, each run's refs and
the hub's records (never secret values), a DONE marker per whole run,
status and failure notices, and restore. The GCS setup Andy runs once is
written out.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 21:10 UTC
Signature
✔ signed by Andy McCall
Hash
6dbd999ad794df9d95fd17ad3b8357b64e6b0c3d29087d82f8d505f120e08282
Depends on
617c2840446c 93be9ce4653e d1727c573f30
Depended on by
9041db4e4d92 8aa5ef384ef1 026a76d251ce 2e9188ca4f98 7b9f4570daf5 d3b048b27277 1e8a6e8bb91b 5ef6cf72cd19 35fea4237cc9 d7d182467382
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
2 atoms, 1 tree operation, 2 files

Browse the tree at this change

added docs/backups.md +167 −0
1 +
# Backups: per site, organisation, user and repository
2 +
3 +
Status: design, 2026-10-13 (oxygenhub#169). Hourly backups configured by a
4 +
user and by an organisation come first; finer-grained ones (per
5 +
repository, more often than hourly) later. The site's own backup
6 +
(everything that runs a hub, #168) is an operations script today
7 +
(`~/backups/oxygenhub/backup.sh`); Admin › Backups shows it later.
8 +
9 +
## 1. Why
10 +
11 +
A forge is where people's work lives. Before Andy moves his GitHub
12 +
repositories here, and before the hub moves to GCP, every owner must be
13 +
able to say "my repositories are copied, every hour, to a place I
14 +
control, and I can bring them back". GitHub leaves this to its users
15 +
(clone everything somewhere, and lose the issues and reviews); GitLab
16 +
backs up the whole instance or nothing. Here an owner backs up their own
17 +
repositories, with everything around them, to their own bucket, without a
18 +
key file anywhere.
19 +
20 +
## 2. What a backup holds
21 +
22 +
For each repository the owner has (a user's own, or an organisation's):
23 +
24 +
- **Its objects:** the store's content-addressed objects (changes,
25 +
  blobs, chunks, states, identities, signatures, seals, policies). An
26 +
  object never changes once written, so a run sends only the objects the
27 +
  destination does not already have: the first run copies everything,
28 +
  later ones only what arrived since. Nothing is re-encoded, so a
29 +
  restored repository is byte for byte the original, every signature
30 +
  still verifying.
31 +
- **Its refs at that moment:** bookmarks, tags and the trust policy's
32 +
  serial, as a small manifest per run. Each run's manifest is kept, so a
33 +
  restore can go back to any run that is still kept.
34 +
- **Its hub records, as JSON:** issues and their comments and events;
35 +
  proposals, their reviews, threads and approvals; labels, milestones,
36 +
  projects; releases and their assets (assets are blobs, sent like
37 +
  objects); the wiki (a repository of its own, backed up with it); and
38 +
  repository settings and webhooks. Secret values (webhook secrets, Flow
39 +
  secrets, tokens) are never written, only their names, so a restored
40 +
  repository asks for them again.
41 +
- **Its owner:** for an organisation, its members, teams and their
42 +
  permissions; for a user, their profile and linked identity keys
43 +
  (public keys only).
44 +
45 +
Not included: Flow runs' logs and artifacts, which belong to the run, not
46 +
the repository (a later choice); caches; anything of another owner.
47 +
48 +
## 3. Where it goes
49 +
50 +
A **destination** is one of:
51 +
52 +
- **A GCS bucket, keylessly.** The hub is already an OIDC issuer (spec
53 +
  §13.5). For a backup it mints a short-lived token with
54 +
  `sub = backup:OWNER` and audience the owner's workload identity
55 +
  provider. Google's STS exchanges it for a short-lived access token,
56 +
  which writes to the bucket. Nothing secret is stored: no service
57 +
  account key exists (Andy's organisation blocks them anyway), and if the
58 +
  hub leaks a token it lasts minutes and is scoped to that owner's
59 +
  prefix. Setup is §6.
60 +
- **A directory on the hub's host**, for the site administrator: the
61 +
  directories an owner may choose are listed in `hub.toml`
62 +
  (`[backups] directories = ["/srv/backups"]`), so nobody can write
63 +
  anywhere else on the host. Each owner gets `DIR/OWNER/`.
64 +
- **Later:** S3 (the same keyless exchange with AWS STS), and another hub.
65 +
66 +
Layout at a destination (`PREFIX` = the owner's chosen prefix):
67 +
68 +
```
69 +
PREFIX/objects/<repository id>/<object hash>    content-addressed, written once
70 +
PREFIX/runs/<run time>/<repository id>.json     the refs at that run
71 +
PREFIX/runs/<run time>/records/<repository id>.json
72 +
PREFIX/runs/<run time>/owner.json
73 +
PREFIX/runs/<run time>/DONE                     written last: the run is whole
74 +
```
75 +
76 +
A run without `DONE` was interrupted; a restore ignores it.
77 +
78 +
## 4. When, how long and how it went
79 +
80 +
- **How often:** hourly to begin with (off, hourly, daily); finer
81 +
  later. The hub's scheduler runs each due backup, at most two at once,
82 +
  at low priority, spread across the hour so owners' runs do not
83 +
  collide.
84 +
- **Back up now:** a button, and `POST /api/v1/{user|orgs/NAME}/backups/run`.
85 +
- **Kept:** runs' manifests for 30 days by default (7, 30 or 90).
86 +
  Objects no kept run refers to are pruned weekly. With GCS, the
87 +
  bucket's own versioning and lifecycle rules work too.
88 +
- **Status:** the last run's start, length, objects and bytes sent,
89 +
  repositories, and outcome (ok, partial with the repositories that
90 +
  failed and why, or failed with the reason, such as "GCS refused the
91 +
  token: check the provider's attribute condition"). The last 50 runs
92 +
  are listed. A failure notifies the owner (an organisation's owners)
93 +
  in the inbox, once per kind of failure until it succeeds again.
94 +
95 +
## 5. Who sets it, and where
96 +
97 +
- **A user:** Settings › Backups, for the repositories they own.
98 +
- **An organisation's owners:** the organisation's Settings › Backups.
99 +
- **The site administrator:** Admin › Backups, a table of every owner's
100 +
  backup (destination, schedule, last run, outcome) and the site's own
101 +
  backup. They may not read an owner's bucket, only see that it ran.
102 +
- **A repository** (later): opt a repository out of its owner's backup,
103 +
  or give it its own schedule.
104 +
105 +
The API mirrors the pages: `GET/PUT /api/v1/user/backups`,
106 +
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and
107 +
`GET …/backups/runs`. `oh backup status|run|restore` follows.
108 +
109 +
## 6. Setting up a GCS destination (for Andy, by hand)
110 +
111 +
Once per GCP project:
112 +
113 +
```
114 +
PROJECT=doomsdayone-oxy
115 +
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
116 +
gcloud iam workload-identity-pools create oxygenhub \
117 +
  --project=$PROJECT --location=global --display-name="OxygenHub"
118 +
gcloud iam workload-identity-pools providers create-oidc oxygenhub-io \
119 +
  --project=$PROJECT --location=global --workload-identity-pool=oxygenhub \
120 +
  --issuer-uri=https://oxygenhub.io \
121 +
  --attribute-mapping=google.subject=assertion.sub \
122 +
  --attribute-condition="assertion.sub.startsWith('backup:')"
123 +
```
124 +
125 +
Once per owner and bucket (here `andymccall`):
126 +
127 +
```
128 +
gcloud storage buckets create gs://andymccall-oxygen-backup \
129 +
  --project=$PROJECT --location=europe-west2 \
130 +
  --uniform-bucket-level-access --public-access-prevention
131 +
gcloud storage buckets update gs://andymccall-oxygen-backup --versioning
132 +
gcloud storage buckets add-iam-policy-binding gs://andymccall-oxygen-backup \
133 +
  --role=roles/storage.objectAdmin \
134 +
  --member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall"
135 +
```
136 +
137 +
Then, in Settings › Backups: destination GCS, bucket
138 +
`andymccall-oxygen-backup`, provider
139 +
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`,
140 +
and TEST, which exchanges a token and writes and deletes a probe object.
141 +
An organisation does the same with `backup:ORG`. Only that principal can
142 +
write the bucket; the hub never holds a key.
143 +
144 +
## 7. Restore
145 +
146 +
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]`
147 +
(or a directory) on any hub:
148 +
149 +
1. reads the run's manifests (the latest whole run by default);
150 +
2. creates each repository, or refuses an existing one unless `--into`
151 +
   names a new owner;
152 +
3. copies its objects, verifying every hash, and sets its refs;
153 +
4. imports its records, then reports what needs re-entering (secret
154 +
   values, webhook secrets).
155 +
156 +
A restore into a fresh hub, compared with the original, is the end-to-end
157 +
test (BK2), as is restoring one repository from a run before a bad push.
158 +
159 +
## 8. Order of work
160 +
161 +
1. **BK2:** settings for users and organisations, the scheduler and
162 +
   "back up now", objects, manifests and records to a directory
163 +
   destination, status and notifications, and restore with its
164 +
   end-to-end test.
165 +
2. **BK3:** the GCS destination with the keyless exchange (tested against
166 +
   a fake STS and a fake GCS), and TEST.
167 +
3. Admin › Backups, then per-repository settings, then S3.
modified docs/spec.md +4 −0
…
579 unchanged lines
580 580
- **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values.
581 581
- **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`.
582 582
583 +
### 13.5.1 Backups (designed 2026-10-13, oxygenhub#169)
584 +
585 +
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
586 +
583 587
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
584 588
585 589
Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow.
…
166 unchanged lines