OXYGENHUB
# d3b048b27277 oxygenhub / docs / backups.md
10.3 KiB · Markdown · last changed Backups per repository: leave one out, or give it its own schedule 22 days ago renderedsource blame history raw
1
# Backups: per site, organisation, user and repository
2
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
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
9
release assets travel with the stores; Admin › Backups (#176);
10
per-repository settings (#177). Next: S3.
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
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
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/`.
70
- **Later:** S3 (the same keyless exchange with AWS STS), and another hub.
71
72
Layout at a destination (`PREFIX` = the owner's chosen prefix):
73
74
```
75
PREFIX/objects/<repository id>/<object hash>    content-addressed, written once
76
PREFIX/runs/<run time>/<repository id>.json     the refs at that run
77
PREFIX/runs/<run time>/records/<repository id>.json
78
PREFIX/runs/<run time>/owner.json
79
PREFIX/runs/<run time>/DONE                     written last: the run is whole
80
```
81
82
A run without `DONE` was interrupted; a restore ignores it.
83
84
## 4. When, how long and how it went
85
86
- **How often:** hourly to begin with (off, hourly, daily); finer
87
  later. The hub's scheduler runs each due backup, at most two at once,
88
  at low priority, spread across the hour so owners' runs do not
89
  collide.
90
- **Back up now:** a button, and `POST /api/v1/{user|orgs/NAME}/backups/run`.
91
- **Kept:** runs' manifests for 30 days by default (7, 30 or 90).
92
  Objects no kept run refers to are pruned weekly. With GCS, the
93
  bucket's own versioning and lifecycle rules work too.
94
- **Status:** the last run's start, length, objects and bytes sent,
95
  repositories, and outcome (ok, partial with the repositories that
96
  failed and why, or failed with the reason, such as "GCS refused the
97
  token: check the provider's attribute condition"). The last 50 runs
98
  are listed. A failure notifies the owner (an organisation's owners)
99
  in the inbox, once per kind of failure until it succeeds again.
100
101
## 5. Who sets it, and where
102
103
- **A user:** Settings › Backups, for the repositories they own.
104
- **An organisation's owners:** the organisation's Settings › Backups.
105
- **The site administrator:** Admin › Backups (built 2026-10-14,
106
  oxygenhub#176), a table of every owner's backup (how often, where, how
107
  long runs are kept, the repositories it covers, the last run with its
108
  outcome, and the runs that failed in the last day), and the owners of
109
  repositories no backup covers. They may not read an owner's bucket, only
110
  see that it ran. The site's own backup runs on its host, outside the
111
  hub, and the page says so.
112
- **A repository** (built 2026-10-14, oxygenhub#177): its Settings ›
113
  Backup, or `PUT /repos/{owner}/{name}/backup {"backup": "owner" |
114
  "hourly" | "daily" | "off"}`, by its owners and admins (migration
115
  0062). Left out, it is in no new run. With its own schedule, its
116
  owner's backup runs at least that often, and runs even when the owner's
117
  own schedule is off (taking only the repositories that asked); a run
118
  sends only what changed and keeps every included repository, so each
119
  run stays whole and restores everything. A run asked for by hand takes
120
  every repository not left out.
121
122
The API mirrors the pages: `GET/PUT /api/v1/user/backups`,
123
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and
124
`GET …/backups/runs`. `oh backup status|run|restore` follows.
125
126
## 6. Setting up a GCS destination (for Andy, by hand)
127
128
Once per GCP project:
129
130
```
131
PROJECT=doomsdayone-oxy
132
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)')
133
gcloud iam workload-identity-pools create oxygenhub \
134
  --project=$PROJECT --location=global --display-name="OxygenHub"
135
gcloud iam workload-identity-pools providers create-oidc oxygenhub-io \
136
  --project=$PROJECT --location=global --workload-identity-pool=oxygenhub \
137
  --issuer-uri=https://oxygenhub.io \
138
  --attribute-mapping=google.subject=assertion.sub \
139
  --attribute-condition="assertion.sub.startsWith('backup:')"
140
```
141
142
Once per owner and bucket (here `andymccall`):
143
144
```
145
gcloud storage buckets create gs://andymccall-oxygen-backup \
146
  --project=$PROJECT --location=europe-west2 \
147
  --uniform-bucket-level-access --public-access-prevention
148
gcloud storage buckets update gs://andymccall-oxygen-backup --versioning
149
gcloud storage buckets add-iam-policy-binding gs://andymccall-oxygen-backup \
150
  --role=roles/storage.objectAdmin \
151
  --member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall"
152
```
153
154
**Done for doomsdayone-oxy on 2026-10-13:** the pool `oxygenhub`, the
155
provider `oxygenhub-io` and the buckets `andymccall-oxygen-backup` and
156
`doomsdayone-oxygen-backup`, each granted to its own `backup:` subject.
157
158
Then, in Settings › Backups: destination GCS, bucket
159
`andymccall-oxygen-backup`, provider
160
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`,
161
and TEST, which exchanges a token and writes and deletes a probe object.
162
An organisation does the same with `backup:ORG`. Only that principal can
163
write the bucket; the hub never holds a key.
164
165
## 7. Restore
166
167
**As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR
168
[--run AT]` into a hub that lacks the repositories: each store and wiki
169
byte for byte (every file checked against its hash), then the
170
repository's row with its settings as they were (visibility, default
171
bookmark, Flow and Diffusion settings and the rest), then its records.
172
Each run's records carry `_names`, the username, organisation or
173
`org/team` behind every account, team and organisation id their rows
174
point at, so the restore finds them again by name on any hub. Tables go
175
in parents first; every row gets a new id and every reference follows
176
it. A reference to an account this hub lacks is left blank where the
177
column allows it (the author's name stays in the row) and the row left
178
out where it does not (an assignment, a review request); the restore
179
says how many. Webhooks come back without their secrets, each named to be
180
entered again. Release assets, which live beside the stores rather than
181
in them, are sent once each as blobs and written back checked against
182
their hash. Search is rebuilt for what came back. Left out on purpose:
183
inboxes (notifications), webhook deliveries, runners' declines.
184
185
The design as first written:
186
187
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]`
188
(or a directory) on any hub:
189
190
1. reads the run's manifests (the latest whole run by default);
191
2. creates each repository, or refuses an existing one unless `--into`
192
   names a new owner;
193
3. copies its objects, verifying every hash, and sets its refs;
194
4. imports its records, then reports what needs re-entering (secret
195
   values, webhook secrets).
196
197
A restore into a fresh hub, compared with the original, is the end-to-end
198
test (BK2), as is restoring one repository from a run before a bad push.
199
200
## 8. Order of work
201
202
1. **BK2:** settings for users and organisations, the scheduler and
203
   "back up now", objects, manifests and records to a directory
204
   destination, status and notifications, and restore with its
205
   end-to-end test.
206
2. **BK3:** the GCS destination with the keyless exchange (tested against
207
   a fake STS and a fake GCS), and TEST.
208
3. Admin › Backups, then per-repository settings, then S3.