1use crate::error::{CliError, CliResult};
8use crate::params::{self, BindMode, SuppliedParams};
9use crate::serve::config::HistoryBackendSpec;
10use crate::serve::history::templates::{
11 DeprecationRecord, TemplateDraft, TemplateId, TemplateRecord, TemplateState, TemplateStatus,
12 TemplateSummary, VersionChannel, VersionSelector,
13};
14use crate::serve::history::{self, RunHistory};
15use crate::serve::load::ConfigFormat;
16use serde_json::Value;
17use std::collections::BTreeMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21pub type TemplateStore = Arc<dyn RunHistory>;
25
26#[derive(Debug, Clone)]
28pub struct RegisterRequest {
29 pub id: Option<String>,
31 pub body: String,
33 pub format: ConfigFormat,
34 pub description: Option<String>,
36 pub tags: Vec<VersionChannel>,
41 pub launch: bool,
46 pub created_by: Option<String>,
48}
49
50#[derive(Debug, Clone)]
53pub struct MaterializedConfig {
54 pub template_id: String,
55 pub version: u32,
56 pub name: Option<String>,
58 pub body: String,
61 pub params_redacted: BTreeMap<String, Value>,
64 pub used_secret_params: bool,
66}
67
68impl MaterializedConfig {
69 pub fn format(&self) -> ConfigFormat {
71 ConfigFormat::Json
72 }
73}
74
75fn parse_body(body: &str, format: ConfigFormat) -> CliResult<Value> {
77 match format {
78 ConfigFormat::Yaml => {
79 serde_yaml::from_str(body).map_err(|e| CliError::Config(format!("invalid YAML: {e}")))
80 }
81 ConfigFormat::Json => {
82 serde_json::from_str(body).map_err(|e| CliError::Config(format!("invalid JSON: {e}")))
83 }
84 }
85}
86
87pub async fn register(store: &TemplateStore, req: RegisterRequest) -> CliResult<TemplateRecord> {
101 let mut doc = parse_body(&req.body, req.format)?;
102 if !doc.is_object() {
103 return Err(CliError::Config(
104 "a pipeline template must be a config document (a YAML/JSON mapping)".into(),
105 ));
106 }
107
108 let declared = params::declared(&doc)?;
111
112 let mut probe = doc.clone();
116 params::bind_document(&mut probe, &SuppliedParams::new(), BindMode::Placeholder)?;
117 let cfg = crate::config::PipelineConfig::from_value(probe)?;
118 if crate::topology::is_topology(&cfg) {
119 crate::topology::validate_topology_spec(&cfg)?;
120 } else {
121 for node in crate::expand::expand(&cfg)? {
125 if node.transforms.is_empty() {
126 continue;
127 }
128 crate::transforms::compile_transforms(&node.transforms)
129 .map_err(|e| CliError::Config(format!("row '{}': {e}", node.id)))?;
130 }
131 }
132
133 let id = match &req.id {
134 Some(raw) => TemplateId::parse(raw)?,
135 None => {
136 let name = cfg.name.as_deref().ok_or_else(|| {
137 CliError::Config(
138 "no template id given and the config has no `name:` to derive one from — \
139 pass an explicit id"
140 .into(),
141 )
142 })?;
143 TemplateId::from_config_name(name)?
144 }
145 };
146
147 let _ = &mut doc;
149
150 for tag in &req.tags {
153 reject_derived(*tag)?;
154 }
155
156 let description = match &req.description {
160 Some(d) => Some(d.clone()),
161 None => store
162 .template_get(id.as_str(), None)
163 .await
164 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
165 .and_then(|prev| prev.description),
166 };
167
168 let draft = TemplateDraft {
169 id,
170 name: cfg.name.clone(),
171 description,
172 body: req.body.clone(),
173 format: req.format,
174 params: declared,
175 created_by: req.created_by.clone(),
176 };
177 let record = store
178 .template_register(&draft)
179 .await
180 .map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
181
182 for tag in &req.tags {
184 store
185 .template_set_tag(&record.id, tag.as_str(), record.version)
186 .await
187 .map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
188 }
189 if req.launch {
191 store
192 .template_launch(&record.id, record.version, req.created_by.as_deref())
193 .await
194 .map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
195 }
196 Ok(record)
197}
198
199fn reject_derived(tag: VersionChannel) -> CliResult<()> {
202 if tag.is_derived() {
203 let how = match tag {
204 VersionChannel::Stable => " — move it with `faucet template launch` instead",
205 VersionChannel::Previous => " — it is whatever was launched before the current version",
206 _ => " — it is always the highest version number",
207 };
208 return Err(CliError::Config(format!(
209 "`{tag}` is a derived channel and cannot be promoted{how}. Promotable channels: {}",
210 VersionChannel::ASSIGNABLE
211 .iter()
212 .map(|c| c.as_str())
213 .collect::<Vec<_>>()
214 .join(", ")
215 )));
216 }
217 Ok(())
218}
219
220pub async fn resolve_version(
227 store: &TemplateStore,
228 id: &str,
229 selector: VersionSelector,
230) -> CliResult<u32> {
231 if let VersionSelector::Pinned(n) = selector {
232 return Ok(n);
233 }
234 let channel = selector
235 .channel()
236 .expect("non-pinned selector names a channel");
237 let state = template_state(store, id).await?;
238 if state.versions.is_empty() {
239 return Err(CliError::UnknownPipelineTemplate {
240 id: id.to_string(),
241 version: None,
242 });
243 }
244 state
245 .derived(channel)
246 .ok_or_else(|| unresolved_channel(id, channel, &state))
247}
248
249fn unresolved_channel(id: &str, channel: VersionChannel, state: &TemplateState) -> CliError {
253 let newest = state
254 .newest
255 .map(|v| v.to_string())
256 .unwrap_or_else(|| "1".into());
257 match channel {
258 VersionChannel::Stable => CliError::Config(format!(
261 "template '{id}' has no launched version (status: {}). Launch one first \
262 (`faucet template launch {id} --version {newest}`, or \
263 `POST /v1/templates/{id}/launch`), or select a specific build with \
264 `newest` / a version number",
265 state.status
266 )),
267 VersionChannel::Previous => CliError::Config(format!(
268 "template '{id}' has no previous version — {}. `previous` is the version launched \
269 before the current one, so it only exists after a second launch",
270 match state.stable {
271 Some(v) => format!("v{v} is the first and only launched version"),
272 None => "nothing has been launched yet".to_string(),
273 }
274 )),
275 VersionChannel::Newest => {
278 CliError::Config(format!("template '{id}' has no versions registered"))
279 }
280 assigned => CliError::Config(format!(
281 "template '{id}' has no `{assigned}` version. Channels currently set: {}. Promote one \
282 with `faucet template promote {id} --tag {assigned} --version <n>`",
283 if state.tags.is_empty() {
284 String::from("(none)")
285 } else {
286 state
287 .tags
288 .iter()
289 .map(|(t, v)| format!("{t}=v{v}"))
290 .collect::<Vec<_>>()
291 .join(", ")
292 }
293 )),
294 }
295}
296
297pub async fn list_with_state(store: &TemplateStore) -> CliResult<Vec<TemplateSummary>> {
303 let mut out = store
304 .template_list()
305 .await
306 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?;
307 for summary in &mut out {
308 summary.state = Some(template_state(store, &summary.id).await?);
309 }
310 Ok(out)
311}
312
313pub async fn template_state(store: &TemplateStore, id: &str) -> CliResult<TemplateState> {
316 store
317 .template_state(id)
318 .await
319 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))
320}
321
322async fn require_version(store: &TemplateStore, id: &str, version: u32) -> CliResult<()> {
324 if store
325 .template_get(id, Some(version))
326 .await
327 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
328 .is_none()
329 {
330 return Err(CliError::UnknownPipelineTemplate {
331 id: id.to_string(),
332 version: Some(version),
333 });
334 }
335 Ok(())
336}
337
338pub async fn promote(
346 store: &TemplateStore,
347 id: &str,
348 tag: VersionChannel,
349 target: VersionSelector,
350) -> CliResult<u32> {
351 reject_derived(tag)?;
352 let version = resolve_version(store, id, target).await?;
353 require_version(store, id, version).await?;
354 store
355 .template_set_tag(id, tag.as_str(), version)
356 .await
357 .map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
358 Ok(version)
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct LaunchOutcome {
364 pub version: u32,
366 pub replaced: Option<u32>,
368 pub already_launched: bool,
370 pub first_launch: bool,
372}
373
374pub async fn launch(
384 store: &TemplateStore,
385 id: &str,
386 target: VersionSelector,
387 launched_by: Option<&str>,
388) -> CliResult<LaunchOutcome> {
389 let version = resolve_version(store, id, target).await?;
390 require_version(store, id, version).await?;
391 let before = template_state(store, id).await?;
392 if before.status == TemplateStatus::Deprecated {
393 return Err(CliError::Config(format!(
394 "template '{id}' is deprecated — un-deprecate it first with \
395 `faucet template deprecate {id} --undo`, then launch"
396 )));
397 }
398 let seq = store
399 .template_launch(id, version, launched_by)
400 .await
401 .map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
402 Ok(LaunchOutcome {
403 version,
404 replaced: before.stable,
405 already_launched: seq.is_none(),
406 first_launch: before.stable.is_none(),
407 })
408}
409
410pub async fn rollback(
413 store: &TemplateStore,
414 id: &str,
415 launched_by: Option<&str>,
416) -> CliResult<LaunchOutcome> {
417 launch(
418 store,
419 id,
420 VersionSelector::Channel(VersionChannel::Previous),
421 launched_by,
422 )
423 .await
424}
425
426pub async fn set_deprecated(
434 store: &TemplateStore,
435 id: &str,
436 reason: Option<String>,
437 by: Option<&str>,
438 deprecated: bool,
439) -> CliResult<TemplateStatus> {
440 let state = template_state(store, id).await?;
441 if state.versions.is_empty() {
442 return Err(CliError::UnknownPipelineTemplate {
443 id: id.to_string(),
444 version: None,
445 });
446 }
447 let record = deprecated.then(|| DeprecationRecord {
448 deprecated_at: chrono::Utc::now(),
449 deprecated_by: by.map(str::to_string),
450 reason,
451 });
452 store
453 .template_set_deprecation(id, record.as_ref())
454 .await
455 .map_err(|e| CliError::Internal(format!("template deprecation write: {e}")))?;
456 Ok(TemplateStatus::derive(state.stable.is_some(), deprecated))
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum Materialize {
463 Local,
467 Persisted,
472}
473
474pub async fn materialize(
492 store: &TemplateStore,
493 id: &str,
494 version: u32,
495 supplied: &SuppliedParams,
496 env_overrides: &BTreeMap<String, String>,
497 mode: Materialize,
498) -> CliResult<MaterializedConfig> {
499 let record = store
503 .template_get(id, Some(version))
504 .await
505 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
506 .ok_or_else(|| CliError::UnknownPipelineTemplate {
507 id: id.to_string(),
508 version: Some(version),
509 })?;
510
511 let mut doc = parse_body(&record.body, record.format)?;
512 if mode == Materialize::Local {
513 let overlay: crate::interpolate::EnvOverlay = env_overrides
514 .iter()
515 .map(|(k, v)| (k.clone(), v.clone()))
516 .collect();
517 crate::interpolate::interpolate_value_with_env(&mut doc, &overlay)?;
518 }
519 let bound = params::bind_document(&mut doc, supplied, BindMode::Strict)?;
520 if let Some(map) = doc.as_object_mut() {
526 map.remove(params::PARAMS_KEY);
527 }
528
529 let body = serde_json::to_string(&doc)
530 .map_err(|e| CliError::Internal(format!("re-serializing template body: {e}")))?;
531 Ok(MaterializedConfig {
532 template_id: record.id.clone(),
533 version: record.version,
534 name: record.name.clone(),
535 body,
536 params_redacted: bound.redacted(),
537 used_secret_params: bound.has_secrets(),
538 })
539}
540
541pub async fn resolve_store_url(url: &str) -> CliResult<TemplateStore> {
546 let backend = match url {
547 "memory" => HistoryBackendSpec::Memory,
548 u if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
549 HistoryBackendSpec::Postgres(u.to_string())
550 }
551 u if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u.to_string()),
552 other => {
553 return Err(CliError::Config(format!(
554 "template store '{other}' is not recognised — expected 'memory', \
555 'sqlite:<path>', or a 'postgres://…' URL"
556 )));
557 }
558 };
559 history::connect(
560 &backend,
561 Duration::from_secs(3600),
564 Duration::from_secs(30),
565 &uuid::Uuid::now_v7().to_string(),
566 )
567 .await
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::serve::history::memory::MemoryHistory;
574 use serde_json::json;
575
576 fn store() -> TemplateStore {
577 Arc::new(MemoryHistory::new(Duration::from_secs(60))) as TemplateStore
578 }
579
580 const PARAMETERIZED: &str = "\
581version: 1
582name: tenant-sync
583params:
584 tenant_id: { required: true, description: Tenant to sync }
585 since: { default: \"1970-01-01\" }
586 page: { type: int, default: 100 }
587pipeline:
588 source:
589 type: rest
590 config:
591 url: \"https://api.example.com/${param.tenant_id}/events?since=${param.since}\"
592 sink:
593 type: jsonl
594 config:
595 path: ./out.jsonl
596";
597
598 fn req(body: &str) -> RegisterRequest {
599 RegisterRequest {
600 id: None,
601 body: body.to_string(),
602 format: ConfigFormat::Yaml,
603 description: Some("test".into()),
604 tags: Vec::new(),
605 launch: false,
606 created_by: Some("tester".into()),
607 }
608 }
609
610 fn req_launched(body: &str) -> RegisterRequest {
612 RegisterRequest {
613 launch: true,
614 ..req(body)
615 }
616 }
617
618 #[tokio::test]
619 async fn registers_and_versions() {
620 let s = store();
621 let first = register(&s, req(PARAMETERIZED)).await.unwrap();
622 assert_eq!(first.id, "tenant-sync");
623 assert_eq!(first.version, 1);
624 assert_eq!(first.created_by.as_deref(), Some("tester"));
625 assert!(first.params["tenant_id"].required);
626 assert_eq!(first.params["page"].default, Some(json!(100)));
627 assert_eq!(first.body, PARAMETERIZED, "body stored verbatim");
628
629 let second = register(&s, req(PARAMETERIZED)).await.unwrap();
630 assert_eq!(second.version, 2);
631 assert_eq!(
632 s.template_versions("tenant-sync").await.unwrap(),
633 vec![2, 1]
634 );
635 let listed = list_with_state(&s).await.unwrap();
636 assert_eq!(listed.len(), 1, "list folds to one row per id");
637 let st = listed[0].state.as_ref().unwrap();
639 assert_eq!(st.status, TemplateStatus::Draft);
640 assert_eq!(st.newest, Some(2));
641 assert_eq!(st.stable, None);
642 }
643
644 #[tokio::test]
645 async fn register_compiles_transforms_not_just_their_shape() {
646 let s = store();
647 let mut bad = req(r#"
650version: 1
651name: tenant-sync
652pipeline:
653 source: { type: rest, config: {} }
654 transforms:
655 - type: set
656 config: { fields: { a: 1 } }
657 sink: { type: jsonl, config: { path: ./o.jsonl } }
658"#);
659 bad.description = None;
660 let err = register(&s, bad).await.unwrap_err().to_string();
661 assert!(err.contains("values"), "names the missing field: {err}");
662 assert!(
663 s.template_versions("tenant-sync").await.unwrap().is_empty(),
664 "nothing is persisted when validation fails"
665 );
666 }
667
668 #[tokio::test]
669 async fn a_description_carries_forward_across_registers() {
670 let s = store();
671 let first = register(&s, req(PARAMETERIZED)).await.unwrap();
672 assert_eq!(first.description.as_deref(), Some("test"));
673
674 let mut bare = req(PARAMETERIZED);
676 bare.description = None;
677 let second = register(&s, bare).await.unwrap();
678 assert_eq!(second.description.as_deref(), Some("test"));
679
680 let mut changed = req(PARAMETERIZED);
682 changed.description = Some("now something else".into());
683 let third = register(&s, changed).await.unwrap();
684 assert_eq!(third.description.as_deref(), Some("now something else"));
685
686 let mut bare2 = req(PARAMETERIZED);
688 bare2.description = None;
689 let fourth = register(&s, bare2).await.unwrap();
690 assert_eq!(fourth.description.as_deref(), Some("now something else"));
691 }
692
693 #[tokio::test]
694 async fn a_register_never_moves_existing_callers() {
695 let s = store();
696 register(&s, req_launched(PARAMETERIZED)).await.unwrap(); assert_eq!(
698 resolve_version(&s, "tenant-sync", VersionSelector::stable())
699 .await
700 .unwrap(),
701 1
702 );
703
704 register(&s, req(PARAMETERIZED)).await.unwrap();
707 assert_eq!(
708 resolve_version(&s, "tenant-sync", VersionSelector::stable())
709 .await
710 .unwrap(),
711 1,
712 "registering a build must not move the launched version"
713 );
714 assert_eq!(
715 resolve_version(&s, "tenant-sync", VersionSelector::newest())
716 .await
717 .unwrap(),
718 2,
719 "`newest` is how you reach the un-launched build"
720 );
721
722 let out = launch(&s, "tenant-sync", VersionSelector::newest(), Some("alice"))
724 .await
725 .unwrap();
726 assert_eq!((out.version, out.replaced), (2, Some(1)));
727 assert!(!out.first_launch);
728 assert_eq!(
729 resolve_version(&s, "tenant-sync", VersionSelector::stable())
730 .await
731 .unwrap(),
732 2
733 );
734 assert_eq!(
736 resolve_version(
737 &s,
738 "tenant-sync",
739 VersionSelector::Channel(VersionChannel::Previous)
740 )
741 .await
742 .unwrap(),
743 1
744 );
745 }
746
747 #[tokio::test]
748 async fn draft_template_has_no_stable_and_says_how_to_fix_it() {
749 let s = store();
750 register(&s, req(PARAMETERIZED)).await.unwrap();
751 let state = template_state(&s, "tenant-sync").await.unwrap();
752 assert_eq!(state.status, TemplateStatus::Draft);
753
754 let err = resolve_version(&s, "tenant-sync", VersionSelector::stable())
757 .await
758 .unwrap_err()
759 .to_string();
760 assert!(err.contains("no launched version"), "{err}");
761 assert!(err.contains("faucet template launch"), "{err}");
762 assert_eq!(
764 resolve_version(&s, "tenant-sync", VersionSelector::newest())
765 .await
766 .unwrap(),
767 1
768 );
769 assert_eq!(
770 resolve_version(&s, "tenant-sync", VersionSelector::Pinned(1))
771 .await
772 .unwrap(),
773 1
774 );
775 }
776
777 #[tokio::test]
778 async fn first_launch_flips_status_and_relaunch_is_a_noop() {
779 let s = store();
780 register(&s, req(PARAMETERIZED)).await.unwrap();
781 let out = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
782 .await
783 .unwrap();
784 assert!(out.first_launch);
785 assert_eq!(out.replaced, None);
786 assert_eq!(
787 template_state(&s, "tenant-sync").await.unwrap().status,
788 TemplateStatus::Launched
789 );
790
791 let again = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
794 .await
795 .unwrap();
796 assert!(again.already_launched);
797 assert_eq!(s.template_launches("tenant-sync").await.unwrap().len(), 1);
798 let err = resolve_version(
799 &s,
800 "tenant-sync",
801 VersionSelector::Channel(VersionChannel::Previous),
802 )
803 .await
804 .unwrap_err()
805 .to_string();
806 assert!(err.contains("no previous version"), "{err}");
807 }
808
809 #[tokio::test]
810 async fn rollback_returns_to_the_prior_launch() {
811 let s = store();
812 for _ in 0..3 {
813 register(&s, req(PARAMETERIZED)).await.unwrap();
814 }
815 launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
816 .await
817 .unwrap();
818 launch(&s, "tenant-sync", VersionSelector::Pinned(3), None)
819 .await
820 .unwrap();
821
822 let out = rollback(&s, "tenant-sync", Some("oncall")).await.unwrap();
823 assert_eq!(out.version, 1, "rollback re-launches `previous`");
824 assert_eq!(out.replaced, Some(3));
825 let state = template_state(&s, "tenant-sync").await.unwrap();
826 assert_eq!(state.stable, Some(1));
827 assert_eq!(
828 state.previous,
829 Some(3),
830 "previous now points at what we left"
831 );
832
833 let log = s.template_launches("tenant-sync").await.unwrap();
835 assert_eq!(
836 log.iter().map(|l| l.version).collect::<Vec<_>>(),
837 vec![1, 3, 1]
838 );
839 assert_eq!(log[0].launched_by.as_deref(), Some("oncall"));
840 }
841
842 #[tokio::test]
843 async fn deprecation_is_template_wide_and_reversible() {
844 let s = store();
845 register(&s, req_launched(PARAMETERIZED)).await.unwrap();
846
847 let status = set_deprecated(
848 &s,
849 "tenant-sync",
850 Some("superseded".into()),
851 Some("bob"),
852 true,
853 )
854 .await
855 .unwrap();
856 assert_eq!(status, TemplateStatus::Deprecated);
857 let state = template_state(&s, "tenant-sync").await.unwrap();
858 assert_eq!(state.status, TemplateStatus::Deprecated);
859 assert_eq!(
860 state.deprecation.as_ref().unwrap().reason.as_deref(),
861 Some("superseded")
862 );
863 assert_eq!(
865 resolve_version(&s, "tenant-sync", VersionSelector::stable())
866 .await
867 .unwrap(),
868 1
869 );
870 let err = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
873 .await
874 .unwrap_err()
875 .to_string();
876 assert!(err.contains("deprecated"), "{err}");
877
878 let status = set_deprecated(&s, "tenant-sync", None, None, false)
880 .await
881 .unwrap();
882 assert_eq!(status, TemplateStatus::Launched);
883 assert!(
884 template_state(&s, "tenant-sync")
885 .await
886 .unwrap()
887 .deprecation
888 .is_none()
889 );
890 assert!(matches!(
892 set_deprecated(&s, "nope", None, None, true)
893 .await
894 .unwrap_err(),
895 CliError::UnknownPipelineTemplate { .. }
896 ));
897 }
898
899 #[tokio::test]
900 async fn explicit_id_wins_and_is_validated() {
901 let s = store();
902 let mut r = req(PARAMETERIZED);
903 r.id = Some("my-template".into());
904 assert_eq!(register(&s, r).await.unwrap().id, "my-template");
905
906 let mut bad = req(PARAMETERIZED);
907 bad.id = Some("Bad Id".into());
908 assert!(register(&s, bad).await.is_err());
909 }
910
911 #[tokio::test]
912 async fn register_requires_an_id_source() {
913 let s = store();
914 let body = "version: 1\npipeline:\n source: { type: csv, config: { path: a.csv } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n";
915 let err = register(&s, req(body)).await.unwrap_err().to_string();
916 assert!(err.contains("no template id"), "{err}");
917 }
918
919 #[tokio::test]
920 async fn register_rejects_a_structurally_invalid_config() {
921 let s = store();
922 let err = register(&s, req("version: 1\nname: x\nnope: 1\npipeline: {}\n"))
923 .await
924 .unwrap_err()
925 .to_string();
926 assert!(err.contains("nope") || err.contains("pipeline"), "{err}");
927 }
928
929 #[tokio::test]
930 async fn register_rejects_an_invalid_params_block() {
931 let s = store();
932 let body = "version: 1\nname: x\nparams:\n a: { required: true, default: 1 }\npipeline:\n source: { type: csv, config: { path: a.csv } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n";
933 let err = register(&s, req(body)).await.unwrap_err().to_string();
934 assert!(err.contains("required"), "{err}");
935 }
936
937 #[tokio::test]
938 async fn register_rejects_a_non_mapping_body() {
939 let s = store();
940 let err = register(&s, req("- a\n- b\n"))
941 .await
942 .unwrap_err()
943 .to_string();
944 assert!(err.contains("mapping"), "{err}");
945 let err = register(&s, req(": :\n")).await.unwrap_err().to_string();
946 assert!(err.contains("YAML"), "{err}");
947 }
948
949 #[tokio::test]
950 async fn materialize_binds_params_and_defaults() {
951 let s = store();
952 register(&s, req_launched(PARAMETERIZED)).await.unwrap();
953 let supplied: SuppliedParams = [("tenant_id".to_string(), json!("acme"))].into();
954 let want = resolve_version(&s, "tenant-sync", VersionSelector::stable())
955 .await
956 .unwrap();
957 let out = materialize(
958 &s,
959 "tenant-sync",
960 want,
961 &supplied,
962 &BTreeMap::new(),
963 Materialize::Local,
964 )
965 .await
966 .unwrap();
967 assert_eq!(out.version, 1);
968 assert_eq!(out.name.as_deref(), Some("tenant-sync"));
969 assert_eq!(out.format(), ConfigFormat::Json);
970 let doc: Value = serde_json::from_str(&out.body).unwrap();
971 assert_eq!(
972 doc["pipeline"]["source"]["config"]["url"],
973 "https://api.example.com/acme/events?since=1970-01-01"
974 );
975 assert_eq!(out.params_redacted["tenant_id"], json!("acme"));
976 assert_eq!(out.params_redacted["page"], json!(100));
977 assert!(!out.used_secret_params);
978 }
979
980 #[tokio::test]
981 async fn materialize_reports_missing_and_unknown_params() {
982 let s = store();
983 register(&s, req_launched(PARAMETERIZED)).await.unwrap();
984 let err = materialize(
985 &s,
986 "tenant-sync",
987 1,
988 &SuppliedParams::new(),
989 &BTreeMap::new(),
990 Materialize::Local,
991 )
992 .await
993 .unwrap_err();
994 assert!(matches!(err, CliError::MissingParam { .. }), "{err:?}");
995
996 let supplied: SuppliedParams = [
997 ("tenant_id".to_string(), json!("a")),
998 ("bogus".to_string(), json!("b")),
999 ]
1000 .into();
1001 let err = materialize(
1002 &s,
1003 "tenant-sync",
1004 1,
1005 &supplied,
1006 &BTreeMap::new(),
1007 Materialize::Local,
1008 )
1009 .await
1010 .unwrap_err();
1011 assert!(matches!(err, CliError::UnknownParam { .. }), "{err:?}");
1012 }
1013
1014 #[tokio::test]
1015 async fn unknown_template_and_version_are_typed_errors() {
1016 let s = store();
1017 register(&s, req_launched(PARAMETERIZED)).await.unwrap();
1018 let err = resolve_version(&s, "nope", VersionSelector::stable())
1019 .await
1020 .unwrap_err();
1021 assert!(
1022 matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
1023 "{err:?}"
1024 );
1025 let supplied: SuppliedParams = [("tenant_id".to_string(), json!("a"))].into();
1026 let err = materialize(
1027 &s,
1028 "tenant-sync",
1029 9,
1030 &supplied,
1031 &BTreeMap::new(),
1032 Materialize::Local,
1033 )
1034 .await
1035 .unwrap_err();
1036 assert!(
1037 matches!(
1038 err,
1039 CliError::UnknownPipelineTemplate {
1040 version: Some(9),
1041 ..
1042 }
1043 ),
1044 "{err:?}"
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn env_overrides_win_over_the_process_environment() {
1050 let s = store();
1051 let body = "\
1052version: 1
1053name: env-template
1054pipeline:
1055 source: { type: rest, config: { url: \"https://x/${env:FAUCET_TPL_REGION}\" } }
1056 sink: { type: jsonl, config: { path: ./o.jsonl } }
1057";
1058 unsafe { std::env::set_var("FAUCET_TPL_REGION", "from-process") };
1059 register(&s, req_launched(body)).await.unwrap();
1060
1061 let out = materialize(
1062 &s,
1063 "env-template",
1064 1,
1065 &SuppliedParams::new(),
1066 &BTreeMap::new(),
1067 Materialize::Local,
1068 )
1069 .await
1070 .unwrap();
1071 let doc: Value = serde_json::from_str(&out.body).unwrap();
1072 assert_eq!(
1073 doc["pipeline"]["source"]["config"]["url"],
1074 "https://x/from-process"
1075 );
1076
1077 let overrides: BTreeMap<String, String> =
1078 [("FAUCET_TPL_REGION".to_string(), "from-request".to_string())].into();
1079 let out = materialize(
1080 &s,
1081 "env-template",
1082 1,
1083 &SuppliedParams::new(),
1084 &overrides,
1085 Materialize::Local,
1086 )
1087 .await
1088 .unwrap();
1089 let doc: Value = serde_json::from_str(&out.body).unwrap();
1090 assert_eq!(
1091 doc["pipeline"]["source"]["config"]["url"],
1092 "https://x/from-request"
1093 );
1094 assert_eq!(std::env::var("FAUCET_TPL_REGION").unwrap(), "from-process");
1095 unsafe { std::env::remove_var("FAUCET_TPL_REGION") };
1096 }
1097
1098 #[tokio::test]
1099 async fn secret_params_are_flagged_and_redacted() {
1100 let s = store();
1101 let body = "\
1102version: 1
1103name: secret-template
1104params:
1105 api_token: { required: true, secret: true }
1106pipeline:
1107 source:
1108 type: rest
1109 config:
1110 url: https://api.example.com/events
1111 auth: { type: bearer, config: { token: \"${param.api_token}\" } }
1112 sink: { type: jsonl, config: { path: ./o.jsonl } }
1113";
1114 register(&s, req_launched(body)).await.unwrap();
1115 let supplied: SuppliedParams =
1116 [("api_token".to_string(), json!("tok-abcdefghijklmnop"))].into();
1117 let out = materialize(
1118 &s,
1119 "secret-template",
1120 1,
1121 &supplied,
1122 &BTreeMap::new(),
1123 Materialize::Local,
1124 )
1125 .await
1126 .unwrap();
1127 assert!(out.used_secret_params);
1128 assert_eq!(out.params_redacted["api_token"], json!("***"));
1129 assert!(out.body.contains("tok-abcdefghijklmnop"));
1130 assert_eq!(
1131 crate::secrets::registry::redact("token=tok-abcdefghijklmnop"),
1132 "token=***"
1133 );
1134 }
1135
1136 #[tokio::test]
1137 async fn channels_are_promoted_independently_of_launching() {
1138 let s = store();
1139 register(&s, req_launched(PARAMETERIZED)).await.unwrap(); register(&s, req(PARAMETERIZED)).await.unwrap(); let mut tagged = req(PARAMETERIZED);
1142 tagged.tags = vec![VersionChannel::Dev];
1143 register(&s, tagged).await.unwrap(); assert_eq!(
1146 resolve_version(
1147 &s,
1148 "tenant-sync",
1149 VersionSelector::Channel(VersionChannel::Dev)
1150 )
1151 .await
1152 .unwrap(),
1153 3
1154 );
1155 assert_eq!(
1157 promote(
1158 &s,
1159 "tenant-sync",
1160 VersionChannel::PreProd,
1161 VersionSelector::Channel(VersionChannel::Dev)
1162 )
1163 .await
1164 .unwrap(),
1165 3
1166 );
1167 let state = template_state(&s, "tenant-sync").await.unwrap();
1168 assert_eq!(state.stable, Some(1), "promote must not move `stable`");
1169 assert_eq!(state.tags["dev"], 3);
1170 assert_eq!(state.tags["pre-prod"], 3);
1171 assert!(!state.tags.contains_key("stable"), "derived, never stored");
1172
1173 let out = launch(
1175 &s,
1176 "tenant-sync",
1177 VersionSelector::Channel(VersionChannel::PreProd),
1178 None,
1179 )
1180 .await
1181 .unwrap();
1182 assert_eq!((out.version, out.replaced), (3, Some(1)));
1183 }
1184
1185 #[tokio::test]
1186 async fn derived_channels_cannot_be_promoted() {
1187 let s = store();
1188 register(&s, req_launched(PARAMETERIZED)).await.unwrap();
1189 for (tag, needle) in [
1190 (VersionChannel::Stable, "launch"),
1191 (VersionChannel::Previous, "launched before"),
1192 (VersionChannel::Newest, "highest version"),
1193 ] {
1194 let err = promote(&s, "tenant-sync", tag, VersionSelector::Pinned(1))
1195 .await
1196 .unwrap_err()
1197 .to_string();
1198 assert!(err.contains("derived"), "{tag}: {err}");
1199 assert!(err.contains(needle), "{tag}: {err}");
1200 }
1201 let mut bad = req(PARAMETERIZED);
1203 bad.tags = vec![VersionChannel::Stable];
1204 assert!(register(&s, bad).await.is_err());
1205 assert_eq!(
1206 s.template_versions("tenant-sync").await.unwrap(),
1207 vec![1],
1208 "the rejected register must not have appended a version"
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn promoting_to_a_missing_version_is_rejected() {
1214 let s = store();
1215 register(&s, req(PARAMETERIZED)).await.unwrap();
1216 let err = promote(
1217 &s,
1218 "tenant-sync",
1219 VersionChannel::Prod,
1220 VersionSelector::Pinned(9),
1221 )
1222 .await
1223 .unwrap_err();
1224 assert!(
1225 matches!(
1226 err,
1227 CliError::UnknownPipelineTemplate {
1228 version: Some(9),
1229 ..
1230 }
1231 ),
1232 "{err:?}"
1233 );
1234 assert!(matches!(
1235 promote(&s, "nope", VersionChannel::Prod, VersionSelector::Pinned(1))
1236 .await
1237 .unwrap_err(),
1238 CliError::UnknownPipelineTemplate { .. }
1239 ));
1240 }
1241
1242 #[tokio::test]
1243 async fn deleting_a_version_drops_pointers_aimed_at_it() {
1244 let s = store();
1245 register(&s, req(PARAMETERIZED)).await.unwrap();
1246 register(&s, req(PARAMETERIZED)).await.unwrap();
1247 launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
1248 .await
1249 .unwrap();
1250 launch(&s, "tenant-sync", VersionSelector::Pinned(2), None)
1251 .await
1252 .unwrap();
1253 promote(
1254 &s,
1255 "tenant-sync",
1256 VersionChannel::Prod,
1257 VersionSelector::Pinned(1),
1258 )
1259 .await
1260 .unwrap();
1261
1262 assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
1265 let state = template_state(&s, "tenant-sync").await.unwrap();
1266 assert!(!state.tags.contains_key("prod"), "{:?}", state.tags);
1267 assert_eq!(state.stable, Some(2));
1268 assert_eq!(state.previous, None, "v1's launch entry went with it");
1269
1270 s.template_delete("tenant-sync", None).await.unwrap();
1271 assert!(s.template_launches("tenant-sync").await.unwrap().is_empty());
1272 assert!(s.template_tags("tenant-sync").await.unwrap().is_empty());
1273 }
1274
1275 #[tokio::test]
1276 async fn delete_removes_one_version_or_all() {
1277 let s = store();
1278 register(&s, req(PARAMETERIZED)).await.unwrap();
1279 register(&s, req(PARAMETERIZED)).await.unwrap();
1280 assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
1281 assert_eq!(s.template_versions("tenant-sync").await.unwrap(), vec![2]);
1282 assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 1);
1283 assert!(s.template_list().await.unwrap().is_empty());
1284 assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 0);
1285 assert_eq!(s.template_delete("tenant-sync", Some(3)).await.unwrap(), 0);
1286 }
1287
1288 #[tokio::test]
1289 async fn registers_a_topology_config() {
1290 let s = store();
1291 let body = "\
1292version: 1
1293name: topo-template
1294params:
1295 path: { default: ./in.csv }
1296pipeline:
1297 sources:
1298 s: { type: csv, config: { path: \"${param.path}\" } }
1299 sinks:
1300 o: { type: jsonl, config: { path: ./out.jsonl } }
1301 nodes:
1302 src: { kind: source, ref: s }
1303 w: { kind: sink, ref: o }
1304 edges:
1305 - { from: src, to: w }
1306";
1307 let rec = register(&s, req_launched(body)).await.unwrap();
1308 assert_eq!(rec.id, "topo-template");
1309 let out = materialize(
1310 &s,
1311 "topo-template",
1312 1,
1313 &SuppliedParams::new(),
1314 &BTreeMap::new(),
1315 Materialize::Local,
1316 )
1317 .await
1318 .unwrap();
1319 let doc: Value = serde_json::from_str(&out.body).unwrap();
1320 assert_eq!(
1321 doc["pipeline"]["sources"]["s"]["config"]["path"],
1322 "./in.csv"
1323 );
1324 }
1325
1326 #[tokio::test]
1327 async fn version_history_is_bounded() {
1328 use crate::serve::history::templates::VERSION_RETAIN;
1329 let s = store();
1330 for _ in 0..(VERSION_RETAIN + 3) {
1331 register(&s, req(PARAMETERIZED)).await.unwrap();
1332 }
1333 let versions = s.template_versions("tenant-sync").await.unwrap();
1334 assert_eq!(versions.len(), VERSION_RETAIN);
1335 assert_eq!(versions[0], (VERSION_RETAIN + 3) as u32, "newest kept");
1336 assert!(!versions.contains(&1), "oldest pruned");
1337 }
1338
1339 #[tokio::test]
1340 async fn store_url_grammar() {
1341 assert!(resolve_store_url("memory").await.is_ok());
1342 match resolve_store_url("mysql://nope").await {
1344 Ok(_) => panic!("an unrecognised scheme must be rejected"),
1345 Err(e) => assert!(e.to_string().contains("template store"), "{e}"),
1346 }
1347 let dir = tempfile::tempdir().unwrap();
1350 let url = format!("sqlite:{}", dir.path().join("t.db").display());
1351 if let Err(e) = resolve_store_url(&url).await {
1352 assert!(e.to_string().contains("serve-history-sqlite"), "{e}");
1353 }
1354 }
1355}