fn0-deploy 0.1.12

Deploy client for fn0 cloud
Documentation
use crate::credentials::Credentials;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

const ENV_YAML_FILENAME: &str = "env.yaml";
const DEK_KEY: &str = "__dek";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
    Plain,
    Secret,
}

pub fn set_plain(project_dir: &Path, key: &str, value: &str) -> Result<()> {
    reject_reserved(key)?;
    let env_path = env_yaml_path(project_dir);
    let mut mapping = load_mapping(&env_path)?;
    mapping.insert(
        serde_yaml::Value::String(key.to_string()),
        serde_yaml::Value::String(value.to_string()),
    );
    save_mapping(&env_path, &mapping)?;
    Ok(())
}

pub async fn set_secret(
    project_dir: &Path,
    key: &str,
    value: &str,
    creds: &Credentials,
) -> Result<()> {
    reject_reserved(key)?;
    let env_path = env_yaml_path(project_dir);
    let mut mapping = load_mapping(&env_path)?;

    let dek_ct = ensure_dek(&mut mapping, creds).await?;
    let ciphertext = call_secrets_encrypt(creds, &dek_ct, value).await?;

    let mut entry = serde_yaml::Mapping::new();
    entry.insert(
        serde_yaml::Value::String("secret".to_string()),
        serde_yaml::Value::String(ciphertext),
    );
    mapping.insert(
        serde_yaml::Value::String(key.to_string()),
        serde_yaml::Value::Mapping(entry),
    );

    save_mapping(&env_path, &mapping)?;
    Ok(())
}

pub fn list_entries(project_dir: &Path) -> Result<Vec<(String, EntryKind)>> {
    let env_path = env_yaml_path(project_dir);
    if !env_path.exists() {
        return Ok(Vec::new());
    }
    let mapping = load_mapping(&env_path)?;
    let mut out = Vec::new();
    for (key_v, value_v) in &mapping {
        let Some(name) = key_v.as_str() else {
            continue;
        };
        if name == DEK_KEY {
            continue;
        }
        let kind = match value_v {
            serde_yaml::Value::Mapping(m) if m.contains_key("secret") => EntryKind::Secret,
            _ => EntryKind::Plain,
        };
        out.push((name.to_string(), kind));
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(out)
}

pub fn unset(project_dir: &Path, key: &str) -> Result<()> {
    reject_reserved(key)?;
    let env_path = env_yaml_path(project_dir);
    if !env_path.exists() {
        return Err(anyhow!("no {} in this project", env_path.display()));
    }
    let mut mapping = load_mapping(&env_path)?;
    if mapping
        .remove(serde_yaml::Value::String(key.to_string()))
        .is_none()
    {
        return Err(anyhow!("{} not present in {}", key, env_path.display()));
    }
    save_mapping(&env_path, &mapping)?;
    Ok(())
}

pub fn env_yaml_path(project_dir: &Path) -> PathBuf {
    project_dir.join(ENV_YAML_FILENAME)
}

fn reject_reserved(key: &str) -> Result<()> {
    if key == DEK_KEY {
        return Err(anyhow!("{} is reserved", DEK_KEY));
    }
    Ok(())
}

async fn ensure_dek(mapping: &mut serde_yaml::Mapping, creds: &Credentials) -> Result<String> {
    if let Some(serde_yaml::Value::Mapping(dek_map)) = mapping.get(DEK_KEY)
        && let Some(serde_yaml::Value::String(s)) = dek_map.get("encrypted")
    {
        return Ok(s.clone());
    }

    let ct = call_secrets_init(creds).await?;
    let mut dek_entry = serde_yaml::Mapping::new();
    dek_entry.insert(
        serde_yaml::Value::String("encrypted".to_string()),
        serde_yaml::Value::String(ct.clone()),
    );
    mapping.insert(
        serde_yaml::Value::String(DEK_KEY.to_string()),
        serde_yaml::Value::Mapping(dek_entry),
    );
    Ok(ct)
}

async fn call_secrets_init(creds: &Credentials) -> Result<String> {
    #[derive(Serialize)]
    struct Empty {}
    #[derive(Deserialize)]
    #[serde(tag = "t", rename_all_fields = "camelCase")]
    enum InitResp {
        Ok { encrypted_dek: String },
        Unauthorized,
        Error { message: String },
    }
    let resp: InitResp = post_action(creds, "secrets_init", &Empty {}).await?;
    match resp {
        InitResp::Ok { encrypted_dek } => Ok(encrypted_dek),
        InitResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
        InitResp::Error { message } => Err(anyhow!("control error: {message}")),
    }
}

async fn call_secrets_encrypt(
    creds: &Credentials,
    encrypted_dek: &str,
    value: &str,
) -> Result<String> {
    #[derive(Serialize)]
    #[serde(rename_all = "camelCase")]
    struct Req<'a> {
        encrypted_dek: &'a str,
        value: &'a str,
    }
    #[derive(Deserialize)]
    #[serde(tag = "t", rename_all_fields = "camelCase")]
    enum EncResp {
        Ok { ciphertext: String },
        Unauthorized,
        Error { message: String },
    }
    let resp: EncResp = post_action(
        creds,
        "secrets_encrypt",
        &Req {
            encrypted_dek,
            value,
        },
    )
    .await?;
    match resp {
        EncResp::Ok { ciphertext } => Ok(ciphertext),
        EncResp::Unauthorized => Err(anyhow!("unauthorized — `fn0 login` again")),
        EncResp::Error { message } => Err(anyhow!("control error: {message}")),
    }
}

async fn post_action<I, O>(creds: &Credentials, name: &str, body: &I) -> Result<O>
where
    I: Serialize,
    O: serde::de::DeserializeOwned,
{
    let url = format!(
        "{}/__forte_action/{}",
        creds.control_url.trim_end_matches('/'),
        name
    );
    let resp = reqwest::Client::new()
        .post(url)
        .bearer_auth(&creds.token)
        .json(body)
        .send()
        .await?;
    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(anyhow!("control returned {status}: {body}"));
    }
    let parsed = resp.json().await?;
    Ok(parsed)
}

fn load_mapping(p: &Path) -> Result<serde_yaml::Mapping> {
    if !p.exists() {
        return Ok(serde_yaml::Mapping::new());
    }
    let content = std::fs::read_to_string(p)?;
    if content.trim().is_empty() {
        return Ok(serde_yaml::Mapping::new());
    }
    let value: serde_yaml::Value = serde_yaml::from_str(&content)?;
    match value {
        serde_yaml::Value::Mapping(m) => Ok(m),
        _ => Err(anyhow!("{} must contain a mapping", p.display())),
    }
}

fn save_mapping(p: &Path, m: &serde_yaml::Mapping) -> Result<()> {
    let s = serde_yaml::to_string(&serde_yaml::Value::Mapping(m.clone()))?;
    std::fs::write(p, s)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn set_plain_writes_scalar() {
        let dir = TempDir::new().unwrap();
        set_plain(dir.path(), "FOO", "bar").unwrap();
        let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
        assert!(content.contains("FOO: bar"));
    }

    #[test]
    fn set_plain_overwrites_existing() {
        let dir = TempDir::new().unwrap();
        set_plain(dir.path(), "FOO", "first").unwrap();
        set_plain(dir.path(), "FOO", "second").unwrap();
        let content = std::fs::read_to_string(env_yaml_path(dir.path())).unwrap();
        assert!(content.contains("FOO: second"));
        assert!(!content.contains("FOO: first"));
    }

    #[test]
    fn list_entries_classifies_plain_and_secret() {
        let dir = TempDir::new().unwrap();
        std::fs::write(
            env_yaml_path(dir.path()),
            "__dek:\n  encrypted: ct\nFOO: plain_value\nBAR:\n  secret: ct\n",
        )
        .unwrap();
        let entries = list_entries(dir.path()).unwrap();
        assert_eq!(
            entries,
            vec![
                ("BAR".to_string(), EntryKind::Secret),
                ("FOO".to_string(), EntryKind::Plain),
            ]
        );
    }

    #[test]
    fn unset_removes_entry() {
        let dir = TempDir::new().unwrap();
        set_plain(dir.path(), "FOO", "bar").unwrap();
        set_plain(dir.path(), "BAZ", "qux").unwrap();
        unset(dir.path(), "FOO").unwrap();
        let entries = list_entries(dir.path()).unwrap();
        assert_eq!(entries, vec![("BAZ".to_string(), EntryKind::Plain)]);
    }

    #[test]
    fn unset_missing_key_errors() {
        let dir = TempDir::new().unwrap();
        set_plain(dir.path(), "FOO", "bar").unwrap();
        let err = unset(dir.path(), "NOPE").unwrap_err();
        assert!(err.to_string().contains("not present"));
    }

    #[test]
    fn reject_reserved_dek_key() {
        let dir = TempDir::new().unwrap();
        let err = set_plain(dir.path(), DEK_KEY, "x").unwrap_err();
        assert!(err.to_string().contains("reserved"));
    }
}