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};
13
14/// Arguments passed down by the CLI command.
15pub struct GenerateSystemdArgs {
16    pub output_dir: PathBuf,
17    pub service: Option<String>,
18    pub api: bool,
19}
20
21/// Generate systemd units for the configured services.
22pub 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}