| ⋯ 23 unchanged lines |
| 24 | 24 | | App, |
| 25 | 25 | | ci::{ |
| 26 | 26 | | self, |
| 27 | | - | ArtifactSpec, |
| 28 | 27 | | Pipeline, |
| 29 | 28 | | }, |
| 30 | 29 | | config::CiConfig, |
| ⋯ 5 unchanged lines |
| 36 | 35 | | self, |
| 37 | 36 | | TreeFile, |
| 38 | 37 | | }; |
| 38 | + | use anvil_job::{ |
| 39 | + | ArtifactSpec, |
| 40 | + | CollectedArtifact, |
| 41 | + | JobSpec, |
| 42 | + | META_DIR, |
| 43 | + | META_TAR_CAP, |
| 44 | + | META_VALUE_CAP, |
| 45 | + | Sandbox, |
| 46 | + | Stored, |
| 47 | + | WORKDIR, |
| 48 | + | }; |
| 39 | 49 | | use bollard::{ |
| 40 | 50 | | Docker, |
| 41 | 51 | | container::{ |
| ⋯ 11 unchanged lines |
| 53 | 63 | | use futures_util::StreamExt; |
| 54 | 64 | | use tokio::sync::mpsc::UnboundedReceiver; |
| 55 | 65 | | |
| 56 | | - | const WORKDIR: &str = "/workspace"; |
| 57 | | - | |
| 58 | | - | /// In-container directory where meta-extractor outputs land, one file per |
| 59 | | - | /// `<artifact>/<key>`. Downloaded as a tar after the run; file-per-value |
| 60 | | - | /// sidesteps quoting/JSON-escaping in shell entirely. |
| 61 | | - | const META_DIR: &str = "/tmp/anvil-meta"; |
| 62 | | - | |
| 63 | | - | /// Cap on the meta-extractor tar (the values are short strings). |
| 64 | | - | const META_TAR_CAP: u64 = 1024 * 1024; |
| 66 | + | /// Turn a parsed pipeline into the job a runner receives. |
| 67 | + | /// |
| 68 | + | /// The server's half of the split (see `docs/remote-runners.md`): image |
| 69 | + | /// resolution, the allowlist check and script assembly all happen here. A |
| 70 | + | /// runner therefore never parses `.anvil/ci.yml`, and cannot widen what it was |
| 71 | + | /// permitted to run by reinterpreting one. |
| 72 | + | fn build_job( |
| 73 | + | run_id: i64, |
| 74 | + | pipeline: &Pipeline, |
| 75 | + | cfg: &CiConfig, |
| 76 | + | env: &[(String, String)], |
| 77 | + | ) -> Result<JobSpec, String> { |
| 78 | + | // What the pipeline asked for, or the shared runner image when it omitted |
| 79 | + | // `image:` entirely. |
| 80 | + | let image = cfg.resolve_image(&pipeline.image); |
| 81 | + | if !cfg.image_allowed(image) { |
| 82 | + | return Err(format!( |
| 83 | + | "image {image} is not permitted by ci.allowed_images" |
| 84 | + | )); |
| 85 | + | } |
| 86 | + | Ok(JobSpec { |
| 87 | + | run_id, |
| 88 | + | image: image.to_string(), |
| 89 | + | // No source for this yet: the pipeline schema has no `platform:` key, |
| 90 | + | // so every job takes the runner daemon's native architecture, exactly |
| 91 | + | // as before. Honoured end to end the moment one is set. |
| 92 | + | platform: None, |
| 93 | + | script: build_script(pipeline), |
| 94 | + | env: env.to_vec(), |
| 95 | + | artifacts: pipeline |
| 96 | + | .artifacts |
| 97 | + | .iter() |
| 98 | + | .map(|a| ArtifactSpec { |
| 99 | + | name: a.name.clone(), |
| 100 | + | path: a.path.clone(), |
| 101 | + | browse: a.browse, |
| 102 | + | // The extractor commands themselves are already in the script; |
| 103 | + | // the runner only needs to know whether to look for output. |
| 104 | + | has_meta: !a.meta.is_empty(), |
| 105 | + | }) |
| 106 | + | .collect(), |
| 107 | + | sandbox: Sandbox { |
| 108 | + | memory_mb: cfg.memory_mb, |
| 109 | + | cpus: cfg.cpus, |
| 110 | + | pids_limit: cfg.pids_limit, |
| 111 | + | timeout_secs: cfg.timeout_secs, |
| 112 | + | network: cfg.network, |
| 113 | + | run_as: cfg.run_as.clone(), |
| 114 | + | artifact_max_mb: cfg.artifact_max_mb, |
| 115 | + | artifact_run_max_mb: cfg.artifact_run_max_mb, |
| 116 | + | }, |
| 117 | + | }) |
| 118 | + | } |
| 65 | 119 | | |
| 66 | | - | /// Per-value cap on extractor output, in bytes (after trimming). |
| 67 | | - | const META_VALUE_CAP: usize = 1024; |
| 120 | + | /// Assemble the single `sh -c` program a job runs. |
| 121 | + | /// |
| 122 | + | /// The steps run in a subshell so the meta-extractor trailer still runs (and |
| 123 | + | /// the original exit code is preserved) when a step fails — failure artifacts |
| 124 | + | /// like test reports are the ones that matter most. |
| 125 | + | fn build_script(pipeline: &Pipeline) -> String { |
| 126 | + | let mut script = String::from("(\nset -e\n"); |
| 127 | + | for step in &pipeline.steps { |
| 128 | + | script.push_str("printf '\\n=== %s ===\\n' "); |
| 129 | + | script.push_str(&single_quote(step.label())); |
| 130 | + | script.push('\n'); |
| 131 | + | script.push_str(&step.run); |
| 132 | + | script.push('\n'); |
| 133 | + | } |
| 134 | + | script.push_str(")\nanvil_rc=$?\n"); |
| 135 | + | for a in &pipeline.artifacts { |
| 136 | + | if a.meta.is_empty() { |
| 137 | + | continue; |
| 138 | + | } |
| 139 | + | // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they |
| 140 | + | // interpolate into the script safely. |
| 141 | + | script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name)); |
| 142 | + | for (key, cmd) in &a.meta { |
| 143 | + | script.push_str(&format!( |
| 144 | + | "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n", |
| 145 | + | a.name |
| 146 | + | )); |
| 147 | + | } |
| 148 | + | } |
| 149 | + | script.push_str("exit $anvil_rc\n"); |
| 150 | + | script |
| 151 | + | } |
| 68 | 152 | | |
| 69 | 153 | | /// Run the CI worker loop: recover interrupted runs, drain the queue, then |
| 70 | 154 | | /// process run ids as they arrive on `rx`. Runs one job at a time. |
| ⋯ 101 unchanged lines |
| 172 | 256 | | )); |
| 173 | 257 | | } |
| 174 | 258 | | |
| 175 | | - | let (status, collected) = |
| 176 | | - | match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch, &env).await { |
| 177 | | - | Ok((0, collected)) => (ci::status::SUCCESS, collected), |
| 178 | | - | Ok((code, collected)) => { |
| 179 | | - | log.push_str(&format!("\n[exited with status {code}]\n")); |
| 180 | | - | (ci::status::FAILURE, collected) |
| 181 | | - | } |
| 182 | | - | Err(e) => { |
| 183 | | - | log.push_str(&format!("\n[runner error] {e}\n")); |
| 184 | | - | (ci::status::ERROR, Vec::new()) |
| 185 | | - | } |
| 186 | | - | }; |
| 259 | + | let mut sink = ScratchSink { scratch: &scratch }; |
| 260 | + | let outcome = match build_job(run_id, &pipeline, &app.config.ci, &env) { |
| 261 | + | Ok(job) => execute(&job, tar, &mut log, &mut sink).await, |
| 262 | + | Err(e) => Err(e), |
| 263 | + | }; |
| 264 | + | let (status, collected) = match outcome { |
| 265 | + | Ok((0, collected)) => (ci::status::SUCCESS, collected), |
| 266 | + | Ok((code, collected)) => { |
| 267 | + | log.push_str(&format!("\n[exited with status {code}]\n")); |
| 268 | + | (ci::status::FAILURE, collected) |
| 269 | + | } |
| 270 | + | Err(e) => { |
| 271 | + | log.push_str(&format!("\n[runner error] {e}\n")); |
| 272 | + | (ci::status::ERROR, Vec::new()) |
| 273 | + | } |
| 274 | + | }; |
| 187 | 275 | | |
| 188 | 276 | | // Swap the collected set into place, replacing any earlier run's |
| 189 | 277 | | // artifacts for this commit, then record the rows. |
| ⋯ 159 unchanged lines |
| 349 | 437 | | } |
| 350 | 438 | | } |
| 351 | 439 | | |
| 352 | | - | /// One artifact collected from the job container, already written under the |
| 353 | | - | /// scratch directory; `process` swaps it into the commit's directory. |
| 354 | | - | struct Collected { |
| 355 | | - | name: String, |
| 356 | | - | size: i64, |
| 357 | | - | is_dir: bool, |
| 358 | | - | browse: bool, |
| 359 | | - | /// JSON object of extractor key → output. |
| 360 | | - | meta: String, |
| 440 | + | /// Where a collected artifact's bytes go. |
| 441 | + | /// |
| 442 | + | /// Exists so the download loop can hand each tar off and drop it rather than |
| 443 | + | /// buffering every artifact — `ci.artifact_run_max_mb` defaults to 512, which |
| 444 | + | /// is not an amount to hold in RAM on the host anvil runs on. The in-process |
| 445 | + | /// implementation writes to the scratch directory; a remote runner's uploads |
| 446 | + | /// it. Returning [`Stored`] rather than `()` is what lets the caller charge |
| 447 | + | /// the run budget the size that actually landed. |
| 448 | + | #[async_trait::async_trait] |
| 449 | + | pub trait ArtifactSink: Send { |
| 450 | + | async fn put(&mut self, spec: &ArtifactSpec, tar: &[u8]) -> Result<Stored, String>; |
| 451 | + | } |
| 452 | + | |
| 453 | + | /// The in-process sink: straight into the run's scratch directory, which |
| 454 | + | /// `process` then renames into the commit's artifact directory. |
| 455 | + | struct ScratchSink<'a> { |
| 456 | + | scratch: &'a Path, |
| 457 | + | } |
| 458 | + | |
| 459 | + | #[async_trait::async_trait] |
| 460 | + | impl ArtifactSink for ScratchSink<'_> { |
| 461 | + | async fn put(&mut self, spec: &ArtifactSpec, tar: &[u8]) -> Result<Stored, String> { |
| 462 | + | // Created lazily rather than up front: a run whose artifacts all fail |
| 463 | + | // to download should leave no empty scratch directory behind. |
| 464 | + | std::fs::create_dir_all(self.scratch).map_err(|e| format!("creating scratch dir: {e}"))?; |
| 465 | + | let (size, is_dir) = store_artifact(spec, tar, self.scratch)?; |
| 466 | + | Ok(Stored { size, is_dir }) |
| 467 | + | } |
| 361 | 468 | | } |
| 362 | 469 | | |
| 363 | | - | /// Execute the pipeline in a sandboxed container, streaming output into `log`. |
| 364 | | - | /// Returns the container's exit code and any artifacts collected into |
| 365 | | - | /// `scratch` (empty on timeout — the container is already gone). |
| 366 | | - | /// |
| 367 | | - | /// The job container never sees the Docker socket and gets no mounts of any |
| 368 | | - | /// kind (the checkout is *uploaded*, not bind-mounted; artifacts are |
| 369 | | - | /// *downloaded* out the same way). All capabilities are dropped and |
| 370 | | - | /// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the |
| 371 | | - | /// wall-clock timeout, network access, the container user, and the image |
| 372 | | - | /// allowlist come from `cfg`. |
| 373 | 470 | | /// Replace every secret value in `log` with `***`. |
| 374 | 471 | | /// |
| 375 | 472 | | /// Only values worth hiding: very short ones (a one-character secret) would |
| ⋯ 6 unchanged lines |
| 382 | 479 | | } |
| 383 | 480 | | } |
| 384 | 481 | | |
| 385 | | - | async fn execute( |
| 386 | | - | pipeline: &Pipeline, |
| 482 | + | /// Execute a job in a sandboxed container, streaming output into `log`. |
| 483 | + | /// Returns the exit code and whatever artifacts `sink` accepted (none on |
| 484 | + | /// timeout — the container is already gone). |
| 485 | + | /// |
| 486 | + | /// The job container never sees the Docker socket and gets no mounts of any |
| 487 | + | /// kind (the checkout is *uploaded*, not bind-mounted; artifacts are |
| 488 | + | /// *downloaded* out the same way). All capabilities are dropped and |
| 489 | + | /// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the |
| 490 | + | /// wall-clock timeout, network access and the container user come from |
| 491 | + | /// `spec.sandbox`. The image allowlist was applied when the spec was built — |
| 492 | + | /// a runner receiving a spec does not get to widen it. |
| 493 | + | pub async fn execute( |
| 494 | + | spec: &JobSpec, |
| 387 | 495 | | tar: Vec<u8>, |
| 388 | 496 | | log: &mut String, |
| 389 | | - | cfg: &CiConfig, |
| 390 | | - | scratch: &Path, |
| 391 | | - | env: &[(String, String)], |
| 392 | | - | ) -> Result<(i64, Vec<Collected>), String> { |
| 393 | | - | // What the pipeline asked for, or the shared runner image when it omitted |
| 394 | | - | // `image:` entirely. |
| 395 | | - | let image = cfg.resolve_image(&pipeline.image); |
| 396 | | - | if !cfg.image_allowed(image) { |
| 397 | | - | return Err(format!( |
| 398 | | - | "image {image} is not permitted by ci.allowed_images" |
| 399 | | - | )); |
| 400 | | - | } |
| 497 | + | sink: &mut dyn ArtifactSink, |
| 498 | + | ) -> Result<(i64, Vec<CollectedArtifact>), String> { |
| 499 | + | let platform = spec.platform.clone().unwrap_or_default(); |
| 401 | 500 | | let docker = anvil_docker::connect()?; |
| 402 | | - | anvil_docker::ensure_image(&docker, image).await?; |
| 403 | | - | |
| 404 | | - | // Build a single `set -e` script from the steps. The steps run in a |
| 405 | | - | // subshell so the meta-extractor trailer still runs (and the original |
| 406 | | - | // exit code is preserved) when a step fails — failure artifacts like test |
| 407 | | - | // reports are the ones that matter most. |
| 408 | | - | let mut script = String::from("(\nset -e\n"); |
| 409 | | - | for step in &pipeline.steps { |
| 410 | | - | script.push_str("printf '\\n=== %s ===\\n' "); |
| 411 | | - | script.push_str(&single_quote(step.label())); |
| 412 | | - | script.push('\n'); |
| 413 | | - | script.push_str(&step.run); |
| 414 | | - | script.push('\n'); |
| 415 | | - | } |
| 416 | | - | script.push_str(")\nanvil_rc=$?\n"); |
| 417 | | - | for a in &pipeline.artifacts { |
| 418 | | - | if a.meta.is_empty() { |
| 419 | | - | continue; |
| 420 | | - | } |
| 421 | | - | // Names and keys are parse-time validated to [A-Za-z0-9._-]+, so they |
| 422 | | - | // interpolate into the script safely. |
| 423 | | - | script.push_str(&format!("mkdir -p {META_DIR}/{}\n", a.name)); |
| 424 | | - | for (key, cmd) in &a.meta { |
| 425 | | - | script.push_str(&format!( |
| 426 | | - | "{{\n{cmd}\n}} > {META_DIR}/{}/{key} 2>/dev/null || :\n", |
| 427 | | - | a.name |
| 428 | | - | )); |
| 429 | | - | } |
| 430 | | - | } |
| 431 | | - | script.push_str("exit $anvil_rc\n"); |
| 501 | + | anvil_docker::ensure_image(&docker, &spec.image, &platform).await?; |
| 502 | + | let sb = &spec.sandbox; |
| 432 | 503 | | |
| 433 | 504 | | // The sandbox. Limits of 0 mean "unlimited" and omit the corresponding cap. |
| 434 | 505 | | let host_config = HostConfig { |
| 435 | 506 | | cap_drop: Some(vec!["ALL".to_string()]), |
| 436 | 507 | | security_opt: Some(vec!["no-new-privileges:true".to_string()]), |
| 437 | | - | pids_limit: (cfg.pids_limit > 0).then_some(cfg.pids_limit), |
| 438 | | - | memory: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024), |
| 439 | | - | memory_swap: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024), |
| 440 | | - | nano_cpus: (cfg.cpus > 0.0).then_some((cfg.cpus * 1e9) as i64), |
| 441 | | - | network_mode: (!cfg.network).then(|| "none".to_string()), |
| 508 | + | pids_limit: (sb.pids_limit > 0).then_some(sb.pids_limit), |
| 509 | + | memory: (sb.memory_mb > 0).then(|| sb.memory_mb * 1024 * 1024), |
| 510 | + | memory_swap: (sb.memory_mb > 0).then(|| sb.memory_mb * 1024 * 1024), |
| 511 | + | nano_cpus: (sb.cpus > 0.0).then_some((sb.cpus * 1e9) as i64), |
| 512 | + | network_mode: (!sb.network).then(|| "none".to_string()), |
| 442 | 513 | | ..Default::default() |
| 443 | 514 | | }; |
| 444 | 515 | | let config = Config { |
| 445 | | - | image: Some(image.to_string()), |
| 446 | | - | cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]), |
| 447 | | - | env: (!env.is_empty()).then(|| env.iter().map(|(k, v)| format!("{k}={v}")).collect()), |
| 516 | + | image: Some(spec.image.clone()), |
| 517 | + | cmd: Some(vec![ |
| 518 | + | "sh".to_string(), |
| 519 | + | "-c".to_string(), |
| 520 | + | spec.script.clone(), |
| 521 | + | ]), |
| 522 | + | env: (!spec.env.is_empty()) |
| 523 | + | .then(|| spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect()), |
| 448 | 524 | | working_dir: Some(WORKDIR.to_string()), |
| 449 | | - | user: (!cfg.run_as.is_empty()).then(|| cfg.run_as.clone()), |
| 525 | + | user: (!sb.run_as.is_empty()).then(|| sb.run_as.clone()), |
| 450 | 526 | | host_config: Some(host_config), |
| 451 | 527 | | ..Default::default() |
| 452 | 528 | | }; |
| 529 | + | // An explicit platform needs the options struct; without one, pass None so |
| 530 | + | // the daemon picks its native architecture exactly as before. |
| 531 | + | let opts = spec.platform.as_ref().map(|p| CreateContainerOptions { |
| 532 | + | name: String::new(), |
| 533 | + | platform: Some(p.clone()), |
| 534 | + | }); |
| 453 | 535 | | let created = docker |
| 454 | | - | .create_container(None::<CreateContainerOptions<String>>, config) |
| 536 | + | .create_container(opts, config) |
| 455 | 537 | | .await |
| 456 | 538 | | .map_err(|e| format!("create container: {e}"))?; |
| 457 | 539 | | let id = created.id; |
| ⋯ 51 unchanged lines |
| 509 | 591 | | } |
| 510 | 592 | | Ok(code) |
| 511 | 593 | | }; |
| 512 | | - | let result = match cfg.timeout_secs { |
| 594 | + | let result = match sb.timeout_secs { |
| 513 | 595 | | 0 => run.await, |
| 514 | 596 | | secs => tokio::time::timeout(std::time::Duration::from_secs(secs), run) |
| 515 | 597 | | .await |
| ⋯ 2 unchanged lines |
| 518 | 600 | | |
| 519 | 601 | | // Artifacts come out of the (now stopped) container before it is removed. |
| 520 | 602 | | let collected = match &result { |
| 521 | | - | Ok(_) if !pipeline.artifacts.is_empty() => { |
| 522 | | - | collect_artifacts(&docker, &id, pipeline, cfg, scratch, log).await |
| 603 | + | Ok(_) if !spec.artifacts.is_empty() => { |
| 604 | + | collect_artifacts(&docker, &id, spec, log, sink).await |
| 523 | 605 | | } |
| 524 | 606 | | _ => Vec::new(), |
| 525 | 607 | | }; |
| ⋯ 11 unchanged lines |
| 537 | 619 | | result.map(|code| (code, collected)) |
| 538 | 620 | | } |
| 539 | 621 | | |
| 540 | | - | /// Collect the pipeline's declared artifacts from the stopped container into |
| 541 | | - | /// `scratch`. Failures are per-artifact: each is logged and skipped, never |
| 542 | | - | /// failing the run. |
| 622 | + | /// Collect the job's declared artifacts from the stopped container, handing |
| 623 | + | /// each tar to `sink` as it is downloaded. Failures are per-artifact: each is |
| 624 | + | /// logged and skipped, never failing the run. |
| 625 | + | /// |
| 626 | + | /// One artifact is in memory at a time by construction — the tar is dropped |
| 627 | + | /// once the sink has taken it — which is what keeps `artifact_run_max_mb` |
| 628 | + | /// (512 MiB by default) a disk budget rather than a memory one. |
| 543 | 629 | | async fn collect_artifacts( |
| 544 | 630 | | docker: &Docker, |
| 545 | 631 | | id: &str, |
| 546 | | - | pipeline: &Pipeline, |
| 547 | | - | cfg: &CiConfig, |
| 548 | | - | scratch: &Path, |
| 632 | + | job: &JobSpec, |
| 549 | 633 | | log: &mut String, |
| 550 | | - | ) -> Vec<Collected> { |
| 551 | | - | if let Err(e) = std::fs::create_dir_all(scratch) { |
| 552 | | - | log.push_str(&format!( |
| 553 | | - | "\n[artifacts: creating scratch dir failed: {e}]\n" |
| 554 | | - | )); |
| 555 | | - | return Vec::new(); |
| 556 | | - | } |
| 557 | | - | |
| 634 | + | sink: &mut dyn ArtifactSink, |
| 635 | + | ) -> Vec<CollectedArtifact> { |
| 558 | 636 | | // Extractor outputs first: artifact name → key → value. |
| 559 | 637 | | let mut metas: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); |
| 560 | | - | if pipeline.artifacts.iter().any(|a| !a.meta.is_empty()) { |
| 638 | + | if job.artifacts.iter().any(|a| a.has_meta) { |
| 561 | 639 | | match download_tar(docker, id, META_DIR, META_TAR_CAP).await { |
| 562 | 640 | | Ok(Some(bytes)) => metas = parse_meta_tar(&bytes), |
| 563 | 641 | | Ok(None) => log.push_str("\n[artifacts: extractor output exceeded its cap]\n"), |
| ⋯ 1 unchanged line |
| 565 | 643 | | } |
| 566 | 644 | | } |
| 567 | 645 | | |
| 568 | | - | let per_artifact_cap = mb_cap(cfg.artifact_max_mb); |
| 569 | | - | let mut run_budget = mb_cap(cfg.artifact_run_max_mb); |
| 646 | + | let per_artifact_cap = mb_cap(job.sandbox.artifact_max_mb); |
| 647 | + | let mut run_budget = mb_cap(job.sandbox.artifact_run_max_mb); |
| 570 | 648 | | let mut collected = Vec::new(); |
| 571 | | - | for spec in &pipeline.artifacts { |
| 649 | + | for spec in &job.artifacts { |
| 572 | 650 | | let cap = per_artifact_cap.min(run_budget); |
| 573 | 651 | | let note = |log: &mut String, what: &str| { |
| 574 | 652 | | log.push_str(&format!("\n[artifact {}: {what}]\n", spec.name)); |
| ⋯ 9 unchanged lines |
| 584 | 662 | | continue; |
| 585 | 663 | | } |
| 586 | 664 | | }; |
| 587 | | - | match store_artifact(spec, &bytes, scratch) { |
| 588 | | - | Ok((size, is_dir)) => { |
| 665 | + | match sink.put(spec, &bytes).await { |
| 666 | + | Ok(Stored { size, is_dir }) => { |
| 589 | 667 | | run_budget = run_budget.saturating_sub(size as u64); |
| 590 | 668 | | let meta = metas.get(&spec.name).cloned().unwrap_or_default(); |
| 591 | | - | collected.push(Collected { |
| 669 | + | collected.push(CollectedArtifact { |
| 592 | 670 | | name: spec.name.clone(), |
| 593 | 671 | | size, |
| 594 | 672 | | is_dir, |
| ⋯ 207 unchanged lines |
| 802 | 880 | | name: name.into(), |
| 803 | 881 | | path: path.into(), |
| 804 | 882 | | browse, |
| 805 | | - | meta: Default::default(), |
| 883 | + | has_meta: false, |
| 806 | 884 | | } |
| 807 | 885 | | } |
| 808 | 886 | | |
| ⋯ 65 unchanged lines |