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