Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/system-manager/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ clap.workspace = true
env_logger.workspace = true
log.workspace = true
rpassword.workspace = true

[dev-dependencies]
tempfile.workspace = true
175 changes: 149 additions & 26 deletions crates/system-manager/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 = std::env::consts::ARCH.to_owned() + "-linux";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this. Please keep in mind that not every ARCH is a system supported by the flake, and the system namespace may be different between rust and nix, so you may want to have an explicit mapping and error for those cases.

https://doc.rust-lang.org/std/env/consts/constant.ARCH.html


let has_flake_support = process::Command::new("nix")
.arg("show-config")
Expand All @@ -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(())
}
Expand Down Expand Up @@ -474,6 +474,33 @@ 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 render_template(template: &[u8], host_platform: &str) -> String {
String::from_utf8_lossy(template).replace(HOST_PLATFORM_PLACEHOLDER, host_platform)
}

fn print_store_path<SP: AsRef<StorePath>>(store_path: SP) -> Result<()> {
println!("{}", store_path.as_ref());
Ok(())
Expand Down Expand Up @@ -878,27 +905,45 @@ 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}'"
))),
}
}

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<StorePath>) -> PathBuf {
maybe_store_path.map_or_else(
|| {
Expand All @@ -922,6 +967,7 @@ fn handle_toplevel_error<T>(r: Result<T>) -> ExitCode {
mod tests {
use super::*;
use clap::Parser;
use tempfile::tempdir;

#[test]
fn legacy_use_remote_sudo_flag_is_accepted() {
Expand Down Expand Up @@ -1031,4 +1077,81 @@ 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 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\";"));
}

#[test]
fn ssh_connection_failures_get_a_targeted_message() {
Comment thread
RobertDeRose marked this conversation as resolved.
let target_host = "wrong-user@example.com";
let ssh_options = ["-p 2222".to_string(), "-o BatchMode=yes".to_string()];
let message = format!(
"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(b"Permission denied (publickey)")
);

assert!(message.contains("wrong-user@example.com"));
Comment thread
picnoir marked this conversation as resolved.
assert!(message.contains("SSH user/authentication/options are incorrect"));
assert!(message.contains("ssh -p 2222 -o BatchMode=yes wrong-user@example.com"));
assert!(message.contains("ssh stderr:\nPermission denied (publickey)"));
}

#[test]
fn remote_probe_failures_emphasize_nix_and_show_diagnostic_command() {
let target_host = "user@example.com";
let ssh_options = ["-p 2222".to_string()];
let message = format!(
"Connected to target host '{target_host}', but the remote probe `command -v nix-store` failed with exit status exit status: 1. \
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{}",
ssh_options.join(" "),
format_probe_stderr(b"bash: nix-store: command not found")
);

assert!(message.contains("Make sure Nix is installed on the target host"));
Comment thread
picnoir marked this conversation as resolved.
assert!(message.contains("ssh -p 2222 user@example.com nix-store --version"));
assert!(message.contains("ssh stderr:\nbash: nix-store: command not found"));
}

#[test]
fn format_probe_stderr_omits_empty_output() {
Comment thread
picnoir marked this conversation as resolved.
assert!(format_probe_stderr(b" \n").is_empty());
}
}