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