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 cfsctl::composefs;
17use cfsctl::composefs_boot;
18use cfsctl::composefs_oci;
19use clap::CommandFactory;
20use clap::Parser;
21use clap::ValueEnum;
22use composefs::dumpfile;
23use composefs::fsverity;
24use composefs::fsverity::FsVerityHashValue;
25use composefs::splitstream::SplitStreamWriter;
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        /// Additional arguments to pass to ukify (after `--`).
442        #[clap(last = true)]
443        args: Vec<OsString>,
444    },
445    /// Export container filesystem as a tar archive.
446    ///
447    /// This command exports the container filesystem in a bootable format with proper
448    /// SELinux labeling. The output is written to stdout by default or to a specified file.
449    ///
450    /// Example:
451    ///   bootc container export /target > output.tar
452    #[clap(hide = true)]
453    Export {
454        /// Format for export output
455        #[clap(long, default_value = "tar")]
456        format: ExportFormat,
457
458        /// Output file (defaults to stdout)
459        #[clap(long, short = 'o')]
460        output: Option<Utf8PathBuf>,
461
462        /// Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility.
463        /// This is useful for installers that expect the kernel in /boot.
464        #[clap(long)]
465        kernel_in_boot: bool,
466
467        /// Disable SELinux labeling in the exported archive.
468        #[clap(long)]
469        disable_selinux: bool,
470
471        /// Path to the container filesystem root
472        target: Utf8PathBuf,
473    },
474}
475
476#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
477pub(crate) enum ExportFormat {
478    /// Export as tar archive
479    Tar,
480}
481
482/// Subcommands which operate on images.
483#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
484pub(crate) enum ImageCmdOpts {
485    /// Wrapper for `podman image list` in bootc storage.
486    List {
487        #[clap(allow_hyphen_values = true)]
488        args: Vec<OsString>,
489    },
490    /// Wrapper for `podman image build` in bootc storage.
491    Build {
492        #[clap(allow_hyphen_values = true)]
493        args: Vec<OsString>,
494    },
495    /// Wrapper for `podman image pull` in bootc storage.
496    Pull {
497        #[clap(allow_hyphen_values = true)]
498        args: Vec<OsString>,
499    },
500    /// Wrapper for `podman image push` in bootc storage.
501    Push {
502        #[clap(allow_hyphen_values = true)]
503        args: Vec<OsString>,
504    },
505}
506
507#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
508#[serde(rename_all = "kebab-case")]
509pub(crate) enum ImageListType {
510    /// List all images
511    #[default]
512    All,
513    /// List only logically bound images
514    Logical,
515    /// List only host images
516    Host,
517}
518
519impl std::fmt::Display for ImageListType {
520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521        self.to_possible_value().unwrap().get_name().fmt(f)
522    }
523}
524
525#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
526#[serde(rename_all = "kebab-case")]
527pub(crate) enum ImageListFormat {
528    /// Human readable table format
529    #[default]
530    Table,
531    /// JSON format
532    Json,
533}
534impl std::fmt::Display for ImageListFormat {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        self.to_possible_value().unwrap().get_name().fmt(f)
537    }
538}
539
540/// Subcommands which operate on images.
541#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
542pub(crate) enum ImageOpts {
543    /// List fetched images stored in the bootc storage.
544    ///
545    /// Note that these are distinct from images stored via e.g. `podman`.
546    List {
547        /// Type of image to list
548        #[clap(long = "type")]
549        #[arg(default_value_t)]
550        list_type: ImageListType,
551        #[clap(long = "format")]
552        #[arg(default_value_t)]
553        list_format: ImageListFormat,
554    },
555    /// Copy a container image from the bootc storage to `containers-storage:`.
556    ///
557    /// The source and target are both optional; if both are left unspecified,
558    /// via a simple invocation of `bootc image copy-to-storage`, then the default is to
559    /// push the currently booted image to `containers-storage` (as used by podman, etc.)
560    /// and tagged with the image name `localhost/bootc`,
561    ///
562    /// ## Copying a non-default container image
563    ///
564    /// It is also possible to copy an image other than the currently booted one by
565    /// specifying `--source`.
566    ///
567    /// ## Pulling images
568    ///
569    /// At the current time there is no explicit support for pulling images other than indirectly
570    /// via e.g. `bootc switch` or `bootc upgrade`.
571    CopyToStorage {
572        #[clap(long)]
573        /// The source image; if not specified, the booted image will be used.
574        source: Option<String>,
575
576        #[clap(long)]
577        /// The destination; if not specified, then the default is to push to `containers-storage:localhost/bootc`;
578        /// this will make the image accessible via e.g. `podman run localhost/bootc` and for builds.
579        target: Option<String>,
580    },
581    /// Re-pull the currently booted image into the bootc-owned container storage.
582    ///
583    /// This onboards the system to the unified storage path so that future
584    /// upgrade/switch operations can read from the bootc storage directly.
585    SetUnified,
586    /// Copy a container image from the default `containers-storage:` to the bootc-owned container storage.
587    PullFromDefaultStorage {
588        /// The image to pull
589        image: String,
590    },
591    /// Wrapper for selected `podman image` subcommands in bootc storage.
592    #[clap(subcommand)]
593    Cmd(ImageCmdOpts),
594}
595
596#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)]
597pub(crate) enum SchemaType {
598    Host,
599    Progress,
600}
601
602/// Options for consistency checking
603#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
604pub(crate) enum FsverityOpts {
605    /// Measure the fsverity digest of the target file.
606    Measure {
607        /// Path to file
608        path: Utf8PathBuf,
609    },
610    /// Enable fsverity on the target file.
611    Enable {
612        /// Ptah to file
613        path: Utf8PathBuf,
614    },
615}
616
617/// Hidden, internal only options
618#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
619pub(crate) enum InternalsOpts {
620    SystemdGenerator {
621        normal_dir: Utf8PathBuf,
622        #[allow(dead_code)]
623        early_dir: Option<Utf8PathBuf>,
624        #[allow(dead_code)]
625        late_dir: Option<Utf8PathBuf>,
626    },
627    FixupEtcFstab,
628    /// Should only be used by `make update-generated`
629    PrintJsonSchema {
630        #[clap(long)]
631        of: SchemaType,
632    },
633    #[clap(subcommand)]
634    Fsverity(FsverityOpts),
635    /// Perform consistency checking.
636    Fsck,
637    /// Perform cleanup actions
638    Cleanup,
639    Relabel {
640        #[clap(long)]
641        /// Relabel using this path as root
642        as_path: Option<Utf8PathBuf>,
643
644        /// Relabel this path
645        path: Utf8PathBuf,
646    },
647    /// Proxy frontend for the `ostree-ext` CLI.
648    OstreeExt {
649        #[clap(allow_hyphen_values = true)]
650        args: Vec<OsString>,
651    },
652    /// Proxy frontend for the `cfsctl` CLI
653    Cfs {
654        #[clap(allow_hyphen_values = true)]
655        args: Vec<OsString>,
656    },
657    /// Proxy frontend for the legacy `ostree container` CLI.
658    OstreeContainer {
659        #[clap(allow_hyphen_values = true)]
660        args: Vec<OsString>,
661    },
662    /// Ensure that a composefs repository is initialized
663    TestComposefs,
664    /// Loopback device cleanup helper (internal use only)
665    LoopbackCleanupHelper {
666        /// Device path to clean up
667        #[clap(long)]
668        device: String,
669    },
670    /// Test loopback device allocation and cleanup (internal use only)
671    AllocateCleanupLoopback {
672        /// File path to create loopback device for
673        #[clap(long)]
674        file_path: Utf8PathBuf,
675    },
676    /// Invoked from ostree-ext to complete an installation.
677    BootcInstallCompletion {
678        /// Path to the sysroot
679        sysroot: Utf8PathBuf,
680
681        // The stateroot
682        stateroot: String,
683    },
684    /// Initiate a reboot the same way we would after --apply; intended
685    /// primarily for testing.
686    Reboot,
687    #[cfg(feature = "rhsm")]
688    /// Publish subscription-manager facts to /etc/rhsm/facts/bootc.facts
689    PublishRhsmFacts,
690    /// Internal command for testing etc-diff/etc-merge
691    DirDiff {
692        /// Directory path to the pristine_etc
693        pristine_etc: Utf8PathBuf,
694        /// Directory path to the current_etc
695        current_etc: Utf8PathBuf,
696        /// Directory path to the new_etc
697        new_etc: Utf8PathBuf,
698        /// Whether to perform the three way merge or not
699        #[clap(long)]
700        merge: bool,
701    },
702    #[cfg(feature = "docgen")]
703    /// Dump CLI structure as JSON for documentation generation
704    DumpCliJson,
705    PrepSoftReboot {
706        #[clap(required_unless_present = "reset")]
707        deployment: Option<String>,
708        #[clap(long, conflicts_with = "reset")]
709        reboot: bool,
710        #[clap(long, conflicts_with = "reboot")]
711        reset: bool,
712    },
713    ComposefsGC {
714        #[clap(long)]
715        dry_run: bool,
716    },
717}
718
719#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
720pub(crate) enum StateOpts {
721    /// Remove all ostree deployments from this system
722    WipeOstree,
723}
724
725impl InternalsOpts {
726    /// The name of the binary we inject into /usr/lib/systemd/system-generators
727    const GENERATOR_BIN: &'static str = "bootc-systemd-generator";
728}
729
730/// Deploy and transactionally in-place with bootable container images.
731///
732/// The `bootc` project currently uses ostree-containers as a backend
733/// to support a model of bootable container images.  Once installed,
734/// whether directly via `bootc install` (executed as part of a container)
735/// or via another mechanism such as an OS installer tool, further
736/// updates can be pulled and `bootc upgrade`.
737#[derive(Debug, Parser, PartialEq, Eq)]
738#[clap(name = "bootc")]
739#[clap(rename_all = "kebab-case")]
740#[clap(version,long_version=clap::crate_version!())]
741#[allow(clippy::large_enum_variant)]
742pub(crate) enum Opt {
743    /// Download and queue an updated container image to apply.
744    ///
745    /// This does not affect the running system; updates operate in an "A/B" style by default.
746    ///
747    /// A queued update is visible as `staged` in `bootc status`.
748    ///
749    /// Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`.
750    /// There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting)
751    /// if the system has changed.
752    ///
753    /// However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply`
754    /// do *not* automatically apply the update in addition.
755    #[clap(alias = "update")]
756    Upgrade(UpgradeOpts),
757    /// Target a new container image reference to boot.
758    ///
759    /// This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference
760    /// instead.
761    ///
762    /// ## Usage
763    ///
764    /// A common pattern is to have a management agent control operating system updates via container image tags;
765    /// for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines
766    /// are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`.
767    Switch(SwitchOpts),
768    /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot,
769    /// and the current will become rollback.  If there is a `staged` entry (an unapplied, queued upgrade)
770    /// then it will be discarded.
771    ///
772    /// Note that absent any additional control logic, if there is an active agent doing automated upgrades
773    /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the
774    /// change here may be reverted.  It's recommended to only use this in concert with an agent that
775    /// is in active control.
776    ///
777    /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in
778    /// order to detect a rollback invocation.
779    #[command(after_help = indoc! {r#"
780        Note on Rollbacks and the `/etc` Directory:
781
782        When you perform a rollback (e.g., with `bootc rollback`), any
783        changes made to files in the `/etc` directory won't carry over
784        to the rolled-back deployment.  The `/etc` files will revert
785        to their state from that previous deployment instead.
786
787        This is because `bootc rollback` just reorders the existing
788        deployments. It doesn't create new deployments. The `/etc`
789        merges happen when new deployments are created.
790    "#})]
791    Rollback(RollbackOpts),
792    /// Apply full changes to the host specification.
793    ///
794    /// This command operates very similarly to `kubectl apply`; if invoked interactively,
795    /// then the current host specification will be presented in the system default `$EDITOR`
796    /// for interactive changes.
797    ///
798    /// It is also possible to directly provide new contents via `bootc edit --filename`.
799    ///
800    /// Only changes to the `spec` section are honored.
801    Edit(EditOpts),
802    /// Display status.
803    ///
804    /// Shows bootc system state. Outputs YAML by default, human-readable if terminal detected.
805    Status(StatusOpts),
806    /// Add a transient overlayfs on `/usr`.
807    ///
808    /// Allows temporary package installation that will be discarded on reboot.
809    #[clap(alias = "usroverlay")]
810    UsrOverlay(UsrOverlayOpts),
811    /// Install the running container to a target.
812    ///
813    /// Takes a container image and installs it to disk in a bootable format.
814    #[clap(subcommand)]
815    Install(InstallOpts),
816    /// Operations which can be executed as part of a container build.
817    #[clap(subcommand)]
818    Container(ContainerOpts),
819    /// Operations on container images.
820    ///
821    /// Stability: This interface may change in the future.
822    #[clap(subcommand, hide = true)]
823    Image(ImageOpts),
824    /// Execute the given command in the host mount namespace
825    #[clap(hide = true)]
826    ExecInHostMountNamespace {
827        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
828        args: Vec<OsString>,
829    },
830    /// Modify the state of the system
831    #[clap(hide = true)]
832    #[clap(subcommand)]
833    State(StateOpts),
834    #[clap(subcommand)]
835    #[clap(hide = true)]
836    Internals(InternalsOpts),
837    ComposefsFinalizeStaged,
838    /// Diff current /etc configuration versus default
839    #[clap(hide = true)]
840    ConfigDiff,
841    /// Generate shell completion script for supported shells.
842    ///
843    /// Example: `bootc completion bash` prints a bash completion script to stdout.
844    #[clap(hide = true)]
845    Completion {
846        /// Shell type to generate (bash, zsh, fish)
847        #[clap(value_enum)]
848        shell: clap_complete::aot::Shell,
849    },
850    #[clap(hide = true)]
851    DeleteDeployment {
852        depl_id: String,
853    },
854}
855
856/// Ensure we've entered a mount namespace, so that we can remount
857/// `/sysroot` read-write
858/// TODO use <https://github.com/ostreedev/ostree/pull/2779> once
859/// we can depend on a new enough ostree
860#[context("Ensuring mountns")]
861pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> {
862    let uid = rustix::process::getuid();
863    if !uid.is_root() {
864        tracing::debug!("Not root, assuming no need to unshare");
865        return Ok(());
866    }
867    let recurse_env = "_ostree_unshared";
868    let ns_pid1 = std::fs::read_link("/proc/1/ns/mnt").context("Reading /proc/1/ns/mnt")?;
869    let ns_self = std::fs::read_link("/proc/self/ns/mnt").context("Reading /proc/self/ns/mnt")?;
870    // If we already appear to be in a mount namespace, or we're already pid1, we're done
871    if ns_pid1 != ns_self {
872        tracing::debug!("Already in a mount namespace");
873        return Ok(());
874    }
875    if std::env::var_os(recurse_env).is_some() {
876        let am_pid1 = rustix::process::getpid().is_init();
877        if am_pid1 {
878            tracing::debug!("We are pid 1");
879            return Ok(());
880        } else {
881            anyhow::bail!("Failed to unshare mount namespace");
882        }
883    }
884    bootc_utils::reexec::reexec_with_guardenv(recurse_env, &["unshare", "-m", "--"])
885}
886
887/// Load global storage state, expecting that we're booted into a bootc system.
888/// This prepares the process for write operations (re-exec, mount namespace, etc).
889#[context("Initializing storage")]
890pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
891    let env = crate::store::Environment::detect()?;
892    // Always call prepare_for_write() for write operations - it checks
893    // for container, root privileges, mount namespace setup, etc.
894    prepare_for_write()?;
895    let r = BootedStorage::new(env)
896        .await?
897        .ok_or_else(|| anyhow!("System not booted via bootc"))?;
898    Ok(r)
899}
900
901#[context("Querying root privilege")]
902pub(crate) fn require_root(is_container: bool) -> Result<()> {
903    ensure!(
904        rustix::process::getuid().is_root(),
905        if is_container {
906            "The user inside the container from which you are running this command must be root"
907        } else {
908            "This command must be executed as the root user"
909        }
910    );
911
912    ensure!(
913        rustix::thread::capability_is_in_bounding_set(rustix::thread::CapabilitySet::SYS_ADMIN)?,
914        if is_container {
915            "The container must be executed with full privileges (e.g. --privileged flag)"
916        } else {
917            "This command requires full root privileges (CAP_SYS_ADMIN)"
918        }
919    );
920
921    tracing::trace!("Verified uid 0 with CAP_SYS_ADMIN");
922
923    Ok(())
924}
925
926/// Check if a deployment has soft reboot capability
927fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool {
928    deployment.map(|d| d.soft_reboot_capable).unwrap_or(false)
929}
930
931/// Prepare a soft reboot for the given deployment
932#[context("Preparing soft reboot")]
933fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> Result<()> {
934    let cancellable = ostree::gio::Cancellable::NONE;
935    sysroot
936        .deployment_set_soft_reboot(deployment, false, cancellable)
937        .context("Failed to prepare soft-reboot")?;
938    Ok(())
939}
940
941/// Handle soft reboot based on the configured mode
942#[context("Handling soft reboot")]
943fn handle_soft_reboot<F>(
944    soft_reboot_mode: Option<SoftRebootMode>,
945    entry: Option<&crate::spec::BootEntry>,
946    deployment_type: &str,
947    execute_soft_reboot: F,
948) -> Result<()>
949where
950    F: FnOnce() -> Result<()>,
951{
952    let Some(mode) = soft_reboot_mode else {
953        return Ok(());
954    };
955
956    let can_soft_reboot = has_soft_reboot_capability(entry);
957    match mode {
958        SoftRebootMode::Required => {
959            if can_soft_reboot {
960                execute_soft_reboot()?;
961            } else {
962                anyhow::bail!(
963                    "Soft reboot was required but {} deployment is not soft-reboot capable",
964                    deployment_type
965                );
966            }
967        }
968        SoftRebootMode::Auto => {
969            if can_soft_reboot {
970                execute_soft_reboot()?;
971            }
972        }
973    }
974    Ok(())
975}
976
977/// Handle soft reboot for staged deployments (used by upgrade and switch)
978#[context("Handling staged soft reboot")]
979fn handle_staged_soft_reboot(
980    booted_ostree: &BootedOstree<'_>,
981    soft_reboot_mode: Option<SoftRebootMode>,
982    host: &crate::spec::Host,
983) -> Result<()> {
984    handle_soft_reboot(
985        soft_reboot_mode,
986        host.status.staged.as_ref(),
987        "staged",
988        || soft_reboot_staged(booted_ostree.sysroot),
989    )
990}
991
992/// Perform a soft reboot for a staged deployment
993#[context("Soft reboot staged deployment")]
994fn soft_reboot_staged(sysroot: &SysrootLock) -> Result<()> {
995    println!("Staged deployment is soft-reboot capable, preparing for soft-reboot...");
996
997    let deployments_list = sysroot.deployments();
998    let staged_deployment = deployments_list
999        .iter()
1000        .find(|d| d.is_staged())
1001        .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?;
1002
1003    prepare_soft_reboot(sysroot, staged_deployment)?;
1004    Ok(())
1005}
1006
1007/// Perform a soft reboot for a rollback deployment
1008#[context("Soft reboot rollback deployment")]
1009fn soft_reboot_rollback(booted_ostree: &BootedOstree<'_>) -> Result<()> {
1010    println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot...");
1011
1012    let deployments_list = booted_ostree.sysroot.deployments();
1013    let target_deployment = deployments_list
1014        .first()
1015        .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?;
1016
1017    prepare_soft_reboot(booted_ostree.sysroot, target_deployment)
1018}
1019
1020/// A few process changes that need to be made for writing.
1021/// IMPORTANT: This may end up re-executing the current process,
1022/// so anything that happens before this should be idempotent.
1023#[context("Preparing for write")]
1024pub(crate) fn prepare_for_write() -> Result<()> {
1025    use std::sync::atomic::{AtomicBool, Ordering};
1026
1027    // This is intending to give "at most once" semantics to this
1028    // function. We should never invoke this from multiple threads
1029    // at the same time, but verifying "on main thread" is messy.
1030    // Yes, using SeqCst is likely overkill, but there is nothing perf
1031    // sensitive about this.
1032    static ENTERED: AtomicBool = AtomicBool::new(false);
1033    if ENTERED.load(Ordering::SeqCst) {
1034        return Ok(());
1035    }
1036    if ostree_ext::container_utils::running_in_container() {
1037        anyhow::bail!("Detected container; this command requires a booted host system.");
1038    }
1039    crate::cli::require_root(false)?;
1040    ensure_self_unshared_mount_namespace()?;
1041    if crate::lsm::selinux_enabled()? && !crate::lsm::selinux_ensure_install()? {
1042        tracing::debug!("Do not have install_t capabilities");
1043    }
1044    ENTERED.store(true, Ordering::SeqCst);
1045    Ok(())
1046}
1047
1048/// Implementation of the `bootc upgrade` CLI command.
1049#[context("Upgrading")]
1050async fn upgrade(
1051    opts: UpgradeOpts,
1052    storage: &Storage,
1053    booted_ostree: &BootedOstree<'_>,
1054) -> Result<()> {
1055    let repo = &booted_ostree.repo();
1056
1057    let host = crate::status::get_status(booted_ostree)?.1;
1058    let current_image = host.spec.image.as_ref();
1059
1060    // Handle --tag: derive target from current image + new tag
1061    let derived_image = if let Some(ref tag) = opts.tag {
1062        let image = current_image.ok_or_else(|| {
1063            anyhow::anyhow!("--tag requires a booted image with a specified source")
1064        })?;
1065        Some(image.with_tag(tag)?)
1066    } else {
1067        None
1068    };
1069
1070    let imgref = derived_image.as_ref().or(current_image);
1071    let prog: ProgressWriter = opts.progress.try_into()?;
1072
1073    // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
1074    if imgref.is_none() {
1075        let booted_incompatible = host.status.booted.as_ref().is_some_and(|b| b.incompatible);
1076
1077        let staged_incompatible = host.status.staged.as_ref().is_some_and(|b| b.incompatible);
1078
1079        if booted_incompatible || staged_incompatible {
1080            return Err(anyhow::anyhow!(
1081                "Deployment contains local rpm-ostree modifications; cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the modifications."
1082            ));
1083        }
1084    }
1085
1086    let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
1087    // Use the derived image reference (if --tag was specified) instead of the spec's image
1088    let spec = RequiredHostSpec { image: imgref };
1089    let booted_image = host
1090        .status
1091        .booted
1092        .as_ref()
1093        .map(|b| b.query_image(repo))
1094        .transpose()?
1095        .flatten();
1096    // Find the currently queued digest, if any before we pull
1097    let staged = host.status.staged.as_ref();
1098    let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
1099    let mut changed = false;
1100
1101    // Handle --from-downloaded: unlock existing staged deployment without fetching from image source
1102    if opts.from_downloaded {
1103        let ostree = storage.get_ostree()?;
1104        let staged_deployment = ostree
1105            .staged_deployment()
1106            .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
1107
1108        if staged_deployment.is_finalization_locked() {
1109            ostree.change_finalization(&staged_deployment)?;
1110            println!("Staged deployment will now be applied on reboot");
1111        } else {
1112            println!("Staged deployment is already set to apply on reboot");
1113        }
1114
1115        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1116        if opts.apply {
1117            crate::reboot::reboot()?;
1118        }
1119        return Ok(());
1120    }
1121
1122    // Ensure the bootc storage directory is initialized; the --check path
1123    // needs this for update_mtime() and the non-check path needs it for
1124    // unified pull detection.
1125    let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?;
1126
1127    if opts.check {
1128        let ostree_imgref = imgref.clone().into();
1129        let mut imp =
1130            crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment))
1131                .await?;
1132        match imp.prepare().await? {
1133            PrepareResult::AlreadyPresent(_) => {
1134                println!("No changes in: {ostree_imgref:#}");
1135            }
1136            PrepareResult::Ready(r) => {
1137                crate::deploy::check_bootc_label(&r.config);
1138                println!("Update available for: {ostree_imgref:#}");
1139                if let Some(version) = r.version() {
1140                    println!("  Version: {version}");
1141                }
1142                println!("  Digest: {}", r.manifest_digest);
1143                changed = true;
1144                if let Some(previous_image) = booted_image.as_ref() {
1145                    let diff =
1146                        ostree_container::ManifestDiff::new(&previous_image.manifest, &r.manifest);
1147                    diff.print();
1148                }
1149            }
1150        }
1151    } else {
1152        let fetched = if use_unified {
1153            crate::deploy::pull_unified(
1154                repo,
1155                imgref,
1156                None,
1157                opts.quiet,
1158                prog.clone(),
1159                storage,
1160                Some(&booted_ostree.deployment),
1161            )
1162            .await?
1163        } else {
1164            crate::deploy::pull(
1165                repo,
1166                imgref,
1167                None,
1168                opts.quiet,
1169                prog.clone(),
1170                Some(&booted_ostree.deployment),
1171            )
1172            .await?
1173        };
1174        let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
1175        let fetched_digest = &fetched.manifest_digest;
1176        tracing::debug!("staged: {staged_digest:?}");
1177        tracing::debug!("fetched: {fetched_digest}");
1178        let staged_unchanged = staged_digest
1179            .as_ref()
1180            .map(|d| d == fetched_digest)
1181            .unwrap_or_default();
1182        let booted_unchanged = booted_image
1183            .as_ref()
1184            .map(|img| &img.manifest_digest == fetched_digest)
1185            .unwrap_or_default();
1186        if staged_unchanged {
1187            let staged_deployment = storage.get_ostree()?.staged_deployment();
1188            let mut download_only_changed = false;
1189
1190            if let Some(staged) = staged_deployment {
1191                // Handle download-only mode based on flags
1192                if opts.download_only {
1193                    // --download-only: set download-only mode
1194                    if !staged.is_finalization_locked() {
1195                        storage.get_ostree()?.change_finalization(&staged)?;
1196                        println!("Image downloaded, but will not be applied on reboot");
1197                        download_only_changed = true;
1198                    }
1199                } else if !opts.check {
1200                    // --apply or no flags: clear download-only mode
1201                    // (skip if --check, which is read-only)
1202                    if staged.is_finalization_locked() {
1203                        storage.get_ostree()?.change_finalization(&staged)?;
1204                        println!("Staged deployment will now be applied on reboot");
1205                        download_only_changed = true;
1206                    }
1207                }
1208            } else if opts.download_only || opts.apply {
1209                anyhow::bail!("No staged deployment found");
1210            }
1211
1212            if !download_only_changed {
1213                println!("Staged update present, not changed");
1214            }
1215
1216            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1217            if opts.apply {
1218                crate::reboot::reboot()?;
1219            }
1220        } else if booted_unchanged {
1221            println!("No update available.")
1222        } else {
1223            let stateroot = booted_ostree.stateroot();
1224            let from = MergeState::from_stateroot(storage, &stateroot)?;
1225            crate::deploy::stage(
1226                storage,
1227                from,
1228                &fetched,
1229                &spec,
1230                prog.clone(),
1231                opts.download_only,
1232            )
1233            .await?;
1234            changed = true;
1235            if let Some(prev) = booted_image.as_ref() {
1236                if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
1237                    let diff =
1238                        ostree_container::ManifestDiff::new(&prev.manifest, &fetched_manifest);
1239                    diff.print();
1240                }
1241            }
1242        }
1243    }
1244    if changed {
1245        storage.update_mtime()?;
1246
1247        if opts.soft_reboot.is_some() {
1248            // At this point we have new staged deployment and the host definition has changed.
1249            // We need the updated host status before we check if we can prepare the soft-reboot.
1250            let updated_host = crate::status::get_status(booted_ostree)?.1;
1251            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1252        }
1253
1254        if opts.apply {
1255            crate::reboot::reboot()?;
1256        }
1257    } else {
1258        tracing::debug!("No changes");
1259    }
1260
1261    Ok(())
1262}
1263pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> {
1264    let transport = ostree_container::Transport::try_from(opts.transport.as_str())?;
1265    let imgref = ostree_container::ImageReference {
1266        transport,
1267        name: opts.target.to_string(),
1268    };
1269    let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy);
1270    let target = ostree_container::OstreeImageReference { sigverify, imgref };
1271    let target = ImageReference::from(target);
1272
1273    return Ok(target);
1274}
1275
1276/// Implementation of the `bootc switch` CLI command for ostree backend.
1277#[context("Switching (ostree)")]
1278async fn switch_ostree(
1279    opts: SwitchOpts,
1280    storage: &Storage,
1281    booted_ostree: &BootedOstree<'_>,
1282) -> Result<()> {
1283    let target = imgref_for_switch(&opts)?;
1284    let prog: ProgressWriter = opts.progress.try_into()?;
1285    let cancellable = gio::Cancellable::NONE;
1286
1287    let repo = &booted_ostree.repo();
1288    let (_, host) = crate::status::get_status(booted_ostree)?;
1289
1290    let new_spec = {
1291        let mut new_spec = host.spec.clone();
1292        new_spec.image = Some(target.clone());
1293        new_spec
1294    };
1295
1296    if new_spec == host.spec {
1297        println!("Image specification is unchanged.");
1298        return Ok(());
1299    }
1300
1301    // Log the switch operation to systemd journal
1302    const SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1";
1303    let old_image = host
1304        .spec
1305        .image
1306        .as_ref()
1307        .map(|i| i.image.as_str())
1308        .unwrap_or("none");
1309
1310    tracing::info!(
1311        message_id = SWITCH_JOURNAL_ID,
1312        bootc.old_image_reference = old_image,
1313        bootc.new_image_reference = &target.image,
1314        bootc.new_image_transport = &target.transport,
1315        "Switching from image {} to {}",
1316        old_image,
1317        target.image
1318    );
1319
1320    let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
1321
1322    // Determine whether to use unified storage path.
1323    // If explicitly requested via flag, use unified storage directly.
1324    // Otherwise, auto-detect based on whether the image exists in bootc storage.
1325    let use_unified = if opts.unified_storage_exp {
1326        true
1327    } else {
1328        crate::deploy::image_exists_in_unified_storage(storage, &target).await?
1329    };
1330
1331    let fetched = if use_unified {
1332        crate::deploy::pull_unified(
1333            repo,
1334            &target,
1335            None,
1336            opts.quiet,
1337            prog.clone(),
1338            storage,
1339            Some(&booted_ostree.deployment),
1340        )
1341        .await?
1342    } else {
1343        crate::deploy::pull(
1344            repo,
1345            &target,
1346            None,
1347            opts.quiet,
1348            prog.clone(),
1349            Some(&booted_ostree.deployment),
1350        )
1351        .await?
1352    };
1353
1354    if !opts.retain {
1355        // By default, we prune the previous ostree ref so it will go away after later upgrades
1356        if let Some(booted_origin) = booted_ostree.deployment.origin() {
1357            if let Some(ostree_ref) = booted_origin.optional_string("origin", "refspec")? {
1358                let (remote, ostree_ref) =
1359                    ostree::parse_refspec(&ostree_ref).context("Failed to parse ostree ref")?;
1360                repo.set_ref_immediate(remote.as_deref(), &ostree_ref, None, cancellable)?;
1361            }
1362        }
1363    }
1364
1365    let stateroot = booted_ostree.stateroot();
1366    let from = MergeState::from_stateroot(storage, &stateroot)?;
1367    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1368
1369    storage.update_mtime()?;
1370
1371    if opts.soft_reboot.is_some() {
1372        // At this point we have staged the deployment and the host definition has changed.
1373        // We need the updated host status before we check if we can prepare the soft-reboot.
1374        let updated_host = crate::status::get_status(booted_ostree)?.1;
1375        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1376    }
1377
1378    if opts.apply {
1379        crate::reboot::reboot()?;
1380    }
1381
1382    Ok(())
1383}
1384
1385/// Implementation of the `bootc switch` CLI command.
1386#[context("Switching")]
1387async fn switch(opts: SwitchOpts) -> Result<()> {
1388    // If we're doing an in-place mutation, we shortcut most of the rest of the work here
1389    // TODO: what we really want here is Storage::detect_from_root() that also handles
1390    // composefs. But for now this just assumes ostree.
1391    if opts.mutate_in_place {
1392        let target = imgref_for_switch(&opts)?;
1393        let deployid = {
1394            // Clone to pass into helper thread
1395            let target = target.clone();
1396            let root = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1397            tokio::task::spawn_blocking(move || {
1398                crate::deploy::switch_origin_inplace(&root, &target)
1399            })
1400            .await??
1401        };
1402        println!("Updated {deployid} to pull from {target}");
1403        return Ok(());
1404    }
1405    let storage = &get_storage().await?;
1406    match storage.kind()? {
1407        BootedStorageKind::Ostree(booted_ostree) => {
1408            switch_ostree(opts, storage, &booted_ostree).await
1409        }
1410        BootedStorageKind::Composefs(booted_cfs) => {
1411            switch_composefs(opts, storage, &booted_cfs).await
1412        }
1413    }
1414}
1415
1416/// Implementation of the `bootc rollback` CLI command for ostree backend.
1417#[context("Rollback (ostree)")]
1418async fn rollback_ostree(
1419    opts: &RollbackOpts,
1420    storage: &Storage,
1421    booted_ostree: &BootedOstree<'_>,
1422) -> Result<()> {
1423    crate::deploy::rollback(storage).await?;
1424
1425    if opts.soft_reboot.is_some() {
1426        // Get status of rollback deployment to check soft-reboot capability
1427        let host = crate::status::get_status(booted_ostree)?.1;
1428
1429        handle_soft_reboot(
1430            opts.soft_reboot,
1431            host.status.rollback.as_ref(),
1432            "rollback",
1433            || soft_reboot_rollback(booted_ostree),
1434        )?;
1435    }
1436
1437    Ok(())
1438}
1439
1440/// Implementation of the `bootc rollback` CLI command.
1441#[context("Rollback")]
1442async fn rollback(opts: &RollbackOpts) -> Result<()> {
1443    let storage = &get_storage().await?;
1444    match storage.kind()? {
1445        BootedStorageKind::Ostree(booted_ostree) => {
1446            rollback_ostree(opts, storage, &booted_ostree).await
1447        }
1448        BootedStorageKind::Composefs(booted_cfs) => composefs_rollback(storage, &booted_cfs).await,
1449    }
1450}
1451
1452/// Implementation of the `bootc edit` CLI command for ostree backend.
1453#[context("Editing spec (ostree)")]
1454async fn edit_ostree(
1455    opts: EditOpts,
1456    storage: &Storage,
1457    booted_ostree: &BootedOstree<'_>,
1458) -> Result<()> {
1459    let repo = &booted_ostree.repo();
1460    let (_, host) = crate::status::get_status(booted_ostree)?;
1461
1462    let new_host: Host = if let Some(filename) = opts.filename {
1463        let mut r = std::io::BufReader::new(std::fs::File::open(filename)?);
1464        serde_yaml::from_reader(&mut r)?
1465    } else {
1466        let tmpf = tempfile::NamedTempFile::with_suffix(".yaml")?;
1467        serde_yaml::to_writer(std::io::BufWriter::new(tmpf.as_file()), &host)?;
1468        crate::utils::spawn_editor(&tmpf)?;
1469        tmpf.as_file().seek(std::io::SeekFrom::Start(0))?;
1470        serde_yaml::from_reader(&mut tmpf.as_file())?
1471    };
1472
1473    if new_host.spec == host.spec {
1474        println!("Edit cancelled, no changes made.");
1475        return Ok(());
1476    }
1477    host.spec.verify_transition(&new_host.spec)?;
1478    let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?;
1479
1480    let prog = ProgressWriter::default();
1481
1482    // We only support two state transitions right now; switching the image,
1483    // or flipping the bootloader ordering.
1484    if host.spec.boot_order != new_host.spec.boot_order {
1485        return crate::deploy::rollback(storage).await;
1486    }
1487
1488    let fetched = crate::deploy::pull(
1489        repo,
1490        new_spec.image,
1491        None,
1492        opts.quiet,
1493        prog.clone(),
1494        Some(&booted_ostree.deployment),
1495    )
1496    .await?;
1497
1498    // TODO gc old layers here
1499
1500    let stateroot = booted_ostree.stateroot();
1501    let from = MergeState::from_stateroot(storage, &stateroot)?;
1502    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1503
1504    storage.update_mtime()?;
1505
1506    Ok(())
1507}
1508
1509/// Implementation of the `bootc edit` CLI command.
1510#[context("Editing spec")]
1511async fn edit(opts: EditOpts) -> Result<()> {
1512    let storage = &get_storage().await?;
1513    match storage.kind()? {
1514        BootedStorageKind::Ostree(booted_ostree) => {
1515            edit_ostree(opts, storage, &booted_ostree).await
1516        }
1517        BootedStorageKind::Composefs(_) => {
1518            anyhow::bail!("Edit is not yet supported for composefs backend")
1519        }
1520    }
1521}
1522
1523/// Implementation of `bootc usroverlay`
1524async fn usroverlay(access_mode: FilesystemOverlayAccessMode) -> Result<()> {
1525    // This is just a pass-through today.  At some point we may make this a libostree API
1526    // or even oxidize it.
1527    let args = match access_mode {
1528        // In this context, "--transient" means "read-only overlay"
1529        FilesystemOverlayAccessMode::ReadOnly => ["admin", "unlock", "--transient"].as_slice(),
1530
1531        FilesystemOverlayAccessMode::ReadWrite => ["admin", "unlock"].as_slice(),
1532    };
1533    Err(Command::new("ostree").args(args).exec().into())
1534}
1535
1536/// Join the host IPC namespace if we're in an isolated one and have
1537/// sufficient privileges. The default for `podman run` is a separate IPC
1538/// namespace, which for e.g. `bootc install` can cause failures where tools
1539/// like udev/cryptsetup expect semaphores to be in sync with the host.
1540/// While we do want callers to pass `--ipc=host`, we don't want to force
1541/// them to need to either.
1542///
1543/// Requires `CAP_SYS_ADMIN` (needed for `setns()`); silently skipped when
1544/// running unprivileged (e.g. during RPM build for manpage generation).
1545fn join_host_ipc_namespace() -> Result<()> {
1546    let caps = rustix::thread::capabilities(None).context("capget")?;
1547    if !caps
1548        .effective
1549        .contains(rustix::thread::CapabilitySet::SYS_ADMIN)
1550    {
1551        return Ok(());
1552    }
1553    let ns_pid1 = std::fs::read_link("/proc/1/ns/ipc").context("reading /proc/1/ns/ipc")?;
1554    let ns_self = std::fs::read_link("/proc/self/ns/ipc").context("reading /proc/self/ns/ipc")?;
1555    if ns_pid1 != ns_self {
1556        let pid1ipcns = std::fs::File::open("/proc/1/ns/ipc").context("open pid1 ipcns")?;
1557        rustix::thread::move_into_link_name_space(
1558            pid1ipcns.as_fd(),
1559            Some(rustix::thread::LinkNameSpaceType::InterProcessCommunication),
1560        )
1561        .context("setns(ipc)")?;
1562        tracing::debug!("Joined pid1 IPC namespace");
1563    }
1564    Ok(())
1565}
1566
1567/// Perform process global initialization. This should be called as early as possible
1568/// in the standard `main` function.
1569#[allow(unsafe_code)]
1570pub fn global_init() -> Result<()> {
1571    join_host_ipc_namespace()?;
1572    // In some cases we re-exec with a temporary binary,
1573    // so ensure that the syslog identifier is set.
1574    ostree::glib::set_prgname(bootc_utils::NAME.into());
1575    if let Err(e) = rustix::thread::set_name(&CString::new(bootc_utils::NAME).unwrap()) {
1576        // This shouldn't ever happen
1577        eprintln!("failed to set name: {e}");
1578    }
1579    // Silence SELinux log warnings
1580    ostree::SePolicy::set_null_log();
1581    let am_root = rustix::process::getuid().is_root();
1582    // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common)
1583    // bombing out if it is unset.
1584    if std::env::var_os("HOME").is_none() && am_root {
1585        // Setting the environment is thread-unsafe, but we ask calling code
1586        // to invoke this as early as possible. (In practice, that's just the cli's `main.rs`)
1587        // xref https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475
1588        // SAFETY: Called early in main() before any threads are spawned.
1589        unsafe {
1590            std::env::set_var("HOME", "/root");
1591        }
1592    }
1593    Ok(())
1594}
1595
1596/// Parse the provided arguments and execute.
1597/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program.
1598pub async fn run_from_iter<I>(args: I) -> Result<()>
1599where
1600    I: IntoIterator,
1601    I::Item: Into<OsString> + Clone,
1602{
1603    run_from_opt(Opt::parse_including_static(args)).await
1604}
1605
1606/// Find the base binary name from argv0 (without a full path). The empty string
1607/// is never returned; instead a fallback string is used. If the input is not valid
1608/// UTF-8, a default is used.
1609fn callname_from_argv0(argv0: &OsStr) -> &str {
1610    let default = "bootc";
1611    std::path::Path::new(argv0)
1612        .file_name()
1613        .and_then(|s| s.to_str())
1614        .filter(|s| !s.is_empty())
1615        .unwrap_or(default)
1616}
1617
1618impl Opt {
1619    /// In some cases (e.g. systemd generator) we dispatch specifically on argv0.  This
1620    /// requires some special handling in clap.
1621    fn parse_including_static<I>(args: I) -> Self
1622    where
1623        I: IntoIterator,
1624        I::Item: Into<OsString> + Clone,
1625    {
1626        let mut args = args.into_iter();
1627        let first = if let Some(first) = args.next() {
1628            let first: OsString = first.into();
1629            let argv0 = callname_from_argv0(&first);
1630            tracing::debug!("argv0={argv0:?}");
1631            let mapped = match argv0 {
1632                InternalsOpts::GENERATOR_BIN => {
1633                    Some(["bootc", "internals", "systemd-generator"].as_slice())
1634                }
1635                "ostree-container" | "ostree-ima-sign" | "ostree-provisional-repair" => {
1636                    Some(["bootc", "internals", "ostree-ext"].as_slice())
1637                }
1638                _ => None,
1639            };
1640            if let Some(base_args) = mapped {
1641                let base_args = base_args.iter().map(OsString::from);
1642                return Opt::parse_from(base_args.chain(args.map(|i| i.into())));
1643            }
1644            Some(first)
1645        } else {
1646            None
1647        };
1648        Opt::parse_from(first.into_iter().chain(args.map(|i| i.into())))
1649    }
1650}
1651
1652/// Internal (non-generic/monomorphized) primary CLI entrypoint
1653async fn run_from_opt(opt: Opt) -> Result<()> {
1654    let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1655    match opt {
1656        Opt::Upgrade(opts) => {
1657            let storage = &get_storage().await?;
1658            match storage.kind()? {
1659                BootedStorageKind::Ostree(booted_ostree) => {
1660                    upgrade(opts, storage, &booted_ostree).await
1661                }
1662                BootedStorageKind::Composefs(booted_cfs) => {
1663                    upgrade_composefs(opts, storage, &booted_cfs).await
1664                }
1665            }
1666        }
1667        Opt::Switch(opts) => switch(opts).await,
1668        Opt::Rollback(opts) => {
1669            rollback(&opts).await?;
1670            if opts.apply {
1671                crate::reboot::reboot()?;
1672            }
1673            Ok(())
1674        }
1675        Opt::Edit(opts) => edit(opts).await,
1676        Opt::UsrOverlay(opts) => {
1677            use crate::store::Environment;
1678            let env = Environment::detect()?;
1679            let access_mode = if opts.read_only {
1680                FilesystemOverlayAccessMode::ReadOnly
1681            } else {
1682                FilesystemOverlayAccessMode::ReadWrite
1683            };
1684            match env {
1685                Environment::OstreeBooted => usroverlay(access_mode).await,
1686                Environment::ComposefsBooted(_) => composefs_usr_overlay(access_mode),
1687                _ => anyhow::bail!("usroverlay only applies on booted hosts"),
1688            }
1689        }
1690        Opt::Container(opts) => match opts {
1691            ContainerOpts::Inspect {
1692                rootfs,
1693                json,
1694                format,
1695            } => crate::status::container_inspect(&rootfs, json, format),
1696            ContainerOpts::Lint {
1697                rootfs,
1698                fatal_warnings,
1699                list,
1700                skip,
1701                no_truncate,
1702            } => {
1703                if list {
1704                    return lints::lint_list(std::io::stdout().lock());
1705                }
1706                let warnings = if fatal_warnings {
1707                    lints::WarningDisposition::FatalWarnings
1708                } else {
1709                    lints::WarningDisposition::AllowWarnings
1710                };
1711                let root_type = if rootfs == "/" {
1712                    lints::RootType::Running
1713                } else {
1714                    lints::RootType::Alternative
1715                };
1716
1717                let root = &Dir::open_ambient_dir(rootfs, cap_std::ambient_authority())?;
1718                let skip = skip.iter().map(|s| s.as_str());
1719                lints::lint(
1720                    root,
1721                    warnings,
1722                    root_type,
1723                    skip,
1724                    std::io::stdout().lock(),
1725                    no_truncate,
1726                )?;
1727                Ok(())
1728            }
1729            ContainerOpts::ComputeComposefsDigest {
1730                path,
1731                write_dumpfile_to,
1732            } => {
1733                let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?;
1734                println!("{digest}");
1735                Ok(())
1736            }
1737            ContainerOpts::ComputeComposefsDigestFromStorage {
1738                write_dumpfile_to,
1739                image,
1740            } => {
1741                let (_td_guard, repo) = new_temp_composefs_repo()?;
1742
1743                let mut proxycfg = crate::deploy::new_proxy_config();
1744
1745                let image = if let Some(image) = image {
1746                    image
1747                } else {
1748                    let host_container_store = Utf8Path::new("/run/host-container-storage");
1749                    // If no image is provided, assume that we're running in a container in privileged mode
1750                    // with access to the container storage.
1751                    let container_info = crate::containerenv::get_container_execution_info(&root)?;
1752                    let iid = container_info.imageid;
1753                    tracing::debug!("Computing digest of {iid}");
1754
1755                    if !host_container_store.try_exists()? {
1756                        anyhow::bail!(
1757                            "Must be readonly mount of host container store: {host_container_store}"
1758                        );
1759                    }
1760                    // And ensure we're finding the image in the host storage
1761                    let mut cmd = Command::new("skopeo");
1762                    set_additional_image_store(&mut cmd, "/run/host-container-storage");
1763                    proxycfg.skopeo_cmd = Some(cmd);
1764                    iid
1765                };
1766
1767                let imgref = format!("containers-storage:{image}");
1768                let pull_result = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg))
1769                    .await
1770                    .context("Pulling image")?;
1771                let mut fs = composefs_oci::image::create_filesystem(
1772                    &repo,
1773                    &pull_result.config_digest,
1774                    Some(&pull_result.config_verity),
1775                )
1776                .context("Populating fs")?;
1777                fs.transform_for_boot(&repo).context("Preparing for boot")?;
1778                let id = fs.compute_image_id();
1779                println!("{}", id.to_hex());
1780
1781                if let Some(path) = write_dumpfile_to.as_deref() {
1782                    let mut w = File::create(path)
1783                        .with_context(|| format!("Opening {path}"))
1784                        .map(BufWriter::new)?;
1785                    dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
1786                }
1787
1788                Ok(())
1789            }
1790            ContainerOpts::Ukify {
1791                rootfs,
1792                kargs,
1793                allow_missing_verity,
1794                args,
1795            } => crate::ukify::build_ukify(&rootfs, &kargs, &args, allow_missing_verity),
1796            ContainerOpts::Export {
1797                format,
1798                target,
1799                output,
1800                kernel_in_boot,
1801                disable_selinux,
1802            } => {
1803                crate::container_export::export(
1804                    &format,
1805                    &target,
1806                    output.as_deref(),
1807                    kernel_in_boot,
1808                    disable_selinux,
1809                )
1810                .await
1811            }
1812        },
1813        Opt::Completion { shell } => {
1814            use clap_complete::aot::generate;
1815
1816            let mut cmd = Opt::command();
1817            let mut stdout = std::io::stdout();
1818            let bin_name = "bootc";
1819            generate(shell, &mut cmd, bin_name, &mut stdout);
1820            Ok(())
1821        }
1822        Opt::Image(opts) => match opts {
1823            ImageOpts::List {
1824                list_type,
1825                list_format,
1826            } => crate::image::list_entrypoint(list_type, list_format).await,
1827
1828            ImageOpts::CopyToStorage { source, target } => {
1829                // We get "host" here to avoid deadlock in the ostree path
1830                let host = get_host().await?;
1831
1832                let storage = get_storage().await?;
1833
1834                match storage.kind()? {
1835                    BootedStorageKind::Ostree(..) => {
1836                        crate::image::push_entrypoint(
1837                            &storage,
1838                            &host,
1839                            source.as_deref(),
1840                            target.as_deref(),
1841                        )
1842                        .await
1843                    }
1844                    BootedStorageKind::Composefs(booted) => {
1845                        bootc_composefs::export::export_repo_to_image(
1846                            &storage,
1847                            &booted,
1848                            source.as_deref(),
1849                            target.as_deref(),
1850                        )
1851                        .await
1852                    }
1853                }
1854            }
1855            ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
1856            ImageOpts::PullFromDefaultStorage { image } => {
1857                let storage = get_storage().await?;
1858                storage
1859                    .get_ensure_imgstore()?
1860                    .pull_from_host_storage(&image)
1861                    .await
1862            }
1863            ImageOpts::Cmd(opt) => {
1864                let storage = get_storage().await?;
1865                let imgstore = storage.get_ensure_imgstore()?;
1866                match opt {
1867                    ImageCmdOpts::List { args } => {
1868                        crate::image::imgcmd_entrypoint(imgstore, "list", &args).await
1869                    }
1870                    ImageCmdOpts::Build { args } => {
1871                        crate::image::imgcmd_entrypoint(imgstore, "build", &args).await
1872                    }
1873                    ImageCmdOpts::Pull { args } => {
1874                        crate::image::imgcmd_entrypoint(imgstore, "pull", &args).await
1875                    }
1876                    ImageCmdOpts::Push { args } => {
1877                        crate::image::imgcmd_entrypoint(imgstore, "push", &args).await
1878                    }
1879                }
1880            }
1881        },
1882        Opt::Install(opts) => match opts {
1883            #[cfg(feature = "install-to-disk")]
1884            InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,
1885            InstallOpts::ToFilesystem(opts) => {
1886                crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip)
1887                    .await
1888            }
1889            InstallOpts::ToExistingRoot(opts) => {
1890                crate::install::install_to_existing_root(opts).await
1891            }
1892            InstallOpts::Reset(opts) => crate::install::install_reset(opts).await,
1893            InstallOpts::PrintConfiguration(opts) => crate::install::print_configuration(opts),
1894            InstallOpts::EnsureCompletion {} => {
1895                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1896                crate::install::completion::run_from_anaconda(rootfs).await
1897            }
1898            InstallOpts::Finalize { root_path } => {
1899                crate::install::install_finalize(&root_path).await
1900            }
1901        },
1902        Opt::ExecInHostMountNamespace { args } => {
1903            crate::install::exec_in_host_mountns(args.as_slice())
1904        }
1905        Opt::Status(opts) => super::status::status(opts).await,
1906        Opt::Internals(opts) => match opts {
1907            InternalsOpts::SystemdGenerator {
1908                normal_dir,
1909                early_dir: _,
1910                late_dir: _,
1911            } => {
1912                let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?;
1913                crate::generator::generator(root, unit_dir)
1914            }
1915            InternalsOpts::OstreeExt { args } => {
1916                ostree_ext::cli::run_from_iter(["ostree-ext".into()].into_iter().chain(args)).await
1917            }
1918            InternalsOpts::OstreeContainer { args } => {
1919                ostree_ext::cli::run_from_iter(
1920                    ["ostree-ext".into(), "container".into()]
1921                        .into_iter()
1922                        .chain(args),
1923                )
1924                .await
1925            }
1926            InternalsOpts::TestComposefs => {
1927                // This is a stub to be replaced
1928                let storage = get_storage().await?;
1929                let cfs = storage.get_ensure_composefs()?;
1930                let testdata = b"some test data";
1931                let testdata_digest = hex::encode(openssl::sha::sha256(testdata));
1932                let mut w = SplitStreamWriter::new(&cfs, 0);
1933                w.write_inline(testdata);
1934                let object = cfs
1935                    .write_stream(w, &testdata_digest, Some("testobject"))?
1936                    .to_hex();
1937                assert_eq!(
1938                    object,
1939                    "dc31ae5d2f637e98d2171821d60d2fcafb8084d6a4bb3bd9cdc7ad41decce6e48f85d5413d22371d36b223945042f53a2a6ab449b8e45d8896ba7d8694a16681"
1940                );
1941                Ok(())
1942            }
1943            // We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.
1944            InternalsOpts::Fsverity(args) => match args {
1945                FsverityOpts::Measure { path } => {
1946                    let fd =
1947                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
1948                    let digest: fsverity::Sha256HashValue = fsverity::measure_verity(&fd)?;
1949                    let digest = digest.to_hex();
1950                    println!("{digest}");
1951                    Ok(())
1952                }
1953                FsverityOpts::Enable { path } => {
1954                    let fd =
1955                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
1956                    fsverity::enable_verity_raw::<fsverity::Sha256HashValue>(&fd)?;
1957                    Ok(())
1958                }
1959            },
1960            InternalsOpts::Cfs { args } => cfsctl::run_from_iter(args.iter()).await,
1961            InternalsOpts::Reboot => crate::reboot::reboot(),
1962            InternalsOpts::Fsck => {
1963                let storage = &get_storage().await?;
1964                crate::fsck::fsck(&storage, std::io::stdout().lock()).await?;
1965                Ok(())
1966            }
1967            InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
1968            InternalsOpts::PrintJsonSchema { of } => {
1969                let schema = match of {
1970                    SchemaType::Host => schema_for!(crate::spec::Host),
1971                    SchemaType::Progress => schema_for!(crate::progress_jsonl::Event),
1972                };
1973                let mut stdout = std::io::stdout().lock();
1974                serde_json::to_writer_pretty(&mut stdout, &schema)?;
1975                Ok(())
1976            }
1977            InternalsOpts::Cleanup => {
1978                let storage = get_storage().await?;
1979                crate::deploy::cleanup(&storage).await
1980            }
1981            InternalsOpts::Relabel { as_path, path } => {
1982                let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1983                let path = path.strip_prefix("/")?;
1984                let sepolicy =
1985                    &ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)?;
1986                crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?;
1987                Ok(())
1988            }
1989            InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => {
1990                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1991                crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await
1992            }
1993            InternalsOpts::LoopbackCleanupHelper { device } => {
1994                crate::blockdev::run_loopback_cleanup_helper(&device).await
1995            }
1996            InternalsOpts::AllocateCleanupLoopback { file_path: _ } => {
1997                // Create a temporary file for testing
1998                let temp_file =
1999                    tempfile::NamedTempFile::new().context("Failed to create temporary file")?;
2000                let temp_path = temp_file.path();
2001
2002                // Create a loopback device
2003                let loopback = crate::blockdev::LoopbackDevice::new(temp_path)
2004                    .context("Failed to create loopback device")?;
2005
2006                println!("Created loopback device: {}", loopback.path());
2007
2008                // Close the device to test cleanup
2009                loopback
2010                    .close()
2011                    .context("Failed to close loopback device")?;
2012
2013                println!("Successfully closed loopback device");
2014                Ok(())
2015            }
2016            #[cfg(feature = "rhsm")]
2017            InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await,
2018            #[cfg(feature = "docgen")]
2019            InternalsOpts::DumpCliJson => {
2020                use clap::CommandFactory;
2021                let cmd = Opt::command();
2022                let json = crate::cli_json::dump_cli_json(&cmd)?;
2023                println!("{}", json);
2024                Ok(())
2025            }
2026            InternalsOpts::DirDiff {
2027                pristine_etc,
2028                current_etc,
2029                new_etc,
2030                merge,
2031            } => {
2032                let pristine_etc =
2033                    Dir::open_ambient_dir(pristine_etc, cap_std::ambient_authority())?;
2034                let current_etc = Dir::open_ambient_dir(current_etc, cap_std::ambient_authority())?;
2035                let new_etc = Dir::open_ambient_dir(new_etc, cap_std::ambient_authority())?;
2036
2037                let (p, c, n) =
2038                    etc_merge::traverse_etc(&pristine_etc, &current_etc, Some(&new_etc))?;
2039
2040                let n = n
2041                    .as_ref()
2042                    .ok_or_else(|| anyhow::anyhow!("Failed to get new directory tree"))?;
2043
2044                let diff = compute_diff(&p, &c, &n)?;
2045                print_diff(&diff, &mut std::io::stdout());
2046
2047                if merge {
2048                    etc_merge::merge(&current_etc, &c, &new_etc, &n, &diff)?;
2049                }
2050
2051                Ok(())
2052            }
2053            InternalsOpts::PrepSoftReboot {
2054                deployment,
2055                reboot,
2056                reset,
2057            } => {
2058                let storage = &get_storage().await?;
2059
2060                match storage.kind()? {
2061                    BootedStorageKind::Ostree(..) => {
2062                        // TODO: Call ostree implementation?
2063                        anyhow::bail!("soft-reboot only implemented for composefs")
2064                    }
2065
2066                    BootedStorageKind::Composefs(booted_cfs) => {
2067                        if reset {
2068                            return reset_soft_reboot();
2069                        }
2070
2071                        prepare_soft_reboot_composefs(
2072                            &storage,
2073                            &booted_cfs,
2074                            deployment.as_deref(),
2075                            SoftRebootMode::Required,
2076                            reboot,
2077                        )
2078                        .await
2079                    }
2080                }
2081            }
2082            InternalsOpts::ComposefsGC { dry_run } => {
2083                let storage = &get_storage().await?;
2084
2085                match storage.kind()? {
2086                    BootedStorageKind::Ostree(..) => {
2087                        anyhow::bail!("composefs-gc only works for composefs backend");
2088                    }
2089
2090                    BootedStorageKind::Composefs(booted_cfs) => {
2091                        let gc_result = composefs_gc(storage, &booted_cfs, dry_run).await?;
2092
2093                        if dry_run {
2094                            println!("Dry run (no files deleted)");
2095                        }
2096
2097                        println!(
2098                            "Objects: {} removed ({} bytes)",
2099                            gc_result.objects_removed, gc_result.objects_bytes
2100                        );
2101
2102                        if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 {
2103                            println!(
2104                                "Pruned symlinks: {} images, {} streams",
2105                                gc_result.images_pruned, gc_result.streams_pruned
2106                            );
2107                        }
2108
2109                        Ok(())
2110                    }
2111                }
2112            }
2113        },
2114        Opt::State(opts) => match opts {
2115            StateOpts::WipeOstree => {
2116                let sysroot = ostree::Sysroot::new_default();
2117                sysroot.load(gio::Cancellable::NONE)?;
2118                crate::deploy::wipe_ostree(sysroot).await?;
2119                Ok(())
2120            }
2121        },
2122
2123        Opt::ComposefsFinalizeStaged => {
2124            let storage = &get_storage().await?;
2125            match storage.kind()? {
2126                BootedStorageKind::Ostree(_) => {
2127                    anyhow::bail!("ComposefsFinalizeStaged is only supported for composefs backend")
2128                }
2129                BootedStorageKind::Composefs(booted_cfs) => {
2130                    composefs_backend_finalize(storage, &booted_cfs).await
2131                }
2132            }
2133        }
2134
2135        Opt::ConfigDiff => {
2136            let storage = &get_storage().await?;
2137            match storage.kind()? {
2138                BootedStorageKind::Ostree(_) => {
2139                    anyhow::bail!("ConfigDiff is only supported for composefs backend")
2140                }
2141                BootedStorageKind::Composefs(booted_cfs) => {
2142                    get_etc_diff(storage, &booted_cfs).await
2143                }
2144            }
2145        }
2146
2147        Opt::DeleteDeployment { depl_id } => {
2148            let storage = &get_storage().await?;
2149            match storage.kind()? {
2150                BootedStorageKind::Ostree(_) => {
2151                    anyhow::bail!("DeleteDeployment is only supported for composefs backend")
2152                }
2153                BootedStorageKind::Composefs(booted_cfs) => {
2154                    delete_composefs_deployment(&depl_id, storage, &booted_cfs).await
2155                }
2156            }
2157        }
2158    }
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164
2165    #[test]
2166    fn test_callname() {
2167        use std::os::unix::ffi::OsStrExt;
2168
2169        // Cases that change
2170        let mapped_cases = [
2171            ("", "bootc"),
2172            ("/foo/bar", "bar"),
2173            ("/foo/bar/", "bar"),
2174            ("foo/bar", "bar"),
2175            ("../foo/bar", "bar"),
2176            ("usr/bin/ostree-container", "ostree-container"),
2177        ];
2178        for (input, output) in mapped_cases {
2179            assert_eq!(
2180                output,
2181                callname_from_argv0(OsStr::new(input)),
2182                "Handling mapped case {input}"
2183            );
2184        }
2185
2186        // Invalid UTF-8
2187        assert_eq!("bootc", callname_from_argv0(OsStr::from_bytes(b"foo\x80")));
2188
2189        // Cases that are identical
2190        let ident_cases = ["foo", "bootc"];
2191        for case in ident_cases {
2192            assert_eq!(
2193                case,
2194                callname_from_argv0(OsStr::new(case)),
2195                "Handling ident case {case}"
2196            );
2197        }
2198    }
2199
2200    #[test]
2201    fn test_parse_install_args() {
2202        // Verify we still process the legacy --target-no-signature-verification
2203        let o = Opt::try_parse_from([
2204            "bootc",
2205            "install",
2206            "to-filesystem",
2207            "--target-no-signature-verification",
2208            "/target",
2209        ])
2210        .unwrap();
2211        let o = match o {
2212            Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts,
2213            o => panic!("Expected filesystem opts, not {o:?}"),
2214        };
2215        assert!(o.target_opts.target_no_signature_verification);
2216        assert_eq!(o.filesystem_opts.root_path.as_str(), "/target");
2217        // Ensure we default to old bound images behavior
2218        assert_eq!(
2219            o.config_opts.bound_images,
2220            crate::install::BoundImagesOpt::Stored
2221        );
2222    }
2223
2224    #[test]
2225    fn test_parse_opts() {
2226        assert!(matches!(
2227            Opt::parse_including_static(["bootc", "status"]),
2228            Opt::Status(StatusOpts {
2229                json: false,
2230                format: None,
2231                format_version: None,
2232                booted: false,
2233                verbose: false
2234            })
2235        ));
2236        assert!(matches!(
2237            Opt::parse_including_static(["bootc", "status", "--format-version=0"]),
2238            Opt::Status(StatusOpts {
2239                format_version: Some(0),
2240                ..
2241            })
2242        ));
2243
2244        // Test verbose long form
2245        assert!(matches!(
2246            Opt::parse_including_static(["bootc", "status", "--verbose"]),
2247            Opt::Status(StatusOpts { verbose: true, .. })
2248        ));
2249
2250        // Test verbose short form
2251        assert!(matches!(
2252            Opt::parse_including_static(["bootc", "status", "-v"]),
2253            Opt::Status(StatusOpts { verbose: true, .. })
2254        ));
2255    }
2256
2257    #[test]
2258    fn test_parse_generator() {
2259        assert!(matches!(
2260            Opt::parse_including_static([
2261                "/usr/lib/systemd/system/bootc-systemd-generator",
2262                "/run/systemd/system"
2263            ]),
2264            Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system"
2265        ));
2266    }
2267
2268    #[test]
2269    fn test_parse_ostree_ext() {
2270        assert!(matches!(
2271            Opt::parse_including_static(["bootc", "internals", "ostree-container"]),
2272            Opt::Internals(InternalsOpts::OstreeContainer { .. })
2273        ));
2274
2275        fn peel(o: Opt) -> Vec<OsString> {
2276            match o {
2277                Opt::Internals(InternalsOpts::OstreeExt { args }) => args,
2278                o => panic!("unexpected {o:?}"),
2279            }
2280        }
2281        let args = peel(Opt::parse_including_static([
2282            "/usr/libexec/libostree/ext/ostree-ima-sign",
2283            "ima-sign",
2284            "--repo=foo",
2285            "foo",
2286            "bar",
2287            "baz",
2288        ]));
2289        assert_eq!(
2290            args.as_slice(),
2291            ["ima-sign", "--repo=foo", "foo", "bar", "baz"]
2292        );
2293
2294        let args = peel(Opt::parse_including_static([
2295            "/usr/libexec/libostree/ext/ostree-container",
2296            "container",
2297            "image",
2298            "pull",
2299        ]));
2300        assert_eq!(args.as_slice(), ["container", "image", "pull"]);
2301    }
2302
2303    #[test]
2304    fn test_parse_upgrade_options() {
2305        // Test upgrade with --tag
2306        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1"]).unwrap();
2307        match o {
2308            Opt::Upgrade(opts) => {
2309                assert_eq!(opts.tag, Some("v1.1".to_string()));
2310            }
2311            _ => panic!("Expected Upgrade variant"),
2312        }
2313
2314        // Test that --tag works with --check (should compose naturally)
2315        let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1", "--check"]).unwrap();
2316        match o {
2317            Opt::Upgrade(opts) => {
2318                assert_eq!(opts.tag, Some("v1.1".to_string()));
2319                assert!(opts.check);
2320            }
2321            _ => panic!("Expected Upgrade variant"),
2322        }
2323    }
2324
2325    #[test]
2326    fn test_image_reference_with_tag() {
2327        // Test basic tag replacement for registry transport
2328        let current = ImageReference {
2329            image: "quay.io/example/myapp:v1.0".to_string(),
2330            transport: "registry".to_string(),
2331            signature: None,
2332        };
2333        let result = current.with_tag("v1.1").unwrap();
2334        assert_eq!(result.image, "quay.io/example/myapp:v1.1");
2335        assert_eq!(result.transport, "registry");
2336
2337        // Test tag replacement with digest (digest should be stripped for registry)
2338        let current_with_digest = ImageReference {
2339            image: "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
2340            transport: "registry".to_string(),
2341            signature: None,
2342        };
2343        let result = current_with_digest.with_tag("v2.0").unwrap();
2344        assert_eq!(result.image, "quay.io/example/myapp:v2.0");
2345
2346        // Test that non-registry transport works (containers-storage)
2347        let containers_storage = ImageReference {
2348            image: "localhost/myapp:v1.0".to_string(),
2349            transport: "containers-storage".to_string(),
2350            signature: None,
2351        };
2352        let result = containers_storage.with_tag("v1.1").unwrap();
2353        assert_eq!(result.image, "localhost/myapp:v1.1");
2354        assert_eq!(result.transport, "containers-storage");
2355
2356        // Test digest stripping for non-registry transport
2357        let containers_storage_with_digest = ImageReference {
2358            image:
2359                "localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
2360                    .to_string(),
2361            transport: "containers-storage".to_string(),
2362            signature: None,
2363        };
2364        let result = containers_storage_with_digest.with_tag("v2.0").unwrap();
2365        assert_eq!(result.image, "localhost/myapp:v2.0");
2366        assert_eq!(result.transport, "containers-storage");
2367
2368        // Test image without tag (edge case)
2369        let no_tag = ImageReference {
2370            image: "localhost/myapp".to_string(),
2371            transport: "containers-storage".to_string(),
2372            signature: None,
2373        };
2374        let result = no_tag.with_tag("v1.0").unwrap();
2375        assert_eq!(result.image, "localhost/myapp:v1.0");
2376        assert_eq!(result.transport, "containers-storage");
2377    }
2378
2379    #[test]
2380    fn test_generate_completion_scripts_contain_commands() {
2381        use clap_complete::aot::{Shell, generate};
2382
2383        // For each supported shell, generate the completion script and
2384        // ensure obvious subcommands appear in the output. This mirrors
2385        // the style of completion checks used in other projects (e.g.
2386        // podman) where the generated script is examined for expected
2387        // tokens.
2388
2389        // `completion` is intentionally hidden from --help / suggestions;
2390        // ensure other visible subcommands are present instead.
2391        let want = ["install", "upgrade"];
2392
2393        for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
2394            let mut cmd = Opt::command();
2395            let mut buf = Vec::new();
2396            generate(shell, &mut cmd, "bootc", &mut buf);
2397            let s = String::from_utf8(buf).expect("completion should be utf8");
2398            for w in &want {
2399                assert!(s.contains(w), "{shell:?} completion missing {w}");
2400            }
2401        }
2402    }
2403}