Skip to main content

xbp_cli/commands/
generate_systemd.rs

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
15/// Arguments passed down by the CLI command.
16pub struct GenerateSystemdArgs {
17    pub output_dir: PathBuf,
18    pub service: Option<String>,
19    pub api: bool,
20}
21
22/// Generate systemd units for the configured services.
23pub 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        }
457    }
458
459    #[test]
460    fn merge_systemd_config_preserves_order_and_dedupes() {
461        let project = SystemdConfig {
462            environment_files: vec!["/etc/default/demo".to_string()],
463            config_paths: vec!["/etc/demo/config.yaml".to_string()],
464            read_write_paths: vec!["/var/lib/demo".to_string()],
465            runtime_directories: vec!["demo".to_string()],
466            state_directories: vec!["demo".to_string()],
467        };
468        let service = SystemdConfig {
469            environment_files: vec![
470                "/etc/default/demo".to_string(),
471                "/etc/default/demo-service".to_string(),
472            ],
473            config_paths: vec!["/etc/demo/config.yaml".to_string()],
474            read_write_paths: vec!["/var/lib/demo-service".to_string()],
475            runtime_directories: vec!["demo".to_string(), "demo-worker".to_string()],
476            state_directories: vec!["demo-worker".to_string()],
477        };
478
479        let merged = merge_systemd_config(Some(&project), Some(&service)).unwrap();
480        assert_eq!(
481            merged.environment_files,
482            vec![
483                "/etc/default/demo".to_string(),
484                "/etc/default/demo-service".to_string()
485            ]
486        );
487        assert_eq!(merged.runtime_directories, vec!["demo", "demo-worker"]);
488    }
489
490    #[test]
491    fn build_project_unit_uses_configured_systemd_paths() {
492        let mut config = base_config();
493        config.systemd = Some(SystemdConfig {
494            environment_files: vec!["/etc/default/demo".to_string()],
495            config_paths: vec!["/etc/demo/config.yaml".to_string()],
496            read_write_paths: vec!["/var/lib/demo".to_string()],
497            runtime_directories: vec!["demo".to_string()],
498            state_directories: vec!["demo".to_string()],
499        });
500
501        let unit = build_project_unit(Path::new("/srv"), &config).expect("unit");
502        assert!(unit
503            .environment_files
504            .contains(&"/etc/default/demo".to_string()));
505        assert!(unit
506            .config_paths
507            .contains(&"/etc/demo/config.yaml".to_string()));
508        assert!(unit.read_write_paths.contains(&"/var/lib/demo".to_string()));
509        assert!(unit.runtime_directories.contains(&"demo".to_string()));
510    }
511}