Skip to main content

consortium_builder/compile/deploy/
systemd.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Renders a systemd service unit for the Linux application daemon.
18//!
19//! Remoteproc bring-up happens from inside the application itself (via sysfs
20//! writes), not a separate unit, so there is no `Requires=`/`After=` remoteproc
21//! convention to honour: the default ordering is just `After=network.target`.
22//! Callers that need richer ordering can extend the rendered text; one unit for
23//! the app daemon is sufficient for this pass.
24
25use std::path::{Path, PathBuf};
26
27/// How systemd should restart the daemon.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum SystemdRestartPolicy {
30    /// Never restart automatically.
31    No,
32    /// Restart only on non-clean exit (the default).
33    #[default]
34    OnFailure,
35    /// Always restart.
36    Always,
37}
38
39impl SystemdRestartPolicy {
40    /// The `Restart=` value for this policy.
41    fn as_str(self) -> &'static str {
42        match self {
43            Self::No => "no",
44            Self::OnFailure => "on-failure",
45            Self::Always => "always",
46        }
47    }
48}
49
50/// Parameters for the application's systemd service unit.
51#[derive(Debug, Clone)]
52pub struct SystemdUnitConfig {
53    /// Unit name without the `.service` suffix (e.g. `consortium-app`).
54    pub unit_name: String,
55    /// `ExecStart=` command (typically `{deploy_root}/app/<binary>`).
56    pub exec_start: String,
57    /// `WorkingDirectory=` (typically `{deploy_root}/app`).
58    pub working_directory: String,
59    /// Human-readable `Description=`; a default is used when absent.
60    pub description: Option<String>,
61    /// Restart policy.
62    pub restart: SystemdRestartPolicy,
63}
64
65impl Default for SystemdUnitConfig {
66    fn default() -> Self {
67        Self {
68            unit_name: "consortium-app".to_string(),
69            exec_start: String::new(),
70            working_directory: String::new(),
71            description: None,
72            restart: SystemdRestartPolicy::default(),
73        }
74    }
75}
76
77impl SystemdUnitConfig {
78    /// The file name this unit is written as (`<unit_name>.service`).
79    pub fn file_name(&self) -> String {
80        format!("{}.service", self.unit_name)
81    }
82}
83
84/// Renders the `.service` unit text for `config`.
85pub fn render_unit(config: &SystemdUnitConfig) -> String {
86    let description = config
87        .description
88        .clone()
89        .unwrap_or_else(|| "Consortium application daemon".to_string());
90
91    let mut ini = ini::Ini::new();
92    ini.with_section(Some("Unit"))
93        .set("Description", description)
94        .set("After", "network.target");
95    ini.with_section(Some("Service"))
96        .set("Type", "simple")
97        .set("ExecStart", &config.exec_start)
98        .set("WorkingDirectory", &config.working_directory)
99        .set("Restart", config.restart.as_str());
100    ini.with_section(Some("Install"))
101        .set("WantedBy", "multi-user.target");
102
103    // systemd expects `key=value` (no spaces around the separator).
104    let opts = ini::WriteOption {
105        kv_separator: "=",
106        ..Default::default()
107    };
108    let mut buf = Vec::new();
109    ini.write_to_opt(&mut buf, opts)
110        .expect("writing an in-memory INI cannot fail");
111    String::from_utf8(buf).expect("systemd unit fields are UTF-8")
112}
113
114/// Renders `config` and writes it to `dir/<unit_name>.service`, returning the
115/// written path.
116pub async fn write_unit(dir: &Path, config: &SystemdUnitConfig) -> std::io::Result<PathBuf> {
117    tokio::fs::create_dir_all(dir).await?;
118    let path = dir.join(config.file_name());
119    tokio::fs::write(&path, render_unit(config)).await?;
120    Ok(path)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn config() -> SystemdUnitConfig {
128        SystemdUnitConfig {
129            unit_name: "consortium-app".to_string(),
130            exec_start: "/opt/consortium/app/melt-pot-app".to_string(),
131            working_directory: "/opt/consortium/app".to_string(),
132            description: None,
133            restart: SystemdRestartPolicy::OnFailure,
134        }
135    }
136
137    #[test]
138    fn renders_exact_unit_text() {
139        let expected = "\
140[Unit]
141Description=Consortium application daemon
142After=network.target
143
144[Service]
145Type=simple
146ExecStart=/opt/consortium/app/melt-pot-app
147WorkingDirectory=/opt/consortium/app
148Restart=on-failure
149
150[Install]
151WantedBy=multi-user.target
152";
153        assert_eq!(render_unit(&config()), expected);
154    }
155
156    #[test]
157    fn honours_description_and_restart_overrides() {
158        let mut cfg = config();
159        cfg.description = Some("Melt-pot HMI".to_string());
160        cfg.restart = SystemdRestartPolicy::Always;
161        let out = render_unit(&cfg);
162        assert!(out.contains("Description=Melt-pot HMI\n"));
163        assert!(out.contains("Restart=always\n"));
164    }
165
166    #[tokio::test]
167    async fn write_unit_writes_named_file() {
168        let dir = std::env::temp_dir().join(format!("consortium-systemd-{}", std::process::id()));
169        let _ = std::fs::remove_dir_all(&dir);
170        let path = write_unit(&dir, &config()).await.expect("write unit");
171        assert_eq!(path, dir.join("consortium-app.service"));
172        let text = std::fs::read_to_string(&path).expect("read unit");
173        assert_eq!(text, render_unit(&config()));
174        let _ = std::fs::remove_dir_all(&dir);
175    }
176}