Skip to main content

faucet_cli/serve/
load.rs

1//! Turn a submitted config body into expanded nodes, applying the workspace
2//! `--default-config` base. Mirrors `PipelineConfig::from_path_async` but merges
3//! a base `Value` and uses `from_value`. All `${env}`/`${file}`/`${secret}` and
4//! `${vault:…}`-style directives resolve against the *server's* environment and
5//! credentials (the documented privilege surface — spec §13).
6
7use crate::config::PipelineConfig;
8use crate::expand::{ExpandedNode, expand};
9use crate::serve::error::ServeError;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Wire format of a submitted config body.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ConfigFormat {
17    #[default]
18    Yaml,
19    Json,
20}
21
22/// A loaded submission: the merged/resolved config and its expanded nodes.
23#[derive(Debug)]
24pub struct LoadedSubmission {
25    pub cfg: PipelineConfig,
26    pub nodes: Vec<ExpandedNode>,
27}
28
29/// Load + merge + expand a submitted config body.
30pub async fn load_submission(
31    body: &str,
32    format: ConfigFormat,
33    default_base: Option<&Value>,
34) -> Result<LoadedSubmission, ServeError> {
35    // 1. ${env}/${file}/${secret} interpolation against the server's env/fs.
36    let interpolated =
37        crate::interpolate::interpolate(body).map_err(|e| ServeError::BadConfig(e.to_string()))?;
38
39    // 2. Parse to a Value per the declared format.
40    let submitted: Value = match format {
41        ConfigFormat::Yaml => serde_yaml::from_str(&interpolated)
42            .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
43        ConfigFormat::Json => serde_json::from_str(&interpolated)
44            .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
45    };
46
47    // 3. Merge onto the workspace default (submitted wins; see merge.rs semantics).
48    let merged = match default_base {
49        Some(base) => {
50            let mut m = base.clone();
51            crate::merge::merge_value(&mut m, submitted);
52            m
53        }
54        None => submitted,
55    };
56
57    // 4. Version gate + structural-ref resolution.
58    let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
59        message: e.to_string(),
60        details: None,
61    })?;
62
63    // serve runs once per submission; a schedule: block is a category error.
64    #[cfg(feature = "schedule")]
65    if cfg.schedule.is_some() {
66        return Err(ServeError::BadConfig(
67            "submitted config contains a `schedule:` block — serve runs once per \
68             submission; use `faucet schedule` for cron scheduling"
69                .into(),
70        ));
71    }
72
73    // 5. Secret-manager directives (${vault:…} etc.) with the server's creds.
74    crate::secrets::resolve_secrets(&mut cfg)
75        .await
76        .map_err(|e| ServeError::BadConfig(e.to_string()))?;
77
78    // 6. Expand the matrix.
79    let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
80        message: e.to_string(),
81        details: None,
82    })?;
83
84    Ok(LoadedSubmission { cfg, nodes })
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use serde_json::json;
91
92    fn base() -> Value {
93        json!({
94            "version": 1,
95            "pipeline": {
96                "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
97                "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
98            }
99        })
100    }
101
102    #[tokio::test]
103    async fn submitted_overrides_default() {
104        let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
105        let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
106            .await
107            .unwrap();
108        // The override wins; the default sink survives the merge.
109        let node = &loaded.nodes[0];
110        assert_eq!(node.source.config["path"], "OVERRIDE.csv");
111        assert_eq!(node.sink.config["path"], "out.jsonl");
112    }
113
114    #[tokio::test]
115    async fn missing_version_without_base_is_unprocessable() {
116        // version defaults to 1 via serde, so this exercises the expand/validation
117        // failure path (pipeline with no source/sink). Accept either layer's error.
118        let body = r#"{ "pipeline": {} }"#;
119        let err = load_submission(body, ConfigFormat::Json, None)
120            .await
121            .unwrap_err();
122        assert!(matches!(
123            err,
124            ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
125        ));
126    }
127
128    #[cfg(feature = "schedule")]
129    #[tokio::test]
130    async fn schedule_block_is_rejected() {
131        let body = r#"
132version: 1
133pipeline:
134  source: { type: csv, config: { path: x.csv } }
135  sink: { type: jsonl, config: { path: out.jsonl } }
136schedule:
137  cron: "0 * * * *"
138  timezone: UTC
139"#;
140        let err = load_submission(body, ConfigFormat::Yaml, None)
141            .await
142            .unwrap_err();
143        match err {
144            ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
145            other => panic!("expected BadConfig, got {other:?}"),
146        }
147    }
148
149    #[tokio::test]
150    async fn invalid_yaml_is_bad_config() {
151        let err = load_submission("{[bad", ConfigFormat::Yaml, None)
152            .await
153            .unwrap_err();
154        assert!(matches!(err, ServeError::BadConfig(_)));
155    }
156
157    #[tokio::test]
158    async fn submitted_extends_is_rejected_with_composition_hint() {
159        // Composition must NOT run for HTTP-submitted bodies — otherwise a client
160        // could read arbitrary server files via `extends`. `deny_unknown_fields`
161        // rejects the key during `from_value` (no I/O), and `friendly_parse_error`
162        // attaches the composition hint.
163        let body = "version: 1\nextends: /etc/passwd\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: o.jsonl } }\n";
164        let err = load_submission(body, ConfigFormat::Yaml, None)
165            .await
166            .unwrap_err();
167        // ServeError doesn't implement Display; pull the inner message directly.
168        let msg = match &err {
169            ServeError::Unprocessable { message, .. } => message.clone(),
170            ServeError::BadConfig(m) => m.clone(),
171            other => format!("{other:?}"),
172        };
173        // Assert the hint fired (not merely that serde named the field) so a
174        // regression in `friendly_parse_error` is caught.
175        assert!(
176            msg.contains("composition"),
177            "submitted extends must be rejected with the composition hint, got: {msg}"
178        );
179    }
180}