1use crate::peers::PeerConfig;
8use crate::registries::{extract_registry_host, RegistryConfig, RegistryConfigError};
9use crate::types::{
10 GhaWorkflowConfig, OnFail, ParamDef, Pipeline, Placement, QedStep, StepKind,
11 StepValidationError, SubPipelineRef, SubPipelineResolver,
12};
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18
19fn parse_p_prefix(stem: &str) -> Option<(u32, &str)> {
22 let rest = stem.strip_prefix('P')?;
23 let dash = rest.find('-')?;
24 if dash == 0 {
25 return None;
26 }
27 let num: u32 = rest[..dash].parse().ok()?;
28 Some((num, &rest[dash + 1..]))
29}
30
31fn find_pipeline_file(dir: &Path, name: &str) -> Option<PathBuf> {
35 if !dir.exists() {
36 return None;
37 }
38 if let Ok(entries) = fs::read_dir(dir) {
40 for entry in entries.flatten() {
41 let path = entry.path();
42 if path.extension().map_or(false, |e| e == "toml") {
43 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
44 if let Some((_, stem_name)) = parse_p_prefix(stem) {
45 if stem_name == name {
46 return Some(path);
47 }
48 }
49 }
50 }
51 }
52 }
53 let legacy = dir.join(format!("{name}.toml"));
55 if legacy.exists() {
56 Some(legacy)
57 } else {
58 None
59 }
60}
61
62#[derive(Error, Debug)]
63pub enum ConfigError {
64 #[error("IO error: {0}")]
65 Io(#[from] std::io::Error),
66 #[error("TOML parse error: {0}")]
67 TomlParse(#[from] toml::de::Error),
68 #[error("Pipeline not found: {0}")]
69 NotFound(String),
70 #[error("Invalid step: {0}")]
71 InvalidStep(#[from] StepValidationError),
72 #[error("Registry config: {0}")]
73 Registry(#[from] RegistryConfigError),
74 #[error("Sub-pipeline graph: {0}")]
75 SubPipelineGraph(#[from] crate::types::SubPipelineError),
76 #[error("Invalid bind: {0}")]
77 InvalidBind(String),
78}
79
80#[derive(Debug, Deserialize)]
86#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
87pub struct PipelineToml {
88 pub pipeline: PipelineConfig,
89 #[serde(default, rename = "bind")]
93 #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
94 pub binds: Vec<manifest_bind::BindSpec>,
95 #[serde(default)]
98 #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
99 pub on_change: Vec<manifest_bind::OnChangeHook>,
100}
101
102#[derive(Debug, Deserialize)]
103#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
104pub struct PipelineConfig {
105 name: String,
106 label: String,
107 #[serde(default)]
108 steps: Vec<QedStep>,
109 #[serde(default)]
110 params: Option<HashMap<String, ParamDef>>,
111 #[serde(default)]
112 on_success: Vec<crate::types::Outcome>,
113 #[serde(default)]
114 on_fail: Vec<crate::types::Outcome>,
115 #[serde(default)]
116 triggers: Vec<crate::types::Trigger>,
117 #[serde(default)]
118 concurrency_key: Option<String>,
119 #[serde(default)]
120 placement: Placement,
121 #[serde(default)]
122 workspace: crate::types::WorkspaceMode,
123 #[serde(default)]
124 wraps: Option<String>,
125 #[serde(default)]
126 #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
127 matrix: Option<crate::matrix::MatrixSpec>,
128 #[serde(default)]
129 #[cfg_attr(feature = "json-schema", schemars(schema_with = "crate::types::permissive_schema"))]
130 toolchain: Option<crate::toolchain::ToolchainSpec>,
131 #[serde(default)]
135 finally: Vec<QedStep>,
136}
137
138#[derive(Clone)]
139pub struct PipelineLoader {
140 pub(crate) qed_dir: std::path::PathBuf,
141 registries: RegistryConfig,
145 pub(crate) peers: PeerConfig,
150}
151
152impl PipelineLoader {
153 pub fn new(qed_dir: impl AsRef<Path>) -> Self {
159 let qed_dir = qed_dir.as_ref().to_path_buf();
160 let registries = RegistryConfig::load(&qed_dir).unwrap_or_default();
161 let peers = PeerConfig::load(&qed_dir).unwrap_or_default();
162 Self {
163 qed_dir,
164 registries,
165 peers,
166 }
167 }
168
169 pub fn with_registries(mut self, registries: RegistryConfig) -> Self {
172 self.registries = registries;
173 self
174 }
175
176 pub fn with_peers(mut self, peers: PeerConfig) -> Self {
180 self.peers = peers;
181 self
182 }
183
184 pub fn load(&self, name: &str) -> Result<Pipeline, ConfigError> {
189 if let Some(path) = find_pipeline_file(&self.qed_dir, name) {
190 return self.load_from_file(&path);
191 }
192 if let Some(entry) = self.find_gha_workflow(name) {
193 return Ok(synthesise_gha_pipeline(&entry));
194 }
195 Err(ConfigError::NotFound(name.to_string()))
196 }
197
198 pub fn has_camp_file(&self, name: &str) -> bool {
201 find_pipeline_file(&self.qed_dir, name).is_some()
202 }
203
204 pub fn workspace_root(&self) -> PathBuf {
208 self.qed_dir
209 .parent()
210 .and_then(|p| p.parent())
211 .map(|p| p.to_path_buf())
212 .unwrap_or_else(|| self.qed_dir.clone())
213 }
214
215 pub fn list_gha_workflows(&self) -> Vec<GhaWorkflowEntry> {
219 let workflows_dir = self.workspace_root().join(".github").join("workflows");
220 if !workflows_dir.exists() {
221 return Vec::new();
222 }
223 let mut out = Vec::new();
224 let entries = match fs::read_dir(&workflows_dir) {
225 Ok(it) => it,
226 Err(_) => return Vec::new(),
227 };
228 for entry in entries.flatten() {
229 let path = entry.path();
230 let ext = path.extension().and_then(|e| e.to_str());
231 if ext != Some("yml") && ext != Some("yaml") {
232 continue;
233 }
234 let name = match path.file_stem().and_then(|s| s.to_str()) {
235 Some(s) => s.to_string(),
236 None => continue,
237 };
238 let content = match fs::read_to_string(&path) {
239 Ok(c) => c,
240 Err(_) => continue,
241 };
242 let workflow = match yah_qed_gha::parse_workflow(&content) {
243 Ok(w) => w,
244 Err(e) => {
245 tracing::warn!(
246 path = %path.display(),
247 error = %e,
248 "gha workflow parse failed; skipping",
249 );
250 continue;
251 }
252 };
253 let rel_path = path
254 .strip_prefix(self.workspace_root())
255 .map(|p| p.to_path_buf())
256 .unwrap_or_else(|_| path.clone());
257 out.push(GhaWorkflowEntry {
258 name,
259 rel_path,
260 workflow,
261 });
262 }
263 out.sort_by(|a, b| a.name.cmp(&b.name));
264 out
265 }
266
267 fn find_gha_workflow(&self, name: &str) -> Option<GhaWorkflowEntry> {
268 self.list_gha_workflows()
269 .into_iter()
270 .find(|w| w.name == name)
271 }
272
273 pub fn list_all(&self) -> Result<Vec<String>, ConfigError> {
276 let mut names: Vec<String> = Vec::new();
277
278 if self.qed_dir.exists() {
279 let mut file_entries: Vec<(u32, String)> = Vec::new();
280 for entry in fs::read_dir(&self.qed_dir)? {
281 let entry = entry?;
282 let path = entry.path();
283 if path.extension().map_or(false, |e| e == "toml") {
284 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
285 let (num, name) = if let Some((n, base)) = parse_p_prefix(stem) {
286 (n, base.to_string())
287 } else {
288 (u32::MAX, stem.to_string())
289 };
290 if !names.iter().any(|n| n == &name) {
291 file_entries.push((num, name));
292 }
293 }
294 }
295 }
296 file_entries.sort_by_key(|(n, _)| *n);
297 names.extend(file_entries.into_iter().map(|(_, n)| n));
298 }
299
300 Ok(names)
301 }
302
303 pub fn load_and_validate_graph(&self, name: &str) -> Result<Pipeline, ConfigError> {
310 let pipeline = self.load(name)?;
311 let resolver = LoaderSubPipelineResolver::new(self.clone());
312 crate::types::validate_sub_pipeline_graph(&pipeline, &resolver)?;
313 Ok(pipeline)
314 }
315
316 pub fn load_p_numbers(&self, names: &[String]) -> HashMap<String, u32> {
321 let mut map: HashMap<String, u32> = HashMap::new();
322 if self.qed_dir.exists() {
323 if let Ok(entries) = fs::read_dir(&self.qed_dir) {
324 for entry in entries.flatten() {
325 let path = entry.path();
326 if path.extension().map_or(false, |e| e == "toml") {
327 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
328 if let Some((num, name)) = parse_p_prefix(stem) {
329 map.insert(name.to_string(), num);
330 }
331 }
332 }
333 }
334 }
335 }
336 names
337 .iter()
338 .filter_map(|n| map.get(n).map(|&v| (n.clone(), v)))
339 .collect()
340 }
341
342 pub fn list_files(&self) -> Result<Vec<String>, ConfigError> {
345 let mut pipelines = Vec::new();
346 if self.qed_dir.exists() {
347 for entry in fs::read_dir(&self.qed_dir)? {
348 let entry = entry?;
349 let path = entry.path();
350 if path.extension().map_or(false, |ext| ext == "toml") {
351 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
352 let name = if let Some((_, base)) = parse_p_prefix(stem) {
353 base.to_string()
354 } else {
355 stem.to_string()
356 };
357 pipelines.push(name);
358 }
359 }
360 }
361 }
362 Ok(pipelines)
363 }
364
365 #[cfg(test)]
366 fn load_from_str(&self, content: &str) -> Result<Pipeline, ConfigError> {
367 let parsed: PipelineToml = toml::from_str(content)?;
368 let pipeline = Pipeline {
369 name: parsed.pipeline.name,
370 label: parsed.pipeline.label,
371 steps: parsed.pipeline.steps,
372 params: parsed.pipeline.params.unwrap_or_default(),
373 on_success: parsed.pipeline.on_success,
374 on_fail: parsed.pipeline.on_fail,
375 triggers: parsed.pipeline.triggers,
376 concurrency_key: parsed.pipeline.concurrency_key,
377 placement: parsed.pipeline.placement,
378 workspace: parsed.pipeline.workspace,
379 wraps: parsed.pipeline.wraps,
380 matrix: parsed.pipeline.matrix,
381 toolchain: parsed.pipeline.toolchain,
382 binds: parsed.binds,
383 on_change: parsed.on_change,
384 finally: parsed.pipeline.finally,
385 };
386 self.validate_steps(&pipeline)?;
387 self.validate_binds(&pipeline)?;
388 Ok(pipeline)
389 }
390
391 pub fn parse_from_path(&self, path: &Path) -> Result<Pipeline, ConfigError> {
396 self.load_from_file(path)
397 }
398
399 pub(crate) fn load_from_file(&self, path: &Path) -> Result<Pipeline, ConfigError> {
400 let content = fs::read_to_string(path)?;
401 let parsed: PipelineToml = toml::from_str(&content)?;
402 let pipeline = Pipeline {
403 name: parsed.pipeline.name,
404 label: parsed.pipeline.label,
405 steps: parsed.pipeline.steps,
406 params: parsed.pipeline.params.unwrap_or_default(),
407 on_success: parsed.pipeline.on_success,
408 on_fail: parsed.pipeline.on_fail,
409 triggers: parsed.pipeline.triggers,
410 concurrency_key: parsed.pipeline.concurrency_key,
411 placement: parsed.pipeline.placement,
412 workspace: parsed.pipeline.workspace,
413 wraps: parsed.pipeline.wraps,
414 matrix: parsed.pipeline.matrix,
415 toolchain: parsed.pipeline.toolchain,
416 binds: parsed.binds,
417 on_change: parsed.on_change,
418 finally: parsed.pipeline.finally,
419 };
420 self.validate_steps(&pipeline)?;
421 self.validate_binds(&pipeline)?;
422 Ok(pipeline)
423 }
424
425 fn validate_steps(&self, pipeline: &Pipeline) -> Result<(), ConfigError> {
431 for step in &pipeline.steps {
432 step.validate()?;
433 if matches!(step.kind, StepKind::BuildImage) && step.push {
434 let tag_for_host = step.tag.as_deref().or(step.image.as_deref()).unwrap_or("");
435 let host = extract_registry_host(tag_for_host);
436 if !self.registries.is_writable(host) {
437 return Err(ConfigError::InvalidStep(
438 StepValidationError::PushRequiresWritableRegistry {
439 step: step.name.clone(),
440 host: host.to_string(),
441 },
442 ));
443 }
444 }
445 }
446 for step in &pipeline.finally {
450 step.validate_finally()
451 .map_err(ConfigError::InvalidStep)?;
452 }
453 Ok(())
454 }
455
456 fn validate_binds(&self, pipeline: &Pipeline) -> Result<(), ConfigError> {
463 for bind in &pipeline.binds {
464 match &bind.from {
465 manifest_bind::OutputRef::Uri(_) => continue,
466 manifest_bind::OutputRef::StepOutput { step, key } => {
467 let Some(producer) = pipeline.steps.iter().find(|s| &s.name == step) else {
468 return Err(ConfigError::InvalidBind(format!(
469 "[[bind]] file = {:?}: from references unknown step {step:?}",
470 bind.file
471 )));
472 };
473 if !producer.outputs.iter().any(|o| &o.name == key) {
474 return Err(ConfigError::InvalidBind(format!(
475 "[[bind]] file = {:?}: step {step:?} does not declare output {key:?} \
476 (declare it under [[pipeline.steps]].outputs)",
477 bind.file
478 )));
479 }
480 }
481 }
482 }
483 for hook in &pipeline.on_change {
488 if !pipeline.binds.iter().any(|b| b.path == hook.bind) {
489 return Err(ConfigError::InvalidBind(format!(
490 "[[on_change]] bind = {:?}: no [[bind]] declares path {:?} \
491 (the selector must match a bound slot's `path`)",
492 hook.bind, hook.bind
493 )));
494 }
495 }
496 Ok(())
497 }
498}
499
500pub struct GhaWorkflowEntry {
529 pub name: String,
531 pub rel_path: PathBuf,
533 pub workflow: yah_qed_gha::Workflow,
537}
538
539fn synthesise_gha_pipeline(entry: &GhaWorkflowEntry) -> Pipeline {
544 let step = QedStep {
545 background: false,
546 background_until: None,
547 wait_for: None,
548 manifest_stitch: None,
549 name: "gha-workflow".to_string(),
550 argv: Vec::new(),
551 cwd: None,
552 env: HashMap::new(),
553 timeout: None,
554 on_fail: OnFail::Abort,
555 produces: Vec::new(),
556 runtime: None,
557 kind: StepKind::GhaWorkflow,
558 image: None,
559 tag: None,
560 push: false,
561 platforms: Vec::new(),
562 binary_path: None,
563 triple: None,
564 package: None,
565 context: None,
566 load: false,
567 sub_pipeline: None,
568 outputs: Vec::new(),
569 import: None,
570 gha_workflow: Some(GhaWorkflowConfig {
571 path: entry.rel_path.clone(),
572 event: None,
573 inputs: HashMap::new(),
574 }),
575 matrix: None,
576 enabled: true,
577 activation: crate::types::StepActivation::Active,
578 if_cond: None,
579 platform: None,
580 toolchain: None,
581 };
582 Pipeline {
583 name: entry.name.clone(),
584 label: entry
585 .workflow
586 .name
587 .clone()
588 .unwrap_or_else(|| entry.name.clone()),
589 steps: vec![step],
590 params: HashMap::new(),
591 on_success: Vec::new(),
592 on_fail: Vec::new(),
593 triggers: Vec::new(),
594 concurrency_key: None,
595 placement: Placement::default(),
596 workspace: crate::types::WorkspaceMode::default(),
597 wraps: None,
598 matrix: None,
599 toolchain: None,
600 binds: Vec::new(),
601 on_change: Vec::new(),
602 finally: Vec::new(),
603 }
604}
605
606pub struct LoaderSubPipelineResolver {
607 loader: PipelineLoader,
608}
609
610impl LoaderSubPipelineResolver {
611 pub fn new(loader: PipelineLoader) -> Self {
612 Self { loader }
613 }
614
615 fn local_peer_camp_root(&self, camp: &str) -> Option<std::path::PathBuf> {
621 let entry = self.loader.peers.get(camp)?;
622 if entry.rig.is_some() {
623 return None;
624 }
625 if entry.path.is_absolute() {
626 return Some(entry.path.clone());
627 }
628 self.loader
631 .qed_dir
632 .parent()
633 .and_then(|p| p.parent())
634 .map(|root| root.join(&entry.path))
635 }
636}
637
638impl SubPipelineResolver for LoaderSubPipelineResolver {
639 fn resolve(&self, target: &SubPipelineRef) -> Option<Pipeline> {
640 match target {
641 SubPipelineRef::Builtin(name) => self.loader.load(name).ok(),
642 SubPipelineRef::Path(p) => {
643 let resolved: std::path::PathBuf = if p.is_absolute() {
644 p.clone()
645 } else {
646 self.loader
651 .qed_dir
652 .parent()
653 .and_then(|p| p.parent())
654 .map(|root| root.join(p))
655 .unwrap_or_else(|| p.clone())
656 };
657 self.loader.load_from_file(&resolved).ok()
658 }
659 SubPipelineRef::GhaWorkflow {
668 path,
669 event,
670 inputs,
671 } => {
672 let step = crate::types::QedStep {
673 background: false,
674 background_until: None,
675 wait_for: None,
676 manifest_stitch: None,
677 name: "gha-workflow".into(),
678 argv: Vec::new(),
679 cwd: None,
680 env: std::collections::HashMap::new(),
681 timeout: None,
682 on_fail: crate::types::OnFail::Abort,
683 produces: Vec::new(),
684 runtime: None,
685 kind: crate::types::StepKind::GhaWorkflow,
686 image: None,
687 tag: None,
688 push: false,
689 platforms: Vec::new(),
690 binary_path: None,
691 triple: None,
692 package: None,
693 context: None,
694 load: false,
695 sub_pipeline: None,
696 outputs: Vec::new(),
697 import: None,
698 gha_workflow: Some(crate::types::GhaWorkflowConfig {
699 path: path.clone(),
700 event: event.clone(),
701 inputs: inputs.clone(),
702 }),
703 matrix: None,
704 enabled: true,
705 activation: crate::types::StepActivation::Active,
706 if_cond: None,
707 platform: None,
708 toolchain: None,
709 };
710 Some(crate::types::Pipeline {
711 name: format!("gha-workflow:{}", path.display()),
712 label: String::new(),
713 concurrency_key: None,
714 steps: vec![step],
715 triggers: Vec::new(),
716 on_success: Vec::new(),
717 on_fail: Vec::new(),
718 placement: crate::types::Placement::default(),
719 workspace: crate::types::WorkspaceMode::default(),
720 wraps: None,
721 matrix: None,
722 params: std::collections::HashMap::new(),
723 toolchain: None,
724 binds: Vec::new(),
725 on_change: Vec::new(),
726 finally: Vec::new(),
727 })
728 }
729 SubPipelineRef::Peer { camp, pipeline } => {
739 let peer_camp_root = self.local_peer_camp_root(camp)?;
745 let peer_qed_dir = peer_camp_root.join(".yah").join("qed");
746 let peer_loader = PipelineLoader::new(&peer_qed_dir);
747 let mut child = peer_loader.load(pipeline).ok()?;
748 if child.concurrency_key.is_none() {
749 child.concurrency_key = Some(format!("peer:{camp}"));
750 }
751 Some(child)
752 }
753 }
754 }
755
756 fn unresolved_reason(&self, target: &SubPipelineRef) -> Option<String> {
757 match target {
758 SubPipelineRef::Peer { camp, pipeline } => match self.loader.peers.get(camp) {
759 None => Some(format!(
760 "peer camp `{camp}` is not declared in `{}/peers.toml` \
761 (add `[peer.{camp}]` with `path = \"...\"`)",
762 self.loader.qed_dir.display()
763 )),
764 Some(entry) => entry
765 .rig
766 .as_ref()
767 .map(|rig| {
768 format!(
769 "remote peer `{camp}` lives on rig `{rig}` — \
770 cross-rig peer execution is not yet supported \
771 (R494-T5: kamaji hop pending). Drop the `rig = ...` \
772 field on `[peer.{camp}]` in peers.toml to run the \
773 peer camp locally, or wait for R494-F10.",
774 )
775 })
776 .or_else(|| {
777 Some(format!(
778 "peer camp `{camp}` is declared but pipeline `{pipeline}` \
779 was not found in `{}/.yah/qed/` \
780 (check the peer's pipeline name)",
781 entry.path.display()
782 ))
783 }),
784 },
785 _ => None,
786 }
787 }
788
789 fn resolved_camp_root(&self, target: &SubPipelineRef) -> Option<std::path::PathBuf> {
790 match target {
793 SubPipelineRef::Peer { camp, .. } => self.local_peer_camp_root(camp),
794 _ => None,
795 }
796 }
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802
803 #[test]
804 fn parse_p_prefix_parses_canonical_form() {
805 assert_eq!(
806 parse_p_prefix("P006-build-yah-yubaba"),
807 Some((6, "build-yah-yubaba"))
808 );
809 assert_eq!(parse_p_prefix("P001-check"), Some((1, "check")));
810 assert_eq!(
811 parse_p_prefix("P013-full-release"),
812 Some((13, "full-release"))
813 );
814 }
815
816 #[test]
817 fn parse_p_prefix_rejects_non_prefixed() {
818 assert_eq!(parse_p_prefix("check"), None);
819 assert_eq!(parse_p_prefix("publish-assets"), None);
820 assert_eq!(parse_p_prefix("peers"), None);
821 assert_eq!(parse_p_prefix("P-bad"), None);
822 assert_eq!(parse_p_prefix("P"), None);
823 }
824 use crate::registries::RegistryEntry;
825 use crate::types::Outcome;
826
827 #[test]
831 fn loads_pipeline_with_typed_output_and_bind() {
832 let toml = r#"
833[pipeline]
834name = "publish-assets"
835label = "Publish whisper assets"
836
837[[pipeline.steps]]
838name = "apply"
839kind = "subprocess"
840argv = ["yah", "cloud", "apply"]
841
842[[pipeline.steps.outputs]]
843name = "discovered_asset_blake3"
844type = "blake3-hex"
845
846[[pipeline.steps.outputs]]
847name = "discovered_fetch_blake3"
848type = "blake3-hex"
849
850[[bind]]
851file = "app/yah/desktop/assets/whisper/workload.toml"
852path = "asset[filename='whisper.tar.gz'].blake3"
853from = "apply.outputs.discovered_asset_blake3"
854intent = "latest"
855
856[[bind]]
857file = "app/yah/desktop/assets/whisper/workload.toml"
858path = "asset[filename='whisper.tar.gz'].derive.fetch.blake3"
859from = "apply.outputs.discovered_fetch_blake3"
860"#;
861 let dir = tempfile::tempdir().unwrap();
862 let loader = PipelineLoader::new(dir.path());
863 let pipeline = loader.load_from_str(toml).expect("loads cleanly");
864 assert_eq!(pipeline.binds.len(), 2);
865 assert_eq!(pipeline.steps[0].outputs.len(), 2);
866 assert_eq!(
867 pipeline.steps[0].outputs[0].kind,
868 manifest_bind::ValueType::Blake3Hex,
869 );
870 assert!(matches!(
872 pipeline.binds[0].intent,
873 manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Latest)
874 ));
875 assert!(matches!(
876 pipeline.binds[1].intent,
877 manifest_bind::Intent::Keyword(manifest_bind::IntentKeyword::Pin)
878 ));
879 }
880
881 #[test]
884 fn rejects_bind_referencing_undeclared_output() {
885 let toml = r#"
886[pipeline]
887name = "publish-assets"
888label = "Publish whisper assets"
889
890[[pipeline.steps]]
891name = "apply"
892kind = "subprocess"
893argv = ["yah", "cloud", "apply"]
894
895[[bind]]
896file = "workload.toml"
897path = "image"
898from = "apply.outputs.missing_key"
899intent = "latest"
900"#;
901 let dir = tempfile::tempdir().unwrap();
902 let loader = PipelineLoader::new(dir.path());
903 let err = loader.load_from_str(toml).unwrap_err();
904 assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
905 }
906
907 #[test]
910 fn parses_finally_teardown_steps() {
911 let toml = r#"
912[pipeline]
913name = "e2e"
914label = "Dashboard E2E"
915
916[[pipeline.steps]]
917name = "test"
918kind = "subprocess"
919argv = ["playwright", "test"]
920
921[[pipeline.finally]]
922name = "upload-traces"
923kind = "subprocess"
924argv = ["aws", "s3", "cp", "traces/", "s3://ci/traces/", "--recursive"]
925"#;
926 let dir = tempfile::tempdir().unwrap();
927 let loader = PipelineLoader::new(dir.path());
928 let pipeline = loader.load_from_str(toml).expect("loads cleanly");
929 assert_eq!(pipeline.finally.len(), 1);
930 assert_eq!(pipeline.finally[0].name, "upload-traces");
931 assert_eq!(pipeline.finally[0].kind, StepKind::Subprocess);
932 }
933
934 #[test]
937 fn rejects_non_subprocess_finally_step() {
938 let toml = r#"
939[pipeline]
940name = "e2e"
941label = "Dashboard E2E"
942
943[[pipeline.steps]]
944name = "test"
945kind = "subprocess"
946argv = ["true"]
947
948[[pipeline.finally]]
949name = "gate"
950kind = "wait-for"
951[pipeline.finally.wait_for]
952http = "http://localhost:3000/health"
953"#;
954 let dir = tempfile::tempdir().unwrap();
955 let loader = PipelineLoader::new(dir.path());
956 let err = loader.load_from_str(toml).unwrap_err();
957 assert!(
958 matches!(
959 err,
960 ConfigError::InvalidStep(StepValidationError::FinallyRequiresSubprocess(_))
961 ),
962 "got {err:?}"
963 );
964 }
965
966 #[test]
969 fn rejects_bind_referencing_unknown_step() {
970 let toml = r#"
971[pipeline]
972name = "publish-assets"
973label = "Publish whisper assets"
974
975[[pipeline.steps]]
976name = "apply"
977kind = "subprocess"
978argv = ["yah", "cloud", "apply"]
979
980[[bind]]
981file = "workload.toml"
982path = "image"
983from = "doesnt_exist.outputs.x"
984intent = "latest"
985"#;
986 let dir = tempfile::tempdir().unwrap();
987 let loader = PipelineLoader::new(dir.path());
988 let err = loader.load_from_str(toml).unwrap_err();
989 assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
990 }
991
992 #[test]
996 fn loads_pipeline_with_on_change_hooks() {
997 let toml = r#"
998[pipeline]
999name = "publish-assets"
1000label = "Publish whisper assets"
1001
1002[[pipeline.steps]]
1003name = "apply"
1004kind = "subprocess"
1005argv = ["yah", "cloud", "apply"]
1006
1007[[pipeline.steps.outputs]]
1008name = "discovered_asset_blake3"
1009type = "blake3-hex"
1010
1011[[bind]]
1012file = "app/yah/desktop/assets/whisper/workload.toml"
1013path = "asset[filename='whisper.tar.gz'].blake3"
1014from = "apply.outputs.discovered_asset_blake3"
1015intent = "latest"
1016
1017[[on_change]]
1018bind = "asset[filename='whisper.tar.gz'].blake3"
1019action = { pipeline = "release.bump-manifest", params = { component = "whisper-coreml" } }
1020
1021[[on_change]]
1022bind = "asset[filename='whisper.tar.gz'].blake3"
1023action = { journal = ".yah/qed/whisper.journal" }
1024"#;
1025 let dir = tempfile::tempdir().unwrap();
1026 let loader = PipelineLoader::new(dir.path());
1027 let pipeline = loader.load_from_str(toml).expect("loads cleanly");
1028 assert_eq!(pipeline.on_change.len(), 2);
1029 assert!(matches!(
1030 pipeline.on_change[0].action,
1031 manifest_bind::OnChangeAction::Pipeline { .. }
1032 ));
1033 assert!(matches!(
1034 pipeline.on_change[1].action,
1035 manifest_bind::OnChangeAction::Journal { .. }
1036 ));
1037 }
1038
1039 #[test]
1042 fn rejects_on_change_referencing_undeclared_bind() {
1043 let toml = r#"
1044[pipeline]
1045name = "publish-assets"
1046label = "Publish whisper assets"
1047
1048[[pipeline.steps]]
1049name = "apply"
1050kind = "subprocess"
1051argv = ["yah", "cloud", "apply"]
1052
1053[[pipeline.steps.outputs]]
1054name = "discovered_asset_blake3"
1055type = "blake3-hex"
1056
1057[[bind]]
1058file = "workload.toml"
1059path = "blake3"
1060from = "apply.outputs.discovered_asset_blake3"
1061intent = "latest"
1062
1063[[on_change]]
1064bind = "image"
1065action = { journal = ".yah/qed/x.journal" }
1066"#;
1067 let dir = tempfile::tempdir().unwrap();
1068 let loader = PipelineLoader::new(dir.path());
1069 let err = loader.load_from_str(toml).unwrap_err();
1070 assert!(matches!(err, ConfigError::InvalidBind(_)), "got {err:?}");
1071 }
1072
1073 #[test]
1076 fn uri_from_bypasses_step_existence_check() {
1077 let toml = r#"
1078[pipeline]
1079name = "pin-image"
1080label = "Pin python image"
1081
1082[[pipeline.steps]]
1083name = "noop"
1084kind = "subprocess"
1085argv = ["true"]
1086
1087[[bind]]
1088file = ".yah/qed/transforms/whisper-bundle-tar.toml"
1089path = "image"
1090from = "registry://python:3.12-slim"
1091intent = { semver = "^3.12" }
1092"#;
1093 let dir = tempfile::tempdir().unwrap();
1094 let loader = PipelineLoader::new(dir.path());
1095 let pipeline = loader.load_from_str(toml).expect("URI from loads cleanly");
1096 assert_eq!(pipeline.binds.len(), 1);
1097 assert!(matches!(
1098 pipeline.binds[0].from,
1099 manifest_bind::OutputRef::Uri(_)
1100 ));
1101 }
1102
1103 #[test]
1104 fn parses_on_success_outcomes_from_toml() {
1105 let loader = PipelineLoader::new(".yah/qed");
1106 let toml = r#"
1107[pipeline]
1108name = "release"
1109label = "Release pipeline"
1110
1111[[pipeline.steps]]
1112name = "build"
1113argv = ["cargo", "build", "--release", "-p", "yah"]
1114
1115[[pipeline.on_success]]
1116kind = "yubaba-deploy"
1117service = "yah"
1118env = "production"
1119
1120[[pipeline.on_success]]
1121kind = "almanac-run"
1122pipeline = "update-release-index"
1123
1124[[pipeline.on_fail]]
1125kind = "almanac-run"
1126pipeline = "notify-failure"
1127"#;
1128 let pipeline = loader.load_from_str(toml).expect("should parse");
1129 assert_eq!(pipeline.on_success.len(), 2);
1130 assert_eq!(pipeline.on_fail.len(), 1);
1131
1132 assert!(matches!(
1133 &pipeline.on_success[0],
1134 Outcome::WardenDeploy { service, env }
1135 if service == "yah" && env == "production"
1136 ));
1137 assert!(matches!(
1138 &pipeline.on_success[1],
1139 Outcome::AlmanacRun { pipeline } if pipeline == "update-release-index"
1140 ));
1141 assert!(matches!(
1142 &pipeline.on_fail[0],
1143 Outcome::AlmanacRun { pipeline } if pipeline == "notify-failure"
1144 ));
1145 }
1146
1147 #[test]
1148 fn parses_provider_outcome_with_config_table() {
1149 let loader = PipelineLoader::new(".yah/qed");
1154 let toml = r#"
1155[pipeline]
1156name = "release.apple"
1157label = "Apple release"
1158
1159[[pipeline.steps]]
1160name = "build"
1161argv = ["cargo", "build", "--release"]
1162
1163[[pipeline.on_success]]
1164kind = "provider"
1165provider = "notarize"
1166base_url = "https://releases.yah.dev"
1167with = { artifacts = ["desktop"] }
1168"#;
1169 let pipeline = loader.load_from_str(toml).expect("should parse");
1170 assert_eq!(pipeline.on_success.len(), 1);
1171 match &pipeline.on_success[0] {
1172 Outcome::Provider {
1173 provider,
1174 with,
1175 base_url,
1176 } => {
1177 assert_eq!(provider, "notarize");
1178 assert_eq!(base_url.as_deref(), Some("https://releases.yah.dev"));
1179 assert_eq!(with["artifacts"][0], "desktop");
1180 }
1181 other => panic!("expected Outcome::Provider, got {other:?}"),
1182 }
1183 }
1184
1185 #[test]
1186 fn pipeline_without_outcomes_defaults_to_empty() {
1187 let loader = PipelineLoader::new(".yah/qed");
1188 let toml = r#"
1189[pipeline]
1190name = "check"
1191label = "Quick check"
1192
1193[[pipeline.steps]]
1194name = "cargo-check"
1195argv = ["cargo", "check"]
1196"#;
1197 let pipeline = loader.load_from_str(toml).expect("should parse");
1198 assert!(pipeline.on_success.is_empty());
1199 assert!(pipeline.on_fail.is_empty());
1200 }
1201
1202 #[test]
1203 fn parses_toolchain_pins_pipeline_and_step_scope() {
1204 let loader = PipelineLoader::new(".yah/qed");
1207 let toml = r#"
1208[pipeline]
1209name = "release.apple"
1210label = "Apple release"
1211
1212[pipeline.toolchain]
1213rust = "1.84.0"
1214xcode = "15.4"
1215ndk = "r27"
1216
1217[[pipeline.steps]]
1218name = "build"
1219argv = ["cargo", "build", "--release"]
1220
1221[[pipeline.steps]]
1222name = "build-android"
1223argv = ["cargo", "ndk", "build"]
1224toolchain.ndk = "r26d"
1225"#;
1226 let pipeline = loader.load_from_str(toml).expect("should parse");
1227 let tc = pipeline
1228 .toolchain
1229 .as_ref()
1230 .expect("pipeline toolchain present");
1231 assert_eq!(tc.pins.get("xcode").map(String::as_str), Some("15.4"));
1232 assert_eq!(tc.pins.get("rust").map(String::as_str), Some("1.84.0"));
1233 assert!(pipeline.steps[0].toolchain.is_none());
1235 let step_tc = pipeline.steps[1]
1237 .toolchain
1238 .as_ref()
1239 .expect("step override present");
1240 assert_eq!(step_tc.pins.get("ndk").map(String::as_str), Some("r26d"));
1241 let eff = crate::toolchain::effective_pins(
1243 pipeline.toolchain.as_ref(),
1244 pipeline.steps[1].toolchain.as_ref(),
1245 );
1246 assert_eq!(eff.get("ndk").map(String::as_str), Some("r26d"));
1247 assert_eq!(eff.get("rust").map(String::as_str), Some("1.84.0"));
1248 }
1249
1250 #[test]
1251 fn parses_schedule_trigger_from_toml() {
1252 use crate::types::Trigger;
1253
1254 let loader = PipelineLoader::new(".yah/qed");
1255 let toml = r#"
1256[pipeline]
1257name = "nightly"
1258label = "Nightly CI run"
1259
1260[[pipeline.steps]]
1261name = "cargo-check"
1262argv = ["cargo", "check", "--workspace"]
1263
1264[[pipeline.triggers]]
1265kind = "schedule"
1266cron = "0 2 * * *"
1267
1268[[pipeline.triggers]]
1269kind = "manual"
1270"#;
1271 let pipeline = loader.load_from_str(toml).expect("should parse");
1272 assert_eq!(pipeline.triggers.len(), 2);
1273 assert!(matches!(
1274 &pipeline.triggers[0],
1275 Trigger::Schedule { cron } if cron == "0 2 * * *"
1276 ));
1277 assert!(matches!(&pipeline.triggers[1], Trigger::Manual));
1278 }
1279
1280 #[test]
1286 fn pipeline_without_triggers_defaults_to_empty_vec() {
1287 let loader = PipelineLoader::new(".yah/qed");
1288 let toml = r#"
1289[pipeline]
1290name = "check"
1291label = "Quick check"
1292
1293[[pipeline.steps]]
1294name = "cargo-check"
1295argv = ["cargo", "check"]
1296"#;
1297 let pipeline = loader.load_from_str(toml).expect("should parse");
1298 assert!(pipeline.triggers.is_empty());
1299 }
1300
1301 #[test]
1302 fn parses_optional_runtime_per_step() {
1303 use velveteen::TaskRuntime;
1304
1305 let loader = PipelineLoader::new(".yah/qed");
1306 let toml = r#"
1307[pipeline]
1308name = "mixed"
1309label = "Mixed runtime pipeline"
1310
1311[[pipeline.steps]]
1312name = "native-step"
1313argv = ["echo", "hi"]
1314
1315[[pipeline.steps]]
1316name = "container-step"
1317argv = ["echo", "hi"]
1318runtime = "container"
1319"#;
1320 let pipeline = loader.load_from_str(toml).expect("should parse");
1321 assert_eq!(pipeline.steps.len(), 2);
1322 assert!(
1323 pipeline.steps[0].runtime.is_none(),
1324 "no runtime ⇒ pipeline default"
1325 );
1326 assert_eq!(pipeline.steps[1].runtime, Some(TaskRuntime::Container));
1327 }
1328
1329 #[test]
1330 fn parses_build_image_step_from_toml() {
1331 use crate::types::StepKind;
1332
1333 let registries = RegistryConfig {
1335 registries: vec![RegistryEntry {
1336 name: "ghcr".into(),
1337 host: "ghcr.io".into(),
1338 writable: true,
1339 }],
1340 };
1341 let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1342 let toml = r#"
1343[pipeline]
1344name = "image"
1345label = "Bake an image"
1346
1347[[pipeline.steps]]
1348name = "bake"
1349kind = "build-image"
1350image = "yah-rust"
1351tag = "ghcr.io/yah-ai/yah-rust:dev"
1352push = true
1353runtime = "container"
1354"#;
1355 let pipeline = loader.load_from_str(toml).expect("valid build-image step");
1356 assert_eq!(pipeline.steps.len(), 1);
1357 let step = &pipeline.steps[0];
1358 assert_eq!(step.kind, StepKind::BuildImage);
1359 assert_eq!(step.image.as_deref(), Some("yah-rust"));
1360 assert_eq!(step.tag.as_deref(), Some("ghcr.io/yah-ai/yah-rust:dev"));
1361 assert!(step.push);
1362 }
1363
1364 #[test]
1367 fn build_image_push_without_registry_rejected() {
1368 use crate::types::StepValidationError;
1369
1370 let loader = PipelineLoader::new(".yah/qed"); let toml = r#"
1372[pipeline]
1373name = "image"
1374label = "Bake an image"
1375
1376[[pipeline.steps]]
1377name = "bake"
1378kind = "build-image"
1379image = "yah-rust"
1380tag = "ghcr.io/yah-ai/yah-rust:dev"
1381push = true
1382"#;
1383 let err = loader.load_from_str(toml).expect_err("must reject");
1384 match err {
1385 ConfigError::InvalidStep(StepValidationError::PushRequiresWritableRegistry {
1386 step,
1387 host,
1388 }) => {
1389 assert_eq!(step, "bake");
1390 assert_eq!(host, "ghcr.io");
1391 }
1392 other => panic!("expected PushRequiresWritableRegistry, got {other:?}"),
1393 }
1394 }
1395
1396 #[test]
1397 fn build_image_push_with_writable_registry_accepted() {
1398 let registries = RegistryConfig {
1399 registries: vec![RegistryEntry {
1400 name: "ghcr".into(),
1401 host: "ghcr.io".into(),
1402 writable: true,
1403 }],
1404 };
1405 let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1406 let toml = r#"
1407[pipeline]
1408name = "image"
1409label = "Bake an image"
1410
1411[[pipeline.steps]]
1412name = "bake"
1413kind = "build-image"
1414image = "yah-rust"
1415tag = "ghcr.io/yah-ai/yah-rust:dev"
1416push = true
1417"#;
1418 loader
1419 .load_from_str(toml)
1420 .expect("writable registry should allow push");
1421 }
1422
1423 #[test]
1424 fn build_image_push_with_readonly_registry_rejected() {
1425 let registries = RegistryConfig {
1427 registries: vec![RegistryEntry {
1428 name: "ghcr".into(),
1429 host: "ghcr.io".into(),
1430 writable: false,
1431 }],
1432 };
1433 let loader = PipelineLoader::new(".yah/qed").with_registries(registries);
1434 let toml = r#"
1435[pipeline]
1436name = "image"
1437label = "Bake an image"
1438
1439[[pipeline.steps]]
1440name = "bake"
1441kind = "build-image"
1442image = "yah-rust"
1443tag = "ghcr.io/yah-ai/yah-rust:dev"
1444push = true
1445"#;
1446 loader
1447 .load_from_str(toml)
1448 .expect_err("readonly registry must reject push");
1449 }
1450
1451 #[test]
1452 fn build_image_push_false_ignores_registry_config() {
1453 let loader = PipelineLoader::new(".yah/qed");
1455 let toml = r#"
1456[pipeline]
1457name = "image"
1458label = "Bake an image"
1459
1460[[pipeline.steps]]
1461name = "bake"
1462kind = "build-image"
1463image = "yah-rust"
1464tag = "ghcr.io/yah-ai/yah-rust:dev"
1465# push omitted → default false → OCI archive fallback (R381-T4)
1466"#;
1467 loader
1468 .load_from_str(toml)
1469 .expect("push=false bypasses registry check");
1470 }
1471
1472 #[test]
1473 fn build_image_push_falls_back_to_image_when_tag_absent() {
1474 use crate::types::StepValidationError;
1477
1478 let loader = PipelineLoader::new(".yah/qed");
1479 let toml = r#"
1480[pipeline]
1481name = "image"
1482label = "Bake an image"
1483
1484[[pipeline.steps]]
1485name = "bake"
1486kind = "build-image"
1487image = "yah-rust"
1488push = true
1489"#;
1490 let err = loader.load_from_str(toml).expect_err("must reject");
1491 match err {
1492 ConfigError::InvalidStep(StepValidationError::PushRequiresWritableRegistry {
1493 step,
1494 host,
1495 }) => {
1496 assert_eq!(step, "bake");
1497 assert_eq!(host, "docker.io", "no tag → docker.io fallback");
1498 }
1499 other => panic!("expected PushRequiresWritableRegistry, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn build_image_step_without_image_field_rejected() {
1505 use crate::types::StepValidationError;
1506
1507 let loader = PipelineLoader::new(".yah/qed");
1508 let toml = r#"
1509[pipeline]
1510name = "image"
1511label = "Bake an image"
1512
1513[[pipeline.steps]]
1514name = "bake"
1515kind = "build-image"
1516"#;
1517 let err = loader.load_from_str(toml).expect_err("must reject");
1518 match err {
1519 ConfigError::InvalidStep(StepValidationError::BuildImageMissingImage(name)) => {
1520 assert_eq!(name, "bake");
1521 }
1522 other => panic!("expected BuildImageMissingImage, got {other:?}"),
1523 }
1524 }
1525
1526 #[test]
1527 fn build_image_step_with_native_runtime_rejected() {
1528 use crate::types::StepValidationError;
1529
1530 let loader = PipelineLoader::new(".yah/qed");
1531 let toml = r#"
1532[pipeline]
1533name = "image"
1534label = "Bake an image"
1535
1536[[pipeline.steps]]
1537name = "bake"
1538kind = "build-image"
1539image = "yah-rust"
1540runtime = "native"
1541"#;
1542 let err = loader.load_from_str(toml).expect_err("must reject");
1543 match err {
1544 ConfigError::InvalidStep(StepValidationError::BuildImageNativeRuntime(name)) => {
1545 assert_eq!(name, "bake");
1546 }
1547 other => panic!("expected BuildImageNativeRuntime, got {other:?}"),
1548 }
1549 }
1550
1551 #[test]
1554 fn parses_package_native_tarball_step_from_toml() {
1555 use crate::types::StepKind;
1556
1557 let loader = PipelineLoader::new(".yah/qed");
1558 let toml = r#"
1559[pipeline]
1560name = "pack-yubaba"
1561label = "Package native yubaba"
1562
1563[[pipeline.steps]]
1564name = "pack"
1565kind = "package-native-tarball"
1566image = "yah-yubaba"
1567binary_path = "target/x86_64-unknown-linux-musl/release/yubaba"
1568triple = "x86_64-unknown-linux-musl"
1569"#;
1570 let pipeline = loader.load_from_str(toml).expect("valid package step");
1571 assert_eq!(pipeline.steps.len(), 1);
1572 let step = &pipeline.steps[0];
1573 assert_eq!(step.kind, StepKind::PackageNativeTarball);
1574 assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1575 assert_eq!(
1576 step.binary_path.as_deref(),
1577 Some("target/x86_64-unknown-linux-musl/release/yubaba"),
1578 );
1579 assert_eq!(step.triple.as_deref(), Some("x86_64-unknown-linux-musl"));
1580 }
1581
1582 #[test]
1583 fn package_native_tarball_without_image_rejected_at_parse_time() {
1584 use crate::types::StepValidationError;
1585
1586 let loader = PipelineLoader::new(".yah/qed");
1587 let toml = r#"
1588[pipeline]
1589name = "pack"
1590label = "pack"
1591
1592[[pipeline.steps]]
1593name = "p"
1594kind = "package-native-tarball"
1595binary_path = "target/release/yubaba"
1596"#;
1597 let err = loader.load_from_str(toml).expect_err("must reject");
1598 assert!(matches!(
1599 err,
1600 ConfigError::InvalidStep(StepValidationError::PackageNativeTarballMissingImage(ref n))
1601 if n == "p"
1602 ));
1603 }
1604
1605 #[test]
1606 fn package_native_tarball_without_binary_path_rejected_at_parse_time() {
1607 use crate::types::StepValidationError;
1608
1609 let loader = PipelineLoader::new(".yah/qed");
1610 let toml = r#"
1611[pipeline]
1612name = "pack"
1613label = "pack"
1614
1615[[pipeline.steps]]
1616name = "p"
1617kind = "package-native-tarball"
1618image = "yah-yubaba"
1619"#;
1620 let err = loader.load_from_str(toml).expect_err("must reject");
1621 assert!(matches!(
1622 err,
1623 ConfigError::InvalidStep(StepValidationError::PackageNativeTarballMissingBinaryPath(ref n))
1624 if n == "p"
1625 ));
1626 }
1627
1628 #[test]
1629 fn package_native_tarball_with_container_runtime_rejected_at_parse_time() {
1630 use crate::types::StepValidationError;
1631
1632 let loader = PipelineLoader::new(".yah/qed");
1633 let toml = r#"
1634[pipeline]
1635name = "pack"
1636label = "pack"
1637
1638[[pipeline.steps]]
1639name = "p"
1640kind = "package-native-tarball"
1641image = "yah-yubaba"
1642binary_path = "target/release/yubaba"
1643runtime = "container"
1644"#;
1645 let err = loader.load_from_str(toml).expect_err("must reject");
1646 assert!(matches!(
1647 err,
1648 ConfigError::InvalidStep(StepValidationError::PackageNativeTarballContainerRuntime(ref n))
1649 if n == "p"
1650 ));
1651 }
1652
1653 #[test]
1656 fn parses_musl_static_preflight_step_from_toml() {
1657 use crate::types::StepKind;
1658
1659 let loader = PipelineLoader::new(".yah/qed");
1660 let toml = r#"
1661[pipeline]
1662name = "yubaba-preflight"
1663label = "Gate yubaba against musl-static deps"
1664
1665[[pipeline.steps]]
1666name = "musl-gate"
1667kind = "musl-static-preflight"
1668package = "yubaba"
1669"#;
1670 let pipeline = loader.load_from_str(toml).expect("valid preflight step");
1671 assert_eq!(pipeline.steps.len(), 1);
1672 let step = &pipeline.steps[0];
1673 assert_eq!(step.kind, StepKind::MuslStaticPreflight);
1674 assert_eq!(step.package.as_deref(), Some("yubaba"));
1675 }
1676
1677 #[test]
1678 fn musl_static_preflight_without_package_rejected_at_parse_time() {
1679 use crate::types::StepValidationError;
1680
1681 let loader = PipelineLoader::new(".yah/qed");
1682 let toml = r#"
1683[pipeline]
1684name = "preflight"
1685label = "preflight"
1686
1687[[pipeline.steps]]
1688name = "p"
1689kind = "musl-static-preflight"
1690"#;
1691 let err = loader.load_from_str(toml).expect_err("must reject");
1692 assert!(matches!(
1693 err,
1694 ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightMissingPackage(ref n))
1695 if n == "p"
1696 ));
1697 }
1698
1699 #[test]
1700 fn musl_static_preflight_with_container_runtime_rejected_at_parse_time() {
1701 use crate::types::StepValidationError;
1702
1703 let loader = PipelineLoader::new(".yah/qed");
1704 let toml = r#"
1705[pipeline]
1706name = "preflight"
1707label = "preflight"
1708
1709[[pipeline.steps]]
1710name = "p"
1711kind = "musl-static-preflight"
1712package = "yubaba"
1713runtime = "container"
1714"#;
1715 let err = loader.load_from_str(toml).expect_err("must reject");
1716 assert!(matches!(
1717 err,
1718 ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightContainerRuntime(ref n))
1719 if n == "p"
1720 ));
1721 }
1722
1723 #[test]
1724 fn musl_static_preflight_with_argv_rejected_at_parse_time() {
1725 use crate::types::StepValidationError;
1726
1727 let loader = PipelineLoader::new(".yah/qed");
1728 let toml = r#"
1729[pipeline]
1730name = "preflight"
1731label = "preflight"
1732
1733[[pipeline.steps]]
1734name = "p"
1735kind = "musl-static-preflight"
1736package = "yubaba"
1737argv = ["cargo", "metadata"]
1738"#;
1739 let err = loader.load_from_str(toml).expect_err("must reject");
1740 assert!(matches!(
1741 err,
1742 ConfigError::InvalidStep(StepValidationError::MuslStaticPreflightHasArgv(ref n))
1743 if n == "p"
1744 ));
1745 }
1746
1747 #[test]
1750 fn parses_sign_native_tarball_step_from_toml() {
1751 use crate::types::StepKind;
1752
1753 let loader = PipelineLoader::new(".yah/qed");
1754 let toml = r#"
1755[pipeline]
1756name = "sign-yubaba"
1757label = "Sign native yubaba tarball"
1758
1759[[pipeline.steps]]
1760name = "sign"
1761kind = "sign-native-tarball"
1762image = "yah-yubaba"
1763triple = "x86_64-unknown-linux-musl"
1764"#;
1765 let pipeline = loader.load_from_str(toml).expect("valid sign step");
1766 assert_eq!(pipeline.steps.len(), 1);
1767 let step = &pipeline.steps[0];
1768 assert_eq!(step.kind, StepKind::SignNativeTarball);
1769 assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1770 assert_eq!(step.triple.as_deref(), Some("x86_64-unknown-linux-musl"));
1771 }
1772
1773 #[test]
1774 fn sign_native_tarball_without_image_rejected_at_parse_time() {
1775 use crate::types::StepValidationError;
1776
1777 let loader = PipelineLoader::new(".yah/qed");
1778 let toml = r#"
1779[pipeline]
1780name = "sign"
1781label = "sign"
1782
1783[[pipeline.steps]]
1784name = "s"
1785kind = "sign-native-tarball"
1786"#;
1787 let err = loader.load_from_str(toml).expect_err("must reject");
1788 assert!(matches!(
1789 err,
1790 ConfigError::InvalidStep(StepValidationError::SignNativeTarballMissingImage(ref n))
1791 if n == "s"
1792 ));
1793 }
1794
1795 #[test]
1796 fn sign_native_tarball_with_argv_rejected_at_parse_time() {
1797 use crate::types::StepValidationError;
1798
1799 let loader = PipelineLoader::new(".yah/qed");
1800 let toml = r#"
1801[pipeline]
1802name = "sign"
1803label = "sign"
1804
1805[[pipeline.steps]]
1806name = "s"
1807kind = "sign-native-tarball"
1808image = "yah-yubaba"
1809argv = ["cosign", "sign-blob"]
1810"#;
1811 let err = loader.load_from_str(toml).expect_err("must reject");
1812 assert!(matches!(
1813 err,
1814 ConfigError::InvalidStep(StepValidationError::SignNativeTarballHasArgv(ref n))
1815 if n == "s"
1816 ));
1817 }
1818
1819 #[test]
1820 fn sign_native_tarball_with_container_runtime_rejected_at_parse_time() {
1821 use crate::types::StepValidationError;
1822
1823 let loader = PipelineLoader::new(".yah/qed");
1824 let toml = r#"
1825[pipeline]
1826name = "sign"
1827label = "sign"
1828
1829[[pipeline.steps]]
1830name = "s"
1831kind = "sign-native-tarball"
1832image = "yah-yubaba"
1833runtime = "container"
1834"#;
1835 let err = loader.load_from_str(toml).expect_err("must reject");
1836 assert!(matches!(
1837 err,
1838 ConfigError::InvalidStep(StepValidationError::SignNativeTarballContainerRuntime(ref n))
1839 if n == "s"
1840 ));
1841 }
1842
1843 #[test]
1844 fn build_image_step_with_argv_rejected() {
1845 use crate::types::StepValidationError;
1846
1847 let loader = PipelineLoader::new(".yah/qed");
1848 let toml = r#"
1849[pipeline]
1850name = "image"
1851label = "Bake an image"
1852
1853[[pipeline.steps]]
1854name = "bake"
1855kind = "build-image"
1856image = "yah-rust"
1857argv = ["docker", "build", "."]
1858"#;
1859 let err = loader.load_from_str(toml).expect_err("must reject");
1860 match err {
1861 ConfigError::InvalidStep(StepValidationError::BuildImageHasArgv(name)) => {
1862 assert_eq!(name, "bake");
1863 }
1864 other => panic!("expected BuildImageHasArgv, got {other:?}"),
1865 }
1866 }
1867
1868 #[test]
1869 fn build_image_step_parses_context_and_load_fields() {
1870 use crate::types::StepKind;
1871 use std::path::PathBuf;
1872
1873 let loader = PipelineLoader::new(".yah/qed");
1874 let toml = r#"
1875[pipeline]
1876name = "build-yubaba"
1877label = "Build yah-yubaba locally"
1878
1879[[pipeline.steps]]
1880name = "image"
1881kind = "build-image"
1882image = "yah-yubaba"
1883tag = "ghcr.io/yah-ai/yah-yubaba:latest"
1884context = "target/yah-yubaba-ctx"
1885load = true
1886push = false
1887"#;
1888 let pipeline = loader.load_from_str(toml).expect("valid build-image step");
1889 assert_eq!(pipeline.steps.len(), 1);
1890 let step = &pipeline.steps[0];
1891 assert_eq!(step.kind, StepKind::BuildImage);
1892 assert_eq!(step.image.as_deref(), Some("yah-yubaba"));
1893 assert_eq!(
1894 step.tag.as_deref(),
1895 Some("ghcr.io/yah-ai/yah-yubaba:latest")
1896 );
1897 assert_eq!(step.context, Some(PathBuf::from("target/yah-yubaba-ctx")));
1898 assert!(step.load);
1899 assert!(!step.push);
1900 }
1901
1902 #[test]
1909 fn native_container_run_step_parses_and_offloads() {
1910 let loader = PipelineLoader::new(".yah/qed");
1911 let toml = r#"
1912[pipeline]
1913name = "rusty-v8-musl"
1914label = "Build rusty_v8 static lib for x86_64-unknown-linux-musl"
1915placement = "anywhere"
1916
1917[[pipeline.steps]]
1918name = "build-v8-musl"
1919image = "cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64@sha256:a1fb9d9cc631dcb844fbbb949dc65a80be1d532fa80868c4df5ed4b21939f9a4"
1920runtime = "container"
1921platform = { target = "x86_64-unknown-linux-musl", native = true }
1922argv = ["build-v8.sh 'x86_64-unknown-linux-musl' '/tmp/out.tar.gz'"]
1923timeout = 9000
1924"#;
1925 let pipeline = loader
1926 .load_from_str(toml)
1927 .expect("rusty-v8-musl pipeline shape must parse");
1928 assert_eq!(pipeline.steps.len(), 1);
1929 let step = &pipeline.steps[0];
1930 assert_eq!(
1931 step.image.as_deref(),
1932 Some(
1933 "cr.yah.dev/rusty-v8-musl-builder:v149.4.0-amd64\
1934 @sha256:a1fb9d9cc631dcb844fbbb949dc65a80be1d532fa80868c4df5ed4b21939f9a4"
1935 ),
1936 "the full digest-pinned ref survives the loader verbatim (R590-B5)",
1937 );
1938 let plat = step.platform.as_ref().expect("platform declared");
1939 assert_eq!(plat.target.as_deref(), Some("x86_64-unknown-linux-musl"));
1940 assert!(plat.native, "native flag must round-trip from the inline table");
1941
1942 assert!(crate::runner::pipeline_needs_offload(
1944 &pipeline,
1945 "aarch64-apple-darwin"
1946 ));
1947 assert!(!crate::runner::pipeline_needs_offload(
1949 &pipeline,
1950 "x86_64-unknown-linux-gnu"
1951 ));
1952 }
1953
1954 #[test]
1955 fn build_image_step_context_defaults_to_none_when_absent() {
1956 use crate::types::StepKind;
1957
1958 let loader = PipelineLoader::new(".yah/qed");
1959 let toml = r#"
1960[pipeline]
1961name = "build-yubaba"
1962label = "Build image"
1963
1964[[pipeline.steps]]
1965name = "image"
1966kind = "build-image"
1967image = "yah-yubaba"
1968"#;
1969 let pipeline = loader.load_from_str(toml).expect("valid");
1970 let step = &pipeline.steps[0];
1971 assert_eq!(step.kind, StepKind::BuildImage);
1972 assert!(step.context.is_none(), "context should default to None");
1973 assert!(!step.load, "load should default to false");
1974 }
1975
1976 #[test]
1977 fn loader_resolver_synthesizes_pipeline_for_gha_workflow_target() {
1978 use crate::types::SubPipelineRef;
1982 let loader = PipelineLoader::new(".yah/qed");
1983 let resolver = LoaderSubPipelineResolver::new(loader);
1984 let mut inputs = std::collections::HashMap::new();
1985 inputs.insert("tag".into(), "v1.0.0".into());
1986 let target = SubPipelineRef::GhaWorkflow {
1987 path: std::path::PathBuf::from(".github/workflows/release.yml"),
1988 event: Some("workflow_dispatch".into()),
1989 inputs,
1990 };
1991 let pipeline = resolver.resolve(&target).expect("must resolve");
1992 assert_eq!(pipeline.steps.len(), 1);
1993 let step = &pipeline.steps[0];
1994 assert_eq!(step.kind, crate::types::StepKind::GhaWorkflow);
1995 let cfg = step.gha_workflow.as_ref().expect("gha_workflow block");
1996 assert_eq!(
1997 cfg.path,
1998 std::path::PathBuf::from(".github/workflows/release.yml")
1999 );
2000 assert_eq!(cfg.event.as_deref(), Some("workflow_dispatch"));
2001 assert_eq!(cfg.inputs.get("tag").map(|s| s.as_str()), Some("v1.0.0"));
2002 }
2003
2004 #[test]
2005 fn parses_tag_trigger_from_toml() {
2006 use crate::types::Trigger;
2007
2008 let loader = PipelineLoader::new(".yah/qed");
2009 let toml = r#"
2010[pipeline]
2011name = "release"
2012label = "Release on tag"
2013
2014[[pipeline.steps]]
2015name = "build"
2016argv = ["cargo", "build", "--release"]
2017
2018[[pipeline.triggers]]
2019kind = "tag"
2020pattern = "v*.*.*"
2021"#;
2022 let pipeline = loader.load_from_str(toml).expect("should parse");
2023 assert_eq!(pipeline.triggers.len(), 1);
2024 assert!(matches!(
2025 &pipeline.triggers[0],
2026 Trigger::Tag { pattern } if pattern == "v*.*.*"
2027 ));
2028 }
2029
2030 fn fixture_peer_camp(
2038 tmp: &Path,
2039 peer_pipeline_toml: &str,
2040 peers_toml: &str,
2041 ) -> std::path::PathBuf {
2042 let parent_qed = tmp.join("parent/.yah/qed");
2043 fs::create_dir_all(&parent_qed).unwrap();
2044 fs::write(parent_qed.join("peers.toml"), peers_toml).unwrap();
2045
2046 let peer_qed = tmp.join("peers/cheers/.yah/qed");
2047 fs::create_dir_all(&peer_qed).unwrap();
2048 fs::write(peer_qed.join("publish.toml"), peer_pipeline_toml).unwrap();
2049
2050 parent_qed
2051 }
2052
2053 const PEER_PUBLISH_TOML: &str = r#"
2054[pipeline]
2055name = "publish"
2056label = "Publish cheers"
2057
2058[[pipeline.steps]]
2059name = "build"
2060argv = ["cargo", "build", "--release"]
2061"#;
2062
2063 #[test]
2064 fn peer_resolver_loads_pipeline_from_sibling_camp() {
2065 let tmp = tempfile::tempdir().unwrap();
2066 let parent_qed = fixture_peer_camp(
2067 tmp.path(),
2068 PEER_PUBLISH_TOML,
2069 r#"
2070 [peer.cheers]
2071 path = "../peers/cheers"
2072 "#,
2073 );
2074 let loader = PipelineLoader::new(&parent_qed);
2075 let resolver = LoaderSubPipelineResolver::new(loader);
2076 let resolved = resolver
2077 .resolve(&SubPipelineRef::Peer {
2078 camp: "cheers".into(),
2079 pipeline: "publish".into(),
2080 })
2081 .expect("peer pipeline should resolve");
2082 assert_eq!(resolved.name, "publish");
2083 assert_eq!(resolved.steps.len(), 1);
2084 assert_eq!(resolved.concurrency_key.as_deref(), Some("peer:cheers"));
2088 }
2089
2090 #[test]
2091 fn peer_resolver_reports_peer_camp_root_for_subprocess_cwd() {
2092 let tmp = tempfile::tempdir().unwrap();
2098 let parent_qed = fixture_peer_camp(
2099 tmp.path(),
2100 PEER_PUBLISH_TOML,
2101 r#"
2102 [peer.cheers]
2103 path = "../peers/cheers"
2104 "#,
2105 );
2106 let loader = PipelineLoader::new(&parent_qed);
2107 let resolver = LoaderSubPipelineResolver::new(loader);
2108 let root = resolver
2109 .resolved_camp_root(&SubPipelineRef::Peer {
2110 camp: "cheers".into(),
2111 pipeline: "publish".into(),
2112 })
2113 .expect("peer camp root should resolve");
2114 assert_eq!(root, tmp.path().join("parent").join("../peers/cheers"));
2117 assert!(resolver
2119 .resolved_camp_root(&SubPipelineRef::Builtin("check".into()))
2120 .is_none());
2121 assert!(resolver
2123 .resolved_camp_root(&SubPipelineRef::Peer {
2124 camp: "ghost".into(),
2125 pipeline: "publish".into(),
2126 })
2127 .is_none());
2128 }
2129
2130 #[test]
2131 fn peer_resolver_preserves_explicit_concurrency_key() {
2132 let tmp = tempfile::tempdir().unwrap();
2133 let parent_qed = fixture_peer_camp(
2134 tmp.path(),
2135 r#"
2136[pipeline]
2137name = "publish"
2138label = "Publish cheers"
2139concurrency_key = "@parallel"
2140
2141[[pipeline.steps]]
2142name = "build"
2143argv = ["cargo", "build", "--release"]
2144"#,
2145 r#"
2146 [peer.cheers]
2147 path = "../peers/cheers"
2148 "#,
2149 );
2150 let loader = PipelineLoader::new(&parent_qed);
2151 let resolver = LoaderSubPipelineResolver::new(loader);
2152 let resolved = resolver
2153 .resolve(&SubPipelineRef::Peer {
2154 camp: "cheers".into(),
2155 pipeline: "publish".into(),
2156 })
2157 .expect("peer pipeline should resolve");
2158 assert_eq!(resolved.concurrency_key.as_deref(), Some("@parallel"));
2160 }
2161
2162 #[test]
2163 fn peer_resolver_returns_none_for_unknown_camp() {
2164 let tmp = tempfile::tempdir().unwrap();
2165 let parent_qed = fixture_peer_camp(
2166 tmp.path(),
2167 PEER_PUBLISH_TOML,
2168 r#"
2169 [peer.cheers]
2170 path = "../peers/cheers"
2171 "#,
2172 );
2173 let loader = PipelineLoader::new(&parent_qed);
2174 let resolver = LoaderSubPipelineResolver::new(loader);
2175 let resolved = resolver.resolve(&SubPipelineRef::Peer {
2176 camp: "ghost".into(),
2177 pipeline: "publish".into(),
2178 });
2179 assert!(resolved.is_none());
2180 }
2181
2182 #[test]
2183 fn peer_resolver_returns_none_for_unknown_pipeline_in_known_camp() {
2184 let tmp = tempfile::tempdir().unwrap();
2185 let parent_qed = fixture_peer_camp(
2186 tmp.path(),
2187 PEER_PUBLISH_TOML,
2188 r#"
2189 [peer.cheers]
2190 path = "../peers/cheers"
2191 "#,
2192 );
2193 let loader = PipelineLoader::new(&parent_qed);
2194 let resolver = LoaderSubPipelineResolver::new(loader);
2195 let resolved = resolver.resolve(&SubPipelineRef::Peer {
2196 camp: "cheers".into(),
2197 pipeline: "no-such-pipeline".into(),
2198 });
2199 assert!(resolved.is_none());
2200 }
2201
2202 #[test]
2203 fn peer_resolver_remote_peer_surfaces_typed_unsupported_reason() {
2204 let tmp = tempfile::tempdir().unwrap();
2209 let parent_qed = fixture_peer_camp(
2210 tmp.path(),
2211 PEER_PUBLISH_TOML,
2212 r#"
2213 [peer.cheers]
2214 rig = "rig-tokyo-1"
2215 path = "/srv/camps/cheers"
2216 "#,
2217 );
2218 let loader = PipelineLoader::new(&parent_qed);
2219 let resolver = LoaderSubPipelineResolver::new(loader);
2220 let target = SubPipelineRef::Peer {
2221 camp: "cheers".into(),
2222 pipeline: "publish".into(),
2223 };
2224 assert!(
2225 resolver.resolve(&target).is_none(),
2226 "remote peer should not resolve in v1"
2227 );
2228 let reason = resolver
2229 .unresolved_reason(&target)
2230 .expect("remote-peer miss should publish a typed reason");
2231 assert!(
2232 reason.contains("rig-tokyo-1"),
2233 "reason names the rig: {reason}"
2234 );
2235 assert!(reason.contains("cheers"), "reason names the camp: {reason}");
2236 assert!(
2237 reason.contains("R494-T5"),
2238 "reason cites the ticket: {reason}"
2239 );
2240 }
2241
2242 #[test]
2243 fn peer_resolver_unknown_camp_publishes_actionable_reason() {
2244 let tmp = tempfile::tempdir().unwrap();
2247 let parent_qed = fixture_peer_camp(
2248 tmp.path(),
2249 PEER_PUBLISH_TOML,
2250 r#"
2251 [peer.cheers]
2252 path = "../peers/cheers"
2253 "#,
2254 );
2255 let loader = PipelineLoader::new(&parent_qed);
2256 let resolver = LoaderSubPipelineResolver::new(loader);
2257 let target = SubPipelineRef::Peer {
2258 camp: "ghost".into(),
2259 pipeline: "publish".into(),
2260 };
2261 assert!(resolver.resolve(&target).is_none());
2262 let reason = resolver
2263 .unresolved_reason(&target)
2264 .expect("reason for unknown camp");
2265 assert!(reason.contains("ghost"), "reason names the camp: {reason}");
2266 assert!(
2267 reason.contains("peers.toml"),
2268 "reason routes to peers.toml: {reason}"
2269 );
2270 }
2271
2272 #[test]
2273 fn peer_resolver_unknown_pipeline_in_known_camp_publishes_reason() {
2274 let tmp = tempfile::tempdir().unwrap();
2277 let parent_qed = fixture_peer_camp(
2278 tmp.path(),
2279 PEER_PUBLISH_TOML,
2280 r#"
2281 [peer.cheers]
2282 path = "../peers/cheers"
2283 "#,
2284 );
2285 let loader = PipelineLoader::new(&parent_qed);
2286 let resolver = LoaderSubPipelineResolver::new(loader);
2287 let target = SubPipelineRef::Peer {
2288 camp: "cheers".into(),
2289 pipeline: "no-such".into(),
2290 };
2291 assert!(resolver.resolve(&target).is_none());
2292 let reason = resolver
2293 .unresolved_reason(&target)
2294 .expect("reason for missing pipeline");
2295 assert!(
2296 reason.contains("no-such"),
2297 "reason names the pipeline: {reason}"
2298 );
2299 assert!(reason.contains("cheers"), "reason names the camp: {reason}");
2300 }
2301
2302 #[test]
2303 fn peer_resolver_unresolved_reason_is_none_for_non_peer_targets() {
2304 let tmp = tempfile::tempdir().unwrap();
2308 let parent_qed = fixture_peer_camp(
2309 tmp.path(),
2310 PEER_PUBLISH_TOML,
2311 r#"
2312 [peer.cheers]
2313 path = "../peers/cheers"
2314 "#,
2315 );
2316 let loader = PipelineLoader::new(&parent_qed);
2317 let resolver = LoaderSubPipelineResolver::new(loader);
2318 assert!(resolver
2319 .unresolved_reason(&SubPipelineRef::Builtin("missing".into()))
2320 .is_none());
2321 assert!(resolver
2322 .unresolved_reason(&SubPipelineRef::Path(".yah/qed/missing.toml".into()))
2323 .is_none());
2324 }
2325}