use std::collections::BTreeMap;
use std::num::NonZeroU16;
use std::pin::Pin;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::config::{BoxConfig, ResourceConfig};
use crate::execution::ResolvedExecutionPlan;
use crate::log::{LogConfig, LogEntry};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ExecutionId(String);
impl ExecutionId {
pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
let value = value.into();
if value.trim().is_empty() {
return Err(ExecutionManagerError::InvalidRequest(
"execution ID cannot be empty".to_string(),
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ExecutionId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for ExecutionId {
type Error = ExecutionManagerError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ExecutionId> for String {
fn from(value: ExecutionId) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ExecutionSnapshotId(String);
impl ExecutionSnapshotId {
pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
let value = value.into();
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(ExecutionManagerError::InvalidRequest(
"execution snapshot ID must match [A-Za-z0-9_-]{1,128}".to_string(),
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ExecutionSnapshotId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for ExecutionSnapshotId {
type Error = ExecutionManagerError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ExecutionSnapshotId> for String {
fn from(value: ExecutionSnapshotId) -> Self {
value.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct OperationId(String);
impl OperationId {
pub fn new(value: impl Into<String>) -> ExecutionManagerResult<Self> {
let value = value.into();
if value.trim().is_empty() {
return Err(ExecutionManagerError::InvalidRequest(
"operation ID cannot be empty".to_string(),
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for OperationId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for OperationId {
type Error = ExecutionManagerError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<OperationId> for String {
fn from(value: OperationId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "u64", into = "u64")]
pub struct ExecutionGeneration(u64);
impl ExecutionGeneration {
pub const INITIAL: Self = Self(1);
pub fn new(value: u64) -> ExecutionManagerResult<Self> {
if value == 0 {
return Err(ExecutionManagerError::InvalidRequest(
"execution generation must be greater than zero".to_string(),
));
}
Ok(Self(value))
}
pub const fn get(self) -> u64 {
self.0
}
}
impl TryFrom<u64> for ExecutionGeneration {
type Error = ExecutionManagerError;
fn try_from(value: u64) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ExecutionGeneration> for u64 {
fn from(value: ExecutionGeneration) -> Self {
value.0
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExecutionRestartPolicy {
#[default]
No,
Always,
OnFailure,
UnlessStopped,
}
impl ExecutionRestartPolicy {
pub const fn as_str(self) -> &'static str {
match self {
Self::No => "no",
Self::Always => "always",
Self::OnFailure => "on-failure",
Self::UnlessStopped => "unless-stopped",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionHealthCheck {
pub cmd: Vec<String>,
#[serde(default = "default_health_interval")]
pub interval_secs: u64,
#[serde(default = "default_health_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_health_retries")]
pub retries: u32,
#[serde(default)]
pub start_period_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionRecordPolicy {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub auto_remove: bool,
#[serde(default)]
pub restart_policy: ExecutionRestartPolicy,
#[serde(default)]
pub max_restart_count: u32,
#[serde(default)]
pub health_check: Option<ExecutionHealthCheck>,
#[serde(default)]
pub healthcheck_disabled: bool,
#[serde(default)]
pub log_config: LogConfig,
#[serde(default)]
pub volume_names: Vec<String>,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub init: bool,
#[serde(default)]
pub devices: Vec<String>,
#[serde(default)]
pub gpus: Option<String>,
#[serde(default)]
pub shm_size: Option<u64>,
#[serde(default)]
pub stop_signal: Option<String>,
#[serde(default)]
pub stop_timeout: Option<u64>,
#[serde(default)]
pub oom_kill_disable: bool,
#[serde(default)]
pub oom_score_adj: Option<i32>,
}
impl Default for ExecutionRecordPolicy {
fn default() -> Self {
Self {
name: None,
auto_remove: false,
restart_policy: ExecutionRestartPolicy::No,
max_restart_count: 0,
health_check: None,
healthcheck_disabled: false,
log_config: LogConfig::default(),
volume_names: Vec::new(),
platform: None,
init: false,
devices: Vec::new(),
gpus: None,
shm_size: None,
stop_signal: None,
stop_timeout: None,
oom_kill_disable: false,
oom_score_adj: None,
}
}
}
fn default_health_interval() -> u64 {
30
}
fn default_health_timeout() -> u64 {
5
}
fn default_health_retries() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateExecutionRequest {
pub external_sandbox_id: String,
pub config: BoxConfig,
pub labels: BTreeMap<String, String>,
#[serde(default)]
pub policy: ExecutionRecordPolicy,
#[serde(default)]
pub rootfs_snapshot_id: Option<ExecutionSnapshotId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionReservation {
pub execution_id: ExecutionId,
pub generation: ExecutionGeneration,
pub plan: ResolvedExecutionPlan,
pub resources: ResourceConfig,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLease {
pub execution_id: ExecutionId,
pub generation: ExecutionGeneration,
pub plan: ResolvedExecutionPlan,
pub resources: ResourceConfig,
pub started_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionSnapshot {
pub snapshot_id: ExecutionSnapshotId,
pub size_bytes: u64,
pub state: ExecutionState,
pub lease: ExecutionLease,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionState {
Created,
Creating,
Running,
Paused,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionStatus {
pub execution_id: ExecutionId,
pub generation: ExecutionGeneration,
pub state: ExecutionState,
pub plan: ResolvedExecutionPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KillOutcome {
Killed,
AlreadyStopped,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KillExecutionOptions {
#[serde(default)]
pub signal: Option<i32>,
#[serde(default)]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestartExecutionOptions {
#[serde(default)]
pub stop_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone)]
pub enum ReconcileOutcome {
Absent,
Created(ExecutionReservation),
Creating,
Ready(ExecutionLease),
Failed,
}
#[derive(Debug, Error)]
pub enum ExecutionManagerError {
#[error("invalid execution request: {0}")]
InvalidRequest(String),
#[error("execution not found: {0}")]
NotFound(ExecutionId),
#[error("execution conflict for {execution_id}: {message}")]
Conflict {
execution_id: ExecutionId,
message: String,
},
#[error("execution backend unavailable: {0}")]
Unavailable(String),
#[error("execution lifecycle failed: {0}")]
Internal(String),
}
pub type ExecutionManagerResult<T> = std::result::Result<T, ExecutionManagerError>;
pub trait ExecutionPortIo: AsyncRead + AsyncWrite + Send + Unpin {}
impl<T> ExecutionPortIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {}
pub type ExecutionPortStream = Pin<Box<dyn ExecutionPortIo>>;
#[async_trait]
pub trait ExecutionPortConnector: Send + Sync {
async fn connect_port(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
port: NonZeroU16,
timeout: Duration,
) -> ExecutionManagerResult<ExecutionPortStream>;
}
#[async_trait]
pub trait ExecutionManager: Send + Sync {
async fn create(
&self,
_request: CreateExecutionRequest,
_operation_id: &OperationId,
) -> ExecutionManagerResult<ExecutionReservation> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support staged create".to_string(),
))
}
async fn start(
&self,
_execution_id: &ExecutionId,
_generation: ExecutionGeneration,
) -> ExecutionManagerResult<ExecutionLease> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support staged start".to_string(),
))
}
async fn create_and_start(
&self,
request: CreateExecutionRequest,
operation_id: &OperationId,
) -> ExecutionManagerResult<ExecutionLease> {
let reservation = self.create(request, operation_id).await?;
self.start(&reservation.execution_id, reservation.generation)
.await
}
async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<ExecutionStatus>;
async fn read_logs(
&self,
_execution_id: &ExecutionId,
_generation: ExecutionGeneration,
) -> ExecutionManagerResult<Vec<LogEntry>> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not expose structured logs".to_string(),
))
}
async fn create_filesystem_snapshot(
&self,
_execution_id: &ExecutionId,
_generation: ExecutionGeneration,
_snapshot_id: &ExecutionSnapshotId,
) -> ExecutionManagerResult<ExecutionSnapshot> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support filesystem snapshots".to_string(),
))
}
async fn filesystem_snapshot_size(
&self,
_snapshot_id: &ExecutionSnapshotId,
) -> ExecutionManagerResult<Option<u64>> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not expose filesystem snapshots".to_string(),
))
}
async fn delete_filesystem_snapshot(
&self,
_snapshot_id: &ExecutionSnapshotId,
) -> ExecutionManagerResult<bool> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support filesystem snapshot deletion".to_string(),
))
}
async fn pause(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
keep_memory: bool,
) -> ExecutionManagerResult<ExecutionLease>;
async fn resume(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
) -> ExecutionManagerResult<ExecutionLease>;
async fn restart(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
operation_id: &OperationId,
) -> ExecutionManagerResult<ExecutionLease> {
self.restart_with_options(
execution_id,
generation,
operation_id,
RestartExecutionOptions::default(),
)
.await
}
async fn restart_with_options(
&self,
_execution_id: &ExecutionId,
_generation: ExecutionGeneration,
_operation_id: &OperationId,
_options: RestartExecutionOptions,
) -> ExecutionManagerResult<ExecutionLease> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support restart".to_string(),
))
}
async fn kill(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
) -> ExecutionManagerResult<KillOutcome>;
async fn kill_with_options(
&self,
execution_id: &ExecutionId,
generation: ExecutionGeneration,
_options: KillExecutionOptions,
) -> ExecutionManagerResult<KillOutcome> {
self.kill(execution_id, generation).await
}
async fn remove(
&self,
_execution_id: &ExecutionId,
_generation: ExecutionGeneration,
) -> ExecutionManagerResult<bool> {
Err(ExecutionManagerError::Unavailable(
"this execution manager does not support execution removal".to_string(),
))
}
async fn reconcile(
&self,
operation_id: &OperationId,
) -> ExecutionManagerResult<ReconcileOutcome>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiers_reject_empty_values() {
assert!(matches!(
ExecutionId::new(" "),
Err(ExecutionManagerError::InvalidRequest(_))
));
assert!(matches!(
OperationId::new(""),
Err(ExecutionManagerError::InvalidRequest(_))
));
}
#[test]
fn generation_rejects_zero() {
assert!(matches!(
ExecutionGeneration::new(0),
Err(ExecutionManagerError::InvalidRequest(_))
));
assert_eq!(ExecutionGeneration::INITIAL.get(), 1);
assert!(serde_json::from_str::<ExecutionGeneration>("0").is_err());
}
#[test]
fn identifier_deserialization_preserves_invariants() {
assert!(serde_json::from_str::<ExecutionId>("\"\"").is_err());
assert!(serde_json::from_str::<OperationId>("\" \"").is_err());
}
#[test]
fn snapshot_identifiers_are_safe_managed_directory_names() {
for valid in ["snapshot-1", "SNAPSHOT_2", "a"] {
assert_eq!(ExecutionSnapshotId::new(valid).unwrap().as_str(), valid);
}
for invalid in [
"",
".",
"..",
"../snapshot",
"snapshot/path",
"snapshot:tag",
"snapshot id",
] {
assert!(matches!(
ExecutionSnapshotId::new(invalid),
Err(ExecutionManagerError::InvalidRequest(_))
));
}
assert!(ExecutionSnapshotId::new("x".repeat(129)).is_err());
assert!(serde_json::from_str::<ExecutionSnapshotId>("\"../snapshot\"").is_err());
}
#[test]
fn legacy_creation_requests_default_record_policy() {
let request: CreateExecutionRequest = serde_json::from_value(serde_json::json!({
"external_sandbox_id": "sandbox-1",
"config": BoxConfig::default(),
"labels": {"purpose": "compatibility"}
}))
.unwrap();
assert_eq!(request.policy, ExecutionRecordPolicy::default());
assert_eq!(request.policy.restart_policy, ExecutionRestartPolicy::No);
assert!(request.rootfs_snapshot_id.is_none());
}
#[test]
fn restart_policy_has_stable_record_values() {
assert_eq!(ExecutionRestartPolicy::No.as_str(), "no");
assert_eq!(ExecutionRestartPolicy::Always.as_str(), "always");
assert_eq!(ExecutionRestartPolicy::OnFailure.as_str(), "on-failure");
assert_eq!(
ExecutionRestartPolicy::UnlessStopped.as_str(),
"unless-stopped"
);
assert_eq!(
serde_json::to_value(ExecutionRestartPolicy::OnFailure).unwrap(),
"on-failure"
);
}
}