1use 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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "kebab-case")]
190pub enum ExecutionRestartPolicy {
191 #[default]
193 No,
194 Always,
196 OnFailure,
198 UnlessStopped,
200}
201
202impl ExecutionRestartPolicy {
203 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ExecutionHealthCheck {
217 pub cmd: Vec<String>,
219 #[serde(default = "default_health_interval")]
221 pub interval_secs: u64,
222 #[serde(default = "default_health_timeout")]
224 pub timeout_secs: u64,
225 #[serde(default = "default_health_retries")]
227 pub retries: u32,
228 #[serde(default)]
230 pub start_period_secs: u64,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ExecutionRecordPolicy {
240 #[serde(default)]
242 pub name: Option<String>,
243 #[serde(default)]
245 pub auto_remove: bool,
246 #[serde(default)]
248 pub restart_policy: ExecutionRestartPolicy,
249 #[serde(default)]
251 pub max_restart_count: u32,
252 #[serde(default)]
254 pub health_check: Option<ExecutionHealthCheck>,
255 #[serde(default)]
257 pub healthcheck_disabled: bool,
258 #[serde(default)]
260 pub log_config: LogConfig,
261 #[serde(default)]
263 pub volume_names: Vec<String>,
264 #[serde(default)]
266 pub platform: Option<String>,
267 #[serde(default)]
269 pub init: bool,
270 #[serde(default)]
272 pub devices: Vec<String>,
273 #[serde(default)]
275 pub gpus: Option<String>,
276 #[serde(default)]
278 pub shm_size: Option<u64>,
279 #[serde(default)]
281 pub stop_signal: Option<String>,
282 #[serde(default)]
284 pub stop_timeout: Option<u64>,
285 #[serde(default)]
287 pub oom_kill_disable: bool,
288 #[serde(default)]
290 pub oom_score_adj: Option<i32>,
291 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct CreateExecutionRequest {
341 pub external_sandbox_id: String,
343 pub config: BoxConfig,
345 pub labels: BTreeMap<String, String>,
347 #[serde(default)]
349 pub policy: ExecutionRecordPolicy,
350 #[serde(default)]
354 pub rootfs_snapshot_id: Option<ExecutionSnapshotId>,
355}
356
357#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct ExecutionSnapshot {
380 pub snapshot_id: ExecutionSnapshotId,
381 pub size_bytes: u64,
382 pub state: ExecutionState,
384 pub lease: ExecutionLease,
386}
387
388#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum KillOutcome {
412 Killed,
413 AlreadyStopped,
414}
415
416#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
418pub struct KillExecutionOptions {
419 #[serde(default)]
422 pub signal: Option<i32>,
423 #[serde(default)]
426 pub timeout_secs: Option<u64>,
427}
428
429#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
431pub struct RestartExecutionOptions {
432 #[serde(default)]
435 pub stop_timeout_secs: Option<u64>,
436}
437
438pub const MAX_EXECUTION_EVENT_BATCH_ITEMS: u32 = 4_096;
440
441#[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 #[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 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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone)]
782pub enum ReconcileOutcome {
783 Absent,
784 Created(ExecutionReservation),
785 Creating,
786 Ready(ExecutionLease),
787 Failed,
788}
789
790#[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
810pub 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#[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#[async_trait]
835pub trait ExecutionManager: Send + Sync {
836 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}