diff --git a/Cargo.lock b/Cargo.lock index 13696ad4..165d786a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -671,6 +671,7 @@ dependencies = [ "log", "rpassword", "system-manager-engine", + "tempfile", ] [[package]] diff --git a/crates/system-manager/Cargo.toml b/crates/system-manager/Cargo.toml index 9d91fc76..2f775ae0 100644 --- a/crates/system-manager/Cargo.toml +++ b/crates/system-manager/Cargo.toml @@ -17,3 +17,6 @@ clap.workspace = true env_logger.workspace = true log.workspace = true rpassword.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/system-manager/src/main.rs b/crates/system-manager/src/main.rs index 910ab327..4cbd400e 100644 --- a/crates/system-manager/src/main.rs +++ b/crates/system-manager/src/main.rs @@ -31,6 +31,8 @@ pub const STANDALONE_FLAKE_TEMPLATE: &[u8; 864] = /// network calls when initializing a system-manager configuration from the command line. pub const SYSTEM_MODULE_TEMPLATE: &[u8; 1159] = include_bytes!("../../../templates/system.nix"); +const HOST_PLATFORM_PLACEHOLDER: &str = "x86_64-linux"; + /// Name of the engine binary in the store path const ENGINE_BIN: &str = "system-manager-engine"; @@ -374,8 +376,7 @@ fn go(args: Args) -> Result<()> { path.display() ); - let system_config_filepath = path.join("system.nix"); - init_config_file(&system_config_filepath, SYSTEM_MODULE_TEMPLATE)?; + let host_platform = detect_host_platform(std::env::consts::ARCH)?; let has_flake_support = process::Command::new("nix") .arg("show-config") @@ -386,17 +387,16 @@ fn go(args: Args) -> Result<()> { && out_str.contains("flakes") && out_str.contains("nix-command") }); - if !no_flake && has_flake_support { - let flake_config_filepath = path.join("flake.nix"); - let is_nixos = process::Command::new("nixos-version") - .output() - .is_ok_and(|output| !output.stdout.is_empty()); - if is_nixos { - init_config_file(&flake_config_filepath, NIXOS_FLAKE_TEMPLATE)? - } else { - init_config_file(&flake_config_filepath, STANDALONE_FLAKE_TEMPLATE)? - } - } + let is_nixos = process::Command::new("nixos-version") + .output() + .is_ok_and(|output| !output.stdout.is_empty()); + + init_configuration( + &path, + !no_flake && has_flake_support, + is_nixos, + &host_platform, + )?; log::info!("Configuration '{}' ready for activation!", path.display()); Ok(()) } @@ -474,6 +474,43 @@ fn init_config_file(filepath: &Path, buf: &[u8]) -> Result<()> { Ok(()) } +fn init_configuration( + path: &Path, + include_flake: bool, + is_nixos: bool, + host_platform: &str, +) -> Result<()> { + let system_config_filepath = path.join("system.nix"); + let system_template = render_template(SYSTEM_MODULE_TEMPLATE, host_platform); + init_config_file(&system_config_filepath, system_template.as_bytes())?; + + if include_flake { + let flake_config_filepath = path.join("flake.nix"); + let flake_template = if is_nixos { + render_template(NIXOS_FLAKE_TEMPLATE, host_platform) + } else { + render_template(STANDALONE_FLAKE_TEMPLATE, host_platform) + }; + init_config_file(&flake_config_filepath, flake_template.as_bytes())?; + } + + Ok(()) +} + +fn detect_host_platform(arch: &str) -> Result<&'static str> { + match arch { + "aarch64" => Ok("aarch64-linux"), + "x86_64" => Ok("x86_64-linux"), + _ => bail!( + "system-manager init does not know how to map Rust architecture '{arch}' to a supported Nix system" + ), + } +} + +fn render_template(template: &[u8], host_platform: &str) -> String { + String::from_utf8_lossy(template).replace(HOST_PLATFORM_PLACEHOLDER, host_platform) +} + fn print_store_path>(store_path: SP) -> Result<()> { println!("{}", store_path.as_ref()); Ok(()) @@ -878,20 +915,27 @@ fn do_copy_closure( } fn ensure_nix_on_target(target_host: &str, ssh_options: &[String]) -> Result<()> { + let probe = "command -v nix-store"; let mut cmd = process::Command::new("ssh"); - cmd.args(ssh_options) - .arg(target_host) - .arg("command -v nix-store") - .stdout(process::Stdio::null()) - .stderr(process::Stdio::null()); - match cmd.status() { - Ok(status) if status.success() => Ok(()), - Ok(_) => anyhow::bail!( - "Nix is not installed on target host '{target_host}' \ - (nix-store not found in PATH). \ - system-manager requires Nix on the target to receive the closure. \ - Install it by running on the target host:\n\ - \n curl -sSfL https://artifacts.nixos.org/nix-installer | sh -s -- install --no-confirm\n" + cmd.args(ssh_options).arg(target_host).arg(probe); + match cmd.output() { + Ok(output) if output.status.success() => Ok(()), + Ok(output) if output.status.code() == Some(255) => anyhow::bail!( + "Failed to connect to target host '{target_host}' over SSH while checking for Nix. \ + This usually means the host is unreachable or the SSH user/authentication/options are incorrect. \ + Verify that this command works first:\n\ + \n ssh {} {target_host}\n{}", + ssh_options.join(" "), + format_probe_stderr(&output.stderr) + ), + Ok(output) => anyhow::bail!( + "Connected to target host '{target_host}', but the remote probe `{probe}` failed with exit status {}. \ + system-manager requires `nix-store` to be available on the target to receive the closure. \ + Make sure Nix is installed on the target host, then verify the remote user and PATH with:\n\ + \n ssh {} {target_host} nix-store --version\n{}", + output.status, + ssh_options.join(" "), + format_probe_stderr(&output.stderr) ), Err(e) => Err(anyhow::Error::from(e).context(format!( "Failed to run ssh to check for Nix on target host '{target_host}'" @@ -899,6 +943,17 @@ fn ensure_nix_on_target(target_host: &str, ssh_options: &[String]) -> Result<()> } } +fn format_probe_stderr(stderr: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stderr = stderr.trim(); + + if stderr.is_empty() { + String::new() + } else { + format!("\n\nssh stderr:\n{stderr}") + } +} + fn store_path_or_active_profile(maybe_store_path: Option) -> PathBuf { maybe_store_path.map_or_else( || { @@ -922,6 +977,7 @@ fn handle_toplevel_error(r: Result) -> ExitCode { mod tests { use super::*; use clap::Parser; + use tempfile::tempdir; #[test] fn legacy_use_remote_sudo_flag_is_accepted() { @@ -1031,4 +1087,54 @@ mod tests { assert!(args.ssh_options.is_empty()); } + + #[test] + fn render_template_substitutes_host_platform_in_system_module() { + let rendered = render_template(SYSTEM_MODULE_TEMPLATE, "aarch64-linux"); + + assert!(rendered.contains("nixpkgs.hostPlatform = \"aarch64-linux\";")); + assert!(!rendered.contains("nixpkgs.hostPlatform = \"x86_64-linux\";")); + } + + #[test] + fn detect_host_platform_maps_supported_architectures() { + assert_eq!(detect_host_platform("aarch64").unwrap(), "aarch64-linux"); + assert_eq!(detect_host_platform("x86_64").unwrap(), "x86_64-linux"); + } + + #[test] + fn detect_host_platform_rejects_unsupported_architectures() { + let error = detect_host_platform("arm").expect_err("expected unsupported arch to fail"); + + assert!(error + .to_string() + .contains("does not know how to map Rust architecture 'arm'")); + } + + #[test] + fn init_configuration_writes_aarch64_linux_to_nixos_flake() { + let tempdir = tempdir().expect("failed to create tempdir"); + + init_configuration(tempdir.path(), true, true, "aarch64-linux") + .expect("failed to initialize configuration"); + + let rendered = std::fs::read_to_string(tempdir.path().join("flake.nix")) + .expect("failed to read generated flake.nix"); + + assert!(rendered.contains("system = \"aarch64-linux\";")); + assert!(!rendered.contains("system = \"x86_64-linux\";")); + } + + #[test] + fn init_configuration_writes_x86_64_linux_to_nixos_flake() { + let tempdir = tempdir().expect("failed to create tempdir"); + + init_configuration(tempdir.path(), true, true, "x86_64-linux") + .expect("failed to initialize configuration"); + + let rendered = std::fs::read_to_string(tempdir.path().join("flake.nix")) + .expect("failed to read generated flake.nix"); + + assert!(rendered.contains("system = \"x86_64-linux\";")); + } } diff --git a/lib/container-test-driver/distros.nix b/lib/container-test-driver/distros.nix index b68595c2..56ac7316 100644 --- a/lib/container-test-driver/distros.nix +++ b/lib/container-test-driver/distros.nix @@ -56,6 +56,18 @@ in maskableService = "unattended-upgrades.service"; }; + ubuntu-26_04 = { + systems = builtins.attrNames images.ubuntu-26_04; + rootfs = makeRootfs.buildRootfs { + name = "ubuntu-26_04"; + cloudImg = fetchCloudImg "ubuntu-26_04"; + cloudImgFormat = "tar"; + excludePatterns = ubuntuExcludePatterns; + extraDirs = [ "var/lib/apt/lists/partial" ]; + }; + maskableService = "unattended-upgrades.service"; + }; + debian-13 = { systems = builtins.attrNames images.debian-13; rootfs = makeRootfs.buildRootfs { diff --git a/lib/container-test-driver/images.json b/lib/container-test-driver/images.json index b98b7837..292854a7 100644 --- a/lib/container-test-driver/images.json +++ b/lib/container-test-driver/images.json @@ -28,5 +28,15 @@ "sha256": "0xyp9pin8m6ys0gxk6spq83dgdfl3malbvps7v78hrp7sjg3rp0k", "url": "https://cloud-images.ubuntu.com/releases/noble/release-20260321/ubuntu-24.04-server-cloudimg-amd64-root.tar.xz" } + }, + "ubuntu-26_04": { + "aarch64-linux": { + "sha256": "077i5k2yd36bwayybvh475v5lyr9ilb7c847n99mmyj8p3lcb5d7", + "url": "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-arm64-root.tar.xz" + }, + "x86_64-linux": { + "sha256": "12318m2bikm6cgzlwa215301y9vf3p4666yb7lxmik4z4llp42m8", + "url": "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64-root.tar.xz" + } } } diff --git a/testFlake/flake.lock b/testFlake/flake.lock index bdb65f6b..7eadaa0f 100644 --- a/testFlake/flake.lock +++ b/testFlake/flake.lock @@ -149,11 +149,11 @@ ] }, "locked": { - "lastModified": 1783265394, - "narHash": "sha256-cq4YfNFGYzp0NItZP8tEC7xUI8OSgY4fj75AU/NSaPM=", + "lastModified": 1786699124, + "narHash": "sha256-VtY2VR600/5vFcl4diMpf2sbliY0W+g3GW2VnyDUFS8=", "owner": "numtide", "repo": "nix-vm-test", - "rev": "1a587212d2ac8b669c6c32499015f996506b6ba5", + "rev": "ecce8be2ede211ee1c2bc603d188e0bf41376ca8", "type": "github" }, "original": { diff --git a/tools/update-images.py b/tools/update-images.py index 63f71e7f..df2ad3f6 100755 --- a/tools/update-images.py +++ b/tools/update-images.py @@ -22,6 +22,7 @@ UBUNTU_RELEASES = { "ubuntu-22_04": "https://cloud-images.ubuntu.com/releases/jammy/", "ubuntu-24_04": "https://cloud-images.ubuntu.com/releases/noble/", + "ubuntu-26_04": "https://cloud-images.ubuntu.com/releases/resolute/", } DEBIAN_RELEASES = {