fnox_core/lease_backends/
command.rs1use 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
10pub 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}