| 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 |
| 9 |
release assets travel with the stores. Next: Admin › Backups, |
| 10 |
per-repository settings, S3. |
| 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/`. |
| 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, a table of every owner's |
| 106 |
backup (destination, schedule, last run, outcome) and the site's own |
| 107 |
backup. They may not read an owner's bucket, only see that it ran. |
| 108 |
- **A repository** (later): opt a repository out of its owner's backup, |
| 109 |
or give it its own schedule. |
| 110 |
|
| 111 |
The API mirrors the pages: `GET/PUT /api/v1/user/backups`, |
| 112 |
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and |
| 113 |
`GET …/backups/runs`. `oh backup status|run|restore` follows. |
| 114 |
|
| 115 |
## 6. Setting up a GCS destination (for Andy, by hand) |
| 116 |
|
| 117 |
Once per GCP project: |
| 118 |
|
| 119 |
``` |
| 120 |
PROJECT=doomsdayone-oxy |
| 121 |
NUM=$(gcloud projects describe $PROJECT --format='value(projectNumber)') |
| 122 |
gcloud iam workload-identity-pools create oxygenhub \ |
| 123 |
--project=$PROJECT --location=global --display-name="OxygenHub" |
| 124 |
gcloud iam workload-identity-pools providers create-oidc oxygenhub-io \ |
| 125 |
--project=$PROJECT --location=global --workload-identity-pool=oxygenhub \ |
| 126 |
--issuer-uri=https://oxygenhub.io \ |
| 127 |
--attribute-mapping=google.subject=assertion.sub \ |
| 128 |
--attribute-condition="assertion.sub.startsWith('backup:')" |
| 129 |
``` |
| 130 |
|
| 131 |
Once per owner and bucket (here `andymccall`): |
| 132 |
|
| 133 |
``` |
| 134 |
gcloud storage buckets create gs://andymccall-oxygen-backup \ |
| 135 |
--project=$PROJECT --location=europe-west2 \ |
| 136 |
--uniform-bucket-level-access --public-access-prevention |
| 137 |
gcloud storage buckets update gs://andymccall-oxygen-backup --versioning |
| 138 |
gcloud storage buckets add-iam-policy-binding gs://andymccall-oxygen-backup \ |
| 139 |
--role=roles/storage.objectAdmin \ |
| 140 |
--member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall" |
| 141 |
``` |
| 142 |
|
| 35fea423 Andy McCall ↶ |
143 |
**Done for doomsdayone-oxy on 2026-10-13:** the pool `oxygenhub`, the |
| 144 |
provider `oxygenhub-io` and the buckets `andymccall-oxygen-backup` and |
| 145 |
`doomsdayone-oxygen-backup`, each granted to its own `backup:` subject. |
| 146 |
|
| 6dbd999a Andy McCall ↶ |
147 |
Then, in Settings › Backups: destination GCS, bucket |
| 148 |
`andymccall-oxygen-backup`, provider |
| 149 |
`//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`, |
| 150 |
and TEST, which exchanges a token and writes and deletes a probe object. |
| 151 |
An organisation does the same with `backup:ORG`. Only that principal can |
| 152 |
write the bucket; the hub never holds a key. |
| 153 |
|
| 154 |
## 7. Restore |
| 155 |
|
| 5ef6cf72 Andy McCall ↶ |
156 |
**As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR |
| 157 |
[--run AT]` into a hub that lacks the repositories: each store and wiki |
| 158 |
byte for byte (every file checked against its hash), then the |
| 159 |
repository's row with its settings as they were (visibility, default |
| 160 |
bookmark, Flow and Diffusion settings and the rest), then its records. |
| 161 |
Each run's records carry `_names`, the username, organisation or |
| 162 |
`org/team` behind every account, team and organisation id their rows |
| 163 |
point at, so the restore finds them again by name on any hub. Tables go |
| 164 |
in parents first; every row gets a new id and every reference follows |
| 165 |
it. A reference to an account this hub lacks is left blank where the |
| 166 |
column allows it (the author's name stays in the row) and the row left |
| 167 |
out where it does not (an assignment, a review request); the restore |
| 168 |
says how many. Webhooks come back without their secrets, each named to be |
| 169 |
entered again. Release assets, which live beside the stores rather than |
| 170 |
in them, are sent once each as blobs and written back checked against |
| 171 |
their hash. Search is rebuilt for what came back. Left out on purpose: |
| 172 |
inboxes (notifications), webhook deliveries, runners' declines. |
| 173 |
|
| 174 |
The design as first written: |
| 175 |
|
| 6dbd999a Andy McCall ↶ |
176 |
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]` |
| 177 |
(or a directory) on any hub: |
| 178 |
|
| 179 |
1. reads the run's manifests (the latest whole run by default); |
| 180 |
2. creates each repository, or refuses an existing one unless `--into` |
| 181 |
names a new owner; |
| 182 |
3. copies its objects, verifying every hash, and sets its refs; |
| 183 |
4. imports its records, then reports what needs re-entering (secret |
| 184 |
values, webhook secrets). |
| 185 |
|
| 186 |
A restore into a fresh hub, compared with the original, is the end-to-end |
| 187 |
test (BK2), as is restoring one repository from a run before a bad push. |
| 188 |
|
| 189 |
## 8. Order of work |
| 190 |
|
| 191 |
1. **BK2:** settings for users and organisations, the scheduler and |
| 192 |
"back up now", objects, manifests and records to a directory |
| 193 |
destination, status and notifications, and restore with its |
| 194 |
end-to-end test. |
| 195 |
2. **BK3:** the GCS destination with the keyless exchange (tested against |
| 196 |
a fake STS and a fake GCS), and TEST. |
| 197 |
3. Admin › Backups, then per-repository settings, then S3. |