Runner updates: policy, test groups and the offer 8501fd5815b9
Admin > Runners > Updates: where runner builds come from (a repository's releases, flow-OS-ARCH files) and who takes them. Off unless chosen; the site, an owner, a test group and a runner each may set updates, channel, window and dry run, the nearest setting winning. Test groups are chosen runners or a percentage by a stable hash of runner ids. GET /api/v1/flow/runner/update tells a runner its policy and the build to install; POST takes what it did. Migration 0095. Pins OxygenFlow main. refs #322
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-26 01:23 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
8501fd5815b9e83cd465032461acf282f4538f115450cef2e0272e12284fd83b- Depends on
- 166f6e9bf4f1 19bb31ff1f5c 380996db84e5 3b1bc27fe805 42fa95540468 4ac01a732552 4d2e44d79b03 5d90e0e045e2 5dead1f4f26d 6ca0b971ff07 737de0fac722 750579ecc721 a7e6e8e6094c a9f1c0bf403a b5668108487f b6b7e4cf6b24 c2efc0b4674d c5f3bde466d1 d23464c0ac60 e4203e141e90 e97500b70f9a e9b9619cd857
- Depended on by
- 2f79b17e089a c26267b3aba6 1ec29691b67a dd0788b756f2 9bbca80f777a faf5246c900e dc5c64296b0a b6dfcb61cf5e d7050703d2f4
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 29 atoms, 6 tree operations, 20 files
Browse the tree at this change
added crates/hub/migrations-pg/0095_runner_updates.sql +36 −0
| 1 | + | -- Runner updates (oxygenhub#322, OxygenFlow docs/jetstream.md §8.2). The |
|
| 2 | + | -- policy of the site (scope 'site', key ''), an owner ('owner', its name), |
|
| 3 | + | -- a test group ('group', its id) or a runner ('runner', its id). A NULL |
|
| 4 | + | -- inherits from the next level out; the nearest setting wins; off unless |
|
| 5 | + | -- some level turns updates on. |
|
| 6 | + | CREATE TABLE runner_update_policies ( |
|
| 7 | + | scope TEXT NOT NULL, |
|
| 8 | + | key TEXT NOT NULL, |
|
| 9 | + | updates BOOLEAN, |
|
| 10 | + | -- 'stable', 'canary' or a version. |
|
| 11 | + | channel TEXT, |
|
| 12 | + | -- Hours (UTC) in which updates may start: from, to (to may wrap past midnight). |
|
| 13 | + | window_from BIGINT, |
|
| 14 | + | window_to BIGINT, |
|
| 15 | + | dry_run BOOLEAN, |
|
| 16 | + | PRIMARY KEY (scope, key) |
|
| 17 | + | ); |
|
| 18 | + | -- Test groups: named sets of the site's runners (owner '') or one owner's. |
|
| 19 | + | -- A runner is in one when chosen, or when a stable hash of its id falls in |
|
| 20 | + | -- the group's percentage, so the same machines are in it every rollout. |
|
| 21 | + | CREATE TABLE runner_groups ( |
|
| 22 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 23 | + | owner TEXT NOT NULL, |
|
| 24 | + | name TEXT NOT NULL, |
|
| 25 | + | percent BIGINT, |
|
| 26 | + | created_ms BIGINT NOT NULL, |
|
| 27 | + | UNIQUE (owner, name) |
|
| 28 | + | ); |
|
| 29 | + | CREATE TABLE runner_group_members ( |
|
| 30 | + | group_id BIGINT NOT NULL REFERENCES runner_groups (id) ON DELETE CASCADE, |
|
| 31 | + | runner_id BIGINT NOT NULL REFERENCES flow_runners (id) ON DELETE CASCADE, |
|
| 32 | + | PRIMARY KEY (group_id, runner_id) |
|
| 33 | + | ); |
|
| 34 | + | -- What the runner last said about its update, and when. |
|
| 35 | + | ALTER TABLE flow_runners ADD COLUMN update_note TEXT NOT NULL DEFAULT ''; |
|
| 36 | + | ALTER TABLE flow_runners ADD COLUMN update_ms BIGINT; |
added crates/hub/migrations/0095_runner_updates.sql +36 −0
| 1 | + | -- Runner updates (oxygenhub#322, OxygenFlow docs/jetstream.md §8.2). The |
|
| 2 | + | -- policy of the site (scope 'site', key ''), an owner ('owner', its name), |
|
| 3 | + | -- a test group ('group', its id) or a runner ('runner', its id). A NULL |
|
| 4 | + | -- inherits from the next level out; the nearest setting wins; off unless |
|
| 5 | + | -- some level turns updates on. |
|
| 6 | + | CREATE TABLE runner_update_policies ( |
|
| 7 | + | scope TEXT NOT NULL, |
|
| 8 | + | key TEXT NOT NULL, |
|
| 9 | + | updates INTEGER, |
|
| 10 | + | -- 'stable', 'canary' or a version. |
|
| 11 | + | channel TEXT, |
|
| 12 | + | -- Hours (UTC) in which updates may start: from, to (to may wrap past midnight). |
|
| 13 | + | window_from INTEGER, |
|
| 14 | + | window_to INTEGER, |
|
| 15 | + | dry_run INTEGER, |
|
| 16 | + | PRIMARY KEY (scope, key) |
|
| 17 | + | ); |
|
| 18 | + | -- Test groups: named sets of the site's runners (owner '') or one owner's. |
|
| 19 | + | -- A runner is in one when chosen, or when a stable hash of its id falls in |
|
| 20 | + | -- the group's percentage, so the same machines are in it every rollout. |
|
| 21 | + | CREATE TABLE runner_groups ( |
|
| 22 | + | id INTEGER PRIMARY KEY, |
|
| 23 | + | owner TEXT NOT NULL, |
|
| 24 | + | name TEXT NOT NULL, |
|
| 25 | + | percent INTEGER, |
|
| 26 | + | created_ms INTEGER NOT NULL, |
|
| 27 | + | UNIQUE (owner, name) |
|
| 28 | + | ); |
|
| 29 | + | CREATE TABLE runner_group_members ( |
|
| 30 | + | group_id INTEGER NOT NULL REFERENCES runner_groups (id) ON DELETE CASCADE, |
|
| 31 | + | runner_id INTEGER NOT NULL REFERENCES flow_runners (id) ON DELETE CASCADE, |
|
| 32 | + | PRIMARY KEY (group_id, runner_id) |
|
| 33 | + | ); |
|
| 34 | + | -- What the runner last said about its update, and when. |
|
| 35 | + | ALTER TABLE flow_runners ADD COLUMN update_note TEXT NOT NULL DEFAULT ''; |
|
| 36 | + | ALTER TABLE flow_runners ADD COLUMN update_ms INTEGER; |
added crates/hub/src/db/runner_updates.rs +127 −0
| 1 | + | //! Runner updates (oxygenhub#322): the policy at each level and the test |
|
| 2 | + | //! groups runners are put in. |
|
| 3 | + | ||
| 4 | + | use super::{now_ms, Pool}; |
|
| 5 | + | use sqlx::FromRow; |
|
| 6 | + | ||
| 7 | + | /// One level's settings; `None` inherits from the next level out. |
|
| 8 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, FromRow)] |
|
| 9 | + | pub struct Policy { |
|
| 10 | + | pub scope: String, |
|
| 11 | + | pub key: String, |
|
| 12 | + | pub updates: Option<bool>, |
|
| 13 | + | pub channel: Option<String>, |
|
| 14 | + | pub window_from: Option<i64>, |
|
| 15 | + | pub window_to: Option<i64>, |
|
| 16 | + | pub dry_run: Option<bool>, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | impl Policy { |
|
| 20 | + | /// Nothing set: the level inherits everything. |
|
| 21 | + | pub fn is_empty(&self) -> bool { |
|
| 22 | + | self.updates.is_none() && self.channel.is_none() && self.window_from.is_none() && self.dry_run.is_none() |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | #[derive(Clone, Debug, PartialEq, Eq, FromRow)] |
|
| 27 | + | pub struct Group { |
|
| 28 | + | pub id: i64, |
|
| 29 | + | /// Empty: a group of the site's runners. |
|
| 30 | + | pub owner: String, |
|
| 31 | + | pub name: String, |
|
| 32 | + | /// A share of the runners in reach, by a stable hash of their ids; |
|
| 33 | + | /// `None`: only the chosen ones. |
|
| 34 | + | pub percent: Option<i64>, |
|
| 35 | + | pub created_ms: i64, |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | pub async fn policies(pool: &Pool) -> sqlx::Result<Vec<Policy>> { |
|
| 39 | + | sqlx::query_as("SELECT * FROM runner_update_policies").fetch_all(pool).await |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | pub async fn policy(pool: &Pool, scope: &str, key: &str) -> sqlx::Result<Policy> { |
|
| 43 | + | let p: Option<Policy> = sqlx::query_as("SELECT * FROM runner_update_policies WHERE scope = $1 AND key = $2") |
|
| 44 | + | .bind(scope) |
|
| 45 | + | .bind(key) |
|
| 46 | + | .fetch_optional(pool) |
|
| 47 | + | .await?; |
|
| 48 | + | Ok(p.unwrap_or(Policy { scope: scope.into(), key: key.into(), ..Policy::default() })) |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// Set a level's settings; one with nothing set is removed. |
|
| 52 | + | pub async fn set_policy(pool: &Pool, p: &Policy) -> sqlx::Result<()> { |
|
| 53 | + | sqlx::query("DELETE FROM runner_update_policies WHERE scope = $1 AND key = $2").bind(&p.scope).bind(&p.key).execute(pool).await?; |
|
| 54 | + | if p.is_empty() { |
|
| 55 | + | return Ok(()); |
|
| 56 | + | } |
|
| 57 | + | sqlx::query( |
|
| 58 | + | "INSERT INTO runner_update_policies (scope, key, updates, channel, window_from, window_to, dry_run) |
|
| 59 | + | VALUES ($1, $2, $3, $4, $5, $6, $7)", |
|
| 60 | + | ) |
|
| 61 | + | .bind(&p.scope) |
|
| 62 | + | .bind(&p.key) |
|
| 63 | + | .bind(p.updates) |
|
| 64 | + | .bind(&p.channel) |
|
| 65 | + | .bind(p.window_from) |
|
| 66 | + | .bind(p.window_to) |
|
| 67 | + | .bind(p.dry_run) |
|
| 68 | + | .execute(pool) |
|
| 69 | + | .await?; |
|
| 70 | + | Ok(()) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | pub async fn groups(pool: &Pool) -> sqlx::Result<Vec<Group>> { |
|
| 74 | + | sqlx::query_as("SELECT * FROM runner_groups ORDER BY owner, name").fetch_all(pool).await |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | pub async fn group(pool: &Pool, id: i64) -> sqlx::Result<Option<Group>> { |
|
| 78 | + | sqlx::query_as("SELECT * FROM runner_groups WHERE id = $1").bind(id).fetch_optional(pool).await |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub async fn create_group(pool: &Pool, owner: &str, name: &str, percent: Option<i64>) -> sqlx::Result<Group> { |
|
| 82 | + | sqlx::query_as("INSERT INTO runner_groups (owner, name, percent, created_ms) VALUES ($1, $2, $3, $4) RETURNING *") |
|
| 83 | + | .bind(owner) |
|
| 84 | + | .bind(name) |
|
| 85 | + | .bind(percent) |
|
| 86 | + | .bind(now_ms()) |
|
| 87 | + | .fetch_one(pool) |
|
| 88 | + | .await |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | pub async fn set_percent(pool: &Pool, id: i64, percent: Option<i64>) -> sqlx::Result<()> { |
|
| 92 | + | sqlx::query("UPDATE runner_groups SET percent = $1 WHERE id = $2").bind(percent).bind(id).execute(pool).await?; |
|
| 93 | + | Ok(()) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | /// Remove a group, its members and its policy. |
|
| 97 | + | pub async fn delete_group(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 98 | + | sqlx::query("DELETE FROM runner_group_members WHERE group_id = $1").bind(id).execute(pool).await?; |
|
| 99 | + | sqlx::query("DELETE FROM runner_update_policies WHERE scope = 'group' AND key = $1").bind(id.to_string()).execute(pool).await?; |
|
| 100 | + | sqlx::query("DELETE FROM runner_groups WHERE id = $1").bind(id).execute(pool).await?; |
|
| 101 | + | Ok(()) |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// Every chosen member, as (group, runner). |
|
| 105 | + | pub async fn members(pool: &Pool) -> sqlx::Result<Vec<(i64, i64)>> { |
|
| 106 | + | sqlx::query_as("SELECT group_id, runner_id FROM runner_group_members").fetch_all(pool).await |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /// Make `runners` the group's chosen members. |
|
| 110 | + | pub async fn set_members(pool: &Pool, group: i64, runners: &[i64]) -> sqlx::Result<()> { |
|
| 111 | + | sqlx::query("DELETE FROM runner_group_members WHERE group_id = $1").bind(group).execute(pool).await?; |
|
| 112 | + | for r in runners { |
|
| 113 | + | sqlx::query("INSERT INTO runner_group_members (group_id, runner_id) VALUES ($1, $2)").bind(group).bind(r).execute(pool).await?; |
|
| 114 | + | } |
|
| 115 | + | Ok(()) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// What a runner said about its update. |
|
| 119 | + | pub async fn report(pool: &Pool, runner: i64, note: &str) -> sqlx::Result<()> { |
|
| 120 | + | sqlx::query("UPDATE flow_runners SET update_note = $1, update_ms = $2 WHERE id = $3") |
|
| 121 | + | .bind(note) |
|
| 122 | + | .bind(now_ms()) |
|
| 123 | + | .bind(runner) |
|
| 124 | + | .execute(pool) |
|
| 125 | + | .await?; |
|
| 126 | + | Ok(()) |
|
| 127 | + | } |
added crates/hub/src/runner_updates.rs +277 −0
| 1 | + | //! Runner updates (oxygenhub#322, OxygenFlow docs/jetstream.md §8.2): |
|
| 2 | + | //! which build each runner should run, under the policy the site, its |
|
| 3 | + | //! owner, its test groups and the runner itself set, the nearest setting |
|
| 4 | + | //! winning. Off unless some level turns updates on. |
|
| 5 | + | ||
| 6 | + | use crate::db::runner_updates::{self as rdb, Group, Policy}; |
|
| 7 | + | use crate::db::runners::Runner; |
|
| 8 | + | use crate::db::{self, Pool}; |
|
| 9 | + | use flow_core::api::{UpdateOffer, UpdatePolicy}; |
|
| 10 | + | use std::collections::{BTreeSet, HashMap}; |
|
| 11 | + | ||
| 12 | + | /// The site setting naming the repository whose releases carry runner |
|
| 13 | + | /// builds (`owner/name`), each an asset `flow-OS-ARCH`. |
|
| 14 | + | pub const SETTING_REPO: &str = "runner_updates_repo"; |
|
| 15 | + | ||
| 16 | + | /// A runner's place, 0 to 99, for percentage groups: a stable hash of its |
|
| 17 | + | /// id, so a group's share is the same machines every rollout, and a |
|
| 18 | + | /// smaller share sits inside a larger one. |
|
| 19 | + | pub fn bucket(runner_id: i64) -> i64 { |
|
| 20 | + | let h = blake3::hash(format!("runner {runner_id}").as_bytes()); |
|
| 21 | + | let mut first = [0u8; 8]; |
|
| 22 | + | first.copy_from_slice(&h.as_bytes()[..8]); |
|
| 23 | + | (u64::from_le_bytes(first) % 100) as i64 |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// Whether a group may hold the runner at all: a site group holds any, an |
|
| 27 | + | /// owner's group only that owner's runners. |
|
| 28 | + | pub fn in_reach(g: &Group, r: &Runner) -> bool { |
|
| 29 | + | g.owner.is_empty() || g.owner == r.scope_owner |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /// Every level's settings and the groups, read once. |
|
| 33 | + | #[derive(Clone, Debug, Default)] |
|
| 34 | + | pub struct Levels { |
|
| 35 | + | pub policies: HashMap<(String, String), Policy>, |
|
| 36 | + | pub groups: Vec<Group>, |
|
| 37 | + | /// Chosen members, as (group, runner). |
|
| 38 | + | pub chosen: BTreeSet<(i64, i64)>, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl Levels { |
|
| 42 | + | pub async fn load(pool: &Pool) -> sqlx::Result<Levels> { |
|
| 43 | + | let policies = rdb::policies(pool).await?.into_iter().map(|p| ((p.scope.clone(), p.key.clone()), p)).collect(); |
|
| 44 | + | Ok(Levels { policies, groups: rdb::groups(pool).await?, chosen: rdb::members(pool).await?.into_iter().collect() }) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub fn member(&self, g: &Group, r: &Runner) -> bool { |
|
| 48 | + | in_reach(g, r) && (self.chosen.contains(&(g.id, r.id)) || g.percent.is_some_and(|p| bucket(r.id) < p)) |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// The runner's groups, nearest first: its owner's, then the site's. |
|
| 52 | + | pub fn groups_of(&self, r: &Runner) -> Vec<&Group> { |
|
| 53 | + | let mut gs: Vec<&Group> = self.groups.iter().filter(|g| self.member(g, r)).collect(); |
|
| 54 | + | gs.sort_by_key(|g| (g.owner.is_empty(), g.name.clone())); |
|
| 55 | + | gs |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | fn get(&self, scope: &str, key: &str) -> Option<&Policy> { |
|
| 59 | + | self.policies.get(&(scope.to_string(), key.to_string())) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | /// The levels that apply to `r`, nearest first, each with its name. |
|
| 63 | + | fn chain(&self, r: &Runner) -> Vec<(String, &Policy)> { |
|
| 64 | + | let mut out = vec![]; |
|
| 65 | + | if let Some(p) = self.get("runner", &r.id.to_string()) { |
|
| 66 | + | out.push(("runner".to_string(), p)); |
|
| 67 | + | } |
|
| 68 | + | for g in self.groups_of(r) { |
|
| 69 | + | if let Some(p) = self.get("group", &g.id.to_string()) { |
|
| 70 | + | out.push((format!("group {}", g.name), p)); |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | if !r.scope_owner.is_empty() { |
|
| 74 | + | if let Some(p) = self.get("owner", &r.scope_owner) { |
|
| 75 | + | out.push((format!("owner {}", r.scope_owner), p)); |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | if let Some(p) = self.get("site", "") { |
|
| 79 | + | out.push(("site".to_string(), p)); |
|
| 80 | + | } |
|
| 81 | + | out |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// The policy for `r`: each setting from the nearest level that sets it. |
|
| 85 | + | pub fn resolve(&self, r: &Runner) -> UpdatePolicy { |
|
| 86 | + | let chain = self.chain(r); |
|
| 87 | + | fn pick<T>(chain: &[(String, &Policy)], f: impl Fn(&Policy) -> Option<T>) -> (Option<T>, String) { |
|
| 88 | + | chain.iter().find_map(|(name, p)| f(p).map(|v| (Some(v), name.clone()))).unwrap_or((None, "default".into())) |
|
| 89 | + | } |
|
| 90 | + | let (updates, u_from) = pick(&chain, |p| p.updates); |
|
| 91 | + | let (channel, c_from) = pick(&chain, |p| p.channel.clone().filter(|c| !c.is_empty())); |
|
| 92 | + | let (window, w_from) = pick(&chain, |p| Some((p.window_from? as u8, p.window_to? as u8))); |
|
| 93 | + | let (dry_run, d_from) = pick(&chain, |p| p.dry_run); |
|
| 94 | + | UpdatePolicy { |
|
| 95 | + | updates: updates.unwrap_or(false), |
|
| 96 | + | channel: channel.unwrap_or_else(|| "stable".into()), |
|
| 97 | + | window, |
|
| 98 | + | dry_run: dry_run.unwrap_or(false), |
|
| 99 | + | from: vec![u_from, c_from, w_from, d_from], |
|
| 100 | + | } |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | /// Whether hour `h` (UTC) falls in `[from, to)`, which may wrap past midnight. |
|
| 105 | + | pub fn in_window((from, to): (u8, u8), h: u8) -> bool { |
|
| 106 | + | if from <= to { |
|
| 107 | + | from <= h && h < to |
|
| 108 | + | } else { |
|
| 109 | + | h >= from || h < to |
|
| 110 | + | } |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | /// Parse `02-05` (hours, UTC) as a window; blank is none. |
|
| 114 | + | pub fn parse_window(s: &str) -> Result<Option<(u8, u8)>, String> { |
|
| 115 | + | let s = s.trim(); |
|
| 116 | + | if s.is_empty() { |
|
| 117 | + | return Ok(None); |
|
| 118 | + | } |
|
| 119 | + | let (a, b) = s.split_once('-').ok_or("a window is two hours, from-to, e.g. 02-05")?; |
|
| 120 | + | let hour = |x: &str| x.trim().trim_end_matches(":00").parse::<u8>().ok().filter(|h| *h < 24); |
|
| 121 | + | match (hour(a), hour(b)) { |
|
| 122 | + | (Some(a), Some(b)) if a != b => Ok(Some((a, b))), |
|
| 123 | + | _ => Err("a window is two different hours from 0 to 23, from-to, e.g. 02-05".into()), |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | /// A channel as written: `stable`, `canary`, or a version. |
|
| 128 | + | pub fn valid_channel(c: &str) -> bool { |
|
| 129 | + | matches!(c, "stable" | "canary") || (!c.is_empty() && c.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))) |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | /// The version in a runner's or a tag's words: `0.3.1 (build)` → `0.3.1`, |
|
| 133 | + | /// `v0.3.1` → `0.3.1`. |
|
| 134 | + | pub fn version_of(s: &str) -> String { |
|
| 135 | + | s.split_whitespace().next().unwrap_or("").trim_start_matches('v').to_string() |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | fn older(a: &str, b: &str) -> bool { |
|
| 139 | + | let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>(); |
|
| 140 | + | parts(a) < parts(b) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | /// The build `r` should install under `policy` at hour `hour` (UTC), or |
|
| 144 | + | /// why there is none. |
|
| 145 | + | pub async fn offer(pool: &Pool, r: &Runner, policy: &UpdatePolicy, hour: u8) -> sqlx::Result<(Option<UpdateOffer>, String)> { |
|
| 146 | + | if !policy.updates { |
|
| 147 | + | return Ok((None, format!("updates are off ({})", policy.from[0]))); |
|
| 148 | + | } |
|
| 149 | + | let Some(spec) = db::accounts::setting(pool, SETTING_REPO).await?.filter(|s| !s.trim().is_empty()) else { |
|
| 150 | + | return Ok((None, "no runner releases repository is set (Admin › Runners › Updates)".into())); |
|
| 151 | + | }; |
|
| 152 | + | let Some((owner, name)) = spec.trim().split_once('/') else { |
|
| 153 | + | return Ok((None, format!("the runner releases repository {spec:?} is not owner/name"))); |
|
| 154 | + | }; |
|
| 155 | + | let Some(repo) = db::repos::get(pool, owner, name).await? else { |
|
| 156 | + | return Ok((None, format!("the runner releases repository {spec} does not exist"))); |
|
| 157 | + | }; |
|
| 158 | + | let releases = db::releases::list(pool, repo.id).await?; |
|
| 159 | + | let release = match policy.channel.as_str() { |
|
| 160 | + | "stable" => releases.iter().find(|x| !x.prerelease), |
|
| 161 | + | "canary" => releases.first(), |
|
| 162 | + | v => releases.iter().find(|x| version_of(&x.tag) == v), |
|
| 163 | + | }; |
|
| 164 | + | let Some(release) = release else { |
|
| 165 | + | return Ok((None, format!("{spec} has no release for the {} channel", policy.channel))); |
|
| 166 | + | }; |
|
| 167 | + | let (want, have) = (version_of(&release.tag), version_of(&r.version)); |
|
| 168 | + | if want == have { |
|
| 169 | + | return Ok((None, format!("up to date: flow {have} ({} channel)", policy.channel))); |
|
| 170 | + | } |
|
| 171 | + | let going_back = older(&want, &have); |
|
| 172 | + | if going_back && matches!(policy.channel.as_str(), "stable" | "canary") { |
|
| 173 | + | return Ok((None, format!("runs flow {have}, newer than the {} channel's {want}", policy.channel))); |
|
| 174 | + | } |
|
| 175 | + | let file = format!("flow-{}-{}", r.os, r.arch); |
|
| 176 | + | let assets = db::releases::assets(pool, release.id).await?; |
|
| 177 | + | let Some(a) = assets.iter().find(|a| a.name == file) else { |
|
| 178 | + | return Ok((None, format!("{spec} {} has no {file}", release.tag))); |
|
| 179 | + | }; |
|
| 180 | + | if let Some(w) = policy.window.filter(|w| !in_window(*w, hour)) { |
|
| 181 | + | return Ok((None, format!("flow {want} waits for its window, {:02}:00–{:02}:00 UTC", w.0, w.1))); |
|
| 182 | + | } |
|
| 183 | + | let note = format!( |
|
| 184 | + | "flow {want} ({} channel){}", |
|
| 185 | + | policy.channel, |
|
| 186 | + | if policy.dry_run { ": dry run, check it and say what would happen" } else { "" } |
|
| 187 | + | ); |
|
| 188 | + | Ok(( |
|
| 189 | + | Some(UpdateOffer { |
|
| 190 | + | version: want, |
|
| 191 | + | tag: release.tag.clone(), |
|
| 192 | + | asset: a.name.clone(), |
|
| 193 | + | url: format!("/{owner}/{name}/asset/{}/{}", a.id, a.name), |
|
| 194 | + | blake3: a.hash.clone(), |
|
| 195 | + | size: a.size as u64, |
|
| 196 | + | older: going_back, |
|
| 197 | + | }), |
|
| 198 | + | note, |
|
| 199 | + | )) |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | #[cfg(test)] |
|
| 203 | + | mod tests { |
|
| 204 | + | use super::*; |
|
| 205 | + | ||
| 206 | + | fn runner(id: i64, owner: &str) -> Runner { |
|
| 207 | + | Runner { id, scope_owner: owner.into(), version: "0.3.1 (build)".into(), ..Runner::default() } |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | #[test] |
|
| 211 | + | fn a_smaller_share_sits_inside_a_larger_one_and_is_about_its_size() { |
|
| 212 | + | let ten: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 10).collect(); |
|
| 213 | + | let fifty: Vec<i64> = (1..=1000).filter(|id| bucket(*id) < 50).collect(); |
|
| 214 | + | assert!(ten.iter().all(|id| fifty.contains(id))); |
|
| 215 | + | assert!((60..=140).contains(&ten.len()), "{}", ten.len()); |
|
| 216 | + | assert_eq!(bucket(7), bucket(7), "stable"); |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | #[test] |
|
| 220 | + | fn the_nearest_level_wins_each_setting() { |
|
| 221 | + | let mut l = Levels::default(); |
|
| 222 | + | let pol = |scope: &str, key: &str, f: &dyn Fn(&mut Policy)| { |
|
| 223 | + | let mut p = Policy { scope: scope.into(), key: key.into(), ..Policy::default() }; |
|
| 224 | + | f(&mut p); |
|
| 225 | + | ((scope.to_string(), key.to_string()), p) |
|
| 226 | + | }; |
|
| 227 | + | l.policies.extend([ |
|
| 228 | + | pol("site", "", &|p| { |
|
| 229 | + | p.updates = Some(false); |
|
| 230 | + | p.channel = Some("stable".into()); |
|
| 231 | + | }), |
|
| 232 | + | pol("owner", "acme", &|p| p.updates = Some(true)), |
|
| 233 | + | pol("group", "1", &|p| p.channel = Some("canary".into())), |
|
| 234 | + | pol("runner", "3", &|p| p.updates = Some(false)), |
|
| 235 | + | ]); |
|
| 236 | + | l.groups.push(Group { id: 1, owner: "acme".into(), name: "canary".into(), percent: None, created_ms: 0 }); |
|
| 237 | + | l.chosen.insert((1, 2)); |
|
| 238 | + | l.chosen.insert((1, 9)); |
|
| 239 | + | ||
| 240 | + | let site_runner = l.resolve(&runner(1, "")); |
|
| 241 | + | assert!(!site_runner.updates, "off at the site"); |
|
| 242 | + | assert_eq!(site_runner.from, ["site", "site", "default", "default"]); |
|
| 243 | + | ||
| 244 | + | let canary = l.resolve(&runner(2, "acme")); |
|
| 245 | + | assert!(canary.updates && canary.channel == "canary"); |
|
| 246 | + | assert_eq!(canary.from[..2], ["owner acme".to_string(), "group canary".to_string()]); |
|
| 247 | + | ||
| 248 | + | let held = l.resolve(&runner(3, "acme")); |
|
| 249 | + | assert!(!held.updates, "the runner's own setting wins"); |
|
| 250 | + | assert_eq!(held.channel, "stable"); |
|
| 251 | + | ||
| 252 | + | assert!(l.groups_of(&runner(9, "other")).is_empty(), "an owner's group holds only its own runners"); |
|
| 253 | + | assert!(!Levels::default().resolve(&runner(4, "")).updates, "off unless chosen"); |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | #[test] |
|
| 257 | + | fn a_percentage_group_takes_its_share_of_the_runners_in_reach() { |
|
| 258 | + | let mut l = Levels::default(); |
|
| 259 | + | l.groups.push(Group { id: 5, owner: String::new(), name: "farm 20%".into(), percent: Some(20), created_ms: 0 }); |
|
| 260 | + | let n = (1..=500).filter(|id| !l.groups_of(&runner(*id, "")).is_empty()).count(); |
|
| 261 | + | assert!((60..=140).contains(&n), "{n}"); |
|
| 262 | + | } |
|
| 263 | + | ||
| 264 | + | #[test] |
|
| 265 | + | fn windows_and_channels_read_as_written() { |
|
| 266 | + | assert_eq!(parse_window("02-05"), Ok(Some((2, 5)))); |
|
| 267 | + | assert_eq!(parse_window(" 22:00-04:00 "), Ok(Some((22, 4)))); |
|
| 268 | + | assert_eq!(parse_window(""), Ok(None)); |
|
| 269 | + | assert!(parse_window("5").is_err() && parse_window("3-3").is_err() && parse_window("1-24").is_err()); |
|
| 270 | + | assert!(in_window((2, 5), 2) && in_window((2, 5), 4) && !in_window((2, 5), 5)); |
|
| 271 | + | assert!(in_window((22, 4), 23) && in_window((22, 4), 1) && !in_window((22, 4), 12)); |
|
| 272 | + | assert!(valid_channel("stable") && valid_channel("canary") && valid_channel("0.3.4")); |
|
| 273 | + | assert!(!valid_channel("latest") && !valid_channel("0.3.") && !valid_channel("")); |
|
| 274 | + | assert_eq!(version_of("v0.3.2"), "0.3.2"); |
|
| 275 | + | assert_eq!(version_of("0.3.1 (local build)"), "0.3.1"); |
|
| 276 | + | } |
|
| 277 | + | } |
added crates/hub/src/web/runner_updates.rs +417 −0
| 1 | + | //! Admin → Runners → Updates (oxygenhub#322): where runner builds come |
|
| 2 | + | //! from, and who takes them: the site's, each owner's, each test group's |
|
| 3 | + | //! and each runner's settings, the nearest winning; test groups of chosen |
|
| 4 | + | //! runners or a percentage of them. |
|
| 5 | + | ||
| 6 | + | use super::settings::csrf; |
|
| 7 | + | use super::{filters, Error, Page, Shell}; |
|
| 8 | + | use crate::auth::Viewer; |
|
| 9 | + | use crate::db::runner_updates::{self as rdb, Group, Policy}; |
|
| 10 | + | use crate::db::{accounts, runners}; |
|
| 11 | + | use crate::runner_updates::{self as ru, Levels}; |
|
| 12 | + | use crate::Shared; |
|
| 13 | + | use askama::Template; |
|
| 14 | + | use axum::extract::{Form, Path, State}; |
|
| 15 | + | use flow_core::api::UpdatePolicy; |
|
| 16 | + | use serde::Deserialize; |
|
| 17 | + | ||
| 18 | + | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| 19 | + | match viewer.account() { |
|
| 20 | + | Some(a) if a.is_admin => Ok(a.clone()), |
|
| 21 | + | _ => Err(Error::not_found(shell.clone())), |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// One level's settings as its form shows them: `inherit`, `on` or `off` |
|
| 26 | + | /// for the switches, blank for an inherited channel or window. |
|
| 27 | + | pub struct PolicyView { |
|
| 28 | + | pub scope: String, |
|
| 29 | + | pub key: String, |
|
| 30 | + | pub label: String, |
|
| 31 | + | pub updates: &'static str, |
|
| 32 | + | pub channel: String, |
|
| 33 | + | pub window: String, |
|
| 34 | + | pub dry_run: &'static str, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | impl PolicyView { |
|
| 38 | + | fn of(p: &Policy, label: String) -> PolicyView { |
|
| 39 | + | let tri = |b: Option<bool>| match b { |
|
| 40 | + | Some(true) => "on", |
|
| 41 | + | Some(false) => "off", |
|
| 42 | + | None => "inherit", |
|
| 43 | + | }; |
|
| 44 | + | PolicyView { |
|
| 45 | + | scope: p.scope.clone(), |
|
| 46 | + | key: p.key.clone(), |
|
| 47 | + | label, |
|
| 48 | + | updates: tri(p.updates), |
|
| 49 | + | channel: p.channel.clone().unwrap_or_default(), |
|
| 50 | + | window: match (p.window_from, p.window_to) { |
|
| 51 | + | (Some(a), Some(b)) => format!("{a:02}-{b:02}"), |
|
| 52 | + | _ => String::new(), |
|
| 53 | + | }, |
|
| 54 | + | dry_run: tri(p.dry_run), |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// The settings in words, for a table. |
|
| 59 | + | pub fn summary(&self) -> String { |
|
| 60 | + | let mut s = vec![]; |
|
| 61 | + | if self.updates != "inherit" { |
|
| 62 | + | s.push(format!("updates {}", self.updates)); |
|
| 63 | + | } |
|
| 64 | + | if !self.channel.is_empty() { |
|
| 65 | + | s.push(format!("channel {}", self.channel)); |
|
| 66 | + | } |
|
| 67 | + | if !self.window.is_empty() { |
|
| 68 | + | s.push(format!("window {} UTC", self.window)); |
|
| 69 | + | } |
|
| 70 | + | if self.dry_run != "inherit" { |
|
| 71 | + | s.push(format!("dry run {}", self.dry_run)); |
|
| 72 | + | } |
|
| 73 | + | if s.is_empty() { |
|
| 74 | + | "inherits everything".into() |
|
| 75 | + | } else { |
|
| 76 | + | s.join(", ") |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub struct GroupView { |
|
| 82 | + | pub g: Group, |
|
| 83 | + | /// Whom it can hold, in words. |
|
| 84 | + | pub reach: String, |
|
| 85 | + | /// Its members now, by name. |
|
| 86 | + | pub members: Vec<String>, |
|
| 87 | + | pub chosen: Vec<i64>, |
|
| 88 | + | pub policy: PolicyView, |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | pub struct RunnerView { |
|
| 92 | + | pub r: runners::Runner, |
|
| 93 | + | pub groups: Vec<String>, |
|
| 94 | + | pub effective: UpdatePolicy, |
|
| 95 | + | /// What the hub would offer it now. |
|
| 96 | + | pub offer: String, |
|
| 97 | + | pub policy: PolicyView, |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | impl RunnerView { |
|
| 101 | + | /// Its effective settings, each with where it came from. |
|
| 102 | + | pub fn effective_words(&self) -> String { |
|
| 103 | + | let e = &self.effective; |
|
| 104 | + | let mut s = vec![format!("updates {} ({})", if e.updates { "on" } else { "off" }, e.from[0])]; |
|
| 105 | + | if e.updates { |
|
| 106 | + | s.push(format!("channel {} ({})", e.channel, e.from[1])); |
|
| 107 | + | if let Some((a, b)) = e.window { |
|
| 108 | + | s.push(format!("window {a:02}–{b:02} UTC ({})", e.from[2])); |
|
| 109 | + | } |
|
| 110 | + | if e.dry_run { |
|
| 111 | + | s.push(format!("dry run ({})", e.from[3])); |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | s.join(" · ") |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | #[derive(Template)] |
|
| 119 | + | #[template(path = "admin_runner_updates.html")] |
|
| 120 | + | pub struct UpdatesPage { |
|
| 121 | + | shell: Shell, |
|
| 122 | + | repo: String, |
|
| 123 | + | site: PolicyView, |
|
| 124 | + | owners: Vec<PolicyView>, |
|
| 125 | + | groups: Vec<GroupView>, |
|
| 126 | + | runners: Vec<RunnerView>, |
|
| 127 | + | notice: Option<String>, |
|
| 128 | + | error: Option<String>, |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | async fn page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<UpdatesPage>, Error> { |
|
| 132 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 133 | + | let levels = Levels::load(&app.db).await.map_err(e)?; |
|
| 134 | + | let all = runners::list(&app.db).await.map_err(e)?; |
|
| 135 | + | let repo = accounts::setting(&app.db, ru::SETTING_REPO).await.map_err(e)?.unwrap_or_default(); |
|
| 136 | + | let policy = |scope: &str, key: &str| { |
|
| 137 | + | levels.policies.get(&(scope.to_string(), key.to_string())).cloned().unwrap_or(Policy { |
|
| 138 | + | scope: scope.into(), |
|
| 139 | + | key: key.into(), |
|
| 140 | + | ..Policy::default() |
|
| 141 | + | }) |
|
| 142 | + | }; |
|
| 143 | + | let mut owners: Vec<PolicyView> = |
|
| 144 | + | levels.policies.values().filter(|p| p.scope == "owner").map(|p| PolicyView::of(p, format!("owner {}", p.key))).collect(); |
|
| 145 | + | owners.sort_by(|a, b| a.key.cmp(&b.key)); |
|
| 146 | + | let groups = levels |
|
| 147 | + | .groups |
|
| 148 | + | .iter() |
|
| 149 | + | .map(|g| GroupView { |
|
| 150 | + | g: g.clone(), |
|
| 151 | + | reach: if g.owner.is_empty() { "the site's runners".into() } else { format!("{}'s runners", g.owner) }, |
|
| 152 | + | members: all.iter().filter(|r| levels.member(g, r)).map(|r| r.name.clone()).collect(), |
|
| 153 | + | chosen: levels.chosen.iter().filter(|(gid, _)| *gid == g.id).map(|(_, r)| *r).collect(), |
|
| 154 | + | policy: PolicyView::of(&policy("group", &g.id.to_string()), format!("group {}", g.name)), |
|
| 155 | + | }) |
|
| 156 | + | .collect(); |
|
| 157 | + | let hour = time::OffsetDateTime::now_utc().hour(); |
|
| 158 | + | let mut views = vec![]; |
|
| 159 | + | for r in all { |
|
| 160 | + | let effective = levels.resolve(&r); |
|
| 161 | + | let (_, offer) = ru::offer(&app.db, &r, &effective, hour).await.map_err(e)?; |
|
| 162 | + | views.push(RunnerView { |
|
| 163 | + | groups: levels.groups_of(&r).iter().map(|g| g.name.clone()).collect(), |
|
| 164 | + | policy: PolicyView::of(&policy("runner", &r.id.to_string()), format!("runner {}", r.name)), |
|
| 165 | + | effective, |
|
| 166 | + | offer, |
|
| 167 | + | r, |
|
| 168 | + | }); |
|
| 169 | + | } |
|
| 170 | + | Ok(Page(UpdatesPage { |
|
| 171 | + | site: PolicyView::of(&policy("site", ""), "the site".into()), |
|
| 172 | + | shell, |
|
| 173 | + | repo, |
|
| 174 | + | owners, |
|
| 175 | + | groups, |
|
| 176 | + | runners: views, |
|
| 177 | + | notice, |
|
| 178 | + | error, |
|
| 179 | + | })) |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | pub async fn show(State(app): State<Shared>, viewer: Viewer) -> Result<Page<UpdatesPage>, Error> { |
|
| 183 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 184 | + | require_admin(&viewer, &shell)?; |
|
| 185 | + | page(&app, shell, None, None).await |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | #[derive(Deserialize)] |
|
| 189 | + | pub struct SourceForm { |
|
| 190 | + | #[serde(rename = "_csrf")] |
|
| 191 | + | csrf_token: String, |
|
| 192 | + | repo: String, |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// `POST /admin/runners/updates/source`: the repository whose releases carry runner builds. |
|
| 196 | + | pub async fn save_source(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SourceForm>) -> Result<Page<UpdatesPage>, Error> { |
|
| 197 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 198 | + | let me = require_admin(&viewer, &shell)?; |
|
| 199 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 200 | + | let repo = f.repo.trim().trim_matches('/').to_string(); |
|
| 201 | + | if !repo.is_empty() { |
|
| 202 | + | let found = match repo.split_once('/') { |
|
| 203 | + | Some((o, n)) => crate::db::repos::get(&app.db, o, n).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
|
| 204 | + | None => None, |
|
| 205 | + | }; |
|
| 206 | + | if found.is_none() { |
|
| 207 | + | return page(&app, shell, None, Some(format!("There is no repository {repo:?} here; give it as owner/name."))).await; |
|
| 208 | + | } |
|
| 209 | + | } |
|
| 210 | + | accounts::set_setting(&app.db, ru::SETTING_REPO, &repo).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 211 | + | accounts::audit(&app.db, Some(&me), "runner.updates.source", &repo, "runner builds come from its releases").await.ok(); |
|
| 212 | + | let n = if repo.is_empty() { |
|
| 213 | + | "Saved: no repository, so no runner is offered a build.".into() |
|
| 214 | + | } else { |
|
| 215 | + | format!("Saved: runner builds come from {repo}'s releases.") |
|
| 216 | + | }; |
|
| 217 | + | page(&app, shell, Some(n), None).await |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | #[derive(Deserialize)] |
|
| 221 | + | pub struct PolicyForm { |
|
| 222 | + | #[serde(rename = "_csrf")] |
|
| 223 | + | csrf_token: String, |
|
| 224 | + | scope: String, |
|
| 225 | + | #[serde(default)] |
|
| 226 | + | key: String, |
|
| 227 | + | #[serde(default)] |
|
| 228 | + | updates: String, |
|
| 229 | + | #[serde(default)] |
|
| 230 | + | channel: String, |
|
| 231 | + | #[serde(default)] |
|
| 232 | + | window: String, |
|
| 233 | + | #[serde(default)] |
|
| 234 | + | dry_run: String, |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /// `POST /admin/runners/updates/policy`: one level's settings. |
|
| 238 | + | pub async fn save_policy(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PolicyForm>) -> Result<Page<UpdatesPage>, Error> { |
|
| 239 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 240 | + | let me = require_admin(&viewer, &shell)?; |
|
| 241 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 242 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 243 | + | let key = f.key.trim().to_string(); |
|
| 244 | + | let label = match f.scope.as_str() { |
|
| 245 | + | "site" if key.is_empty() => "the site".to_string(), |
|
| 246 | + | "owner" => { |
|
| 247 | + | let known = accounts::account_by_name(&app.db, &key).await.map_err(e)?.is_some() |
|
| 248 | + | || accounts::org_by_name(&app.db, &key).await.map_err(e)?.is_some(); |
|
| 249 | + | if !known { |
|
| 250 | + | return page(&app, shell, None, Some(format!("There is no account or organisation {key:?}."))).await; |
|
| 251 | + | } |
|
| 252 | + | format!("owner {key}") |
|
| 253 | + | } |
|
| 254 | + | "group" => match key.parse().ok().map(|id| rdb::group(&app.db, id)) { |
|
| 255 | + | Some(g) => match g.await.map_err(e)? { |
|
| 256 | + | Some(g) => format!("group {}", g.name), |
|
| 257 | + | None => return page(&app, shell, None, Some("That test group is gone.".into())).await, |
|
| 258 | + | }, |
|
| 259 | + | None => return page(&app, shell, None, Some("That test group is gone.".into())).await, |
|
| 260 | + | }, |
|
| 261 | + | "runner" => match key.parse().ok().map(|id| runners::get(&app.db, id)) { |
|
| 262 | + | Some(r) => match r.await.map_err(e)? { |
|
| 263 | + | Some(r) => format!("runner {}", r.name), |
|
| 264 | + | None => return page(&app, shell, None, Some("That runner is gone.".into())).await, |
|
| 265 | + | }, |
|
| 266 | + | None => return page(&app, shell, None, Some("That runner is gone.".into())).await, |
|
| 267 | + | }, |
|
| 268 | + | _ => return page(&app, shell, None, Some("Choose the site, an owner, a test group or a runner.".into())).await, |
|
| 269 | + | }; |
|
| 270 | + | let tri = |v: &str| match v { |
|
| 271 | + | "on" => Some(true), |
|
| 272 | + | "off" => Some(false), |
|
| 273 | + | _ => None, |
|
| 274 | + | }; |
|
| 275 | + | let channel = f.channel.trim().to_string(); |
|
| 276 | + | if !channel.is_empty() && !ru::valid_channel(&channel) { |
|
| 277 | + | return page(&app, shell, None, Some(format!("A channel is stable, canary or a version such as 0.3.4, not {channel:?}."))).await; |
|
| 278 | + | } |
|
| 279 | + | let window = match ru::parse_window(&f.window) { |
|
| 280 | + | Ok(w) => w, |
|
| 281 | + | Err(why) => return page(&app, shell, None, Some(format!("{why}."))).await, |
|
| 282 | + | }; |
|
| 283 | + | let p = Policy { |
|
| 284 | + | scope: f.scope.clone(), |
|
| 285 | + | key, |
|
| 286 | + | updates: tri(&f.updates), |
|
| 287 | + | channel: Some(channel).filter(|c| !c.is_empty()), |
|
| 288 | + | window_from: window.map(|w| w.0 as i64), |
|
| 289 | + | window_to: window.map(|w| w.1 as i64), |
|
| 290 | + | dry_run: tri(&f.dry_run), |
|
| 291 | + | }; |
|
| 292 | + | rdb::set_policy(&app.db, &p).await.map_err(e)?; |
|
| 293 | + | let words = PolicyView::of(&p, label.clone()).summary(); |
|
| 294 | + | accounts::audit(&app.db, Some(&me), "runner.updates.policy", &label, &words).await.ok(); |
|
| 295 | + | page(&app, shell, Some(format!("Saved for {label}: {words}.")), None).await |
|
| 296 | + | } |
|
| 297 | + | ||
| 298 | + | #[derive(Deserialize)] |
|
| 299 | + | pub struct GroupForm { |
|
| 300 | + | #[serde(rename = "_csrf")] |
|
| 301 | + | csrf_token: String, |
|
| 302 | + | #[serde(default)] |
|
| 303 | + | owner: String, |
|
| 304 | + | #[serde(default)] |
|
| 305 | + | name: String, |
|
| 306 | + | /// `percent` or `chosen`. |
|
| 307 | + | #[serde(default)] |
|
| 308 | + | mode: String, |
|
| 309 | + | #[serde(default)] |
|
| 310 | + | percent: String, |
|
| 311 | + | #[serde(default)] |
|
| 312 | + | runner: Vec<i64>, |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | /// The percentage a form gives, or why it cannot be one. |
|
| 316 | + | fn percent_of(f: &GroupForm) -> Result<Option<i64>, String> { |
|
| 317 | + | if f.mode != "percent" { |
|
| 318 | + | return Ok(None); |
|
| 319 | + | } |
|
| 320 | + | match f.percent.trim().trim_end_matches('%').parse::<i64>() { |
|
| 321 | + | Ok(p) if (1..=100).contains(&p) => Ok(Some(p)), |
|
| 322 | + | _ => Err("A percentage is a whole number from 1 to 100.".into()), |
|
| 323 | + | } |
|
| 324 | + | } |
|
| 325 | + | ||
| 326 | + | /// `POST /admin/runners/updates/groups`: a new test group. |
|
| 327 | + | pub async fn create_group( |
|
| 328 | + | State(app): State<Shared>, |
|
| 329 | + | viewer: Viewer, |
|
| 330 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
|
| 331 | + | ) -> Result<Page<UpdatesPage>, Error> { |
|
| 332 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 333 | + | let me = require_admin(&viewer, &shell)?; |
|
| 334 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 335 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 336 | + | let name = f.name.trim().to_string(); |
|
| 337 | + | if name.is_empty() || name.len() > 60 { |
|
| 338 | + | return page(&app, shell, None, Some("A test group needs a name of up to 60 characters.".into())).await; |
|
| 339 | + | } |
|
| 340 | + | let owner = f.owner.trim().to_string(); |
|
| 341 | + | if !owner.is_empty() |
|
| 342 | + | && accounts::account_by_name(&app.db, &owner).await.map_err(e)?.is_none() |
|
| 343 | + | && accounts::org_by_name(&app.db, &owner).await.map_err(e)?.is_none() |
|
| 344 | + | { |
|
| 345 | + | return page(&app, shell, None, Some(format!("There is no account or organisation {owner:?}."))).await; |
|
| 346 | + | } |
|
| 347 | + | let percent = match percent_of(&f) { |
|
| 348 | + | Ok(p) => p, |
|
| 349 | + | Err(why) => return page(&app, shell, None, Some(why)).await, |
|
| 350 | + | }; |
|
| 351 | + | if rdb::groups(&app.db).await.map_err(e)?.iter().any(|g| g.owner == owner && g.name == name) { |
|
| 352 | + | return page(&app, shell, None, Some(format!("There is already a test group {name:?} for those runners."))).await; |
|
| 353 | + | } |
|
| 354 | + | let g = rdb::create_group(&app.db, &owner, &name, percent).await.map_err(e)?; |
|
| 355 | + | if percent.is_none() { |
|
| 356 | + | rdb::set_members(&app.db, g.id, &f.runner).await.map_err(e)?; |
|
| 357 | + | } |
|
| 358 | + | let what = match percent { |
|
| 359 | + | Some(p) => format!("{p}% of {}", if owner.is_empty() { "the site's runners".into() } else { format!("{owner}'s runners") }), |
|
| 360 | + | None => format!("{} chosen runners", f.runner.len()), |
|
| 361 | + | }; |
|
| 362 | + | accounts::audit(&app.db, Some(&me), "runner.updates.group", &name, &format!("created: {what}")).await.ok(); |
|
| 363 | + | page(&app, shell, Some(format!("Test group {name} made: {what}. Give it its settings below.")), None).await |
|
| 364 | + | } |
|
| 365 | + | ||
| 366 | + | /// `POST /admin/runners/updates/groups/{id}`: who is in a test group. |
|
| 367 | + | pub async fn save_group( |
|
| 368 | + | State(app): State<Shared>, |
|
| 369 | + | viewer: Viewer, |
|
| 370 | + | Path(id): Path<i64>, |
|
| 371 | + | axum_extra::extract::Form(f): axum_extra::extract::Form<GroupForm>, |
|
| 372 | + | ) -> Result<Page<UpdatesPage>, Error> { |
|
| 373 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 374 | + | let me = require_admin(&viewer, &shell)?; |
|
| 375 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 376 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 377 | + | let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else { |
|
| 378 | + | return page(&app, shell, None, Some("That test group is gone.".into())).await; |
|
| 379 | + | }; |
|
| 380 | + | let percent = match percent_of(&f) { |
|
| 381 | + | Ok(p) => p, |
|
| 382 | + | Err(why) => return page(&app, shell, None, Some(why)).await, |
|
| 383 | + | }; |
|
| 384 | + | rdb::set_percent(&app.db, id, percent).await.map_err(e)?; |
|
| 385 | + | rdb::set_members(&app.db, id, if percent.is_some() { &[] } else { &f.runner }).await.map_err(e)?; |
|
| 386 | + | let what = match percent { |
|
| 387 | + | Some(p) => format!("{p}% of its runners"), |
|
| 388 | + | None => format!("{} chosen runners", f.runner.len()), |
|
| 389 | + | }; |
|
| 390 | + | accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, &what).await.ok(); |
|
| 391 | + | page(&app, shell, Some(format!("Test group {} is now {what}.", g.name)), None).await |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | #[derive(Deserialize)] |
|
| 395 | + | pub struct CsrfOnly { |
|
| 396 | + | #[serde(rename = "_csrf")] |
|
| 397 | + | csrf_token: String, |
|
| 398 | + | } |
|
| 399 | + | ||
| 400 | + | /// `POST /admin/runners/updates/groups/{id}/delete`. |
|
| 401 | + | pub async fn delete_group( |
|
| 402 | + | State(app): State<Shared>, |
|
| 403 | + | viewer: Viewer, |
|
| 404 | + | Path(id): Path<i64>, |
|
| 405 | + | Form(f): Form<CsrfOnly>, |
|
| 406 | + | ) -> Result<Page<UpdatesPage>, Error> { |
|
| 407 | + | let shell = Shell::new(&app, "/admin/runners/updates", &viewer); |
|
| 408 | + | let me = require_admin(&viewer, &shell)?; |
|
| 409 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 410 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 411 | + | let Some(g) = rdb::group(&app.db, id).await.map_err(e)? else { |
|
| 412 | + | return page(&app, shell, None, Some("That test group is gone.".into())).await; |
|
| 413 | + | }; |
|
| 414 | + | rdb::delete_group(&app.db, id).await.map_err(e)?; |
|
| 415 | + | accounts::audit(&app.db, Some(&me), "runner.updates.group", &g.name, "removed").await.ok(); |
|
| 416 | + | page(&app, shell, Some(format!("Test group {} removed; its runners follow the levels above it.", g.name)), None).await |
|
| 417 | + | } |
added templates/admin_runner_updates.html +115 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 3 | + | {# One level's update settings (oxygenhub#322); `top` is the site, which inherits from nothing. #} |
|
| 4 | + | {% macro policy_fields(p, top) %} |
|
| 5 | + | <label class="pol">Updates <select name="updates">{% if !top %}<option value="inherit"{% if p.updates == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.updates == "off" || (top && p.updates == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.updates == "on" %} selected{% endif %}>on</option></select></label> |
|
| 6 | + | <label class="pol">Channel <input type="text" name="channel" value="{{ p.channel }}" placeholder="{% if top %}stable{% else %}inherit{% endif %}" size="9" list="update-channels"></label> |
|
| 7 | + | <label class="pol">Window, UTC <input type="text" name="window" value="{{ p.window }}" placeholder="{% if top %}any time{% else %}inherit{% endif %}" size="7" title="Hours in which updates may start, from-to, e.g. 02-05"></label> |
|
| 8 | + | <label class="pol">Dry run <select name="dry_run">{% if !top %}<option value="inherit"{% if p.dry_run == "inherit" %} selected{% endif %}>inherit</option>{% endif %}<option value="off"{% if p.dry_run == "off" || (top && p.dry_run == "inherit") %} selected{% endif %}>off</option><option value="on"{% if p.dry_run == "on" %} selected{% endif %}>on</option></select></label> |
|
| 9 | + | {% endmacro %} |
|
| 10 | + | {% block title %}Runner updates · Administration{% endblock %} |
|
| 11 | + | {% block content %} |
|
| 12 | + | <div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div> |
|
| 13 | + | <div class="settings-layout"> |
|
| 14 | + | {% call an::nav(crate::web::admin::AdminSection::RunnerUpdates) %} |
|
| 15 | + | <div class="sections settings-body"> |
|
| 16 | + | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a><a href="/admin/runners/updates" class="on">updates</a></span></div> |
|
| 17 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 18 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 19 | + | <datalist id="update-channels"><option value="stable"><option value="canary"></datalist> |
|
| 20 | + | <section class="panel"> |
|
| 21 | + | <h2>Runners update themselves</h2> |
|
| 22 | + | <p>A runner asks the hub which build it should run, fetches it, checks it as a release, finishes what it holds and swaps itself in; a build that does not reach the hub goes back to the one before. <strong>Off unless chosen.</strong> The site, an owner, a test group and a runner each may set any of the settings below, and the nearest setting wins: a runner's own, then its test groups', then its owner's, then the site's. A runner's <code>flow.toml</code> can still refuse every update (<code>[runner.update] allow = false</code>).</p> |
|
| 23 | + | <p class="small mute">A rollout: turn updates on for a test group (two chosen runners, or 10% of them), watch it, then widen it to 50%, then turn updates on for everyone. A percentage is taken by a stable hash of each runner's id, so it is the same machines every time, and 10% sits inside 50%.</p> |
|
| 24 | + | </section> |
|
| 25 | + | <form class="card wide" method="post" action="/admin/runners/updates/source"> |
|
| 26 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 27 | + | <h2>Where builds come from</h2> |
|
| 28 | + | <label for="update-repo">The repository whose releases carry runner builds</label> |
|
| 29 | + | <input type="text" id="update-repo" name="repo" value="{{ repo }}" placeholder="owner/name, e.g. andymccall/oxygenflow"> |
|
| 30 | + | <p class="small mute">Each release offers one file per machine, <code>flow-linux-x86_64</code>, <code>flow-linux-aarch64</code>, <code>flow-linux-riscv64</code>. <em>stable</em> is its newest release, <em>canary</em> its newest including pre-releases; a version (<code>0.3.4</code>) pins it, going back if need be.</p> |
|
| 31 | + | <button type="submit">Save</button> |
|
| 32 | + | </form> |
|
| 33 | + | <form class="card wide" method="post" action="/admin/runners/updates/policy"> |
|
| 34 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="site"> |
|
| 35 | + | <h2>The site</h2> |
|
| 36 | + | <p class="small mute">What every runner does unless a nearer level says otherwise: {{ site.summary() }}.</p> |
|
| 37 | + | <div class="pol-row">{% call policy_fields(site, true) %}</div> |
|
| 38 | + | <button type="submit">Save</button> |
|
| 39 | + | </form> |
|
| 40 | + | <section class="panel" id="owners"> |
|
| 41 | + | <h2>Owners</h2> |
|
| 42 | + | <p class="small mute">For the runners an account or organisation registered for its own repositories.</p> |
|
| 43 | + | {% if owners.is_empty() %}<p class="mute small">No owner sets anything; their runners follow the site.</p>{% endif %} |
|
| 44 | + | {% for o in owners %} |
|
| 45 | + | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 46 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"><input type="hidden" name="key" value="{{ o.key }}"> |
|
| 47 | + | <strong>{{ o.key }}</strong>{% call policy_fields(o, false) %}<button type="submit">Save</button> |
|
| 48 | + | </form> |
|
| 49 | + | {% endfor %} |
|
| 50 | + | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 51 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="owner"> |
|
| 52 | + | <label class="pol">Owner <input type="text" name="key" size="12" placeholder="account or org" required></label>{% call policy_fields(site, false) %}<button type="submit">Add</button> |
|
| 53 | + | </form> |
|
| 54 | + | </section> |
|
| 55 | + | <section class="panel" id="groups"> |
|
| 56 | + | <h2>Test groups</h2> |
|
| 57 | + | <p class="small mute">A named set of runners: those you choose, or a percentage of the site's runners (or one owner's). Give a group its own settings to try a build on it before the rest.</p> |
|
| 58 | + | {% if groups.is_empty() %}<p class="mute small">No test groups yet.</p>{% endif %} |
|
| 59 | + | {% for gv in groups %}{% let g = gv.g %} |
|
| 60 | + | <div class="card wide update-group"> |
|
| 61 | + | <h3>{{ g.name }} <small class="mute">{{ gv.reach }} · {% if let Some(p) = g.percent %}{{ p }}%{% else %}chosen{% endif %} · {{ gv.members.len()|plural("runner", "runners") }} now</small></h3> |
|
| 62 | + | <p class="small">{% if gv.members.is_empty() %}<span class="mute">No runners in it.</span>{% else %}{% for m in gv.members %}<span class="lbl">{{ m }}</span> {% endfor %}{% endif %}</p> |
|
| 63 | + | <form class="pol-form" method="post" action="/admin/runners/updates/policy"> |
|
| 64 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="group"><input type="hidden" name="key" value="{{ g.id }}"> |
|
| 65 | + | {% call policy_fields(gv.policy, false) %}<button type="submit">Save settings</button> |
|
| 66 | + | </form> |
|
| 67 | + | <details><summary class="small">Who is in it</summary> |
|
| 68 | + | <form method="post" action="/admin/runners/updates/groups/{{ g.id }}"> |
|
| 69 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 70 | + | <label class="pol"><input type="radio" name="mode" value="percent"{% if g.percent.is_some() %} checked{% endif %}> a percentage of {{ gv.reach }}: <input type="number" name="percent" min="1" max="100" size="4" value="{% if let Some(p) = g.percent %}{{ p }}{% endif %}">%</label> |
|
| 71 | + | <label class="pol"><input type="radio" name="mode" value="chosen"{% if g.percent.is_none() %} checked{% endif %}> these runners:</label> |
|
| 72 | + | <div class="pol-row">{% for rv in runners %}{% if g.owner.is_empty() || g.owner == rv.r.scope_owner %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"{% if gv.chosen.contains(rv.r.id) %} checked{% endif %}> {{ rv.r.name }}</label>{% endif %}{% endfor %}</div> |
|
| 73 | + | <button type="submit">Save members</button> |
|
| 74 | + | </form> |
|
| 75 | + | <form class="tiny" method="post" action="/admin/runners/updates/groups/{{ g.id }}/delete" data-confirm="Remove the test group {{ g.name }}? Its runners follow the levels above it."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove group</button></form> |
|
| 76 | + | </details> |
|
| 77 | + | </div> |
|
| 78 | + | {% endfor %} |
|
| 79 | + | <form class="card wide" method="post" action="/admin/runners/updates/groups"> |
|
| 80 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 81 | + | <h3>New test group</h3> |
|
| 82 | + | <div class="pol-row"> |
|
| 83 | + | <label class="pol">Name <input type="text" name="name" size="14" required placeholder="canary"></label> |
|
| 84 | + | <label class="pol">Of <input type="text" name="owner" size="12" placeholder="the site's runners" title="Blank for the site's runners, or an owner for theirs"></label> |
|
| 85 | + | </div> |
|
| 86 | + | <label class="pol"><input type="radio" name="mode" value="percent"> a percentage: <input type="number" name="percent" min="1" max="100" size="4">%</label> |
|
| 87 | + | <label class="pol"><input type="radio" name="mode" value="chosen" checked> these runners:</label> |
|
| 88 | + | <div class="pol-row">{% for rv in runners %}<label class="pol"><input type="checkbox" name="runner" value="{{ rv.r.id }}"> {{ rv.r.name }}</label>{% endfor %}</div> |
|
| 89 | + | <button type="submit">Make group</button> |
|
| 90 | + | </form> |
|
| 91 | + | </section> |
|
| 92 | + | <section class="panel" id="runners"> |
|
| 93 | + | <h2>Runners</h2> |
|
| 94 | + | {% if runners.is_empty() %}<p class="mute">No runners yet.</p>{% else %} |
|
| 95 | + | <div class="table"><table> |
|
| 96 | + | <tr><th>Runner</th><th>Runs</th><th>Groups</th><th>Its policy</th><th>Now</th><th>Last said</th><th>Its own settings</th></tr> |
|
| 97 | + | {% for rv in runners %}{% let r = rv.r %} |
|
| 98 | + | <tr> |
|
| 99 | + | <td><a href="/admin/runners/{{ r.id }}"><strong>{{ r.name }}</strong></a>{% if !r.scope_owner.is_empty() %}<br><span class="small mute">{{ r.scope_owner }}'s</span>{% endif %}</td> |
|
| 100 | + | <td class="small">{% if r.version.is_empty() %}<span class="mute">unknown</span>{% else %}flow {{ r.version }}{% endif %}</td> |
|
| 101 | + | <td class="small">{% for g in rv.groups %}<span class="lbl">{{ g }}</span> {% endfor %}</td> |
|
| 102 | + | <td class="small">{{ rv.effective_words() }}</td> |
|
| 103 | + | <td class="small">{{ rv.offer }}</td> |
|
| 104 | + | <td class="small">{% if r.update_note.is_empty() %}<span class="mute">nothing yet</span>{% else %}{{ r.update_note }}{% if let Some(t) = r.update_ms %} <time class="mute" title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td> |
|
| 105 | + | <td><form class="pol-form tiny" method="post" action="/admin/runners/updates/policy"> |
|
| 106 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="scope" value="runner"><input type="hidden" name="key" value="{{ r.id }}"> |
|
| 107 | + | {% call policy_fields(rv.policy, false) %}<button type="submit">set</button></form></td> |
|
| 108 | + | </tr> |
|
| 109 | + | {% endfor %} |
|
| 110 | + | </table></div> |
|
| 111 | + | {% endif %} |
|
| 112 | + | </section> |
|
| 113 | + | </div> |
|
| 114 | + | </div> |
|
| 115 | + | {% endblock %} |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. Pins OxygenFlow's main. |
|
| 8 | + | ||
| 7 | 9 | ## 0.9.5 (2026-10-26) |
|
| 8 | 10 | ||
| 9 | 11 | - **No failed run every minute for a pipeline that does not parse (#321):** the scheduler asked each repository's pipeline every minute and, for one it could not read (a mistake, or no jobs yet), recorded a failed `schedule` run each time: oxygenhub-android had about 1,500. A scheduled tick now starts nothing for such a pipeline; its push's run still says what is wrong. |
|
| … | 333 unchanged lines |
modified crates/hub/src/api/mod.rs +1 −0
| … | 60 unchanged lines |
||
| 61 | 61 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 62 | 62 | .route("/flow/runners/register", post(crate::flow::register)) |
|
| 63 | 63 | .route("/flow/runners/heartbeat", post(crate::flow::heartbeat)) |
|
| 64 | + | .route("/flow/runner/update", get(crate::flow::runner_update).post(crate::flow::report_update)) |
|
| 64 | 65 | .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES))) |
|
| 65 | 66 | .route("/flow/swarm/announce", post(crate::swarm::announce)) |
|
| 66 | 67 | .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk)) |
|
| … | 3024 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 13 unchanged lines |
||
| 14 | 14 | pub mod queue; |
|
| 15 | 15 | pub mod releases; |
|
| 16 | 16 | pub mod repos; |
|
| 17 | + | pub mod runner_updates; |
|
| 17 | 18 | pub mod runners; |
|
| 18 | 19 | pub mod sessions; |
|
| 19 | 20 | pub mod terraform; |
|
| … | 151 unchanged lines |
modified crates/hub/src/db/runners.rs +5 −0
| … | 45 unchanged lines |
||
| 46 | 46 | pub images: String, |
|
| 47 | 47 | /// BLAKE3 of what it offers; its declines hold until this changes. |
|
| 48 | 48 | pub profile: String, |
|
| 49 | + | /// What it last said about its update (oxygenhub#322), and when. |
|
| 50 | + | pub update_note: String, |
|
| 51 | + | pub update_ms: Option<i64>, |
|
| 49 | 52 | } |
|
| 50 | 53 | ||
| 51 | 54 | /// Which repositories' jobs a runner may take (oxygenhub#41). |
|
| … | 230 unchanged lines |
||
| 282 | 285 | ||
| 283 | 286 | /// Forget a runner: its credential stops working. Its jobs keep its name. |
|
| 284 | 287 | pub async fn remove(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 288 | + | sqlx::query("DELETE FROM runner_group_members WHERE runner_id = $1").bind(id).execute(pool).await?; |
|
| 289 | + | sqlx::query("DELETE FROM runner_update_policies WHERE scope = 'runner' AND key = $1").bind(id.to_string()).execute(pool).await?; |
|
| 285 | 290 | sqlx::query("DELETE FROM flow_runners WHERE id = $1").bind(id).execute(pool).await?; |
|
| 286 | 291 | Ok(()) |
|
| 287 | 292 | } |
|
| … | 172 unchanged lines |
modified crates/hub/src/flow.rs +30 −0
| … | 1489 unchanged lines |
||
| 1490 | 1490 | Ok(StatusCode::NO_CONTENT) |
|
| 1491 | 1491 | } |
|
| 1492 | 1492 | ||
| 1493 | + | /// `GET /api/v1/flow/runner/update` (oxygenhub#322): the update policy |
|
| 1494 | + | /// that applies to this runner and the build it should run, if another. |
|
| 1495 | + | pub async fn runner_update(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<flow_core::api::UpdateAnswer>, ApiError> { |
|
| 1496 | + | let me = runner_auth(&app, &headers).await?; |
|
| 1497 | + | db::runners::seen(&app.db, me.id, None, None).await?; |
|
| 1498 | + | let policy = crate::runner_updates::Levels::load(&app.db).await?.resolve(&me); |
|
| 1499 | + | let hour = time::OffsetDateTime::now_utc().hour(); |
|
| 1500 | + | let (offer, note) = crate::runner_updates::offer(&app.db, &me, &policy, hour).await?; |
|
| 1501 | + | Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note })) |
|
| 1502 | + | } |
|
| 1503 | + | ||
| 1504 | + | /// `POST /api/v1/flow/runner/update`: what the runner did about its update |
|
| 1505 | + | /// (fetched, checked, installed, a dry run's verdict, a rollback), kept on |
|
| 1506 | + | /// its page and in the audit log. |
|
| 1507 | + | pub async fn report_update( |
|
| 1508 | + | State(app): State<Shared>, |
|
| 1509 | + | headers: HeaderMap, |
|
| 1510 | + | Json(r): Json<flow_core::api::UpdateReport>, |
|
| 1511 | + | ) -> Result<StatusCode, ApiError> { |
|
| 1512 | + | let me = runner_auth(&app, &headers).await?; |
|
| 1513 | + | let note: String = r.note.chars().map(|c| if c.is_control() { ' ' } else { c }).take(500).collect(); |
|
| 1514 | + | let note = note.trim(); |
|
| 1515 | + | if note.is_empty() { |
|
| 1516 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "say what happened (note)".into())); |
|
| 1517 | + | } |
|
| 1518 | + | db::runner_updates::report(&app.db, me.id, note).await?; |
|
| 1519 | + | db::accounts::audit(&app.db, None, "runner.update", &me.name, note).await.ok(); |
|
| 1520 | + | Ok(StatusCode::NO_CONTENT) |
|
| 1521 | + | } |
|
| 1522 | + | ||
| 1493 | 1523 | pub async fn decline( |
|
| 1494 | 1524 | State(app): State<Shared>, |
|
| 1495 | 1525 | headers: HeaderMap, |
|
| … | 1514 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 43 unchanged lines |
||
| 44 | 44 | pub mod render; |
|
| 45 | 45 | pub mod reports; |
|
| 46 | 46 | pub mod repos; |
|
| 47 | + | pub mod runner_updates; |
|
| 47 | 48 | pub mod secrets; |
|
| 48 | 49 | pub mod setup; |
|
| 49 | 50 | pub mod shutdown; |
|
| … | 378 unchanged lines |
modified crates/hub/src/web/admin.rs +5 −0
| … | 23 unchanged lines |
||
| 24 | 24 | Runners, |
|
| 25 | 25 | Images, |
|
| 26 | 26 | RunnerSettings, |
|
| 27 | + | RunnerUpdates, |
|
| 27 | 28 | Insights, |
|
| 28 | 29 | Variables, |
|
| 29 | 30 | Secrets, |
|
| … | 16 unchanged lines |
||
| 46 | 47 | AdminSection::Runners, |
|
| 47 | 48 | AdminSection::Images, |
|
| 48 | 49 | AdminSection::RunnerSettings, |
|
| 50 | + | AdminSection::RunnerUpdates, |
|
| 49 | 51 | AdminSection::Insights, |
|
| 50 | 52 | AdminSection::Variables, |
|
| 51 | 53 | AdminSection::Secrets, |
|
| … | 23 unchanged lines |
||
| 75 | 77 | AdminSection::Runners => "/admin/runners", |
|
| 76 | 78 | AdminSection::Images => "/admin/runners/images", |
|
| 77 | 79 | AdminSection::RunnerSettings => "/admin/runners/settings", |
|
| 80 | + | AdminSection::RunnerUpdates => "/admin/runners/updates", |
|
| 78 | 81 | AdminSection::Insights => "/admin/insights", |
|
| 79 | 82 | AdminSection::Variables => "/admin/variables", |
|
| 80 | 83 | AdminSection::Secrets => "/admin/secrets", |
|
| … | 19 unchanged lines |
||
| 100 | 103 | AdminSection::Runners => "runners", |
|
| 101 | 104 | AdminSection::Images => "images", |
|
| 102 | 105 | AdminSection::RunnerSettings => "runner-settings", |
|
| 106 | + | AdminSection::RunnerUpdates => "runner-updates", |
|
| 103 | 107 | AdminSection::Insights => "insights", |
|
| 104 | 108 | AdminSection::Variables => "variables", |
|
| 105 | 109 | AdminSection::Secrets => "secrets", |
|
| … | 17 unchanged lines |
||
| 123 | 127 | AdminSection::Runners => "Runners", |
|
| 124 | 128 | AdminSection::Images => "Images", |
|
| 125 | 129 | AdminSection::RunnerSettings => "Runner settings", |
|
| 130 | + | AdminSection::RunnerUpdates => "Runner updates", |
|
| 126 | 131 | AdminSection::Insights => "Insights", |
|
| 127 | 132 | AdminSection::Variables => "Variables", |
|
| 128 | 133 | AdminSection::Secrets => "Secrets", |
|
| … | 594 unchanged lines |
modified crates/hub/src/web/mod.rs +7 −0
| … | 50 unchanged lines |
||
| 51 | 51 | mod repo_settings; |
|
| 52 | 52 | pub mod reports; |
|
| 53 | 53 | mod resolve; |
|
| 54 | + | pub mod runner_updates; |
|
| 54 | 55 | pub mod runners; |
|
| 55 | 56 | mod secrets; |
|
| 56 | 57 | mod session; |
|
| … | 106 unchanged lines |
||
| 163 | 164 | .route("/admin/runners/settings", get(runners::settings).post(runners::save_settings)) |
|
| 164 | 165 | .route("/admin/runners/priority", post(runners::save_priority)) |
|
| 165 | 166 | .route("/admin/runners/images", get(runners::images)) |
|
| 167 | + | .route("/admin/runners/updates", get(runner_updates::show)) |
|
| 168 | + | .route("/admin/runners/updates/source", post(runner_updates::save_source)) |
|
| 169 | + | .route("/admin/runners/updates/policy", post(runner_updates::save_policy)) |
|
| 170 | + | .route("/admin/runners/updates/groups", post(runner_updates::create_group)) |
|
| 171 | + | .route("/admin/runners/updates/groups/{id}", post(runner_updates::save_group)) |
|
| 172 | + | .route("/admin/runners/updates/groups/{id}/delete", post(runner_updates::delete_group)) |
|
| 166 | 173 | .route("/admin/insights", get(insights::site)) |
|
| 167 | 174 | .route("/insights/{owner}", get(insights::owner)) |
|
| 168 | 175 | .route("/admin/diffusion", get(diffusion::admin)) |
|
| … | 782 unchanged lines |
modified crates/hub/tests/flow.rs +137 −0
| … | 6572 unchanged lines |
||
| 6573 | 6573 | superseded_by(3, 10).await; |
|
| 6574 | 6574 | superseded_by(9, 10).await; |
|
| 6575 | 6575 | } |
|
| 6576 | + | ||
| 6577 | + | /// oxygenhub#322: runners are offered the build their nearest update |
|
| 6578 | + | /// setting asks for, off unless some level turns it on; a test group of |
|
| 6579 | + | /// chosen runners tries it first. |
|
| 6580 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 6581 | + | async fn runners_are_offered_updates_by_their_nearest_setting() { |
|
| 6582 | + | use oxy_core::identity::SecretKey; |
|
| 6583 | + | let h = hub().await; |
|
| 6584 | + | create(&h.app, "andy", "oxygenflow").await; |
|
| 6585 | + | let res = oxygenhub::router(h.app.clone()) |
|
| 6586 | + | .oneshot( |
|
| 6587 | + | Request::post("/register") |
|
| 6588 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 6589 | + | .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup")) |
|
| 6590 | + | .unwrap(), |
|
| 6591 | + | ) |
|
| 6592 | + | .await |
|
| 6593 | + | .unwrap(); |
|
| 6594 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 6595 | + | let admin = db::accounts::account_by_name(&h.app.db, "carol").await.unwrap().unwrap(); |
|
| 6596 | + | db::accounts::set_admin(&h.app.db, admin.id, true).await.unwrap(); |
|
| 6597 | + | let web = |method: &'static str, path: &str, body: Option<String>| { |
|
| 6598 | + | let (app, cookie, path) = (h.app.clone(), cookie.clone(), path.to_string()); |
|
| 6599 | + | async move { |
|
| 6600 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 6601 | + | if body.is_some() { |
|
| 6602 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 6603 | + | } |
|
| 6604 | + | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 6605 | + | let st = res.status(); |
|
| 6606 | + | (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 6607 | + | } |
|
| 6608 | + | }; |
|
| 6609 | + | let (st, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 6610 | + | assert_eq!(st, StatusCode::OK, "{page}"); |
|
| 6611 | + | let csrf = { |
|
| 6612 | + | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 6613 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 6614 | + | }; |
|
| 6615 | + | assert_eq!(call(&h.app, "GET", "/admin/runners/updates", None, None).await.0, StatusCode::NOT_FOUND, "administrators only"); |
|
| 6616 | + | let mut creds = vec![]; |
|
| 6617 | + | for name in ["plex", "narvi"] { |
|
| 6618 | + | let (_, page) = web("POST", "/admin/runners/token", Some(format!("_csrf={csrf}"))).await; |
|
| 6619 | + | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 6620 | + | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| 6621 | + | let identity = SecretKey::generate().identity(&format!("{name} (OxygenFlow runner)"), "", 1); |
|
| 6622 | + | let info = serde_json::json!({ "arch": "x86_64", "os": "linux", "version": "0.1.0 (build)", "max_jobs": 1 }); |
|
| 6623 | + | let reg = serde_json::json!({ "token": token, "name": name, "identity": hex::encode(identity.encode()), "info": info }); |
|
| 6624 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await; |
|
| 6625 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 6626 | + | creds.push(serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string()); |
|
| 6627 | + | } |
|
| 6628 | + | let ask = |who: usize| { |
|
| 6629 | + | let (app, cred) = (h.app.clone(), creds[who].clone()); |
|
| 6630 | + | async move { |
|
| 6631 | + | let (st, body) = call(&app, "GET", "/api/v1/flow/runner/update", Some(&cred), None).await; |
|
| 6632 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 6633 | + | serde_json::from_str::<flow_core::api::UpdateAnswer>(&body).unwrap() |
|
| 6634 | + | } |
|
| 6635 | + | }; |
|
| 6636 | + | let a = ask(0).await; |
|
| 6637 | + | assert!(!a.policy.updates && a.offer.is_none(), "off unless chosen: {a:?}"); |
|
| 6638 | + | assert_eq!(a.note, "updates are off (default)"); |
|
| 6639 | + | assert_eq!(call(&h.app, "GET", "/api/v1/flow/runner/update", Some("flowrunner_nope"), None).await.0, StatusCode::UNAUTHORIZED); |
|
| 6640 | + | ||
| 6641 | + | // A release of builds, and the site taking them from it. |
|
| 6642 | + | let repo = db::repos::get(&h.app.db, "andy", "oxygenflow").await.unwrap().unwrap(); |
|
| 6643 | + | let rel = db::releases::create(&h.app.db, repo.id, "v0.2.0", "0.2.0", "", false, None).await.unwrap(); |
|
| 6644 | + | db::releases::add_asset(&h.app.db, rel.id, "flow-linux-x86_64", 42, &"ab".repeat(32), "application/octet-stream").await.unwrap(); |
|
| 6645 | + | let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Fnope"))).await; |
|
| 6646 | + | assert!(page.contains("There is no repository"), "{page}"); |
|
| 6647 | + | let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow"))).await; |
|
| 6648 | + | assert!(page.contains("runner builds come from andy/oxygenflow"), "{page}"); |
|
| 6649 | + | ||
| 6650 | + | // A test group of one chosen runner, with updates on: plex, not narvi. |
|
| 6651 | + | let plex = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "plex").unwrap(); |
|
| 6652 | + | let (_, page) = |
|
| 6653 | + | web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=canary&mode=chosen&runner={}", plex.id))).await; |
|
| 6654 | + | assert!(page.contains("Test group canary made: 1 chosen runners"), "{page}"); |
|
| 6655 | + | let group = db::runner_updates::groups(&h.app.db).await.unwrap().remove(0); |
|
| 6656 | + | let (_, page) = |
|
| 6657 | + | web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=group&key={}&updates=on", group.id))).await; |
|
| 6658 | + | assert!(page.contains("Saved for group canary: updates on."), "{page}"); |
|
| 6659 | + | let a = ask(0).await; |
|
| 6660 | + | let offer = a.offer.clone().expect("plex is offered the build"); |
|
| 6661 | + | assert_eq!((offer.version.as_str(), offer.asset.as_str(), offer.blake3.len()), ("0.2.0", "flow-linux-x86_64", 64)); |
|
| 6662 | + | assert!(offer.url.starts_with("/andy/oxygenflow/asset/") && !offer.older, "{offer:?}"); |
|
| 6663 | + | assert_eq!(a.policy.from[0], "group canary"); |
|
| 6664 | + | assert!(ask(1).await.offer.is_none(), "narvi is not in the group"); |
|
| 6665 | + | ||
| 6666 | + | // The site's dry run reaches plex; narvi's own setting pins its version. |
|
| 6667 | + | let (_, page) = web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&updates=off&dry_run=on"))).await; |
|
| 6668 | + | assert!(page.contains("Saved for the site"), "{page}"); |
|
| 6669 | + | let a = ask(0).await; |
|
| 6670 | + | assert!(a.policy.dry_run && a.policy.from[3] == "site" && a.note.contains("dry run"), "{a:?}"); |
|
| 6671 | + | let narvi = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "narvi").unwrap(); |
|
| 6672 | + | web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=runner&key={}&updates=on&channel=0.1.0", narvi.id))) |
|
| 6673 | + | .await; |
|
| 6674 | + | let b = ask(1).await; |
|
| 6675 | + | assert!(b.policy.updates && b.offer.is_none() && b.note.contains("has no release"), "{b:?}"); |
|
| 6676 | + | ||
| 6677 | + | // Outside its window it waits. |
|
| 6678 | + | let now = time::OffsetDateTime::now_utc().hour(); |
|
| 6679 | + | let (from, to) = ((now + 2) % 24, (now + 4) % 24); |
|
| 6680 | + | web( |
|
| 6681 | + | "POST", |
|
| 6682 | + | "/admin/runners/updates/policy", |
|
| 6683 | + | Some(format!("_csrf={csrf}&scope=group&key={}&updates=on&window={from:02}-{to:02}", group.id)), |
|
| 6684 | + | ) |
|
| 6685 | + | .await; |
|
| 6686 | + | let a = ask(0).await; |
|
| 6687 | + | assert!(a.offer.is_none() && a.note.contains("waits for its window"), "{a:?}"); |
|
| 6688 | + | ||
| 6689 | + | // Mistakes are refused, saying why. |
|
| 6690 | + | let (_, page) = web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&channel=latest"))).await; |
|
| 6691 | + | assert!(page.contains("A channel is stable, canary or a version"), "{page}"); |
|
| 6692 | + | let (_, page) = web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&window=9"))).await; |
|
| 6693 | + | assert!(page.contains("a window is two hours"), "{page}"); |
|
| 6694 | + | let (_, page) = web("POST", "/admin/runners/updates/groups", Some(format!("_csrf={csrf}&name=farm&mode=percent&percent=0"))).await; |
|
| 6695 | + | assert!(page.contains("A percentage is a whole number from 1 to 100."), "{page}"); |
|
| 6696 | + | ||
| 6697 | + | // What a runner says shows on the page and in the audit log. |
|
| 6698 | + | let report = serde_json::json!({ "note": "dry run: flow 0.2.0 checks out" }).to_string(); |
|
| 6699 | + | assert_eq!(call(&h.app, "POST", "/api/v1/flow/runner/update", Some(&creds[0]), Some(report)).await.0, StatusCode::NO_CONTENT); |
|
| 6700 | + | let (_, page) = web("GET", "/admin/runners/updates", None).await; |
|
| 6701 | + | assert!(page.contains("dry run: flow 0.2.0 checks out") && page.contains(">canary</span>"), "{page}"); |
|
| 6702 | + | assert!(page.contains("updates on (group canary)") && page.contains("updates on (runner) · channel 0.1.0 (runner)"), "{page}"); |
|
| 6703 | + | ||
| 6704 | + | // A group of a share: removed, its runners follow the levels above. |
|
| 6705 | + | let (_, page) = |
|
| 6706 | + | web("POST", &format!("/admin/runners/updates/groups/{}", group.id), Some(format!("_csrf={csrf}&mode=percent&percent=100"))).await; |
|
| 6707 | + | assert!(page.contains("Test group canary is now 100% of its runners."), "{page}"); |
|
| 6708 | + | assert!(ask(1).await.policy.from[0] == "runner", "narvi's own setting is nearer than any group"); |
|
| 6709 | + | let (_, page) = web("POST", &format!("/admin/runners/updates/groups/{}/delete", group.id), Some(format!("_csrf={csrf}"))).await; |
|
| 6710 | + | assert!(page.contains("Test group canary removed"), "{page}"); |
|
| 6711 | + | assert!(!ask(0).await.policy.updates, "back to the site's off"); |
|
| 6712 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified templates/admin_images.html +1 −1
| … | 10 unchanged lines |
||
| 11 | 11 | <div class="settings-layout"> |
|
| 12 | 12 | {% call an::nav(crate::web::admin::AdminSection::Images) %} |
|
| 13 | 13 | <div class="sections settings-body"> |
|
| 14 | - | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 14 | + | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a><a href="/admin/runners/updates">updates</a></span></div> |
|
| 15 | 15 | <p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p> |
|
| 16 | 16 | {% if images.is_empty() %} |
|
| 17 | 17 | <p class="mute">No images yet.</p> |
|
| … | 18 unchanged lines |
modified templates/admin_runner_settings.html +1 −1
| … | 5 unchanged lines |
||
| 6 | 6 | <div class="settings-layout"> |
|
| 7 | 7 | {% call an::nav(crate::web::admin::AdminSection::RunnerSettings) %} |
|
| 8 | 8 | <div class="sections settings-body"> |
|
| 9 | - | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings" class="on">settings</a></span></div> |
|
| 9 | + | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings" class="on">settings</a><a href="/admin/runners/updates">updates</a></span></div> |
|
| 10 | 10 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 11 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | 12 | <form class="card wide" method="post" action="/admin/runners/settings"> |
|
| … | 29 unchanged lines |
modified templates/admin_runners.html +1 −1
| … | 6 unchanged lines |
||
| 7 | 7 | <div class="settings-layout"> |
|
| 8 | 8 | {% call an::nav(crate::web::admin::AdminSection::Runners) %} |
|
| 9 | 9 | <div class="sections settings-body"> |
|
| 10 | - | <div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 10 | + | <div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a><a href="/admin/runners/updates">updates</a></span></div> |
|
| 11 | 11 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 12 | 12 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 13 | 13 | {% if let Some(t) = new_token %} |
|
| … | 62 unchanged lines |
modified templates/style.css +7 −0
| … | 1459 unchanged lines |
||
| 1460 | 1460 | .drop-comment-form textarea { width: 100%; box-sizing: border-box; } |
|
| 1461 | 1461 | .drop-file { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; margin: 0 0 12px; } |
|
| 1462 | 1462 | .drop-file-actions { display: flex; align-items: flex-end; justify-content: flex-end; } |
|
| 1463 | + | /* Admin › Runners › Updates (oxygenhub#322): a level's settings in a row. */ |
|
| 1464 | + | .pol-row, .pol-form { display: flex; flex-wrap: wrap; align-items: end; gap: 6px 14px; margin: 6px 0; } |
|
| 1465 | + | .pol-form > strong { align-self: center; min-width: 8em; } |
|
| 1466 | + | label.pol { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-weight: normal; } |
|
| 1467 | + | label.pol input[type=text], label.pol input[type=number], label.pol select { width: auto; margin: 0; } |
|
| 1468 | + | td .pol-form { flex-wrap: nowrap; gap: 4px 8px; } |
|
| 1469 | + | .update-group h3 small { font-weight: normal; } |