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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ComposeBuildArtifact {
17 pub service: String,
19 pub image: String,
21 pub context: PathBuf,
23 pub fingerprint: BuildContextFingerprint,
25}
26
27#[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 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 #[must_use]
89 pub fn operation_id(&self) -> &OperationId {
90 &self.operation_id
91 }
92 #[must_use]
94 pub fn operation(&self) -> &OperationName {
95 &self.operation
96 }
97 #[must_use]
99 pub const fn project(&self) -> &ComposeProjectRef {
100 &self.project
101 }
102 #[must_use]
104 pub fn service(&self) -> Option<&str> {
105 self.service.as_deref()
106 }
107 #[must_use]
109 pub fn artifacts(&self) -> &[ComposeBuildArtifact] {
110 &self.artifacts
111 }
112 #[must_use]
114 pub const fn deadline(&self) -> Timestamp {
115 self.deadline
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ComposeBuildReceipt {
122 pub host: HostId,
124 pub topology_revision: TopologyRevision,
126 pub project: String,
128 pub service: Option<String>,
130 pub send_state: MutationSendState,
132 pub stdout: String,
134 pub stderr: String,
136 pub output_truncated: bool,
138 pub progress_delivery_errors: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct ComposeBuiltImage {
145 pub service: String,
147 pub image: String,
149 pub context: BuildContextFingerprint,
151 pub before: Option<ImageIdentity>,
153 pub after: Option<ImageIdentity>,
155 pub changed: bool,
157 pub verified: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ComposeBuildOutcome {
164 pub host: HostId,
166 pub topology_revision: TopologyRevision,
168 pub project: String,
170 pub service: Option<String>,
172 pub images: Vec<ComposeBuiltImage>,
174 pub changed: bool,
176 pub send_state: MutationSendState,
178 pub stdout: String,
180 pub stderr: String,
182 pub output_truncated: bool,
184 pub progress_delivery_errors: Vec<String>,
186 pub verification_status: VerificationStatus,
188 pub verification: MutationVerification,
190}
191
192#[async_trait]
194pub trait ComposeBuildMutator: Send + Sync {
195 async fn build_compose(
197 &self,
198 host: &HostRecord,
199 request: &ComposeBuildRequest,
200 progress: &dyn MutationProgressReporter,
201 cancellation: &CancellationToken,
202 ) -> MutationResult<ComposeBuildReceipt>;
203}
204
205pub 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;