Skip to main content

iron/
app.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4
5use crate::cli::AppCommand;
6use crate::config::FleetConfig;
7use crate::ui;
8
9pub struct ParsedPortMap {
10    pub internal: u16,
11    pub external: u16,
12    pub protocol: String,
13}
14
15fn parse_port_map(s: &str) -> Result<ParsedPortMap> {
16    let (ports_part, protocol) = if let Some((p, proto)) = s.rsplit_once('/') {
17        (p, proto.to_string())
18    } else {
19        (s, "tcp".to_string())
20    };
21
22    if protocol != "tcp" && protocol != "udp" {
23        bail!("Invalid protocol '{protocol}' (must be tcp or udp)");
24    }
25
26    let (external_str, internal_str) = ports_part
27        .split_once(':')
28        .context("Port map must be in external:internal format")?;
29
30    let external: u16 = external_str
31        .parse()
32        .context("Invalid external port number")?;
33    let internal: u16 = internal_str
34        .parse()
35        .context("Invalid internal port number")?;
36
37    if external == 0 || internal == 0 {
38        bail!("Ports must be non-zero");
39    }
40
41    Ok(ParsedPortMap {
42        internal,
43        external,
44        protocol,
45    })
46}
47
48pub fn run(config_path: &str, command: AppCommand) -> Result<()> {
49    match command {
50        AppCommand::Add {
51            name,
52            image,
53            server: servers,
54            port,
55            domain: domains,
56            health_path,
57            health_interval,
58            port_map: raw_port_maps,
59            deploy_strategy,
60        } => {
61            let interactive = name.is_none() && image.is_none() && servers.is_empty();
62            if interactive {
63                interactive_add(config_path)
64            } else {
65                let name = name.context("App name is required")?;
66                let image = image.context("--image is required")?;
67                if servers.is_empty() {
68                    bail!("--server is required");
69                }
70                let deploy_strategy = deploy_strategy.unwrap_or_else(|| "rolling".to_string());
71                add(
72                    config_path,
73                    &name,
74                    &image,
75                    &servers,
76                    port,
77                    &domains,
78                    health_path.as_deref(),
79                    health_interval.as_deref(),
80                    &raw_port_maps,
81                    &deploy_strategy,
82                )
83            }
84        }
85        AppCommand::AddService {
86            app,
87            name,
88            image,
89            volume: volumes,
90            healthcheck,
91            depends_on,
92        } => add_service(
93            config_path,
94            &app,
95            &name,
96            &image,
97            &volumes,
98            healthcheck.as_deref(),
99            depends_on.as_deref(),
100        ),
101        AppCommand::RemoveService { app, name } => remove_service(config_path, &app, &name),
102    }
103}
104
105fn interactive_add(config_path: &str) -> Result<()> {
106    let config_path_p = Path::new(config_path);
107    let content = std::fs::read_to_string(config_path_p)
108        .with_context(|| format!("Failed to read {}", config_path_p.display()))?;
109    let config: FleetConfig = toml::from_str(&content)
110        .with_context(|| format!("Failed to parse {}", config_path_p.display()))?;
111
112    ui::header("Add app");
113
114    let Some(name) = ui::prompt("App name:") else {
115        bail!("App name is required");
116    };
117
118    let Some(image) = ui::prompt("Docker image (e.g. ghcr.io/org/app:latest):") else {
119        bail!("Docker image is required");
120    };
121
122    let available_servers: Vec<&str> = config.servers.keys().map(String::as_str).collect();
123    if available_servers.is_empty() {
124        bail!("No servers in fleet.toml — add one with 'flow server add' first");
125    }
126    println!("  Available servers: {}", available_servers.join(", "));
127
128    let mut servers = Vec::new();
129    loop {
130        let label = if servers.is_empty() {
131            "Server:"
132        } else {
133            "Another server (empty to finish):"
134        };
135        let Some(server) = ui::prompt(label) else {
136            if servers.is_empty() {
137                ui::error("At least one server is required");
138                continue;
139            }
140            break;
141        };
142        if !config.servers.contains_key(server.as_str()) {
143            ui::error(&format!("Server '{server}' not in fleet.toml"));
144            continue;
145        }
146        if servers.contains(&server) {
147            ui::error(&format!("Server '{server}' already added"));
148            continue;
149        }
150        servers.push(server);
151    }
152
153    let mut port = None;
154    let mut domains = Vec::new();
155    let mut health_path = None;
156    let mut health_interval = None;
157    let mut raw_port_maps = Vec::new();
158
159    if ui::confirm("Add HTTP routing via Caddy? (y/N)") {
160        let Some(port_str) = ui::prompt("Container port:") else {
161            bail!("Port is required for HTTP routing");
162        };
163        port = Some(port_str.parse::<u16>().context("Invalid port number")?);
164
165        loop {
166            let label = if domains.is_empty() {
167                "Domain (e.g. app.example.com):"
168            } else {
169                "Another domain (empty to finish):"
170            };
171            let Some(domain) = ui::prompt(label) else {
172                if domains.is_empty() {
173                    ui::error("At least one domain is required");
174                    continue;
175                }
176                break;
177            };
178            domains.push(domain);
179        }
180
181        health_path = ui::prompt("Health check path (e.g. /health, empty to skip):");
182        if health_path.is_some() {
183            health_interval = ui::prompt("Health check interval (e.g. 5s, empty for default):");
184        }
185    } else if ui::confirm("Add direct port mappings? (y/N)") {
186        loop {
187            let Some(pm) =
188                ui::prompt("Port mapping (external:internal[/protocol], empty to finish):")
189            else {
190                break;
191            };
192            if let Err(e) = parse_port_map(&pm) {
193                ui::error(&format!("{e}"));
194                continue;
195            }
196            raw_port_maps.push(pm);
197        }
198    }
199
200    let deploy_strategy = ui::prompt("Deploy strategy (rolling/recreate, empty for rolling):")
201        .unwrap_or_else(|| "rolling".to_string());
202
203    add(
204        config_path,
205        &name,
206        &image,
207        &servers,
208        port,
209        &domains,
210        health_path.as_deref(),
211        health_interval.as_deref(),
212        &raw_port_maps,
213        &deploy_strategy,
214    )
215}
216
217#[allow(clippy::too_many_arguments)]
218fn add(
219    config_path: &str,
220    name: &str,
221    image: &str,
222    servers: &[String],
223    port: Option<u16>,
224    domains: &[String],
225    health_path: Option<&str>,
226    health_interval: Option<&str>,
227    raw_port_maps: &[String],
228    deploy_strategy: &str,
229) -> Result<()> {
230    let config_path = Path::new(config_path);
231    let content = std::fs::read_to_string(config_path)
232        .with_context(|| format!("Failed to read {}", config_path.display()))?;
233    let config: FleetConfig = toml::from_str(&content)
234        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
235
236    if config.apps.contains_key(name) {
237        bail!("App '{name}' already exists");
238    }
239
240    for server in servers {
241        if !config.servers.contains_key(server.as_str()) {
242            bail!("Server '{server}' does not exist in fleet.toml");
243        }
244    }
245
246    if !domains.is_empty() && !raw_port_maps.is_empty() {
247        bail!("Cannot use both --domain and --port-map (mutually exclusive)");
248    }
249
250    if !domains.is_empty() && port.is_none() {
251        bail!("--port is required when using --domain");
252    }
253
254    if domains.is_empty() && (health_path.is_some() || health_interval.is_some()) {
255        bail!("--health-path and --health-interval require --domain");
256    }
257
258    if deploy_strategy != "rolling" && deploy_strategy != "recreate" {
259        bail!("Invalid deploy strategy '{deploy_strategy}' (must be 'rolling' or 'recreate')");
260    }
261
262    let port_maps: Vec<ParsedPortMap> = raw_port_maps
263        .iter()
264        .map(|s| parse_port_map(s))
265        .collect::<Result<_>>()?;
266
267    write_app_to_config(
268        config_path,
269        name,
270        image,
271        servers,
272        port,
273        domains,
274        health_path,
275        health_interval,
276        &port_maps,
277        deploy_strategy,
278    )?;
279
280    ui::success(&format!("App '{name}' added to fleet.toml"));
281    ui::success(&format!("Run 'flow deploy {name}' to deploy"));
282    Ok(())
283}
284
285fn add_service(
286    config_path: &str,
287    app_name: &str,
288    service_name: &str,
289    image: &str,
290    volumes: &[String],
291    healthcheck: Option<&str>,
292    depends_on: Option<&str>,
293) -> Result<()> {
294    let config_path = Path::new(config_path);
295    let content = std::fs::read_to_string(config_path)
296        .with_context(|| format!("Failed to read {}", config_path.display()))?;
297    let config: FleetConfig = toml::from_str(&content)
298        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
299
300    let app = config
301        .apps
302        .get(app_name)
303        .ok_or_else(|| anyhow::anyhow!("App '{app_name}' does not exist in fleet.toml"))?;
304
305    if app.services.iter().any(|s| s.name == service_name) {
306        bail!("Service '{service_name}' already exists in app '{app_name}'");
307    }
308
309    if let Some(dep) = depends_on {
310        if !app.services.iter().any(|s| s.name == dep) {
311            bail!("depends-on service '{dep}' does not exist in app '{app_name}'");
312        }
313    }
314
315    write_service_to_config(
316        config_path,
317        app_name,
318        service_name,
319        image,
320        volumes,
321        healthcheck,
322        depends_on,
323    )?;
324
325    ui::success(&format!(
326        "Service '{service_name}' added to app '{app_name}'"
327    ));
328    Ok(())
329}
330
331fn remove_service(config_path: &str, app_name: &str, service_name: &str) -> Result<()> {
332    let config_path = Path::new(config_path);
333    let content = std::fs::read_to_string(config_path)
334        .with_context(|| format!("Failed to read {}", config_path.display()))?;
335    let config: FleetConfig = toml::from_str(&content)
336        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
337
338    let app = config
339        .apps
340        .get(app_name)
341        .ok_or_else(|| anyhow::anyhow!("App '{app_name}' does not exist in fleet.toml"))?;
342
343    if !app.services.iter().any(|s| s.name == service_name) {
344        bail!("Service '{service_name}' does not exist in app '{app_name}'");
345    }
346
347    remove_service_from_config(config_path, app_name, service_name)?;
348
349    ui::success(&format!(
350        "Service '{service_name}' removed from app '{app_name}'"
351    ));
352    Ok(())
353}
354
355#[allow(clippy::too_many_arguments)]
356pub fn write_app_to_config(
357    config_path: &Path,
358    name: &str,
359    image: &str,
360    servers: &[String],
361    port: Option<u16>,
362    domains: &[String],
363    health_path: Option<&str>,
364    health_interval: Option<&str>,
365    port_maps: &[ParsedPortMap],
366    deploy_strategy: &str,
367) -> Result<()> {
368    let content = std::fs::read_to_string(config_path)
369        .with_context(|| format!("Failed to read {}", config_path.display()))?;
370    let mut doc = content
371        .parse::<toml_edit::DocumentMut>()
372        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
373
374    let apps = doc
375        .entry("apps")
376        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
377        .as_table_mut()
378        .context("'apps' is not a table")?;
379
380    let mut app_table = toml_edit::Table::new();
381    app_table.insert("image", toml_edit::value(image));
382
383    let mut servers_array = toml_edit::Array::new();
384    for s in servers {
385        servers_array.push(s.as_str());
386    }
387    app_table.insert("servers", toml_edit::value(servers_array));
388
389    if let Some(p) = port {
390        app_table.insert("port", toml_edit::value(i64::from(p)));
391    }
392
393    if deploy_strategy != "rolling" {
394        app_table.insert("deploy_strategy", toml_edit::value(deploy_strategy));
395    }
396
397    if !domains.is_empty() {
398        let mut routing_table = toml_edit::Table::new();
399        let mut domains_array = toml_edit::Array::new();
400        for d in domains {
401            domains_array.push(d.as_str());
402        }
403        routing_table.insert("domains", toml_edit::value(domains_array));
404        if let Some(hp) = health_path {
405            routing_table.insert("health_path", toml_edit::value(hp));
406        }
407        if let Some(hi) = health_interval {
408            routing_table.insert("health_interval", toml_edit::value(hi));
409        }
410        app_table.insert("routing", toml_edit::Item::Table(routing_table));
411    }
412
413    if !port_maps.is_empty() {
414        let mut ports_array = toml_edit::ArrayOfTables::new();
415        for pm in port_maps {
416            let mut port_table = toml_edit::Table::new();
417            port_table.insert("internal", toml_edit::value(i64::from(pm.internal)));
418            port_table.insert("external", toml_edit::value(i64::from(pm.external)));
419            if pm.protocol != "tcp" {
420                port_table.insert("protocol", toml_edit::value(pm.protocol.as_str()));
421            }
422            ports_array.push(port_table);
423        }
424        app_table.insert("ports", toml_edit::Item::ArrayOfTables(ports_array));
425    }
426
427    apps.insert(name, toml_edit::Item::Table(app_table));
428
429    std::fs::write(config_path, doc.to_string())
430        .with_context(|| format!("Failed to write {}", config_path.display()))?;
431    Ok(())
432}
433
434pub fn write_service_to_config(
435    config_path: &Path,
436    app_name: &str,
437    service_name: &str,
438    image: &str,
439    volumes: &[String],
440    healthcheck: Option<&str>,
441    depends_on: Option<&str>,
442) -> Result<()> {
443    let content = std::fs::read_to_string(config_path)
444        .with_context(|| format!("Failed to read {}", config_path.display()))?;
445    let mut doc = content
446        .parse::<toml_edit::DocumentMut>()
447        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
448
449    let apps = doc
450        .get_mut("apps")
451        .and_then(|a| a.as_table_mut())
452        .context("'apps' table not found")?;
453
454    let app = apps
455        .get_mut(app_name)
456        .and_then(|a| a.as_table_mut())
457        .with_context(|| format!("App '{app_name}' not found"))?;
458
459    let services = app
460        .entry("services")
461        .or_insert_with(|| toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
462        .as_array_of_tables_mut()
463        .context("'services' is not an array of tables")?;
464
465    let mut svc_table = toml_edit::Table::new();
466    svc_table.insert("name", toml_edit::value(service_name));
467    svc_table.insert("image", toml_edit::value(image));
468    if !volumes.is_empty() {
469        let mut vol_array = toml_edit::Array::new();
470        for v in volumes {
471            vol_array.push(v.as_str());
472        }
473        svc_table.insert("volumes", toml_edit::value(vol_array));
474    }
475    if let Some(hc) = healthcheck {
476        svc_table.insert("healthcheck", toml_edit::value(hc));
477    }
478    if let Some(dep) = depends_on {
479        svc_table.insert("depends_on", toml_edit::value(dep));
480    }
481    services.push(svc_table);
482
483    std::fs::write(config_path, doc.to_string())
484        .with_context(|| format!("Failed to write {}", config_path.display()))?;
485    Ok(())
486}
487
488pub fn remove_service_from_config(
489    config_path: &Path,
490    app_name: &str,
491    service_name: &str,
492) -> Result<()> {
493    let content = std::fs::read_to_string(config_path)
494        .with_context(|| format!("Failed to read {}", config_path.display()))?;
495    let mut doc = content
496        .parse::<toml_edit::DocumentMut>()
497        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
498
499    let apps = doc
500        .get_mut("apps")
501        .and_then(|a| a.as_table_mut())
502        .context("'apps' table not found")?;
503
504    let app = apps
505        .get_mut(app_name)
506        .and_then(|a| a.as_table_mut())
507        .with_context(|| format!("App '{app_name}' not found"))?;
508
509    let services = app
510        .get_mut("services")
511        .and_then(|s| s.as_array_of_tables_mut())
512        .with_context(|| format!("App '{app_name}' has no services"))?;
513
514    let idx = (0..services.len())
515        .find(|&i| {
516            services
517                .get(i)
518                .and_then(|t| t.get("name"))
519                .and_then(|n| n.as_str())
520                == Some(service_name)
521        })
522        .with_context(|| format!("Service '{service_name}' not found in app '{app_name}'"))?;
523
524    services.remove(idx);
525
526    if services.is_empty() {
527        app.remove("services");
528    }
529
530    std::fs::write(config_path, doc.to_string())
531        .with_context(|| format!("Failed to write {}", config_path.display()))?;
532    Ok(())
533}