From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 4ECA5BC61D for ; Thu, 28 Mar 2024 14:51:13 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8129ACC8C for ; Thu, 28 Mar 2024 14:50:40 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Thu, 28 Mar 2024 14:50:36 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 37E7A42936 for ; Thu, 28 Mar 2024 14:50:36 +0100 (CET) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Thu, 28 Mar 2024 14:50:28 +0100 Message-Id: <20240328135028.504520-31-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240328135028.504520-1-a.lauterer@proxmox.com> References: <20240328135028.504520-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.211 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH v3 30/30] add proxmox-chroot utility X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 28 Mar 2024 13:51:13 -0000 it is meant as a helper utility to prepare an installation for chroot and clean up afterwards It tries to determine the used FS from the previous installation, will do what is necessary to mount/import the root FS to /target. It then will set up all bind mounts. Signed-off-by: Aaron Lauterer --- Cargo.toml | 1 + Makefile | 5 +- proxmox-chroot/Cargo.toml | 16 ++ proxmox-chroot/src/main.rs | 353 +++++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 proxmox-chroot/Cargo.toml create mode 100644 proxmox-chroot/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index b694d5b..b3afc7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "proxmox-auto-installer", "proxmox-autoinst-helper", + "proxmox-chroot", "proxmox-fetch-answer", "proxmox-installer-common", "proxmox-tui-installer", diff --git a/Makefile b/Makefile index e32d28f..d69dc6f 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ INSTALLER_SOURCES=$(shell git ls-files) country.dat PREFIX = /usr BINDIR = $(PREFIX)/bin USR_BIN := \ + proxmox-chroot\ proxmox-tui-installer\ proxmox-autoinst-helper\ proxmox-fetch-answer\ @@ -54,6 +55,7 @@ $(BUILDDIR): proxmox-auto-installer/ \ proxmox-autoinst-helper/ \ proxmox-fetch-answer/ \ + proxmox-chroot \ proxmox-tui-installer/ \ proxmox-installer-common/ \ test/ \ @@ -127,7 +129,8 @@ cargo-build: $(CARGO) build --package proxmox-tui-installer --bin proxmox-tui-installer \ --package proxmox-auto-installer --bin proxmox-auto-installer \ --package proxmox-fetch-answer --bin proxmox-fetch-answer \ - --package proxmox-autoinst-helper --bin proxmox-autoinst-helper $(CARGO_BUILD_ARGS) + --package proxmox-autoinst-helper --bin proxmox-autoinst-helper \ + --package proxmox-chroot --bin proxmox-chroot $(CARGO_BUILD_ARGS) %-banner.png: %-banner.svg rsvg-convert -o $@ $< diff --git a/proxmox-chroot/Cargo.toml b/proxmox-chroot/Cargo.toml new file mode 100644 index 0000000..43b96ff --- /dev/null +++ b/proxmox-chroot/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "proxmox-chroot" +version = "0.1.0" +edition = "2021" +authors = [ "Aaron Lauterer " ] +license = "AGPL-3" +exclude = [ "build", "debian" ] +homepage = "https://www.proxmox.com" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.0", features = ["derive"] } +nix = "0.26.1" +proxmox-installer-common = { path = "../proxmox-installer-common" } +regex = "1.7" +serde_json = "1.0" diff --git a/proxmox-chroot/src/main.rs b/proxmox-chroot/src/main.rs new file mode 100644 index 0000000..075ddc6 --- /dev/null +++ b/proxmox-chroot/src/main.rs @@ -0,0 +1,353 @@ +use std::{fs, io, path, process::Command}; + +use anyhow::{bail, Result}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use nix::mount::{mount, umount, MsFlags}; +use proxmox_installer_common::{ + options::FsType, + setup::{InstallConfig, SetupInfo}, +}; +use regex::Regex; + +const ANSWER_MP: &str = "answer"; +static BINDMOUNTS: [&str; 4] = ["dev", "proc", "run", "sys"]; +const TARGET_DIR: &str = "/target"; +const ZPOOL_NAME: &str = "rpool"; + +/// Helper tool to prepare eveything to `chroot` into an installation +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Prepare(CommandPrepare), + Cleanup(CommandCleanup), +} + +/// Mount the root file system and bind mounts in preparation to chroot into the installation +#[derive(Args, Debug)] +struct CommandPrepare { + /// Filesystem used for the installation. Will try to automatically detect it after a + /// successful installation. + #[arg(short, long, value_enum)] + filesystem: Option, + + /// Numerical ID of `rpool` ZFS pool to import. Needed if multiple pools of name `rpool` are present. + #[arg(long)] + rpool_id: Option, + + /// UUID of the BTRFS file system to mount. Needed if multiple BTRFS file systems are present. + #[arg(long)] + btrfs_uuid: Option, +} + +/// Unmount everything. Use once done with chroot. +#[derive(Args, Debug)] +struct CommandCleanup { + /// Filesystem used for the installation. Will try to automatically detect it by default. + #[arg(short, long, value_enum)] + filesystem: Option, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum Filesystems { + Zfs, + Ext4, + Xfs, + Btrfs, +} + +impl From for Filesystems { + fn from(fs: FsType) -> Self { + match fs { + FsType::Xfs => Self::Xfs, + FsType::Ext4 => Self::Ext4, + FsType::Zfs(_) => Self::Zfs, + FsType::Btrfs(_) => Self::Btrfs, + } + } +} + +fn main() { + let args = Cli::parse(); + let res = match &args.command { + Commands::Prepare(args) => prepare(args), + Commands::Cleanup(args) => cleanup(args), + }; + if let Err(err) = res { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn prepare(args: &CommandPrepare) -> Result<()> { + let fs = get_fs(args.filesystem)?; + + fs::create_dir_all(TARGET_DIR)?; + + match fs { + Filesystems::Zfs => mount_zpool(args.rpool_id)?, + Filesystems::Xfs => mount_fs()?, + Filesystems::Ext4 => mount_fs()?, + Filesystems::Btrfs => mount_btrfs(args.btrfs_uuid.clone())?, + } + + if let Err(e) = bindmount() { + eprintln!("{e}") + } + + println!("Done. You can now use 'chroot /target /bin/bash'!"); + Ok(()) +} + +fn cleanup(args: &CommandCleanup) -> Result<()> { + let fs = get_fs(args.filesystem)?; + + if let Err(e) = bind_umount() { + eprintln!("{e}") + } + + match fs { + Filesystems::Zfs => umount_zpool(), + Filesystems::Xfs => umount_fs()?, + Filesystems::Ext4 => umount_fs()?, + _ => (), + } + + println!("Chroot cleanup done. You can now reboot or leave the shell."); + Ok(()) +} + +fn get_fs(filesystem: Option) -> Result { + let fs = match filesystem { + None => { + let low_level_config = get_low_level_config()?; + Filesystems::from(low_level_config.filesys) + } + Some(fs) => fs, + }; + + Ok(fs) +} + +fn get_low_level_config() -> Result { + let file = fs::File::open("/tmp/low-level-config.json")?; + let reader = io::BufReader::new(file); + let config: InstallConfig = serde_json::from_reader(reader)?; + Ok(config) +} + +fn get_iso_info() -> Result { + let file = fs::File::open("/run/proxmox-installer/iso-info.json")?; + let reader = io::BufReader::new(file); + let setup_info: SetupInfo = serde_json::from_reader(reader)?; + Ok(setup_info) +} + +fn mount_zpool(pool_id: Option) -> Result<()> { + println!("importing ZFS pool to {TARGET_DIR}"); + let mut import = Command::new("zpool"); + import.arg("import").args(["-R", TARGET_DIR]); + match pool_id { + None => { + import.arg(ZPOOL_NAME); + } + Some(id) => { + import.arg(id.to_string()); + } + } + match import.status() { + Ok(s) if !s.success() => bail!("Could not import ZFS pool. Abort!"), + _ => (), + } + println!("successfully imported ZFS pool to {TARGET_DIR}"); + Ok(()) +} + +fn umount_zpool() { + match Command::new("zpool").arg("export").arg(ZPOOL_NAME).status() { + Ok(s) if !s.success() => println!("failure on exporting {ZPOOL_NAME}"), + _ => (), + } +} + +fn mount_fs() -> Result<()> { + let iso_info = get_iso_info()?; + let product = iso_info.config.product; + + println!("Activating VG '{product}'"); + let res = Command::new("vgchange") + .arg("-ay") + .arg(product.to_string()) + .output(); + match res { + Err(e) => bail!("{e}"), + Ok(output) => { + if output.status.success() { + println!( + "successfully activated VG '{product}': {}", + String::from_utf8(output.stdout)? + ); + } else { + bail!( + "activation of VG '{product}' failed: {}", + String::from_utf8(output.stderr)? + ) + } + } + } + + match Command::new("mount") + .arg(format!("/dev/mapper/{product}-root")) + .arg("/target") + .output() + { + Err(e) => bail!("{e}"), + Ok(output) => { + if output.status.success() { + println!("mounted root file system successfully"); + } else { + bail!( + "mounting of root file system failed: {}", + String::from_utf8(output.stderr)? + ) + } + } + } + + Ok(()) +} + +fn umount_fs() -> Result<()> { + umount(TARGET_DIR)?; + Ok(()) +} + +fn mount_btrfs(btrfs_uuid: Option) -> Result<()> { + let uuid = match btrfs_uuid { + Some(uuid) => uuid, + None => get_btrfs_uuid()?, + }; + + match Command::new("mount") + .arg("--uuid") + .arg(uuid) + .arg("/target") + .output() + { + Err(e) => bail!("{e}"), + Ok(output) => { + if output.status.success() { + println!("mounted BTRFS root file system successfully"); + } else { + bail!( + "mounting of BTRFS root file system failed: {}", + String::from_utf8(output.stderr)? + ) + } + } + } + + Ok(()) +} + +fn get_btrfs_uuid() -> Result { + let output = Command::new("btrfs") + .arg("filesystem") + .arg("show") + .output()?; + if !output.status.success() { + bail!( + "Error checking for BTRFS file systems: {}", + String::from_utf8(output.stderr)? + ); + } + let out = String::from_utf8(output.stdout)?; + let mut uuids = Vec::new(); + + let re_uuid = + Regex::new(r"uuid: ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$")?; + for line in out.lines() { + if let Some(cap) = re_uuid.captures(line) { + if let Some(uuid) = cap.get(1) { + uuids.push(uuid.as_str()); + } + } + } + match uuids.len() { + 0 => bail!("Could not find any BTRFS UUID"), + i if i > 1 => { + let uuid_list = uuids + .iter() + .fold(String::new(), |acc, &arg| format!("{acc}\n{arg}")); + bail!("Found {i} UUIDs:{uuid_list}\nPlease specify the UUID to use with the --btrfs-uuid parameter") + } + _ => (), + } + Ok(uuids[0].into()) +} + +fn bindmount() -> Result<()> { + println!("Bind mounting"); + // https://github.com/nix-rust/nix/blob/7badbee1e388618457ed0d725c1091359f253012/test/test_mount.rs#L19 + // https://github.com/nix-rust/nix/blob/7badbee1e388618457ed0d725c1091359f253012/test/test_mount.rs#L146 + const NONE: Option<&'static [u8]> = None; + + let flags = MsFlags::MS_BIND; + for item in BINDMOUNTS { + let source = path::Path::new("/").join(item); + let target = path::Path::new(TARGET_DIR).join(item); + + println!("Bindmount {} to {}", source.display(), target.display()); + mount(Some(source.as_path()), target.as_path(), NONE, flags, NONE)?; + } + + let answer_path = path::Path::new("/mnt").join(ANSWER_MP); + if answer_path.exists() { + let target = path::Path::new(TARGET_DIR).join("mnt").join(ANSWER_MP); + + println!("Create dir {}", target.display()); + fs::create_dir_all(&target)?; + + println!( + "Bindmount {} to {}", + answer_path.display(), + target.display() + ); + mount( + Some(answer_path.as_path()), + target.as_path(), + NONE, + flags, + NONE, + )?; + } + Ok(()) +} + +fn bind_umount() -> Result<()> { + for item in BINDMOUNTS { + let target = path::Path::new(TARGET_DIR).join(item); + println!("Unmounting {}", target.display()); + if let Err(e) = umount(target.as_path()) { + eprintln!("{e}"); + } + } + + let answer_target = path::Path::new(TARGET_DIR).join("mnt").join(ANSWER_MP); + if answer_target.exists() { + println!("Unmounting and removing answer mountpoint"); + if let Err(e) = umount(answer_target.as_os_str()) { + eprintln!("{e}"); + } + if let Err(e) = fs::remove_dir(answer_target) { + eprintln!("{e}"); + } + } + + Ok(()) +} -- 2.39.2