Images by hash: flow rootfs push and pull, and runners fetch what they lack 4cf0c99a1486
flow rootfs push HASH sends an image to a hub: only the chunks the hub lacks, eight at a time, then the manifest the hub checks against the hash. flow rootfs pull HASH fetches one into a Flow home. A runner whose job's image is not here fetches it before the preflight, one image at a time, saying so in the job's log; chunks stay in the home's chunk cache, so a rebuilt image costs only its changed chunks. Claims say the runner fetches images (fetches_images), so the hub may offer it jobs whose image only the hub holds. refs #39
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-11 17:23 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
4cf0c99a1486e32d3395552e851e5eb55af06d9ed5e909c980cd26b60c43942f- Depends on
- 1abadd4a2fa6 1daca0b6bb8c 3007d8f011fa a72abeeedd78 b11e30f5031d e0918b887850
- Depended on by
- 8b7034c55402 1215f05ff8d0 32f54b393396 8a4f5174526a 5cf1e8aefbb6 4be5e4cd0af6 077a65242359 01043bfff9fd b14aeaae683a e38bb032e38b 62829d15ef09
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 11 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/flow/src/images.rs +220 −0
| 1 | + | //! Images by hash with a hub (oxygenflow#39, docs/jetstream.md §8): |
|
| 2 | + | //! `flow rootfs push` sends an image's missing chunks and its manifest; |
|
| 3 | + | //! `flow rootfs pull`, and a runner that lacks a job's image, fetch the |
|
| 4 | + | //! manifest and the chunks they lack and write the image back, checked |
|
| 5 | + | //! against its hash. Fetched chunks stay in the home's chunk cache |
|
| 6 | + | //! (`chunks/`), so a rebuilt image costs only the chunks that changed. |
|
| 7 | + | ||
| 8 | + | use crate::home::Home; |
|
| 9 | + | use anyhow::{bail, Context, Result}; |
|
| 10 | + | use jet::images::{self, Chunk, Manifest}; |
|
| 11 | + | use std::collections::HashSet; |
|
| 12 | + | use std::path::{Path, PathBuf}; |
|
| 13 | + | use std::sync::Mutex; |
|
| 14 | + | use std::time::{Duration, Instant}; |
|
| 15 | + | ||
| 16 | + | /// Requests in flight at once. |
|
| 17 | + | const PARALLEL: usize = 8; |
|
| 18 | + | ||
| 19 | + | /// A hub's image endpoints, with a token. |
|
| 20 | + | pub struct Client { |
|
| 21 | + | base: String, |
|
| 22 | + | token: String, |
|
| 23 | + | agent: ureq::Agent, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// What a push or a fetch moved. |
|
| 27 | + | pub struct Moved { |
|
| 28 | + | pub chunks: usize, |
|
| 29 | + | pub bytes: u64, |
|
| 30 | + | pub elapsed: Duration, |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | impl Client { |
|
| 34 | + | pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> { |
|
| 35 | + | let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 36 | + | let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build(); |
|
| 37 | + | Ok(Client { base: format!("{}/api/v1", hub.trim_end_matches('/')), token: token.into(), agent }) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | fn auth(&self) -> String { |
|
| 41 | + | format!("Bearer {}", self.token) |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | fn fail(url: &str, e: ureq::Error) -> anyhow::Error { |
|
| 45 | + | match e { |
|
| 46 | + | ureq::Error::Status(code, r) => { |
|
| 47 | + | let text = r.into_string().unwrap_or_default(); |
|
| 48 | + | let detail = serde_json::from_str::<serde_json::Value>(&text) |
|
| 49 | + | .ok() |
|
| 50 | + | .and_then(|v| v["detail"].as_str().map(String::from)) |
|
| 51 | + | .unwrap_or(text); |
|
| 52 | + | anyhow::anyhow!("{url}: HTTP {code}: {detail}") |
|
| 53 | + | } |
|
| 54 | + | e => anyhow::anyhow!("{url}: {e}"), |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// The hub's manifest of image `hash`, `None` when it does not hold it. |
|
| 59 | + | pub fn manifest(&self, hash: &str) -> Result<Option<Manifest>> { |
|
| 60 | + | let url = format!("{}/flow/images/{hash}", self.base); |
|
| 61 | + | match self.agent.get(&url).set("Authorization", &self.auth()).call() { |
|
| 62 | + | Ok(r) => Ok(Some(r.into_json()?)), |
|
| 63 | + | Err(ureq::Error::Status(404, _)) => Ok(None), |
|
| 64 | + | Err(e) => Err(Self::fail(&url, e)), |
|
| 65 | + | } |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | fn chunk(&self, hash: &str) -> Result<Vec<u8>> { |
|
| 69 | + | let url = format!("{}/flow/images/chunks/{hash}", self.base); |
|
| 70 | + | let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?; |
|
| 71 | + | let mut out = Vec::new(); |
|
| 72 | + | std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?; |
|
| 73 | + | Ok(out) |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | /// Send image `hash` from `home`: only the chunks the hub lacks, then |
|
| 77 | + | /// the manifest, which the hub checks against the hash. |
|
| 78 | + | pub fn push(&self, home: &Home, hash: &str) -> Result<Moved> { |
|
| 79 | + | let start = Instant::now(); |
|
| 80 | + | let path = home.rootfs(hash); |
|
| 81 | + | if !path.is_file() { |
|
| 82 | + | bail!("{} is not in this Flow home ({}); import it first", &hash[..12.min(hash.len())], path.display()); |
|
| 83 | + | } |
|
| 84 | + | let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?; |
|
| 85 | + | if m.hash != hash { |
|
| 86 | + | bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]); |
|
| 87 | + | } |
|
| 88 | + | let mut missing: HashSet<String> = HashSet::new(); |
|
| 89 | + | for ask in m.needed().chunks(50_000) { |
|
| 90 | + | let url = format!("{}/flow/images/missing", self.base); |
|
| 91 | + | let r: serde_json::Value = self |
|
| 92 | + | .agent |
|
| 93 | + | .post(&url) |
|
| 94 | + | .set("Authorization", &self.auth()) |
|
| 95 | + | .send_json(serde_json::json!({ "chunks": ask })) |
|
| 96 | + | .map_err(|e| Self::fail(&url, e))? |
|
| 97 | + | .into_json()?; |
|
| 98 | + | missing.extend(r["missing"].as_array().into_iter().flatten().filter_map(|h| h.as_str().map(String::from))); |
|
| 99 | + | } |
|
| 100 | + | // Read the image again, sending the missing chunks as they come. |
|
| 101 | + | let (count, bytes) = (missing.len(), Mutex::new(0u64)); |
|
| 102 | + | let queue: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new()); |
|
| 103 | + | let failed: Mutex<Option<anyhow::Error>> = Mutex::new(None); |
|
| 104 | + | let sent = Mutex::new(HashSet::new()); |
|
| 105 | + | let send = |c: &str, b: &[u8]| -> Result<()> { |
|
| 106 | + | let url = format!("{}/flow/images/chunks/{c}", self.base); |
|
| 107 | + | self.agent.put(&url).set("Authorization", &self.auth()).send_bytes(b).map_err(|e| Self::fail(&url, e))?; |
|
| 108 | + | *bytes.lock().unwrap() += b.len() as u64; |
|
| 109 | + | Ok(()) |
|
| 110 | + | }; |
|
| 111 | + | images::scan_file(&path, None, |c: &Chunk, b: &[u8]| { |
|
| 112 | + | if missing.contains(c.hash()) && sent.lock().unwrap().insert(c.hash().to_string()) { |
|
| 113 | + | queue.lock().unwrap().push((c.hash().to_string(), b.to_vec())); |
|
| 114 | + | } |
|
| 115 | + | if queue.lock().unwrap().len() >= PARALLEL * 4 { |
|
| 116 | + | let batch: Vec<_> = std::mem::take(&mut *queue.lock().unwrap()); |
|
| 117 | + | send_batch(&batch, &send, &failed); |
|
| 118 | + | } |
|
| 119 | + | match failed.lock().unwrap().take() { |
|
| 120 | + | Some(e) => Err(images::Error::Bad(format!("{e:#}"))), |
|
| 121 | + | None => Ok(()), |
|
| 122 | + | } |
|
| 123 | + | })?; |
|
| 124 | + | let rest: Vec<_> = std::mem::take(&mut *queue.lock().unwrap()); |
|
| 125 | + | send_batch(&rest, &send, &failed); |
|
| 126 | + | if let Some(e) = failed.lock().unwrap().take() { |
|
| 127 | + | return Err(e); |
|
| 128 | + | } |
|
| 129 | + | let url = format!("{}/flow/images/{hash}", self.base); |
|
| 130 | + | self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?; |
|
| 131 | + | let bytes = *bytes.lock().unwrap(); |
|
| 132 | + | Ok(Moved { chunks: count, bytes, elapsed: start.elapsed() }) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// Fetch image `hash` into `home`: the chunks its cache lacks, the |
|
| 136 | + | /// image written and checked, its architecture recorded. `Ok(None)` |
|
| 137 | + | /// when the hub does not hold it. |
|
| 138 | + | pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> { |
|
| 139 | + | let start = Instant::now(); |
|
| 140 | + | let Some(m) = self.manifest(hash)? else { return Ok(None) }; |
|
| 141 | + | if m.hash != hash { |
|
| 142 | + | bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]); |
|
| 143 | + | } |
|
| 144 | + | let cache = chunk_cache(home); |
|
| 145 | + | let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect(); |
|
| 146 | + | let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>)); |
|
| 147 | + | let next = Mutex::new(wanted.iter()); |
|
| 148 | + | std::thread::scope(|s| { |
|
| 149 | + | for _ in 0..PARALLEL { |
|
| 150 | + | s.spawn(|| loop { |
|
| 151 | + | let Some(h) = next.lock().unwrap().next() else { break }; |
|
| 152 | + | if failed.lock().unwrap().is_some() { |
|
| 153 | + | break; |
|
| 154 | + | } |
|
| 155 | + | let got = self.chunk(h).and_then(|b| { |
|
| 156 | + | if blake3::hash(&b).to_hex().as_str() != h { |
|
| 157 | + | bail!("chunk {} came back with other bytes", &h[..12]); |
|
| 158 | + | } |
|
| 159 | + | let p = cache_path(&cache, h); |
|
| 160 | + | std::fs::create_dir_all(p.parent().expect("a chunk directory"))?; |
|
| 161 | + | let tmp = p.with_extension("part"); |
|
| 162 | + | std::fs::write(&tmp, &b)?; |
|
| 163 | + | std::fs::rename(&tmp, &p)?; |
|
| 164 | + | *bytes.lock().unwrap() += b.len() as u64; |
|
| 165 | + | Ok(()) |
|
| 166 | + | }); |
|
| 167 | + | if let Err(e) = got { |
|
| 168 | + | *failed.lock().unwrap() = Some(e); |
|
| 169 | + | break; |
|
| 170 | + | } |
|
| 171 | + | }); |
|
| 172 | + | } |
|
| 173 | + | }); |
|
| 174 | + | if let Some(e) = failed.into_inner().unwrap() { |
|
| 175 | + | return Err(e); |
|
| 176 | + | } |
|
| 177 | + | let out = home.rootfs(hash); |
|
| 178 | + | std::fs::create_dir_all(out.parent().expect("the cas directory"))?; |
|
| 179 | + | images::assemble(&m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?)) |
|
| 180 | + | .with_context(|| format!("write image {}", &hash[..12]))?; |
|
| 181 | + | if let Some(arch) = &m.arch { |
|
| 182 | + | let manifest = serde_json::json!({ "arch": arch, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0), "from": "hub" }); |
|
| 183 | + | std::fs::write(home.image_manifest(hash), serde_json::to_vec(&manifest)?)?; |
|
| 184 | + | } |
|
| 185 | + | Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), elapsed: start.elapsed() })) |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) { |
|
| 190 | + | std::thread::scope(|s| { |
|
| 191 | + | for part in batch.chunks(batch.len().div_ceil(PARALLEL).max(1)) { |
|
| 192 | + | s.spawn(move || { |
|
| 193 | + | for (h, b) in part { |
|
| 194 | + | if let Err(e) = send(h, b) { |
|
| 195 | + | *failed.lock().unwrap() = Some(e); |
|
| 196 | + | return; |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | }); |
|
| 200 | + | } |
|
| 201 | + | }); |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | /// The home's chunk cache. |
|
| 205 | + | pub fn chunk_cache(home: &Home) -> PathBuf { |
|
| 206 | + | home.root.join("chunks") |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | fn cache_path(cache: &Path, hash: &str) -> PathBuf { |
|
| 210 | + | cache.join(&hash[..2]).join(hash) |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | /// A size for people: 991 MiB, 1.2 GiB. |
|
| 214 | + | pub fn human(bytes: u64) -> String { |
|
| 215 | + | if bytes >= 1 << 30 { |
|
| 216 | + | format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64) |
|
| 217 | + | } else { |
|
| 218 | + | format!("{} MiB", bytes >> 20) |
|
| 219 | + | } |
|
| 220 | + | } |
modified Cargo.lock +1 −0
| … | 420 unchanged lines |
||
| 421 | 421 | "flow-core", |
|
| 422 | 422 | "hex", |
|
| 423 | 423 | "ignore", |
|
| 424 | + | "jet", |
|
| 424 | 425 | "libc", |
|
| 425 | 426 | "oxy-core", |
|
| 426 | 427 | "serde", |
|
| … | 1253 unchanged lines |
modified crates/flow-core/src/api.rs +5 −0
| … | 32 unchanged lines |
||
| 33 | 33 | /// runner whose architecture its image is for. |
|
| 34 | 34 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 35 | 35 | pub image_archs: Option<std::collections::BTreeMap<String, String>>, |
|
| 36 | + | /// Whether the runner fetches a job's image from the hub when it lacks |
|
| 37 | + | /// it (oxygenflow#39): the hub may then offer it jobs whose image only |
|
| 38 | + | /// the hub holds. |
|
| 39 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 40 | + | pub fetches_images: bool, |
|
| 36 | 41 | /// The jobs this runner holds now: running, or finished with a result |
|
| 37 | 42 | /// not yet reported. The hub ends any other job it has as running on |
|
| 38 | 43 | /// this runner, which the runner lost by restarting or never received |
|
| … | 198 unchanged lines |
modified crates/flow/Cargo.toml +1 −0
| … | 10 unchanged lines |
||
| 11 | 11 | ||
| 12 | 12 | [dependencies] |
|
| 13 | 13 | flow-core.workspace = true |
|
| 14 | + | jet.workspace = true |
|
| 14 | 15 | oxy-core.workspace = true |
|
| 15 | 16 | serde.workspace = true |
|
| 16 | 17 | serde_json.workspace = true |
|
| … | 11 unchanged lines |
modified crates/flow/src/main.rs +60 −0
| … | 6 unchanged lines |
||
| 7 | 7 | #[path = "../../flow-init/src/job.rs"] |
|
| 8 | 8 | mod guest; |
|
| 9 | 9 | mod home; |
|
| 10 | + | mod images; |
|
| 10 | 11 | mod microvm; |
|
| 11 | 12 | mod outputs; |
|
| 12 | 13 | mod runner; |
|
| … | 119 unchanged lines |
||
| 132 | 133 | }, |
|
| 133 | 134 | /// List the images this home holds |
|
| 134 | 135 | List, |
|
| 136 | + | /// Send an image to a hub, so its runners fetch it instead of having it |
|
| 137 | + | /// copied to them: only the chunks the hub lacks travel. Uses |
|
| 138 | + | /// $OXY_TOKEN (a token that may write repositories) and $OXY_CA |
|
| 139 | + | Push { |
|
| 140 | + | /// The image's hash, as pipelines pin it (cas://blob/HASH) |
|
| 141 | + | hash: String, |
|
| 142 | + | /// The hub (default: the runner's, from runner.toml or flow.toml) |
|
| 143 | + | #[arg(long)] |
|
| 144 | + | hub: Option<String>, |
|
| 145 | + | }, |
|
| 146 | + | /// Fetch an image a hub holds into this home, as a runner does for a |
|
| 147 | + | /// job whose image it lacks. Uses $OXY_TOKEN and $OXY_CA |
|
| 148 | + | Pull { |
|
| 149 | + | hash: String, |
|
| 150 | + | #[arg(long)] |
|
| 151 | + | hub: Option<String>, |
|
| 152 | + | }, |
|
| 135 | 153 | } |
|
| 136 | 154 | ||
| 137 | 155 | fn main() { |
|
| … | 17 unchanged lines |
||
| 155 | 173 | let home = Home::locate(cli.home.as_deref())?; |
|
| 156 | 174 | match cmd { |
|
| 157 | 175 | RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env), |
|
| 176 | + | RootfsCmd::Push { hash, hub } => { |
|
| 177 | + | let client = image_client(&home, hub)?; |
|
| 178 | + | eprintln!("Sending image {} …", &hash[..12.min(hash.len())]); |
|
| 179 | + | let m = client.push(&home, &hash)?; |
|
| 180 | + | println!( |
|
| 181 | + | "Sent image {}: {} chunks the hub lacked, {} in {:.1} s", |
|
| 182 | + | &hash[..12], |
|
| 183 | + | m.chunks, |
|
| 184 | + | images::human(m.bytes), |
|
| 185 | + | m.elapsed.as_secs_f64() |
|
| 186 | + | ); |
|
| 187 | + | Ok(()) |
|
| 188 | + | } |
|
| 189 | + | RootfsCmd::Pull { hash, hub } => { |
|
| 190 | + | let client = image_client(&home, hub)?; |
|
| 191 | + | match client.fetch(&home, &hash)? { |
|
| 192 | + | Some(m) => { |
|
| 193 | + | println!( |
|
| 194 | + | "Fetched image {}: {} chunks, {} in {:.1} s", |
|
| 195 | + | &hash[..12], |
|
| 196 | + | m.chunks, |
|
| 197 | + | images::human(m.bytes), |
|
| 198 | + | m.elapsed.as_secs_f64() |
|
| 199 | + | ); |
|
| 200 | + | Ok(()) |
|
| 201 | + | } |
|
| 202 | + | None => anyhow::bail!("the hub does not hold image {}; send it with flow rootfs push", &hash[..12.min(hash.len())]), |
|
| 203 | + | } |
|
| 204 | + | } |
|
| 158 | 205 | RootfsCmd::List => { |
|
| 159 | 206 | for e in std::fs::read_dir(home.root.join("cas"))?.flatten() { |
|
| 160 | 207 | let size = e.metadata().map(|m| m.len()).unwrap_or(0); |
|
| … | 71 unchanged lines |
||
| 232 | 279 | std::process::exit(code); |
|
| 233 | 280 | } |
|
| 234 | 281 | } |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /// The hub image commands talk to: `--hub`, else the runner's; with |
|
| 285 | + | /// $OXY_TOKEN, else the runner's own token. |
|
| 286 | + | fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> { |
|
| 287 | + | let runner = home.config.runner.clone(); |
|
| 288 | + | let hub = hub.or_else(|| runner.as_ref().map(|r| r.hub.clone())).context("name the hub with --hub https://HUB")?; |
|
| 289 | + | let token = std::env::var("OXY_TOKEN") |
|
| 290 | + | .ok() |
|
| 291 | + | .or_else(|| runner.as_ref().map(|r| r.token.clone())) |
|
| 292 | + | .context("set OXY_TOKEN to a token of yours on the hub")?; |
|
| 293 | + | let ca = std::env::var_os("OXY_CA").map(PathBuf::from).or_else(|| runner.as_ref().and_then(|r| r.ca.clone())); |
|
| 294 | + | images::Client::new(&hub, &token, ca.as_deref()) |
|
| 235 | 295 | } |
|
| 236 | 296 | ||
| 237 | 297 | fn load(file: &Path) -> Result<Pipeline> { |
|
| … | 252 unchanged lines |
modified crates/flow/src/runner.rs +38 −0
| … | 311 unchanged lines |
||
| 312 | 312 | Ok((have, missing)) |
|
| 313 | 313 | } |
|
| 314 | 314 | ||
| 315 | + | /// Fetch `claim`'s image from the hub when this home lacks it |
|
| 316 | + | /// (oxygenflow#39), saying so in the job's log. A hub that does not hold it |
|
| 317 | + | /// leaves it missing, and the preflight declines the job as before. |
|
| 318 | + | fn fetch_image(home: &Home, hub: &Hub, cfg: &RunnerConfig, claim: &Claim) { |
|
| 319 | + | let hash = &claim.sandbox.rootfs; |
|
| 320 | + | if hash.is_empty() || home.rootfs(hash).is_file() || !jet::images::is_hash(hash) { |
|
| 321 | + | return; |
|
| 322 | + | } |
|
| 323 | + | let note = |text: String| { |
|
| 324 | + | eprintln!("job {}: {text}", claim.job_id); |
|
| 325 | + | hub.log(claim.job_id, vec![Record { t: 0, stream: "flow".into(), text }]).ok(); |
|
| 326 | + | }; |
|
| 327 | + | // One fetch of an image at a time: two jobs wanting it wait for one. |
|
| 328 | + | static FETCHING: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
|
| 329 | + | let _one = FETCHING.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 330 | + | if home.rootfs(hash).is_file() { |
|
| 331 | + | return; |
|
| 332 | + | } |
|
| 333 | + | let client = match crate::images::Client::new(&cfg.hub, &cfg.token, cfg.ca.as_deref()) { |
|
| 334 | + | Ok(c) => c, |
|
| 335 | + | Err(e) => return note(format!("image {}: cannot reach the hub: {e:#}", &hash[..12])), |
|
| 336 | + | }; |
|
| 337 | + | note(format!("image {} is not on this runner: fetching it from the hub", &hash[..12])); |
|
| 338 | + | match client.fetch(home, hash) { |
|
| 339 | + | Ok(Some(m)) => note(format!( |
|
| 340 | + | "image {}: fetched {} chunks, {} in {:.1} s", |
|
| 341 | + | &hash[..12], |
|
| 342 | + | m.chunks, |
|
| 343 | + | crate::images::human(m.bytes), |
|
| 344 | + | m.elapsed.as_secs_f64() |
|
| 345 | + | )), |
|
| 346 | + | Ok(None) => note(format!("image {}: the hub does not hold it either", &hash[..12])), |
|
| 347 | + | Err(e) => note(format!("image {}: fetch failed: {e:#}", &hash[..12])), |
|
| 348 | + | } |
|
| 349 | + | } |
|
| 350 | + | ||
| 315 | 351 | /// Why this runner cannot run `claim` now (spec §11.5), checked before |
|
| 316 | 352 | /// anything is fetched: the tier, the architecture, the image and the |
|
| 317 | 353 | /// memory. The hub chose it from what the runner last said, which may be |
|
| … | 104 unchanged lines |
||
| 422 | 458 | images: Some(home.images()), |
|
| 423 | 459 | image_archs: Some(home.image_archs()), |
|
| 424 | 460 | holding: Some(held.lock().unwrap().iter().copied().collect()), |
|
| 461 | + | fetches_images: true, |
|
| 425 | 462 | }; |
|
| 426 | 463 | match hub.claim(&req) { |
|
| 427 | 464 | Ok(Some(claim)) => { |
|
| … | 9 unchanged lines |
||
| 437 | 474 | let (home, hub, cfg, used, key, held) = (home, &hub, &cfg, &used, &key, &held); |
|
| 438 | 475 | scope.spawn(move || { |
|
| 439 | 476 | let id = claim.job_id; |
|
| 477 | + | fetch_image(home, hub, cfg, &claim); |
|
| 440 | 478 | if let Some(why) = preflight(home, &claim) { |
|
| 441 | 479 | eprintln!("job {id}: declined: {why}"); |
|
| 442 | 480 | if let Err(e) = hub.decline(id, &why) { |
|
| … | 586 unchanged lines |