OXYGENHUB

Images by hash on the hub: chunks, verified manifests, and placement for runners that fetch 92a797b20912

The hub keeps rootfs images site-wide under <data>/images: chunks
zstd-compressed and shared, a manifest each, accepted only when every
chunk is here and they make an image with its hash (jet_images lists
them). Accounts that may write repositories send them (missing chunks,
then the manifest); runners and accounts fetch them. A runner whose
claims say it fetches images is offered jobs whose image only the hub
holds, and the waiting reason says when neither holds it. Bumps
deps/oxygenflow for the jet crate and the claim's fetches_images.

refs andymccall/oxygenflow#39
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 17:40 UTC
Signature
✔ signed by Andy McCall
Hash
92a797b20912c4c3ae63111a9ad18c3499f0c12ceb7fb95afe42866579556908
Depends on
0b203bbf7cc3 18e381ea88c2 380996db84e5 42fa95540468 4d2e44d79b03 67040b158ae2 76bc5a058351 7f5f6c056d8f 93be9ce4653e a160b98fb922 a36bab6b9ffc b0612e7dce30 b1e92a6233ee b5668108487f b64e2537d702 b6b7e4cf6b24 c8df33a6829f cc19697f8871 d1727c573f30 d23464c0ac60 df71c7892d27 ef20fdc14e8b efd29a0033e8
Depended on by
b7e1be264c0a 12325e1b1020 951aed50c2f5 d9d0d314fdcb dd0788b756f2 69e4f27c5633 794ff94f194a ab2d8b40f9f4 166f6e9bf4f1 9c04c779f032 58cbf42447bc e97500b70f9a 16ee5d19d220 3b1bc27fe805 5d90e0e045e2 93501da2a162 750579ecc721 6ca0b971ff07 a46d15e72101 1c8276b90314 7829b7c7bb59 501d8b268dd4 44a3d2ebaddd 2fb70d019851 40dd3f86379a
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
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
37 atoms, 3 tree operations, 15 files

Browse the tree at this change

added crates/hub/migrations/0051_jet_images.sql +14 −0
1 +
-- Images by hash (oxygenflow#39, Jetstream): rootfs images this hub keeps
2 +
-- for runners to fetch, as chunks under <data>/images and a manifest each.
3 +
CREATE TABLE jet_images (
4 +
    hash        TEXT PRIMARY KEY,
5 +
    size        INTEGER NOT NULL,
6 +
    arch        TEXT NOT NULL,
7 +
    chunks      INTEGER NOT NULL,
8 +
    uploader    TEXT NOT NULL,
9 +
    created_ms  INTEGER NOT NULL
10 +
);
11 +
12 +
-- Whether a runner fetches a job's image it lacks from the hub, as its
13 +
-- claims say; the placement pass counts the hub's images as its own then.
14 +
ALTER TABLE flow_runners ADD COLUMN fetches_images INTEGER NOT NULL DEFAULT 0;
added crates/hub/src/api/images.rs +121 −0
1 +
//! Images by hash over the API (oxygenflow#39): `flow rootfs push` sends
2 +
//! an image's missing chunks and then its manifest; runners, and `flow
3 +
//! rootfs pull`, fetch a manifest and the chunks they lack.
4 +
5 +
use super::{caller, not_found, ApiError};
6 +
use crate::Shared;
7 +
use axum::body::Bytes;
8 +
use axum::extract::{Path, State};
9 +
use axum::http::{header, HeaderMap, StatusCode};
10 +
use axum::response::{IntoResponse, Response};
11 +
use axum::Json;
12 +
use jet::images::{is_hash, Manifest};
13 +
use serde::{Deserialize, Serialize};
14 +
15 +
/// Most chunk names one `missing` question may ask about.
16 +
const MAX_ASK: usize = 200_000;
17 +
18 +
/// Who sends images: an account whose token may write repositories.
19 +
async fn sender(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
20 +
    let c = caller(app, headers).await?;
21 +
    Ok(c.require("repo:write")?.username.clone())
22 +
}
23 +
24 +
/// Who fetches images: a runner, or any account (for `flow rootfs pull`).
25 +
async fn fetcher(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
26 +
    if crate::flow::runner_auth(app, headers).await.is_ok() {
27 +
        return Ok(());
28 +
    }
29 +
    let c = caller(app, headers).await?;
30 +
    c.account.as_ref().map(|_| ()).ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a runner or personal access token is required".into()))
31 +
}
32 +
33 +
#[derive(Deserialize)]
34 +
pub struct Ask {
35 +
    chunks: Vec<String>,
36 +
}
37 +
38 +
#[derive(Serialize)]
39 +
pub struct Missing {
40 +
    missing: Vec<String>,
41 +
}
42 +
43 +
/// `POST /flow/images/missing {"chunks": [..]}`: which of these chunks the
44 +
/// hub lacks.
45 +
pub async fn missing(State(app): State<Shared>, headers: HeaderMap, Json(ask): Json<Ask>) -> Result<Json<Missing>, ApiError> {
46 +
    sender(&app, &headers).await?;
47 +
    if ask.chunks.len() > MAX_ASK {
48 +
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("ask about at most {MAX_ASK} chunks at once")));
49 +
    }
50 +
    let store = app.images();
51 +
    let missing = tokio::task::spawn_blocking(move || {
52 +
        let mut seen = std::collections::HashSet::new();
53 +
        ask.chunks.into_iter().filter(|h| is_hash(h) && seen.insert(h.clone()) && !store.has_chunk(h)).collect()
54 +
    })
55 +
    .await
56 +
    .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
57 +
    Ok(Json(Missing { missing }))
58 +
}
59 +
60 +
/// `PUT /flow/images/chunks/{hash}`: one chunk, checked against its name.
61 +
pub async fn put_chunk(
62 +
    State(app): State<Shared>,
63 +
    headers: HeaderMap,
64 +
    Path(hash): Path<String>,
65 +
    body: Bytes,
66 +
) -> Result<StatusCode, ApiError> {
67 +
    sender(&app, &headers).await?;
68 +
    if !is_hash(&hash) || body.is_empty() || body.len() > oxy_core::chunk::MAX_SIZE {
69 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "not a chunk: a name is 64 hex digits, a chunk 1 byte to 256 KiB".into()));
70 +
    }
71 +
    if blake3::hash(&body).to_hex().as_str() != hash {
72 +
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("those bytes are not chunk {}", &hash[..12])));
73 +
    }
74 +
    let store = app.images();
75 +
    tokio::task::spawn_blocking(move || store.put_chunk(&hash, &body))
76 +
        .await
77 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
78 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("keep the chunk: {e}")))?;
79 +
    Ok(StatusCode::NO_CONTENT)
80 +
}
81 +
82 +
/// `PUT /flow/images/{hash}`: the manifest, accepted once every chunk is
83 +
/// here and the image they make has this hash.
84 +
pub async fn put_manifest(
85 +
    State(app): State<Shared>,
86 +
    headers: HeaderMap,
87 +
    Path(hash): Path<String>,
88 +
    Json(m): Json<Manifest>,
89 +
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
90 +
    let who = sender(&app, &headers).await?;
91 +
    if m.hash != hash {
92 +
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
93 +
    }
94 +
    let store = app.images();
95 +
    let kept = m.clone();
96 +
    tokio::task::spawn_blocking(move || store.verify_and_keep(&kept))
97 +
        .await
98 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
99 +
        .map_err(|e| ApiError(StatusCode::CONFLICT, e))?;
100 +
    crate::images::record(&app.db, &m, &who).await?;
101 +
    crate::db::accounts::audit(&app.db, None, "flow.image", &who, &format!("{} ({} MiB)", &m.hash[..12], m.size >> 20)).await.ok();
102 +
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len() }))))
103 +
}
104 +
105 +
/// `GET /flow/images/{hash}`: an image's manifest.
106 +
pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> {
107 +
    fetcher(&app, &headers).await?;
108 +
    app.images().manifest(&hash).map(Json).ok_or_else(|| not_found("this hub does not hold that image; send it with flow rootfs push"))
109 +
}
110 +
111 +
/// `GET /flow/images/chunks/{hash}`: one chunk's bytes.
112 +
pub async fn get_chunk(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Response, ApiError> {
113 +
    fetcher(&app, &headers).await?;
114 +
    let store = app.images();
115 +
    let bytes = tokio::task::spawn_blocking(move || store.chunk(&hash))
116 +
        .await
117 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
118 +
        .map_err(|_| not_found("no such chunk"))?;
119 +
    Ok(([(header::CONTENT_TYPE, "application/octet-stream"), (header::CACHE_CONTROL, "private, max-age=31536000, immutable")], bytes)
120 +
        .into_response())
121 +
}
added crates/hub/src/images.rs +116 −0
1 +
//! Images by hash (oxygenflow#39, OxygenFlow docs/jetstream.md §8): rootfs
2 +
//! images kept site-wide for runners to fetch, so nobody copies an image
3 +
//! between machines. An image arrives as chunks (Oxygen's FastCDC, sent
4 +
//! only when this hub lacks them) and then its manifest, which is accepted
5 +
//! only when every chunk is here and the image they make has the hash it
6 +
//! is named by. Chunks are kept zstd-compressed under
7 +
//! `<data>/images/chunks`, shared by every image that has them.
8 +
9 +
use crate::db::{now_ms, Pool};
10 +
use jet::images::{is_hash, Manifest};
11 +
use std::io::Write;
12 +
use std::path::{Path, PathBuf};
13 +
14 +
/// Where this hub keeps images.
15 +
#[derive(Clone, Debug)]
16 +
pub struct Store {
17 +
    root: PathBuf,
18 +
}
19 +
20 +
impl Store {
21 +
    pub fn new(data: &Path) -> Store {
22 +
        Store { root: data.join("images") }
23 +
    }
24 +
25 +
    fn chunk_path(&self, hash: &str) -> PathBuf {
26 +
        self.root.join("chunks").join(&hash[..2]).join(hash)
27 +
    }
28 +
29 +
    fn manifest_path(&self, hash: &str) -> PathBuf {
30 +
        self.root.join("manifests").join(format!("{hash}.json"))
31 +
    }
32 +
33 +
    pub fn has_chunk(&self, hash: &str) -> bool {
34 +
        is_hash(hash) && self.chunk_path(hash).exists()
35 +
    }
36 +
37 +
    /// Keep chunk `hash`, whose bytes the caller has checked.
38 +
    pub fn put_chunk(&self, hash: &str, bytes: &[u8]) -> std::io::Result<()> {
39 +
        let path = self.chunk_path(hash);
40 +
        if path.exists() {
41 +
            return Ok(());
42 +
        }
43 +
        std::fs::create_dir_all(path.parent().expect("a chunk's directory"))?;
44 +
        let tmp = path.with_extension(format!("tmp{}", std::process::id()));
45 +
        let mut f = std::fs::File::create(&tmp)?;
46 +
        f.write_all(&zstd::encode_all(bytes, 3)?)?;
47 +
        f.sync_all()?;
48 +
        std::fs::rename(&tmp, &path)
49 +
    }
50 +
51 +
    pub fn chunk(&self, hash: &str) -> std::io::Result<Vec<u8>> {
52 +
        if !is_hash(hash) {
53 +
            return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "not a chunk name"));
54 +
        }
55 +
        zstd::decode_all(std::fs::File::open(self.chunk_path(hash))?)
56 +
    }
57 +
58 +
    pub fn manifest(&self, hash: &str) -> Option<Manifest> {
59 +
        if !is_hash(hash) {
60 +
            return None;
61 +
        }
62 +
        serde_json::from_slice(&std::fs::read(self.manifest_path(hash)).ok()?).ok()
63 +
    }
64 +
65 +
    /// Accept manifest `m`: well formed, every chunk here, and the image
66 +
    /// they make hashing to its name. Reads the whole image once.
67 +
    pub fn verify_and_keep(&self, m: &Manifest) -> Result<(), String> {
68 +
        m.check().map_err(|e| e.to_string())?;
69 +
        let mut whole = blake3::Hasher::new();
70 +
        for c in &m.chunks {
71 +
            if c.is_zeros() {
72 +
                whole.update(&vec![0u8; c.len()]);
73 +
                continue;
74 +
            }
75 +
            let bytes = self.chunk(c.hash()).map_err(|_| format!("chunk {} was not sent; send it first", &c.hash()[..12]))?;
76 +
            if bytes.len() != c.len() {
77 +
                return Err(format!("chunk {} is {} bytes, not {}", &c.hash()[..12], bytes.len(), c.len()));
78 +
            }
79 +
            whole.update(&bytes);
80 +
        }
81 +
        let got = whole.finalize().to_hex().to_string();
82 +
        if got != m.hash {
83 +
            return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12]));
84 +
        }
85 +
        let path = self.manifest_path(&m.hash);
86 +
        std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?;
87 +
        let tmp = path.with_extension("json.tmp");
88 +
        std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
89 +
        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
90 +
    }
91 +
}
92 +
93 +
/// Record an image this hub now keeps.
94 +
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
95 +
    sqlx::query(
96 +
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, ?, ?, ?, ?, ?) \
97 +
         ON CONFLICT (hash) DO NOTHING",
98 +
    )
99 +
    .bind(&m.hash)
100 +
    .bind(m.size as i64)
101 +
    .bind(m.arch.clone().unwrap_or_default())
102 +
    .bind(m.chunks.len() as i64)
103 +
    .bind(uploader)
104 +
    .bind(now_ms())
105 +
    .execute(pool)
106 +
    .await?;
107 +
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
108 +
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
109 +
    }
110 +
    Ok(())
111 +
}
112 +
113 +
/// Every image this hub keeps, by hash.
114 +
pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> {
115 +
    Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect())
116 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Images by hash (oxygenflow#39).** Rootfs images live on the hub: `flow rootfs push` sends only the chunks the hub lacks (a rebuilt image costs its changes, runs of zeros cost nothing), the hub checks the whole image against its hash, and a runner that lacks a job's image fetches it instead of anyone copying it between machines. The hub offers such a job to runners that fetch, and says when neither the runner nor the hub holds the image.
8 +
7 9
- **Reports (#127).** A Flow job's `report "NAME" { path … }` output is kept file by file with every run, and a repository with reports gets one **Reports** tab in place of Coverage and Tests: each report's latest run with its kind, summary and metrics, and how each moved against the run before. A report's page shows its metrics with sparklines across runs, its thresholds, its `[context]`, its runs, and its own page (`index.html`) in a sandboxed frame that carries no session. Each metric with `fail_above` or `fail_below` sets a check named `report/NAME/METRIC` on the change.
8 10
9 11
- **Flow's artifacts survive the nightly gc (#126).** They are named only in the hub's database, so garbage collection found them unreachable and would have deleted each one 14 days after its run: coverage files, test reports and dist files alike. The nightly gc now keeps every artifact a repository's runs name.
…
153 unchanged lines
modified Cargo.lock +13 −0
…
1746 unchanged lines
1747 1747
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1748 1748
1749 1749
[[package]]
1750 +
name = "jet"
1751 +
version = "0.1.0"
1752 +
dependencies = [
1753 +
 "blake3",
1754 +
 "hex",
1755 +
 "oxy-core",
1756 +
 "serde",
1757 +
 "serde_json",
1758 +
 "thiserror 1.0.69",
1759 +
]
1760 +
1761 +
[[package]]
1750 1762
name = "jobserver"
1751 1763
version = "0.1.35"
1752 1764
source = "registry+https://github.com/rust-lang/crates.io-index"
…
608 unchanged lines
2361 2373
 "http-body-util",
2362 2374
 "hub-api",
2363 2375
 "image",
2376 +
 "jet",
2364 2377
 "lettre",
2365 2378
 "lzma-rs",
2366 2379
 "md-5 0.10.6",
…
2489 unchanged lines
modified Cargo.toml +1 −0
…
18 unchanged lines
19 19
oxy-strata = { path = "deps/oxygenflow/deps/oxygen/crates/oxy-strata" }
20 20
hub-api = { path = "crates/hub-api" }
21 21
flow-core = { path = "deps/oxygenflow/crates/flow-core" }
22 +
jet = { path = "deps/oxygenflow/crates/jet" }
22 23
23 24
anyhow = "1"
24 25
thiserror = "2"
…
48 unchanged lines
modified crates/hub/Cargo.toml +1 −0
…
25 unchanged lines
26 26
oxy-strata.workspace = true
27 27
hub-api.workspace = true
28 28
flow-core.workspace = true
29 +
jet.workspace = true
29 30
anyhow.workspace = true
30 31
thiserror.workspace = true
31 32
serde.workspace = true
…
53 unchanged lines
modified crates/hub/src/api/mod.rs +7 −0
…
15 unchanged lines
16 16
use serde::Deserialize;
17 17
18 18
pub mod device;
19 +
mod images;
19 20
mod projects;
20 21
mod reports;
21 22
…
21 unchanged lines
43 44
        .fallback(no_route)
44 45
        .route("/flow/claim", post(crate::flow::claim))
45 46
        .route("/flow/runners/register", post(crate::flow::register))
47 +
        .route("/flow/images/missing", post(images::missing))
48 +
        .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk))
49 +
        .route(
50 +
            "/flow/images/{hash}",
51 +
            axum::routing::put(images::put_manifest).get(images::get_manifest).layer(axum::extract::DefaultBodyLimit::max(64 << 20)),
52 +
        )
46 53
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
47 54
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
48 55
        .route("/flow/jobs/{id}/decline", post(crate::flow::decline))
…
2329 unchanged lines
modified crates/hub/src/db/flow.rs +4 −1
…
334 unchanged lines
335 335
    pub image_archs: std::collections::HashMap<String, String>,
336 336
    /// Jobs this runner declined, not to be offered to it again (spec §11.5).
337 337
    pub declined: std::collections::HashSet<i64>,
338 +
    /// Images the hub holds that this runner would fetch when it lacks
339 +
    /// them (oxygenflow#39); empty for a runner that does not fetch.
340 +
    pub fetchable: std::collections::HashSet<String>,
338 341
}
339 342
340 343
impl Room {
…
10 unchanged lines
351 354
        arch_ok
352 355
            && self.memory_mib.is_none_or(|m| need("memory_mib") <= m as u64)
353 356
            && self.cpus.is_none_or(|c| need("cpus") <= c as u64)
354 -
            && (rootfs.is_empty() || self.images.as_ref().is_none_or(|have| have.contains(rootfs)))
357 +
            && (rootfs.is_empty() || self.images.as_ref().is_none_or(|have| have.contains(rootfs)) || self.fetchable.contains(rootfs))
355 358
    }
356 359
}
357 360
…
525 unchanged lines
modified crates/hub/src/db/runners.rs +12 −2
…
30 unchanged lines
31 31
    pub admin_labels: String,
32 32
    /// A time limit for its jobs in minutes, below the site's, when set.
33 33
    pub timeout_minutes: Option<i64>,
34 +
    /// It fetches images it lacks from the hub (oxygenflow#39).
35 +
    pub fetches_images: bool,
34 36
    /// The tiers it offered at its last claim, comma-separated.
35 37
    pub tiers: String,
36 38
    /// The images it held then (a JSON array); empty when it did not say.
…
129 unchanged lines
166 168
/// What a runner offers at a claim (OxygenFlow spec §11.1): its tiers and
167 169
/// images, and the profile hash over those, its architecture, labels and
168 170
/// capacity.
169 -
pub async fn set_offer(pool: &Pool, id: i64, tiers: &[String], images: Option<&[String]>, profile: &str) -> sqlx::Result<()> {
171 +
pub async fn set_offer(
172 +
    pool: &Pool,
173 +
    id: i64,
174 +
    tiers: &[String],
175 +
    images: Option<&[String]>,
176 +
    fetches_images: bool,
177 +
    profile: &str,
178 +
) -> sqlx::Result<()> {
170 179
    let images = images.map(|i| serde_json::to_string(i).unwrap_or_default()).unwrap_or_default();
171 -
    sqlx::query("UPDATE flow_runners SET tiers = ?, images = ?, profile = ? WHERE id = ?")
180 +
    sqlx::query("UPDATE flow_runners SET tiers = ?, images = ?, fetches_images = ?, profile = ? WHERE id = ?")
172 181
        .bind(tiers.join(","))
173 182
        .bind(images)
183 +
        .bind(fetches_images)
174 184
        .bind(profile)
175 185
        .bind(id)
176 186
        .execute(pool)
…
201 unchanged lines
modified crates/hub/src/flow.rs +15 −7
…
403 unchanged lines
404 404
/// The runner a bearer token belongs to: a registered runner, or one
405 405
/// configured with a hub.toml token (tracked from its first request). A
406 406
/// disabled runner is refused.
407 -
async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
407 +
pub(crate) async fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<db::runners::Runner, ApiError> {
408 408
    let Some(t) = oxy_server::bearer_token(headers) else {
409 409
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
410 410
    };
…
59 unchanged lines
470 470
    // What it offers, kept for the placement pass; a job it declined is
471 471
    // not offered to it again while this stays the same (spec §11.5).
472 472
    let profile = profile_of(&req, &arch);
473 -
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), &profile).await?;
473 +
    db::runners::set_offer(&app.db, me.id, &req.runtimes, req.images.as_deref(), req.fetches_images, &profile).await?;
474 474
    let room = fdb::Room {
475 475
        declined: fdb::declined_by(&app.db, me.id, &profile).await?,
476 476
        memory_mib: req.free_memory_mib,
…
1 unchanged lines
478 478
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
479 479
        arch,
480 480
        image_archs: fdb::image_archs(&app.db).await?,
481 +
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
481 482
    };
482 483
    // The labels it reports now, or those it registered with, and those
483 484
    // an administrator gave it (#98).
…
176 unchanged lines
660 661
/// now (spec §11.4), from what each runner offered at its last claim:
661 662
/// empty when one could (it waits its turn or a free slot), else what
662 663
/// each one lacks. `declines` are the job's.
664 +
#[allow(clippy::too_many_arguments)]
663 665
pub fn why_waiting(
664 666
    job: &fdb::Job,
665 667
    owner: &str,
…
2 unchanged lines
668 670
    image_archs: &std::collections::HashMap<String, String>,
669 671
    declines: &[fdb::DeclineRow],
670 672
    now: i64,
673 +
    hub_images: &std::collections::HashSet<String>,
671 674
) -> String {
672 675
    let sandbox: serde_json::Value = serde_json::from_str(&job.sandbox).unwrap_or_default();
673 676
    let rootfs = sandbox["rootfs"].as_str().unwrap_or("");
…
15 unchanged lines
689 692
        if let Some(w) = wanted_arch.as_deref().filter(|w| !r.arch.is_empty() && *w != r.arch) {
690 693
            return Some(format!("is {}; the job needs {w}", r.arch));
691 694
        }
692 -
        if r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
693 -
            return Some(format!("does not hold image {}", &rootfs[..12.min(rootfs.len())]));
695 +
        let fetches = r.fetches_images && hub_images.contains(rootfs);
696 +
        if !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
697 +
            let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
698 +
            return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
694 699
        }
695 700
        let labels = r.label_list();
696 701
        if let Some(l) = needs.iter().find(|l| !labels.contains(&l.as_str())) {
…
38 unchanged lines
735 740
    }
736 741
    let runners = db::runners::list(&app.db).await?;
737 742
    let archs = fdb::image_archs(&app.db).await?;
743 +
    let held = crate::images::held(&app.db).await?;
738 744
    let now = db::now_ms();
739 745
    for (job, owner, repo_id) in ready {
740 746
        let declines = fdb::declines_of_job(&app.db, job.id).await?;
741 -
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now);
747 +
        let why = why_waiting(&job, &owner, repo_id, &runners, &archs, &declines, now, &held);
742 748
        if why == job.waiting {
743 749
            continue;
744 750
        }
…
923 unchanged lines
1668 1674
            ..Default::default()
1669 1675
        };
1670 1676
        let archs = std::collections::HashMap::new();
1671 -
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| why_waiting(&job, "andy", 1, runners, &archs, declines, now);
1677 +
        let why = |runners: &[Runner], declines: &[fdb::DeclineRow]| {
1678 +
            why_waiting(&job, "andy", 1, runners, &archs, declines, now, &Default::default())
1679 +
        };
1672 1680
        assert_eq!(why(&[], &[]), "no runner is online");
1673 1681
        assert_eq!(why(&[runner(1, "earth-1")], &[]), "", "one can take it: it only waits its turn");
1674 1682
        let microvm_only = Runner { tiers: "microvm".into(), ..runner(1, "earth-1") };
…
7 unchanged lines
1682 1690
        );
1683 1691
        let archs_known: std::collections::HashMap<String, String> = [(rootfs.clone(), "x86_64".to_string())].into();
1684 1692
        assert_eq!(
1685 -
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now),
1693 +
            why_waiting(&job, "andy", 1, &[riscv], &archs_known, &[], now, &Default::default()),
1686 1694
            "no runner can take it: narvi is riscv64; the job needs x86_64"
1687 1695
        );
1688 1696
        let without = Runner { images: "[]".into(), ..runner(6, "new") };
…
16 unchanged lines
modified crates/hub/src/lib.rs +6 −0
…
12 unchanged lines
13 13
pub mod events;
14 14
pub mod flow;
15 15
pub mod hooks;
16 +
pub mod images;
16 17
pub mod junit;
17 18
pub mod keys;
18 19
pub mod levels;
…
48 unchanged lines
67 68
}
68 69
69 70
impl App {
71 +
    /// The rootfs images this hub keeps for runners (oxygenflow#39).
72 +
    pub fn images(&self) -> images::Store {
73 +
        images::Store::new(&self.config.data)
74 +
    }
75 +
70 76
    /// The OIDC issuer, its key loaded or made the first time it is asked for.
71 77
    pub async fn oidc(&self) -> anyhow::Result<&oidc::Issuer> {
72 78
        let keys = self.config.keys_dir();
…
98 unchanged lines
modified crates/hub/tests/flow.rs +120 −1
…
2524 unchanged lines
2525 2525
    let r = db::runners::configured(&h.app.db, &blake3::hash(b"elsewhere").to_hex(), "earth-9").await.unwrap();
2526 2526
    db::runners::seen(&h.app.db, r.id, None, None).await.unwrap();
2527 2527
    let other = "ab".repeat(32);
2528 -
    db::runners::set_offer(&h.app.db, r.id, &["microvm".to_string()], Some(&[other]), "p").await.unwrap();
2528 +
    db::runners::set_offer(&h.app.db, r.id, &["microvm".to_string()], Some(&[other]), false, "p").await.unwrap();
2529 2529
    oxygenhub::flow::explain_waits(&h.app).await.unwrap();
2530 2530
2531 2531
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", Some("site"), None).await;
…
7 unchanged lines
2539 2539
    let check = checks.iter().find(|c| c.name == "flow/build").unwrap();
2540 2540
    assert!(check.summary.contains("does not hold image"), "{check:?}");
2541 2541
}
2542 +
2543 +
async fn put_bytes(app: &Shared, path: &str, bearer: &str, bytes: Vec<u8>) -> (StatusCode, String) {
2544 +
    let req = Request::put(path)
2545 +
        .header(header::AUTHORIZATION, format!("Bearer {bearer}"))
2546 +
        .header(header::CONTENT_TYPE, "application/octet-stream")
2547 +
        .body(Body::from(bytes))
2548 +
        .unwrap();
2549 +
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
2550 +
    let st = res.status();
2551 +
    (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
2552 +
}
2553 +
2554 +
/// oxygenflow#39: an image is sent as the chunks the hub lacks and then its
2555 +
/// manifest, checked against its hash; runners fetch it; and a job whose
2556 +
/// image only the hub holds is offered to a runner that fetches, not to one
2557 +
/// that does not.
2558 +
#[tokio::test(flavor = "multi_thread")]
2559 +
async fn images_are_sent_by_chunk_and_offered_to_runners_that_fetch() {
2560 +
    let h = hub().await;
2561 +
    // The site token acts as the first site administrator.
2562 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
2563 +
    // An image: bytes, a run of zeros, more bytes.
2564 +
    let mut image: Vec<u8> = (0..400_000u32).map(|i| (i % 251) as u8).collect();
2565 +
    image.extend(std::iter::repeat_n(0u8, 2 << 20));
2566 +
    image.extend((0..100_000u32).map(|i| (i % 239) as u8));
2567 +
    let mut chunks = std::collections::HashMap::new();
2568 +
    let m = jet::images::scan(&image[..], Some("x86_64".into()), |c, b| {
2569 +
        chunks.insert(c.hash().to_string(), b.to_vec());
2570 +
        Ok(())
2571 +
    })
2572 +
    .unwrap();
2573 +
    let hash = m.hash.clone();
2574 +
2575 +
    // Which chunks the hub lacks: every one but zeros; a manifest before them is refused.
2576 +
    let ask = serde_json::json!({ "chunks": m.needed() }).to_string();
2577 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/images/missing", Some("site"), Some(ask.clone())).await;
2578 +
    assert_eq!(st, StatusCode::OK, "{body}");
2579 +
    let missing: Vec<String> = serde_json::from_str::<serde_json::Value>(&body).unwrap()["missing"]
2580 +
        .as_array()
2581 +
        .unwrap()
2582 +
        .iter()
2583 +
        .map(|v| v.as_str().unwrap().to_string())
2584 +
        .collect();
2585 +
    assert_eq!(missing.len(), m.needed().len());
2586 +
    let manifest = serde_json::to_string(&m).unwrap();
2587 +
    let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}"), Some("site"), Some(manifest.clone())).await;
2588 +
    assert!(st == StatusCode::CONFLICT && body.contains("was not sent"), "{st} {body}");
2589 +
    // A chunk is checked against its name; a runner token may not send.
2590 +
    let first = &missing[0];
2591 +
    assert_eq!(put_bytes(&h.app, &format!("/api/v1/flow/images/chunks/{first}"), "site", vec![1, 2, 3]).await.0, StatusCode::BAD_REQUEST);
2592 +
    assert_ne!(
2593 +
        put_bytes(&h.app, &format!("/api/v1/flow/images/chunks/{first}"), "runner-secret", chunks[first].clone()).await.0,
2594 +
        StatusCode::NO_CONTENT
2595 +
    );
2596 +
    for c in &missing {
2597 +
        let (st, body) = put_bytes(&h.app, &format!("/api/v1/flow/images/chunks/{c}"), "site", chunks[c].clone()).await;
2598 +
        assert_eq!(st, StatusCode::NO_CONTENT, "{body}");
2599 +
    }
2600 +
    let (_, body) = call(&h.app, "POST", "/api/v1/flow/images/missing", Some("site"), Some(ask)).await;
2601 +
    assert!(body.contains("\"missing\":[]"), "{body}");
2602 +
    // A manifest naming another hash is refused; the right one is kept.
2603 +
    let mut wrong = m.clone();
2604 +
    wrong.hash = "ab".repeat(32);
2605 +
    let (st, _) =
2606 +
        call(&h.app, "PUT", &format!("/api/v1/flow/images/{}", wrong.hash), Some("site"), Some(serde_json::to_string(&wrong).unwrap()))
2607 +
            .await;
2608 +
    assert_eq!(st, StatusCode::CONFLICT, "chunks that make another image");
2609 +
    let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}"), Some("site"), Some(manifest)).await;
2610 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
2611 +
2612 +
    // A runner fetches the manifest and the chunks, and the image comes back whole.
2613 +
    let (st, body) = call(&h.app, "GET", &format!("/api/v1/flow/images/{hash}"), Some("runner-secret"), None).await;
2614 +
    assert_eq!(st, StatusCode::OK, "{body}");
2615 +
    let got: jet::images::Manifest = serde_json::from_str(&body).unwrap();
2616 +
    assert_eq!(got, m);
2617 +
    let dir = tempfile::tempdir().unwrap();
2618 +
    let out = dir.path().join("img.ext4");
2619 +
    let mut fetched = Vec::new();
2620 +
    for c in got.needed() {
2621 +
        let req = Request::get(format!("/api/v1/flow/images/chunks/{c}"))
2622 +
            .header(header::AUTHORIZATION, "Bearer runner-secret")
2623 +
            .body(Body::empty())
2624 +
            .unwrap();
2625 +
        let res = oxygenhub::router(h.app.clone()).oneshot(req).await.unwrap();
2626 +
        assert_eq!(res.status(), StatusCode::OK);
2627 +
        let bytes = res.into_body().collect().await.unwrap().to_bytes().to_vec();
2628 +
        fetched.push((c.to_string(), bytes));
2629 +
    }
2630 +
    jet::images::assemble(&got, &out, |c| Ok(fetched.iter().find(|(h, _)| h == c.hash()).unwrap().1.clone())).unwrap();
2631 +
    assert_eq!(std::fs::read(&out).unwrap(), image);
2632 +
    assert_eq!(
2633 +
        call(&h.app, "GET", &format!("/api/v1/flow/images/{}", "cd".repeat(32)), Some("runner-secret"), None).await.0,
2634 +
        StatusCode::NOT_FOUND
2635 +
    );
2636 +
    assert_ne!(call(&h.app, "GET", &format!("/api/v1/flow/images/{hash}"), None, None).await.0, StatusCode::OK, "not anonymous");
2637 +
2638 +
    // A job on that image: a runner holding no images and not fetching
2639 +
    // is offered nothing; one that fetches is offered it.
2640 +
    create(&h.app, "andy", "demo").await;
2641 +
    let url = format!("{}/andy/demo", h.base);
2642 +
    let work = tempfile::tempdir().unwrap();
2643 +
    let c = client(&url, work.path());
2644 +
    write(
2645 +
        work.path(),
2646 +
        ".flow/pipeline.kdl",
2647 +
        &format!("schema-version 1\nsandbox \"img\" {{\n    rootfs \"cas://blob/{hash}\"\n}}\njob \"build\" {{\n    sandbox \"img\"\n    exec \"make\"\n}}\n"),
2648 +
    );
2649 +
    c.commit("Build on the image", &[]).unwrap();
2650 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2651 +
    runs_of(&h.app, "andy", "demo").await;
2652 +
    let ask = |fetches: bool| format!(r#"{{"runner":"test-1","runtimes":["microvm"],"images":[],"fetches_images":{fetches}}}"#);
2653 +
    let (st, _) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(false))).await;
2654 +
    assert_eq!(st, StatusCode::NO_CONTENT, "it lacks the image and does not fetch");
2655 +
    oxygenhub::flow::explain_waits(&h.app).await.unwrap();
2656 +
    let (_, body) = call(&h.app, "GET", "/api/v1/repos/andy/demo/flow/runs/1", Some("site"), None).await;
2657 +
    assert!(body.contains("does not hold image"), "{body}");
2658 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/claim", Some("runner-secret"), Some(ask(true))).await;
2659 +
    assert_eq!(st, StatusCode::OK, "it fetches, and the hub holds the image: {body}");
2660 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - 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`.
516 516
517 517
  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`.
518 -
- **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. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28).
518 +
- **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. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28).
519 519
- **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.
520 520
- **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.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines