Skip to main content

arcbox_computer_runtime/rootfs/
inject.rs

1//! Write vm-agent into ext4 images through a loop mount.
2
3use super::RootfsBuilder;
4use anyhow::{Context, Result, bail};
5use std::path::Path;
6#[cfg(target_os = "linux")]
7use std::{path::PathBuf, sync::Arc};
8
9impl RootfsBuilder {
10    /// Inject the configured agent at [`super::VM_AGENT_PATH`] and link
11    /// `/etc/resolv.conf` into `/run`. Preserve the image's `/sbin/init`.
12    /// An owned task completes injection and cleanup after caller cancellation.
13    /// Runtime shutdown or process termination can interrupt resource cleanup.
14    pub async fn inject_vm_agent(&self, image: &Path) -> crate::error::Result<()> {
15        let builder = Self::new(self.paths.clone(), self.block_tools.clone());
16        let image = image.to_owned();
17        let (reply, completed) = tokio::sync::oneshot::channel();
18        tokio::spawn(async move {
19            let result = builder.inject_agent(&image).await;
20            if let Err(Err(error)) = reply.send(result) {
21                tracing::error!(?error, "cancelled vm-agent injection failed");
22            }
23        });
24        completed
25            .await
26            .context("vm-agent injection task stopped")
27            .and_then(std::convert::identity)
28            .map_err(super::rootfs_err)
29    }
30
31    /// Inject vm-agent into an ext4 image through a loop mount.
32    ///
33    /// The image is attached as a loop device through the composer's
34    /// [`BlockTools`](arcbox_snapshot::snapshot_cow::BlockTools) and mounted with `mount(2)`; nothing here shells out.
35    /// Success means the agent is in the image *and* every resource is
36    /// released again: an image whose mount or loop device could not be
37    /// torn down is not published, because the caller's rename would cache
38    /// a file another mount holds and a leaked `/dev/loopN` per build would
39    /// eventually exhaust the pool.
40    #[cfg(target_os = "linux")]
41    pub(super) async fn inject_agent(&self, image: &Path) -> Result<()> {
42        if !self.paths.vm_agent.exists() {
43            bail!("vm-agent not found at {}", self.paths.vm_agent.display());
44        }
45
46        // Attach first: a failed attach (no free loop device, tooling
47        // missing) then leaves nothing behind.
48        let loop_dev = self
49            .attach_loop(image)
50            .await
51            .context("failed to attach ext4 image for vm-agent injection")?;
52        let mount_dir =
53            std::env::temp_dir().join(format!("arcbox-inject-{}", uuid::Uuid::new_v4()));
54        let staged = match tokio::fs::create_dir_all(&mount_dir).await {
55            Ok(()) => mount_ext4(&loop_dev, &mount_dir)
56                .await
57                .with_context(|| format!("mount {loop_dev} for vm-agent injection")),
58            Err(e) => Err(e).context("failed to create injection mount dir"),
59        };
60        if let Err(error) = staged {
61            // Release the device even when staging fails. Preserve every failure.
62            let mut failures = Vec::new();
63            self.detach_loop(&loop_dev)
64                .await
65                .push_failure(&mut failures);
66            remove_mount_dir(&mount_dir)
67                .await
68                .push_failure(&mut failures);
69            return finish_cleanup(Err(error), failures);
70        }
71
72        let injected = self.write_agent_into(&mount_dir).await;
73
74        // Release everything, collecting rather than short-circuiting so a
75        // failed step never skips the ones after it.
76        let mut failures = Vec::new();
77        unmount(&mount_dir).await.push_failure(&mut failures);
78        self.detach_loop(&loop_dev)
79            .await
80            .push_failure(&mut failures);
81        remove_mount_dir(&mount_dir)
82            .await
83            .push_failure(&mut failures);
84
85        finish_cleanup(injected, failures)
86    }
87
88    /// Copy the agent to `/sbin/vm-agent` (mode 755) and point
89    /// `/etc/resolv.conf` at the `/run` tmpfs inside the mounted image.
90    #[cfg(target_os = "linux")]
91    async fn write_agent_into(&self, mount_dir: &Path) -> Result<()> {
92        let sbin = mount_dir.join("sbin");
93        let dest = mount_dir.join(super::VM_AGENT_PATH.trim_start_matches('/'));
94        tokio::fs::create_dir_all(&sbin)
95            .await
96            .context("failed to create /sbin in rootfs")?;
97        tokio::fs::copy(&self.paths.vm_agent, &dest)
98            .await
99            .context("failed to copy vm-agent into rootfs")?;
100        tokio::fs::set_permissions(&dest, std::os::unix::fs::PermissionsExt::from_mode(0o755))
101            .await
102            .context("failed to chmod vm-agent in rootfs")?;
103
104        // Point resolv.conf at tmpfs, mirroring the default template: DNS
105        // rewrites must stay off the CoW block device (CORE-75). Rewriting
106        // is safe for Docker images — Docker itself never ships meaningful
107        // resolv.conf content (it bind-mounts one at run time).
108        let etc = mount_dir.join("etc");
109        tokio::fs::create_dir_all(&etc)
110            .await
111            .context("create /etc in rootfs")?;
112        let resolv = etc.join("resolv.conf");
113        match tokio::fs::remove_file(&resolv).await {
114            Ok(()) => {}
115            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
116            Err(error) => return Err(error).context("replace /etc/resolv.conf in rootfs"),
117        }
118        tokio::fs::symlink("../run/resolv.conf", &resolv)
119            .await
120            .context("link /etc/resolv.conf into the run tmpfs")?;
121        Ok(())
122    }
123
124    /// Loop mounts are a Linux operation; the builder compiles elsewhere but
125    /// cannot inject.
126    #[cfg(not(target_os = "linux"))]
127    pub(super) async fn inject_agent(&self, _image: &Path) -> Result<()> {
128        bail!("vm-agent injection needs a Linux loop mount")
129    }
130
131    /// [`BlockTools::attach_loop`](arcbox_snapshot::snapshot_cow::BlockTools::attach_loop) on a blocking thread.
132    #[cfg(target_os = "linux")]
133    async fn attach_loop(&self, image: &Path) -> Result<String> {
134        let tools = Arc::clone(&self.block_tools);
135        let image = PathBuf::from(image);
136        tokio::task::spawn_blocking(move || tools.attach_loop(&image, false))
137            .await
138            .context("loop attach task panicked")?
139            .map_err(anyhow::Error::from)
140    }
141
142    /// [`BlockTools::detach_loop`](arcbox_snapshot::snapshot_cow::BlockTools::detach_loop) on a blocking thread.
143    ///
144    /// The kernel accepts `LOOP_CLR_FD` on a device that still has users
145    /// (it flags the device autoclear and frees it when the last one goes),
146    /// so this succeeds after a lazy unmount as well as a clean one.
147    #[cfg(target_os = "linux")]
148    async fn detach_loop(&self, loop_dev: &str) -> Result<()> {
149        let tools = Arc::clone(&self.block_tools);
150        let dev = loop_dev.to_owned();
151        tokio::task::spawn_blocking(move || tools.detach_loop(&dev))
152            .await
153            .context("loop detach task panicked")?
154            .with_context(|| format!("detach {loop_dev}"))
155    }
156}
157
158#[cfg(target_os = "linux")]
159async fn remove_mount_dir(dir: &Path) -> Result<()> {
160    match tokio::fs::remove_dir(dir).await {
161        Ok(()) => Ok(()),
162        // Staging can fail before the directory exists.
163        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
164        Err(error) => Err(error).with_context(|| format!("remove mount dir {}", dir.display())),
165    }
166}
167
168#[cfg(target_os = "linux")]
169fn finish_cleanup(injected: Result<()>, failures: Vec<String>) -> Result<()> {
170    if failures.is_empty() {
171        return injected;
172    }
173    let cleanup = format!(
174        "vm-agent injection left resources behind: {}",
175        failures.join("; ")
176    );
177    tracing::error!(error = %cleanup, "vm-agent injection cleanup failed");
178    match injected {
179        Ok(()) => Err(anyhow::anyhow!(cleanup)),
180        Err(error) => Err(error.context(cleanup)),
181    }
182}
183
184/// `mount(2)` an ext4 image's loop device at `dir`, off the executor —
185/// mounting can replay the journal.
186#[cfg(target_os = "linux")]
187async fn mount_ext4(loop_dev: &str, dir: &Path) -> Result<()> {
188    use nix::mount::{MsFlags, mount};
189
190    let dev = loop_dev.to_owned();
191    let dir = dir.to_path_buf();
192    tokio::task::spawn_blocking(move || {
193        mount(
194            Some(dev.as_str()),
195            &dir,
196            Some("ext4"),
197            MsFlags::empty(),
198            None::<&str>,
199        )
200    })
201    .await
202    .context("mount task panicked")?
203    .map_err(anyhow::Error::from)
204}
205
206/// `umount(2)` `dir`, falling back to a lazy unmount (`MNT_DETACH`) when
207/// something still holds the tree, so the following loop detach can at
208/// least schedule the device's release instead of stranding it.
209#[cfg(target_os = "linux")]
210async fn unmount(dir: &Path) -> Result<()> {
211    use nix::mount::{MntFlags, umount, umount2};
212
213    let target = dir.to_path_buf();
214    tokio::task::spawn_blocking(move || match umount(&target) {
215        Ok(()) => Ok(()),
216        // Still busy: detach the tree lazily so the loop device can be
217        // released once the last user goes, and report the unmount as
218        // failed either way — the image is not safe to publish.
219        Err(busy) => match umount2(&target, MntFlags::MNT_DETACH) {
220            Ok(()) => bail!(
221                "umount {}: {busy} (lazily detached instead)",
222                target.display()
223            ),
224            Err(lazy) => bail!("umount {}: {busy}; lazy unmount: {lazy}", target.display()),
225        },
226    })
227    .await
228    .context("umount task panicked")?
229}
230
231/// Collect a step's failure without stopping the cleanup sequence.
232#[cfg(target_os = "linux")]
233trait PushFailure {
234    fn push_failure(self, failures: &mut Vec<String>);
235}
236
237#[cfg(target_os = "linux")]
238impl PushFailure for Result<()> {
239    fn push_failure(self, failures: &mut Vec<String>) {
240        if let Err(e) = self {
241            failures.push(format!("{e:#}"));
242        }
243    }
244}
245
246#[cfg(all(test, target_os = "linux"))]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn cleanup_preserves_the_injection_error_and_every_release_failure() {
252        let error = finish_cleanup(
253            Err(anyhow::anyhow!("agent copy failed")),
254            vec!["unmount failed".into(), "loop detach failed".into()],
255        )
256        .unwrap_err();
257        let details = format!("{error:#}");
258        assert!(details.contains("agent copy failed"), "{details}");
259        assert!(details.contains("unmount failed"), "{details}");
260        assert!(details.contains("loop detach failed"), "{details}");
261    }
262
263    #[test]
264    fn unreceived_injection_cleanup_errors_are_logged() {
265        if !super::super::tests::isolated_log_test(
266            "rootfs::inject::tests::unreceived_injection_cleanup_errors_are_logged",
267        ) {
268            return;
269        }
270        let log = tempfile::NamedTempFile::new().unwrap();
271        let writer = log.reopen().unwrap();
272        let subscriber = tracing_subscriber::fmt()
273            .with_ansi(false)
274            .without_time()
275            .with_writer(move || writer.try_clone().unwrap())
276            .finish();
277        tracing::subscriber::with_default(subscriber, || {
278            let result = finish_cleanup(Ok(()), vec!["loop detach failed".into()]);
279            let (reply, completed) = tokio::sync::oneshot::channel();
280            reply.send(result).unwrap();
281            drop(completed);
282        });
283        let logged = std::fs::read_to_string(log.path()).unwrap();
284        assert!(logged.contains("loop detach failed"), "{logged}");
285    }
286}