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