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::{Context, Result, anyhow, ensure};
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, reset_soft_reboot};
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        #[clap(required_unless_present = "reset")]
622        deployment: Option<String>,
623        #[clap(long, conflicts_with = "reset")]
624        reboot: bool,
625        #[clap(long, conflicts_with = "reboot")]
626        reset: bool,
627    },
628}
629
630#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
631pub(crate) enum StateOpts {
632    /// Remove all ostree deployments from this system
633    WipeOstree,
634}
635
636impl InternalsOpts {
637    /// The name of the binary we inject into /usr/lib/systemd/system-generators
638    const GENERATOR_BIN: &'static str = "bootc-systemd-generator";
639}
640
641/// Deploy and transactionally in-place with bootable container images.
642///
643/// The `bootc` project currently uses ostree-containers as a backend
644/// to support a model of bootable container images.  Once installed,
645/// whether directly via `bootc install` (executed as part of a container)
646/// or via another mechanism such as an OS installer tool, further
647/// updates can be pulled and `bootc upgrade`.
648#[derive(Debug, Parser, PartialEq, Eq)]
649#[clap(name = "bootc")]
650#[clap(rename_all = "kebab-case")]
651#[clap(version,long_version=clap::crate_version!())]
652#[allow(clippy::large_enum_variant)]
653pub(crate) enum Opt {
654    /// Download and queue an updated container image to apply.
655    ///
656    /// This does not affect the running system; updates operate in an "A/B" style by default.
657    ///
658    /// A queued update is visible as `staged` in `bootc status`.
659    ///
660    /// Currently by default, the update will be applied at shutdown time via `ostree-finalize-staged.service`.
661    /// There is also an explicit `bootc upgrade --apply` verb which will automatically take action (rebooting)
662    /// if the system has changed.
663    ///
664    /// However, in the future this is likely to change such that reboots outside of a `bootc upgrade --apply`
665    /// do *not* automatically apply the update in addition.
666    #[clap(alias = "update")]
667    Upgrade(UpgradeOpts),
668    /// Target a new container image reference to boot.
669    ///
670    /// This is almost exactly the same operation as `upgrade`, but additionally changes the container image reference
671    /// instead.
672    ///
673    /// ## Usage
674    ///
675    /// A common pattern is to have a management agent control operating system updates via container image tags;
676    /// for example, `quay.io/exampleos/someuser:v1.0` and `quay.io/exampleos/someuser:v1.1` where some machines
677    /// are tracking `:v1.0`, and as a rollout progresses, machines can be switched to `v:1.1`.
678    Switch(SwitchOpts),
679    /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot,
680    /// and the current will become rollback.  If there is a `staged` entry (an unapplied, queued upgrade)
681    /// then it will be discarded.
682    ///
683    /// Note that absent any additional control logic, if there is an active agent doing automated upgrades
684    /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the
685    /// change here may be reverted.  It's recommended to only use this in concert with an agent that
686    /// is in active control.
687    ///
688    /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in
689    /// order to detect a rollback invocation.
690    #[command(after_help = indoc! {r#"
691        Note on Rollbacks and the `/etc` Directory:
692
693        When you perform a rollback (e.g., with `bootc rollback`), any
694        changes made to files in the `/etc` directory won't carry over
695        to the rolled-back deployment.  The `/etc` files will revert
696        to their state from that previous deployment instead.
697
698        This is because `bootc rollback` just reorders the existing
699        deployments. It doesn't create new deployments. The `/etc`
700        merges happen when new deployments are created.
701    "#})]
702    Rollback(RollbackOpts),
703    /// Apply full changes to the host specification.
704    ///
705    /// This command operates very similarly to `kubectl apply`; if invoked interactively,
706    /// then the current host specification will be presented in the system default `$EDITOR`
707    /// for interactive changes.
708    ///
709    /// It is also possible to directly provide new contents via `bootc edit --filename`.
710    ///
711    /// Only changes to the `spec` section are honored.
712    Edit(EditOpts),
713    /// Display status.
714    ///
715    /// Shows bootc system state. Outputs YAML by default, human-readable if terminal detected.
716    Status(StatusOpts),
717    /// Add a transient writable overlayfs on `/usr`.
718    ///
719    /// Allows temporary package installation that will be discarded on reboot.
720    #[clap(alias = "usroverlay")]
721    UsrOverlay,
722    /// Install the running container to a target.
723    ///
724    /// Takes a container image and installs it to disk in a bootable format.
725    #[clap(subcommand)]
726    Install(InstallOpts),
727    /// Operations which can be executed as part of a container build.
728    #[clap(subcommand)]
729    Container(ContainerOpts),
730    /// Operations on container images.
731    ///
732    /// Stability: This interface may change in the future.
733    #[clap(subcommand, hide = true)]
734    Image(ImageOpts),
735    /// Execute the given command in the host mount namespace
736    #[clap(hide = true)]
737    ExecInHostMountNamespace {
738        #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
739        args: Vec<OsString>,
740    },
741    /// Modify the state of the system
742    #[clap(hide = true)]
743    #[clap(subcommand)]
744    State(StateOpts),
745    #[clap(subcommand)]
746    #[clap(hide = true)]
747    Internals(InternalsOpts),
748    ComposefsFinalizeStaged,
749    /// Diff current /etc configuration versus default
750    #[clap(hide = true)]
751    ConfigDiff,
752    /// Generate shell completion script for supported shells.
753    ///
754    /// Example: `bootc completion bash` prints a bash completion script to stdout.
755    #[clap(hide = true)]
756    Completion {
757        /// Shell type to generate (bash, zsh, fish)
758        #[clap(value_enum)]
759        shell: clap_complete::aot::Shell,
760    },
761    #[clap(hide = true)]
762    DeleteDeployment {
763        depl_id: String,
764    },
765}
766
767/// Ensure we've entered a mount namespace, so that we can remount
768/// `/sysroot` read-write
769/// TODO use https://github.com/ostreedev/ostree/pull/2779 once
770/// we can depend on a new enough ostree
771#[context("Ensuring mountns")]
772pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> {
773    let uid = rustix::process::getuid();
774    if !uid.is_root() {
775        tracing::debug!("Not root, assuming no need to unshare");
776        return Ok(());
777    }
778    let recurse_env = "_ostree_unshared";
779    let ns_pid1 = std::fs::read_link("/proc/1/ns/mnt").context("Reading /proc/1/ns/mnt")?;
780    let ns_self = std::fs::read_link("/proc/self/ns/mnt").context("Reading /proc/self/ns/mnt")?;
781    // If we already appear to be in a mount namespace, or we're already pid1, we're done
782    if ns_pid1 != ns_self {
783        tracing::debug!("Already in a mount namespace");
784        return Ok(());
785    }
786    if std::env::var_os(recurse_env).is_some() {
787        let am_pid1 = rustix::process::getpid().is_init();
788        if am_pid1 {
789            tracing::debug!("We are pid 1");
790            return Ok(());
791        } else {
792            anyhow::bail!("Failed to unshare mount namespace");
793        }
794    }
795    bootc_utils::reexec::reexec_with_guardenv(recurse_env, &["unshare", "-m", "--"])
796}
797
798/// Load global storage state, expecting that we're booted into a bootc system.
799/// This prepares the process for write operations (re-exec, mount namespace, etc).
800#[context("Initializing storage")]
801pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
802    let env = crate::store::Environment::detect()?;
803    // Always call prepare_for_write() for write operations - it checks
804    // for container, root privileges, mount namespace setup, etc.
805    prepare_for_write()?;
806    let r = BootedStorage::new(env)
807        .await?
808        .ok_or_else(|| anyhow!("System not booted via bootc"))?;
809    Ok(r)
810}
811
812#[context("Querying root privilege")]
813pub(crate) fn require_root(is_container: bool) -> Result<()> {
814    ensure!(
815        rustix::process::getuid().is_root(),
816        if is_container {
817            "The user inside the container from which you are running this command must be root"
818        } else {
819            "This command must be executed as the root user"
820        }
821    );
822
823    ensure!(
824        rustix::thread::capability_is_in_bounding_set(rustix::thread::CapabilitySet::SYS_ADMIN)?,
825        if is_container {
826            "The container must be executed with full privileges (e.g. --privileged flag)"
827        } else {
828            "This command requires full root privileges (CAP_SYS_ADMIN)"
829        }
830    );
831
832    tracing::trace!("Verified uid 0 with CAP_SYS_ADMIN");
833
834    Ok(())
835}
836
837/// Check if a deployment has soft reboot capability
838fn has_soft_reboot_capability(deployment: Option<&crate::spec::BootEntry>) -> bool {
839    deployment.map(|d| d.soft_reboot_capable).unwrap_or(false)
840}
841
842/// Prepare a soft reboot for the given deployment
843#[context("Preparing soft reboot")]
844fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> Result<()> {
845    let cancellable = ostree::gio::Cancellable::NONE;
846    sysroot
847        .deployment_set_soft_reboot(deployment, false, cancellable)
848        .context("Failed to prepare soft-reboot")?;
849    Ok(())
850}
851
852/// Handle soft reboot based on the configured mode
853#[context("Handling soft reboot")]
854fn handle_soft_reboot<F>(
855    soft_reboot_mode: Option<SoftRebootMode>,
856    entry: Option<&crate::spec::BootEntry>,
857    deployment_type: &str,
858    execute_soft_reboot: F,
859) -> Result<()>
860where
861    F: FnOnce() -> Result<()>,
862{
863    let Some(mode) = soft_reboot_mode else {
864        return Ok(());
865    };
866
867    let can_soft_reboot = has_soft_reboot_capability(entry);
868    match mode {
869        SoftRebootMode::Required => {
870            if can_soft_reboot {
871                execute_soft_reboot()?;
872            } else {
873                anyhow::bail!(
874                    "Soft reboot was required but {} deployment is not soft-reboot capable",
875                    deployment_type
876                );
877            }
878        }
879        SoftRebootMode::Auto => {
880            if can_soft_reboot {
881                execute_soft_reboot()?;
882            }
883        }
884    }
885    Ok(())
886}
887
888/// Handle soft reboot for staged deployments (used by upgrade and switch)
889#[context("Handling staged soft reboot")]
890fn handle_staged_soft_reboot(
891    booted_ostree: &BootedOstree<'_>,
892    soft_reboot_mode: Option<SoftRebootMode>,
893    host: &crate::spec::Host,
894) -> Result<()> {
895    handle_soft_reboot(
896        soft_reboot_mode,
897        host.status.staged.as_ref(),
898        "staged",
899        || soft_reboot_staged(booted_ostree.sysroot),
900    )
901}
902
903/// Perform a soft reboot for a staged deployment
904#[context("Soft reboot staged deployment")]
905fn soft_reboot_staged(sysroot: &SysrootLock) -> Result<()> {
906    println!("Staged deployment is soft-reboot capable, preparing for soft-reboot...");
907
908    let deployments_list = sysroot.deployments();
909    let staged_deployment = deployments_list
910        .iter()
911        .find(|d| d.is_staged())
912        .ok_or_else(|| anyhow::anyhow!("Failed to find staged deployment"))?;
913
914    prepare_soft_reboot(sysroot, staged_deployment)?;
915    Ok(())
916}
917
918/// Perform a soft reboot for a rollback deployment
919#[context("Soft reboot rollback deployment")]
920fn soft_reboot_rollback(booted_ostree: &BootedOstree<'_>) -> Result<()> {
921    println!("Rollback deployment is soft-reboot capable, preparing for soft-reboot...");
922
923    let deployments_list = booted_ostree.sysroot.deployments();
924    let target_deployment = deployments_list
925        .first()
926        .ok_or_else(|| anyhow::anyhow!("No rollback deployment found!"))?;
927
928    prepare_soft_reboot(booted_ostree.sysroot, target_deployment)
929}
930
931/// A few process changes that need to be made for writing.
932/// IMPORTANT: This may end up re-executing the current process,
933/// so anything that happens before this should be idempotent.
934#[context("Preparing for write")]
935pub(crate) fn prepare_for_write() -> Result<()> {
936    use std::sync::atomic::{AtomicBool, Ordering};
937
938    // This is intending to give "at most once" semantics to this
939    // function. We should never invoke this from multiple threads
940    // at the same time, but verifying "on main thread" is messy.
941    // Yes, using SeqCst is likely overkill, but there is nothing perf
942    // sensitive about this.
943    static ENTERED: AtomicBool = AtomicBool::new(false);
944    if ENTERED.load(Ordering::SeqCst) {
945        return Ok(());
946    }
947    if ostree_ext::container_utils::running_in_container() {
948        anyhow::bail!("Detected container; this command requires a booted host system.");
949    }
950    crate::cli::require_root(false)?;
951    ensure_self_unshared_mount_namespace()?;
952    if crate::lsm::selinux_enabled()? && !crate::lsm::selinux_ensure_install()? {
953        tracing::debug!("Do not have install_t capabilities");
954    }
955    ENTERED.store(true, Ordering::SeqCst);
956    Ok(())
957}
958
959/// Implementation of the `bootc upgrade` CLI command.
960#[context("Upgrading")]
961async fn upgrade(
962    opts: UpgradeOpts,
963    storage: &Storage,
964    booted_ostree: &BootedOstree<'_>,
965) -> Result<()> {
966    let repo = &booted_ostree.repo();
967
968    let host = crate::status::get_status(booted_ostree)?.1;
969    let imgref = host.spec.image.as_ref();
970    let prog: ProgressWriter = opts.progress.try_into()?;
971
972    // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
973    if imgref.is_none() {
974        let booted_incompatible = host.status.booted.as_ref().is_some_and(|b| b.incompatible);
975
976        let staged_incompatible = host.status.staged.as_ref().is_some_and(|b| b.incompatible);
977
978        if booted_incompatible || staged_incompatible {
979            return Err(anyhow::anyhow!(
980                "Deployment contains local rpm-ostree modifications; cannot upgrade via bootc. You can run `rpm-ostree reset` to undo the modifications."
981            ));
982        }
983    }
984
985    let spec = RequiredHostSpec::from_spec(&host.spec)?;
986    let booted_image = host
987        .status
988        .booted
989        .as_ref()
990        .map(|b| b.query_image(repo))
991        .transpose()?
992        .flatten();
993    let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
994    // Find the currently queued digest, if any before we pull
995    let staged = host.status.staged.as_ref();
996    let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
997    let mut changed = false;
998
999    // Handle --from-downloaded: unlock existing staged deployment without fetching from image source
1000    if opts.from_downloaded {
1001        let ostree = storage.get_ostree()?;
1002        let staged_deployment = ostree
1003            .staged_deployment()
1004            .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
1005
1006        if staged_deployment.is_finalization_locked() {
1007            ostree.change_finalization(&staged_deployment)?;
1008            println!("Staged deployment will now be applied on reboot");
1009        } else {
1010            println!("Staged deployment is already set to apply on reboot");
1011        }
1012
1013        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1014        if opts.apply {
1015            crate::reboot::reboot()?;
1016        }
1017        return Ok(());
1018    }
1019
1020    if opts.check {
1021        let imgref = imgref.clone().into();
1022        let mut imp = crate::deploy::new_importer(repo, &imgref).await?;
1023        match imp.prepare().await? {
1024            PrepareResult::AlreadyPresent(_) => {
1025                println!("No changes in: {imgref:#}");
1026            }
1027            PrepareResult::Ready(r) => {
1028                crate::deploy::check_bootc_label(&r.config);
1029                println!("Update available for: {imgref:#}");
1030                if let Some(version) = r.version() {
1031                    println!("  Version: {version}");
1032                }
1033                println!("  Digest: {}", r.manifest_digest);
1034                changed = true;
1035                if let Some(previous_image) = booted_image.as_ref() {
1036                    let diff =
1037                        ostree_container::ManifestDiff::new(&previous_image.manifest, &r.manifest);
1038                    diff.print();
1039                }
1040            }
1041        }
1042    } else {
1043        // Auto-detect whether to use unified storage based on image presence in bootc storage
1044        let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?;
1045
1046        let fetched = if use_unified {
1047            crate::deploy::pull_unified(repo, imgref, None, opts.quiet, prog.clone(), storage)
1048                .await?
1049        } else {
1050            crate::deploy::pull(repo, imgref, None, opts.quiet, prog.clone()).await?
1051        };
1052        let staged_digest = staged_image.map(|s| s.digest().expect("valid digest in status"));
1053        let fetched_digest = &fetched.manifest_digest;
1054        tracing::debug!("staged: {staged_digest:?}");
1055        tracing::debug!("fetched: {fetched_digest}");
1056        let staged_unchanged = staged_digest
1057            .as_ref()
1058            .map(|d| d == fetched_digest)
1059            .unwrap_or_default();
1060        let booted_unchanged = booted_image
1061            .as_ref()
1062            .map(|img| &img.manifest_digest == fetched_digest)
1063            .unwrap_or_default();
1064        if staged_unchanged {
1065            let staged_deployment = storage.get_ostree()?.staged_deployment();
1066            let mut download_only_changed = false;
1067
1068            if let Some(staged) = staged_deployment {
1069                // Handle download-only mode based on flags
1070                if opts.download_only {
1071                    // --download-only: set download-only mode
1072                    if !staged.is_finalization_locked() {
1073                        storage.get_ostree()?.change_finalization(&staged)?;
1074                        println!("Image downloaded, but will not be applied on reboot");
1075                        download_only_changed = true;
1076                    }
1077                } else if !opts.check {
1078                    // --apply or no flags: clear download-only mode
1079                    // (skip if --check, which is read-only)
1080                    if staged.is_finalization_locked() {
1081                        storage.get_ostree()?.change_finalization(&staged)?;
1082                        println!("Staged deployment will now be applied on reboot");
1083                        download_only_changed = true;
1084                    }
1085                }
1086            } else if opts.download_only || opts.apply {
1087                anyhow::bail!("No staged deployment found");
1088            }
1089
1090            if !download_only_changed {
1091                println!("Staged update present, not changed");
1092            }
1093
1094            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
1095            if opts.apply {
1096                crate::reboot::reboot()?;
1097            }
1098        } else if booted_unchanged {
1099            println!("No update available.")
1100        } else {
1101            let stateroot = booted_ostree.stateroot();
1102            let from = MergeState::from_stateroot(storage, &stateroot)?;
1103            crate::deploy::stage(
1104                storage,
1105                from,
1106                &fetched,
1107                &spec,
1108                prog.clone(),
1109                opts.download_only,
1110            )
1111            .await?;
1112            changed = true;
1113            if let Some(prev) = booted_image.as_ref() {
1114                if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
1115                    let diff =
1116                        ostree_container::ManifestDiff::new(&prev.manifest, &fetched_manifest);
1117                    diff.print();
1118                }
1119            }
1120        }
1121    }
1122    if changed {
1123        storage.update_mtime()?;
1124
1125        if opts.soft_reboot.is_some() {
1126            // At this point we have new staged deployment and the host definition has changed.
1127            // We need the updated host status before we check if we can prepare the soft-reboot.
1128            let updated_host = crate::status::get_status(booted_ostree)?.1;
1129            handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1130        }
1131
1132        if opts.apply {
1133            crate::reboot::reboot()?;
1134        }
1135    } else {
1136        tracing::debug!("No changes");
1137    }
1138
1139    Ok(())
1140}
1141
1142pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> {
1143    let transport = ostree_container::Transport::try_from(opts.transport.as_str())?;
1144    let imgref = ostree_container::ImageReference {
1145        transport,
1146        name: opts.target.to_string(),
1147    };
1148    let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy);
1149    let target = ostree_container::OstreeImageReference { sigverify, imgref };
1150    let target = ImageReference::from(target);
1151
1152    return Ok(target);
1153}
1154
1155/// Implementation of the `bootc switch` CLI command for ostree backend.
1156#[context("Switching (ostree)")]
1157async fn switch_ostree(
1158    opts: SwitchOpts,
1159    storage: &Storage,
1160    booted_ostree: &BootedOstree<'_>,
1161) -> Result<()> {
1162    let target = imgref_for_switch(&opts)?;
1163    let prog: ProgressWriter = opts.progress.try_into()?;
1164    let cancellable = gio::Cancellable::NONE;
1165
1166    let repo = &booted_ostree.repo();
1167    let (_, host) = crate::status::get_status(booted_ostree)?;
1168
1169    let new_spec = {
1170        let mut new_spec = host.spec.clone();
1171        new_spec.image = Some(target.clone());
1172        new_spec
1173    };
1174
1175    if new_spec == host.spec {
1176        println!("Image specification is unchanged.");
1177        return Ok(());
1178    }
1179
1180    // Log the switch operation to systemd journal
1181    const SWITCH_JOURNAL_ID: &str = "7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1";
1182    let old_image = host
1183        .spec
1184        .image
1185        .as_ref()
1186        .map(|i| i.image.as_str())
1187        .unwrap_or("none");
1188
1189    tracing::info!(
1190        message_id = SWITCH_JOURNAL_ID,
1191        bootc.old_image_reference = old_image,
1192        bootc.new_image_reference = &target.image,
1193        bootc.new_image_transport = &target.transport,
1194        "Switching from image {} to {}",
1195        old_image,
1196        target.image
1197    );
1198
1199    let new_spec = RequiredHostSpec::from_spec(&new_spec)?;
1200
1201    // Determine whether to use unified storage path.
1202    // If explicitly requested via flag, use unified storage directly.
1203    // Otherwise, auto-detect based on whether the image exists in bootc storage.
1204    let use_unified = if opts.unified_storage_exp {
1205        true
1206    } else {
1207        crate::deploy::image_exists_in_unified_storage(storage, &target).await?
1208    };
1209
1210    let fetched = if use_unified {
1211        crate::deploy::pull_unified(repo, &target, None, opts.quiet, prog.clone(), storage).await?
1212    } else {
1213        crate::deploy::pull(repo, &target, None, opts.quiet, prog.clone()).await?
1214    };
1215
1216    if !opts.retain {
1217        // By default, we prune the previous ostree ref so it will go away after later upgrades
1218        if let Some(booted_origin) = booted_ostree.deployment.origin() {
1219            if let Some(ostree_ref) = booted_origin.optional_string("origin", "refspec")? {
1220                let (remote, ostree_ref) =
1221                    ostree::parse_refspec(&ostree_ref).context("Failed to parse ostree ref")?;
1222                repo.set_ref_immediate(remote.as_deref(), &ostree_ref, None, cancellable)?;
1223            }
1224        }
1225    }
1226
1227    let stateroot = booted_ostree.stateroot();
1228    let from = MergeState::from_stateroot(storage, &stateroot)?;
1229    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1230
1231    storage.update_mtime()?;
1232
1233    if opts.soft_reboot.is_some() {
1234        // At this point we have staged the deployment and the host definition has changed.
1235        // We need the updated host status before we check if we can prepare the soft-reboot.
1236        let updated_host = crate::status::get_status(booted_ostree)?.1;
1237        handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?;
1238    }
1239
1240    if opts.apply {
1241        crate::reboot::reboot()?;
1242    }
1243
1244    Ok(())
1245}
1246
1247/// Implementation of the `bootc switch` CLI command.
1248#[context("Switching")]
1249async fn switch(opts: SwitchOpts) -> Result<()> {
1250    // If we're doing an in-place mutation, we shortcut most of the rest of the work here
1251    // TODO: what we really want here is Storage::detect_from_root() that also handles
1252    // composefs. But for now this just assumes ostree.
1253    if opts.mutate_in_place {
1254        let target = imgref_for_switch(&opts)?;
1255        let deployid = {
1256            // Clone to pass into helper thread
1257            let target = target.clone();
1258            let root = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1259            tokio::task::spawn_blocking(move || {
1260                crate::deploy::switch_origin_inplace(&root, &target)
1261            })
1262            .await??
1263        };
1264        println!("Updated {deployid} to pull from {target}");
1265        return Ok(());
1266    }
1267    let storage = &get_storage().await?;
1268    match storage.kind()? {
1269        BootedStorageKind::Ostree(booted_ostree) => {
1270            switch_ostree(opts, storage, &booted_ostree).await
1271        }
1272        BootedStorageKind::Composefs(booted_cfs) => {
1273            switch_composefs(opts, storage, &booted_cfs).await
1274        }
1275    }
1276}
1277
1278/// Implementation of the `bootc rollback` CLI command for ostree backend.
1279#[context("Rollback (ostree)")]
1280async fn rollback_ostree(
1281    opts: &RollbackOpts,
1282    storage: &Storage,
1283    booted_ostree: &BootedOstree<'_>,
1284) -> Result<()> {
1285    crate::deploy::rollback(storage).await?;
1286
1287    if opts.soft_reboot.is_some() {
1288        // Get status of rollback deployment to check soft-reboot capability
1289        let host = crate::status::get_status(booted_ostree)?.1;
1290
1291        handle_soft_reboot(
1292            opts.soft_reboot,
1293            host.status.rollback.as_ref(),
1294            "rollback",
1295            || soft_reboot_rollback(booted_ostree),
1296        )?;
1297    }
1298
1299    Ok(())
1300}
1301
1302/// Implementation of the `bootc rollback` CLI command.
1303#[context("Rollback")]
1304async fn rollback(opts: &RollbackOpts) -> Result<()> {
1305    let storage = &get_storage().await?;
1306    match storage.kind()? {
1307        BootedStorageKind::Ostree(booted_ostree) => {
1308            rollback_ostree(opts, storage, &booted_ostree).await
1309        }
1310        BootedStorageKind::Composefs(booted_cfs) => composefs_rollback(storage, &booted_cfs).await,
1311    }
1312}
1313
1314/// Implementation of the `bootc edit` CLI command for ostree backend.
1315#[context("Editing spec (ostree)")]
1316async fn edit_ostree(
1317    opts: EditOpts,
1318    storage: &Storage,
1319    booted_ostree: &BootedOstree<'_>,
1320) -> Result<()> {
1321    let repo = &booted_ostree.repo();
1322    let (_, host) = crate::status::get_status(booted_ostree)?;
1323
1324    let new_host: Host = if let Some(filename) = opts.filename {
1325        let mut r = std::io::BufReader::new(std::fs::File::open(filename)?);
1326        serde_yaml::from_reader(&mut r)?
1327    } else {
1328        let tmpf = tempfile::NamedTempFile::with_suffix(".yaml")?;
1329        serde_yaml::to_writer(std::io::BufWriter::new(tmpf.as_file()), &host)?;
1330        crate::utils::spawn_editor(&tmpf)?;
1331        tmpf.as_file().seek(std::io::SeekFrom::Start(0))?;
1332        serde_yaml::from_reader(&mut tmpf.as_file())?
1333    };
1334
1335    if new_host.spec == host.spec {
1336        println!("Edit cancelled, no changes made.");
1337        return Ok(());
1338    }
1339    host.spec.verify_transition(&new_host.spec)?;
1340    let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?;
1341
1342    let prog = ProgressWriter::default();
1343
1344    // We only support two state transitions right now; switching the image,
1345    // or flipping the bootloader ordering.
1346    if host.spec.boot_order != new_host.spec.boot_order {
1347        return crate::deploy::rollback(storage).await;
1348    }
1349
1350    let fetched = crate::deploy::pull(repo, new_spec.image, None, opts.quiet, prog.clone()).await?;
1351
1352    // TODO gc old layers here
1353
1354    let stateroot = booted_ostree.stateroot();
1355    let from = MergeState::from_stateroot(storage, &stateroot)?;
1356    crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
1357
1358    storage.update_mtime()?;
1359
1360    Ok(())
1361}
1362
1363/// Implementation of the `bootc edit` CLI command.
1364#[context("Editing spec")]
1365async fn edit(opts: EditOpts) -> Result<()> {
1366    let storage = &get_storage().await?;
1367    match storage.kind()? {
1368        BootedStorageKind::Ostree(booted_ostree) => {
1369            edit_ostree(opts, storage, &booted_ostree).await
1370        }
1371        BootedStorageKind::Composefs(_) => {
1372            anyhow::bail!("Edit is not yet supported for composefs backend")
1373        }
1374    }
1375}
1376
1377/// Implementation of `bootc usroverlay`
1378async fn usroverlay() -> Result<()> {
1379    // This is just a pass-through today.  At some point we may make this a libostree API
1380    // or even oxidize it.
1381    Err(Command::new("ostree")
1382        .args(["admin", "unlock"])
1383        .exec()
1384        .into())
1385}
1386
1387/// Perform process global initialization. This should be called as early as possible
1388/// in the standard `main` function.
1389#[allow(unsafe_code)]
1390pub fn global_init() -> Result<()> {
1391    // In some cases we re-exec with a temporary binary,
1392    // so ensure that the syslog identifier is set.
1393    ostree::glib::set_prgname(bootc_utils::NAME.into());
1394    if let Err(e) = rustix::thread::set_name(&CString::new(bootc_utils::NAME).unwrap()) {
1395        // This shouldn't ever happen
1396        eprintln!("failed to set name: {e}");
1397    }
1398    // Silence SELinux log warnings
1399    ostree::SePolicy::set_null_log();
1400    let am_root = rustix::process::getuid().is_root();
1401    // Work around bootc-image-builder not setting HOME, in combination with podman (really c/common)
1402    // bombing out if it is unset.
1403    if std::env::var_os("HOME").is_none() && am_root {
1404        // Setting the environment is thread-unsafe, but we ask calling code
1405        // to invoke this as early as possible. (In practice, that's just the cli's `main.rs`)
1406        // xref https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475
1407        // SAFETY: Called early in main() before any threads are spawned.
1408        unsafe {
1409            std::env::set_var("HOME", "/root");
1410        }
1411    }
1412    Ok(())
1413}
1414
1415/// Parse the provided arguments and execute.
1416/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program.
1417pub async fn run_from_iter<I>(args: I) -> Result<()>
1418where
1419    I: IntoIterator,
1420    I::Item: Into<OsString> + Clone,
1421{
1422    run_from_opt(Opt::parse_including_static(args)).await
1423}
1424
1425/// Find the base binary name from argv0 (without a full path). The empty string
1426/// is never returned; instead a fallback string is used. If the input is not valid
1427/// UTF-8, a default is used.
1428fn callname_from_argv0(argv0: &OsStr) -> &str {
1429    let default = "bootc";
1430    std::path::Path::new(argv0)
1431        .file_name()
1432        .and_then(|s| s.to_str())
1433        .filter(|s| !s.is_empty())
1434        .unwrap_or(default)
1435}
1436
1437impl Opt {
1438    /// In some cases (e.g. systemd generator) we dispatch specifically on argv0.  This
1439    /// requires some special handling in clap.
1440    fn parse_including_static<I>(args: I) -> Self
1441    where
1442        I: IntoIterator,
1443        I::Item: Into<OsString> + Clone,
1444    {
1445        let mut args = args.into_iter();
1446        let first = if let Some(first) = args.next() {
1447            let first: OsString = first.into();
1448            let argv0 = callname_from_argv0(&first);
1449            tracing::debug!("argv0={argv0:?}");
1450            let mapped = match argv0 {
1451                InternalsOpts::GENERATOR_BIN => {
1452                    Some(["bootc", "internals", "systemd-generator"].as_slice())
1453                }
1454                "ostree-container" | "ostree-ima-sign" | "ostree-provisional-repair" => {
1455                    Some(["bootc", "internals", "ostree-ext"].as_slice())
1456                }
1457                _ => None,
1458            };
1459            if let Some(base_args) = mapped {
1460                let base_args = base_args.iter().map(OsString::from);
1461                return Opt::parse_from(base_args.chain(args.map(|i| i.into())));
1462            }
1463            Some(first)
1464        } else {
1465            None
1466        };
1467        Opt::parse_from(first.into_iter().chain(args.map(|i| i.into())))
1468    }
1469}
1470
1471/// Internal (non-generic/monomorphized) primary CLI entrypoint
1472async fn run_from_opt(opt: Opt) -> Result<()> {
1473    let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1474    match opt {
1475        Opt::Upgrade(opts) => {
1476            let storage = &get_storage().await?;
1477            match storage.kind()? {
1478                BootedStorageKind::Ostree(booted_ostree) => {
1479                    upgrade(opts, storage, &booted_ostree).await
1480                }
1481                BootedStorageKind::Composefs(booted_cfs) => {
1482                    upgrade_composefs(opts, storage, &booted_cfs).await
1483                }
1484            }
1485        }
1486        Opt::Switch(opts) => switch(opts).await,
1487        Opt::Rollback(opts) => {
1488            rollback(&opts).await?;
1489            if opts.apply {
1490                crate::reboot::reboot()?;
1491            }
1492            Ok(())
1493        }
1494        Opt::Edit(opts) => edit(opts).await,
1495        Opt::UsrOverlay => {
1496            use crate::store::Environment;
1497            let env = Environment::detect()?;
1498            match env {
1499                Environment::OstreeBooted => usroverlay().await,
1500                Environment::ComposefsBooted(_) => composefs_usr_overlay(),
1501                _ => anyhow::bail!("usroverlay only applies on booted hosts"),
1502            }
1503        }
1504        Opt::Container(opts) => match opts {
1505            ContainerOpts::Inspect {
1506                rootfs,
1507                json,
1508                format,
1509            } => crate::status::container_inspect(&rootfs, json, format),
1510            ContainerOpts::Lint {
1511                rootfs,
1512                fatal_warnings,
1513                list,
1514                skip,
1515                no_truncate,
1516            } => {
1517                if list {
1518                    return lints::lint_list(std::io::stdout().lock());
1519                }
1520                let warnings = if fatal_warnings {
1521                    lints::WarningDisposition::FatalWarnings
1522                } else {
1523                    lints::WarningDisposition::AllowWarnings
1524                };
1525                let root_type = if rootfs == "/" {
1526                    lints::RootType::Running
1527                } else {
1528                    lints::RootType::Alternative
1529                };
1530
1531                let root = &Dir::open_ambient_dir(rootfs, cap_std::ambient_authority())?;
1532                let skip = skip.iter().map(|s| s.as_str());
1533                lints::lint(
1534                    root,
1535                    warnings,
1536                    root_type,
1537                    skip,
1538                    std::io::stdout().lock(),
1539                    no_truncate,
1540                )?;
1541                Ok(())
1542            }
1543            ContainerOpts::ComputeComposefsDigest {
1544                path,
1545                write_dumpfile_to,
1546            } => {
1547                let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?;
1548                println!("{digest}");
1549                Ok(())
1550            }
1551            ContainerOpts::ComputeComposefsDigestFromStorage {
1552                write_dumpfile_to,
1553                image,
1554            } => {
1555                let (_td_guard, repo) = new_temp_composefs_repo()?;
1556
1557                let mut proxycfg = ImageProxyConfig::default();
1558
1559                let image = if let Some(image) = image {
1560                    image
1561                } else {
1562                    let host_container_store = Utf8Path::new("/run/host-container-storage");
1563                    // If no image is provided, assume that we're running in a container in privileged mode
1564                    // with access to the container storage.
1565                    let container_info = crate::containerenv::get_container_execution_info(&root)?;
1566                    let iid = container_info.imageid;
1567                    tracing::debug!("Computing digest of {iid}");
1568
1569                    if !host_container_store.try_exists()? {
1570                        anyhow::bail!(
1571                            "Must be readonly mount of host container store: {host_container_store}"
1572                        );
1573                    }
1574                    // And ensure we're finding the image in the host storage
1575                    let mut cmd = Command::new("skopeo");
1576                    set_additional_image_store(&mut cmd, "/run/host-container-storage");
1577                    proxycfg.skopeo_cmd = Some(cmd);
1578                    iid
1579                };
1580
1581                let imgref = format!("containers-storage:{image}");
1582                let (imgid, verity) = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg))
1583                    .await
1584                    .context("Pulling image")?;
1585                let imgid = hex::encode(imgid);
1586                let mut fs = composefs_oci::image::create_filesystem(&repo, &imgid, Some(&verity))
1587                    .context("Populating fs")?;
1588                fs.transform_for_boot(&repo).context("Preparing for boot")?;
1589                let id = fs.compute_image_id();
1590                println!("{}", id.to_hex());
1591
1592                if let Some(path) = write_dumpfile_to.as_deref() {
1593                    let mut w = File::create(path)
1594                        .with_context(|| format!("Opening {path}"))
1595                        .map(BufWriter::new)?;
1596                    dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
1597                }
1598
1599                Ok(())
1600            }
1601        },
1602        Opt::Completion { shell } => {
1603            use clap_complete::aot::generate;
1604
1605            let mut cmd = Opt::command();
1606            let mut stdout = std::io::stdout();
1607            let bin_name = "bootc";
1608            generate(shell, &mut cmd, bin_name, &mut stdout);
1609            Ok(())
1610        }
1611        Opt::Image(opts) => match opts {
1612            ImageOpts::List {
1613                list_type,
1614                list_format,
1615            } => crate::image::list_entrypoint(list_type, list_format).await,
1616
1617            ImageOpts::CopyToStorage { source, target } => {
1618                // We get "host" here to avoid deadlock in the ostree path
1619                let host = get_host().await?;
1620
1621                let storage = get_storage().await?;
1622
1623                match storage.kind()? {
1624                    BootedStorageKind::Ostree(..) => {
1625                        crate::image::push_entrypoint(
1626                            &storage,
1627                            &host,
1628                            source.as_deref(),
1629                            target.as_deref(),
1630                        )
1631                        .await
1632                    }
1633                    BootedStorageKind::Composefs(booted) => {
1634                        bootc_composefs::export::export_repo_to_image(
1635                            &storage,
1636                            &booted,
1637                            source.as_deref(),
1638                            target.as_deref(),
1639                        )
1640                        .await
1641                    }
1642                }
1643            }
1644            ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
1645            ImageOpts::PullFromDefaultStorage { image } => {
1646                let storage = get_storage().await?;
1647                storage
1648                    .get_ensure_imgstore()?
1649                    .pull_from_host_storage(&image)
1650                    .await
1651            }
1652            ImageOpts::Cmd(opt) => {
1653                let storage = get_storage().await?;
1654                let imgstore = storage.get_ensure_imgstore()?;
1655                match opt {
1656                    ImageCmdOpts::List { args } => {
1657                        crate::image::imgcmd_entrypoint(imgstore, "list", &args).await
1658                    }
1659                    ImageCmdOpts::Build { args } => {
1660                        crate::image::imgcmd_entrypoint(imgstore, "build", &args).await
1661                    }
1662                    ImageCmdOpts::Pull { args } => {
1663                        crate::image::imgcmd_entrypoint(imgstore, "pull", &args).await
1664                    }
1665                    ImageCmdOpts::Push { args } => {
1666                        crate::image::imgcmd_entrypoint(imgstore, "push", &args).await
1667                    }
1668                }
1669            }
1670        },
1671        Opt::Install(opts) => match opts {
1672            #[cfg(feature = "install-to-disk")]
1673            InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,
1674            InstallOpts::ToFilesystem(opts) => {
1675                crate::install::install_to_filesystem(opts, false, crate::install::Cleanup::Skip)
1676                    .await
1677            }
1678            InstallOpts::ToExistingRoot(opts) => {
1679                crate::install::install_to_existing_root(opts).await
1680            }
1681            InstallOpts::Reset(opts) => crate::install::install_reset(opts).await,
1682            InstallOpts::PrintConfiguration(opts) => crate::install::print_configuration(opts),
1683            InstallOpts::EnsureCompletion {} => {
1684                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1685                crate::install::completion::run_from_anaconda(rootfs).await
1686            }
1687            InstallOpts::Finalize { root_path } => {
1688                crate::install::install_finalize(&root_path).await
1689            }
1690        },
1691        Opt::ExecInHostMountNamespace { args } => {
1692            crate::install::exec_in_host_mountns(args.as_slice())
1693        }
1694        Opt::Status(opts) => super::status::status(opts).await,
1695        Opt::Internals(opts) => match opts {
1696            InternalsOpts::SystemdGenerator {
1697                normal_dir,
1698                early_dir: _,
1699                late_dir: _,
1700            } => {
1701                let unit_dir = &Dir::open_ambient_dir(normal_dir, cap_std::ambient_authority())?;
1702                crate::generator::generator(root, unit_dir)
1703            }
1704            InternalsOpts::OstreeExt { args } => {
1705                ostree_ext::cli::run_from_iter(["ostree-ext".into()].into_iter().chain(args)).await
1706            }
1707            InternalsOpts::OstreeContainer { args } => {
1708                ostree_ext::cli::run_from_iter(
1709                    ["ostree-ext".into(), "container".into()]
1710                        .into_iter()
1711                        .chain(args),
1712                )
1713                .await
1714            }
1715            InternalsOpts::TestComposefs => {
1716                // This is a stub to be replaced
1717                let storage = get_storage().await?;
1718                let cfs = storage.get_ensure_composefs()?;
1719                let testdata = b"some test data";
1720                let testdata_digest = hex::encode(openssl::sha::sha256(testdata));
1721                let mut w = SplitStreamWriter::new(&cfs, 0);
1722                w.write_inline(testdata);
1723                let object = cfs
1724                    .write_stream(w, &testdata_digest, Some("testobject"))?
1725                    .to_hex();
1726                assert_eq!(
1727                    object,
1728                    "dc31ae5d2f637e98d2171821d60d2fcafb8084d6a4bb3bd9cdc7ad41decce6e48f85d5413d22371d36b223945042f53a2a6ab449b8e45d8896ba7d8694a16681"
1729                );
1730                Ok(())
1731            }
1732            // We don't depend on fsverity-utils today, so re-expose some helpful CLI tools.
1733            InternalsOpts::Fsverity(args) => match args {
1734                FsverityOpts::Measure { path } => {
1735                    let fd =
1736                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
1737                    let digest: fsverity::Sha256HashValue = fsverity::measure_verity(&fd)?;
1738                    let digest = digest.to_hex();
1739                    println!("{digest}");
1740                    Ok(())
1741                }
1742                FsverityOpts::Enable { path } => {
1743                    let fd =
1744                        std::fs::File::open(&path).with_context(|| format!("Reading {path}"))?;
1745                    fsverity::enable_verity_raw::<fsverity::Sha256HashValue>(&fd)?;
1746                    Ok(())
1747                }
1748            },
1749            InternalsOpts::Cfs { args } => crate::cfsctl::run_from_iter(args.iter()).await,
1750            InternalsOpts::Reboot => crate::reboot::reboot(),
1751            InternalsOpts::Fsck => {
1752                let storage = &get_storage().await?;
1753                crate::fsck::fsck(&storage, std::io::stdout().lock()).await?;
1754                Ok(())
1755            }
1756            InternalsOpts::FixupEtcFstab => crate::deploy::fixup_etc_fstab(&root),
1757            InternalsOpts::PrintJsonSchema { of } => {
1758                let schema = match of {
1759                    SchemaType::Host => schema_for!(crate::spec::Host),
1760                    SchemaType::Progress => schema_for!(crate::progress_jsonl::Event),
1761                };
1762                let mut stdout = std::io::stdout().lock();
1763                serde_json::to_writer_pretty(&mut stdout, &schema)?;
1764                Ok(())
1765            }
1766            InternalsOpts::Cleanup => {
1767                let storage = get_storage().await?;
1768                crate::deploy::cleanup(&storage).await
1769            }
1770            InternalsOpts::Relabel { as_path, path } => {
1771                let root = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1772                let path = path.strip_prefix("/")?;
1773                let sepolicy =
1774                    &ostree::SePolicy::new(&gio::File::for_path("/"), gio::Cancellable::NONE)?;
1775                crate::lsm::relabel_recurse(root, path, as_path.as_deref(), sepolicy)?;
1776                Ok(())
1777            }
1778            InternalsOpts::BootcInstallCompletion { sysroot, stateroot } => {
1779                let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
1780                crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await
1781            }
1782            InternalsOpts::LoopbackCleanupHelper { device } => {
1783                crate::blockdev::run_loopback_cleanup_helper(&device).await
1784            }
1785            InternalsOpts::AllocateCleanupLoopback { file_path: _ } => {
1786                // Create a temporary file for testing
1787                let temp_file =
1788                    tempfile::NamedTempFile::new().context("Failed to create temporary file")?;
1789                let temp_path = temp_file.path();
1790
1791                // Create a loopback device
1792                let loopback = crate::blockdev::LoopbackDevice::new(temp_path)
1793                    .context("Failed to create loopback device")?;
1794
1795                println!("Created loopback device: {}", loopback.path());
1796
1797                // Close the device to test cleanup
1798                loopback
1799                    .close()
1800                    .context("Failed to close loopback device")?;
1801
1802                println!("Successfully closed loopback device");
1803                Ok(())
1804            }
1805            #[cfg(feature = "rhsm")]
1806            InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await,
1807            #[cfg(feature = "docgen")]
1808            InternalsOpts::DumpCliJson => {
1809                use clap::CommandFactory;
1810                let cmd = Opt::command();
1811                let json = crate::cli_json::dump_cli_json(&cmd)?;
1812                println!("{}", json);
1813                Ok(())
1814            }
1815            InternalsOpts::DirDiff {
1816                pristine_etc,
1817                current_etc,
1818                new_etc,
1819                merge,
1820            } => {
1821                let pristine_etc =
1822                    Dir::open_ambient_dir(pristine_etc, cap_std::ambient_authority())?;
1823                let current_etc = Dir::open_ambient_dir(current_etc, cap_std::ambient_authority())?;
1824                let new_etc = Dir::open_ambient_dir(new_etc, cap_std::ambient_authority())?;
1825
1826                let (p, c, n) =
1827                    etc_merge::traverse_etc(&pristine_etc, &current_etc, Some(&new_etc))?;
1828
1829                let n = n
1830                    .as_ref()
1831                    .ok_or_else(|| anyhow::anyhow!("Failed to get new directory tree"))?;
1832
1833                let diff = compute_diff(&p, &c, &n)?;
1834                print_diff(&diff, &mut std::io::stdout());
1835
1836                if merge {
1837                    etc_merge::merge(&current_etc, &c, &new_etc, &n, &diff)?;
1838                }
1839
1840                Ok(())
1841            }
1842            InternalsOpts::PrepSoftReboot {
1843                deployment,
1844                reboot,
1845                reset,
1846            } => {
1847                let storage = &get_storage().await?;
1848
1849                match storage.kind()? {
1850                    BootedStorageKind::Ostree(..) => {
1851                        // TODO: Call ostree implementation?
1852                        anyhow::bail!("soft-reboot only implemented for composefs")
1853                    }
1854
1855                    BootedStorageKind::Composefs(booted_cfs) => {
1856                        if reset {
1857                            return reset_soft_reboot();
1858                        }
1859
1860                        prepare_soft_reboot_composefs(
1861                            &storage,
1862                            &booted_cfs,
1863                            deployment.as_deref(),
1864                            SoftRebootMode::Required,
1865                            reboot,
1866                        )
1867                        .await
1868                    }
1869                }
1870            }
1871        },
1872        Opt::State(opts) => match opts {
1873            StateOpts::WipeOstree => {
1874                let sysroot = ostree::Sysroot::new_default();
1875                sysroot.load(gio::Cancellable::NONE)?;
1876                crate::deploy::wipe_ostree(sysroot).await?;
1877                Ok(())
1878            }
1879        },
1880
1881        Opt::ComposefsFinalizeStaged => {
1882            let storage = &get_storage().await?;
1883            match storage.kind()? {
1884                BootedStorageKind::Ostree(_) => {
1885                    anyhow::bail!("ComposefsFinalizeStaged is only supported for composefs backend")
1886                }
1887                BootedStorageKind::Composefs(booted_cfs) => {
1888                    composefs_backend_finalize(storage, &booted_cfs).await
1889                }
1890            }
1891        }
1892
1893        Opt::ConfigDiff => {
1894            let storage = &get_storage().await?;
1895            match storage.kind()? {
1896                BootedStorageKind::Ostree(_) => {
1897                    anyhow::bail!("ConfigDiff is only supported for composefs backend")
1898                }
1899                BootedStorageKind::Composefs(booted_cfs) => {
1900                    get_etc_diff(storage, &booted_cfs).await
1901                }
1902            }
1903        }
1904
1905        Opt::DeleteDeployment { depl_id } => {
1906            let storage = &get_storage().await?;
1907            match storage.kind()? {
1908                BootedStorageKind::Ostree(_) => {
1909                    anyhow::bail!("DeleteDeployment is only supported for composefs backend")
1910                }
1911                BootedStorageKind::Composefs(booted_cfs) => {
1912                    delete_composefs_deployment(&depl_id, storage, &booted_cfs).await
1913                }
1914            }
1915        }
1916    }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922
1923    #[test]
1924    fn test_callname() {
1925        use std::os::unix::ffi::OsStrExt;
1926
1927        // Cases that change
1928        let mapped_cases = [
1929            ("", "bootc"),
1930            ("/foo/bar", "bar"),
1931            ("/foo/bar/", "bar"),
1932            ("foo/bar", "bar"),
1933            ("../foo/bar", "bar"),
1934            ("usr/bin/ostree-container", "ostree-container"),
1935        ];
1936        for (input, output) in mapped_cases {
1937            assert_eq!(
1938                output,
1939                callname_from_argv0(OsStr::new(input)),
1940                "Handling mapped case {input}"
1941            );
1942        }
1943
1944        // Invalid UTF-8
1945        assert_eq!("bootc", callname_from_argv0(OsStr::from_bytes(b"foo\x80")));
1946
1947        // Cases that are identical
1948        let ident_cases = ["foo", "bootc"];
1949        for case in ident_cases {
1950            assert_eq!(
1951                case,
1952                callname_from_argv0(OsStr::new(case)),
1953                "Handling ident case {case}"
1954            );
1955        }
1956    }
1957
1958    #[test]
1959    fn test_parse_install_args() {
1960        // Verify we still process the legacy --target-no-signature-verification
1961        let o = Opt::try_parse_from([
1962            "bootc",
1963            "install",
1964            "to-filesystem",
1965            "--target-no-signature-verification",
1966            "/target",
1967        ])
1968        .unwrap();
1969        let o = match o {
1970            Opt::Install(InstallOpts::ToFilesystem(fsopts)) => fsopts,
1971            o => panic!("Expected filesystem opts, not {o:?}"),
1972        };
1973        assert!(o.target_opts.target_no_signature_verification);
1974        assert_eq!(o.filesystem_opts.root_path.as_str(), "/target");
1975        // Ensure we default to old bound images behavior
1976        assert_eq!(
1977            o.config_opts.bound_images,
1978            crate::install::BoundImagesOpt::Stored
1979        );
1980    }
1981
1982    #[test]
1983    fn test_parse_opts() {
1984        assert!(matches!(
1985            Opt::parse_including_static(["bootc", "status"]),
1986            Opt::Status(StatusOpts {
1987                json: false,
1988                format: None,
1989                format_version: None,
1990                booted: false,
1991                verbose: false
1992            })
1993        ));
1994        assert!(matches!(
1995            Opt::parse_including_static(["bootc", "status", "--format-version=0"]),
1996            Opt::Status(StatusOpts {
1997                format_version: Some(0),
1998                ..
1999            })
2000        ));
2001
2002        // Test verbose long form
2003        assert!(matches!(
2004            Opt::parse_including_static(["bootc", "status", "--verbose"]),
2005            Opt::Status(StatusOpts { verbose: true, .. })
2006        ));
2007
2008        // Test verbose short form
2009        assert!(matches!(
2010            Opt::parse_including_static(["bootc", "status", "-v"]),
2011            Opt::Status(StatusOpts { verbose: true, .. })
2012        ));
2013    }
2014
2015    #[test]
2016    fn test_parse_generator() {
2017        assert!(matches!(
2018            Opt::parse_including_static([
2019                "/usr/lib/systemd/system/bootc-systemd-generator",
2020                "/run/systemd/system"
2021            ]),
2022            Opt::Internals(InternalsOpts::SystemdGenerator { normal_dir, .. }) if normal_dir == "/run/systemd/system"
2023        ));
2024    }
2025
2026    #[test]
2027    fn test_parse_ostree_ext() {
2028        assert!(matches!(
2029            Opt::parse_including_static(["bootc", "internals", "ostree-container"]),
2030            Opt::Internals(InternalsOpts::OstreeContainer { .. })
2031        ));
2032
2033        fn peel(o: Opt) -> Vec<OsString> {
2034            match o {
2035                Opt::Internals(InternalsOpts::OstreeExt { args }) => args,
2036                o => panic!("unexpected {o:?}"),
2037            }
2038        }
2039        let args = peel(Opt::parse_including_static([
2040            "/usr/libexec/libostree/ext/ostree-ima-sign",
2041            "ima-sign",
2042            "--repo=foo",
2043            "foo",
2044            "bar",
2045            "baz",
2046        ]));
2047        assert_eq!(
2048            args.as_slice(),
2049            ["ima-sign", "--repo=foo", "foo", "bar", "baz"]
2050        );
2051
2052        let args = peel(Opt::parse_including_static([
2053            "/usr/libexec/libostree/ext/ostree-container",
2054            "container",
2055            "image",
2056            "pull",
2057        ]));
2058        assert_eq!(args.as_slice(), ["container", "image", "pull"]);
2059    }
2060
2061    #[test]
2062    fn test_generate_completion_scripts_contain_commands() {
2063        use clap_complete::aot::{Shell, generate};
2064
2065        // For each supported shell, generate the completion script and
2066        // ensure obvious subcommands appear in the output. This mirrors
2067        // the style of completion checks used in other projects (e.g.
2068        // podman) where the generated script is examined for expected
2069        // tokens.
2070
2071        // `completion` is intentionally hidden from --help / suggestions;
2072        // ensure other visible subcommands are present instead.
2073        let want = ["install", "upgrade"];
2074
2075        for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] {
2076            let mut cmd = Opt::command();
2077            let mut buf = Vec::new();
2078            generate(shell, &mut cmd, "bootc", &mut buf);
2079            let s = String::from_utf8(buf).expect("completion should be utf8");
2080            for w in &want {
2081                assert!(s.contains(w), "{shell:?} completion missing {w}");
2082            }
2083        }
2084    }
2085}