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, ExecutionSnapshotId,
10 NetworkMode, OperationId, ResolvedExecutionPlan,
11};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15pub use a3s_box_core::ExecutionHealthCheck as HealthCheck;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BoxRecord {
20 pub id: String,
22 pub short_id: String,
24 pub name: String,
26 pub image: String,
28 #[serde(default)]
30 pub isolation: ExecutionIsolation,
31 #[serde(default)]
37 pub managed_execution: Option<ManagedExecutionMetadata>,
38 pub status: String,
44 pub pid: Option<u32>,
46 #[serde(default)]
48 pub pid_start_time: Option<u64>,
49 pub cpus: u32,
51 pub memory_mb: u32,
53 pub volumes: Vec<String>,
55 #[serde(default)]
57 pub virtiofs_cache: Option<String>,
58 pub env: HashMap<String, String>,
60 pub cmd: Vec<String>,
62 #[serde(default)]
64 pub entrypoint: Option<Vec<String>>,
65 pub box_dir: PathBuf,
67 #[serde(default)]
69 pub exec_socket_path: PathBuf,
70 pub console_log: PathBuf,
72 pub created_at: DateTime<Utc>,
74 pub started_at: Option<DateTime<Utc>>,
76 pub auto_remove: bool,
78 #[serde(default)]
80 pub hostname: Option<String>,
81 #[serde(default)]
83 pub user: Option<String>,
84 #[serde(default)]
86 pub workdir: Option<String>,
87 #[serde(default = "default_restart_policy")]
89 pub restart_policy: String,
90 #[serde(default)]
92 pub port_map: Vec<String>,
93 #[serde(default)]
95 pub labels: HashMap<String, String>,
96 #[serde(default)]
98 pub stopped_by_user: bool,
99 #[serde(default)]
101 pub restart_count: u32,
102 #[serde(default)]
104 pub max_restart_count: u32,
105 #[serde(default)]
107 pub exit_code: Option<i32>,
108 #[serde(default)]
110 pub health_check: Option<HealthCheck>,
111 #[serde(default)]
113 pub healthcheck_disabled: bool,
114 #[serde(default = "default_health_status")]
116 pub health_status: String,
117 #[serde(default)]
119 pub health_retries: u32,
120 #[serde(default)]
122 pub health_last_check: Option<DateTime<Utc>>,
123 #[serde(default)]
125 pub network_mode: NetworkMode,
126 #[serde(default)]
128 pub network_name: Option<String>,
129 #[serde(default)]
131 pub volume_names: Vec<String>,
132 #[serde(default)]
134 pub tmpfs: Vec<String>,
135 #[serde(default)]
137 pub anonymous_volumes: Vec<String>,
138 #[serde(default)]
140 pub resource_limits: ResourceLimits,
141 #[serde(default)]
143 pub log_config: LogConfig,
144 #[serde(default)]
146 pub add_host: Vec<String>,
147 #[serde(default)]
149 pub platform: Option<String>,
150 #[serde(default)]
152 pub init: bool,
153 #[serde(default)]
155 pub read_only: bool,
156 #[serde(default)]
158 pub cap_add: Vec<String>,
159 #[serde(default)]
161 pub cap_drop: Vec<String>,
162 #[serde(default)]
164 pub security_opt: Vec<String>,
165 #[serde(default)]
167 pub privileged: bool,
168 #[serde(default)]
170 pub devices: Vec<String>,
171 #[serde(default)]
173 pub gpus: Option<String>,
174 #[serde(default)]
176 pub shm_size: Option<u64>,
177 #[serde(default)]
179 pub stop_signal: Option<String>,
180 #[serde(default)]
182 pub stop_timeout: Option<u64>,
183 #[serde(default)]
185 pub oom_kill_disable: bool,
186 #[serde(default)]
188 pub oom_score_adj: Option<i32>,
189}
190
191impl BoxRecord {
192 pub fn make_short_id(id: &str) -> String {
194 id.replace('-', "").chars().take(12).collect()
195 }
196
197 pub fn is_active(&self) -> bool {
199 if self.managed_execution.is_some() {
200 return self
201 .managed_state()
202 .is_ok_and(|state| state.is_some_and(ManagedExecutionState::keeps_resources));
203 }
204 matches!(self.status.as_str(), "running" | "paused")
205 }
206
207 pub fn managed_state(&self) -> a3s_box_core::Result<Option<ManagedExecutionState>> {
213 let Some(metadata) = self.managed_execution.as_ref() else {
214 return Ok(None);
215 };
216 let state = ManagedExecutionState::from_status(&self.status)?;
217 validate_pending_operation(state, metadata)?;
218 Ok(Some(state))
219 }
220
221 pub fn status_summary(&self) -> String {
223 let mut annotations = Vec::new();
224 if self.is_active() && self.health_check.is_some() && self.health_status != "none" {
225 annotations.push(self.health_status.clone());
226 }
227 if matches!(self.status.as_str(), "stopped" | "dead") {
228 if let Some(exit_code) = self.exit_code {
229 annotations.push(format!("Exit {exit_code}"));
230 }
231 }
232 if self.restart_count > 0 {
233 annotations.push(format!("Restarts: {}", self.restart_count));
234 }
235 if annotations.is_empty() {
236 self.status.clone()
237 } else {
238 format!("{} ({})", self.status, annotations.join(", "))
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum ManagedExecutionState {
251 Creating,
252 Created,
253 Starting,
254 Running,
255 Pausing,
256 Paused,
257 Resuming,
258 Snapshotting,
259 Killing,
260 RestartStopping,
261 RestartStarting,
262 Removing,
263 Stopped,
264 Failed,
265}
266
267impl ManagedExecutionState {
268 pub const fn as_status(self) -> &'static str {
270 match self {
271 Self::Creating => "creating",
272 Self::Created => "created",
273 Self::Starting => "starting",
274 Self::Running => "running",
275 Self::Pausing => "pausing",
276 Self::Paused => "paused",
277 Self::Resuming => "resuming",
278 Self::Snapshotting => "snapshotting",
279 Self::Killing => "killing",
280 Self::RestartStopping => "restart_stopping",
281 Self::RestartStarting => "restart_starting",
282 Self::Removing => "removing",
283 Self::Stopped => "stopped",
284 Self::Failed => "failed",
285 }
286 }
287
288 pub fn from_status(status: &str) -> a3s_box_core::Result<Self> {
290 match status {
291 "creating" => Ok(Self::Creating),
292 "created" => Ok(Self::Created),
293 "starting" => Ok(Self::Starting),
294 "running" => Ok(Self::Running),
295 "pausing" => Ok(Self::Pausing),
296 "paused" => Ok(Self::Paused),
297 "resuming" => Ok(Self::Resuming),
298 "snapshotting" => Ok(Self::Snapshotting),
299 "killing" => Ok(Self::Killing),
300 "restart_stopping" => Ok(Self::RestartStopping),
301 "restart_starting" => Ok(Self::RestartStarting),
302 "removing" => Ok(Self::Removing),
303 "stopped" => Ok(Self::Stopped),
304 "dead" | "failed" => Ok(Self::Failed),
305 other => Err(a3s_box_core::BoxError::StateError(format!(
306 "unknown managed execution state: {other}"
307 ))),
308 }
309 }
310
311 pub const fn keeps_resources(self) -> bool {
313 !matches!(
314 self,
315 Self::Creating | Self::Created | Self::Stopped | Self::Failed
316 )
317 }
318
319 pub const fn is_terminal(self) -> bool {
321 matches!(self, Self::Stopped | Self::Failed)
322 }
323}
324
325impl std::fmt::Display for ManagedExecutionState {
326 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 formatter.write_str(self.as_status())
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ManagedExecutionMetadata {
336 pub operation_id: OperationId,
338 pub generation: ExecutionGeneration,
340 pub request: CreateExecutionRequest,
342 pub plan: ResolvedExecutionPlan,
344 #[serde(default)]
346 pub pending_operation: Option<ManagedExecutionOperation>,
347 #[serde(default)]
349 pub last_restart: Option<ManagedRestartCompletion>,
350 #[serde(default)]
352 pub finished_at: Option<DateTime<Utc>>,
353 #[serde(default = "default_paused_with_memory")]
358 pub paused_with_memory: bool,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(tag = "kind", rename_all = "snake_case")]
364pub enum ManagedExecutionOperation {
365 Start,
366 Pause {
367 keep_memory: bool,
368 },
369 Resume,
370 Snapshot {
371 snapshot_id: ExecutionSnapshotId,
372 source_state: ManagedExecutionState,
373 },
374 Kill {
375 #[serde(default)]
376 signal: Option<i32>,
377 #[serde(default)]
378 timeout_secs: Option<u64>,
379 },
380 Remove,
381 Restart {
382 operation_id: OperationId,
383 source_generation: ExecutionGeneration,
384 source_state: ManagedExecutionState,
385 #[serde(default)]
386 stop_timeout_secs: Option<u64>,
387 },
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum ManagedRestartOutcome {
394 Running,
395 Failed,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct ManagedRestartCompletion {
401 pub operation_id: OperationId,
402 pub source_generation: ExecutionGeneration,
403 pub target_generation: ExecutionGeneration,
404 pub outcome: ManagedRestartOutcome,
405 #[serde(default)]
406 pub stop_timeout_secs: Option<u64>,
407}
408
409impl ManagedExecutionMetadata {
410 pub fn new(
412 operation_id: OperationId,
413 generation: ExecutionGeneration,
414 request: CreateExecutionRequest,
415 ) -> a3s_box_core::Result<Self> {
416 if request.external_sandbox_id.trim().is_empty() {
417 return Err(a3s_box_core::BoxError::ConfigError(
418 "external sandbox ID cannot be empty".to_string(),
419 ));
420 }
421 let plan = a3s_box_core::resolve_execution(&request.config)?;
422 Ok(Self {
423 operation_id,
424 generation,
425 request,
426 plan,
427 pending_operation: None,
428 last_restart: None,
429 finished_at: None,
430 paused_with_memory: true,
431 })
432 }
433
434 pub fn validate(&self) -> a3s_box_core::Result<()> {
436 if self.request.external_sandbox_id.trim().is_empty() {
437 return Err(a3s_box_core::BoxError::StateError(
438 "managed execution has an empty external sandbox ID".to_string(),
439 ));
440 }
441 let resolved = a3s_box_core::resolve_execution(&self.request.config)?;
442 if resolved != self.plan {
443 return Err(a3s_box_core::BoxError::StateError(
444 "managed execution plan does not match its persisted creation request".to_string(),
445 ));
446 }
447 if let Some(completed) = &self.last_restart {
448 let expected_target = next_generation(completed.source_generation)?;
449 if completed.target_generation != expected_target {
450 return Err(a3s_box_core::BoxError::StateError(format!(
451 "completed restart {} has inconsistent generations",
452 completed.operation_id
453 )));
454 }
455 validate_stop_timeout(completed.stop_timeout_secs)?;
456 }
457 Ok(())
458 }
459}
460
461fn validate_pending_operation(
462 state: ManagedExecutionState,
463 metadata: &ManagedExecutionMetadata,
464) -> a3s_box_core::Result<()> {
465 let operation = metadata.pending_operation.as_ref();
466 let consistent = matches!(
467 (state, operation),
468 (
469 ManagedExecutionState::Starting,
470 Some(ManagedExecutionOperation::Start)
471 ) | (
472 ManagedExecutionState::Pausing,
473 Some(ManagedExecutionOperation::Pause { .. })
474 ) | (
475 ManagedExecutionState::Resuming,
476 Some(ManagedExecutionOperation::Resume)
477 ) | (
478 ManagedExecutionState::Snapshotting,
479 Some(ManagedExecutionOperation::Snapshot { .. })
480 ) | (
481 ManagedExecutionState::Killing,
482 Some(ManagedExecutionOperation::Kill { .. })
483 ) | (
484 ManagedExecutionState::Removing,
485 Some(ManagedExecutionOperation::Remove)
486 ) | (
487 ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting,
488 Some(ManagedExecutionOperation::Restart { .. })
489 ) | (
490 ManagedExecutionState::Creating
491 | ManagedExecutionState::Created
492 | ManagedExecutionState::Running
493 | ManagedExecutionState::Paused
494 | ManagedExecutionState::Stopped
495 | ManagedExecutionState::Failed,
496 None
497 )
498 );
499 if !consistent {
500 return Err(a3s_box_core::BoxError::StateError(format!(
501 "managed execution state {state} has inconsistent pending operation"
502 )));
503 }
504
505 if let Some(ManagedExecutionOperation::Restart {
506 source_generation,
507 source_state,
508 stop_timeout_secs,
509 ..
510 }) = operation
511 {
512 if !matches!(
513 source_state,
514 ManagedExecutionState::Created
515 | ManagedExecutionState::Running
516 | ManagedExecutionState::Paused
517 | ManagedExecutionState::Stopped
518 | ManagedExecutionState::Failed
519 ) {
520 return Err(a3s_box_core::BoxError::StateError(
521 "restart source state is not stable".to_string(),
522 ));
523 }
524 let expected = match state {
525 ManagedExecutionState::RestartStopping => *source_generation,
526 ManagedExecutionState::RestartStarting => next_generation(*source_generation)?,
527 _ => {
528 return Err(a3s_box_core::BoxError::StateError(
529 "restart operation is attached to a non-restart state".to_string(),
530 ))
531 }
532 };
533 if metadata.generation != expected {
534 return Err(a3s_box_core::BoxError::StateError(format!(
535 "restart state {state} has generation {}, expected {}",
536 metadata.generation.get(),
537 expected.get()
538 )));
539 }
540 validate_stop_timeout(*stop_timeout_secs)?;
541 }
542 if let Some(ManagedExecutionOperation::Snapshot { source_state, .. }) = operation {
543 if state != ManagedExecutionState::Snapshotting
544 || !matches!(
545 source_state,
546 ManagedExecutionState::Running | ManagedExecutionState::Paused
547 )
548 {
549 return Err(a3s_box_core::BoxError::StateError(
550 "snapshot operation has an invalid source state".to_string(),
551 ));
552 }
553 }
554 if let Some(ManagedExecutionOperation::Kill {
555 signal,
556 timeout_secs,
557 }) = operation
558 {
559 if signal.is_some_and(|signal| signal <= 0) {
560 return Err(a3s_box_core::BoxError::StateError(
561 "kill signal must be positive".to_string(),
562 ));
563 }
564 validate_stop_timeout(*timeout_secs)?;
565 }
566 if !metadata.paused_with_memory {
567 let valid_cold_pause_state = match (state, operation) {
568 (
569 ManagedExecutionState::Pausing,
570 Some(ManagedExecutionOperation::Pause { keep_memory }),
571 ) => !keep_memory,
572 (ManagedExecutionState::Paused | ManagedExecutionState::Resuming, _) => true,
573 (
574 ManagedExecutionState::Snapshotting,
575 Some(ManagedExecutionOperation::Snapshot { source_state, .. }),
576 ) => *source_state == ManagedExecutionState::Paused,
577 (ManagedExecutionState::Killing | ManagedExecutionState::Removing, _) => true,
579 (
580 ManagedExecutionState::RestartStopping,
581 Some(ManagedExecutionOperation::Restart { source_state, .. }),
582 ) => *source_state == ManagedExecutionState::Paused,
583 _ => false,
584 };
585 if !valid_cold_pause_state {
586 return Err(a3s_box_core::BoxError::StateError(format!(
587 "managed execution state {state} cannot retain a filesystem-only pause"
588 )));
589 }
590 }
591 Ok(())
592}
593
594fn validate_stop_timeout(timeout_secs: Option<u64>) -> a3s_box_core::Result<()> {
595 if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) {
596 Err(a3s_box_core::BoxError::StateError(
597 "managed stop timeout is too large".to_string(),
598 ))
599 } else {
600 Ok(())
601 }
602}
603
604fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result<ExecutionGeneration> {
605 let value = generation.get().checked_add(1).ok_or_else(|| {
606 a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string())
607 })?;
608 ExecutionGeneration::new(value).map_err(|error| {
609 a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}"))
610 })
611}
612
613fn default_restart_policy() -> String {
614 "no".to_string()
615}
616
617fn default_health_status() -> String {
618 "none".to_string()
619}
620
621const fn default_paused_with_memory() -> bool {
622 true
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn minimal_record() -> serde_json::Value {
630 serde_json::json!({
631 "id": "11111111-1111-4111-8111-111111111111",
632 "short_id": "111111111111",
633 "name": "fixture",
634 "image": "alpine:latest",
635 "status": "created",
636 "pid": null,
637 "cpus": 1,
638 "memory_mb": 128,
639 "volumes": [],
640 "env": {},
641 "cmd": ["sh"],
642 "box_dir": "/tmp/fixture",
643 "console_log": "/tmp/fixture/console.log",
644 "created_at": "2026-07-14T12:00:00Z",
645 "started_at": null,
646 "auto_remove": false
647 })
648 }
649
650 #[test]
651 fn legacy_records_default_without_losing_runtime_fields() {
652 let mut value = minimal_record();
653 value["virtiofs_cache"] = serde_json::json!("always");
654 let record: BoxRecord = serde_json::from_value(value).unwrap();
655
656 assert_eq!(record.isolation, ExecutionIsolation::Microvm);
657 assert!(record.managed_execution.is_none());
658 assert_eq!(record.virtiofs_cache.as_deref(), Some("always"));
659 assert_eq!(record.restart_policy, "no");
660 assert_eq!(record.health_status, "none");
661 assert_eq!(
662 serde_json::to_value(record).unwrap()["virtiofs_cache"],
663 "always"
664 );
665 }
666
667 #[test]
668 fn managed_execution_metadata_round_trips_recovery_intent() {
669 let mut config = a3s_box_core::BoxConfig {
670 image: "alpine:latest".to_string(),
671 isolation: ExecutionIsolation::Sandbox,
672 ..Default::default()
673 };
674 config.resources.vcpus = 1;
675 config.resources.memory_mb = 128;
676 let metadata = ManagedExecutionMetadata::new(
677 OperationId::new("create-op-1").unwrap(),
678 ExecutionGeneration::INITIAL,
679 CreateExecutionRequest {
680 external_sandbox_id: "sandbox-1".to_string(),
681 config,
682 labels: Default::default(),
683 policy: Default::default(),
684 rootfs_snapshot_id: None,
685 },
686 )
687 .unwrap();
688 let mut value = minimal_record();
689 value["managed_execution"] = serde_json::to_value(metadata).unwrap();
690 value["managed_execution"]
691 .as_object_mut()
692 .unwrap()
693 .remove("paused_with_memory");
694
695 let record: BoxRecord = serde_json::from_value(value).unwrap();
696 let encoded = serde_json::to_value(&record).unwrap();
697 assert_eq!(
698 record.managed_state().unwrap(),
699 Some(ManagedExecutionState::Created)
700 );
701 assert!(!record.is_active());
702 let managed = record.managed_execution.unwrap();
703
704 assert_eq!(managed.operation_id.as_str(), "create-op-1");
705 assert_eq!(managed.generation, ExecutionGeneration::INITIAL);
706 assert_eq!(managed.request.external_sandbox_id, "sandbox-1");
707 assert!(managed.paused_with_memory);
708 assert_eq!(
709 managed.request.config.isolation,
710 ExecutionIsolation::Sandbox
711 );
712 assert_eq!(encoded["managed_execution"]["generation"], 1);
713 assert_eq!(encoded["managed_execution"]["paused_with_memory"], true);
714 }
715
716 #[test]
717 fn legacy_kill_operation_defaults_new_termination_options() {
718 let operation: ManagedExecutionOperation =
719 serde_json::from_value(serde_json::json!({ "kind": "kill" })).unwrap();
720
721 assert_eq!(
722 operation,
723 ManagedExecutionOperation::Kill {
724 signal: None,
725 timeout_secs: None,
726 }
727 );
728 }
729
730 #[test]
731 fn managed_execution_rejects_a_cold_pause_marker_in_running_state() {
732 let config = a3s_box_core::BoxConfig {
733 image: "alpine:latest".to_string(),
734 isolation: ExecutionIsolation::Sandbox,
735 ..Default::default()
736 };
737 let mut metadata = ManagedExecutionMetadata::new(
738 OperationId::new("create-op-cold-invalid").unwrap(),
739 ExecutionGeneration::INITIAL,
740 CreateExecutionRequest {
741 external_sandbox_id: "sandbox-cold-invalid".to_string(),
742 config,
743 labels: Default::default(),
744 policy: Default::default(),
745 rootfs_snapshot_id: None,
746 },
747 )
748 .unwrap();
749 metadata.paused_with_memory = false;
750 let mut value = minimal_record();
751 value["status"] = serde_json::json!("running");
752 value["managed_execution"] = serde_json::to_value(metadata).unwrap();
753 let record: BoxRecord = serde_json::from_value(value).unwrap();
754
755 assert!(record.managed_state().is_err());
756 }
757
758 #[test]
759 fn managed_execution_validation_rejects_plan_drift() {
760 let config = a3s_box_core::BoxConfig {
761 image: "alpine:latest".to_string(),
762 isolation: ExecutionIsolation::Sandbox,
763 ..Default::default()
764 };
765 let mut metadata = ManagedExecutionMetadata::new(
766 OperationId::new("create-op-1").unwrap(),
767 ExecutionGeneration::INITIAL,
768 CreateExecutionRequest {
769 external_sandbox_id: "sandbox-1".to_string(),
770 config,
771 labels: Default::default(),
772 policy: Default::default(),
773 rootfs_snapshot_id: None,
774 },
775 )
776 .unwrap();
777 metadata.plan =
778 a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
779
780 assert!(metadata.validate().is_err());
781 }
782}