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