Skip to main content

soma_infra/
compose_recreate.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use soma_fleet::{HostId, HostRecord, TopologyRevision};
4use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp, VerificationStatus};
5use tokio_util::sync::CancellationToken;
6
7use crate::{
8    ComposeConfig, ComposeInspector, ComposeProjectRef, ComposeStatus, InfraError, InfraResult,
9    MutationResult, MutationVerification,
10};
11
12/// Stable fingerprint of the Compose configuration and service pre-state.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ComposeRecreateFingerprint {
15    /// Compose project name.
16    pub project: String,
17    /// Deterministic service names.
18    pub services: Vec<String>,
19    /// SHA-256 of normalized configuration and status material.
20    pub sha256: String,
21}
22
23impl ComposeRecreateFingerprint {
24    /// Creates a validated fingerprint.
25    pub fn new(
26        project: impl Into<String>,
27        mut services: Vec<String>,
28        sha256: impl Into<String>,
29    ) -> InfraResult<Self> {
30        let project = project.into();
31        services.sort();
32        services.dedup();
33        let sha256 = sha256.into();
34        if project.is_empty() || services.is_empty() {
35            return Err(InfraError::InvalidRequest {
36                domain: "compose-recreate",
37                message: "project and at least one service are required".into(),
38            });
39        }
40        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
41            return Err(InfraError::InvalidRequest {
42                domain: "compose-recreate",
43                message: "Compose fingerprint must be SHA-256 hex".into(),
44            });
45        }
46        Ok(Self {
47            project,
48            services,
49            sha256: sha256.to_ascii_lowercase(),
50        })
51    }
52}
53
54/// Produces deterministic replacement material from canonical Compose reads.
55pub fn compose_recreate_fingerprint(
56    config: &ComposeConfig,
57    status: &ComposeStatus,
58) -> InfraResult<ComposeRecreateFingerprint> {
59    if config.project != status.project {
60        return Err(InfraError::InvalidRequest {
61            domain: "compose-recreate",
62            message: "Compose config and status project identities differ".into(),
63        });
64    }
65    let mut rows = status.services.clone();
66    rows.sort_by(|a, b| a.service.cmp(&b.service));
67    let services = config.services.keys().cloned().collect::<Vec<_>>();
68    let encoded = serde_json::to_vec(&(config, rows)).map_err(|error| InfraError::Parse {
69        domain: "compose-recreate",
70        message: error.to_string(),
71    })?;
72    ComposeRecreateFingerprint::new(
73        config.project.clone(),
74        services,
75        crate::mutation::sha256_hex(&encoded),
76    )
77}
78
79/// Deadline-bound Compose replacement request.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ComposeRecreateRequest {
82    operation_id: OperationId,
83    operation: OperationName,
84    project: ComposeProjectRef,
85    expected: ComposeRecreateFingerprint,
86    deadline: Timestamp,
87}
88
89impl ComposeRecreateRequest {
90    /// Creates a replacement request.
91    #[must_use]
92    pub fn new(
93        operation_id: OperationId,
94        operation: OperationName,
95        project: ComposeProjectRef,
96        expected: ComposeRecreateFingerprint,
97        deadline: Timestamp,
98    ) -> Self {
99        Self {
100            operation_id,
101            operation,
102            project,
103            expected,
104            deadline,
105        }
106    }
107    /// Returns the operation identity.
108    #[must_use]
109    pub fn operation_id(&self) -> &OperationId {
110        &self.operation_id
111    }
112    /// Returns the canonical operation.
113    #[must_use]
114    pub fn operation(&self) -> &OperationName {
115        &self.operation
116    }
117    /// Returns the project reference.
118    #[must_use]
119    pub const fn project(&self) -> &ComposeProjectRef {
120        &self.project
121    }
122    /// Returns the expected pre-state fingerprint.
123    #[must_use]
124    pub const fn expected(&self) -> &ComposeRecreateFingerprint {
125        &self.expected
126    }
127    /// Returns the deadline.
128    #[must_use]
129    pub const fn deadline(&self) -> Timestamp {
130        self.deadline
131    }
132}
133
134/// Process driver receipt for Compose force-recreate.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct ComposeRecreateReceipt {
137    /// Target host.
138    pub host: HostId,
139    /// Exact topology revision.
140    pub topology_revision: TopologyRevision,
141    /// Project name.
142    pub project: String,
143    /// Backend send state.
144    pub send_state: MutationSendState,
145    /// Bounded stdout.
146    pub stdout: String,
147    /// Bounded stderr.
148    pub stderr: String,
149    /// Whether command output was truncated.
150    pub output_truncated: bool,
151}
152
153/// Verified Compose replacement outcome.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ComposeRecreateOutcome {
156    /// Target host.
157    pub host: HostId,
158    /// Exact topology revision.
159    pub topology_revision: TopologyRevision,
160    /// Project name.
161    pub project: String,
162    /// Pre-replacement status.
163    pub before: ComposeStatus,
164    /// Post-replacement status when readable.
165    pub after: Option<ComposeStatus>,
166    /// Whether a force-recreate command was sent.
167    pub changed: bool,
168    /// Backend send state.
169    pub send_state: MutationSendState,
170    /// Bounded stdout.
171    pub stdout: String,
172    /// Bounded stderr.
173    pub stderr: String,
174    /// Whether command output was truncated.
175    pub output_truncated: bool,
176    /// Verification status.
177    pub verification_status: VerificationStatus,
178    /// Verification explanation.
179    pub verification: MutationVerification,
180}
181
182/// Executes Docker Compose force-recreate.
183#[async_trait]
184pub trait ComposeRecreateMutator: Send + Sync {
185    /// Performs one force-recreate command.
186    async fn recreate_compose(
187        &self,
188        host: &HostRecord,
189        request: &ComposeRecreateRequest,
190        cancellation: &CancellationToken,
191    ) -> MutationResult<ComposeRecreateReceipt>;
192}
193
194/// Complete Compose client required by the replacement engine.
195pub trait ComposeRecreateClient: ComposeInspector + ComposeRecreateMutator {}
196impl<T> ComposeRecreateClient for T where T: ComposeInspector + ComposeRecreateMutator {}
197
198#[cfg(test)]
199#[path = "compose_recreate_tests.rs"]
200mod tests;