Skip to main content

faucet_cli/serve/handlers/
templates.rs

1//! `/v1/templates*` — the pipeline template registry + parameterized trigger
2//! API (#444).
3//!
4//! Thin adapters over [`crate::templates`]: deserialize, call, map to a status
5//! code. Registration/read/delete need the `TemplateWrite` / `TemplateRead`
6//! permissions; triggering a run needs **both** `TemplateRead` (to resolve the
7//! template) and `RunWrite` (to start a run), and then flows through the very
8//! same [`crate::serve::runner::submit`] as `POST /v1/runs` — so idempotency
9//! keys, `doctor_first`, queue limits, cluster dispatch, metrics, and the audit
10//! log all behave identically.
11
12use 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
28/// Map a `CliError` from the templates layer onto an HTTP status. A missing
29/// template is a 404; anything the caller could have sent differently is a 422;
30/// a registry failure is a 500.
31fn 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
43/// The server's own run-history backend doubles as the template registry, so a
44/// `--history sqlite:…`/`postgres://…` server persists templates across
45/// restarts and shares them across a cluster.
46fn store(state: &ServerState) -> TemplateStore {
47    state.history()
48}
49
50// ── POST /v1/templates ──────────────────────────────────────────────────────
51
52/// `POST /v1/templates` request body.
53#[derive(Debug, Deserialize)]
54pub struct RegisterBody {
55    /// Registry id. Derived from the config's `name:` when omitted.
56    #[serde(default)]
57    pub id: Option<String>,
58    /// The config document, stored verbatim.
59    pub config: String,
60    #[serde(default)]
61    pub config_format: ConfigFormatWire,
62    #[serde(default)]
63    pub description: Option<String>,
64    /// Named environment channels to point at the newly registered version
65    /// (`dev`, `pre-prod`, …). Derived channels are rejected.
66    #[serde(default)]
67    pub tags: Vec<VersionChannel>,
68    /// Launch the new version immediately, making it `stable`. Off by default: a
69    /// register is inert so a new build never moves existing callers.
70    #[serde(default)]
71    pub launch: bool,
72}
73
74/// `POST /v1/templates` → 201 with the newly registered version's summary.
75pub 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    // `config_fingerprint` carries the sha256 of the registered document — a
96    // genuine config fingerprint, and a stable identifier for exactly what was
97    // stored. Which template/version it became is in the structured log line
98    // below and, durably, in the registry record's own `created_by`/`created_at`.
99    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// ── GET /v1/templates ───────────────────────────────────────────────────────
122
123/// `GET /v1/templates` response body.
124#[derive(Debug, Serialize)]
125pub struct ListResponse {
126    pub templates: Vec<TemplateSummary>,
127}
128
129/// `GET /v1/templates` → 200. Latest version of each registered template.
130pub 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// ── GET /v1/templates/{id} ──────────────────────────────────────────────────
140
141/// Optional `?version=` selector shared by get + delete. Accepts a channel name
142/// (`stable` — the default when omitted — `newest`, `previous`, `prod`, …) or an
143/// exact version number.
144#[derive(Debug, Default, Deserialize)]
145pub struct VersionQuery {
146    #[serde(default)]
147    pub version: Option<VersionSelector>,
148}
149
150impl VersionQuery {
151    /// The selector to act on, defaulting to `stable` (the launched version).
152    fn selector(&self) -> VersionSelector {
153        self.version.unwrap_or_default()
154    }
155}
156
157/// `GET /v1/templates/{id}` response body: one version, plus the template's whole
158/// release state — so a client can pin, promote, launch, or roll back without a
159/// second request.
160#[derive(Debug, Serialize)]
161pub struct GetResponse {
162    #[serde(flatten)]
163    pub template: TemplateRecord,
164    /// Status, every stored version, the `stable` / `previous` / `newest`
165    /// pointers, channel assignments, and any deprecation.
166    #[serde(flatten)]
167    pub state: TemplateState,
168    /// Whether the returned version is the currently launched one.
169    pub is_stable: bool,
170    /// The launch log, newest first — who blessed which build, and when.
171    pub launches: Vec<crate::serve::history::templates::LaunchRecord>,
172}
173
174/// `GET /v1/templates/{id}[?version=N]` → 200 / 404.
175pub 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
205// ── DELETE /v1/templates/{id} ───────────────────────────────────────────────
206
207/// `DELETE /v1/templates/{id}[?version=N]` → 204 / 404. Without `version` every
208/// version of the template is removed. Runs already produced by the template are
209/// untouched — their records stand on their own.
210pub 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    // Unlike `GET`, an omitted selector and an explicit channel mean *different*
218    // things here: no selector deletes the whole template, whereas a channel
219    // deletes only the version it points at. So a channel must be resolved to a
220    // number first rather than collapsing to the "all versions" `None`.
221    let target = match q.version {
222        None => None,
223        // A selector always resolves to a concrete version, so `--version stable`
224        // removes just the launched one rather than collapsing to "all versions".
225        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// ── POST /v1/templates/{id}/tags ────────────────────────────────────────────
250
251/// `POST /v1/templates/{id}/tags` request body: point a named channel at a
252/// version.
253#[derive(Debug, Deserialize)]
254pub struct PromoteBody {
255    /// Channel to move — one of the closed set, and never the derived `latest`.
256    pub tag: VersionChannel,
257    /// Where to point it: a version number, or another channel whose current
258    /// target should be copied (`{"tag":"prod","version":"stable"}` promotes
259    /// whatever `stable` names today). Defaults to `latest`.
260    #[serde(default)]
261    pub version: Option<VersionSelector>,
262}
263
264/// `POST /v1/templates/{id}/tags` response body.
265#[derive(Debug, Serialize)]
266pub struct PromoteResponse {
267    pub id: String,
268    pub tag: String,
269    /// The concrete version the channel now points at.
270    pub version: u32,
271}
272
273/// `POST /v1/templates/{id}/tags` → 200 / 404 / 422.
274///
275/// Promotion is the whole point of the named channels: versions themselves are
276/// immutable and auto-incrementing, and this is how `prod` moves from v3 to v4.
277pub 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// ── POST /v1/templates/{id}/launch  ·  /rollback  ·  /deprecate ─────────────
307
308/// `POST /v1/templates/{id}/launch` request body.
309#[derive(Debug, Default, Deserialize)]
310pub struct LaunchBody {
311    /// Which version to make live: a number, or a channel whose current target to
312    /// copy. Defaults to `newest` — launching what you just registered is the
313    /// common case.
314    #[serde(default)]
315    pub version: Option<VersionSelector>,
316}
317
318/// Response for launch / rollback.
319#[derive(Debug, Serialize)]
320pub struct LaunchResponse {
321    pub id: String,
322    /// The version now live.
323    pub version: u32,
324    /// The version it replaced — the new `previous`. `None` on a first launch.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub replaced: Option<u32>,
327    /// True when the version was already live, so nothing changed.
328    pub already_launched: bool,
329    /// The template's status after the launch.
330    pub status: String,
331}
332
333/// `POST /v1/templates/{id}/launch` → 200 / 404 / 422.
334///
335/// The one operation that moves unpinned callers. Registering a build does not;
336/// that separation is what lets a nightly land without dragging anyone along.
337pub 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
350/// `POST /v1/templates/{id}/rollback` → 200 / 404 / 422. Re-launches `previous`.
351pub 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
362/// Shared tail for launch + rollback: log, audit, and shape the response.
363async 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/// `POST /v1/templates/{id}/deprecate` request body.
394#[derive(Debug, Default, Deserialize)]
395pub struct DeprecateBody {
396    /// Why it is being retired, surfaced to anyone who triggers it.
397    #[serde(default)]
398    pub reason: Option<String>,
399    /// Revive instead of retire.
400    #[serde(default)]
401    pub undo: bool,
402}
403
404/// `POST /v1/templates/{id}/deprecate` → 200 / 404.
405///
406/// Deprecation is template-wide. A deprecated template keeps serving callers who
407/// pin or ride `stable` — retiring must not hard-break them — but every trigger
408/// warns and listings mark it. `DELETE` is the hard stop.
409pub 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// ── POST /v1/templates/{id}/runs ────────────────────────────────────────────
442
443/// `POST /v1/templates/{id}/runs` request body. Everything after `params`/`env`
444/// mirrors `POST /v1/runs`, because the run is submitted through the same path.
445#[derive(Debug, Default, Deserialize)]
446pub struct TriggerBody {
447    /// Values for the template's declared `params:`.
448    #[serde(default)]
449    pub params: BTreeMap<String, Value>,
450    /// Values that win over the server's environment for `${env:VAR}` during
451    /// this materialization only.
452    #[serde(default)]
453    pub env: BTreeMap<String, String>,
454    /// Version to run: a number, or a named channel (`"latest"` — the default
455    /// when omitted — `"prod"`, `"pre-prod"`, `"dev"`, …).
456    #[serde(default)]
457    pub version: Option<VersionSelector>,
458    /// Run name override (default: the template's config `name:`).
459    #[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/// `POST /v1/templates/{id}/runs` success body (202): the ordinary submit
474/// response plus which template version produced it and the (redacted) params
475/// it was bound with.
476#[derive(Debug, Serialize)]
477pub struct TriggerResponse {
478    #[serde(flatten)]
479    pub run: SubmitResponse,
480    pub template_id: String,
481    pub template_version: u32,
482    /// Bound params with every `secret: true` value replaced by `"***"`.
483    pub params: BTreeMap<String, Value>,
484    /// Present only when the template is deprecated — the run still started, but
485    /// the caller should migrate. Silently succeeding would hide the retirement.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub deprecated: Option<String>,
488}
489
490/// Label keys stamped on a template-triggered run, so `GET /v1/runs` can filter
491/// by provenance.
492const LABEL_TEMPLATE: &str = "template";
493const LABEL_TEMPLATE_VERSION: &str = "template_version";
494
495/// `POST /v1/templates/{id}/runs` → 202 / 404 / 422 / 429.
496pub 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    // Resolve through the registry: a channel needs a lookup, and an unpinned
504    // request means `stable` — the *launched* version, never "the newest build".
505    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    // A deprecated template still runs — retiring must not hard-break callers —
511    // but every trigger says so, loudly enough to show up in the operator's logs.
512    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    // A clustered submit persists the materialized config so any instance can
528    // re-run it, so nothing secret may be baked into that body. In cluster mode we
529    // therefore materialize in `Persisted` mode: `${env:}` / `${file:}` /
530    // `${secret:}` stay as tokens and are resolved by the executing instance
531    // (#456 C5). What cannot be deferred is a value the *caller* supplied, so
532    // those are refused below.
533    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    // Caller-supplied values that would land in the persisted body: a
544    // `secret: true` param, or an `env:` override (which substitutes into the
545    // config exactly like a param and is equally likely to be a credential —
546    // #456 M4). Both are refused rather than written to a shared database that is
547    // deliberately not a secret store.
548    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    // `submit` already recorded `run.submit`; this second entry attributes the
585    // *trigger* specifically, and its `run_id` links to the run record whose
586    // `template` / `template_version` labels name the version used.
587    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    /// Register **and launch**, for tests that just need a usable template.
663    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        // The stored body is verbatim, so the interpolation token survives.
704        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        // Registered but NOT launched — the work-in-progress state.
830        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        // …but an explicit selector runs it, so a draft is testable.
852        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; // v1, launched
874        register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; // v2, a build
875
876        // An unpinned trigger still runs v1 — this is the whole point.
877        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        // Launching v2 is the deliberate act that moves them.
905        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        // Rollback returns to v1.
920        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        // Both are audited under their own action.
932        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        // Existing callers keep working — retiring must not hard-break them — but
967        // the response says so, so the deprecation cannot pass unnoticed.
968        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        // Launching into a retired template is refused until it is revived.
988        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        // `undo` restores the derived status.
999        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; // v1 live
1019        register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; // v2 build
1020
1021        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        // A derived channel is not a promote target; `latest` is not a channel.
1046        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        // Never falls back to `stable` — running the wrong version silently is the
1074        // failure mode this guards.
1075        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; // v1 launched
1097        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        // `?version=newest` peels off only v3.
1101        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        // No selector removes the whole template.
1117        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    /// #456 M4: an `env` override substitutes into the config exactly like a
1172    /// param and is just as likely to be a credential, so on a clustered server —
1173    /// where the materialized body is persisted for a peer — it must be refused
1174    /// alongside `secret: true` params.
1175    #[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    /// #456 C5: on a clustered server the persisted body must still carry the
1219    /// load-time directives as *tokens* — resolving them here would serialise the
1220    /// server's own credentials into the shared run-history database. The
1221    /// executing instance resolves them instead.
1222    #[tokio::test]
1223    async fn a_clustered_trigger_persists_tokens_not_resolved_values() {
1224        let dir = tempfile::tempdir().unwrap();
1225        // SAFETY: single-threaded test; the value is read back below only via the
1226        // materialize path we are asserting about.
1227        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        // The local (non-clustered) path still resolves, so behaviour there is
1270        // unchanged — nothing is persisted in that mode.
1271        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        // A query string decodes every value as a string, so that is the shape
1292        // that matters here; a JSON body may send a bare number.
1293        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        // Deleting one version leaves the other.
1345        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}