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 |