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. Parse to a Value per the declared format.
36    let mut submitted: Value = match format {
37        ConfigFormat::Yaml => serde_yaml::from_str(body)
38            .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
39        ConfigFormat::Json => serde_json::from_str(body)
40            .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
41    };
42
43    // 2. ${env}/${file}/${secret} interpolation against the server's env/fs,
44    // resolved INTO the parsed tree (post-parse) so a resolved value can never
45    // alter the submitted document's structure (F43).
46    crate::interpolate::interpolate_value(&mut submitted)
47        .map_err(|e| ServeError::BadConfig(e.to_string()))?;
48
49    // 3. Merge onto the workspace default (submitted wins; see merge.rs semantics).
50    let mut merged = match default_base {
51        Some(base) => {
52            let mut m = base.clone();
53            crate::merge::merge_value(&mut m, submitted);
54            m
55        }
56        None => submitted,
57    };
58
59    // 3b. Bind `${param.*}` against the config's own `params:` defaults (#444).
60    // A body materialized by the template registry has no `params:` block left,
61    // so this is a no-op for template-triggered runs; for a directly-submitted
62    // parameterized config it applies the declared defaults and rejects a
63    // `required` param with no value — which is the honest answer, since
64    // `POST /v1/runs` has no param channel (register a template to get one).
65    crate::params::bind_document(
66        &mut merged,
67        &Default::default(),
68        crate::params::BindMode::Strict,
69    )
70    .map_err(|e| ServeError::Unprocessable {
71        message: e.to_string(),
72        details: None,
73    })?;
74
75    // 4. Version gate + structural-ref resolution.
76    let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
77        message: e.to_string(),
78        details: None,
79    })?;
80
81    // serve runs once per submission; a schedule: block is a category error.
82    #[cfg(feature = "schedule")]
83    if cfg.schedule.is_some() {
84        return Err(ServeError::BadConfig(
85            "submitted config contains a `schedule:` block — serve runs once per \
86             submission; use `faucet schedule` for cron scheduling"
87                .into(),
88        ));
89    }
90
91    // 5. Secret-manager directives (${vault:…} etc.) with the server's creds.
92    crate::secrets::resolve_secrets(&mut cfg)
93        .await
94        .map_err(|e| ServeError::BadConfig(e.to_string()))?;
95
96    // 6. Expand the matrix.
97    let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
98        message: e.to_string(),
99        details: None,
100    })?;
101
102    Ok(LoadedSubmission { cfg, nodes })
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    fn base() -> Value {
111        json!({
112            "version": 1,
113            "pipeline": {
114                "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
115                "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
116            }
117        })
118    }
119
120    #[tokio::test]
121    async fn submitted_overrides_default() {
122        let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
123        let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
124            .await
125            .unwrap();
126        // The override wins; the default sink survives the merge.
127        let node = &loaded.nodes[0];
128        assert_eq!(node.source.config["path"], "OVERRIDE.csv");
129        assert_eq!(node.sink.config["path"], "out.jsonl");
130    }
131
132    #[tokio::test]
133    async fn missing_version_without_base_is_unprocessable() {
134        // version defaults to 1 via serde, so this exercises the expand/validation
135        // failure path (pipeline with no source/sink). Accept either layer's error.
136        let body = r#"{ "pipeline": {} }"#;
137        let err = load_submission(body, ConfigFormat::Json, None)
138            .await
139            .unwrap_err();
140        assert!(matches!(
141            err,
142            ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
143        ));
144    }
145
146    #[cfg(feature = "schedule")]
147    #[tokio::test]
148    async fn schedule_block_is_rejected() {
149        let body = r#"
150version: 1
151pipeline:
152  source: { type: csv, config: { path: x.csv } }
153  sink: { type: jsonl, config: { path: out.jsonl } }
154schedule:
155  cron: "0 * * * *"
156  timezone: UTC
157"#;
158        let err = load_submission(body, ConfigFormat::Yaml, None)
159            .await
160            .unwrap_err();
161        match err {
162            ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
163            other => panic!("expected BadConfig, got {other:?}"),
164        }
165    }
166
167    #[tokio::test]
168    async fn invalid_yaml_is_bad_config() {
169        let err = load_submission("{[bad", ConfigFormat::Yaml, None)
170            .await
171            .unwrap_err();
172        assert!(matches!(err, ServeError::BadConfig(_)));
173    }
174
175    #[tokio::test]
176    async fn submitted_extends_is_rejected_with_composition_hint() {
177        // Composition must NOT run for HTTP-submitted bodies — otherwise a client
178        // could read arbitrary server files via `extends`. `deny_unknown_fields`
179        // rejects the key during `from_value` (no I/O), and `friendly_parse_error`
180        // attaches the composition hint.
181        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";
182        let err = load_submission(body, ConfigFormat::Yaml, None)
183            .await
184            .unwrap_err();
185        // ServeError doesn't implement Display; pull the inner message directly.
186        let msg = match &err {
187            ServeError::Unprocessable { message, .. } => message.clone(),
188            ServeError::BadConfig(m) => m.clone(),
189            other => format!("{other:?}"),
190        };
191        // Assert the hint fired (not merely that serde named the field) so a
192        // regression in `friendly_parse_error` is caught.
193        assert!(
194            msg.contains("composition"),
195            "submitted extends must be rejected with the composition hint, got: {msg}"
196        );
197    }
198}