OXYGENHUB

Coverage / crates/hub/src/db/runners.rs 96.4%

318 of 330 lines run, 74 of 132 functions · run #497 on main · run missed

1
//! OxygenFlow runners (hub spec §13.2): who may claim jobs, what they are,
2
//! whether they are taking work, and what they have run.
3
4
use super::{now_ms, Pool};
5
use sqlx::FromRow;
6
7
#[derive(Clone, Debug, Default, FromRow)]
8
pub struct Runner {
9
    pub id: i64,
10
    pub name: String,
11
    pub token_hash: String,
12
    pub source: String,
13
    pub key: String,
14
    pub key_name: String,
15
    pub arch: String,
16
    pub os: String,
17
    pub version: String,
18
    pub labels: String,
19
    pub max_jobs: i64,
20
    pub memory_mib: Option<i64>,
21
    pub cpus: Option<i64>,
22
    pub state: String,
23
    pub created_ms: i64,
24
    pub created_by: String,
25
    pub last_seen_ms: Option<i64>,
26
    /// The owner whose repositories it serves; empty for the whole site.
27
    pub scope_owner: String,
28
    /// The one repository it serves, when it serves one.
29
    pub scope_repo_id: Option<i64>,
30
    /// Labels an administrator gave it, beside those it reports (#98).
31
    pub admin_labels: String,
32
    /// A time limit for its jobs in minutes, below the site's, when set.
33
    pub timeout_minutes: Option<i64>,
34
    /// It fetches images it lacks from the hub (oxygenflow#39).
35
    pub fetches_images: bool,
36
    /// What it measured at its last claim (oxygenflow#40): free disk (MiB),
37
    /// the 1-minute load ×100, its speed score and class, its kernel.
38
    pub free_disk_mib: Option<i64>,
39
    pub load_x100: Option<i64>,
40
    pub speed: Option<i64>,
41
    pub speed_class: Option<i64>,
42
    pub kernel: String,
43
    /// The tiers it offered at its last claim, comma-separated.
44
    pub tiers: String,
45
    /// The images it held then (a JSON array); empty when it did not say.
46
    pub images: String,
47
    /// BLAKE3 of what it offers; its declines hold until this changes.
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>,
52
}
53
54
/// Which repositories' jobs a runner may take (oxygenhub#41).
55
#[derive(Clone, Debug, Default, PartialEq, Eq)]
56
pub struct Scope {
57
    /// Empty: any owner.
58
    pub owner: String,
59
    /// `None`: any of the owner's repositories.
60
    pub repo_id: Option<i64>,
61
}
62
63
impl Scope {
646×
    pub fn is_site(&self) -> bool {
656×
        self.owner.is_empty() && self.repo_id.is_none()
666×
    }
67
}
68
69
/// A runner that has not polled for this long is shown offline.
70
pub const ONLINE_MS: i64 = 90_000;
71
72
impl Runner {
73165×
    pub fn online(&self) -> bool {
74165×
        self.last_seen_ms.is_some_and(|t| now_ms() - t < ONLINE_MS)
75165×
    }
76
77
    /// Whether the hub has heard from it lately (oxygenhub#243): any call it
78
    /// makes counts, a busy runner's heartbeat included.
7933×
    pub fn connectivity(&self) -> &'static str {
8033×
        if self.online() {
8131×
            "online"
82
        } else {
832×
            "offline"
84
        }
8533×
    }
86
87
    /// `online`, `offline`, `draining` or `disabled`.
8888×
    pub fn status(&self) -> &'static str {
8988×
        match self.state.as_str() {
9088×
            "disabled" => "disabled",
9188×
            "draining" => "draining",
9288×
            _ if self.online() => "online",
930×
            _ => "offline",
94
        }
9588×
    }
96
97
    /// Every label it carries: what it reports, and what an administrator
98
    /// gave it.
9952×
    pub fn label_list(&self) -> Vec<&str> {
10052×
        let mut out: Vec<&str> = self.labels.split(',').filter(|l| !l.is_empty()).collect();
10152×
        for l in self.admin_label_list() {
10210×
            if !out.contains(&l) {
10310×
                out.push(l);
10410×
            }
105
        }
10652×
        out
10752×
    }
108
109279×
    pub fn admin_label_list(&self) -> Vec<&str> {
110286×
        self.admin_labels.split(',').filter(|l| !l.is_empty()).collect()
111279×
    }
112
113
    /// The labels it reported itself, as the admin page distinguishes them.
114216×
    pub fn reported_label_list(&self) -> Vec<&str> {
115216×
        self.labels.split(',').filter(|l| !l.is_empty()).collect()
116216×
    }
117
118279×
    pub fn scope(&self) -> Scope {
119279×
        Scope { owner: self.scope_owner.clone(), repo_id: self.scope_repo_id }
120279×
    }
121
}
122
123464×
pub async fn by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<Runner>> {
124464×
    sqlx::query_as("SELECT * FROM flow_runners WHERE token_hash = $1").bind(token_hash).fetch_optional(pool).await
125464×
}
126
12734×
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Runner>> {
12834×
    sqlx::query_as("SELECT * FROM flow_runners WHERE id = $1").bind(id).fetch_optional(pool).await
12934×
}
130
131456×
pub async fn list(pool: &Pool) -> sqlx::Result<Vec<Runner>> {
132456×
    sqlx::query_as("SELECT * FROM flow_runners ORDER BY name, id").fetch_all(pool).await
133456×
}
134
135
/// The row of a runner configured with a hub.toml token, made on first sight.
13667×
pub async fn configured(pool: &Pool, token_hash: &str, name: &str) -> sqlx::Result<Runner> {
13767×
    sqlx::query("INSERT INTO flow_runners (name, token_hash, source, created_ms) VALUES ($1, $2, 'config', $3) ON CONFLICT DO NOTHING")
13867×
        .bind(name)
13967×
        .bind(token_hash)
14067×
        .bind(now_ms())
14167×
        .execute(pool)
14267×
        .await?;
14367×
    Ok(by_token(pool, token_hash).await?.expect("just made"))
14467×
}
145
146
/// What a claim says about the runner, and that it was seen now. A
147
/// configured runner takes the name it claims with.
148
/// The administrator's settings for a runner (#98).
1491×
pub async fn set_limits(pool: &Pool, id: i64, admin_labels: &str, timeout_minutes: Option<i64>) -> sqlx::Result<()> {
1501×
    sqlx::query("UPDATE flow_runners SET admin_labels = $1, timeout_minutes = $2 WHERE id = $3")
1511×
        .bind(admin_labels)
1521×
        .bind(timeout_minutes)
1531×
        .bind(id)
1541×
        .execute(pool)
1551×
        .await?;
1561×
    Ok(())
1571×
}
158
159366×
pub async fn seen(pool: &Pool, id: i64, name: Option<&str>, info: Option<&flow_core::api::RunnerInfo>) -> sqlx::Result<()> {
160366×
    let now = now_ms();
161366×
    match info {
16228×
        Some(i) => {
16328×
            sqlx::query(
16428×
                "UPDATE flow_runners SET arch = $1, os = $2, version = $3, labels = $4, max_jobs = $5, memory_mib = $6, cpus = $7, last_seen_ms = $8 WHERE id = $9",
16528×
            )
16628×
            .bind(&i.arch)
16728×
            .bind(&i.os)
16828×
            .bind(&i.version)
16928×
            .bind(i.labels.join(","))
17028×
            .bind(i.max_jobs as i64)
17128×
            .bind(i.memory_mib.map(i64::from))
17228×
            .bind(i.cpus.map(i64::from))
17328×
            .bind(now)
17428×
            .bind(id)
17528×
            .execute(pool)
17628×
            .await?;
177
        }
178
        None => {
179338×
            sqlx::query("UPDATE flow_runners SET last_seen_ms = $1 WHERE id = $2").bind(now).bind(id).execute(pool).await?;
180
        }
181
    }
182366×
    if let Some(n) = name {
183224×
        sqlx::query("UPDATE flow_runners SET name = $1 WHERE id = $2 AND source = 'config'").bind(n).bind(id).execute(pool).await?;
184142×
    }
185366×
    Ok(())
186366×
}
187
188
/// Keep what a runner measured about itself at a claim (oxygenflow#40).
18913×
pub async fn set_facts(pool: &Pool, id: i64, f: &flow_core::api::Facts) -> sqlx::Result<()> {
19013×
    sqlx::query("UPDATE flow_runners SET free_disk_mib = $1, load_x100 = $2, speed = $3, speed_class = $4, kernel = $5 WHERE id = $6")
19113×
        .bind(f.free_disk_mib.map(|v| v.min(i64::MAX as u64) as i64))
19213×
        .bind(f.load_x100.map(i64::from))
19313×
        .bind(f.speed.map(i64::from))
19413×
        .bind(f.speed_class.map(i64::from))
19513×
        .bind(f.kernel.clone().unwrap_or_default())
19613×
        .bind(id)
19713×
        .execute(pool)
19813×
        .await?;
19913×
    Ok(())
20013×
}
201
202
impl Runner {
203
    /// Its facts as words for the Runners page: "speed 100 (class 3) · load
204
    /// 1.12 · 41 GiB free"; empty when it has sent none.
20517×
    pub fn facts_line(&self) -> String {
20617×
        let mut out = Vec::new();
20717×
        if let Some(s) = self.speed {
2081×
            out.push(format!("speed {s} (class {})", self.speed_class.unwrap_or(0)));
20916×
        }
21017×
        if let Some(l) = self.load_x100 {
2111×
            out.push(format!("load {:.2}", l as f64 / 100.0));
21216×
        }
21317×
        if let Some(d) = self.free_disk_mib {
2141×
            out.push(if d >= 1024 { format!("{} GiB free", d / 1024) } else { format!("{d} MiB free") });
21516×
        }
21617×
        out.join(" · ")
21717×
    }
218
}
219
220
/// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and
221
/// images, and the profile hash over those, its architecture, labels and
222
/// capacity.
223226×
pub async fn set_offer(
224226×
    pool: &Pool,
225226×
    id: i64,
226226×
    tiers: &[String],
227226×
    images: Option<&[String]>,
228226×
    fetches_images: bool,
229226×
    profile: &str,
230226×
) -> sqlx::Result<()> {
231226×
    let images = images.map(|i| serde_json::to_string(i).unwrap_or_default()).unwrap_or_default();
232226×
    sqlx::query("UPDATE flow_runners SET tiers = $1, images = $2, fetches_images = $3, profile = $4 WHERE id = $5")
233226×
        .bind(tiers.join(","))
234226×
        .bind(images)
235226×
        .bind(fetches_images)
236226×
        .bind(profile)
237226×
        .bind(id)
238226×
        .execute(pool)
239226×
        .await?;
240226×
    Ok(())
241226×
}
242
243
impl Runner {
24438×
    pub fn tier_list(&self) -> Vec<&str> {
24545×
        self.tiers.split(',').filter(|t| !t.is_empty()).collect()
24638×
    }
247
248
    /// The images it holds, `None` when it does not say.
249418×
    pub fn image_list(&self) -> Option<Vec<String>> {
250418×
        if self.images.is_empty() {
251344×
            None
252
        } else {
25374×
            serde_json::from_str(&self.images).ok()
254
        }
255418×
    }
256
}
257
2584×
pub async fn set_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> {
2594×
    sqlx::query("UPDATE flow_runners SET state = $1, state_ms = $2 WHERE id = $3")
2604×
        .bind(state)
2614×
        .bind(super::now_ms())
2624×
        .bind(id)
2634×
        .execute(pool)
2644×
        .await?;
2654×
    Ok(())
2664×
}
267
268
/// Whether runner `id`'s last `n` jobs since its state last changed all
269
/// ended in an infrastructure error (oxygenflow#30), with the last one's.
27012×
pub async fn erroring(pool: &Pool, id: i64, n: u32) -> sqlx::Result<Option<String>> {
27112×
    if n == 0 {
2720×
        return Ok(None);
27312×
    }
27412×
    let last: Vec<(String, String)> = sqlx::query_as(
27512×
        "SELECT j.status, j.error FROM flow_jobs j JOIN flow_runners r ON r.id = j.runner_id
27612×
         WHERE j.runner_id = $1 AND j.finished_ms IS NOT NULL AND j.finished_ms >= r.state_ms
27712×
         ORDER BY j.finished_ms DESC, j.id DESC LIMIT $2",
27812×
    )
27912×
    .bind(id)
28012×
    .bind(n as i64)
28112×
    .fetch_all(pool)
28212×
    .await?;
28317×
    Ok((last.len() == n as usize && last.iter().all(|(s, _)| s == "error")).then(|| last[0].1.clone()))
28412×
}
285
286
/// Forget a runner: its credential stops working. Its jobs keep its name.
2873×
pub async fn remove(pool: &Pool, id: i64) -> sqlx::Result<()> {
2883×
    sqlx::query("DELETE FROM runner_group_members WHERE runner_id = $1").bind(id).execute(pool).await?;
2893×
    sqlx::query("DELETE FROM runner_update_policies WHERE scope = 'runner' AND key = $1").bind(id.to_string()).execute(pool).await?;
2903×
    sqlx::query("DELETE FROM flow_runners WHERE id = $1").bind(id).execute(pool).await?;
2913×
    Ok(())
2923×
}
293
294
/// The key a runner signs receipts with: the one bound, or `key` (named
295
/// `name`) when none is yet.
2963×
pub async fn bind_key(pool: &Pool, id: i64, key: &str, name: &str) -> sqlx::Result<String> {
2973×
    sqlx::query("UPDATE flow_runners SET key = $1, key_name = $2 WHERE id = $3 AND key = ''")
2983×
        .bind(key)
2993×
        .bind(name)
3003×
        .bind(id)
3013×
        .execute(pool)
3023×
        .await?;
3033×
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE id = $1").bind(id).fetch_one(pool).await
3043×
}
305
306
/// Keys that runners of this hub sign with.
3075×
pub async fn keys(pool: &Pool) -> sqlx::Result<Vec<String>> {
3085×
    sqlx::query_scalar("SELECT key FROM flow_runners WHERE key != ''").fetch_all(pool).await
3095×
}
310
311
/// A one-time registration token, valid for a day, for a runner of `scope`.
31214×
pub async fn add_registration(pool: &Pool, token_hash: &str, by: &str, scope: &Scope) -> sqlx::Result<()> {
31314×
    let now = now_ms();
31414×
    sqlx::query(
31514×
        "INSERT INTO flow_runner_registrations (token_hash, created_by, created_ms, expires_ms, scope_owner, scope_repo_id) VALUES ($1, $2, $3, $4, $5, $6)",
31614×
    )
31714×
    .bind(token_hash)
31814×
    .bind(by)
31914×
    .bind(now)
32014×
    .bind(now + 24 * 3_600_000)
32114×
    .bind(&scope.owner)
32214×
    .bind(scope.repo_id)
32314×
    .execute(pool)
32414×
    .await?;
32514×
    Ok(())
32614×
}
327
328
/// The runners that may take a repository's jobs: the site's, its owner's
329
/// and its own (oxygenhub#41).
330268×
pub async fn serving(pool: &Pool, owner: &str, repo_id: i64) -> sqlx::Result<Vec<Runner>> {
331268×
    sqlx::query_as(
332268×
        "SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) \
333268×
         OR (scope_owner = $1 AND scope_repo_id IS NULL) OR scope_repo_id = $2 ORDER BY name",
334268×
    )
335268×
    .bind(owner)
336268×
    .bind(repo_id)
337268×
    .fetch_all(pool)
338268×
    .await
339268×
}
340
341
/// The runners that serve an owner: the site's, and those scoped to the
342
/// owner or one of its repositories.
34331×
pub async fn serving_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Runner>> {
34431×
    sqlx::query_as("SELECT * FROM flow_runners WHERE (scope_owner = '' AND scope_repo_id IS NULL) OR scope_owner = $1 ORDER BY name")
34531×
        .bind(owner)
34631×
        .fetch_all(pool)
34731×
        .await
34831×
}
349
350
/// Change the repositories a runner serves.
3510×
pub async fn set_scope(pool: &Pool, id: i64, scope: &Scope) -> sqlx::Result<()> {
3520×
    sqlx::query("UPDATE flow_runners SET scope_owner = $1, scope_repo_id = $2 WHERE id = $3")
3530×
        .bind(&scope.owner)
3540×
        .bind(scope.repo_id)
3550×
        .bind(id)
3560×
        .execute(pool)
3570×
        .await?;
3580×
    Ok(())
3590×
}
360
361
/// Registration tokens not used yet and not expired: (created by, created, expires).
36214×
pub async fn pending_registrations(pool: &Pool) -> sqlx::Result<Vec<(String, i64, i64)>> {
36314×
    sqlx::query_as("SELECT created_by, created_ms, expires_ms FROM flow_runner_registrations WHERE used_ms IS NULL AND expires_ms > $1 ORDER BY created_ms DESC")
36414×
        .bind(now_ms())
36514×
        .fetch_all(pool)
36614×
        .await
36714×
}
368
369
/// Use registration token `token_hash` to register a runner: `None` when
370
/// the token is unknown, used or expired.
371
#[allow(clippy::too_many_arguments)]
37215×
pub async fn register(
37315×
    pool: &Pool,
37415×
    registration: &str,
37515×
    name: &str,
37615×
    token_hash: &str,
37715×
    key: &str,
37815×
    key_name: &str,
37915×
    by_default: &str,
38015×
) -> sqlx::Result<Option<Runner>> {
38115×
    let mut tx = super::begin_write(pool).await?;
38215×
    let now = now_ms();
38315×
    let found: Option<(String, String, Option<i64>)> = sqlx::query_as(
38415×
        "SELECT created_by, scope_owner, scope_repo_id FROM flow_runner_registrations WHERE token_hash = $1 AND used_ms IS NULL AND expires_ms > $2",
38515×
    )
38615×
    .bind(registration)
38715×
    .bind(now)
38815×
    .fetch_optional(&mut *tx)
38915×
    .await?;
39015×
    let Some((who, scope_owner, scope_repo_id)) = found else { return Ok(None) };
39114×
    let id: i64 = sqlx::query_scalar(
39214×
        "INSERT INTO flow_runners (name, token_hash, source, key, key_name, created_ms, created_by, scope_owner, scope_repo_id) VALUES ($1, $2, 'registered', $3, $4, $5, $6, $7, $8) RETURNING id",
393
    )
39414×
    .bind(name)
39514×
    .bind(token_hash)
39614×
    .bind(key)
39714×
    .bind(key_name)
39814×
    .bind(now)
39914×
    .bind(if who.is_empty() { by_default } else { &who })
40014×
    .bind(scope_owner)
40114×
    .bind(scope_repo_id)
40214×
    .fetch_one(&mut *tx)
40314×
    .await?;
40414×
    sqlx::query("UPDATE flow_runner_registrations SET used_ms = $1, runner_id = $2 WHERE token_hash = $3")
40514×
        .bind(now)
40614×
        .bind(id)
40714×
        .bind(registration)
40814×
        .execute(&mut *tx)
40914×
        .await?;
41014×
    tx.commit().await?;
41114×
    get(pool, id).await
41215×
}
413
414
/// A job a runner ran, for its history.
415
#[derive(Clone, Debug, FromRow)]
416
pub struct RunnerJob {
417
    pub owner: String,
418
    pub repo: String,
419
    pub run_number: i64,
420
    pub job: String,
421
    pub status: String,
422
    pub started_ms: Option<i64>,
423
    pub finished_ms: Option<i64>,
424
    pub receipt_attest: String,
425
}
426
427
/// Jobs runner `id` ran or is running, newest first.
4284×
pub async fn history(pool: &Pool, id: i64, limit: i64, before: Option<i64>) -> sqlx::Result<Vec<RunnerJob>> {
4294×
    sqlx::query_as(
4304×
        "SELECT p.owner AS owner, p.name AS repo, r.number AS run_number, j.name AS job, j.status AS status, j.started_ms AS started_ms, \
4314×
         j.finished_ms AS finished_ms, j.receipt_attest AS receipt_attest \
4324×
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id \
4334×
         WHERE j.runner_id = $1 AND ($2 IS NULL OR j.started_ms < $3) ORDER BY j.started_ms DESC LIMIT $4",
4344×
    )
4354×
    .bind(id)
4364×
    .bind(before)
4374×
    .bind(before)
4384×
    .bind(limit)
4394×
    .fetch_all(pool)
4404×
    .await
4414×
}
442
443
/// (jobs run, passed, busy milliseconds) of runner `id`.
4443×
pub async fn totals(pool: &Pool, id: i64) -> sqlx::Result<(i64, i64, i64)> {
4453×
    sqlx::query_as(
4463×
        "SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END), 0), CAST(COALESCE(SUM(finished_ms - started_ms), 0) AS BIGINT) \
4473×
         FROM flow_jobs WHERE runner_id = $1 AND finished_ms IS NOT NULL",
4483×
    )
4493×
    .bind(id)
4503×
    .fetch_one(pool)
4513×
    .await
4523×
}
453
454
/// Jobs running on each runner now, by runner id.
455247×
pub async fn running(pool: &Pool) -> sqlx::Result<Vec<(i64, i64)>> {
456247×
    sqlx::query_as("SELECT runner_id, COUNT(*) FROM flow_jobs WHERE status = 'running' AND runner_id IS NOT NULL GROUP BY runner_id")
457247×
        .fetch_all(pool)
458247×
        .await
459247×
}
460
461156×
pub async fn set_job_runner(pool: &Pool, job_id: i64, runner_id: i64) -> sqlx::Result<()> {
462156×
    sqlx::query("UPDATE flow_jobs SET runner_id = $1 WHERE id = $2").bind(runner_id).bind(job_id).execute(pool).await?;
463156×
    Ok(())
464156×
}