Skip to content
Merged
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
152 changes: 79 additions & 73 deletions cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<String> {
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(
Expand Down Expand Up @@ -345,18 +338,12 @@ async fn wait_for_terminal(
client: &connector::Client,
eval_id: &str,
out: Output,
) -> Option<EvaluationResponse> {
) -> 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;
Expand Down Expand Up @@ -424,37 +411,28 @@ pub(crate) fn parse_overrides(raw: &[String]) -> Result<Vec<(String, String)>, 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 {
Expand Down Expand Up @@ -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
));
}
}

Expand Down Expand Up @@ -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() {
Expand Down
61 changes: 32 additions & 29 deletions cli/src/commands/build_nix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<String> = 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!(
Expand Down Expand Up @@ -231,16 +232,16 @@ 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",
"nix-command",
"copy",
"--from",
&cache.url,
path,
]);
cmd.args(paths);
if let Some(netrc) = &cache.netrc {
cmd.args(["--option", "netrc-file"]).arg(netrc.path());
}
Expand All @@ -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 \
Expand Down Expand Up @@ -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}");
}
Expand Down
4 changes: 3 additions & 1 deletion docs/src/guides/build-before-push.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading