1use std::io::Write;
4use std::path::Path;
5
6use a3s_box_core::error::{BoxError, Result};
7use a3s_box_core::{SnapshotImageConfig, SnapshotImageHealthCheck, SnapshotMetadata};
8use serde::de::DeserializeOwned;
9
10use crate::oci::{OciHealthCheck, OciImageConfig};
11
12pub const RESOLVED_IMAGE_CONFIG_FILE: &str = ".oci-image-config.json";
14
15const MAX_IMAGE_CONFIG_BYTES: u64 = 1024 * 1024;
16
17pub fn load_resolved_image_config(box_dir: &Path) -> Result<Option<SnapshotImageConfig>> {
23 let path = box_dir.join(RESOLVED_IMAGE_CONFIG_FILE);
24 match std::fs::symlink_metadata(&path) {
25 Ok(_) => read_regular_json(&path, "resolved image configuration").map(Some),
26 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
27 Err(error) => Err(BoxError::ConfigError(format!(
28 "Failed to inspect resolved image configuration {}: {error}",
29 path.display()
30 ))),
31 }
32}
33
34pub(crate) fn persist_resolved_image_config(box_dir: &Path, config: &OciImageConfig) -> Result<()> {
35 let config = SnapshotImageConfig::from(config);
36 persist_snapshot_image_config(box_dir, &config)
37}
38
39pub(crate) fn persist_snapshot_image_config(
44 box_dir: &Path,
45 config: &SnapshotImageConfig,
46) -> Result<()> {
47 let mut encoded = serde_json::to_vec_pretty(&config).map_err(|error| {
48 BoxError::SerializationError(format!(
49 "Failed to encode resolved image configuration: {error}"
50 ))
51 })?;
52 encoded.push(b'\n');
53
54 let destination = box_dir.join(RESOLVED_IMAGE_CONFIG_FILE);
55 let mut temporary = tempfile::NamedTempFile::new_in(box_dir).map_err(|error| {
56 BoxError::ConfigError(format!(
57 "Failed to create resolved image configuration beside {}: {error}",
58 destination.display()
59 ))
60 })?;
61 temporary.write_all(&encoded).map_err(|error| {
62 BoxError::ConfigError(format!(
63 "Failed to write resolved image configuration {}: {error}",
64 destination.display()
65 ))
66 })?;
67 temporary.as_file().sync_all().map_err(|error| {
68 BoxError::ConfigError(format!(
69 "Failed to sync resolved image configuration {}: {error}",
70 destination.display()
71 ))
72 })?;
73 temporary.persist(&destination).map_err(|error| {
74 BoxError::ConfigError(format!(
75 "Failed to publish resolved image configuration {}: {}",
76 destination.display(),
77 error.error
78 ))
79 })?;
80 if let Ok(directory) = std::fs::File::open(box_dir) {
81 let _ = directory.sync_all();
82 }
83 Ok(())
84}
85
86pub(crate) fn load_snapshot_oci_config(
87 rootfs: &Path,
88 expected_image: &str,
89) -> Result<OciImageConfig> {
90 if rootfs.file_name().is_none_or(|name| name != "rootfs") {
91 return Err(BoxError::ConfigError(format!(
92 "Snapshot lower must end in rootfs: {}",
93 rootfs.display()
94 )));
95 }
96 let snapshot_dir = rootfs.parent().ok_or_else(|| {
97 BoxError::ConfigError(format!(
98 "Snapshot lower has no snapshot directory: {}",
99 rootfs.display()
100 ))
101 })?;
102 let metadata_path = snapshot_dir.join("metadata.json");
103 let metadata: SnapshotMetadata =
104 read_regular_json(&metadata_path, "filesystem snapshot metadata")?;
105 let directory_id = snapshot_dir.file_name().and_then(|value| value.to_str());
106 if directory_id != Some(metadata.id.as_str()) {
107 return Err(BoxError::ConfigError(format!(
108 "Snapshot metadata identity does not match {}",
109 snapshot_dir.display()
110 )));
111 }
112 if metadata.image != expected_image {
113 return Err(BoxError::ConfigError(format!(
114 "Snapshot image {} does not match requested image {expected_image}",
115 metadata.image
116 )));
117 }
118 Ok(OciImageConfig::from(
119 metadata.require_image_config()?.clone(),
120 ))
121}
122
123fn read_regular_json<T: DeserializeOwned>(path: &Path, description: &str) -> Result<T> {
124 let file = std::fs::symlink_metadata(path).map_err(|error| {
125 BoxError::ConfigError(format!(
126 "Failed to inspect {description} {}: {error}",
127 path.display()
128 ))
129 })?;
130 if !file.file_type().is_file() || file.file_type().is_symlink() {
131 return Err(BoxError::ConfigError(format!(
132 "{description} is not a regular file: {}",
133 path.display()
134 )));
135 }
136 if file.len() > MAX_IMAGE_CONFIG_BYTES {
137 return Err(BoxError::ConfigError(format!(
138 "{description} exceeds {MAX_IMAGE_CONFIG_BYTES} bytes: {}",
139 path.display()
140 )));
141 }
142 let encoded = std::fs::read(path).map_err(|error| {
143 BoxError::ConfigError(format!(
144 "Failed to read {description} {}: {error}",
145 path.display()
146 ))
147 })?;
148 serde_json::from_slice(&encoded).map_err(|error| {
149 BoxError::SerializationError(format!(
150 "Failed to parse {description} {}: {error}",
151 path.display()
152 ))
153 })
154}
155
156impl From<&OciImageConfig> for SnapshotImageConfig {
157 fn from(config: &OciImageConfig) -> Self {
158 Self {
159 entrypoint: config.entrypoint.clone(),
160 cmd: config.cmd.clone(),
161 env: config.env.clone(),
162 working_dir: config.working_dir.clone(),
163 user: config.user.clone(),
164 exposed_ports: config.exposed_ports.clone(),
165 labels: config.labels.clone(),
166 volumes: config.volumes.clone(),
167 stop_signal: config.stop_signal.clone(),
168 health_check: config
169 .health_check
170 .as_ref()
171 .map(SnapshotImageHealthCheck::from),
172 onbuild: config.onbuild.clone(),
173 }
174 }
175}
176
177impl From<&OciHealthCheck> for SnapshotImageHealthCheck {
178 fn from(health_check: &OciHealthCheck) -> Self {
179 Self {
180 test: health_check.test.clone(),
181 interval: health_check.interval,
182 timeout: health_check.timeout,
183 retries: health_check.retries,
184 start_period: health_check.start_period,
185 }
186 }
187}
188
189impl From<SnapshotImageConfig> for OciImageConfig {
190 fn from(config: SnapshotImageConfig) -> Self {
191 Self {
192 entrypoint: config.entrypoint,
193 cmd: config.cmd,
194 env: config.env,
195 working_dir: config.working_dir,
196 user: config.user,
197 exposed_ports: config.exposed_ports,
198 labels: config.labels,
199 volumes: config.volumes,
200 stop_signal: config.stop_signal,
201 health_check: config.health_check.map(OciHealthCheck::from),
202 onbuild: config.onbuild,
203 }
204 }
205}
206
207impl From<SnapshotImageHealthCheck> for OciHealthCheck {
208 fn from(health_check: SnapshotImageHealthCheck) -> Self {
209 Self {
210 test: health_check.test,
211 interval: health_check.interval,
212 timeout: health_check.timeout,
213 retries: health_check.retries,
214 start_period: health_check.start_period,
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use std::collections::HashMap;
222
223 use super::*;
224
225 fn image_config() -> OciImageConfig {
226 OciImageConfig {
227 entrypoint: Some(vec!["/usr/local/bin/envd".to_string()]),
228 cmd: Some(vec!["--port".to_string(), "49983".to_string()]),
229 env: vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())],
230 working_dir: Some("/home/user".to_string()),
231 user: Some("1000:1000".to_string()),
232 exposed_ports: vec!["49983/tcp".to_string()],
233 labels: HashMap::from([("runtime".to_string(), "envd".to_string())]),
234 volumes: vec!["/home/user".to_string()],
235 stop_signal: Some("SIGTERM".to_string()),
236 health_check: Some(OciHealthCheck {
237 test: vec!["CMD".to_string(), "envd-health".to_string()],
238 interval: Some(10),
239 timeout: Some(2),
240 retries: Some(3),
241 start_period: Some(5),
242 }),
243 onbuild: vec!["RUN prepare-runtime".to_string()],
244 }
245 }
246
247 #[test]
248 fn resolved_image_config_round_trips_through_the_snapshot_schema() {
249 let original = image_config();
250 let restored = OciImageConfig::from(SnapshotImageConfig::from(&original));
251
252 assert_eq!(restored.entrypoint, original.entrypoint);
253 assert_eq!(restored.cmd, original.cmd);
254 assert_eq!(restored.env, original.env);
255 assert_eq!(restored.working_dir, original.working_dir);
256 assert_eq!(restored.user, original.user);
257 assert_eq!(restored.exposed_ports, original.exposed_ports);
258 assert_eq!(restored.labels, original.labels);
259 assert_eq!(restored.volumes, original.volumes);
260 assert_eq!(restored.stop_signal, original.stop_signal);
261 assert_eq!(restored.health_check, original.health_check);
262 assert_eq!(restored.onbuild, original.onbuild);
263 }
264
265 #[test]
266 fn box_image_config_artifact_survives_process_local_state() {
267 let directory = tempfile::tempdir().unwrap();
268 let original = image_config();
269
270 persist_resolved_image_config(directory.path(), &original).unwrap();
271 let loaded = load_resolved_image_config(directory.path())
272 .unwrap()
273 .unwrap();
274
275 assert_eq!(loaded, SnapshotImageConfig::from(&original));
276 }
277
278 #[test]
279 fn legacy_snapshot_without_image_config_fails_closed() {
280 let directory = tempfile::tempdir().unwrap();
281 let source = directory.path().join("source");
282 std::fs::create_dir_all(&source).unwrap();
283 std::fs::write(source.join("state.txt"), "captured").unwrap();
284 let store = crate::SnapshotStore::new(&directory.path().join("snapshots")).unwrap();
285 store
286 .save(
287 SnapshotMetadata::new(
288 "legacy-snapshot".to_string(),
289 "legacy-snapshot".to_string(),
290 "source-execution".to_string(),
291 "alpine:3.20".to_string(),
292 ),
293 &source,
294 )
295 .unwrap();
296
297 let error = load_snapshot_oci_config(&store.rootfs_path("legacy-snapshot"), "alpine:3.20")
298 .unwrap_err();
299
300 assert!(format!("{error}").contains("resolved OCI image configuration"));
301 }
302}