Skip to main content

a3s_box_runtime/
box_record.rs

1//! Canonical persisted metadata schema for local box executions.
2
3use 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/// Metadata record for a single local box execution.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BoxRecord {
20    /// Full UUID.
21    pub id: String,
22    /// First 12 hex characters of the UUID, without dashes.
23    pub short_id: String,
24    /// User-assigned or generated name.
25    pub name: String,
26    /// OCI image reference.
27    pub image: String,
28    /// Requested execution isolation. Records written before this field default to MicroVM.
29    #[serde(default)]
30    pub isolation: ExecutionIsolation,
31    /// Runtime lifecycle identity and recoverable creation intent.
32    ///
33    /// Legacy CLI-created records omit this field. Managed executions persist
34    /// it before launch so an operation can be reconciled after a service
35    /// restart without creating a second execution.
36    #[serde(default)]
37    pub managed_execution: Option<ManagedExecutionMetadata>,
38    /// Persisted lifecycle state.
39    ///
40    /// Legacy records use `created`, `running`, `paused`, `stopped`, and
41    /// `dead`. Managed executions additionally use the durable transition
42    /// states defined by [`ManagedExecutionState`].
43    pub status: String,
44    /// Shim process PID while the execution is active.
45    pub pid: Option<u32>,
46    /// Start-time identity token used to reject a reused PID.
47    #[serde(default)]
48    pub pid_start_time: Option<u64>,
49    /// Number of virtual CPUs.
50    pub cpus: u32,
51    /// Memory in MiB.
52    pub memory_mb: u32,
53    /// Volume mounts encoded as host-to-guest pairs.
54    pub volumes: Vec<String>,
55    /// virtio-fs cache mode for host directory volumes.
56    #[serde(default)]
57    pub virtiofs_cache: Option<String>,
58    /// Environment variables.
59    pub env: HashMap<String, String>,
60    /// Command override.
61    pub cmd: Vec<String>,
62    /// Entrypoint override.
63    #[serde(default)]
64    pub entrypoint: Option<Vec<String>>,
65    /// Host-side execution directory.
66    pub box_dir: PathBuf,
67    /// Path to the exec socket.
68    #[serde(default)]
69    pub exec_socket_path: PathBuf,
70    /// Path to the console log.
71    pub console_log: PathBuf,
72    /// Creation timestamp.
73    pub created_at: DateTime<Utc>,
74    /// Start timestamp.
75    pub started_at: Option<DateTime<Utc>>,
76    /// Whether the execution is removed automatically after it stops.
77    pub auto_remove: bool,
78    /// Custom hostname.
79    #[serde(default)]
80    pub hostname: Option<String>,
81    /// User inside the workload.
82    #[serde(default)]
83    pub user: Option<String>,
84    /// Working directory inside the workload.
85    #[serde(default)]
86    pub workdir: Option<String>,
87    /// Restart policy.
88    #[serde(default = "default_restart_policy")]
89    pub restart_policy: String,
90    /// Port mappings.
91    #[serde(default)]
92    pub port_map: Vec<String>,
93    /// User-defined labels.
94    #[serde(default)]
95    pub labels: HashMap<String, String>,
96    /// Whether the execution was explicitly stopped by a user.
97    #[serde(default)]
98    pub stopped_by_user: bool,
99    /// Automatic restart count.
100    #[serde(default)]
101    pub restart_count: u32,
102    /// Maximum restart count for a bounded on-failure policy.
103    #[serde(default)]
104    pub max_restart_count: u32,
105    /// Last captured exit code.
106    #[serde(default)]
107    pub exit_code: Option<i32>,
108    /// Health-check configuration.
109    #[serde(default)]
110    pub health_check: Option<HealthCheck>,
111    /// Whether an image-defined health check was disabled explicitly.
112    #[serde(default)]
113    pub healthcheck_disabled: bool,
114    /// Current health state.
115    #[serde(default = "default_health_status")]
116    pub health_status: String,
117    /// Consecutive health-check failures.
118    #[serde(default)]
119    pub health_retries: u32,
120    /// Timestamp of the most recent health check.
121    #[serde(default)]
122    pub health_last_check: Option<DateTime<Utc>>,
123    /// Network mode.
124    #[serde(default)]
125    pub network_mode: NetworkMode,
126    /// Attached bridge network name.
127    #[serde(default)]
128    pub network_name: Option<String>,
129    /// Attached named volumes.
130    #[serde(default)]
131    pub volume_names: Vec<String>,
132    /// tmpfs mounts.
133    #[serde(default)]
134    pub tmpfs: Vec<String>,
135    /// Anonymous volumes materialized from OCI declarations.
136    #[serde(default)]
137    pub anonymous_volumes: Vec<String>,
138    /// Host resource controls.
139    #[serde(default)]
140    pub resource_limits: ResourceLimits,
141    /// Logging configuration.
142    #[serde(default)]
143    pub log_config: LogConfig,
144    /// Custom host-to-IP mappings.
145    #[serde(default)]
146    pub add_host: Vec<String>,
147    /// Target OCI platform.
148    #[serde(default)]
149    pub platform: Option<String>,
150    /// Whether to run an init process as PID 1.
151    #[serde(default)]
152    pub init: bool,
153    /// Whether the root filesystem is read-only.
154    #[serde(default)]
155    pub read_only: bool,
156    /// Added Linux capabilities.
157    #[serde(default)]
158    pub cap_add: Vec<String>,
159    /// Dropped Linux capabilities.
160    #[serde(default)]
161    pub cap_drop: Vec<String>,
162    /// OCI security options.
163    #[serde(default)]
164    pub security_opt: Vec<String>,
165    /// Whether extended privileges are enabled.
166    #[serde(default)]
167    pub privileged: bool,
168    /// Device mappings.
169    #[serde(default)]
170    pub devices: Vec<String>,
171    /// GPU selection.
172    #[serde(default)]
173    pub gpus: Option<String>,
174    /// Shared-memory size in bytes.
175    #[serde(default)]
176    pub shm_size: Option<u64>,
177    /// Signal used for graceful stop.
178    #[serde(default)]
179    pub stop_signal: Option<String>,
180    /// Graceful stop timeout in seconds.
181    #[serde(default)]
182    pub stop_timeout: Option<u64>,
183    /// Whether the OOM killer is disabled.
184    #[serde(default)]
185    pub oom_kill_disable: bool,
186    /// Host OOM score adjustment.
187    #[serde(default)]
188    pub oom_score_adj: Option<i32>,
189}
190
191impl BoxRecord {
192    /// Generate the stable short ID used by local CLI and SDK lookup.
193    pub fn make_short_id(id: &str) -> String {
194        id.replace('-', "").chars().take(12).collect()
195    }
196
197    /// Whether the persisted lifecycle state represents an active execution.
198    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    /// Parse the lifecycle state of a managed execution.
208    ///
209    /// Legacy records return `None`. Unknown managed states fail closed so a
210    /// runtime service cannot operate on a record written by incompatible
211    /// code.
212    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    /// Render a concise lifecycle status with health, exit, and restart annotations.
222    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/// Durable lifecycle state for an execution owned by `ExecutionManager`.
244///
245/// Transitional states are persisted before backend side effects. This lets
246/// a restarted manager distinguish work that was never claimed from work that
247/// may already have reached the runtime.
248#[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    /// Canonical value written to [`BoxRecord::status`].
268    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    /// Parse a persisted managed lifecycle state.
287    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    /// Whether host resources may still belong to this execution.
309    pub const fn keeps_resources(self) -> bool {
310        !matches!(
311            self,
312            Self::Creating | Self::Created | Self::Stopped | Self::Failed
313        )
314    }
315
316    /// Whether no further lifecycle operation can revive this execution.
317    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/// Durable lifecycle metadata for an execution owned by [`ExecutionManager`].
329///
330/// [`ExecutionManager`]: a3s_box_core::ExecutionManager
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ManagedExecutionMetadata {
333    /// Idempotency key of the create operation.
334    pub operation_id: OperationId,
335    /// Runtime generation used to reject stale lifecycle requests.
336    pub generation: ExecutionGeneration,
337    /// Full creation intent required to recover an interrupted launch.
338    pub request: CreateExecutionRequest,
339    /// Backend resolution validated before any launch side effects.
340    pub plan: ResolvedExecutionPlan,
341    /// Lifecycle side effect claimed before calling the backend.
342    #[serde(default)]
343    pub pending_operation: Option<ManagedExecutionOperation>,
344    /// Most recent completed restart retained for idempotent response replay.
345    #[serde(default)]
346    pub last_restart: Option<ManagedRestartCompletion>,
347}
348
349/// Recoverable backend operation associated with a transitional state.
350#[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/// Durable result of the most recent restart operation.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum ManagedRestartOutcome {
376    Running,
377    Failed,
378}
379
380/// Restart identity retained after its transitional state has completed.
381#[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    /// Build validated recovery metadata from one creation request.
393    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    /// Validate deserialized metadata before it participates in reconciliation.
415    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}