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, resolve_service_root};
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 resolve_service_root(project_root, override_dir)
312}
313
314fn merge_environment(
315 project_root: &Path,
316 global: Option<&HashMap<String, String>>,
317 service: Option<&HashMap<String, String>>,
318) -> BTreeMap<String, String> {
319 let mut merged = HashMap::new();
320 if let Some(globals) = global {
321 for (k, v) in globals {
322 merged.insert(k.clone(), v.clone());
323 }
324 }
325 if let Some(custom) = service {
326 for (k, v) in custom {
327 merged.insert(k.clone(), v.clone());
328 }
329 }
330 resolve_env_placeholders(project_root, &merged)
331 .into_iter()
332 .collect::<BTreeMap<_, _>>()
333}
334
335fn ensure_service_port(mut env: BTreeMap<String, String>, port: u16) -> BTreeMap<String, String> {
336 if port > 0 {
337 env.entry("PORT".to_string())
338 .or_insert_with(|| port.to_string());
339 }
340 env
341}
342
343fn merge_systemd_config(
344 project: Option<&SystemdConfig>,
345 service: Option<&SystemdConfig>,
346) -> Option<SystemdConfig> {
347 let mut combined = SystemdConfig::default();
348 let mut any = false;
349
350 if let Some(cfg) = project {
351 append_systemd_config(&mut combined, cfg);
352 any = true;
353 }
354 if let Some(cfg) = service {
355 append_systemd_config(&mut combined, cfg);
356 any = true;
357 }
358
359 if any {
360 Some(combined)
361 } else {
362 None
363 }
364}
365
366fn append_systemd_config(target: &mut SystemdConfig, source: &SystemdConfig) {
367 merge_unique(&mut target.environment_files, &source.environment_files);
368 merge_unique(&mut target.config_paths, &source.config_paths);
369 merge_unique(&mut target.read_write_paths, &source.read_write_paths);
370 merge_unique(&mut target.runtime_directories, &source.runtime_directories);
371 merge_unique(&mut target.state_directories, &source.state_directories);
372}
373
374fn merge_unique(target: &mut Vec<String>, source: &[String]) {
375 for value in source {
376 if !target.iter().any(|existing| existing == value) {
377 target.push(value.clone());
378 }
379 }
380}
381
382fn slugify(parts: &[&str]) -> String {
383 parts
384 .join("-")
385 .to_lowercase()
386 .chars()
387 .map(|ch| match ch {
388 'a'..='z' | '0'..='9' => ch,
389 _ => '-',
390 })
391 .collect::<String>()
392 .split('-')
393 .filter(|segment| !segment.is_empty())
394 .collect::<Vec<_>>()
395 .join("-")
396}
397
398fn wrap_exec_command(command: &str) -> String {
399 let escaped = command.replace('\'', r"'\''");
400 format!("/bin/sh -c '{}'", escaped)
401}
402
403fn project_name_or_default(config: &XbpConfig) -> &str {
404 if config.project_name.trim().is_empty() {
405 "xbp"
406 } else {
407 &config.project_name
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn base_config() -> XbpConfig {
416 XbpConfig {
417 project_name: "demo".to_string(),
418 version: "0.1.0".to_string(),
419 port: 3000,
420 build_dir: "/srv/demo".to_string(),
421 app_type: None,
422 build_command: None,
423 start_command: Some("node server.js".to_string()),
424 install_command: None,
425 environment: None,
426 services: None,
427 openapi: None,
428 workers: 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 oci: None,
441 kubernetes: None,
442 deploy: None,
443 target: None,
444 branch: None,
445 crate_name: None,
446 npm_script: None,
447 port_storybook: None,
448 url: None,
449 url_storybook: None,
450 linear: None,
451 github: None,
452 publish: None,
453 version_targets: Vec::new(),
454 version_domains: Vec::new(),
455 ignore_paths: Vec::new(),
456 watch_ignore_paths: Vec::new(),
457 versioning_disabled: Vec::new(),
458 release_disabled: Vec::new(),
459 discord: None,
460 }
461 }
462
463 #[test]
464 fn merge_systemd_config_preserves_order_and_dedupes() {
465 let project = SystemdConfig {
466 environment_files: vec!["/etc/default/demo".to_string()],
467 config_paths: vec!["/etc/demo/config.yaml".to_string()],
468 read_write_paths: vec!["/var/lib/demo".to_string()],
469 runtime_directories: vec!["demo".to_string()],
470 state_directories: vec!["demo".to_string()],
471 };
472 let service = SystemdConfig {
473 environment_files: vec![
474 "/etc/default/demo".to_string(),
475 "/etc/default/demo-service".to_string(),
476 ],
477 config_paths: vec!["/etc/demo/config.yaml".to_string()],
478 read_write_paths: vec!["/var/lib/demo-service".to_string()],
479 runtime_directories: vec!["demo".to_string(), "demo-worker".to_string()],
480 state_directories: vec!["demo-worker".to_string()],
481 };
482
483 let merged = merge_systemd_config(Some(&project), Some(&service)).unwrap();
484 assert_eq!(
485 merged.environment_files,
486 vec![
487 "/etc/default/demo".to_string(),
488 "/etc/default/demo-service".to_string()
489 ]
490 );
491 assert_eq!(merged.runtime_directories, vec!["demo", "demo-worker"]);
492 }
493
494 #[test]
495 fn build_project_unit_uses_configured_systemd_paths() {
496 let mut config = base_config();
497 config.systemd = Some(SystemdConfig {
498 environment_files: vec!["/etc/default/demo".to_string()],
499 config_paths: vec!["/etc/demo/config.yaml".to_string()],
500 read_write_paths: vec!["/var/lib/demo".to_string()],
501 runtime_directories: vec!["demo".to_string()],
502 state_directories: vec!["demo".to_string()],
503 });
504
505 let unit = build_project_unit(Path::new("/srv"), &config).expect("unit");
506 assert!(unit
507 .environment_files
508 .contains(&"/etc/default/demo".to_string()));
509 assert!(unit
510 .config_paths
511 .contains(&"/etc/demo/config.yaml".to_string()));
512 assert!(unit.read_write_paths.contains(&"/var/lib/demo".to_string()));
513 assert!(unit.runtime_directories.contains(&"demo".to_string()));
514 }
515}