bootc_lib/install/
completion.rs

1//! This module handles finishing/completion after an ostree-based
2//! install from e.g. Anaconda.
3
4use std::io;
5use std::os::fd::AsFd;
6use std::process::Command;
7
8use anyhow::{Context, Result};
9use bootc_utils::CommandRunExt;
10use camino::Utf8Path;
11use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
12use fn_error_context::context;
13use ostree_ext::{gio, ostree};
14use rustix::fs::Mode;
15use rustix::fs::OFlags;
16
17use crate::podstorage::CStorage;
18use crate::utils::deployment_fd;
19
20use super::config;
21
22/// An environment variable set by anaconda that hints
23/// we are running as part of that environment.
24const ANACONDA_ENV_HINT: &str = "ANA_INSTALL_PATH";
25/// The path where Anaconda sets up the target.
26/// <https://anaconda-installer.readthedocs.io/en/latest/mount-points.html#mnt-sysroot>
27const ANACONDA_SYSROOT: &str = "mnt/sysroot";
28/// Global flag to signal we're in a booted ostree system
29const OSTREE_BOOTED: &str = "run/ostree-booted";
30/// The very well-known DNS resolution file
31const RESOLVCONF: &str = "etc/resolv.conf";
32/// A renamed file
33const RESOLVCONF_ORIG: &str = "etc/resolv.conf.bootc-original";
34/// The root filesystem for pid 1
35const PROC1_ROOT: &str = "proc/1/root";
36/// The cgroupfs mount point, which we may propagate from the host if needed
37const CGROUPFS: &str = "sys/fs/cgroup";
38/// The path to the temporary global ostree pull secret
39const RUN_OSTREE_AUTH: &str = "run/ostree/auth.json";
40/// A sub path of /run which is used to ensure idempotency
41pub(crate) const RUN_BOOTC_INSTALL_RECONCILED: &str = "run/bootc-install-reconciled";
42
43/// Assuming that the current root is an ostree deployment, pull kargs
44/// from it and inject them.
45fn reconcile_kargs(sysroot: &ostree::Sysroot, deployment: &ostree::Deployment) -> Result<()> {
46    let deployment_root = &crate::utils::deployment_fd(sysroot, deployment)?;
47    let cancellable = gio::Cancellable::NONE;
48
49    let current_kargs = deployment
50        .bootconfig()
51        .expect("bootconfig for deployment")
52        .get("options");
53    let current_kargs = current_kargs
54        .as_ref()
55        .map(|s| s.as_str())
56        .unwrap_or_default();
57    tracing::debug!("current_kargs={current_kargs}");
58    let current_kargs = ostree::KernelArgs::from_string(&current_kargs);
59
60    // Keep this in sync with install_container
61    let install_config = config::load_config()?;
62    let install_config_kargs = install_config
63        .as_ref()
64        .and_then(|c| c.kargs.as_ref())
65        .into_iter()
66        .flatten()
67        .map(|s| s.as_str())
68        .collect::<Vec<_>>();
69    let kargsd = crate::bootc_kargs::get_kargs_in_root(deployment_root, std::env::consts::ARCH)?;
70    let kargsd_strs = kargsd.iter_str().collect::<Vec<_>>();
71
72    current_kargs.append_argv(&install_config_kargs);
73    current_kargs.append_argv(&kargsd_strs);
74    let new_kargs = current_kargs.to_string();
75    tracing::debug!("new_kargs={new_kargs}");
76
77    sysroot.deployment_set_kargs_in_place(deployment, Some(&new_kargs), cancellable)?;
78    Ok(())
79}
80
81/// A little helper struct which on drop renames a file. Used for putting back /etc/resolv.conf.
82#[must_use]
83struct Renamer<'d> {
84    dir: &'d Dir,
85    from: &'static Utf8Path,
86    to: &'static Utf8Path,
87}
88
89impl Renamer<'_> {
90    fn _impl_drop(&mut self) -> Result<()> {
91        self.dir
92            .rename(self.from, self.dir, self.to)
93            .map_err(Into::into)
94    }
95
96    fn consume(mut self) -> Result<()> {
97        self._impl_drop()
98    }
99}
100
101impl Drop for Renamer<'_> {
102    fn drop(&mut self) {
103        let _ = self._impl_drop();
104    }
105}
106/// Work around https://github.com/containers/buildah/issues/4242#issuecomment-2492480586
107/// among other things. We unconditionally replace the contents of `/etc/resolv.conf`
108/// in the target root with whatever the host uses (in Fedora 41+, that's systemd-resolved for Anaconda).
109#[context("Copying host resolv.conf")]
110fn ensure_resolvconf<'d>(rootfs: &'d Dir, proc1_root: &Dir) -> Result<Option<Renamer<'d>>> {
111    // Now check the state of etc/resolv.conf in the target root
112    let meta = rootfs
113        .symlink_metadata_optional(RESOLVCONF)
114        .context("stat")?;
115    let renamer = if meta.is_some() {
116        rootfs
117            .rename(RESOLVCONF, &rootfs, RESOLVCONF_ORIG)
118            .context("Renaming")?;
119        Some(Renamer {
120            dir: &rootfs,
121            from: RESOLVCONF_ORIG.into(),
122            to: RESOLVCONF.into(),
123        })
124    } else {
125        None
126    };
127    // If we got here, /etc/resolv.conf either didn't exist or we removed it.
128    // Copy the host data into it (note this will follow symlinks; e.g.
129    // Anaconda in Fedora 41+ defaults to systemd-resolved)
130    proc1_root
131        .copy(RESOLVCONF, rootfs, RESOLVCONF)
132        .context("Copying new resolv.conf")?;
133    Ok(renamer)
134}
135
136/// Bind a mount point from the host namespace into our root
137fn bind_from_host(
138    rootfs: &Dir,
139    src: impl AsRef<Utf8Path>,
140    target: impl AsRef<Utf8Path>,
141) -> Result<()> {
142    fn bind_from_host_impl(rootfs: &Dir, src: &Utf8Path, target: &Utf8Path) -> Result<()> {
143        rootfs.create_dir_all(target)?;
144        if rootfs.is_mountpoint(target)?.unwrap_or_default() {
145            return Ok(());
146        }
147        let target = format!("/{ANACONDA_SYSROOT}/{target}");
148        tracing::debug!("Binding {src} to {target}");
149        // We're run in a mount namespace, but not a pid namespace; use nsenter
150        // via the pid namespace to escape to the host's mount namespace and
151        // perform a mount there.
152        Command::new("nsenter")
153            .args(["-m", "-t", "1", "--", "mount", "--bind"])
154            .arg(src)
155            .arg(&target)
156            .run_capture_stderr()?;
157        Ok(())
158    }
159
160    bind_from_host_impl(rootfs, src.as_ref(), target.as_ref())
161}
162
163/// Anaconda doesn't mount /sys/fs/cgroup in /mnt/sysroot
164#[context("Ensuring cgroupfs")]
165fn ensure_cgroupfs(rootfs: &Dir) -> Result<()> {
166    bind_from_host(rootfs, CGROUPFS, CGROUPFS)
167}
168
169/// If we have /etc/ostree/auth.json in the Anaconda environment then propagate
170/// it into /run/ostree/auth.json
171#[context("Propagating ostree auth")]
172fn ensure_ostree_auth(rootfs: &Dir, host_root: &Dir) -> Result<()> {
173    let Some((authpath, authfd)) =
174        ostree_ext::globals::get_global_authfile(&host_root).context("Querying authfiles")?
175    else {
176        tracing::debug!("No auth found in host");
177        return Ok(());
178    };
179    tracing::debug!("Discovered auth in host: {authpath}");
180    let mut authfd = io::BufReader::new(authfd);
181    let run_ostree_auth = Utf8Path::new(RUN_OSTREE_AUTH);
182    rootfs.create_dir_all(run_ostree_auth.parent().unwrap())?;
183    rootfs.atomic_replace_with(run_ostree_auth, |w| std::io::copy(&mut authfd, w))?;
184    Ok(())
185}
186
187#[context("Opening {PROC1_ROOT}")]
188fn open_proc1_root(rootfs: &Dir) -> Result<Dir> {
189    let proc1_root = rustix::fs::openat(
190        &rootfs.as_fd(),
191        PROC1_ROOT,
192        OFlags::CLOEXEC | OFlags::DIRECTORY,
193        Mode::empty(),
194    )?;
195    Dir::reopen_dir(&proc1_root.as_fd()).map_err(Into::into)
196}
197
198/// Core entrypoint invoked when we are likely being invoked from inside Anaconda as a `%post`.
199pub(crate) async fn run_from_anaconda(rootfs: &Dir) -> Result<()> {
200    // unshare our mount namespace, so any *further* mounts aren't leaked.
201    // Note that because this does a re-exec, anything *before* this point
202    // should be idempotent.
203    crate::cli::require_root(false)?;
204    crate::cli::ensure_self_unshared_mount_namespace()?;
205
206    if std::env::var_os(ANACONDA_ENV_HINT).is_none() {
207        anyhow::bail!("Missing environment variable {ANACONDA_ENV_HINT}");
208    } else {
209        // In the way Anaconda sets up the bind mounts today, this doesn't exist. Later
210        // code expects it to exist, so do so.
211        if !rootfs.try_exists(OSTREE_BOOTED)? {
212            tracing::debug!("Writing {OSTREE_BOOTED}");
213            rootfs.atomic_write(OSTREE_BOOTED, b"")?;
214        }
215    }
216
217    // Get access to the real root by opening /proc/1/root
218    let proc1_root = &open_proc1_root(rootfs)?;
219
220    if proc1_root
221        .try_exists(RUN_BOOTC_INSTALL_RECONCILED)
222        .context("Querying reconciliation")?
223    {
224        println!("Reconciliation already completed.");
225        return Ok(());
226    }
227
228    ensure_cgroupfs(rootfs)?;
229    // Sometimes Anaconda may not initialize networking in the target root?
230    let resolvconf = ensure_resolvconf(rootfs, proc1_root)?;
231    // Propagate an injected authfile for pulling logically bound images
232    ensure_ostree_auth(rootfs, proc1_root)?;
233
234    let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path("/")));
235    sysroot
236        .load(gio::Cancellable::NONE)
237        .context("Loading sysroot")?;
238    impl_completion(rootfs, &sysroot, None).await?;
239
240    proc1_root
241        .write(RUN_BOOTC_INSTALL_RECONCILED, b"")
242        .with_context(|| format!("Writing {RUN_BOOTC_INSTALL_RECONCILED}"))?;
243    if let Some(resolvconf) = resolvconf {
244        resolvconf.consume()?;
245    }
246    Ok(())
247}
248
249/// From ostree-rs-ext, run through the rest of bootc install functionality
250pub async fn run_from_ostree(rootfs: &Dir, sysroot: &Utf8Path, stateroot: &str) -> Result<()> {
251    crate::cli::require_root(false)?;
252    // Load sysroot from the provided path
253    let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(sysroot)));
254    sysroot.load(gio::Cancellable::NONE)?;
255
256    impl_completion(rootfs, &sysroot, Some(stateroot)).await?;
257
258    // In this case we write the completion directly to /run as we're running from
259    // the host context.
260    rootfs
261        .write(RUN_BOOTC_INSTALL_RECONCILED, b"")
262        .with_context(|| format!("Writing {RUN_BOOTC_INSTALL_RECONCILED}"))?;
263    Ok(())
264}
265
266/// Core entrypoint for completion of an ostree-based install to a bootc one:
267///
268/// - kernel argument handling
269/// - logically bound images
270///
271/// We could also do other things here, such as write an aleph file or
272/// ensure the repo config is synchronized, but these two are the most important
273/// for now.
274pub(crate) async fn impl_completion(
275    rootfs: &Dir,
276    sysroot: &ostree::Sysroot,
277    stateroot: Option<&str>,
278) -> Result<()> {
279    // Log the completion operation to systemd journal
280    const COMPLETION_JOURNAL_ID: &str = "0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4";
281    tracing::info!(
282        message_id = COMPLETION_JOURNAL_ID,
283        bootc.stateroot = stateroot.unwrap_or("default"),
284        "Starting bootc installation completion"
285    );
286
287    let deployment = &sysroot
288        .merge_deployment(stateroot)
289        .ok_or_else(|| anyhow::anyhow!("Failed to find deployment (stateroot={stateroot:?})"))?;
290    let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?;
291
292    // Create a subdir in /run
293    let rundir = "run/bootc-install";
294    rootfs.create_dir_all(rundir)?;
295    let rundir = &rootfs.open_dir(rundir)?;
296
297    // ostree-ext doesn't do kargs, so handle that now
298    reconcile_kargs(&sysroot, deployment)?;
299
300    // ostree-ext doesn't do logically bound images
301    let bound_images = crate::boundimage::query_bound_images_for_deployment(sysroot, deployment)?;
302
303    if !bound_images.is_empty() {
304        // Log bound images found
305        tracing::info!(
306            message_id = COMPLETION_JOURNAL_ID,
307            bootc.bound_images_count = bound_images.len(),
308            "Found {} bound images for completion",
309            bound_images.len()
310        );
311
312        // load the selinux policy from the target ostree deployment
313        let deployment_fd = deployment_fd(sysroot, deployment)?;
314        let sepolicy = crate::lsm::new_sepolicy_at(deployment_fd)?;
315
316        // When we're run through ostree, we only lazily initialize the podman storage to avoid
317        // having a hard dependency on it.
318        let imgstorage = &CStorage::create(&sysroot_dir, &rundir, sepolicy.as_ref())?;
319        crate::boundimage::pull_images_impl(imgstorage, bound_images)
320            .await
321            .context("pulling bound images")?;
322    }
323
324    // Log completion success
325    tracing::info!(
326        message_id = COMPLETION_JOURNAL_ID,
327        bootc.stateroot = stateroot.unwrap_or("default"),
328        "Successfully completed bootc installation"
329    );
330
331    Ok(())
332}