1use std::collections::{BTreeMap, HashMap};
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::commands::service::load_xbp_config_with_root;
7use crate::commands::systemd_unit::RenderedUnit;
8use crate::commands::systemd_unit::{
9 create_install_script, render_unit_and_store, SystemdUnitSpec,
10};
11use crate::logging::{log_info, log_success, log_warn};
12use crate::strategies::{ServiceConfig, SystemdConfig, XbpConfig};
13use crate::utils::resolve_env_placeholders;
14
15pub struct GenerateSystemdArgs {
17 pub output_dir: PathBuf,
18 pub service: Option<String>,
19 pub api: bool,
20}
21
22pub async fn run_generate_systemd(args: GenerateSystemdArgs, _debug: bool) -> Result<(), String> {
24 let (project_root, config) = load_xbp_config_with_root().await?;
25
26 let services: Vec<ServiceConfig> = config.services.clone().unwrap_or_default();
27 let selected: Vec<ServiceConfig> = if services.is_empty() {
28 Vec::new()
29 } else if let Some(ref name) = args.service {
30 let matches: Vec<ServiceConfig> = services
31 .iter()
32 .filter(|s| s.name == *name)
33 .cloned()
34 .collect();
35 if matches.is_empty() {
36 return Err(format!("Service '{}' not found in configuration", name));
37 }
38 matches
39 } else {
40 services
41 };
42
43 if let Err(e) = fs::create_dir_all(&args.output_dir) {
44 return Err(format!(
45 "Failed to prepare systemd directory {}: {}",
46 args.output_dir.display(),
47 e
48 ));
49 }
50
51 let mut rendered_units = Vec::new();
52
53 if args.api {
54 let unit = build_api_unit()?;
55 let rendered = render_unit_and_store(&unit, &args.output_dir)?;
56 if let Some(ref path) = rendered.written_path {
57 let _ = log_info(
58 "generate-systemd",
59 "Wrote XBP API systemd unit",
60 Some(&format!("{}", path.display())),
61 )
62 .await;
63 }
64 rendered_units.push(rendered);
65 }
66
67 if selected.is_empty() {
68 let _ = log_warn(
69 "generate-systemd",
70 "No services configured; generating a single project-level unit.",
71 None,
72 )
73 .await;
74 let unit = build_project_unit(&project_root, &config)?;
75 let rendered = render_unit_and_store(&unit, &args.output_dir)?;
76 if let Some(ref path) = rendered.written_path {
77 let _ = log_info(
78 "generate-systemd",
79 "Wrote systemd unit",
80 Some(&format!("{}", path.display())),
81 )
82 .await;
83 }
84 rendered_units.push(rendered);
85 } else {
86 for service in selected {
87 let unit = build_service_unit(&project_root, &config, &service)?;
88 let rendered = render_unit_and_store(&unit, &args.output_dir)?;
89 if let Some(ref path) = rendered.written_path {
90 let _ = log_info(
91 "generate-systemd",
92 "Wrote systemd unit",
93 Some(&format!("{}", path.display())),
94 )
95 .await;
96 }
97 rendered_units.push(rendered);
98 }
99 }
100
101 if rendered_units.is_empty() {
102 return Err("No systemd units were generated.".to_string());
103 }
104
105 let written_count = rendered_units
106 .iter()
107 .filter(|unit| unit.written_path.is_some())
108 .count();
109 let failed_units: Vec<RenderedUnit> = rendered_units
110 .iter()
111 .filter(|unit| unit.write_error.is_some())
112 .cloned()
113 .collect();
114
115 let script_path = if !failed_units.is_empty() {
116 Some(create_install_script(
117 &project_root,
118 &args.output_dir,
119 &failed_units,
120 )?)
121 } else {
122 None
123 };
124
125 if let Some(ref path) = script_path {
126 let _ = log_warn(
127 "generate-systemd",
128 "Permission denied writing some units; run the generated install script with sudo.",
129 Some(&format!("{}", path.display())),
130 )
131 .await;
132 }
133
134 let success_message = if let Some(ref path) = script_path {
135 let failed_count = failed_units.len();
136 format!(
137 "Wrote {} file(s) to {}; run {} to install {} unit(s) that need sudo",
138 written_count,
139 args.output_dir.display(),
140 path.display(),
141 failed_count
142 )
143 } else {
144 format!(
145 "Wrote {} file(s) to {}",
146 written_count,
147 args.output_dir.display()
148 )
149 };
150
151 let _ = log_success(
152 "generate-systemd",
153 "Generated systemd units.",
154 Some(&success_message),
155 )
156 .await;
157
158 Ok(())
159}
160
161fn build_service_unit(
162 project_root: &Path,
163 config: &XbpConfig,
164 service: &ServiceConfig,
165) -> Result<SystemdUnitSpec, String> {
166 let start_command = resolve_start_command(service, config)?;
167 let working_dir = resolve_working_dir(project_root, service.root_directory.as_deref());
168 let mut environment = merge_environment(
169 project_root,
170 config.environment.as_ref(),
171 service.environment.as_ref(),
172 );
173 environment = ensure_service_port(environment, service.port);
174
175 let systemd = merge_systemd_config(config.systemd.as_ref(), service.systemd.as_ref());
176 let project_label = project_name_or_default(config);
177
178 let slug = if let Some(ref name) = service.systemd_service_name {
179 name.clone()
180 } else {
181 slugify(&[project_label, &service.name])
182 };
183
184 let description = format!("{} service ({})", project_label, service.name);
185
186 Ok(SystemdUnitSpec {
187 slug,
188 description,
189 working_dir,
190 start_command: wrap_exec_command(&start_command),
191 unit_after: vec!["network.target".to_string()],
192 unit_wants: Vec::new(),
193 environment,
194 environment_files: systemd
195 .as_ref()
196 .map(|cfg| cfg.environment_files.clone())
197 .unwrap_or_default(),
198 config_paths: systemd
199 .as_ref()
200 .map(|cfg| cfg.config_paths.clone())
201 .unwrap_or_default(),
202 read_write_paths: systemd
203 .as_ref()
204 .map(|cfg| cfg.read_write_paths.clone())
205 .unwrap_or_default(),
206 runtime_directories: systemd
207 .as_ref()
208 .map(|cfg| cfg.runtime_directories.clone())
209 .unwrap_or_default(),
210 state_directories: systemd
211 .as_ref()
212 .map(|cfg| cfg.state_directories.clone())
213 .unwrap_or_default(),
214 service_directives: Vec::new(),
215 })
216}
217
218fn build_project_unit(project_root: &Path, config: &XbpConfig) -> Result<SystemdUnitSpec, String> {
219 let start_command = config
220 .start_command
221 .as_ref()
222 .filter(|cmd| !cmd.trim().is_empty())
223 .ok_or_else(|| {
224 "Project start command is missing; cannot generate systemd unit.".to_string()
225 })?;
226
227 let working_dir = resolve_working_dir(project_root, Some(config.build_dir.as_str()));
228 let mut environment = merge_environment(project_root, config.environment.as_ref(), None);
229 if config.port > 0 {
230 environment.insert("PORT".to_string(), config.port.to_string());
231 }
232
233 let systemd = config.systemd.as_ref();
234 let project_label = project_name_or_default(config);
235
236 let slug = if let Some(ref name) = config.systemd_service_name {
237 name.clone()
238 } else {
239 slugify(&[project_label])
240 };
241
242 let description = format!("{} project service", project_label);
243
244 Ok(SystemdUnitSpec {
245 slug,
246 description,
247 working_dir,
248 start_command: wrap_exec_command(start_command),
249 unit_after: vec!["network.target".to_string()],
250 unit_wants: Vec::new(),
251 environment,
252 environment_files: systemd
253 .map(|cfg| cfg.environment_files.clone())
254 .unwrap_or_default(),
255 config_paths: systemd
256 .map(|cfg| cfg.config_paths.clone())
257 .unwrap_or_default(),
258 read_write_paths: systemd
259 .map(|cfg| cfg.read_write_paths.clone())
260 .unwrap_or_default(),
261 runtime_directories: systemd
262 .map(|cfg| cfg.runtime_directories.clone())
263 .unwrap_or_default(),
264 state_directories: systemd
265 .map(|cfg| cfg.state_directories.clone())
266 .unwrap_or_default(),
267 service_directives: Vec::new(),
268 })
269}
270
271fn build_api_unit() -> Result<SystemdUnitSpec, String> {
272 let exe =
273 env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?;
274 let working_dir = exe
275 .parent()
276 .map(|p| p.to_path_buf())
277 .unwrap_or_else(|| PathBuf::from("."));
278
279 let port = env::var("PORT_XBP_API").unwrap_or_else(|_| "8080".to_string());
280 let port = port.parse::<u16>().unwrap_or(8080);
281
282 Ok(crate::commands::build_api_unit_spec(
283 &working_dir,
284 &exe,
285 port,
286 ))
287}
288
289fn resolve_start_command(service: &ServiceConfig, config: &XbpConfig) -> Result<String, String> {
290 let candidate = service
291 .commands
292 .as_ref()
293 .and_then(|commands| commands.start.clone())
294 .filter(|cmd| !cmd.trim().is_empty())
295 .or_else(|| {
296 config
297 .start_command
298 .clone()
299 .filter(|cmd| !cmd.trim().is_empty())
300 });
301
302 candidate.ok_or_else(|| {
303 format!(
304 "No start command configured for service '{}' and the project fallback is unset.",
305 service.name
306 )
307 })
308}
309
310fn resolve_working_dir(project_root: &Path, override_dir: Option<&str>) -> PathBuf {
311 if let Some(dir) = override_dir {
312 let candidate = PathBuf::from(dir);
313 if candidate.is_absolute() {
314 candidate
315 } else {
316 project_root.join(candidate)
317 }
318 } else {
319 project_root.to_path_buf()
320 }
321}
322
323fn merge_environment(
324 project_root: &Path,
325 global: Option<&HashMap<String, String>>,
326 service: Option<&HashMap<String, String>>,
327) -> BTreeMap<String, String> {
328 let mut merged = HashMap::new();
329 if let Some(globals) = global {
330 for (k, v) in globals {
331 merged.insert(k.clone(), v.clone());
332 }
333 }
334 if let Some(custom) = service {
335 for (k, v) in custom {
336 merged.insert(k.clone(), v.clone());
337 }
338 }
339 resolve_env_placeholders(project_root, &merged)
340 .into_iter()
341 .collect::<BTreeMap<_, _>>()
342}
343
344fn ensure_service_port(mut env: BTreeMap<String, String>, port: u16) -> BTreeMap<String, String> {
345 if port > 0 {
346 env.entry("PORT".to_string())
347 .or_insert_with(|| port.to_string());
348 }
349 env
350}
351
352fn merge_systemd_config(
353 project: Option<&SystemdConfig>,
354 service: Option<&SystemdConfig>,
355) -> Option<SystemdConfig> {
356 let mut combined = SystemdConfig::default();
357 let mut any = false;
358
359 if let Some(cfg) = project {
360 append_systemd_config(&mut combined, cfg);
361 any = true;
362 }
363 if let Some(cfg) = service {
364 append_systemd_config(&mut combined, cfg);
365 any = true;
366 }
367
368 if any {
369 Some(combined)
370 } else {
371 None
372 }
373}
374
375fn append_systemd_config(target: &mut SystemdConfig, source: &SystemdConfig) {
376 merge_unique(&mut target.environment_files, &source.environment_files);
377 merge_unique(&mut target.config_paths, &source.config_paths);
378 merge_unique(&mut target.read_write_paths, &source.read_write_paths);
379 merge_unique(&mut target.runtime_directories, &source.runtime_directories);
380 merge_unique(&mut target.state_directories, &source.state_directories);
381}
382
383fn merge_unique(target: &mut Vec<String>, source: &[String]) {
384 for value in source {
385 if !target.iter().any(|existing| existing == value) {
386 target.push(value.clone());
387 }
388 }
389}
390
391fn slugify(parts: &[&str]) -> String {
392 parts
393 .join("-")
394 .to_lowercase()
395 .chars()
396 .map(|ch| match ch {
397 'a'..='z' | '0'..='9' => ch,
398 _ => '-',
399 })
400 .collect::<String>()
401 .split('-')
402 .filter(|segment| !segment.is_empty())
403 .collect::<Vec<_>>()
404 .join("-")
405}
406
407fn wrap_exec_command(command: &str) -> String {
408 let escaped = command.replace('\'', r"'\''");
409 format!("/bin/sh -c '{}'", escaped)
410}
411
412fn project_name_or_default(config: &XbpConfig) -> &str {
413 if config.project_name.trim().is_empty() {
414 "xbp"
415 } else {
416 &config.project_name
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn base_config() -> XbpConfig {
425 XbpConfig {
426 project_name: "demo".to_string(),
427 version: "0.1.0".to_string(),
428 port: 3000,
429 build_dir: "/srv/demo".to_string(),
430 app_type: None,
431 build_command: None,
432 start_command: Some("node server.js".to_string()),
433 install_command: None,
434 environment: None,
435 services: None,
436 systemd_service_name: None,
437 systemd: None,
438 kafka_brokers: None,
439 kafka_topic: None,
440 kafka_public_url: None,
441 log_files: None,
442 monitor_url: None,
443 monitor_method: None,
444 monitor_expected_code: None,
445 monitor_interval: None,
446 database: None,
447 target: None,
448 branch: None,
449 crate_name: None,
450 npm_script: None,
451 port_storybook: None,
452 url: None,
453 url_storybook: None,
454 linear: None,
455 github: None,
456 publish: None,
457 version_targets: Vec::new(),
458 }
459 }
460
461 #[test]
462 fn merge_systemd_config_preserves_order_and_dedupes() {
463 let project = SystemdConfig {
464 environment_files: vec!["/etc/default/demo".to_string()],
465 config_paths: vec!["/etc/demo/config.yaml".to_string()],
466 read_write_paths: vec!["/var/lib/demo".to_string()],
467 runtime_directories: vec!["demo".to_string()],
468 state_directories: vec!["demo".to_string()],
469 };
470 let service = SystemdConfig {
471 environment_files: vec![
472 "/etc/default/demo".to_string(),
473 "/etc/default/demo-service".to_string(),
474 ],
475 config_paths: vec!["/etc/demo/config.yaml".to_string()],
476 read_write_paths: vec!["/var/lib/demo-service".to_string()],
477 runtime_directories: vec!["demo".to_string(), "demo-worker".to_string()],
478 state_directories: vec!["demo-worker".to_string()],
479 };
480
481 let merged = merge_systemd_config(Some(&project), Some(&service)).unwrap();
482 assert_eq!(
483 merged.environment_files,
484 vec![
485 "/etc/default/demo".to_string(),
486 "/etc/default/demo-service".to_string()
487 ]
488 );
489 assert_eq!(merged.runtime_directories, vec!["demo", "demo-worker"]);
490 }
491
492 #[test]
493 fn build_project_unit_uses_configured_systemd_paths() {
494 let mut config = base_config();
495 config.systemd = Some(SystemdConfig {
496 environment_files: vec!["/etc/default/demo".to_string()],
497 config_paths: vec!["/etc/demo/config.yaml".to_string()],
498 read_write_paths: vec!["/var/lib/demo".to_string()],
499 runtime_directories: vec!["demo".to_string()],
500 state_directories: vec!["demo".to_string()],
501 });
502
503 let unit = build_project_unit(Path::new("/srv"), &config).expect("unit");
504 assert!(unit
505 .environment_files
506 .contains(&"/etc/default/demo".to_string()));
507 assert!(unit
508 .config_paths
509 .contains(&"/etc/demo/config.yaml".to_string()));
510 assert!(unit.read_write_paths.contains(&"/var/lib/demo".to_string()));
511 assert!(unit.runtime_directories.contains(&"demo".to_string()));
512 }
513}