OXYGENHUB
# d7050703d2f4 oxygenhub / crates / hub / src / runner_updates.rs
11.7 KiB · Rust · last changed Runner updates: policy, test groups and the offer 11 days ago source blame history raw
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
}