OXYGENHUB

Peer-held images: the hub keeps runners' manifests of images it lacks and places their jobs where a peer in the site holds them (closes #342; pins OxygenFlow #94) 951aed50c2f5

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 32754bcc2e32
When
· 2026-10-27 02:34 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
951aed50c2f5854e52d81f28953e61194b4f20492918fe9bbb1ed6c8fd1e64cc
Depends on
380996db84e5 3b1bc27fe805 42fa95540468 44a3d2ebaddd 4d2e44d79b03 4fa0da126b70 501d8b268dd4 56b8dbe66974 58cbf42447bc 5caa65fd51e7 5d90e0e045e2 668b2ad7495c 6ca0b971ff07 750579ecc721 92a797b20912 93501da2a162 a46d15e72101 a7e6e8e6094c b0612e7dce30 b5668108487f c95446a17fdd d23464c0ac60 efd29a0033e8
Depended on by
0468394734e9 c4bffee5b0d4
In bookmarks
main
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.8 v0.9.9
Edits
33 atoms, 2 tree operations, 10 files

Browse the tree at this change

added crates/hub/migrations-pg/0098_peer_images.sql +7 −0
1 +
-- Images only runners hold (oxygenflow#94, oxygenhub#342): a runner sends the
2 +
-- manifest of an image it holds that the hub keeps none of, and the swarm
3 +
-- moves the chunks between runners. `source` is 'hub' for an image pushed
4 +
-- here and verified whole, 'peer' for one only runners hold; `holder` names
5 +
-- the runner that sent a peer manifest.
6 +
ALTER TABLE jet_images ADD COLUMN source TEXT NOT NULL DEFAULT 'hub';
7 +
ALTER TABLE jet_images ADD COLUMN holder TEXT NOT NULL DEFAULT '';
added crates/hub/migrations/0098_peer_images.sql +7 −0
1 +
-- Images only runners hold (oxygenflow#94, oxygenhub#342): a runner sends the
2 +
-- manifest of an image it holds that the hub keeps none of, and the swarm
3 +
-- moves the chunks between runners. `source` is 'hub' for an image pushed
4 +
-- here and verified whole, 'peer' for one only runners hold; `holder` names
5 +
-- the runner that sent a peer manifest.
6 +
ALTER TABLE jet_images ADD COLUMN source TEXT NOT NULL DEFAULT 'hub';
7 +
ALTER TABLE jet_images ADD COLUMN holder TEXT NOT NULL DEFAULT '';
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Images only runners hold (#342, oxygenflow#94).** A runner that serves its peers sends the manifest of each image it holds that the hub lacks (`POST /flow/images/held`, `PUT /flow/images/{hash}/held`, runner tokens only); the hub keeps it as held by runners and offers the image's jobs to runners of the same swarm site, which fetch it from their peers (the announce says the hub is no seed for it). A pushed image replaces such a record. On 2026-10-25 a re-pinned image that was never pushed kept every Rust job on one runner for a day. Migration 0098 (`jet_images.source`, `holder`).
8 +
7 9
## 0.9.7 (2026-10-27)
8 10
9 11
- **A push to a landed proposal's bookmark says so (#329).** The changes reach no target until a new proposal takes them, and the push was silent: now it is answered `its proposal #N has landed in TARGET, and these changes are not on TARGET: open a new proposal with oh proposal create --from BOOKMARK` (oxygen's `Backend::push_notes`). The landed proposal's page lists them as changes since it landed, with a link to a new proposal; it no longer shows them as its own, and no longer offers to delete a bookmark that holds them.
…
356 unchanged lines
modified crates/hub/src/api/images.rs +47 −0
…
116 unchanged lines
117 117
    Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len(), "arch": m.arch }))))
118 118
}
119 119
120 +
/// Most images one `held` question may name.
121 +
const MAX_HELD: usize = 1000;
122 +
123 +
/// `POST /flow/images/held` (a runner's token): the images it holds; the
124 +
/// answer names those the hub keeps no manifest of, for the runner to send
125 +
/// with `PUT /flow/images/{hash}/held` (oxygenflow#94).
126 +
pub async fn held(
127 +
    State(app): State<Shared>,
128 +
    headers: HeaderMap,
129 +
    Json(ask): Json<flow_core::api::HeldImages>,
130 +
) -> Result<Json<flow_core::api::WantedManifests>, ApiError> {
131 +
    crate::flow::runner_auth(&app, &headers).await?;
132 +
    let kept = crate::images::held(&app.db).await?;
133 +
    let mut wanted: Vec<String> =
134 +
        ask.images.into_iter().take(MAX_HELD).map(|h| h.to_ascii_lowercase()).filter(|h| is_hash(h) && !kept.contains(h)).collect();
135 +
    wanted.sort();
136 +
    wanted.dedup();
137 +
    Ok(Json(flow_core::api::WantedManifests { wanted }))
138 +
}
139 +
140 +
/// `PUT /flow/images/{hash}/held` (a runner's token): the manifest of an
141 +
/// image the runner holds and the hub does not. Kept as held by runners
142 +
/// only: the hub has none of its chunks to check it against, and a fetcher
143 +
/// checks what it assembles against the pinned hash (jetstream.md §8.1.1).
144 +
pub async fn put_held(
145 +
    State(app): State<Shared>,
146 +
    headers: HeaderMap,
147 +
    Path(hash): Path<String>,
148 +
    Json(m): Json<Manifest>,
149 +
) -> Result<StatusCode, ApiError> {
150 +
    let runner = crate::flow::runner_auth(&app, &headers).await?;
151 +
    if !is_hash(&hash) || m.hash != hash {
152 +
        return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash)));
153 +
    }
154 +
    if crate::images::held(&app.db).await?.contains(&hash) {
155 +
        return Ok(StatusCode::NO_CONTENT);
156 +
    }
157 +
    let store = app.images();
158 +
    let kept = m.clone();
159 +
    tokio::task::spawn_blocking(move || store.keep_peer_manifest(&kept))
160 +
        .await
161 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
162 +
        .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
163 +
    crate::images::record_peer(&app.db, &m, &runner.name).await?;
164 +
    Ok(StatusCode::CREATED)
165 +
}
166 +
120 167
/// `GET /flow/images/{hash}`: an image's manifest.
121 168
pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> {
122 169
    fetcher(&app, &headers).await?;
…
14 unchanged lines
modified crates/hub/src/api/mod.rs +4 −0
…
62 unchanged lines
63 63
        .route("/flow/runners/heartbeat", post(crate::flow::heartbeat))
64 64
        .route("/flow/runner/update", get(crate::flow::runner_update).post(crate::flow::report_update))
65 65
        .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES)))
66 +
        .route("/flow/images/held", post(images::held))
66 67
        .route("/flow/swarm/announce", post(crate::swarm::announce))
67 68
        .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk))
68 69
        .route(
69 70
            "/flow/images/{hash}",
70 71
            axum::routing::put(images::put_manifest).get(images::get_manifest).layer(axum::extract::DefaultBodyLimit::max(64 << 20)),
71 72
        )
73 +
        // A runner's manifest of an image only runners hold (oxygenflow#94);
74 +
        // a 14 GB image's is about 11 MB.
75 +
        .route("/flow/images/{hash}/held", axum::routing::put(images::put_held).layer(axum::extract::DefaultBodyLimit::max(64 << 20)))
72 76
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
73 77
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
74 78
        .route("/flow/jobs/{id}/decline", post(crate::flow::decline))
…
3125 unchanged lines
modified crates/hub/src/flow.rs +19 −11
…
1048 unchanged lines
1049 1049
        images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()),
1050 1050
        arch,
1051 1051
        image_archs: fdb::image_archs(&app.db).await?,
1052 -
        fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() },
1052 +
        // The hub's own images, and those a runner of its site holds (oxygenflow#94).
1053 +
        fetchable: if req.fetches_images { crate::images::reach(&app).await?.for_runner(me.id) } else { Default::default() },
1053 1054
        free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib),
1054 1055
    };
1055 1056
    // The labels it reports now, or those it registered with, and those
…
17 unchanged lines
1073 1074
    // Each job's build cache slot (oxygenflow#51), as runners report theirs.
1074 1075
    let slots: std::collections::HashMap<i64, String> =
1075 1076
        queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect();
1076 -
    let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?);
1077 +
    let (archs, reach) = (fdb::image_archs(&app.db).await?, crate::images::reach(&app).await?);
1077 1078
    let now = db::now_ms();
1078 -
    let fleet = Fleet { image_archs: &archs, hub_images: &held, now };
1079 +
    let fleet = Fleet { image_archs: &archs, reach: &reach, now };
1079 1080
    let hub_key = app.repos.root().display().to_string();
1080 1081
    let better = |j: &fdb::Job| -> bool {
1081 1082
        let Some((owner, repo_id)) = owners.get(&j.id) else { return false };
…
495 unchanged lines
1577 1578
/// the hub holds, and the time.
1578 1579
pub struct Fleet<'a> {
1579 1580
    pub image_archs: &'a std::collections::HashMap<String, String>,
1580 -
    pub hub_images: &'a std::collections::HashSet<String>,
1581 +
    /// Where images can come from: the hub, or a peer in a runner's site.
1582 +
    pub reach: &'a crate::images::Reach,
1581 1583
    pub now: i64,
1582 1584
}
1583 1585
…
27 unchanged lines
1611 1613
        return Some(format!("is {}; the job needs {w}", r.arch));
1612 1614
    }
1613 1615
    // A job with no image (the WASM tier) needs none on the runner, as its claim says.
1614 -
    let fetches = r.fetches_images && fleet.hub_images.contains(rootfs);
1616 +
    let fetches = r.fetches_images && fleet.reach.fetchable(r.id, rootfs);
1615 1617
    if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) {
1616 -
        let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" };
1618 +
        let why = if !r.fetches_images {
1619 +
            ""
1620 +
        } else if fleet.reach.peer_only(rootfs) {
1621 +
            " (only runners hold it, none in this runner's swarm site: send it to the hub with flow rootfs push)"
1622 +
        } else {
1623 +
            " (nor does the hub: send it with flow rootfs push)"
1624 +
        };
1617 1625
        return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())]));
1618 1626
    }
1619 1627
    let labels = r.label_list();
…
36 unchanged lines
1656 1664
    image_archs: &std::collections::HashMap<String, String>,
1657 1665
    declines: &[fdb::DeclineRow],
1658 1666
    now: i64,
1659 -
    hub_images: &std::collections::HashSet<String>,
1667 +
    reach: &crate::images::Reach,
1660 1668
) -> String {
1661 -
    let fleet = Fleet { image_archs, hub_images, now };
1669 +
    let fleet = Fleet { image_archs, reach, now };
1662 1670
    let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect();
1663 1671
    if online.is_empty() {
1664 1672
        return "no runner is online".into();
…
24 unchanged lines
1689 1697
    owner: &str,
1690 1698
    repo_id: i64,
1691 1699
    runners: &[db::runners::Runner],
1692 -
    fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64),
1700 +
    fleet: (&std::collections::HashMap<String, String>, &crate::images::Reach, i64),
1693 1701
    declines: &[fdb::DeclineRow],
1694 1702
    running: &std::collections::HashMap<i64, i64>,
1695 1703
    ahead: usize,
1696 1704
    behind_urgent: &[String],
1697 1705
) -> String {
1698 -
    let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 };
1706 +
    let fleet = Fleet { image_archs: fleet.0, reach: fleet.1, now: fleet.2 };
1699 1707
    let mut busy = Vec::new();
1700 1708
    for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) {
1701 1709
        if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() {
…
119 unchanged lines
1821 1829
    }
1822 1830
    let runners = db::runners::list(&app.db).await?;
1823 1831
    let archs = fdb::image_archs(&app.db).await?;
1824 -
    let held = crate::images::held(&app.db).await?;
1832 +
    let held = crate::images::reach(app).await?;
1825 1833
    let now = db::now_ms();
1826 1834
    let ranks = fdb::queued_ranks(&app.db).await?;
1827 1835
    let running = fdb::running_by_runner(&app.db).await?;
…
1222 unchanged lines
modified crates/hub/src/images.rs +124 −4
…
84 unchanged lines
85 85
        self.keep_manifest(m)
86 86
    }
87 87
88 +
    /// Keep manifest `m` of an image only runners hold (oxygenflow#94): well
89 +
    /// formed, its chunks not here, so not checked against its hash; a
90 +
    /// fetcher checks the image it assembles against the pinned hash.
91 +
    /// Never over a manifest already kept.
92 +
    pub fn keep_peer_manifest(&self, m: &Manifest) -> Result<(), String> {
93 +
        m.check().map_err(|e| e.to_string())?;
94 +
        if self.manifest_path(&m.hash).exists() {
95 +
            return Ok(());
96 +
        }
97 +
        self.keep_manifest(m)
98 +
    }
99 +
88 100
    /// Write manifest `m`, whose image is here.
89 101
    fn keep_manifest(&self, m: &Manifest) -> Result<(), String> {
90 102
        let path = self.manifest_path(&m.hash);
…
72 unchanged lines
163 175
    }
164 176
}
165 177
166 -
/// Record an image this hub now keeps.
178 +
/// Record an image this hub now keeps, its chunks and all: one only
179 +
/// runners held before becomes the hub's.
167 180
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
168 181
    sqlx::query(
169 182
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, $2, $3, $4, $5, $6) \
170 -
         ON CONFLICT (hash) DO UPDATE SET arch = excluded.arch WHERE excluded.arch != ''",
183 +
         ON CONFLICT (hash) DO UPDATE SET source = 'hub', holder = '', \
184 +
         arch = CASE WHEN excluded.arch != '' THEN excluded.arch ELSE jet_images.arch END",
171 185
    )
172 186
    .bind(&m.hash)
173 187
    .bind(m.size as i64)
…
35 unchanged lines
209 223
    }
210 224
    crate::db::flow::learn_archs_from_runs(&app.db).await?;
211 225
    Ok(read)
226 +
}
227 +
228 +
/// Record an image only runners hold, whose manifest `holder` sent
229 +
/// (oxygenflow#94). Never over one the hub keeps.
230 +
pub async fn record_peer(pool: &Pool, m: &Manifest, holder: &str) -> sqlx::Result<()> {
231 +
    sqlx::query(
232 +
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms, source, holder) VALUES ($1, $2, $3, $4, $5, $6, 'peer', $7) \
233 +
         ON CONFLICT (hash) DO NOTHING",
234 +
    )
235 +
    .bind(&m.hash)
236 +
    .bind(m.size as i64)
237 +
    .bind(m.arch.clone().unwrap_or_default())
238 +
    .bind(m.chunks.len() as i64)
239 +
    .bind(holder)
240 +
    .bind(now_ms())
241 +
    .bind(holder)
242 +
    .execute(pool)
243 +
    .await?;
244 +
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
245 +
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
246 +
    }
247 +
    Ok(())
212 248
}
213 249
214 -
/// Every image this hub keeps, by hash.
250 +
/// Every image this hub keeps a manifest of, by hash: its own and those
251 +
/// only runners hold.
215 252
pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> {
216 253
    Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect())
217 254
}
218 255
256 +
/// Where the images jobs need can come from (oxygenflow#94): the hub's
257 +
/// own, pushed and verified, and those only runners hold, each with the
258 +
/// swarm sites of the online runners holding it now.
259 +
#[derive(Debug, Default)]
260 +
pub struct Reach {
261 +
    pub hub: std::collections::HashSet<String>,
262 +
    pub peers: std::collections::HashMap<String, Vec<String>>,
263 +
    /// Each runner in a swarm now, and its site.
264 +
    pub sites: std::collections::HashMap<i64, String>,
265 +
}
266 +
267 +
impl Reach {
268 +
    /// Whether a runner that fetches images can get `image`: from the hub,
269 +
    /// or from a runner of its own site.
270 +
    pub fn fetchable(&self, runner: i64, image: &str) -> bool {
271 +
        self.hub.contains(image) || self.sites.get(&runner).is_some_and(|s| self.peers.get(image).is_some_and(|v| v.contains(s)))
272 +
    }
273 +
274 +
    /// Every image `runner` can fetch.
275 +
    pub fn for_runner(&self, runner: i64) -> std::collections::HashSet<String> {
276 +
        let mut out = self.hub.clone();
277 +
        out.extend(self.peers.keys().filter(|i| self.fetchable(runner, i)).cloned());
278 +
        out
279 +
    }
280 +
281 +
    /// Whether only runners hold `image`.
282 +
    pub fn peer_only(&self, image: &str) -> bool {
283 +
        !self.hub.contains(image) && self.peers.contains_key(image)
284 +
    }
285 +
}
286 +
287 +
/// [`Reach`] now.
288 +
pub async fn reach(app: &crate::Shared) -> sqlx::Result<Reach> {
289 +
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, source FROM jet_images").fetch_all(&app.db).await?;
290 +
    let mut reach = Reach::default();
291 +
    for (hash, source) in rows {
292 +
        if source == "peer" {
293 +
            reach.peers.insert(hash, Vec::new());
294 +
        } else {
295 +
            reach.hub.insert(hash);
296 +
        }
297 +
    }
298 +
    for r in crate::db::runners::list(&app.db).await? {
299 +
        if r.state == "disabled" || !r.online() {
300 +
            continue;
301 +
        }
302 +
        let Some(addr) = app.swarm.addr(r.id) else { continue };
303 +
        for image in r.image_list().unwrap_or_default() {
304 +
            if let Some(sites) = reach.peers.get_mut(&image.to_ascii_lowercase()) {
305 +
                if !sites.contains(&addr.site) {
306 +
                    sites.push(addr.site.clone());
307 +
                }
308 +
            }
309 +
        }
310 +
        reach.sites.insert(r.id, addr.site);
311 +
    }
312 +
    Ok(reach)
313 +
}
314 +
219 315
/// An image this hub keeps (`jet_images`).
220 316
#[derive(sqlx::FromRow)]
221 317
pub struct Kept {
…
3 unchanged lines
225 321
    pub chunks: i64,
226 322
    pub uploader: String,
227 323
    pub created_ms: i64,
324 +
    /// 'hub', or 'peer' for one only runners hold (oxygenflow#94).
325 +
    pub source: String,
326 +
    pub holder: String,
228 327
}
229 328
230 329
pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> {
231 -
    sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms FROM jet_images").fetch_all(pool).await
330 +
    sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms, source, holder FROM jet_images").fetch_all(pool).await
232 331
}
233 332
234 333
/// One repository's use of one image: its jobs since `since_ms`, and when
…
42 unchanged lines
277 376
/// The architecture runners reported for each image (`flow_images`).
278 377
pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
279 378
    Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect())
379 +
}
380 +
381 +
#[cfg(test)]
382 +
mod reach_tests {
383 +
    use super::Reach;
384 +
385 +
    #[test]
386 +
    fn an_image_only_runners_hold_is_fetchable_in_their_site_alone() {
387 +
        let mut r = Reach::default();
388 +
        r.hub.insert("hub-image".into());
389 +
        r.peers.insert("peer-image".into(), vec!["home".into()]);
390 +
        r.sites.insert(1, "home".into());
391 +
        r.sites.insert(2, "cloud".into());
392 +
        assert!(r.fetchable(1, "hub-image") && r.fetchable(2, "hub-image") && r.fetchable(3, "hub-image"));
393 +
        assert!(r.fetchable(1, "peer-image"), "a runner of the holder's site");
394 +
        assert!(!r.fetchable(2, "peer-image"), "another site");
395 +
        assert!(!r.fetchable(3, "peer-image"), "a runner in no swarm");
396 +
        assert!(r.peer_only("peer-image") && !r.peer_only("hub-image"));
397 +
        assert_eq!(r.for_runner(1).len(), 2);
398 +
        assert_eq!(r.for_runner(2).len(), 1);
399 +
    }
280 400
}
modified crates/hub/src/swarm.rs +10 −1
…
129 unchanged lines
130 130
        }
131 131
    }
132 132
    let ticket = ticket(&app, &me, &mine.site)?;
133 -
    Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S }))
133 +
    // An image only runners hold (oxygenflow#94): the hub has its manifest
134 +
    // and none of its chunks, so the fetcher asks peers alone.
135 +
    let seed = Some(
136 +
        sqlx::query_scalar::<_, String>("SELECT source FROM jet_images WHERE hash = $1")
137 +
            .bind(&image)
138 +
            .fetch_optional(&app.db)
139 +
            .await?
140 +
            .is_none_or(|s| s != "peer"),
141 +
    );
142 +
    Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S, seed }))
134 143
}
135 144
136 145
/// The runner's ticket, made again when it nears its end or its site
…
33 unchanged lines
modified crates/hub/tests/flow.rs +52 −0
…
3129 unchanged lines
3130 3130
/// manifest, checked against its hash; runners fetch it; and a job whose
3131 3131
/// image only the hub holds is offered to a runner that fetches, not to one
3132 3132
/// that does not.
3133 +
/// oxygenflow#94, #342: a runner sends the manifest of an image it holds
3134 +
/// that the hub lacks; the hub keeps it as held by runners only (no chunks,
3135 +
/// announce says it is no seed) until the image is pushed whole.
3133 3136
#[tokio::test(flavor = "multi_thread")]
3137 +
async fn a_runner_offers_the_manifest_of_an_image_the_hub_lacks() {
3138 +
    let h = hub().await;
3139 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
3140 +
    let image: Vec<u8> = (0..300_000u32).map(|i| (i % 241) as u8).collect();
3141 +
    let mut chunks = std::collections::HashMap::new();
3142 +
    let m = jet::images::scan(&image[..], Some("x86_64".into()), |c, b| {
3143 +
        chunks.insert(c.hash().to_string(), b.to_vec());
3144 +
        Ok(())
3145 +
    })
3146 +
    .unwrap();
3147 +
    let hash = m.hash.clone();
3148 +
    // Asked which of its images the hub lacks: this one.
3149 +
    let held = serde_json::json!({ "images": [hash.clone(), "not-a-hash"] }).to_string();
3150 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/images/held", Some("runner-secret"), Some(held.clone())).await;
3151 +
    assert_eq!(st, StatusCode::OK, "{body}");
3152 +
    assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["wanted"], serde_json::json!([hash.clone()]));
3153 +
    // Only runners may say so.
3154 +
    assert_ne!(call(&h.app, "POST", "/api/v1/flow/images/held", Some("site"), Some(held.clone())).await.0, StatusCode::OK);
3155 +
    // A manifest filed under another hash is refused; its own is kept as peer-held.
3156 +
    let manifest = serde_json::to_string(&m).unwrap();
3157 +
    let wrong = format!("/api/v1/flow/images/{}/held", "ab".repeat(32));
3158 +
    assert_eq!(call(&h.app, "PUT", &wrong, Some("runner-secret"), Some(manifest.clone())).await.0, StatusCode::BAD_REQUEST);
3159 +
    let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}/held"), Some("runner-secret"), Some(manifest.clone())).await;
3160 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
3161 +
    let kept = oxygenhub::images::kept(&h.app.db).await.unwrap();
3162 +
    let row = kept.iter().find(|k| k.hash == hash).expect("recorded");
3163 +
    assert_eq!(row.source, "peer");
3164 +
    let (_, body) = call(&h.app, "POST", "/api/v1/flow/images/held", Some("runner-secret"), Some(held)).await;
3165 +
    assert!(body.contains("\"wanted\":[]"), "{body}");
3166 +
    // Runners fetch its manifest; the hub has none of its chunks.
3167 +
    let (st, _) = call(&h.app, "GET", &format!("/api/v1/flow/images/{hash}"), Some("runner-secret"), None).await;
3168 +
    assert_eq!(st, StatusCode::OK);
3169 +
    let first = m.needed()[0].to_string();
3170 +
    assert_ne!(call(&h.app, "GET", &format!("/api/v1/flow/images/chunks/{first}"), Some("runner-secret"), None).await.0, StatusCode::OK);
3171 +
    let reach = oxygenhub::images::reach(&h.app).await.unwrap();
3172 +
    assert!(reach.peer_only(&hash));
3173 +
    // Pushed whole, it becomes the hub's.
3174 +
    for c in m.needed() {
3175 +
        let (st, body) = put_bytes(&h.app, &format!("/api/v1/flow/images/chunks/{c}"), "site", chunks[c].clone()).await;
3176 +
        assert_eq!(st, StatusCode::NO_CONTENT, "{body}");
3177 +
    }
3178 +
    let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}"), Some("site"), Some(manifest)).await;
3179 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
3180 +
    let kept = oxygenhub::images::kept(&h.app.db).await.unwrap();
3181 +
    assert_eq!(kept.iter().find(|k| k.hash == hash).unwrap().source, "hub");
3182 +
    assert!(!oxygenhub::images::reach(&h.app).await.unwrap().peer_only(&hash));
3183 +
}
3184 +
3185 +
#[tokio::test(flavor = "multi_thread")]
3134 3186
async fn images_are_sent_by_chunk_and_offered_to_runners_that_fetch() {
3135 3187
    let h = hub().await;
3136 3188
    // The site token acts as the first site administrator.
…
3786 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.