|
use anyhow::{Context, Result, bail}; |
|
use clap::{Parser, Subcommand}; |
|
use serde::Deserialize; |
|
use std::collections::HashMap; |
|
use std::env; |
|
use std::fs; |
|
use std::path::{Path, PathBuf}; |
|
use std::process::{Command, Stdio}; |
|
|
|
const DEFAULT_BUNDLE: &str = match option_env!("NIX_OFFLINE_DEFAULT_BUNDLE") { |
|
Some(val) => val, |
|
None => "nix-offline-bundle.nar.zst", |
|
}; |
|
|
|
const DEFAULT_NIXPKGS_PATH: &str = match option_env!("NIX_OFFLINE_NIXPKGS_PATH") { |
|
Some(val) => val, |
|
None => "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz", |
|
}; |
|
|
|
const DEFAULT_SYSTEMS_STR: Option<&'static str> = option_env!("NIX_OFFLINE_SYSTEMS"); |
|
const DEFAULT_HOST_SYSTEM: &str = match option_env!("NIX_HOST_SYSTEM") { |
|
Some(val) => val, |
|
None => "x86_64-linux", |
|
}; |
|
|
|
#[derive(Parser, Debug)] |
|
struct Cli { |
|
#[command(subcommand)] |
|
command: Option<Commands>, |
|
} |
|
|
|
#[derive(Subcommand, Debug)] |
|
enum Commands { |
|
/// Pre-cache nixpkgs tarballs and/or Flake inputs into /nix/store |
|
Precache(PrecacheArgs), |
|
|
|
/// Export or import compressed .nar.zst archives |
|
Archive { |
|
#[command(subcommand)] |
|
action: ArchiveAction, |
|
}, |
|
} |
|
|
|
#[derive(Parser, Debug)] |
|
struct PrecacheArgs { |
|
/// Pre-cache nixpkgs tarballs for specific system architecture |
|
#[arg(short, long)] |
|
system: Option<String>, |
|
|
|
/// Pre-cache nixpkgs tarballs for all supported system architectures |
|
#[arg(short = 'a', long)] |
|
all_systems: bool, |
|
|
|
/// Flake path(s) to archive and GC-pin |
|
#[arg(short, long)] |
|
flake: Vec<String>, |
|
|
|
/// Skip Flake input pre-caching (only pre-cache nixpkgs tarballs) |
|
#[arg(long)] |
|
no_flake: bool, |
|
|
|
/// Skip nixpkgs tarball pre-caching (only pre-cache Flake inputs) |
|
#[arg(long)] |
|
no_nixpkgs: bool, |
|
} |
|
|
|
impl PrecacheArgs { |
|
fn flake_paths(&self) -> Vec<String> { |
|
if self.flake.is_empty() { |
|
vec![".".to_string()] |
|
} else { |
|
self.flake.clone() |
|
} |
|
} |
|
} |
|
|
|
#[derive(Subcommand, Debug)] |
|
enum ArchiveAction { |
|
/// Export nixpkgs tarballs and/or Flake inputs to a compressed .nar.zst archive |
|
Export(ExportArgs), |
|
|
|
/// Import a .nar.zst archive into /nix/store and pin GC roots |
|
Import(ImportArgs), |
|
} |
|
|
|
#[derive(Parser, Debug)] |
|
struct ExportArgs { |
|
/// Include nixpkgs tarballs for specific target system architecture |
|
#[arg(short, long)] |
|
system: Option<String>, |
|
|
|
/// Include nixpkgs tarballs for all supported system architectures |
|
#[arg(short = 'a', long)] |
|
all_systems: bool, |
|
|
|
/// Flake path(s) to include |
|
#[arg(short, long)] |
|
flake: Vec<String>, |
|
|
|
/// Output .nar.zst bundle path |
|
#[arg(short, long, default_value = DEFAULT_BUNDLE)] |
|
output: String, |
|
|
|
/// Do not include Flake inputs in exported archive |
|
#[arg(long)] |
|
no_flake: bool, |
|
|
|
/// Do not include nixpkgs tarballs in exported archive |
|
#[arg(long)] |
|
no_nixpkgs: bool, |
|
} |
|
|
|
impl ExportArgs { |
|
fn flake_paths(&self) -> Vec<String> { |
|
if self.flake.is_empty() { |
|
vec![".".to_string()] |
|
} else { |
|
self.flake.clone() |
|
} |
|
} |
|
} |
|
|
|
#[derive(Parser, Debug)] |
|
struct ImportArgs { |
|
/// Compressed .nar.zst archive file |
|
#[arg(default_value = DEFAULT_BUNDLE)] |
|
file: String, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct FlakeArchiveOutput { |
|
path: Option<String>, |
|
inputs: Option<HashMap<String, FlakeInput>>, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct FlakeInput { |
|
path: Option<String>, |
|
} |
|
|
|
fn get_nix_flags() -> Vec<String> { |
|
let mut flags = vec![ |
|
"--option".to_string(), |
|
"hashed-mirrors".to_string(), |
|
"https://tarballs.nixos.org".to_string(), |
|
"--option".to_string(), |
|
"http-connections".to_string(), |
|
env::var("NIX_HTTP_CONNECTIONS").unwrap_or_else(|_| "16".to_string()), |
|
]; |
|
|
|
let token = env::var("GITHUB_TOKEN") |
|
.or_else(|_| env::var("GH_TOKEN")) |
|
.unwrap_or_default(); |
|
if !token.is_empty() { |
|
flags.push("--option".to_string()); |
|
flags.push("access-tokens".to_string()); |
|
flags.push(format!("github.com={}", token)); |
|
} |
|
|
|
flags |
|
} |
|
|
|
fn get_gc_dir() -> Result<PathBuf> { |
|
let state_home = env::var("XDG_STATE_HOME").unwrap_or_else(|_| { |
|
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
format!("{}/.local/state", home) |
|
}); |
|
let gc_dir = PathBuf::from(state_home).join("nix").join("gcroots"); |
|
fs::create_dir_all(&gc_dir).context("Failed to create GC roots directory")?; |
|
Ok(gc_dir) |
|
} |
|
|
|
fn get_host_system() -> String { |
|
env::var("NIX_HOST_SYSTEM").unwrap_or_else(|_| DEFAULT_HOST_SYSTEM.to_string()) |
|
} |
|
|
|
fn eval_expr(sys: &str, nixpkgs_path: &str) -> String { |
|
format!( |
|
"let pkgs = import {} {{ system = \"{}\"; }}; in import {}/maintainers/scripts/all-tarballs.nix {{ inherit pkgs; }}", |
|
nixpkgs_path, sys, nixpkgs_path |
|
) |
|
} |
|
|
|
fn build_system_tarballs(systems: &[String], nixpkgs_path: &str) -> Result<Vec<String>> { |
|
let mut tarballs = Vec::new(); |
|
let flags = get_nix_flags(); |
|
|
|
for sys in systems { |
|
eprintln!("==> Building nixpkgs tarballs for {}...", sys); |
|
let expr = eval_expr(sys, nixpkgs_path); |
|
|
|
let mut cmd = Command::new("nix"); |
|
cmd.arg("build") |
|
.arg("--impure") |
|
.args(&flags) |
|
.arg("--print-out-paths") |
|
.arg("--expr") |
|
.arg(&expr); |
|
|
|
let output = cmd |
|
.output() |
|
.context("Failed to run nix build for tarballs")?; |
|
if !output.status.success() { |
|
bail!( |
|
"nix build failed for system {}: {}", |
|
sys, |
|
String::from_utf8_lossy(&output.stderr) |
|
); |
|
} |
|
|
|
let paths_str = String::from_utf8_lossy(&output.stdout); |
|
for line in paths_str.lines() { |
|
let trimmed = line.trim(); |
|
if !trimmed.is_empty() { |
|
tarballs.push(trimmed.to_string()); |
|
} |
|
} |
|
} |
|
|
|
Ok(tarballs) |
|
} |
|
|
|
fn collect_flake_paths(flakes: &[String]) -> Result<Vec<String>> { |
|
let mut store_paths = Vec::new(); |
|
let flags = get_nix_flags(); |
|
|
|
let target_flakes = if flakes.is_empty() { |
|
vec![".".to_string()] |
|
} else { |
|
flakes.to_vec() |
|
}; |
|
|
|
for f in &target_flakes { |
|
if f.is_empty() { |
|
continue; |
|
} |
|
|
|
let mut cmd = Command::new("nix"); |
|
cmd.arg("flake") |
|
.arg("archive") |
|
.args(&flags) |
|
.arg("--json") |
|
.arg(f); |
|
|
|
if let Ok(out) = cmd.output() { |
|
if !out.status.success() { |
|
continue; |
|
} |
|
if let Ok(parsed) = serde_json::from_slice::<FlakeArchiveOutput>(&out.stdout) { |
|
if let Some(p) = &parsed.path { |
|
store_paths.push(p.clone()); |
|
} |
|
if let Some(inputs) = &parsed.inputs { |
|
for input in inputs.values() { |
|
if let Some(ip) = &input.path { |
|
store_paths.push(ip.clone()); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
Ok(store_paths) |
|
} |
|
|
|
fn run_precache( |
|
args: PrecacheArgs, |
|
nixpkgs_path: &str, |
|
supported_systems: &[String], |
|
) -> Result<()> { |
|
let gc_dir = get_gc_dir()?; |
|
let flags = get_nix_flags(); |
|
|
|
if !args.no_nixpkgs { |
|
let systems_to_cache = if args.all_systems { |
|
supported_systems.to_vec() |
|
} else { |
|
vec![args.system.clone().unwrap_or_else(get_host_system)] |
|
}; |
|
|
|
for sys in &systems_to_cache { |
|
eprintln!("==> Pre-caching nixpkgs tarballs for {}...", sys); |
|
let expr = eval_expr(sys, nixpkgs_path); |
|
let out_link = gc_dir.join(format!("nix-offline-{}", sys)); |
|
|
|
let mut cmd = Command::new("nix"); |
|
cmd.arg("build") |
|
.arg("--impure") |
|
.args(&flags) |
|
.arg("--out-link") |
|
.arg(&out_link) |
|
.arg("--expr") |
|
.arg(&expr); |
|
|
|
let status = cmd.status().context("Failed to run nix build")?; |
|
if !status.success() { |
|
bail!("nix build failed for system {}", sys); |
|
} |
|
} |
|
} |
|
|
|
if !args.no_flake { |
|
let store_paths = collect_flake_paths(&args.flake_paths())?; |
|
if !store_paths.is_empty() { |
|
eprintln!("==> GC-pinning Flake inputs into {}...", gc_dir.display()); |
|
let target_pin_dir = gc_dir.join("nix-offline-flake"); |
|
|
|
let mut cmd = Command::new("nix"); |
|
cmd.arg("store") |
|
.arg("pin") |
|
.arg("--into") |
|
.arg(&target_pin_dir) |
|
.args(&store_paths); |
|
|
|
let _ = cmd.status(); |
|
} |
|
} |
|
|
|
Ok(()) |
|
} |
|
|
|
fn run_export(args: ExportArgs, nixpkgs_path: &str, supported_systems: &[String]) -> Result<()> { |
|
let target_sys = args.system.clone().unwrap_or_else(get_host_system); |
|
let output_file = args.output.clone(); |
|
|
|
let mut paths_to_export = Vec::new(); |
|
|
|
if !args.no_nixpkgs { |
|
let systems_to_export = if args.all_systems { |
|
supported_systems.to_vec() |
|
} else { |
|
vec![target_sys] |
|
}; |
|
|
|
let tarball_paths = build_system_tarballs(&systems_to_export, nixpkgs_path)?; |
|
paths_to_export.extend(tarball_paths); |
|
paths_to_export.push(nixpkgs_path.to_string()); |
|
} |
|
|
|
if !args.no_flake { |
|
let flake_paths = collect_flake_paths(&args.flake_paths())?; |
|
paths_to_export.extend(flake_paths); |
|
} |
|
|
|
eprintln!( |
|
"==> Exporting {} store paths to {} using zstd...", |
|
paths_to_export.len(), |
|
output_file |
|
); |
|
|
|
let mut nix_export = Command::new("nix") |
|
.arg("store") |
|
.arg("export") |
|
.args(&paths_to_export) |
|
.stdout(Stdio::piped()) |
|
.spawn() |
|
.context("Failed to execute nix store export")?; |
|
|
|
let export_stdout = nix_export |
|
.stdout |
|
.take() |
|
.context("Failed to capture nix store export stdout")?; |
|
|
|
let zstd_status = Command::new("zstd") |
|
.arg("-T0") |
|
.arg("-o") |
|
.arg(&output_file) |
|
.stdin(export_stdout) |
|
.status() |
|
.context("Failed to execute zstd compression")?; |
|
|
|
let nix_status = nix_export.wait().context("nix store export failed")?; |
|
|
|
if !nix_status.success() || !zstd_status.success() { |
|
bail!("Archive export failed!"); |
|
} |
|
|
|
eprintln!("==> Export completed successfully: {}", output_file); |
|
Ok(()) |
|
} |
|
|
|
fn run_import(args: ImportArgs) -> Result<()> { |
|
let gc_dir = get_gc_dir()?; |
|
let path = Path::new(&args.file); |
|
if !path.exists() { |
|
bail!("Archive file '{}' not found.", args.file); |
|
} |
|
|
|
eprintln!("==> Importing {} into /nix/store...", args.file); |
|
|
|
let mut zstd_cmd = Command::new("zstd") |
|
.arg("-dc") |
|
.arg(&args.file) |
|
.stdout(Stdio::piped()) |
|
.spawn() |
|
.context("Failed to execute zstd decompression")?; |
|
|
|
let zstd_stdout = zstd_cmd |
|
.stdout |
|
.take() |
|
.context("Failed to capture zstd stdout")?; |
|
|
|
let nix_import_output = Command::new("nix") |
|
.arg("store") |
|
.arg("import") |
|
.stdin(zstd_stdout) |
|
.output() |
|
.context("Failed to execute nix store import")?; |
|
|
|
let zstd_status = zstd_cmd.wait().context("zstd decompression failed")?; |
|
|
|
if !zstd_status.success() || !nix_import_output.status.success() { |
|
bail!( |
|
"Archive import failed: {}", |
|
String::from_utf8_lossy(&nix_import_output.stderr) |
|
); |
|
} |
|
|
|
let imported_paths_str = String::from_utf8_lossy(&nix_import_output.stdout); |
|
let imported_paths: Vec<&str> = imported_paths_str |
|
.lines() |
|
.map(|l| l.trim()) |
|
.filter(|l| !l.is_empty()) |
|
.collect(); |
|
|
|
if !imported_paths.is_empty() { |
|
let target_pin_dir = gc_dir.join("nix-offline-imported"); |
|
let mut cmd = Command::new("nix"); |
|
cmd.arg("store") |
|
.arg("pin") |
|
.arg("--into") |
|
.arg(&target_pin_dir) |
|
.args(&imported_paths); |
|
|
|
let _ = cmd.status(); |
|
} |
|
|
|
eprintln!( |
|
"==> Done. Store paths imported and GC-pinned into {}", |
|
gc_dir.display() |
|
); |
|
Ok(()) |
|
} |
|
|
|
fn main() -> Result<()> { |
|
let nixpkgs_path = |
|
env::var("NIX_OFFLINE_NIXPKGS_PATH").unwrap_or_else(|_| DEFAULT_NIXPKGS_PATH.to_string()); |
|
|
|
let supported_systems: Vec<String> = env::var("NIX_OFFLINE_SYSTEMS") |
|
.map(|s| s.split_whitespace().map(|x| x.to_string()).collect()) |
|
.unwrap_or_else(|_| { |
|
DEFAULT_SYSTEMS_STR |
|
.map(|s| s.split_whitespace().map(|x| x.to_string()).collect()) |
|
.unwrap_or_else(|| { |
|
vec![ |
|
"aarch64-darwin".to_string(), |
|
"aarch64-linux".to_string(), |
|
"x86_64-darwin".to_string(), |
|
"x86_64-linux".to_string(), |
|
] |
|
}) |
|
}); |
|
|
|
let cli = Cli::parse(); |
|
|
|
match cli.command { |
|
Some(Commands::Precache(args)) => run_precache(args, &nixpkgs_path, &supported_systems), |
|
Some(Commands::Archive { action }) => match action { |
|
ArchiveAction::Export(args) => run_export(args, &nixpkgs_path, &supported_systems), |
|
ArchiveAction::Import(args) => run_import(args), |
|
}, |
|
None => run_precache( |
|
PrecacheArgs { |
|
system: None, |
|
all_systems: false, |
|
flake: vec![".".to_string()], |
|
no_flake: false, |
|
no_nixpkgs: false, |
|
}, |
|
&nixpkgs_path, |
|
&supported_systems, |
|
), |
|
} |
|
} |
Issue: make host follows the nixpkgs input