ostree_ext/container/
deploy.rs

1//! Perform initial setup for a container image based system root
2
3use anyhow::Result;
4use fn_error_context::context;
5use ostree::glib;
6use std::collections::HashSet;
7
8use super::store::{LayeredImageState, gc_image_layers};
9use super::{ImageReference, OstreeImageReference};
10use crate::container::store::PrepareResult;
11use crate::keyfileext::KeyFileExt;
12use crate::sysroot::SysrootLock;
13
14/// The key in the OSTree origin which holds a serialized [`super::OstreeImageReference`].
15pub const ORIGIN_CONTAINER: &str = "container-image-reference";
16
17/// The name of the default stateroot.
18// xref https://github.com/ostreedev/ostree/issues/2794
19pub const STATEROOT_DEFAULT: &str = "default";
20
21/// Options configuring deployment.
22#[derive(Debug, Default)]
23#[non_exhaustive]
24pub struct DeployOpts<'a> {
25    /// Kernel arguments to use.
26    pub kargs: Option<&'a [&'a str]>,
27    /// Target image reference, as distinct from the source.
28    ///
29    /// In many cases, one may want a workflow where a system is provisioned from
30    /// an image with a specific digest (e.g. `quay.io/example/os@sha256:...) for
31    /// reproducibilty.  However, one would want `ostree admin upgrade` to fetch
32    /// `quay.io/example/os:latest`.
33    ///
34    /// To implement this, use this option for the latter `:latest` tag.
35    pub target_imgref: Option<&'a OstreeImageReference>,
36
37    /// Configuration for fetching containers.
38    pub proxy_cfg: Option<super::store::ImageProxyConfig>,
39
40    /// If true, then no image reference will be written; but there will be refs
41    /// for the fetched layers.  This ensures that if the machine is later updated
42    /// to a different container image, the fetch process will reuse shared layers, but
43    /// it will not be necessary to remove the previous image.
44    pub no_imgref: bool,
45
46    /// Do not invoke bootc completion
47    pub skip_completion: bool,
48
49    /// Do not cleanup deployments
50    pub no_clean: bool,
51}
52
53/// Write a container image to an OSTree deployment.
54///
55/// This API is currently intended for only an initial deployment.
56#[context("Performing deployment")]
57pub async fn deploy(
58    sysroot: &ostree::Sysroot,
59    stateroot: &str,
60    imgref: &OstreeImageReference,
61    options: Option<DeployOpts<'_>>,
62) -> Result<Box<LayeredImageState>> {
63    // Log the deployment operation to systemd journal (Debug level since staging already logged the main info)
64
65    const DEPLOY_JOURNAL_ID: &str = "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3";
66
67    tracing::debug!(
68        message_id = DEPLOY_JOURNAL_ID,
69        bootc.image.reference = &imgref.imgref.name,
70        bootc.image.transport = &imgref.imgref.transport.to_string(),
71        bootc.stateroot = stateroot,
72        "Deploying container image to OSTree: {}",
73        imgref
74    );
75
76    let cancellable = ostree::gio::Cancellable::NONE;
77    let options = options.unwrap_or_default();
78    let repo = &sysroot.repo();
79    let merge_deployment = sysroot.merge_deployment(Some(stateroot));
80    let mut imp =
81        super::store::ImageImporter::new(repo, imgref, options.proxy_cfg.unwrap_or_default())
82            .await?;
83    imp.require_bootable();
84    if let Some(target) = options.target_imgref {
85        imp.set_target(target);
86    }
87    if options.no_imgref {
88        imp.set_no_imgref();
89    }
90    let state = match imp.prepare().await? {
91        PrepareResult::AlreadyPresent(r) => r,
92        PrepareResult::Ready(prep) => {
93            if let Some(warning) = prep.deprecated_warning() {
94                crate::cli::print_deprecated_warning(warning).await;
95            }
96
97            imp.import(prep).await?
98        }
99    };
100    let commit = state.merge_commit.as_str();
101    let origin = glib::KeyFile::new();
102    let target_imgref = options.target_imgref.unwrap_or(imgref);
103    origin.set_string("origin", ORIGIN_CONTAINER, &target_imgref.to_string());
104
105    let opts = ostree::SysrootDeployTreeOpts {
106        override_kernel_argv: options.kargs,
107        ..Default::default()
108    };
109
110    if sysroot.booted_deployment().is_some() {
111        sysroot.stage_tree_with_options(
112            Some(stateroot),
113            commit,
114            Some(&origin),
115            merge_deployment.as_ref(),
116            &opts,
117            cancellable,
118        )?;
119    } else {
120        let deployment = &sysroot.deploy_tree_with_options(
121            Some(stateroot),
122            commit,
123            Some(&origin),
124            merge_deployment.as_ref(),
125            Some(&opts),
126            cancellable,
127        )?;
128        let flags = if options.no_clean {
129            ostree::SysrootSimpleWriteDeploymentFlags::NO_CLEAN
130        } else {
131            ostree::SysrootSimpleWriteDeploymentFlags::NONE
132        };
133        sysroot.simple_write_deployment(
134            Some(stateroot),
135            deployment,
136            merge_deployment.as_ref(),
137            flags,
138            cancellable,
139        )?;
140
141        // We end up re-executing ourselves as a subprocess because
142        // otherwise right now we end up with a circular dependency between
143        // crates. We need an option to skip though so when the *main*
144        // bootc install code calls this API, we don't do this as it
145        // will have already been handled.
146        // Note also we do this under a feature gate to ensure rpm-ostree
147        // doesn't try to invoke this, as that won't work right now.
148        #[cfg(feature = "bootc")]
149        if !options.skip_completion {
150            use bootc_utils::CommandRunExt;
151            use cap_std_ext::cmdext::CapStdExtCommandExt;
152            use ocidir::cap_std::fs::Dir;
153
154            let sysroot_dir = &Dir::reopen_dir(&crate::sysroot::sysroot_fd(sysroot))?;
155
156            // Note that the sysroot is provided as `.`  but we use cwd_dir to
157            // make the process current working directory the sysroot.
158            let st = std::process::Command::new(std::env::current_exe()?)
159                .args(["internals", "bootc-install-completion", ".", stateroot])
160                .cwd_dir(sysroot_dir.try_clone()?)
161                .lifecycle_bind()
162                .status()?;
163            if !st.success() {
164                anyhow::bail!("Failed to complete bootc install");
165            }
166        }
167
168        if !options.no_clean {
169            sysroot.cleanup(cancellable)?;
170        }
171    }
172
173    Ok(state)
174}
175
176/// Query the container image reference for a deployment
177fn deployment_origin_container(
178    deploy: &ostree::Deployment,
179) -> Result<Option<OstreeImageReference>> {
180    let origin = deploy
181        .origin()
182        .map(|o| o.optional_string("origin", ORIGIN_CONTAINER))
183        .transpose()?
184        .flatten();
185    let r = origin
186        .map(|v| OstreeImageReference::try_from(v.as_str()))
187        .transpose()?;
188    Ok(r)
189}
190
191/// Remove all container images which are not the target of a deployment.
192/// This acts equivalently to [`super::store::remove_images()`] - the underlying layers
193/// are not pruned.
194///
195/// The set of removed images is returned.
196pub fn remove_undeployed_images(sysroot: &SysrootLock) -> Result<Vec<ImageReference>> {
197    let repo = &sysroot.repo();
198    let deployment_origins: Result<HashSet<_>> = sysroot
199        .deployments()
200        .into_iter()
201        .filter_map(|deploy| {
202            deployment_origin_container(&deploy)
203                .map(|v| v.map(|v| v.imgref))
204                .transpose()
205        })
206        .collect();
207    let deployment_origins = deployment_origins?;
208    // TODO add an API that returns ImageReference instead
209    let all_images = super::store::list_images(&sysroot.repo())?
210        .into_iter()
211        .filter_map(|img| ImageReference::try_from(img.as_str()).ok());
212    let mut removed = Vec::new();
213    for image in all_images {
214        if !deployment_origins.contains(&image) {
215            super::store::remove_image(repo, &image)?;
216            removed.push(image);
217        }
218    }
219    Ok(removed)
220}
221
222/// The result of a prune operation
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct Pruned {
225    /// The number of images that were pruned
226    pub n_images: u32,
227    /// The number of image layers that were pruned
228    pub n_layers: u32,
229    /// The number of OSTree objects that were pruned
230    pub n_objects_pruned: u32,
231    /// The total size of pruned objects
232    pub objsize: u64,
233}
234
235impl Pruned {
236    /// Whether this prune was a no-op (i.e. no images, layers or objects were pruned).
237    pub fn is_empty(&self) -> bool {
238        self.n_images == 0 && self.n_layers == 0 && self.n_objects_pruned == 0
239    }
240}
241
242/// This combines the functionality of [`remove_undeployed_images()`] with [`super::store::gc_image_layers()`].
243pub fn prune(sysroot: &SysrootLock) -> Result<Pruned> {
244    let repo = &sysroot.repo();
245    // Prune container images which are not deployed.
246    // SAFETY: There should never be more than u32 images
247    let n_images = remove_undeployed_images(sysroot)?.len().try_into().unwrap();
248    // Prune unreferenced layer branches.
249    let n_layers = gc_image_layers(repo)?;
250    // Prune the objects in the repo; the above just removed refs (branches).
251    let (_, n_objects_pruned, objsize) = repo.prune(
252        ostree::RepoPruneFlags::REFS_ONLY,
253        0,
254        ostree::gio::Cancellable::NONE,
255    )?;
256    // SAFETY: The number of pruned objects should never be negative
257    let n_objects_pruned = u32::try_from(n_objects_pruned).unwrap();
258    Ok(Pruned {
259        n_images,
260        n_layers,
261        n_objects_pruned,
262        objsize,
263    })
264}