1use crate::error::RegentError;
136use crate::hosts::handlers::shell_quote;
137use crate::hosts::managed_host::InternalApiCallOutcome;
138use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
139use crate::hosts::properties::{HostProperties, OsKind};
140use crate::secrets::SecretProvidersPool;
141use crate::state::Check;
142use crate::state::attribute::HostHandler;
143use crate::state::attribute::Privilege;
144use crate::state::attribute::Remediation;
145use crate::state::attribute::RemediationsList;
146use crate::state::compliance::AttributeComplianceAssessment;
147use crate::state::expected_state::Parameter;
148use serde::{Deserialize, Serialize};
149use std::collections::BTreeMap;
150use std::fmt;
151use std::str::FromStr;
152use std::time::Duration;
153
154use super::container_spec::{ContainerCommand, ContainerName, GpuId, GpuSelection, invalid};
155use super::service_unit::{
156 AbsolutePath, BindMount, ServiceEngine, ServiceEnvVarName, ServiceStep, ServiceUnitAction,
157 ServiceUnitId, UnitRestartPolicy, UnitScope, UnitSpec, file_exists, needs_removal,
158 observe_service, plan_service, read_optional_file, render_env_file, render_unit,
159 resolve_executable, run_action, run_remediation, write_files,
160};
161
162const NVIDIA_VISIBLE_DEVICES: &str = "NVIDIA_VISIBLE_DEVICES";
163const NVIDIA_DRIVER_CAPABILITIES: &str = "NVIDIA_DRIVER_CAPABILITIES";
164const GPU_DRIVER_CAPABILITIES: &str = "compute,utility";
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168#[serde(rename_all = "PascalCase")]
169pub struct EnrootExpectedState {
170 name: ContainerName,
172 state: EnrootServiceState,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176#[serde(rename_all = "PascalCase")]
177pub enum EnrootServiceState {
178 Running(Box<EnrootServiceConfig>),
179 Stopped(Box<EnrootServiceConfig>),
180 Absent,
181}
182
183impl EnrootExpectedState {
184 pub fn running(name: ContainerName, config: EnrootServiceConfig) -> Self {
185 Self {
186 name,
187 state: EnrootServiceState::Running(Box::new(config)),
188 }
189 }
190
191 pub fn stopped(name: ContainerName, config: EnrootServiceConfig) -> Self {
192 Self {
193 name,
194 state: EnrootServiceState::Stopped(Box::new(config)),
195 }
196 }
197
198 pub fn absent(name: ContainerName) -> Self {
199 Self {
200 name,
201 state: EnrootServiceState::Absent,
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(rename_all = "PascalCase")]
208pub enum EnrootImage {
209 Local(AbsolutePath),
211 Imported(EnrootImportedImage),
212}
213
214impl EnrootImage {
215 pub fn local(path: AbsolutePath) -> Self {
217 EnrootImage::Local(path)
218 }
219
220 pub fn imported(source: EnrootImageUri, path: AbsolutePath) -> Self {
222 EnrootImage::Imported(EnrootImportedImage { source, path })
223 }
224
225 pub fn path(&self) -> &AbsolutePath {
226 match self {
227 EnrootImage::Local(path) => path,
228 EnrootImage::Imported(imported) => &imported.path,
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235#[serde(rename_all = "PascalCase")]
236pub struct EnrootImportedImage {
237 source: EnrootImageUri,
238 path: AbsolutePath,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(try_from = "String", into = "String")]
247pub struct EnrootImageUri(String);
248
249const ENROOT_URI_SCHEMES: [&str; 3] = ["docker://", "dockerd://", "podman://"];
250
251impl TryFrom<String> for EnrootImageUri {
252 type Error = RegentError;
253
254 fn try_from(uri: String) -> Result<Self, Self::Error> {
255 let rest = ENROOT_URI_SCHEMES
256 .iter()
257 .find_map(|scheme| uri.strip_prefix(scheme));
258 match rest {
259 None => Err(invalid(
260 "image URI",
261 &uri,
262 &format!("supported schemes are {}", ENROOT_URI_SCHEMES.join(", ")),
263 )),
264 Some(rest) if rest.is_empty() || uri.chars().any(char::is_whitespace) => Err(invalid(
265 "image URI",
266 &uri,
267 "must name an image, without whitespace",
268 )),
269 Some(_) => Ok(Self(uri)),
270 }
271 }
272}
273
274impl FromStr for EnrootImageUri {
275 type Err = RegentError;
276
277 fn from_str(uri: &str) -> Result<Self, Self::Err> {
278 Self::try_from(uri.to_string())
279 }
280}
281
282impl From<EnrootImageUri> for String {
283 fn from(uri: EnrootImageUri) -> String {
284 uri.0
285 }
286}
287
288impl fmt::Display for EnrootImageUri {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 write!(f, "{}", self.0)
291 }
292}
293
294impl EnrootImageUri {
295 pub fn as_str(&self) -> &str {
296 &self.0
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301#[serde(try_from = "EnrootServiceConfigFields")]
302#[serde(rename_all = "PascalCase")]
303pub struct EnrootServiceConfig {
304 image: EnrootImage,
305 command: Option<ContainerCommand>,
307 gpus: Option<GpuSelection>,
308 mounts: Option<Vec<BindMount>>,
309 env: Option<BTreeMap<ServiceEnvVarName, Parameter<String>>>,
311 root: bool,
313 read_write: bool,
315 restart_policy: UnitRestartPolicy,
316}
317
318#[derive(Deserialize)]
320#[serde(deny_unknown_fields)]
321#[serde(rename_all = "PascalCase")]
322struct EnrootServiceConfigFields {
323 image: EnrootImage,
324 command: Option<ContainerCommand>,
325 gpus: Option<GpuSelection>,
326 mounts: Option<Vec<BindMount>>,
327 env: Option<BTreeMap<ServiceEnvVarName, Parameter<String>>>,
328 #[serde(default)]
329 root: bool,
330 #[serde(default)]
331 read_write: bool,
332 #[serde(default)]
333 restart_policy: UnitRestartPolicy,
334}
335
336impl TryFrom<EnrootServiceConfigFields> for EnrootServiceConfig {
337 type Error = RegentError;
338
339 fn try_from(fields: EnrootServiceConfigFields) -> Result<Self, Self::Error> {
340 let config = Self {
341 image: fields.image,
342 command: fields.command,
343 gpus: fields.gpus,
344 mounts: fields.mounts,
345 env: fields.env,
346 root: fields.root,
347 read_write: fields.read_write,
348 restart_policy: fields.restart_policy,
349 };
350 config.validate()?;
351 Ok(config)
352 }
353}
354
355impl EnrootServiceConfig {
356 pub fn new(image: EnrootImage) -> Self {
357 Self {
358 image,
359 command: None,
360 gpus: None,
361 mounts: None,
362 env: None,
363 root: false,
364 read_write: false,
365 restart_policy: UnitRestartPolicy::default(),
366 }
367 }
368
369 pub fn from_local_image(path: AbsolutePath) -> Self {
371 Self::new(EnrootImage::local(path))
372 }
373
374 pub fn from_imported_image(source: EnrootImageUri, path: AbsolutePath) -> Self {
376 Self::new(EnrootImage::imported(source, path))
377 }
378
379 pub fn with_command(mut self, command: ContainerCommand) -> Self {
380 self.command = Some(command);
381 self
382 }
383
384 pub fn with_gpus(mut self, gpus: GpuSelection) -> Self {
385 self.gpus = Some(gpus);
386 self
387 }
388
389 pub fn with_all_gpus(self) -> Self {
392 self.with_gpus(GpuSelection::All)
393 }
394
395 pub fn with_gpu_device(mut self, gpu: GpuId) -> Self {
398 self.gpus = Some(GpuSelection::add_device(self.gpus.take(), gpu));
399 self
400 }
401
402 pub fn with_mounts(mut self, mounts: Vec<BindMount>) -> Self {
403 self.mounts = Some(mounts);
404 self
405 }
406
407 pub fn with_env(mut self, key: ServiceEnvVarName, value: Parameter<String>) -> Self {
409 self.env
410 .get_or_insert_with(BTreeMap::new)
411 .insert(key, value);
412 self
413 }
414
415 pub fn with_root(mut self, root: bool) -> Self {
416 self.root = root;
417 self
418 }
419
420 pub fn with_read_write(mut self, read_write: bool) -> Self {
421 self.read_write = read_write;
422 self
423 }
424
425 pub fn with_restart_policy(mut self, restart_policy: UnitRestartPolicy) -> Self {
426 self.restart_policy = restart_policy;
427 self
428 }
429
430 pub fn with_restart_never(self) -> Self {
432 self.with_restart_policy(UnitRestartPolicy::No)
433 }
434
435 pub fn with_restart_on_failure(self) -> Self {
437 self.with_restart_policy(UnitRestartPolicy::OnFailure)
438 }
439
440 pub fn with_restart_always(self) -> Self {
442 self.with_restart_policy(UnitRestartPolicy::Always)
443 }
444
445 fn validate(&self) -> Result<(), RegentError> {
447 for key in self.env.iter().flat_map(BTreeMap::keys) {
448 let key = key.as_str();
449 if key.starts_with("ENROOT_")
450 || key.starts_with("XDG_")
451 || key == "HOME"
452 || key == "PATH"
453 {
454 return Err(invalid(
455 "environment variable",
456 key,
457 "it would also configure enroot itself",
458 ));
459 }
460 if self.gpus.is_some()
461 && (key == NVIDIA_VISIBLE_DEVICES || key == NVIDIA_DRIVER_CAPABILITIES)
462 {
463 return Err(invalid("environment variable", key, "it is set by Gpus"));
464 }
465 }
466 Ok(())
467 }
468
469 fn gpu_env(&self) -> Vec<(String, String)> {
470 match &self.gpus {
471 Some(gpus) => vec![
472 (
473 NVIDIA_VISIBLE_DEVICES.to_string(),
474 gpus.ids().into_iter().collect::<Vec<String>>().join(","),
475 ),
476 (
477 NVIDIA_DRIVER_CAPABILITIES.to_string(),
478 GPU_DRIVER_CAPABILITIES.to_string(),
479 ),
480 ],
481 None => Vec::new(),
482 }
483 }
484
485 fn env_names(&self) -> Vec<String> {
487 let mut names: Vec<String> = self
488 .env
489 .iter()
490 .flat_map(BTreeMap::keys)
491 .map(ServiceEnvVarName::to_string)
492 .collect();
493 names.extend(self.gpu_env().into_iter().map(|(name, _)| name));
494 names
495 }
496
497 fn exec_start(&self, enroot_path: &str, name: &ContainerName) -> Vec<String> {
498 let mut args = vec![enroot_path.to_string(), "start".to_string()];
499 if self.root {
500 args.push("--root".to_string());
501 }
502 if self.read_write {
503 args.push("--rw".to_string());
504 }
505 for mount in self.mounts.iter().flatten() {
506 args.push("--mount".to_string());
507 args.push(mount.enroot_arg());
508 }
509 for env_name in self.env_names() {
510 args.push("--env".to_string());
511 args.push(env_name);
512 }
513 args.push(name.to_string());
514 args.extend(
515 self.command
516 .iter()
517 .flat_map(|command| command.args().iter().cloned()),
518 );
519 args
520 }
521
522 fn unit_content(&self, id: &ServiceUnitId, enroot_path: &str, home: &Option<String>) -> String {
525 render_unit(
526 id,
527 &UnitSpec {
528 exec_start: self.exec_start(enroot_path, id.name()),
529 environment: home
530 .iter()
531 .map(|home| ("HOME".to_string(), home.clone()))
532 .collect(),
533 restart: self.restart_policy,
534 },
535 )
536 }
537
538 async fn env_content(
540 &self,
541 optional_secret_provider: &Option<SecretProvidersPool>,
542 ) -> Result<String, RegentError> {
543 let mut entries = Vec::new();
544 if let Some(env) = &self.env {
545 for (key, value) in env {
546 entries.push((
547 key.to_string(),
548 value.clone().inner_raw(optional_secret_provider).await?,
549 ));
550 }
551 }
552 entries.extend(self.gpu_env());
553 Ok(render_env_file(&entries))
554 }
555}
556
557fn marker_content(image: &AbsolutePath, identity: &str) -> String {
559 format!("image={}\nidentity={}\n", image, identity)
560}
561
562fn recreation_reasons(marker: Option<&str>, image: &AbsolutePath, identity: &str) -> Vec<String> {
565 let Some(marker) = marker else {
566 return vec![
567 "image can't be verified on a container which was not created by Regent".to_string(),
568 ];
569 };
570 if marker == marker_content(image, identity) {
571 return Vec::new();
572 }
573 let recorded = |key: &str| {
574 marker
575 .lines()
576 .find_map(|line| line.strip_prefix(key))
577 .unwrap_or_default()
578 .to_string()
579 };
580 let recorded_image = recorded("image=");
581 if recorded_image != image.as_str() {
582 vec![format!(
583 "container was created from {:?}, expected {}",
584 recorded_image, image
585 )]
586 } else {
587 vec![format!(
588 "image {} changed since the container was created",
589 image
590 )]
591 }
592}
593
594fn parse_identity(output: &str) -> Option<String> {
596 output
597 .lines()
598 .map(str::trim)
599 .find(|line| {
600 let parts: Vec<&str> = line.split(' ').collect();
601 parts.len() == 2
602 && parts
603 .iter()
604 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
605 })
606 .map(String::from)
607}
608
609fn is_listed(output: &str, name: &ContainerName) -> bool {
611 output.lines().any(|line| line.trim() == name.as_str())
612}
613
614impl Timeout for EnrootExpectedState {
615 fn default_timeout(&self) -> Duration {
616 Duration::from_secs(120)
617 }
618}
619
620impl Check for EnrootExpectedState {
621 fn check(&self) -> Result<(), RegentError> {
622 match &self.state {
625 EnrootServiceState::Running(config) | EnrootServiceState::Stopped(config) => {
626 config.validate()
627 }
628 EnrootServiceState::Absent => Ok(()),
629 }
630 }
631
632 fn check_host_compatibility(
633 &self,
634 host_properties: &HostProperties,
635 ) -> Result<(), RegentError> {
636 check_enroot_host_compatibility(host_properties)
637 }
638}
639
640impl<Handler: HostHandler> AssessCompliance<Handler> for EnrootExpectedState {
641 async fn assess_compliance(
642 &self,
643 host_handler: &mut Handler,
644 host_properties: &Option<HostProperties>,
645 privilege: &Privilege,
646 optional_secret_provider: &Option<SecretProvidersPool>,
647 ) -> Result<AttributeComplianceAssessment, RegentError> {
648 if let Some(props) = host_properties {
649 self.check_host_compatibility(props)?;
650 }
651
652 let (scope, home) = super::service_unit::detect_scope(host_handler, privilege).await?;
653 let id = ServiceUnitId::new(ServiceEngine::Enroot, self.name.clone(), scope);
654 let enroot_path = resolve_executable(host_handler, "enroot", privilege).await?;
655
656 let mut remediations: Vec<Remediation> = Vec::new();
657 let mut push = |api_call: EnrootInternalApiCall| {
658 remediations.push(Remediation::Enroot(EnrootApiCall::from(
659 api_call,
660 privilege.clone(),
661 )));
662 };
663
664 let (config, should_run) = match &self.state {
665 EnrootServiceState::Absent => {
666 let observed = observe_service(host_handler, &id, false, privilege).await?;
667 if needs_removal(&observed) {
668 push(EnrootInternalApiCall::Service {
669 unit: id.clone(),
670 action: ServiceUnitAction::Remove,
671 });
672 }
673 let is_container_present = match &enroot_path {
675 Some(_) => is_container_listed(host_handler, &self.name, privilege).await?,
676 None => false,
677 };
678 let marker =
679 read_optional_file(host_handler, &marker_path(&id), "image marker", privilege)
680 .await?;
681 if is_container_present || marker.is_some() {
682 push(EnrootInternalApiCall::RemoveContainer {
683 unit: id,
684 is_container_present,
685 });
686 }
687 return assessment(remediations);
688 }
689 EnrootServiceState::Running(config) => (config, true),
690 EnrootServiceState::Stopped(config) => (config, false),
691 };
692
693 let Some(enroot_path) = enroot_path else {
694 return Err(RegentError::FailedDryRunEvaluation(
695 "Enroot CLI (enroot) is not available on this host".to_string(),
696 ));
697 };
698
699 let image_path = config.image.path();
700 let mut is_image_imported = false;
701 if !file_exists(host_handler, image_path, privilege).await? {
702 match &config.image {
703 EnrootImage::Local(path) => {
704 return Ok(AttributeComplianceAssessment::NonCompliantFatal(format!(
705 "image {} is not present on the host",
706 path
707 )));
708 }
709 EnrootImage::Imported(imported) => {
710 push(EnrootInternalApiCall::Import(imported.clone()));
711 is_image_imported = true;
712 }
713 }
714 }
715
716 let is_container_present = is_container_listed(host_handler, &self.name, privilege).await?;
717 let reasons = if is_image_imported {
718 vec![format!("image {} will be imported", image_path)]
719 } else {
720 let identity = image_identity(host_handler, image_path, privilege).await?;
721 let marker =
722 read_optional_file(host_handler, &marker_path(&id), "image marker", privilege)
723 .await?;
724 recreation_reasons(marker.as_deref(), image_path, &identity)
725 };
726
727 let container_changed = match (is_container_present, reasons.is_empty()) {
728 (false, _) => {
729 push(EnrootInternalApiCall::CreateContainer {
730 unit: id.clone(),
731 image: image_path.clone(),
732 reasons: Vec::new(),
733 });
734 true
735 }
736 (true, false) => {
737 push(EnrootInternalApiCall::CreateContainer {
738 unit: id.clone(),
739 image: image_path.clone(),
740 reasons,
741 });
742 true
743 }
744 (true, true) => false,
745 };
746
747 let home = match scope {
748 UnitScope::System if !home.is_empty() => Some(home),
749 _ => None,
750 };
751 let observed = observe_service(host_handler, &id, should_run, privilege).await?;
752 let expected_unit = config.unit_content(&id, &enroot_path, &home);
753 let expected_env = config.env_content(optional_secret_provider).await?;
754
755 for step in plan_service(
756 should_run,
757 &expected_unit,
758 &expected_env,
759 &observed,
760 container_changed,
761 ) {
762 push(match step {
763 ServiceStep::WriteFiles(reasons) => EnrootInternalApiCall::WriteServiceFiles {
764 unit: id.clone(),
765 config: config.clone(),
766 enroot_path: enroot_path.clone(),
767 home: home.clone(),
768 reasons,
769 },
770 ServiceStep::Action(action) => EnrootInternalApiCall::Service {
771 unit: id.clone(),
772 action,
773 },
774 });
775 }
776
777 assessment(remediations)
778 }
779}
780
781fn assessment(
782 remediations: Vec<Remediation>,
783) -> Result<AttributeComplianceAssessment, RegentError> {
784 if remediations.is_empty() {
785 Ok(AttributeComplianceAssessment::Compliant)
786 } else {
787 Ok(AttributeComplianceAssessment::NonCompliant(
788 RemediationsList::from(remediations)?,
789 ))
790 }
791}
792
793fn marker_path(id: &ServiceUnitId) -> String {
794 id.regent_file("image")
795}
796
797async fn is_container_listed<Handler: HostHandler>(
798 host_handler: &mut Handler,
799 name: &ContainerName,
800 privilege: &Privilege,
801) -> Result<bool, RegentError> {
802 let result = host_handler
803 .run_command("enroot list", privilege)
804 .await
805 .map_err(|e| {
806 RegentError::FailedDryRunEvaluation(format!("Unable to list containers: {:?}", e))
807 })?;
808 if result.return_code != 0 {
809 return Err(RegentError::FailedDryRunEvaluation(format!(
810 "Unable to list containers: {:?}",
811 result
812 )));
813 }
814 Ok(is_listed(&result.stdout, name))
815}
816
817async fn image_identity<Handler: HostHandler>(
818 host_handler: &mut Handler,
819 image: &AbsolutePath,
820 privilege: &Privilege,
821) -> Result<String, RegentError> {
822 let result = host_handler
823 .run_command(
824 &format!("stat -c '%s %Y' {}", shell_quote(image.as_str())),
825 privilege,
826 )
827 .await
828 .map_err(|e| {
829 RegentError::FailedDryRunEvaluation(format!("Unable to stat {}: {:?}", image, e))
830 })?;
831 parse_identity(&result.stdout).ok_or_else(|| {
832 RegentError::FailedDryRunEvaluation(format!("Unable to stat {}: {:?}", image, result))
833 })
834}
835
836#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
837#[serde(rename_all = "PascalCase", rename_all_fields = "PascalCase")]
838pub enum EnrootInternalApiCall {
839 Import(EnrootImportedImage),
840 CreateContainer {
843 unit: ServiceUnitId,
844 image: AbsolutePath,
845 reasons: Vec<String>,
846 },
847 RemoveContainer {
848 unit: ServiceUnitId,
849 is_container_present: bool,
850 },
851 WriteServiceFiles {
852 unit: ServiceUnitId,
853 config: Box<EnrootServiceConfig>,
854 enroot_path: String,
855 home: Option<String>,
856 reasons: Vec<String>,
857 },
858 Service {
859 unit: ServiceUnitId,
860 action: ServiceUnitAction,
861 },
862}
863
864#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
865pub struct EnrootApiCall {
866 pub api_call: EnrootInternalApiCall,
867 privilege: Privilege,
868}
869
870impl EnrootApiCall {
871 pub fn display(&self) -> String {
872 match &self.api_call {
873 EnrootInternalApiCall::Import(imported) => {
874 format!("Import image {} into {}", imported.source, imported.path)
875 }
876 EnrootInternalApiCall::CreateContainer {
877 unit,
878 image,
879 reasons,
880 } => {
881 if reasons.is_empty() {
882 format!("Create container {} from {}", unit.name(), image)
883 } else {
884 format!(
885 "Recreate container {} from {} ({})",
886 unit.name(),
887 image,
888 reasons.join("; ")
889 )
890 }
891 }
892 EnrootInternalApiCall::RemoveContainer { unit, .. } => {
893 format!("Remove container {}", unit.name())
894 }
895 EnrootInternalApiCall::WriteServiceFiles { unit, reasons, .. } => format!(
896 "Write {} and its environment file ({})",
897 unit,
898 reasons.join("; ")
899 ),
900 EnrootInternalApiCall::Service { unit, action } => action.display(unit),
901 }
902 }
903
904 fn from(api_call: EnrootInternalApiCall, privilege: Privilege) -> EnrootApiCall {
905 EnrootApiCall {
906 api_call,
907 privilege,
908 }
909 }
910}
911
912impl Timeout for EnrootApiCall {
913 fn default_timeout(&self) -> Duration {
914 match &self.api_call {
915 EnrootInternalApiCall::Import(_) => Duration::from_secs(3600),
917 EnrootInternalApiCall::CreateContainer { .. } => Duration::from_secs(900),
919 _ => Duration::from_secs(180),
920 }
921 }
922}
923
924impl Check for EnrootApiCall {
925 fn check(&self) -> Result<(), RegentError> {
926 Ok(())
927 }
928
929 fn check_host_compatibility(
930 &self,
931 host_properties: &HostProperties,
932 ) -> Result<(), RegentError> {
933 check_enroot_host_compatibility(host_properties)
934 }
935}
936
937impl<Handler: HostHandler> ReachCompliance<Handler> for EnrootApiCall {
938 async fn call(
939 &self,
940 host_handler: &mut Handler,
941 host_properties: &Option<HostProperties>,
942 optional_secret_provider: &Option<SecretProvidersPool>,
943 ) -> Result<InternalApiCallOutcome, RegentError> {
944 if let Some(props) = host_properties {
945 self.check_host_compatibility(props)?;
946 }
947
948 let command = match &self.api_call {
949 EnrootInternalApiCall::Import(imported) => import_command(imported),
950 EnrootInternalApiCall::CreateContainer {
951 unit,
952 image,
953 reasons,
954 } => create_command(unit, image, !reasons.is_empty()),
955 EnrootInternalApiCall::RemoveContainer {
956 unit,
957 is_container_present,
958 } => remove_command(unit, *is_container_present),
959 EnrootInternalApiCall::WriteServiceFiles {
960 unit,
961 config,
962 enroot_path,
963 home,
964 ..
965 } => {
966 let env = config.env_content(optional_secret_provider).await?;
967 return write_files(
968 host_handler,
969 unit,
970 &config.unit_content(unit, enroot_path, home),
971 &env,
972 &self.privilege,
973 )
974 .await;
975 }
976 EnrootInternalApiCall::Service { unit, action } => {
977 return run_action(host_handler, unit, *action, &self.privilege).await;
978 }
979 };
980 run_remediation(host_handler, &command, &self.privilege, true).await
981 }
982}
983
984fn import_command(imported: &EnrootImportedImage) -> String {
986 format!(
987 "mkdir -p {} && enroot import -o {} {}",
988 shell_quote(imported.path.parent()),
989 shell_quote(imported.path.as_str()),
990 shell_quote(imported.source.as_str())
991 )
992}
993
994fn create_command(id: &ServiceUnitId, image: &AbsolutePath, recreate: bool) -> String {
997 let name = shell_quote(id.name().as_str());
998 let image_path = shell_quote(image.as_str());
999 let removal = if recreate {
1000 format!(
1001 "{{ {} >/dev/null 2>&1; true; }} && enroot remove --force {} && ",
1002 id.systemctl(&["stop", &id.unit_name()]),
1003 name
1004 )
1005 } else {
1006 String::new()
1007 };
1008 format!(
1009 "{removal}enroot create --name {name} {image_path} && (umask 077 && mkdir -p {dir} && printf 'image=%s\\nidentity=%s\\n' {image_path} \"$(stat -c '%s %Y' {image_path})\" > {marker})",
1010 removal = removal,
1011 name = name,
1012 image_path = image_path,
1013 dir = id.regent_dir(),
1014 marker = marker_path(id),
1015 )
1016}
1017
1018fn remove_command(id: &ServiceUnitId, is_container_present: bool) -> String {
1019 let marker_removal = format!("rm -f {}", marker_path(id));
1020 if is_container_present {
1021 format!(
1022 "enroot remove --force {} && {}",
1023 shell_quote(id.name().as_str()),
1024 marker_removal
1025 )
1026 } else {
1027 marker_removal
1028 }
1029}
1030
1031fn check_enroot_host_compatibility(host_properties: &HostProperties) -> Result<(), RegentError> {
1032 match host_properties.os_kind() {
1033 OsKind::Linux(_) => Ok(()),
1034 incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
1035 "Host is {:?} but Enroot services are only supported on Linux",
1036 incompatible_os_kind
1037 ))),
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use crate::secrets::SecretReference;
1045
1046 use crate::state::attribute::container::container_spec::GpuList;
1047 fn full_yaml() -> &'static str {
1048 "---
1049Name: vllm
1050State: !Running
1051 Image: !Imported
1052 Source: docker://nvcr.io#nvidia/vllm:24.10-py3
1053 Path: /opt/images/vllm-24.10.sqsh
1054 Command:
1055 - vllm
1056 - serve
1057 - meta-llama/Llama-3.1-8B-Instruct
1058 - --port
1059 - '8000'
1060 Gpus: !Devices [0, GPU-4b9d7a3c-2f1e-4d5a-9b8c-7e6f5a4b3c2d]
1061 Mounts:
1062 - /srv/models:/root/.cache/huggingface
1063 - /srv/config:/config:ro
1064 Env:
1065 TZ: Europe/Paris
1066 HF_TOKEN:
1067 SecRef: hf_token
1068 Provider: files
1069 Root: true
1070 ReadWrite: true
1071 RestartPolicy: Always
1072"
1073 }
1074
1075 fn full_config() -> EnrootServiceConfig {
1076 let state: EnrootExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1077 match state.state {
1078 EnrootServiceState::Running(config) => *config,
1079 other => panic!("unexpected state {:?}", other),
1080 }
1081 }
1082
1083 fn id(scope: UnitScope) -> ServiceUnitId {
1084 ServiceUnitId::new(ServiceEngine::Enroot, "vllm".parse().unwrap(), scope)
1085 }
1086
1087 fn block_on<F: std::future::Future>(future: F) -> F::Output {
1088 tokio::runtime::Builder::new_current_thread()
1089 .build()
1090 .unwrap()
1091 .block_on(future)
1092 }
1093
1094 #[test]
1095 fn parsing_enroot_module_block_from_yaml_str() {
1096 let config = full_config();
1097 assert_eq!(config.image.path().as_str(), "/opt/images/vllm-24.10.sqsh");
1098 assert_eq!(
1099 config
1100 .env
1101 .as_ref()
1102 .unwrap()
1103 .get(&"HF_TOKEN".parse().unwrap()),
1104 Some(&Parameter::Secret(SecretReference::from(
1105 "hf_token",
1106 Some("files".to_string())
1107 )))
1108 );
1109 assert!(config.root && config.read_write);
1110
1111 let raw = "---
1112- Name: legacy
1113 State: Absent
1114- Name: app
1115 State: !Stopped
1116 Image: !Local /opt/images/app.sqsh
1117- Name: llm
1118 State: !Running
1119 Image: !Local /opt/images/llm.sqsh
1120 Gpus: All
1121";
1122 let states: Vec<EnrootExpectedState> = yaml_serde::from_str(raw).unwrap();
1123 assert_eq!(states[0].state, EnrootServiceState::Absent);
1124 match &states[1].state {
1125 EnrootServiceState::Stopped(config) => {
1126 assert_eq!(config.command, None);
1127 assert_eq!(config.restart_policy, UnitRestartPolicy::OnFailure);
1128 assert!(!config.root && !config.read_write);
1129 }
1130 other => panic!("unexpected state {:?}", other),
1131 }
1132 match &states[2].state {
1133 EnrootServiceState::Running(config) => {
1134 assert_eq!(config.gpus, Some(GpuSelection::All))
1135 }
1136 other => panic!("unexpected state {:?}", other),
1137 }
1138 }
1139
1140 #[test]
1141 fn serialization_round_trip_preserves_state() {
1142 let state: EnrootExpectedState = yaml_serde::from_str(full_yaml()).unwrap();
1144 let json = serde_json::to_string(&state).unwrap();
1145 assert_eq!(
1146 serde_json::from_str::<EnrootExpectedState>(&json).unwrap(),
1147 state
1148 );
1149 }
1150
1151 #[test]
1152 fn invalid_states_are_rejected_while_parsing() {
1153 let base = "Name: llm\nState: !Running\n Image: !Local /opt/llm.sqsh\n";
1154 let rejected = [
1155 (
1157 "Name: old\nState: !Absent\n Image: !Local /opt/x.sqsh\n",
1158 "expected unit",
1159 ),
1160 (
1162 "Name: llm\nState: !Running\n Root: true\n",
1163 "missing field `Image`",
1164 ),
1165 (
1167 "Name: llm\nState: !Running\n Image: !Imported\n Source: oras://ghcr.io/x/y\n Path: /opt/x.sqsh\n",
1168 "supported schemes",
1169 ),
1170 (
1171 "Name: llm\nState: !Running\n Image: !Imported\n Source: docker://vllm/vllm-openai\n",
1172 "missing field `Path`",
1173 ),
1174 (
1175 "Name: llm\nState: !Running\n Image: !Local x.sqsh\n",
1176 "absolute",
1177 ),
1178 (
1179 &format!("{} Command: []\n", base),
1180 "Command can't be empty",
1181 ),
1182 (&format!("{} Gpus: !Devices []\n", base), "can't be empty"),
1184 (&format!("{} Gpus: !Devices [gpu0]\n", base), "GPU-<uuid>"),
1185 (
1186 &format!(
1187 "{} Gpus: All\n Env:\n NVIDIA_VISIBLE_DEVICES: '0'\n",
1188 base
1189 ),
1190 "set by Gpus",
1191 ),
1192 (
1194 &format!("{} Env:\n ENROOT_DATA_PATH: /tmp\n", base),
1195 "configure enroot",
1196 ),
1197 (
1198 &format!("{} Env:\n PATH: /bin\n", base),
1199 "configure enroot",
1200 ),
1201 (&format!("{} Mounts: [/srv:srv]\n", base), "absolute"),
1202 (&format!("{} Mounts: ['/srv:/srv:z']\n", base), "ro or rw"),
1203 (&format!("{} Privileged: true\n", base), "unknown field"),
1204 ];
1205 for (raw, reason) in rejected {
1206 let error = yaml_serde::from_str::<EnrootExpectedState>(raw)
1207 .expect_err(&format!("should be rejected: {}", raw))
1208 .to_string();
1209 assert!(
1210 error.contains(reason),
1211 "{:?} rejected for another reason: {}",
1212 raw,
1213 error
1214 );
1215 }
1216
1217 assert!(
1219 yaml_serde::from_str::<EnrootExpectedState>(&format!(
1220 "{} Env:\n NVIDIA_VISIBLE_DEVICES: '0'\n",
1221 base
1222 ))
1223 .is_ok()
1224 );
1225 let built = EnrootExpectedState::running(
1226 "llm".parse().unwrap(),
1227 EnrootServiceConfig::new(EnrootImage::Local("/opt/llm.sqsh".parse().unwrap()))
1228 .with_gpus(GpuSelection::All)
1229 .with_env(
1230 "NVIDIA_DRIVER_CAPABILITIES".parse().unwrap(),
1231 Parameter::Clear("all".to_string()),
1232 ),
1233 );
1234 assert!(built.check().is_err());
1235 }
1236
1237 #[test]
1238 fn exec_start_follows_enroot_cli() {
1239 let name = "vllm".parse().unwrap();
1240 assert_eq!(
1241 full_config().exec_start("/usr/bin/enroot", &name),
1242 vec![
1243 "/usr/bin/enroot",
1244 "start",
1245 "--root",
1246 "--rw",
1247 "--mount",
1248 "/srv/models:/root/.cache/huggingface:none:x-create=auto,rbind",
1249 "--mount",
1250 "/srv/config:/config:none:x-create=auto,rbind,ro",
1251 "--env",
1252 "HF_TOKEN",
1253 "--env",
1254 "TZ",
1255 "--env",
1256 "NVIDIA_VISIBLE_DEVICES",
1257 "--env",
1258 "NVIDIA_DRIVER_CAPABILITIES",
1259 "vllm",
1260 "vllm",
1261 "serve",
1262 "meta-llama/Llama-3.1-8B-Instruct",
1263 "--port",
1264 "8000",
1265 ]
1266 );
1267 let simple = EnrootServiceConfig::new(EnrootImage::Local("/opt/a.sqsh".parse().unwrap()));
1268 assert_eq!(
1269 simple.exec_start("/usr/bin/enroot", &name),
1270 vec!["/usr/bin/enroot", "start", "vllm"]
1271 );
1272 }
1273
1274 #[test]
1275 fn unit_and_environment_file_are_rendered() {
1276 let config = full_config();
1277 let unit = config.unit_content(
1278 &id(UnitScope::System),
1279 "/usr/bin/enroot",
1280 &Some("/root".to_string()),
1281 );
1282 assert!(unit.contains("\nEnvironment=HOME=/root\n"));
1283 assert!(unit.contains("\nEnvironmentFile=/etc/regent-sdk/enroot/vllm.env\n"));
1284 let unit = config.unit_content(&id(UnitScope::User), "/usr/bin/enroot", &None);
1285 assert!(!unit.contains("Environment=HOME"));
1286 assert!(unit.contains("\nEnvironmentFile=%E/regent-sdk/enroot/vllm.env\n"));
1287
1288 let config = EnrootServiceConfig::new(EnrootImage::Local("/opt/a.sqsh".parse().unwrap()))
1289 .with_gpus(GpuSelection::Devices(
1290 vec!["1".parse().unwrap(), "0".parse().unwrap()]
1291 .try_into()
1292 .unwrap(),
1293 ))
1294 .with_env(
1295 "HF_TOKEN".parse().unwrap(),
1296 Parameter::Clear("hf_\"x\"".to_string()),
1297 );
1298 assert_eq!(
1299 block_on(config.env_content(&None)).unwrap(),
1300 "# Managed by regent-sdk: manual changes will be overwritten
1301HF_TOKEN=\"hf_\\\"x\\\"\"
1302NVIDIA_VISIBLE_DEVICES=\"0,1\"
1303NVIDIA_DRIVER_CAPABILITIES=\"compute,utility\"
1304"
1305 );
1306 }
1307
1308 #[test]
1309 fn container_drift_is_detected_through_the_marker() {
1310 let image: AbsolutePath = "/opt/images/vllm-24.10.sqsh".parse().unwrap();
1311 let marker = marker_content(&image, "1234 1700000000");
1312 assert_eq!(
1313 marker,
1314 "image=/opt/images/vllm-24.10.sqsh\nidentity=1234 1700000000\n"
1315 );
1316 assert!(recreation_reasons(Some(&marker), &image, "1234 1700000000").is_empty());
1317 assert_eq!(
1318 recreation_reasons(None, &image, "1234 1700000000"),
1319 vec!["image can't be verified on a container which was not created by Regent"]
1320 );
1321 assert_eq!(
1322 recreation_reasons(Some(&marker), &image, "1234 1800000000"),
1323 vec!["image /opt/images/vllm-24.10.sqsh changed since the container was created"]
1324 );
1325 let other: AbsolutePath = "/opt/images/vllm-24.11.sqsh".parse().unwrap();
1326 assert_eq!(
1327 recreation_reasons(Some(&marker), &other, "1234 1700000000"),
1328 vec![
1329 "container was created from \"/opt/images/vllm-24.10.sqsh\", expected /opt/images/vllm-24.11.sqsh"
1330 ]
1331 );
1332
1333 assert_eq!(
1334 parse_identity("stat: warning\n1234 1700000000\n"),
1335 Some("1234 1700000000".to_string())
1336 );
1337 assert_eq!(parse_identity("stat: cannot stat"), None);
1338 let name = "vllm".parse().unwrap();
1339 assert!(is_listed("other\nvllm\n", &name));
1340 assert!(!is_listed("vllm2\n", &name));
1341 }
1342
1343 #[test]
1344 fn container_commands_follow_enroot_cli() {
1345 let image: AbsolutePath = "/opt/images/vllm.sqsh".parse().unwrap();
1346 assert_eq!(
1347 create_command(&id(UnitScope::System), &image, false),
1348 "enroot create --name 'vllm' '/opt/images/vllm.sqsh' && (umask 077 && mkdir -p /etc/regent-sdk/enroot && printf 'image=%s\\nidentity=%s\\n' '/opt/images/vllm.sqsh' \"$(stat -c '%s %Y' '/opt/images/vllm.sqsh')\" > /etc/regent-sdk/enroot/'vllm.image')"
1349 );
1350 assert!(
1351 create_command(&id(UnitScope::System), &image, true).starts_with(
1352 "{ 'systemctl' 'stop' 'regent-enroot-vllm.service' >/dev/null 2>&1; true; } && enroot remove --force 'vllm' && enroot create"
1353 )
1354 );
1355 assert_eq!(
1356 remove_command(&id(UnitScope::User), true),
1357 "enroot remove --force 'vllm' && rm -f \"${XDG_CONFIG_HOME:-$HOME/.config}\"/regent-sdk/enroot/'vllm.image'"
1358 );
1359 assert_eq!(
1360 import_command(&EnrootImportedImage {
1361 source: "docker://nvcr.io#nvidia/vllm:24.10-py3".parse().unwrap(),
1362 path: image,
1363 }),
1364 "mkdir -p '/opt/images' && enroot import -o '/opt/images/vllm.sqsh' 'docker://nvcr.io#nvidia/vllm:24.10-py3'"
1365 );
1366 }
1367
1368 #[test]
1369 fn remediations_never_show_secret_values() {
1370 let call = EnrootApiCall::from(
1371 EnrootInternalApiCall::WriteServiceFiles {
1372 unit: id(UnitScope::User),
1373 config: Box::new(full_config()),
1374 enroot_path: "/usr/bin/enroot".to_string(),
1375 home: None,
1376 reasons: vec!["environment file differs".to_string()],
1377 },
1378 Privilege::None,
1379 );
1380 assert_eq!(
1381 call.display(),
1382 "Write regent-enroot-vllm.service (user unit) and its environment file (environment file differs)"
1383 );
1384 }
1385
1386 #[test]
1387 fn choice_methods_match_the_explicit_enums() {
1388 let source = || {
1389 "docker://vllm/vllm-openai:v0.6.3"
1390 .parse::<EnrootImageUri>()
1391 .unwrap()
1392 };
1393 let path = || {
1394 "/opt/images/vllm-0.6.3.sqsh"
1395 .parse::<AbsolutePath>()
1396 .unwrap()
1397 };
1398 assert_eq!(EnrootImage::local(path()), EnrootImage::Local(path()));
1399 assert_eq!(
1400 EnrootServiceConfig::from_local_image(path()),
1401 EnrootServiceConfig::new(EnrootImage::Local(path()))
1402 );
1403 assert_eq!(
1404 EnrootServiceConfig::from_imported_image(source(), path()),
1405 EnrootServiceConfig::new(EnrootImage::Imported(EnrootImportedImage {
1406 source: source(),
1407 path: path(),
1408 }))
1409 );
1410
1411 let new = || EnrootServiceConfig::from_local_image(path());
1412 assert_eq!(new().with_all_gpus(), new().with_gpus(GpuSelection::All));
1413 let devices = GpuSelection::Devices(
1414 GpuList::try_from(vec!["0".parse().unwrap(), "MIG-1a2b".parse().unwrap()]).unwrap(),
1415 );
1416 assert_eq!(
1417 new()
1418 .with_all_gpus()
1419 .with_gpu_device("0".parse().unwrap())
1420 .with_gpu_device("MIG-1a2b".parse().unwrap()),
1421 new().with_gpus(devices)
1422 );
1423 assert_eq!(
1424 new().with_gpu_device("0".parse().unwrap()).with_all_gpus(),
1425 new().with_gpus(GpuSelection::All)
1426 );
1427
1428 for (config, policy) in [
1429 (new().with_restart_never(), UnitRestartPolicy::No),
1430 (
1431 new().with_restart_always().with_restart_on_failure(),
1432 UnitRestartPolicy::OnFailure,
1433 ),
1434 (new().with_restart_always(), UnitRestartPolicy::Always),
1435 ] {
1436 assert_eq!(config, new().with_restart_policy(policy));
1437 }
1438 }
1439}