OXYGENHUB

Jobs go to runners of the right architecture efd29a0033e8

Runners report each image's architecture on their claims, kept in
flow_images; a job is offered only to a runner whose architecture
matches its sandbox's arch, else its image's as known, and the queue
says which architecture a waiting job asks for (OxygenFlow spec §11,
oxygenflow#36; pin moved).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 17:40 UTC
Signature
✔ signed by Andy McCall
Hash
efd29a0033e83bc66c1a20de2fe6bb68baf256c4220eb182ff3fd7104f450a70
Depends on
2a83f5303458 380996db84e5 42fa95540468 749d9a5fcd9f 76bc5a058351 83940d53664f 93be9ce4653e b0612e7dce30 b5668108487f b66e70b7b6c7 b6b7e4cf6b24 bf3d07541368 c4c87f481d05 cc19697f8871 d1727c573f30 d23464c0ac60
Depended on by
951aed50c2f5 e9b9619cd857 5d90e0e045e2 93501da2a162 92a797b20912 67040b158ae2 4d2e44d79b03 206c1fc1b06a
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
16 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/hub/migrations/0042_flow_images.sql +8 −0
1 +
-- The architecture of each rootfs image, as runners report it from their
2 +
-- image manifests (OxygenFlow spec §11.2, oxygenflow#36): a job whose
3 +
-- image is for one architecture is never offered to a runner of another.
4 +
CREATE TABLE flow_images (
5 +
    hash     TEXT PRIMARY KEY,
6 +
    arch     TEXT NOT NULL,
7 +
    seen_ms  INTEGER NOT NULL
8 +
);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Jobs go to runners of the right architecture (oxygenflow#36).** Runners report each image's architecture; the hub matches a job's `arch` or its image's against the runner's, and the queue says what a waiting job asks for.
8 +
7 9
- **Sign in with GitHub or Google (#106).** Set up per provider under Administration (client id, sealed secret, on or off); the login page offers them. A first sign-in links the account with the same verified email, or makes an account when registration is open; connect or disconnect under Settings › Security.
8 10
9 11
- **Wikis (#104).** Each repository can have a wiki: Markdown pages kept as an Oxygen repository of their own, read and edited on the web (with history, a sidebar, a page list and search) or cloned and pushed as `OWNER/NAME.wiki`. Web edits are signed changes, as code edits are. Settings › Advanced deletes the wiki data.
…
109 unchanged lines
modified crates/hub/src/db/flow.rs +42 −1
…
189 unchanged lines
190 190
    }
191 191
}
192 192
193 +
/// The architecture a sandbox's JSON asks for, if any.
194 +
fn arch_in(sandbox: &str) -> Option<String> {
195 +
    serde_json::from_str::<serde_json::Value>(sandbox).ok()?["arch"].as_str().map(String::from)
196 +
}
197 +
193 198
/// A job waiting for or held by a runner, with where it comes from.
194 199
#[derive(Clone, Debug, FromRow)]
195 200
pub struct QueueEntry {
…
16 unchanged lines
212 217
    /// The runner labels the job needs.
213 218
    pub fn needs(&self) -> Vec<String> {
214 219
        labels_in(&self.spec, &self.sandbox)
220 +
    }
221 +
222 +
    /// The architecture the job's sandbox asks for (OxygenFlow spec §11.3).
223 +
    pub fn arch(&self) -> Option<String> {
224 +
        arch_in(&self.sandbox)
215 225
    }
216 226
}
217 227
…
93 unchanged lines
311 321
    pub memory_mib: Option<u32>,
312 322
    pub cpus: Option<u32>,
313 323
    pub images: Option<std::collections::HashSet<String>>,
324 +
    /// The runner's architecture (`x86_64`, `riscv64`, …), empty when unknown.
325 +
    pub arch: String,
326 +
    /// Each image's architecture as runners have reported it (`flow_images`).
327 +
    pub image_archs: std::collections::HashMap<String, String>,
314 328
}
315 329
316 330
impl Room {
…
3 unchanged lines
320 334
        let v: serde_json::Value = serde_json::from_str(sandbox_json).unwrap_or_default();
321 335
        let need = |k: &str| v[k].as_u64().unwrap_or(0);
322 336
        let rootfs = v["rootfs"].as_str().unwrap_or("");
323 -
        self.memory_mib.is_none_or(|m| need("memory_mib") <= m as u64)
337 +
        // The architecture: the sandbox's own ask, else the image's as known;
338 +
        // an unknown one matches, as before (OxygenFlow spec §11.3).
339 +
        let wanted = v["arch"].as_str().map(String::from).or_else(|| self.image_archs.get(rootfs).cloned());
340 +
        let arch_ok = self.arch.is_empty() || wanted.as_deref().is_none_or(|w| w == self.arch);
341 +
        arch_ok
342 +
            && self.memory_mib.is_none_or(|m| need("memory_mib") <= m as u64)
324 343
            && self.cpus.is_none_or(|c| need("cpus") <= c as u64)
325 344
            && (rootfs.is_empty() || self.images.as_ref().is_none_or(|have| have.contains(rootfs)))
345 +
    }
346 +
}
347 +
348 +
/// Remember which architecture each image is for, as a runner reports it.
349 +
pub async fn record_image_archs(pool: &Pool, archs: &std::collections::BTreeMap<String, String>) -> sqlx::Result<()> {
350 +
    for (hash, arch) in archs.iter().take(500) {
351 +
        if hash.len() != 64 || !flow_core::arch_ok(arch) {
352 +
            continue;
353 +
        }
354 +
        sqlx::query("INSERT INTO flow_images (hash, arch, seen_ms) VALUES (?, ?, ?) ON CONFLICT (hash) DO UPDATE SET arch = excluded.arch, seen_ms = excluded.seen_ms")
355 +
        .bind(hash.to_ascii_lowercase())
356 +
        .bind(arch)
357 +
        .bind(now_ms())
358 +
        .execute(pool)
359 +
        .await?;
326 360
    }
361 +
    Ok(())
362 +
}
363 +
364 +
/// Every image's architecture the hub knows.
365 +
pub async fn image_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
366 +
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, arch FROM flow_images").fetch_all(pool).await?;
367 +
    Ok(rows.into_iter().collect())
327 368
}
328 369
329 370
pub async fn claim(pool: &Pool, runtimes: &[String], runner: &str, token_hash: &str) -> sqlx::Result<Option<Job>> {
…
340 unchanged lines
modified crates/hub/src/flow.rs +8 −0
…
460 unchanged lines
461 461
    }
462 462
    let runner = if me.source == "registered" { me.name.clone() } else { claimed };
463 463
    let secret = format!("{JOB_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
464 +
    // What it holds and what it is (OxygenFlow spec §11): the images'
465 +
    // architectures it reports are remembered for every runner's claims.
466 +
    if let Some(archs) = &req.image_archs {
467 +
        fdb::record_image_archs(&app.db, archs).await?;
468 +
    }
469 +
    let arch = req.info.as_ref().map(|i| i.arch.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| me.arch.clone());
464 470
    let room = fdb::Room {
465 471
        memory_mib: req.free_memory_mib,
466 472
        cpus: req.free_cpus,
467 473
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
474 +
        arch,
475 +
        image_archs: fdb::image_archs(&app.db).await?,
468 476
    };
469 477
    // The labels it reports now, or those it registered with.
470 478
    let labels: Vec<String> = match &req.info {
…
672 unchanged lines
modified crates/hub/tests/flow.rs +54 −0
…
1988 unchanged lines
1989 1989
    let log = db::flow::log(&app.db, id, 0, 100).await.unwrap();
1990 1990
    assert!(log.iter().any(|l| l.text == "TOKEN=*** end"), "{log:?}");
1991 1991
}
1992 +
1993 +
#[tokio::test(flavor = "multi_thread")]
1994 +
async fn jobs_are_offered_only_to_runners_of_their_architecture() {
1995 +
    let h = hub().await;
1996 +
    let app = &h.app;
1997 +
    create(app, "andy", "demo").await;
1998 +
    let url = format!("{}/andy/demo", h.base);
1999 +
    let work = tempfile::tempdir().unwrap();
2000 +
    let c = client(&url, work.path());
2001 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline("sandbox \"board\" {\n    rootfs \"cas://blob/{ROOTFS}\"\n    arch \"riscv64\"\n}\njob \"flash\" {\n    sandbox \"board\"\n    exec \"flash\"\n}\n").replace("{ROOTFS}", &"ab".repeat(32)));
2002 +
    write(work.path(), "test.sh", "echo ok\n");
2003 +
    c.snapshot("test").unwrap();
2004 +
    c.commit("Add a pipeline", &[]).unwrap();
2005 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2006 +
    let claim_as = |arch: &'static str, archs: serde_json::Value| {
2007 +
        let app = app.clone();
2008 +
        async move {
2009 +
            let body = serde_json::json!({
2010 +
                "runner": format!("{arch}-1"), "runtimes": ["microvm"],
2011 +
                "info": { "arch": arch, "os": "linux", "version": "t", "labels": [], "max_jobs": 1 },
2012 +
                "image_archs": archs,
2013 +
            });
2014 +
            let (st, body) = call(&app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(body.to_string())).await;
2015 +
            match st {
2016 +
                StatusCode::NO_CONTENT => None,
2017 +
                StatusCode::OK => {
2018 +
                    Some(serde_json::from_str::<serde_json::Value>(&body).unwrap()["job"]["name"].as_str().unwrap().to_string())
2019 +
                }
2020 +
                other => panic!("claim: {other} {body}"),
2021 +
            }
2022 +
        }
2023 +
    };
2024 +
    // An x86_64 runner that says the alpine image is x86_64: it takes the
2025 +
    // alpine jobs, never the one that asks for riscv64.
2026 +
    let rootfs = pipeline("").split("cas://blob/").nth(1).unwrap()[..64].to_string();
2027 +
    for _ in 0..50 {
2028 +
        if !runs_of(app, "andy", "demo").await.is_empty() {
2029 +
            break;
2030 +
        }
2031 +
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2032 +
    }
2033 +
    let runs = runs_of(app, "andy", "demo").await;
2034 +
    assert_eq!(runs.len(), 1, "{runs:?}");
2035 +
    let mut taken = vec![];
2036 +
    while let Some(j) = claim_as("x86_64", serde_json::json!({ rootfs.clone(): "x86_64" })).await {
2037 +
        taken.push(j);
2038 +
    }
2039 +
    assert_eq!(taken, ["build", "lint"], "test waits for build; flash is for riscv64");
2040 +
    // A riscv64 runner: the alpine image is known to be x86_64, so only flash.
2041 +
    assert_eq!(claim_as("riscv64", serde_json::json!({})).await.as_deref(), Some("flash"));
2042 +
    assert_eq!(claim_as("riscv64", serde_json::json!({})).await, None);
2043 +
    let (_, queue) = call(app, "GET", "/admin/runners", None, None).await;
2044 +
    let _ = queue;
2045 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −0
…
503 unchanged lines
504 504
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
505 505
506 506
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
507 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for.
507 508
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
508 509
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
509 510
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
207 unchanged lines
modified templates/admin_runners.html +1 −1
…
43 unchanged lines
44 44
  {% if queue.is_empty() %}<p class="mute small">Nothing running or waiting.</p>{% else %}
45 45
  <div class="table"><table>
46 46
  <tr><th>Repository</th><th>Run</th><th>Job</th><th>Status</th><th>Runner</th></tr>
47 -
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %}
47 +
  {% for q in queue %}<tr><td><a href="/{{ q.owner }}/{{ q.name }}">{{ q.owner }}/{{ q.name }}</a></td><td><a href="/{{ q.owner }}/{{ q.name }}/flow/{{ q.run_number }}">#{{ q.run_number }}</a></td><td>{{ q.job }}</td><td>{{ q.status }}</td><td>{% if q.runner.is_empty() %}<span class="mute">waiting{% let needs = q.needs() %}{% if !needs.is_empty() %} for a runner with {% for l in needs %}<span class="lbl">{{ l }}</span> {% endfor %}{% endif %}{% if let Some(a) = q.arch() %} on <span class="lbl">{{ a }}</span>{% endif %}</span>{% else %}{{ q.runner }}{% endif %}</td></tr>{% endfor %}
48 48
  </table></div>
49 49
  <p class="small mute">Queued jobs are in the order runners claim them; a job waits for its dependencies and for a runner whose scope, tier and room fit it.</p>
50 50
  {% endif %}
…
12 unchanged lines