diff --git a/Cargo.lock b/Cargo.lock index 1ac1932b..c8aa24d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ "serde_with", "shell-words", "sysinfo", + "tempfile", "thiserror", "tokio", "tracing", diff --git a/crates/nix_rs/Cargo.toml b/crates/nix_rs/Cargo.toml index 86360a8f..dccafd80 100644 --- a/crates/nix_rs/Cargo.toml +++ b/crates/nix_rs/Cargo.toml @@ -27,6 +27,7 @@ colored = { workspace = true } shell-words = { workspace = true } is_proc_translated = { workspace = true } sysinfo = { workspace = true } +tempfile = { workspace = true } bytesize = { workspace = true } clap = { workspace = true, optional = true } nonempty = { workspace = true } diff --git a/crates/nix_rs/src/store/command.rs b/crates/nix_rs/src/store/command.rs index e776f7f3..2ab4916d 100644 --- a/crates/nix_rs/src/store/command.rs +++ b/crates/nix_rs/src/store/command.rs @@ -1,8 +1,9 @@ //! Rust wrapper for `nix-store` -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::command::{CommandError, NixCmdError}; use serde::{Deserialize, Serialize}; +use tempfile::TempDir; use thiserror::Error; use tokio::process::Command; @@ -51,25 +52,15 @@ impl NixStoreCmd { cmd.args(["--query", "--valid-derivers"]) .args(out_paths.iter().map(StorePath::as_path)); - crate::command::trace_cmd(&cmd); - - let out = cmd.output().await?; - if out.status.success() { - let drv_paths: Vec = String::from_utf8(out.stdout)? - .lines() - .map(PathBuf::from) - .collect(); - if drv_paths.contains(&PathBuf::from("unknown-deriver")) { - return Err(NixStoreCmdError::UnknownDeriver); - } - Ok(drv_paths) - } else { - // TODO(refactor): When upstreaming this module to nix-rs, create a - // nicer and unified way to create `ProcessFailed` - let stderr = Some(String::from_utf8_lossy(&out.stderr).to_string()); - let exit_code = out.status.code(); - Err(CommandError::ProcessFailed { stderr, exit_code }.into()) + let stdout = run_awaiting_stdout(&mut cmd).await?; + let drv_paths: Vec = String::from_utf8(stdout)? + .lines() + .map(PathBuf::from) + .collect(); + if drv_paths.contains(&PathBuf::from("unknown-deriver")) { + return Err(NixStoreCmdError::UnknownDeriver); } + Ok(drv_paths) } /// Given the derivation paths, this function recursively queries and return all @@ -82,20 +73,67 @@ impl NixStoreCmd { cmd.args(["--query", "--requisites", "--include-outputs"]) .args(drv_paths); - crate::command::trace_cmd(&cmd); - - let out = cmd.output().await?; - if out.status.success() { - Ok(String::from_utf8(out.stdout)? - .lines() - .map(|line| StorePath::new(PathBuf::from(line))) - .collect()) - } else { - // TODO(refactor): see above - let stderr = Some(String::from_utf8_lossy(&out.stderr).to_string()); - let exit_code = out.status.code(); - Err(CommandError::ProcessFailed { stderr, exit_code }.into()) - } + let stdout = run_awaiting_stdout(&mut cmd).await?; + Ok(String::from_utf8(stdout)? + .lines() + .map(|line| StorePath::new(PathBuf::from(line))) + .collect()) + } + + /// Create a file in the Nix store such that it escapes garbage collection. + /// + /// Return the nix store path added. + pub async fn add_file_permanently( + &self, + symlink: &Path, + contents: &str, + ) -> Result { + let temp_dir = TempDir::with_prefix("omnix-ci-")?; + let temp_file = temp_dir.path().join("om.json"); + std::fs::write(&temp_file, contents)?; + + let path = self.nix_store_add(&temp_file).await?; + self.nix_store_add_root(symlink, &[&path]).await?; + Ok(path) + } + + /// Run `nix-store --add` on the give path and return the store path added. + pub async fn nix_store_add(&self, path: &Path) -> Result { + let mut cmd = self.command(); + cmd.arg("--add").arg(path); + + let stdout = run_awaiting_stdout(&mut cmd).await?; + Ok(StorePath::new(PathBuf::from( + String::from_utf8(stdout)?.trim_end(), + ))) + } + + /// Run `nix-store --add-root` on the given paths and return the store path added. + pub async fn nix_store_add_root( + &self, + symlink: &Path, + paths: &[&StorePath], + ) -> Result<(), NixStoreCmdError> { + let mut cmd = self.command(); + cmd.arg("--add-root") + .arg(symlink) + .arg("--realise") + .args(paths); + + run_awaiting_stdout(&mut cmd).await?; + Ok(()) + } +} + +async fn run_awaiting_stdout(cmd: &mut Command) -> Result, NixStoreCmdError> { + crate::command::trace_cmd(cmd); + let out = cmd.output().await?; + if out.status.success() { + Ok(out.stdout) + } else { + let stderr = Some(String::from_utf8_lossy(&out.stderr).to_string()); + let exit_code = out.status.code(); + Err(CommandError::ProcessFailed { stderr, exit_code }.into()) } } diff --git a/crates/omnix-ci/src/command/run.rs b/crates/omnix-ci/src/command/run.rs index 999c5208..36813e65 100644 --- a/crates/omnix-ci/src/command/run.rs +++ b/crates/omnix-ci/src/command/run.rs @@ -9,7 +9,7 @@ use nix_rs::{ config::NixConfig, flake::{system::System, url::FlakeUrl}, info::NixInfo, - store::{path::StorePath, uri::StoreURI}, + store::{command::NixStoreCmd, path::StorePath, uri::StoreURI}, system_list::{SystemsList, SystemsListFlakeRef}, }; use omnix_common::config::OmConfig; @@ -38,9 +38,19 @@ pub struct RunCommand { #[arg(long)] pub systems: Option, - /// Path to write the results of the CI run (in JSON) to - #[arg(long, short = 'o')] - pub results: Option, + /// Symlink to create to build results JSON. Defaults to `result` + #[arg( + long, + short = 'o', + default_value = "result", + conflicts_with = "no_out_link", + alias = "results" // For backwards compat + )] + out_link: Option, + + /// Do not create a symlink to build results JSON + #[arg(long)] + no_out_link: bool, /// Flake URL or github URL /// @@ -66,6 +76,25 @@ impl RunCommand { self.steps_args.build_step_args.preprocess(); } + /// Get the out-link path + pub fn get_out_link(&self) -> Option<&PathBuf> { + if self.no_out_link { + None + } else { + self.out_link.as_ref() + } + } + + /// Override the flake_ref and out_link for building locally. + pub fn local_with(&self, flake_ref: FlakeRef, out_link: Option) -> Self { + let mut new = self.clone(); + new.on = None; // Disable remote building + new.flake_ref = flake_ref; + new.no_out_link = out_link.is_none(); + new.out_link = out_link; + new + } + /// Run the build command which decides whether to do ci run on current machine or a remote machine pub async fn run(&self, nixcmd: &NixCmd, verbose: bool, cfg: OmConfig) -> anyhow::Result<()> { match &self.on { @@ -96,11 +125,14 @@ impl RunCommand { ); let res = ci_run(nixcmd, verbose, self, &cfg, &nix_info.nix_config).await?; - if let Some(results_file) = self.results.as_ref() { - serde_json::to_writer(std::fs::File::create(results_file)?, &res)?; + if let Some(out_link) = self.get_out_link() { + let s = serde_json::to_string_pretty(&res)?; + let nix_store = NixStoreCmd {}; + let results_path = nix_store.add_file_permanently(out_link, &s).await?; tracing::info!( - "Results written to {}", - results_file.to_string_lossy().bold() + "Result available at {:?} and symlinked at {:?}", + results_path.as_path(), + out_link ); } @@ -136,9 +168,13 @@ impl RunCommand { args.push(systems.0 .0.clone()); } - if let Some(results_file) = self.results.as_ref() { - args.push("-o".to_string()); - args.push(results_file.to_string_lossy().to_string()); + if let Some(out_link) = self.out_link.as_ref() { + args.push("--out-link".to_string()); + args.push(out_link.to_string_lossy().to_string()); + } + + if self.no_out_link { + args.push("--no-out-link".to_string()); } args.push(self.flake_ref.to_string()); diff --git a/crates/omnix-ci/src/command/run_remote.rs b/crates/omnix-ci/src/command/run_remote.rs index ea971c56..53439f99 100644 --- a/crates/omnix-ci/src/command/run_remote.rs +++ b/crates/omnix-ci/src/command/run_remote.rs @@ -2,12 +2,18 @@ use colored::Colorize; use nix_rs::{ - command::NixCmd, + command::{CommandError, NixCmd}, + copy::{nix_copy, NixCopyOptions}, flake::{metadata::FlakeMetadata, url::FlakeUrl}, - store::uri::StoreURI, + store::{command::NixStoreCmd, path::StorePath, uri::StoreURI}, }; use omnix_common::config::OmConfig; -use std::{ffi::OsString, os::unix::ffi::OsStringExt, path::PathBuf}; +use std::{ + ffi::{OsStr, OsString}, + fs::File, + os::unix::ffi::OsStringExt, + path::{Path, PathBuf}, +}; use tokio::process::Command; use crate::command::run::RunResult; @@ -34,97 +40,128 @@ pub async fn run_on_remote_store( let StoreURI::SSH(ssh_uri) = store_uri; // First, copy the flake and omnix source to the remote store, because we will be needing them when running over ssh. - nix_rs::copy::nix_copy( - nixcmd, - nix_rs::copy::NixCopyOptions { - to: Some(store_uri.clone()), - no_check_sigs: true, - ..Default::default() - }, - &[&omnix_source, &local_flake_path], - ) - .await?; + nix_copy_to_remote(nixcmd, store_uri, &[&omnix_source, &local_flake_path]).await?; - // If the user requested creation of `om.json`, we copy all built store paths back, so that the resultant om.json available locally contains valid paths. `-o` can thus be used to trick omnix into copying build results back to local store. - if let Some(results_file) = run_cmd.results.as_ref() { - // Create a temp file to hold om.json - let om_json_path = parse_path_line( + // If out-link is requested, we need to copy the results back to local store - so that when we create the out-link *locally* the paths in it refer to valid paths in the local store. Thus, --out-link can be used to trick Omnix into copying all built paths back. + if let Some(out_link) = run_cmd.get_out_link() { + // A temporary location on ssh remote to hold the result + let tmpdir = parse_path_line( &run_ssh_with_output( &ssh_uri.to_string(), - &[ - "nix", - "shell", - "nixpkgs#coreutils", - "-c", - "mktemp", - "-t", - "om.json.XXXXXX", - ], + &nixpkgs_cmd("coreutils", &["mktemp", "-d", "-t", "om.json.XXXXXX"]), ) .await?, ); + let om_json_path = tmpdir.join("om.json"); - // Then, SSH and run the same `om ci run` CLI but without the `--on` argument. + // Then, SSH and run the same `om ci run` CLI but without the `--on` argument but with `--out-link` pointing to the temporary location. run_ssh( &ssh_uri.to_string(), - &om_cli_with(&RunCommand { - on: None, - flake_ref: local_flake_url.clone().into(), - results: Some(om_json_path.clone()), - ..run_cmd.clone() - }), + &om_cli_with( + run_cmd.local_with(local_flake_url.clone().into(), Some(om_json_path.clone())), + ), ) .await?; - // Get om.json - let om_result: RunResult = serde_json::from_slice( + // Get the out-link store path. + let om_result_path: StorePath = StorePath::new(parse_path_line( &run_ssh_with_output( &ssh_uri.to_string(), - &["cat", om_json_path.to_string_lossy().as_ref()], + &nixpkgs_cmd( + "coreutils", + &["readlink", om_json_path.to_string_lossy().as_ref()], + ), ) .await?, - )?; - - // Copy the results back to local store - tracing::info!("{}", "📦 Copying built paths back to local store".bold()); - nix_rs::copy::nix_copy( - nixcmd, - nix_rs::copy::NixCopyOptions { - from: Some(store_uri.clone()), - no_check_sigs: true, - ..Default::default() - }, - om_result.all_out_paths(), - ) - .await?; - - // Write the om.json to the requested file - serde_json::to_writer(std::fs::File::create(results_file)?, &om_result)?; + )); + + // Copy the results back to local store, including the out-link. + tracing::info!("{}", "📦 Copying results back to local store".bold()); + nix_copy_from_remote(nixcmd, store_uri, &[&om_result_path]).await?; + let om_results: RunResult = serde_json::from_reader(File::open(&om_result_path)?)?; + // Copy all paths referenced in results file + nix_copy_from_remote(nixcmd, store_uri, om_results.all_out_paths()).await?; + + // Write the local out-link + let nix_store = NixStoreCmd {}; + nix_store + .nix_store_add_root(out_link, &[&om_result_path]) + .await?; tracing::info!( - "Results written to {}", - results_file.to_string_lossy().bold() + "Results available at {:?} symlinked at {:?}", + om_result_path.as_path(), + out_link ); } else { // Then, SSH and run the same `om ci run` CLI but without the `--on` argument. run_ssh( &ssh_uri.to_string(), - &om_cli_with(&RunCommand { - on: None, - flake_ref: local_flake_url.clone().into(), - results: None, - ..run_cmd.clone() - }), + &om_cli_with(run_cmd.local_with(local_flake_url.clone().into(), None)), ) .await?; } Ok(()) } +async fn nix_copy_to_remote( + nixcmd: &NixCmd, + store_uri: &StoreURI, + paths: I, +) -> Result<(), CommandError> +where + I: IntoIterator, + P: AsRef + AsRef, +{ + nix_copy( + nixcmd, + NixCopyOptions { + to: Some(store_uri.to_owned()), + no_check_sigs: true, + ..Default::default() + }, + paths, + ) + .await +} + +async fn nix_copy_from_remote( + nixcmd: &NixCmd, + store_uri: &StoreURI, + paths: I, +) -> Result<(), CommandError> +where + I: IntoIterator, + P: AsRef + AsRef, +{ + nix_copy( + nixcmd, + NixCopyOptions { + from: Some(store_uri.to_owned()), + no_check_sigs: true, + ..Default::default() + }, + paths, + ) + .await +} + fn parse_path_line(bytes: &[u8]) -> PathBuf { let trimmed_bytes = bytes.trim_ascii_end(); PathBuf::from(OsString::from_vec(trimmed_bytes.to_vec())) } +/// Construct CLI arguments for running a program from nixpkgs using given arguments +fn nixpkgs_cmd(package: &str, cmd: &[&str]) -> Vec { + let mut args = vec![ + "nix".to_owned(), + "shell".to_owned(), + format!("nixpkgs#{}", package), + ]; + args.push("-c".to_owned()); + args.extend(cmd.iter().map(|s| s.to_string())); + args +} + /// Return the locally cached [FlakeUrl] for the given flake url that points to same selected [ConfigRef]. async fn cache_flake(nixcmd: &NixCmd, cfg: &OmConfig) -> anyhow::Result<(PathBuf, FlakeUrl)> { let metadata = FlakeMetadata::from_nix(nixcmd, &cfg.flake_url).await?; @@ -141,7 +178,7 @@ async fn cache_flake(nixcmd: &NixCmd, cfg: &OmConfig) -> anyhow::Result<(PathBuf /// Construct a `nix run ...` based CLI that runs Omnix using given arguments. /// /// Omnix itself will be compiled from source ([OMNIX_SOURCE]) if necessary. Thus, this invocation is totally independent and can be run on remote machines, as long as the paths exista on the nix store. -fn om_cli_with(run_cmd: &RunCommand) -> Vec { +fn om_cli_with(run_cmd: RunCommand) -> Vec { let mut args: Vec = vec![]; let omnix_flake = format!("{}#default", OMNIX_SOURCE);