Skip to main content

soma_infra/
image_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, ImageIdentity, InfraError, MutationProgressReporter, MutationResult,
11    MutationVerification,
12};
13
14const MAX_TAG_CHARS: usize = 256;
15const MAX_DOCKERFILE_CHARS: usize = 4096;
16
17/// Deadline-bound request for one Docker image build.
18#[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    /// Creates a validated image build request.
32    #[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    /// Returns operation identity.
78    #[must_use]
79    pub fn operation_id(&self) -> &OperationId {
80        &self.operation_id
81    }
82    /// Returns canonical operation.
83    #[must_use]
84    pub fn operation(&self) -> &OperationName {
85        &self.operation
86    }
87    /// Returns absolute build context.
88    #[must_use]
89    pub fn context(&self) -> &Path {
90        &self.context
91    }
92    /// Returns relative Dockerfile path.
93    #[must_use]
94    pub fn dockerfile(&self) -> Option<&Path> {
95        self.dockerfile.as_deref()
96    }
97    /// Returns output tag.
98    #[must_use]
99    pub fn tag(&self) -> &str {
100        &self.tag
101    }
102    /// Returns no-cache flag.
103    #[must_use]
104    pub const fn no_cache(&self) -> bool {
105        self.no_cache
106    }
107    /// Returns planned context fingerprint.
108    #[must_use]
109    pub const fn expected_context(&self) -> &BuildContextFingerprint {
110        &self.expected_context
111    }
112    /// Returns deadline.
113    #[must_use]
114    pub const fn deadline(&self) -> Timestamp {
115        self.deadline
116    }
117}
118
119/// Receipt returned after a build command reaches a terminal process state.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct ImageBuildReceipt {
122    /// Target host.
123    pub host: HostId,
124    /// Exact topology revision.
125    pub topology_revision: TopologyRevision,
126    /// Output image tag.
127    pub tag: String,
128    /// Backend send state.
129    pub send_state: MutationSendState,
130    /// Bounded stdout log.
131    pub stdout: String,
132    /// Bounded stderr log.
133    pub stderr: String,
134    /// Whether either output stream was truncated.
135    pub output_truncated: bool,
136    /// Progress delivery failures that did not alter execution truth.
137    pub progress_delivery_errors: Vec<String>,
138}
139
140/// Verified image build outcome.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct ImageBuildOutcome {
143    /// Target host.
144    pub host: HostId,
145    /// Exact topology revision.
146    pub topology_revision: TopologyRevision,
147    /// Output image tag.
148    pub tag: String,
149    /// Verified context fingerprint.
150    pub context: BuildContextFingerprint,
151    /// Image identity before build.
152    pub before: Option<ImageIdentity>,
153    /// Image identity after build.
154    pub after: Option<ImageIdentity>,
155    /// Whether content identity changed.
156    pub changed: bool,
157    /// Backend send state.
158    pub send_state: MutationSendState,
159    /// Bounded stdout log.
160    pub stdout: String,
161    /// Bounded stderr log.
162    pub stderr: String,
163    /// Whether output was truncated.
164    pub output_truncated: bool,
165    /// Progress delivery failures.
166    pub progress_delivery_errors: Vec<String>,
167    /// Independent verification status.
168    pub verification_status: VerificationStatus,
169    /// Verification explanation.
170    pub verification: MutationVerification,
171}
172
173/// Driver for one Docker image build command.
174#[async_trait]
175pub trait ImageBuildMutator: Send + Sync {
176    /// Executes one image build while preserving send uncertainty.
177    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;