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