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}
472
473#[derive(Debug, Serialize)]
477pub struct TriggerResponse {
478 #[serde(flatten)]
479 pub run: SubmitResponse,
480 pub template_id: String,
481 pub template_version: u32,
482 pub params: BTreeMap<String, Value>,
484 #[serde(skip_serializing_if = "Option::is_none")]
487 pub deprecated: Option<String>,
488}
489
490const LABEL_TEMPLATE: &str = "template";
493const LABEL_TEMPLATE_VERSION: &str = "template_version";
494
495pub async fn trigger_template(
497 State(state): State<ServerState>,
498 Extension(actor): Extension<AuthContext>,
499 Path(id): Path<String>,
500 Json(body): Json<TriggerBody>,
501) -> Result<(StatusCode, Json<TriggerResponse>), ServeError> {
502 let supplied: SuppliedParams = body.params.into_iter().collect();
503 let s = store(&state);
506 let want = crate::templates::resolve_version(&s, &id, body.version.unwrap_or_default())
507 .await
508 .map_err(map_err)?;
509
510 let tstate = crate::templates::template_state(&s, &id)
513 .await
514 .map_err(map_err)?;
515 if tstate.status == crate::serve::history::templates::TemplateStatus::Deprecated {
516 tracing::warn!(
517 template = %id,
518 version = want,
519 reason = tstate
520 .deprecation
521 .as_ref()
522 .and_then(|d| d.reason.as_deref())
523 .unwrap_or("(none given)"),
524 "triggering a DEPRECATED pipeline template"
525 );
526 }
527 let clustered = state.cluster().enabled();
534 let mode = if clustered {
535 crate::templates::Materialize::Persisted
536 } else {
537 crate::templates::Materialize::Local
538 };
539 let materialized = crate::templates::materialize(&s, &id, want, &supplied, &body.env, mode)
540 .await
541 .map_err(map_err)?;
542
543 if clustered && (materialized.used_secret_params || !body.env.is_empty()) {
549 let what = if materialized.used_secret_params {
550 "declares `secret: true` param(s)"
551 } else {
552 "was triggered with `env` overrides"
553 };
554 return Err(ServeError::Unprocessable {
555 message: format!(
556 "this template {what}, and a clustered server persists the materialized config \
557 so a peer can execute it — which would store the value in the shared \
558 run-history database. Reference the secret from the template body instead \
559 (`${{env:VAR}}`, `${{vault:…}}`, `${{aws-sm:…}}`, … — all resolved on the \
560 executing instance, never persisted), or trigger it on a non-clustered server"
561 ),
562 details: None,
563 });
564 }
565
566 let mut labels = body.labels;
567 labels.insert(LABEL_TEMPLATE.into(), materialized.template_id.clone());
568 labels.insert(
569 LABEL_TEMPLATE_VERSION.into(),
570 materialized.version.to_string(),
571 );
572
573 let req = SubmitRequest {
574 config: materialized.body.clone(),
575 config_format: ConfigFormatWire::Json,
576 name: body.name.or_else(|| materialized.name.clone()),
577 labels,
578 timeout_secs: body.timeout_secs,
579 doctor_first: body.doctor_first,
580 idempotency_key: body.idempotency_key,
581 clock: body.clock,
582 };
583 let run = runner::submit(state.clone(), req, actor.clone()).await?;
584 crate::serve::audit::write(
588 &state,
589 &actor,
590 "template.run",
591 Some(run.run_id.clone()),
592 None,
593 "ok",
594 )
595 .await;
596 Ok((
597 StatusCode::ACCEPTED,
598 Json(TriggerResponse {
599 run,
600 template_id: materialized.template_id,
601 template_version: materialized.version,
602 params: materialized.params_redacted,
603 deprecated: (tstate.status
604 == crate::serve::history::templates::TemplateStatus::Deprecated)
605 .then(|| {
606 tstate
607 .deprecation
608 .as_ref()
609 .and_then(|d| d.reason.clone())
610 .unwrap_or_else(|| "this template is deprecated".to_string())
611 }),
612 }),
613 ))
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::serve::history::AuditFilter;
620 use crate::serve::rbac::Role;
621 use crate::serve::test_support::test_state;
622 use serde_json::json;
623
624 fn actor() -> AuthContext {
625 AuthContext {
626 principal: "tester".into(),
627 role: Role::Admin,
628 source_ip: None,
629 }
630 }
631
632 fn template_yaml(out: &std::path::Path) -> String {
633 format!(
634 "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",
635 out.display()
636 )
637 }
638
639 async fn register_demo_opts(
640 state: &ServerState,
641 out: &std::path::Path,
642 launch: bool,
643 ) -> TemplateSummary {
644 register_template(
645 State(state.clone()),
646 Extension(actor()),
647 Json(RegisterBody {
648 id: None,
649 config: template_yaml(out),
650 config_format: ConfigFormatWire::Yaml,
651 description: Some("demo".into()),
652 tags: vec![],
653 launch,
654 }),
655 )
656 .await
657 .expect("register")
658 .1
659 .0
660 }
661
662 async fn register_demo(state: &ServerState, out: &std::path::Path) -> TemplateSummary {
664 register_demo_opts(state, out, true).await
665 }
666
667 async fn get(
668 state: &ServerState,
669 id: &str,
670 q: VersionQuery,
671 ) -> Result<GetResponse, ServeError> {
672 get_template(State(state.clone()), Path(id.into()), Query(q))
673 .await
674 .map(|j| j.0)
675 }
676
677 #[tokio::test]
678 async fn register_list_get_delete_round_trip() {
679 let dir = tempfile::tempdir().unwrap();
680 let state = test_state();
681 let summary = register_demo(&state, &dir.path().join("o.jsonl")).await;
682 assert_eq!(summary.id, "tpl-demo");
683 assert_eq!(summary.version, 1);
684 assert_eq!(summary.created_by.as_deref(), Some("tester"));
685 assert!(summary.params["tag"].required);
686
687 let listed = list_templates(State(state.clone())).await.unwrap().0;
688 assert_eq!(listed.templates.len(), 1);
689 let st = listed.templates[0]
690 .state
691 .as_ref()
692 .expect("state on list rows");
693 assert_eq!(st.status.as_str(), "launched");
694 assert_eq!(st.stable, Some(1));
695
696 let got = get(&state, "tpl-demo", VersionQuery::default())
697 .await
698 .unwrap();
699 assert_eq!(got.template.version, 1);
700 assert_eq!(got.state.versions, vec![1]);
701 assert!(got.is_stable);
702 assert_eq!(got.launches.len(), 1, "the launch is recorded");
703 assert!(got.template.body.contains("${param.tag}"));
705
706 assert!(matches!(
707 get(&state, "nope", VersionQuery::default()).await,
708 Err(ServeError::NotFound)
709 ));
710 assert!(matches!(
711 get(
712 &state,
713 "tpl-demo",
714 VersionQuery {
715 version: Some(VersionSelector::Pinned(9)),
716 },
717 )
718 .await,
719 Err(ServeError::NotFound)
720 ));
721
722 let code = delete_template(
723 State(state.clone()),
724 Extension(actor()),
725 Path("tpl-demo".into()),
726 Query(VersionQuery::default()),
727 )
728 .await
729 .unwrap();
730 assert_eq!(code, StatusCode::NO_CONTENT);
731 assert!(matches!(
732 delete_template(
733 State(state.clone()),
734 Extension(actor()),
735 Path("tpl-demo".into()),
736 Query(VersionQuery::default())
737 )
738 .await,
739 Err(ServeError::NotFound)
740 ));
741
742 let entries = state
743 .history()
744 .list_audit(&AuditFilter {
745 limit: 20,
746 ..Default::default()
747 })
748 .await
749 .unwrap();
750 let actions: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
751 assert!(actions.contains(&"template.register"), "{actions:?}");
752 assert!(actions.contains(&"template.delete"), "{actions:?}");
753 }
754
755 #[tokio::test]
756 async fn register_rejects_an_invalid_config() {
757 let state = test_state();
758 let err = register_template(
759 State(state),
760 Extension(actor()),
761 Json(RegisterBody {
762 id: None,
763 config: "version: 1\nname: x\nbogus_key: 1\npipeline: {}\n".into(),
764 config_format: ConfigFormatWire::Yaml,
765 description: None,
766 tags: vec![],
767 launch: false,
768 }),
769 )
770 .await
771 .unwrap_err();
772 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
773 }
774
775 #[tokio::test]
776 async fn trigger_binds_params_and_stamps_provenance() {
777 let dir = tempfile::tempdir().unwrap();
778 let state = test_state();
779 register_demo(&state, &dir.path().join("o.jsonl")).await;
780
781 let (code, resp) = trigger_template(
782 State(state.clone()),
783 Extension(actor()),
784 Path("tpl-demo".into()),
785 Json(TriggerBody {
786 params: [("tag".to_string(), json!("alpha"))].into(),
787 ..Default::default()
788 }),
789 )
790 .await
791 .expect("trigger");
792 assert_eq!(code, StatusCode::ACCEPTED);
793 assert_eq!(resp.0.template_id, "tpl-demo");
794 assert_eq!(resp.0.template_version, 1);
795 assert_eq!(resp.0.params["tag"], json!("alpha"));
796 assert_eq!(resp.0.params["page"], json!(7));
797 assert!(resp.0.deprecated.is_none());
798
799 let rec = state
800 .history()
801 .get(&resp.0.run.run_id)
802 .await
803 .unwrap()
804 .expect("run record");
805 assert_eq!(rec.labels[LABEL_TEMPLATE], "tpl-demo");
806 assert_eq!(rec.labels[LABEL_TEMPLATE_VERSION], "1");
807 assert_eq!(rec.name.as_deref(), Some("tpl-demo"));
808
809 let entries = state
810 .history()
811 .list_audit(&AuditFilter {
812 action: Some("template.run".into()),
813 limit: 10,
814 ..Default::default()
815 })
816 .await
817 .unwrap();
818 assert_eq!(entries.len(), 1);
819 assert_eq!(
820 entries[0].run_id.as_deref(),
821 Some(resp.0.run.run_id.as_str())
822 );
823 }
824
825 #[tokio::test]
826 async fn a_draft_template_cannot_be_triggered_unpinned() {
827 let dir = tempfile::tempdir().unwrap();
828 let state = test_state();
829 register_demo_opts(&state, &dir.path().join("o.jsonl"), false).await;
831
832 let err = trigger_template(
833 State(state.clone()),
834 Extension(actor()),
835 Path("tpl-demo".into()),
836 Json(TriggerBody {
837 params: [("tag".to_string(), json!("x"))].into(),
838 ..Default::default()
839 }),
840 )
841 .await
842 .unwrap_err();
843 match err {
844 ServeError::Unprocessable { message, .. } => {
845 assert!(message.contains("no launched version"), "{message}");
846 assert!(message.contains("launch"), "{message}");
847 }
848 other => panic!("expected 422, got {other:?}"),
849 }
850
851 let resp = trigger_template(
853 State(state.clone()),
854 Extension(actor()),
855 Path("tpl-demo".into()),
856 Json(TriggerBody {
857 params: [("tag".to_string(), json!("x"))].into(),
858 version: Some(VersionSelector::newest()),
859 ..Default::default()
860 }),
861 )
862 .await
863 .expect("explicit newest runs a draft")
864 .1
865 .0;
866 assert_eq!(resp.template_version, 1);
867 }
868
869 #[tokio::test]
870 async fn launch_moves_callers_and_registering_does_not() {
871 let dir = tempfile::tempdir().unwrap();
872 let state = test_state();
873 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>| {
878 let state = state.clone();
879 async move {
880 trigger_template(
881 State(state),
882 Extension(actor()),
883 Path("tpl-demo".into()),
884 Json(TriggerBody {
885 params: [("tag".to_string(), json!("x"))].into(),
886 version,
887 ..Default::default()
888 }),
889 )
890 .await
891 .expect("trigger")
892 .1
893 .0
894 }
895 };
896 assert_eq!(trigger(None).await.template_version, 1);
897 assert_eq!(
898 trigger(Some(VersionSelector::newest()))
899 .await
900 .template_version,
901 2
902 );
903
904 let resp = launch_template(
906 State(state.clone()),
907 Extension(actor()),
908 Path("tpl-demo".into()),
909 Json(LaunchBody::default()),
910 )
911 .await
912 .unwrap()
913 .0;
914 assert_eq!((resp.version, resp.replaced), (2, Some(1)));
915 assert_eq!(resp.status, "launched");
916 assert!(!resp.already_launched);
917 assert_eq!(trigger(None).await.template_version, 2);
918
919 let resp = rollback_template(
921 State(state.clone()),
922 Extension(actor()),
923 Path("tpl-demo".into()),
924 )
925 .await
926 .unwrap()
927 .0;
928 assert_eq!((resp.version, resp.replaced), (1, Some(2)));
929 assert_eq!(trigger(None).await.template_version, 1);
930
931 for action in ["template.launch", "template.rollback"] {
933 let entries = state
934 .history()
935 .list_audit(&AuditFilter {
936 action: Some(action.into()),
937 limit: 10,
938 ..Default::default()
939 })
940 .await
941 .unwrap();
942 assert_eq!(entries.len(), 1, "{action}");
943 }
944 }
945
946 #[tokio::test]
947 async fn deprecation_warns_but_keeps_serving() {
948 let dir = tempfile::tempdir().unwrap();
949 let state = test_state();
950 register_demo(&state, &dir.path().join("o.jsonl")).await;
951
952 let body = deprecate_template(
953 State(state.clone()),
954 Extension(actor()),
955 Path("tpl-demo".into()),
956 Json(DeprecateBody {
957 reason: Some("superseded by tenant-sync-v2".into()),
958 undo: false,
959 }),
960 )
961 .await
962 .unwrap()
963 .0;
964 assert_eq!(body["status"], "deprecated");
965
966 let resp = trigger_template(
969 State(state.clone()),
970 Extension(actor()),
971 Path("tpl-demo".into()),
972 Json(TriggerBody {
973 params: [("tag".to_string(), json!("x"))].into(),
974 ..Default::default()
975 }),
976 )
977 .await
978 .expect("a deprecated template still runs")
979 .1
980 .0;
981 assert_eq!(resp.template_version, 1);
982 assert_eq!(
983 resp.deprecated.as_deref(),
984 Some("superseded by tenant-sync-v2")
985 );
986
987 let err = launch_template(
989 State(state.clone()),
990 Extension(actor()),
991 Path("tpl-demo".into()),
992 Json(LaunchBody::default()),
993 )
994 .await
995 .unwrap_err();
996 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
997
998 let body = deprecate_template(
1000 State(state.clone()),
1001 Extension(actor()),
1002 Path("tpl-demo".into()),
1003 Json(DeprecateBody {
1004 reason: None,
1005 undo: true,
1006 }),
1007 )
1008 .await
1009 .unwrap()
1010 .0;
1011 assert_eq!(body["status"], "launched");
1012 }
1013
1014 #[tokio::test]
1015 async fn promote_moves_a_channel_without_touching_what_is_live() {
1016 let dir = tempfile::tempdir().unwrap();
1017 let state = test_state();
1018 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; let resp = promote_template(
1022 State(state.clone()),
1023 Extension(actor()),
1024 Path("tpl-demo".into()),
1025 Json(PromoteBody {
1026 tag: VersionChannel::PreProd,
1027 version: Some(VersionSelector::newest()),
1028 }),
1029 )
1030 .await
1031 .unwrap()
1032 .0;
1033 assert_eq!((resp.tag.as_str(), resp.version), ("pre-prod", 2));
1034
1035 let got = get(&state, "tpl-demo", VersionQuery::default())
1036 .await
1037 .unwrap();
1038 assert_eq!(got.state.stable, Some(1), "promote must not move `stable`");
1039 assert_eq!(got.state.tags["pre-prod"], 2);
1040 assert!(
1041 !got.state.tags.contains_key("stable"),
1042 "derived, never stored"
1043 );
1044
1045 let err = promote_template(
1047 State(state.clone()),
1048 Extension(actor()),
1049 Path("tpl-demo".into()),
1050 Json(PromoteBody {
1051 tag: VersionChannel::Stable,
1052 version: Some(VersionSelector::Pinned(1)),
1053 }),
1054 )
1055 .await
1056 .unwrap_err();
1057 match err {
1058 ServeError::Unprocessable { message, .. } => {
1059 assert!(message.contains("derived"), "{message}")
1060 }
1061 other => panic!("expected 422, got {other:?}"),
1062 }
1063 assert!(
1064 serde_json::from_value::<PromoteBody>(json!({"tag": "latest", "version": 1})).is_err()
1065 );
1066 }
1067
1068 #[tokio::test]
1069 async fn selecting_an_unset_channel_is_unprocessable() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let state = test_state();
1072 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1073 let err = get(
1076 &state,
1077 "tpl-demo",
1078 VersionQuery {
1079 version: Some(VersionSelector::Channel(VersionChannel::Canary)),
1080 },
1081 )
1082 .await
1083 .unwrap_err();
1084 match err {
1085 ServeError::Unprocessable { message, .. } => {
1086 assert!(message.contains("no `canary` version"), "{message}")
1087 }
1088 other => panic!("expected 422, got {other:?}"),
1089 }
1090 }
1091
1092 #[tokio::test]
1093 async fn delete_by_selector_removes_one_version_but_omitted_removes_all() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let state = test_state();
1096 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await;
1098 register_demo_opts(&state, &dir.path().join("v3.jsonl"), false).await;
1099
1100 delete_template(
1102 State(state.clone()),
1103 Extension(actor()),
1104 Path("tpl-demo".into()),
1105 Query(VersionQuery {
1106 version: Some(VersionSelector::newest()),
1107 }),
1108 )
1109 .await
1110 .unwrap();
1111 assert_eq!(
1112 state.history().template_versions("tpl-demo").await.unwrap(),
1113 vec![2, 1]
1114 );
1115
1116 delete_template(
1118 State(state.clone()),
1119 Extension(actor()),
1120 Path("tpl-demo".into()),
1121 Query(VersionQuery::default()),
1122 )
1123 .await
1124 .unwrap();
1125 assert!(state.history().template_list().await.unwrap().is_empty());
1126 }
1127
1128 #[tokio::test]
1129 async fn secret_params_are_refused_on_a_clustered_server() {
1130 let dir = tempfile::tempdir().unwrap();
1131 let state = crate::serve::test_support::test_state_clustered();
1132 let body = format!(
1133 "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",
1134 dir.path().join("o.jsonl").display()
1135 );
1136 let _registered = register_template(
1137 State(state.clone()),
1138 Extension(actor()),
1139 Json(RegisterBody {
1140 id: None,
1141 config: body,
1142 config_format: ConfigFormatWire::Yaml,
1143 description: None,
1144 tags: vec![],
1145 launch: true,
1146 }),
1147 )
1148 .await
1149 .expect("register");
1150
1151 let err = trigger_template(
1152 State(state),
1153 Extension(actor()),
1154 Path("tpl-secret".into()),
1155 Json(TriggerBody {
1156 params: [("token".to_string(), json!("super-secret-value"))].into(),
1157 ..Default::default()
1158 }),
1159 )
1160 .await
1161 .unwrap_err();
1162 match err {
1163 ServeError::Unprocessable { message, .. } => {
1164 assert!(message.contains("clustered"), "{message}");
1165 assert!(!message.contains("super-secret-value"), "leaked: {message}");
1166 }
1167 other => panic!("expected 422, got {other:?}"),
1168 }
1169 }
1170
1171 #[tokio::test]
1176 async fn env_overrides_are_refused_on_a_clustered_server() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let state = crate::serve::test_support::test_state_clustered();
1179 let body = format!(
1180 "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",
1181 dir.path().join("o.jsonl").display()
1182 );
1183 let _registered = register_template(
1184 State(state.clone()),
1185 Extension(actor()),
1186 Json(RegisterBody {
1187 id: None,
1188 config: body,
1189 config_format: ConfigFormatWire::Yaml,
1190 description: None,
1191 tags: vec![],
1192 launch: true,
1193 }),
1194 )
1195 .await
1196 .expect("register");
1197
1198 let err = trigger_template(
1199 State(state),
1200 Extension(actor()),
1201 Path("tpl-env".into()),
1202 Json(TriggerBody {
1203 env: [("SRC_PATH".to_string(), "s3cret-path".to_string())].into(),
1204 ..Default::default()
1205 }),
1206 )
1207 .await
1208 .unwrap_err();
1209 match err {
1210 ServeError::Unprocessable { message, .. } => {
1211 assert!(message.contains("env"), "{message}");
1212 assert!(!message.contains("s3cret-path"), "leaked: {message}");
1213 }
1214 other => panic!("expected 422, got {other:?}"),
1215 }
1216 }
1217
1218 #[tokio::test]
1223 async fn a_clustered_trigger_persists_tokens_not_resolved_values() {
1224 let dir = tempfile::tempdir().unwrap();
1225 unsafe { std::env::set_var("FAUCET_TEST_C5_SECRET", "hunter2-should-not-persist") };
1228 let s = store(&crate::serve::test_support::test_state_clustered());
1229 let body = format!(
1230 "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",
1231 dir.path().join("o.jsonl").display()
1232 );
1233 crate::templates::register(
1234 &s,
1235 crate::templates::RegisterRequest {
1236 id: None,
1237 body,
1238 format: crate::serve::load::ConfigFormat::Yaml,
1239 description: None,
1240 tags: vec![],
1241 launch: true,
1242 created_by: None,
1243 },
1244 )
1245 .await
1246 .expect("register");
1247
1248 let persisted = crate::templates::materialize(
1249 &s,
1250 "tpl-c5",
1251 1,
1252 &Default::default(),
1253 &Default::default(),
1254 crate::templates::Materialize::Persisted,
1255 )
1256 .await
1257 .expect("materialize");
1258 assert!(
1259 !persisted.body.contains("hunter2-should-not-persist"),
1260 "a resolved secret must never reach a persisted body: {}",
1261 persisted.body
1262 );
1263 assert!(
1264 persisted.body.contains("${env:FAUCET_TEST_C5_SECRET}"),
1265 "the directive must survive as a token for the executor: {}",
1266 persisted.body
1267 );
1268
1269 let local = crate::templates::materialize(
1272 &s,
1273 "tpl-c5",
1274 1,
1275 &Default::default(),
1276 &Default::default(),
1277 crate::templates::Materialize::Local,
1278 )
1279 .await
1280 .expect("materialize");
1281 assert!(
1282 local.body.contains("hunter2-should-not-persist"),
1283 "{}",
1284 local.body
1285 );
1286 unsafe { std::env::remove_var("FAUCET_TEST_C5_SECRET") };
1287 }
1288
1289 #[test]
1290 fn version_query_deserializes_channels_and_numbers() {
1291 assert!(
1294 serde_json::from_value::<VersionQuery>(json!({}))
1295 .unwrap()
1296 .selector()
1297 .is_stable(),
1298 "an omitted selector means `stable`"
1299 );
1300 assert!(
1301 serde_json::from_value::<VersionQuery>(json!({ "version": "stable" }))
1302 .unwrap()
1303 .selector()
1304 .is_stable()
1305 );
1306 for wire in [json!({ "version": "2" }), json!({ "version": 2 })] {
1307 let q: VersionQuery = serde_json::from_value(wire.clone()).unwrap();
1308 assert_eq!(q.selector().pinned(), Some(2), "{wire}");
1309 }
1310 for bad in [
1311 json!({ "version": "nope" }),
1312 json!({ "version": 0 }),
1313 json!({ "version": "latest" }),
1314 ] {
1315 assert!(
1316 serde_json::from_value::<VersionQuery>(bad.clone()).is_err(),
1317 "{bad} should be rejected"
1318 );
1319 }
1320 }
1321
1322 #[tokio::test]
1323 async fn version_pinning_selects_an_older_body() {
1324 let dir = tempfile::tempdir().unwrap();
1325 let state = test_state();
1326 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1327 let v2 = register_demo(&state, &dir.path().join("v2.jsonl")).await;
1328 assert_eq!(v2.version, 2);
1329
1330 let got = get_template(
1331 State(state.clone()),
1332 Path("tpl-demo".into()),
1333 Query(VersionQuery {
1334 version: Some(VersionSelector::Pinned(1)),
1335 }),
1336 )
1337 .await
1338 .unwrap()
1339 .0;
1340 assert!(got.template.body.contains("v1.jsonl"));
1341 assert_eq!(got.state.versions, vec![2, 1]);
1342 assert!(!got.is_stable, "v2 is live, so a pinned v1 is not");
1343
1344 delete_template(
1346 State(state.clone()),
1347 Extension(actor()),
1348 Path("tpl-demo".into()),
1349 Query(VersionQuery {
1350 version: Some(VersionSelector::Pinned(1)),
1351 }),
1352 )
1353 .await
1354 .unwrap();
1355 assert_eq!(
1356 state.history().template_versions("tpl-demo").await.unwrap(),
1357 vec![2]
1358 );
1359 }
1360}