feat: keyless GCS backups, and TEST (closes #169) 35fea4237cc9
A GCS destination signs in without a key: the hub mints a ten-minute token as backup:OWNER for the owner's workload identity provider, Google's STS exchanges it for an access token scoped devstorage.read_write, and the Cloud Storage JSON API takes the run's blobs, manifests and records; the access token is exchanged again before it runs out, and a refusal says which role to grant to which principal. TEST writes, reads and removes a probe at the saved destination. Tested against a fake STS and GCS that check the subject, the audience and the bucket's owner: a backup, a second run sending nothing, a probe, a restore into a fresh hub byte for byte, and another owner refused.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 23:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
35fea4237cc9db585d05ea889d741c2bbd6341c6e63bd47dde6aa90884f76d06- Depends on
- 19bb31ff1f5c 6dbd999ad794 93be9ce4653e a7e6e8e6094c b5668108487f c9aacc0e7dc1 d1727c573f30 d23464c0ac60 d7d182467382
- Depended on by
- 9041db4e4d92 32d97df55ab7 0cd118748131 2e9188ca4f98 7b9f4570daf5 5ef6cf72cd19
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 30 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified CHANGELOG.md +1 −1
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | - | - **Backups (#169, docs/backups.md):** a user (Settings › Backups) or an organisation's owners copy every repository they own, with its wiki, issues, proposals, releases and settings (never a secret value), hourly or daily, to a directory the hub allows. Only what changed travels, runs are kept 7, 30 or 90 days, a failure lands in the owner's inbox, and `oxygenhub admin backup restore` brings repositories back byte for byte. GCS, with no key, is next. |
|
| 7 | + | - **Backups (#169, docs/backups.md):** a user (Settings › Backups) or an organisation's owners copy every repository they own, with its wiki, issues, proposals, releases and settings (never a secret value), hourly or daily, to a directory the hub allows or a Google Cloud Storage bucket with no key at all (the hub signs in as `backup:OWNER` through workload identity federation; TEST checks it). Only what changed travels, runs are kept 7, 30 or 90 days, a failure lands in the owner's inbox, and `oxygenhub admin backup restore` brings repositories back byte for byte. |
|
| 8 | 8 | - **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land. |
|
| 9 | 9 | - **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension. |
|
| 10 | 10 | - **The card layout everywhere (#159–#164):** the About card's counts in its heading and its buttons centred; wider repository cards with each Diffusion row on one line; the Code button and + New project in the accent blue; proposal and issue sidebars, profile and organisation pages, search results and the wiki's sidebar as cards; every panel's and form card's heading a shaded bar. |
|
| … | 169 unchanged lines |
modified crates/hub/src/backups.rs +256 −10
| … | 175 unchanged lines |
||
| 176 | 176 | } |
|
| 177 | 177 | } |
|
| 178 | 178 | ||
| 179 | + | /// A Cloud Storage bucket, reached keylessly (docs/backups.md §3, §6): |
|
| 180 | + | /// the hub mints a short token as `backup:OWNER`, Google's STS exchanges |
|
| 181 | + | /// it for an access token through the owner's workload identity |
|
| 182 | + | /// provider, and that writes the bucket. No key exists anywhere; the |
|
| 183 | + | /// access token lasts an hour and is fetched again before it runs out. |
|
| 184 | + | pub struct Gcs { |
|
| 185 | + | bucket: String, |
|
| 186 | + | /// Keys go under this, when set. |
|
| 187 | + | prefix: String, |
|
| 188 | + | storage: String, |
|
| 189 | + | sts: String, |
|
| 190 | + | /// The provider's resource name, `//iam.googleapis.com/projects/…`. |
|
| 191 | + | provider: String, |
|
| 192 | + | subject: String, |
|
| 193 | + | app: Shared, |
|
| 194 | + | client: reqwest::Client, |
|
| 195 | + | rt: tokio::runtime::Handle, |
|
| 196 | + | token: std::sync::Mutex<Option<(String, i64)>>, |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | impl Gcs { |
|
| 200 | + | pub fn new(app: &Shared, s: &Setting) -> Gcs { |
|
| 201 | + | Gcs { |
|
| 202 | + | bucket: s.target.clone(), |
|
| 203 | + | prefix: s.prefix.trim_matches('/').to_string(), |
|
| 204 | + | storage: app.config.backups.storage_url.trim_end_matches('/').to_string(), |
|
| 205 | + | sts: app.config.backups.sts_url.trim_end_matches('/').to_string(), |
|
| 206 | + | provider: s.provider.clone(), |
|
| 207 | + | subject: format!("backup:{}", s.owner), |
|
| 208 | + | app: app.clone(), |
|
| 209 | + | client: reqwest::Client::builder().timeout(std::time::Duration::from_secs(300)).build().unwrap_or_default(), |
|
| 210 | + | rt: tokio::runtime::Handle::current(), |
|
| 211 | + | token: std::sync::Mutex::new(None), |
|
| 212 | + | } |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | fn name(&self, key: &str) -> String { |
|
| 216 | + | if self.prefix.is_empty() { |
|
| 217 | + | key.to_string() |
|
| 218 | + | } else { |
|
| 219 | + | format!("{}/{key}", self.prefix) |
|
| 220 | + | } |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// An access token, exchanged for a fresh one of the hub's when the |
|
| 224 | + | /// last is within five minutes of running out. |
|
| 225 | + | fn access(&self) -> Result<String> { |
|
| 226 | + | let now = db::now_ms(); |
|
| 227 | + | if let Some((t, until)) = self.token.lock().unwrap().as_ref() { |
|
| 228 | + | if *until - now > 300_000 { |
|
| 229 | + | return Ok(t.clone()); |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | let (token, expires_in) = self.rt.block_on(async { |
|
| 233 | + | let issuer = self.app.oidc().await?; |
|
| 234 | + | // Google's default audience for a provider: its name over https. |
|
| 235 | + | let audience = format!("https:{}", self.provider); |
|
| 236 | + | let jwt = issuer.mint_for(self.app.config.base_url(), &self.subject, &audience, db::now_ms(), 600); |
|
| 237 | + | let res = self |
|
| 238 | + | .client |
|
| 239 | + | .post(format!("{}/v1/token", self.sts)) |
|
| 240 | + | .json(&serde_json::json!({ |
|
| 241 | + | "grantType": "urn:ietf:params:oauth:grant-type:token-exchange", |
|
| 242 | + | "audience": self.provider, |
|
| 243 | + | "scope": "https://www.googleapis.com/auth/devstorage.read_write", |
|
| 244 | + | "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token", |
|
| 245 | + | "subjectToken": jwt, |
|
| 246 | + | "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt", |
|
| 247 | + | })) |
|
| 248 | + | .send() |
|
| 249 | + | .await?; |
|
| 250 | + | let status = res.status(); |
|
| 251 | + | let body: serde_json::Value = res.json().await.unwrap_or_default(); |
|
| 252 | + | if !status.is_success() { |
|
| 253 | + | bail!( |
|
| 254 | + | "Google's token exchange refused {}: {} (check the provider's issuer is this hub's address and its attribute condition admits backup:)", |
|
| 255 | + | self.subject, |
|
| 256 | + | body["error_description"].as_str().or(body["error"].as_str()).unwrap_or("no reason given") |
|
| 257 | + | ); |
|
| 258 | + | } |
|
| 259 | + | let token = body["access_token"].as_str().context("the token exchange gave no access token")?.to_string(); |
|
| 260 | + | anyhow::Ok((token, body["expires_in"].as_i64().unwrap_or(3600))) |
|
| 261 | + | })?; |
|
| 262 | + | *self.token.lock().unwrap() = Some((token.clone(), now + expires_in * 1000)); |
|
| 263 | + | Ok(token) |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | fn object_url(&self, key: &str) -> Result<reqwest::Url> { |
|
| 267 | + | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
|
| 268 | + | u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&self.name(key)); |
|
| 269 | + | Ok(u) |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | fn refused(&self, what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error { |
|
| 273 | + | let hint = if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::UNAUTHORIZED { |
|
| 274 | + | format!(" (grant roles/storage.objectAdmin on gs://{} to the principal …/subject/{})", self.bucket, self.subject) |
|
| 275 | + | } else { |
|
| 276 | + | String::new() |
|
| 277 | + | }; |
|
| 278 | + | anyhow::anyhow!("GCS {what}: {status}{hint}: {}", body.chars().take(200).collect::<String>()) |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | /// Every object name under `full` (no delimiter), for removal. |
|
| 282 | + | fn all_under(&self, full: &str) -> Result<Vec<String>> { |
|
| 283 | + | let mut out = Vec::new(); |
|
| 284 | + | let mut page: Option<String> = None; |
|
| 285 | + | loop { |
|
| 286 | + | let token = self.access()?; |
|
| 287 | + | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
|
| 288 | + | u.query_pairs_mut().append_pair("prefix", full).append_pair("fields", "items(name),nextPageToken"); |
|
| 289 | + | if let Some(p) = &page { |
|
| 290 | + | u.query_pairs_mut().append_pair("pageToken", p); |
|
| 291 | + | } |
|
| 292 | + | let body: serde_json::Value = self.rt.block_on(async { |
|
| 293 | + | let res = self.client.get(u).bearer_auth(&token).send().await?; |
|
| 294 | + | let status = res.status(); |
|
| 295 | + | let text = res.text().await?; |
|
| 296 | + | if !status.is_success() { |
|
| 297 | + | return Err(self.refused("list", status, &text)); |
|
| 298 | + | } |
|
| 299 | + | anyhow::Ok(serde_json::from_str(&text)?) |
|
| 300 | + | })?; |
|
| 301 | + | out.extend(body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str().map(String::from))); |
|
| 302 | + | match body["nextPageToken"].as_str() { |
|
| 303 | + | Some(p) => page = Some(p.to_string()), |
|
| 304 | + | None => return Ok(out), |
|
| 305 | + | } |
|
| 306 | + | } |
|
| 307 | + | } |
|
| 308 | + | } |
|
| 309 | + | ||
| 310 | + | impl Destination for Gcs { |
|
| 311 | + | fn exists(&self, key: &str) -> Result<bool> { |
|
| 312 | + | let token = self.access()?; |
|
| 313 | + | let mut u = self.object_url(key)?; |
|
| 314 | + | u.query_pairs_mut().append_pair("fields", "name"); |
|
| 315 | + | self.rt.block_on(async { |
|
| 316 | + | let res = self.client.get(u).bearer_auth(&token).send().await?; |
|
| 317 | + | match res.status() { |
|
| 318 | + | s if s.is_success() => Ok(true), |
|
| 319 | + | reqwest::StatusCode::NOT_FOUND => Ok(false), |
|
| 320 | + | s => Err(self.refused("read", s, &res.text().await.unwrap_or_default())), |
|
| 321 | + | } |
|
| 322 | + | }) |
|
| 323 | + | } |
|
| 324 | + | fn put(&self, key: &str, bytes: &[u8]) -> Result<()> { |
|
| 325 | + | let token = self.access()?; |
|
| 326 | + | let mut u = reqwest::Url::parse(&format!("{}/upload/storage/v1/b/{}/o", self.storage, self.bucket))?; |
|
| 327 | + | u.query_pairs_mut().append_pair("uploadType", "media").append_pair("name", &self.name(key)); |
|
| 328 | + | let body = bytes.to_vec(); |
|
| 329 | + | self.rt.block_on(async { |
|
| 330 | + | let res = self.client.post(u).bearer_auth(&token).header("content-type", "application/octet-stream").body(body).send().await?; |
|
| 331 | + | let status = res.status(); |
|
| 332 | + | if !status.is_success() { |
|
| 333 | + | return Err(self.refused("write", status, &res.text().await.unwrap_or_default())); |
|
| 334 | + | } |
|
| 335 | + | Ok(()) |
|
| 336 | + | }) |
|
| 337 | + | } |
|
| 338 | + | fn get(&self, key: &str) -> Result<Vec<u8>> { |
|
| 339 | + | let token = self.access()?; |
|
| 340 | + | let mut u = self.object_url(key)?; |
|
| 341 | + | u.query_pairs_mut().append_pair("alt", "media"); |
|
| 342 | + | self.rt.block_on(async { |
|
| 343 | + | let res = self.client.get(u).bearer_auth(&token).send().await?; |
|
| 344 | + | let status = res.status(); |
|
| 345 | + | if !status.is_success() { |
|
| 346 | + | return Err(self.refused(&format!("read {key}"), status, &res.text().await.unwrap_or_default())); |
|
| 347 | + | } |
|
| 348 | + | Ok(res.bytes().await?.to_vec()) |
|
| 349 | + | }) |
|
| 350 | + | } |
|
| 351 | + | fn list(&self, prefix: &str) -> Result<Vec<String>> { |
|
| 352 | + | let full = format!("{}/", self.name(prefix.trim_end_matches('/'))); |
|
| 353 | + | let mut out = std::collections::BTreeSet::new(); |
|
| 354 | + | let mut page: Option<String> = None; |
|
| 355 | + | loop { |
|
| 356 | + | let token = self.access()?; |
|
| 357 | + | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
|
| 358 | + | u.query_pairs_mut() |
|
| 359 | + | .append_pair("prefix", &full) |
|
| 360 | + | .append_pair("delimiter", "/") |
|
| 361 | + | .append_pair("fields", "items(name),prefixes,nextPageToken"); |
|
| 362 | + | if let Some(p) = &page { |
|
| 363 | + | u.query_pairs_mut().append_pair("pageToken", p); |
|
| 364 | + | } |
|
| 365 | + | let body: serde_json::Value = self.rt.block_on(async { |
|
| 366 | + | let res = self.client.get(u).bearer_auth(&token).send().await?; |
|
| 367 | + | let status = res.status(); |
|
| 368 | + | let text = res.text().await?; |
|
| 369 | + | if !status.is_success() { |
|
| 370 | + | return Err(self.refused("list", status, &text)); |
|
| 371 | + | } |
|
| 372 | + | anyhow::Ok(serde_json::from_str(&text)?) |
|
| 373 | + | })?; |
|
| 374 | + | for n in body["items"].as_array().into_iter().flatten().filter_map(|i| i["name"].as_str()) { |
|
| 375 | + | out.insert(n.trim_start_matches(&full).to_string()); |
|
| 376 | + | } |
|
| 377 | + | for p in body["prefixes"].as_array().into_iter().flatten().filter_map(|p| p.as_str()) { |
|
| 378 | + | out.insert(p.trim_start_matches(&full).trim_end_matches('/').to_string()); |
|
| 379 | + | } |
|
| 380 | + | match body["nextPageToken"].as_str() { |
|
| 381 | + | Some(p) => page = Some(p.to_string()), |
|
| 382 | + | None => return Ok(out.into_iter().filter(|n| !n.is_empty()).collect()), |
|
| 383 | + | } |
|
| 384 | + | } |
|
| 385 | + | } |
|
| 386 | + | fn remove(&self, key: &str) -> Result<()> { |
|
| 387 | + | let mut names = self.all_under(&format!("{}/", self.name(key)))?; |
|
| 388 | + | names.push(self.name(key)); |
|
| 389 | + | for n in names { |
|
| 390 | + | let token = self.access()?; |
|
| 391 | + | let mut u = reqwest::Url::parse(&format!("{}/storage/v1/b/{}/o", self.storage, self.bucket))?; |
|
| 392 | + | u.path_segments_mut().map_err(|_| anyhow::anyhow!("bad storage URL"))?.push(&n); |
|
| 393 | + | self.rt.block_on(async { |
|
| 394 | + | let res = self.client.delete(u).bearer_auth(&token).send().await?; |
|
| 395 | + | match res.status() { |
|
| 396 | + | s if s.is_success() || s == reqwest::StatusCode::NOT_FOUND => Ok(()), |
|
| 397 | + | s => Err(self.refused("delete", s, &res.text().await.unwrap_or_default())), |
|
| 398 | + | } |
|
| 399 | + | })?; |
|
| 400 | + | } |
|
| 401 | + | Ok(()) |
|
| 402 | + | } |
|
| 403 | + | } |
|
| 404 | + | ||
| 405 | + | /// Check a destination end to end: write a probe, read it back, remove it. |
|
| 406 | + | pub fn probe(dest: &dyn Destination) -> Result<()> { |
|
| 407 | + | let marker = format!("probe {}", db::now_ms()); |
|
| 408 | + | dest.put(".oxyhub-probe", marker.as_bytes())?; |
|
| 409 | + | if dest.get(".oxyhub-probe")? != marker.as_bytes() { |
|
| 410 | + | bail!("the probe read back differently"); |
|
| 411 | + | } |
|
| 412 | + | dest.remove(".oxyhub-probe") |
|
| 413 | + | } |
|
| 414 | + | ||
| 179 | 415 | /// The owner's destination, from its setting and the site's rules. |
|
| 180 | 416 | pub fn destination(app: &Shared, s: &Setting) -> Result<Box<dyn Destination>> { |
|
| 181 | 417 | match s.destination.as_str() { |
|
| … | 8 unchanged lines |
||
| 190 | 426 | root = root.join(s.prefix.trim_matches('/')); |
|
| 191 | 427 | } |
|
| 192 | 428 | Ok(Box::new(Directory { root })) |
|
| 429 | + | } |
|
| 430 | + | "gcs" => { |
|
| 431 | + | if s.target.is_empty() || s.provider.is_empty() { |
|
| 432 | + | bail!("a GCS destination needs its bucket and its workload identity provider"); |
|
| 433 | + | } |
|
| 434 | + | Ok(Box::new(Gcs::new(app, s))) |
|
| 193 | 435 | } |
|
| 194 | - | "gcs" => bail!("GCS destinations come with oxygenhub#169's next step"), |
|
| 195 | 436 | other => bail!("unknown destination {other:?}"), |
|
| 196 | 437 | } |
|
| 197 | 438 | } |
|
| … | 274 unchanged lines |
||
| 472 | 713 | sent.bytes += s1.bytes + s2.bytes; |
|
| 473 | 714 | let m = Manifest { owner: row.owner.clone(), name: row.name.clone(), kind: kind.into(), files }; |
|
| 474 | 715 | let key = format!("runs/{at}/repos/{kind}-{}.json", row.name); |
|
| 475 | - | dest.put(&key, &serde_json::to_vec(&m)?)?; |
|
| 716 | + | tokio::task::block_in_place(|| dest.put(&key, &serde_json::to_vec(&m)?))?; |
|
| 476 | 717 | if kind == "repos" { |
|
| 477 | 718 | repos += 1; |
|
| 478 | 719 | } |
|
| … | 11 unchanged lines |
||
| 490 | 731 | anyhow::Ok(r) |
|
| 491 | 732 | }; |
|
| 492 | 733 | match rec.await { |
|
| 493 | - | Ok(r) => dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?)?, |
|
| 734 | + | Ok(r) => tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?, |
|
| 494 | 735 | Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)), |
|
| 495 | 736 | } |
|
| 496 | 737 | } |
|
| … | 9 unchanged lines |
||
| 506 | 747 | None => serde_json::json!({}), |
|
| 507 | 748 | } |
|
| 508 | 749 | }; |
|
| 509 | - | dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?; |
|
| 510 | - | dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?; |
|
| 511 | - | dest.put("LATEST", at.as_bytes())?; |
|
| 512 | - | prune(dest.as_ref(), s.keep_days)?; |
|
| 750 | + | // The blocking calls of a destination (GCS drives its client on this |
|
| 751 | + | // runtime) run where blocking is allowed. |
|
| 752 | + | tokio::task::block_in_place(|| -> Result<()> { |
|
| 753 | + | dest.put(&format!("runs/{at}/owner.json"), &serde_json::to_vec(&owner_rows)?)?; |
|
| 754 | + | dest.put(&format!("runs/{at}/DONE"), at.as_bytes())?; |
|
| 755 | + | dest.put("LATEST", at.as_bytes())?; |
|
| 756 | + | prune(dest.as_ref(), s.keep_days) |
|
| 757 | + | })?; |
|
| 513 | 758 | Ok(((repos, sent), problems)) |
|
| 514 | 759 | } |
|
| 515 | 760 | ||
| … | 160 unchanged lines |
||
| 676 | 921 | let done = tokio::task::spawn_blocking(move || restore(d.as_ref(), a.as_deref(), &repos_root, &wikis_root)).await??; |
|
| 677 | 922 | let at = match at { |
|
| 678 | 923 | Some(a) => a, |
|
| 679 | - | None => String::from_utf8_lossy(&dest.get("LATEST")?).trim().to_string(), |
|
| 924 | + | None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(), |
|
| 680 | 925 | }; |
|
| 681 | - | for name in dest.list(&format!("runs/{at}/records"))? { |
|
| 682 | - | let rec: serde_json::Value = serde_json::from_slice(&dest.get(&format!("runs/{at}/records/{name}"))?)?; |
|
| 926 | + | for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? { |
|
| 927 | + | let rec: serde_json::Value = |
|
| 928 | + | serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?; |
|
| 683 | 929 | let Some(row) = rec["repositories"].as_array().and_then(|a| a.first()) else { continue }; |
|
| 684 | 930 | let (owner, repo) = (row["owner"].as_str().unwrap_or_default(), row["name"].as_str().unwrap_or_default()); |
|
| 685 | 931 | if owner.is_empty() || db::repos::get(&app.db, owner, repo).await?.is_some() { |
|
| … | 58 unchanged lines |
modified crates/hub/src/config.rs +15 −1
| … | 26 unchanged lines |
||
| 27 | 27 | ||
| 28 | 28 | /// Backups (docs/backups.md, oxygenhub#169): the directories on this host |
|
| 29 | 29 | /// an owner may back up to. Empty, only GCS destinations can be chosen. |
|
| 30 | - | #[derive(Clone, Debug, Default, Deserialize)] |
|
| 30 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 31 | 31 | #[serde(default, deny_unknown_fields)] |
|
| 32 | 32 | pub struct Backups { |
|
| 33 | 33 | pub directories: Vec<PathBuf>, |
|
| 34 | + | /// Google's token exchange and Cloud Storage, for GCS destinations; |
|
| 35 | + | /// set only to point them elsewhere (tests, a private endpoint). |
|
| 36 | + | pub sts_url: String, |
|
| 37 | + | pub storage_url: String, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | impl Default for Backups { |
|
| 41 | + | fn default() -> Self { |
|
| 42 | + | Backups { |
|
| 43 | + | directories: Vec::new(), |
|
| 44 | + | sts_url: "https://sts.googleapis.com".into(), |
|
| 45 | + | storage_url: "https://storage.googleapis.com".into(), |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 34 | 48 | } |
|
| 35 | 49 | ||
| 36 | 50 | /// Diffusion (#148, docs/diffusion.md §4.2): the site's default for |
|
| … | 364 unchanged lines |
modified crates/hub/src/oidc.rs +19 −0
| … | 134 unchanged lines |
||
| 135 | 135 | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
|
| 136 | 136 | } |
|
| 137 | 137 | ||
| 138 | + | /// A signed token for the hub itself acting as `subject` (a backup's |
|
| 139 | + | /// `backup:OWNER`, oxygenhub#169), for `audience`, living `ttl_s`. |
|
| 140 | + | pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String { |
|
| 141 | + | let iat = now_ms / 1000; |
|
| 142 | + | let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid }); |
|
| 143 | + | let payload = serde_json::json!({ |
|
| 144 | + | "iss": issuer, |
|
| 145 | + | "sub": subject, |
|
| 146 | + | "aud": audience, |
|
| 147 | + | "iat": iat, |
|
| 148 | + | "nbf": iat - 30, |
|
| 149 | + | "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64, |
|
| 150 | + | "jti": crate::auth::random_hex(16), |
|
| 151 | + | }); |
|
| 152 | + | let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes())); |
|
| 153 | + | let sig = self.signing.sign(signing_input.as_bytes()); |
|
| 154 | + | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
|
| 155 | + | } |
|
| 156 | + | ||
| 138 | 157 | /// The claims of a token this hub signed, when its signature holds, |
|
| 139 | 158 | /// it is for `audience` and it has not expired at `now_ms`. |
|
| 140 | 159 | pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> { |
|
| … | 147 unchanged lines |
modified crates/hub/src/web/backups.rs +39 −0
| … | 146 unchanged lines |
||
| 147 | 147 | page(app, shell, kind, owner, Some("Backing up now: the run shows below when it finishes.".into()), None).await |
|
| 148 | 148 | } |
|
| 149 | 149 | ||
| 150 | + | /// TEST: reach the saved destination and write, read and remove a probe. |
|
| 151 | + | async fn test(app: &Shared, shell: Shell, kind: &'static str, owner: &str) -> Result<Page<BackupsPage>, Error> { |
|
| 152 | + | let Some(set) = backups::setting(&app.db, owner).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 153 | + | return page(app, shell, kind, owner, None, Some("Save where backups go first.".into())).await; |
|
| 154 | + | }; |
|
| 155 | + | let outcome = match backups::destination(app, &set) { |
|
| 156 | + | Ok(dest) => { |
|
| 157 | + | tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap_or_else(|e| Err(anyhow::anyhow!("{e}"))) |
|
| 158 | + | } |
|
| 159 | + | Err(e) => Err(e), |
|
| 160 | + | }; |
|
| 161 | + | match outcome { |
|
| 162 | + | Ok(()) => { |
|
| 163 | + | let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) }; |
|
| 164 | + | page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await |
|
| 165 | + | } |
|
| 166 | + | Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await, |
|
| 167 | + | } |
|
| 168 | + | } |
|
| 169 | + | ||
| 150 | 170 | #[derive(Deserialize)] |
|
| 151 | 171 | pub struct Csrf { |
|
| 152 | 172 | #[serde(rename = "_csrf")] |
|
| … | 48 unchanged lines |
||
| 201 | 221 | let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; |
|
| 202 | 222 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 203 | 223 | run_now(&app, shell, "org", &org.name).await |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | pub async fn user_test(State(app): State<Shared>, viewer: Viewer, Form(f): Form<Csrf>) -> Result<Page<BackupsPage>, Error> { |
|
| 227 | + | let shell = Shell::new(&app, "/settings/backups", &viewer); |
|
| 228 | + | let me = require(&viewer, &shell)?; |
|
| 229 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 230 | + | test(&app, shell, "user", &me.account.username).await |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | pub async fn org_test( |
|
| 234 | + | State(app): State<Shared>, |
|
| 235 | + | viewer: Viewer, |
|
| 236 | + | Path(name): Path<String>, |
|
| 237 | + | Form(f): Form<Csrf>, |
|
| 238 | + | ) -> Result<Page<BackupsPage>, Error> { |
|
| 239 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings/backups"), &viewer); |
|
| 240 | + | let (_, org) = super::orgs::owned_org(&app, &shell, &viewer, &name).await?; |
|
| 241 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 242 | + | test(&app, shell, "org", &org.name).await |
|
| 204 | 243 | } |
modified crates/hub/src/web/mod.rs +2 −0
| … | 90 unchanged lines |
||
| 91 | 91 | .route("/settings/tokens", get(settings::tokens).post(settings::tokens_create)) |
|
| 92 | 92 | .route("/settings/backups", get(backups::user).post(backups::user_save)) |
|
| 93 | 93 | .route("/settings/backups/run", post(backups::user_run)) |
|
| 94 | + | .route("/settings/backups/test", post(backups::user_test)) |
|
| 94 | 95 | .route("/orgs/{name}/settings/backups", get(backups::org).post(backups::org_save)) |
|
| 95 | 96 | .route("/orgs/{name}/settings/backups/run", post(backups::org_run)) |
|
| 97 | + | .route("/orgs/{name}/settings/backups/test", post(backups::org_test)) |
|
| 96 | 98 | .route("/settings/tokens/{id}/delete", post(settings::tokens_delete)) |
|
| 97 | 99 | .route("/new", get(repo_settings::new_form).post(repo_settings::create)) |
|
| 98 | 100 | .route("/orgs/new", get(orgs::new_form).post(orgs::create)) |
|
| … | 652 unchanged lines |
modified crates/hub/tests/backups.rs +204 −0
| … | 27 unchanged lines |
||
| 28 | 28 | } |
|
| 29 | 29 | ||
| 30 | 30 | async fn hub(backups: &Path) -> Hub { |
|
| 31 | + | hub_with(backups, None).await |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | async fn hub_with(backups: &Path, google: Option<&str>) -> Hub { |
|
| 31 | 35 | hermetic(); |
|
| 32 | 36 | let dir = tempfile::tempdir().unwrap(); |
|
| 33 | 37 | let mut config = Config::load(dir.path()).unwrap(); |
|
| 34 | 38 | config.auth.push_tokens = vec!["site".into()]; |
|
| 35 | 39 | config.backups.directories = vec![backups.to_path_buf()]; |
|
| 40 | + | if let Some(g) = google { |
|
| 41 | + | config.backups.sts_url = g.to_string(); |
|
| 42 | + | config.backups.storage_url = g.to_string(); |
|
| 43 | + | } |
|
| 36 | 44 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 37 | 45 | let port = listener.local_addr().unwrap().port(); |
|
| 38 | 46 | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| … | 123 unchanged lines |
||
| 162 | 170 | .await |
|
| 163 | 171 | .unwrap(); |
|
| 164 | 172 | assert!(backups::due(&h.app.db).await.unwrap().is_empty(), "ran just now"); |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | // ------------------------------------------------------------ a fake Google |
|
| 176 | + | ||
| 177 | + | /// What the fake has seen and holds. |
|
| 178 | + | #[derive(Default)] |
|
| 179 | + | struct Google { |
|
| 180 | + | /// Objects by bucket and name. |
|
| 181 | + | objects: std::collections::BTreeMap<(String, String), Vec<u8>>, |
|
| 182 | + | /// The subjects and audiences of the tokens exchanged. |
|
| 183 | + | exchanged: Vec<(String, String)>, |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | type Shared2 = Arc<std::sync::Mutex<Google>>; |
|
| 187 | + | ||
| 188 | + | fn decode(s: &str) -> String { |
|
| 189 | + | let b = s.as_bytes(); |
|
| 190 | + | let mut out = Vec::new(); |
|
| 191 | + | let mut i = 0; |
|
| 192 | + | while i < b.len() { |
|
| 193 | + | if b[i] == b'%' && i + 2 < b.len() { |
|
| 194 | + | out.push(u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap()); |
|
| 195 | + | i += 3; |
|
| 196 | + | } else { |
|
| 197 | + | out.push(b[i]); |
|
| 198 | + | i += 1; |
|
| 199 | + | } |
|
| 200 | + | } |
|
| 201 | + | String::from_utf8(out).unwrap() |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | fn query(uri: &axum::http::Uri, key: &str) -> Option<String> { |
|
| 205 | + | uri.query()?.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| decode(v))) |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// Google's STS and Cloud Storage, as much as backups use: a token |
|
| 209 | + | /// exchange that admits `backup:` subjects for the provider's audience, |
|
| 210 | + | /// and buckets that admit only their owner's token (bucket `OWNER-backup`). |
|
| 211 | + | async fn fake_google() -> (String, Shared2) { |
|
| 212 | + | use axum::extract::{Request, State}; |
|
| 213 | + | use axum::http::StatusCode; |
|
| 214 | + | use axum::response::IntoResponse; |
|
| 215 | + | let state: Shared2 = Default::default(); |
|
| 216 | + | async fn handle(State(g): State<Shared2>, req: Request) -> axum::response::Response { |
|
| 217 | + | let (parts, body) = req.into_parts(); |
|
| 218 | + | let bytes = http_body_util::BodyExt::collect(body).await.unwrap().to_bytes(); |
|
| 219 | + | let path = parts.uri.path().to_string(); |
|
| 220 | + | if path == "/v1/token" { |
|
| 221 | + | let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); |
|
| 222 | + | let jwt = v["subjectToken"].as_str().unwrap(); |
|
| 223 | + | let payload = jwt.split('.').nth(1).unwrap(); |
|
| 224 | + | let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)).unwrap(); |
|
| 225 | + | let (sub, aud) = (claims["sub"].as_str().unwrap().to_string(), claims["aud"].as_str().unwrap().to_string()); |
|
| 226 | + | let provider = v["audience"].as_str().unwrap(); |
|
| 227 | + | g.lock().unwrap().exchanged.push((sub.clone(), aud.clone())); |
|
| 228 | + | if !sub.starts_with("backup:") || aud != format!("https:{provider}") { |
|
| 229 | + | return ( |
|
| 230 | + | StatusCode::BAD_REQUEST, |
|
| 231 | + | axum::Json(serde_json::json!({"error": "invalid_grant", "error_description": "not admitted"})), |
|
| 232 | + | ) |
|
| 233 | + | .into_response(); |
|
| 234 | + | } |
|
| 235 | + | return axum::Json(serde_json::json!({ "access_token": format!("tok-{sub}"), "expires_in": 3600 })).into_response(); |
|
| 236 | + | } |
|
| 237 | + | let auth = parts.headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
|
| 238 | + | let segs: Vec<&str> = path.split('/').collect(); |
|
| 239 | + | // /storage/v1/b/{bucket}/o[/{name}] or /upload/storage/v1/b/{bucket}/o |
|
| 240 | + | let upload = segs.get(1) == Some(&"upload"); |
|
| 241 | + | let at = if upload { 5 } else { 4 }; |
|
| 242 | + | let bucket = segs.get(at).unwrap_or(&"").to_string(); |
|
| 243 | + | let owner = bucket.trim_end_matches("-backup"); |
|
| 244 | + | if auth != format!("Bearer tok-backup:{owner}") { |
|
| 245 | + | return (StatusCode::FORBIDDEN, "caller does not have storage.objects access").into_response(); |
|
| 246 | + | } |
|
| 247 | + | let mut g = g.lock().unwrap(); |
|
| 248 | + | if upload { |
|
| 249 | + | let name = query(&parts.uri, "name").unwrap(); |
|
| 250 | + | g.objects.insert((bucket, name), bytes.to_vec()); |
|
| 251 | + | return axum::Json(serde_json::json!({})).into_response(); |
|
| 252 | + | } |
|
| 253 | + | let name = segs.get(at + 2).map(|n| decode(n)); |
|
| 254 | + | match (parts.method.as_str(), name) { |
|
| 255 | + | ("GET", Some(n)) => match g.objects.get(&(bucket, n.clone())) { |
|
| 256 | + | Some(b) if query(&parts.uri, "alt").as_deref() == Some("media") => b.clone().into_response(), |
|
| 257 | + | Some(_) => axum::Json(serde_json::json!({ "name": n })).into_response(), |
|
| 258 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 259 | + | }, |
|
| 260 | + | ("DELETE", Some(n)) => { |
|
| 261 | + | g.objects.remove(&(bucket, n)); |
|
| 262 | + | StatusCode::NO_CONTENT.into_response() |
|
| 263 | + | } |
|
| 264 | + | ("GET", None) => { |
|
| 265 | + | let prefix = query(&parts.uri, "prefix").unwrap_or_default(); |
|
| 266 | + | let delimited = query(&parts.uri, "delimiter").is_some(); |
|
| 267 | + | let (mut items, mut prefixes) = (Vec::new(), std::collections::BTreeSet::new()); |
|
| 268 | + | for (b, n) in g.objects.keys() { |
|
| 269 | + | if *b != bucket || !n.starts_with(&prefix) { |
|
| 270 | + | continue; |
|
| 271 | + | } |
|
| 272 | + | let rest = &n[prefix.len()..]; |
|
| 273 | + | match rest.find('/') { |
|
| 274 | + | Some(i) if delimited => { |
|
| 275 | + | prefixes.insert(format!("{prefix}{}", &rest[..=i])); |
|
| 276 | + | } |
|
| 277 | + | _ => items.push(serde_json::json!({ "name": n })), |
|
| 278 | + | } |
|
| 279 | + | } |
|
| 280 | + | axum::Json(serde_json::json!({ "items": items, "prefixes": prefixes })).into_response() |
|
| 281 | + | } |
|
| 282 | + | _ => StatusCode::METHOD_NOT_ALLOWED.into_response(), |
|
| 283 | + | } |
|
| 284 | + | } |
|
| 285 | + | let app = axum::Router::new().fallback(handle).with_state(state.clone()); |
|
| 286 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 287 | + | let port = listener.local_addr().unwrap().port(); |
|
| 288 | + | tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
|
| 289 | + | (format!("http://127.0.0.1:{port}"), state) |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | fn base64url(s: &str) -> Vec<u8> { |
|
| 293 | + | let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; |
|
| 294 | + | let mut bits = 0u32; |
|
| 295 | + | let mut n = 0; |
|
| 296 | + | let mut out = Vec::new(); |
|
| 297 | + | for c in s.bytes() { |
|
| 298 | + | let v = table.iter().position(|t| *t == c).unwrap() as u32; |
|
| 299 | + | bits = (bits << 6) | v; |
|
| 300 | + | n += 6; |
|
| 301 | + | if n >= 8 { |
|
| 302 | + | n -= 8; |
|
| 303 | + | out.push((bits >> n) as u8); |
|
| 304 | + | bits &= (1 << n) - 1; |
|
| 305 | + | } |
|
| 306 | + | } |
|
| 307 | + | out |
|
| 308 | + | } |
|
| 309 | + | ||
| 310 | + | const PROVIDER: &str = "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io"; |
|
| 311 | + | ||
| 312 | + | fn gcs(owner: &str, bucket: &str) -> Setting { |
|
| 313 | + | let mut s = Setting::off(owner, "user"); |
|
| 314 | + | s.every = "hourly".into(); |
|
| 315 | + | s.destination = "gcs".into(); |
|
| 316 | + | s.target = bucket.into(); |
|
| 317 | + | s.provider = PROVIDER.into(); |
|
| 318 | + | s |
|
| 319 | + | } |
|
| 320 | + | ||
| 321 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 322 | + | async fn a_gcs_backup_needs_no_key_and_restores_the_same_repository() { |
|
| 323 | + | let (google, seen) = fake_google().await; |
|
| 324 | + | let store = tempfile::tempdir().unwrap(); |
|
| 325 | + | let h = hub_with(store.path(), Some(&google)).await; |
|
| 326 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 327 | + | db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 328 | + | create(&h.app, "andy", "demo", hub_api::Visibility::Public).await; |
|
| 329 | + | let work = tempfile::tempdir().unwrap(); |
|
| 330 | + | push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]); |
|
| 331 | + | ||
| 332 | + | backups::save(&h.app.db, &gcs("andy", "andy-backup")).await.unwrap(); |
|
| 333 | + | let first = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 334 | + | assert_eq!((first.status.as_str(), first.repos), ("ok", 1), "{}", first.detail); |
|
| 335 | + | { |
|
| 336 | + | let g = seen.lock().unwrap(); |
|
| 337 | + | assert!(g.objects.keys().any(|(b, n)| b == "andy-backup" && n == &format!("runs/{}/DONE", first.at))); |
|
| 338 | + | assert!(g.objects.keys().all(|(b, _)| b == "andy-backup")); |
|
| 339 | + | let (sub, aud) = g.exchanged.first().unwrap(); |
|
| 340 | + | assert_eq!( |
|
| 341 | + | (sub.as_str(), aud.as_str()), |
|
| 342 | + | ("backup:andy", format!("https:{PROVIDER}").as_str()), |
|
| 343 | + | "the hub signs in as the owner, for the provider" |
|
| 344 | + | ); |
|
| 345 | + | } |
|
| 346 | + | tokio::time::sleep(std::time::Duration::from_millis(1100)).await; |
|
| 347 | + | let second = backups::run(&h.app, "andy", "now").await.unwrap(); |
|
| 348 | + | assert_eq!((second.status.as_str(), second.sent), ("ok", 0), "{}", second.detail); |
|
| 349 | + | // The probe writes, reads and removes; the bucket is as it was. |
|
| 350 | + | let before = seen.lock().unwrap().objects.len(); |
|
| 351 | + | let dest = backups::destination(&h.app, &gcs("andy", "andy-backup")).unwrap(); |
|
| 352 | + | tokio::task::spawn_blocking(move || backups::probe(dest.as_ref())).await.unwrap().unwrap(); |
|
| 353 | + | assert_eq!(seen.lock().unwrap().objects.len(), before); |
|
| 354 | + | ||
| 355 | + | // Restored from the bucket into a fresh hub: the same repository. |
|
| 356 | + | let empty = tempfile::tempdir().unwrap(); |
|
| 357 | + | let fresh = hub_with(empty.path(), Some(&google)).await; |
|
| 358 | + | let dest: Arc<dyn Destination> = backups::destination(&fresh.app, &gcs("andy", "andy-backup")).unwrap().into(); |
|
| 359 | + | assert_eq!(backups::restore_into_hub(&fresh.app, dest, None).await.unwrap(), vec!["andy/demo (repos)"]); |
|
| 360 | + | let name = RepoName::parse("andy/demo").unwrap(); |
|
| 361 | + | assert_eq!(files(&fresh.app.repos.dir(&name)), files(&h.app.repos.dir(&name)), "byte for byte"); |
|
| 362 | + | ||
| 363 | + | // Bob cannot write Andy's bucket: Google refuses his token, and the |
|
| 364 | + | // run says what to grant. |
|
| 365 | + | backups::save(&h.app.db, &gcs("bob", "andy-backup")).await.unwrap(); |
|
| 366 | + | let refused = backups::run(&h.app, "bob", "now").await.unwrap(); |
|
| 367 | + | assert_eq!(refused.status, "failed"); |
|
| 368 | + | assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail); |
|
| 165 | 369 | } |
modified docs/backups.md +15 −11
| 1 | 1 | # Backups: per site, organisation, user and repository |
|
| 2 | 2 | ||
| 3 | - | Status: BK2 built 2026-10-13 (oxygenhub#169): user and organisation |
|
| 4 | - | settings, the hourly and daily schedule with "back up now", directory |
|
| 5 | - | destinations, every repository's and wiki's store byte for byte, the |
|
| 6 | - | records as JSON, retention, failure notices and restore of the stores and |
|
| 7 | - | repository rows (`oxygenhub admin backup run|restore`). Next: the GCS |
|
| 8 | - | destination (BK3), then restoring issues and proposals from the records. Hourly backups configured by a |
|
| 9 | - | user and by an organisation come first; finer-grained ones (per |
|
| 10 | - | repository, more often than hourly) later. The site's own backup |
|
| 11 | - | (everything that runs a hub, #168) is an operations script today |
|
| 12 | - | (`~/backups/oxygenhub/backup.sh`); Admin › Backups shows it later. |
|
| 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. |
|
| 13 | 10 | ||
| 14 | 11 | ## 1. Why |
|
| 15 | 12 | ||
| … | 39 unchanged lines |
||
| 55 | 52 | A **destination** is one of: |
|
| 56 | 53 | ||
| 57 | 54 | - **A GCS bucket, keylessly.** The hub is already an OIDC issuer (spec |
|
| 58 | - | §13.5). For a backup it mints a short-lived token with |
|
| 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 |
|
| 59 | 59 | `sub = backup:OWNER` and audience the owner's workload identity |
|
| 60 | 60 | provider. Google's STS exchanges it for a short-lived access token, |
|
| 61 | 61 | which writes to the bucket. Nothing secret is stored: no service |
|
| … | 77 unchanged lines |
||
| 139 | 139 | --member="principal://iam.googleapis.com/projects/$NUM/locations/global/workloadIdentityPools/oxygenhub/subject/backup:andymccall" |
|
| 140 | 140 | ``` |
|
| 141 | 141 | ||
| 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 | + | ||
| 142 | 146 | Then, in Settings › Backups: destination GCS, bucket |
|
| 143 | 147 | `andymccall-oxygen-backup`, provider |
|
| 144 | 148 | `//iam.googleapis.com/projects/NUM/locations/global/workloadIdentityPools/oxygenhub/providers/oxygenhub-io`, |
|
| … | 28 unchanged lines |
modified docs/spec.md +2 −2
| … | 579 unchanged lines |
||
| 580 | 580 | - **Masking.** Log intake replaces any token this hub signed (recognised by its `kid`) with `***`, as it does secret values. |
|
| 581 | 581 | - **Trust, for example.** AWS: an IAM OIDC provider with the hub's URL and audience `sts.amazonaws.com`, a role whose trust policy matches `sub` `repo:andymccall/metalgear:bookmark:main`; the job then calls `aws sts assume-role-with-web-identity --web-identity-token file:///run/secrets/aws-token`. Google: a workload identity pool provider with the same issuer, mapping `assertion.sub`. HashiCorp Vault: the JWT auth method with `bound_audiences` and `bound_claims` on `repository`. |
|
| 582 | 582 | ||
| 583 | - | ### 13.5.1 Backups (oxygenhub#169: directory destinations built 2026-10-13, GCS next) |
|
| 583 | + | ### 13.5.1 Backups (oxygenhub#169, built 2026-10-13/14) |
|
| 584 | 584 | ||
| 585 | - | Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. The design, the layout at a destination, setup and restore are in `docs/backups.md`. |
|
| 585 | + | Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. A GCS destination is keyless: the hub mints a 10-minute token (`Issuer::mint_for`, `sub = backup:OWNER`, `aud` the provider's name over https), Google's STS exchanges it (`[backups] sts_url`) for an access token scoped `devstorage.read_write`, used with the Cloud Storage JSON API (`[backups] storage_url`) and exchanged again before it expires; TEST writes, reads and removes a probe. The design, the layout at a destination, setup and restore are in `docs/backups.md`. |
|
| 586 | 586 | ||
| 587 | 587 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 588 | 588 | ||
| … | 168 unchanged lines |
modified templates/backups.html +2 −1
| … | 37 unchanged lines |
||
| 38 | 38 | ||
| 39 | 39 | <section class="card-box" id="runs"> |
|
| 40 | 40 | <div class="card-head"><h2>Runs</h2><span class="count-chip">{{ runs.len() }}</span> |
|
| 41 | - | <form class="inline more" method="post" action="{{ base }}/run"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small primary-btn"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %}>Back up now</button></form> |
|
| 41 | + | <form class="inline more" method="post" action="{{ base }}/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %} title="Write, read and remove a probe at the destination">TEST</button></form> |
|
| 42 | + | <form class="inline" method="post" action="{{ base }}/run"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small primary-btn"{% if setting.updated_ms == 0 %} disabled title="Save where backups go first"{% endif %}>Back up now</button></form> |
|
| 42 | 43 | </div> |
|
| 43 | 44 | {% if runs.is_empty() %}<p class="empty small">No runs yet.{% if setting.every == "off" %} Pick how often and where, then save.{% endif %}</p> |
|
| 44 | 45 | {% else %} |
|
| … | 17 unchanged lines |