Skip to main content

fnox_core/lease_backends/
command.rs

1use crate::error::{FnoxError, Result};
2use crate::lease_backends::{Lease, LeaseBackend};
3use async_trait::async_trait;
4use indexmap::IndexMap;
5use std::time::Duration;
6use tokio::process::Command;
7
8const URL: &str = "https://fnox.jdx.dev/leases/command";
9
10/// Command backends can consume arbitrary env vars (determined at runtime),
11/// but `fnox get` never routes through a Command backend because
12/// `produces_env_var` always returns `false` for this variant.
13/// This constant is intentionally empty and unused by the current routing logic.
14pub const CONSUMED_ENV_VARS: &[&str] = &[];
15
16pub fn check_prerequisites() -> Option<String> {
17    None
18}
19
20pub fn required_env_vars() -> Vec<(&'static str, &'static str)> {
21    vec![]
22}
23
24pub struct CommandBackend {
25    create_command: String,
26    revoke_command: Option<String>,
27    timeout: Duration,
28}
29
30impl CommandBackend {
31    pub fn new(create_command: String, revoke_command: Option<String>, timeout: Duration) -> Self {
32        Self {
33            create_command,
34            revoke_command,
35            timeout,
36        }
37    }
38
39    async fn run_command(
40        &self,
41        cmd_str: &str,
42        envs: &[(&str, String)],
43        action: &str,
44    ) -> Result<std::process::Output> {
45        let mut cmd = Command::new("sh");
46        cmd.arg("-c").arg(cmd_str);
47        for (k, v) in envs {
48            cmd.env(k, v);
49        }
50
51        let output = tokio::time::timeout(self.timeout, cmd.output())
52            .await
53            .map_err(|_| FnoxError::ProviderCliFailed {
54                provider: "Command".to_string(),
55                details: format!("{} timed out after {}s", action, self.timeout.as_secs()),
56                hint: format!(
57                    "Check that '{}' completes in time, or increase the timeout",
58                    cmd_str
59                ),
60                url: URL.to_string(),
61            })?
62            .map_err(|e| FnoxError::ProviderCliFailed {
63                provider: "Command".to_string(),
64                details: e.to_string(),
65                hint: format!("Failed to execute {}: {}", action, cmd_str),
66                url: URL.to_string(),
67            })?;
68
69        if !output.status.success() {
70            let stderr = String::from_utf8_lossy(&output.stderr);
71            return Err(FnoxError::ProviderCliFailed {
72                provider: "Command".to_string(),
73                details: stderr.trim().to_string(),
74                hint: format!("{} exited with {}", action, output.status),
75                url: URL.to_string(),
76            });
77        }
78
79        Ok(output)
80    }
81}
82
83#[async_trait]
84impl LeaseBackend for CommandBackend {
85    async fn create_lease(&self, duration: Duration, label: &str) -> Result<Lease> {
86        let output = self
87            .run_command(
88                &self.create_command,
89                &[
90                    ("FNOX_LEASE_DURATION", duration.as_secs().to_string()),
91                    ("FNOX_LEASE_LABEL", label.to_string()),
92                ],
93                "create_command",
94            )
95            .await?;
96
97        let stdout =
98            String::from_utf8(output.stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
99                provider: "Command".to_string(),
100                details: format!("Invalid UTF-8 in command output: {}", e),
101                hint: "Command must output valid UTF-8 JSON".to_string(),
102                url: URL.to_string(),
103            })?;
104
105        let parsed: serde_json::Value =
106            serde_json::from_str(&stdout).map_err(|e| FnoxError::ProviderInvalidResponse {
107                provider: "Command".to_string(),
108                details: format!("Invalid JSON output: {}", e),
109                hint: "Command must output JSON with a 'credentials' object".to_string(),
110                url: URL.to_string(),
111            })?;
112
113        let creds_obj = parsed["credentials"].as_object().ok_or_else(|| {
114            FnoxError::ProviderInvalidResponse {
115                provider: "Command".to_string(),
116                details: "Output missing 'credentials' object".to_string(),
117                hint: "Command must output JSON: { \"credentials\": { \"KEY\": \"value\" } }"
118                    .to_string(),
119                url: URL.to_string(),
120            }
121        })?;
122
123        let mut credentials = IndexMap::new();
124        for (key, value) in creds_obj {
125            if let Some(v) = value.as_str() {
126                credentials.insert(key.clone(), v.to_string());
127            } else {
128                tracing::warn!(
129                    "Command backend: credential '{}' is not a string, skipping",
130                    key
131                );
132            }
133        }
134        if credentials.is_empty() {
135            return Err(FnoxError::ProviderInvalidResponse {
136                provider: "Command".to_string(),
137                details: "Command returned an empty 'credentials' object".to_string(),
138                hint: "Ensure the command outputs at least one string credential".to_string(),
139                url: URL.to_string(),
140            });
141        }
142
143        let expires_at = parsed["expires_at"].as_str().and_then(|s| {
144            match chrono::DateTime::parse_from_rfc3339(s) {
145                Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
146                Err(e) => {
147                    tracing::warn!(
148                        "Command backend: could not parse expires_at {:?}: {}; lease treated as non-expiring",
149                        s, e
150                    );
151                    None
152                }
153            }
154        });
155
156        let lease_id = parsed["lease_id"]
157            .as_str()
158            .map(|s| s.to_string())
159            .unwrap_or_else(|| super::generate_lease_id("cmd"));
160
161        Ok(Lease {
162            credentials,
163            expires_at,
164            lease_id,
165        })
166    }
167
168    async fn revoke_lease(
169        &self,
170        lease_id: &str,
171        _credentials: Option<&IndexMap<String, String>>,
172    ) -> Result<()> {
173        let Some(revoke_cmd) = &self.revoke_command else {
174            return Ok(());
175        };
176
177        self.run_command(
178            revoke_cmd,
179            &[("FNOX_LEASE_ID", lease_id.to_string())],
180            "revoke_command",
181        )
182        .await?;
183
184        Ok(())
185    }
186
187    fn max_lease_duration(&self) -> Duration {
188        Duration::from_secs(24 * 3600)
189    }
190}