1use std::collections::BTreeMap;
4use std::num::NonZeroU16;
5use std::pin::Pin;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use tokio::io::{AsyncRead, AsyncWrite};
13
14use crate::config::{BoxConfig, ResourceConfig};
15use crate::execution::ResolvedExecutionPlan;
16use crate::log::{LogConfig, LogEntry};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(try_from = "String", into = "String")]
21pub struct ExecutionId(String);
22
23impl ExecutionId {
24 pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
25 let value = value.into();
26 if value.trim().is_empty() {
27 return Err(ExecutionManagerError::InvalidRequest(
28 "execution ID cannot be empty".to_string(),
29 ));
30 }
31 Ok(Self(value))
32 }
33
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl std::fmt::Display for ExecutionId {
40 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 formatter.write_str(&self.0)
42 }
43}
44
45impl TryFrom<String> for ExecutionId {
46 type Error = ExecutionManagerError;
47
48 fn try_from(value: String) -> Result<Self, Self::Error> {
49 Self::new(value)
50 }
51}
52
53impl From<ExecutionId> for String {
54 fn from(value: ExecutionId) -> Self {
55 value.0
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
65#[serde(try_from = "String", into = "String")]
66pub struct ExecutionSnapshotId(String);
67
68impl ExecutionSnapshotId {
69 pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
70 let value = value.into();
71 if value.is_empty()
72 || value.len() > 128
73 || !value
74 .bytes()
75 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
76 {
77 return Err(ExecutionManagerError::InvalidRequest(
78 "execution snapshot ID must match [A-Za-z0-9_-]{1,128}".to_string(),
79 ));
80 }
81 Ok(Self(value))
82 }
83
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87}
88
89impl std::fmt::Display for ExecutionSnapshotId {
90 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 formatter.write_str(&self.0)
92 }
93}
94
95impl TryFrom<String> for ExecutionSnapshotId {
96 type Error = ExecutionManagerError;
97
98 fn try_from(value: String) -> Result<Self, Self::Error> {
99 Self::new(value)
100 }
101}
102
103impl From<ExecutionSnapshotId> for String {
104 fn from(value: ExecutionSnapshotId) -> Self {
105 value.0
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
111#[serde(try_from = "String", into = "String")]
112pub struct OperationId(String);
113
114impl OperationId {
115 pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
116 let value = value.into();
117 if value.trim().is_empty() {
118 return Err(ExecutionManagerError::InvalidRequest(
119 "operation ID cannot be empty".to_string(),
120 ));
121 }
122 Ok(Self(value))
123 }
124
125 pub fn as_str(&self) -> &str {
126 &self.0
127 }
128}
129
130impl std::fmt::Display for OperationId {
131 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 formatter.write_str(&self.0)
133 }
134}
135
136impl TryFrom<String> for OperationId {
137 type Error = ExecutionManagerError;
138
139 fn try_from(value: String) -> Result<Self, Self::Error> {
140 Self::new(value)
141 }
142}
143
144impl From<OperationId> for String {
145 fn from(value: OperationId) -> Self {
146 value.0
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
152#[serde(try_from = "u64", into = "u64")]
153pub struct ExecutionGeneration(u64);
154
155impl ExecutionGeneration {
156 pub const INITIAL: Self = Self(1);
157
158 pub fn new(value: u64) -> ExecutionManagerResult<Self> {
159 if value == 0 {
160 return Err(ExecutionManagerError::InvalidRequest(
161 "execution generation must be greater than zero".to_string(),
162 ));
163 }
164 Ok(Self(value))
165 }
166
167 pub const fn get(self) -> u64 {
168 self.0
169 }
170}
171
172impl TryFrom<u64> for ExecutionGeneration {
173 type Error = ExecutionManagerError;
174
175 fn try_from(value: u64) -> Result<Self, Self::Error> {
176 Self::new(value)
177 }
178}
179
180impl From<ExecutionGeneration> for u64 {
181 fn from(value: ExecutionGeneration) -> Self {
182 value.0
183 }
184}
185
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum ExecutionRestartPolicy {
190 #[default]
192 No,
193 Always,
195 OnFailure,
197 UnlessStopped,
199}
200
201impl ExecutionRestartPolicy {
202 pub const fn as_str(self) -> &'static str {
204 match self {
205 Self::No => "no",
206 Self::Always => "always",
207 Self::OnFailure => "on-failure",
208 Self::UnlessStopped => "unless-stopped",
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct ExecutionHealthCheck {
216 pub cmd: Vec<String>,
218 #[serde(default = "default_health_interval")]
220 pub interval_secs: u64,
221 #[serde(default = "default_health_timeout")]
223 pub timeout_secs: u64,
224 #[serde(default = "default_health_retries")]
226 pub retries: u32,
227 #[serde(default)]
229 pub start_period_secs: u64,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct ExecutionRecordPolicy {
239 #[serde(default)]
241 pub name: Option<String>,
242 #[serde(default)]
244 pub auto_remove: bool,
245 #[serde(default)]
247 pub restart_policy: ExecutionRestartPolicy,
248 #[serde(default)]
250 pub max_restart_count: u32,
251 #[serde(default)]
253 pub health_check: Option<ExecutionHealthCheck>,
254 #[serde(default)]
256 pub healthcheck_disabled: bool,
257 #[serde(default)]
259 pub log_config: LogConfig,
260 #[serde(default)]
262 pub volume_names: Vec<String>,
263 #[serde(default)]
265 pub platform: Option<String>,
266 #[serde(default)]
268 pub init: bool,
269 #[serde(default)]
271 pub devices: Vec<String>,
272 #[serde(default)]
274 pub gpus: Option<String>,
275 #[serde(default)]
277 pub shm_size: Option<u64>,
278 #[serde(default)]
280 pub stop_signal: Option<String>,
281 #[serde(default)]
283 pub stop_timeout: Option<u64>,
284 #[serde(default)]
286 pub oom_kill_disable: bool,
287 #[serde(default)]
289 pub oom_score_adj: Option<i32>,
290}
291
292impl Default for ExecutionRecordPolicy {
293 fn default() -> Self {
294 Self {
295 name: None,
296 auto_remove: false,
297 restart_policy: ExecutionRestartPolicy::No,
298 max_restart_count: 0,
299 health_check: None,
300 healthcheck_disabled: false,
301 log_config: LogConfig::default(),
302 volume_names: Vec::new(),
303 platform: None,
304 init: false,
305 devices: Vec::new(),
306 gpus: None,
307 shm_size: None,
308 stop_signal: None,
309 stop_timeout: None,
310 oom_kill_disable: false,
311 oom_score_adj: None,
312 }
313 }
314}
315
316fn default_health_interval() -> u64 {
317 30
318}
319
320fn default_health_timeout() -> u64 {
321 5
322}
323
324fn default_health_retries() -> u32 {
325 3
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct CreateExecutionRequest {
331 pub external_sandbox_id: String,
333 pub config: BoxConfig,
335 pub labels: BTreeMap<String, String>,
337 #[serde(default)]
339 pub policy: ExecutionRecordPolicy,
340 #[serde(default)]
344 pub rootfs_snapshot_id: Option<ExecutionSnapshotId>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct ExecutionReservation {
350 pub execution_id: ExecutionId,
351 pub generation: ExecutionGeneration,
352 pub plan: ResolvedExecutionPlan,
353 pub resources: ResourceConfig,
354 pub created_at: DateTime<Utc>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ExecutionLease {
360 pub execution_id: ExecutionId,
361 pub generation: ExecutionGeneration,
362 pub plan: ResolvedExecutionPlan,
363 pub resources: ResourceConfig,
364 pub started_at: DateTime<Utc>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ExecutionSnapshot {
370 pub snapshot_id: ExecutionSnapshotId,
371 pub size_bytes: u64,
372 pub state: ExecutionState,
374 pub lease: ExecutionLease,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum ExecutionState {
382 Created,
383 Creating,
384 Running,
385 Paused,
386 Stopped,
387 Failed,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct ExecutionStatus {
393 pub execution_id: ExecutionId,
394 pub generation: ExecutionGeneration,
395 pub state: ExecutionState,
396 pub plan: ResolvedExecutionPlan,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum KillOutcome {
402 Killed,
403 AlreadyStopped,
404}
405
406#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
408pub struct RestartExecutionOptions {
409 #[serde(default)]
412 pub stop_timeout_secs: Option<u64>,
413}
414
415#[derive(Debug, Clone)]
417pub enum ReconcileOutcome {
418 Absent,
419 Created(ExecutionReservation),
420 Creating,
421 Ready(ExecutionLease),
422 Failed,
423}
424
425#[derive(Debug, Error)]
427pub enum ExecutionManagerError {
428 #[error("invalid execution request: {0}")]
429 InvalidRequest(String),
430 #[error("execution not found: {0}")]
431 NotFound(ExecutionId),
432 #[error("execution conflict for {execution_id}: {message}")]
433 Conflict {
434 execution_id: ExecutionId,
435 message: String,
436 },
437 #[error("execution backend unavailable: {0}")]
438 Unavailable(String),
439 #[error("execution lifecycle failed: {0}")]
440 Internal(String),
441}
442
443pub type ExecutionManagerResult<T> = std::result::Result<T, ExecutionManagerError>;
444
445pub trait ExecutionPortIo: AsyncRead + AsyncWrite + Send + Unpin {}
447
448impl<T> ExecutionPortIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {}
449
450pub type ExecutionPortStream = Pin<Box<dyn ExecutionPortIo>>;
451
452#[async_trait]
458pub trait ExecutionPortConnector: Send + Sync {
459 async fn connect_port(
460 &self,
461 execution_id: &ExecutionId,
462 generation: ExecutionGeneration,
463 port: NonZeroU16,
464 timeout: Duration,
465 ) -> ExecutionManagerResult<ExecutionPortStream>;
466}
467
468#[async_trait]
470pub trait ExecutionManager: Send + Sync {
471 async fn create(
473 &self,
474 _request: CreateExecutionRequest,
475 _operation_id: &OperationId,
476 ) -> ExecutionManagerResult<ExecutionReservation> {
477 Err(ExecutionManagerError::Unavailable(
478 "this execution manager does not support staged create".to_string(),
479 ))
480 }
481
482 async fn start(
484 &self,
485 _execution_id: &ExecutionId,
486 _generation: ExecutionGeneration,
487 ) -> ExecutionManagerResult<ExecutionLease> {
488 Err(ExecutionManagerError::Unavailable(
489 "this execution manager does not support staged start".to_string(),
490 ))
491 }
492
493 async fn create_and_start(
498 &self,
499 request: CreateExecutionRequest,
500 operation_id: &OperationId,
501 ) -> ExecutionManagerResult<ExecutionLease> {
502 let reservation = self.create(request, operation_id).await?;
503 self.start(&reservation.execution_id, reservation.generation)
504 .await
505 }
506
507 async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<ExecutionStatus>;
508
509 async fn read_logs(
511 &self,
512 _execution_id: &ExecutionId,
513 _generation: ExecutionGeneration,
514 ) -> ExecutionManagerResult<Vec<LogEntry>> {
515 Err(ExecutionManagerError::Unavailable(
516 "this execution manager does not expose structured logs".to_string(),
517 ))
518 }
519
520 async fn create_filesystem_snapshot(
523 &self,
524 _execution_id: &ExecutionId,
525 _generation: ExecutionGeneration,
526 _snapshot_id: &ExecutionSnapshotId,
527 ) -> ExecutionManagerResult<ExecutionSnapshot> {
528 Err(ExecutionManagerError::Unavailable(
529 "this execution manager does not support filesystem snapshots".to_string(),
530 ))
531 }
532
533 async fn filesystem_snapshot_size(
536 &self,
537 _snapshot_id: &ExecutionSnapshotId,
538 ) -> ExecutionManagerResult<Option<u64>> {
539 Err(ExecutionManagerError::Unavailable(
540 "this execution manager does not expose filesystem snapshots".to_string(),
541 ))
542 }
543
544 async fn delete_filesystem_snapshot(
547 &self,
548 _snapshot_id: &ExecutionSnapshotId,
549 ) -> ExecutionManagerResult<bool> {
550 Err(ExecutionManagerError::Unavailable(
551 "this execution manager does not support filesystem snapshot deletion".to_string(),
552 ))
553 }
554
555 async fn pause(
557 &self,
558 execution_id: &ExecutionId,
559 generation: ExecutionGeneration,
560 keep_memory: bool,
561 ) -> ExecutionManagerResult<ExecutionLease>;
562
563 async fn resume(
564 &self,
565 execution_id: &ExecutionId,
566 generation: ExecutionGeneration,
567 ) -> ExecutionManagerResult<ExecutionLease>;
568
569 async fn restart(
572 &self,
573 execution_id: &ExecutionId,
574 generation: ExecutionGeneration,
575 operation_id: &OperationId,
576 ) -> ExecutionManagerResult<ExecutionLease> {
577 self.restart_with_options(
578 execution_id,
579 generation,
580 operation_id,
581 RestartExecutionOptions::default(),
582 )
583 .await
584 }
585
586 async fn restart_with_options(
588 &self,
589 _execution_id: &ExecutionId,
590 _generation: ExecutionGeneration,
591 _operation_id: &OperationId,
592 _options: RestartExecutionOptions,
593 ) -> ExecutionManagerResult<ExecutionLease> {
594 Err(ExecutionManagerError::Unavailable(
595 "this execution manager does not support restart".to_string(),
596 ))
597 }
598
599 async fn kill(
600 &self,
601 execution_id: &ExecutionId,
602 generation: ExecutionGeneration,
603 ) -> ExecutionManagerResult<KillOutcome>;
604
605 async fn reconcile(
606 &self,
607 operation_id: &OperationId,
608 ) -> ExecutionManagerResult<ReconcileOutcome>;
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn identifiers_reject_empty_values() {
617 assert!(matches!(
618 ExecutionId::new(" "),
619 Err(ExecutionManagerError::InvalidRequest(_))
620 ));
621 assert!(matches!(
622 OperationId::new(""),
623 Err(ExecutionManagerError::InvalidRequest(_))
624 ));
625 }
626
627 #[test]
628 fn generation_rejects_zero() {
629 assert!(matches!(
630 ExecutionGeneration::new(0),
631 Err(ExecutionManagerError::InvalidRequest(_))
632 ));
633 assert_eq!(ExecutionGeneration::INITIAL.get(), 1);
634 assert!(serde_json::from_str::<ExecutionGeneration>("0").is_err());
635 }
636
637 #[test]
638 fn identifier_deserialization_preserves_invariants() {
639 assert!(serde_json::from_str::<ExecutionId>("\"\"").is_err());
640 assert!(serde_json::from_str::<OperationId>("\" \"").is_err());
641 }
642
643 #[test]
644 fn snapshot_identifiers_are_safe_managed_directory_names() {
645 for valid in ["snapshot-1", "SNAPSHOT_2", "a"] {
646 assert_eq!(ExecutionSnapshotId::new(valid).unwrap().as_str(), valid);
647 }
648 for invalid in [
649 "",
650 ".",
651 "..",
652 "../snapshot",
653 "snapshot/path",
654 "snapshot:tag",
655 "snapshot id",
656 ] {
657 assert!(matches!(
658 ExecutionSnapshotId::new(invalid),
659 Err(ExecutionManagerError::InvalidRequest(_))
660 ));
661 }
662 assert!(ExecutionSnapshotId::new("x".repeat(129)).is_err());
663 assert!(serde_json::from_str::<ExecutionSnapshotId>("\"../snapshot\"").is_err());
664 }
665
666 #[test]
667 fn legacy_creation_requests_default_record_policy() {
668 let request: CreateExecutionRequest = serde_json::from_value(serde_json::json!({
669 "external_sandbox_id": "sandbox-1",
670 "config": BoxConfig::default(),
671 "labels": {"purpose": "compatibility"}
672 }))
673 .unwrap();
674
675 assert_eq!(request.policy, ExecutionRecordPolicy::default());
676 assert_eq!(request.policy.restart_policy, ExecutionRestartPolicy::No);
677 assert!(request.rootfs_snapshot_id.is_none());
678 }
679
680 #[test]
681 fn restart_policy_has_stable_record_values() {
682 assert_eq!(ExecutionRestartPolicy::No.as_str(), "no");
683 assert_eq!(ExecutionRestartPolicy::Always.as_str(), "always");
684 assert_eq!(ExecutionRestartPolicy::OnFailure.as_str(), "on-failure");
685 assert_eq!(
686 ExecutionRestartPolicy::UnlessStopped.as_str(),
687 "unless-stopped"
688 );
689 assert_eq!(
690 serde_json::to_value(ExecutionRestartPolicy::OnFailure).unwrap(),
691 "on-failure"
692 );
693 }
694}