Skip to main content

iron/
config.rs

1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use std::collections::{HashMap, HashSet};
4use std::net::Ipv4Addr;
5use std::path::Path;
6
7#[derive(Debug, Deserialize)]
8pub struct FleetConfig {
9    pub domain: Option<String>,
10    pub ssh_key: Option<String>,
11    #[serde(default = "default_network")]
12    pub network: String,
13    #[serde(default)]
14    pub servers: HashMap<String, Server>,
15    #[serde(default)]
16    pub apps: HashMap<String, App>,
17    #[serde(default)]
18    pub runners: HashMap<String, Runner>,
19}
20
21fn default_network() -> String {
22    "flow".to_string()
23}
24
25#[derive(Debug, Deserialize, Clone)]
26#[serde(deny_unknown_fields)]
27pub struct Server {
28    pub host: String,
29    pub ip: Option<String>,
30    #[serde(default = "default_user")]
31    pub user: String,
32    pub ssh_key: Option<String>,
33}
34
35fn default_user() -> String {
36    "deploy".to_string()
37}
38
39#[derive(Debug, Deserialize, Clone)]
40#[serde(deny_unknown_fields)]
41pub struct App {
42    pub image: String,
43    #[serde(default)]
44    pub servers: Vec<String>,
45    pub port: Option<u16>,
46    #[serde(default)]
47    pub deploy_strategy: DeployStrategy,
48    #[serde(default)]
49    pub routing: Option<Routing>,
50    #[serde(default)]
51    pub services: Vec<Sidecar>,
52    #[serde(default)]
53    pub ports: Vec<PortMapping>,
54}
55
56#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
57#[serde(rename_all = "lowercase")]
58pub enum DeployStrategy {
59    #[default]
60    Rolling,
61    Recreate,
62}
63
64#[derive(Debug, Deserialize, Clone)]
65#[serde(deny_unknown_fields)]
66pub struct Routing {
67    #[serde(default)]
68    pub domains: Vec<String>,
69    pub health_path: Option<String>,
70    pub health_interval: Option<String>,
71}
72
73#[derive(Debug, Deserialize, Clone)]
74#[serde(deny_unknown_fields)]
75pub struct Sidecar {
76    pub name: String,
77    pub image: String,
78    #[serde(default)]
79    pub volumes: Vec<String>,
80    pub healthcheck: Option<String>,
81    pub depends_on: Option<String>,
82}
83
84#[derive(Debug, Deserialize, Clone)]
85#[serde(deny_unknown_fields)]
86pub struct PortMapping {
87    pub internal: u16,
88    pub external: u16,
89    #[serde(default = "default_protocol")]
90    pub protocol: String,
91}
92
93fn default_protocol() -> String {
94    "tcp".to_string()
95}
96
97#[derive(Debug, Deserialize, Clone)]
98#[serde(deny_unknown_fields)]
99pub struct Runner {
100    pub server: String,
101    pub scope: RunnerScope,
102    pub target: String,
103    #[serde(default)]
104    pub labels: Vec<String>,
105    #[serde(default = "default_ephemeral")]
106    pub ephemeral: bool,
107}
108
109#[derive(Debug, Deserialize, Clone, PartialEq)]
110#[serde(rename_all = "lowercase")]
111pub enum RunnerScope {
112    Org,
113    Repo,
114}
115
116fn default_ephemeral() -> bool {
117    true
118}
119
120#[derive(Debug, Deserialize, Default)]
121pub struct EnvConfig {
122    #[serde(default)]
123    pub apps: HashMap<String, AppEnv>,
124    #[serde(default)]
125    pub fleet: FleetSecrets,
126}
127
128#[derive(Debug, Deserialize, Default, Clone)]
129pub struct AppEnv {
130    #[serde(flatten)]
131    pub env: HashMap<String, toml::Value>,
132    #[serde(default)]
133    pub services: HashMap<String, HashMap<String, String>>,
134}
135
136#[derive(Debug, Deserialize, Default, Clone)]
137pub struct FleetSecrets {
138    pub gh_token: Option<String>,
139    pub gh_username: Option<String>,
140    pub cloudflare_api_token: Option<String>,
141    pub discord_webhook_url: Option<String>,
142}
143
144#[derive(Debug)]
145pub struct Fleet {
146    pub domain: Option<String>,
147    pub network: String,
148    pub servers: HashMap<String, Server>,
149    pub apps: HashMap<String, ResolvedApp>,
150    pub runners: HashMap<String, Runner>,
151    pub secrets: FleetSecrets,
152}
153
154#[derive(Debug, Clone)]
155pub struct ResolvedApp {
156    pub name: String,
157    pub image: String,
158    pub servers: Vec<String>,
159    pub port: Option<u16>,
160    pub deploy_strategy: DeployStrategy,
161    pub routing: Option<Routing>,
162    pub env: HashMap<String, String>,
163    pub services: Vec<ResolvedSidecar>,
164    pub ports: Vec<PortMapping>,
165}
166
167#[derive(Debug, Clone)]
168pub struct ResolvedSidecar {
169    pub name: String,
170    pub image: String,
171    pub volumes: Vec<String>,
172    pub env: HashMap<String, String>,
173    pub healthcheck: Option<String>,
174    pub depends_on: Option<String>,
175}
176
177fn is_valid_caddy_duration(s: &str) -> bool {
178    for suffix in &["ms", "s", "m", "h", "d"] {
179        if let Some(num_part) = s.strip_suffix(suffix) {
180            return !num_part.is_empty() && num_part.parse::<f64>().is_ok();
181        }
182    }
183    false
184}
185
186fn validate(config: &FleetConfig) -> Result<()> {
187    for (server_name, server) in &config.servers {
188        if let Some(ref ip) = server.ip {
189            if ip.parse::<Ipv4Addr>().is_err() {
190                bail!("Server '{server_name}' has invalid IP '{ip}'");
191            }
192        }
193    }
194
195    let mut all_domains: Vec<(&str, &str)> = Vec::new();
196
197    for (app_name, app) in &config.apps {
198        if app.servers.is_empty() {
199            bail!("App '{app_name}' has no servers");
200        }
201
202        if app.image.is_empty() {
203            bail!("App '{app_name}' has an empty image");
204        }
205
206        if app.routing.is_some() && app.port.is_none() {
207            bail!("App '{app_name}' has routing but no port");
208        }
209
210        if !app.ports.is_empty() && app.routing.is_some() {
211            bail!("App '{app_name}' has both routing and ports (mutually exclusive)");
212        }
213
214        if let Some(port) = app.port {
215            if port == 0 {
216                bail!("App '{app_name}' has invalid port 0");
217            }
218        }
219        for pm in &app.ports {
220            if pm.internal == 0 || pm.external == 0 {
221                bail!("App '{app_name}' has invalid port 0");
222            }
223            if pm.protocol != "tcp" && pm.protocol != "udp" {
224                bail!(
225                    "App '{app_name}' has invalid port protocol '{}' (must be tcp or udp)",
226                    pm.protocol
227                );
228            }
229        }
230
231        if let Some(ref routing) = app.routing {
232            for domain in &routing.domains {
233                if domain.is_empty() {
234                    bail!("App '{app_name}' has an empty domain");
235                }
236                if domain.contains(char::is_whitespace) {
237                    bail!("App '{app_name}' has domain '{domain}' containing whitespace");
238                }
239                if domain.contains("://") {
240                    bail!(
241                        "App '{app_name}' has domain '{domain}' with protocol prefix (use bare hostname)"
242                    );
243                }
244                if !domain.contains('.') {
245                    bail!(
246                        "App '{app_name}' has domain '{domain}' with no dot (expected hostname like example.com)"
247                    );
248                }
249                all_domains.push((domain, app_name));
250            }
251            if let Some(ref health_path) = routing.health_path {
252                if !health_path.starts_with('/') {
253                    bail!(
254                        "App '{app_name}' has invalid health_path '{health_path}' (must start with /)"
255                    );
256                }
257            }
258            if let Some(ref health_interval) = routing.health_interval {
259                if !is_valid_caddy_duration(health_interval) {
260                    bail!(
261                        "App '{app_name}' has invalid health_interval '{health_interval}' (expected format: 5s, 1m, 500ms)"
262                    );
263                }
264            }
265        }
266
267        let sidecar_names: Vec<&str> = app.services.iter().map(|s| s.name.as_str()).collect();
268        let mut seen_sidecar_names: HashSet<&str> = HashSet::new();
269        for name in &sidecar_names {
270            if !seen_sidecar_names.insert(name) {
271                bail!("App '{app_name}' has duplicate service name '{name}'");
272            }
273        }
274        for svc in &app.services {
275            if svc.image.is_empty() {
276                bail!(
277                    "Service '{}' in app '{}' has an empty image",
278                    svc.name,
279                    app_name
280                );
281            }
282            if let Some(ref dep) = svc.depends_on {
283                if !sidecar_names.contains(&dep.as_str()) {
284                    bail!(
285                        "Service '{}' in app '{}' depends on '{}' which doesn't exist",
286                        svc.name,
287                        app_name,
288                        dep
289                    );
290                }
291            }
292        }
293    }
294
295    let mut seen_domains: HashMap<&str, &str> = HashMap::new();
296    for (domain, app_name) in &all_domains {
297        if let Some(other_app) = seen_domains.get(domain) {
298            bail!("Duplicate domain '{domain}' in apps '{other_app}' and '{app_name}'");
299        }
300        seen_domains.insert(domain, app_name);
301    }
302
303    for (runner_name, runner) in &config.runners {
304        if runner.target.is_empty() {
305            bail!("Runner '{runner_name}' has an empty target");
306        }
307        if !config.servers.contains_key(&runner.server) {
308            bail!(
309                "Runner '{runner_name}' references unknown server '{}'",
310                runner.server
311            );
312        }
313    }
314
315    Ok(())
316}
317
318pub fn load(config_path: &str) -> Result<Fleet> {
319    let config_path = Path::new(config_path);
320    let content = std::fs::read_to_string(config_path)
321        .with_context(|| format!("Failed to read {}", config_path.display()))?;
322    let config: FleetConfig = toml::from_str(&content)
323        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
324
325    let env_path = config_path.with_file_name("fleet.env.toml");
326    let env_config: EnvConfig = if env_path.exists() {
327        let env_content = std::fs::read_to_string(&env_path)
328            .with_context(|| format!("Failed to read {}", env_path.display()))?;
329        toml::from_str(&env_content)
330            .with_context(|| format!("Failed to parse {}", env_path.display()))?
331    } else {
332        EnvConfig::default()
333    };
334
335    for (app_name, app) in &config.apps {
336        for server in &app.servers {
337            if !config.servers.contains_key(server) {
338                bail!("App '{app_name}' references unknown server '{server}'");
339            }
340        }
341    }
342
343    validate(&config)?;
344
345    let mut resolved_apps = HashMap::new();
346    for (name, app) in config.apps {
347        let mut env = HashMap::new();
348
349        if let Some(app_env) = env_config.apps.get(&name) {
350            for (k, v) in &app_env.env {
351                if let toml::Value::String(s) = v {
352                    env.insert(k.clone(), s.clone());
353                }
354            }
355        }
356
357        let resolved_services: Vec<ResolvedSidecar> = app
358            .services
359            .iter()
360            .map(|svc| {
361                let mut svc_env = HashMap::new();
362                if let Some(app_env) = env_config.apps.get(&name) {
363                    if let Some(svc_env_vals) = app_env.services.get(&svc.name) {
364                        for (k, v) in svc_env_vals {
365                            svc_env.insert(k.clone(), v.clone());
366                        }
367                    }
368                }
369                ResolvedSidecar {
370                    name: svc.name.clone(),
371                    image: svc.image.clone(),
372                    volumes: svc.volumes.clone(),
373                    env: svc_env,
374                    healthcheck: svc.healthcheck.clone(),
375                    depends_on: svc.depends_on.clone(),
376                }
377            })
378            .collect();
379
380        resolved_apps.insert(
381            name.clone(),
382            ResolvedApp {
383                name: name.clone(),
384                image: app.image,
385                servers: app.servers,
386                port: app.port,
387                deploy_strategy: app.deploy_strategy,
388                routing: app.routing,
389                env,
390                services: resolved_services,
391                ports: app.ports,
392            },
393        );
394    }
395
396    Ok(Fleet {
397        domain: config.domain,
398        network: config.network,
399        servers: config.servers,
400        apps: resolved_apps,
401        runners: config.runners,
402        secrets: env_config.fleet,
403    })
404}