| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 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 | |
|
| 17 | | const PARALLEL: usize = 8; |
| 18 | |
|
| 19 | | const ASK: usize = 20_000; |
| 20 | |
|
| 21 | |
|
| 22 | | pub struct Client { |
| 23 | | root: String, |
| 24 | | base: String, |
| 25 | | token: String, |
| 26 | | agent: ureq::Agent, |
| 27 | | } |
| 28 | |
|
| 29 | |
|
| 30 | | pub struct Moved { |
| 31 | | pub chunks: usize, |
| 32 | | pub bytes: u64, |
| 33 | | |
| 34 | | pub peer_bytes: u64, |
| 35 | | pub elapsed: Duration, |
| 36 | | } |
| 37 | |
|
| 38 | | impl Client { |
| 39 | 13× | pub fn new(hub: &str, token: &str, ca: Option<&Path>) -> Result<Client> { |
| 40 | 13× | let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 41 | 13× | let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(120)).build(); |
| 42 | 13× | let root = hub.trim_end_matches('/').to_string(); |
| 43 | 13× | Ok(Client { base: format!("{root}/api/v1"), root, token: token.into(), agent }) |
| 44 | 13× | } |
| 45 | |
|
| 46 | 148× | fn auth(&self) -> String { |
| 47 | 148× | format!("Bearer {}", self.token) |
| 48 | 148× | } |
| 49 | |
|
| 50 | 0× | fn fail(url: &str, e: ureq::Error) -> anyhow::Error { |
| 51 | 0× | match e { |
| 52 | 0× | ureq::Error::Status(code, r) => { |
| 53 | 0× | let text = r.into_string().unwrap_or_default(); |
| 54 | 0× | let detail = serde_json::from_str::<serde_json::Value>(&text) |
| 55 | 0× | .ok() |
| 56 | 0× | .and_then(|v| v["detail"].as_str().map(String::from)) |
| 57 | 0× | .unwrap_or(text); |
| 58 | 0× | anyhow::anyhow!("{url}: HTTP {code}: {detail}") |
| 59 | | } |
| 60 | 0× | e => anyhow::anyhow!("{url}: {e}"), |
| 61 | | } |
| 62 | 0× | } |
| 63 | |
|
| 64 | | |
| 65 | 7× | pub fn manifest(&self, hash: &str) -> Result<Option<Manifest>> { |
| 66 | 7× | let url = format!("{}/flow/images/{hash}", self.base); |
| 67 | 7× | match self.agent.get(&url).set("Authorization", &self.auth()).call() { |
| 68 | 7× | Ok(r) => Ok(Some(r.into_json()?)), |
| 69 | 0× | Err(ureq::Error::Status(404, _)) => Ok(None), |
| 70 | 0× | Err(e) => Err(Self::fail(&url, e)), |
| 71 | | } |
| 72 | 7× | } |
| 73 | |
|
| 74 | | |
| 75 | | |
| 76 | 12× | pub fn hub_identity(&self) -> Result<oxy_core::identity::Identity> { |
| 77 | 12× | let url = format!("{}/.well-known/oxyhub-key", self.root); |
| 78 | 12× | let r = self.agent.get(&url).call().map_err(|e| Self::fail(&url, e))?; |
| 79 | 12× | let mut out = Vec::new(); |
| 80 | 12× | std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?; |
| 81 | 12× | oxy_core::identity::Identity::decode(&out).map_err(|e| anyhow::anyhow!("{url}: not a hub identity: {e}")) |
| 82 | 12× | } |
| 83 | |
|
| 84 | | |
| 85 | | |
| 86 | 17× | pub fn announce(&self, hash: &str, state: &str) -> Result<flow_core::api::Announced> { |
| 87 | 17× | let url = format!("{}/flow/swarm/announce", self.base); |
| 88 | 17× | let body = flow_core::api::Announce { image: hash.into(), state: state.into() }; |
| 89 | 17× | Ok(self.agent.post(&url).set("Authorization", &self.auth()).send_json(&body).map_err(|e| Self::fail(&url, e))?.into_json()?) |
| 90 | 17× | } |
| 91 | |
|
| 92 | 124× | pub(crate) fn chunk(&self, hash: &str) -> Result<Vec<u8>> { |
| 93 | 124× | let url = format!("{}/flow/images/chunks/{hash}", self.base); |
| 94 | 124× | let r = self.agent.get(&url).set("Authorization", &self.auth()).call().map_err(|e| Self::fail(&url, e))?; |
| 95 | 124× | let mut out = Vec::new(); |
| 96 | 124× | std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?; |
| 97 | 124× | Ok(out) |
| 98 | 124× | } |
| 99 | |
|
| 100 | | |
| 101 | | |
| 102 | 0× | pub fn push(&self, home: &Home, hash: &str) -> Result<Moved> { |
| 103 | 0× | let start = Instant::now(); |
| 104 | 0× | let path = home.rootfs(hash); |
| 105 | 0× | if !path.is_file() { |
| 106 | 0× | bail!("{} is not in this Flow home ({}); import it first", &hash[..12.min(hash.len())], path.display()); |
| 107 | 0× | } |
| 108 | 0× | let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?; |
| 109 | 0× | if m.hash != hash { |
| 110 | 0× | bail!("{} holds an image named {}, not {}: the file was changed", path.display(), &m.hash[..12], &hash[..12]); |
| 111 | 0× | } |
| 112 | 0× | let mut missing: HashSet<String> = HashSet::new(); |
| 113 | | |
| 114 | | |
| 115 | 0× | for ask in m.needed().chunks(ASK) { |
| 116 | 0× | let url = format!("{}/flow/images/missing", self.base); |
| 117 | 0× | let r: serde_json::Value = self |
| 118 | 0× | .agent |
| 119 | 0× | .post(&url) |
| 120 | 0× | .set("Authorization", &self.auth()) |
| 121 | 0× | .send_json(serde_json::json!({ "chunks": ask })) |
| 122 | 0× | .map_err(|e| Self::fail(&url, e))? |
| 123 | 0× | .into_json()?; |
| 124 | 0× | missing.extend(r["missing"].as_array().into_iter().flatten().filter_map(|h| h.as_str().map(String::from))); |
| 125 | | } |
| 126 | | |
| 127 | 0× | let (count, bytes) = (missing.len(), Mutex::new(0u64)); |
| 128 | 0× | let queue: Mutex<Vec<(String, Vec<u8>)>> = Mutex::new(Vec::new()); |
| 129 | 0× | let failed: Mutex<Option<anyhow::Error>> = Mutex::new(None); |
| 130 | 0× | let sent = Mutex::new(HashSet::new()); |
| 131 | 0× | let send = |c: &str, b: &[u8]| -> Result<()> { |
| 132 | 0× | let url = format!("{}/flow/images/chunks/{c}", self.base); |
| 133 | 0× | self.agent.put(&url).set("Authorization", &self.auth()).send_bytes(b).map_err(|e| Self::fail(&url, e))?; |
| 134 | 0× | *bytes.lock().unwrap() += b.len() as u64; |
| 135 | 0× | Ok(()) |
| 136 | 0× | }; |
| 137 | 0× | images::scan_file(&path, None, |c: &Chunk, b: &[u8]| { |
| 138 | 0× | if missing.contains(c.hash()) && sent.lock().unwrap().insert(c.hash().to_string()) { |
| 139 | 0× | queue.lock().unwrap().push((c.hash().to_string(), b.to_vec())); |
| 140 | 0× | } |
| 141 | 0× | if queue.lock().unwrap().len() >= PARALLEL * 4 { |
| 142 | 0× | let batch: Vec<_> = std::mem::take(&mut *queue.lock().unwrap()); |
| 143 | 0× | send_batch(&batch, &send, &failed); |
| 144 | 0× | } |
| 145 | 0× | match failed.lock().unwrap().take() { |
| 146 | 0× | Some(e) => Err(images::Error::Bad(format!("{e:#}"))), |
| 147 | 0× | None => Ok(()), |
| 148 | | } |
| 149 | 0× | })?; |
| 150 | 0× | let rest: Vec<_> = std::mem::take(&mut *queue.lock().unwrap()); |
| 151 | 0× | send_batch(&rest, &send, &failed); |
| 152 | 0× | if let Some(e) = failed.lock().unwrap().take() { |
| 153 | 0× | return Err(e); |
| 154 | 0× | } |
| 155 | 0× | let url = format!("{}/flow/images/{hash}", self.base); |
| 156 | | |
| 157 | | |
| 158 | 0× | self.agent |
| 159 | 0× | .put(&url) |
| 160 | 0× | .timeout(Duration::from_secs(3600)) |
| 161 | 0× | .set("Authorization", &self.auth()) |
| 162 | 0× | .send_json(&m) |
| 163 | 0× | .map_err(|e| Self::fail(&url, e))?; |
| 164 | 0× | let bytes = *bytes.lock().unwrap(); |
| 165 | 0× | Ok(Moved { chunks: count, bytes, peer_bytes: 0, elapsed: start.elapsed() }) |
| 166 | 0× | } |
| 167 | |
|
| 168 | | |
| 169 | | |
| 170 | | |
| 171 | 0× | pub fn fetch(&self, home: &Home, hash: &str) -> Result<Option<Moved>> { |
| 172 | 0× | let start = Instant::now(); |
| 173 | 0× | let Some(m) = self.manifest(hash)? else { return Ok(None) }; |
| 174 | 0× | if m.hash != hash { |
| 175 | 0× | bail!("the hub's manifest for {} names {}", &hash[..12], &m.hash[..12]); |
| 176 | 0× | } |
| 177 | 0× | let cache = chunk_cache(home); |
| 178 | 0× | save_manifest(&home.root, &m)?; |
| 179 | 0× | let wanted: Vec<String> = m.needed().into_iter().filter(|h| !cache_path(&cache, h).is_file()).map(String::from).collect(); |
| 180 | 0× | let (bytes, failed) = (Mutex::new(0u64), Mutex::new(None::<anyhow::Error>)); |
| 181 | 0× | let next = Mutex::new(wanted.iter()); |
| 182 | 0× | std::thread::scope(|s| { |
| 183 | 0× | for _ in 0..PARALLEL { |
| 184 | 0× | s.spawn(|| loop { |
| 185 | 0× | let Some(h) = next.lock().unwrap().next() else { break }; |
| 186 | 0× | if failed.lock().unwrap().is_some() { |
| 187 | 0× | break; |
| 188 | 0× | } |
| 189 | 0× | let got = self.chunk(h).and_then(|b| { |
| 190 | 0× | if blake3::hash(&b).to_hex().as_str() != h { |
| 191 | 0× | bail!("chunk {} came back with other bytes", &h[..12]); |
| 192 | 0× | } |
| 193 | 0× | let p = cache_path(&cache, h); |
| 194 | 0× | std::fs::create_dir_all(p.parent().expect("a chunk directory"))?; |
| 195 | 0× | let tmp = p.with_extension("part"); |
| 196 | 0× | std::fs::write(&tmp, &b)?; |
| 197 | 0× | std::fs::rename(&tmp, &p)?; |
| 198 | 0× | *bytes.lock().unwrap() += b.len() as u64; |
| 199 | 0× | Ok(()) |
| 200 | 0× | }); |
| 201 | 0× | if let Err(e) = got { |
| 202 | 0× | *failed.lock().unwrap() = Some(e); |
| 203 | 0× | break; |
| 204 | 0× | } |
| 205 | 0× | }); |
| 206 | | } |
| 207 | 0× | }); |
| 208 | 0× | if let Some(e) = failed.into_inner().unwrap() { |
| 209 | 0× | return Err(e); |
| 210 | 0× | } |
| 211 | 0× | write_image(home, &m)?; |
| 212 | 0× | Ok(Some(Moved { chunks: wanted.len(), bytes: bytes.into_inner().unwrap(), peer_bytes: 0, elapsed: start.elapsed() })) |
| 213 | 0× | } |
| 214 | | } |
| 215 | |
|
| 216 | |
|
| 217 | |
|
| 218 | 6× | pub(crate) fn write_image(home: &Home, m: &Manifest) -> Result<()> { |
| 219 | 6× | let cache = chunk_cache(home); |
| 220 | 6× | let out = home.rootfs(&m.hash); |
| 221 | 6× | std::fs::create_dir_all(out.parent().expect("the cas directory"))?; |
| 222 | 228× | images::assemble(m, &out, |c| Ok(std::fs::read(cache_path(&cache, c.hash()))?)) |
| 223 | 6× | .with_context(|| format!("write image {}", &m.hash[..12]))?; |
| 224 | 6× | if let Some(arch) = &m.arch { |
| 225 | 2× | 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" }); |
| 226 | 2× | std::fs::write(home.image_manifest(&m.hash), serde_json::to_vec(&manifest)?)?; |
| 227 | 4× | } |
| 228 | 6× | Ok(()) |
| 229 | 6× | } |
| 230 | |
|
| 231 | | impl Client { |
| 232 | | |
| 233 | | |
| 234 | | |
| 235 | | |
| 236 | | |
| 237 | 0× | pub fn offer_held(&self, home: &Home) -> Result<usize> { |
| 238 | 0× | let images = home.images(); |
| 239 | 0× | if images.is_empty() { |
| 240 | 0× | return Ok(0); |
| 241 | 0× | } |
| 242 | 0× | let url = format!("{}/flow/images/held", self.base); |
| 243 | 0× | let wanted: flow_core::api::WantedManifests = |
| 244 | 0× | match self.agent.post(&url).set("Authorization", &self.auth()).send_json(flow_core::api::HeldImages { images }) { |
| 245 | 0× | Ok(r) => r.into_json()?, |
| 246 | 0× | Err(ureq::Error::Status(404, _)) => return Ok(0), |
| 247 | 0× | Err(e) => return Err(Self::fail(&url, e)), |
| 248 | | }; |
| 249 | 0× | let mut sent = 0; |
| 250 | 0× | for hash in wanted.wanted.iter().filter(|h| images::is_hash(h)) { |
| 251 | 0× | let path = home.rootfs(hash); |
| 252 | 0× | if !path.is_file() { |
| 253 | 0× | continue; |
| 254 | 0× | } |
| 255 | 0× | let kept = std::fs::read(manifest_path(&home.root, hash)).ok().and_then(|b| serde_json::from_slice::<Manifest>(&b).ok()); |
| 256 | 0× | let m = match kept.filter(|m| m.hash == *hash) { |
| 257 | 0× | Some(m) => m, |
| 258 | | None => { |
| 259 | 0× | let m = images::scan_file(&path, home.image_arch(hash), |_, _| Ok(()))?; |
| 260 | 0× | if m.hash != *hash { |
| 261 | 0× | continue; |
| 262 | 0× | } |
| 263 | 0× | save_manifest(&home.root, &m)?; |
| 264 | 0× | m |
| 265 | | } |
| 266 | | }; |
| 267 | 0× | let url = format!("{}/flow/images/{hash}/held", self.base); |
| 268 | 0× | self.agent.put(&url).set("Authorization", &self.auth()).send_json(&m).map_err(|e| Self::fail(&url, e))?; |
| 269 | 0× | sent += 1; |
| 270 | | } |
| 271 | 0× | Ok(sent) |
| 272 | 0× | } |
| 273 | | } |
| 274 | |
|
| 275 | |
|
| 276 | |
|
| 277 | 14× | pub(crate) fn manifest_path(root: &Path, hash: &str) -> PathBuf { |
| 278 | 14× | root.join("manifests").join(format!("{hash}.json")) |
| 279 | 14× | } |
| 280 | |
|
| 281 | 10× | pub(crate) fn save_manifest(root: &Path, m: &Manifest) -> Result<()> { |
| 282 | 10× | let p = manifest_path(root, &m.hash); |
| 283 | 10× | std::fs::create_dir_all(p.parent().expect("the manifests directory"))?; |
| 284 | 10× | let tmp = p.with_extension("part"); |
| 285 | 10× | std::fs::write(&tmp, serde_json::to_vec(m)?)?; |
| 286 | 10× | std::fs::rename(&tmp, &p)?; |
| 287 | 10× | Ok(()) |
| 288 | 10× | } |
| 289 | |
|
| 290 | 0× | fn send_batch(batch: &[(String, Vec<u8>)], send: &(dyn Fn(&str, &[u8]) -> Result<()> + Sync), failed: &Mutex<Option<anyhow::Error>>) { |
| 291 | 0× | std::thread::scope(|s| { |
| 292 | 0× | for part in batch.chunks(batch.len().div_ceil(PARALLEL).max(1)) { |
| 293 | 0× | s.spawn(move || { |
| 294 | 0× | for (h, b) in part { |
| 295 | 0× | if let Err(e) = send(h, b) { |
| 296 | 0× | *failed.lock().unwrap() = Some(e); |
| 297 | 0× | return; |
| 298 | 0× | } |
| 299 | | } |
| 300 | 0× | }); |
| 301 | | } |
| 302 | 0× | }); |
| 303 | 0× | } |
| 304 | |
|
| 305 | |
|
| 306 | 13× | pub fn chunk_cache(home: &Home) -> PathBuf { |
| 307 | 13× | home.root.join("chunks") |
| 308 | 13× | } |
| 309 | |
|
| 310 | 826× | pub(crate) fn cache_path(cache: &Path, hash: &str) -> PathBuf { |
| 311 | 826× | cache.join(&hash[..2]).join(hash) |
| 312 | 826× | } |
| 313 | |
|
| 314 | |
|
| 315 | 38× | pub fn human(bytes: u64) -> String { |
| 316 | 38× | if bytes >= 1 << 30 { |
| 317 | 0× | format!("{:.1} GiB", bytes as f64 / (1u64 << 30) as f64) |
| 318 | | } else { |
| 319 | 38× | format!("{} MiB", bytes >> 20) |
| 320 | | } |
| 321 | 38× | } |