1use crate::error::RegentError;
30use crate::hosts::handlers::shell_quote;
31use crate::hosts::managed_host::InternalApiCallOutcome;
32use crate::state::attribute::HostHandler;
33use crate::state::attribute::Privilege;
34use serde::{Deserialize, Serialize};
35use std::fmt;
36use std::str::FromStr;
37
38use super::container_spec::{ContainerName, invalid, quoted_command};
39
40const TIMEOUT_STOP_SEC: u32 = 60;
42const RESTART_SEC: u32 = 5;
44
45const HEADER: &str = "# Managed by regent-sdk: manual changes will be overwritten";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "PascalCase")]
49pub enum ServiceEngine {
50 Apptainer,
51 Enroot,
52}
53
54impl ServiceEngine {
55 pub fn as_str(&self) -> &'static str {
56 match self {
57 ServiceEngine::Apptainer => "apptainer",
58 ServiceEngine::Enroot => "enroot",
59 }
60 }
61
62 fn display_name(&self) -> &'static str {
63 match self {
64 ServiceEngine::Apptainer => "Apptainer",
65 ServiceEngine::Enroot => "Enroot",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "PascalCase")]
72pub enum UnitScope {
73 System,
75 User,
77}
78
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "PascalCase")]
82pub enum UnitRestartPolicy {
83 No,
84 #[default]
85 OnFailure,
86 Always,
87}
88
89impl UnitRestartPolicy {
90 fn as_directive(&self) -> &'static str {
91 match self {
92 UnitRestartPolicy::No => "no",
93 UnitRestartPolicy::OnFailure => "on-failure",
94 UnitRestartPolicy::Always => "always",
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "PascalCase")]
101pub struct ServiceUnitId {
102 engine: ServiceEngine,
103 name: ContainerName,
104 scope: UnitScope,
105}
106
107const USER_CONFIG_ROOT: &str = "\"${XDG_CONFIG_HOME:-$HOME/.config}\"";
109
110impl ServiceUnitId {
111 pub fn new(engine: ServiceEngine, name: ContainerName, scope: UnitScope) -> Self {
112 Self {
113 engine,
114 name,
115 scope,
116 }
117 }
118
119 pub fn name(&self) -> &ContainerName {
120 &self.name
121 }
122
123 pub fn scope(&self) -> UnitScope {
124 self.scope
125 }
126
127 pub fn unit_name(&self) -> String {
129 format!("regent-{}-{}.service", self.engine.as_str(), self.name)
130 }
131
132 fn config_root(&self) -> &'static str {
133 match self.scope {
134 UnitScope::System => "/etc",
135 UnitScope::User => USER_CONFIG_ROOT,
136 }
137 }
138
139 pub(super) fn unit_path(&self) -> String {
141 let directory = match self.scope {
142 UnitScope::System => "/etc/systemd/system".to_string(),
143 UnitScope::User => format!("{}/systemd/user", USER_CONFIG_ROOT),
144 };
145 format!("{}/{}", directory, shell_quote(&self.unit_name()))
146 }
147
148 pub(super) fn regent_dir(&self) -> String {
150 format!("{}/regent-sdk/{}", self.config_root(), self.engine.as_str())
151 }
152
153 pub(super) fn regent_file(&self, extension: &str) -> String {
155 format!(
156 "{}/{}",
157 self.regent_dir(),
158 shell_quote(&format!("{}.{}", self.name, extension))
159 )
160 }
161
162 pub(super) fn env_path(&self) -> String {
164 self.regent_file("env")
165 }
166
167 fn env_path_in_unit(&self) -> String {
169 let root = match self.scope {
170 UnitScope::System => "/etc",
171 UnitScope::User => "%E",
172 };
173 format!(
174 "{}/regent-sdk/{}/{}.env",
175 root,
176 self.engine.as_str(),
177 self.name
178 )
179 }
180
181 pub(super) fn systemctl(&self, args: &[&str]) -> String {
183 match self.scope {
184 UnitScope::System => {
185 let mut command = vec!["systemctl"];
186 command.extend_from_slice(args);
187 quoted_command(&command)
188 }
189 UnitScope::User => {
190 let mut command = vec!["systemctl", "--user"];
191 command.extend_from_slice(args);
192 format!(
195 "XDG_RUNTIME_DIR=\"${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}\" {}",
196 quoted_command(&command)
197 )
198 }
199 }
200 }
201}
202
203impl fmt::Display for ServiceUnitId {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 match self.scope {
206 UnitScope::System => write!(f, "{}", self.unit_name()),
207 UnitScope::User => write!(f, "{} (user unit)", self.unit_name()),
208 }
209 }
210}
211
212pub(super) struct UnitSpec {
213 pub(super) exec_start: Vec<String>,
215 pub(super) environment: Vec<(String, String)>,
217 pub(super) restart: UnitRestartPolicy,
218}
219
220pub(super) fn render_unit(id: &ServiceUnitId, spec: &UnitSpec) -> String {
222 let mut unit = String::new();
223 let mut line = |text: String| {
224 unit.push_str(&text);
225 unit.push('\n');
226 };
227
228 line(HEADER.to_string());
229 line("[Unit]".to_string());
230 line(format!(
231 "Description={} container {} (regent-sdk)",
232 id.engine.display_name(),
233 id.name
234 ));
235 if id.scope == UnitScope::System {
237 line("Wants=network-online.target".to_string());
238 line("After=network-online.target".to_string());
239 }
240 line(String::new());
241 line("[Service]".to_string());
242 line("Type=simple".to_string());
243 for (key, value) in &spec.environment {
244 line(format!(
245 "Environment={}",
246 systemd_quote(&format!("{}={}", key, value))
247 ));
248 }
249 line(format!("EnvironmentFile={}", id.env_path_in_unit()));
250 line(format!(
251 "ExecStart={}",
252 spec.exec_start
253 .iter()
254 .map(|arg| systemd_quote(arg))
255 .collect::<Vec<String>>()
256 .join(" ")
257 ));
258 line(format!("Restart={}", spec.restart.as_directive()));
259 line(format!("RestartSec={}", RESTART_SEC));
260 line(format!("TimeoutStopSec={}", TIMEOUT_STOP_SEC));
261 line(String::new());
262 line("[Install]".to_string());
263 line(format!(
264 "WantedBy={}",
265 match id.scope {
266 UnitScope::System => "multi-user.target",
267 UnitScope::User => "default.target",
268 }
269 ));
270
271 unit
272}
273
274pub(super) fn systemd_quote(arg: &str) -> String {
279 let escaped = arg.replace('%', "%%").replace('$', "$$");
280 let is_safe = !arg.is_empty()
281 && arg
282 .chars()
283 .all(|c| c.is_ascii_alphanumeric() || "/._-+=:,@".contains(c));
284 if is_safe {
285 return escaped;
286 }
287 let mut quoted = String::with_capacity(escaped.len() + 2);
288 quoted.push('"');
289 for c in escaped.chars() {
290 match c {
291 '\\' => quoted.push_str("\\\\"),
292 '"' => quoted.push_str("\\\""),
293 '\n' => quoted.push_str("\\n"),
294 '\t' => quoted.push_str("\\t"),
295 c => quoted.push(c),
296 }
297 }
298 quoted.push('"');
299 quoted
300}
301
302pub(super) fn render_env_file(entries: &[(String, String)]) -> String {
305 let mut content = format!("{}\n", HEADER);
306 for (key, value) in entries {
307 content.push_str(&format!("{}={}\n", key, env_file_quote(value)));
308 }
309 content
310}
311
312fn env_file_quote(value: &str) -> String {
313 let mut quoted = String::with_capacity(value.len() + 2);
314 quoted.push('"');
315 for c in value.chars() {
316 if matches!(c, '\\' | '"' | '$' | '`') {
317 quoted.push('\\');
318 }
319 quoted.push(c);
320 }
321 quoted.push('"');
322 quoted
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
330#[serde(try_from = "String", into = "String")]
331pub struct ServiceEnvVarName(String);
332
333impl TryFrom<String> for ServiceEnvVarName {
334 type Error = RegentError;
335
336 fn try_from(name: String) -> Result<Self, Self::Error> {
337 let is_valid = name
338 .chars()
339 .next()
340 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
341 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
342 if is_valid {
343 Ok(Self(name))
344 } else {
345 Err(invalid(
346 "environment variable name",
347 &name,
348 "allowed characters are [A-Za-z_][A-Za-z0-9_]*",
349 ))
350 }
351 }
352}
353
354impl FromStr for ServiceEnvVarName {
355 type Err = RegentError;
356
357 fn from_str(name: &str) -> Result<Self, Self::Err> {
358 Self::try_from(name.to_string())
359 }
360}
361
362impl From<ServiceEnvVarName> for String {
363 fn from(name: ServiceEnvVarName) -> String {
364 name.0
365 }
366}
367
368impl fmt::Display for ServiceEnvVarName {
369 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
370 write!(f, "{}", self.0)
371 }
372}
373
374impl ServiceEnvVarName {
375 pub fn as_str(&self) -> &str {
376 &self.0
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(try_from = "String", into = "String")]
383pub struct AbsolutePath(String);
384
385impl TryFrom<String> for AbsolutePath {
386 type Error = RegentError;
387
388 fn try_from(path: String) -> Result<Self, Self::Error> {
389 if !path.starts_with('/') {
390 Err(invalid("path", &path, "must be absolute"))
391 } else if path.len() > 1 && path.ends_with('/') {
392 Err(invalid("path", &path, "must not end with '/'"))
393 } else if path.chars().any(char::is_control) {
394 Err(invalid(
395 "path",
396 &path,
397 "must not contain control characters",
398 ))
399 } else {
400 Ok(Self(path))
401 }
402 }
403}
404
405impl FromStr for AbsolutePath {
406 type Err = RegentError;
407
408 fn from_str(path: &str) -> Result<Self, Self::Err> {
409 Self::try_from(path.to_string())
410 }
411}
412
413impl From<AbsolutePath> for String {
414 fn from(path: AbsolutePath) -> String {
415 path.0
416 }
417}
418
419impl fmt::Display for AbsolutePath {
420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421 write!(f, "{}", self.0)
422 }
423}
424
425impl AbsolutePath {
426 pub fn as_str(&self) -> &str {
427 &self.0
428 }
429
430 pub(super) fn parent(&self) -> &str {
432 match self.0.rfind('/') {
433 Some(0) | None => "/",
434 Some(index) => &self.0[..index],
435 }
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(try_from = "String", into = "String")]
446pub struct BindMount {
447 source: String,
448 target: String,
449 read_only: bool,
450}
451
452impl TryFrom<String> for BindMount {
453 type Error = RegentError;
454
455 fn try_from(spec: String) -> Result<Self, Self::Error> {
456 let error = |details: &str| invalid("bind mount", &spec, details);
457 let parts: Vec<&str> = spec.split(':').collect();
458 let (source, target, read_only) = match parts.as_slice() {
459 [source] => (*source, *source, false),
460 [source, target] => (*source, *target, false),
461 [source, target, "ro"] => (*source, *target, true),
462 [source, target, "rw"] => (*source, *target, false),
463 [_, _, _] => return Err(error("options must be ro or rw")),
464 _ => return Err(error("expected source[:target[:ro|rw]]")),
465 };
466 for path in [source, target] {
467 if !path.starts_with('/') {
468 return Err(error("paths must be absolute"));
469 }
470 if path.chars().any(|c| c == ',' || c.is_whitespace()) {
471 return Err(error("paths can't contain ',' or whitespace"));
472 }
473 }
474 Ok(Self {
475 source: source.to_string(),
476 target: target.to_string(),
477 read_only,
478 })
479 }
480}
481
482impl FromStr for BindMount {
483 type Err = RegentError;
484
485 fn from_str(spec: &str) -> Result<Self, Self::Err> {
486 Self::try_from(spec.to_string())
487 }
488}
489
490impl From<BindMount> for String {
491 fn from(mount: BindMount) -> String {
492 mount.to_string()
493 }
494}
495
496impl fmt::Display for BindMount {
497 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498 write!(
499 f,
500 "{}:{}:{}",
501 self.source,
502 self.target,
503 if self.read_only { "ro" } else { "rw" }
504 )
505 }
506}
507
508impl BindMount {
509 pub(super) fn apptainer_arg(&self) -> String {
511 self.to_string()
512 }
513
514 pub(super) fn enroot_arg(&self) -> String {
516 format!(
517 "{}:{}:none:x-create=auto,rbind{}",
518 self.source,
519 self.target,
520 if self.read_only { ",ro" } else { "" }
521 )
522 }
523}
524
525#[derive(Debug, Clone, Default, PartialEq)]
527pub(super) struct UnitStatus {
528 pub(super) load_state: String,
529 pub(super) active_state: String,
530 pub(super) unit_file_state: String,
531 pub(super) need_daemon_reload: bool,
532}
533
534impl UnitStatus {
535 pub(super) fn is_active(&self) -> bool {
537 matches!(
538 self.active_state.as_str(),
539 "active" | "activating" | "reloading"
540 )
541 }
542
543 pub(super) fn is_enabled(&self) -> bool {
544 self.unit_file_state == "enabled"
545 }
546
547 pub(super) fn parse(output: &str) -> Self {
549 let mut status = UnitStatus::default();
550 for line in output.lines() {
551 match line.trim().split_once('=') {
552 Some(("LoadState", value)) => status.load_state = value.to_string(),
553 Some(("ActiveState", value)) => status.active_state = value.to_string(),
554 Some(("UnitFileState", value)) => status.unit_file_state = value.to_string(),
555 Some(("NeedDaemonReload", value)) => status.need_daemon_reload = value == "yes",
556 _ => {}
557 }
558 }
559 status
560 }
561}
562
563#[derive(Debug, Clone, Default, PartialEq)]
564pub(super) struct ObservedService {
565 pub(super) unit_file: Option<String>,
566 pub(super) env_file: Option<String>,
567 pub(super) status: UnitStatus,
568 pub(super) linger: Option<bool>,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "PascalCase")]
574pub enum ServiceUnitAction {
575 EnableLinger,
577 DaemonReload,
578 Enable,
579 Disable,
580 Start,
581 Stop,
582 Restart,
583 Remove,
584}
585
586impl ServiceUnitAction {
587 pub(super) fn command(&self, id: &ServiceUnitId) -> String {
588 let unit_name = id.unit_name();
589 let unit = unit_name.as_str();
590 match self {
591 ServiceUnitAction::EnableLinger => "loginctl enable-linger \"$(id -un)\"".to_string(),
592 ServiceUnitAction::DaemonReload => id.systemctl(&["daemon-reload"]),
593 ServiceUnitAction::Enable => id.systemctl(&["enable", unit]),
594 ServiceUnitAction::Disable => id.systemctl(&["disable", unit]),
595 ServiceUnitAction::Start => id.systemctl(&["start", unit]),
596 ServiceUnitAction::Stop => id.systemctl(&["stop", unit]),
597 ServiceUnitAction::Restart => id.systemctl(&["restart", unit]),
598 ServiceUnitAction::Remove => format!(
599 "{{ {} >/dev/null 2>&1; {} >/dev/null 2>&1; rm -f {} {} && {}; }}",
601 id.systemctl(&["stop", unit]),
602 id.systemctl(&["disable", unit]),
603 id.unit_path(),
604 id.env_path(),
605 id.systemctl(&["daemon-reload"]),
606 ),
607 }
608 }
609
610 pub(super) fn display(&self, id: &ServiceUnitId) -> String {
611 match self {
612 ServiceUnitAction::EnableLinger => {
613 format!("Enable lingering of the user running {}", id)
614 }
615 ServiceUnitAction::DaemonReload => format!("Reload systemd units for {}", id),
616 ServiceUnitAction::Enable => format!("Enable {}", id),
617 ServiceUnitAction::Disable => format!("Disable {}", id),
618 ServiceUnitAction::Start => format!("Start {}", id),
619 ServiceUnitAction::Stop => format!("Stop {}", id),
620 ServiceUnitAction::Restart => format!("Restart {}", id),
621 ServiceUnitAction::Remove => {
622 format!("Remove {} and its environment file", id)
623 }
624 }
625 }
626}
627
628#[derive(Debug, Clone, PartialEq)]
629pub(super) enum ServiceStep {
630 WriteFiles(Vec<String>),
631 Action(ServiceUnitAction),
632}
633
634pub(super) fn plan_service(
639 should_run: bool,
640 expected_unit: &str,
641 expected_env: &str,
642 observed: &ObservedService,
643 container_changed: bool,
644) -> Vec<ServiceStep> {
645 let mut steps = Vec::new();
646
647 if should_run && observed.linger == Some(false) {
648 steps.push(ServiceStep::Action(ServiceUnitAction::EnableLinger));
649 }
650
651 let mut reasons = Vec::new();
652 for (file, current, expected) in [
653 ("unit file", &observed.unit_file, expected_unit),
654 ("environment file", &observed.env_file, expected_env),
655 ] {
656 match current {
657 None => reasons.push(format!("{} is missing", file)),
658 Some(current) if current != expected => reasons.push(format!("{} differs", file)),
660 Some(_) => {}
661 }
662 }
663 let files_changed = !reasons.is_empty();
664 if files_changed {
665 steps.push(ServiceStep::WriteFiles(reasons));
666 } else if observed.status.need_daemon_reload {
667 steps.push(ServiceStep::Action(ServiceUnitAction::DaemonReload));
668 }
669
670 let is_active = observed.status.is_active();
671 let is_enabled = observed.status.is_enabled();
672 if should_run {
673 if !is_enabled {
674 steps.push(ServiceStep::Action(ServiceUnitAction::Enable));
675 }
676 if !is_active {
677 steps.push(ServiceStep::Action(ServiceUnitAction::Start));
678 } else if files_changed || container_changed {
679 steps.push(ServiceStep::Action(ServiceUnitAction::Restart));
680 }
681 } else {
682 if is_enabled {
683 steps.push(ServiceStep::Action(ServiceUnitAction::Disable));
684 }
685 if is_active {
686 steps.push(ServiceStep::Action(ServiceUnitAction::Stop));
687 }
688 }
689
690 steps
691}
692
693pub(super) fn needs_removal(observed: &ObservedService) -> bool {
695 observed.unit_file.is_some() || observed.env_file.is_some()
696}
697
698pub(super) async fn detect_scope<Handler: HostHandler>(
699 host_handler: &mut Handler,
700 privilege: &Privilege,
701) -> Result<(UnitScope, String), RegentError> {
702 let result = host_handler
703 .run_command(
704 "printf 'regent-uid=%s\\nregent-home=%s\\n' \"$(id -u)\" \"$HOME\"",
705 privilege,
706 )
707 .await
708 .map_err(|e| {
709 RegentError::FailedDryRunEvaluation(format!("Unable to get the user id: {:?}", e))
710 })?;
711 parse_scope_probe(&result.stdout).ok_or_else(|| {
712 RegentError::FailedDryRunEvaluation(format!("Unable to get the user id: {:?}", result))
713 })
714}
715
716fn parse_scope_probe(output: &str) -> Option<(UnitScope, String)> {
717 let mut uid = None;
718 let mut home = String::new();
719 for line in output.lines() {
720 if let Some(value) = line.strip_prefix("regent-uid=") {
721 uid = value.trim().parse::<u32>().ok();
722 } else if let Some(value) = line.strip_prefix("regent-home=") {
723 home = value.to_string();
724 }
725 }
726 let scope = match uid? {
727 0 => UnitScope::System,
728 _ => UnitScope::User,
729 };
730 Some((scope, home))
731}
732
733pub(super) async fn is_linger_enabled<Handler: HostHandler>(
735 host_handler: &mut Handler,
736 privilege: &Privilege,
737) -> Result<bool, RegentError> {
738 let result = host_handler
739 .run_command(
740 "loginctl show-user \"$(id -un)\" -p Linger --value",
741 privilege,
742 )
743 .await
744 .map_err(|e| {
745 RegentError::FailedDryRunEvaluation(format!("Unable to check lingering: {:?}", e))
746 })?;
747 Ok(parse_linger(&result.stdout))
748}
749
750fn parse_linger(output: &str) -> bool {
751 output.lines().any(|line| line.trim() == "yes")
752}
753
754const FILE_PRESENT: &str = "regent-file-present\n";
755const FILE_MISSING: &str = "regent-file-missing";
756const FILE_END: &str = "\nregent-file-end";
757
758pub(super) async fn read_optional_file<Handler: HostHandler>(
762 host_handler: &mut Handler,
763 path: &str,
764 description: &str,
765 privilege: &Privilege,
766) -> Result<Option<String>, RegentError> {
767 let command = format!(
768 "if [ -e {path} ]; then printf 'regent-file-present\\n' && cat {path} && printf '\\nregent-file-end\\n'; else printf 'regent-file-missing\\n'; fi",
769 path = path
770 );
771 let result = host_handler
772 .run_command(&command, privilege)
773 .await
774 .map_err(|e| {
775 RegentError::FailedDryRunEvaluation(format!(
776 "Unable to read the {}: {:?}",
777 description, e
778 ))
779 })?;
780 if result.return_code != 0 {
781 return Err(RegentError::FailedDryRunEvaluation(format!(
782 "Unable to read the {} (return code {})",
783 description, result.return_code
784 )));
785 }
786 parse_file_probe(&result.stdout).ok_or_else(|| {
787 RegentError::FailureToParseContent(format!(
788 "Unexpected output while reading the {}",
789 description
790 ))
791 })
792}
793
794fn parse_file_probe(output: &str) -> Option<Option<String>> {
795 match output.find(FILE_PRESENT) {
796 Some(start) => {
797 let content = &output[start + FILE_PRESENT.len()..];
798 let end = content.rfind(FILE_END)?;
799 Some(Some(content[..end].to_string()))
800 }
801 None if output.contains(FILE_MISSING) => Some(None),
802 None => None,
803 }
804}
805
806pub(super) async fn unit_status<Handler: HostHandler>(
808 host_handler: &mut Handler,
809 id: &ServiceUnitId,
810 privilege: &Privilege,
811) -> Result<UnitStatus, RegentError> {
812 let unit = id.unit_name();
813 let command = id.systemctl(&[
814 "show",
815 &unit,
816 "-p",
817 "LoadState",
818 "-p",
819 "ActiveState",
820 "-p",
821 "UnitFileState",
822 "-p",
823 "NeedDaemonReload",
824 ]);
825 let result = host_handler
826 .run_command(&command, privilege)
827 .await
828 .map_err(|e| {
829 RegentError::FailedDryRunEvaluation(format!("Unable to query {}: {:?}", id, e))
830 })?;
831 if result.return_code != 0 {
832 return Err(RegentError::FailedDryRunEvaluation(format!(
833 "Unable to query {}: {:?}",
834 id, result
835 )));
836 }
837 Ok(UnitStatus::parse(&result.stdout))
838}
839
840pub(super) async fn observe_service<Handler: HostHandler>(
841 host_handler: &mut Handler,
842 id: &ServiceUnitId,
843 check_linger: bool,
844 privilege: &Privilege,
845) -> Result<ObservedService, RegentError> {
846 let unit_file =
847 read_optional_file(host_handler, &id.unit_path(), "unit file", privilege).await?;
848 let env_file =
849 read_optional_file(host_handler, &id.env_path(), "environment file", privilege).await?;
850 let status = unit_status(host_handler, id, privilege).await?;
851 let linger = if check_linger && id.scope == UnitScope::User {
852 Some(is_linger_enabled(host_handler, privilege).await?)
853 } else {
854 None
855 };
856 Ok(ObservedService {
857 unit_file,
858 env_file,
859 status,
860 linger,
861 })
862}
863
864pub(super) fn write_files_command(id: &ServiceUnitId, unit: &str, env: &str) -> String {
868 let unit_path = id.unit_path();
869 let unit_dir = match id.scope {
870 UnitScope::System => "/etc/systemd/system".to_string(),
871 UnitScope::User => format!("{}/systemd/user", USER_CONFIG_ROOT),
872 };
873 format!(
874 "(umask 077 && mkdir -p {regent_dir} && printf '%s' {env} > {env_path} && chmod 600 {env_path}) && \
875mkdir -p {unit_dir} && printf '%s' {unit} > {unit_path} && chmod 644 {unit_path} && {reload}",
876 regent_dir = id.regent_dir(),
877 env = shell_quote(env),
878 env_path = id.env_path(),
879 unit_dir = unit_dir,
880 unit = shell_quote(unit),
881 unit_path = unit_path,
882 reload = id.systemctl(&["daemon-reload"]),
883 )
884}
885
886pub(super) async fn resolve_executable<Handler: HostHandler>(
888 host_handler: &mut Handler,
889 executable: &str,
890 privilege: &Privilege,
891) -> Result<Option<String>, RegentError> {
892 let result = host_handler
893 .run_command(
894 &format!("command -v {}", shell_quote(executable)),
895 privilege,
896 )
897 .await
898 .map_err(|e| {
899 RegentError::FailedDryRunEvaluation(format!(
900 "Unable to look for {}: {:?}",
901 executable, e
902 ))
903 })?;
904 if result.return_code != 0 {
905 return Ok(None);
906 }
907 Ok(result
908 .stdout
909 .lines()
910 .map(str::trim)
911 .find(|line| line.starts_with('/'))
912 .map(String::from))
913}
914
915pub(super) async fn file_exists<Handler: HostHandler>(
916 host_handler: &mut Handler,
917 path: &AbsolutePath,
918 privilege: &Privilege,
919) -> Result<bool, RegentError> {
920 let result = host_handler
921 .run_command(
922 &format!("test -f {}", shell_quote(path.as_str())),
923 privilege,
924 )
925 .await
926 .map_err(|e| {
927 RegentError::FailedDryRunEvaluation(format!("Unable to check {}: {:?}", path, e))
928 })?;
929 Ok(result.return_code == 0)
930}
931
932pub(super) async fn run_remediation<Handler: HostHandler>(
934 host_handler: &mut Handler,
935 command: &str,
936 privilege: &Privilege,
937 show_output: bool,
938) -> Result<InternalApiCallOutcome, RegentError> {
939 match host_handler.run_command(command, privilege).await {
940 Ok(result) if result.return_code == 0 => Ok(InternalApiCallOutcome::Success(None)),
941 Ok(result) if show_output => Ok(InternalApiCallOutcome::Failure(format!(
942 "RC: {}, STDOUT: {}, STDERR: {}",
943 result.return_code, result.stdout, result.stderr
944 ))),
945 Ok(result) => Ok(InternalApiCallOutcome::Failure(format!(
946 "RC: {} (output hidden as it may contain secrets)",
947 result.return_code
948 ))),
949 Err(e) if show_output => Ok(InternalApiCallOutcome::Failure(format!(
950 "Command execution failed: {:?}",
951 e
952 ))),
953 Err(_) => Ok(InternalApiCallOutcome::Failure(
954 "Command execution failed (details hidden as they may contain secrets)".to_string(),
955 )),
956 }
957}
958
959pub(super) async fn run_action<Handler: HostHandler>(
960 host_handler: &mut Handler,
961 id: &ServiceUnitId,
962 action: ServiceUnitAction,
963 privilege: &Privilege,
964) -> Result<InternalApiCallOutcome, RegentError> {
965 run_remediation(host_handler, &action.command(id), privilege, true).await
966}
967
968pub(super) async fn write_files<Handler: HostHandler>(
970 host_handler: &mut Handler,
971 id: &ServiceUnitId,
972 unit: &str,
973 env: &str,
974 privilege: &Privilege,
975) -> Result<InternalApiCallOutcome, RegentError> {
976 run_remediation(
977 host_handler,
978 &write_files_command(id, unit, env),
979 privilege,
980 false,
981 )
982 .await
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 fn id(scope: UnitScope) -> ServiceUnitId {
990 ServiceUnitId::new(ServiceEngine::Apptainer, "vllm".parse().unwrap(), scope)
991 }
992
993 fn spec() -> UnitSpec {
994 UnitSpec {
995 exec_start: vec![
996 "/usr/bin/apptainer".to_string(),
997 "run".to_string(),
998 "--nv".to_string(),
999 "/opt/images/vllm 0.6.sif".to_string(),
1000 ],
1001 environment: vec![("HOME".to_string(), "/root".to_string())],
1002 restart: UnitRestartPolicy::OnFailure,
1003 }
1004 }
1005
1006 #[test]
1007 fn system_unit_is_rendered_deterministically() {
1008 assert_eq!(
1009 render_unit(&id(UnitScope::System), &spec()),
1010 "# Managed by regent-sdk: manual changes will be overwritten
1011[Unit]
1012Description=Apptainer container vllm (regent-sdk)
1013Wants=network-online.target
1014After=network-online.target
1015
1016[Service]
1017Type=simple
1018Environment=HOME=/root
1019EnvironmentFile=/etc/regent-sdk/apptainer/vllm.env
1020ExecStart=/usr/bin/apptainer run --nv \"/opt/images/vllm 0.6.sif\"
1021Restart=on-failure
1022RestartSec=5
1023TimeoutStopSec=60
1024
1025[Install]
1026WantedBy=multi-user.target
1027"
1028 );
1029 }
1030
1031 #[test]
1032 fn user_unit_uses_the_user_manager_paths() {
1033 let unit = render_unit(&id(UnitScope::User), &spec());
1034 assert!(unit.contains("EnvironmentFile=%E/regent-sdk/apptainer/vllm.env\n"));
1035 assert!(unit.contains("WantedBy=default.target\n"));
1036 assert!(!unit.contains("network-online.target"));
1037
1038 let user = id(UnitScope::User);
1039 assert_eq!(
1040 user.unit_path(),
1041 "\"${XDG_CONFIG_HOME:-$HOME/.config}\"/systemd/user/'regent-apptainer-vllm.service'"
1042 );
1043 assert_eq!(
1044 user.env_path(),
1045 "\"${XDG_CONFIG_HOME:-$HOME/.config}\"/regent-sdk/apptainer/'vllm.env'"
1046 );
1047 assert_eq!(
1048 user.systemctl(&["start", "x.service"]),
1049 "XDG_RUNTIME_DIR=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}\" 'systemctl' '--user' 'start' 'x.service'"
1050 );
1051 assert_eq!(
1052 id(UnitScope::System).unit_path(),
1053 "/etc/systemd/system/'regent-apptainer-vllm.service'"
1054 );
1055 }
1056
1057 #[test]
1058 fn exec_start_arguments_are_escaped_for_systemd() {
1059 assert_eq!(systemd_quote("--port=8000"), "--port=8000");
1060 assert_eq!(systemd_quote("a b"), "\"a b\"");
1061 assert_eq!(systemd_quote("100%"), "\"100%%\"");
1062 assert_eq!(systemd_quote("$HOME"), "\"$$HOME\"");
1063 assert_eq!(systemd_quote("say \"hi\""), "\"say \\\"hi\\\"\"");
1064 assert_eq!(systemd_quote("C:\\dir"), "\"C:\\\\dir\"");
1065 assert_eq!(systemd_quote("line\nbreak"), "\"line\\nbreak\"");
1066 assert_eq!(systemd_quote(""), "\"\"");
1067 assert_eq!(systemd_quote("{\"a\": 1}"), "\"{\\\"a\\\": 1}\"");
1068 }
1069
1070 #[test]
1071 fn env_file_values_are_taken_literally() {
1072 let env = render_env_file(&[
1073 ("HF_TOKEN".to_string(), "hf_$abc\"`x`\\".to_string()),
1074 ("EMPTY".to_string(), String::new()),
1075 ]);
1076 assert_eq!(
1077 env,
1078 "# Managed by regent-sdk: manual changes will be overwritten
1079HF_TOKEN=\"hf_\\$abc\\\"\\`x\\`\\\\\"
1080EMPTY=\"\"
1081"
1082 );
1083 }
1084
1085 #[test]
1086 fn value_types_are_validated() {
1087 assert!("HF_TOKEN".parse::<ServiceEnvVarName>().is_ok());
1088 assert!("_x1".parse::<ServiceEnvVarName>().is_ok());
1089 for name in ["", "1X", "A-B", "A B", "A=B"] {
1090 assert!(name.parse::<ServiceEnvVarName>().is_err(), "{}", name);
1091 }
1092
1093 assert!("/opt/x.sif".parse::<AbsolutePath>().is_ok());
1094 assert_eq!(
1095 "/opt/x.sif".parse::<AbsolutePath>().unwrap().parent(),
1096 "/opt"
1097 );
1098 assert_eq!("/x.sif".parse::<AbsolutePath>().unwrap().parent(), "/");
1099 for path in ["x.sif", "/opt/", "/opt/a\nb", ""] {
1100 assert!(path.parse::<AbsolutePath>().is_err(), "{:?}", path);
1101 }
1102
1103 let mount = |spec: &str| spec.parse::<BindMount>();
1104 assert_eq!(mount("/data").unwrap().to_string(), "/data:/data:rw");
1105 assert_eq!(
1106 mount("/models:/root/.cache:ro").unwrap().apptainer_arg(),
1107 "/models:/root/.cache:ro"
1108 );
1109 assert_eq!(
1110 mount("/models:/models:ro").unwrap().enroot_arg(),
1111 "/models:/models:none:x-create=auto,rbind,ro"
1112 );
1113 assert_eq!(
1114 mount("/data:/data").unwrap().enroot_arg(),
1115 "/data:/data:none:x-create=auto,rbind"
1116 );
1117 for spec in [
1118 "data:/data",
1119 "/data:data",
1120 "/data:/data:Z",
1121 "/a,b:/c",
1122 "/a b:/c",
1123 "/a:/b:ro:x",
1124 ] {
1125 assert!(mount(spec).is_err(), "{}", spec);
1126 }
1127 }
1128
1129 #[test]
1130 fn probes_are_parsed_despite_noise() {
1131 assert_eq!(
1132 parse_scope_probe("sudo: warning\nregent-uid=0\nregent-home=/root\n"),
1133 Some((UnitScope::System, "/root".to_string()))
1134 );
1135 assert_eq!(
1136 parse_scope_probe("regent-uid=1000\nregent-home=/home/ai\n"),
1137 Some((UnitScope::User, "/home/ai".to_string()))
1138 );
1139 assert_eq!(parse_scope_probe("garbage"), None);
1140
1141 assert!(parse_linger("yes\n"));
1142 assert!(!parse_linger("no\n"));
1143 assert!(!parse_linger(
1144 "Failed to get user: User ID 1000 is not logged in or lingering\n"
1145 ));
1146
1147 assert_eq!(
1148 parse_file_probe("warning\nregent-file-present\nline\n\nregent-file-end\n"),
1149 Some(Some("line\n".to_string()))
1150 );
1151 assert_eq!(
1152 parse_file_probe("regent-file-present\n\nregent-file-end\n"),
1153 Some(Some(String::new()))
1154 );
1155 assert_eq!(parse_file_probe("regent-file-missing\n"), Some(None));
1156 assert_eq!(parse_file_probe("weird"), None);
1157
1158 let status = UnitStatus::parse(
1159 "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nNeedDaemonReload=no\n",
1160 );
1161 assert!(status.is_active() && status.is_enabled() && !status.need_daemon_reload);
1162 let status = UnitStatus::parse(
1163 "LoadState=not-found\nActiveState=inactive\nUnitFileState=\nNeedDaemonReload=no\n",
1164 );
1165 assert!(!status.is_active() && !status.is_enabled());
1166 }
1167
1168 fn observed(unit: &str, env: &str, active: bool, enabled: bool) -> ObservedService {
1169 ObservedService {
1170 unit_file: Some(unit.to_string()),
1171 env_file: Some(env.to_string()),
1172 status: UnitStatus {
1173 load_state: "loaded".to_string(),
1174 active_state: if active { "active" } else { "inactive" }.to_string(),
1175 unit_file_state: if enabled { "enabled" } else { "disabled" }.to_string(),
1176 need_daemon_reload: false,
1177 },
1178 linger: None,
1179 }
1180 }
1181
1182 #[test]
1183 fn service_plan_reaches_the_expected_state() {
1184 use ServiceUnitAction::*;
1185 let act = ServiceStep::Action;
1186
1187 let fresh = ObservedService {
1189 linger: Some(false),
1190 ..ObservedService::default()
1191 };
1192 assert_eq!(
1193 plan_service(true, "U", "E", &fresh, false),
1194 vec![
1195 act(EnableLinger),
1196 ServiceStep::WriteFiles(vec![
1197 "unit file is missing".to_string(),
1198 "environment file is missing".to_string()
1199 ]),
1200 act(Enable),
1201 act(Start),
1202 ]
1203 );
1204
1205 assert!(plan_service(true, "U", "E", &observed("U", "E", true, true), false).is_empty());
1207 assert!(plan_service(false, "U", "E", &observed("U", "E", false, false), false).is_empty());
1208
1209 assert_eq!(
1211 plan_service(true, "U", "E2", &observed("U", "E", true, true), false),
1212 vec![
1213 ServiceStep::WriteFiles(vec!["environment file differs".to_string()]),
1214 act(Restart),
1215 ]
1216 );
1217
1218 assert_eq!(
1220 plan_service(true, "U", "E", &observed("U", "E", true, true), true),
1221 vec![act(Restart)]
1222 );
1223
1224 assert_eq!(
1226 plan_service(false, "U", "E", &observed("U", "E", true, true), true),
1227 vec![act(Disable), act(Stop)]
1228 );
1229
1230 let mut stale = observed("U", "E", false, true);
1232 stale.status.need_daemon_reload = true;
1233 assert_eq!(
1234 plan_service(true, "U", "E", &stale, false),
1235 vec![act(DaemonReload), act(Start)]
1236 );
1237
1238 assert!(needs_removal(&observed("U", "E", false, false)));
1239 assert!(!needs_removal(&ObservedService::default()));
1240 }
1241
1242 #[test]
1243 fn write_command_protects_the_environment_file() {
1244 let command = write_files_command(&id(UnitScope::System), "UNIT", "A=\"it's\"\n");
1245 assert_eq!(
1246 command,
1247 "(umask 077 && mkdir -p /etc/regent-sdk/apptainer && printf '%s' 'A=\"it'\\''s\"\n' > /etc/regent-sdk/apptainer/'vllm.env' && chmod 600 /etc/regent-sdk/apptainer/'vllm.env') && \
1248mkdir -p /etc/systemd/system && printf '%s' 'UNIT' > /etc/systemd/system/'regent-apptainer-vllm.service' && chmod 644 /etc/systemd/system/'regent-apptainer-vllm.service' && 'systemctl' 'daemon-reload'"
1249 );
1250 assert_eq!(
1251 ServiceUnitAction::Remove.command(&id(UnitScope::System)),
1252 "{ 'systemctl' 'stop' 'regent-apptainer-vllm.service' >/dev/null 2>&1; 'systemctl' 'disable' 'regent-apptainer-vllm.service' >/dev/null 2>&1; rm -f /etc/systemd/system/'regent-apptainer-vllm.service' /etc/regent-sdk/apptainer/'vllm.env' && 'systemctl' 'daemon-reload'; }"
1253 );
1254 }
1255}