OXYGENHUB

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; }