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 Stopped,
263 Failed,
264}
265
266impl ManagedExecutionState {
267 pub const fn as_status(self) -> &'static str {
269 match self {
270 Self::Creating => "creating",
271 Self::Created => "created",
272 Self::Starting => "starting",
273 Self::Running => "running",
274 Self::Pausing => "pausing",
275 Self::Paused => "paused",
276 Self::Resuming => "resuming",
277 Self::Snapshotting => "snapshotting",
278 Self::Killing => "killing",
279 Self::RestartStopping => "restart_stopping",
280 Self::RestartStarting => "restart_starting",
281 Self::Stopped => "stopped",
282 Self::Failed => "failed",
283 }
284 }
285
286 pub fn from_status(status: &str) -> a3s_box_core::Result<Self> {
288 match status {
289 "creating" => Ok(Self::Creating),
290 "created" => Ok(Self::Created),
291 "starting" => Ok(Self::Starting),
292 "running" => Ok(Self::Running),
293 "pausing" => Ok(Self::Pausing),
294 "paused" => Ok(Self::Paused),
295 "resuming" => Ok(Self::Resuming),
296 "snapshotting" => Ok(Self::Snapshotting),
297 "killing" => Ok(Self::Killing),
298 "restart_stopping" => Ok(Self::RestartStopping),
299 "restart_starting" => Ok(Self::RestartStarting),
300 "stopped" => Ok(Self::Stopped),
301 "dead" | "failed" => Ok(Self::Failed),
302 other => Err(a3s_box_core::BoxError::StateError(format!(
303 "unknown managed execution state: {other}"
304 ))),
305 }
306 }
307
308 pub const fn keeps_resources(self) -> bool {
310 !matches!(
311 self,
312 Self::Creating | Self::Created | Self::Stopped | Self::Failed
313 )
314 }
315
316 pub const fn is_terminal(self) -> bool {
318 matches!(self, Self::Stopped | Self::Failed)
319 }
320}
321
322impl std::fmt::Display for ManagedExecutionState {
323 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 formatter.write_str(self.as_status())
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ManagedExecutionMetadata {
333 pub operation_id: OperationId,
335 pub generation: ExecutionGeneration,
337 pub request: CreateExecutionRequest,
339 pub plan: ResolvedExecutionPlan,
341 #[serde(default)]
343 pub pending_operation: Option<ManagedExecutionOperation>,
344 #[serde(default)]
346 pub last_restart: Option<ManagedRestartCompletion>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(tag = "kind", rename_all = "snake_case")]
352pub enum ManagedExecutionOperation {
353 Start,
354 Pause {
355 keep_memory: bool,
356 },
357 Resume,
358 Snapshot {
359 snapshot_id: ExecutionSnapshotId,
360 source_state: ManagedExecutionState,
361 },
362 Kill,
363 Restart {
364 operation_id: OperationId,
365 source_generation: ExecutionGeneration,
366 source_state: ManagedExecutionState,
367 #[serde(default)]
368 stop_timeout_secs: Option<u64>,
369 },
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum ManagedRestartOutcome {
376 Running,
377 Failed,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct ManagedRestartCompletion {
383 pub operation_id: OperationId,
384 pub source_generation: ExecutionGeneration,
385 pub target_generation: ExecutionGeneration,
386 pub outcome: ManagedRestartOutcome,
387 #[serde(default)]
388 pub stop_timeout_secs: Option<u64>,
389}
390
391impl ManagedExecutionMetadata {
392 pub fn new(
394 operation_id: OperationId,
395 generation: ExecutionGeneration,
396 request: CreateExecutionRequest,
397 ) -> a3s_box_core::Result<Self> {
398 if request.external_sandbox_id.trim().is_empty() {
399 return Err(a3s_box_core::BoxError::ConfigError(
400 "external sandbox ID cannot be empty".to_string(),
401 ));
402 }
403 let plan = a3s_box_core::resolve_execution(&request.config)?;
404 Ok(Self {
405 operation_id,
406 generation,
407 request,
408 plan,
409 pending_operation: None,
410 last_restart: None,
411 })
412 }
413
414 pub fn validate(&self) -> a3s_box_core::Result<()> {
416 if self.request.external_sandbox_id.trim().is_empty() {
417 return Err(a3s_box_core::BoxError::StateError(
418 "managed execution has an empty external sandbox ID".to_string(),
419 ));
420 }
421 let resolved = a3s_box_core::resolve_execution(&self.request.config)?;
422 if resolved != self.plan {
423 return Err(a3s_box_core::BoxError::StateError(
424 "managed execution plan does not match its persisted creation request".to_string(),
425 ));
426 }
427 if let Some(completed) = &self.last_restart {
428 let expected_target = next_generation(completed.source_generation)?;
429 if completed.target_generation != expected_target {
430 return Err(a3s_box_core::BoxError::StateError(format!(
431 "completed restart {} has inconsistent generations",
432 completed.operation_id
433 )));
434 }
435 validate_restart_timeout(completed.stop_timeout_secs)?;
436 }
437 Ok(())
438 }
439}
440
441fn validate_pending_operation(
442 state: ManagedExecutionState,
443 metadata: &ManagedExecutionMetadata,
444) -> a3s_box_core::Result<()> {
445 let operation = metadata.pending_operation.as_ref();
446 let consistent = matches!(
447 (state, operation),
448 (
449 ManagedExecutionState::Starting,
450 Some(ManagedExecutionOperation::Start)
451 ) | (
452 ManagedExecutionState::Pausing,
453 Some(ManagedExecutionOperation::Pause { .. })
454 ) | (
455 ManagedExecutionState::Resuming,
456 Some(ManagedExecutionOperation::Resume)
457 ) | (
458 ManagedExecutionState::Snapshotting,
459 Some(ManagedExecutionOperation::Snapshot { .. })
460 ) | (
461 ManagedExecutionState::Killing,
462 Some(ManagedExecutionOperation::Kill)
463 ) | (
464 ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting,
465 Some(ManagedExecutionOperation::Restart { .. })
466 ) | (
467 ManagedExecutionState::Creating
468 | ManagedExecutionState::Created
469 | ManagedExecutionState::Running
470 | ManagedExecutionState::Paused
471 | ManagedExecutionState::Stopped
472 | ManagedExecutionState::Failed,
473 None
474 )
475 );
476 if !consistent {
477 return Err(a3s_box_core::BoxError::StateError(format!(
478 "managed execution state {state} has inconsistent pending operation"
479 )));
480 }
481
482 if let Some(ManagedExecutionOperation::Restart {
483 source_generation,
484 source_state,
485 stop_timeout_secs,
486 ..
487 }) = operation
488 {
489 if !matches!(
490 source_state,
491 ManagedExecutionState::Created
492 | ManagedExecutionState::Running
493 | ManagedExecutionState::Paused
494 | ManagedExecutionState::Stopped
495 | ManagedExecutionState::Failed
496 ) {
497 return Err(a3s_box_core::BoxError::StateError(
498 "restart source state is not stable".to_string(),
499 ));
500 }
501 let expected = match state {
502 ManagedExecutionState::RestartStopping => *source_generation,
503 ManagedExecutionState::RestartStarting => next_generation(*source_generation)?,
504 _ => {
505 return Err(a3s_box_core::BoxError::StateError(
506 "restart operation is attached to a non-restart state".to_string(),
507 ))
508 }
509 };
510 if metadata.generation != expected {
511 return Err(a3s_box_core::BoxError::StateError(format!(
512 "restart state {state} has generation {}, expected {}",
513 metadata.generation.get(),
514 expected.get()
515 )));
516 }
517 validate_restart_timeout(*stop_timeout_secs)?;
518 }
519 if let Some(ManagedExecutionOperation::Snapshot { source_state, .. }) = operation {
520 if state != ManagedExecutionState::Snapshotting
521 || !matches!(
522 source_state,
523 ManagedExecutionState::Running | ManagedExecutionState::Paused
524 )
525 {
526 return Err(a3s_box_core::BoxError::StateError(
527 "snapshot operation has an invalid source state".to_string(),
528 ));
529 }
530 }
531 Ok(())
532}
533
534fn validate_restart_timeout(timeout_secs: Option<u64>) -> a3s_box_core::Result<()> {
535 if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) {
536 Err(a3s_box_core::BoxError::StateError(
537 "restart stop timeout is too large".to_string(),
538 ))
539 } else {
540 Ok(())
541 }
542}
543
544fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result<ExecutionGeneration> {
545 let value = generation.get().checked_add(1).ok_or_else(|| {
546 a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string())
547 })?;
548 ExecutionGeneration::new(value).map_err(|error| {
549 a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}"))
550 })
551}
552
553fn default_restart_policy() -> String {
554 "no".to_string()
555}
556
557fn default_health_status() -> String {
558 "none".to_string()
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 fn minimal_record() -> serde_json::Value {
566 serde_json::json!({
567 "id": "11111111-1111-4111-8111-111111111111",
568 "short_id": "111111111111",
569 "name": "fixture",
570 "image": "alpine:latest",
571 "status": "created",
572 "pid": null,
573 "cpus": 1,
574 "memory_mb": 128,
575 "volumes": [],
576 "env": {},
577 "cmd": ["sh"],
578 "box_dir": "/tmp/fixture",
579 "console_log": "/tmp/fixture/console.log",
580 "created_at": "2026-07-14T12:00:00Z",
581 "started_at": null,
582 "auto_remove": false
583 })
584 }
585
586 #[test]
587 fn legacy_records_default_without_losing_runtime_fields() {
588 let mut value = minimal_record();
589 value["virtiofs_cache"] = serde_json::json!("always");
590 let record: BoxRecord = serde_json::from_value(value).unwrap();
591
592 assert_eq!(record.isolation, ExecutionIsolation::Microvm);
593 assert!(record.managed_execution.is_none());
594 assert_eq!(record.virtiofs_cache.as_deref(), Some("always"));
595 assert_eq!(record.restart_policy, "no");
596 assert_eq!(record.health_status, "none");
597 assert_eq!(
598 serde_json::to_value(record).unwrap()["virtiofs_cache"],
599 "always"
600 );
601 }
602
603 #[test]
604 fn managed_execution_metadata_round_trips_recovery_intent() {
605 let mut config = a3s_box_core::BoxConfig {
606 image: "alpine:latest".to_string(),
607 isolation: ExecutionIsolation::Sandbox,
608 ..Default::default()
609 };
610 config.resources.vcpus = 1;
611 config.resources.memory_mb = 128;
612 let metadata = ManagedExecutionMetadata::new(
613 OperationId::new("create-op-1").unwrap(),
614 ExecutionGeneration::INITIAL,
615 CreateExecutionRequest {
616 external_sandbox_id: "sandbox-1".to_string(),
617 config,
618 labels: Default::default(),
619 policy: Default::default(),
620 rootfs_snapshot_id: None,
621 },
622 )
623 .unwrap();
624 let mut value = minimal_record();
625 value["managed_execution"] = serde_json::to_value(metadata).unwrap();
626
627 let record: BoxRecord = serde_json::from_value(value).unwrap();
628 let encoded = serde_json::to_value(&record).unwrap();
629 assert_eq!(
630 record.managed_state().unwrap(),
631 Some(ManagedExecutionState::Created)
632 );
633 assert!(!record.is_active());
634 let managed = record.managed_execution.unwrap();
635
636 assert_eq!(managed.operation_id.as_str(), "create-op-1");
637 assert_eq!(managed.generation, ExecutionGeneration::INITIAL);
638 assert_eq!(managed.request.external_sandbox_id, "sandbox-1");
639 assert_eq!(
640 managed.request.config.isolation,
641 ExecutionIsolation::Sandbox
642 );
643 assert_eq!(encoded["managed_execution"]["generation"], 1);
644 }
645
646 #[test]
647 fn managed_execution_validation_rejects_plan_drift() {
648 let config = a3s_box_core::BoxConfig {
649 image: "alpine:latest".to_string(),
650 isolation: ExecutionIsolation::Sandbox,
651 ..Default::default()
652 };
653 let mut metadata = ManagedExecutionMetadata::new(
654 OperationId::new("create-op-1").unwrap(),
655 ExecutionGeneration::INITIAL,
656 CreateExecutionRequest {
657 external_sandbox_id: "sandbox-1".to_string(),
658 config,
659 labels: Default::default(),
660 policy: Default::default(),
661 rootfs_snapshot_id: None,
662 },
663 )
664 .unwrap();
665 metadata.plan =
666 a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap();
667
668 assert!(metadata.validate().is_err());
669 }
670}