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, resolve_service_root};
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    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}