Skip to main content

soma_infra/
compose_build.rs

1use std::path::{Component, Path, PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp, VerificationStatus};
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10    BuildContextFingerprint, ComposeProjectRef, ImageIdentity, InfraError,
11    MutationProgressReporter, MutationResult, MutationVerification,
12};
13
14/// One planned Compose service build artifact.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ComposeBuildArtifact {
17    /// Compose service name.
18    pub service: String,
19    /// Expected output image tag.
20    pub image: String,
21    /// Resolved absolute context path.
22    pub context: PathBuf,
23    /// Planned context fingerprint.
24    pub fingerprint: BuildContextFingerprint,
25}
26
27/// Deadline-bound Compose build request.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ComposeBuildRequest {
30    operation_id: OperationId,
31    operation: OperationName,
32    project: ComposeProjectRef,
33    service: Option<String>,
34    artifacts: Vec<ComposeBuildArtifact>,
35    deadline: Timestamp,
36}
37
38impl ComposeBuildRequest {
39    /// Creates a validated Compose build request.
40    pub fn new(
41        operation_id: OperationId,
42        operation: OperationName,
43        project: ComposeProjectRef,
44        service: Option<String>,
45        artifacts: Vec<ComposeBuildArtifact>,
46        deadline: Timestamp,
47    ) -> Result<Self, InfraError> {
48        if artifacts.is_empty() {
49            return Err(invalid(
50                "no build-enabled services with explicit image tags were selected",
51            ));
52        }
53        let mut names = std::collections::BTreeSet::new();
54        for artifact in &artifacts {
55            crate::compose_pull::validate_service(&artifact.service)?;
56            if !names.insert(artifact.service.clone()) {
57                return Err(invalid("duplicate Compose build service"));
58            }
59            if artifact.image.is_empty()
60                || artifact.image.starts_with('-')
61                || artifact.image.chars().any(char::is_control)
62            {
63                return Err(invalid("invalid Compose build image tag"));
64            }
65            if artifact.fingerprint.path != artifact.context {
66                return Err(invalid("Compose context fingerprint path mismatch"));
67            }
68            artifact.fingerprint.validate()?;
69        }
70        if let Some(service) = &service {
71            crate::compose_pull::validate_service(service)?;
72            if artifacts.len() != 1 || artifacts[0].service != *service {
73                return Err(invalid(
74                    "service filter does not match planned build artifact",
75                ));
76            }
77        }
78        Ok(Self {
79            operation_id,
80            operation,
81            project,
82            service,
83            artifacts,
84            deadline,
85        })
86    }
87    /// Returns operation identity.
88    #[must_use]
89    pub fn operation_id(&self) -> &OperationId {
90        &self.operation_id
91    }
92    /// Returns canonical operation.
93    #[must_use]
94    pub fn operation(&self) -> &OperationName {
95        &self.operation
96    }
97    /// Returns project.
98    #[must_use]
99    pub const fn project(&self) -> &ComposeProjectRef {
100        &self.project
101    }
102    /// Returns optional service.
103    #[must_use]
104    pub fn service(&self) -> Option<&str> {
105        self.service.as_deref()
106    }
107    /// Returns planned artifacts.
108    #[must_use]
109    pub fn artifacts(&self) -> &[ComposeBuildArtifact] {
110        &self.artifacts
111    }
112    /// Returns deadline.
113    #[must_use]
114    pub const fn deadline(&self) -> Timestamp {
115        self.deadline
116    }
117}
118
119/// Compose build process receipt.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ComposeBuildReceipt {
122    /// Host.
123    pub host: HostId,
124    /// Topology revision.
125    pub topology_revision: TopologyRevision,
126    /// Project name.
127    pub project: String,
128    /// Optional service.
129    pub service: Option<String>,
130    /// Send state.
131    pub send_state: MutationSendState,
132    /// Bounded stdout.
133    pub stdout: String,
134    /// Bounded stderr.
135    pub stderr: String,
136    /// Output truncation flag.
137    pub output_truncated: bool,
138    /// Progress delivery failures.
139    pub progress_delivery_errors: Vec<String>,
140}
141
142/// One verified Compose service build result.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct ComposeBuiltImage {
145    /// Service.
146    pub service: String,
147    /// Image tag.
148    pub image: String,
149    /// Context fingerprint.
150    pub context: BuildContextFingerprint,
151    /// Before identity.
152    pub before: Option<ImageIdentity>,
153    /// After identity.
154    pub after: Option<ImageIdentity>,
155    /// Whether identity changed.
156    pub changed: bool,
157    /// Whether output image was verified.
158    pub verified: bool,
159}
160
161/// Verified Compose build outcome.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ComposeBuildOutcome {
164    /// Host.
165    pub host: HostId,
166    /// Topology revision.
167    pub topology_revision: TopologyRevision,
168    /// Project.
169    pub project: String,
170    /// Optional service.
171    pub service: Option<String>,
172    /// Per-service images.
173    pub images: Vec<ComposeBuiltImage>,
174    /// Whether any identity changed.
175    pub changed: bool,
176    /// Send state.
177    pub send_state: MutationSendState,
178    /// Bounded stdout.
179    pub stdout: String,
180    /// Bounded stderr.
181    pub stderr: String,
182    /// Output truncation.
183    pub output_truncated: bool,
184    /// Progress delivery failures.
185    pub progress_delivery_errors: Vec<String>,
186    /// Verification status.
187    pub verification_status: VerificationStatus,
188    /// Verification explanation.
189    pub verification: MutationVerification,
190}
191
192/// Driver for one Compose build command.
193#[async_trait]
194pub trait ComposeBuildMutator: Send + Sync {
195    /// Executes a Compose build.
196    async fn build_compose(
197        &self,
198        host: &HostRecord,
199        request: &ComposeBuildRequest,
200        progress: &dyn MutationProgressReporter,
201        cancellation: &CancellationToken,
202    ) -> MutationResult<ComposeBuildReceipt>;
203}
204
205/// Resolves an absolute or Compose-file-relative build context without permitting root escape.
206pub fn resolve_compose_build_context(
207    config_file: &Path,
208    context: &str,
209) -> Result<PathBuf, InfraError> {
210    let raw = Path::new(context);
211    let joined = if raw.is_absolute() {
212        raw.to_path_buf()
213    } else {
214        config_file
215            .parent()
216            .ok_or_else(|| invalid("Compose config has no parent directory"))?
217            .join(raw)
218    };
219    let mut normalized = PathBuf::from("/");
220    for part in joined.components() {
221        match part {
222            Component::RootDir => {}
223            Component::Normal(value) => normalized.push(value),
224            Component::CurDir => {}
225            Component::ParentDir => {
226                if !normalized.pop() {
227                    return Err(invalid("Compose build context escapes filesystem root"));
228                }
229            }
230            Component::Prefix(_) => {
231                return Err(invalid("unsupported Compose build context prefix"));
232            }
233        }
234    }
235    Ok(normalized)
236}
237fn invalid(message: &str) -> InfraError {
238    InfraError::InvalidRequest {
239        domain: "compose-build",
240        message: message.into(),
241    }
242}
243
244#[cfg(test)]
245#[path = "compose_build_tests.rs"]
246mod tests;