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 #[serde(default)]
151 pub clean: bool,
152}
153
154impl VersionQuery {
155 fn selector(&self) -> VersionSelector {
157 self.version.unwrap_or_default()
158 }
159}
160
161#[derive(Debug, Serialize)]
165pub struct GetResponse {
166 #[serde(flatten)]
167 pub template: TemplateRecord,
168 #[serde(flatten)]
171 pub state: TemplateState,
172 pub is_stable: bool,
174 pub launches: Vec<crate::serve::history::templates::LaunchRecord>,
176}
177
178pub async fn get_template(
180 State(state): State<ServerState>,
181 Path(id): Path<String>,
182 Query(q): Query<VersionQuery>,
183) -> Result<Json<GetResponse>, ServeError> {
184 let s = store(&state);
185 let want = crate::templates::resolve_version(&s, &id, q.selector())
186 .await
187 .map_err(map_err)?;
188 let mut template = s
189 .template_get(&id, Some(want))
190 .await
191 .map_err(|e| ServeError::Internal(e.to_string()))?
192 .ok_or(ServeError::NotFound)?;
193 if q.clean {
194 template.body = crate::templates::clean_config_yaml(&template.body).map_err(map_err)?;
195 }
196 let state = crate::templates::template_state(&s, &id)
197 .await
198 .map_err(map_err)?;
199 let launches = s
200 .template_launches(&id)
201 .await
202 .map_err(|e| ServeError::Internal(e.to_string()))?;
203 let is_stable = state.stable == Some(template.version);
204 Ok(Json(GetResponse {
205 template,
206 state,
207 is_stable,
208 launches,
209 }))
210}
211
212pub async fn delete_template(
218 State(state): State<ServerState>,
219 Extension(actor): Extension<AuthContext>,
220 Path(id): Path<String>,
221 Query(q): Query<VersionQuery>,
222) -> Result<StatusCode, ServeError> {
223 let s = store(&state);
224 let target = match q.version {
229 None => None,
230 Some(selector) => Some(
233 crate::templates::resolve_version(&s, &id, selector)
234 .await
235 .map_err(map_err)?,
236 ),
237 };
238 let removed = s
239 .template_delete(&id, target)
240 .await
241 .map_err(|e| ServeError::Internal(e.to_string()))?;
242 if removed == 0 {
243 return Err(ServeError::NotFound);
244 }
245 tracing::info!(
246 principal = %actor.principal,
247 template = %id,
248 version = ?target,
249 removed,
250 "deleted pipeline template version(s)"
251 );
252 crate::serve::audit::write(&state, &actor, "template.delete", None, None, "ok").await;
253 Ok(StatusCode::NO_CONTENT)
254}
255
256#[derive(Debug, Deserialize)]
261pub struct PromoteBody {
262 pub tag: VersionChannel,
264 #[serde(default)]
268 pub version: Option<VersionSelector>,
269}
270
271#[derive(Debug, Serialize)]
273pub struct PromoteResponse {
274 pub id: String,
275 pub tag: String,
276 pub version: u32,
278}
279
280pub async fn promote_template(
285 State(state): State<ServerState>,
286 Extension(actor): Extension<AuthContext>,
287 Path(id): Path<String>,
288 Json(body): Json<PromoteBody>,
289) -> Result<Json<PromoteResponse>, ServeError> {
290 let version = crate::templates::promote(
291 &store(&state),
292 &id,
293 body.tag,
294 body.version.unwrap_or_default(),
295 )
296 .await
297 .map_err(map_err)?;
298 tracing::info!(
299 principal = %actor.principal,
300 template = %id,
301 tag = body.tag.as_str(),
302 version,
303 "promoted pipeline template channel"
304 );
305 crate::serve::audit::write(&state, &actor, "template.promote", None, None, "ok").await;
306 Ok(Json(PromoteResponse {
307 id,
308 tag: body.tag.as_str().to_string(),
309 version,
310 }))
311}
312
313#[derive(Debug, Default, Deserialize)]
317pub struct LaunchBody {
318 #[serde(default)]
322 pub version: Option<VersionSelector>,
323}
324
325#[derive(Debug, Serialize)]
327pub struct LaunchResponse {
328 pub id: String,
329 pub version: u32,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub replaced: Option<u32>,
334 pub already_launched: bool,
336 pub status: String,
338}
339
340pub async fn launch_template(
345 State(state): State<ServerState>,
346 Extension(actor): Extension<AuthContext>,
347 Path(id): Path<String>,
348 Json(body): Json<LaunchBody>,
349) -> Result<Json<LaunchResponse>, ServeError> {
350 let target = body.version.unwrap_or_else(VersionSelector::newest);
351 let outcome = crate::templates::launch(&store(&state), &id, target, Some(&actor.principal))
352 .await
353 .map_err(map_err)?;
354 finish_launch(&state, &actor, &id, outcome, "template.launch").await
355}
356
357pub async fn rollback_template(
359 State(state): State<ServerState>,
360 Extension(actor): Extension<AuthContext>,
361 Path(id): Path<String>,
362) -> Result<Json<LaunchResponse>, ServeError> {
363 let outcome = crate::templates::rollback(&store(&state), &id, Some(&actor.principal))
364 .await
365 .map_err(map_err)?;
366 finish_launch(&state, &actor, &id, outcome, "template.rollback").await
367}
368
369async fn finish_launch(
371 state: &ServerState,
372 actor: &AuthContext,
373 id: &str,
374 outcome: crate::templates::LaunchOutcome,
375 action: &str,
376) -> Result<Json<LaunchResponse>, ServeError> {
377 let status = crate::templates::template_state(&store(state), id)
378 .await
379 .map_err(map_err)?
380 .status;
381 tracing::info!(
382 principal = %actor.principal,
383 template = %id,
384 version = outcome.version,
385 replaced = ?outcome.replaced,
386 already_launched = outcome.already_launched,
387 action,
388 "pipeline template launch"
389 );
390 crate::serve::audit::write(state, actor, action, None, None, "ok").await;
391 Ok(Json(LaunchResponse {
392 id: id.to_string(),
393 version: outcome.version,
394 replaced: outcome.replaced,
395 already_launched: outcome.already_launched,
396 status: status.as_str().to_string(),
397 }))
398}
399
400#[derive(Debug, Default, Deserialize)]
402pub struct DeprecateBody {
403 #[serde(default)]
405 pub reason: Option<String>,
406 #[serde(default)]
408 pub undo: bool,
409}
410
411pub async fn deprecate_template(
417 State(state): State<ServerState>,
418 Extension(actor): Extension<AuthContext>,
419 Path(id): Path<String>,
420 Json(body): Json<DeprecateBody>,
421) -> Result<Json<serde_json::Value>, ServeError> {
422 let status = crate::templates::set_deprecated(
423 &store(&state),
424 &id,
425 body.reason.clone(),
426 Some(&actor.principal),
427 !body.undo,
428 )
429 .await
430 .map_err(map_err)?;
431 let action = if body.undo {
432 "template.undeprecate"
433 } else {
434 "template.deprecate"
435 };
436 tracing::info!(
437 principal = %actor.principal,
438 template = %id,
439 status = status.as_str(),
440 "pipeline template deprecation changed"
441 );
442 crate::serve::audit::write(&state, &actor, action, None, None, "ok").await;
443 Ok(Json(
444 serde_json::json!({ "id": id, "status": status.as_str() }),
445 ))
446}
447
448#[derive(Debug, Default, Deserialize)]
453pub struct TriggerBody {
454 #[serde(default)]
456 pub params: BTreeMap<String, Value>,
457 #[serde(default)]
460 pub env: BTreeMap<String, String>,
461 #[serde(default)]
464 pub version: Option<VersionSelector>,
465 #[serde(default)]
467 pub name: Option<String>,
468 #[serde(default)]
469 pub labels: BTreeMap<String, String>,
470 #[serde(default)]
471 pub timeout_secs: Option<u64>,
472 #[serde(default)]
473 pub doctor_first: bool,
474 #[serde(default)]
475 pub idempotency_key: Option<String>,
476 #[serde(default)]
477 pub clock: Option<String>,
478 #[serde(default)]
482 pub callback: Option<crate::serve::callback::CallbackSpec>,
483}
484
485#[derive(Debug, Serialize)]
489pub struct TriggerResponse {
490 #[serde(flatten)]
491 pub run: SubmitResponse,
492 pub template_id: String,
493 pub template_version: u32,
494 pub params: BTreeMap<String, Value>,
496 #[serde(skip_serializing_if = "Option::is_none")]
499 pub deprecated: Option<String>,
500}
501
502const LABEL_TEMPLATE: &str = "template";
505const LABEL_TEMPLATE_VERSION: &str = "template_version";
506
507pub async fn trigger_template(
509 State(state): State<ServerState>,
510 Extension(actor): Extension<AuthContext>,
511 Path(id): Path<String>,
512 Json(body): Json<TriggerBody>,
513) -> Result<(StatusCode, Json<TriggerResponse>), ServeError> {
514 let supplied: SuppliedParams = body.params.into_iter().collect();
515 let s = store(&state);
518 let want = crate::templates::resolve_version(&s, &id, body.version.unwrap_or_default())
519 .await
520 .map_err(map_err)?;
521
522 let tstate = crate::templates::template_state(&s, &id)
525 .await
526 .map_err(map_err)?;
527 if tstate.status == crate::serve::history::templates::TemplateStatus::Deprecated {
528 tracing::warn!(
529 template = %id,
530 version = want,
531 reason = tstate
532 .deprecation
533 .as_ref()
534 .and_then(|d| d.reason.as_deref())
535 .unwrap_or("(none given)"),
536 "triggering a DEPRECATED pipeline template"
537 );
538 }
539 let clustered = state.cluster().enabled();
546 let mode = if clustered {
547 crate::templates::Materialize::Persisted
548 } else {
549 crate::templates::Materialize::Local
550 };
551 let materialized = crate::templates::materialize(&s, &id, want, &supplied, &body.env, mode)
552 .await
553 .map_err(map_err)?;
554
555 if clustered && (materialized.used_secret_params || !body.env.is_empty()) {
561 let what = if materialized.used_secret_params {
562 "declares `secret: true` param(s)"
563 } else {
564 "was triggered with `env` overrides"
565 };
566 return Err(ServeError::Unprocessable {
567 message: format!(
568 "this template {what}, and a clustered server persists the materialized config \
569 so a peer can execute it — which would store the value in the shared \
570 run-history database. Reference the secret from the template body instead \
571 (`${{env:VAR}}`, `${{vault:…}}`, `${{aws-sm:…}}`, … — all resolved on the \
572 executing instance, never persisted), or trigger it on a non-clustered server"
573 ),
574 details: None,
575 });
576 }
577
578 let mut labels = body.labels;
579 labels.insert(LABEL_TEMPLATE.into(), materialized.template_id.clone());
580 labels.insert(
581 LABEL_TEMPLATE_VERSION.into(),
582 materialized.version.to_string(),
583 );
584
585 let req = SubmitRequest {
586 config: materialized.body.clone(),
587 config_format: ConfigFormatWire::Json,
588 name: body.name.or_else(|| materialized.name.clone()),
589 labels,
590 timeout_secs: body.timeout_secs,
591 doctor_first: body.doctor_first,
592 idempotency_key: body.idempotency_key,
593 clock: body.clock,
594 callback: body.callback,
595 };
596 let run = runner::submit(state.clone(), req, actor.clone()).await?;
597 crate::serve::audit::write(
601 &state,
602 &actor,
603 "template.run",
604 Some(run.run_id.clone()),
605 None,
606 "ok",
607 )
608 .await;
609 Ok((
610 StatusCode::ACCEPTED,
611 Json(TriggerResponse {
612 run,
613 template_id: materialized.template_id,
614 template_version: materialized.version,
615 params: materialized.params_redacted,
616 deprecated: (tstate.status
617 == crate::serve::history::templates::TemplateStatus::Deprecated)
618 .then(|| {
619 tstate
620 .deprecation
621 .as_ref()
622 .and_then(|d| d.reason.clone())
623 .unwrap_or_else(|| "this template is deprecated".to_string())
624 }),
625 }),
626 ))
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use crate::serve::history::AuditFilter;
633 use crate::serve::rbac::Role;
634 use crate::serve::test_support::test_state;
635 use serde_json::json;
636
637 fn actor() -> AuthContext {
638 AuthContext {
639 principal: "tester".into(),
640 role: Role::Admin,
641 source_ip: None,
642 }
643 }
644
645 fn template_yaml(out: &std::path::Path) -> String {
646 format!(
647 "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",
648 out.display()
649 )
650 }
651
652 async fn register_demo_opts(
653 state: &ServerState,
654 out: &std::path::Path,
655 launch: bool,
656 ) -> TemplateSummary {
657 register_template(
658 State(state.clone()),
659 Extension(actor()),
660 Json(RegisterBody {
661 id: None,
662 config: template_yaml(out),
663 config_format: ConfigFormatWire::Yaml,
664 description: Some("demo".into()),
665 tags: vec![],
666 launch,
667 }),
668 )
669 .await
670 .expect("register")
671 .1
672 .0
673 }
674
675 async fn register_demo(state: &ServerState, out: &std::path::Path) -> TemplateSummary {
677 register_demo_opts(state, out, true).await
678 }
679
680 async fn get(
681 state: &ServerState,
682 id: &str,
683 q: VersionQuery,
684 ) -> Result<GetResponse, ServeError> {
685 get_template(State(state.clone()), Path(id.into()), Query(q))
686 .await
687 .map(|j| j.0)
688 }
689
690 #[tokio::test]
691 async fn register_list_get_delete_round_trip() {
692 let dir = tempfile::tempdir().unwrap();
693 let state = test_state();
694 let summary = register_demo(&state, &dir.path().join("o.jsonl")).await;
695 assert_eq!(summary.id, "tpl-demo");
696 assert_eq!(summary.version, 1);
697 assert_eq!(summary.created_by.as_deref(), Some("tester"));
698 assert!(summary.params["tag"].required);
699
700 let listed = list_templates(State(state.clone())).await.unwrap().0;
701 assert_eq!(listed.templates.len(), 1);
702 let st = listed.templates[0]
703 .state
704 .as_ref()
705 .expect("state on list rows");
706 assert_eq!(st.status.as_str(), "launched");
707 assert_eq!(st.stable, Some(1));
708
709 let got = get(&state, "tpl-demo", VersionQuery::default())
710 .await
711 .unwrap();
712 assert_eq!(got.template.version, 1);
713 assert_eq!(got.state.versions, vec![1]);
714 assert!(got.is_stable);
715 assert_eq!(got.launches.len(), 1, "the launch is recorded");
716 assert!(got.template.body.contains("${param.tag}"));
718
719 assert!(matches!(
720 get(&state, "nope", VersionQuery::default()).await,
721 Err(ServeError::NotFound)
722 ));
723 assert!(matches!(
724 get(
725 &state,
726 "tpl-demo",
727 VersionQuery {
728 version: Some(VersionSelector::Pinned(9)),
729 clean: false,
730 },
731 )
732 .await,
733 Err(ServeError::NotFound)
734 ));
735
736 let code = delete_template(
737 State(state.clone()),
738 Extension(actor()),
739 Path("tpl-demo".into()),
740 Query(VersionQuery::default()),
741 )
742 .await
743 .unwrap();
744 assert_eq!(code, StatusCode::NO_CONTENT);
745 assert!(matches!(
746 delete_template(
747 State(state.clone()),
748 Extension(actor()),
749 Path("tpl-demo".into()),
750 Query(VersionQuery::default())
751 )
752 .await,
753 Err(ServeError::NotFound)
754 ));
755
756 let entries = state
757 .history()
758 .list_audit(&AuditFilter {
759 limit: 20,
760 ..Default::default()
761 })
762 .await
763 .unwrap();
764 let actions: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
765 assert!(actions.contains(&"template.register"), "{actions:?}");
766 assert!(actions.contains(&"template.delete"), "{actions:?}");
767 }
768
769 #[tokio::test]
770 async fn register_rejects_an_invalid_config() {
771 let state = test_state();
772 let err = register_template(
773 State(state),
774 Extension(actor()),
775 Json(RegisterBody {
776 id: None,
777 config: "version: 1\nname: x\nbogus_key: 1\npipeline: {}\n".into(),
778 config_format: ConfigFormatWire::Yaml,
779 description: None,
780 tags: vec![],
781 launch: false,
782 }),
783 )
784 .await
785 .unwrap_err();
786 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
787 }
788
789 #[tokio::test]
790 async fn trigger_binds_params_and_stamps_provenance() {
791 let dir = tempfile::tempdir().unwrap();
792 let state = test_state();
793 register_demo(&state, &dir.path().join("o.jsonl")).await;
794
795 let (code, resp) = trigger_template(
796 State(state.clone()),
797 Extension(actor()),
798 Path("tpl-demo".into()),
799 Json(TriggerBody {
800 params: [("tag".to_string(), json!("alpha"))].into(),
801 ..Default::default()
802 }),
803 )
804 .await
805 .expect("trigger");
806 assert_eq!(code, StatusCode::ACCEPTED);
807 assert_eq!(resp.0.template_id, "tpl-demo");
808 assert_eq!(resp.0.template_version, 1);
809 assert_eq!(resp.0.params["tag"], json!("alpha"));
810 assert_eq!(resp.0.params["page"], json!(7));
811 assert!(resp.0.deprecated.is_none());
812
813 let rec = state
814 .history()
815 .get(&resp.0.run.run_id)
816 .await
817 .unwrap()
818 .expect("run record");
819 assert_eq!(rec.labels[LABEL_TEMPLATE], "tpl-demo");
820 assert_eq!(rec.labels[LABEL_TEMPLATE_VERSION], "1");
821 assert_eq!(rec.name.as_deref(), Some("tpl-demo"));
822
823 let entries = state
824 .history()
825 .list_audit(&AuditFilter {
826 action: Some("template.run".into()),
827 limit: 10,
828 ..Default::default()
829 })
830 .await
831 .unwrap();
832 assert_eq!(entries.len(), 1);
833 assert_eq!(
834 entries[0].run_id.as_deref(),
835 Some(resp.0.run.run_id.as_str())
836 );
837 }
838
839 #[tokio::test]
840 async fn a_draft_template_cannot_be_triggered_unpinned() {
841 let dir = tempfile::tempdir().unwrap();
842 let state = test_state();
843 register_demo_opts(&state, &dir.path().join("o.jsonl"), false).await;
845
846 let err = trigger_template(
847 State(state.clone()),
848 Extension(actor()),
849 Path("tpl-demo".into()),
850 Json(TriggerBody {
851 params: [("tag".to_string(), json!("x"))].into(),
852 ..Default::default()
853 }),
854 )
855 .await
856 .unwrap_err();
857 match err {
858 ServeError::Unprocessable { message, .. } => {
859 assert!(message.contains("no launched version"), "{message}");
860 assert!(message.contains("launch"), "{message}");
861 }
862 other => panic!("expected 422, got {other:?}"),
863 }
864
865 let resp = trigger_template(
867 State(state.clone()),
868 Extension(actor()),
869 Path("tpl-demo".into()),
870 Json(TriggerBody {
871 params: [("tag".to_string(), json!("x"))].into(),
872 version: Some(VersionSelector::newest()),
873 ..Default::default()
874 }),
875 )
876 .await
877 .expect("explicit newest runs a draft")
878 .1
879 .0;
880 assert_eq!(resp.template_version, 1);
881 }
882
883 #[tokio::test]
884 async fn launch_moves_callers_and_registering_does_not() {
885 let dir = tempfile::tempdir().unwrap();
886 let state = test_state();
887 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>| {
892 let state = state.clone();
893 async move {
894 trigger_template(
895 State(state),
896 Extension(actor()),
897 Path("tpl-demo".into()),
898 Json(TriggerBody {
899 params: [("tag".to_string(), json!("x"))].into(),
900 version,
901 ..Default::default()
902 }),
903 )
904 .await
905 .expect("trigger")
906 .1
907 .0
908 }
909 };
910 assert_eq!(trigger(None).await.template_version, 1);
911 assert_eq!(
912 trigger(Some(VersionSelector::newest()))
913 .await
914 .template_version,
915 2
916 );
917
918 let resp = launch_template(
920 State(state.clone()),
921 Extension(actor()),
922 Path("tpl-demo".into()),
923 Json(LaunchBody::default()),
924 )
925 .await
926 .unwrap()
927 .0;
928 assert_eq!((resp.version, resp.replaced), (2, Some(1)));
929 assert_eq!(resp.status, "launched");
930 assert!(!resp.already_launched);
931 assert_eq!(trigger(None).await.template_version, 2);
932
933 let resp = rollback_template(
935 State(state.clone()),
936 Extension(actor()),
937 Path("tpl-demo".into()),
938 )
939 .await
940 .unwrap()
941 .0;
942 assert_eq!((resp.version, resp.replaced), (1, Some(2)));
943 assert_eq!(trigger(None).await.template_version, 1);
944
945 for action in ["template.launch", "template.rollback"] {
947 let entries = state
948 .history()
949 .list_audit(&AuditFilter {
950 action: Some(action.into()),
951 limit: 10,
952 ..Default::default()
953 })
954 .await
955 .unwrap();
956 assert_eq!(entries.len(), 1, "{action}");
957 }
958 }
959
960 #[tokio::test]
961 async fn deprecation_warns_but_keeps_serving() {
962 let dir = tempfile::tempdir().unwrap();
963 let state = test_state();
964 register_demo(&state, &dir.path().join("o.jsonl")).await;
965
966 let body = deprecate_template(
967 State(state.clone()),
968 Extension(actor()),
969 Path("tpl-demo".into()),
970 Json(DeprecateBody {
971 reason: Some("superseded by tenant-sync-v2".into()),
972 undo: false,
973 }),
974 )
975 .await
976 .unwrap()
977 .0;
978 assert_eq!(body["status"], "deprecated");
979
980 let resp = trigger_template(
983 State(state.clone()),
984 Extension(actor()),
985 Path("tpl-demo".into()),
986 Json(TriggerBody {
987 params: [("tag".to_string(), json!("x"))].into(),
988 ..Default::default()
989 }),
990 )
991 .await
992 .expect("a deprecated template still runs")
993 .1
994 .0;
995 assert_eq!(resp.template_version, 1);
996 assert_eq!(
997 resp.deprecated.as_deref(),
998 Some("superseded by tenant-sync-v2")
999 );
1000
1001 let err = launch_template(
1003 State(state.clone()),
1004 Extension(actor()),
1005 Path("tpl-demo".into()),
1006 Json(LaunchBody::default()),
1007 )
1008 .await
1009 .unwrap_err();
1010 assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
1011
1012 let body = deprecate_template(
1014 State(state.clone()),
1015 Extension(actor()),
1016 Path("tpl-demo".into()),
1017 Json(DeprecateBody {
1018 reason: None,
1019 undo: true,
1020 }),
1021 )
1022 .await
1023 .unwrap()
1024 .0;
1025 assert_eq!(body["status"], "launched");
1026 }
1027
1028 #[tokio::test]
1029 async fn promote_moves_a_channel_without_touching_what_is_live() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let state = test_state();
1032 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; let resp = promote_template(
1036 State(state.clone()),
1037 Extension(actor()),
1038 Path("tpl-demo".into()),
1039 Json(PromoteBody {
1040 tag: VersionChannel::PreProd,
1041 version: Some(VersionSelector::newest()),
1042 }),
1043 )
1044 .await
1045 .unwrap()
1046 .0;
1047 assert_eq!((resp.tag.as_str(), resp.version), ("pre-prod", 2));
1048
1049 let got = get(&state, "tpl-demo", VersionQuery::default())
1050 .await
1051 .unwrap();
1052 assert_eq!(got.state.stable, Some(1), "promote must not move `stable`");
1053 assert_eq!(got.state.tags["pre-prod"], 2);
1054 assert!(
1055 !got.state.tags.contains_key("stable"),
1056 "derived, never stored"
1057 );
1058
1059 let err = promote_template(
1061 State(state.clone()),
1062 Extension(actor()),
1063 Path("tpl-demo".into()),
1064 Json(PromoteBody {
1065 tag: VersionChannel::Stable,
1066 version: Some(VersionSelector::Pinned(1)),
1067 }),
1068 )
1069 .await
1070 .unwrap_err();
1071 match err {
1072 ServeError::Unprocessable { message, .. } => {
1073 assert!(message.contains("derived"), "{message}")
1074 }
1075 other => panic!("expected 422, got {other:?}"),
1076 }
1077 assert!(
1078 serde_json::from_value::<PromoteBody>(json!({"tag": "latest", "version": 1})).is_err()
1079 );
1080 }
1081
1082 #[tokio::test]
1083 async fn selecting_an_unset_channel_is_unprocessable() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let state = test_state();
1086 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1087 let err = get(
1090 &state,
1091 "tpl-demo",
1092 VersionQuery {
1093 version: Some(VersionSelector::Channel(VersionChannel::Canary)),
1094 clean: false,
1095 },
1096 )
1097 .await
1098 .unwrap_err();
1099 match err {
1100 ServeError::Unprocessable { message, .. } => {
1101 assert!(message.contains("no `canary` version"), "{message}")
1102 }
1103 other => panic!("expected 422, got {other:?}"),
1104 }
1105 }
1106
1107 #[tokio::test]
1108 async fn delete_by_selector_removes_one_version_but_omitted_removes_all() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let state = test_state();
1111 register_demo(&state, &dir.path().join("v1.jsonl")).await; register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await;
1113 register_demo_opts(&state, &dir.path().join("v3.jsonl"), false).await;
1114
1115 delete_template(
1117 State(state.clone()),
1118 Extension(actor()),
1119 Path("tpl-demo".into()),
1120 Query(VersionQuery {
1121 version: Some(VersionSelector::newest()),
1122 clean: false,
1123 }),
1124 )
1125 .await
1126 .unwrap();
1127 assert_eq!(
1128 state.history().template_versions("tpl-demo").await.unwrap(),
1129 vec![2, 1]
1130 );
1131
1132 delete_template(
1134 State(state.clone()),
1135 Extension(actor()),
1136 Path("tpl-demo".into()),
1137 Query(VersionQuery::default()),
1138 )
1139 .await
1140 .unwrap();
1141 assert!(state.history().template_list().await.unwrap().is_empty());
1142 }
1143
1144 #[tokio::test]
1145 async fn secret_params_are_refused_on_a_clustered_server() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let state = crate::serve::test_support::test_state_clustered();
1148 let body = format!(
1149 "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",
1150 dir.path().join("o.jsonl").display()
1151 );
1152 let _registered = register_template(
1153 State(state.clone()),
1154 Extension(actor()),
1155 Json(RegisterBody {
1156 id: None,
1157 config: body,
1158 config_format: ConfigFormatWire::Yaml,
1159 description: None,
1160 tags: vec![],
1161 launch: true,
1162 }),
1163 )
1164 .await
1165 .expect("register");
1166
1167 let err = trigger_template(
1168 State(state),
1169 Extension(actor()),
1170 Path("tpl-secret".into()),
1171 Json(TriggerBody {
1172 params: [("token".to_string(), json!("super-secret-value"))].into(),
1173 ..Default::default()
1174 }),
1175 )
1176 .await
1177 .unwrap_err();
1178 match err {
1179 ServeError::Unprocessable { message, .. } => {
1180 assert!(message.contains("clustered"), "{message}");
1181 assert!(!message.contains("super-secret-value"), "leaked: {message}");
1182 }
1183 other => panic!("expected 422, got {other:?}"),
1184 }
1185 }
1186
1187 #[tokio::test]
1192 async fn env_overrides_are_refused_on_a_clustered_server() {
1193 let dir = tempfile::tempdir().unwrap();
1194 let state = crate::serve::test_support::test_state_clustered();
1195 let body = format!(
1196 "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",
1197 dir.path().join("o.jsonl").display()
1198 );
1199 let _registered = register_template(
1200 State(state.clone()),
1201 Extension(actor()),
1202 Json(RegisterBody {
1203 id: None,
1204 config: body,
1205 config_format: ConfigFormatWire::Yaml,
1206 description: None,
1207 tags: vec![],
1208 launch: true,
1209 }),
1210 )
1211 .await
1212 .expect("register");
1213
1214 let err = trigger_template(
1215 State(state),
1216 Extension(actor()),
1217 Path("tpl-env".into()),
1218 Json(TriggerBody {
1219 env: [("SRC_PATH".to_string(), "s3cret-path".to_string())].into(),
1220 ..Default::default()
1221 }),
1222 )
1223 .await
1224 .unwrap_err();
1225 match err {
1226 ServeError::Unprocessable { message, .. } => {
1227 assert!(message.contains("env"), "{message}");
1228 assert!(!message.contains("s3cret-path"), "leaked: {message}");
1229 }
1230 other => panic!("expected 422, got {other:?}"),
1231 }
1232 }
1233
1234 #[tokio::test]
1239 async fn a_clustered_trigger_persists_tokens_not_resolved_values() {
1240 let dir = tempfile::tempdir().unwrap();
1241 unsafe { std::env::set_var("FAUCET_TEST_C5_SECRET", "hunter2-should-not-persist") };
1244 let s = store(&crate::serve::test_support::test_state_clustered());
1245 let body = format!(
1246 "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",
1247 dir.path().join("o.jsonl").display()
1248 );
1249 crate::templates::register(
1250 &s,
1251 crate::templates::RegisterRequest {
1252 id: None,
1253 body,
1254 format: crate::serve::load::ConfigFormat::Yaml,
1255 description: None,
1256 tags: vec![],
1257 launch: true,
1258 created_by: None,
1259 },
1260 )
1261 .await
1262 .expect("register");
1263
1264 let persisted = crate::templates::materialize(
1265 &s,
1266 "tpl-c5",
1267 1,
1268 &Default::default(),
1269 &Default::default(),
1270 crate::templates::Materialize::Persisted,
1271 )
1272 .await
1273 .expect("materialize");
1274 assert!(
1275 !persisted.body.contains("hunter2-should-not-persist"),
1276 "a resolved secret must never reach a persisted body: {}",
1277 persisted.body
1278 );
1279 assert!(
1280 persisted.body.contains("${env:FAUCET_TEST_C5_SECRET}"),
1281 "the directive must survive as a token for the executor: {}",
1282 persisted.body
1283 );
1284
1285 let local = crate::templates::materialize(
1288 &s,
1289 "tpl-c5",
1290 1,
1291 &Default::default(),
1292 &Default::default(),
1293 crate::templates::Materialize::Local,
1294 )
1295 .await
1296 .expect("materialize");
1297 assert!(
1298 local.body.contains("hunter2-should-not-persist"),
1299 "{}",
1300 local.body
1301 );
1302 unsafe { std::env::remove_var("FAUCET_TEST_C5_SECRET") };
1303 }
1304
1305 #[test]
1306 fn version_query_deserializes_channels_and_numbers() {
1307 assert!(
1310 serde_json::from_value::<VersionQuery>(json!({}))
1311 .unwrap()
1312 .selector()
1313 .is_stable(),
1314 "an omitted selector means `stable`"
1315 );
1316 assert!(
1317 serde_json::from_value::<VersionQuery>(json!({ "version": "stable" }))
1318 .unwrap()
1319 .selector()
1320 .is_stable()
1321 );
1322 for wire in [json!({ "version": "2" }), json!({ "version": 2 })] {
1323 let q: VersionQuery = serde_json::from_value(wire.clone()).unwrap();
1324 assert_eq!(q.selector().pinned(), Some(2), "{wire}");
1325 }
1326 for bad in [
1327 json!({ "version": "nope" }),
1328 json!({ "version": 0 }),
1329 json!({ "version": "latest" }),
1330 ] {
1331 assert!(
1332 serde_json::from_value::<VersionQuery>(bad.clone()).is_err(),
1333 "{bad} should be rejected"
1334 );
1335 }
1336 }
1337
1338 #[tokio::test]
1339 async fn version_pinning_selects_an_older_body() {
1340 let dir = tempfile::tempdir().unwrap();
1341 let state = test_state();
1342 register_demo(&state, &dir.path().join("v1.jsonl")).await;
1343 let v2 = register_demo(&state, &dir.path().join("v2.jsonl")).await;
1344 assert_eq!(v2.version, 2);
1345
1346 let got = get_template(
1347 State(state.clone()),
1348 Path("tpl-demo".into()),
1349 Query(VersionQuery {
1350 version: Some(VersionSelector::Pinned(1)),
1351 clean: false,
1352 }),
1353 )
1354 .await
1355 .unwrap()
1356 .0;
1357 assert!(got.template.body.contains("v1.jsonl"));
1358 assert_eq!(got.state.versions, vec![2, 1]);
1359 assert!(!got.is_stable, "v2 is live, so a pinned v1 is not");
1360
1361 delete_template(
1363 State(state.clone()),
1364 Extension(actor()),
1365 Path("tpl-demo".into()),
1366 Query(VersionQuery {
1367 version: Some(VersionSelector::Pinned(1)),
1368 clean: false,
1369 }),
1370 )
1371 .await
1372 .unwrap();
1373 assert_eq!(
1374 state.history().template_versions("tpl-demo").await.unwrap(),
1375 vec![2]
1376 );
1377 }
1378}