Skip to main content

bootc_lib/
cli.rs

1//! # Bootable container image CLI
2//!
3//! Command line tool to manage bootable ostree-based containers.
4
5use std::ffi::{CString, OsStr, OsString};
6use std::fs::File;
7use std::io::{BufWriter, Seek};
8use std::os::fd::AsFd;
9use std::os::unix::process::CommandExt;
10use std::process::Command;
11
12use anyhow::{Context, Result, anyhow, ensure};
13use camino::{Utf8Path, Utf8PathBuf};
14use cap_std_ext::cap_std;
15use cap_std_ext::cap_std::fs::Dir;
16use clap::CommandFactory;
17use clap::Parser;
18use clap::ValueEnum;
19use composefs::dumpfile;
20use composefs::fsverity;
21use composefs::fsverity::FsVerityHashValue;
22use composefs_ctl::composefs;
23use composefs_ctl::composefs_boot;
24use composefs_ctl::composefs_oci;
25
26use composefs_boot::BootOps as _;
27use etc_merge::{compute_diff, print_diff};
28use fn_error_context::context;
29use indoc::indoc;
30use ostree::gio;
31use ostree_container::store::PrepareResult;
32use ostree_ext::container as ostree_container;
33
34use ostree_ext::keyfileext::KeyFileExt;
35use ostree_ext::ostree;
36use ostree_ext::sysroot::SysrootLock;
37use schemars::schema_for;
38use serde::{Deserialize, Serialize};
39
40use crate::bootc_composefs::delete::delete_composefs_deployment;
41use crate::bootc_composefs::gc::composefs_gc;
42use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot};
43use crate::bootc_composefs::{
44    digest::{compute_composefs_digest, new_temp_composefs_repo},
45    finalize::{composefs_backend_finalize, get_etc_diff},
46    rollback::composefs_rollback,
47    state::composefs_usr_overlay,
48    switch::switch_composefs,
49    update::upgrade_composefs,
50};
51use crate::deploy::{MergeState, RequiredHostSpec};
52use crate::podstorage::set_additional_image_store;
53use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
54use crate::spec::FilesystemOverlayAccessMode;
55use crate::spec::Host;
56use crate::spec::ImageReference;
57use crate::status::get_host;
58use crate::store::{BootedOstree, Storage};
59use crate::store::{BootedStorage, BootedStorageKind};
60use crate::utils::sigpolicy_from_opt;
61use crate::{bootc_composefs, lints};
62
63/// Shared progress options
64#[derive(Debug, Parser, PartialEq, Eq)]
65pub(crate) struct ProgressOptions {
66    /// File descriptor number which must refer to an open pipe.
67    ///
68    /// Progress is written as JSON lines to this file descriptor.
69    #[clap(long, hide = true)]
70    pub(crate) progress_fd: Option<RawProgressFd>,
71}
72
73impl TryFrom<ProgressOptions> for ProgressWriter {
74    type Error = anyhow::Error;
75
76    fn try_from(value: ProgressOptions) -> Result<Self> {
77        let r = value
78            .progress_fd
79            .map(TryInto::try_into)
80            .transpose()?
81            .unwrap_or_default();
82        Ok(r)
83    }
84}
85
86/// Perform an upgrade operation
87#[derive(Debug, Parser, PartialEq, Eq)]
88pub(crate) struct UpgradeOpts {
89    /// Don't display progress
90    #[clap(long)]
91    pub(crate) quiet: bool,
92
93    /// Check if an update is available without applying it.
94    ///
95    /// This only downloads updated metadata, not the full image layers.
96    #[clap(long, conflicts_with = "apply")]
97    pub(crate) check: bool,
98
99    /// Restart or reboot into the new target image.
100    ///
101    /// Currently, this always reboots. Future versions may support userspace-only restart.
102    #[clap(long, conflicts_with = "check")]
103    pub(crate) apply: bool,
104
105    /// Configure soft reboot behavior.
106    ///
107    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
108    #[clap(long = "soft-reboot", conflicts_with = "check")]
109    pub(crate) soft_reboot: Option<SoftRebootMode>,
110
111    /// Download and stage the update without applying it.
112    ///
113    /// Download the update and ensure it's retained on disk for the lifetime of this system boot,
114    /// but it will not be applied on reboot. If the system is rebooted without applying the update,
115    /// the image will be eligible for garbage collection again.
116    #[clap(long, conflicts_with_all = ["check", "apply"])]
117    pub(crate) download_only: bool,
118
119    /// Apply a staged deployment that was previously downloaded with --download-only.
120    ///
121    /// This unlocks the staged deployment without fetching updates from the container image source.
122    /// The deployment will be applied on the next shutdown or reboot. Use with --apply to
123    /// reboot immediately.
124    #[clap(long, conflicts_with_all = ["check", "download_only"])]
125    pub(crate) from_downloaded: bool,
126
127    /// Upgrade to a different tag of the currently booted image.
128    ///
129    /// This derives the target image by replacing the tag portion of the current
130    /// booted image reference.
131    #[clap(long)]
132    pub(crate) tag: Option<String>,
133
134    #[clap(flatten)]
135    pub(crate) progress: ProgressOptions,
136}
137
138/// Perform an switch operation
139#[derive(Debug, Parser, PartialEq, Eq)]
140pub(crate) struct SwitchOpts {
141    /// Don't display progress
142    #[clap(long)]
143    pub(crate) quiet: bool,
144
145    /// Restart or reboot into the new target image.
146    ///
147    /// Currently, this always reboots. Future versions may support userspace-only restart.
148    #[clap(long)]
149    pub(crate) apply: bool,
150
151    /// Configure soft reboot behavior.
152    ///
153    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
154    #[clap(long = "soft-reboot")]
155    pub(crate) soft_reboot: Option<SoftRebootMode>,
156
157    /// The transport; e.g. registry, oci, oci-archive, docker-daemon, containers-storage.  Defaults to `registry`.
158    #[clap(long, default_value = "registry")]
159    pub(crate) transport: String,
160
161    /// This argument is deprecated and does nothing.
162    #[clap(long, hide = true)]
163    pub(crate) no_signature_verification: bool,
164
165    /// This is the inverse of the previous `--target-no-signature-verification` (which is now
166    /// a no-op).
167    ///
168    /// Enabling this option enforces that `/etc/containers/policy.json` includes a
169    /// default policy which requires signatures.
170    #[clap(long)]
171    pub(crate) enforce_container_sigpolicy: bool,
172
173    /// Don't create a new deployment, but directly mutate the booted state.
174    /// This is hidden because it's not something we generally expect to be done,
175    /// but this can be used in e.g. Anaconda %post to fixup
176    #[clap(long, hide = true)]
177    pub(crate) mutate_in_place: bool,
178
179    /// Retain reference to currently booted image
180    #[clap(long)]
181    pub(crate) retain: bool,
182
183    /// Use unified storage path to pull images (experimental)
184    ///
185    /// When enabled, this uses bootc's container storage (/usr/lib/bootc/storage) to pull
186    /// the image first, then imports it from there. This is the same approach used for
187    /// logically bound images.
188    #[clap(long = "experimental-unified-storage", hide = true)]
189    pub(crate) unified_storage_exp: bool,
190
191    /// Target image to use for the next boot.
192    pub(crate) target: String,
193
194    #[clap(flatten)]
195    pub(crate) progress: ProgressOptions,
196}
197
198/// Options controlling rollback
199#[derive(Debug, Parser, PartialEq, Eq)]
200pub(crate) struct RollbackOpts {
201    /// Restart or reboot into the rollback image.
202    ///
203    /// Currently, this option always reboots.  In the future this command
204    /// will detect the case where no kernel changes are queued, and perform
205    /// a userspace-only restart.
206    #[clap(long)]
207    pub(crate) apply: bool,
208
209    /// Configure soft reboot behavior.
210    ///
211    /// 'required' fails if soft reboot unavailable, 'auto' falls back to regular reboot.
212    #[clap(long = "soft-reboot")]
213    pub(crate) soft_reboot: Option<SoftRebootMode>,
214}
215
216/// Perform an edit operation
217#[derive(Debug, Parser, PartialEq, Eq)]
218pub(crate) struct EditOpts {
219    /// Use filename to edit system specification
220    #[clap(long, short = 'f')]
221    pub(crate) filename: Option<String>,
222
223    /// Don't display progress
224    #[clap(long)]
225    pub(crate) quiet: bool,
226}
227
228#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
229#[clap(rename_all = "lowercase")]
230pub(crate) enum OutputFormat {
231    /// Output in Human Readable format.
232    HumanReadable,
233    /// Output in YAML format.
234    Yaml,
235    /// Output in JSON format.
236    Json,
237}
238
239#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
240#[clap(rename_all = "lowercase")]
241pub(crate) enum SoftRebootMode {
242    /// Require a soft reboot; fail if not possible
243    Required,
244    /// Automatically use soft reboot if possible, otherwise use regular reboot
245    Auto,
246}
247
248/// Perform an status operation
249#[derive(Debug, Parser, PartialEq, Eq)]
250pub(crate) struct StatusOpts {
251    /// Output in JSON format.
252    ///
253    /// Superceded by the `format` option.
254    #[clap(long, hide = true)]
255    pub(crate) json: bool,
256
257    /// The output format.
258    #[clap(long)]
259    pub(crate) format: Option<OutputFormat>,
260
261    /// The desired format version. There is currently one supported
262    /// version, which is exposed as both `0` and `1`. Pass this
263    /// option to explicitly request it; it is possible that another future
264    /// version 2 or newer will be supported in the future.
265    #[clap(long)]
266    pub(crate) format_version: Option<u32>,
267
268    /// Only display status for the booted deployment.
269    #[clap(long)]
270    pub(crate) booted: bool,
271
272    /// Include additional fields in human readable format.
273    #[clap(long, short = 'v')]
274    pub(crate) verbose: bool,
275}
276
277/// Add a transient overlayfs on /usr
278#[derive(Debug, Parser, PartialEq, Eq)]
279pub(crate) struct UsrOverlayOpts {
280    /// Mount the overlayfs as read-only. A read-only overlayfs is useful since it may be remounted
281    /// as read/write in a private mount namespace and written to while the mount point remains
282    /// read-only to the rest of the system.
283    #[clap(long)]
284    pub(crate) read_only: bool,
285}
286
287#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
288pub(crate) enum InstallOpts {
289    /// Install to the target block device.
290    ///
291    /// This command must be invoked inside of the container, which will be
292    /// installed. The container must be run in `--privileged` mode, and hence
293    /// will be able to see all block devices on the system.
294    ///
295    /// The default storage layout uses the root filesystem type configured
296    /// in the container image, alongside any required system partitions such as
297    /// the EFI system partition. Use `install to-filesystem` for anything more
298    /// complex such as RAID, LVM, LUKS etc.
299    #[cfg(feature = "install-to-disk")]
300    ToDisk(crate::install::InstallToDiskOpts),
301    /// Install to an externally created filesystem structure.
302    ///
303    /// In this variant of installation, the root filesystem alongside any necessary
304    /// platform partitions (such as the EFI system partition) are prepared and mounted by an
305    /// external tool or script. The root filesystem is currently expected to be empty
306    /// by default.
307    ToFilesystem(crate::install::InstallToFilesystemOpts),
308    /// Install to the host root filesystem.
309    ///
310    /// This is a variant of `install to-filesystem` that is designed to install "alongside"
311    /// the running host root filesystem. Currently, the host root filesystem's `/boot` partition
312    /// will be wiped, but the content of the existing root will otherwise be retained, and will
313    /// need to be cleaned up if desired when rebooted into the new root.
314    ToExistingRoot(crate::install::InstallToExistingRootOpts),
315    /// Nondestructively create a fresh installation state inside an existing bootc system.
316    ///
317    /// This is a nondestructive variant of `install to-existing-root` that works only inside
318    /// an existing bootc system.
319    #[clap(hide = true)]
320    Reset(crate::install::InstallResetOpts),
321    /// Execute this as the penultimate step of an installation using `install to-filesystem`.
322    ///
323    Finalize {
324        /// Path to the mounted root filesystem.
325        root_path: Utf8PathBuf,
326    },
327    /// Intended for use in environments that are performing an ostree-based installation, not bootc.
328    ///
329    /// In this scenario the installation may be missing bootc specific features such as
330    /// kernel arguments, logically bound images and more. This command can be used to attempt
331    /// to reconcile. At the current time, the only tested environment is Anaconda using `ostreecontainer`
332    /// and it is recommended to avoid usage outside of that environment. Instead, ensure your
333    /// code is using `bootc install to-filesystem` from the start.
334    EnsureCompletion {},
335    /// Output JSON to stdout that contains the merged installation configuration
336    /// as it may be relevant to calling processes using `install to-filesystem`
337    /// that in particular want to discover the desired root filesystem type from the container image.
338    ///
339    /// At the current time, the only output key is `root-fs-type` which is a string-valued
340    /// filesystem name suitable for passing to `mkfs.$type`.
341    PrintConfiguration(crate::install::InstallPrintConfigurationOpts),
342}
343
344/// Subcommands which can be executed as part of a container build.
345#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
346pub(crate) enum ContainerOpts {
347    /// Output information about the container image.
348    ///
349    /// By default, a human-readable summary is output. Use --json or --format
350    /// to change the output format.
351    Inspect {
352        /// Operate on the provided rootfs.
353        #[clap(long, default_value = "/")]
354        rootfs: Utf8PathBuf,
355
356        /// Output in JSON format.
357        #[clap(long)]
358        json: bool,
359
360        /// The output format.
361        #[clap(long, conflicts_with = "json")]
362        format: Option<OutputFormat>,
363    },
364    /// Perform relatively inexpensive static analysis checks as part of a container
365    /// build.
366    ///
367    /// This is intended to be invoked via e.g. `RUN bootc container lint` as part
368    /// of a build process; it will error if any problems are detected.
369    Lint {
370        /// Operate on the provided rootfs.
371        #[clap(long, default_value = "/")]
372        rootfs: Utf8PathBuf,
373
374        /// Make warnings fatal.
375        #[clap(long)]
376        fatal_warnings: bool,
377
378        /// Instead of executing the lints, just print all available lints.
379        /// At the current time, this will output in YAML format because it's
380        /// reasonably human friendly. However, there is no commitment to
381        /// maintaining this exact format; do not parse it via code or scripts.
382        #[clap(long)]
383        list: bool,
384
385        /// Skip checking the targeted lints, by name. Use `--list` to discover the set
386        /// of available lints.
387        ///
388        /// Example: --skip nonempty-boot --skip baseimage-root
389        #[clap(long)]
390        skip: Vec<String>,
391
392        /// Don't truncate the output. By default, only a limited number of entries are
393        /// shown for each lint, followed by a count of remaining entries.
394        #[clap(long)]
395        no_truncate: bool,
396    },
397    /// Output the bootable composefs digest for a directory.
398    #[clap(hide = true)]
399    ComputeComposefsDigest {
400        /// Path to the filesystem root
401        #[clap(default_value = "/target")]
402        path: Utf8PathBuf,
403
404        /// Additionally generate a dumpfile written to the target path
405        #[clap(long)]
406        write_dumpfile_to: Option<Utf8PathBuf>,
407    },
408    /// Output the bootable composefs digest from container storage.
409    #[clap(hide = true)]
410    ComputeComposefsDigestFromStorage {
411        /// Additionally generate a dumpfile written to the target path
412        #[clap(long)]
413        write_dumpfile_to: Option<Utf8PathBuf>,
414
415        /// Identifier for image; if not provided, the running image will be used.
416        image: Option<String>,
417    },
418    /// Build a Unified Kernel Image (UKI) using ukify.
419    ///
420    /// This command computes the necessary arguments from the container image
421    /// (kernel, initrd, cmdline, os-release) and invokes ukify with them.
422    /// Any additional arguments after `--` are passed through to ukify unchanged.
423    ///
424    /// Example:
425    ///   bootc container ukify --rootfs /target -- --output /output/uki.efi
426    Ukify {
427        /// Operate on the provided rootfs.
428        #[clap(long, default_value = "/")]
429        rootfs: Utf8PathBuf,
430
431        /// Additional kernel arguments to append to the cmdline.
432        /// Can be specified multiple times.
433        /// This is a temporary workaround and will be removed.
434        #[clap(long = "karg", hide = true)]
435        kargs: Vec<String>,
436
437        /// Make fs-verity validation optional in case the filesystem doesn't support it
438        #[clap(long)]
439        allow_missing_verity: bool,
440
441        /// Write a dumpfile to this path
442        #[clap(long)]
443        write_dumpfile_to: Option<Utf8PathBuf>,
444
445        /// Additional arguments to pass to ukify (after `--`).
446        #[clap(last = true)]
447        args: Vec<OsString>,
448    },
449    /// Export container filesystem as a tar archive.
450    ///
451    /// This command exports the container filesystem in a bootable format with proper
452    /// SELinux labeling. The output is written to stdout by default or to a specified file.
453    ///
454    /// Example:
455    ///   bootc container export /target > output.tar
456    #[clap(hide = true)]
457    Export {
458        /// Format for export output
459        #[clap(long, default_value = "tar")]
460        format: ExportFormat,
461
462        /// Output file (defaults to stdout)
463        #[clap(long, short = 'o')]
464        output: Option<Utf8PathBuf>,
465
466        /// Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility.
467        /// This is useful for installers that expect the kernel in /boot.
468        #[clap(long)]
469        kernel_in_boot: bool,
470
471        /// Disable SELinux labeling in the exported archive.
472        #[clap(long)]
473        disable_selinux: bool,
474
475        /// Path to the container filesystem root
476        target: Utf8PathBuf,
477    },
478}
479
480#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
481pub(crate) enum ExportFormat {
482    /// Export as tar archive
483    Tar,
484}
485
486/// Subcommands which operate on images.
487#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
488pub(crate) enum ImageCmdOpts {
489    /// Wrapper for `podman image list` in bootc storage.
490    List {
491        #[clap(allow_hyphen_values = true)]
492        args: Vec<OsString>,
493    },
494    /// Wrapper for `podman image build` in bootc storage.
495    Build {
496        #[clap(allow_hyphen_values = true)]
497        args: Vec<OsString>,
498    },
499    /// Pull image(s) into bootc storage.
500    Pull {
501        /// Image references to pull (e.g. quay.io/myorg/myimage:latest)
502        #[clap(required = true)]
503        images: Vec<String>,
504    },
505    /// Wrapper for `podman image push` in bootc storage.
506    Push {
507        #[clap(allow_hyphen_values = true)]
508        args: Vec<OsString>,
509    },
510}
511
512#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
513#[serde(rename_all = "kebab-case")]
514pub(crate) enum ImageListType {
515    /// List all images
516    #[default]
517    All,
518    /// List only logically bound images
519    Logical,
520    /// List only host images
521    Host,
522}
523
524impl std::fmt::Display for ImageListType {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        self.to_possible_value().unwrap().get_name().fmt(f)
527    }
528}
529
530#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
531#[serde(rename_all = "kebab-case")]
532pub(crate) enum ImageListFormat {
533    /// Human readable table format
534    #[default]
535    Table,
536    /// JSON format
537    Json,
538}
539impl std::fmt::Display for ImageListFormat {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        self.to_possible_value().unwrap().get_name().fmt(f)
542    }
543}
544
545/// Subcommands which operate on images.
546#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
547pub(crate) enum ImageOpts {
548    /// List fetched images stored in the bootc storage.
549    ///
550    /// Note that these are distinct from images stored via e.g. `podman`.
551    List {
552        /// Type of image to list
553        #[clap(long = "type")]
554        #[arg(default_value_t)]
555        list_type: ImageListType,
556        #[clap(long = "format")]
557        #[arg(default_value_t)]
558        list_format: ImageListFormat,
559    },
560    /// Copy a container image from the bootc storage to `containers-storage:`.
561    ///
562    /// The source and target are both optional; if both are left unspecified,
563    /// via a simple invocation of `bootc image copy-to-storage`, then the default is to
564    /// push the currently booted image to `containers-storage` (as used by podman, etc.)
565    /// and tagged with the image name `localhost/bootc`,
566    ///
567    /// ## Copying a non-default container image
568    ///
569    /// It is also possible to copy an image other than the currently booted one by
570    /// specifying `--source`.
571    ///
572    /// ## Pulling images
573    ///
574    /// At the current time there is no explicit support for pulling images other than indirectly
575    /// via e.g. `bootc switch` or `bootc upgrade`.
576    CopyToStorage {
577        #[clap(long)]
578        /// The source image; if not specified, the booted image will be used.
579        source: Option<String>,
580
581        #[clap(long)]
582        /// The destination; if not specified, then the default is to push to `containers-storage:localhost/bootc`;
583        /// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds.
584        target: Option<String>,
585    },
586    /// Re-pull the currently booted image into the bootc-owned container storage.
587    ///
588    /// This onboards the system to the unified storage path so that future
589    /// upgrade/switch operations can read from the bootc storage directly.
590    SetUnified,
591    /// Copy a container image from the default `containers-storage:` to the bootc-owned container storage.
592    PullFromDefaultStorage {
593        /// The image to pull
594        image: String,
595    },
596    /// Wrapper for selected `podman image` subcommands in bootc storage.
597    #[clap(subcommand)]
598    Cmd(ImageCmdOpts),
599}
600
601#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)]
602pub(crate) enum SchemaType {
603    Host,
604    Progress,
605}
606
607/// Options for consistency checking
608#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
609pub(crate) enum FsverityOpts {
610    /// Measure the fsverity digest of the target file.
611    Measure {
612        /// Path to file
613        path: Utf8PathBuf,
614    },
615    /// Enable fsverity on the target file.
616    Enable {
617        /// Ptah to file
618        path: Utf8PathBuf,
619    },
620}
621
622/// Hidden, internal only options
623#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
624pub(crate) enum InternalsOpts {
625    SystemdGenerator {
626        normal_dir: Utf8PathBuf,
627        #[allow(dead_code)]
628        early_dir: Option<Utf8PathBuf>,
629        #[allow(dead_code)]
630        late_dir: Option<Utf8PathBuf>,
631    },
632    FixupEtcFstab,
633    /// Should only be used by `make update-generated`
634    PrintJsonSchema {
635        #[clap(long)]
636        of: SchemaType,
637    },
638    #[clap(subcommand)]
639    Fsverity(FsverityOpts),
640    /// Perform consistency checking.
641    Fsck,
642    /// Perform cleanup actions
643    Cleanup,
644    Relabel {
645        #[clap(long)]
646        /// Relabel using this path as root
647        as_path: Option<Utf8PathBuf>,
648
649        /// Relabel this path
650        path: Utf8PathBuf,
651    },
652    /// Relabel the overlay mount point inodes after SELinux policy load.
653    /// Called by the generated bootc-early-overlay-relabel unit.
654    RelabelOverlayMountpoints,
655    /// Proxy frontend for the `ostree-ext` CLI.
656    OstreeExt {
657        #[clap(allow_hyphen_values = true)]
658        args: Vec<OsString>,
659    },
660    /// Proxy frontend for the `cfsctl` CLI
661    Cfs {
662        #[clap(allow_hyphen_values = true)]
663        args: Vec<OsString>,
664    },
665    /// Proxy frontend for the legacy `ostree container` CLI.
666    OstreeContainer {
667        #[clap(allow_hyphen_values = true)]
668        args: Vec<OsString>,
669    },
670    /// Ensure that a composefs repository is initialized
671    TestComposefs,
672    /// Loopback device cleanup helper (internal use only)
673    LoopbackCleanupHelper {
674        /// Device path to clean up
675        #[clap(long)]
676        device: String,
677    },
678    /// Test loopback device allocation and cleanup (internal use only)
679    AllocateCleanupLoopback {
680        /// File path to create loopback device for
681        #[clap(long)]
682        file_path: Utf8PathBuf,
683    },
684    /// Invoked from ostree-ext to complete an installation.
685    BootcInstallCompletion {
686        /// Path to the sysroot
687        sysroot: Utf8PathBuf,
688
689        // The stateroot
690        stateroot: String,
691    },
692    /// Initiate a reboot the same way we would after --apply; intended
693    /// primarily for testing.
694    Reboot,
695    #[cfg(feature = "rhsm")]
696    /// Publish subscription-manager facts to /etc/rhsm/facts/bootc.facts
697    PublishRhsmFacts,
698    /// Internal command for testing etc-diff/etc-merge
699    DirDiff {
700        /// Directory path to the pristine_etc
701        pristine_etc: Utf8PathBuf,
702        /// Directory path to the current_etc
703        current_etc: Utf8PathBuf,
704        /// Directory path to the new_etc
705        new_etc: Utf8PathBuf,
706        /// Whether to perform the three way merge or not
707        #[clap(long)]
708        merge: bool,
709    },
710    #[cfg(feature = "docgen")]
711    /// Dump CLI structure as JSON for documentation generation
712    DumpCliJson,
713    PrepSoftReboot {
714        #[clap(required_unless_present = "reset")]
715        deployment: Option<String>,
716        #[clap(long, conflicts_with = "reset")]
717        reboot: bool,
718        #[clap(long, conflicts_with = "reboot")]
719        reset: bool,
720    },
721    ComposefsGC {
722        #[clap(long)]
723        dry_run: bool,
724        /// Exit with an error if GC would remove any objects or prune any symlinks.
725        /// Implies `--dry-run`.  Intended for use in tests and health-checks.
726        #[clap(long)]
727        assert_no_op: bool,
728        /// Prune the composefs repository in addition to boot binaries
729        #[clap(long)]
730        prune_repo: bool,
731    },
732    /// Block device inspection tools.
733    #[clap(subcommand)]
734    Blockdev(BlockdevOpts),
735}
736
737/// Subcommands for `bootc internals blockdev`.
738#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
739pub(crate) enum BlockdevOpts {
740    /// List block device information (as JSON) for a given device path.
741    ///
742    /// This runs lsblk and backfills any missing partition metadata,
743    /// including falling back to `blkid -p` when the udev database
744    /// is unavailable.
745    Ls {
746        /// Block device path (e.g. /dev/vda)
747        device: Utf8PathBuf,
748    },
749    /// List block device information (as JSON) for the device backing a filesystem.
750    ///
751    /// Takes a directory path, finds the underlying block device, and
752    /// outputs its full device tree with backfilled metadata.
753    LsFilesystem {
754        /// Filesystem path (e.g. /sysroot)
755        path: Utf8PathBuf,
756    },
757}
758
759/// Options for the `set-options-for-source` subcommand.
760#[derive(Debug, Parser, PartialEq, Eq)]
761pub(crate) struct SetOptionsForSourceOpts {
762    /// The name of the source that owns these kernel arguments.
763    ///
764    /// Must contain only alphanumeric characters, hyphens, or underscores.
765    /// Examples: "tuned", "admin", "bootc-kargs-d"
766    #[clap(long)]
767    pub(crate) source: String,
768
769    /// The kernel arguments to set for this source.
770    ///
771    /// If not provided, the source is removed and its options are
772    /// dropped from the merged `options` line.
773    #[clap(long)]
774    pub(crate) options: Option<String>,
775}
776
777/// Operations on Boot Loader Specification (BLS) entries.
778///
779/// These commands support managing kernel arguments from multiple independent
780/// sources (e.g., TuneD, admin, bootc kargs.d) by tracking argument ownership
781/// via `x-options-source-<name>` extension keys in BLS config files.
782///
783/// See <https://github.com/ostreedev/ostree/pull/3570>
784#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
785pub(crate) enum LoaderEntriesOpts {
786    /// Set or update the kernel arguments owned by a specific source.
787    ///
788    /// Each source's arguments are tracked via `x-options-source-<name>`
789    /// keys in BLS config files. The `options` line is recomputed as the
790    /// merge of all tracked sources plus any untracked (pre-existing) options.
791    ///
792    /// This stages a new deployment with the updated kernel arguments.
793    ///
794    /// ## Examples
795    ///
796    /// Add TuneD kernel arguments:
797    /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=1-3 nohz_full=1-3"
798    ///
799    /// Update TuneD kernel arguments:
800    /// bootc loader-entries set-options-for-source --source tuned --options "isolcpus=0-7"
801    ///
802    /// Remove TuneD kernel arguments:
803    /// bootc loader-entries set-options-for-source --source tuned
804    SetOptionsForSource(SetOptionsForSourceOpts),
805}
806
807#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
808pub(crate) enum StateOpts {
809    /// Remove all ostree deployments from this system
810    WipeOstree,
811}
812
813impl InternalsOpts {
814    /// The name of the binary we inject into /usr/lib/systemd/system-generators
815    const GENERATOR_BIN: &'static str = "bootc-systemd-generator";
816}
817
818/// Deploy and transactionally in-place with bootable container images.
819///
820/// The `bootc` project currently uses ostree-containers as a backend
821/// to support a model of bootable container images.  Once installed,
822/// whether directly via `bootc install` (executed as part of a container)
823/// or via another mechanism such as an OS installer tool, further
824/// updates can be pulled and `bootc upgrade`.
825#[derive(Debug, Parser, PartialEq, Eq)]
826#[clap(name = "bootc")]
827#[clap(rename_all = "kebab-case")]
828#[clap(version,long_version=clap::crate_version!())]
829#[allow(clippy::large_enum_variant)]
830pub(crate) enum Opt {
831    /// Download and queue an updated container image to apply.
832    ///
833    /// This does not affect the running system; updates operate in an "A/B" style by default.
834    ///
835    /// A queued update is visible as `staged` in `bootc status`.
836    ///
837    /// Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`.
838    /// There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting)
839    /// if the system has changed.
840    ///
841    /// However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply`
842    /// do *not* automatically apply the update in addition.
843    #[clap(alias = "update")]
844    Upgrade(UpgradeOpts),
845    /// Target a new container image reference to boot.
846    ///
847    /// This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference
848    /// instead.
849    ///
850    /// ## Usage
851    ///
852    /// A common pattern is to have a management agent control operating system updates via container image tags;
853    /// for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines
854    /// are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`.
855    Switch(SwitchOpts),
856    /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot,
857    /// and the current will become rollback.  If there is a `staged` entry (an unapplied, queued upgrade)
858    /// then it will be discarded.
859    ///
860    /// Note that absent any additional control logic, if there is an active agent doing automated upgrades
861    /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the
862    /// change here may be reverted.  It's recommended to only use this in concert with an agent that
863    /// is in active control.
864    ///
865    /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in
866    /// order to detect a rollback invocation.
867    #[command(after_help = indoc! {r#"
868        Note on Rollbacks and the `/etc` Directory:
869
870        When you perform a rollback (e.g., with `bootc rollback`), any
871        changes made to files in the `/etc` directory won't carry over
872        to the rolled-back deployment.  The `/etc` files will revert
873        to their state from that previous deployment instead.
874
875        This is because `bootc rollback` just reorders the existing
876        deployments. It doesn't create new deployments. The `/etc`
877        merges happen when new deployments are created.
878    "#})]
879    Rollback(RollbackOpts),
880    /// Apply full changes to the host specification.
881    ///
882    /// This command operates very similarly to `kubectl apply`; if invoked interactively,
883    /// then the current host specification will be presented in the system default `$EDITOR`
884    /// for interactive changes.
885    ///
886    /// It is also possible to directly provide new contents via `bootc edit --filename`.
887    ///
888    /// Only changes to the `spec` section are honored.
889    Edit(EditOpts),
890    /// Display status.
891    ///
892    /// Shows bootc system state. Outputs YAML by default, human-readable if terminal detected.
893    Status(StatusOpts),
894    /// Add a transient overlayfs on `/usr`.
895    ///
896    /// Allows temporary package installation that will be discarded on reboot.
897    #[clap(alias = "usroverlay")]
898    UsrOverlay(UsrOverlayOpts),
899    /// Install the running container to a target.
900    ///
901    /// Takes a container image and installs it to disk in a bootable format.
902    #[clap(subcommand)]
903    Install(InstallOpts),
904    /// Operations which can be executed as part of a container build.
905    #[clap(subcommand)]
906    Container(ContainerOpts),
907    /// Operations on container images.
908    ///
909    /// Stability: This interface may change in the future.
910    #[clap(subcommand, hide = true)]
911    Image(ImageOpts),
912    /// Operations on Boot Loader Specification (BLS) entries.
913    ///
914    /// Manage kernel arguments from multiple independent sources.
915    #[clap(subcommand)]
916    LoaderEntries(LoaderEntriesOpts),
917    /// Execute the given command in the host mount namespace
918    #[clap(hide = true)]
919    ExecInHostMountNamespace {
920        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
921        args: Vec<OsString>,
922    },
923    /// Modify the state of the system
924    #[clap(hide = true)]
925    #[clap(subcommand)]
926    State(StateOpts),
927    #[clap(subcommand)]
928    #[clap(hide = true)]
929    Internals(InternalsOpts),
930    ComposefsFinalizeStaged,
931    /// Diff current /etc configuration versus default
932    #[clap(hide = true)]
933    ConfigDiff,
934    /// Generate shell completion script for supported shells.
935    ///
936    /// Example: `bootc completion bash` prints a bash completion script to stdout.
937    #[clap(hide = true)]
938    Completion {
939        /// Shell type to generate (bash, zsh, fish)
940        #[clap(value_enum)]
941        shell: clap_complete::aot::Shell,
942    },
943    #[clap(hide = true)]
944    DeleteDeployment {
945        depl_id: String,
946    },
947}
948
949/// Ensure we've entered a mount namespace, so that we can remount
950/// `/sysroot` read-write
951/// TODO use <https://github.com/ostreedev/ostree/pull/2779> once
952/// we can depend on a new enough ostree
953#[context("Ensuring mountns")]
954pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> {
955    let uid = rustix::process::getuid();
956    if !uid.is_root() {
957        tracing::debug!("Not root, assuming no need to unshare");
958        return Ok(());
959    }
960    let recurse_env = "_ostree_unshared";
961    let ns_pid1 = std::fs::read_link("/proc/1/ns/mnt").context("Reading /proc/1/ns/mnt")?;
962    let ns_self = std::fs::read_link("/proc/self/ns/mnt").context("Reading /proc/self/ns/mnt")?;
963    // If we already appear to be in a mount namespace, or we're already pid1, we're done
964    if ns_pid1 != ns_self {
965        tracing::debug!("Already in a mount namespace");
966        return Ok(());
967    }
968    if std::env::var_os(recurse_env).is_some() {
969        let am_pid1 = rustix::process::getpid().is_init();
970        if am_pid1 {
971            tracing::debug!("We are pid 1");
972            return Ok(());
973        } else {
974            anyhow::bail!("Failed to unshare mount namespace");
975        }
976    }
977    bootc_utils::reexec::reexec_with_guardenv(recurse_env, &["unshare", "-m", "--"])
978}
979
980/// Load global storage state, expecting that we're booted into a bootc system.
981/// This prepares the process for write operations (re-exec, mount namespace, etc).
982#[context("Initializing storage")]
983pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
984    let env = crate::store::Environment::detect()?;
985    // Always call prepare_for_write() for write operations - it checks
986    // for container, root privileges, mount namespace setup, etc.
987    prepare_for_write()?;
988    let r = BootedStorage::new(env)
989        .await?
990        .ok_or_else(|| anyhow!("System not booted via bootc"))?;
991    Ok(r)
992}
993
994#[context("Querying root privilege")]
995pub(crate) fn require_root(is_container: bool) -> Result<()> {
996    ensure!(
997        rustix::process::getuid().is_root(),
998        if is_container {
999            "The user inside the container from which you are running this command must be root"
1000        } else {
1001            "This command must be executed as the root user"
1002        }
1003    );
1004
1005    ensure!(
1006        rustix::thread::capability_is_in_bounding_set(rustix::thread::CapabilitySet::SYS_ADMIN)?,
1007        if is_container {
1008            "The container must be executed with full privileges (e.g. --privileged flag)"
1009        } else {
1010            "This command requires full root privileges (CAP_SYS_ADMIN)"
1011        }
1012    );
1013
1014    tracing::trace!("Verified uid 0 with CAP_SYS_ADMIN");
1015
1016    Ok(())
1017}
1018
1019/// Check if a deployment has soft reboot capability
1020fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool {
1021    deployment.map(|d| d.soft_reboot_capable).unwrap_or(false)
1022}
1023
1024/// Prepare a soft reboot for the given deployment
1025#[context("Preparing soft reboot")]
1026fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> Result<()> {
1027    let cancellable = ostree::gio::Cancellable::NONE;
1028    sysroot
1029        .deployment_set_soft_reboot(deployment, false, cancellable)
1030        .context("Failed to prepare soft-reboot")?;
1031    Ok(())
1032}
1033
1034/// Handle soft reboot based on the configured mode
1035#[context("Handling soft reboot")]
1036fn handle_soft_reboot<F>(
1037    soft_reboot_mode: Option<SoftRebootMode>,
1038    entry: Option<&crate::spec::BootEntry>,
1039    deployment_type: &str,
1040    execute_soft_reboot: F,
1041) -> Result<()>
1042where
1043    F: FnOnce() -> Result<()>,
1044{
1045    let Some(mode) = soft_reboot_mode else {
1046        return Ok(());
1047    };
1048
1049    let can_soft_reboot = has_soft_reboot_capability(entry);
1050    match mode {
1051        SoftRebootMode::Required => {
1052            if can_soft_reboot {
1053                execute_soft_reboot()?;
1054            } else {
1055                anyhow::bail!(
1056                    "Soft reboot was required but {} deployment is not soft-reboot capable",
1057                    deployment_type
1058                );
1059            }
1060        }
1061        SoftRebootMode::Auto => {
1062            if can_soft_reboot {
1063                execute_soft_reboot()?;
1064            }
1065        }
1066    }
1067    Ok(())
1068}
1069
1070/// Handle soft reboot for staged deployments (used by upgrade and switch)
1071#[context("Handling staged soft reboot")]
1072fn handle_staged_soft_reboot(
1073    booted_ostree: &BootedOstree<'_>,
1074    soft_reboot_mode: Option<SoftRebootMode>,
1075    host: &crate::spec::Host,
1076) -> Result<()> {
1077    handle_soft_reboot(
1078        soft_reboot_mode,
1079        host.status.staged.as_ref(),
1080        "staged",
1081        || soft_reboot_staged(booted_ostree.sysroot),
1082    )
1083}
1084
1085/// Perform a soft reboot for a staged deployment
1086#[context("Soft reboot staged deployment")]
1087fn soft_reboot_staged(sysroot: &SysrootLock) -> Result<()> {
1088    println!("Staged deployment is soft-reboot capable, preparing for soft-reboot...");
1089
1090    let deployments_list = sysroot.deployments();
1091    let staged_deployment = deployments_list
1092        .iter()
1093        .find(|d| d.is_staged())
1094        .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?;
1095
1096    prepare_soft_reboot(sysroot, staged_deployment)?;
1097    Ok(())
1098}
1099
1100/// Perform a soft reboot for a rollback deployment
1101#[context("Soft reboot rollback deployment")]
1102fn soft_reboot_rollback(booted_ostree: &BootedOstree<'_>) -> Result<()> {
1103    println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot...");
1104
1105    let deployments_list = booted_ostree.sysroot.deployments();
1106    let target_deployment = deployments_list
1107        .first()
1108        .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?;
1109
1110    prepare_soft_reboot(booted_ostree.sysroot, target_deployment)
1111}
1112
1113/// A few process changes that need to be made for writing.
1114/// IMPORTANT: This may end up re-executing the current process,
1115/// so anything that happens before this should be idempotent.
1116#[context("Preparing for write")]
1117pub(crate) fn prepare_for_write() -> Result<()> {
1118    use std::sync::atomic::{AtomicBool, Ordering};
1119
1120    // This is intending to give "at most once" semantics to this
1121    // function. We should never invoke this from multiple threads
1122    // at the same time, but verifying "on main thread" is messy.
1123    // Yes, using SeqCst is likely overkill, but there is nothing perf
1124    // sensitive about this.
1125    static ENTERED: AtomicBool = AtomicBool::new(false);
1126    if ENTERED.load(Ordering::SeqCst) {
1127        return Ok(());
1128    }
1129    if ostree_ext::container_utils::running_in_container() {
1130        anyhow::bail!("Detected container; this command requires a booted host system.");
1131    }
1132    crate::cli::require_root(false)?;
1133    ensure_self_unshared_mount_namespace()?;
1134    if crate::lsm::selinux_enabled()? && !crate::lsm::selinux_ensure_install()? {
1135        tracing::debug!("Do not have install_t capabilities");
1136    }
1137    ENTERED.store(true, Ordering::SeqCst);
1138    Ok(())
1139}
1140
1141/// Implementation of the `bootc upgrade` CLI command.
1142#[context("Upgrading")]
1143async fn upgrade(
1144    opts: UpgradeOpts,
1145    storage: &Storage,
1146    booted_ostree: &BootedOstree<'_>,
1147) -> Result<()> {
1148    let repo = &booted_ostree.repo();
1149
1150    let host = crate::status::get_status(booted_ostree)?.1;
1151    let current_image = host.spec.image.as_ref();
1152
1153    // Handle --tag: derive target from current image + new tag
1154    let derived_image = if let Some(ref tag) = opts.tag {
1155        let image = current_image.ok_or_else(|| {
1156            anyhow::anyhow!("--tag requires a booted image with a specified source")
1157        })?;
1158        Some(image.with_tag(tag)?)
1159    } else {
1160        None
1161    };
1162
1163    let imgref = derived_image.as_ref().or(current_image);
1164    let prog: ProgressWriter = opts.progress.try_into()?;
1165
1166    // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
1167    if imgref.is_none() {
1168        let booted_incompatible = host.status.booted.as_ref().is_some_and(|b| b.incompatible);
1169
1170        let staged_incompatible = host.status.staged.as_ref().is_some_and(|b| b.incompatible);
1171
1172        if booted_incompatible || staged_incompatible {
1173            return Err(anyhow::anyhow!(
1174                "Deployment contains local rpm-ostree modifications; cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the modifications."
1175            ));
1176        }
1177    }
1178
1179    let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
1180    // Use the derived image reference (if --tag was specified) instead of the spec's image
1181    let spec = RequiredHostSpec { image: imgref };
1182    let booted_image = host
1183        .status
1184        .booted
1185        .as_ref()
1186        .map(|b| b.query_image(repo))
1187        .transpose()?
1188        .flatten();
1189    // Find the currently queued digest, if any before we pull
1190    let staged = host.status.staged.as_ref();
1191    let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
1192    let mut changed = false;
1193
1194    // Handle --from-downloaded: unlock existing staged deployment without fetching from image source
1195    if opts.from_downloaded {
1196        let ostree = storage.get_ostree()?;
1197        let staged_deployment = ostree
1198            .staged_deployment()
1199            .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
1200
1201        if staged_deployment.is_finalization_locked() {
1202            ostree.change_finalization(&staged_deployment)?;
1203            println!("Staged deployment will now be applied on reboot");
1204        } else {
1205            println!("Staged deployment is already set to apply on reboot");
1206        }
1207
1208        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1209        if opts.apply {
1210            crate::reboot::reboot()?;
1211        }
1212        return Ok(());
1213    }
1214
1215    // Ensure the bootc storage directory is initialized; the --check path
1216    // needs this for update_mtime() and the non-check path needs it for
1217    // unified pull detection.
1218    let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?;
1219
1220    if opts.check {
1221        let ostree_imgref = imgref.clone().into();
1222        let mut imp =
1223            crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment))
1224                .await?;
1225        match imp.prepare().await? {
1226            PrepareResult::AlreadyPresent(_) => {
1227                println!("No changes in: {ostree_imgref:#}");
1228            }
1229            PrepareResult::Ready(r) => {
1230                crate::deploy::check_bootc_label(&r.config);
1231                println!("Update available for: {ostree_imgref:#}");
1232                if let Some(version) = r.version() {
1233                    println!("  Version: {version}");
1234                }
1235                println!("  Digest: {}", r.manifest_digest);
1236                changed = true;
1237                if let Some(previous_image) = booted_image.as_ref() {
1238                    let diff =
1239                        ostree_container::ManifestDiff::new(&previous_image.manifest, &r.manifest);
1240                    diff.print();
1241                }
1242            }
1243        }
1244    } else {
1245        let fetched = if use_unified {
1246            crate::deploy::pull_unified(
1247                repo,
1248                imgref,
1249                None,
1250                opts.quiet,
1251                prog.clone(),
1252                storage,
1253                Some(&booted_ostree.deployment),
1254            )
1255            .await?
1256        } else {
1257            crate::deploy::pull(
1258                repo,
1259                imgref,
1260                None,
1261                opts.quiet,
1262                prog.clone(),
1263                Some(&booted_ostree.deployment),
1264            )
1265            .await?
1266        };
1267        let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
1268        let fetched_digest = &fetched.manifest_digest;
1269        tracing::debug!("staged: {staged_digest:?}");
1270        tracing::debug!("fetched: {fetched_digest}");
1271        let staged_unchanged = staged_digest
1272            .as_ref()
1273            .map(|d| d == fetched_digest)
1274            .unwrap_or_default();
1275        let booted_unchanged = booted_image
1276            .as_ref()
1277            .map(|img| &img.manifest_digest == fetched_digest)
1278            .unwrap_or_default();
1279        if staged_unchanged {
1280            let staged_deployment = storage.get_ostree()?.staged_deployment();
1281            let mut download_only_changed = false;
1282
1283            if let Some(staged) = staged_deployment {
1284                // Handle download-only mode based on flags
1285                if opts.download_only {
1286                    // --download-only: set download-only mode
1287                    if !staged.is_finalization_locked() {
1288                        storage.get_ostree()?.change_finalization(&staged)?;
1289                        println!("Image downloaded, but will not be applied on reboot");
1290                        download_only_changed = true;
1291                    }
1292                } else if !opts.check {
1293                    // --apply or no flags: clear download-only mode
1294                    // (skip if --check, which is read-only)
1295                    if staged.is_finalization_locked() {
1296                        storage.get_ostree()?.change_finalization(&staged)?;
1297                        println!("Staged deployment will now be applied on reboot");
1298                        download_only_changed = true;
1299                    }
1300                }
1301            } else if opts.download_only || opts.apply {
1302                anyhow::bail!("No staged deployment found");
1303            }
1304
1305            if !download_only_changed {
1306                println!("Staged update present, not changed");
1307            }
1308
1309            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1310            if opts.apply {
1311                crate::reboot::reboot()?;
1312            }
1313        } else if booted_unchanged {
1314            println!("No update available.")
1315        } else {
1316            let stateroot = booted_ostree.stateroot();
1317            let from = MergeState::from_stateroot(storage, &stateroot)?;
1318            crate::deploy::stage(
1319                storage,
1320                from,
1321                &fetched,
1322                &spec,
1323                prog.clone(),
1324                opts.download_only,
1325            )
1326            .await?;
1327            changed = true;
1328            if let Some(prev) = booted_image.as_ref() {
1329                if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
1330                    let diff =
1331                        ostree_container::ManifestDiff::new(&prev.manifest, &fetched_manifest);
1332                    diff.print();
1333                }
1334            }
1335        }
1336    }
1337    if changed {
1338        storage.update_mtime()?;
1339
1340        if opts.soft_reboot.is_some() {
1341            // At this point we have new staged deployment and the host definition has changed.
1342            // We need the updated host status before we check if we can prepare the soft-reboot.
1343            let updated_host = crate::status::get_status(booted_ostree)?.1;
1344            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1345        }
1346
1347        if opts.apply {
1348            crate::reboot::reboot()?;
1349        }
1350    } else {
1351        tracing::debug!("No changes");
1352    }
1353
1354    Ok(())
1355}
1356pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> {
1357    let transport = ostree_container::Transport::try_from(opts.transport.as_str())?;
1358    let imgref = ostree_container::ImageReference {
1359        transport,
1360        name: opts.target.to_string(),
1361    };
1362    let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy);
1363    let target = ostree_container::OstreeImageReference { sigverify, imgref };
1364    let target = ImageReference::from(target);
1365
1366    return Ok(target);
1367}
1368
1369/// Implementation of the `bootc switch` CLI command for ostree backend.
1370#[context("Switching (ostree)")]
1371async fn switch_ostree(
1372    opts: SwitchOpts,
1373    storage: &Storage,
1374    booted_ostree: &BootedOstree<'_>,
1375) -> Result<()> {
1376    let target = imgref_for_switch(&opts)?;
1377    let prog: ProgressWriter = opts.progress.try_into()?;
1378    let cancellable = gio::Cancellable::NONE;
1379
1380    let repo = &booted_ostree.repo();
1381    let (_, host) = crate::status::get_status(booted_ostree)?;
1382
1383    let new_spec = {
1384        let mut new_spec = host.spec.clone();
1385        new_spec.image = Some(target.clone());
1386        new_spec
1387    };
1388
1389    if new_spec == host.spec {
1390        println!("Image specification is unchanged.");
1391        return Ok(());
1392    }
1393
1394    // Log the switch operation to systemd journal
1395    const SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1";
1396    let old_image = host
1397        .spec
1398        .image
1399        .as_ref()
1400        .map(|i| i.image.as_str())
1401        .unwrap_or("none");
1402
1403    tracing::info!(
1404        message_id = SWITCH_JOURNAL_ID,
1405        bootc.old_image_reference = old_image,
1406        bootc.new_image_reference = &target.image,
1407        bootc.new_image_transport = &target.transport,
1408        "Switching from image {} to {}",
1409        old_image,
1410        target.image
1411    );
1412
1413    let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
1414
1415    // Determine whether to use unified storage path.
1416    // If explicitly requested via flag, use unified storage directly.
1417    // Otherwise, auto-detect based on whether the image exists in bootc storage.
1418    let use_unified = if opts.unified_storage_exp {
1419        true
1420    } else {
1421        crate::deploy::image_exists_in_unified_storage(storage, &target).await?
1422    };
1423
1424    let fetched = if use_unified {
1425        crate::deploy::pull_unified(
1426            repo,
1427            &target,
1428            None,
1429            opts.quiet,
1430            prog.clone(),
1431            storage,
1432            Some(&booted_ostree.deployment),
1433        )
1434        .await?
1435    } else {
1436        crate::deploy::pull(
1437            repo,
1438            &target,
1439            None,
1440            opts.quiet,
1441            prog.clone(),
1442            Some(&booted_ostree.deployment),
1443        )
1444        .await?
1445    };
1446
1447    if !opts.retain {
1448        // By default, we prune the previous ostree ref so it will go away after later upgrades
1449        if let Some(booted_origin) = booted_ostree.deployment.origin() {
1450            if let Some(ostree_ref) = booted_origin.optional_string("origin", "refspec")? {
1451                let (remote, ostree_ref) =
1452                    ostree::parse_refspec(&ostree_ref).context("Failed to parse ostree ref")?;
1453                repo.set_ref_immediate(remote.as_deref(), &ostree_ref, None, cancellable)?;
1454            }
1455        }
1456    }
1457
1458    let stateroot = booted_ostree.stateroot();
1459    let from = MergeState::from_stateroot(storage, &stateroot)?;
1460    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1461
1462    storage.update_mtime()?;
1463
1464    if opts.soft_reboot.is_some() {
1465        // At this point we have staged the deployment and the host definition has changed.
1466        // We need the updated host status before we check if we can prepare the soft-reboot.
1467        let updated_host = crate::status::get_status(booted_ostree)?.1;
1468        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1469    }
1470
1471    if opts.apply {
1472        crate::reboot::reboot()?;
1473    }
1474
1475    Ok(())
1476}
1477
1478/// Implementation of the `bootc switch` CLI command.
1479#[context("Switching")]
1480async fn switch(opts: SwitchOpts) -> Result<()> {
1481    // If we're doing an in-place mutation, we shortcut most of the rest of the work here
1482    // TODO: what we really want here is Storage::detect_from_root() that also handles
1483    // composefs. But for now this just assumes ostree.
1484    if opts.mutate_in_place {
1485        let target = imgref_for_switch(&opts)?;
1486        let deployid = {
1487            // Clone to pass into helper thread
1488            let target = target.clone();
1489            let root = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1490            tokio::task::spawn_blocking(move || {
1491                crate::deploy::switch_origin_inplace(&root, &target)
1492            })
1493            .await??
1494        };
1495        println!("Updated {deployid} to pull from {target}");
1496        return Ok(());
1497    }
1498    let storage = &get_storage().await?;
1499    match storage.kind()? {
1500        BootedStorageKind::Ostree(booted_ostree) => {
1501            switch_ostree(opts, storage, &booted_ostree).await
1502        }
1503        BootedStorageKind::Composefs(booted_cfs) => {
1504            switch_composefs(opts, storage, &booted_cfs).await
1505        }
1506    }
1507}
1508
1509/// Implementation of the `bootc rollback` CLI command for ostree backend.
1510#[context("Rollback (ostree)")]
1511async fn rollback_ostree(
1512    opts: &RollbackOpts,
1513    storage: &Storage,
1514    booted_ostree: &BootedOstree<'_>,
1515) -> Result<()> {
1516    crate::deploy::rollback(storage).await?;
1517
1518    if opts.soft_reboot.is_some() {
1519        // Get status of rollback deployment to check soft-reboot capability
1520        let host = crate::status::get_status(booted_ostree)?.1;
1521
1522        handle_soft_reboot(
1523            opts.soft_reboot,
1524            host.status.rollback.as_ref(),
1525            "rollback",
1526            || soft_reboot_rollback(booted_ostree),
1527        )?;
1528    }
1529
1530    Ok(())
1531}
1532
1533/// Implementation of the `bootc rollback` CLI command.
1534#[context("Rollback")]
1535async fn rollback(opts: &RollbackOpts) -> Result<()> {
1536    let storage = &get_storage().await?;
1537    match storage.kind()? {
1538        BootedStorageKind::Ostree(booted_ostree) => {
1539            rollback_ostree(opts, storage, &booted_ostree).await
1540        }
1541        BootedStorageKind::Composefs(booted_cfs) => composefs_rollback(storage, &booted_cfs).await,
1542    }
1543}
1544
1545/// Implementation of the `bootc edit` CLI command for ostree backend.
1546#[context("Editing spec (ostree)")]
1547async fn edit_ostree(
1548    opts: EditOpts,
1549    storage: &Storage,
1550    booted_ostree: &BootedOstree<'_>,
1551) -> Result<()> {
1552    let repo = &booted_ostree.repo();
1553    let (_, host) = crate::status::get_status(booted_ostree)?;
1554
1555    let new_host: Host = if let Some(filename) = opts.filename {
1556        let mut r = std::io::BufReader::new(std::fs::File::open(filename)?);
1557        serde_yaml::from_reader(&mut r)?
1558    } else {
1559        let tmpf = tempfile::NamedTempFile::with_suffix(".yaml")?;
1560        serde_yaml::to_writer(std::io::BufWriter::new(tmpf.as_file()), &host)?;
1561        crate::utils::spawn_editor(&tmpf)?;
1562        tmpf.as_file().seek(std::io::SeekFrom::Start(0))?;
1563        serde_yaml::from_reader(&mut tmpf.as_file())?
1564    };
1565
1566    if new_host.spec == host.spec {
1567        println!("Edit cancelled, no changes made.");
1568        return Ok(());
1569    }
1570    host.spec.verify_transition(&new_host.spec)?;
1571    let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?;
1572
1573    let prog = ProgressWriter::default();
1574
1575    // We only support two state transitions right now; switching the image,
1576    // or flipping the bootloader ordering.
1577    if host.spec.boot_order != new_host.spec.boot_order {
1578        return crate::deploy::rollback(storage).await;
1579    }
1580
1581    let fetched = crate::deploy::pull(
1582        repo,
1583        new_spec.image,
1584        None,
1585        opts.quiet,
1586        prog.clone(),
1587        Some(&booted_ostree.deployment),
1588    )
1589    .await?;
1590
1591    // TODO gc old layers here
1592
1593    let stateroot = booted_ostree.stateroot();
1594    let from = MergeState::from_stateroot(storage, &stateroot)?;
1595    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1596
1597    storage.update_mtime()?;
1598
1599    Ok(())
1600}
1601
1602/// Implementation of the `bootc edit` CLI command.
1603#[context("Editing spec")]
1604async fn edit(opts: EditOpts) -> Result<()> {
1605    let storage = &get_storage().await?;
1606    match storage.kind()? {
1607        BootedStorageKind::Ostree(booted_ostree) => {
1608            edit_ostree(opts, storage, &booted_ostree).await
1609        }
1610        BootedStorageKind::Composefs(_) => {
1611            anyhow::bail!("Edit is not yet supported for composefs backend")
1612        }
1613    }
1614}
1615
1616/// Implementation of `bootc usroverlay`
1617async fn usroverlay(access_mode: FilesystemOverlayAccessMode) -> Result<()> {
1618    // This is just a pass-through today.  At some point we may make this a libostree API
1619    // or even oxidize it.
1620    let args = match access_mode {
1621        // In this context, "--transient" means "read-only overlay"
1622        FilesystemOverlayAccessMode::ReadOnly => ["admin", "unlock", "--transient"].as_slice(),
1623
1624        FilesystemOverlayAccessMode::ReadWrite => ["admin", "unlock"].as_slice(),
1625    };
1626    Err(Command::new("ostree").args(args).exec().into())
1627}
1628
1629/// Join the host IPC namespace if we're in an isolated one and have
1630/// sufficient privileges. The default for `podman run` is a separate IPC
1631/// namespace, which for e.g. `bootc install` can cause failures where tools
1632/// like udev/cryptsetup expect semaphores to be in sync with the host.
1633/// While we do want callers to pass `--ipc=host`, we don't want to force
1634/// them to need to either.
1635///
1636/// Requires `CAP_SYS_ADMIN` (needed for `setns()`); silently skipped when
1637/// running unprivileged (e.g. during RPM build for manpage generation).
1638/// Also skipped when `/proc/1/ns/ipc` is not accessible, which can happen
1639/// in restricted build environments (e.g. Tekton/Buildah containers) where
1640/// `/proc` is masked even for processes with `CAP_SYS_ADMIN`.
1641fn join_host_ipc_namespace() -> Result<()> {
1642    let caps = rustix::thread::capabilities(None).context("capget")?;
1643    if !caps
1644        .effective
1645        .contains(rustix::thread::CapabilitySet::SYS_ADMIN)
1646    {
1647        return Ok(());
1648    }
1649    let ns_pid1 = match std::fs::read_link("/proc/1/ns/ipc") {
1650        Ok(v) => v,
1651        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
1652            return Ok(());
1653        }
1654        Err(e) => return Err(e).context("reading /proc/1/ns/ipc"),
1655    };
1656    let ns_self = std::fs::read_link("/proc/self/ns/ipc").context("reading /proc/self/ns/ipc")?;
1657    if ns_pid1 != ns_self {
1658        let pid1ipcns = std::fs::File::open("/proc/1/ns/ipc").context("open pid1 ipcns")?;
1659        rustix::thread::move_into_link_name_space(
1660            pid1ipcns.as_fd(),
1661            Some(rustix::thread::LinkNameSpaceType::InterProcessCommunication),
1662        )
1663        .context("setns(ipc)")?;
1664    }
1665    Ok(())
1666}
1667
1668/// Perform process global initialization. This should be called as early as possible
1669/// in the standard `main` function.
1670#[allow(unsafe_code)]
1671pub fn global_init() -> Result<()> {
1672    join_host_ipc_namespace()?;
1673    // In some cases we re-exec with a temporary binary,
1674    // so ensure that the syslog identifier is set.
1675    ostree::glib::set_prgname(bootc_utils::NAME.into());
1676    if let Err(e) = rustix::thread::set_name(&CString::new(bootc_utils::NAME).unwrap()) {
1677        // This shouldn't ever happen
1678        eprintln!("failed to set name: {e}");
1679    }
1680    // Silence SELinux log warnings
1681    ostree::SePolicy::set_null_log();
1682    let am_root = rustix::process::getuid().is_root();
1683    // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common)
1684    // bombing out if it is unset.
1685    if std::env::var_os("HOME").is_none() && am_root {
1686        // Setting the environment is thread-unsafe, but we ask calling code
1687        // to invoke this as early as possible. (In practice, that's just the cli's `main.rs`)
1688        // xref https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475
1689        // SAFETY: Called early in main() before any threads are spawned.
1690        unsafe {
1691            std::env::set_var("HOME", "/root");
1692        }
1693    }
1694    Ok(())
1695}
1696
1697/// Parse the provided arguments and execute.
1698/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program.
1699pub async fn run_from_iter<I>(args: I) -> Result<()>
1700where
1701    I: IntoIterator,
1702    I::Item: Into<OsString> + Clone,
1703{
1704    run_from_opt(Opt::parse_including_static(args)).await
1705}
1706
1707/// Find the base binary name from argv0 (without a full path). The empty string
1708/// is never returned; instead a fallback string is used. If the input is not valid
1709/// UTF-8, a default is used.
1710fn callname_from_argv0(argv0: &OsStr) -> &str {
1711    let default = "bootc";
1712    std::path::Path::new(argv0)
1713        .file_name()
1714        .and_then(|s| s.to_str())
1715        .filter(|s| !s.is_empty())
1716        .unwrap_or(default)
1717}
1718
1719impl Opt {
1720    /// In some cases (e.g. systemd generator) we dispatch specifically on argv0.  This
1721    /// requires some special handling in clap.
1722    fn parse_including_static<I>(args: I) -> Self
1723    where
1724        I: IntoIterator,
1725        I::Item: Into<OsString> + Clone,
1726    {
1727        let mut args = args.into_iter();
1728        let first = if let Some(first) = args.next() {
1729            let first: OsString = first.into();
1730            let argv0 = callname_from_argv0(&first);
1731            tracing::debug!("argv0={argv0:?}");
1732            let mapped = match argv0 {
1733                InternalsOpts::GENERATOR_BIN => {
1734                    Some(["bootc", "internals", "systemd-generator"].as_slice())
1735                }
1736                "ostree-container" | "ostree-ima-sign" | "ostree-provisional-repair" => {
1737                    Some(["bootc", "internals", "ostree-ext"].as_slice())
1738                }
1739                _ => None,
1740            };
1741            if let Some(base_args) = mapped {
1742                let base_args = base_args.iter().map(OsString::from);
1743                return Opt::parse_from(base_args.chain(args.map(|i| i.into())));
1744            }
1745            Some(first)
1746        } else {
1747            None
1748        };
1749        Opt::parse_from(first.into_iter().chain(args.map(|i| i.into())))
1750    }
1751}
1752
1753/// Internal (non-generic/monomorphized) primary CLI entrypoint
1754async fn run_from_opt(opt: Opt) -> Result<()> {
1755    let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1756    match opt {
1757        Opt::Upgrade(opts) => {
1758            let storage = &get_storage().await?;
1759            match storage.kind()? {
1760                BootedStorageKind::Ostree(booted_ostree) => {
1761                    upgrade(opts, storage, &booted_ostree).await
1762                }
1763                BootedStorageKind::Composefs(booted_cfs) => {
1764                    upgrade_composefs(opts, storage, &booted_cfs).await
1765                }
1766            }
1767        }
1768        Opt::Switch(opts) => switch(opts).await,
1769        Opt::Rollback(opts) => {
1770            rollback(&opts).await?;
1771            if opts.apply {
1772                crate::reboot::reboot()?;
1773            }
1774            Ok(())
1775        }
1776        Opt::Edit(opts) => edit(opts).await,
1777        Opt::UsrOverlay(opts) => {
1778            use crate::store::Environment;
1779            let env = Environment::detect()?;
1780            let access_mode = if opts.read_only {
1781                FilesystemOverlayAccessMode::ReadOnly
1782            } else {
1783                FilesystemOverlayAccessMode::ReadWrite
1784            };
1785            match env {
1786                Environment::OstreeBooted => usroverlay(access_mode).await,
1787                Environment::ComposefsBooted(_) => composefs_usr_overlay(access_mode),
1788                _ => anyhow::bail!("usroverlay only applies on booted hosts"),
1789            }
1790        }
1791        Opt::Container(opts) => match opts {
1792            ContainerOpts::Inspect {
1793                rootfs,
1794                json,
1795                format,
1796            } => crate::status::container_inspect(&rootfs, json, format),
1797            ContainerOpts::Lint {
1798                rootfs,
1799                fatal_warnings,
1800                list,
1801                skip,
1802                no_truncate,
1803            } => {
1804                if list {
1805                    return lints::lint_list(std::io::stdout().lock());
1806                }
1807                let warnings = if fatal_warnings {
1808                    lints::WarningDisposition::FatalWarnings
1809                } else {
1810                    lints::WarningDisposition::AllowWarnings
1811                };
1812                let root_type = if rootfs == "/" {
1813                    lints::RootType::Running
1814                } else {
1815                    lints::RootType::Alternative
1816                };
1817
1818                let root = &Dir::open_ambient_dir(rootfs, cap_std::ambient_authority())?;
1819                let skip = skip.iter().map(|s| s.as_str());
1820                lints::lint(
1821                    root,
1822                    warnings,
1823                    root_type,
1824                    skip,
1825                    std::io::stdout().lock(),
1826                    no_truncate,
1827                )?;
1828                Ok(())
1829            }
1830            ContainerOpts::ComputeComposefsDigest {
1831                path,
1832                write_dumpfile_to,
1833            } => {
1834                let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref()).await?;
1835                println!("{digest}");
1836                Ok(())
1837            }
1838            ContainerOpts::ComputeComposefsDigestFromStorage {
1839                write_dumpfile_to,
1840                image,
1841            } => {
1842                let (_td_guard, repo) = new_temp_composefs_repo()?;
1843
1844                let mut proxycfg = crate::deploy::new_proxy_config();
1845
1846                let image = if let Some(image) = image {
1847                    image
1848                } else {
1849                    let host_container_store = Utf8Path::new("/run/host-container-storage");
1850                    // If no image is provided, assume that we're running in a container in privileged mode
1851                    // with access to the container storage.
1852                    let container_info = crate::containerenv::get_container_execution_info(&root)?;
1853                    let iid = container_info.imageid;
1854                    tracing::debug!("Computing digest of {iid}");
1855
1856                    if !host_container_store.try_exists()? {
1857                        anyhow::bail!(
1858                            "Must be readonly mount of host container store: {host_container_store}"
1859                        );
1860                    }
1861                    // And ensure we're finding the image in the host storage
1862                    let mut cmd = Command::new(bootc_utils::skopeo_bin());
1863                    set_additional_image_store(&mut cmd, "/run/host-container-storage");
1864                    proxycfg.skopeo_cmd = Some(cmd);
1865                    iid
1866                };
1867
1868                let imgref = format!("containers-storage:{image}");
1869                let host_store = std::path::Path::new("/run/host-container-storage");
1870                let opts = composefs_oci::PullOptions {
1871                    img_proxy_config: Some(proxycfg),
1872                    additional_image_stores: &[host_store],
1873                    ..Default::default()
1874                };
1875                let pull_result = composefs_oci::pull(&repo, &imgref, None, opts)
1876                    .await
1877                    .context("Pulling image")?;
1878                let mut fs = composefs_oci::image::create_filesystem(
1879                    &repo,
1880                    &pull_result.config_digest,
1881                    Some(&pull_result.config_verity),
1882                )
1883                .context("Populating fs")?;
1884                fs.transform_for_boot(&repo).context("Preparing for boot")?;
1885                let id = fs.compute_image_id();
1886                println!("{}", id.to_hex());
1887
1888                if let Some(path) = write_dumpfile_to.as_deref() {
1889                    let mut w = File::create(path)
1890                        .with_context(|| format!("Opening {path}"))
1891                        .map(BufWriter::new)?;
1892                    dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
1893                }
1894
1895                Ok(())
1896            }
1897            ContainerOpts::Ukify {
1898                rootfs,
1899                kargs,
1900                allow_missing_verity,
1901                write_dumpfile_to,
1902                args,
1903            } => {
1904                crate::ukify::build_ukify(
1905                    &rootfs,
1906                    &kargs,
1907                    &args,
1908                    allow_missing_verity,
1909                    write_dumpfile_to.as_deref(),
1910                )
1911                .await
1912            }
1913            ContainerOpts::Export {
1914                format,
1915                target,
1916                output,
1917                kernel_in_boot,
1918                disable_selinux,
1919            } => {
1920                crate::container_export::export(
1921                    &format,
1922                    &target,
1923                    output.as_deref(),
1924                    kernel_in_boot,
1925                    disable_selinux,
1926                )
1927                .await
1928            }
1929        },
1930        Opt::Completion { shell } => {
1931            use clap_complete::aot::generate;
1932
1933            let mut cmd = Opt::command();
1934            let mut stdout = std::io::stdout();
1935            let bin_name = "bootc";
1936            generate(shell, &mut cmd, bin_name, &mut stdout);
1937            Ok(())
1938        }
1939        Opt::Image(opts) => match opts {
1940            ImageOpts::List {
1941                list_type,
1942                list_format,
1943            } => crate::image::list_entrypoint(list_type, list_format).await,
1944
1945            ImageOpts::CopyToStorage { source, target } => {
1946                // We get "host" here to avoid deadlock in the ostree path
1947                let host = get_host().await?;
1948
1949                let storage = get_storage().await?;
1950
1951                match storage.kind()? {
1952                    BootedStorageKind::Ostree(..) => {
1953                        crate::image::push_entrypoint(
1954                            &storage,
1955                            &host,
1956                            source.as_deref(),
1957                            target.as_deref(),
1958                        )
1959                        .await
1960                    }
1961                    BootedStorageKind::Composefs(booted) => {
1962                        bootc_composefs::export::export_repo_to_image(
1963                            &storage,
1964                            &booted,
1965                            source.as_deref(),
1966                            target.as_deref(),
1967                        )
1968                        .await
1969                    }
1970                }
1971            }
1972            ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
1973            ImageOpts::PullFromDefaultStorage { image } => {
1974                let storage = get_storage().await?;
1975                storage
1976                    .get_ensure_imgstore()?
1977                    .pull_from_host_storage(&image)
1978                    .await
1979            }
1980            ImageOpts::Cmd(opt) => {
1981                let storage = get_storage().await?;
1982                let imgstore = storage.get_ensure_imgstore()?;
1983                match opt {
1984                    ImageCmdOpts::List { args } => {
1985                        crate::image::imgcmd_entrypoint(imgstore, "list", &args).await
1986                    }
1987                    ImageCmdOpts::Build { args } => {
1988                        crate::image::imgcmd_entrypoint(imgstore, "build", &args).await
1989                    }
1990                    ImageCmdOpts::Pull { images } => {
1991                        for image in &images {
1992                            imgstore.pull_with_progress(image).await?;
1993                        }
1994                        Ok(())
1995                    }
1996                    ImageCmdOpts::Push { args } => {
1997                        crate::image::imgcmd_entrypoint(imgstore, "push", &args).await
1998                    }
1999                }
2000            }
2001        },
2002        Opt::Install(opts) => match opts {
2003            #[cfg(feature = "install-to-disk")]
2004            InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,
2005            InstallOpts::ToFilesystem(opts) => {
2006                crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip)
2007                    .await
2008            }
2009            InstallOpts::ToExistingRoot(opts) => {
2010                crate::install::install_to_existing_root(opts).await
2011            }
2012            InstallOpts::Reset(opts) => crate::install::install_reset(opts).await,
2013            InstallOpts::PrintConfiguration(opts) => crate::install::print_configuration(opts),
2014            InstallOpts::EnsureCompletion {} => {
2015                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2016                crate::install::completion::run_from_anaconda(rootfs).await
2017            }
2018            InstallOpts::Finalize { root_path } => {
2019                crate::install::install_finalize(&root_path).await
2020            }
2021        },
2022        Opt::LoaderEntries(opts) => match opts {
2023            LoaderEntriesOpts::SetOptionsForSource(opts) => {
2024                let storage = get_storage().await?;
2025                let sysroot = storage.get_ostree()?;
2026                crate::loader_entries::set_options_for_source_staged(
2027                    sysroot,
2028                    &opts.source,
2029                    opts.options.as_deref(),
2030                )?;
2031                Ok(())
2032            }
2033        },
2034        Opt::ExecInHostMountNamespace { args } => {
2035            crate::install::exec_in_host_mountns(args.as_slice())
2036        }
2037        Opt::Status(opts) => super::status::status(opts).await,
2038        Opt::Internals(opts) => match opts {
2039            InternalsOpts::SystemdGenerator {
2040                normal_dir,
2041                early_dir: _,
2042                late_dir: _,
2043            } => {
2044                let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?;
2045                crate::generator::generator(root, unit_dir)
2046            }
2047            InternalsOpts::OstreeExt { args } => {
2048                ostree_ext::cli::run_from_iter(["ostree-ext".into()].into_iter().chain(args)).await
2049            }
2050            InternalsOpts::OstreeContainer { args } => {
2051                ostree_ext::cli::run_from_iter(
2052                    ["ostree-ext".into(), "container".into()]
2053                        .into_iter()
2054                        .chain(args),
2055                )
2056                .await
2057            }
2058            InternalsOpts::TestComposefs => {
2059                // This is a stub to be replaced
2060                let storage = get_storage().await?;
2061                let cfs = storage.get_ensure_composefs()?;
2062                let testdata = b"some test data";
2063                let testdata_digest = hex::encode(openssl::sha::sha256(testdata));
2064                let mut w = cfs.create_stream(0)?;
2065                w.write_inline(testdata);
2066                let object = cfs
2067                    .write_stream(w, &testdata_digest, Some("testobject"))?
2068                    .to_hex();
2069                assert_eq!(
2070                    object,
2071                    "84245c6936db9939dda9c1fbeafdcbd2b49f7605354c88d4f016c4d941551f45bad0fbcdbee12ba8adfe4fb63541de57ac02729edbacdb556325e342b89d340d"
2072                );
2073                Ok(())
2074            }
2075            // We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.
2076            InternalsOpts::Fsverity(args) => match args {
2077                FsverityOpts::Measure { path } => {
2078                    let fd =
2079                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
2080                    let digest: fsverity::Sha256HashValue = fsverity::measure_verity(&fd)?;
2081                    let digest = digest.to_hex();
2082                    println!("{digest}");
2083                    Ok(())
2084                }
2085                FsverityOpts::Enable { path } => {
2086                    let fd =
2087                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
2088                    fsverity::enable_verity_raw::<fsverity::Sha256HashValue>(&fd)?;
2089                    Ok(())
2090                }
2091            },
2092            InternalsOpts::Cfs { args } => composefs_ctl::run_from_iter(args.iter()).await,
2093            InternalsOpts::Reboot => crate::reboot::reboot(),
2094            InternalsOpts::Fsck => {
2095                let storage = &get_storage().await?;
2096                crate::fsck::fsck(&storage, std::io::stdout().lock()).await?;
2097                Ok(())
2098            }
2099            InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
2100            InternalsOpts::PrintJsonSchema { of } => {
2101                let schema = match of {
2102                    SchemaType::Host => schema_for!(crate::spec::Host),
2103                    SchemaType::Progress => schema_for!(crate::progress_jsonl::Event),
2104                };
2105                let mut stdout = std::io::stdout().lock();
2106                serde_json::to_writer_pretty(&mut stdout, &schema)?;
2107                Ok(())
2108            }
2109            InternalsOpts::Cleanup => {
2110                let storage = get_storage().await?;
2111                crate::deploy::cleanup(&storage).await
2112            }
2113            InternalsOpts::Relabel { as_path, path } => {
2114                let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2115                let path = path.strip_prefix("/")?;
2116                let sepolicy =
2117                    &ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)?;
2118                crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?;
2119                Ok(())
2120            }
2121            InternalsOpts::RelabelOverlayMountpoints => {
2122                crate::generator::relabel_overlay_mountpoints()
2123            }
2124            InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => {
2125                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
2126                crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await
2127            }
2128            InternalsOpts::LoopbackCleanupHelper { device } => {
2129                crate::blockdev::run_loopback_cleanup_helper(&device).await
2130            }
2131            InternalsOpts::AllocateCleanupLoopback { file_path: _ } => {
2132                // Create a temporary file for testing
2133                let temp_file =
2134                    tempfile::NamedTempFile::new().context("Failed to create temporary file")?;
2135                let temp_path = temp_file.path();
2136
2137                // Create a loopback device
2138                let loopback = crate::blockdev::LoopbackDevice::new(temp_path)
2139                    .context("Failed to create loopback device")?;
2140
2141                println!("Created loopback device: {}", loopback.path());
2142
2143                // Close the device to test cleanup
2144                loopback
2145                    .close()
2146                    .context("Failed to close loopback device")?;
2147
2148                println!("Successfully closed loopback device");
2149                Ok(())
2150            }
2151            #[cfg(feature = "rhsm")]
2152            InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await,
2153            #[cfg(feature = "docgen")]
2154            InternalsOpts::DumpCliJson => {
2155                use clap::CommandFactory;
2156                let cmd = Opt::command();
2157                let json = crate::cli_json::dump_cli_json(&cmd)?;
2158                println!("{}", json);
2159                Ok(())
2160            }
2161            InternalsOpts::DirDiff {
2162                pristine_etc,
2163                current_etc,
2164                new_etc,
2165                merge,
2166            } => {
2167                let pristine_etc =
2168                    Dir::open_ambient_dir(pristine_etc, cap_std::ambient_authority())?;
2169                let current_etc = Dir::open_ambient_dir(current_etc, cap_std::ambient_authority())?;
2170                let new_etc = Dir::open_ambient_dir(new_etc, cap_std::ambient_authority())?;
2171
2172                let (p, c, n) =
2173                    etc_merge::traverse_etc(&pristine_etc, &current_etc, Some(&new_etc))?;
2174
2175                let n = n
2176                    .as_ref()
2177                    .ok_or_else(|| anyhow::anyhow!("Failed to get new directory tree"))?;
2178
2179                let diff = compute_diff(&p, &c, &n)?;
2180                print_diff(&diff, &mut std::io::stdout());
2181
2182                if merge {
2183                    etc_merge::merge(&current_etc, &c, &new_etc, &n, &diff)?;
2184                }
2185
2186                Ok(())
2187            }
2188            InternalsOpts::PrepSoftReboot {
2189                deployment,
2190                reboot,
2191                reset,
2192            } => {
2193                let storage = &get_storage().await?;
2194
2195                match storage.kind()? {
2196                    BootedStorageKind::Ostree(..) => {
2197                        // TODO: Call ostree implementation?
2198                        anyhow::bail!("soft-reboot only implemented for composefs")
2199                    }
2200
2201                    BootedStorageKind::Composefs(booted_cfs) => {
2202                        if reset {
2203                            return reset_soft_reboot();
2204                        }
2205
2206                        prepare_soft_reboot_composefs(
2207                            &storage,
2208                            &booted_cfs,
2209                            deployment.as_deref(),
2210                            SoftRebootMode::Required,
2211                            reboot,
2212                        )
2213                        .await
2214                    }
2215                }
2216            }
2217            InternalsOpts::ComposefsGC {
2218                dry_run,
2219                assert_no_op,
2220                prune_repo,
2221            } => {
2222                let storage = &get_storage().await?;
2223
2224                match storage.kind()? {
2225                    BootedStorageKind::Ostree(..) => {
2226                        anyhow::bail!("composefs-gc only works for composefs backend");
2227                    }
2228
2229                    BootedStorageKind::Composefs(booted_cfs) => {
2230                        let effective_dry_run = dry_run || assert_no_op;
2231                        let gc_result =
2232                            composefs_gc(storage, &booted_cfs, effective_dry_run, prune_repo)
2233                                .await?;
2234
2235                        if effective_dry_run {
2236                            println!("Dry run (no files deleted)");
2237                        }
2238
2239                        println!(
2240                            "Objects: {} removed ({} bytes)",
2241                            gc_result.objects_removed, gc_result.objects_bytes
2242                        );
2243
2244                        if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 {
2245                            println!(
2246                                "Pruned symlinks: {} images, {} streams",
2247                                gc_result.images_pruned, gc_result.streams_pruned
2248                            );
2249                        }
2250
2251                        if assert_no_op {
2252                            let is_noop = gc_result.objects_removed == 0
2253                                && gc_result.images_pruned == 0
2254                                && gc_result.streams_pruned == 0;
2255                            if !is_noop {
2256                                anyhow::bail!(
2257                                    "--assert-no-op: GC would remove {} object(s), {} image symlink(s), {} stream symlink(s) (issue #1808)",
2258                                    gc_result.objects_removed,
2259                                    gc_result.images_pruned,
2260                                    gc_result.streams_pruned,
2261                                );
2262                            }
2263                        }
2264
2265                        Ok(())
2266                    }
2267                }
2268            }
2269            InternalsOpts::Blockdev(opts) => {
2270                let dev = match opts {
2271                    BlockdevOpts::Ls { device } => crate::blockdev::list_dev(&device)?,
2272                    BlockdevOpts::LsFilesystem { path } => {
2273                        let dir = Dir::open_ambient_dir(&path, cap_std::ambient_authority())?;
2274                        crate::blockdev::list_dev_by_dir(&dir)?
2275                    }
2276                };
2277                serde_json::to_writer_pretty(std::io::stdout().lock(), &dev)?;
2278                println!();
2279                Ok(())
2280            }
2281        },
2282        Opt::State(opts) => match opts {
2283            StateOpts::WipeOstree => {
2284                let sysroot = ostree::Sysroot::new_default();
2285                sysroot.load(gio::Cancellable::NONE)?;
2286                crate::deploy::wipe_ostree(sysroot).await?;
2287                Ok(())
2288            }
2289        },
2290
2291        Opt::ComposefsFinalizeStaged => {
2292            let storage = &get_storage().await?;
2293            match storage.kind()? {
2294                BootedStorageKind::Ostree(_) => {
2295                    anyhow::bail!("ComposefsFinalizeStaged is only supported for composefs backend")
2296                }
2297                BootedStorageKind::Composefs(booted_cfs) => {
2298                    composefs_backend_finalize(storage, &booted_cfs).await
2299                }
2300            }
2301        }
2302
2303        Opt::ConfigDiff => {
2304            let storage = &get_storage().await?;
2305            match storage.kind()? {
2306                BootedStorageKind::Ostree(_) => {
2307                    anyhow::bail!("ConfigDiff is only supported for composefs backend")
2308                }
2309                BootedStorageKind::Composefs(booted_cfs) => {
2310                    get_etc_diff(storage, &booted_cfs).await
2311                }
2312            }
2313        }
2314
2315        Opt::DeleteDeployment { depl_id } => {
2316            let storage = &get_storage().await?;
2317            match storage.kind()? {
2318                BootedStorageKind::Ostree(_) => {
2319                    anyhow::bail!("DeleteDeployment is only supported for composefs backend")
2320                }
2321                BootedStorageKind::Composefs(booted_cfs) => {
2322                    delete_composefs_deployment(&depl_id, storage, &booted_cfs).await
2323                }
2324            }
2325        }
2326    }
2327}
2328
2329#[cfg(test)]
2330mod tests {
2331    use super::*;
2332
2333    #[test]
2334    fn test_callname() {
2335        use std::os::unix::ffi::OsStrExt;
2336
2337        // Cases that change
2338        let mapped_cases = [
2339            ("", "bootc"),
2340            ("/foo/bar", "bar"),
2341            ("/foo/bar/", "bar"),
2342            ("foo/bar", "bar"),
2343            ("../foo/bar", "bar"),
2344            ("usr/bin/ostree-container", "ostree-container"),
2345        ];
2346        for (input, output) in mapped_cases {
2347            assert_eq!(
2348                output,
2349                callname_from_argv0(OsStr::new(input)),
2350                "Handling mapped case {input}"
2351            );
2352        }
2353
2354        // Invalid UTF-8
2355        assert_eq!("bootc", callname_from_argv0(OsStr::from_bytes(b"foo\x80")));
2356
2357        // Cases that are identical
2358        let ident_cases = ["foo", "bootc"];
2359        for case in ident_cases {
2360            assert_eq!(
2361                case,
2362                callname_from_argv0(OsStr::new(case)),
2363                "Handling ident case {case}"
2364            );
2365        }
2366    }
2367
2368    #[test]
2369    fn test_parse_install_args() {
2370        // Verify we still process the legacy --target-no-signature-verification
2371        let o = Opt::try_parse_from([
2372            "bootc",
2373            "install",
2374            "to-filesystem",
2375            "--target-no-signature-verification",
2376            "/target",
2377        ])
2378        .unwrap();
2379        let o = match o {
2380            Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts,
2381            o => panic!("Expected filesystem opts, not {o:?}"),
2382        };
2383        assert!(o.target_opts.target_no_signature_verification);
2384        assert_eq!(o.filesystem_opts.root_path.as_str(), "/target");
2385        // Ensure we default to old bound images behavior
2386        assert_eq!(
2387            o.config_opts.bound_images,
2388            crate::install::BoundImagesOpt::Stored
2389        );
2390    }
2391
2392    #[test]
2393    fn test_parse_opts() {
2394        assert!(matches!(
2395            Opt::parse_including_static(["bootc", "status"]),
2396            Opt::Status(StatusOpts {
2397                json: false,
2398                format: None,
2399                format_version: None,
2400                booted: false,
2401                verbose: false
2402            })
2403        ));
2404        assert!(matches!(
2405            Opt::parse_including_static(["bootc", "status", "--format-version=0"]),
2406            Opt::Status(StatusOpts {
2407                format_version: Some(0),
2408                ..
2409            })
2410        ));
2411
2412        // Test verbose long form
2413        assert!(matches!(
2414            Opt::parse_including_static(["bootc", "status", "--verbose"]),
2415            Opt::Status(StatusOpts { verbose: true, .. })
2416        ));
2417
2418        // Test verbose short form
2419        assert!(matches!(
2420            Opt::parse_including_static(["bootc", "status", "-v"]),
2421            Opt::Status(StatusOpts { verbose: true, .. })
2422        ));
2423    }
2424
2425    #[test]
2426    fn test_parse_generator() {
2427        assert!(matches!(
2428            Opt::parse_including_static([
2429                "/usr/lib/systemd/system/bootc-systemd-generator",
2430                "/run/systemd/system"
2431            ]),
2432            Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system"
2433        ));
2434    }
2435
2436    #[test]
2437    fn test_parse_ostree_ext() {
2438        assert!(matches!(
2439            Opt::parse_including_static(["bootc", "internals", "ostree-container"]),
2440            Opt::Internals(InternalsOpts::OstreeContainer { .. })
2441        ));
2442
2443        fn peel(o: Opt) -> Vec<OsString> {
2444            match o {
2445                Opt::Internals(InternalsOpts::OstreeExt { args }) => args,
2446                o => panic!("unexpected {o:?}"),
2447            }
2448        }
2449        let args = peel(Opt::parse_including_static([
2450            "/usr/libexec/libostree/ext/ostree-ima-sign",
2451            "ima-sign",
2452            "--repo=foo",
2453            "foo",
2454            "bar",
2455            "baz",
2456        ]));
2457        assert_eq!(
2458            args.as_slice(),
2459            ["ima-sign", "--repo=foo", "foo", "bar", "baz"]
2460        );
2461
2462        let args = peel(Opt::parse_including_static([
2463            "/usr/libexec/libostree/ext/ostree-container",
2464            "container",
2465            "image",
2466            "pull",
2467        ]));
2468        assert_eq!(args.as_slice(), ["container", "image", "pull"]);
2469    }
2470
2471    #[test]
2472    fn test_parse_upgrade_options() {
2473        // Test upgrade with --tag
2474        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1"]).unwrap();
2475        match o {
2476            Opt::Upgrade(opts) => {
2477                assert_eq!(opts.tag, Some("v1.1".to_string()));
2478            }
2479            _ => panic!("Expected Upgrade variant"),
2480        }
2481
2482        // Test that --tag works with --check (should compose naturally)
2483        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1", "--check"]).unwrap();
2484        match o {
2485            Opt::Upgrade(opts) => {
2486                assert_eq!(opts.tag, Some("v1.1".to_string()));
2487                assert!(opts.check);
2488            }
2489            _ => panic!("Expected Upgrade variant"),
2490        }
2491    }
2492
2493    #[test]
2494    fn test_image_reference_with_tag() {
2495        // Test basic tag replacement for registry transport
2496        let current = ImageReference {
2497            image: "quay.io/example/myapp:v1.0".to_string(),
2498            transport: "registry".to_string(),
2499            signature: None,
2500        };
2501        let result = current.with_tag("v1.1").unwrap();
2502        assert_eq!(result.image, "quay.io/example/myapp:v1.1");
2503        assert_eq!(result.transport, "registry");
2504
2505        // Test tag replacement with digest (digest should be stripped for registry)
2506        let current_with_digest = ImageReference {
2507            image: "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
2508            transport: "registry".to_string(),
2509            signature: None,
2510        };
2511        let result = current_with_digest.with_tag("v2.0").unwrap();
2512        assert_eq!(result.image, "quay.io/example/myapp:v2.0");
2513
2514        // Test that non-registry transport works (containers-storage)
2515        let containers_storage = ImageReference {
2516            image: "localhost/myapp:v1.0".to_string(),
2517            transport: "containers-storage".to_string(),
2518            signature: None,
2519        };
2520        let result = containers_storage.with_tag("v1.1").unwrap();
2521        assert_eq!(result.image, "localhost/myapp:v1.1");
2522        assert_eq!(result.transport, "containers-storage");
2523
2524        // Test digest stripping for non-registry transport
2525        let containers_storage_with_digest = ImageReference {
2526            image:
2527                "localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
2528                    .to_string(),
2529            transport: "containers-storage".to_string(),
2530            signature: None,
2531        };
2532        let result = containers_storage_with_digest.with_tag("v2.0").unwrap();
2533        assert_eq!(result.image, "localhost/myapp:v2.0");
2534        assert_eq!(result.transport, "containers-storage");
2535
2536        // Test image without tag (edge case)
2537        let no_tag = ImageReference {
2538            image: "localhost/myapp".to_string(),
2539            transport: "containers-storage".to_string(),
2540            signature: None,
2541        };
2542        let result = no_tag.with_tag("v1.0").unwrap();
2543        assert_eq!(result.image, "localhost/myapp:v1.0");
2544        assert_eq!(result.transport, "containers-storage");
2545    }
2546
2547    #[test]
2548    fn test_generate_completion_scripts_contain_commands() {
2549        use clap_complete::aot::{Shell, generate};
2550
2551        // For each supported shell, generate the completion script and
2552        // ensure obvious subcommands appear in the output. This mirrors
2553        // the style of completion checks used in other projects (e.g.
2554        // podman) where the generated script is examined for expected
2555        // tokens.
2556
2557        // `completion` is intentionally hidden from --help / suggestions;
2558        // ensure other visible subcommands are present instead.
2559        let want = ["install", "upgrade"];
2560
2561        for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
2562            let mut cmd = Opt::command();
2563            let mut buf = Vec::new();
2564            generate(shell, &mut cmd, "bootc", &mut buf);
2565            let s = String::from_utf8(buf).expect("completion should be utf8");
2566            for w in &want {
2567                assert!(s.contains(w), "{shell:?} completion missing {w}");
2568            }
2569        }
2570    }
2571}