Skip to main content

a3s_box_runtime/
resolved_image.rs

1//! Durable resolved OCI image defaults used by filesystem snapshots.
2
3use 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
12/// Box-local artifact containing the resolved defaults from the source image.
13pub const RESOLVED_IMAGE_CONFIG_FILE: &str = ".oci-image-config.json";
14
15const MAX_IMAGE_CONFIG_BYTES: u64 = 1024 * 1024;
16
17/// Load the resolved image defaults persisted for a box.
18///
19/// The artifact is independent of the control-plane process so a filesystem
20/// snapshot created after a service restart retains the source image's OCI
21/// entrypoint, command, environment, working directory, and user.
22pub 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
39/// Persist the resolved defaults carried by a filesystem snapshot before its
40/// rootfs is booted. Raw-ext4 restores have no host directory lower from which
41/// layout preparation could recover this metadata, so the snapshot bundle is
42/// the authority for the new box-local copy.
43pub(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}