Skip to main content

soma_infra/
container_mutation.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use soma_fleet::{HostId, HostRecord, TopologyRevision};
7use soma_ops::{MutationSendState, Timestamp, VerificationStatus};
8use tokio_util::sync::CancellationToken;
9
10use crate::{ContainerReader, ContainerState, InfraError, MutationResult, MutationVerification};
11
12const MAX_CONTAINER_ID_CHARS: usize = 256;
13const MAX_VERIFY_ATTEMPTS: u8 = 20;
14const MAX_VERIFY_INTERVAL: Duration = Duration::from_secs(5);
15
16/// Supported reversible container lifecycle mutations.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ContainerLifecycleAction {
20    /// Start a stopped container.
21    Start,
22    /// Stop a running container.
23    Stop,
24    /// Restart a container.
25    Restart,
26    /// Pause a running container.
27    Pause,
28    /// Resume a paused container.
29    Resume,
30}
31
32impl ContainerLifecycleAction {
33    /// Returns the canonical operation name.
34    #[must_use]
35    pub const fn operation_name(self) -> &'static str {
36        match self {
37            Self::Start => "container.start",
38            Self::Stop => "container.stop",
39            Self::Restart => "container.restart",
40            Self::Pause => "container.pause",
41            Self::Resume => "container.resume",
42        }
43    }
44
45    /// Returns a stable backend-neutral action label.
46    #[must_use]
47    pub const fn action_label(self) -> &'static str {
48        match self {
49            Self::Start => "start",
50            Self::Stop => "stop",
51            Self::Restart => "restart",
52            Self::Pause => "pause",
53            Self::Resume => "resume",
54        }
55    }
56
57    pub(crate) fn already_satisfied(self, state: &ContainerState) -> bool {
58        match self {
59            Self::Start | Self::Resume => matches!(state, ContainerState::Running),
60            Self::Stop => matches!(state, ContainerState::Exited | ContainerState::Dead),
61            Self::Pause => matches!(state, ContainerState::Paused),
62            Self::Restart => false,
63        }
64    }
65
66    pub(crate) fn verified(self, state: &ContainerState) -> bool {
67        match self {
68            Self::Start | Self::Restart | Self::Resume => {
69                matches!(state, ContainerState::Running)
70            }
71            Self::Stop => matches!(state, ContainerState::Exited | ContainerState::Dead),
72            Self::Pause => matches!(state, ContainerState::Paused),
73        }
74    }
75}
76
77/// Deadline-bound container lifecycle request.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ContainerLifecycleRequest {
80    container: String,
81    action: ContainerLifecycleAction,
82    deadline: Timestamp,
83}
84
85impl ContainerLifecycleRequest {
86    /// Creates a validated lifecycle request.
87    pub fn new(
88        container: impl Into<String>,
89        action: ContainerLifecycleAction,
90        deadline: Timestamp,
91    ) -> Result<Self, InfraError> {
92        let container = container.into();
93        let count = container.chars().count();
94        if count == 0 || count > MAX_CONTAINER_ID_CHARS || container.chars().any(char::is_control) {
95            return Err(InfraError::InvalidRequest {
96                domain: "container-mutation",
97                message: "invalid container identifier".into(),
98            });
99        }
100        Ok(Self {
101            container,
102            action,
103            deadline,
104        })
105    }
106
107    /// Returns the container identifier.
108    #[must_use]
109    pub fn container(&self) -> &str {
110        &self.container
111    }
112
113    /// Returns the lifecycle action.
114    #[must_use]
115    pub const fn action(&self) -> ContainerLifecycleAction {
116        self.action
117    }
118
119    /// Returns the absolute request deadline.
120    #[must_use]
121    pub const fn deadline(&self) -> Timestamp {
122        self.deadline
123    }
124}
125
126/// Bounded post-mutation verification policy.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct MutationVerificationPolicy {
129    attempts: u8,
130    interval: Duration,
131}
132
133impl MutationVerificationPolicy {
134    /// Creates a verification policy with explicit bounds.
135    pub fn new(attempts: u8, interval: Duration) -> Result<Self, InfraError> {
136        if attempts == 0 || attempts > MAX_VERIFY_ATTEMPTS || interval > MAX_VERIFY_INTERVAL {
137            return Err(InfraError::InvalidRequest {
138                domain: "container-mutation",
139                message: format!(
140                    "verification requires 1-{MAX_VERIFY_ATTEMPTS} attempts and an interval no greater than {} ms",
141                    MAX_VERIFY_INTERVAL.as_millis()
142                ),
143            });
144        }
145        Ok(Self { attempts, interval })
146    }
147
148    /// Returns the attempt count.
149    #[must_use]
150    pub const fn attempts(self) -> u8 {
151        self.attempts
152    }
153
154    /// Returns the delay between attempts.
155    #[must_use]
156    pub const fn interval(self) -> Duration {
157        self.interval
158    }
159}
160
161impl Default for MutationVerificationPolicy {
162    fn default() -> Self {
163        Self {
164            attempts: 5,
165            interval: Duration::from_millis(200),
166        }
167    }
168}
169
170/// Receipt returned once a lifecycle mutation was accepted by the driver.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ContainerMutationReceipt {
173    /// Target host.
174    pub host: HostId,
175    /// Exact topology revision.
176    pub topology_revision: TopologyRevision,
177    /// Container identifier.
178    pub container: String,
179    /// Executed action.
180    pub action: ContainerLifecycleAction,
181    /// Backend send state.
182    pub send_state: MutationSendState,
183}
184
185/// Verified lifecycle mutation outcome.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ContainerLifecycleOutcome {
188    /// Target host.
189    pub host: HostId,
190    /// Exact topology revision.
191    pub topology_revision: TopologyRevision,
192    /// Container identifier.
193    pub container: String,
194    /// Requested action.
195    pub action: ContainerLifecycleAction,
196    /// Whether a backend mutation was sent.
197    pub changed: bool,
198    /// Mutation send state.
199    pub send_state: MutationSendState,
200    /// State observed before admission.
201    pub before: ContainerState,
202    /// Last state observed after execution.
203    pub after: Option<ContainerState>,
204    /// Independent verification status.
205    pub verification_status: VerificationStatus,
206    /// Stable verification detail.
207    pub verification: MutationVerification,
208}
209
210/// Driver for one reversible container lifecycle mutation.
211#[async_trait]
212pub trait ContainerLifecycleMutator: Send + Sync {
213    /// Sends one lifecycle mutation while preserving send uncertainty.
214    async fn mutate_container(
215        &self,
216        host: &HostRecord,
217        request: &ContainerLifecycleRequest,
218        cancellation: &CancellationToken,
219    ) -> MutationResult<ContainerMutationReceipt>;
220}
221
222/// Complete Docker client required by the lifecycle coordinator.
223pub trait DockerMutationClient: ContainerReader + ContainerLifecycleMutator {}
224
225impl<T> DockerMutationClient for T where T: ContainerReader + ContainerLifecycleMutator {}
226
227/// Factory for host- and revision-bound Docker mutation clients.
228#[async_trait]
229pub trait DockerMutationClientProvider: Send + Sync {
230    /// Returns a mutation-capable client bound to the exact host revision.
231    async fn mutation_client(
232        &self,
233        host: &HostRecord,
234        cancellation: &CancellationToken,
235    ) -> Result<Arc<dyn DockerMutationClient>, InfraError>;
236}
237
238#[cfg(test)]
239#[path = "container_mutation_tests.rs"]
240mod tests;