diff --git a/cli/src/commands/build.rs b/cli/src/commands/build.rs index 31a5a9e82..8b5c72443 100644 --- a/cli/src/commands/build.rs +++ b/cli/src/commands/build.rs @@ -10,9 +10,9 @@ use crate::input::client_from_config; use crate::output::{ExitKind, Output}; use crate::tui::watch::eval_is_terminal; use connector::ConnectorError; -use connector::api::{ - ArtefactTree, DispatchResponse, EntryPointArtefacts, EvaluationResponse, EvaluationStatus, -}; +#[cfg(not(feature = "nix"))] +use connector::api::ArtefactTree; +use connector::api::{DispatchResponse, EvaluationResponse, EvaluationStatus}; #[cfg(not(feature = "nix"))] use std::io::{BufReader, Read}; use std::path::{Path, PathBuf}; @@ -139,51 +139,44 @@ pub async fn handle_build( crate::commands::logstream::stream_eval_logs(&client, &evaluation, out).await; - if no_link { - return; - } - let eval = wait_for_terminal(&client, &evaluation, out).await; - let status = eval.as_ref().map(|e| e.status); - if status != Some(EvaluationStatus::Completed) { - if !quiet { - if let Some(err) = eval - .as_ref() - .and_then(|e| e.error.as_deref()) - .filter(|s| !s.is_empty()) - { - out.human(format!("Evaluation error: {err}")); - } + if let Some(failure) = failure_message(eval.status, eval.error.as_deref()) { + out.err(ExitKind::Api, failure); + } - out.human(format!( - "Build did not complete (status: {}); skipping result.", - status.map(|s| s.to_string()).unwrap_or_default() - )); - } + if no_link { return; } - let tree = match client.evals().artefacts(&evaluation).await { - Ok(t) => t, - Err(e) => { - if !quiet { - out.progress(format!("Could not fetch artefacts: {}", e)); - } - return; - } - }; + let tree = client + .evals() + .artefacts(&evaluation) + .await + .unwrap_or_else(|e| out.err(ExitKind::Api, format!("Could not fetch artefacts: {e}"))); #[cfg(feature = "nix")] - crate::commands::build_nix::link_result( - &client, - &started, - &tree, - params.target.as_deref(), - out, - ) - .await; + crate::commands::build_nix::link_results(&client, &started, &tree, out).await; #[cfg(not(feature = "nix"))] - download_result_dir(&client, &tree, params.target.as_deref(), out).await; + download_result_dirs(&client, &tree, out).await; +} + +fn failure_message(status: EvaluationStatus, error: Option<&str>) -> Option { + if status == EvaluationStatus::Completed { + return None; + } + + let status = format!("Build did not complete (status: {status})."); + Some(match error.filter(|s| !s.is_empty()) { + Some(error) => format!("Evaluation error: {error}\n{status}"), + None => status, + }) +} + +pub(crate) fn result_link_name(index: usize) -> String { + match index { + 0 => "result".to_owned(), + n => format!("result-{n}"), + } } async fn upload_and_start( @@ -345,18 +338,12 @@ async fn wait_for_terminal( client: &connector::Client, eval_id: &str, out: Output, -) -> Option { +) -> EvaluationResponse { loop { match client.evals().get(eval_id).await { - Ok(e) => { - if eval_is_terminal(e.status) { - return Some(e); - } - } - Err(e) => { - out.progress(format!("Status poll failed: {}", e)); - return None; - } + Ok(e) if eval_is_terminal(e.status) => return e, + Ok(_) => {} + Err(e) => out.err(ExitKind::Api, format!("Status poll failed: {e}")), } tokio::time::sleep(std::time::Duration::from_secs(2)).await; @@ -424,37 +411,28 @@ pub(crate) fn parse_overrides(raw: &[String]) -> Result, S Ok(out) } -pub(crate) fn select_primary_entry_point<'a>( - tree: &'a ArtefactTree, - target: Option<&str>, -) -> Option<&'a EntryPointArtefacts> { - if let Some(t) = target.filter(|t| !t.contains('*')) - && let Some(ep) = tree - .entry_points - .iter() - .find(|e| e.attr == t || e.attr.ends_with(t)) - { - return Some(ep); +#[cfg(not(feature = "nix"))] +async fn download_result_dirs(client: &connector::Client, tree: &ArtefactTree, out: Output) { + if tree.entry_points.is_empty() { + out.human("No outputs to download."); + return; } - tree.entry_points.first() + for (index, ep) in tree.entry_points.iter().enumerate() { + download_result_dir(client, ep, &result_link_name(index), out).await; + } } #[cfg(not(feature = "nix"))] async fn download_result_dir( client: &connector::Client, - tree: &ArtefactTree, - target: Option<&str>, + ep: &connector::api::EntryPointArtefacts, + dir_name: &str, out: Output, ) { use crate::commands::download::{product_filename, safe_relative_name}; - let Some(ep) = select_primary_entry_point(tree, target) else { - out.human("No outputs to download."); - return; - }; - - let dir = PathBuf::from("result"); + let dir = PathBuf::from(dir_name); let mut wrote = 0usize; for output in &ep.outputs { for product in &output.products { @@ -483,9 +461,15 @@ async fn download_result_dir( } if wrote == 0 { - out.human("No build products to place in result/."); + out.human(format!( + "No build products of {} to place in {dir_name}/.", + ep.attr + )); } else { - out.human(format!("Wrote {} product(s) to result/", wrote)); + out.human(format!( + "Wrote {wrote} product(s) of {} to {dir_name}/", + ep.attr + )); } } @@ -513,7 +497,29 @@ fn hash_file(path: &Path) -> std::io::Result<(String, i64)> { #[cfg(test)] mod tests { - use super::{normalize_installable, normalize_target}; + use super::{failure_message, normalize_installable, normalize_target, result_link_name}; + use connector::api::EvaluationStatus; + + #[test] + fn only_a_completed_evaluation_succeeds() { + assert_eq!(failure_message(EvaluationStatus::Completed, None), None); + for status in [EvaluationStatus::Failed, EvaluationStatus::Aborted] { + assert!(failure_message(status, None).is_some(), "{status}"); + } + } + + #[test] + fn failure_message_carries_the_evaluation_error() { + let message = failure_message(EvaluationStatus::Failed, Some("attr missing")).unwrap(); + assert!(message.contains("attr missing"), "{message}"); + assert!(message.contains("Failed"), "{message}"); + } + + #[test] + fn result_links_follow_nix_build_numbering() { + let names: Vec<_> = (0..3).map(result_link_name).collect(); + assert_eq!(names, ["result", "result-1", "result-2"]); + } #[test] fn parse_overrides_accepts_remote_refs() { diff --git a/cli/src/commands/build_nix.rs b/cli/src/commands/build_nix.rs index 9fec0f5d3..bab219edd 100644 --- a/cli/src/commands/build_nix.rs +++ b/cli/src/commands/build_nix.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use crate::commands::build::{BuildParams, TrackedFile, select_primary_entry_point}; +use crate::commands::build::{BuildParams, TrackedFile, result_link_name}; use crate::config::{ConfigKey, load_config}; use crate::input::server_base; use crate::output::{ExitKind, Output, to_exit_kind}; @@ -152,47 +152,48 @@ fn upload_error_message( } } -pub async fn link_result( +pub async fn link_results( client: &connector::Client, started: &DispatchResponse, tree: &ArtefactTree, - target: Option<&str>, out: Output, ) { - let Some(primary) = select_primary_entry_point(tree, target) else { - out.human("No outputs to link."); - return; - }; - - let Some(out_path) = primary - .outputs + let paths: Vec = tree + .entry_points .iter() - .find(|o| o.name == "out") - .or_else(|| primary.outputs.first()) - else { + .filter_map(|ep| { + ep.outputs + .iter() + .find(|o| o.name == "out") + .or_else(|| ep.outputs.first()) + }) + .map(|o| o.full_store_path()) + .collect(); + + if paths.is_empty() { + out.human("No outputs to link."); return; - }; - let realise_path = out_path.full_store_path(); + } if let Some(cache) = cache_source(client, started, out).await - && let Err(stderr) = copy_from_cache(&cache, &realise_path).await + && let Err(stderr) = copy_from_cache(&cache, &paths).await { out.err( ExitKind::Api, - untrusted_copy_message(&realise_path, &stderr, cache.public_key.as_deref()), + untrusted_copy_message(&paths, &stderr, cache.public_key.as_deref()), ); } + for (index, path) in paths.iter().enumerate() { + add_result_link(path, &result_link_name(index), out).await; + } +} + +async fn add_result_link(realise_path: &str, link: &str, out: Output) { let mut cmd = tokio::process::Command::new("nix-store"); - cmd.args([ - "--realise", - &realise_path, - "--add-root", - "result", - "--indirect", - ]); + cmd.args(["--realise", realise_path, "--add-root", link, "--indirect"]); match cmd.output().await { - Ok(o) if o.status.success() => out.human(format!("result -> {realise_path}")), + Ok(o) if o.status.success() => out.human(format!("{link} -> {realise_path}")), Ok(o) => out.err( ExitKind::Api, format!( @@ -231,7 +232,7 @@ async fn cache_source( /// The Nix client is downloading the closure itself. A daemon is ignoring the substituters and the /// netrc of a user it is not trusting, but it is accepting paths signed by a key it trusts. -async fn copy_from_cache(cache: &CacheSource, path: &str) -> Result<(), String> { +async fn copy_from_cache(cache: &CacheSource, paths: &[String]) -> Result<(), String> { let mut cmd = tokio::process::Command::new("nix"); cmd.args([ "--extra-experimental-features", @@ -239,8 +240,8 @@ async fn copy_from_cache(cache: &CacheSource, path: &str) -> Result<(), String> "copy", "--from", &cache.url, - path, ]); + cmd.args(paths); if let Some(netrc) = &cache.netrc { cmd.args(["--option", "netrc-file"]).arg(netrc.path()); } @@ -252,7 +253,8 @@ async fn copy_from_cache(cache: &CacheSource, path: &str) -> Result<(), String> } } -fn untrusted_copy_message(path: &str, stderr: &str, public_key: Option<&str>) -> String { +fn untrusted_copy_message(paths: &[String], stderr: &str, public_key: Option<&str>) -> String { + let path = paths.join(", "); match public_key { Some(key) if stderr.contains("lacks a signature by a trusted key") => format!( "Could not copy {path} from the gradient cache: the local Nix daemon does not trust \ @@ -290,7 +292,8 @@ mod tests { #[test] fn an_untrusted_signature_names_the_key_to_trust() { let stderr = "error: cannot add path '/nix/store/x-ollama' because it lacks a signature by a trusted key"; - let message = untrusted_copy_message("/nix/store/x-ollama", stderr, Some("cache:abc=")); + let message = + untrusted_copy_message(&["/nix/store/x-ollama".into()], stderr, Some("cache:abc=")); assert!(message.contains("`cache:abc=`"), "{message}"); assert!(message.contains("trusted-public-keys"), "{message}"); } diff --git a/docs/src/guides/build-before-push.md b/docs/src/guides/build-before-push.md index cf8680bbd..6272680ee 100644 --- a/docs/src/guides/build-before-push.md +++ b/docs/src/guides/build-before-push.md @@ -31,9 +31,11 @@ gradient build .#hello The build output lands in a `result` symlink. The symlink points into the local store, populated from the project cache. Pass `--no-link` to skip the link creation. +Multiple entry points produce `result-1`, `result-2` and more links, matching `nix build` behavior. A failed evaluation or build exits with code `1`, even with `--no-link`. + The local Nix daemon trusts fetched paths only if the cache public key sits in `trusted-public-keys`. Setup steps live in [Share a Cache](share-a-cache.md). Users in `trusted-users` need no key. -The [static binary](../reference/cli.md#install) has no Nix support. This binary downloads build products into a `result/` folder instead. +[Static binaries](../reference/cli.md#install) come without Nix support. These binaries download build products into `result/`, `result-1/` and further folders instead. ## Override Inputs