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    /// `clean=true` returns the config body with comments stripped and re-emitted
149    /// as canonical YAML (the pure template). Powers the console's "Clean" toggle.
150    #[serde(default)]
151    pub clean: bool,
152}
153
154impl VersionQuery {
155    /// The selector to act on, defaulting to `stable` (the launched version).
156    fn selector(&self) -> VersionSelector {
157        self.version.unwrap_or_default()
158    }
159}
160
161/// `GET /v1/templates/{id}` response body: one version, plus the template's whole
162/// release state — so a client can pin, promote, launch, or roll back without a
163/// second request.
164#[derive(Debug, Serialize)]
165pub struct GetResponse {
166    #[serde(flatten)]
167    pub template: TemplateRecord,
168    /// Status, every stored version, the `stable` / `previous` / `newest`
169    /// pointers, channel assignments, and any deprecation.
170    #[serde(flatten)]
171    pub state: TemplateState,
172    /// Whether the returned version is the currently launched one.
173    pub is_stable: bool,
174    /// The launch log, newest first — who blessed which build, and when.
175    pub launches: Vec<crate::serve::history::templates::LaunchRecord>,
176}
177
178/// `GET /v1/templates/{id}[?version=N]` → 200 / 404.
179pub async fn get_template(
180    State(state): State<ServerState>,
181    Path(id): Path<String>,
182    Query(q): Query<VersionQuery>,
183) -> Result<Json<GetResponse>, ServeError> {
184    let s = store(&state);
185    let want = crate::templates::resolve_version(&s, &id, q.selector())
186        .await
187        .map_err(map_err)?;
188    let mut template = s
189        .template_get(&id, Some(want))
190        .await
191        .map_err(|e| ServeError::Internal(e.to_string()))?
192        .ok_or(ServeError::NotFound)?;
193    if q.clean {
194        template.body = crate::templates::clean_config_yaml(&template.body).map_err(map_err)?;
195    }
196    let state = crate::templates::template_state(&s, &id)
197        .await
198        .map_err(map_err)?;
199    let launches = s
200        .template_launches(&id)
201        .await
202        .map_err(|e| ServeError::Internal(e.to_string()))?;
203    let is_stable = state.stable == Some(template.version);
204    Ok(Json(GetResponse {
205        template,
206        state,
207        is_stable,
208        launches,
209    }))
210}
211
212// ── DELETE /v1/templates/{id} ───────────────────────────────────────────────
213
214/// `DELETE /v1/templates/{id}[?version=N]` → 204 / 404. Without `version` every
215/// version of the template is removed. Runs already produced by the template are
216/// untouched — their records stand on their own.
217pub async fn delete_template(
218    State(state): State<ServerState>,
219    Extension(actor): Extension<AuthContext>,
220    Path(id): Path<String>,
221    Query(q): Query<VersionQuery>,
222) -> Result<StatusCode, ServeError> {
223    let s = store(&state);
224    // Unlike `GET`, an omitted selector and an explicit channel mean *different*
225    // things here: no selector deletes the whole template, whereas a channel
226    // deletes only the version it points at. So a channel must be resolved to a
227    // number first rather than collapsing to the "all versions" `None`.
228    let target = match q.version {
229        None => None,
230        // A selector always resolves to a concrete version, so `--version stable`
231        // removes just the launched one rather than collapsing to "all versions".
232        Some(selector) => Some(
233            crate::templates::resolve_version(&s, &id, selector)
234                .await
235                .map_err(map_err)?,
236        ),
237    };
238    let removed = s
239        .template_delete(&id, target)
240        .await
241        .map_err(|e| ServeError::Internal(e.to_string()))?;
242    if removed == 0 {
243        return Err(ServeError::NotFound);
244    }
245    tracing::info!(
246        principal = %actor.principal,
247        template = %id,
248        version = ?target,
249        removed,
250        "deleted pipeline template version(s)"
251    );
252    crate::serve::audit::write(&state, &actor, "template.delete", None, None, "ok").await;
253    Ok(StatusCode::NO_CONTENT)
254}
255
256// ── POST /v1/templates/{id}/tags ────────────────────────────────────────────
257
258/// `POST /v1/templates/{id}/tags` request body: point a named channel at a
259/// version.
260#[derive(Debug, Deserialize)]
261pub struct PromoteBody {
262    /// Channel to move — one of the closed set, and never the derived `latest`.
263    pub tag: VersionChannel,
264    /// Where to point it: a version number, or another channel whose current
265    /// target should be copied (`{"tag":"prod","version":"stable"}` promotes
266    /// whatever `stable` names today). Defaults to `latest`.
267    #[serde(default)]
268    pub version: Option<VersionSelector>,
269}
270
271/// `POST /v1/templates/{id}/tags` response body.
272#[derive(Debug, Serialize)]
273pub struct PromoteResponse {
274    pub id: String,
275    pub tag: String,
276    /// The concrete version the channel now points at.
277    pub version: u32,
278}
279
280/// `POST /v1/templates/{id}/tags` → 200 / 404 / 422.
281///
282/// Promotion is the whole point of the named channels: versions themselves are
283/// immutable and auto-incrementing, and this is how `prod` moves from v3 to v4.
284pub async fn promote_template(
285    State(state): State<ServerState>,
286    Extension(actor): Extension<AuthContext>,
287    Path(id): Path<String>,
288    Json(body): Json<PromoteBody>,
289) -> Result<Json<PromoteResponse>, ServeError> {
290    let version = crate::templates::promote(
291        &store(&state),
292        &id,
293        body.tag,
294        body.version.unwrap_or_default(),
295    )
296    .await
297    .map_err(map_err)?;
298    tracing::info!(
299        principal = %actor.principal,
300        template = %id,
301        tag = body.tag.as_str(),
302        version,
303        "promoted pipeline template channel"
304    );
305    crate::serve::audit::write(&state, &actor, "template.promote", None, None, "ok").await;
306    Ok(Json(PromoteResponse {
307        id,
308        tag: body.tag.as_str().to_string(),
309        version,
310    }))
311}
312
313// ── POST /v1/templates/{id}/launch  ·  /rollback  ·  /deprecate ─────────────
314
315/// `POST /v1/templates/{id}/launch` request body.
316#[derive(Debug, Default, Deserialize)]
317pub struct LaunchBody {
318    /// Which version to make live: a number, or a channel whose current target to
319    /// copy. Defaults to `newest` — launching what you just registered is the
320    /// common case.
321    #[serde(default)]
322    pub version: Option<VersionSelector>,
323}
324
325/// Response for launch / rollback.
326#[derive(Debug, Serialize)]
327pub struct LaunchResponse {
328    pub id: String,
329    /// The version now live.
330    pub version: u32,
331    /// The version it replaced — the new `previous`. `None` on a first launch.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub replaced: Option<u32>,
334    /// True when the version was already live, so nothing changed.
335    pub already_launched: bool,
336    /// The template's status after the launch.
337    pub status: String,
338}
339
340/// `POST /v1/templates/{id}/launch` → 200 / 404 / 422.
341///
342/// The one operation that moves unpinned callers. Registering a build does not;
343/// that separation is what lets a nightly land without dragging anyone along.
344pub async fn launch_template(
345    State(state): State<ServerState>,
346    Extension(actor): Extension<AuthContext>,
347    Path(id): Path<String>,
348    Json(body): Json<LaunchBody>,
349) -> Result<Json<LaunchResponse>, ServeError> {
350    let target = body.version.unwrap_or_else(VersionSelector::newest);
351    let outcome = crate::templates::launch(&store(&state), &id, target, Some(&actor.principal))
352        .await
353        .map_err(map_err)?;
354    finish_launch(&state, &actor, &id, outcome, "template.launch").await
355}
356
357/// `POST /v1/templates/{id}/rollback` → 200 / 404 / 422. Re-launches `previous`.
358pub async fn rollback_template(
359    State(state): State<ServerState>,
360    Extension(actor): Extension<AuthContext>,
361    Path(id): Path<String>,
362) -> Result<Json<LaunchResponse>, ServeError> {
363    let outcome = crate::templates::rollback(&store(&state), &id, Some(&actor.principal))
364        .await
365        .map_err(map_err)?;
366    finish_launch(&state, &actor, &id, outcome, "template.rollback").await
367}
368
369/// Shared tail for launch + rollback: log, audit, and shape the response.
370async fn finish_launch(
371    state: &ServerState,
372    actor: &AuthContext,
373    id: &str,
374    outcome: crate::templates::LaunchOutcome,
375    action: &str,
376) -> Result<Json<LaunchResponse>, ServeError> {
377    let status = crate::templates::template_state(&store(state), id)
378        .await
379        .map_err(map_err)?
380        .status;
381    tracing::info!(
382        principal = %actor.principal,
383        template = %id,
384        version = outcome.version,
385        replaced = ?outcome.replaced,
386        already_launched = outcome.already_launched,
387        action,
388        "pipeline template launch"
389    );
390    crate::serve::audit::write(state, actor, action, None, None, "ok").await;
391    Ok(Json(LaunchResponse {
392        id: id.to_string(),
393        version: outcome.version,
394        replaced: outcome.replaced,
395        already_launched: outcome.already_launched,
396        status: status.as_str().to_string(),
397    }))
398}
399
400/// `POST /v1/templates/{id}/deprecate` request body.
401#[derive(Debug, Default, Deserialize)]
402pub struct DeprecateBody {
403    /// Why it is being retired, surfaced to anyone who triggers it.
404    #[serde(default)]
405    pub reason: Option<String>,
406    /// Revive instead of retire.
407    #[serde(default)]
408    pub undo: bool,
409}
410
411/// `POST /v1/templates/{id}/deprecate` → 200 / 404.
412///
413/// Deprecation is template-wide. A deprecated template keeps serving callers who
414/// pin or ride `stable` — retiring must not hard-break them — but every trigger
415/// warns and listings mark it. `DELETE` is the hard stop.
416pub async fn deprecate_template(
417    State(state): State<ServerState>,
418    Extension(actor): Extension<AuthContext>,
419    Path(id): Path<String>,
420    Json(body): Json<DeprecateBody>,
421) -> Result<Json<serde_json::Value>, ServeError> {
422    let status = crate::templates::set_deprecated(
423        &store(&state),
424        &id,
425        body.reason.clone(),
426        Some(&actor.principal),
427        !body.undo,
428    )
429    .await
430    .map_err(map_err)?;
431    let action = if body.undo {
432        "template.undeprecate"
433    } else {
434        "template.deprecate"
435    };
436    tracing::info!(
437        principal = %actor.principal,
438        template = %id,
439        status = status.as_str(),
440        "pipeline template deprecation changed"
441    );
442    crate::serve::audit::write(&state, &actor, action, None, None, "ok").await;
443    Ok(Json(
444        serde_json::json!({ "id": id, "status": status.as_str() }),
445    ))
446}
447
448// ── POST /v1/templates/{id}/runs ────────────────────────────────────────────
449
450/// `POST /v1/templates/{id}/runs` request body. Everything after `params`/`env`
451/// mirrors `POST /v1/runs`, because the run is submitted through the same path.
452#[derive(Debug, Default, Deserialize)]
453pub struct TriggerBody {
454    /// Values for the template's declared `params:`.
455    #[serde(default)]
456    pub params: BTreeMap<String, Value>,
457    /// Values that win over the server's environment for `${env:VAR}` during
458    /// this materialization only.
459    #[serde(default)]
460    pub env: BTreeMap<String, String>,
461    /// Version to run: a number, or a named channel (`"latest"` — the default
462    /// when omitted — `"prod"`, `"pre-prod"`, `"dev"`, …).
463    #[serde(default)]
464    pub version: Option<VersionSelector>,
465    /// Run name override (default: the template's config `name:`).
466    #[serde(default)]
467    pub name: Option<String>,
468    #[serde(default)]
469    pub labels: BTreeMap<String, String>,
470    #[serde(default)]
471    pub timeout_secs: Option<u64>,
472    #[serde(default)]
473    pub doctor_first: bool,
474    #[serde(default)]
475    pub idempotency_key: Option<String>,
476    #[serde(default)]
477    pub clock: Option<String>,
478    /// Optional completion callback for this run (#481). The primary use case
479    /// for a per-run callback: one registered template, many callers, each
480    /// reporting to its own endpoint.
481    #[serde(default)]
482    pub callback: Option<crate::serve::callback::CallbackSpec>,
483}
484
485/// `POST /v1/templates/{id}/runs` success body (202): the ordinary submit
486/// response plus which template version produced it and the (redacted) params
487/// it was bound with.
488#[derive(Debug, Serialize)]
489pub struct TriggerResponse {
490    #[serde(flatten)]
491    pub run: SubmitResponse,
492    pub template_id: String,
493    pub template_version: u32,
494    /// Bound params with every `secret: true` value replaced by `"***"`.
495    pub params: BTreeMap<String, Value>,
496    /// Present only when the template is deprecated — the run still started, but
497    /// the caller should migrate. Silently succeeding would hide the retirement.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub deprecated: Option<String>,
500}
501
502/// Label keys stamped on a template-triggered run, so `GET /v1/runs` can filter
503/// by provenance.
504const LABEL_TEMPLATE: &str = "template";
505const LABEL_TEMPLATE_VERSION: &str = "template_version";
506
507/// `POST /v1/templates/{id}/runs` → 202 / 404 / 422 / 429.
508pub async fn trigger_template(
509    State(state): State<ServerState>,
510    Extension(actor): Extension<AuthContext>,
511    Path(id): Path<String>,
512    Json(body): Json<TriggerBody>,
513) -> Result<(StatusCode, Json<TriggerResponse>), ServeError> {
514    let supplied: SuppliedParams = body.params.into_iter().collect();
515    // Resolve through the registry: a channel needs a lookup, and an unpinned
516    // request means `stable` — the *launched* version, never "the newest build".
517    let s = store(&state);
518    let want = crate::templates::resolve_version(&s, &id, body.version.unwrap_or_default())
519        .await
520        .map_err(map_err)?;
521
522    // A deprecated template still runs — retiring must not hard-break callers —
523    // but every trigger says so, loudly enough to show up in the operator's logs.
524    let tstate = crate::templates::template_state(&s, &id)
525        .await
526        .map_err(map_err)?;
527    if tstate.status == crate::serve::history::templates::TemplateStatus::Deprecated {
528        tracing::warn!(
529            template = %id,
530            version = want,
531            reason = tstate
532                .deprecation
533                .as_ref()
534                .and_then(|d| d.reason.as_deref())
535                .unwrap_or("(none given)"),
536            "triggering a DEPRECATED pipeline template"
537        );
538    }
539    // A clustered submit persists the materialized config so any instance can
540    // re-run it, so nothing secret may be baked into that body. In cluster mode we
541    // therefore materialize in `Persisted` mode: `${env:}` / `${file:}` /
542    // `${secret:}` stay as tokens and are resolved by the executing instance
543    // (#456 C5). What cannot be deferred is a value the *caller* supplied, so
544    // those are refused below.
545    let clustered = state.cluster().enabled();
546    let mode = if clustered {
547        crate::templates::Materialize::Persisted
548    } else {
549        crate::templates::Materialize::Local
550    };
551    let materialized = crate::templates::materialize(&s, &id, want, &supplied, &body.env, mode)
552        .await
553        .map_err(map_err)?;
554
555    // Caller-supplied values that would land in the persisted body: a
556    // `secret: true` param, or an `env:` override (which substitutes into the
557    // config exactly like a param and is equally likely to be a credential —
558    // #456 M4). Both are refused rather than written to a shared database that is
559    // deliberately not a secret store.
560    if clustered && (materialized.used_secret_params || !body.env.is_empty()) {
561        let what = if materialized.used_secret_params {
562            "declares `secret: true` param(s)"
563        } else {
564            "was triggered with `env` overrides"
565        };
566        return Err(ServeError::Unprocessable {
567            message: format!(
568                "this template {what}, and a clustered server persists the materialized config \
569                 so a peer can execute it — which would store the value in the shared \
570                 run-history database. Reference the secret from the template body instead \
571                 (`${{env:VAR}}`, `${{vault:…}}`, `${{aws-sm:…}}`, … — all resolved on the \
572                 executing instance, never persisted), or trigger it on a non-clustered server"
573            ),
574            details: None,
575        });
576    }
577
578    let mut labels = body.labels;
579    labels.insert(LABEL_TEMPLATE.into(), materialized.template_id.clone());
580    labels.insert(
581        LABEL_TEMPLATE_VERSION.into(),
582        materialized.version.to_string(),
583    );
584
585    let req = SubmitRequest {
586        config: materialized.body.clone(),
587        config_format: ConfigFormatWire::Json,
588        name: body.name.or_else(|| materialized.name.clone()),
589        labels,
590        timeout_secs: body.timeout_secs,
591        doctor_first: body.doctor_first,
592        idempotency_key: body.idempotency_key,
593        clock: body.clock,
594        callback: body.callback,
595    };
596    let run = runner::submit(state.clone(), req, actor.clone()).await?;
597    // `submit` already recorded `run.submit`; this second entry attributes the
598    // *trigger* specifically, and its `run_id` links to the run record whose
599    // `template` / `template_version` labels name the version used.
600    crate::serve::audit::write(
601        &state,
602        &actor,
603        "template.run",
604        Some(run.run_id.clone()),
605        None,
606        "ok",
607    )
608    .await;
609    Ok((
610        StatusCode::ACCEPTED,
611        Json(TriggerResponse {
612            run,
613            template_id: materialized.template_id,
614            template_version: materialized.version,
615            params: materialized.params_redacted,
616            deprecated: (tstate.status
617                == crate::serve::history::templates::TemplateStatus::Deprecated)
618                .then(|| {
619                    tstate
620                        .deprecation
621                        .as_ref()
622                        .and_then(|d| d.reason.clone())
623                        .unwrap_or_else(|| "this template is deprecated".to_string())
624                }),
625        }),
626    ))
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::serve::history::AuditFilter;
633    use crate::serve::rbac::Role;
634    use crate::serve::test_support::test_state;
635    use serde_json::json;
636
637    fn actor() -> AuthContext {
638        AuthContext {
639            principal: "tester".into(),
640            role: Role::Admin,
641            source_ip: None,
642        }
643    }
644
645    fn template_yaml(out: &std::path::Path) -> String {
646        format!(
647            "version: 1\nname: tpl-demo\nparams:\n  tag: {{ required: true }}\n  page: {{ type: int, default: 7 }}\npipeline:\n  source:\n    type: csv\n    config:\n      path: ./missing-${{param.tag}}.csv\n  sink:\n    type: jsonl\n    config:\n      path: {}\n",
648            out.display()
649        )
650    }
651
652    async fn register_demo_opts(
653        state: &ServerState,
654        out: &std::path::Path,
655        launch: bool,
656    ) -> TemplateSummary {
657        register_template(
658            State(state.clone()),
659            Extension(actor()),
660            Json(RegisterBody {
661                id: None,
662                config: template_yaml(out),
663                config_format: ConfigFormatWire::Yaml,
664                description: Some("demo".into()),
665                tags: vec![],
666                launch,
667            }),
668        )
669        .await
670        .expect("register")
671        .1
672        .0
673    }
674
675    /// Register **and launch**, for tests that just need a usable template.
676    async fn register_demo(state: &ServerState, out: &std::path::Path) -> TemplateSummary {
677        register_demo_opts(state, out, true).await
678    }
679
680    async fn get(
681        state: &ServerState,
682        id: &str,
683        q: VersionQuery,
684    ) -> Result<GetResponse, ServeError> {
685        get_template(State(state.clone()), Path(id.into()), Query(q))
686            .await
687            .map(|j| j.0)
688    }
689
690    #[tokio::test]
691    async fn register_list_get_delete_round_trip() {
692        let dir = tempfile::tempdir().unwrap();
693        let state = test_state();
694        let summary = register_demo(&state, &dir.path().join("o.jsonl")).await;
695        assert_eq!(summary.id, "tpl-demo");
696        assert_eq!(summary.version, 1);
697        assert_eq!(summary.created_by.as_deref(), Some("tester"));
698        assert!(summary.params["tag"].required);
699
700        let listed = list_templates(State(state.clone())).await.unwrap().0;
701        assert_eq!(listed.templates.len(), 1);
702        let st = listed.templates[0]
703            .state
704            .as_ref()
705            .expect("state on list rows");
706        assert_eq!(st.status.as_str(), "launched");
707        assert_eq!(st.stable, Some(1));
708
709        let got = get(&state, "tpl-demo", VersionQuery::default())
710            .await
711            .unwrap();
712        assert_eq!(got.template.version, 1);
713        assert_eq!(got.state.versions, vec![1]);
714        assert!(got.is_stable);
715        assert_eq!(got.launches.len(), 1, "the launch is recorded");
716        // The stored body is verbatim, so the interpolation token survives.
717        assert!(got.template.body.contains("${param.tag}"));
718
719        assert!(matches!(
720            get(&state, "nope", VersionQuery::default()).await,
721            Err(ServeError::NotFound)
722        ));
723        assert!(matches!(
724            get(
725                &state,
726                "tpl-demo",
727                VersionQuery {
728                    version: Some(VersionSelector::Pinned(9)),
729                    clean: false,
730                },
731            )
732            .await,
733            Err(ServeError::NotFound)
734        ));
735
736        let code = delete_template(
737            State(state.clone()),
738            Extension(actor()),
739            Path("tpl-demo".into()),
740            Query(VersionQuery::default()),
741        )
742        .await
743        .unwrap();
744        assert_eq!(code, StatusCode::NO_CONTENT);
745        assert!(matches!(
746            delete_template(
747                State(state.clone()),
748                Extension(actor()),
749                Path("tpl-demo".into()),
750                Query(VersionQuery::default())
751            )
752            .await,
753            Err(ServeError::NotFound)
754        ));
755
756        let entries = state
757            .history()
758            .list_audit(&AuditFilter {
759                limit: 20,
760                ..Default::default()
761            })
762            .await
763            .unwrap();
764        let actions: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
765        assert!(actions.contains(&"template.register"), "{actions:?}");
766        assert!(actions.contains(&"template.delete"), "{actions:?}");
767    }
768
769    #[tokio::test]
770    async fn register_rejects_an_invalid_config() {
771        let state = test_state();
772        let err = register_template(
773            State(state),
774            Extension(actor()),
775            Json(RegisterBody {
776                id: None,
777                config: "version: 1\nname: x\nbogus_key: 1\npipeline: {}\n".into(),
778                config_format: ConfigFormatWire::Yaml,
779                description: None,
780                tags: vec![],
781                launch: false,
782            }),
783        )
784        .await
785        .unwrap_err();
786        assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
787    }
788
789    #[tokio::test]
790    async fn trigger_binds_params_and_stamps_provenance() {
791        let dir = tempfile::tempdir().unwrap();
792        let state = test_state();
793        register_demo(&state, &dir.path().join("o.jsonl")).await;
794
795        let (code, resp) = trigger_template(
796            State(state.clone()),
797            Extension(actor()),
798            Path("tpl-demo".into()),
799            Json(TriggerBody {
800                params: [("tag".to_string(), json!("alpha"))].into(),
801                ..Default::default()
802            }),
803        )
804        .await
805        .expect("trigger");
806        assert_eq!(code, StatusCode::ACCEPTED);
807        assert_eq!(resp.0.template_id, "tpl-demo");
808        assert_eq!(resp.0.template_version, 1);
809        assert_eq!(resp.0.params["tag"], json!("alpha"));
810        assert_eq!(resp.0.params["page"], json!(7));
811        assert!(resp.0.deprecated.is_none());
812
813        let rec = state
814            .history()
815            .get(&resp.0.run.run_id)
816            .await
817            .unwrap()
818            .expect("run record");
819        assert_eq!(rec.labels[LABEL_TEMPLATE], "tpl-demo");
820        assert_eq!(rec.labels[LABEL_TEMPLATE_VERSION], "1");
821        assert_eq!(rec.name.as_deref(), Some("tpl-demo"));
822
823        let entries = state
824            .history()
825            .list_audit(&AuditFilter {
826                action: Some("template.run".into()),
827                limit: 10,
828                ..Default::default()
829            })
830            .await
831            .unwrap();
832        assert_eq!(entries.len(), 1);
833        assert_eq!(
834            entries[0].run_id.as_deref(),
835            Some(resp.0.run.run_id.as_str())
836        );
837    }
838
839    #[tokio::test]
840    async fn a_draft_template_cannot_be_triggered_unpinned() {
841        let dir = tempfile::tempdir().unwrap();
842        let state = test_state();
843        // Registered but NOT launched — the work-in-progress state.
844        register_demo_opts(&state, &dir.path().join("o.jsonl"), false).await;
845
846        let err = trigger_template(
847            State(state.clone()),
848            Extension(actor()),
849            Path("tpl-demo".into()),
850            Json(TriggerBody {
851                params: [("tag".to_string(), json!("x"))].into(),
852                ..Default::default()
853            }),
854        )
855        .await
856        .unwrap_err();
857        match err {
858            ServeError::Unprocessable { message, .. } => {
859                assert!(message.contains("no launched version"), "{message}");
860                assert!(message.contains("launch"), "{message}");
861            }
862            other => panic!("expected 422, got {other:?}"),
863        }
864
865        // …but an explicit selector runs it, so a draft is testable.
866        let resp = trigger_template(
867            State(state.clone()),
868            Extension(actor()),
869            Path("tpl-demo".into()),
870            Json(TriggerBody {
871                params: [("tag".to_string(), json!("x"))].into(),
872                version: Some(VersionSelector::newest()),
873                ..Default::default()
874            }),
875        )
876        .await
877        .expect("explicit newest runs a draft")
878        .1
879        .0;
880        assert_eq!(resp.template_version, 1);
881    }
882
883    #[tokio::test]
884    async fn launch_moves_callers_and_registering_does_not() {
885        let dir = tempfile::tempdir().unwrap();
886        let state = test_state();
887        register_demo(&state, &dir.path().join("v1.jsonl")).await; // v1, launched
888        register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; // v2, a build
889
890        // An unpinned trigger still runs v1 — this is the whole point.
891        let trigger = |version: Option<VersionSelector>| {
892            let state = state.clone();
893            async move {
894                trigger_template(
895                    State(state),
896                    Extension(actor()),
897                    Path("tpl-demo".into()),
898                    Json(TriggerBody {
899                        params: [("tag".to_string(), json!("x"))].into(),
900                        version,
901                        ..Default::default()
902                    }),
903                )
904                .await
905                .expect("trigger")
906                .1
907                .0
908            }
909        };
910        assert_eq!(trigger(None).await.template_version, 1);
911        assert_eq!(
912            trigger(Some(VersionSelector::newest()))
913                .await
914                .template_version,
915            2
916        );
917
918        // Launching v2 is the deliberate act that moves them.
919        let resp = launch_template(
920            State(state.clone()),
921            Extension(actor()),
922            Path("tpl-demo".into()),
923            Json(LaunchBody::default()),
924        )
925        .await
926        .unwrap()
927        .0;
928        assert_eq!((resp.version, resp.replaced), (2, Some(1)));
929        assert_eq!(resp.status, "launched");
930        assert!(!resp.already_launched);
931        assert_eq!(trigger(None).await.template_version, 2);
932
933        // Rollback returns to v1.
934        let resp = rollback_template(
935            State(state.clone()),
936            Extension(actor()),
937            Path("tpl-demo".into()),
938        )
939        .await
940        .unwrap()
941        .0;
942        assert_eq!((resp.version, resp.replaced), (1, Some(2)));
943        assert_eq!(trigger(None).await.template_version, 1);
944
945        // Both are audited under their own action.
946        for action in ["template.launch", "template.rollback"] {
947            let entries = state
948                .history()
949                .list_audit(&AuditFilter {
950                    action: Some(action.into()),
951                    limit: 10,
952                    ..Default::default()
953                })
954                .await
955                .unwrap();
956            assert_eq!(entries.len(), 1, "{action}");
957        }
958    }
959
960    #[tokio::test]
961    async fn deprecation_warns_but_keeps_serving() {
962        let dir = tempfile::tempdir().unwrap();
963        let state = test_state();
964        register_demo(&state, &dir.path().join("o.jsonl")).await;
965
966        let body = deprecate_template(
967            State(state.clone()),
968            Extension(actor()),
969            Path("tpl-demo".into()),
970            Json(DeprecateBody {
971                reason: Some("superseded by tenant-sync-v2".into()),
972                undo: false,
973            }),
974        )
975        .await
976        .unwrap()
977        .0;
978        assert_eq!(body["status"], "deprecated");
979
980        // Existing callers keep working — retiring must not hard-break them — but
981        // the response says so, so the deprecation cannot pass unnoticed.
982        let resp = trigger_template(
983            State(state.clone()),
984            Extension(actor()),
985            Path("tpl-demo".into()),
986            Json(TriggerBody {
987                params: [("tag".to_string(), json!("x"))].into(),
988                ..Default::default()
989            }),
990        )
991        .await
992        .expect("a deprecated template still runs")
993        .1
994        .0;
995        assert_eq!(resp.template_version, 1);
996        assert_eq!(
997            resp.deprecated.as_deref(),
998            Some("superseded by tenant-sync-v2")
999        );
1000
1001        // Launching into a retired template is refused until it is revived.
1002        let err = launch_template(
1003            State(state.clone()),
1004            Extension(actor()),
1005            Path("tpl-demo".into()),
1006            Json(LaunchBody::default()),
1007        )
1008        .await
1009        .unwrap_err();
1010        assert!(matches!(err, ServeError::Unprocessable { .. }), "{err:?}");
1011
1012        // `undo` restores the derived status.
1013        let body = deprecate_template(
1014            State(state.clone()),
1015            Extension(actor()),
1016            Path("tpl-demo".into()),
1017            Json(DeprecateBody {
1018                reason: None,
1019                undo: true,
1020            }),
1021        )
1022        .await
1023        .unwrap()
1024        .0;
1025        assert_eq!(body["status"], "launched");
1026    }
1027
1028    #[tokio::test]
1029    async fn promote_moves_a_channel_without_touching_what_is_live() {
1030        let dir = tempfile::tempdir().unwrap();
1031        let state = test_state();
1032        register_demo(&state, &dir.path().join("v1.jsonl")).await; // v1 live
1033        register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await; // v2 build
1034
1035        let resp = promote_template(
1036            State(state.clone()),
1037            Extension(actor()),
1038            Path("tpl-demo".into()),
1039            Json(PromoteBody {
1040                tag: VersionChannel::PreProd,
1041                version: Some(VersionSelector::newest()),
1042            }),
1043        )
1044        .await
1045        .unwrap()
1046        .0;
1047        assert_eq!((resp.tag.as_str(), resp.version), ("pre-prod", 2));
1048
1049        let got = get(&state, "tpl-demo", VersionQuery::default())
1050            .await
1051            .unwrap();
1052        assert_eq!(got.state.stable, Some(1), "promote must not move `stable`");
1053        assert_eq!(got.state.tags["pre-prod"], 2);
1054        assert!(
1055            !got.state.tags.contains_key("stable"),
1056            "derived, never stored"
1057        );
1058
1059        // A derived channel is not a promote target; `latest` is not a channel.
1060        let err = promote_template(
1061            State(state.clone()),
1062            Extension(actor()),
1063            Path("tpl-demo".into()),
1064            Json(PromoteBody {
1065                tag: VersionChannel::Stable,
1066                version: Some(VersionSelector::Pinned(1)),
1067            }),
1068        )
1069        .await
1070        .unwrap_err();
1071        match err {
1072            ServeError::Unprocessable { message, .. } => {
1073                assert!(message.contains("derived"), "{message}")
1074            }
1075            other => panic!("expected 422, got {other:?}"),
1076        }
1077        assert!(
1078            serde_json::from_value::<PromoteBody>(json!({"tag": "latest", "version": 1})).is_err()
1079        );
1080    }
1081
1082    #[tokio::test]
1083    async fn selecting_an_unset_channel_is_unprocessable() {
1084        let dir = tempfile::tempdir().unwrap();
1085        let state = test_state();
1086        register_demo(&state, &dir.path().join("v1.jsonl")).await;
1087        // Never falls back to `stable` — running the wrong version silently is the
1088        // failure mode this guards.
1089        let err = get(
1090            &state,
1091            "tpl-demo",
1092            VersionQuery {
1093                version: Some(VersionSelector::Channel(VersionChannel::Canary)),
1094                clean: false,
1095            },
1096        )
1097        .await
1098        .unwrap_err();
1099        match err {
1100            ServeError::Unprocessable { message, .. } => {
1101                assert!(message.contains("no `canary` version"), "{message}")
1102            }
1103            other => panic!("expected 422, got {other:?}"),
1104        }
1105    }
1106
1107    #[tokio::test]
1108    async fn delete_by_selector_removes_one_version_but_omitted_removes_all() {
1109        let dir = tempfile::tempdir().unwrap();
1110        let state = test_state();
1111        register_demo(&state, &dir.path().join("v1.jsonl")).await; // v1 launched
1112        register_demo_opts(&state, &dir.path().join("v2.jsonl"), false).await;
1113        register_demo_opts(&state, &dir.path().join("v3.jsonl"), false).await;
1114
1115        // `?version=newest` peels off only v3.
1116        delete_template(
1117            State(state.clone()),
1118            Extension(actor()),
1119            Path("tpl-demo".into()),
1120            Query(VersionQuery {
1121                version: Some(VersionSelector::newest()),
1122                clean: false,
1123            }),
1124        )
1125        .await
1126        .unwrap();
1127        assert_eq!(
1128            state.history().template_versions("tpl-demo").await.unwrap(),
1129            vec![2, 1]
1130        );
1131
1132        // No selector removes the whole template.
1133        delete_template(
1134            State(state.clone()),
1135            Extension(actor()),
1136            Path("tpl-demo".into()),
1137            Query(VersionQuery::default()),
1138        )
1139        .await
1140        .unwrap();
1141        assert!(state.history().template_list().await.unwrap().is_empty());
1142    }
1143
1144    #[tokio::test]
1145    async fn secret_params_are_refused_on_a_clustered_server() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let state = crate::serve::test_support::test_state_clustered();
1148        let body = format!(
1149            "version: 1\nname: tpl-secret\nparams:\n  token: {{ required: true, secret: true }}\npipeline:\n  source:\n    type: csv\n    config:\n      path: ./x-${{param.token}}.csv\n  sink:\n    type: jsonl\n    config:\n      path: {}\n",
1150            dir.path().join("o.jsonl").display()
1151        );
1152        let _registered = register_template(
1153            State(state.clone()),
1154            Extension(actor()),
1155            Json(RegisterBody {
1156                id: None,
1157                config: body,
1158                config_format: ConfigFormatWire::Yaml,
1159                description: None,
1160                tags: vec![],
1161                launch: true,
1162            }),
1163        )
1164        .await
1165        .expect("register");
1166
1167        let err = trigger_template(
1168            State(state),
1169            Extension(actor()),
1170            Path("tpl-secret".into()),
1171            Json(TriggerBody {
1172                params: [("token".to_string(), json!("super-secret-value"))].into(),
1173                ..Default::default()
1174            }),
1175        )
1176        .await
1177        .unwrap_err();
1178        match err {
1179            ServeError::Unprocessable { message, .. } => {
1180                assert!(message.contains("clustered"), "{message}");
1181                assert!(!message.contains("super-secret-value"), "leaked: {message}");
1182            }
1183            other => panic!("expected 422, got {other:?}"),
1184        }
1185    }
1186
1187    /// #456 M4: an `env` override substitutes into the config exactly like a
1188    /// param and is just as likely to be a credential, so on a clustered server —
1189    /// where the materialized body is persisted for a peer — it must be refused
1190    /// alongside `secret: true` params.
1191    #[tokio::test]
1192    async fn env_overrides_are_refused_on_a_clustered_server() {
1193        let dir = tempfile::tempdir().unwrap();
1194        let state = crate::serve::test_support::test_state_clustered();
1195        let body = format!(
1196            "version: 1\nname: tpl-env\npipeline:\n  source:\n    type: csv\n    config:\n      path: \"${{env:SRC_PATH}}\"\n  sink:\n    type: jsonl\n    config:\n      path: {}\n",
1197            dir.path().join("o.jsonl").display()
1198        );
1199        let _registered = register_template(
1200            State(state.clone()),
1201            Extension(actor()),
1202            Json(RegisterBody {
1203                id: None,
1204                config: body,
1205                config_format: ConfigFormatWire::Yaml,
1206                description: None,
1207                tags: vec![],
1208                launch: true,
1209            }),
1210        )
1211        .await
1212        .expect("register");
1213
1214        let err = trigger_template(
1215            State(state),
1216            Extension(actor()),
1217            Path("tpl-env".into()),
1218            Json(TriggerBody {
1219                env: [("SRC_PATH".to_string(), "s3cret-path".to_string())].into(),
1220                ..Default::default()
1221            }),
1222        )
1223        .await
1224        .unwrap_err();
1225        match err {
1226            ServeError::Unprocessable { message, .. } => {
1227                assert!(message.contains("env"), "{message}");
1228                assert!(!message.contains("s3cret-path"), "leaked: {message}");
1229            }
1230            other => panic!("expected 422, got {other:?}"),
1231        }
1232    }
1233
1234    /// #456 C5: on a clustered server the persisted body must still carry the
1235    /// load-time directives as *tokens* — resolving them here would serialise the
1236    /// server's own credentials into the shared run-history database. The
1237    /// executing instance resolves them instead.
1238    #[tokio::test]
1239    async fn a_clustered_trigger_persists_tokens_not_resolved_values() {
1240        let dir = tempfile::tempdir().unwrap();
1241        // SAFETY: single-threaded test; the value is read back below only via the
1242        // materialize path we are asserting about.
1243        unsafe { std::env::set_var("FAUCET_TEST_C5_SECRET", "hunter2-should-not-persist") };
1244        let s = store(&crate::serve::test_support::test_state_clustered());
1245        let body = format!(
1246            "version: 1\nname: tpl-c5\npipeline:\n  source:\n    type: csv\n    config:\n      path: \"${{env:FAUCET_TEST_C5_SECRET}}\"\n  sink:\n    type: jsonl\n    config:\n      path: {}\n",
1247            dir.path().join("o.jsonl").display()
1248        );
1249        crate::templates::register(
1250            &s,
1251            crate::templates::RegisterRequest {
1252                id: None,
1253                body,
1254                format: crate::serve::load::ConfigFormat::Yaml,
1255                description: None,
1256                tags: vec![],
1257                launch: true,
1258                created_by: None,
1259            },
1260        )
1261        .await
1262        .expect("register");
1263
1264        let persisted = crate::templates::materialize(
1265            &s,
1266            "tpl-c5",
1267            1,
1268            &Default::default(),
1269            &Default::default(),
1270            crate::templates::Materialize::Persisted,
1271        )
1272        .await
1273        .expect("materialize");
1274        assert!(
1275            !persisted.body.contains("hunter2-should-not-persist"),
1276            "a resolved secret must never reach a persisted body: {}",
1277            persisted.body
1278        );
1279        assert!(
1280            persisted.body.contains("${env:FAUCET_TEST_C5_SECRET}"),
1281            "the directive must survive as a token for the executor: {}",
1282            persisted.body
1283        );
1284
1285        // The local (non-clustered) path still resolves, so behaviour there is
1286        // unchanged — nothing is persisted in that mode.
1287        let local = crate::templates::materialize(
1288            &s,
1289            "tpl-c5",
1290            1,
1291            &Default::default(),
1292            &Default::default(),
1293            crate::templates::Materialize::Local,
1294        )
1295        .await
1296        .expect("materialize");
1297        assert!(
1298            local.body.contains("hunter2-should-not-persist"),
1299            "{}",
1300            local.body
1301        );
1302        unsafe { std::env::remove_var("FAUCET_TEST_C5_SECRET") };
1303    }
1304
1305    #[test]
1306    fn version_query_deserializes_channels_and_numbers() {
1307        // A query string decodes every value as a string, so that is the shape
1308        // that matters here; a JSON body may send a bare number.
1309        assert!(
1310            serde_json::from_value::<VersionQuery>(json!({}))
1311                .unwrap()
1312                .selector()
1313                .is_stable(),
1314            "an omitted selector means `stable`"
1315        );
1316        assert!(
1317            serde_json::from_value::<VersionQuery>(json!({ "version": "stable" }))
1318                .unwrap()
1319                .selector()
1320                .is_stable()
1321        );
1322        for wire in [json!({ "version": "2" }), json!({ "version": 2 })] {
1323            let q: VersionQuery = serde_json::from_value(wire.clone()).unwrap();
1324            assert_eq!(q.selector().pinned(), Some(2), "{wire}");
1325        }
1326        for bad in [
1327            json!({ "version": "nope" }),
1328            json!({ "version": 0 }),
1329            json!({ "version": "latest" }),
1330        ] {
1331            assert!(
1332                serde_json::from_value::<VersionQuery>(bad.clone()).is_err(),
1333                "{bad} should be rejected"
1334            );
1335        }
1336    }
1337
1338    #[tokio::test]
1339    async fn version_pinning_selects_an_older_body() {
1340        let dir = tempfile::tempdir().unwrap();
1341        let state = test_state();
1342        register_demo(&state, &dir.path().join("v1.jsonl")).await;
1343        let v2 = register_demo(&state, &dir.path().join("v2.jsonl")).await;
1344        assert_eq!(v2.version, 2);
1345
1346        let got = get_template(
1347            State(state.clone()),
1348            Path("tpl-demo".into()),
1349            Query(VersionQuery {
1350                version: Some(VersionSelector::Pinned(1)),
1351                clean: false,
1352            }),
1353        )
1354        .await
1355        .unwrap()
1356        .0;
1357        assert!(got.template.body.contains("v1.jsonl"));
1358        assert_eq!(got.state.versions, vec![2, 1]);
1359        assert!(!got.is_stable, "v2 is live, so a pinned v1 is not");
1360
1361        // Deleting one version leaves the other.
1362        delete_template(
1363            State(state.clone()),
1364            Extension(actor()),
1365            Path("tpl-demo".into()),
1366            Query(VersionQuery {
1367                version: Some(VersionSelector::Pinned(1)),
1368                clean: false,
1369            }),
1370        )
1371        .await
1372        .unwrap();
1373        assert_eq!(
1374            state.history().template_versions("tpl-demo").await.unwrap(),
1375            vec![2]
1376        );
1377    }
1378}