use crate::error::{CliError, CliResult};
use crate::params::{self, BindMode, SuppliedParams};
use crate::serve::config::HistoryBackendSpec;
use crate::serve::history::templates::{
DeprecationRecord, TemplateDraft, TemplateId, TemplateRecord, TemplateState, TemplateStatus,
TemplateSummary, VersionChannel, VersionSelector,
};
use crate::serve::history::{self, RunHistory};
use crate::serve::load::ConfigFormat;
use serde_json::Value;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
pub type TemplateStore = Arc<dyn RunHistory>;
#[derive(Debug, Clone)]
pub struct RegisterRequest {
pub id: Option<String>,
pub body: String,
pub format: ConfigFormat,
pub description: Option<String>,
pub tags: Vec<VersionChannel>,
pub launch: bool,
pub created_by: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MaterializedConfig {
pub template_id: String,
pub version: u32,
pub name: Option<String>,
pub body: String,
pub params_redacted: BTreeMap<String, Value>,
pub used_secret_params: bool,
}
impl MaterializedConfig {
pub fn format(&self) -> ConfigFormat {
ConfigFormat::Json
}
}
fn parse_body(body: &str, format: ConfigFormat) -> CliResult<Value> {
match format {
ConfigFormat::Yaml => {
serde_yaml::from_str(body).map_err(|e| CliError::Config(format!("invalid YAML: {e}")))
}
ConfigFormat::Json => {
serde_json::from_str(body).map_err(|e| CliError::Config(format!("invalid JSON: {e}")))
}
}
}
pub async fn register(store: &TemplateStore, req: RegisterRequest) -> CliResult<TemplateRecord> {
let mut doc = parse_body(&req.body, req.format)?;
if !doc.is_object() {
return Err(CliError::Config(
"a pipeline template must be a config document (a YAML/JSON mapping)".into(),
));
}
let declared = params::declared(&doc)?;
let mut probe = doc.clone();
params::bind_document(&mut probe, &SuppliedParams::new(), BindMode::Placeholder)?;
let cfg = crate::config::PipelineConfig::from_value(probe)?;
if crate::topology::is_topology(&cfg) {
crate::topology::validate_topology_spec(&cfg)?;
} else {
for node in crate::expand::expand(&cfg)? {
if node.transforms.is_empty() {
continue;
}
crate::transforms::compile_transforms(&node.transforms)
.map_err(|e| CliError::Config(format!("row '{}': {e}", node.id)))?;
}
}
let id = match &req.id {
Some(raw) => TemplateId::parse(raw)?,
None => {
let name = cfg.name.as_deref().ok_or_else(|| {
CliError::Config(
"no template id given and the config has no `name:` to derive one from — \
pass an explicit id"
.into(),
)
})?;
TemplateId::from_config_name(name)?
}
};
let _ = &mut doc;
for tag in &req.tags {
reject_derived(*tag)?;
}
let description = match &req.description {
Some(d) => Some(d.clone()),
None => store
.template_get(id.as_str(), None)
.await
.map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
.and_then(|prev| prev.description),
};
let draft = TemplateDraft {
id,
name: cfg.name.clone(),
description,
body: req.body.clone(),
format: req.format,
params: declared,
created_by: req.created_by.clone(),
};
let record = store
.template_register(&draft)
.await
.map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
for tag in &req.tags {
store
.template_set_tag(&record.id, tag.as_str(), record.version)
.await
.map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
}
if req.launch {
store
.template_launch(&record.id, record.version, req.created_by.as_deref())
.await
.map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
}
Ok(record)
}
fn reject_derived(tag: VersionChannel) -> CliResult<()> {
if tag.is_derived() {
let how = match tag {
VersionChannel::Stable => " — move it with `faucet template launch` instead",
VersionChannel::Previous => " — it is whatever was launched before the current version",
_ => " — it is always the highest version number",
};
return Err(CliError::Config(format!(
"`{tag}` is a derived channel and cannot be promoted{how}. Promotable channels: {}",
VersionChannel::ASSIGNABLE
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
Ok(())
}
pub async fn resolve_version(
store: &TemplateStore,
id: &str,
selector: VersionSelector,
) -> CliResult<u32> {
if let VersionSelector::Pinned(n) = selector {
return Ok(n);
}
let channel = selector
.channel()
.expect("non-pinned selector names a channel");
let state = template_state(store, id).await?;
if state.versions.is_empty() {
return Err(CliError::UnknownPipelineTemplate {
id: id.to_string(),
version: None,
});
}
state
.derived(channel)
.ok_or_else(|| unresolved_channel(id, channel, &state))
}
fn unresolved_channel(id: &str, channel: VersionChannel, state: &TemplateState) -> CliError {
let newest = state
.newest
.map(|v| v.to_string())
.unwrap_or_else(|| "1".into());
match channel {
VersionChannel::Stable => CliError::Config(format!(
"template '{id}' has no launched version (status: {}). Launch one first \
(`faucet template launch {id} --version {newest}`, or \
`POST /v1/templates/{id}/launch`), or select a specific build with \
`newest` / a version number",
state.status
)),
VersionChannel::Previous => CliError::Config(format!(
"template '{id}' has no previous version — {}. `previous` is the version launched \
before the current one, so it only exists after a second launch",
match state.stable {
Some(v) => format!("v{v} is the first and only launched version"),
None => "nothing has been launched yet".to_string(),
}
)),
VersionChannel::Newest => {
CliError::Config(format!("template '{id}' has no versions registered"))
}
assigned => CliError::Config(format!(
"template '{id}' has no `{assigned}` version. Channels currently set: {}. Promote one \
with `faucet template promote {id} --tag {assigned} --version <n>`",
if state.tags.is_empty() {
String::from("(none)")
} else {
state
.tags
.iter()
.map(|(t, v)| format!("{t}=v{v}"))
.collect::<Vec<_>>()
.join(", ")
}
)),
}
}
pub async fn list_with_state(store: &TemplateStore) -> CliResult<Vec<TemplateSummary>> {
let mut out = store
.template_list()
.await
.map_err(|e| CliError::Internal(format!("template registry read: {e}")))?;
for summary in &mut out {
summary.state = Some(template_state(store, &summary.id).await?);
}
Ok(out)
}
pub async fn template_state(store: &TemplateStore, id: &str) -> CliResult<TemplateState> {
store
.template_state(id)
.await
.map_err(|e| CliError::Internal(format!("template registry read: {e}")))
}
async fn require_version(store: &TemplateStore, id: &str, version: u32) -> CliResult<()> {
if store
.template_get(id, Some(version))
.await
.map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
.is_none()
{
return Err(CliError::UnknownPipelineTemplate {
id: id.to_string(),
version: Some(version),
});
}
Ok(())
}
pub async fn promote(
store: &TemplateStore,
id: &str,
tag: VersionChannel,
target: VersionSelector,
) -> CliResult<u32> {
reject_derived(tag)?;
let version = resolve_version(store, id, target).await?;
require_version(store, id, version).await?;
store
.template_set_tag(id, tag.as_str(), version)
.await
.map_err(|e| CliError::Internal(format!("template channel write: {e}")))?;
Ok(version)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchOutcome {
pub version: u32,
pub replaced: Option<u32>,
pub already_launched: bool,
pub first_launch: bool,
}
pub async fn launch(
store: &TemplateStore,
id: &str,
target: VersionSelector,
launched_by: Option<&str>,
) -> CliResult<LaunchOutcome> {
let version = resolve_version(store, id, target).await?;
require_version(store, id, version).await?;
let before = template_state(store, id).await?;
if before.status == TemplateStatus::Deprecated {
return Err(CliError::Config(format!(
"template '{id}' is deprecated — un-deprecate it first with \
`faucet template deprecate {id} --undo`, then launch"
)));
}
let seq = store
.template_launch(id, version, launched_by)
.await
.map_err(|e| CliError::Internal(format!("template launch write: {e}")))?;
Ok(LaunchOutcome {
version,
replaced: before.stable,
already_launched: seq.is_none(),
first_launch: before.stable.is_none(),
})
}
pub async fn rollback(
store: &TemplateStore,
id: &str,
launched_by: Option<&str>,
) -> CliResult<LaunchOutcome> {
launch(
store,
id,
VersionSelector::Channel(VersionChannel::Previous),
launched_by,
)
.await
}
pub async fn set_deprecated(
store: &TemplateStore,
id: &str,
reason: Option<String>,
by: Option<&str>,
deprecated: bool,
) -> CliResult<TemplateStatus> {
let state = template_state(store, id).await?;
if state.versions.is_empty() {
return Err(CliError::UnknownPipelineTemplate {
id: id.to_string(),
version: None,
});
}
let record = deprecated.then(|| DeprecationRecord {
deprecated_at: chrono::Utc::now(),
deprecated_by: by.map(str::to_string),
reason,
});
store
.template_set_deprecation(id, record.as_ref())
.await
.map_err(|e| CliError::Internal(format!("template deprecation write: {e}")))?;
Ok(TemplateStatus::derive(state.stable.is_some(), deprecated))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Materialize {
Local,
Persisted,
}
pub async fn materialize(
store: &TemplateStore,
id: &str,
version: u32,
supplied: &SuppliedParams,
env_overrides: &BTreeMap<String, String>,
mode: Materialize,
) -> CliResult<MaterializedConfig> {
let record = store
.template_get(id, Some(version))
.await
.map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
.ok_or_else(|| CliError::UnknownPipelineTemplate {
id: id.to_string(),
version: Some(version),
})?;
let mut doc = parse_body(&record.body, record.format)?;
if mode == Materialize::Local {
let overlay: crate::interpolate::EnvOverlay = env_overrides
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
crate::interpolate::interpolate_value_with_env(&mut doc, &overlay)?;
}
let bound = params::bind_document(&mut doc, supplied, BindMode::Strict)?;
if let Some(map) = doc.as_object_mut() {
map.remove(params::PARAMS_KEY);
}
let body = serde_json::to_string(&doc)
.map_err(|e| CliError::Internal(format!("re-serializing template body: {e}")))?;
Ok(MaterializedConfig {
template_id: record.id.clone(),
version: record.version,
name: record.name.clone(),
body,
params_redacted: bound.redacted(),
used_secret_params: bound.has_secrets(),
})
}
pub async fn resolve_store_url(url: &str) -> CliResult<TemplateStore> {
let backend = match url {
"memory" => HistoryBackendSpec::Memory,
u if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
HistoryBackendSpec::Postgres(u.to_string())
}
u if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u.to_string()),
other => {
return Err(CliError::Config(format!(
"template store '{other}' is not recognised — expected 'memory', \
'sqlite:<path>', or a 'postgres://…' URL"
)));
}
};
history::connect(
&backend,
Duration::from_secs(3600),
Duration::from_secs(30),
&uuid::Uuid::now_v7().to_string(),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::serve::history::memory::MemoryHistory;
use serde_json::json;
fn store() -> TemplateStore {
Arc::new(MemoryHistory::new(Duration::from_secs(60))) as TemplateStore
}
const PARAMETERIZED: &str = "\
version: 1
name: tenant-sync
params:
tenant_id: { required: true, description: Tenant to sync }
since: { default: \"1970-01-01\" }
page: { type: int, default: 100 }
pipeline:
source:
type: rest
config:
url: \"https://api.example.com/${param.tenant_id}/events?since=${param.since}\"
sink:
type: jsonl
config:
path: ./out.jsonl
";
fn req(body: &str) -> RegisterRequest {
RegisterRequest {
id: None,
body: body.to_string(),
format: ConfigFormat::Yaml,
description: Some("test".into()),
tags: Vec::new(),
launch: false,
created_by: Some("tester".into()),
}
}
fn req_launched(body: &str) -> RegisterRequest {
RegisterRequest {
launch: true,
..req(body)
}
}
#[tokio::test]
async fn registers_and_versions() {
let s = store();
let first = register(&s, req(PARAMETERIZED)).await.unwrap();
assert_eq!(first.id, "tenant-sync");
assert_eq!(first.version, 1);
assert_eq!(first.created_by.as_deref(), Some("tester"));
assert!(first.params["tenant_id"].required);
assert_eq!(first.params["page"].default, Some(json!(100)));
assert_eq!(first.body, PARAMETERIZED, "body stored verbatim");
let second = register(&s, req(PARAMETERIZED)).await.unwrap();
assert_eq!(second.version, 2);
assert_eq!(
s.template_versions("tenant-sync").await.unwrap(),
vec![2, 1]
);
let listed = list_with_state(&s).await.unwrap();
assert_eq!(listed.len(), 1, "list folds to one row per id");
let st = listed[0].state.as_ref().unwrap();
assert_eq!(st.status, TemplateStatus::Draft);
assert_eq!(st.newest, Some(2));
assert_eq!(st.stable, None);
}
#[tokio::test]
async fn register_compiles_transforms_not_just_their_shape() {
let s = store();
let mut bad = req(r#"
version: 1
name: tenant-sync
pipeline:
source: { type: rest, config: {} }
transforms:
- type: set
config: { fields: { a: 1 } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
"#);
bad.description = None;
let err = register(&s, bad).await.unwrap_err().to_string();
assert!(err.contains("values"), "names the missing field: {err}");
assert!(
s.template_versions("tenant-sync").await.unwrap().is_empty(),
"nothing is persisted when validation fails"
);
}
#[tokio::test]
async fn a_description_carries_forward_across_registers() {
let s = store();
let first = register(&s, req(PARAMETERIZED)).await.unwrap();
assert_eq!(first.description.as_deref(), Some("test"));
let mut bare = req(PARAMETERIZED);
bare.description = None;
let second = register(&s, bare).await.unwrap();
assert_eq!(second.description.as_deref(), Some("test"));
let mut changed = req(PARAMETERIZED);
changed.description = Some("now something else".into());
let third = register(&s, changed).await.unwrap();
assert_eq!(third.description.as_deref(), Some("now something else"));
let mut bare2 = req(PARAMETERIZED);
bare2.description = None;
let fourth = register(&s, bare2).await.unwrap();
assert_eq!(fourth.description.as_deref(), Some("now something else"));
}
#[tokio::test]
async fn a_register_never_moves_existing_callers() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap(); assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap(),
1
);
register(&s, req(PARAMETERIZED)).await.unwrap();
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap(),
1,
"registering a build must not move the launched version"
);
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::newest())
.await
.unwrap(),
2,
"`newest` is how you reach the un-launched build"
);
let out = launch(&s, "tenant-sync", VersionSelector::newest(), Some("alice"))
.await
.unwrap();
assert_eq!((out.version, out.replaced), (2, Some(1)));
assert!(!out.first_launch);
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap(),
2
);
assert_eq!(
resolve_version(
&s,
"tenant-sync",
VersionSelector::Channel(VersionChannel::Previous)
)
.await
.unwrap(),
1
);
}
#[tokio::test]
async fn draft_template_has_no_stable_and_says_how_to_fix_it() {
let s = store();
register(&s, req(PARAMETERIZED)).await.unwrap();
let state = template_state(&s, "tenant-sync").await.unwrap();
assert_eq!(state.status, TemplateStatus::Draft);
let err = resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap_err()
.to_string();
assert!(err.contains("no launched version"), "{err}");
assert!(err.contains("faucet template launch"), "{err}");
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::newest())
.await
.unwrap(),
1
);
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::Pinned(1))
.await
.unwrap(),
1
);
}
#[tokio::test]
async fn first_launch_flips_status_and_relaunch_is_a_noop() {
let s = store();
register(&s, req(PARAMETERIZED)).await.unwrap();
let out = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
.await
.unwrap();
assert!(out.first_launch);
assert_eq!(out.replaced, None);
assert_eq!(
template_state(&s, "tenant-sync").await.unwrap().status,
TemplateStatus::Launched
);
let again = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
.await
.unwrap();
assert!(again.already_launched);
assert_eq!(s.template_launches("tenant-sync").await.unwrap().len(), 1);
let err = resolve_version(
&s,
"tenant-sync",
VersionSelector::Channel(VersionChannel::Previous),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("no previous version"), "{err}");
}
#[tokio::test]
async fn rollback_returns_to_the_prior_launch() {
let s = store();
for _ in 0..3 {
register(&s, req(PARAMETERIZED)).await.unwrap();
}
launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
.await
.unwrap();
launch(&s, "tenant-sync", VersionSelector::Pinned(3), None)
.await
.unwrap();
let out = rollback(&s, "tenant-sync", Some("oncall")).await.unwrap();
assert_eq!(out.version, 1, "rollback re-launches `previous`");
assert_eq!(out.replaced, Some(3));
let state = template_state(&s, "tenant-sync").await.unwrap();
assert_eq!(state.stable, Some(1));
assert_eq!(
state.previous,
Some(3),
"previous now points at what we left"
);
let log = s.template_launches("tenant-sync").await.unwrap();
assert_eq!(
log.iter().map(|l| l.version).collect::<Vec<_>>(),
vec![1, 3, 1]
);
assert_eq!(log[0].launched_by.as_deref(), Some("oncall"));
}
#[tokio::test]
async fn deprecation_is_template_wide_and_reversible() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap();
let status = set_deprecated(
&s,
"tenant-sync",
Some("superseded".into()),
Some("bob"),
true,
)
.await
.unwrap();
assert_eq!(status, TemplateStatus::Deprecated);
let state = template_state(&s, "tenant-sync").await.unwrap();
assert_eq!(state.status, TemplateStatus::Deprecated);
assert_eq!(
state.deprecation.as_ref().unwrap().reason.as_deref(),
Some("superseded")
);
assert_eq!(
resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap(),
1
);
let err = launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
.await
.unwrap_err()
.to_string();
assert!(err.contains("deprecated"), "{err}");
let status = set_deprecated(&s, "tenant-sync", None, None, false)
.await
.unwrap();
assert_eq!(status, TemplateStatus::Launched);
assert!(
template_state(&s, "tenant-sync")
.await
.unwrap()
.deprecation
.is_none()
);
assert!(matches!(
set_deprecated(&s, "nope", None, None, true)
.await
.unwrap_err(),
CliError::UnknownPipelineTemplate { .. }
));
}
#[tokio::test]
async fn explicit_id_wins_and_is_validated() {
let s = store();
let mut r = req(PARAMETERIZED);
r.id = Some("my-template".into());
assert_eq!(register(&s, r).await.unwrap().id, "my-template");
let mut bad = req(PARAMETERIZED);
bad.id = Some("Bad Id".into());
assert!(register(&s, bad).await.is_err());
}
#[tokio::test]
async fn register_requires_an_id_source() {
let s = store();
let body = "version: 1\npipeline:\n source: { type: csv, config: { path: a.csv } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n";
let err = register(&s, req(body)).await.unwrap_err().to_string();
assert!(err.contains("no template id"), "{err}");
}
#[tokio::test]
async fn register_rejects_a_structurally_invalid_config() {
let s = store();
let err = register(&s, req("version: 1\nname: x\nnope: 1\npipeline: {}\n"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("nope") || err.contains("pipeline"), "{err}");
}
#[tokio::test]
async fn register_rejects_an_invalid_params_block() {
let s = store();
let body = "version: 1\nname: x\nparams:\n a: { required: true, default: 1 }\npipeline:\n source: { type: csv, config: { path: a.csv } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n";
let err = register(&s, req(body)).await.unwrap_err().to_string();
assert!(err.contains("required"), "{err}");
}
#[tokio::test]
async fn register_rejects_a_non_mapping_body() {
let s = store();
let err = register(&s, req("- a\n- b\n"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("mapping"), "{err}");
let err = register(&s, req(": :\n")).await.unwrap_err().to_string();
assert!(err.contains("YAML"), "{err}");
}
#[tokio::test]
async fn materialize_binds_params_and_defaults() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap();
let supplied: SuppliedParams = [("tenant_id".to_string(), json!("acme"))].into();
let want = resolve_version(&s, "tenant-sync", VersionSelector::stable())
.await
.unwrap();
let out = materialize(
&s,
"tenant-sync",
want,
&supplied,
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap();
assert_eq!(out.version, 1);
assert_eq!(out.name.as_deref(), Some("tenant-sync"));
assert_eq!(out.format(), ConfigFormat::Json);
let doc: Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(
doc["pipeline"]["source"]["config"]["url"],
"https://api.example.com/acme/events?since=1970-01-01"
);
assert_eq!(out.params_redacted["tenant_id"], json!("acme"));
assert_eq!(out.params_redacted["page"], json!(100));
assert!(!out.used_secret_params);
}
#[tokio::test]
async fn materialize_reports_missing_and_unknown_params() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap();
let err = materialize(
&s,
"tenant-sync",
1,
&SuppliedParams::new(),
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap_err();
assert!(matches!(err, CliError::MissingParam { .. }), "{err:?}");
let supplied: SuppliedParams = [
("tenant_id".to_string(), json!("a")),
("bogus".to_string(), json!("b")),
]
.into();
let err = materialize(
&s,
"tenant-sync",
1,
&supplied,
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap_err();
assert!(matches!(err, CliError::UnknownParam { .. }), "{err:?}");
}
#[tokio::test]
async fn unknown_template_and_version_are_typed_errors() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap();
let err = resolve_version(&s, "nope", VersionSelector::stable())
.await
.unwrap_err();
assert!(
matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
"{err:?}"
);
let supplied: SuppliedParams = [("tenant_id".to_string(), json!("a"))].into();
let err = materialize(
&s,
"tenant-sync",
9,
&supplied,
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap_err();
assert!(
matches!(
err,
CliError::UnknownPipelineTemplate {
version: Some(9),
..
}
),
"{err:?}"
);
}
#[tokio::test]
async fn env_overrides_win_over_the_process_environment() {
let s = store();
let body = "\
version: 1
name: env-template
pipeline:
source: { type: rest, config: { url: \"https://x/${env:FAUCET_TPL_REGION}\" } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
";
unsafe { std::env::set_var("FAUCET_TPL_REGION", "from-process") };
register(&s, req_launched(body)).await.unwrap();
let out = materialize(
&s,
"env-template",
1,
&SuppliedParams::new(),
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap();
let doc: Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(
doc["pipeline"]["source"]["config"]["url"],
"https://x/from-process"
);
let overrides: BTreeMap<String, String> =
[("FAUCET_TPL_REGION".to_string(), "from-request".to_string())].into();
let out = materialize(
&s,
"env-template",
1,
&SuppliedParams::new(),
&overrides,
Materialize::Local,
)
.await
.unwrap();
let doc: Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(
doc["pipeline"]["source"]["config"]["url"],
"https://x/from-request"
);
assert_eq!(std::env::var("FAUCET_TPL_REGION").unwrap(), "from-process");
unsafe { std::env::remove_var("FAUCET_TPL_REGION") };
}
#[tokio::test]
async fn secret_params_are_flagged_and_redacted() {
let s = store();
let body = "\
version: 1
name: secret-template
params:
api_token: { required: true, secret: true }
pipeline:
source:
type: rest
config:
url: https://api.example.com/events
auth: { type: bearer, config: { token: \"${param.api_token}\" } }
sink: { type: jsonl, config: { path: ./o.jsonl } }
";
register(&s, req_launched(body)).await.unwrap();
let supplied: SuppliedParams =
[("api_token".to_string(), json!("tok-abcdefghijklmnop"))].into();
let out = materialize(
&s,
"secret-template",
1,
&supplied,
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap();
assert!(out.used_secret_params);
assert_eq!(out.params_redacted["api_token"], json!("***"));
assert!(out.body.contains("tok-abcdefghijklmnop"));
assert_eq!(
crate::secrets::registry::redact("token=tok-abcdefghijklmnop"),
"token=***"
);
}
#[tokio::test]
async fn channels_are_promoted_independently_of_launching() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap(); register(&s, req(PARAMETERIZED)).await.unwrap(); let mut tagged = req(PARAMETERIZED);
tagged.tags = vec![VersionChannel::Dev];
register(&s, tagged).await.unwrap();
assert_eq!(
resolve_version(
&s,
"tenant-sync",
VersionSelector::Channel(VersionChannel::Dev)
)
.await
.unwrap(),
3
);
assert_eq!(
promote(
&s,
"tenant-sync",
VersionChannel::PreProd,
VersionSelector::Channel(VersionChannel::Dev)
)
.await
.unwrap(),
3
);
let state = template_state(&s, "tenant-sync").await.unwrap();
assert_eq!(state.stable, Some(1), "promote must not move `stable`");
assert_eq!(state.tags["dev"], 3);
assert_eq!(state.tags["pre-prod"], 3);
assert!(!state.tags.contains_key("stable"), "derived, never stored");
let out = launch(
&s,
"tenant-sync",
VersionSelector::Channel(VersionChannel::PreProd),
None,
)
.await
.unwrap();
assert_eq!((out.version, out.replaced), (3, Some(1)));
}
#[tokio::test]
async fn derived_channels_cannot_be_promoted() {
let s = store();
register(&s, req_launched(PARAMETERIZED)).await.unwrap();
for (tag, needle) in [
(VersionChannel::Stable, "launch"),
(VersionChannel::Previous, "launched before"),
(VersionChannel::Newest, "highest version"),
] {
let err = promote(&s, "tenant-sync", tag, VersionSelector::Pinned(1))
.await
.unwrap_err()
.to_string();
assert!(err.contains("derived"), "{tag}: {err}");
assert!(err.contains(needle), "{tag}: {err}");
}
let mut bad = req(PARAMETERIZED);
bad.tags = vec![VersionChannel::Stable];
assert!(register(&s, bad).await.is_err());
assert_eq!(
s.template_versions("tenant-sync").await.unwrap(),
vec![1],
"the rejected register must not have appended a version"
);
}
#[tokio::test]
async fn promoting_to_a_missing_version_is_rejected() {
let s = store();
register(&s, req(PARAMETERIZED)).await.unwrap();
let err = promote(
&s,
"tenant-sync",
VersionChannel::Prod,
VersionSelector::Pinned(9),
)
.await
.unwrap_err();
assert!(
matches!(
err,
CliError::UnknownPipelineTemplate {
version: Some(9),
..
}
),
"{err:?}"
);
assert!(matches!(
promote(&s, "nope", VersionChannel::Prod, VersionSelector::Pinned(1))
.await
.unwrap_err(),
CliError::UnknownPipelineTemplate { .. }
));
}
#[tokio::test]
async fn deleting_a_version_drops_pointers_aimed_at_it() {
let s = store();
register(&s, req(PARAMETERIZED)).await.unwrap();
register(&s, req(PARAMETERIZED)).await.unwrap();
launch(&s, "tenant-sync", VersionSelector::Pinned(1), None)
.await
.unwrap();
launch(&s, "tenant-sync", VersionSelector::Pinned(2), None)
.await
.unwrap();
promote(
&s,
"tenant-sync",
VersionChannel::Prod,
VersionSelector::Pinned(1),
)
.await
.unwrap();
assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
let state = template_state(&s, "tenant-sync").await.unwrap();
assert!(!state.tags.contains_key("prod"), "{:?}", state.tags);
assert_eq!(state.stable, Some(2));
assert_eq!(state.previous, None, "v1's launch entry went with it");
s.template_delete("tenant-sync", None).await.unwrap();
assert!(s.template_launches("tenant-sync").await.unwrap().is_empty());
assert!(s.template_tags("tenant-sync").await.unwrap().is_empty());
}
#[tokio::test]
async fn delete_removes_one_version_or_all() {
let s = store();
register(&s, req(PARAMETERIZED)).await.unwrap();
register(&s, req(PARAMETERIZED)).await.unwrap();
assert_eq!(s.template_delete("tenant-sync", Some(1)).await.unwrap(), 1);
assert_eq!(s.template_versions("tenant-sync").await.unwrap(), vec![2]);
assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 1);
assert!(s.template_list().await.unwrap().is_empty());
assert_eq!(s.template_delete("tenant-sync", None).await.unwrap(), 0);
assert_eq!(s.template_delete("tenant-sync", Some(3)).await.unwrap(), 0);
}
#[tokio::test]
async fn registers_a_topology_config() {
let s = store();
let body = "\
version: 1
name: topo-template
params:
path: { default: ./in.csv }
pipeline:
sources:
s: { type: csv, config: { path: \"${param.path}\" } }
sinks:
o: { type: jsonl, config: { path: ./out.jsonl } }
nodes:
src: { kind: source, ref: s }
w: { kind: sink, ref: o }
edges:
- { from: src, to: w }
";
let rec = register(&s, req_launched(body)).await.unwrap();
assert_eq!(rec.id, "topo-template");
let out = materialize(
&s,
"topo-template",
1,
&SuppliedParams::new(),
&BTreeMap::new(),
Materialize::Local,
)
.await
.unwrap();
let doc: Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(
doc["pipeline"]["sources"]["s"]["config"]["path"],
"./in.csv"
);
}
#[tokio::test]
async fn version_history_is_bounded() {
use crate::serve::history::templates::VERSION_RETAIN;
let s = store();
for _ in 0..(VERSION_RETAIN + 3) {
register(&s, req(PARAMETERIZED)).await.unwrap();
}
let versions = s.template_versions("tenant-sync").await.unwrap();
assert_eq!(versions.len(), VERSION_RETAIN);
assert_eq!(versions[0], (VERSION_RETAIN + 3) as u32, "newest kept");
assert!(!versions.contains(&1), "oldest pruned");
}
#[tokio::test]
async fn store_url_grammar() {
assert!(resolve_store_url("memory").await.is_ok());
match resolve_store_url("mysql://nope").await {
Ok(_) => panic!("an unrecognised scheme must be rejected"),
Err(e) => assert!(e.to_string().contains("template store"), "{e}"),
}
let dir = tempfile::tempdir().unwrap();
let url = format!("sqlite:{}", dir.path().join("t.db").display());
if let Err(e) = resolve_store_url(&url).await {
assert!(e.to_string().contains("serve-history-sqlite"), "{e}");
}
}
}