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