1use crate::params::SuppliedParams;
13use crate::serve::error::ServeError;
14use crate::serve::history::templates::{
15 TemplateRecord, TemplateState, TemplateSummary, VersionChannel, VersionSelector,
16};
17use crate::serve::rbac::AuthContext;
18use crate::serve::runner::{self, ConfigFormatWire, SubmitRequest, SubmitResponse};
19use crate::serve::state::ServerState;
20use crate::templates::{RegisterRequest, TemplateStore};
21use axum::Json;
22use axum::extract::{Extension, Path, Query, State};
23use axum::http::StatusCode;
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use std::collections::BTreeMap;
27
28fn map_err(e: crate::error::CliError) -> ServeError {
32 use crate::error::CliError;
33 match e {
34 CliError::UnknownPipelineTemplate { .. } => ServeError::NotFound,
35 CliError::Internal(m) => ServeError::Internal(m),
36 other => ServeError::Unprocessable {
37 message: other.to_string(),
38 details: None,
39 },
40 }
41}
42
43fn store(state: &ServerState) -> TemplateStore {
47 state.history()
48}
49
50#[derive(Debug, Deserialize)]
54pub struct RegisterBody {
55 #[serde(default)]
57 pub id: Option<String>,
58 pub config: String,
60 #[serde(default)]
61 pub config_format: ConfigFormatWire,
62 #[serde(default)]
63 pub description: Option<String>,
64 #[serde(default)]
67 pub tags: Vec<VersionChannel>,
68 #[serde(default)]
71 pub launch: bool,
72}
73
74pub async fn register_template(
76 State(state): State<ServerState>,
77 Extension(actor): Extension<AuthContext>,
78 Json(body): Json<RegisterBody>,
79) -> Result<(StatusCode, Json<TemplateSummary>), ServeError> {
80 let record = crate::templates::register(
81 &store(&state),
82 RegisterRequest {
83 id: body.id,
84 body: body.config,
85 format: body.config_format.into(),
86 description: body.description,
87 tags: body.tags,
88 launch: body.launch,
89 created_by: Some(actor.principal.clone()),
90 },
91 )
92 .await
93 .map_err(map_err)?;
94
95 let fingerprint = crate::serve::idempotency::fingerprint(
100 &serde_json::Value::String(record.body.clone()),
101 record.name.as_deref(),
102 );
103 tracing::info!(
104 principal = %actor.principal,
105 template = %record.id,
106 version = record.version,
107 "registered pipeline template"
108 );
109 crate::serve::audit::write(
110 &state,
111 &actor,
112 "template.register",
113 None,
114 Some(fingerprint),
115 "ok",
116 )
117 .await;
118 Ok((StatusCode::CREATED, Json(record.summary())))
119}
120
121#[derive(Debug, Serialize)]
125pub struct ListResponse {
126 pub templates: Vec<TemplateSummary>,
127}
128
129pub async fn list_templates(
131 State(state): State<ServerState>,
132) -> Result<Json<ListResponse>, ServeError> {
133 let templates = crate::templates::list_with_state(&store(&state))
134 .await
135 .map_err(map_err)?;
136 Ok(Json(ListResponse { templates }))
137}
138
139#[derive(Debug, Default, Deserialize)]
145pub struct VersionQuery {
146 #[serde(default)]
147 pub version: Option<VersionSelector>,
148}
149
150impl VersionQuery {
151 fn selector(&self) -> VersionSelector {
153 self.version.unwrap_or_default()
154 }
155}
156
157#[derive(Debug, Serialize)]
161pub struct GetResponse {
162 #[serde(flatten)]
163 pub template: TemplateRecord,
164 #[serde(flatten)]
167 pub state: TemplateState,
168 pub is_stable: bool,
170 pub launches: Vec<crate::serve::history::templates::LaunchRecord>,
172}
173
174pub async fn get_template(
176 State(state): State<ServerState>,
177 Path(id): Path<String>,
178 Query(q): Query<VersionQuery>,
179) -> Result<Json<GetResponse>, ServeError> {
180 let s = store(&state);
181 let want = crate::templates::resolve_version(&s, &id, q.selector())
182 .await
183 .map_err(map_err)?;
184 let template = s
185 .template_get(&id, Some(want))
186 .await
187 .map_err(|e| ServeError::Internal(e.to_string()))?
188 .ok_or(ServeError::NotFound)?;
189 let state = crate::templates::template_state(&s, &id)
190 .await
191 .map_err(map_err)?;
192 let launches = s
193 .template_launches(&id)
194 .await
195 .map_err(|e| ServeError::Internal(e.to_string()))?;
196 let is_stable = state.stable == Some(template.version);
197 Ok(Json(GetResponse {
198 template,
199 state,
200 is_stable,
201 launches,
202 }))
203}
204
205pub async fn delete_template(
211 State(state): State<ServerState>,
212 Extension(actor): Extension<AuthContext>,
213 Path(id): Path<String>,
214 Query(q): Query<VersionQuery>,
215) -> Result<StatusCode, ServeError> {
216 let s = store(&state);
217 let target = match q.version {
222 None => None,
223 Some(selector) => Some(
226 crate::templates::resolve_version(&s, &id, selector)
227 .await
228 .map_err(map_err)?,
229 ),
230 };
231 let removed = s
232 .template_delete(&id, target)
233 .await
234 .map_err(|e| ServeError::Internal(e.to_string()))?;
235 if removed == 0 {
236 return Err(ServeError::NotFound);
237 }
238 tracing::info!(
239 principal = %actor.principal,
240 template = %id,
241 version = ?target,
242 removed,
243 "deleted pipeline template version(s)"
244 );
245 crate::serve::audit::write(&state, &actor, "template.delete", None, None, "ok").await;
246 Ok(StatusCode::NO_CONTENT)
247}
248
249#[derive(Debug, Deserialize)]
254pub struct PromoteBody {
255 pub tag: VersionChannel,
257 #[serde(default)]
261 pub version: Option<VersionSelector>,
262}
263
264#[derive(Debug, Serialize)]
266pub struct PromoteResponse {
267 pub id: String,
268 pub tag: String,
269 pub version: u32,
271}
272
273pub async fn promote_template(
278 State(state): State<ServerState>,
279 Extension(actor): Extension<AuthContext>,
280 Path(id): Path<String>,
281 Json(body): Json<PromoteBody>,
282) -> Result<Json<PromoteResponse>, ServeError> {
283 let version = crate::templates::promote(
284 &store(&state),
285 &id,
286 body.tag,
287 body.version.unwrap_or_default(),
288 )
289 .await
290 .map_err(map_err)?;
291 tracing::info!(
292 principal = %actor.principal,
293 template = %id,
294 tag = body.tag.as_str(),
295 version,
296 "promoted pipeline template channel"
297 );
298 crate::serve::audit::write(&state, &actor, "template.promote", None, None, "ok").await;
299 Ok(Json(PromoteResponse {
300 id,
301 tag: body.tag.as_str().to_string(),
302 version,
303 }))
304}
305
306#[derive(Debug, Default, Deserialize)]
310pub struct LaunchBody {
311 #[serde(default)]
315 pub version: Option<VersionSelector>,
316}
317
318#[derive(Debug, Serialize)]
320pub struct LaunchResponse {
321 pub id: String,
322 pub version: u32,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub replaced: Option<u32>,
327 pub already_launched: bool,
329 pub status: String,
331}
332
333pub async fn launch_template(
338 State(state): State<ServerState>,
339 Extension(actor): Extension<AuthContext>,
340 Path(id): Path<String>,
341 Json(body): Json<LaunchBody>,
342) -> Result<Json<LaunchResponse>, ServeError> {
343 let target = body.version.unwrap_or_else(VersionSelector::newest);
344 let outcome = crate::templates::launch(&store(&state), &id, target, Some(&actor.principal))
345 .await
346 .map_err(map_err)?;
347 finish_launch(&state, &actor, &id, outcome, "template.launch").await
348}
349
350pub async fn rollback_template(
352 State(state): State<ServerState>,
353 Extension(actor): Extension<AuthContext>,
354 Path(id): Path<String>,
355) -> Result<Json<LaunchResponse>, ServeError> {
356 let outcome = crate::templates::rollback(&store(&state), &id, Some(&actor.principal))
357 .await
358 .map_err(map_err)?;
359 finish_launch(&state, &actor, &id, outcome, "template.rollback").await
360}
361
362async fn finish_launch(
364 state: &ServerState,
365 actor: &AuthContext,
366 id: &str,
367 outcome: crate::templates::LaunchOutcome,
368 action: &str,
369) -> Result<Json<LaunchResponse>, ServeError> {
370 let status = crate::templates::template_state(&store(state), id)
371 .await
372 .map_err(map_err)?
373 .status;
374 tracing::info!(
375 principal = %actor.principal,
376 template = %id,
377 version = outcome.version,
378 replaced = ?outcome.replaced,
379 already_launched = outcome.already_launched,
380 action,
381 "pipeline template launch"
382 );
383 crate::serve::audit::write(state, actor, action, None, None, "ok").await;
384 Ok(Json(LaunchResponse {
385 id: id.to_string(),
386 version: outcome.version,
387 replaced: outcome.replaced,
388 already_launched: outcome.already_launched,
389 status: status.as_str().to_string(),
390 }))
391}
392
393#[derive(Debug, Default, Deserialize)]
395pub struct DeprecateBody {
396 #[serde(default)]
398 pub reason: Option<String>,
399 #[serde(default)]
401 pub undo: bool,
402}
403
404pub async fn deprecate_template(
410 State(state): State<ServerState>,
411 Extension(actor): Extension<AuthContext>,
412 Path(id): Path<String>,
413 Json(body): Json<DeprecateBody>,
414) -> Result<Json<serde_json::Value>, ServeError> {
415 let status = crate::templates::set_deprecated(
416 &store(&state),
417 &id,
418 body.reason.clone(),
419 Some(&actor.principal),
420 !body.undo,
421 )
422 .await
423 .map_err(map_err)?;
424 let action = if body.undo {
425 "template.undeprecate"
426 } else {
427 "template.deprecate"
428 };
429 tracing::info!(
430 principal = %actor.principal,
431 template = %id,
432 status = status.as_str(),
433 "pipeline template deprecation changed"
434 );
435 crate::serve::audit::write(&state, &actor, action, None, None, "ok").await;
436 Ok(Json(
437 serde_json::json!({ "id": id, "status": status.as_str() }),
438 ))
439}
440
441#[derive(Debug, Default, Deserialize)]
446pub struct TriggerBody {
447 #[serde(default)]
449 pub params: BTreeMap<String, Value>,
450 #[serde(default)]
453 pub env: BTreeMap<String, String>,
454 #[serde(default)]
457 pub version: Option<VersionSelector>,
458 #[serde(default)]
460 pub name: Option<String>,
461 #[serde(default)]
462 pub labels: BTreeMap<String, String>,
463 #[serde(default)]
464 pub timeout_secs: Option<u64>,
465 #[serde(default)]
466 pub doctor_first: bool,
467 #[serde(default)]
468 pub idempotency_key: Option<String>,
469 #[serde(default)]
470 pub clock: Option<String>,
471 #[serde(default)]
475 pub callback: Option<crate::serve::callback::CallbackSpec>,
476}
477
478#[derive(Debug, Serialize)]
482pub struct TriggerResponse {
483 #[serde(flatten)]
484 pub run: SubmitResponse,
485 pub template_id: String,
486 pub template_version: u32,
487 pub params: BTreeMap<String, Value>,
489 #[serde(skip_serializing_if = "Option::is_none")]
492 pub deprecated: Option<String>,
493}
494
495const LABEL_TEMPLATE: &str = "template";
498const LABEL_TEMPLATE_VERSION: &str = "template_version";
499
500pub async fn trigger_template(
502 State(state): State<ServerState>,
503 Extension(actor): Extension<AuthContext>,
504 Path(id): Path<String>,
505 Json(body): Json<TriggerBody>,
506) -> Result<(StatusCode, Json<TriggerResponse>), ServeError> {
507 let supplied: SuppliedParams = body.params.into_iter().collect();
508 let s = store(&state);
511 let want = crate::templates::resolve_version(&s, &id, body.version.unwrap_or_default())
512 .await
513 .map_err(map_err)?;
514
515 let tstate = crate::templates::template_state(&s, &id)
518 .await
519 .map_err(map_err)?;
520 if tstate.status == crate::serve::history::templates::TemplateStatus::Deprecated {
521 tracing::warn!(
522 template = %id,
523 version = want,
524 reason = tstate
525 .deprecation
526 .as_ref()
527 .and_then(|d| d.reason.as_deref())
528 .unwrap_or("(none given)"),
529 "triggering a DEPRECATED pipeline template"
530 );
531 }
532 let clustered = state.cluster().enabled();
539 let mode = if clustered {
540 crate::templates::Materialize::Persisted
541 } else {
542 crate::templates::Materialize::Local
543 };
544 let materialized = crate::templates::materialize(&s, &id, want, &supplied, &body.env, mode)
545 .await
546 .map_err(map_err)?;
547
548 if clustered && (materialized.used_secret_params || !body.env.is_empty()) {
554 let what = if materialized.used_secret_params {
555 "declares `secret: true` param(s)"
556 } else {
557 "was triggered with `env` overrides"
558 };
559 return Err(ServeError::Unprocessable {
560 message: format!(
561 "this template {what}, and a clustered server persists the materialized config \
562 so a peer can execute it — which would store the value in the shared \
563 run-history database. Reference the secret from the template body instead \
564 (`${{env:VAR}}`, `${{vault:…}}`, `${{aws-sm:…}}`, … — all resolved on the \
565 executing instance, never persisted), or trigger it on a non-clustered server"
566 ),
567 details: None,
568 });
569 }
570
571 let mut labels = body.labels;
572 labels.insert(LABEL_TEMPLATE.into(), materialized.template_id.clone());
573 labels.insert(
574 LABEL_TEMPLATE_VERSION.into(),
575 materialized.version.to_string(),
576 );
577
578 let req = SubmitRequest {
579 config: materialized.body.clone(),
580 config_format: ConfigFormatWire::Json,
581 name: body.name.or_else(|| materialized.name.clone()),
582 labels,
583 timeout_secs: body.timeout_secs,
584 doctor_first: body.doctor_first,
585 idempotency_key: body.idempotency_key,
586 clock: body.clock,
587 callback: body.callback,
588 };
589 let run = runner::submit(state.clone(), req, actor.clone()).await?;
590 crate::serve::audit::write(
594 &state,
595 &actor,
596 "template.run",
597 Some(run.run_id.clone()),
598 None,
599 "ok",
600 )
601 .await;
602 Ok((
603 StatusCode::ACCEPTED,
604 Json(TriggerResponse {
605 run,
606 template_id: materialized.template_id,
607 template_version: materialized.version,
608 params: materialized.params_redacted,
609 deprecated: (tstate.status
610 == crate::serve::history::templates::TemplateStatus::Deprecated)
611 .then(|| {
612 tstate
613 .deprecation
614 .as_ref()
615 .and_then(|d| d.reason.clone())
616 .unwrap_or_else(|| "this template is deprecated".to_string())
617 }),
618 }),
619 ))
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::serve::history::AuditFilter;
626 use crate::serve::rbac::Role;
627 use crate::serve::test_support::test_state;
628 use serde_json::json;
629
630 fn actor() -> AuthContext {
631 AuthContext {
632 principal: "tester".into(),
633 role: Role::Admin,
634 source_ip: None,
635 }
636 }
637
638 fn template_yaml(out: &std::path::Path) -> String {
639 format!(
640 "version: 1\nname: tpl-demo\nparams:\n tag: {{ required: true }}\n page: {{ type: int, default: 7 }}\npipeline:\n source:\n type: csv\n config:\n path: ./missing-${{param.tag}}.csv\n sink:\n type: jsonl\n config:\n path: {}\n",
641 out.display()
642 )
643 }
644
645 async fn register_demo_opts(
646 state: &ServerState,
647 out: &std::path::Path,
648 launch: bool,
649 ) -> TemplateSummary {
650 register_template(
651 State(state.clone()),
652 Extension(actor()),
653 Json(RegisterBody {
654 id: None,
655 config: template_yaml(out),
656 config_format: ConfigFormatWire::Yaml,
657 description: Some("demo".into()),
658 tags: vec![],
659 launch,
660 }),
661 )
662 .await
663 .expect("register")
664 .1
665 .0
666 }
667
668 async fn register_demo(state: &ServerState, out: &std::path::Path) -> TemplateSummary {
670 register_demo_opts(state, out, true).await
671 }
672
673 async fn get(
674 state: &ServerState,
675 id: &str,
676 q: VersionQuery,
677 ) -> Result<GetResponse, ServeError> {
678 get_template(State(state.clone()), Path(id.into()), Query(q))
679 .await
680 .map(|j| j.0)
681 }
682
683 #[tokio::test]
684 async fn register_list_get_delete_round_trip() {
685 let dir = tempfile::tempdir().unwrap();
686 let state = test_state();
687 let summary = register_demo(&state, &dir.path().join("o.jsonl")).await;
688 assert_eq!(summary.id, "tpl-demo");
689 assert_eq!(summary.version, 1);
690 assert_eq!(summary.created_by.as_deref(), Some("tester"));
691 assert!(summary.params["tag"].required);
692
693 let listed = list_templates(State(state.clone())).await.unwrap().0;
694 assert_eq!(listed.templates.len(), 1);
695 let st = listed.templates[0]
696 .state
697 .as_ref()
698 .expect("state on list rows");
699 assert_eq!(st.status.as_str(), "launched");
700 assert_eq!(st.stable, Some(1));
701
702 let got = get(&state, "tpl-demo", VersionQuery::default())
703 .await
704 .unwrap();
705 assert_eq!(got.template.version, 1);
706 assert_eq!(got.state.versions, vec![1]);
707 assert!(got.is_stable);
708 assert_eq!(got.launches.len(), 1, "the launch is recorded");
709 assert!(got.template.body.contains("${param.tag}"));
711
712 assert!(matches!(
713 get(&state, "nope", VersionQuery::default()).await,
714 Err(ServeError::NotFound)
715 ));
716 assert!(matches!(
717 get(
718 &state,
719 "tpl-demo",
720 VersionQuery {
721 version: Some(VersionSelector::Pinned(9)),
722 },
723 )
724 .await,
725 Err(ServeError::NotFound)
726 ));
727
728 let code = delete_template(
729 State(state.clone()),
730 Extension(actor()),
731 Path("tpl-demo".into()),
732 Query(VersionQuery::default()),
733 )
734 .await
735 .unwrap();
736 assert_eq!(code, StatusCode::NO_CONTENT);
737 assert!(matches!(
738 delete_template(
739 State(state.clone()),
740 Extension(actor()),
741 Path("tpl-demo".into()),
742 Query(VersionQuery::default())
743 )
744 .await,
745 Err(ServeError::NotFound)
746 ));
747
748 let entries = state
749 .history()
750 .list_audit(&AuditFilter {
751 limit: 20,
752 ..Default::default()
753 })
754 .await
755 .unwrap();
756 let actions: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
757 assert!(actions.contains(&"template.register"), "{actions:?}");
758 assert!(actions.contains(&"template.delete"), "{actions:?}");
759 }
760
761 #[tokio::test]
762 async fn register_rejects_an_invalid_config() {
763 let state = test_state();
764 let err = register_template(
765 State(state),
766 Extension(actor()),
767 Json(RegisterBody {
768 id: None,
769 config: "version: 1\nname: x\nbogus_key: 1\npipeline: {}\n".into(),
770 config_format: ConfigFormatWire::Yaml,
771 description: None,
772 tags: vec![],
773 launch: false,
774 }),
775 )
776 .await
777 .unwrap_err();
778 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
779 }
780
781 #[tokio::test]
782 async fn trigger_binds_params_and_stamps_provenance() {
783 let dir = tempfile::tempdir().unwrap();
784 let state = test_state();
785 register_demo(&state, &dir.path().join("o.jsonl")).await;
786
787 let (code, resp) = trigger_template(
788 State(state.clone()),
789 Extension(actor()),
790 Path("tpl-demo".into()),
791 Json(TriggerBody {
792 params: [("tag".to_string(), json!("alpha"))].into(),
793 ..Default::default()
794 }),
795 )
796 .await
797 .expect("trigger");
798 assert_eq!(code, StatusCode::ACCEPTED);
799 assert_eq!(resp.0.template_id, "tpl-demo");
800 assert_eq!(resp.0.template_version, 1);
801 assert_eq!(resp.0.params["tag"], json!("alpha"));
802 assert_eq!(resp.0.params["page"], json!(7));
803 assert!(resp.0.deprecated.is_none());
804
805 let rec = state
806 .history()
807 .get(&resp.0.run.run_id)
808 .await
809 .unwrap()
810 .expect("run record");
811 assert_eq!(rec.labels[LABEL_TEMPLATE], "tpl-demo");
812 assert_eq!(rec.labels[LABEL_TEMPLATE_VERSION], "1");
813 assert_eq!(rec.name.as_deref(), Some("tpl-demo"));
814
815 let entries = state
816 .history()
817 .list_audit(&AuditFilter {
818 action: Some("template.run".into()),
819 limit: 10,
820 ..Default::default()
821 })
822 .await
823 .unwrap();
824 assert_eq!(entries.len(), 1);
825 assert_eq!(
826 entries[0].run_id.as_deref(),
827 Some(resp.0.run.run_id.as_str())
828 );
829 }
830
831 #[tokio::test]
832 async fn a_draft_template_cannot_be_triggered_unpinned() {
833 let dir = tempfile::tempdir().unwrap();
834 let state = test_state();
835 register_demo_opts(&state, &dir.path().join("o.jsonl"), false).await;
837
838 let err = trigger_template(
839 State(state.clone()),
840 Extension(actor()),
841 Path("tpl-demo".into()),
842 Json(TriggerBody {
843 params: [("tag".to_string(), json!("x"))].into(),
844 ..Default::default()
845 }),
846 )
847 .await
848 .unwrap_err();
849 match err {
850 ServeError::Unprocessable { message, .. } => {
851 assert!(message.contains("no launched version"), "{message}");
852 assert!(message.contains("launch"), "{message}");
853 }
854 other => panic!("expected 422, got {other:?}"),
855 }
856
857 let resp = trigger_template(
859 State(state.clone()),
860 Extension(actor()),
861 Path("tpl-demo".into()),
862 Json(TriggerBody {
863 params: [("tag".to_string(), json!("x"))].into(),
864 version: Some(VersionSelector::newest()),
865 ..Default::default()
866 }),
867 )
868 .await
869 .expect("explicit newest runs a draft")
870 .1
871 .0;
872 assert_eq!(resp.template_version, 1);
873 }
874
875 #[tokio::test]
876 async fn launch_moves_callers_and_registering_does_not() {
877 let dir = tempfile::tempdir().unwrap();
878 let state = test_state();
879 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; let trigger = |version: Option<VersionSelector>| {
884 let state = state.clone();
885 async move {
886 trigger_template(
887 State(state),
888 Extension(actor()),
889 Path("tpl-demo".into()),
890 Json(TriggerBody {
891 params: [("tag".to_string(), json!("x"))].into(),
892 version,
893 ..Default::default()
894 }),
895 )
896 .await
897 .expect("trigger")
898 .1
899 .0
900 }
901 };
902 assert_eq!(trigger(None).await.template_version, 1);
903 assert_eq!(
904 trigger(Some(VersionSelector::newest()))
905 .await
906 .template_version,
907 2
908 );
909
910 let resp = launch_template(
912 State(state.clone()),
913 Extension(actor()),
914 Path("tpl-demo".into()),
915 Json(LaunchBody::default()),
916 )
917 .await
918 .unwrap()
919 .0;
920 assert_eq!((resp.version, resp.replaced), (2, Some(1)));
921 assert_eq!(resp.status, "launched");
922 assert!(!resp.already_launched);
923 assert_eq!(trigger(None).await.template_version, 2);
924
925 let resp = rollback_template(
927 State(state.clone()),
928 Extension(actor()),
929 Path("tpl-demo".into()),
930 )
931 .await
932 .unwrap()
933 .0;
934 assert_eq!((resp.version, resp.replaced), (1, Some(2)));
935 assert_eq!(trigger(None).await.template_version, 1);
936
937 for action in ["template.launch", "template.rollback"] {
939 let entries = state
940 .history()
941 .list_audit(&AuditFilter {
942 action: Some(action.into()),
943 limit: 10,
944 ..Default::default()
945 })
946 .await
947 .unwrap();
948 assert_eq!(entries.len(), 1, "{action}");
949 }
950 }
951
952 #[tokio::test]
953 async fn deprecation_warns_but_keeps_serving() {
954 let dir = tempfile::tempdir().unwrap();
955 let state = test_state();
956 register_demo(&state, &dir.path().join("o.jsonl")).await;
957
958 let body = deprecate_template(
959 State(state.clone()),
960 Extension(actor()),
961 Path("tpl-demo".into()),
962 Json(DeprecateBody {
963 reason: Some("superseded by tenant-sync-v2".into()),
964 undo: false,
965 }),
966 )
967 .await
968 .unwrap()
969 .0;
970 assert_eq!(body["status"], "deprecated");
971
972 let resp = trigger_template(
975 State(state.clone()),
976 Extension(actor()),
977 Path("tpl-demo".into()),
978 Json(TriggerBody {
979 params: [("tag".to_string(), json!("x"))].into(),
980 ..Default::default()
981 }),
982 )
983 .await
984 .expect("a deprecated template still runs")
985 .1
986 .0;
987 assert_eq!(resp.template_version, 1);
988 assert_eq!(
989 resp.deprecated.as_deref(),
990 Some("superseded by tenant-sync-v2")
991 );
992
993 let err = launch_template(
995 State(state.clone()),
996 Extension(actor()),
997 Path("tpl-demo".into()),
998 Json(LaunchBody::default()),
999 )
1000 .await
1001 .unwrap_err();
1002 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
1003
1004 let body = deprecate_template(
1006 State(state.clone()),
1007 Extension(actor()),
1008 Path("tpl-demo".into()),
1009 Json(DeprecateBody {
1010 reason: None,
1011 undo: true,
1012 }),
1013 )
1014 .await
1015 .unwrap()
1016 .0;
1017 assert_eq!(body["status"], "launched");
1018 }
1019
1020 #[tokio::test]
1021 async fn promote_moves_a_channel_without_touching_what_is_live() {
1022 let dir = tempfile::tempdir().unwrap();
1023 let state = test_state();
1024 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; let resp = promote_template(
1028 State(state.clone()),
1029 Extension(actor()),
1030 Path("tpl-demo".into()),
1031 Json(PromoteBody {
1032 tag: VersionChannel::PreProd,
1033 version: Some(VersionSelector::newest()),
1034 }),
1035 )
1036 .await
1037 .unwrap()
1038 .0;
1039 assert_eq!((resp.tag.as_str(), resp.version), ("pre-prod", 2));
1040
1041 let got = get(&state, "tpl-demo", VersionQuery::default())
1042 .await
1043 .unwrap();
1044 assert_eq!(got.state.stable, Some(1), "promote must not move `stable`");
1045 assert_eq!(got.state.tags["pre-prod"], 2);
1046 assert!(
1047 !got.state.tags.contains_key("stable"),
1048 "derived, never stored"
1049 );
1050
1051 let err = promote_template(
1053 State(state.clone()),
1054 Extension(actor()),
1055 Path("tpl-demo".into()),
1056 Json(PromoteBody {
1057 tag: VersionChannel::Stable,
1058 version: Some(VersionSelector::Pinned(1)),
1059 }),
1060 )
1061 .await
1062 .unwrap_err();
1063 match err {
1064 ServeError::Unprocessable { message, .. } => {
1065 assert!(message.contains("derived"), "{message}")
1066 }
1067 other => panic!("expected 422, got {other:?}"),
1068 }
1069 assert!(
1070 serde_json::from_value::<PromoteBody>(json!({"tag": "latest", "version": 1})).is_err()
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn selecting_an_unset_channel_is_unprocessable() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let state = test_state();
1078 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1079 let err = get(
1082 &state,
1083 "tpl-demo",
1084 VersionQuery {
1085 version: Some(VersionSelector::Channel(VersionChannel::Canary)),
1086 },
1087 )
1088 .await
1089 .unwrap_err();
1090 match err {
1091 ServeError::Unprocessable { message, .. } => {
1092 assert!(message.contains("no `canary` version"), "{message}")
1093 }
1094 other => panic!("expected 422, got {other:?}"),
1095 }
1096 }
1097
1098 #[tokio::test]
1099 async fn delete_by_selector_removes_one_version_but_omitted_removes_all() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let state = test_state();
1102 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await;
1104 register_demo_opts(&state, &dir.path().join("v3.jsonl"), false).await;
1105
1106 delete_template(
1108 State(state.clone()),
1109 Extension(actor()),
1110 Path("tpl-demo".into()),
1111 Query(VersionQuery {
1112 version: Some(VersionSelector::newest()),
1113 }),
1114 )
1115 .await
1116 .unwrap();
1117 assert_eq!(
1118 state.history().template_versions("tpl-demo").await.unwrap(),
1119 vec![2, 1]
1120 );
1121
1122 delete_template(
1124 State(state.clone()),
1125 Extension(actor()),
1126 Path("tpl-demo".into()),
1127 Query(VersionQuery::default()),
1128 )
1129 .await
1130 .unwrap();
1131 assert!(state.history().template_list().await.unwrap().is_empty());
1132 }
1133
1134 #[tokio::test]
1135 async fn secret_params_are_refused_on_a_clustered_server() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let state = crate::serve::test_support::test_state_clustered();
1138 let body = format!(
1139 "version: 1\nname: tpl-secret\nparams:\n token: {{ required: true, secret: true }}\npipeline:\n source:\n type: csv\n config:\n path: ./x-${{param.token}}.csv\n sink:\n type: jsonl\n config:\n path: {}\n",
1140 dir.path().join("o.jsonl").display()
1141 );
1142 let _registered = register_template(
1143 State(state.clone()),
1144 Extension(actor()),
1145 Json(RegisterBody {
1146 id: None,
1147 config: body,
1148 config_format: ConfigFormatWire::Yaml,
1149 description: None,
1150 tags: vec![],
1151 launch: true,
1152 }),
1153 )
1154 .await
1155 .expect("register");
1156
1157 let err = trigger_template(
1158 State(state),
1159 Extension(actor()),
1160 Path("tpl-secret".into()),
1161 Json(TriggerBody {
1162 params: [("token".to_string(), json!("super-secret-value"))].into(),
1163 ..Default::default()
1164 }),
1165 )
1166 .await
1167 .unwrap_err();
1168 match err {
1169 ServeError::Unprocessable { message, .. } => {
1170 assert!(message.contains("clustered"), "{message}");
1171 assert!(!message.contains("super-secret-value"), "leaked: {message}");
1172 }
1173 other => panic!("expected 422, got {other:?}"),
1174 }
1175 }
1176
1177 #[tokio::test]
1182 async fn env_overrides_are_refused_on_a_clustered_server() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let state = crate::serve::test_support::test_state_clustered();
1185 let body = format!(
1186 "version: 1\nname: tpl-env\npipeline:\n source:\n type: csv\n config:\n path: \"${{env:SRC_PATH}}\"\n sink:\n type: jsonl\n config:\n path: {}\n",
1187 dir.path().join("o.jsonl").display()
1188 );
1189 let _registered = register_template(
1190 State(state.clone()),
1191 Extension(actor()),
1192 Json(RegisterBody {
1193 id: None,
1194 config: body,
1195 config_format: ConfigFormatWire::Yaml,
1196 description: None,
1197 tags: vec![],
1198 launch: true,
1199 }),
1200 )
1201 .await
1202 .expect("register");
1203
1204 let err = trigger_template(
1205 State(state),
1206 Extension(actor()),
1207 Path("tpl-env".into()),
1208 Json(TriggerBody {
1209 env: [("SRC_PATH".to_string(), "s3cret-path".to_string())].into(),
1210 ..Default::default()
1211 }),
1212 )
1213 .await
1214 .unwrap_err();
1215 match err {
1216 ServeError::Unprocessable { message, .. } => {
1217 assert!(message.contains("env"), "{message}");
1218 assert!(!message.contains("s3cret-path"), "leaked: {message}");
1219 }
1220 other => panic!("expected 422, got {other:?}"),
1221 }
1222 }
1223
1224 #[tokio::test]
1229 async fn a_clustered_trigger_persists_tokens_not_resolved_values() {
1230 let dir = tempfile::tempdir().unwrap();
1231 unsafe { std::env::set_var("FAUCET_TEST_C5_SECRET", "hunter2-should-not-persist") };
1234 let s = store(&crate::serve::test_support::test_state_clustered());
1235 let body = format!(
1236 "version: 1\nname: tpl-c5\npipeline:\n source:\n type: csv\n config:\n path: \"${{env:FAUCET_TEST_C5_SECRET}}\"\n sink:\n type: jsonl\n config:\n path: {}\n",
1237 dir.path().join("o.jsonl").display()
1238 );
1239 crate::templates::register(
1240 &s,
1241 crate::templates::RegisterRequest {
1242 id: None,
1243 body,
1244 format: crate::serve::load::ConfigFormat::Yaml,
1245 description: None,
1246 tags: vec![],
1247 launch: true,
1248 created_by: None,
1249 },
1250 )
1251 .await
1252 .expect("register");
1253
1254 let persisted = crate::templates::materialize(
1255 &s,
1256 "tpl-c5",
1257 1,
1258 &Default::default(),
1259 &Default::default(),
1260 crate::templates::Materialize::Persisted,
1261 )
1262 .await
1263 .expect("materialize");
1264 assert!(
1265 !persisted.body.contains("hunter2-should-not-persist"),
1266 "a resolved secret must never reach a persisted body: {}",
1267 persisted.body
1268 );
1269 assert!(
1270 persisted.body.contains("${env:FAUCET_TEST_C5_SECRET}"),
1271 "the directive must survive as a token for the executor: {}",
1272 persisted.body
1273 );
1274
1275 let local = crate::templates::materialize(
1278 &s,
1279 "tpl-c5",
1280 1,
1281 &Default::default(),
1282 &Default::default(),
1283 crate::templates::Materialize::Local,
1284 )
1285 .await
1286 .expect("materialize");
1287 assert!(
1288 local.body.contains("hunter2-should-not-persist"),
1289 "{}",
1290 local.body
1291 );
1292 unsafe { std::env::remove_var("FAUCET_TEST_C5_SECRET") };
1293 }
1294
1295 #[test]
1296 fn version_query_deserializes_channels_and_numbers() {
1297 assert!(
1300 serde_json::from_value::<VersionQuery>(json!({}))
1301 .unwrap()
1302 .selector()
1303 .is_stable(),
1304 "an omitted selector means `stable`"
1305 );
1306 assert!(
1307 serde_json::from_value::<VersionQuery>(json!({ "version": "stable" }))
1308 .unwrap()
1309 .selector()
1310 .is_stable()
1311 );
1312 for wire in [json!({ "version": "2" }), json!({ "version": 2 })] {
1313 let q: VersionQuery = serde_json::from_value(wire.clone()).unwrap();
1314 assert_eq!(q.selector().pinned(), Some(2), "{wire}");
1315 }
1316 for bad in [
1317 json!({ "version": "nope" }),
1318 json!({ "version": 0 }),
1319 json!({ "version": "latest" }),
1320 ] {
1321 assert!(
1322 serde_json::from_value::<VersionQuery>(bad.clone()).is_err(),
1323 "{bad} should be rejected"
1324 );
1325 }
1326 }
1327
1328 #[tokio::test]
1329 async fn version_pinning_selects_an_older_body() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let state = test_state();
1332 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1333 let v2 = register_demo(&state, &dir.path().join("v2.jsonl")).await;
1334 assert_eq!(v2.version, 2);
1335
1336 let got = get_template(
1337 State(state.clone()),
1338 Path("tpl-demo".into()),
1339 Query(VersionQuery {
1340 version: Some(VersionSelector::Pinned(1)),
1341 }),
1342 )
1343 .await
1344 .unwrap()
1345 .0;
1346 assert!(got.template.body.contains("v1.jsonl"));
1347 assert_eq!(got.state.versions, vec![2, 1]);
1348 assert!(!got.is_stable, "v2 is live, so a pinned v1 is not");
1349
1350 delete_template(
1352 State(state.clone()),
1353 Extension(actor()),
1354 Path("tpl-demo".into()),
1355 Query(VersionQuery {
1356 version: Some(VersionSelector::Pinned(1)),
1357 }),
1358 )
1359 .await
1360 .unwrap();
1361 assert_eq!(
1362 state.history().template_versions("tpl-demo").await.unwrap(),
1363 vec![2]
1364 );
1365 }
1366}