1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use a3s_box_core::config::ResourceLimits;
7use a3s_box_core::log::LogConfig;
8use a3s_box_core::{
9 CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, ExecutionResourceUpdate,
10 ExecutionSnapshotId, NetworkMode, OperationId, ResolvedExecutionPlan,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16pub use a3s_box_core::ExecutionHealthCheck as HealthCheck;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BoxRecord {
21 pub id: String,
23 pub short_id: String,
25 pub name: String,
27 pub image: String,
29 #[serde(default)]
31 pub isolation: ExecutionIsolation,
32 #[serde(default)]
38 pub managed_execution: Option<ManagedExecutionMetadata>,
39 pub status: String,
45 pub pid: Option<u32>,
50 #[serde(default)]
52 pub pid_start_time: Option<u64>,
53 pub cpus: u32,
55 pub memory_mb: u32,
57 pub volumes: Vec<String>,
59 #[serde(default)]
61 pub virtiofs_cache: Option<String>,
62 pub env: HashMap<String, String>,
64 pub cmd: Vec<String>,
66 #[serde(default)]
68 pub entrypoint: Option<Vec<String>>,
69 pub box_dir: PathBuf,
71 #[serde(default)]
73 pub exec_socket_path: PathBuf,
74 pub console_log: PathBuf,
76 pub created_at: DateTime<Utc>,
78 pub started_at: Option<DateTime<Utc>>,
82 pub auto_remove: bool,
84 #[serde(default)]
86 pub hostname: Option<String>,
87 #[serde(default)]
89 pub user: Option<String>,
90 #[serde(default)]
92 pub workdir: Option<String>,
93 #[serde(default = "default_restart_policy")]
95 pub restart_policy: String,
96 #[serde(default)]
98 pub port_map: Vec<String>,
99 #[serde(default)]
101 pub labels: HashMap<String, String>,
102 #[serde(default)]
104 pub stopped_by_user: bool,
105 #[serde(default)]
107 pub restart_count: u32,
108 #[serde(default)]
110 pub max_restart_count: u32,
111 #[serde(default)]
113 pub exit_code: Option<i32>,
114 #[serde(default)]
116 pub health_check: Option<HealthCheck>,
117 #[serde(default)]
119 pub healthcheck_disabled: bool,
120 #[serde(default = "default_health_status")]
122 pub health_status: String,
123 #[serde(default)]
125 pub health_retries: u32,
126 #[serde(default)]
128 pub health_last_check: Option<DateTime<Utc>>,
129 #[serde(default)]
131 pub network_mode: NetworkMode,
132 #[serde(default)]
134 pub network_name: Option<String>,
135 #[serde(default)]
137 pub volume_names: Vec<String>,
138 #[serde(default)]
140 pub tmpfs: Vec<String>,
141 #[serde(default)]
143 pub anonymous_volumes: Vec<String>,
144 #[serde(default)]
146 pub resource_limits: ResourceLimits,
147 #[serde(default)]
149 pub log_config: LogConfig,
150 #[serde(default)]
152 pub add_host: Vec<String>,
153 #[serde(default)]
155 pub platform: Option<String>,
156 #[serde(default)]
158 pub init: bool,
159 #[serde(default)]
161 pub read_only: bool,
162 #[serde(default)]
164 pub cap_add: Vec<String>,
165 #[serde(default)]
167 pub cap_drop: Vec<String>,
168 #[serde(default)]
170 pub security_opt: Vec<String>,
171 #[serde(default)]
173 pub privileged: bool,
174 #[serde(default)]
176 pub devices: Vec<String>,
177 #[serde(default)]
179 pub gpus: Option<String>,
180 #[serde(default)]
182 pub shm_size: Option<u64>,
183 #[serde(default)]
185 pub stop_signal: Option<String>,
186 #[serde(default)]
188 pub stop_timeout: Option<u64>,
189 #[serde(default)]
191 pub oom_kill_disable: bool,
192 #[serde(default)]
194 pub oom_score_adj: Option<i32>,
195}
196
197impl BoxRecord {
198 pub fn make_short_id(id: &str) -> String {
200 id.replace('-', "").chars().take(12).collect()
201 }
202
203 pub fn is_active(&self) -> bool {
205 if self.managed_execution.is_some() {
206 return self
207 .managed_state()
208 .is_ok_and(|state| state.is_some_and(ManagedExecutionState::keeps_resources));
209 }
210 matches!(self.status.as_str(), "running" | "paused")
211 }
212
213 pub fn managed_state(&self) -> a3s_box_core::Result<Option<ManagedExecutionState>> {
219 let Some(metadata) = self.managed_execution.as_ref() else {
220 return Ok(None);
221 };
222 let state = ManagedExecutionState::from_status(&self.status)?;
223 validate_pending_operation(state, metadata)?;
224 Ok(Some(state))
225 }
226
227 pub fn status_summary(&self) -> String {
229 let mut annotations = Vec::new();
230 if self.is_active() && self.health_check.is_some() && self.health_status != "none" {
231 annotations.push(self.health_status.clone());
232 }
233 if matches!(self.status.as_str(), "stopped" | "dead") {
234 if let Some(exit_code) = self.exit_code {
235 annotations.push(format!("Exit {exit_code}"));
236 }
237 }
238 if self.restart_count > 0 {
239 annotations.push(format!("Restarts: {}", self.restart_count));
240 }
241 if annotations.is_empty() {
242 self.status.clone()
243 } else {
244 format!("{} ({})", self.status, annotations.join(", "))
245 }
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ManagedExecutionState {
257 Creating,
258 Created,
259 Starting,
260 Running,
261 Pausing,
262 Paused,
263 Resuming,
264 UpdatingResources,
265 Snapshotting,
266 Killing,
267 RestartStopping,
268 RestartStarting,
269 Removing,
270 Stopped,
271 Failed,
272}
273
274impl ManagedExecutionState {
275 pub const fn as_status(self) -> &'static str {
277 match self {
278 Self::Creating => "creating",
279 Self::Created => "created",
280 Self::Starting => "starting",
281 Self::Running => "running",
282 Self::Pausing => "pausing",
283 Self::Paused => "paused",
284 Self::Resuming => "resuming",
285 Self::UpdatingResources => "updating_resources",
286 Self::Snapshotting => "snapshotting",
287 Self::Killing => "killing",
288 Self::RestartStopping => "restart_stopping",
289 Self::RestartStarting => "restart_starting",
290 Self::Removing => "removing",
291 Self::Stopped => "stopped",
292 Self::Failed => "failed",
293 }
294 }
295
296 pub fn from_status(status: &str) -> a3s_box_core::Result<Self> {
298 match status {
299 "creating" => Ok(Self::Creating),
300 "created" => Ok(Self::Created),
301 "starting" => Ok(Self::Starting),
302 "running" => Ok(Self::Running),
303 "pausing" => Ok(Self::Pausing),
304 "paused" => Ok(Self::Paused),
305 "resuming" => Ok(Self::Resuming),
306 "updating_resources" => Ok(Self::UpdatingResources),
307 "snapshotting" => Ok(Self::Snapshotting),
308 "killing" => Ok(Self::Killing),
309 "restart_stopping" => Ok(Self::RestartStopping),
310 "restart_starting" => Ok(Self::RestartStarting),
311 "removing" => Ok(Self::Removing),
312 "stopped" => Ok(Self::Stopped),
313 "dead" | "failed" => Ok(Self::Failed),
314 other => Err(a3s_box_core::BoxError::StateError(format!(
315 "unknown managed execution state: {other}"
316 ))),
317 }
318 }
319
320 pub const fn keeps_resources(self) -> bool {
322 !matches!(
323 self,
324 Self::Creating | Self::Created | Self::Stopped | Self::Failed
325 )
326 }
327
328 pub const fn is_terminal(self) -> bool {
330 matches!(self, Self::Stopped | Self::Failed)
331 }
332}
333
334impl std::fmt::Display for ManagedExecutionState {
335 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 formatter.write_str(self.as_status())
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct ManagedExecutionMetadata {
345 pub operation_id: OperationId,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub creation_intent_digest: Option<String>,
354 pub generation: ExecutionGeneration,
356 pub request: CreateExecutionRequest,
358 #[serde(default)]
361 pub oci_runtime: Option<crate::local_execution::OciRuntimeBinding>,
362 #[serde(default, skip_serializing_if = "ManagedRuntimeRoute::is_unspecified")]
368 pub runtime_route: ManagedRuntimeRoute,
369 pub plan: ResolvedExecutionPlan,
371 #[serde(default)]
373 pub pending_operation: Option<ManagedExecutionOperation>,
374 #[serde(default)]
376 pub last_restart: Option<ManagedRestartCompletion>,
377 #[serde(default)]
379 pub last_resource_update: Option<ManagedResourceUpdateCompletion>,
380 #[serde(default)]
382 pub finished_at: Option<DateTime<Utc>>,
383 #[serde(default = "default_paused_with_memory")]
388 pub paused_with_memory: bool,
389}
390
391#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum ManagedRuntimeRoute {
395 #[default]
397 Unspecified,
398 BoxVm,
400 OciSdk,
402}
403
404impl ManagedRuntimeRoute {
405 pub const fn is_unspecified(&self) -> bool {
406 matches!(self, Self::Unspecified)
407 }
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(tag = "kind", rename_all = "snake_case")]
413pub enum ManagedExecutionOperation {
414 Start,
415 Pause {
416 keep_memory: bool,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 operation_id: Option<OperationId>,
420 },
421 Resume {
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 operation_id: Option<OperationId>,
425 },
426 UpdateResources {
427 operation_id: OperationId,
428 update: ExecutionResourceUpdate,
429 },
430 Snapshot {
431 snapshot_id: ExecutionSnapshotId,
432 source_state: ManagedExecutionState,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
440 operation_id: Option<OperationId>,
441 #[serde(default)]
448 freezer_applied: bool,
449 },
450 Kill {
451 #[serde(default)]
452 signal: Option<i32>,
453 #[serde(default)]
454 timeout_secs: Option<u64>,
455 },
456 Remove,
457 Restart {
458 operation_id: OperationId,
459 source_generation: ExecutionGeneration,
460 source_state: ManagedExecutionState,
461 #[serde(default)]
462 stop_timeout_secs: Option<u64>,
463 },
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum ManagedRestartOutcome {
470 Running,
471 Stopped,
472 Failed,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct ManagedRestartCompletion {
478 pub operation_id: OperationId,
479 pub source_generation: ExecutionGeneration,
480 pub target_generation: ExecutionGeneration,
481 pub outcome: ManagedRestartOutcome,
482 #[serde(default)]
483 pub stop_timeout_secs: Option<u64>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ManagedResourceUpdateCompletion {
489 pub operation_id: OperationId,
490 pub generation: ExecutionGeneration,
491 pub update: ExecutionResourceUpdate,
492}
493
494impl ManagedExecutionMetadata {
495 pub fn new(
497 operation_id: OperationId,
498 generation: ExecutionGeneration,
499 request: CreateExecutionRequest,
500 ) -> a3s_box_core::Result<Self> {
501 if request.external_sandbox_id.trim().is_empty() {
502 return Err(a3s_box_core::BoxError::ConfigError(
503 "external sandbox ID cannot be empty".to_string(),
504 ));
505 }
506 let plan = a3s_box_core::resolve_execution(&request.config)?;
507 let creation_intent_digest = Some(digest_creation_request(&request)?);
508 Ok(Self {
509 operation_id,
510 creation_intent_digest,
511 generation,
512 request,
513 oci_runtime: None,
514 runtime_route: ManagedRuntimeRoute::Unspecified,
515 plan,
516 pending_operation: None,
517 last_restart: None,
518 last_resource_update: None,
519 finished_at: None,
520 paused_with_memory: true,
521 })
522 }
523
524 #[must_use]
530 pub fn is_oci_routed(&self) -> bool {
531 self.runtime_route == ManagedRuntimeRoute::OciSdk || self.oci_runtime.is_some()
532 }
533
534 pub fn validate(&self) -> a3s_box_core::Result<()> {
536 if self.request.external_sandbox_id.trim().is_empty() {
537 return Err(a3s_box_core::BoxError::StateError(
538 "managed execution has an empty external sandbox ID".to_string(),
539 ));
540 }
541 let resolved = a3s_box_core::resolve_execution(&self.request.config)?;
542 if let Some(digest) = self.creation_intent_digest.as_deref() {
543 validate_creation_intent_digest(digest)?;
544 }
545 if !execution_plan_matches(&resolved, &self.plan) {
546 return Err(a3s_box_core::BoxError::StateError(
547 "managed execution plan does not match its persisted creation request".to_string(),
548 ));
549 }
550 if let Some(binding) = &self.oci_runtime {
551 if self.runtime_route == ManagedRuntimeRoute::BoxVm {
552 return Err(a3s_box_core::BoxError::StateError(
553 "Box VM-routed execution contains an A3S OCI binding".to_string(),
554 ));
555 }
556 binding
557 .validate()
558 .map_err(|error| a3s_box_core::BoxError::StateError(error.to_string()))?;
559 let expected_isolation =
560 crate::local_execution::oci_isolation_request(self.request.config.isolation)
561 .class();
562 if binding.isolation != expected_isolation {
563 return Err(a3s_box_core::BoxError::StateError(
564 "A3S OCI binding weakens or changes the requested isolation".to_string(),
565 ));
566 }
567 }
568 if let Some(completed) = &self.last_restart {
569 let expected_target = next_generation(completed.source_generation)?;
570 if completed.target_generation != expected_target {
571 return Err(a3s_box_core::BoxError::StateError(format!(
572 "completed restart {} has inconsistent generations",
573 completed.operation_id
574 )));
575 }
576 validate_stop_timeout(completed.stop_timeout_secs)?;
577 }
578 if let Some(completed) = &self.last_resource_update {
579 completed.update.validate().map_err(|error| {
580 a3s_box_core::BoxError::StateError(format!(
581 "completed resource update {} is invalid: {error}",
582 completed.operation_id
583 ))
584 })?;
585 if completed.generation > self.generation {
586 return Err(a3s_box_core::BoxError::StateError(format!(
587 "completed resource update {} belongs to future generation {}",
588 completed.operation_id,
589 completed.generation.get()
590 )));
591 }
592 }
593 Ok(())
594 }
595}
596
597fn digest_creation_request(request: &CreateExecutionRequest) -> a3s_box_core::Result<String> {
598 let value = serde_json::to_value(request).map_err(|error| {
599 a3s_box_core::BoxError::ConfigError(format!(
600 "failed to encode managed creation intent: {error}"
601 ))
602 })?;
603 let encoded = serde_json::to_vec(&value).map_err(|error| {
604 a3s_box_core::BoxError::ConfigError(format!(
605 "failed to canonicalize managed creation intent: {error}"
606 ))
607 })?;
608 Ok(format!("sha256:{}", hex::encode(Sha256::digest(encoded))))
609}
610
611fn validate_creation_intent_digest(digest: &str) -> a3s_box_core::Result<()> {
612 let valid = digest.strip_prefix("sha256:").is_some_and(|value| {
613 value.len() == 64
614 && value
615 .bytes()
616 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
617 });
618 if valid {
619 Ok(())
620 } else {
621 Err(a3s_box_core::BoxError::StateError(
622 "managed creation intent digest is invalid".to_string(),
623 ))
624 }
625}
626
627fn execution_plan_matches(
628 resolved: &ResolvedExecutionPlan,
629 persisted: &ResolvedExecutionPlan,
630) -> bool {
631 resolved == persisted
632}
633
634fn validate_pending_operation(
635 state: ManagedExecutionState,
636 metadata: &ManagedExecutionMetadata,
637) -> a3s_box_core::Result<()> {
638 let operation = metadata.pending_operation.as_ref();
639 let consistent = matches!(
640 (state, operation),
641 (
642 ManagedExecutionState::Starting,
643 Some(ManagedExecutionOperation::Start)
644 ) | (
645 ManagedExecutionState::Pausing,
646 Some(ManagedExecutionOperation::Pause { .. })
647 ) | (
648 ManagedExecutionState::Resuming,
649 Some(ManagedExecutionOperation::Resume { .. })
650 ) | (
651 ManagedExecutionState::UpdatingResources,
652 Some(ManagedExecutionOperation::UpdateResources { .. })
653 ) | (
654 ManagedExecutionState::Snapshotting,
655 Some(ManagedExecutionOperation::Snapshot { .. })
656 ) | (
657 ManagedExecutionState::Killing,
658 Some(ManagedExecutionOperation::Kill { .. })
659 ) | (
660 ManagedExecutionState::Removing,
661 Some(ManagedExecutionOperation::Remove)
662 ) | (
663 ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting,
664 Some(ManagedExecutionOperation::Restart { .. })
665 ) | (
666 ManagedExecutionState::Creating
667 | ManagedExecutionState::Created
668 | ManagedExecutionState::Running
669 | ManagedExecutionState::Paused
670 | ManagedExecutionState::Stopped
671 | ManagedExecutionState::Failed,
672 None
673 )
674 );
675 if !consistent {
676 return Err(a3s_box_core::BoxError::StateError(format!(
677 "managed execution state {state} has inconsistent pending operation"
678 )));
679 }
680
681 if let Some(ManagedExecutionOperation::Restart {
682 source_generation,
683 source_state,
684 stop_timeout_secs,
685 ..
686 }) = operation
687 {
688 if !matches!(
689 source_state,
690 ManagedExecutionState::Created
691 | ManagedExecutionState::Running
692 | ManagedExecutionState::Paused
693 | ManagedExecutionState::Stopped
694 | ManagedExecutionState::Failed
695 ) {
696 return Err(a3s_box_core::BoxError::StateError(
697 "restart source state is not stable".to_string(),
698 ));
699 }
700 let expected = match state {
701 ManagedExecutionState::RestartStopping => *source_generation,
702 ManagedExecutionState::RestartStarting => next_generation(*source_generation)?,
703 _ => {
704 return Err(a3s_box_core::BoxError::StateError(
705 "restart operation is attached to a non-restart state".to_string(),
706 ))
707 }
708 };
709 if metadata.generation != expected {
710 return Err(a3s_box_core::BoxError::StateError(format!(
711 "restart state {state} has generation {}, expected {}",
712 metadata.generation.get(),
713 expected.get()
714 )));
715 }
716 validate_stop_timeout(*stop_timeout_secs)?;
717 }
718 if let Some(ManagedExecutionOperation::Snapshot {
719 source_state,
720 freezer_applied,
721 ..
722 }) = operation
723 {
724 if state != ManagedExecutionState::Snapshotting
725 || !matches!(
726 source_state,
727 ManagedExecutionState::Running | ManagedExecutionState::Paused
728 )
729 {
730 return Err(a3s_box_core::BoxError::StateError(
731 "snapshot operation has an invalid source state".to_string(),
732 ));
733 }
734 if *freezer_applied && *source_state != ManagedExecutionState::Running {
735 return Err(a3s_box_core::BoxError::StateError(
736 "paused-source snapshot cannot carry a runtime freezer phase".to_string(),
737 ));
738 }
739 }
740 if let Some(ManagedExecutionOperation::UpdateResources { update, .. }) = operation {
741 if state != ManagedExecutionState::UpdatingResources {
742 return Err(a3s_box_core::BoxError::StateError(
743 "resource update operation is attached to a non-update state".to_string(),
744 ));
745 }
746 update.validate().map_err(|error| {
747 a3s_box_core::BoxError::StateError(format!(
748 "persisted resource update is invalid: {error}"
749 ))
750 })?;
751 }
752 if let Some(ManagedExecutionOperation::Kill {
753 signal,
754 timeout_secs,
755 }) = operation
756 {
757 if signal.is_some_and(|signal| signal <= 0 || 128_i32.checked_add(signal).is_none()) {
758 return Err(a3s_box_core::BoxError::StateError(
759 "kill signal must be positive and representable as a Box exit code".to_string(),
760 ));
761 }
762 validate_stop_timeout(*timeout_secs)?;
763 }
764 if !metadata.paused_with_memory {
765 let valid_cold_pause_state = match (state, operation) {
766 (
767 ManagedExecutionState::Pausing,
768 Some(ManagedExecutionOperation::Pause { keep_memory, .. }),
769 ) => !keep_memory,
770 (ManagedExecutionState::Paused | ManagedExecutionState::Resuming, _) => true,
771 (
772 ManagedExecutionState::Snapshotting,
773 Some(ManagedExecutionOperation::Snapshot { source_state, .. }),
774 ) => *source_state == ManagedExecutionState::Paused,
775 (ManagedExecutionState::Killing | ManagedExecutionState::Removing, _) => true,
777 (
778 ManagedExecutionState::RestartStopping,
779 Some(ManagedExecutionOperation::Restart { source_state, .. }),
780 ) => *source_state == ManagedExecutionState::Paused,
781 _ => false,
782 };
783 if !valid_cold_pause_state {
784 return Err(a3s_box_core::BoxError::StateError(format!(
785 "managed execution state {state} cannot retain a filesystem-only pause"
786 )));
787 }
788 }
789 Ok(())
790}
791
792fn validate_stop_timeout(timeout_secs: Option<u64>) -> a3s_box_core::Result<()> {
793 if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) {
794 Err(a3s_box_core::BoxError::StateError(
795 "managed stop timeout is too large".to_string(),
796 ))
797 } else {
798 Ok(())
799 }
800}
801
802fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result<ExecutionGeneration> {
803 let value = generation.get().checked_add(1).ok_or_else(|| {
804 a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string())
805 })?;
806 ExecutionGeneration::new(value).map_err(|error| {
807 a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}"))
808 })
809}
810
811fn default_restart_policy() -> String {
812 "no".to_string()
813}
814
815fn default_health_status() -> String {
816 "none".to_string()
817}
818
819const fn default_paused_with_memory() -> bool {
820 true
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 fn minimal_record() -> serde_json::Value {
828 serde_json::json!({
829 "id": "11111111-1111-4111-8111-111111111111",
830 "short_id": "111111111111",
831 "name": "fixture",
832 "image": "alpine:latest",
833 "status": "created",
834 "pid": null,
835 "cpus": 1,
836 "memory_mb": 128,
837 "volumes": [],
838 "env": {},
839 "cmd": ["sh"],
840 "box_dir": "/tmp/fixture",
841 "console_log": "/tmp/fixture/console.log",
842 "created_at": "2026-07-14T12:00:00Z",
843 "started_at": null,
844 "auto_remove": false
845 })
846 }
847
848 #[test]
849 fn legacy_records_default_without_losing_runtime_fields() {
850 let mut value = minimal_record();
851 value["virtiofs_cache"] = serde_json::json!("always");
852 let record: BoxRecord = serde_json::from_value(value).unwrap();
853
854 assert_eq!(record.isolation, ExecutionIsolation::Microvm);
855 assert!(record.managed_execution.is_none());
856 assert_eq!(record.virtiofs_cache.as_deref(), Some("always"));
857 assert_eq!(record.restart_policy, "no");
858 assert_eq!(record.health_status, "none");
859 assert_eq!(
860 serde_json::to_value(record).unwrap()["virtiofs_cache"],
861 "always"
862 );
863 }
864
865 #[test]
866 fn managed_execution_metadata_round_trips_recovery_intent() {
867 let mut config = a3s_box_core::BoxConfig {
868 image: "alpine:latest".to_string(),
869 isolation: ExecutionIsolation::Sandbox,
870 ..Default::default()
871 };
872 config.resources.vcpus = 1;
873 config.resources.memory_mb = 128;
874 let metadata = ManagedExecutionMetadata::new(
875 OperationId::new("create-op-1").unwrap(),
876 ExecutionGeneration::INITIAL,
877 CreateExecutionRequest {
878 external_sandbox_id: "sandbox-1".to_string(),
879 config,
880 labels: Default::default(),
881 policy: Default::default(),
882 rootfs_snapshot_id: None,
883 },
884 )
885 .unwrap();
886 let mut value = minimal_record();
887 value["managed_execution"] = serde_json::to_value(metadata).unwrap();
888 value["managed_execution"]
889 .as_object_mut()
890 .unwrap()
891 .remove("paused_with_memory");
892
893 let record: BoxRecord = serde_json::from_value(value).unwrap();
894 let encoded = serde_json::to_value(&record).unwrap();
895 assert_eq!(
896 record.managed_state().unwrap(),
897 Some(ManagedExecutionState::Created)
898 );
899 assert!(!record.is_active());
900 let managed = record.managed_execution.unwrap();
901
902 assert_eq!(managed.operation_id.as_str(), "create-op-1");
903 assert_eq!(managed.generation, ExecutionGeneration::INITIAL);
904 assert_eq!(managed.request.external_sandbox_id, "sandbox-1");
905 assert!(managed.paused_with_memory);
906 assert_eq!(
907 managed.request.config.isolation,
908 ExecutionIsolation::Sandbox
909 );
910 assert_eq!(encoded["managed_execution"]["generation"], 1);
911 assert_eq!(encoded["managed_execution"]["paused_with_memory"], true);
912 assert!(encoded["managed_execution"].get("runtime_route").is_none());
913 assert_eq!(managed.runtime_route, ManagedRuntimeRoute::Unspecified);
914 }
915
916 #[test]
917 fn managed_runtime_route_is_exact_and_legacy_compatible() {
918 let mut metadata = ManagedExecutionMetadata::new(
919 OperationId::new("create-op-route").unwrap(),
920 ExecutionGeneration::INITIAL,
921 CreateExecutionRequest {
922 external_sandbox_id: "sandbox-route".to_string(),
923 config: a3s_box_core::BoxConfig {
924 image: "alpine:latest".to_string(),
925 isolation: ExecutionIsolation::Sandbox,
926 ..Default::default()
927 },
928 labels: Default::default(),
929 policy: Default::default(),
930 rootfs_snapshot_id: None,
931 },
932 )
933 .unwrap();
934 metadata.runtime_route = ManagedRuntimeRoute::OciSdk;
935 assert!(metadata.is_oci_routed());
936
937 let encoded = serde_json::to_value(&metadata).unwrap();
938 assert_eq!(encoded["runtime_route"], "oci_sdk");
939 let decoded: ManagedExecutionMetadata = serde_json::from_value(encoded).unwrap();
940 assert_eq!(decoded.runtime_route, ManagedRuntimeRoute::OciSdk);
941
942 let mut legacy = serde_json::to_value(decoded).unwrap();
943 legacy.as_object_mut().unwrap().remove("runtime_route");
944 let decoded: ManagedExecutionMetadata = serde_json::from_value(legacy).unwrap();
945 assert_eq!(decoded.runtime_route, ManagedRuntimeRoute::Unspecified);
946 assert!(!decoded.is_oci_routed());
947 }
948
949 #[test]
950 fn legacy_kill_operation_defaults_new_termination_options() {
951 let operation: ManagedExecutionOperation =
952 serde_json::from_value(serde_json::json!({ "kind": "kill" })).unwrap();
953
954 assert_eq!(
955 operation,
956 ManagedExecutionOperation::Kill {
957 signal: None,
958 timeout_secs: None,
959 }
960 );
961 }
962
963 #[test]
964 fn legacy_freezer_operations_default_claim_identity() {
965 let pause: ManagedExecutionOperation = serde_json::from_value(serde_json::json!({
966 "kind": "pause",
967 "keep_memory": true
968 }))
969 .unwrap();
970 let resume: ManagedExecutionOperation =
971 serde_json::from_value(serde_json::json!({ "kind": "resume" })).unwrap();
972 let snapshot: ManagedExecutionOperation = serde_json::from_value(serde_json::json!({
973 "kind": "snapshot",
974 "snapshot_id": "legacy-snapshot",
975 "source_state": "running"
976 }))
977 .unwrap();
978
979 assert_eq!(
980 pause,
981 ManagedExecutionOperation::Pause {
982 keep_memory: true,
983 operation_id: None,
984 }
985 );
986 assert_eq!(
987 resume,
988 ManagedExecutionOperation::Resume { operation_id: None }
989 );
990 assert_eq!(
991 snapshot,
992 ManagedExecutionOperation::Snapshot {
993 snapshot_id: ExecutionSnapshotId::new("legacy-snapshot").unwrap(),
994 source_state: ManagedExecutionState::Running,
995 operation_id: None,
996 freezer_applied: false,
997 }
998 );
999 }
1000
1001 #[test]
1002 fn managed_execution_rejects_a_cold_pause_marker_in_running_state() {
1003 let config = a3s_box_core::BoxConfig {
1004 image: "alpine:latest".to_string(),
1005 isolation: ExecutionIsolation::Sandbox,
1006 ..Default::default()
1007 };
1008 let mut metadata = ManagedExecutionMetadata::new(
1009 OperationId::new("create-op-cold-invalid").unwrap(),
1010 ExecutionGeneration::INITIAL,
1011 CreateExecutionRequest {
1012 external_sandbox_id: "sandbox-cold-invalid".to_string(),
1013 config,
1014 labels: Default::default(),
1015 policy: Default::default(),
1016 rootfs_snapshot_id: None,
1017 },
1018 )
1019 .unwrap();
1020 metadata.paused_with_memory = false;
1021 let mut value = minimal_record();
1022 value["status"] = serde_json::json!("running");
1023 value["managed_execution"] = serde_json::to_value(metadata).unwrap();
1024 let record: BoxRecord = serde_json::from_value(value).unwrap();
1025
1026 assert!(record.managed_state().is_err());
1027 }
1028
1029 #[test]
1030 fn managed_execution_validation_rejects_plan_drift() {
1031 let config = a3s_box_core::BoxConfig {
1032 image: "alpine:latest".to_string(),
1033 isolation: ExecutionIsolation::Sandbox,
1034 ..Default::default()
1035 };
1036 let mut metadata = ManagedExecutionMetadata::new(
1037 OperationId::new("create-op-1").unwrap(),
1038 ExecutionGeneration::INITIAL,
1039 CreateExecutionRequest {
1040 external_sandbox_id: "sandbox-1".to_string(),
1041 config,
1042 labels: Default::default(),
1043 policy: Default::default(),
1044 rootfs_snapshot_id: None,
1045 },
1046 )
1047 .unwrap();
1048 metadata.plan =
1049 a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
1050
1051 assert!(metadata.validate().is_err());
1052 }
1053}