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}