OXYGENHUB
# 62cdf34daec1 oxygenhub / docs / backups.md
13.0 KiB · Markdown · last changed Backups API: settings, TEST, back up now and runs 22 days ago renderedsource blame history raw
6dbd999a Andy McCall ↶ 1
# Backups: per site, organisation, user and repository
2
35fea423 Andy McCall ↶ 3
Status: built 2026-10-13/14 (oxygenhub#169): user and organisation
4
settings, the hourly and daily schedule with "back up now" and TEST,
5
directory destinations and keyless GCS destinations, every repository's
6
and wiki's store byte for byte, the records as JSON, retention, failure
5ef6cf72 Andy McCall ↶ 7
notices, and restore (`oxygenhub admin backup run|restore`). Since
8
2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and
d3b048b2 Andy McCall ↶ 9
release assets travel with the stores; Admin › Backups (#176);
7b9f4570 Andy McCall ↶ 10
per-repository settings (#177); S3 destinations, keylessly (#178).
6dbd999a Andy McCall ↶ 11
12
## 1. Why
13
14
A forge is where people's work lives. Before Andy moves his GitHub
15
repositories here, and before the hub moves to GCP, every owner must be
16
able to say "my repositories are copied, every hour, to a place I
17
control, and I can bring them back". GitHub leaves this to its users
18
(clone everything somewhere, and lose the issues and reviews); GitLab
19
backs up the whole instance or nothing. Here an owner backs up their own
20
repositories, with everything around them, to their own bucket, without a
21
key file anywhere.
22
23
## 2. What a backup holds
24
25
For each repository the owner has (a user's own, or an organisation's):
26
27
- **Its objects:** the store's content-addressed objects (changes,
28
  blobs, chunks, states, identities, signatures, seals, policies). An
29
  object never changes once written, so a run sends only the objects the
30
  destination does not already have: the first run copies everything,
31
  later ones only what arrived since. Nothing is re-encoded, so a
32
  restored repository is byte for byte the original, every signature
33
  still verifying.
34
- **Its refs at that moment:** bookmarks, tags and the trust policy's
35
  serial, as a small manifest per run. Each run's manifest is kept, so a
36
  restore can go back to any run that is still kept.
37
- **Its hub records, as JSON:** issues and their comments and events;
38
  proposals, their reviews, threads and approvals; labels, milestones,
39
  projects; releases and their assets (assets are blobs, sent like
40
  objects); the wiki (a repository of its own, backed up with it); and
41
  repository settings and webhooks. Secret values (webhook secrets, Flow
42
  secrets, tokens) are never written, only their names, so a restored
43
  repository asks for them again.
44
- **Its owner:** for an organisation, its members, teams and their
45
  permissions; for a user, their profile and linked identity keys
46
  (public keys only).
47
48
Not included: Flow runs' logs and artifacts, which belong to the run, not
49
the repository (a later choice); caches; anything of another owner.
50
51
## 3. Where it goes
52
53
A **destination** is one of:
54
55
- **A GCS bucket, keylessly.** The hub is already an OIDC issuer (spec
35fea423 Andy McCall ↶ 56
  §13.5). Built: the token lives 10 minutes, its audience is the
57
  provider's name over https (Google's default), the exchange asks for
58
  `devstorage.read_write`, and the access token is exchanged again five
59
  minutes before it runs out. A refusal says what to grant. For a backup it mints a short-lived token with
6dbd999a Andy McCall ↶ 60
  `sub = backup:OWNER` and audience the owner's workload identity
61
  provider. Google's STS exchanges it for a short-lived access token,
62
  which writes to the bucket. Nothing secret is stored: no service
63
  account key exists (Andy's organisation blocks them anyway), and if the
64
  hub leaks a token it lasts minutes and is scoped to that owner's
65
  prefix. Setup is §6.
66
- **A directory on the hub's host**, for the site administrator: the
67
  directories an owner may choose are listed in `hub.toml`
68
  (`[backups] directories = ["/srv/backups"]`), so nobody can write
69
  anywhere else on the host. Each owner gets `DIR/OWNER/`.
7b9f4570 Andy McCall ↶ 70
- **An S3 bucket, keylessly** (built 2026-10-14, oxygenhub#178): the hub
71
  mints a 10-minute token with `sub = backup:OWNER` and audience
72
  `sts.amazonaws.com`, AWS STS's AssumeRoleWithWebIdentity gives an
73
  hour's credentials of the IAM role the owner names (exchanged again
74
  five minutes before they run out), and each request is signed with
75
  Signature Version 4 (checked against AWS's published example). The
76
  setting holds the bucket, its region and the role's ARN; setup is §6.
77
- **Later:** another hub.
6dbd999a Andy McCall ↶ 78
79
Layout at a destination (`PREFIX` = the owner's chosen prefix):
80
81
```
82
PREFIX/objects/<repository id>/<object hash>    content-addressed, written once
83
PREFIX/runs/<run time>/<repository id>.json     the refs at that run
84
PREFIX/runs/<run time>/records/<repository id>.json
85
PREFIX/runs/<run time>/owner.json
86
PREFIX/runs/<run time>/DONE                     written last: the run is whole
87
```
88
89
A run without `DONE` was interrupted; a restore ignores it.
90
91
## 4. When, how long and how it went
92
93
- **How often:** hourly to begin with (off, hourly, daily); finer
94
  later. The hub's scheduler runs each due backup, at most two at once,
95
  at low priority, spread across the hour so owners' runs do not
96
  collide.
97
- **Back up now:** a button, and `POST /api/v1/{user|orgs/NAME}/backups/run`.
98
- **Kept:** runs' manifests for 30 days by default (7, 30 or 90).
99
  Objects no kept run refers to are pruned weekly. With GCS, the
100
  bucket's own versioning and lifecycle rules work too.
101
- **Status:** the last run's start, length, objects and bytes sent,
102
  repositories, and outcome (ok, partial with the repositories that
103
  failed and why, or failed with the reason, such as "GCS refused the
104
  token: check the provider's attribute condition"). The last 50 runs
105
  are listed. A failure notifies the owner (an organisation's owners)
106
  in the inbox, once per kind of failure until it succeeds again.
107
108
## 5. Who sets it, and where
109
110
- **A user:** Settings › Backups, for the repositories they own.
111
- **An organisation's owners:** the organisation's Settings › Backups.
1e8a6e8b Andy McCall ↶ 112
- **The site administrator:** Admin › Backups (built 2026-10-14,
113
  oxygenhub#176), a table of every owner's backup (how often, where, how
114
  long runs are kept, the repositories it covers, the last run with its
115
  outcome, and the runs that failed in the last day), and the owners of
116
  repositories no backup covers. They may not read an owner's bucket, only
117
  see that it ran. The site's own backup runs on its host, outside the
118
  hub, and the page says so.
d3b048b2 Andy McCall ↶ 119
- **A repository** (built 2026-10-14, oxygenhub#177): its Settings ›
120
  Backup, or `PUT /repos/{owner}/{name}/backup {"backup": "owner" |
121
  "hourly" | "daily" | "off"}`, by its owners and admins (migration
122
  0062). Left out, it is in no new run. With its own schedule, its
123
  owner's backup runs at least that often, and runs even when the owner's
124
  own schedule is off (taking only the repositories that asked); a run
125
  sends only what changed and keeps every included repository, so each
126
  run stays whole and restores everything. A run asked for by hand takes
127
  every repository not left out.
6dbd999a Andy McCall ↶ 128
2e9188ca Andy McCall ↶ 129
The API mirrors the pages (built 2026-10-14, oxygenhub#179; scope
130
`repo:admin`, an organisation's owners only): `GET/PUT /api/v1/user/backups`
131
and `GET/PUT /api/v1/orgs/{name}/backups` (the setting, checked as the page
132
checks it, with where its runs go and the newest runs; the body takes the
133
page's fields, `keep_days` as a number), `POST …/backups/test` (the probe),
134
`POST …/backups/run` (202: back up now, in the background) and
6dbd999a Andy McCall ↶ 135
`GET …/backups/runs`. `oh backup status|run|restore` follows.
136
137
## 6. Setting up a GCS destination (for Andy, by hand)
138
139
Once per GCP project:
140
141
```
142
PROJECT=doomsdayone-oxy
143
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
144
gcloud iam workload-identity-pools create oxygenhub \
145
  --project=$PROJECT --location=global --display-name="OxygenHub"
146
gcloud iam workload-identity-pools providers create-oidc oxygenhub-io \
147
  --project=$PROJECT --location=global --workload-identity-pool=oxygenhub \
148
  --issuer-uri=https://oxygenhub.io \
149
  --attribute-mapping=google.subject=assertion.sub \
150
  --attribute-condition="assertion.sub.startsWith('backup:')"
151
```
152
153
Once per owner and bucket (here `andymccall`):
154
155
```
156
gcloud storage buckets create gs://andymccall-oxygen-backup \
157
  --project=$PROJECT --location=europe-west2 \
158
  --uniform-bucket-level-access --public-access-prevention
159
gcloud storage buckets update gs://andymccall-oxygen-backup --versioning
160
gcloud storage buckets add-iam-policy-binding gs://andymccall-oxygen-backup \
161
  --role=roles/storage.objectAdmin \
162
  --member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall"
163
```
164
35fea423 Andy McCall ↶ 165
**Done for doomsdayone-oxy on 2026-10-13:** the pool `oxygenhub`, the
166
provider `oxygenhub-io` and the buckets `andymccall-oxygen-backup` and
167
`doomsdayone-oxygen-backup`, each granted to its own `backup:` subject.
168
6dbd999a Andy McCall ↶ 169
Then, in Settings › Backups: destination GCS, bucket
170
`andymccall-oxygen-backup`, provider
171
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`,
172
and TEST, which exchanges a token and writes and deletes a probe object.
173
An organisation does the same with `backup:ORG`. Only that principal can
174
write the bucket; the hub never holds a key.
175
7b9f4570 Andy McCall ↶ 176
### 6.1 Setting up an S3 destination (for Andy, by hand)
177
178
Once per AWS account, the hub as an identity provider (AWS fetches its
179
keys from `https://oxygenhub.io/.well-known/openid-configuration`):
180
181
```
182
aws iam create-open-id-connect-provider \
183
  --url https://oxygenhub.io --client-id-list sts.amazonaws.com
184
```
185
186
Once per owner and bucket (here `andymccall`, in `eu-west-2`):
187
188
```
189
B=andymccall-oxygen-backup; ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
190
aws s3api create-bucket --bucket $B --region eu-west-2 \
191
  --create-bucket-configuration LocationConstraint=eu-west-2
192
aws s3api put-bucket-versioning --bucket $B --versioning-configuration Status=Enabled
193
aws s3api put-public-access-block --bucket $B --public-access-block-configuration \
194
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
195
cat > trust.json <<JSON
196
{"Version": "2012-10-17", "Statement": [{"Effect": "Allow",
197
  "Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/oxygenhub.io"},
198
  "Action": "sts:AssumeRoleWithWebIdentity",
199
  "Condition": {"StringEquals": {"oxygenhub.io:aud": "sts.amazonaws.com",
200
                                 "oxygenhub.io:sub": "backup:andymccall"}}}]}
201
JSON
202
cat > policy.json <<JSON
203
{"Version": "2012-10-17", "Statement": [
204
  {"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::$B"},
205
  {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
206
   "Resource": "arn:aws:s3:::$B/*"}]}
207
JSON
208
aws iam create-role --role-name oxygenhub-backup-andymccall --assume-role-policy-document file://trust.json
209
aws iam put-role-policy --role-name oxygenhub-backup-andymccall --policy-name backup --policy-document file://policy.json
210
```
211
212
Then, in Settings › Backups: destination S3, the bucket, region
213
`eu-west-2`, role `arn:aws:iam::ACCOUNT:role/oxygenhub-backup-andymccall`,
214
SAVE and TEST. `s3:ListBucket` matters: without it S3 answers a missing
215
object with 403 rather than 404, and every run fails. No access key exists
216
anywhere.
217
6dbd999a Andy McCall ↶ 218
## 7. Restore
219
5ef6cf72 Andy McCall ↶ 220
**As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR
221
[--run AT]` into a hub that lacks the repositories: each store and wiki
222
byte for byte (every file checked against its hash), then the
223
repository's row with its settings as they were (visibility, default
224
bookmark, Flow and Diffusion settings and the rest), then its records.
225
Each run's records carry `_names`, the username, organisation or
226
`org/team` behind every account, team and organisation id their rows
227
point at, so the restore finds them again by name on any hub. Tables go
228
in parents first; every row gets a new id and every reference follows
229
it. A reference to an account this hub lacks is left blank where the
230
column allows it (the author's name stays in the row) and the row left
231
out where it does not (an assignment, a review request); the restore
232
says how many. Webhooks come back without their secrets, each named to be
233
entered again. Release assets, which live beside the stores rather than
234
in them, are sent once each as blobs and written back checked against
235
their hash. Search is rebuilt for what came back. Left out on purpose:
236
inboxes (notifications), webhook deliveries, runners' declines.
237
238
The design as first written:
239
6dbd999a Andy McCall ↶ 240
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]`
241
(or a directory) on any hub:
242
243
1. reads the run's manifests (the latest whole run by default);
244
2. creates each repository, or refuses an existing one unless `--into`
245
   names a new owner;
246
3. copies its objects, verifying every hash, and sets its refs;
247
4. imports its records, then reports what needs re-entering (secret
248
   values, webhook secrets).
249
250
A restore into a fresh hub, compared with the original, is the end-to-end
251
test (BK2), as is restoring one repository from a run before a bad push.
252
253
## 8. Order of work
254
255
1. **BK2:** settings for users and organisations, the scheduler and
256
   "back up now", objects, manifests and records to a directory
257
   destination, status and notifications, and restore with its
258
   end-to-end test.
259
2. **BK3:** the GCS destination with the keyless exchange (tested against
260
   a fake STS and a fake GCS), and TEST.
7b9f4570 Andy McCall ↶ 261
3. Admin › Backups, then per-repository settings, then S3: all built
262
   2026-10-14 (#176, #177, #178).