Skip to main content

a3s_box_core/traits/
execution.rs

1//! Backend-neutral lifecycle interface for managed A3S executions.
2
3use std::collections::BTreeMap;
4use std::num::NonZeroU16;
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::time::Duration;
8
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13use tokio::io::{AsyncRead, AsyncWrite};
14
15use crate::config::{BoxConfig, ResourceConfig, ResourceLimits};
16use crate::execution::ResolvedExecutionPlan;
17use crate::log::{LogConfig, LogEntry};
18
19/// Stable identifier assigned to one runtime execution.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(try_from = "String", into = "String")]
22pub struct ExecutionId(String);
23
24impl ExecutionId {
25    pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
26        let value = value.into();
27        if value.trim().is_empty() {
28            return Err(ExecutionManagerError::InvalidRequest(
29                "execution ID cannot be empty".to_string(),
30            ));
31        }
32        Ok(Self(value))
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for ExecutionId {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        formatter.write_str(&self.0)
43    }
44}
45
46impl TryFrom<String> for ExecutionId {
47    type Error = ExecutionManagerError;
48
49    fn try_from(value: String) -> Result<Self, Self::Error> {
50        Self::new(value)
51    }
52}
53
54impl From<ExecutionId> for String {
55    fn from(value: ExecutionId) -> Self {
56        value.0
57    }
58}
59
60/// Opaque identifier for one runtime-managed filesystem snapshot.
61///
62/// Snapshot identifiers are used as directory names below the runtime's
63/// managed snapshot root. Keeping the lexical contract here prevents callers
64/// from turning a protocol template reference into an arbitrary host path.
65#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
66#[serde(try_from = "String", into = "String")]
67pub struct ExecutionSnapshotId(String);
68
69impl ExecutionSnapshotId {
70    pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
71        let value = value.into();
72        if value.is_empty()
73            || value.len() > 128
74            || !value
75                .bytes()
76                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
77        {
78            return Err(ExecutionManagerError::InvalidRequest(
79                "execution snapshot ID must match [A-Za-z0-9_-]{1,128}".to_string(),
80            ));
81        }
82        Ok(Self(value))
83    }
84
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl std::fmt::Display for ExecutionSnapshotId {
91    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        formatter.write_str(&self.0)
93    }
94}
95
96impl TryFrom<String> for ExecutionSnapshotId {
97    type Error = ExecutionManagerError;
98
99    fn try_from(value: String) -> Result<Self, Self::Error> {
100        Self::new(value)
101    }
102}
103
104impl From<ExecutionSnapshotId> for String {
105    fn from(value: ExecutionSnapshotId) -> Self {
106        value.0
107    }
108}
109
110/// Idempotency identity for a lifecycle operation.
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(try_from = "String", into = "String")]
113pub struct OperationId(String);
114
115impl OperationId {
116    pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
117        let value = value.into();
118        if value.trim().is_empty() {
119            return Err(ExecutionManagerError::InvalidRequest(
120                "operation ID cannot be empty".to_string(),
121            ));
122        }
123        Ok(Self(value))
124    }
125
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129}
130
131impl std::fmt::Display for OperationId {
132    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        formatter.write_str(&self.0)
134    }
135}
136
137impl TryFrom<String> for OperationId {
138    type Error = ExecutionManagerError;
139
140    fn try_from(value: String) -> Result<Self, Self::Error> {
141        Self::new(value)
142    }
143}
144
145impl From<OperationId> for String {
146    fn from(value: OperationId) -> Self {
147        value.0
148    }
149}
150
151/// Runtime generation used to reject stale lifecycle operations.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
153#[serde(try_from = "u64", into = "u64")]
154pub struct ExecutionGeneration(u64);
155
156impl ExecutionGeneration {
157    pub const INITIAL: Self = Self(1);
158
159    pub fn new(value: u64) -> ExecutionManagerResult<Self> {
160        if value == 0 {
161            return Err(ExecutionManagerError::InvalidRequest(
162                "execution generation must be greater than zero".to_string(),
163            ));
164        }
165        Ok(Self(value))
166    }
167
168    pub const fn get(self) -> u64 {
169        self.0
170    }
171}
172
173impl TryFrom<u64> for ExecutionGeneration {
174    type Error = ExecutionManagerError;
175
176    fn try_from(value: u64) -> Result<Self, Self::Error> {
177        Self::new(value)
178    }
179}
180
181impl From<ExecutionGeneration> for u64 {
182    fn from(value: ExecutionGeneration) -> Self {
183        value.0
184    }
185}
186
187/// Restart behavior persisted with a local execution.
188#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "kebab-case")]
190pub enum ExecutionRestartPolicy {
191    /// Never restart automatically.
192    #[default]
193    No,
194    /// Restart after every exit.
195    Always,
196    /// Restart only after an unsuccessful exit.
197    OnFailure,
198    /// Restart unless a user explicitly stopped the execution.
199    UnlessStopped,
200}
201
202impl ExecutionRestartPolicy {
203    /// Canonical value stored in the backwards-compatible local record.
204    pub const fn as_str(self) -> &'static str {
205        match self {
206            Self::No => "no",
207            Self::Always => "always",
208            Self::OnFailure => "on-failure",
209            Self::UnlessStopped => "unless-stopped",
210        }
211    }
212}
213
214/// Health-check behavior persisted with a local execution.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ExecutionHealthCheck {
217    /// Command executed by the health check.
218    pub cmd: Vec<String>,
219    /// Interval between checks in seconds.
220    #[serde(default = "default_health_interval")]
221    pub interval_secs: u64,
222    /// Per-check timeout in seconds.
223    #[serde(default = "default_health_timeout")]
224    pub timeout_secs: u64,
225    /// Consecutive failures before the execution is unhealthy.
226    #[serde(default = "default_health_retries")]
227    pub retries: u32,
228    /// Grace period after startup in seconds.
229    #[serde(default)]
230    pub start_period_secs: u64,
231}
232
233/// Caller-owned policy projected into the canonical local execution record.
234///
235/// The complete value is persisted with the creation request so retries cannot
236/// silently reuse an execution with different lifecycle or local resource
237/// policy. Runtime launch requirements remain in [`BoxConfig`].
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ExecutionRecordPolicy {
240    /// User-visible local name. `None` lets the runtime assign a safe name.
241    #[serde(default)]
242    pub name: Option<String>,
243    /// Remove the execution automatically after it stops.
244    #[serde(default)]
245    pub auto_remove: bool,
246    /// Automatic restart behavior.
247    #[serde(default)]
248    pub restart_policy: ExecutionRestartPolicy,
249    /// Maximum automatic restart count, where zero means unlimited.
250    #[serde(default)]
251    pub max_restart_count: u32,
252    /// Effective caller or cached-image health check.
253    #[serde(default)]
254    pub health_check: Option<ExecutionHealthCheck>,
255    /// Prevent a later image-defined health check from being enabled.
256    #[serde(default)]
257    pub healthcheck_disabled: bool,
258    /// Runtime log driver policy.
259    #[serde(default)]
260    pub log_config: LogConfig,
261    /// Named volumes represented by the resolved mounts in [`BoxConfig`].
262    #[serde(default)]
263    pub volume_names: Vec<String>,
264    /// Requested OCI platform retained for inspection and image selection.
265    #[serde(default)]
266    pub platform: Option<String>,
267    /// Whether the caller requested an init process.
268    #[serde(default)]
269    pub init: bool,
270    /// Requested host device mappings.
271    #[serde(default)]
272    pub devices: Vec<String>,
273    /// Requested GPU selection.
274    #[serde(default)]
275    pub gpus: Option<String>,
276    /// Shared-memory size in bytes.
277    #[serde(default)]
278    pub shm_size: Option<u64>,
279    /// Signal used for graceful stop.
280    #[serde(default)]
281    pub stop_signal: Option<String>,
282    /// Graceful stop timeout in seconds.
283    #[serde(default)]
284    pub stop_timeout: Option<u64>,
285    /// Whether the caller requested OOM-killer suppression.
286    #[serde(default)]
287    pub oom_kill_disable: bool,
288    /// Requested host OOM score adjustment.
289    #[serde(default)]
290    pub oom_score_adj: Option<i32>,
291    /// Runtime-owned Linux tmpfs root containing transient Secret files.
292    ///
293    /// This path is persisted only so a reconstructed backend can distinguish
294    /// caller-owned bind mounts from transient files that A3S Box is allowed to
295    /// prepare for the Sandbox user namespace. Secret values never enter this
296    /// policy or the creation request.
297    #[serde(default)]
298    pub managed_secret_root: Option<PathBuf>,
299}
300
301impl Default for ExecutionRecordPolicy {
302    fn default() -> Self {
303        Self {
304            name: None,
305            auto_remove: false,
306            restart_policy: ExecutionRestartPolicy::No,
307            max_restart_count: 0,
308            health_check: None,
309            healthcheck_disabled: false,
310            log_config: LogConfig::default(),
311            volume_names: Vec::new(),
312            platform: None,
313            init: false,
314            devices: Vec::new(),
315            gpus: None,
316            shm_size: None,
317            stop_signal: None,
318            stop_timeout: None,
319            oom_kill_disable: false,
320            oom_score_adj: None,
321            managed_secret_root: None,
322        }
323    }
324}
325
326fn default_health_interval() -> u64 {
327    30
328}
329
330fn default_health_timeout() -> u64 {
331    5
332}
333
334fn default_health_retries() -> u32 {
335    3
336}
337
338/// A fully resolved request submitted to the runtime lifecycle facade.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct CreateExecutionRequest {
341    /// Public identity used only as an untrusted diagnostic label.
342    pub external_sandbox_id: String,
343    /// Backend-neutral runtime configuration resolved from template policy.
344    pub config: BoxConfig,
345    /// Labels persisted with the internal execution.
346    pub labels: BTreeMap<String, String>,
347    /// Caller-owned lifecycle and local record policy.
348    #[serde(default)]
349    pub policy: ExecutionRecordPolicy,
350    /// Runtime-managed filesystem snapshot used as this execution's immutable
351    /// rootfs lower. The runtime derives the host path from this validated ID;
352    /// callers never supply a host path.
353    #[serde(default)]
354    pub rootfs_snapshot_id: Option<ExecutionSnapshotId>,
355}
356
357/// Durable evidence returned after an execution is created but not started.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ExecutionReservation {
360    pub execution_id: ExecutionId,
361    pub generation: ExecutionGeneration,
362    pub plan: ResolvedExecutionPlan,
363    pub resources: ResourceConfig,
364    pub created_at: DateTime<Utc>,
365}
366
367/// Evidence returned when a runtime execution is ready.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ExecutionLease {
370    pub execution_id: ExecutionId,
371    pub generation: ExecutionGeneration,
372    pub plan: ResolvedExecutionPlan,
373    pub resources: ResourceConfig,
374    pub started_at: DateTime<Utc>,
375}
376
377/// Result of atomically capturing one execution filesystem.
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct ExecutionSnapshot {
380    pub snapshot_id: ExecutionSnapshotId,
381    pub size_bytes: u64,
382    /// Stable state restored after the temporary snapshot pause.
383    pub state: ExecutionState,
384    /// Generation-fenced runtime evidence after snapshot completion.
385    pub lease: ExecutionLease,
386}
387
388/// Runtime state visible through the backend-neutral lifecycle facade.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case")]
391pub enum ExecutionState {
392    Created,
393    Creating,
394    Running,
395    Paused,
396    Stopped,
397    Failed,
398}
399
400/// Current state and generation of one execution.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct ExecutionStatus {
403    pub execution_id: ExecutionId,
404    pub generation: ExecutionGeneration,
405    pub state: ExecutionState,
406    pub plan: ResolvedExecutionPlan,
407}
408
409/// Result of an idempotent runtime kill request.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum KillOutcome {
412    Killed,
413    AlreadyStopped,
414}
415
416/// Per-operation controls persisted with an explicit termination request.
417#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
418pub struct KillExecutionOptions {
419    /// POSIX signal delivered before forced termination. `None` uses the
420    /// persisted execution policy or the backend default.
421    #[serde(default)]
422    pub signal: Option<i32>,
423    /// Grace period before forced termination. `None` uses the persisted
424    /// execution policy or the backend default.
425    #[serde(default)]
426    pub timeout_secs: Option<u64>,
427}
428
429/// Per-operation controls persisted with an idempotent restart.
430#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
431pub struct RestartExecutionOptions {
432    /// Graceful stop deadline for the old runtime. `None` uses persisted
433    /// execution policy or the backend default.
434    #[serde(default)]
435    pub stop_timeout_secs: Option<u64>,
436}
437
438/// Maximum number of ordered runtime events returned by one bounded poll.
439pub const MAX_EXECUTION_EVENT_BATCH_ITEMS: u32 = 4_096;
440
441/// Partial live update for cgroup-backed workload controls.
442///
443/// Every `None` field preserves the currently persisted value. Provisioned
444/// vCPU count, hard memory size, rlimits, and device policy are deliberately
445/// absent because changing them requires a new execution generation.
446#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
447pub struct ExecutionResourceUpdate {
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub memory_reservation: Option<u64>,
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub memory_swap: Option<i64>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub pids_limit: Option<u64>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub cpu_shares: Option<u64>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub cpu_quota: Option<i64>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub cpu_period: Option<u64>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub cpuset_cpus: Option<String>,
462}
463
464impl ExecutionResourceUpdate {
465    /// Whether the request carries no resource mutation.
466    #[must_use]
467    pub const fn is_empty(&self) -> bool {
468        self.memory_reservation.is_none()
469            && self.memory_swap.is_none()
470            && self.pids_limit.is_none()
471            && self.cpu_shares.is_none()
472            && self.cpu_quota.is_none()
473            && self.cpu_period.is_none()
474            && self.cpuset_cpus.is_none()
475    }
476
477    /// Validate backend-independent value constraints before a durable claim.
478    pub fn validate(&self) -> ExecutionManagerResult<()> {
479        if self.is_empty() {
480            return Err(ExecutionManagerError::InvalidRequest(
481                "resource update must change at least one supported field".to_string(),
482            ));
483        }
484        if self.memory_swap.is_some_and(|value| value < -1) {
485            return Err(ExecutionManagerError::InvalidRequest(
486                "memory swap must be -1 (unlimited) or non-negative".to_string(),
487            ));
488        }
489        if self.pids_limit == Some(0) {
490            return Err(ExecutionManagerError::InvalidRequest(
491                "PID limit must be greater than zero".to_string(),
492            ));
493        }
494        if self
495            .cpu_shares
496            .is_some_and(|value| !(2..=262_144).contains(&value))
497        {
498            return Err(ExecutionManagerError::InvalidRequest(
499                "CPU shares must be between 2 and 262144".to_string(),
500            ));
501        }
502        if self.cpu_quota.is_some_and(|value| value <= 0) {
503            return Err(ExecutionManagerError::InvalidRequest(
504                "CPU quota must be greater than zero".to_string(),
505            ));
506        }
507        if self.cpu_period == Some(0) {
508            return Err(ExecutionManagerError::InvalidRequest(
509                "CPU period must be greater than zero".to_string(),
510            ));
511        }
512        if self
513            .cpuset_cpus
514            .as_deref()
515            .is_some_and(|value| !valid_cpuset(value))
516        {
517            return Err(ExecutionManagerError::InvalidRequest(
518                "CPU set must be a comma-separated list of indices or ascending ranges".to_string(),
519            ));
520        }
521        Ok(())
522    }
523
524    /// Merge this partial request into a complete persisted limit snapshot.
525    pub fn apply_to(&self, limits: &mut ResourceLimits) {
526        if let Some(value) = self.memory_reservation {
527            limits.memory_reservation = Some(value);
528        }
529        if let Some(value) = self.memory_swap {
530            limits.memory_swap = Some(value);
531        }
532        if let Some(value) = self.pids_limit {
533            limits.pids_limit = Some(value);
534        }
535        if let Some(value) = self.cpu_shares {
536            limits.cpu_shares = Some(value);
537        }
538        if let Some(value) = self.cpu_quota {
539            limits.cpu_quota = Some(value);
540        }
541        if let Some(value) = self.cpu_period {
542            limits.cpu_period = Some(value);
543        }
544        if let Some(value) = self.cpuset_cpus.as_ref() {
545            limits.cpuset_cpus = Some(value.clone());
546        }
547    }
548}
549
550/// One runtime-visible init or exec process in an exact Box generation.
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct ExecutionProcessInfo {
553    pub process_id: String,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub pid: Option<u32>,
556    pub terminal: bool,
557}
558
559/// Exact-generation process inventory returned by the selected runtime.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct ExecutionProcessInventory {
562    pub execution_id: ExecutionId,
563    pub generation: ExecutionGeneration,
564    pub processes: Vec<ExecutionProcessInfo>,
565}
566
567impl ExecutionProcessInventory {
568    pub fn validate(&self) -> ExecutionManagerResult<()> {
569        let mut identifiers = std::collections::BTreeSet::new();
570        for process in &self.processes {
571            if process.process_id.trim().is_empty() {
572                return Err(ExecutionManagerError::Internal(
573                    "runtime process inventory contains an empty process ID".to_string(),
574                ));
575            }
576            if process.pid == Some(0) {
577                return Err(ExecutionManagerError::Internal(format!(
578                    "runtime process {} contains PID zero",
579                    process.process_id
580                )));
581            }
582            if !identifiers.insert(process.process_id.as_str()) {
583                return Err(ExecutionManagerError::Internal(format!(
584                    "runtime process inventory contains duplicate process ID {}",
585                    process.process_id
586                )));
587            }
588        }
589        Ok(())
590    }
591}
592
593/// Normalized CPU counters from one exact runtime generation.
594#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
595pub struct ExecutionCpuStats {
596    pub usage_ns: u64,
597    pub user_ns: u64,
598    pub system_ns: u64,
599    pub throttled_ns: u64,
600}
601
602/// Normalized memory counters from one exact runtime generation.
603#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
604pub struct ExecutionMemoryStats {
605    pub usage_bytes: u64,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub limit_bytes: Option<u64>,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub peak_bytes: Option<u64>,
610}
611
612/// One typed, exact-generation runtime resource snapshot.
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614pub struct ExecutionStats {
615    pub execution_id: ExecutionId,
616    pub generation: ExecutionGeneration,
617    pub timestamp_unix_ns: u64,
618    pub cpu: ExecutionCpuStats,
619    pub memory: ExecutionMemoryStats,
620    pub process_count: u64,
621    pub metrics: BTreeMap<String, u64>,
622}
623
624impl ExecutionStats {
625    pub fn validate(&self) -> ExecutionManagerResult<()> {
626        if self.timestamp_unix_ns == 0 {
627            return Err(ExecutionManagerError::Internal(
628                "runtime stats timestamp must be positive".to_string(),
629            ));
630        }
631        let accounted = self
632            .cpu
633            .user_ns
634            .checked_add(self.cpu.system_ns)
635            .ok_or_else(|| {
636                ExecutionManagerError::Internal(
637                    "runtime CPU user and system counters overflow".to_string(),
638                )
639            })?;
640        if accounted > self.cpu.usage_ns {
641            return Err(ExecutionManagerError::Internal(
642                "runtime CPU user and system counters exceed total usage".to_string(),
643            ));
644        }
645        if self
646            .memory
647            .peak_bytes
648            .is_some_and(|peak| peak < self.memory.usage_bytes)
649        {
650            return Err(ExecutionManagerError::Internal(
651                "runtime memory peak is below current usage".to_string(),
652            ));
653        }
654        if let Some(name) = self.metrics.keys().find(|name| {
655            name.is_empty()
656                || name.len() > 256
657                || name
658                    .chars()
659                    .any(|character| character.is_control() || character.is_whitespace())
660        }) {
661            return Err(ExecutionManagerError::Internal(format!(
662                "runtime metric name is invalid: {name:?}"
663            )));
664        }
665        Ok(())
666    }
667}
668
669/// Bounded cursor request for exact-generation ordered runtime events.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671pub struct ExecutionEventsRequest {
672    pub after_sequence: u64,
673    pub limit: u32,
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub wait_timeout_ms: Option<u64>,
676}
677
678impl ExecutionEventsRequest {
679    pub fn validate(&self) -> ExecutionManagerResult<()> {
680        if self.limit == 0 || self.limit > MAX_EXECUTION_EVENT_BATCH_ITEMS {
681            return Err(ExecutionManagerError::InvalidRequest(format!(
682                "event batch limit must be between 1 and {MAX_EXECUTION_EVENT_BATCH_ITEMS}"
683            )));
684        }
685        Ok(())
686    }
687}
688
689/// Backend-neutral ordered runtime event kind.
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(rename_all = "kebab-case")]
692pub enum ExecutionEventKind {
693    ContainerCreating,
694    ContainerCreated,
695    ContainerStarted,
696    ContainerStopped,
697    ContainerDeleted,
698    ContainerPaused,
699    ContainerResumed,
700    ResourcesUpdated,
701    ProcessCreated,
702    ProcessStarted,
703    ProcessExited,
704    OutputDropped,
705    RuntimeWarning,
706}
707
708/// One event from the runtime's durable global order.
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710pub struct ExecutionRuntimeEvent {
711    pub sequence: u64,
712    pub timestamp_unix_ns: u64,
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub process_id: Option<String>,
715    pub kind: ExecutionEventKind,
716    pub attributes: BTreeMap<String, String>,
717}
718
719/// One bounded event poll result for an exact Box generation.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct ExecutionEventBatch {
722    pub execution_id: ExecutionId,
723    pub generation: ExecutionGeneration,
724    pub events: Vec<ExecutionRuntimeEvent>,
725    pub next_sequence: u64,
726}
727
728impl ExecutionEventBatch {
729    pub fn validate_after(&self, after_sequence: u64) -> ExecutionManagerResult<()> {
730        if self.next_sequence < after_sequence {
731            return Err(ExecutionManagerError::Internal(
732                "runtime event cursor regressed".to_string(),
733            ));
734        }
735        let mut previous = after_sequence;
736        for event in &self.events {
737            if event.sequence == 0 || event.sequence <= previous {
738                return Err(ExecutionManagerError::Internal(
739                    "runtime events are not strictly ordered after the requested cursor"
740                        .to_string(),
741                ));
742            }
743            if event.timestamp_unix_ns == 0 {
744                return Err(ExecutionManagerError::Internal(format!(
745                    "runtime event {} has timestamp zero",
746                    event.sequence
747                )));
748            }
749            previous = event.sequence;
750        }
751        if self.next_sequence < previous {
752            return Err(ExecutionManagerError::Internal(
753                "runtime event next cursor precedes the returned batch".to_string(),
754            ));
755        }
756        Ok(())
757    }
758}
759
760fn valid_cpuset(value: &str) -> bool {
761    let value = value.trim();
762    !value.is_empty()
763        && value.split(',').all(|item| {
764            let item = item.trim();
765            match item.split_once('-') {
766                Some((lower, upper)) => parse_cpu_index(lower)
767                    .zip(parse_cpu_index(upper))
768                    .is_some_and(|(lower, upper)| lower <= upper),
769                None => parse_cpu_index(item).is_some(),
770            }
771        })
772}
773
774fn parse_cpu_index(value: &str) -> Option<u32> {
775    (!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
776        .then(|| value.parse().ok())
777        .flatten()
778}
779
780/// Runtime evidence recovered after a service restart.
781#[derive(Debug, Clone)]
782pub enum ReconcileOutcome {
783    Absent,
784    Created(ExecutionReservation),
785    Creating,
786    Ready(ExecutionLease),
787    Failed,
788}
789
790/// Errors returned by the lifecycle facade without exposing backend internals.
791#[derive(Debug, Error)]
792pub enum ExecutionManagerError {
793    #[error("invalid execution request: {0}")]
794    InvalidRequest(String),
795    #[error("execution not found: {0}")]
796    NotFound(ExecutionId),
797    #[error("execution conflict for {execution_id}: {message}")]
798    Conflict {
799        execution_id: ExecutionId,
800        message: String,
801    },
802    #[error("execution backend unavailable: {0}")]
803    Unavailable(String),
804    #[error("execution lifecycle failed: {0}")]
805    Internal(String),
806}
807
808pub type ExecutionManagerResult<T> = std::result::Result<T, ExecutionManagerError>;
809
810/// Bidirectional byte stream connected to one generation-fenced workload port.
811pub trait ExecutionPortIo: AsyncRead + AsyncWrite + Send + Unpin {}
812
813impl<T> ExecutionPortIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {}
814
815pub type ExecutionPortStream = Pin<Box<dyn ExecutionPortIo>>;
816
817/// Backend-neutral connector used by data-plane gateways.
818///
819/// Implementations must validate the execution generation atomically with
820/// selecting the live runtime. A connector must never fall back to another
821/// execution or generation when the requested runtime is unavailable.
822#[async_trait]
823pub trait ExecutionPortConnector: Send + Sync {
824    async fn connect_port(
825        &self,
826        execution_id: &ExecutionId,
827        generation: ExecutionGeneration,
828        port: NonZeroU16,
829        timeout: Duration,
830    ) -> ExecutionManagerResult<ExecutionPortStream>;
831}
832
833/// Backend-neutral lifecycle facade shared by the CLI, SDK, and remote service.
834#[async_trait]
835pub trait ExecutionManager: Send + Sync {
836    /// Persist exactly one unstarted execution reservation for `operation_id`.
837    async fn create(
838        &self,
839        _request: CreateExecutionRequest,
840        _operation_id: &OperationId,
841    ) -> ExecutionManagerResult<ExecutionReservation> {
842        Err(ExecutionManagerError::Unavailable(
843            "this execution manager does not support staged create".to_string(),
844        ))
845    }
846
847    /// Start one created execution after fencing stale callers by generation.
848    async fn start(
849        &self,
850        _execution_id: &ExecutionId,
851        _generation: ExecutionGeneration,
852    ) -> ExecutionManagerResult<ExecutionLease> {
853        Err(ExecutionManagerError::Unavailable(
854            "this execution manager does not support staged start".to_string(),
855        ))
856    }
857
858    /// Create and start exactly one execution for `operation_id`.
859    ///
860    /// Retrying after a crash reuses the durable reservation and continues its
861    /// start instead of allocating a second execution.
862    async fn create_and_start(
863        &self,
864        request: CreateExecutionRequest,
865        operation_id: &OperationId,
866    ) -> ExecutionManagerResult<ExecutionLease> {
867        let reservation = self.create(request, operation_id).await?;
868        self.start(&reservation.execution_id, reservation.generation)
869            .await
870    }
871
872    async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<ExecutionStatus>;
873
874    /// Read structured stdout/stderr entries after fencing the runtime generation.
875    async fn read_logs(
876        &self,
877        _execution_id: &ExecutionId,
878        _generation: ExecutionGeneration,
879    ) -> ExecutionManagerResult<Vec<LogEntry>> {
880        Err(ExecutionManagerError::Unavailable(
881            "this execution manager does not expose structured logs".to_string(),
882        ))
883    }
884
885    /// Return the runtime-visible init and live exec processes for one exact generation.
886    async fn list_processes(
887        &self,
888        _execution_id: &ExecutionId,
889        _generation: ExecutionGeneration,
890    ) -> ExecutionManagerResult<ExecutionProcessInventory> {
891        Err(ExecutionManagerError::Unavailable(
892            "this execution manager does not expose process inventory".to_string(),
893        ))
894    }
895
896    /// Return one normalized resource snapshot for an exact generation.
897    async fn stats(
898        &self,
899        _execution_id: &ExecutionId,
900        _generation: ExecutionGeneration,
901    ) -> ExecutionManagerResult<ExecutionStats> {
902        Err(ExecutionManagerError::Unavailable(
903            "this execution manager does not expose runtime stats".to_string(),
904        ))
905    }
906
907    /// Poll ordered runtime events without crossing the selected generation.
908    async fn events(
909        &self,
910        _execution_id: &ExecutionId,
911        _generation: ExecutionGeneration,
912        _request: ExecutionEventsRequest,
913    ) -> ExecutionManagerResult<ExecutionEventBatch> {
914        Err(ExecutionManagerError::Unavailable(
915            "this execution manager does not expose runtime events".to_string(),
916        ))
917    }
918
919    /// Apply a replay-safe partial live resource update to one exact generation.
920    async fn update_resources(
921        &self,
922        _execution_id: &ExecutionId,
923        _generation: ExecutionGeneration,
924        _operation_id: &OperationId,
925        _update: ExecutionResourceUpdate,
926    ) -> ExecutionManagerResult<ExecutionLease> {
927        Err(ExecutionManagerError::Unavailable(
928            "this execution manager does not support live resource updates".to_string(),
929        ))
930    }
931
932    /// Temporarily quiesce the execution, atomically capture its rootfs in the
933    /// runtime-managed snapshot store, and restore its prior stable state.
934    async fn create_filesystem_snapshot(
935        &self,
936        _execution_id: &ExecutionId,
937        _generation: ExecutionGeneration,
938        _snapshot_id: &ExecutionSnapshotId,
939    ) -> ExecutionManagerResult<ExecutionSnapshot> {
940        Err(ExecutionManagerError::Unavailable(
941            "this execution manager does not support filesystem snapshots".to_string(),
942        ))
943    }
944
945    /// Return the size of a fully published runtime-managed snapshot, or
946    /// `None` when it does not exist.
947    async fn filesystem_snapshot_size(
948        &self,
949        _snapshot_id: &ExecutionSnapshotId,
950    ) -> ExecutionManagerResult<Option<u64>> {
951        Err(ExecutionManagerError::Unavailable(
952            "this execution manager does not expose filesystem snapshots".to_string(),
953        ))
954    }
955
956    /// Delete a runtime-managed snapshot, refusing while an active execution
957    /// still uses it as a copy-on-write lower.
958    async fn delete_filesystem_snapshot(
959        &self,
960        _snapshot_id: &ExecutionSnapshotId,
961    ) -> ExecutionManagerResult<bool> {
962        Err(ExecutionManagerError::Unavailable(
963            "this execution manager does not support filesystem snapshot deletion".to_string(),
964        ))
965    }
966
967    /// Pause one execution and return the generation-fenced paused lease.
968    async fn pause(
969        &self,
970        execution_id: &ExecutionId,
971        generation: ExecutionGeneration,
972        keep_memory: bool,
973    ) -> ExecutionManagerResult<ExecutionLease>;
974
975    async fn resume(
976        &self,
977        execution_id: &ExecutionId,
978        generation: ExecutionGeneration,
979    ) -> ExecutionManagerResult<ExecutionLease>;
980
981    /// Terminate the current runtime, advance its generation exactly once,
982    /// and start it again under an idempotent operation identity.
983    async fn restart(
984        &self,
985        execution_id: &ExecutionId,
986        generation: ExecutionGeneration,
987        operation_id: &OperationId,
988    ) -> ExecutionManagerResult<ExecutionLease> {
989        self.restart_with_options(
990            execution_id,
991            generation,
992            operation_id,
993            RestartExecutionOptions::default(),
994        )
995        .await
996    }
997
998    /// Restart with controls that become part of the durable operation intent.
999    async fn restart_with_options(
1000        &self,
1001        _execution_id: &ExecutionId,
1002        _generation: ExecutionGeneration,
1003        _operation_id: &OperationId,
1004        _options: RestartExecutionOptions,
1005    ) -> ExecutionManagerResult<ExecutionLease> {
1006        Err(ExecutionManagerError::Unavailable(
1007            "this execution manager does not support restart".to_string(),
1008        ))
1009    }
1010
1011    async fn kill(
1012        &self,
1013        execution_id: &ExecutionId,
1014        generation: ExecutionGeneration,
1015    ) -> ExecutionManagerResult<KillOutcome>;
1016
1017    /// Terminate one execution with controls that survive lifecycle recovery.
1018    ///
1019    /// Managers without option-aware termination may delegate to [`Self::kill`].
1020    async fn kill_with_options(
1021        &self,
1022        execution_id: &ExecutionId,
1023        generation: ExecutionGeneration,
1024        _options: KillExecutionOptions,
1025    ) -> ExecutionManagerResult<KillOutcome> {
1026        self.kill(execution_id, generation).await
1027    }
1028
1029    /// Remove one terminal execution and all runtime-owned resources.
1030    ///
1031    /// Implementations must fence removal by generation and make retries
1032    /// idempotent. Active executions must be stopped explicitly before this
1033    /// operation; removal must never imply an unrequested kill.
1034    async fn remove(
1035        &self,
1036        _execution_id: &ExecutionId,
1037        _generation: ExecutionGeneration,
1038    ) -> ExecutionManagerResult<bool> {
1039        Err(ExecutionManagerError::Unavailable(
1040            "this execution manager does not support execution removal".to_string(),
1041        ))
1042    }
1043
1044    async fn reconcile(
1045        &self,
1046        operation_id: &OperationId,
1047    ) -> ExecutionManagerResult<ReconcileOutcome>;
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    #[test]
1055    fn identifiers_reject_empty_values() {
1056        assert!(matches!(
1057            ExecutionId::new("  "),
1058            Err(ExecutionManagerError::InvalidRequest(_))
1059        ));
1060        assert!(matches!(
1061            OperationId::new(""),
1062            Err(ExecutionManagerError::InvalidRequest(_))
1063        ));
1064    }
1065
1066    #[test]
1067    fn generation_rejects_zero() {
1068        assert!(matches!(
1069            ExecutionGeneration::new(0),
1070            Err(ExecutionManagerError::InvalidRequest(_))
1071        ));
1072        assert_eq!(ExecutionGeneration::INITIAL.get(), 1);
1073        assert!(serde_json::from_str::<ExecutionGeneration>("0").is_err());
1074    }
1075
1076    #[test]
1077    fn identifier_deserialization_preserves_invariants() {
1078        assert!(serde_json::from_str::<ExecutionId>("\"\"").is_err());
1079        assert!(serde_json::from_str::<OperationId>("\" \"").is_err());
1080    }
1081
1082    #[test]
1083    fn snapshot_identifiers_are_safe_managed_directory_names() {
1084        for valid in ["snapshot-1", "SNAPSHOT_2", "a"] {
1085            assert_eq!(ExecutionSnapshotId::new(valid).unwrap().as_str(), valid);
1086        }
1087        for invalid in [
1088            "",
1089            ".",
1090            "..",
1091            "../snapshot",
1092            "snapshot/path",
1093            "snapshot:tag",
1094            "snapshot id",
1095        ] {
1096            assert!(matches!(
1097                ExecutionSnapshotId::new(invalid),
1098                Err(ExecutionManagerError::InvalidRequest(_))
1099            ));
1100        }
1101        assert!(ExecutionSnapshotId::new("x".repeat(129)).is_err());
1102        assert!(serde_json::from_str::<ExecutionSnapshotId>("\"../snapshot\"").is_err());
1103    }
1104
1105    #[test]
1106    fn legacy_creation_requests_default_record_policy() {
1107        let request: CreateExecutionRequest = serde_json::from_value(serde_json::json!({
1108            "external_sandbox_id": "sandbox-1",
1109            "config": BoxConfig::default(),
1110            "labels": {"purpose": "compatibility"}
1111        }))
1112        .unwrap();
1113
1114        assert_eq!(request.policy, ExecutionRecordPolicy::default());
1115        assert_eq!(request.policy.restart_policy, ExecutionRestartPolicy::No);
1116        assert!(request.rootfs_snapshot_id.is_none());
1117    }
1118
1119    #[test]
1120    fn restart_policy_has_stable_record_values() {
1121        assert_eq!(ExecutionRestartPolicy::No.as_str(), "no");
1122        assert_eq!(ExecutionRestartPolicy::Always.as_str(), "always");
1123        assert_eq!(ExecutionRestartPolicy::OnFailure.as_str(), "on-failure");
1124        assert_eq!(
1125            ExecutionRestartPolicy::UnlessStopped.as_str(),
1126            "unless-stopped"
1127        );
1128        assert_eq!(
1129            serde_json::to_value(ExecutionRestartPolicy::OnFailure).unwrap(),
1130            "on-failure"
1131        );
1132    }
1133
1134    #[test]
1135    fn resource_updates_validate_and_preserve_unmentioned_limits() {
1136        let mut limits = ResourceLimits {
1137            memory_swap: Some(-1),
1138            cpu_period: Some(100_000),
1139            ulimits: vec!["NOFILE=1024:2048".to_string()],
1140            ..Default::default()
1141        };
1142        let update = ExecutionResourceUpdate {
1143            memory_reservation: Some(64 * 1024 * 1024),
1144            pids_limit: Some(64),
1145            cpu_shares: Some(512),
1146            cpuset_cpus: Some("0-1,3".to_string()),
1147            ..Default::default()
1148        };
1149
1150        update.validate().unwrap();
1151        update.apply_to(&mut limits);
1152
1153        assert_eq!(limits.memory_reservation, Some(64 * 1024 * 1024));
1154        assert_eq!(limits.memory_swap, Some(-1));
1155        assert_eq!(limits.cpu_period, Some(100_000));
1156        assert_eq!(limits.pids_limit, Some(64));
1157        assert_eq!(limits.cpu_shares, Some(512));
1158        assert_eq!(limits.cpuset_cpus.as_deref(), Some("0-1,3"));
1159        assert_eq!(limits.ulimits, ["NOFILE=1024:2048"]);
1160
1161        for invalid in [
1162            ExecutionResourceUpdate::default(),
1163            ExecutionResourceUpdate {
1164                pids_limit: Some(0),
1165                ..Default::default()
1166            },
1167            ExecutionResourceUpdate {
1168                cpu_shares: Some(1),
1169                ..Default::default()
1170            },
1171            ExecutionResourceUpdate {
1172                cpu_quota: Some(-1),
1173                ..Default::default()
1174            },
1175            ExecutionResourceUpdate {
1176                cpuset_cpus: Some("3-1".to_string()),
1177                ..Default::default()
1178            },
1179        ] {
1180            assert!(matches!(
1181                invalid.validate(),
1182                Err(ExecutionManagerError::InvalidRequest(_))
1183            ));
1184        }
1185    }
1186
1187    #[test]
1188    fn event_batches_require_strict_order_and_nonregressing_cursors() {
1189        let execution_id = ExecutionId::new("events").unwrap();
1190        let batch = ExecutionEventBatch {
1191            execution_id: execution_id.clone(),
1192            generation: ExecutionGeneration::INITIAL,
1193            events: vec![
1194                ExecutionRuntimeEvent {
1195                    sequence: 4,
1196                    timestamp_unix_ns: 10,
1197                    process_id: None,
1198                    kind: ExecutionEventKind::ContainerStarted,
1199                    attributes: BTreeMap::new(),
1200                },
1201                ExecutionRuntimeEvent {
1202                    sequence: 7,
1203                    timestamp_unix_ns: 11,
1204                    process_id: Some("init".to_string()),
1205                    kind: ExecutionEventKind::ProcessStarted,
1206                    attributes: BTreeMap::new(),
1207                },
1208            ],
1209            next_sequence: 7,
1210        };
1211        batch.validate_after(3).unwrap();
1212
1213        let mut duplicate = batch.clone();
1214        duplicate.events[1].sequence = 4;
1215        assert!(matches!(
1216            duplicate.validate_after(3),
1217            Err(ExecutionManagerError::Internal(_))
1218        ));
1219
1220        let mut regressed = batch;
1221        regressed.next_sequence = 3;
1222        assert!(matches!(
1223            regressed.validate_after(3),
1224            Err(ExecutionManagerError::Internal(_))
1225        ));
1226    }
1227}