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}