[kde-linux/kde-linux] /: RootFSv3
Hadi Chokr <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit e41e067611d87cc9d4e8287e58b33005bfb3f5a4 by Hadi Chokr.
Committed on 09/08/2026 at 06:36.
Pushed by silverhadch into branch 'master'.
RootFSv3
Setting the foundation of the Home Recovery Story.
Enable useradd BTRFS_SUBVOLUME_HOME and migrate all @system/home subvolumes to regular directories and have instead the user homes like @system/home/konqi be subvolumes.
Related to https://invent.kde.org/kde-linux/kde-linux/-/work_items/253
M +219 -8 btrfs-migrator/src/main.rs
M +4 -1 mkosi.extra/live/usr/lib/calamares/modules/subvol/main.py
M +13 -2 mkosi.extra/usr/lib/rootfs-transition
M +9 -0 mkosi.finalize.d/99-etc.sh.chroot
https://invent.kde.org/kde-linux/kde-linux/-/commit/e41e067611d87cc9d4e8287e58b33005bfb3f5a4
diff --git a/btrfs-migrator/src/main.rs b/btrfs-migrator/src/main.rs
index b8f3bdf2..c48fb23e 100644
--- a/btrfs-migrator/src/main.rs
+++ b/btrfs-migrator/src/main.rs
@@ -6,6 +6,7 @@ use std::{
error::Error,
fs::{self},
io::{self, Write},
+ os::unix::fs::MetadataExt,
path::{Path, PathBuf},
process::Command,
};
@@ -13,7 +14,9 @@ use std::{
extern crate scopeguard;
use dialoguer::{self, Confirm};
use fstab::FsTab;
-use libbtrfsutil::{CreateSnapshotOptions, CreateSubvolumeOptions, DeleteSubvolumeOptions};
+use libbtrfsutil::{
+ CreateSnapshotOptions, CreateSubvolumeOptions, DeleteSubvolumeOptions, is_subvolume,
+};
fn find_rootfs_v1(root: &Path) -> Option<PathBuf> {
let subvols = fs::read_dir(root).ok()?;
@@ -122,7 +125,7 @@ fn run(root: &Path) -> Result<(), Box<dyn Error>> {
println!(
"Found {concerning_fstab_entries} concerning fstab entries. This suggests you have a more complicated fstab setup that we cannot auto-migrate. \
- If nothing critically important is managed by fstab you can let the auto-migration run. If you have entries that are required for the system to boot you should manually migrate to @system."
+If nothing critically important is managed by fstab you can let the auto-migration run. If you have entries that are required for the system to boot you should manually migrate to @system."
);
io::stdout().flush().unwrap();
@@ -170,9 +173,9 @@ fn run(root: &Path) -> Result<(), Box<dyn Error>> {
defer! {
println!("Unmounting overlay for {}", dir);
Command::new("umount")
- .arg(&compose_dir)
- .status()
- .expect("Failed to unmount overlay for etc/var");
+ .arg(&compose_dir)
+ .status()
+ .expect("Failed to unmount overlay for etc/var");
}
println!(
@@ -237,6 +240,207 @@ fn run(root: &Path) -> Result<(), Box<dyn Error>> {
return Ok(());
}
+// Recursively walk `src`, find any btrfs subvolumes, and replace the corresponding
+// plain-directory copies under `dst` with proper snapshots.
+fn snapshot_nested_subvolumes(src: &Path, dst: &Path) -> Result<(), Box<dyn Error>> {
+ for nested in fs::read_dir(src)? {
+ let nested = nested?;
+ let nested_src = nested.path();
+
+ // file_type() is lstat based, is_subvolume() is not. Homes are full of symlinks that
+ // dangle while we run from the initrd, and the resulting ENOENT aborted the migration.
+ if !nested.file_type()?.is_dir() {
+ continue;
+ }
+
+ let nested_dst = dst.join(nested.file_name());
+ // Subvolume roots are always inode 256.
+ let subvolume = nested.metadata()?.ino() == 256
+ && is_subvolume(&nested_src)
+ .map_err(|e| format!("Failed to stat {nested_src:?}: {e:?}"))?;
+
+ if subvolume {
+ println!("Snapshotting nested subvolume {nested_src:?} -> {nested_dst:?}");
+ fs::remove_dir_all(&nested_dst)?;
+ CreateSnapshotOptions::new()
+ .recursive(true)
+ .create(&nested_src, &nested_dst)
+ .map_err(|e| {
+ format!("Failed to snapshot {nested_src:?} to {nested_dst:?}: {e:?}")
+ })?;
+ // Don't descend further as recursive(true) already handled this subvolume's children.
+ } else {
+ snapshot_nested_subvolumes(&nested_src, &nested_dst)?;
+ }
+ }
+ Ok(())
+}
+
+// remove_dir_all() cannot rmdir the per-user subvolumes inside a staging dir.
+fn remove_staging_dir(dir: &Path) -> Result<(), Box<dyn Error>> {
+ for entry in fs::read_dir(dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ if !entry.file_type()?.is_dir() {
+ continue;
+ }
+ if is_subvolume(&path).unwrap_or(false) {
+ println!("Deleting leftover subvolume {path:?}");
+ DeleteSubvolumeOptions::new()
+ .recursive(true)
+ .delete(&path)
+ .map_err(|e| format!("Failed to delete leftover subvolume {path:?}: {e:?}"))?;
+ }
+ }
+ fs::remove_dir_all(dir)?;
+ Ok(())
+}
+
+fn run_v3(root: &Path) -> Result<(), Box<dyn Error>> {
+ let system_home = root.join("@system/home");
+ let system_home_tmp = root.join("@system/home.v3tmp");
+ let system_home_old = root.join("@system/home.v3old");
+ let _ = Command::new("plymouth")
+ .arg("display-message")
+ .arg("--text=Migrating to v3 rootfs. This will take a while.")
+ .status();
+ println!("Migrating @system/home from subvolume to regular directory with per-user subvolumes");
+
+ // Clean up any leftover staging dirs from a previous failed run.
+ if system_home_old.exists() {
+ if !system_home.exists() {
+ // Crashed after rename(home→old) but before rename(tmp→home).
+ // home.v3old is the only surviving copy of user data so restore it.
+ eprintln!(
+ "Detected partial v3 migration: {system_home:?} is missing but \
+ {system_home_old:?} exists. Restoring original home before retrying."
+ );
+ if system_home_tmp.exists() {
+ println!("Removing incomplete staging dir {system_home_tmp:?}");
+ remove_staging_dir(&system_home_tmp)?;
+ }
+ fs::rename(&system_home_old, &system_home).map_err(|e| {
+ format!(
+ "CRITICAL: failed to restore home from {system_home_old:?}: {e}. \
+ User data may still be in {system_home_old:?}."
+ )
+ })?;
+ println!("Restored {system_home_old:?} -> {system_home:?}. Retrying migration.");
+ } else if is_subvolume(&system_home).unwrap_or(false) {
+ // rename(home -> old) is what creates home.v3old, so the two only coexist once
+ // home has been replaced by the new regular directory. Deleting either of them
+ // here could throw away the only copy of the user data.
+ return Err(format!(
+ "Both {system_home:?} and {system_home_old:?} exist and {system_home:?} is \
+ still a subvolume. Refusing to touch either."
+ )
+ .into());
+ } else {
+ // home is a regular dir, so a previous run completed and only failed to clean up.
+ println!("Cleaning up leftover {system_home_old:?} from previous run");
+ DeleteSubvolumeOptions::new()
+ .recursive(true)
+ .delete(&system_home_old)
+ .map_err(|e| format!("Failed to delete leftover {system_home_old:?}: {e:?}"))?;
+ }
+ }
+
+ if system_home_tmp.exists() {
+ println!("Cleaning up leftover {system_home_tmp:?} from previous run");
+ remove_staging_dir(&system_home_tmp)?;
+ }
+
+ if !system_home.exists() {
+ println!("{system_home:?} does not exist. Nothing to migrate.");
+ return Ok(());
+ }
+ if !is_subvolume(&system_home)
+ .map_err(|e| format!("Failed to stat {system_home:?}: {e:?}"))?
+ {
+ println!("{system_home:?} is already a regular directory. Nothing to do.");
+ return Ok(());
+ }
+
+ // Stage into a sibling directory so that if we crash mid-way, @system/home is still a subvolume
+ // and the next boot will retry the migration cleanly.
+ fs::create_dir(&system_home_tmp)?;
+ for entry in fs::read_dir(&system_home)? {
+ let entry = entry?;
+ let src = entry.path();
+ let file_type = entry.file_type()?;
+ let dst = system_home_tmp.join(entry.file_name());
+
+ // Not a user home, but it still has to make it across: the old subvolume gets deleted
+ // at the end. Note that file_type() is lstat based, so a symlinked home lands here and
+ // is copied as a symlink rather than dereferenced into a subvolume.
+ if !file_type.is_dir() {
+ if !file_type.is_file() && !file_type.is_symlink() {
+ eprintln!("Warning: not copying {src:?}, unsupported file type {file_type:?}");
+ continue;
+ }
+ println!("Copying {src:?} to {dst:?}");
+ let cp_result = Command::new("cp")
+ .arg("--archive")
+ .arg("--reflink=auto")
+ .arg("--no-target-directory")
+ .arg(&src)
+ .arg(&dst)
+ .status()
+ .expect("Failed to copy home entry");
+ if !cp_result.success() {
+ return Err(format!("Failed to copy {src:?} to {dst:?}").into());
+ }
+ continue;
+ }
+
+ println!("Creating user subvolume at {dst:?}");
+ CreateSubvolumeOptions::new()
+ .create(&dst)
+ .map_err(|e| format!("Failed to create subvolume {dst:?}: {e:?}"))?;
+
+ // Copy everything including nested subvolume dirs (we'll replace those with snapshots after)
+ let cp_result = Command::new("cp")
+ .arg("--recursive")
+ .arg("--archive")
+ .arg("--reflink=auto")
+ .arg(format!("{}/.", src.display()))
+ .arg(format!("{}/.", dst.display()))
+ .status()
+ .expect("Failed to copy user home");
+ if !cp_result.success() {
+ return Err(format!("Failed to copy {src:?} to {dst:?}").into());
+ }
+
+ // Recursively replace any nested subvolume dirs (which cp copied as plain dirs) with proper
+ // snapshots. This handles deeply nested cases.
+ snapshot_nested_subvolumes(&src, &dst)?;
+ }
+
+ println!("Renaming {system_home:?} to {system_home_old:?}");
+ fs::rename(&system_home, &system_home_old)?;
+
+ println!("Renaming {system_home_tmp:?} to {system_home:?}");
+ if let Err(e) = fs::rename(&system_home_tmp, &system_home) {
+ eprintln!("Fatal: failed to rename {system_home_tmp:?} to {system_home:?}: {e}");
+ eprintln!("Restoring original home subvolume from {system_home_old:?}");
+ fs::rename(&system_home_old, &system_home)
+ .map_err(|re| format!("CRITICAL: failed to restore original home: {re}. System may be unbootable. Original home is at {system_home_old:?}"))?;
+ return Err(format!("Migration failed, original home restored: {e}").into());
+ }
+
+ // Only delete the old subvolume once we know the new layout is in place
+ println!("Deleting old home subvolume {system_home_old:?}");
+ let _ = DeleteSubvolumeOptions::new()
+ .recursive(true)
+ .delete(&system_home_old)
+ .inspect_err(|e| {
+ eprintln!("Warning: failed to delete old home subvolume {system_home_old:?}: {e}");
+ eprintln!("The migration succeeded but deleting the old subvolume failed. {system_home_old:?} can be manually deleted.");
+ });
+
+ Ok(())
+}
+
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
@@ -245,11 +449,18 @@ fn main() -> Result<(), Box<dyn Error>> {
return Err("Not enough arguments".into());
}
- println!("Migrating to v2 rootfs. This will take a while.");
-
let root = Path::new(&args[1]);
+ let system_path = root.join("@system");
+
+ let result = if system_path.exists() {
+ println!("Migrating to v3 rootfs. This will take a while.");
+ run_v3(root)
+ } else {
+ println!("Migrating to v2 rootfs. This will take a while.");
+ run(root)
+ };
- match run(root) {
+ match result {
Ok(_) => {
// Reactivate in case we deactivated it earlier
let _ = Command::new("plymouth").arg("show-splash").status();
diff --git a/mkosi.extra/live/usr/lib/calamares/modules/subvol/main.py b/mkosi.extra/live/usr/lib/calamares/modules/subvol/main.py
index 8bebdab3..926c1528 100644
--- a/mkosi.extra/live/usr/lib/calamares/modules/subvol/main.py
+++ b/mkosi.extra/live/usr/lib/calamares/modules/subvol/main.py
@@ -253,7 +253,10 @@ def _create_subvolumes(state: InstallState) -> None:
_run(["btrfs", "quota", "enable", "--simple", tmp])
_run(["btrfs", "subvolume", "create", f"{tmp}/@system"])
_run(["btrfs", "subvolume", "create", f"{tmp}/@system/etc"])
- for sub in ("boot", "proc", "sys", "dev", "run", "usr"):
+ # A leftover @system/home subvolume would make the mkdir below a no-op and
+ # leave the old contents in place, so get rid of it first.
+ _run(["btrfs", "subvolume", "delete", f"{tmp}/@system/home"], check=False)
+ for sub in ("boot", "proc", "sys", "dev", "run", "usr", "home"):
(state.tmpdir / "@system" / sub).mkdir(parents=True, exist_ok=True)
diff --git a/mkosi.extra/usr/lib/rootfs-transition b/mkosi.extra/usr/lib/rootfs-transition
index 55dd3e02..748b0f85 100755
--- a/mkosi.extra/usr/lib/rootfs-transition
+++ b/mkosi.extra/usr/lib/rootfs-transition
@@ -24,8 +24,19 @@ mount -o rw,subvol=/ /dev/gpt-auto-root /run/kde-linux-rootfs-transition
# In a way we could think about moving the mounts into a generator TBH. Then we can do proper condition management on
# the systemd side. Question is if they get correctly unmounted automatically.
if [ -e /run/kde-linux-rootfs-transition/@system ]; then
- cd /
- umount --recursive --lazy /run/kde-linux-rootfs-transition
+ # v2->v3: @system/home is a btrfs subvolume but should be a regular directory.
+ # home.v3old or home.v3tmp mean an earlier attempt was interrupted. In the worst case
+ # @system/home no longer exists and home.v3old holds the only copy of the user data, so
+ # the migrator has to run to put it back.
+ if btrfs subvolume show /run/kde-linux-rootfs-transition/@system/home >/dev/null 2>&1 \
+ || [ -e /run/kde-linux-rootfs-transition/@system/home.v3old ] \
+ || [ -e /run/kde-linux-rootfs-transition/@system/home.v3tmp ]; then
+ /usr/lib/btrfs-migrator /run/kde-linux-rootfs-transition
+ umount --recursive --lazy /run/kde-linux-rootfs-transition
+ else
+ cd /
+ umount --recursive --lazy /run/kde-linux-rootfs-transition
+ fi
exit 0
fi
diff --git a/mkosi.finalize.d/99-etc.sh.chroot b/mkosi.finalize.d/99-etc.sh.chroot
index 7ff8ad58..164952aa 100755
--- a/mkosi.finalize.d/99-etc.sh.chroot
+++ b/mkosi.finalize.d/99-etc.sh.chroot
@@ -18,6 +18,15 @@ echo "auth optional pam_faildelay.so delay=0" >> /usr/lib/pam.d/syste
echo "# Default of 3 is way too harsh" >> /etc/security/faillock.conf
echo "deny = 12" >> /etc/security/faillock.conf
+# Set BTRFS_SUBVOLUME_HOME for new users
+if grep -q '^BTRFS_SUBVOLUME_HOME=' /etc/default/useradd; then
+ sed -i 's%^BTRFS_SUBVOLUME_HOME=.*%# IMPORTANT! DO NOT CHANGE!\nBTRFS_SUBVOLUME_HOME=yes%' /etc/default/useradd
+else
+ echo '' >> /etc/default/useradd
+ echo '# IMPORTANT! DO NOT CHANGE!' >> /etc/default/useradd
+ echo 'BTRFS_SUBVOLUME_HOME=yes' >> /etc/default/useradd
+fi
+
# Make double sure we don't seed random pam.d files from factory etc!
# https://invent.kde.org/kde-linux/kde-linux/-/issues/165
rm --recursive --force /usr/share/factory/etc/pam.d