Skip to content

Commit d9ca544

Browse files
committed
Support for install-to-filesystem --replace=alongside
I was trying to be really ambitious in bootc-dev#78 for the full "takeover" path. This is a *much* *much* simpler variant where we just: - Blow away and reinitialize the `/boot` and `/boot/efi` partitions - Write inside the existing filesystem, leaving the OS running Then when we reboot, we'll just need to clean up the old OS state (or optionally leave it). Signed-off-by: Colin Walters <[email protected]>
1 parent 2599c92 commit d9ca544

File tree

5 files changed

+122
-33
lines changed

5 files changed

+122
-33
lines changed

lib/src/bootloader.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ pub(crate) fn install_via_bootupd(
7575
let bootfs = &rootfs.join("boot");
7676
let bootfs = Dir::open_ambient_dir(bootfs, cap_std::ambient_authority())?;
7777

78-
{
78+
if super::install::ARCH_USES_EFI {
7979
let efidir = bootfs.open_dir("efi")?;
8080
install_grub2_efi(&efidir, &grub2_uuid_contents)?;
8181
}

lib/src/install.rs

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use camino::Utf8PathBuf;
2121
use cap_std::fs::Dir;
2222
use cap_std_ext::cap_std;
2323
use cap_std_ext::prelude::CapStdExtDirExt;
24+
use clap::ValueEnum;
2425
use rustix::fs::MetadataExt;
2526

2627
use fn_error_context::context;
@@ -44,6 +45,7 @@ const BOOT: &str = "boot";
4445
const RUN_BOOTC: &str = "/run/bootc";
4546
/// This is an ext4 special directory we need to ignore.
4647
const LOST_AND_FOUND: &str = "lost+found";
48+
pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64"));
4749

4850
/// Kernel argument used to specify we want the rootfs mounted read-write by default
4951
const RW_KARG: &str = "rw";
@@ -117,6 +119,28 @@ pub(crate) struct InstallOpts {
117119
pub(crate) config_opts: InstallConfigOpts,
118120
}
119121

122+
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
123+
#[serde(rename_all = "kebab-case")]
124+
pub(crate) enum ReplaceMode {
125+
/// Completely wipe the contents of the target filesystem. This cannot
126+
/// be done if the target filesystem is the one the system is booted from.
127+
Wipe,
128+
/// This is a destructive operation in the sense that the bootloader state
129+
/// will have its contents wiped and replaced. However,
130+
/// the running system (and all files) will remain in place until reboot.
131+
///
132+
/// As a corollary to this, you will also need to remove all the old operating
133+
/// system binaries after the reboot into the target system; this can be done
134+
/// with code in the new target system, or manually.
135+
Alongside,
136+
}
137+
138+
impl std::fmt::Display for ReplaceMode {
139+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140+
self.to_possible_value().unwrap().get_name().fmt(f)
141+
}
142+
}
143+
120144
/// Options for installing to a filesystem
121145
#[derive(Debug, Clone, clap::Args)]
122146
pub(crate) struct InstallTargetFilesystemOpts {
@@ -141,9 +165,10 @@ pub(crate) struct InstallTargetFilesystemOpts {
141165
#[clap(long)]
142166
pub(crate) boot_mount_spec: Option<String>,
143167

144-
/// Automatically wipe existing data on the filesystems.
168+
/// Initialize the system in-place; at the moment, only one mode for this is implemented.
169+
/// In the future, it may also be supported to set up an explicit "dual boot" system.
145170
#[clap(long)]
146-
pub(crate) wipe: bool,
171+
pub(crate) replace: Option<ReplaceMode>,
147172
}
148173

149174
/// Perform an installation to a mounted filesystem.
@@ -592,6 +617,8 @@ pub(crate) struct RootSetup {
592617
device: Utf8PathBuf,
593618
rootfs: Utf8PathBuf,
594619
rootfs_fd: Dir,
620+
/// If true, do not try to remount the root read-only and flush the journal, etc.
621+
skip_finalize: bool,
595622
boot: MountSpec,
596623
kargs: Vec<String>,
597624
}
@@ -826,9 +853,11 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re
826853
.run()?;
827854

828855
// Finalize mounted filesystems
829-
let bootfs = rootfs.rootfs.join("boot");
830-
for fs in [bootfs.as_path(), rootfs.rootfs.as_path()] {
831-
finalize_filesystem(fs)?;
856+
if !rootfs.skip_finalize {
857+
let bootfs = rootfs.rootfs.join("boot");
858+
for fs in [bootfs.as_path(), rootfs.rootfs.as_path()] {
859+
finalize_filesystem(fs)?;
860+
}
832861
}
833862

834863
Ok(())
@@ -900,6 +929,36 @@ fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> {
900929
Ok(())
901930
}
902931

932+
/// Remove all entries in a directory, but do not traverse across distinct devices.
933+
#[context("Removing entries (noxdev")]
934+
fn remove_all_in_dir_no_xdev(d: &Dir) -> Result<()> {
935+
let parent_dev = d.dir_metadata()?.dev();
936+
for entry in d.entries()? {
937+
let entry = entry?;
938+
let entry_dev = entry.metadata()?.dev();
939+
if entry_dev == parent_dev {
940+
d.remove_all_optional(entry.file_name())?;
941+
}
942+
}
943+
anyhow::Ok(())
944+
}
945+
946+
#[context("Removing boot directory content")]
947+
fn clean_boot_directories(rootfs: &Dir) -> Result<()> {
948+
let bootdir = rootfs.open_dir(BOOT).context("Opening /boot")?;
949+
// This should not remove /boot/efi note.
950+
remove_all_in_dir_no_xdev(&bootdir)?;
951+
if ARCH_USES_EFI {
952+
if let Some(efidir) = bootdir
953+
.open_dir_optional(crate::bootloader::EFI_DIR)
954+
.context("Opening /boot/efi")?
955+
{
956+
remove_all_in_dir_no_xdev(&efidir)?;
957+
}
958+
}
959+
Ok(())
960+
}
961+
903962
/// Implementation of the `bootc install-to-filsystem` CLI command.
904963
pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Result<()> {
905964
// Gather global state, destructuring the provided options
@@ -909,35 +968,44 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu
909968
let root_path = &fsopts.root_path;
910969
let rootfs_fd = Dir::open_ambient_dir(root_path, cap_std::ambient_authority())
911970
.with_context(|| format!("Opening target root directory {root_path}"))?;
912-
if fsopts.wipe {
913-
let rootfs_fd = rootfs_fd.try_clone()?;
914-
println!("Wiping contents of root");
915-
tokio::task::spawn_blocking(move || {
916-
for e in rootfs_fd.entries()? {
917-
let e = e?;
918-
rootfs_fd.remove_all_optional(e.file_name())?;
919-
}
920-
anyhow::Ok(())
921-
})
922-
.await??;
923-
} else {
924-
require_empty_rootdir(&rootfs_fd)?;
971+
match fsopts.replace {
972+
Some(ReplaceMode::Wipe) => {
973+
let rootfs_fd = rootfs_fd.try_clone()?;
974+
println!("Wiping contents of root");
975+
tokio::task::spawn_blocking(move || {
976+
for e in rootfs_fd.entries()? {
977+
let e = e?;
978+
rootfs_fd.remove_all_optional(e.file_name())?;
979+
}
980+
anyhow::Ok(())
981+
})
982+
.await??;
983+
}
984+
Some(ReplaceMode::Alongside) => clean_boot_directories(&rootfs_fd)?,
985+
None => require_empty_rootdir(&rootfs_fd)?,
925986
}
926987

927988
// Gather data about the root filesystem
928989
let inspect = crate::mount::inspect_filesystem(&fsopts.root_path)?;
929990

930991
// We support overriding the mount specification for root (i.e. LABEL vs UUID versus
931992
// raw paths).
932-
let root_mount_spec = if let Some(s) = fsopts.root_mount_spec {
933-
s
993+
let (root_mount_spec, root_extra) = if let Some(s) = fsopts.root_mount_spec {
994+
(s, None)
934995
} else {
935996
let mut uuid = inspect
936997
.uuid
937998
.ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?;
938999
uuid.insert_str(0, "UUID=");
9391000
tracing::debug!("root {uuid}");
940-
uuid
1001+
let opts = match inspect.fstype.as_str() {
1002+
"btrfs" => {
1003+
let subvol = crate::utils::find_mount_option(&inspect.options, "subvol");
1004+
subvol.map(|vol| format!("rootflags=subvol={vol}"))
1005+
}
1006+
_ => None,
1007+
};
1008+
(uuid, opts)
9411009
};
9421010
tracing::debug!("Root mount spec: {root_mount_spec}");
9431011

@@ -967,15 +1035,6 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu
9671035
// for GRUB (BIOS) and in the future zipl (I think).
9681036
let backing_device = {
9691037
let mut dev = inspect.source;
970-
// Hack: trim bind mount information from source
971-
if dev.contains('[') {
972-
dev = inspect
973-
.sources
974-
.into_iter()
975-
.flatten()
976-
.next()
977-
.ok_or_else(|| anyhow!("Expected `sources` in findmnt output"))?;
978-
}
9791038
loop {
9801039
tracing::debug!("Finding parents for {dev}");
9811040
let mut parents = crate::blockdev::find_parent_devices(&dev)?.into_iter();
@@ -1004,7 +1063,11 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu
10041063
// By default, we inject a boot= karg because things like FIPS compliance currently
10051064
// require checking in the initramfs.
10061065
let bootarg = format!("boot={}", &boot.source);
1007-
let kargs = vec![rootarg, RW_KARG.to_string(), bootarg];
1066+
let kargs = [rootarg]
1067+
.into_iter()
1068+
.chain(root_extra)
1069+
.chain([RW_KARG.to_string(), bootarg])
1070+
.collect::<Vec<_>>();
10081071

10091072
let mut rootfs = RootSetup {
10101073
luks_device: None,
@@ -1013,6 +1076,7 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu
10131076
rootfs_fd,
10141077
boot,
10151078
kargs,
1079+
skip_finalize: matches!(fsopts.replace, Some(ReplaceMode::Alongside)),
10161080
};
10171081

10181082
install_to_filesystem_impl(&state, &mut rootfs).await?;

lib/src/install/baseline.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ pub(crate) fn install_create_rootfs(
233233
anyhow::bail!("Unsupported architecture: {}", std::env::consts::ARCH);
234234
}
235235

236-
let espdev = if cfg!(any(target_arch = "x86_64", target_arch = "aarch64")) {
236+
let espdev = if super::ARCH_USES_EFI {
237237
sgdisk_partition(
238238
&mut sgdisk.cmd,
239239
EFIPN,
@@ -370,5 +370,6 @@ pub(crate) fn install_create_rootfs(
370370
rootfs_fd,
371371
boot,
372372
kargs,
373+
skip_finalize: false,
373374
})
374375
}

lib/src/mount.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use crate::task::Task;
1313
#[serde(rename_all = "kebab-case")]
1414
pub(crate) struct Filesystem {
1515
pub(crate) source: String,
16+
pub(crate) fstype: String,
17+
pub(crate) options: String,
1618
pub(crate) uuid: Option<String>,
1719
}
1820

lib/src/utils.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ pub(crate) fn origin_has_rpmostree_stuff(kf: &glib::KeyFile) -> bool {
1818
false
1919
}
2020

21+
/// Given an mount option string list like foo,bar=baz,something=else,ro parse it and find
22+
/// the first entry like $optname=
23+
/// This will not match a bare `optname` without an equals.
24+
pub(crate) fn find_mount_option<'a>(
25+
option_string_list: &'a str,
26+
optname: &'_ str,
27+
) -> Option<&'a str> {
28+
option_string_list
29+
.split(',')
30+
.filter_map(|k| k.split_once('='))
31+
.filter_map(|(k, v)| (k == optname).then_some(v))
32+
.next()
33+
}
34+
2135
/// Run a command in the host mount namespace
2236
#[allow(dead_code)]
2337
pub(crate) fn run_in_host_mountns(cmd: &str) -> Command {
@@ -71,3 +85,11 @@ fn test_digested_pullspec() {
7185
format!("quay.io/example/foo@{digest}")
7286
);
7387
}
88+
89+
#[test]
90+
fn test_find_mount_option() {
91+
const V1: &str = "rw,relatime,compress=foo,subvol=blah,fast";
92+
assert_eq!(find_mount_option(V1, "subvol").unwrap(), "blah");
93+
assert_eq!(find_mount_option(V1, "rw"), None);
94+
assert_eq!(find_mount_option(V1, "somethingelse"), None);
95+
}

0 commit comments

Comments
 (0)