Cylinder C1, first part: artifacts as trees of the repository's chunks, oxy artifact pack/list/show/unpack/verify, kept by gc (refs #108) 642da2636de2
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 609cfbd8113c
- When
- · 2026-10-28 22:09 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
642da2636de23fd2aee00ace74e1ec4abace8241fb73d3f30519be0fdc1d4c8c- Depends on
- 135212de5370 2b5ee371060e 4dee277c327a 64dc5197f9df 6a81dbab471d 7f617ee9b3ad 8d80356371c5 8fd78eb52ad2 982864068a59 9ae4d15a0e4b b582948f1912 c6aa5bd9d1c8 d0016e229b04 e4595038502a e59512f2f075 e993a086c13f fc1a466a1e66 fce44ae9ad29 ff0af1ee299d ff1f62fee654
- Depended on by
- 835ceb9b3ea7 135bdcadec4a ed931d1d1102 6c3dbe2018e0 0588de69a84d 569802ebae0e
- In bookmarks
- main
- In tags
- v0.10.0 v0.11.0 v0.9.0
- Edits
- 18 atoms, 5 tree operations, 11 files
Browse the tree at this change
added crates/cylinder/Cargo.toml +14 −0
| 1 | + | [package] |
|
| 2 | + | name = "cylinder" |
|
| 3 | + | version.workspace = true |
|
| 4 | + | edition.workspace = true |
|
| 5 | + | license.workspace = true |
|
| 6 | + | description = "Cylinder: artifacts as trees of Oxygen chunks (docs/cylinder.md)" |
|
| 7 | + | ||
| 8 | + | [dependencies] |
|
| 9 | + | oxy-core = { path = "../oxy-core" } |
|
| 10 | + | blake3 = "1" |
|
| 11 | + | thiserror = "1" |
|
| 12 | + | ||
| 13 | + | [dev-dependencies] |
|
| 14 | + | tempfile = "3" |
added crates/cylinder/src/lib.rs +729 −0
| 1 | + | //! Cylinder, step C1 (docs/cylinder.md §4.1): an **artifact** is an |
|
| 2 | + | //! immutable, content-addressed object naming a tree of files, each kept as |
|
| 3 | + | //! an ordinary Oxygen blob of FastCDC BLAKE3 chunks. An image rebuilt with |
|
| 4 | + | //! one changed file moves only that file's changed chunks, and a binary in |
|
| 5 | + | //! a release archive and in a runner image is the same chunks. |
|
| 6 | + | //! |
|
| 7 | + | //! An artifact is its canonical CBOR (spec §5.1), named by the BLAKE3 of |
|
| 8 | + | //! that encoding. Until the artifact is an object type of its own (the rest |
|
| 9 | + | //! of C1), the encoding and its tree are kept as blobs in the repository's |
|
| 10 | + | //! store, and `.oxy/artifacts/NAME` holds the encoding's blob so the |
|
| 11 | + | //! artifact can be found and garbage collection keeps it ([`roots`]). |
|
| 12 | + | ||
| 13 | + | use oxy_core::cbor::{self, Value}; |
|
| 14 | + | use oxy_core::hash::ObjectRef; |
|
| 15 | + | use oxy_core::repo::Repo; |
|
| 16 | + | use std::collections::BTreeMap; |
|
| 17 | + | use std::path::{Path, PathBuf}; |
|
| 18 | + | ||
| 19 | + | /// The encoding's version this crate writes and reads. |
|
| 20 | + | pub const VERSION: u64 = 1; |
|
| 21 | + | ||
| 22 | + | #[derive(Debug, thiserror::Error)] |
|
| 23 | + | pub enum Error { |
|
| 24 | + | #[error(transparent)] |
|
| 25 | + | Repo(#[from] oxy_core::repo::RepoError), |
|
| 26 | + | #[error("io: {0}")] |
|
| 27 | + | Io(#[from] std::io::Error), |
|
| 28 | + | #[error("not a canonical artifact encoding: {0}")] |
|
| 29 | + | Cbor(#[from] oxy_core::cbor::CborError), |
|
| 30 | + | #[error("{0}")] |
|
| 31 | + | Bad(String), |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | pub type Result<T> = std::result::Result<T, Error>; |
|
| 35 | + | ||
| 36 | + | fn bad<T>(msg: impl Into<String>) -> Result<T> { |
|
| 37 | + | Err(Error::Bad(msg.into())) |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// What an artifact is for. |
|
| 41 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 42 | + | pub enum Kind { |
|
| 43 | + | Container, |
|
| 44 | + | RunnerImage, |
|
| 45 | + | Package, |
|
| 46 | + | Archive, |
|
| 47 | + | Report, |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | impl Kind { |
|
| 51 | + | pub const ALL: [Kind; 5] = [ |
|
| 52 | + | Kind::Container, |
|
| 53 | + | Kind::RunnerImage, |
|
| 54 | + | Kind::Package, |
|
| 55 | + | Kind::Archive, |
|
| 56 | + | Kind::Report, |
|
| 57 | + | ]; |
|
| 58 | + | ||
| 59 | + | pub fn as_str(self) -> &'static str { |
|
| 60 | + | match self { |
|
| 61 | + | Kind::Container => "container", |
|
| 62 | + | Kind::RunnerImage => "runner-image", |
|
| 63 | + | Kind::Package => "package", |
|
| 64 | + | Kind::Archive => "archive", |
|
| 65 | + | Kind::Report => "report", |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | pub fn parse(s: &str) -> Result<Kind> { |
|
| 70 | + | Kind::ALL.into_iter().find(|k| k.as_str() == s).map_or_else( |
|
| 71 | + | || { |
|
| 72 | + | bad(format!( |
|
| 73 | + | "{s:?} is not a kind of artifact: {}", |
|
| 74 | + | Kind::ALL.map(|k| k.as_str()).join(", ") |
|
| 75 | + | )) |
|
| 76 | + | }, |
|
| 77 | + | Ok, |
|
| 78 | + | ) |
|
| 79 | + | } |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// A file's mode in a tree: a regular file, an executable one, or a |
|
| 83 | + | /// symbolic link (whose blob is its target). |
|
| 84 | + | pub const MODE_FILE: u32 = 0o100644; |
|
| 85 | + | pub const MODE_EXEC: u32 = 0o100755; |
|
| 86 | + | pub const MODE_LINK: u32 = 0o120000; |
|
| 87 | + | ||
| 88 | + | /// A file of an artifact's tree. |
|
| 89 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 90 | + | pub struct Entry { |
|
| 91 | + | /// `/`-separated, relative, never `.` or `..` parts. |
|
| 92 | + | pub path: String, |
|
| 93 | + | pub mode: u32, |
|
| 94 | + | pub blob: ObjectRef, |
|
| 95 | + | pub size: u64, |
|
| 96 | + | } |
|
| 97 | + | ||
| 98 | + | /// An artifact's files, in path order: paths, modes, blobs and sizes. |
|
| 99 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 100 | + | pub struct DirTree { |
|
| 101 | + | pub entries: Vec<Entry>, |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | fn path_ok(p: &str) -> bool { |
|
| 105 | + | !p.is_empty() |
|
| 106 | + | && !p.starts_with('/') |
|
| 107 | + | && p.split('/').all(|s| !s.is_empty() && s != "." && s != "..") |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | fn obj(r: &ObjectRef) -> Value { |
|
| 111 | + | Value::Bytes(r.0.to_vec()) |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | fn get_obj(v: &Value, what: &str) -> Result<ObjectRef> { |
|
| 115 | + | match v.as_bytes() { |
|
| 116 | + | Some(b) if b.len() == 32 => { |
|
| 117 | + | let mut a = [0u8; 32]; |
|
| 118 | + | a.copy_from_slice(b); |
|
| 119 | + | Ok(ObjectRef(a)) |
|
| 120 | + | } |
|
| 121 | + | _ => bad(format!("{what} is not an object reference")), |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | fn get_text(v: &Value, what: &str) -> Result<String> { |
|
| 126 | + | v.as_text() |
|
| 127 | + | .map(str::to_string) |
|
| 128 | + | .map_or_else(|| bad(format!("{what} is not text")), Ok) |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | fn get_uint(v: &Value, what: &str) -> Result<u64> { |
|
| 132 | + | v.as_uint() |
|
| 133 | + | .map_or_else(|| bad(format!("{what} is not a number")), Ok) |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | impl DirTree { |
|
| 137 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 138 | + | let entries = self |
|
| 139 | + | .entries |
|
| 140 | + | .iter() |
|
| 141 | + | .map(|e| { |
|
| 142 | + | Value::Array(vec![ |
|
| 143 | + | Value::Text(e.path.clone()), |
|
| 144 | + | Value::Uint(e.mode as u64), |
|
| 145 | + | obj(&e.blob), |
|
| 146 | + | Value::Uint(e.size), |
|
| 147 | + | ]) |
|
| 148 | + | }) |
|
| 149 | + | .collect(); |
|
| 150 | + | cbor::encode(&Value::map([ |
|
| 151 | + | (0, Value::Uint(VERSION)), |
|
| 152 | + | (1, Value::Array(entries)), |
|
| 153 | + | ])) |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /// Read and check a tree: a known version, clean paths, each once, in |
|
| 157 | + | /// order, with a known mode. |
|
| 158 | + | pub fn decode(bytes: &[u8]) -> Result<DirTree> { |
|
| 159 | + | let v = cbor::decode(bytes)?; |
|
| 160 | + | let m = v.as_map().map_or_else(|| bad("a tree is a map"), Ok)?; |
|
| 161 | + | if m.get(&0).and_then(Value::as_uint) != Some(VERSION) { |
|
| 162 | + | return bad(format!( |
|
| 163 | + | "tree version {:?} is not known; this reads {VERSION}", |
|
| 164 | + | m.get(&0) |
|
| 165 | + | )); |
|
| 166 | + | } |
|
| 167 | + | let mut entries = Vec::new(); |
|
| 168 | + | for item in m.get(&1).and_then(Value::as_array).unwrap_or_default() { |
|
| 169 | + | let [p, mode, blob, size] = item.as_array().unwrap_or_default() else { |
|
| 170 | + | return bad("a tree entry is [path, mode, blob, size]"); |
|
| 171 | + | }; |
|
| 172 | + | let path = get_text(p, "a path")?; |
|
| 173 | + | let mode = get_uint(mode, "a mode")? as u32; |
|
| 174 | + | if !path_ok(&path) { |
|
| 175 | + | return bad(format!("{path:?} is not a clean relative path")); |
|
| 176 | + | } |
|
| 177 | + | if ![MODE_FILE, MODE_EXEC, MODE_LINK].contains(&mode) { |
|
| 178 | + | return bad(format!( |
|
| 179 | + | "{path}: mode {mode:o} is not a file, an executable or a link" |
|
| 180 | + | )); |
|
| 181 | + | } |
|
| 182 | + | if entries.last().is_some_and(|l: &Entry| l.path >= path) { |
|
| 183 | + | return bad(format!( |
|
| 184 | + | "{path}: entries are not in path order, or one is there twice" |
|
| 185 | + | )); |
|
| 186 | + | } |
|
| 187 | + | entries.push(Entry { |
|
| 188 | + | path, |
|
| 189 | + | mode, |
|
| 190 | + | blob: get_obj(blob, "a blob")?, |
|
| 191 | + | size: get_uint(size, "a size")?, |
|
| 192 | + | }); |
|
| 193 | + | } |
|
| 194 | + | Ok(DirTree { entries }) |
|
| 195 | + | } |
|
| 196 | + | } |
|
| 197 | + | ||
| 198 | + | /// An artifact (docs/cylinder.md §4.1). |
|
| 199 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 200 | + | pub struct Artifact { |
|
| 201 | + | pub kind: Kind, |
|
| 202 | + | /// `x86_64`, `riscv64`, `aarch64`. |
|
| 203 | + | pub arch: Option<String>, |
|
| 204 | + | /// `linux`, `darwin`, `none`. |
|
| 205 | + | pub os: Option<String>, |
|
| 206 | + | /// The [`DirTree`]'s blob. |
|
| 207 | + | pub tree: ObjectRef, |
|
| 208 | + | /// The default command, for a container or an image. |
|
| 209 | + | pub entrypoint: Option<Vec<String>>, |
|
| 210 | + | pub env: BTreeMap<String, String>, |
|
| 211 | + | pub labels: BTreeMap<String, String>, |
|
| 212 | + | /// The files' bytes together. |
|
| 213 | + | pub total_size: u64, |
|
| 214 | + | /// The receipt of the Flow job that built it, when one did. |
|
| 215 | + | pub receipt: Option<ObjectRef>, |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | fn pairs(m: &BTreeMap<String, String>) -> Value { |
|
| 219 | + | if m.is_empty() { |
|
| 220 | + | return Value::Null; |
|
| 221 | + | } |
|
| 222 | + | Value::Array( |
|
| 223 | + | m.iter() |
|
| 224 | + | .map(|(k, v)| Value::Array(vec![Value::Text(k.clone()), Value::Text(v.clone())])) |
|
| 225 | + | .collect(), |
|
| 226 | + | ) |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | fn get_pairs(v: Option<&Value>, what: &str) -> Result<BTreeMap<String, String>> { |
|
| 230 | + | let mut out = BTreeMap::new(); |
|
| 231 | + | let Some(v) = v.filter(|v| !v.is_null()) else { |
|
| 232 | + | return Ok(out); |
|
| 233 | + | }; |
|
| 234 | + | for p in v |
|
| 235 | + | .as_array() |
|
| 236 | + | .map_or_else(|| bad(format!("{what} is a list of pairs")), Ok)? |
|
| 237 | + | { |
|
| 238 | + | let [k, val] = p.as_array().unwrap_or_default() else { |
|
| 239 | + | return bad(format!("{what}: a pair is [key, value]")); |
|
| 240 | + | }; |
|
| 241 | + | let k = get_text(k, what)?; |
|
| 242 | + | if out.insert(k.clone(), get_text(val, what)?).is_some() { |
|
| 243 | + | return bad(format!("{what}: {k} is there twice")); |
|
| 244 | + | } |
|
| 245 | + | } |
|
| 246 | + | Ok(out) |
|
| 247 | + | } |
|
| 248 | + | ||
| 249 | + | fn opt_text(v: &Option<String>) -> Value { |
|
| 250 | + | v.as_ref().map_or(Value::Null, |s| Value::Text(s.clone())) |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | impl Artifact { |
|
| 254 | + | /// Its canonical CBOR, keys as in docs/cylinder.md §4.1. |
|
| 255 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 256 | + | let entry = self.entrypoint.as_ref().map_or(Value::Null, |a| { |
|
| 257 | + | Value::Array(a.iter().cloned().map(Value::Text).collect()) |
|
| 258 | + | }); |
|
| 259 | + | cbor::encode(&Value::map([ |
|
| 260 | + | (0, Value::Uint(VERSION)), |
|
| 261 | + | (1, Value::Text(self.kind.as_str().into())), |
|
| 262 | + | (2, opt_text(&self.arch)), |
|
| 263 | + | (3, opt_text(&self.os)), |
|
| 264 | + | (4, obj(&self.tree)), |
|
| 265 | + | (5, entry), |
|
| 266 | + | (6, pairs(&self.env)), |
|
| 267 | + | (7, pairs(&self.labels)), |
|
| 268 | + | (8, Value::Uint(self.total_size)), |
|
| 269 | + | (9, self.receipt.as_ref().map_or(Value::Null, obj)), |
|
| 270 | + | ])) |
|
| 271 | + | } |
|
| 272 | + | ||
| 273 | + | pub fn decode(bytes: &[u8]) -> Result<Artifact> { |
|
| 274 | + | let v = cbor::decode(bytes)?; |
|
| 275 | + | let m = v.as_map().map_or_else(|| bad("an artifact is a map"), Ok)?; |
|
| 276 | + | if m.get(&0).and_then(Value::as_uint) != Some(VERSION) { |
|
| 277 | + | return bad(format!( |
|
| 278 | + | "artifact version {:?} is not known; this reads {VERSION}", |
|
| 279 | + | m.get(&0) |
|
| 280 | + | )); |
|
| 281 | + | } |
|
| 282 | + | if let Some(k) = m.keys().find(|k| **k > 9) { |
|
| 283 | + | return bad(format!("key {k} is not part of an artifact")); |
|
| 284 | + | } |
|
| 285 | + | let text = |k: u64, what: &str| -> Result<Option<String>> { |
|
| 286 | + | match m.get(&k) { |
|
| 287 | + | None | Some(Value::Null) => Ok(None), |
|
| 288 | + | Some(v) => get_text(v, what).map(Some), |
|
| 289 | + | } |
|
| 290 | + | }; |
|
| 291 | + | let entrypoint = match m.get(&5) { |
|
| 292 | + | None | Some(Value::Null) => None, |
|
| 293 | + | Some(v) => Some( |
|
| 294 | + | v.as_array() |
|
| 295 | + | .unwrap_or_default() |
|
| 296 | + | .iter() |
|
| 297 | + | .map(|a| get_text(a, "the entrypoint")) |
|
| 298 | + | .collect::<Result<_>>()?, |
|
| 299 | + | ), |
|
| 300 | + | }; |
|
| 301 | + | let receipt = match m.get(&9) { |
|
| 302 | + | None | Some(Value::Null) => None, |
|
| 303 | + | Some(v) => Some(get_obj(v, "the receipt")?), |
|
| 304 | + | }; |
|
| 305 | + | Ok(Artifact { |
|
| 306 | + | kind: Kind::parse(&text(1, "the kind")?.unwrap_or_default())?, |
|
| 307 | + | arch: text(2, "the architecture")?, |
|
| 308 | + | os: text(3, "the system")?, |
|
| 309 | + | tree: get_obj(m.get(&4).unwrap_or(&Value::Null), "the tree")?, |
|
| 310 | + | entrypoint, |
|
| 311 | + | env: get_pairs(m.get(&6), "env")?, |
|
| 312 | + | labels: get_pairs(m.get(&7), "labels")?, |
|
| 313 | + | total_size: get_uint(m.get(&8).unwrap_or(&Value::Null), "the size")?, |
|
| 314 | + | receipt, |
|
| 315 | + | }) |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | /// Its name: the BLAKE3 of its canonical encoding, hex. |
|
| 319 | + | pub fn name(&self) -> String { |
|
| 320 | + | blake3::hash(&self.encode()).to_hex().to_string() |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | /// What to pack a directory as. |
|
| 325 | + | #[derive(Clone, Debug)] |
|
| 326 | + | pub struct Spec { |
|
| 327 | + | pub kind: Kind, |
|
| 328 | + | pub arch: Option<String>, |
|
| 329 | + | pub os: Option<String>, |
|
| 330 | + | pub entrypoint: Option<Vec<String>>, |
|
| 331 | + | pub env: BTreeMap<String, String>, |
|
| 332 | + | pub labels: BTreeMap<String, String>, |
|
| 333 | + | } |
|
| 334 | + | ||
| 335 | + | impl Spec { |
|
| 336 | + | pub fn new(kind: Kind) -> Spec { |
|
| 337 | + | Spec { |
|
| 338 | + | kind, |
|
| 339 | + | arch: None, |
|
| 340 | + | os: None, |
|
| 341 | + | entrypoint: None, |
|
| 342 | + | env: BTreeMap::new(), |
|
| 343 | + | labels: BTreeMap::new(), |
|
| 344 | + | } |
|
| 345 | + | } |
|
| 346 | + | } |
|
| 347 | + | ||
| 348 | + | /// An artifact kept in a repository, with its name. |
|
| 349 | + | #[derive(Clone, Debug)] |
|
| 350 | + | pub struct Kept { |
|
| 351 | + | pub name: String, |
|
| 352 | + | pub artifact: Artifact, |
|
| 353 | + | /// The blob its encoding is kept in. |
|
| 354 | + | pub blob: ObjectRef, |
|
| 355 | + | } |
|
| 356 | + | ||
| 357 | + | fn kept_dir(repo: &Repo) -> PathBuf { |
|
| 358 | + | repo.oxy_dir().join("artifacts") |
|
| 359 | + | } |
|
| 360 | + | ||
| 361 | + | /// Every file under `dir`, by relative path: regular files and links, in |
|
| 362 | + | /// path order. `.oxy` and `.git` directories are not part of an artifact. |
|
| 363 | + | fn walk(dir: &Path) -> Result<Vec<(String, PathBuf, std::fs::Metadata)>> { |
|
| 364 | + | let mut out = Vec::new(); |
|
| 365 | + | let mut stack = vec![(dir.to_path_buf(), String::new())]; |
|
| 366 | + | while let Some((d, rel)) = stack.pop() { |
|
| 367 | + | for e in std::fs::read_dir(&d)? { |
|
| 368 | + | let e = e?; |
|
| 369 | + | let name = e |
|
| 370 | + | .file_name() |
|
| 371 | + | .into_string() |
|
| 372 | + | .map_err(|n| Error::Bad(format!("{n:?} is not UTF-8")))?; |
|
| 373 | + | let path = if rel.is_empty() { |
|
| 374 | + | name.clone() |
|
| 375 | + | } else { |
|
| 376 | + | format!("{rel}/{name}") |
|
| 377 | + | }; |
|
| 378 | + | let md = std::fs::symlink_metadata(e.path())?; |
|
| 379 | + | if md.is_dir() { |
|
| 380 | + | if name != ".oxy" && name != ".git" { |
|
| 381 | + | stack.push((e.path(), path)); |
|
| 382 | + | } |
|
| 383 | + | } else if md.is_file() || md.file_type().is_symlink() { |
|
| 384 | + | out.push((path, e.path(), md)); |
|
| 385 | + | } |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 388 | + | out.sort_by(|a, b| a.0.cmp(&b.0)); |
|
| 389 | + | Ok(out) |
|
| 390 | + | } |
|
| 391 | + | ||
| 392 | + | #[cfg(unix)] |
|
| 393 | + | fn mode_of(md: &std::fs::Metadata) -> u32 { |
|
| 394 | + | use std::os::unix::fs::PermissionsExt; |
|
| 395 | + | if md.file_type().is_symlink() { |
|
| 396 | + | MODE_LINK |
|
| 397 | + | } else if md.permissions().mode() & 0o111 != 0 { |
|
| 398 | + | MODE_EXEC |
|
| 399 | + | } else { |
|
| 400 | + | MODE_FILE |
|
| 401 | + | } |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | #[cfg(not(unix))] |
|
| 405 | + | fn mode_of(md: &std::fs::Metadata) -> u32 { |
|
| 406 | + | if md.file_type().is_symlink() { |
|
| 407 | + | MODE_LINK |
|
| 408 | + | } else { |
|
| 409 | + | MODE_FILE |
|
| 410 | + | } |
|
| 411 | + | } |
|
| 412 | + | ||
| 413 | + | /// Pack the files under `dir` into an artifact of `spec`'s kind in |
|
| 414 | + | /// `repo`'s store: each file a blob, so files and chunks the store holds |
|
| 415 | + | /// already cost nothing; then its tree and its encoding, kept under its |
|
| 416 | + | /// name. |
|
| 417 | + | pub fn pack(repo: &Repo, dir: &Path, spec: &Spec) -> Result<Kept> { |
|
| 418 | + | if !dir.is_dir() { |
|
| 419 | + | return bad(format!("{} is not a directory", dir.display())); |
|
| 420 | + | } |
|
| 421 | + | let mut tree = DirTree::default(); |
|
| 422 | + | for (path, full, md) in walk(dir)? { |
|
| 423 | + | let mode = mode_of(&md); |
|
| 424 | + | let bytes = if mode == MODE_LINK { |
|
| 425 | + | std::fs::read_link(&full)? |
|
| 426 | + | .to_string_lossy() |
|
| 427 | + | .into_owned() |
|
| 428 | + | .into_bytes() |
|
| 429 | + | } else { |
|
| 430 | + | std::fs::read(&full)? |
|
| 431 | + | }; |
|
| 432 | + | let blob = repo.store_blob(&bytes)?; |
|
| 433 | + | tree.entries.push(Entry { |
|
| 434 | + | path, |
|
| 435 | + | mode, |
|
| 436 | + | blob, |
|
| 437 | + | size: bytes.len() as u64, |
|
| 438 | + | }); |
|
| 439 | + | } |
|
| 440 | + | let artifact = Artifact { |
|
| 441 | + | kind: spec.kind, |
|
| 442 | + | arch: spec.arch.clone(), |
|
| 443 | + | os: spec.os.clone(), |
|
| 444 | + | tree: repo.store_blob(&tree.encode())?, |
|
| 445 | + | entrypoint: spec.entrypoint.clone(), |
|
| 446 | + | env: spec.env.clone(), |
|
| 447 | + | labels: spec.labels.clone(), |
|
| 448 | + | total_size: tree.entries.iter().map(|e| e.size).sum(), |
|
| 449 | + | receipt: None, |
|
| 450 | + | }; |
|
| 451 | + | let bytes = artifact.encode(); |
|
| 452 | + | let blob = repo.store_blob(&bytes)?; |
|
| 453 | + | let name = blake3::hash(&bytes).to_hex().to_string(); |
|
| 454 | + | std::fs::create_dir_all(kept_dir(repo))?; |
|
| 455 | + | std::fs::write(kept_dir(repo).join(&name), blob.to_hex())?; |
|
| 456 | + | Ok(Kept { |
|
| 457 | + | name, |
|
| 458 | + | artifact, |
|
| 459 | + | blob, |
|
| 460 | + | }) |
|
| 461 | + | } |
|
| 462 | + | ||
| 463 | + | /// The names of the artifacts kept in `repo`, sorted. |
|
| 464 | + | pub fn list(repo: &Repo) -> Result<Vec<String>> { |
|
| 465 | + | let mut out = Vec::new(); |
|
| 466 | + | match std::fs::read_dir(kept_dir(repo)) { |
|
| 467 | + | Ok(rd) => { |
|
| 468 | + | for e in rd { |
|
| 469 | + | let n = e?.file_name().to_string_lossy().into_owned(); |
|
| 470 | + | if n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()) { |
|
| 471 | + | out.push(n); |
|
| 472 | + | } |
|
| 473 | + | } |
|
| 474 | + | } |
|
| 475 | + | Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} |
|
| 476 | + | Err(e) => return Err(e.into()), |
|
| 477 | + | } |
|
| 478 | + | out.sort(); |
|
| 479 | + | Ok(out) |
|
| 480 | + | } |
|
| 481 | + | ||
| 482 | + | /// The kept artifact `name` names, or a unique prefix of it (at least six |
|
| 483 | + | /// characters), checked against its name. |
|
| 484 | + | pub fn open(repo: &Repo, name: &str) -> Result<Kept> { |
|
| 485 | + | let name = name.to_ascii_lowercase(); |
|
| 486 | + | if name.len() < 6 { |
|
| 487 | + | return bad("name an artifact by at least six characters of its name"); |
|
| 488 | + | } |
|
| 489 | + | let found: Vec<String> = list(repo)? |
|
| 490 | + | .into_iter() |
|
| 491 | + | .filter(|n| n.starts_with(&name)) |
|
| 492 | + | .collect(); |
|
| 493 | + | let full = match found.as_slice() { |
|
| 494 | + | [one] => one.clone(), |
|
| 495 | + | [] => { |
|
| 496 | + | return bad(format!( |
|
| 497 | + | "no artifact {name} here; `oxy artifact list` shows them" |
|
| 498 | + | )) |
|
| 499 | + | } |
|
| 500 | + | _ => { |
|
| 501 | + | return bad(format!( |
|
| 502 | + | "{name} names {} artifacts: give more of the name", |
|
| 503 | + | found.len() |
|
| 504 | + | )) |
|
| 505 | + | } |
|
| 506 | + | }; |
|
| 507 | + | let hex = std::fs::read_to_string(kept_dir(repo).join(&full))?; |
|
| 508 | + | let blob = ObjectRef::from_hex(hex.trim()).map_or_else( |
|
| 509 | + | || bad(format!("artifact {full}: its record is damaged")), |
|
| 510 | + | Ok, |
|
| 511 | + | )?; |
|
| 512 | + | let bytes = repo.read_blob(&blob)?; |
|
| 513 | + | if blake3::hash(&bytes).to_hex().as_str() != full { |
|
| 514 | + | return bad(format!( |
|
| 515 | + | "artifact {full}: its encoding no longer hashes to its name" |
|
| 516 | + | )); |
|
| 517 | + | } |
|
| 518 | + | Ok(Kept { |
|
| 519 | + | name: full, |
|
| 520 | + | artifact: Artifact::decode(&bytes)?, |
|
| 521 | + | blob, |
|
| 522 | + | }) |
|
| 523 | + | } |
|
| 524 | + | ||
| 525 | + | /// An artifact's tree. |
|
| 526 | + | pub fn tree(repo: &Repo, a: &Artifact) -> Result<DirTree> { |
|
| 527 | + | DirTree::decode(&repo.read_blob(&a.tree)?) |
|
| 528 | + | } |
|
| 529 | + | ||
| 530 | + | /// Check that every file of `k` is in the store whole: its bytes read, at |
|
| 531 | + | /// the size its tree says. Answers how many files were read. |
|
| 532 | + | pub fn verify(repo: &Repo, k: &Kept) -> Result<usize> { |
|
| 533 | + | let t = tree(repo, &k.artifact)?; |
|
| 534 | + | let mut total = 0; |
|
| 535 | + | for e in &t.entries { |
|
| 536 | + | let n = repo.read_blob(&e.blob)?.len() as u64; |
|
| 537 | + | if n != e.size { |
|
| 538 | + | return bad(format!( |
|
| 539 | + | "{}: {n} bytes, but the tree says {}", |
|
| 540 | + | e.path, e.size |
|
| 541 | + | )); |
|
| 542 | + | } |
|
| 543 | + | total += n; |
|
| 544 | + | } |
|
| 545 | + | if total != k.artifact.total_size { |
|
| 546 | + | return bad(format!( |
|
| 547 | + | "the files are {total} bytes together, but the artifact says {}", |
|
| 548 | + | k.artifact.total_size |
|
| 549 | + | )); |
|
| 550 | + | } |
|
| 551 | + | Ok(t.entries.len()) |
|
| 552 | + | } |
|
| 553 | + | ||
| 554 | + | /// Write `k`'s files under `dest`, which must not exist or be empty. |
|
| 555 | + | pub fn unpack(repo: &Repo, k: &Kept, dest: &Path) -> Result<usize> { |
|
| 556 | + | if dest.exists() && std::fs::read_dir(dest)?.next().is_some() { |
|
| 557 | + | return bad(format!("{} is not empty", dest.display())); |
|
| 558 | + | } |
|
| 559 | + | let t = tree(repo, &k.artifact)?; |
|
| 560 | + | for e in &t.entries { |
|
| 561 | + | let out = dest.join(&e.path); |
|
| 562 | + | if let Some(p) = out.parent() { |
|
| 563 | + | std::fs::create_dir_all(p)?; |
|
| 564 | + | } |
|
| 565 | + | let bytes = repo.read_blob(&e.blob)?; |
|
| 566 | + | if e.mode == MODE_LINK { |
|
| 567 | + | #[cfg(unix)] |
|
| 568 | + | std::os::unix::fs::symlink(String::from_utf8_lossy(&bytes).as_ref(), &out)?; |
|
| 569 | + | #[cfg(not(unix))] |
|
| 570 | + | std::fs::write(&out, &bytes)?; |
|
| 571 | + | continue; |
|
| 572 | + | } |
|
| 573 | + | std::fs::write(&out, &bytes)?; |
|
| 574 | + | #[cfg(unix)] |
|
| 575 | + | if e.mode == MODE_EXEC { |
|
| 576 | + | use std::os::unix::fs::PermissionsExt; |
|
| 577 | + | std::fs::set_permissions(&out, std::fs::Permissions::from_mode(0o755))?; |
|
| 578 | + | } |
|
| 579 | + | } |
|
| 580 | + | Ok(t.entries.len()) |
|
| 581 | + | } |
|
| 582 | + | ||
| 583 | + | /// The blobs garbage collection must keep for the kept artifacts: each |
|
| 584 | + | /// one's encoding, its tree and its files (`Repo::gc_keeping`). |
|
| 585 | + | pub fn roots(repo: &Repo) -> Result<Vec<ObjectRef>> { |
|
| 586 | + | let mut out = Vec::new(); |
|
| 587 | + | for name in list(repo)? { |
|
| 588 | + | let k = open(repo, &name)?; |
|
| 589 | + | out.push(k.blob); |
|
| 590 | + | out.push(k.artifact.tree); |
|
| 591 | + | out.extend(tree(repo, &k.artifact)?.entries.iter().map(|e| e.blob)); |
|
| 592 | + | } |
|
| 593 | + | Ok(out) |
|
| 594 | + | } |
|
| 595 | + | ||
| 596 | + | #[cfg(test)] |
|
| 597 | + | mod tests { |
|
| 598 | + | use super::*; |
|
| 599 | + | ||
| 600 | + | fn repo(dir: &Path) -> Repo { |
|
| 601 | + | Repo::init(&dir.join("repo")).unwrap() |
|
| 602 | + | } |
|
| 603 | + | ||
| 604 | + | #[test] |
|
| 605 | + | fn a_directory_packs_into_an_artifact_and_back() { |
|
| 606 | + | let t = tempfile::tempdir().unwrap(); |
|
| 607 | + | let r = repo(t.path()); |
|
| 608 | + | let src = t.path().join("out"); |
|
| 609 | + | std::fs::create_dir_all(src.join("bin")).unwrap(); |
|
| 610 | + | std::fs::write(src.join("README"), b"hello\n").unwrap(); |
|
| 611 | + | std::fs::write(src.join("bin/tool"), vec![7u8; 300_000]).unwrap(); |
|
| 612 | + | #[cfg(unix)] |
|
| 613 | + | { |
|
| 614 | + | use std::os::unix::fs::PermissionsExt; |
|
| 615 | + | std::fs::set_permissions(src.join("bin/tool"), std::fs::Permissions::from_mode(0o755)) |
|
| 616 | + | .unwrap(); |
|
| 617 | + | std::os::unix::fs::symlink("bin/tool", src.join("tool")).unwrap(); |
|
| 618 | + | } |
|
| 619 | + | let mut spec = Spec::new(Kind::Archive); |
|
| 620 | + | spec.arch = Some("x86_64".into()); |
|
| 621 | + | spec.labels.insert("version".into(), "0.1.0".into()); |
|
| 622 | + | let k = pack(&r, &src, &spec).unwrap(); |
|
| 623 | + | assert_eq!(k.name, k.artifact.name()); |
|
| 624 | + | assert_eq!( |
|
| 625 | + | k.artifact.total_size, |
|
| 626 | + | 300_006 + if cfg!(unix) { 8 } else { 0 } |
|
| 627 | + | ); |
|
| 628 | + | assert_eq!(list(&r).unwrap(), vec![k.name.clone()]); |
|
| 629 | + | ||
| 630 | + | let again = open(&r, &k.name[..10]).unwrap(); |
|
| 631 | + | assert_eq!(again.artifact, k.artifact); |
|
| 632 | + | let paths: Vec<(String, u32)> = tree(&r, &again.artifact) |
|
| 633 | + | .unwrap() |
|
| 634 | + | .entries |
|
| 635 | + | .into_iter() |
|
| 636 | + | .map(|e| (e.path, e.mode)) |
|
| 637 | + | .collect(); |
|
| 638 | + | assert_eq!(paths[0], ("README".into(), MODE_FILE)); |
|
| 639 | + | assert_eq!( |
|
| 640 | + | paths[1], |
|
| 641 | + | ( |
|
| 642 | + | "bin/tool".into(), |
|
| 643 | + | if cfg!(unix) { MODE_EXEC } else { MODE_FILE } |
|
| 644 | + | ) |
|
| 645 | + | ); |
|
| 646 | + | assert_eq!(verify(&r, &again).unwrap(), paths.len()); |
|
| 647 | + | ||
| 648 | + | let dest = t.path().join("back"); |
|
| 649 | + | unpack(&r, &again, &dest).unwrap(); |
|
| 650 | + | assert_eq!( |
|
| 651 | + | std::fs::read(dest.join("bin/tool")).unwrap(), |
|
| 652 | + | vec![7u8; 300_000] |
|
| 653 | + | ); |
|
| 654 | + | #[cfg(unix)] |
|
| 655 | + | assert_eq!( |
|
| 656 | + | std::fs::read_link(dest.join("tool")).unwrap(), |
|
| 657 | + | Path::new("bin/tool") |
|
| 658 | + | ); |
|
| 659 | + | assert!(unpack(&r, &again, &dest) |
|
| 660 | + | .unwrap_err() |
|
| 661 | + | .to_string() |
|
| 662 | + | .contains("not empty")); |
|
| 663 | + | ||
| 664 | + | // The same files pack to the same name; garbage collection keeps it. |
|
| 665 | + | assert_eq!(pack(&r, &src, &spec).unwrap().name, k.name); |
|
| 666 | + | let keep = roots(&r).unwrap(); |
|
| 667 | + | assert!(keep.contains(&k.blob) && keep.contains(&k.artifact.tree)); |
|
| 668 | + | r.gc_keeping(true, &keep, &mut |_| {}).unwrap(); |
|
| 669 | + | assert_eq!( |
|
| 670 | + | verify(&r, &open(&r, &k.name).unwrap()).unwrap(), |
|
| 671 | + | paths.len() |
|
| 672 | + | ); |
|
| 673 | + | } |
|
| 674 | + | ||
| 675 | + | #[test] |
|
| 676 | + | fn encodings_are_canonical_and_checked() { |
|
| 677 | + | let a = Artifact { |
|
| 678 | + | kind: Kind::RunnerImage, |
|
| 679 | + | arch: Some("riscv64".into()), |
|
| 680 | + | os: Some("linux".into()), |
|
| 681 | + | tree: ObjectRef([1; 32]), |
|
| 682 | + | entrypoint: Some(vec!["/sbin/init".into()]), |
|
| 683 | + | env: [("PATH".to_string(), "/bin".to_string())].into(), |
|
| 684 | + | labels: BTreeMap::new(), |
|
| 685 | + | total_size: 42, |
|
| 686 | + | receipt: Some(ObjectRef([2; 32])), |
|
| 687 | + | }; |
|
| 688 | + | let bytes = a.encode(); |
|
| 689 | + | assert_eq!(Artifact::decode(&bytes).unwrap(), a); |
|
| 690 | + | assert_eq!(a.name(), blake3::hash(&bytes).to_hex().to_string()); |
|
| 691 | + | assert!(Kind::parse("tarball") |
|
| 692 | + | .unwrap_err() |
|
| 693 | + | .to_string() |
|
| 694 | + | .contains("runner-image")); |
|
| 695 | + | ||
| 696 | + | let wrong = |v: Value| Artifact::decode(&cbor::encode(&v)).unwrap_err().to_string(); |
|
| 697 | + | assert!(wrong(Value::map([(0, Value::Uint(2))])).contains("version")); |
|
| 698 | + | assert!(wrong(Value::map([(0, Value::Uint(1)), (10, Value::Null)])).contains("key 10")); |
|
| 699 | + | ||
| 700 | + | let t = DirTree { |
|
| 701 | + | entries: vec![Entry { |
|
| 702 | + | path: "a".into(), |
|
| 703 | + | mode: MODE_FILE, |
|
| 704 | + | blob: ObjectRef([3; 32]), |
|
| 705 | + | size: 1, |
|
| 706 | + | }], |
|
| 707 | + | }; |
|
| 708 | + | assert_eq!(DirTree::decode(&t.encode()).unwrap(), t); |
|
| 709 | + | let entry = |p: &str, mode: u32| { |
|
| 710 | + | Value::Array(vec![ |
|
| 711 | + | Value::Text(p.into()), |
|
| 712 | + | Value::Uint(mode as u64), |
|
| 713 | + | Value::Bytes(vec![0; 32]), |
|
| 714 | + | Value::Uint(0), |
|
| 715 | + | ]) |
|
| 716 | + | }; |
|
| 717 | + | let tree = |items: Vec<Value>| { |
|
| 718 | + | DirTree::decode(&cbor::encode(&Value::map([ |
|
| 719 | + | (0, Value::Uint(1)), |
|
| 720 | + | (1, Value::Array(items)), |
|
| 721 | + | ]))) |
|
| 722 | + | .unwrap_err() |
|
| 723 | + | .to_string() |
|
| 724 | + | }; |
|
| 725 | + | assert!(tree(vec![entry("../x", MODE_FILE)]).contains("clean")); |
|
| 726 | + | assert!(tree(vec![entry("b", MODE_FILE), entry("a", MODE_FILE)]).contains("order")); |
|
| 727 | + | assert!(tree(vec![entry("a", 0o040000)]).contains("mode")); |
|
| 728 | + | } |
|
| 729 | + | } |
added src/cli/artifact.rs +180 −0
| 1 | + | //! `oxy artifact`: Cylinder's artifacts (docs/cylinder.md §4.1, step C1): |
|
| 2 | + | //! a directory packed into a tree of the repository's own chunks, named by |
|
| 3 | + | //! the BLAKE3 of its canonical encoding. |
|
| 4 | + | ||
| 5 | + | use super::json; |
|
| 6 | + | use super::open; |
|
| 7 | + | use super::ui; |
|
| 8 | + | use anyhow::Result; |
|
| 9 | + | use cylinder::{Kind, Spec}; |
|
| 10 | + | use std::path::Path; |
|
| 11 | + | ||
| 12 | + | /// `KEY=VALUE` pairs, refused when one has no `=`. |
|
| 13 | + | fn pairs(list: &[String], what: &str) -> Result<std::collections::BTreeMap<String, String>> { |
|
| 14 | + | list.iter() |
|
| 15 | + | .map(|p| { |
|
| 16 | + | p.split_once('=') |
|
| 17 | + | .map(|(k, v)| (k.to_string(), v.to_string())) |
|
| 18 | + | .ok_or_else(|| anyhow::anyhow!("{what} {p:?}: write it KEY=VALUE")) |
|
| 19 | + | }) |
|
| 20 | + | .collect() |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | fn summary(k: &cylinder::Kept) -> String { |
|
| 24 | + | let a = &k.artifact; |
|
| 25 | + | let mut parts = vec![a.kind.as_str().to_string()]; |
|
| 26 | + | parts.extend(a.os.clone()); |
|
| 27 | + | parts.extend(a.arch.clone()); |
|
| 28 | + | format!( |
|
| 29 | + | "{} {} {}", |
|
| 30 | + | ui::hash(&k.name[..12]), |
|
| 31 | + | parts.join(" "), |
|
| 32 | + | ui::dim(format!("{} bytes", a.total_size)) |
|
| 33 | + | ) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | fn as_json(k: &cylinder::Kept, files: usize) -> String { |
|
| 37 | + | let a = &k.artifact; |
|
| 38 | + | let text = |v: &Option<String>| v.as_deref().map(json::s).unwrap_or_else(|| "null".into()); |
|
| 39 | + | let map = |m: &std::collections::BTreeMap<String, String>| { |
|
| 40 | + | let inner: Vec<String> = m |
|
| 41 | + | .iter() |
|
| 42 | + | .map(|(k, v)| format!("{}:{}", json::s(k), json::s(v))) |
|
| 43 | + | .collect(); |
|
| 44 | + | format!("{{{}}}", inner.join(",")) |
|
| 45 | + | }; |
|
| 46 | + | json::obj([ |
|
| 47 | + | ("name", json::s(&k.name)), |
|
| 48 | + | ("kind", json::s(a.kind.as_str())), |
|
| 49 | + | ("arch", text(&a.arch)), |
|
| 50 | + | ("os", text(&a.os)), |
|
| 51 | + | ("tree", json::s(&a.tree.to_hex())), |
|
| 52 | + | ("files", json::n(files as u64)), |
|
| 53 | + | ("total_size", json::n(a.total_size)), |
|
| 54 | + | ("env", map(&a.env)), |
|
| 55 | + | ("labels", map(&a.labels)), |
|
| 56 | + | ]) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[allow(clippy::too_many_arguments)] |
|
| 60 | + | pub fn pack( |
|
| 61 | + | dir: &Path, |
|
| 62 | + | kind: &str, |
|
| 63 | + | arch: Option<String>, |
|
| 64 | + | os: Option<String>, |
|
| 65 | + | entrypoint: Vec<String>, |
|
| 66 | + | env: &[String], |
|
| 67 | + | labels: &[String], |
|
| 68 | + | as_json_out: bool, |
|
| 69 | + | ) -> Result<i32> { |
|
| 70 | + | let repo = open()?; |
|
| 71 | + | let mut spec = Spec::new(Kind::parse(kind)?); |
|
| 72 | + | spec.arch = arch; |
|
| 73 | + | spec.os = os; |
|
| 74 | + | spec.entrypoint = (!entrypoint.is_empty()).then_some(entrypoint); |
|
| 75 | + | spec.env = pairs(env, "env")?; |
|
| 76 | + | spec.labels = pairs(labels, "label")?; |
|
| 77 | + | let sp = ui::spinner("Packing…"); |
|
| 78 | + | let k = cylinder::pack(&repo, dir, &spec)?; |
|
| 79 | + | sp.clear(); |
|
| 80 | + | let files = cylinder::tree(&repo, &k.artifact)?.entries.len(); |
|
| 81 | + | if as_json_out { |
|
| 82 | + | println!("{}", as_json(&k, files)); |
|
| 83 | + | } else { |
|
| 84 | + | println!( |
|
| 85 | + | "{} {} as {}", |
|
| 86 | + | ui::done("Packed"), |
|
| 87 | + | ui::path(dir.display()), |
|
| 88 | + | summary(&k) |
|
| 89 | + | ); |
|
| 90 | + | println!("{}", ui::kv("name", &k.name)); |
|
| 91 | + | println!("{}", ui::kv("files", files.to_string())); |
|
| 92 | + | } |
|
| 93 | + | Ok(0) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | pub fn list(as_json_out: bool) -> Result<i32> { |
|
| 97 | + | let repo = open()?; |
|
| 98 | + | let names = cylinder::list(&repo)?; |
|
| 99 | + | let kept: Vec<cylinder::Kept> = names |
|
| 100 | + | .iter() |
|
| 101 | + | .map(|n| cylinder::open(&repo, n)) |
|
| 102 | + | .collect::<std::result::Result<_, _>>()?; |
|
| 103 | + | if as_json_out { |
|
| 104 | + | let items: Vec<String> = kept |
|
| 105 | + | .iter() |
|
| 106 | + | .map(|k| { |
|
| 107 | + | let files = cylinder::tree(&repo, &k.artifact) |
|
| 108 | + | .map(|t| t.entries.len()) |
|
| 109 | + | .unwrap_or(0); |
|
| 110 | + | as_json(k, files) |
|
| 111 | + | }) |
|
| 112 | + | .collect(); |
|
| 113 | + | println!("[{}]", items.join(",")); |
|
| 114 | + | return Ok(0); |
|
| 115 | + | } |
|
| 116 | + | if kept.is_empty() { |
|
| 117 | + | println!("No artifacts yet: oxy artifact pack DIR --kind archive"); |
|
| 118 | + | } |
|
| 119 | + | for k in &kept { |
|
| 120 | + | println!("{}", summary(k)); |
|
| 121 | + | } |
|
| 122 | + | Ok(0) |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | pub fn show(name: &str, as_json_out: bool) -> Result<i32> { |
|
| 126 | + | let repo = open()?; |
|
| 127 | + | let k = cylinder::open(&repo, name)?; |
|
| 128 | + | let t = cylinder::tree(&repo, &k.artifact)?; |
|
| 129 | + | if as_json_out { |
|
| 130 | + | println!("{}", as_json(&k, t.entries.len())); |
|
| 131 | + | return Ok(0); |
|
| 132 | + | } |
|
| 133 | + | println!("{}", summary(&k)); |
|
| 134 | + | println!("{}", ui::kv("name", &k.name)); |
|
| 135 | + | if let Some(e) = &k.artifact.entrypoint { |
|
| 136 | + | println!("{}", ui::kv("entrypoint", e.join(" "))); |
|
| 137 | + | } |
|
| 138 | + | for (key, v) in &k.artifact.labels { |
|
| 139 | + | println!("{}", ui::kv("label", format!("{key}={v}"))); |
|
| 140 | + | } |
|
| 141 | + | for (key, v) in &k.artifact.env { |
|
| 142 | + | println!("{}", ui::kv("env", format!("{key}={v}"))); |
|
| 143 | + | } |
|
| 144 | + | for e in &t.entries { |
|
| 145 | + | let mode = match e.mode { |
|
| 146 | + | cylinder::MODE_EXEC => "x", |
|
| 147 | + | cylinder::MODE_LINK => "l", |
|
| 148 | + | _ => " ", |
|
| 149 | + | }; |
|
| 150 | + | println!(" {mode} {:>10} {}", e.size, e.path); |
|
| 151 | + | } |
|
| 152 | + | Ok(0) |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | pub fn unpack(name: &str, dest: &Path) -> Result<i32> { |
|
| 156 | + | let repo = open()?; |
|
| 157 | + | let k = cylinder::open(&repo, name)?; |
|
| 158 | + | let n = cylinder::unpack(&repo, &k, dest)?; |
|
| 159 | + | println!( |
|
| 160 | + | "{} {} files of {} into {}", |
|
| 161 | + | ui::done("Unpacked"), |
|
| 162 | + | n, |
|
| 163 | + | ui::hash(&k.name[..12]), |
|
| 164 | + | ui::path(dest.display()) |
|
| 165 | + | ); |
|
| 166 | + | Ok(0) |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | pub fn verify(name: &str) -> Result<i32> { |
|
| 170 | + | let repo = open()?; |
|
| 171 | + | let k = cylinder::open(&repo, name)?; |
|
| 172 | + | let n = cylinder::verify(&repo, &k)?; |
|
| 173 | + | println!( |
|
| 174 | + | "{} {}: its name, tree and {} files check out", |
|
| 175 | + | ui::done("Verified"), |
|
| 176 | + | ui::hash(&k.name[..12]), |
|
| 177 | + | n |
|
| 178 | + | ); |
|
| 179 | + | Ok(0) |
|
| 180 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: artifacts (Cylinder, step C1, #108).** `oxy artifact pack DIR --kind archive|package|container|runner-image|report [--arch --os --entrypoint --env K=V --label K=V]` packs a directory into an artifact: each file an ordinary blob of the repository's store, so files and chunks it holds already cost nothing, under a tree and an artifact encoding in canonical CBOR, named by the BLAKE3 of that encoding (docs/cylinder.md §4.1). `oxy artifact list | show NAME | unpack NAME DEST | verify NAME` (any unique prefix of six characters or more; `--json` on list, show and pack), and `oxy gc` keeps every artifact's blobs. The new `cylinder` crate holds the model. |
|
| 5 | 6 | - **Added: oxy on Haiku (#154).** oxy builds and runs on Haiku R1/beta6 (x86_64) with HaikuPorts' Rust; `oxy-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.8.0, with its own `SHA256SUMS-haiku`. The README says how to install it and how to build it (`ZSTD_SYS_USE_PKG_CONFIG=1`). |
|
| 6 | 7 | ||
| 7 | 8 | - **Fixed: a subrepo put back after its removal is pinned in the change that registers it (#152).** `oxy submodule remove` leaves a marker so the next snapshot drops the node while the directory stays; the snapshot cleared it only after reading it, so when nothing looked at the working copy between the removal's commit and the subrepo's return, the change that registered it again in `.oxymodules` left it out and a second commit was needed. A marker is now dropped before it is read once the removal is recorded or the path is registered again. |
|
| … | 298 unchanged lines |
modified Cargo.lock +11 −0
| … | 571 unchanged lines |
||
| 572 | 572 | ] |
|
| 573 | 573 | ||
| 574 | 574 | [[package]] |
|
| 575 | + | name = "cylinder" |
|
| 576 | + | version = "0.8.0" |
|
| 577 | + | dependencies = [ |
|
| 578 | + | "blake3", |
|
| 579 | + | "oxy-core", |
|
| 580 | + | "tempfile", |
|
| 581 | + | "thiserror 1.0.69", |
|
| 582 | + | ] |
|
| 583 | + | ||
| 584 | + | [[package]] |
|
| 575 | 585 | name = "data-encoding" |
|
| 576 | 586 | version = "2.11.1" |
|
| 577 | 587 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 900 unchanged lines |
||
| 1478 | 1488 | "clap_complete", |
|
| 1479 | 1489 | "clap_mangen", |
|
| 1480 | 1490 | "console", |
|
| 1491 | + | "cylinder", |
|
| 1481 | 1492 | "indicatif", |
|
| 1482 | 1493 | "libc", |
|
| 1483 | 1494 | "oxy-core", |
|
| … | 1471 unchanged lines |
modified Cargo.toml +2 −1
| 1 | 1 | [workspace] |
|
| 2 | - | members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "."] |
|
| 2 | + | members = ["crates/oxy-core", "crates/oxy-server", "crates/oxy-strata", "crates/cylinder", "."] |
|
| 3 | 3 | resolver = "2" |
|
| 4 | 4 | ||
| 5 | 5 | [workspace.package] |
|
| … | 17 unchanged lines |
||
| 23 | 23 | oxy-core = { path = "crates/oxy-core" } |
|
| 24 | 24 | oxy-server = { path = "crates/oxy-server" } |
|
| 25 | 25 | oxy-strata = { path = "crates/oxy-strata" } |
|
| 26 | + | cylinder = { path = "crates/cylinder" } |
|
| 26 | 27 | clap = { version = "4", features = ["derive"] } |
|
| 27 | 28 | anyhow = "1" |
|
| 28 | 29 | libc = "0.2" |
|
| … | 18 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +5 −0
| … | 693 unchanged lines |
||
| 694 | 694 | pub fn root(&self) -> &Path { |
|
| 695 | 695 | &self.root |
|
| 696 | 696 | } |
|
| 697 | + | /// The repository's `.oxy` directory (shared by its workspaces), for |
|
| 698 | + | /// what other crates keep beside the store (Cylinder's artifacts). |
|
| 699 | + | pub fn oxy_dir(&self) -> &Path { |
|
| 700 | + | &self.oxy |
|
| 701 | + | } |
|
| 697 | 702 | pub fn store(&self) -> &Store { |
|
| 698 | 703 | &self.store |
|
| 699 | 704 | } |
|
| … | 2114 unchanged lines |
modified docs/cylinder.md +9 −0
| … | 370 unchanged lines |
||
| 371 | 371 | - **C1. The service boundary and artifact model.** Implement the |
|
| 372 | 372 | `Artifact` CBOR object type and FastCDC chunk engine in `cylinder`; |
|
| 373 | 373 | unify runner images with artifacts. |
|
| 374 | + | *Started 2026-10-28 (oxygen#108):* `crates/cylinder` has the |
|
| 375 | + | `Artifact` and `DirTree` encodings of §4.1 (canonical CBOR; a name is |
|
| 376 | + | the BLAKE3 of the encoding; `env` and `labels` as sorted pairs, since |
|
| 377 | + | the profile's maps take integer keys), packing a directory into the |
|
| 378 | + | repository's own blobs and chunks, verifying and unpacking, and the |
|
| 379 | + | roots garbage collection keeps; `oxy artifact pack | list | show | |
|
| 380 | + | unpack | verify`. Until the artifact is an object type of its own, its |
|
| 381 | + | encoding and tree are blobs and `.oxy/artifacts/NAME` records it. Next: |
|
| 382 | + | the object type, then runner images as artifacts. |
|
| 374 | 383 | - **C2. Channels, provenance and consensus.** Flow publishes artifacts; |
|
| 375 | 384 | signed channel move attestations; multi-party `reproduced(N)` consensus; |
|
| 376 | 385 | revset predicates. |
|
| … | 38 unchanged lines |
modified src/cli/mod.rs +78 −0
| 1 | 1 | //! Command-line definitions and dispatch. |
|
| 2 | 2 | ||
| 3 | 3 | mod approve; |
|
| 4 | + | mod artifact; |
|
| 4 | 5 | mod auth; |
|
| 5 | 6 | mod format; |
|
| 6 | 7 | mod identity; |
|
| … | 395 unchanged lines |
||
| 402 | 403 | #[command(subcommand)] |
|
| 403 | 404 | cmd: TlogCmd, |
|
| 404 | 405 | }, |
|
| 406 | + | /// Artifacts: a directory packed into a tree of this repository's chunks (Cylinder, docs/cylinder.md) |
|
| 407 | + | Artifact { |
|
| 408 | + | #[command(subcommand)] |
|
| 409 | + | cmd: ArtifactCmd, |
|
| 410 | + | }, |
|
| 405 | 411 | /// Execution receipts: signed records of what a runner ran on a state |
|
| 406 | 412 | Receipt { |
|
| 407 | 413 | #[command(subcommand)] |
|
| … | 370 unchanged lines |
||
| 778 | 784 | } |
|
| 779 | 785 | ||
| 780 | 786 | #[derive(Subcommand)] |
|
| 787 | + | enum ArtifactCmd { |
|
| 788 | + | /// Pack DIR into an artifact: each file a blob of this repository's store, so files and chunks it holds cost nothing |
|
| 789 | + | Pack { |
|
| 790 | + | /// The directory whose files make the artifact |
|
| 791 | + | dir: std::path::PathBuf, |
|
| 792 | + | /// container, runner-image, package, archive or report |
|
| 793 | + | #[arg(long, default_value = "archive")] |
|
| 794 | + | kind: String, |
|
| 795 | + | /// x86_64, riscv64, aarch64… |
|
| 796 | + | #[arg(long)] |
|
| 797 | + | arch: Option<String>, |
|
| 798 | + | /// linux, darwin, none… |
|
| 799 | + | #[arg(long)] |
|
| 800 | + | os: Option<String>, |
|
| 801 | + | /// The default command, one word per flag |
|
| 802 | + | #[arg(long = "entrypoint", value_name = "WORD")] |
|
| 803 | + | entrypoint: Vec<String>, |
|
| 804 | + | /// KEY=VALUE for the environment (repeatable) |
|
| 805 | + | #[arg(long = "env", value_name = "KEY=VALUE")] |
|
| 806 | + | env: Vec<String>, |
|
| 807 | + | /// KEY=VALUE metadata (repeatable) |
|
| 808 | + | #[arg(long = "label", value_name = "KEY=VALUE")] |
|
| 809 | + | labels: Vec<String>, |
|
| 810 | + | /// Machine-readable output: one JSON document on stdout |
|
| 811 | + | #[arg(long)] |
|
| 812 | + | json: bool, |
|
| 813 | + | }, |
|
| 814 | + | /// The artifacts kept here |
|
| 815 | + | List { |
|
| 816 | + | /// Machine-readable output: one JSON document on stdout |
|
| 817 | + | #[arg(long)] |
|
| 818 | + | json: bool, |
|
| 819 | + | }, |
|
| 820 | + | /// One artifact and its files |
|
| 821 | + | Show { |
|
| 822 | + | /// Its name, or a unique prefix of at least six characters |
|
| 823 | + | name: String, |
|
| 824 | + | /// Machine-readable output: one JSON document on stdout |
|
| 825 | + | #[arg(long)] |
|
| 826 | + | json: bool, |
|
| 827 | + | }, |
|
| 828 | + | /// Write an artifact's files into DEST (missing or empty) |
|
| 829 | + | Unpack { |
|
| 830 | + | /// Its name, or a unique prefix of at least six characters |
|
| 831 | + | name: String, |
|
| 832 | + | /// Where to write its files |
|
| 833 | + | dest: std::path::PathBuf, |
|
| 834 | + | }, |
|
| 835 | + | /// Check an artifact's name, tree and every file against the store |
|
| 836 | + | Verify { |
|
| 837 | + | /// Its name, or a unique prefix of at least six characters |
|
| 838 | + | name: String, |
|
| 839 | + | }, |
|
| 840 | + | } |
|
| 841 | + | ||
| 842 | + | #[derive(Subcommand)] |
|
| 781 | 843 | enum ReceiptCmd { |
|
| 782 | 844 | /// The receipts kept for a state (default: the bookmark heads) |
|
| 783 | 845 | List { |
|
| … | 590 unchanged lines |
||
| 1374 | 1436 | all, |
|
| 1375 | 1437 | json, |
|
| 1376 | 1438 | } => tlog::verify(hub.as_deref(), key.as_deref(), all, json), |
|
| 1439 | + | }, |
|
| 1440 | + | Cmd::Artifact { cmd } => match cmd { |
|
| 1441 | + | ArtifactCmd::Pack { |
|
| 1442 | + | dir, |
|
| 1443 | + | kind, |
|
| 1444 | + | arch, |
|
| 1445 | + | os, |
|
| 1446 | + | entrypoint, |
|
| 1447 | + | env, |
|
| 1448 | + | labels, |
|
| 1449 | + | json, |
|
| 1450 | + | } => artifact::pack(&dir, &kind, arch, os, entrypoint, &env, &labels, json), |
|
| 1451 | + | ArtifactCmd::List { json } => artifact::list(json), |
|
| 1452 | + | ArtifactCmd::Show { name, json } => artifact::show(&name, json), |
|
| 1453 | + | ArtifactCmd::Unpack { name, dest } => artifact::unpack(&name, &dest), |
|
| 1454 | + | ArtifactCmd::Verify { name } => artifact::verify(&name), |
|
| 1377 | 1455 | }, |
|
| 1378 | 1456 | Cmd::Receipt { cmd } => match cmd { |
|
| 1379 | 1457 | ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json), |
|
| … | 206 unchanged lines |
modified src/cli/workspace.rs +3 −1
| … | 1827 unchanged lines |
||
| 1828 | 1828 | repo.config.snapshot_retention_days = 0; |
|
| 1829 | 1829 | } |
|
| 1830 | 1830 | let sp = ui::spinner("Packing and pruning…"); |
|
| 1831 | - | let r = repo.gc_with(now, &mut |phase| sp.set(&format!("{phase}…")))?; |
|
| 1831 | + | // Cylinder's artifacts are named from outside the history: keep them. |
|
| 1832 | + | let artifacts = cylinder::roots(&repo)?; |
|
| 1833 | + | let r = repo.gc_keeping(now, &artifacts, &mut |phase| sp.set(&format!("{phase}…")))?; |
|
| 1832 | 1834 | sp.clear(); |
|
| 1833 | 1835 | if r.packed.objects > 0 { |
|
| 1834 | 1836 | println!( |
|
| … | 1262 unchanged lines |
modified tests/cli_json.rs +69 −0
| … | 256 unchanged lines |
||
| 257 | 257 | ||
| 258 | 258 | /// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback |
|
| 259 | 259 | /// 4 and 6). |
|
| 260 | + | /// Cylinder C1 (oxygen#108): a directory packs into an artifact of this |
|
| 261 | + | /// repository's chunks, lists and shows as JSON, survives gc, and unpacks. |
|
| 262 | + | #[test] |
|
| 263 | + | fn artifacts_pack_show_survive_gc_and_unpack() { |
|
| 264 | + | let tmp = tempfile::tempdir().unwrap(); |
|
| 265 | + | let home = tmp.path().join("home"); |
|
| 266 | + | let dir = tmp.path().join("r"); |
|
| 267 | + | std::fs::create_dir_all(&dir).unwrap(); |
|
| 268 | + | assert_eq!(oxy(&dir, &home, &["init"]).0, 0); |
|
| 269 | + | write(tmp.path(), "out/bin/tool", "#!/bin/sh\necho hi\n"); |
|
| 270 | + | write(tmp.path(), "out/README", "a tool\n"); |
|
| 271 | + | let out = tmp.path().join("out"); |
|
| 272 | + | let pack = [ |
|
| 273 | + | "artifact", |
|
| 274 | + | "pack", |
|
| 275 | + | out.to_str().unwrap(), |
|
| 276 | + | "--kind", |
|
| 277 | + | "package", |
|
| 278 | + | "--arch", |
|
| 279 | + | "x86_64", |
|
| 280 | + | "--label", |
|
| 281 | + | "version=0.1.0", |
|
| 282 | + | "--json", |
|
| 283 | + | ]; |
|
| 284 | + | let a = json(&dir, &home, &pack); |
|
| 285 | + | let name = a["name"].as_str().unwrap().to_string(); |
|
| 286 | + | assert_eq!( |
|
| 287 | + | ( |
|
| 288 | + | a["kind"].as_str(), |
|
| 289 | + | a["files"].as_u64(), |
|
| 290 | + | a["labels"]["version"].as_str() |
|
| 291 | + | ), |
|
| 292 | + | (Some("package"), Some(2), Some("0.1.0")) |
|
| 293 | + | ); |
|
| 294 | + | let list = json(&dir, &home, &["artifact", "list", "--json"]); |
|
| 295 | + | assert_eq!(list[0]["name"], name.as_str()); |
|
| 296 | + | let (code, _, err) = oxy( |
|
| 297 | + | &dir, |
|
| 298 | + | &home, |
|
| 299 | + | &[ |
|
| 300 | + | "artifact", |
|
| 301 | + | "pack", |
|
| 302 | + | out.to_str().unwrap(), |
|
| 303 | + | "--kind", |
|
| 304 | + | "tarball", |
|
| 305 | + | ], |
|
| 306 | + | ); |
|
| 307 | + | assert_ne!(code, 0); |
|
| 308 | + | assert!(err.contains("runner-image"), "{err}"); |
|
| 309 | + | assert_eq!(oxy(&dir, &home, &["gc", "--now"]).0, 0); |
|
| 310 | + | let (code, verified, err) = oxy(&dir, &home, &["artifact", "verify", &name[..8]]); |
|
| 311 | + | assert_eq!(code, 0, "{err}"); |
|
| 312 | + | assert!(String::from_utf8_lossy(&verified).contains("2 files check out")); |
|
| 313 | + | let back = tmp.path().join("back"); |
|
| 314 | + | assert_eq!( |
|
| 315 | + | oxy( |
|
| 316 | + | &dir, |
|
| 317 | + | &home, |
|
| 318 | + | &["artifact", "unpack", &name, back.to_str().unwrap()] |
|
| 319 | + | ) |
|
| 320 | + | .0, |
|
| 321 | + | 0 |
|
| 322 | + | ); |
|
| 323 | + | assert_eq!( |
|
| 324 | + | std::fs::read_to_string(back.join("bin/tool")).unwrap(), |
|
| 325 | + | "#!/bin/sh\necho hi\n" |
|
| 326 | + | ); |
|
| 327 | + | } |
|
| 328 | + | ||
| 260 | 329 | /// oxygen#144: a first identity needs no repository; the next repository |
|
| 261 | 330 | /// signs as it. |
|
| 262 | 331 | #[test] |
|
| … | 499 unchanged lines |