Skip to content
2 changes: 2 additions & 0 deletions nix/devShells/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
python3Packages.flake8

nix
sshpass
socat
];
};
};
Expand Down
4 changes: 3 additions & 1 deletion nix/packages/navi.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
nix-eval-jobs,
makeBinaryWrapper,
nixos-anywhere,
sshpass,
socat,
}:

let
Expand Down Expand Up @@ -66,7 +68,7 @@ in craneLib.buildPackage (commonArgs // {
''}

wrapProgram $out/bin/navi \
--prefix PATH : ${lib.makeBinPath [ nixos-anywhere ]}
--prefix PATH : ${lib.makeBinPath [ nixos-anywhere sshpass socat ]}
'';

passthru = {
Expand Down
28 changes: 17 additions & 11 deletions src/command/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,17 @@ pub async fn run(hive: Hive, opts: Opts) -> NaviResult<()> {
}
})?;

// We only care about Terranix/Terraform provisioners for IP lookup
if prov_config.kind != crate::nix::ProvisionerType::Terranix {
tracing::info!(
"Skipping provisioner '{}' (type {:?}) as it is not Terraform-based.",
prov_name,
prov_config.kind
);
continue;
// Only Terranix and BareMetal provisioners support installation
match prov_config.kind {
crate::nix::ProvisionerType::Terranix | crate::nix::ProvisionerType::BareMetal => {}
_ => {
tracing::info!(
"Skipping provisioner '{}' (type {:?}) — installation not supported.",
prov_name,
prov_config.kind
);
continue;
}
}

// Check if nixos-anywhere is enabled for this provisioner
Expand Down Expand Up @@ -147,8 +150,8 @@ pub async fn run(hive: Hive, opts: Opts) -> NaviResult<()> {

let facts_dir = Path::new(&meta.facts.dir_name).join(&prov_name);

// 3a. Handle Reinstall Logic (Infrastructure Recreation)
if opts.reinstall {
// 3a. Handle Reinstall Logic (Infrastructure Recreation) — Terranix only
if opts.reinstall && prov_config.kind == crate::nix::ProvisionerType::Terranix {
tracing::info!("Reinstall requested. Checking for resources to destroy and recreate...");

// Reconstruct the workspace path
Expand All @@ -162,7 +165,7 @@ pub async fn run(hive: Hive, opts: Opts) -> NaviResult<()> {
tracing::info!("Found resource for node {}: {}", node_name, addr);
tracing::warn!("RECREATING infrastructure for node {}", node_name);

eprintln!("\nTargeting Terraform resource: {}", addr);
eprintln!("\n[33mTargeting Terraform resource: {}[0m", addr);
if confirm_action(&format!("Are you sure you want to destroy and recreate this resource for node '{}'?", node_name))? {
match executor.replace_resource(&addr).await {
Ok(_) => {
Expand Down Expand Up @@ -190,6 +193,8 @@ pub async fn run(hive: Hive, opts: Opts) -> NaviResult<()> {
} else {
tracing::warn!("Provisioner workspace not found at {:?}. Cannot perform reinstall actions.", work_dir);
}
} else if opts.reinstall && prov_config.kind == crate::nix::ProvisionerType::BareMetal {
tracing::info!("Reinstall requested for bare-metal provisioner '{}'. No infrastructure to recreate.", prov_name);
}

// 4. Load Outputs (Cache or Live)
Expand Down Expand Up @@ -228,6 +233,7 @@ pub async fn run(hive: Hive, opts: Opts) -> NaviResult<()> {
&outputs_json,
opts.unlock,
Some(final_nodes),
None,
)
.await?;
}
Expand Down
203 changes: 202 additions & 1 deletion src/command/provision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub struct Opts {
pub list: bool,

/// Explicitly select a provisioner to run
#[arg(conflicts_with = "on", conflicts_with = "exclude")]
#[arg(conflicts_with = "on")]
pub provisioner: Option<String>,

/// Destroy and recreate the infrastructure
Expand All @@ -39,6 +39,15 @@ pub struct Opts {
#[arg(long)]
pub skip_install: bool,

/// IP address for bare-metal provisioning (skips interactive prompt)
#[arg(long)]
pub ip: Option<String>,

/// Password for initial SSH connection to the installer (e.g. for bare-metal
/// hosts that haven't been keyed yet). Passed to nixos-anywhere via --env-password.
#[arg(long)]
pub initial_password: Option<String>,

#[command(flatten)]
pub node_filter: NodeFilterOpts,
}
Expand Down Expand Up @@ -148,6 +157,9 @@ async fn run_provisioner(
ProvisionerType::Terranix => {
run_terranix_provisioner(hive, name, config, targets, opts, meta).await
}
ProvisionerType::BareMetal => {
run_bare_metal_provisioner(hive, name, config, targets, opts, meta).await
}
}
}

Expand Down Expand Up @@ -363,6 +375,133 @@ async fn run_terranix_provisioner(
&output_json,
opts.unlock,
Some(relevant_nodes),
None,
)
.await?;
}
}
} else {
tracing::info!("Skipping install step as requested.");
}

Ok(())
}

/// Bare-metal provisioner: resolves node IPs interactively or via `--ip`,
/// writes them as facts, then optionally runs nixos-anywhere.
async fn run_bare_metal_provisioner(
hive: &Hive,
name: &str,
config: &ProvisionerConfig,
targets: &HashMap<NodeName, TargetNode>,
opts: &Opts,
meta: &MetaConfig,
) -> NaviResult<()> {
let facts_dir = Path::new(&meta.facts.dir_name).join(name);

// Load existing facts if present
let mut existing_outputs: serde_json::Value = if facts_dir.join("outputs.json").exists() {
let content = tokio::fs::read_to_string(facts_dir.join("outputs.json"))
.await
.map_err(|e| NaviError::IoContext {
error: e,
context: format!("reading existing facts from {:?}", facts_dir),
})?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};

// Determine relevant nodes for this provisioner
let relevant_nodes: Vec<(&NodeName, &TargetNode)> = targets
.iter()
.filter(|(_, target)| target.config.provisioner.as_deref() == Some(name))
.collect();

if relevant_nodes.is_empty() {
tracing::warn!("No nodes assigned to bare-metal provisioner '{}'.", name);
return Ok(());
}

// Resolve IP for each node
for (node_name, _target) in &relevant_nodes {
let ip_key = format!("{}_ip", node_name.as_str().replace('-', "_"));

// Check if IP already exists in facts
let existing_ip = existing_outputs
.get(&ip_key)
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());

let ip = if opts.reprovision || existing_ip.is_none() {
// Need to get the IP: from --ip flag or interactively
if let Some(ip) = &opts.ip {
tracing::info!(
"[bare-metal] Using provided IP for {}: {}",
node_name.as_str(),
ip
);
ip.clone()
} else if let Some(existing) = &existing_ip {
if !opts.reprovision {
tracing::info!(
"[bare-metal] Using existing IP for {}: {}",
node_name.as_str(),
existing
);
existing.clone()
} else {
// Reprovision: prompt with existing as hint
prompt_for_ip(node_name.as_str(), Some(existing))?
}
} else {
// No existing IP, no flag: prompt
prompt_for_ip(node_name.as_str(), None)?
}
} else {
let ip = existing_ip.unwrap();
tracing::info!(
"[bare-metal] Using existing IP for {}: {}",
node_name.as_str(),
ip
);
ip
};

// Write/update the fact for this node
existing_outputs[&ip_key] = serde_json::json!({
"sensitive": false,
"type": "string",
"value": ip
});
}

// Persist facts
if meta.facts.enable {
write_bare_metal_facts(&facts_dir, &existing_outputs)?;
tracing::info!("Facts saved to {:?}", facts_dir);
}

// Handle NixOS Anywhere
if !opts.skip_install {
if let Some(na_config) = &config.nixos_anywhere {
if na_config.enable {
tracing::info!("Running nixos-anywhere for bare-metal nodes...");

let node_names: Vec<&str> = relevant_nodes
.iter()
.map(|(n, _)| n.as_str())
.collect();

crate::nix::nixos_anywhere::run(
hive,
targets,
na_config,
&existing_outputs,
opts.unlock,
Some(node_names),
opts.initial_password.as_deref(),
)
.await?;
}
Expand All @@ -374,6 +513,68 @@ async fn run_terranix_provisioner(
Ok(())
}

/// Prompts the user for an IP address interactively.
fn prompt_for_ip(node_name: &str, existing: Option<&str>) -> NaviResult<String> {
if let Some(existing) = existing {
eprint!("Enter IP address for {} [{}]: ", node_name, existing);
} else {
eprint!("Enter IP address for {}: ", node_name);
}

let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| NaviError::IoError { error: e })?;

let trimmed = input.trim();
if trimmed.is_empty() {
if let Some(existing) = existing {
Ok(existing.to_string())
} else {
Err(NaviError::DeploymentError {
message: format!("No IP address provided for node '{}'", node_name),
})
}
} else {
Ok(trimmed.to_string())
}
}

/// Writes bare-metal facts (IP mappings) in the same format as Terraform outputs.
pub fn write_bare_metal_facts(
facts_dir: &Path,
outputs: &serde_json::Value,
) -> NaviResult<()> {
// Create directory
std::fs::create_dir_all(facts_dir).map_err(|e| NaviError::IoContext {
error: e,
context: format!("creating facts directory {:?}", facts_dir),
})?;

// Write outputs.json
let json_path = facts_dir.join("outputs.json");
let json_content =
serde_json::to_string_pretty(outputs).expect("Failed to serialize outputs");
std::fs::write(&json_path, json_content).map_err(|e| NaviError::IoContext {
error: e,
context: format!("writing facts to {:?}", json_path),
})?;

// Write default.nix (same format as Terraform facts)
let nix_path = facts_dir.join("default.nix");
let nix_content = r#"let
raw = builtins.fromJSON (builtins.readFile ./outputs.json);
in
builtins.mapAttrs (n: v: v.value) raw
"#;
std::fs::write(&nix_path, nix_content).map_err(|e| NaviError::IoContext {
error: e,
context: format!("writing nix facts to {:?}", nix_path),
})?;

Ok(())
}

async fn inject_registrant_providers(
work_dir: &Path,
registrants: &crate::nix::RegistrantsConfig,
Expand Down
16 changes: 16 additions & 0 deletions src/nix/hive/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,22 @@ rec {
type = types.nullOr types.str;
default = null;
};
forceHwLink = lib.mkOption {
description = ''
Force SSH connections through physical network interfaces (e.g.
enp*, wlp*), bypassing overlay networks such as Tailscale.

When enabled, Navi will detect an appropriate physical interface
and route SSH traffic through it using socat's bindtodevice.
This is useful for bare-metal hosts on a LAN that should be
reached directly rather than through a VPN tunnel.

Note: This setting applies to regular deployment connections.
For initrd/unlock connections, use deployment.unlock.forceHwLink.
'';
type = types.bool;
default = false;
};
providers = lib.mkOption {
description = ''
Cloud provider settings.
Expand Down
Loading