use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::commands::service::load_xbp_config_with_root;
use crate::logging::{log_info, log_success, log_warn};
use crate::strategies::{ServiceConfig, XbpConfig};
pub struct GenerateSystemdArgs {
pub output_dir: PathBuf,
pub service: Option<String>,
pub api: bool,
}
pub async fn run_generate_systemd(args: GenerateSystemdArgs, _debug: bool) -> Result<(), String> {
let (project_root, config) = load_xbp_config_with_root().await?;
let services: Vec<ServiceConfig> = config.services.clone().unwrap_or_default();
let selected: Vec<ServiceConfig> = if services.is_empty() {
Vec::new()
} else if let Some(ref name) = args.service {
let matches: Vec<ServiceConfig> = services
.iter()
.filter(|s| s.name == *name)
.cloned()
.collect();
if matches.is_empty() {
return Err(format!("Service '{}' not found in configuration", name));
}
matches
} else {
services
};
if let Err(e) = fs::create_dir_all(&args.output_dir) {
return Err(format!(
"Failed to prepare systemd directory {}: {}",
args.output_dir.display(),
e
));
}
let mut rendered_units = Vec::new();
if args.api {
let unit = build_api_unit()?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote XBP API systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
}
if selected.is_empty() {
let _ = log_warn(
"generate-systemd",
"No services configured; generating a single project-level unit.",
None,
)
.await;
let unit = build_project_unit(&project_root, &config)?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
} else {
for service in selected {
let unit = build_service_unit(&project_root, &config, &service)?;
let rendered = render_unit_and_store(&unit, &args.output_dir)?;
if let Some(ref path) = rendered.written_path {
let _ = log_info(
"generate-systemd",
"Wrote systemd unit",
Some(&format!("{}", path.display())),
)
.await;
}
rendered_units.push(rendered);
}
}
if rendered_units.is_empty() {
return Err("No systemd units were generated.".to_string());
}
let written_count = rendered_units
.iter()
.filter(|unit| unit.written_path.is_some())
.count();
let failed_units: Vec<&RenderedUnit> = rendered_units
.iter()
.filter(|unit| unit.write_error.is_some())
.collect();
let script_path = if !failed_units.is_empty() {
Some(create_install_script(
&project_root,
&args.output_dir,
&failed_units,
)?)
} else {
None
};
if let Some(ref path) = script_path {
let _ = log_warn(
"generate-systemd",
"Permission denied writing some units; run the generated install script with sudo.",
Some(&format!("{}", path.display())),
)
.await;
}
let success_message = if let Some(ref path) = script_path {
let failed_count = failed_units.len();
format!(
"Wrote {} file(s) to {}; run {} to install {} unit(s) that need sudo",
written_count,
args.output_dir.display(),
path.display(),
failed_count
)
} else {
format!(
"Wrote {} file(s) to {}",
written_count,
args.output_dir.display()
)
};
let _ = log_success(
"generate-systemd",
"Generated systemd units.",
Some(&success_message),
)
.await;
Ok(())
}
struct UnitSpec {
slug: String,
description: String,
working_dir: PathBuf,
start_command: String,
environment: BTreeMap<String, String>,
}
struct RenderedUnit {
filename: String,
content: String,
written_path: Option<PathBuf>,
write_error: Option<String>,
}
enum UnitWriteError {
PermissionDenied(String),
Other(String),
}
fn build_service_unit(
project_root: &Path,
config: &XbpConfig,
service: &ServiceConfig,
) -> Result<UnitSpec, String> {
let start_command = resolve_start_command(service, config)?;
let working_dir = resolve_working_dir(project_root, service.root_directory.as_deref());
let mut environment =
merge_environment(config.environment.as_ref(), service.environment.as_ref());
environment = ensure_service_port(environment, service.port);
let project_label = project_name_or_default(config);
let slug = if let Some(ref name) = service.systemd_service_name {
name.clone()
} else {
slugify(&[project_label, &service.name])
};
let description = format!("{} service ({})", project_label, service.name);
Ok(UnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(&start_command),
environment,
})
}
fn build_project_unit(project_root: &Path, config: &XbpConfig) -> Result<UnitSpec, String> {
let start_command = config
.start_command
.as_ref()
.filter(|cmd| !cmd.trim().is_empty())
.ok_or_else(|| {
"Project start command is missing; cannot generate systemd unit.".to_string()
})?;
let working_dir = resolve_working_dir(project_root, Some(config.build_dir.as_str()));
let mut environment = merge_environment(config.environment.as_ref(), None);
if config.port > 0 {
environment.insert("PORT".to_string(), config.port.to_string());
}
let project_label = project_name_or_default(config);
let slug = if let Some(ref name) = config.systemd_service_name {
name.clone()
} else {
slugify(&[project_label])
};
let description = format!("{} project service", project_label);
Ok(UnitSpec {
slug,
description,
working_dir,
start_command: wrap_exec_command(start_command),
environment,
})
}
fn build_api_unit() -> Result<UnitSpec, String> {
let exe =
env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))?;
let working_dir = exe
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let port = env::var("PORT_XBP_API").unwrap_or_else(|_| "8080".to_string());
let mut environment = BTreeMap::new();
environment.insert("XBP_API_BIND".to_string(), "127.0.0.1".to_string());
environment.insert("PORT_XBP_API".to_string(), port);
Ok(UnitSpec {
slug: "xbp-api".to_string(),
description: "XBP API server".to_string(),
working_dir,
start_command: wrap_exec_command(&exe.display().to_string()),
environment,
})
}
fn resolve_start_command(service: &ServiceConfig, config: &XbpConfig) -> Result<String, String> {
let candidate = service
.commands
.as_ref()
.and_then(|commands| commands.start.clone())
.filter(|cmd| !cmd.trim().is_empty())
.or_else(|| {
config
.start_command
.clone()
.filter(|cmd| !cmd.trim().is_empty())
});
candidate.ok_or_else(|| {
format!(
"No start command configured for service '{}' and the project fallback is unset.",
service.name
)
})
}
fn resolve_working_dir(project_root: &Path, override_dir: Option<&str>) -> PathBuf {
if let Some(dir) = override_dir {
let candidate = PathBuf::from(dir);
if candidate.is_absolute() {
candidate
} else {
project_root.join(candidate)
}
} else {
project_root.to_path_buf()
}
}
fn merge_environment(
global: Option<&HashMap<String, String>>,
service: Option<&HashMap<String, String>>,
) -> BTreeMap<String, String> {
let mut merged = BTreeMap::new();
if let Some(globals) = global {
for (k, v) in globals {
merged.insert(k.clone(), v.clone());
}
}
if let Some(custom) = service {
for (k, v) in custom {
merged.insert(k.clone(), v.clone());
}
}
merged
}
fn ensure_service_port(mut env: BTreeMap<String, String>, port: u16) -> BTreeMap<String, String> {
if port > 0 {
env.entry("PORT".to_string())
.or_insert_with(|| port.to_string());
}
env
}
fn slugify(parts: &[&str]) -> String {
parts
.join("-")
.to_lowercase()
.chars()
.map(|ch| match ch {
'a'..='z' | '0'..='9' => ch,
_ => '-',
})
.collect::<String>()
.split('-')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn wrap_exec_command(command: &str) -> String {
let escaped = command.replace('\'', r"'\''");
format!("/bin/sh -c '{}'", escaped)
}
fn unit_file_name(slug: &str) -> String {
format!("{}.service", slug)
}
fn render_unit(spec: &UnitSpec) -> String {
let mut lines = vec![
"[Unit]".to_string(),
format!("Description={}", spec.description),
"After=network.target".to_string(),
String::new(),
"[Service]".to_string(),
"Type=simple".to_string(),
format!("WorkingDirectory={}", spec.working_dir.display()),
];
for (key, value) in &spec.environment {
lines.push(format!(
"Environment=\"{}={}\"",
key,
escape_environment(value)
));
}
lines.extend([
format!("ExecStart={}", spec.start_command),
"Restart=always".to_string(),
"RestartSec=10".to_string(),
String::new(),
"[Install]".to_string(),
"WantedBy=multi-user.target".to_string(),
]);
lines.join("\n")
}
fn escape_environment(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn render_unit_and_store(unit: &UnitSpec, directory: &Path) -> Result<RenderedUnit, String> {
let content = render_unit(unit);
let filename = unit_file_name(&unit.slug);
match write_unit_file(&filename, directory, &content) {
Ok(path) => Ok(RenderedUnit {
filename,
content,
written_path: Some(path),
write_error: None,
}),
Err(UnitWriteError::PermissionDenied(err)) => Ok(RenderedUnit {
filename,
content,
written_path: None,
write_error: Some(err),
}),
Err(UnitWriteError::Other(err)) => Err(err),
}
}
fn write_unit_file(
filename: &str,
directory: &Path,
content: &str,
) -> Result<PathBuf, UnitWriteError> {
let path = directory.join(filename);
fs::write(&path, content).map_err(|e| {
let message = format!("Failed to write {}: {}", path.display(), e);
if e.kind() == ErrorKind::PermissionDenied {
UnitWriteError::PermissionDenied(message)
} else {
UnitWriteError::Other(message)
}
})?;
Ok(path)
}
fn create_install_script(
project_root: &Path,
output_dir: &Path,
units: &[&RenderedUnit],
) -> Result<PathBuf, String> {
let script_dir = project_root.join(".xbp");
fs::create_dir_all(&script_dir).map_err(|e| {
format!(
"Failed to prepare script directory {}: {}",
script_dir.display(),
e
)
})?;
let script_path = script_dir.join("install-systemd-units.sh");
let mut script_content = String::from("#!/bin/sh\nset -euo pipefail\n\n");
for unit in units {
let target = output_dir.join(&unit.filename);
script_content.push_str(&format!(
"sudo tee {} <<'EOF'\n{}\nEOF\n\n",
target.display(),
unit.content
));
}
script_content.push_str("sudo systemctl daemon-reload\n");
fs::write(&script_path, script_content)
.map_err(|e| format!("Failed to write {}: {}", script_path.display(), e))?;
#[cfg(unix)]
{
let mut permissions = fs::metadata(&script_path)
.map_err(|e| {
format!(
"Failed to read metadata for {}: {}",
script_path.display(),
e
)
})?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).map_err(|e| {
format!(
"Failed to set permissions on {}: {}",
script_path.display(),
e
)
})?;
}
Ok(script_path)
}
fn project_name_or_default(config: &XbpConfig) -> &str {
if config.project_name.trim().is_empty() {
"xbp"
} else {
&config.project_name
}
}