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, ImageIdentity, InfraError, MutationProgressReporter, MutationResult,
11 MutationVerification,
12};
13
14const MAX_TAG_CHARS: usize = 256;
15const MAX_DOCKERFILE_CHARS: usize = 4096;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ImageBuildRequest {
20 operation_id: OperationId,
21 operation: OperationName,
22 context: PathBuf,
23 dockerfile: Option<PathBuf>,
24 tag: String,
25 no_cache: bool,
26 expected_context: BuildContextFingerprint,
27 deadline: Timestamp,
28}
29
30impl ImageBuildRequest {
31 #[allow(clippy::too_many_arguments)]
33 pub fn new(
34 operation_id: OperationId,
35 operation: OperationName,
36 context: PathBuf,
37 dockerfile: Option<PathBuf>,
38 tag: impl Into<String>,
39 no_cache: bool,
40 expected_context: BuildContextFingerprint,
41 deadline: Timestamp,
42 ) -> Result<Self, InfraError> {
43 validate_context(&context)?;
44 if let Some(path) = &dockerfile {
45 validate_dockerfile(path)?;
46 }
47 let tag = tag.into();
48 let count = tag.chars().count();
49 if count == 0
50 || count > MAX_TAG_CHARS
51 || tag.starts_with('-')
52 || tag.chars().any(char::is_control)
53 {
54 return Err(InfraError::InvalidRequest {
55 domain: "image-build",
56 message: "invalid image tag".into(),
57 });
58 }
59 if expected_context.path != context {
60 return Err(InfraError::InvalidRequest {
61 domain: "image-build",
62 message: "expected context path differs from request".into(),
63 });
64 }
65 expected_context.validate()?;
66 Ok(Self {
67 operation_id,
68 operation,
69 context,
70 dockerfile,
71 tag,
72 no_cache,
73 expected_context,
74 deadline,
75 })
76 }
77 #[must_use]
79 pub fn operation_id(&self) -> &OperationId {
80 &self.operation_id
81 }
82 #[must_use]
84 pub fn operation(&self) -> &OperationName {
85 &self.operation
86 }
87 #[must_use]
89 pub fn context(&self) -> &Path {
90 &self.context
91 }
92 #[must_use]
94 pub fn dockerfile(&self) -> Option<&Path> {
95 self.dockerfile.as_deref()
96 }
97 #[must_use]
99 pub fn tag(&self) -> &str {
100 &self.tag
101 }
102 #[must_use]
104 pub const fn no_cache(&self) -> bool {
105 self.no_cache
106 }
107 #[must_use]
109 pub const fn expected_context(&self) -> &BuildContextFingerprint {
110 &self.expected_context
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 ImageBuildReceipt {
122 pub host: HostId,
124 pub topology_revision: TopologyRevision,
126 pub tag: String,
128 pub send_state: MutationSendState,
130 pub stdout: String,
132 pub stderr: String,
134 pub output_truncated: bool,
136 pub progress_delivery_errors: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct ImageBuildOutcome {
143 pub host: HostId,
145 pub topology_revision: TopologyRevision,
147 pub tag: String,
149 pub context: BuildContextFingerprint,
151 pub before: Option<ImageIdentity>,
153 pub after: Option<ImageIdentity>,
155 pub changed: bool,
157 pub send_state: MutationSendState,
159 pub stdout: String,
161 pub stderr: String,
163 pub output_truncated: bool,
165 pub progress_delivery_errors: Vec<String>,
167 pub verification_status: VerificationStatus,
169 pub verification: MutationVerification,
171}
172
173#[async_trait]
175pub trait ImageBuildMutator: Send + Sync {
176 async fn build_image(
178 &self,
179 host: &HostRecord,
180 request: &ImageBuildRequest,
181 progress: &dyn MutationProgressReporter,
182 cancellation: &CancellationToken,
183 ) -> MutationResult<ImageBuildReceipt>;
184}
185
186fn validate_context(path: &Path) -> Result<(), InfraError> {
187 if !path.is_absolute()
188 || path
189 .components()
190 .any(|part| matches!(part, Component::ParentDir))
191 {
192 return Err(InfraError::InvalidRequest {
193 domain: "image-build",
194 message: "build context must be an absolute normalized path".into(),
195 });
196 }
197 Ok(())
198}
199fn validate_dockerfile(path: &Path) -> Result<(), InfraError> {
200 let text = path.to_string_lossy();
201 if path.is_absolute()
202 || text.is_empty()
203 || text.chars().count() > MAX_DOCKERFILE_CHARS
204 || text.contains('~')
205 || text.contains('$')
206 || text.chars().any(char::is_control)
207 || path
208 .components()
209 .any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
210 {
211 return Err(InfraError::InvalidRequest {
212 domain: "image-build",
213 message: "Dockerfile must be a bounded relative path without traversal or expansion"
214 .into(),
215 });
216 }
217 Ok(())
218}
219
220#[cfg(test)]
221#[path = "image_build_tests.rs"]
222mod tests;