Diffusion: turning it on e71583501b1d
A consumer follows a subrepo's upstream as its .oxymodules entry says
(follow, land, target, watch), else as its repository setting says
(Settings › Diffusion, or PUT /repos/{owner}/{name}/diffusion by its owners
and admins), else as [diffusion] follow in hub.toml says, off by default.
refs #148
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-12 21:58 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e71583501b1d28e733327b161531c4e6899cdf30605e7dfa27dc6b32733b98ba- Depends on
- 19bb31ff1f5c 1f78655a0466 42fa95540468 9008bf6c2b8c 93be9ce4653e b64e2537d702 cc19697f8871 d0fbea2eb46e d23464c0ac60 ea85398a51bc f677d6270298
- Depended on by
- 3e868a1f94e1 c7067c7bda56 a9f1c0bf403a 4a68f1064b08 107eaba52c9d 5d90e0e045e2 62cdf34daec1 5360cea36c30 d3b048b27277 f4d6ef418a96 d7d182467382 eb2a3502601d c4e432da4bc9 3776b70d9b17 a7e6e8e6094c 1d634f698ecd
- In bookmarks
- api-unknown-fields cancel-landed-runs close-proposal-140 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
- 21 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0058_diffusion_setting.sql +5 −0
| 1 | + | -- Diffusion (#148, docs/diffusion.md §4.2): whether this repository follows |
|
| 2 | + | -- the upstreams it pins. Empty follows hub.toml's [diffusion] follow; |
|
| 3 | + | -- otherwise off, green (when the upstream's checks pass) or any (on |
|
| 4 | + | -- every move). A repository's .oxymodules keys win over this. |
|
| 5 | + | ALTER TABLE repositories ADD COLUMN diffusion TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/api/mod.rs +37 −0
| … | 84 unchanged lines |
||
| 85 | 85 | .route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) |
|
| 86 | 86 | .route("/repos/{owner}/{name}/tags", get(tags)) |
|
| 87 | 87 | .route("/repos/{owner}/{name}/consumers", get(consumers)) |
|
| 88 | + | .route("/repos/{owner}/{name}/diffusion", axum::routing::put(set_diffusion)) |
|
| 88 | 89 | .route("/repos/{owner}/{name}/history/{r}", get(history)) |
|
| 89 | 90 | .route("/repos/{owner}/{name}/changes/{hash}", get(change)) |
|
| 90 | 91 | .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) |
|
| … | 477 unchanged lines |
||
| 568 | 569 | Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) |
|
| 569 | 570 | } |
|
| 570 | 571 | ||
| 572 | + | #[derive(serde::Deserialize)] |
|
| 573 | + | struct DiffusionSetting { |
|
| 574 | + | /// `off`, `green`, `any`, or `default` (the site's). |
|
| 575 | + | follow: String, |
|
| 576 | + | } |
|
| 577 | + | ||
| 578 | + | /// `PUT /repos/{owner}/{name}/diffusion {"follow": ...}`: whether this |
|
| 579 | + | /// repository follows the upstreams it pins (#148). Owners and admins. |
|
| 580 | + | async fn set_diffusion( |
|
| 581 | + | State(app): State<Shared>, |
|
| 582 | + | headers: HeaderMap, |
|
| 583 | + | Path((owner, name)): Path<(String, String)>, |
|
| 584 | + | Json(d): Json<DiffusionSetting>, |
|
| 585 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 586 | + | let c = caller(&app, &headers).await?; |
|
| 587 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
|
| 588 | + | let setting = match d.follow.as_str() { |
|
| 589 | + | "off" | "green" | "any" => d.follow.as_str(), |
|
| 590 | + | "default" => "", |
|
| 591 | + | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "follow is off, green, any or default".into())), |
|
| 592 | + | }; |
|
| 593 | + | db::repos::set_diffusion(&app.db, row.id, setting).await?; |
|
| 594 | + | db::accounts::audit( |
|
| 595 | + | &app.db, |
|
| 596 | + | c.account.as_ref(), |
|
| 597 | + | "repo.diffusion", |
|
| 598 | + | &format!("{owner}/{name}"), |
|
| 599 | + | if setting.is_empty() { "default" } else { setting }, |
|
| 600 | + | ) |
|
| 601 | + | .await |
|
| 602 | + | .ok(); |
|
| 603 | + | let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row); |
|
| 604 | + | let rule = crate::diffusion::rule(&app.config.diffusion.follow, &row, None, ""); |
|
| 605 | + | Ok(Json(serde_json::json!({ "follow": rule.follow, "from": rule.from }))) |
|
| 606 | + | } |
|
| 607 | + | ||
| 571 | 608 | /// `GET /repos/{owner}/{name}/consumers`: the repositories that pin this |
|
| 572 | 609 | /// one as a subrepo and that the caller may read, each with its pin and |
|
| 573 | 610 | /// how far behind this repository's default bookmark it is (#148). |
|
| … | 1848 unchanged lines |
modified crates/hub/src/config.rs +17 −0
| … | 20 unchanged lines |
||
| 21 | 21 | pub retention: Retention, |
|
| 22 | 22 | pub flow: Flow, |
|
| 23 | 23 | pub apt: Apt, |
|
| 24 | + | pub diffusion: Diffusion, |
|
| 24 | 25 | } |
|
| 25 | 26 | ||
| 27 | + | /// Diffusion (#148, docs/diffusion.md §4.2): the site's default for |
|
| 28 | + | /// repositories that say nothing. |
|
| 29 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 30 | + | #[serde(default, deny_unknown_fields)] |
|
| 31 | + | pub struct Diffusion { |
|
| 32 | + | /// `off`, `green` or `any`. |
|
| 33 | + | pub follow: String, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl Default for Diffusion { |
|
| 37 | + | fn default() -> Self { |
|
| 38 | + | Diffusion { follow: "off".into() } |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 26 | 42 | /// The apt repository at `/apt` (oxygenhub#85): the `.deb` assets of these |
|
| 27 | 43 | /// public repositories' releases, signed with the key in `gnupg_home`. |
|
| 28 | 44 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 216 unchanged lines |
||
| 245 | 261 | retention: Retention::default(), |
|
| 246 | 262 | flow: Flow::default(), |
|
| 247 | 263 | apt: Apt::default(), |
|
| 264 | + | diffusion: Diffusion::default(), |
|
| 248 | 265 | } |
|
| 249 | 266 | } |
|
| 250 | 267 | } |
|
| … | 123 unchanged lines |
modified crates/hub/src/db/repos.rs +10 −1
| … | 3 unchanged lines |
||
| 4 | 4 | use hub_api::Visibility; |
|
| 5 | 5 | use sqlx::FromRow; |
|
| 6 | 6 | ||
| 7 | - | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | #[derive(Clone, Debug, Default, FromRow)] |
|
| 8 | 8 | pub struct RepoRow { |
|
| 9 | 9 | pub id: i64, |
|
| 10 | 10 | pub owner: String, |
|
| … | 16 unchanged lines |
||
| 27 | 27 | /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018). |
|
| 28 | 28 | #[sqlx(default)] |
|
| 29 | 29 | pub flow: String, |
|
| 30 | + | /// Diffusion: `off`, `green`, `any`, or empty to follow hub.toml |
|
| 31 | + | /// (migration 0058, #148). |
|
| 32 | + | #[sqlx(default)] |
|
| 33 | + | pub diffusion: String, |
|
| 30 | 34 | /// Read-only, with why (migration 0040, #108). |
|
| 31 | 35 | #[sqlx(default)] |
|
| 32 | 36 | pub archived: bool, |
|
| … | 262 unchanged lines |
||
| 295 | 299 | /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). |
|
| 296 | 300 | pub async fn set_flow(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 297 | 301 | sqlx::query("UPDATE repositories SET flow = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; |
|
| 302 | + | Ok(()) |
|
| 303 | + | } |
|
| 304 | + | ||
| 305 | + | pub async fn set_diffusion(pool: &Pool, id: i64, setting: &str) -> sqlx::Result<()> { |
|
| 306 | + | sqlx::query("UPDATE repositories SET diffusion = ? WHERE id = ?").bind(setting).bind(id).execute(pool).await?; |
|
| 298 | 307 | Ok(()) |
|
| 299 | 308 | } |
|
| 300 | 309 | ||
| … | 24 unchanged lines |
modified crates/hub/src/diffusion.rs +146 −0
| … | 165 unchanged lines |
||
| 166 | 166 | } |
|
| 167 | 167 | Ok(out) |
|
| 168 | 168 | } |
|
| 169 | + | ||
| 170 | + | /// When a consumer takes an upstream's moves (§4.2). |
|
| 171 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
|
| 172 | + | #[serde(rename_all = "lowercase")] |
|
| 173 | + | pub enum Follow { |
|
| 174 | + | Off, |
|
| 175 | + | /// When the upstream state's checks pass. |
|
| 176 | + | Green, |
|
| 177 | + | /// On every move. |
|
| 178 | + | Any, |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | impl Follow { |
|
| 182 | + | pub fn parse(s: &str) -> Option<Follow> { |
|
| 183 | + | match s.trim() { |
|
| 184 | + | "off" => Some(Follow::Off), |
|
| 185 | + | "green" => Some(Follow::Green), |
|
| 186 | + | "any" => Some(Follow::Any), |
|
| 187 | + | _ => None, |
|
| 188 | + | } |
|
| 189 | + | } |
|
| 190 | + | } |
|
| 191 | + | ||
| 192 | + | /// How one consumer follows one subrepo, from its `.oxymodules` entry, its |
|
| 193 | + | /// repository setting and the site's default, in that order. |
|
| 194 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
|
| 195 | + | pub struct Rule { |
|
| 196 | + | pub follow: Follow, |
|
| 197 | + | /// Land a green bump itself (D5); D1 only proposes. |
|
| 198 | + | pub land_auto: bool, |
|
| 199 | + | /// The bookmark to bump, when `.oxymodules` names one (else learned). |
|
| 200 | + | pub target: Option<String>, |
|
| 201 | + | /// Paths the consumer watches: upstream changes touching them are |
|
| 202 | + | /// flagged in a bump's changelog. |
|
| 203 | + | pub watch: Vec<String>, |
|
| 204 | + | /// Where `follow` came from: `.oxymodules`, `setting` or `site`. |
|
| 205 | + | pub from: &'static str, |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// The Diffusion keys of `path`'s entry in `.oxymodules` text: `follow`, |
|
| 209 | + | /// `land`, `target` and `watch` (comma-separated, or a TOML list). |
|
| 210 | + | pub fn module_keys(text: &str, path: &str) -> (Option<Follow>, Option<bool>, Option<String>, Vec<String>) { |
|
| 211 | + | let (mut follow, mut land, mut target, mut watch) = (None, None, None, Vec::new()); |
|
| 212 | + | let mut here = false; |
|
| 213 | + | for line in text.lines() { |
|
| 214 | + | let line = line.trim(); |
|
| 215 | + | if line.is_empty() || line.starts_with('#') { |
|
| 216 | + | continue; |
|
| 217 | + | } |
|
| 218 | + | if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { |
|
| 219 | + | here = inner.trim().trim_matches('"').trim_end_matches('/') == path.trim_end_matches('/'); |
|
| 220 | + | continue; |
|
| 221 | + | } |
|
| 222 | + | let (true, Some((k, v))) = (here, line.split_once('=')) else { continue }; |
|
| 223 | + | let v = v.trim(); |
|
| 224 | + | let unquoted = v.trim_matches('"').trim(); |
|
| 225 | + | match k.trim() { |
|
| 226 | + | "follow" => follow = Follow::parse(unquoted), |
|
| 227 | + | "land" => land = Some(unquoted == "auto"), |
|
| 228 | + | "target" if !unquoted.is_empty() => target = Some(unquoted.to_string()), |
|
| 229 | + | "watch" => { |
|
| 230 | + | watch = v |
|
| 231 | + | .trim_start_matches('[') |
|
| 232 | + | .trim_end_matches(']') |
|
| 233 | + | .split(',') |
|
| 234 | + | .map(|w| w.trim().trim_matches('"').trim().to_string()) |
|
| 235 | + | .filter(|w| !w.is_empty()) |
|
| 236 | + | .collect() |
|
| 237 | + | } |
|
| 238 | + | _ => {} |
|
| 239 | + | } |
|
| 240 | + | } |
|
| 241 | + | (follow, land, target, watch) |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | /// How `row` follows its subrepo at `path`, given its `.oxymodules` text. |
|
| 245 | + | pub fn rule(site_default: &str, row: &RepoRow, oxymodules: Option<&str>, path: &str) -> Rule { |
|
| 246 | + | let (follow, land, target, watch) = oxymodules.map(|t| module_keys(t, path)).unwrap_or_default(); |
|
| 247 | + | let (follow, from) = match follow { |
|
| 248 | + | Some(f) => (f, ".oxymodules"), |
|
| 249 | + | None => match Follow::parse(&row.diffusion) { |
|
| 250 | + | Some(f) => (f, "setting"), |
|
| 251 | + | None => (Follow::parse(site_default).unwrap_or(Follow::Off), "site"), |
|
| 252 | + | }, |
|
| 253 | + | }; |
|
| 254 | + | Rule { follow, land_auto: land.unwrap_or(false), target, watch, from } |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | #[cfg(test)] |
|
| 258 | + | mod tests { |
|
| 259 | + | use super::*; |
|
| 260 | + | ||
| 261 | + | const MODULES: &str = r#" |
|
| 262 | + | [rstar-api] |
|
| 263 | + | url = https://oxygenhub.io/doomsdayone/rstar-api |
|
| 264 | + | follow = green |
|
| 265 | + | land = "propose" |
|
| 266 | + | target = develop |
|
| 267 | + | watch = ["src/x16/irq*", "src/x16/frame*"] |
|
| 268 | + | ||
| 269 | + | [other] |
|
| 270 | + | url = https://example/other |
|
| 271 | + | follow = any |
|
| 272 | + | "#; |
|
| 273 | + | ||
| 274 | + | fn row(diffusion: &str) -> RepoRow { |
|
| 275 | + | RepoRow { |
|
| 276 | + | id: 1, |
|
| 277 | + | owner: "o".into(), |
|
| 278 | + | name: "n".into(), |
|
| 279 | + | description: String::new(), |
|
| 280 | + | default_bookmark: "main".into(), |
|
| 281 | + | visibility: "public".into(), |
|
| 282 | + | created_ms: 0, |
|
| 283 | + | updated_ms: 0, |
|
| 284 | + | git_mirror: false, |
|
| 285 | + | git_mirror_state: String::new(), |
|
| 286 | + | forked_from: None, |
|
| 287 | + | topics: String::new(), |
|
| 288 | + | flow: String::new(), |
|
| 289 | + | diffusion: diffusion.into(), |
|
| 290 | + | ..Default::default() |
|
| 291 | + | } |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | #[test] |
|
| 295 | + | fn oxymodules_keys_are_read_per_entry() { |
|
| 296 | + | let (f, land, target, watch) = module_keys(MODULES, "rstar-api"); |
|
| 297 | + | assert_eq!((f, land, target.as_deref()), (Some(Follow::Green), Some(false), Some("develop"))); |
|
| 298 | + | assert_eq!(watch, vec!["src/x16/irq*", "src/x16/frame*"]); |
|
| 299 | + | assert_eq!(module_keys(MODULES, "other").0, Some(Follow::Any)); |
|
| 300 | + | assert_eq!(module_keys(MODULES, "missing"), (None, None, None, vec![])); |
|
| 301 | + | assert_eq!(module_keys("[a]\nwatch = x, y\n", "a").3, vec!["x", "y"]); |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | #[test] |
|
| 305 | + | fn oxymodules_wins_then_the_setting_then_the_site() { |
|
| 306 | + | let r = rule("off", &row("any"), Some(MODULES), "rstar-api"); |
|
| 307 | + | assert_eq!((r.follow, r.from), (Follow::Green, ".oxymodules")); |
|
| 308 | + | let r = rule("off", &row("any"), Some("[rstar-api]\nurl = x\n"), "rstar-api"); |
|
| 309 | + | assert_eq!((r.follow, r.from), (Follow::Any, "setting")); |
|
| 310 | + | let r = rule("green", &row(""), None, "rstar-api"); |
|
| 311 | + | assert_eq!((r.follow, r.from), (Follow::Green, "site")); |
|
| 312 | + | assert_eq!(rule("nonsense", &row(""), None, "x").follow, Follow::Off); |
|
| 313 | + | } |
|
| 314 | + | } |
modified crates/hub/src/web/mod.rs +1 −0
| … | 254 unchanged lines |
||
| 255 | 255 | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 256 | 256 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 257 | 257 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 258 | + | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 258 | 259 | .route("/{owner}/{name}/settings/storage", post(repo_settings::storage)) |
|
| 259 | 260 | .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage)) |
|
| 260 | 261 | .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token)) |
|
| … | 476 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +49 −0
| … | 170 unchanged lines |
||
| 171 | 171 | required_checks: String, |
|
| 172 | 172 | /// Whether hub.toml alone would run Flow here, and whether it runs. |
|
| 173 | 173 | flow_default: bool, |
|
| 174 | + | /// hub.toml's `[diffusion] follow` (#148). |
|
| 175 | + | diffusion_site: String, |
|
| 174 | 176 | flow_enabled: bool, |
|
| 175 | 177 | site_admin: bool, |
|
| 176 | 178 | notice: Option<String>, |
|
| … | 34 unchanged lines |
||
| 211 | 213 | let hooks = super::webhooks::rows(app, hooks).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 212 | 214 | let required_checks = db::hooks::required_checks(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.join(", "); |
|
| 213 | 215 | let flow_default = app.config.flow.allows(&row.owner, &row.name); |
|
| 216 | + | let diffusion_site = app.config.diffusion.follow.clone(); |
|
| 214 | 217 | let flow_enabled = crate::flow::enabled(app, &row); |
|
| 215 | 218 | let site_admin = shell.is_admin; |
|
| 216 | 219 | let runners = db::runners::serving(&app.db, &row.owner, row.id) |
|
| … | 35 unchanged lines |
||
| 252 | 255 | hooks, |
|
| 253 | 256 | required_checks, |
|
| 254 | 257 | flow_default, |
|
| 258 | + | diffusion_site, |
|
| 255 | 259 | flow_enabled, |
|
| 256 | 260 | site_admin, |
|
| 257 | 261 | notice, |
|
| … | 477 unchanged lines |
||
| 735 | 739 | } |
|
| 736 | 740 | ||
| 737 | 741 | #[derive(Deserialize)] |
|
| 742 | + | pub struct DiffusionForm { |
|
| 743 | + | #[serde(rename = "_csrf")] |
|
| 744 | + | csrf_token: String, |
|
| 745 | + | /// `off`, `green`, `any`, or `default`. |
|
| 746 | + | diffusion: String, |
|
| 747 | + | } |
|
| 748 | + | ||
| 749 | + | /// Whether this repository follows the upstreams it pins (Diffusion, |
|
| 750 | + | /// #148): its owners and admins decide, since it opens proposals here. |
|
| 751 | + | pub async fn diffusion( |
|
| 752 | + | State(app): State<Shared>, |
|
| 753 | + | viewer: Viewer, |
|
| 754 | + | Path((owner, name)): Path<(String, String)>, |
|
| 755 | + | Form(f): Form<DiffusionForm>, |
|
| 756 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 757 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 758 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 759 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 760 | + | let setting = match f.diffusion.as_str() { |
|
| 761 | + | "off" | "green" | "any" => f.diffusion.as_str(), |
|
| 762 | + | "default" => "", |
|
| 763 | + | _ => return settings_page(&app, shell, row, role, None, Some("Pick off, green, any or the site default.".into())).await, |
|
| 764 | + | }; |
|
| 765 | + | db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 766 | + | accounts::audit( |
|
| 767 | + | &app.db, |
|
| 768 | + | viewer.account(), |
|
| 769 | + | "repo.diffusion", |
|
| 770 | + | &format!("{owner}/{name}"), |
|
| 771 | + | if setting.is_empty() { "default" } else { setting }, |
|
| 772 | + | ) |
|
| 773 | + | .await |
|
| 774 | + | .ok(); |
|
| 775 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 776 | + | let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow { |
|
| 777 | + | crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.", |
|
| 778 | + | crate::diffusion::Follow::Green => { |
|
| 779 | + | "Diffusion is on: when a subrepo's upstream moves and its checks pass, a proposal moves the pin here." |
|
| 780 | + | } |
|
| 781 | + | crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.", |
|
| 782 | + | }; |
|
| 783 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 784 | + | } |
|
| 785 | + | ||
| 786 | + | #[derive(Deserialize)] |
|
| 738 | 787 | pub struct FlowForm { |
|
| 739 | 788 | #[serde(rename = "_csrf")] |
|
| 740 | 789 | csrf_token: String, |
|
| … | 171 unchanged lines |
modified crates/hub/tests/flow.rs +13 −0
| … | 3113 unchanged lines |
||
| 3114 | 3114 | #[tokio::test(flavor = "multi_thread")] |
|
| 3115 | 3115 | async fn consumers_of_a_subrepo_are_indexed_with_how_far_behind_they_are() { |
|
| 3116 | 3116 | let h = hub().await; |
|
| 3117 | + | // The site token acts as the first site administrator. |
|
| 3118 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 3117 | 3119 | create(&h.app, "andy", "api").await; |
|
| 3118 | 3120 | create(&h.app, "andy", "game").await; |
|
| 3119 | 3121 | let api_work = tempfile::tempdir().unwrap(); |
|
| … | 55 unchanged lines |
||
| 3175 | 3177 | let (_, page) = call(&h.app, "GET", "/andy/api", None, None).await; |
|
| 3176 | 3178 | assert!(!page.contains("andy/game")); |
|
| 3177 | 3179 | sqlx::query("UPDATE repositories SET visibility = 'public' WHERE name = 'game'").execute(&h.app.db).await.unwrap(); |
|
| 3180 | + | ||
| 3181 | + | // Following is off by default; owners turn it on (DF2). |
|
| 3182 | + | let put = |
|
| 3183 | + | |body: &'static str, who: Option<&'static str>| call(&h.app, "PUT", "/api/v1/repos/andy/game/diffusion", who, Some(body.into())); |
|
| 3184 | + | let (st, body) = put(r#"{"follow":"green"}"#, Some("site")).await; |
|
| 3185 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 3186 | + | assert!(body.contains("\"follow\":\"green\"") && body.contains("\"from\":\"setting\""), "{body}"); |
|
| 3187 | + | assert_eq!(put(r#"{"follow":"sometimes"}"#, Some("site")).await.0, StatusCode::BAD_REQUEST); |
|
| 3188 | + | assert_ne!(put(r#"{"follow":"any"}"#, None).await.0, StatusCode::OK, "not without a token"); |
|
| 3189 | + | let (_, body) = put(r#"{"follow":"default"}"#, Some("site")).await; |
|
| 3190 | + | assert!(body.contains("\"follow\":\"off\"") && body.contains("\"from\":\"site\""), "{body}"); |
|
| 3178 | 3191 | ||
| 3179 | 3192 | // Dropping the subrepo drops the consumer. |
|
| 3180 | 3193 | game.submodule_remove("deps/api").unwrap(); |
|
| … | 7 unchanged lines |
modified docs/spec.md +1 −1
| … | 514 unchanged lines |
||
| 515 | 515 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 516 | 516 | ||
| 517 | 517 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 518 | - | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 518 | + | - **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409). |
|
| 519 | 519 | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 520 | 520 | - **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value. |
|
| 521 | 521 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| … | 231 unchanged lines |
modified templates/repo_settings.html +13 −0
| … | 82 unchanged lines |
||
| 83 | 83 | <p class="small mute">A site administrator turns OxygenFlow on or off here.</p> |
|
| 84 | 84 | {% endif %} |
|
| 85 | 85 | </form> |
|
| 86 | + | <form class="card wide" id="diffusion" method="post" action="{{ repo.url }}/settings/diffusion"> |
|
| 87 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 88 | + | <h2>Diffusion</h2> |
|
| 89 | + | <p>When a repository this one pins as a subrepo moves on, Diffusion opens a proposal here that moves the pin, tested by this repository's own pipeline, with the upstream's changes listed. It never lands anything by itself.</p> |
|
| 90 | + | <div class="choice"> |
|
| 91 | + | <label><input type="radio" name="diffusion" value="green"{% if row.diffusion == "green" %} checked{% endif %}> when the upstream's checks pass</label> |
|
| 92 | + | <label><input type="radio" name="diffusion" value="any"{% if row.diffusion == "any" %} checked{% endif %}> on every upstream move</label> |
|
| 93 | + | <label><input type="radio" name="diffusion" value="off"{% if row.diffusion == "off" %} checked{% endif %}> off</label> |
|
| 94 | + | <label><input type="radio" name="diffusion" value="default"{% if row.diffusion.is_empty() %} checked{% endif %}> site default ({{ diffusion_site }}, from <code>[diffusion] follow</code> in <code>hub.toml</code>)</label> |
|
| 95 | + | </div> |
|
| 96 | + | <p class="small mute">A subrepo's <code>.oxymodules</code> entry overrides this with <code>follow = green</code>, <code>any</code> or <code>off</code>; <code>target</code> names the bookmark to move (else the one this repository's proposals land on) and <code>watch</code> lists paths whose upstream changes are flagged.</p> |
|
| 97 | + | <button type="submit">SAVE</button> |
|
| 98 | + | </form> |
|
| 86 | 99 | {% if role == crate::auth::Role::Admin %} |
|
| 87 | 100 | <form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage"> |
|
| 88 | 101 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 123 unchanged lines |