| 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); S3 destinations, keylessly (#178). |
| 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 |
- **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. |
| 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. |
| 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. |
| 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. |
| 128 |
|
| 129 |
The API mirrors the pages: `GET/PUT /api/v1/user/backups`, |
| 130 |
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and |
| 131 |
`GET …/backups/runs`. `oh backup status|run|restore` follows. |
| 132 |
|
| 133 |
## 6. Setting up a GCS destination (for Andy, by hand) |
| 134 |
|
| 135 |
Once per GCP project: |
| 136 |
|
| 137 |
``` |
| 138 |
PROJECT=doomsdayone-oxy |
| 139 |
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)') |
| 140 |
gcloud iam workload-identity-pools create oxygenhub \ |
| 141 |
--project=$PROJECT --location=global --display-name="OxygenHub" |
| 142 |
gcloud iam workload-identity-pools providers create-oidc oxygenhub-io \ |
| 143 |
--project=$PROJECT --location=global --workload-identity-pool=oxygenhub \ |
| 144 |
--issuer-uri=https://oxygenhub.io \ |
| 145 |
--attribute-mapping=google.subject=assertion.sub \ |
| 146 |
--attribute-condition="assertion.sub.startsWith('backup:')" |
| 147 |
``` |
| 148 |
|
| 149 |
Once per owner and bucket (here `andymccall`): |
| 150 |
|
| 151 |
``` |
| 152 |
gcloud storage buckets create gs://andymccall-oxygen-backup \ |
| 153 |
--project=$PROJECT --location=europe-west2 \ |
| 154 |
--uniform-bucket-level-access --public-access-prevention |
| 155 |
gcloud storage buckets update gs://andymccall-oxygen-backup --versioning |
| 156 |
gcloud storage buckets add-iam-policy-binding gs://andymccall-oxygen-backup \ |
| 157 |
--role=roles/storage.objectAdmin \ |
| 158 |
--member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall" |
| 159 |
``` |
| 160 |
|
| 161 |
**Done for doomsdayone-oxy on 2026-10-13:** the pool `oxygenhub`, the |
| 162 |
provider `oxygenhub-io` and the buckets `andymccall-oxygen-backup` and |
| 163 |
`doomsdayone-oxygen-backup`, each granted to its own `backup:` subject. |
| 164 |
|
| 165 |
Then, in Settings › Backups: destination GCS, bucket |
| 166 |
`andymccall-oxygen-backup`, provider |
| 167 |
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`, |
| 168 |
and TEST, which exchanges a token and writes and deletes a probe object. |
| 169 |
An organisation does the same with `backup:ORG`. Only that principal can |
| 170 |
write the bucket; the hub never holds a key. |
| 171 |
|
| 172 |
### 6.1 Setting up an S3 destination (for Andy, by hand) |
| 173 |
|
| 174 |
Once per AWS account, the hub as an identity provider (AWS fetches its |
| 175 |
keys from `https://oxygenhub.io/.well-known/openid-configuration`): |
| 176 |
|
| 177 |
``` |
| 178 |
aws iam create-open-id-connect-provider \ |
| 179 |
--url https://oxygenhub.io --client-id-list sts.amazonaws.com |
| 180 |
``` |
| 181 |
|
| 182 |
Once per owner and bucket (here `andymccall`, in `eu-west-2`): |
| 183 |
|
| 184 |
``` |
| 185 |
B=andymccall-oxygen-backup; ACCOUNT=$(aws sts get-caller-identity --query Account --output text) |
| 186 |
aws s3api create-bucket --bucket $B --region eu-west-2 \ |
| 187 |
--create-bucket-configuration LocationConstraint=eu-west-2 |
| 188 |
aws s3api put-bucket-versioning --bucket $B --versioning-configuration Status=Enabled |
| 189 |
aws s3api put-public-access-block --bucket $B --public-access-block-configuration \ |
| 190 |
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true |
| 191 |
cat > trust.json <<JSON |
| 192 |
{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", |
| 193 |
"Principal": {"Federated": "arn:aws:iam::$ACCOUNT:oidc-provider/oxygenhub.io"}, |
| 194 |
"Action": "sts:AssumeRoleWithWebIdentity", |
| 195 |
"Condition": {"StringEquals": {"oxygenhub.io:aud": "sts.amazonaws.com", |
| 196 |
"oxygenhub.io:sub": "backup:andymccall"}}}]} |
| 197 |
JSON |
| 198 |
cat > policy.json <<JSON |
| 199 |
{"Version": "2012-10-17", "Statement": [ |
| 200 |
{"Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::$B"}, |
| 201 |
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], |
| 202 |
"Resource": "arn:aws:s3:::$B/*"}]} |
| 203 |
JSON |
| 204 |
aws iam create-role --role-name oxygenhub-backup-andymccall --assume-role-policy-document file://trust.json |
| 205 |
aws iam put-role-policy --role-name oxygenhub-backup-andymccall --policy-name backup --policy-document file://policy.json |
| 206 |
``` |
| 207 |
|
| 208 |
Then, in Settings › Backups: destination S3, the bucket, region |
| 209 |
`eu-west-2`, role `arn:aws:iam::ACCOUNT:role/oxygenhub-backup-andymccall`, |
| 210 |
SAVE and TEST. `s3:ListBucket` matters: without it S3 answers a missing |
| 211 |
object with 403 rather than 404, and every run fails. No access key exists |
| 212 |
anywhere. |
| 213 |
|
| 214 |
## 7. Restore |
| 215 |
|
| 216 |
**As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR |
| 217 |
[--run AT]` into a hub that lacks the repositories: each store and wiki |
| 218 |
byte for byte (every file checked against its hash), then the |
| 219 |
repository's row with its settings as they were (visibility, default |
| 220 |
bookmark, Flow and Diffusion settings and the rest), then its records. |
| 221 |
Each run's records carry `_names`, the username, organisation or |
| 222 |
`org/team` behind every account, team and organisation id their rows |
| 223 |
point at, so the restore finds them again by name on any hub. Tables go |
| 224 |
in parents first; every row gets a new id and every reference follows |
| 225 |
it. A reference to an account this hub lacks is left blank where the |
| 226 |
column allows it (the author's name stays in the row) and the row left |
| 227 |
out where it does not (an assignment, a review request); the restore |
| 228 |
says how many. Webhooks come back without their secrets, each named to be |
| 229 |
entered again. Release assets, which live beside the stores rather than |
| 230 |
in them, are sent once each as blobs and written back checked against |
| 231 |
their hash. Search is rebuilt for what came back. Left out on purpose: |
| 232 |
inboxes (notifications), webhook deliveries, runners' declines. |
| 233 |
|
| 234 |
The design as first written: |
| 235 |
|
| 236 |
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]` |
| 237 |
(or a directory) on any hub: |
| 238 |
|
| 239 |
1. reads the run's manifests (the latest whole run by default); |
| 240 |
2. creates each repository, or refuses an existing one unless `--into` |
| 241 |
names a new owner; |
| 242 |
3. copies its objects, verifying every hash, and sets its refs; |
| 243 |
4. imports its records, then reports what needs re-entering (secret |
| 244 |
values, webhook secrets). |
| 245 |
|
| 246 |
A restore into a fresh hub, compared with the original, is the end-to-end |
| 247 |
test (BK2), as is restoring one repository from a run before a bad push. |
| 248 |
|
| 249 |
## 8. Order of work |
| 250 |
|
| 251 |
1. **BK2:** settings for users and organisations, the scheduler and |
| 252 |
"back up now", objects, manifests and records to a directory |
| 253 |
destination, status and notifications, and restore with its |
| 254 |
end-to-end test. |
| 255 |
2. **BK3:** the GCS destination with the keyless exchange (tested against |
| 256 |
a fake STS and a fake GCS), and TEST. |
| 257 |
3. Admin › Backups, then per-repository settings, then S3: all built |
| 258 |
2026-10-14 (#176, #177, #178). |