1use crate::cli::{
10 TemplateArgs, TemplateCommand, TemplateDeleteArgs, TemplateDeprecateArgs, TemplateLaunchArgs,
11 TemplateListArgs, TemplatePromoteArgs, TemplateRegisterArgs, TemplateRollbackArgs,
12 TemplateRunArgs, TemplateShowArgs, TemplateStoreArgs,
13};
14use crate::error::{CliError, CliResult};
15use crate::serve::history::templates::{
16 TemplateRecord, TemplateSummary, VersionChannel, VersionSelector,
17};
18use crate::serve::load::ConfigFormat;
19use crate::templates::{RegisterRequest, TemplateStore};
20
21pub async fn run(args: TemplateArgs) -> CliResult<()> {
23 match args.command {
24 TemplateCommand::Register(a) => register(a).await,
25 TemplateCommand::List(a) => list(a).await,
26 TemplateCommand::Show(a) => show(a).await,
27 TemplateCommand::Launch(a) => launch(a).await,
28 TemplateCommand::Rollback(a) => rollback(a).await,
29 TemplateCommand::Deprecate(a) => deprecate(a).await,
30 TemplateCommand::Promote(a) => promote(a).await,
31 TemplateCommand::Delete(a) => delete(a).await,
32 TemplateCommand::Run(a) => run_template(a).await,
33 }
34}
35
36async fn connect(common: &TemplateStoreArgs) -> CliResult<TemplateStore> {
39 let cwd = std::env::current_dir()?;
40 let env_path =
41 crate::env_loader::resolve_env_file(common.env_file.as_deref(), common.no_env_file, &cwd)?;
42 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
43 crate::templates::resolve_store_url(&common.store).await
44}
45
46fn to_pretty<T: serde::Serialize>(value: &T) -> CliResult<String> {
47 serde_json::to_string_pretty(value)
48 .map_err(|e| CliError::Internal(format!("rendering template JSON: {e}")))
49}
50
51fn format_of(path: &std::path::Path) -> CliResult<ConfigFormat> {
53 match path
54 .extension()
55 .and_then(|e| e.to_str())
56 .map(str::to_ascii_lowercase)
57 .as_deref()
58 {
59 Some("yaml" | "yml") => Ok(ConfigFormat::Yaml),
60 Some("json") => Ok(ConfigFormat::Json),
61 _ => Err(CliError::UnknownExtension {
62 path: path.to_path_buf(),
63 }),
64 }
65}
66
67async fn register(args: TemplateRegisterArgs) -> CliResult<()> {
68 let store = connect(&args.common).await?;
69 let format = format_of(&args.config)?;
70 let body = std::fs::read_to_string(&args.config).map_err(|e| {
71 CliError::Config(format!(
72 "reading template config '{}': {e}",
73 args.config.display()
74 ))
75 })?;
76 let tags = args
77 .tag
78 .iter()
79 .map(|t| VersionChannel::parse(t))
80 .collect::<CliResult<Vec<_>>>()?;
81 let record = crate::templates::register(
82 &store,
83 RegisterRequest {
84 id: args.id.clone(),
85 body,
86 format,
87 description: args.description.clone(),
88 tags: tags.clone(),
89 launch: args.launch,
90 created_by: None,
91 },
92 )
93 .await?;
94
95 if args.common.json {
96 println!("{}", to_pretty(&record.summary())?);
97 return Ok(());
98 }
99 println!(
100 "registered template '{}' version {}{}",
101 record.id,
102 record.version,
103 if tags.is_empty() {
104 String::new()
105 } else {
106 format!(
107 " (channels: {})",
108 tags.iter()
109 .map(|c| c.as_str())
110 .collect::<Vec<_>>()
111 .join(", ")
112 )
113 }
114 );
115 print_params(&record.summary());
116 println!(
117 "\ntrigger it with:\n faucet template run {} --store {}{}",
118 record.id,
119 args.common.store,
120 required_param_hint(&record.summary())
121 );
122 Ok(())
123}
124
125fn required_param_hint(summary: &TemplateSummary) -> String {
128 summary
129 .params
130 .iter()
131 .filter(|(_, p)| p.required)
132 .map(|(name, p)| format!(" --param {name}=<{}>", p.kind.as_str()))
133 .collect()
134}
135
136fn print_params(summary: &TemplateSummary) {
137 if summary.params.is_empty() {
138 println!("params: (none — this template takes no overrides)");
139 return;
140 }
141 println!("\nparams:");
142 for (name, p) in &summary.params {
143 let requirement = if p.required {
144 "required".to_string()
145 } else {
146 match &p.default {
147 Some(d) => format!("default {d}"),
148 None => "optional".to_string(),
149 }
150 };
151 println!(
152 " {:<20} {:<7} {}{}{}",
153 name,
154 p.kind.as_str(),
155 requirement,
156 if p.secret { " [secret]" } else { "" },
157 match &p.description {
158 Some(d) => format!(" — {d}"),
159 None => String::new(),
160 }
161 );
162 }
163}
164
165async fn list(args: TemplateListArgs) -> CliResult<()> {
166 let store = connect(&args.common).await?;
167 let templates = crate::templates::list_with_state(&store).await?;
168 if args.common.json {
169 println!(
170 "{}",
171 to_pretty(&serde_json::json!({ "templates": templates }))?
172 );
173 return Ok(());
174 }
175 if templates.is_empty() {
176 println!("no templates registered in this store — add one with `faucet template register`");
177 return Ok(());
178 }
179 println!(
183 "{:<26} {:<11} {:<6} {:<7} {:>6} DESCRIPTION",
184 "ID", "STATUS", "LIVE", "NEWEST", "PARAMS"
185 );
186 for t in &templates {
187 let (status, live, newest) = match &t.state {
188 Some(st) => (
189 st.status.to_string(),
190 st.stable.map(|v| format!("v{v}")).unwrap_or("—".into()),
191 st.newest.map(|v| format!("v{v}")).unwrap_or("—".into()),
192 ),
193 None => ("?".into(), "?".into(), format!("v{}", t.version)),
194 };
195 println!(
196 "{:<26} {:<11} {:<6} {:<7} {:>6} {}",
197 t.id,
198 status,
199 live,
200 newest,
201 t.params.len(),
202 t.description.as_deref().unwrap_or("")
203 );
204 }
205 Ok(())
206}
207
208async fn fetch(store: &TemplateStore, id: &str, version: Option<u32>) -> CliResult<TemplateRecord> {
210 store
211 .template_get(id, version)
212 .await
213 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
214 .ok_or_else(|| CliError::UnknownPipelineTemplate {
215 id: id.to_string(),
216 version,
217 })
218}
219
220async fn show(args: TemplateShowArgs) -> CliResult<()> {
221 let store = connect(&args.common).await?;
222 let selector = VersionSelector::parse(&args.version)?;
223 let want = crate::templates::resolve_version(&store, &args.id, selector).await?;
224 let record = fetch(&store, &args.id, Some(want)).await?;
225 let state = crate::templates::template_state(&store, &args.id).await?;
226 let launches = store
227 .template_launches(&args.id)
228 .await
229 .map_err(|e| CliError::Internal(format!("template launch read: {e}")))?;
230
231 if args.common.json {
232 println!(
233 "{}",
234 to_pretty(&serde_json::json!({
235 "template": record,
236 "state": state,
237 "is_stable": state.stable == Some(record.version),
238 "launches": launches,
239 }))?
240 );
241 return Ok(());
242 }
243 println!("template {} [{}]", record.id, state.status);
244 if let Some(name) = &record.name {
245 println!("name {name}");
246 }
247 if let Some(d) = &record.description {
248 println!("about {d}");
249 }
250 println!(
251 "created {}{}",
252 record.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
253 match &record.created_by {
254 Some(p) => format!(" by {p}"),
255 None => String::new(),
256 }
257 );
258 println!(
259 "showing v{}{}",
260 record.version,
261 if state.stable == Some(record.version) {
262 " (live)"
263 } else {
264 ""
265 }
266 );
267 println!("\nversions:");
270 for v in &state.versions {
271 let mut marks: Vec<String> = Vec::new();
272 if state.stable == Some(*v) {
273 marks.push("live".into());
274 }
275 if state.previous == Some(*v) {
276 marks.push("previous".into());
277 }
278 if state.newest == Some(*v) {
279 marks.push("newest".into());
280 }
281 marks.extend(
282 state
283 .tags
284 .iter()
285 .filter(|(_, pointed)| *pointed == v)
286 .map(|(t, _)| t.clone()),
287 );
288 println!(
289 " v{:<4} {}",
290 v,
291 if marks.is_empty() {
292 String::from("—")
293 } else {
294 marks.join(", ")
295 }
296 );
297 }
298 if let Some(d) = &state.deprecation {
299 println!(
300 "\ndeprecated {}{}",
301 d.deprecated_at.format("%Y-%m-%dT%H:%M:%SZ"),
302 match &d.reason {
303 Some(r) => format!(" — {r}"),
304 None => String::new(),
305 }
306 );
307 }
308 if !launches.is_empty() {
309 println!("\nlaunch history (newest first):");
310 for l in launches.iter().take(10) {
311 println!(
312 " v{:<4} {}{}",
313 l.version,
314 l.launched_at.format("%Y-%m-%dT%H:%M:%SZ"),
315 match &l.launched_by {
316 Some(by) => format!(" by {by}"),
317 None => String::new(),
318 }
319 );
320 }
321 }
322 print_params(&record.summary());
323 println!("\nconfig ({:?}, stored verbatim):", record.format);
324 for line in record.body.lines() {
325 println!(" {line}");
326 }
327 Ok(())
328}
329
330async fn delete(args: TemplateDeleteArgs) -> CliResult<()> {
331 let store = connect(&args.common).await?;
332 let pinned = match args.version.as_deref() {
334 None => None,
335 Some(raw) => Some(
338 crate::templates::resolve_version(&store, &args.id, VersionSelector::parse(raw)?)
339 .await?,
340 ),
341 };
342 let removed = store
343 .template_delete(&args.id, pinned)
344 .await
345 .map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
346 if removed == 0 {
347 return Err(CliError::UnknownPipelineTemplate {
348 id: args.id.clone(),
349 version: pinned,
350 });
351 }
352 if args.common.json {
353 println!(
354 "{}",
355 to_pretty(&serde_json::json!({ "id": args.id, "deleted_versions": removed }))?
356 );
357 return Ok(());
358 }
359 println!("deleted {removed} version(s) of template '{}'", args.id);
360 Ok(())
361}
362
363fn report_launch(
365 id: &str,
366 outcome: &crate::templates::LaunchOutcome,
367 json: bool,
368 verb: &str,
369) -> CliResult<()> {
370 if json {
371 println!(
372 "{}",
373 to_pretty(&serde_json::json!({
374 "id": id,
375 "version": outcome.version,
376 "replaced": outcome.replaced,
377 "already_launched": outcome.already_launched,
378 "first_launch": outcome.first_launch,
379 }))?
380 );
381 return Ok(());
382 }
383 if outcome.already_launched {
384 println!(
385 "template '{id}': v{} was already live — nothing changed",
386 outcome.version
387 );
388 return Ok(());
389 }
390 println!(
391 "template '{id}': {verb} v{}{}",
392 outcome.version,
393 match outcome.replaced {
394 Some(prev) => format!(" (was v{prev}; previous → v{prev})"),
395 None => String::from(" — first launch, template is now `launched`"),
396 }
397 );
398 Ok(())
399}
400
401async fn launch(args: TemplateLaunchArgs) -> CliResult<()> {
402 let store = connect(&args.common).await?;
403 let target = VersionSelector::parse(&args.version)?;
404 let outcome = crate::templates::launch(&store, &args.id, target, None).await?;
405 report_launch(&args.id, &outcome, args.common.json, "launched")
406}
407
408async fn rollback(args: TemplateRollbackArgs) -> CliResult<()> {
409 let store = connect(&args.common).await?;
410 let outcome = crate::templates::rollback(&store, &args.id, None).await?;
411 report_launch(&args.id, &outcome, args.common.json, "rolled back to")
412}
413
414async fn deprecate(args: TemplateDeprecateArgs) -> CliResult<()> {
415 let store = connect(&args.common).await?;
416 let status =
417 crate::templates::set_deprecated(&store, &args.id, args.reason.clone(), None, !args.undo)
418 .await?;
419 if args.common.json {
420 println!(
421 "{}",
422 to_pretty(&serde_json::json!({ "id": args.id, "status": status.as_str() }))?
423 );
424 return Ok(());
425 }
426 println!("template '{}' is now {status}", args.id);
427 if !args.undo {
428 println!(
429 " existing callers keep working (pinned runs and `stable` still resolve) but every \
430 trigger warns — use `faucet template delete` for a hard stop"
431 );
432 }
433 Ok(())
434}
435
436async fn promote(args: TemplatePromoteArgs) -> CliResult<()> {
437 let store = connect(&args.common).await?;
438 let tag = VersionChannel::parse(&args.tag)?;
439 let target = VersionSelector::parse(&args.version)?;
440 let version = crate::templates::promote(&store, &args.id, tag, target).await?;
441 if args.common.json {
442 println!(
443 "{}",
444 to_pretty(&serde_json::json!({
445 "id": args.id, "tag": tag.as_str(), "version": version,
446 }))?
447 );
448 return Ok(());
449 }
450 println!("template '{}': {tag} → v{version}", args.id);
451 Ok(())
452}
453
454async fn run_template(args: TemplateRunArgs) -> CliResult<()> {
455 let store = connect(&args.common).await?;
456 let supplied = crate::params::collect_cli_params(&args.param)?;
457 let env = crate::params::collect_env_overrides(&args.param_env)?;
458 let selector = VersionSelector::parse(&args.version)?;
459 let want = crate::templates::resolve_version(&store, &args.id, selector).await?;
460 let materialized = crate::templates::materialize(
461 &store,
462 &args.id,
463 want,
464 &supplied,
465 &env,
466 crate::templates::Materialize::Local,
468 )
469 .await?;
470
471 tracing::info!(
472 template = %materialized.template_id,
473 version = materialized.version,
474 "materialized pipeline template"
475 );
476
477 let doc: serde_json::Value = serde_json::from_str(&materialized.body)
481 .map_err(|e| CliError::Internal(format!("re-parsing materialized template: {e}")))?;
482 let mut cfg = crate::config::PipelineConfig::from_value(doc)?;
483 crate::secrets::resolve_secrets(&mut cfg).await?;
484
485 if args.dry_run && args.common.json {
486 println!("{}", to_pretty(&cfg)?);
487 return Ok(());
488 }
489
490 let run_args = crate::cli::RunArgs {
494 dry_run: args.dry_run,
495 limit: args.limit,
496 no_env_file: true,
497 ..Default::default()
498 };
499 crate::commands::run::execute(cfg, run_args, None).await
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::cli::TemplateStoreArgs;
506
507 fn common(store: &str, json: bool) -> TemplateStoreArgs {
508 TemplateStoreArgs {
509 store: store.to_string(),
510 env_file: None,
511 no_env_file: true,
512 json,
513 }
514 }
515
516 const BODY: &str = "\
517version: 1
518name: cli-tpl
519params:
520 tag: { required: true, description: Output tag }
521 page: { type: int, default: 5 }
522pipeline:
523 source:
524 type: csv
525 config:
526 path: IN_PATH
527 sink:
528 type: jsonl
529 config:
530 path: OUT_PATH
531";
532
533 #[cfg(feature = "serve-history-sqlite")]
537 #[tokio::test]
538 async fn register_launch_promote_run_round_trip() {
539 let dir = tempfile::tempdir().unwrap();
540 let input = dir.path().join("in.csv");
541 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
542 let output = dir.path().join("out.jsonl");
543 let cfg_path = dir.path().join("tpl.yaml");
544 std::fs::write(
545 &cfg_path,
546 BODY.replace("IN_PATH", &input.display().to_string())
547 .replace("OUT_PATH", &output.display().to_string()),
548 )
549 .unwrap();
550 let store = format!("sqlite:{}", dir.path().join("registry.db").display());
551 let reg = |launch: bool, tag: Vec<String>| TemplateRegisterArgs {
552 config: cfg_path.clone(),
553 id: None,
554 description: Some("round trip".into()),
555 tag,
556 launch,
557 common: common(&store, false),
558 };
559
560 register(reg(false, vec!["dev".into()]))
562 .await
563 .expect("register v1");
564 list(TemplateListArgs {
565 common: common(&store, true),
566 })
567 .await
568 .expect("list");
569
570 let err = run_template(TemplateRunArgs {
572 id: "cli-tpl".into(),
573 version: "stable".into(),
574 param: vec!["tag=alpha".into()],
575 param_env: vec![],
576 dry_run: true,
577 limit: None,
578 common: common(&store, false),
579 })
580 .await
581 .unwrap_err()
582 .to_string();
583 assert!(err.contains("no launched version"), "{err}");
584
585 launch(TemplateLaunchArgs {
587 id: "cli-tpl".into(),
588 version: "newest".into(),
589 common: common(&store, false),
590 })
591 .await
592 .expect("launch");
593 run_template(TemplateRunArgs {
594 id: "cli-tpl".into(),
595 version: "stable".into(),
596 param: vec!["tag=alpha".into()],
597 param_env: vec![],
598 dry_run: false,
599 limit: None,
600 common: common(&store, false),
601 })
602 .await
603 .expect("run");
604 assert_eq!(
605 std::fs::read_to_string(&output).unwrap().lines().count(),
606 2,
607 "the launched version's pipeline wrote both records"
608 );
609
610 register(reg(false, vec![])).await.expect("register v2");
612 show(TemplateShowArgs {
613 id: "cli-tpl".into(),
614 version: "stable".into(),
615 common: common(&store, false),
616 })
617 .await
618 .expect("show");
619 promote(TemplatePromoteArgs {
620 id: "cli-tpl".into(),
621 tag: "pre-prod".into(),
622 version: "newest".into(),
623 common: common(&store, false),
624 })
625 .await
626 .expect("promote");
627 launch(TemplateLaunchArgs {
629 id: "cli-tpl".into(),
630 version: "pre-prod".into(),
631 common: common(&store, true),
632 })
633 .await
634 .expect("launch from channel");
635 rollback(TemplateRollbackArgs {
636 id: "cli-tpl".into(),
637 common: common(&store, false),
638 })
639 .await
640 .expect("rollback");
641
642 for tag in ["stable", "previous", "newest", "prd", "latest"] {
644 assert!(
645 promote(TemplatePromoteArgs {
646 id: "cli-tpl".into(),
647 tag: tag.into(),
648 version: "1".into(),
649 common: common(&store, false),
650 })
651 .await
652 .is_err(),
653 "`{tag}` must not be promotable"
654 );
655 }
656
657 deprecate(TemplateDeprecateArgs {
659 id: "cli-tpl".into(),
660 reason: Some("superseded".into()),
661 undo: false,
662 common: common(&store, false),
663 })
664 .await
665 .expect("deprecate");
666 deprecate(TemplateDeprecateArgs {
667 id: "cli-tpl".into(),
668 reason: None,
669 undo: true,
670 common: common(&store, true),
671 })
672 .await
673 .expect("undeprecate");
674
675 delete(TemplateDeleteArgs {
677 id: "cli-tpl".into(),
678 version: Some("newest".into()),
679 common: common(&store, false),
680 })
681 .await
682 .expect("delete newest");
683 delete(TemplateDeleteArgs {
684 id: "cli-tpl".into(),
685 version: None,
686 common: common(&store, true),
687 })
688 .await
689 .expect("delete all");
690 let err = delete(TemplateDeleteArgs {
691 id: "cli-tpl".into(),
692 version: None,
693 common: common(&store, false),
694 })
695 .await
696 .unwrap_err();
697 assert!(
698 matches!(err, CliError::UnknownPipelineTemplate { .. }),
699 "{err:?}"
700 );
701 }
702
703 #[tokio::test]
704 async fn show_and_run_report_an_unknown_template() {
705 let c = common("memory", false);
706 let store = connect(&c).await.unwrap();
707 let err = fetch(&store, "nope", None).await.unwrap_err();
708 assert!(
709 matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
710 "{err:?}"
711 );
712 let err = promote(TemplatePromoteArgs {
715 id: "nope".into(),
716 tag: "prod".into(),
717 version: "newest".into(),
718 common: common("memory", false),
719 })
720 .await
721 .unwrap_err();
722 assert!(
723 matches!(err, CliError::UnknownPipelineTemplate { .. }),
724 "{err:?}"
725 );
726 }
727
728 #[test]
729 fn format_is_taken_from_the_extension() {
730 assert_eq!(
731 format_of(std::path::Path::new("a.yaml")).unwrap(),
732 ConfigFormat::Yaml
733 );
734 assert_eq!(
735 format_of(std::path::Path::new("a.YML")).unwrap(),
736 ConfigFormat::Yaml
737 );
738 assert_eq!(
739 format_of(std::path::Path::new("a.json")).unwrap(),
740 ConfigFormat::Json
741 );
742 assert!(format_of(std::path::Path::new("a.toml")).is_err());
743 assert!(format_of(std::path::Path::new("a")).is_err());
744 }
745
746 #[test]
747 fn required_param_hint_lists_only_required_params() {
748 let mut params = crate::params::ParamsSpec::new();
749 params.insert(
750 "tag".into(),
751 crate::params::ParamSpec {
752 kind: crate::params::ParamType::String,
753 required: true,
754 default: None,
755 secret: false,
756 description: None,
757 },
758 );
759 params.insert(
760 "page".into(),
761 crate::params::ParamSpec {
762 kind: crate::params::ParamType::Int,
763 required: false,
764 default: Some(serde_json::json!(5)),
765 secret: false,
766 description: None,
767 },
768 );
769 let summary = TemplateSummary {
770 state: None,
771 id: "t".into(),
772 version: 1,
773 name: None,
774 description: None,
775 params,
776 created_at: chrono::Utc::now(),
777 created_by: None,
778 };
779 let hint = required_param_hint(&summary);
780 assert_eq!(hint, " --param tag=<string>");
781 print_params(&summary);
783 }
784
785 #[tokio::test]
786 async fn register_rejects_a_bad_extension_and_a_missing_file() {
787 let dir = tempfile::tempdir().unwrap();
788 let bad = dir.path().join("cfg.toml");
789 std::fs::write(&bad, "x = 1").unwrap();
790 let err = register(TemplateRegisterArgs {
791 config: bad,
792 id: None,
793 description: None,
794 tag: vec![],
795 launch: false,
796 common: common("memory", false),
797 })
798 .await
799 .unwrap_err();
800 assert!(matches!(err, CliError::UnknownExtension { .. }), "{err:?}");
801
802 let err = register(TemplateRegisterArgs {
803 config: dir.path().join("nope.yaml"),
804 id: None,
805 description: None,
806 tag: vec![],
807 launch: false,
808 common: common("memory", false),
809 })
810 .await
811 .unwrap_err()
812 .to_string();
813 assert!(err.contains("reading template config"), "{err}");
814 }
815
816 #[tokio::test]
817 async fn list_reports_an_empty_store() {
818 list(TemplateListArgs {
819 common: common("memory", false),
820 })
821 .await
822 .expect("empty list is not an error");
823 }
824}