Skip to main content

fn0_deploy/
env.rs

1use crate::credentials::Credentials;
2use anyhow::{Result, anyhow};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const ENV_YAML_FILENAME: &str = "env.yaml";
7const ENV_LOCAL_YAML_FILENAME: &str = "env.local.yaml";
8const DEK_KEY: &str = "__dek";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EntryKind {
12    Plain,
13    Secret,
14}
15
16/// Environment for `forte dev`, resolved without network or credentials:
17/// plain `env.yaml` entries overlaid with `env.local.yaml`.
18#[derive(Debug, Default, PartialEq, Eq)]
19pub struct DevEnv {
20    pub vars: Vec<(String, String)>,
21    /// `env.yaml` secrets with no `env.local.yaml` override. Their ciphertext
22    /// can only be opened by the worker's vault, so dev leaves them unset.
23    pub unresolved_secrets: Vec<String>,
24}
25
26pub fn set_plain(project_dir: &Path, key: &str, value: &str) -> Result<()> {
27    set_plain_at(&env_yaml_path(project_dir), key, value)
28}
29
30pub fn set_plain_local(project_dir: &Path, key: &str, value: &str) -> Result<()> {
31    set_plain_at(&env_local_yaml_path(project_dir), key, value)
32}
33
34fn set_plain_at(env_path: &Path, key: &str, value: &str) -> Result<()> {
35    reject_reserved(key)?;
36    let mut mapping = load_mapping(env_path)?;
37    mapping.insert(
38        serde_yaml::Value::String(key.to_string()),
39        serde_yaml::Value::String(value.to_string()),
40    );
41    save_mapping(env_path, &mapping)?;
42    Ok(())
43}
44
45pub fn load_dev_env(project_dir: &Path) -> Result<DevEnv> {
46    let mut dev_env = DevEnv::default();
47
48    let shared_path = env_yaml_path(project_dir);
49    for (key, value) in load_mapping(&shared_path)? {
50        let key = entry_key(&shared_path, &key)?;
51        if key == DEK_KEY {
52            continue;
53        }
54        match value {
55            serde_yaml::Value::Mapping(m) if m.contains_key("secret") => {
56                dev_env.unresolved_secrets.push(key.to_string());
57            }
58            value => dev_env
59                .vars
60                .push((key.to_string(), plain_value(&shared_path, key, &value)?)),
61        }
62    }
63
64    let local_path = env_local_yaml_path(project_dir);
65    for (key, value) in load_mapping(&local_path)? {
66        let key = entry_key(&local_path, &key)?;
67        reject_reserved(key)?;
68        if matches!(&value, serde_yaml::Value::Mapping(m) if m.contains_key("secret")) {
69            return Err(anyhow!(
70                "{}: {key} uses `secret:`, but {ENV_LOCAL_YAML_FILENAME} is plaintext and gitignored — write the value directly",
71                local_path.display()
72            ));
73        }
74        let value = plain_value(&local_path, key, &value)?;
75        dev_env.unresolved_secrets.retain(|k| k != key);
76        match dev_env.vars.iter_mut().find(|(k, _)| k == key) {
77            Some(existing) => existing.1 = value,
78            None => dev_env.vars.push((key.to_string(), value)),
79        }
80    }
81
82    Ok(dev_env)
83}
84
85fn entry_key<'a>(path: &Path, key: &'a serde_yaml::Value) -> Result<&'a str> {
86    key.as_str()
87        .ok_or_else(|| anyhow!("{}: key is not a string", path.display()))
88}
89
90// Mirrors the worker's stricter parse (fn0/worker/src/env_yaml.rs): a bare
91// `PORT: 8080` is a YAML number and must fail here too, not silently work in
92// dev and then break the deploy.
93fn plain_value(path: &Path, key: &str, value: &serde_yaml::Value) -> Result<String> {
94    match value {
95        serde_yaml::Value::String(s) => Ok(s.clone()),
96        _ => Err(anyhow!(
97            "{}: {key} must be a quoted string or a `secret:` mapping",
98            path.display()
99        )),
100    }
101}
102
103pub async fn set_secret(
104    project_dir: &Path,
105    key: &str,
106    value: &str,
107    creds: &Credentials,
108) -> Result<()> {
109    reject_reserved(key)?;
110    let env_path = env_yaml_path(project_dir);
111    let mut mapping = load_mapping(&env_path)?;
112
113    let dek_ct = ensure_dek(&mut mapping, creds).await?;
114    let ciphertext = call_secrets_encrypt(creds, &dek_ct, value).await?;
115
116    let mut entry = serde_yaml::Mapping::new();
117    entry.insert(
118        serde_yaml::Value::String("secret".to_string()),
119        serde_yaml::Value::String(ciphertext),
120    );
121    mapping.insert(
122        serde_yaml::Value::String(key.to_string()),
123        serde_yaml::Value::Mapping(entry),
124    );
125
126    save_mapping(&env_path, &mapping)?;
127    Ok(())
128}
129
130pub fn list_entries(project_dir: &Path) -> Result<Vec<(String, EntryKind)>> {
131    let env_path = env_yaml_path(project_dir);
132    if !env_path.exists() {
133        return Ok(Vec::new());
134    }
135    let mapping = load_mapping(&env_path)?;
136    let mut out = Vec::new();
137    for (key_v, value_v) in &mapping {
138        let Some(name) = key_v.as_str() else {
139            continue;
140        };
141        if name == DEK_KEY {
142            continue;
143        }
144        let kind = match value_v {
145            serde_yaml::Value::Mapping(m) if m.contains_key("secret") => EntryKind::Secret,
146            _ => EntryKind::Plain,
147        };
148        out.push((name.to_string(), kind));
149    }
150    out.sort_by(|a, b| a.0.cmp(&b.0));
151    Ok(out)
152}
153
154pub fn unset(project_dir: &Path, key: &str) -> Result<()> {
155    reject_reserved(key)?;
156    let env_path = env_yaml_path(project_dir);
157    if !env_path.exists() {
158        return Err(anyhow!("no {} in this project", env_path.display()));
159    }
160    let mut mapping = load_mapping(&env_path)?;
161    if mapping
162        .remove(serde_yaml::Value::String(key.to_string()))
163        .is_none()
164    {
165        return Err(anyhow!("{} not present in {}", key, env_path.display()));
166    }
167    save_mapping(&env_path, &mapping)?;
168    Ok(())
169}
170
171pub fn env_yaml_path(project_dir: &Path) -> PathBuf {
172    project_dir.join(ENV_YAML_FILENAME)
173}
174
175pub fn env_local_yaml_path(project_dir: &Path) -> PathBuf {
176    project_dir.join(ENV_LOCAL_YAML_FILENAME)
177}
178
179fn reject_reserved(key: &str) -> Result<()> {
180    if key == DEK_KEY {
181        return Err(anyhow!("{} is reserved", DEK_KEY));
182    }
183    Ok(())
184}
185
186async fn ensure_dek(mapping: &mut serde_yaml::Mapping, creds: &Credentials) -> Result<String> {
187    if let Some(serde_yaml::Value::Mapping(dek_map)) = mapping.get(DEK_KEY)
188        && let Some(serde_yaml::Value::String(s)) = dek_map.get("encrypted")
189    {
190        return Ok(s.clone());
191    }
192
193    let ct = call_secrets_init(creds).await?;
194    let mut dek_entry = serde_yaml::Mapping::new();
195    dek_entry.insert(
196        serde_yaml::Value::String("encrypted".to_string()),
197        serde_yaml::Value::String(ct.clone()),
198    );
199    mapping.insert(
200        serde_yaml::Value::String(DEK_KEY.to_string()),
201        serde_yaml::Value::Mapping(dek_entry),
202    );
203    Ok(ct)
204}
205
206async fn call_secrets_init(creds: &Credentials) -> Result<String> {
207    #[derive(Serialize)]
208    struct Empty {}
209    #[derive(Deserialize)]
210    #[serde(tag = "t", rename_all_fields = "camelCase")]
211    enum InitResp {
212        Ok { encrypted_dek: String },
213        Unauthorized,
214        Error { message: String },
215    }
216    let resp: InitResp = post_action(creds, "secrets_init", &Empty {}).await?;
217    match resp {
218        InitResp::Ok { encrypted_dek } => Ok(encrypted_dek),
219        InitResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
220        InitResp::Error { message } => Err(anyhow!("control error: {message}")),
221    }
222}
223
224async fn call_secrets_encrypt(
225    creds: &Credentials,
226    encrypted_dek: &str,
227    value: &str,
228) -> Result<String> {
229    #[derive(Serialize)]
230    #[serde(rename_all = "camelCase")]
231    struct Req<'a> {
232        encrypted_dek: &'a str,
233        value: &'a str,
234    }
235    #[derive(Deserialize)]
236    #[serde(tag = "t", rename_all_fields = "camelCase")]
237    enum EncResp {
238        Ok { ciphertext: String },
239        Unauthorized,
240        Error { message: String },
241    }
242    let resp: EncResp = post_action(
243        creds,
244        "secrets_encrypt",
245        &Req {
246            encrypted_dek,
247            value,
248        },
249    )
250    .await?;
251    match resp {
252        EncResp::Ok { ciphertext } => Ok(ciphertext),
253        EncResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
254        EncResp::Error { message } => Err(anyhow!("control error: {message}")),
255    }
256}
257
258async fn post_action<I, O>(creds: &Credentials, name: &str, body: &I) -> Result<O>
259where
260    I: Serialize,
261    O: serde::de::DeserializeOwned,
262{
263    let url = format!(
264        "{}/__forte_action/{}",
265        creds.control_url.trim_end_matches('/'),
266        name
267    );
268    let resp = reqwest::Client::new()
269        .post(url)
270        .bearer_auth(&creds.token)
271        .json(body)
272        .send()
273        .await?;
274    if !resp.status().is_success() {
275        let status = resp.status();
276        let body = resp.text().await.unwrap_or_default();
277        return Err(anyhow!("control returned {status}: {body}"));
278    }
279    let parsed = resp.json().await?;
280    Ok(parsed)
281}
282
283fn load_mapping(p: &Path) -> Result<serde_yaml::Mapping> {
284    if !p.exists() {
285        return Ok(serde_yaml::Mapping::new());
286    }
287    let content = std::fs::read_to_string(p)?;
288    if content.trim().is_empty() {
289        return Ok(serde_yaml::Mapping::new());
290    }
291    let value: serde_yaml::Value = serde_yaml::from_str(&content)?;
292    match value {
293        serde_yaml::Value::Mapping(m) => Ok(m),
294        _ => Err(anyhow!("{} must contain a mapping", p.display())),
295    }
296}
297
298fn save_mapping(p: &Path, m: &serde_yaml::Mapping) -> Result<()> {
299    let s = serde_yaml::to_string(&serde_yaml::Value::Mapping(m.clone()))?;
300    std::fs::write(p, s)?;
301    Ok(())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use tempfile::TempDir;
308
309    #[test]
310    fn set_plain_writes_scalar() {
311        let dir = TempDir::new().unwrap();
312        set_plain(dir.path(), "FOO", "bar").unwrap();
313        let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
314        assert!(content.contains("FOO: bar"));
315    }
316
317    #[test]
318    fn set_plain_overwrites_existing() {
319        let dir = TempDir::new().unwrap();
320        set_plain(dir.path(), "FOO", "first").unwrap();
321        set_plain(dir.path(), "FOO", "second").unwrap();
322        let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
323        assert!(content.contains("FOO: second"));
324        assert!(!content.contains("FOO: first"));
325    }
326
327    #[test]
328    fn list_entries_classifies_plain_and_secret() {
329        let dir = TempDir::new().unwrap();
330        std::fs::write(
331            env_yaml_path(dir.path()),
332            "__dek:\n  encrypted: ct\nFOO: plain_value\nBAR:\n  secret: ct\n",
333        )
334        .unwrap();
335        let entries = list_entries(dir.path()).unwrap();
336        assert_eq!(
337            entries,
338            vec![
339                ("BAR".to_string(), EntryKind::Secret),
340                ("FOO".to_string(), EntryKind::Plain),
341            ]
342        );
343    }
344
345    #[test]
346    fn unset_removes_entry() {
347        let dir = TempDir::new().unwrap();
348        set_plain(dir.path(), "FOO", "bar").unwrap();
349        set_plain(dir.path(), "BAZ", "qux").unwrap();
350        unset(dir.path(), "FOO").unwrap();
351        let entries = list_entries(dir.path()).unwrap();
352        assert_eq!(entries, vec![("BAZ".to_string(), EntryKind::Plain)]);
353    }
354
355    #[test]
356    fn unset_missing_key_errors() {
357        let dir = TempDir::new().unwrap();
358        set_plain(dir.path(), "FOO", "bar").unwrap();
359        let err = unset(dir.path(), "NOPE").unwrap_err();
360        assert!(err.to_string().contains("not present"));
361    }
362
363    #[test]
364    fn reject_reserved_dek_key() {
365        let dir = TempDir::new().unwrap();
366        let err = set_plain(dir.path(), DEK_KEY, "x").unwrap_err();
367        assert!(err.to_string().contains("reserved"));
368    }
369
370    #[test]
371    fn dev_env_is_empty_without_files() {
372        let dir = TempDir::new().unwrap();
373        assert_eq!(load_dev_env(dir.path()).unwrap(), DevEnv::default());
374    }
375
376    #[test]
377    fn dev_env_takes_plain_shared_entries_and_defers_secrets() {
378        let dir = TempDir::new().unwrap();
379        std::fs::write(
380            env_yaml_path(dir.path()),
381            "__dek:\n  encrypted: ct\nSHARED: from_shared\nAPI_KEY:\n  secret: ct\n",
382        )
383        .unwrap();
384
385        let dev_env = load_dev_env(dir.path()).unwrap();
386        assert_eq!(
387            dev_env.vars,
388            vec![("SHARED".to_string(), "from_shared".to_string())]
389        );
390        assert_eq!(dev_env.unresolved_secrets, vec!["API_KEY".to_string()]);
391    }
392
393    #[test]
394    fn local_overrides_shared_and_resolves_secret() {
395        let dir = TempDir::new().unwrap();
396        std::fs::write(
397            env_yaml_path(dir.path()),
398            "SHARED: from_shared\nAPI_KEY:\n  secret: ct\n",
399        )
400        .unwrap();
401        std::fs::write(
402            env_local_yaml_path(dir.path()),
403            "SHARED: from_local\nAPI_KEY: dev_key\nLOCAL_ONLY: x\n",
404        )
405        .unwrap();
406
407        let dev_env = load_dev_env(dir.path()).unwrap();
408        assert_eq!(
409            dev_env.vars,
410            vec![
411                ("SHARED".to_string(), "from_local".to_string()),
412                ("API_KEY".to_string(), "dev_key".to_string()),
413                ("LOCAL_ONLY".to_string(), "x".to_string()),
414            ]
415        );
416        assert!(dev_env.unresolved_secrets.is_empty());
417    }
418
419    #[test]
420    fn local_secret_entry_is_rejected() {
421        let dir = TempDir::new().unwrap();
422        std::fs::write(env_local_yaml_path(dir.path()), "API_KEY:\n  secret: ct\n").unwrap();
423        let err = load_dev_env(dir.path()).unwrap_err();
424        assert!(err.to_string().contains("plaintext and gitignored"));
425    }
426
427    #[test]
428    fn non_string_plain_value_is_rejected() {
429        let dir = TempDir::new().unwrap();
430        std::fs::write(env_yaml_path(dir.path()), "PORT: 8080\n").unwrap();
431        let err = load_dev_env(dir.path()).unwrap_err();
432        assert!(err.to_string().contains("must be a quoted string"));
433    }
434}