From dfc183e24d1b9a388a165eaed08d4b4e92a5d8f4 Mon Sep 17 00:00:00 2001 From: John Ericson Date: Thu, 23 Apr 2026 22:46:23 -0400 Subject: [PATCH 1/3] Port non-trivial dynamic derivation test from Nix, fix builder upload closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port `tests/functional/dyn-drv/non-trivial.nix` from the Nix test suite as a Hydra integration test. This exercises recursive-nix: a single derivation dynamically creates a DAG of inner derivations (a-e) via `nix derivation add`, outputs the final `.drv`, and a wrapper depends on building that `.drv` through `builtins.outputOf`. Porting this test uncovered a bug in `upload_nars_regular`: it only uploaded the direct build outputs without computing their closure first. When the output is a `.drv` file whose references include other dynamically-created store paths (from recursive-nix), the queue runner's import would fail because those referenced paths were never uploaded. `upload_nars_presigned` already handled this correctly by calling `query_requisites` before uploading — this commit brings the regular path in line. Supporting changes: - Add `nixBinDir` to test `config.nix.in` so job expressions can find the `nix` CLI for recursive-nix builds - Generalize `replace_variable_in_file` to accept multiple substitutions in a single pass (needed because it reads from `.in` each time) - Add `extra-system-features = recursive-nix` to the test's nix.conf so the builder advertises the feature for scheduling --- subprojects/hydra-builder/src/state.rs | 8 ++ .../content-addressed/dyn-drv-non-trivial.t | 44 +++++++ subprojects/hydra-tests/jobs/config.nix.in | 1 + .../hydra-tests/jobs/dyn-drv-non-trivial.nix | 113 ++++++++++++++++++ .../hydra-tests/lib/HydraTestContext.pm | 12 +- 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 subprojects/hydra-tests/content-addressed/dyn-drv-non-trivial.t create mode 100644 subprojects/hydra-tests/jobs/dyn-drv-non-trivial.nix diff --git a/subprojects/hydra-builder/src/state.rs b/subprojects/hydra-builder/src/state.rs index 02d5b48d1..002798d6c 100644 --- a/subprojects/hydra-builder/src/state.rs +++ b/subprojects/hydra-builder/src/state.rs @@ -879,6 +879,14 @@ async fn upload_nars_regular( metrics: Arc, nars: Vec, ) -> anyhow::Result<()> { + // Compute the full closure of output paths so that all referenced + // store paths (e.g. dynamically-created derivations from recursive-nix) + // are uploaded alongside the direct outputs. + let nars = store + .query_requisites(&nars.iter().collect::>(), true) + .await + .unwrap_or(nars); + let nars = { use futures::stream::StreamExt as _; diff --git a/subprojects/hydra-tests/content-addressed/dyn-drv-non-trivial.t b/subprojects/hydra-tests/content-addressed/dyn-drv-non-trivial.t new file mode 100644 index 000000000..5db8e468f --- /dev/null +++ b/subprojects/hydra-tests/content-addressed/dyn-drv-non-trivial.t @@ -0,0 +1,44 @@ +use feature 'unicode_strings'; +use strict; +use warnings; +use Setup; +use Test2::V0; + +# Adapted from https://github.com/NixOS/nix/blob/master/tests/functional/dyn-drv/non-trivial.nix +# +# A single derivation uses recursive-nix to dynamically create a DAG of +# inner derivations (a through e) via `nix derivation add`, then outputs +# the final .drv path. A wrapper depends on building that +# dynamically-produced .drv and using its output via builtins.outputOf. + +my $ctx = test_context( + nix_config => qq| + experimental-features = ca-derivations dynamic-derivations recursive-nix + extra-system-features = recursive-nix + |, +); + +my $db = $ctx->db(); + +my $jobset = createBaseJobset($db, "dyn-drv-non-trivial", "dyn-drv-non-trivial.nix", $ctx->jobsdir); + +ok(evalSucceeds($ctx, $jobset), "Evaluation of dyn-drv-non-trivial.nix should succeed"); +is(nrQueuedBuildsForJobset($jobset), 1, "Should queue 1 build (wrapper)"); + +my @builds = queuedBuildsForJobset($jobset); +ok(runBuilds($ctx, @builds), "All builds should complete"); + +# wrapper is the dynamic derivation consumer. +# It exercises the full chain: build makeDerivations (which uses +# recursive-nix to create derivations a-e), discover the .drv at its +# output, build that .drv (transitively building a through e), and use +# its output. +my ($wrapper) = grep { $_->job eq 'wrapper' } @builds; +ok(defined $wrapper, "wrapper build should exist"); +if ($wrapper) { + $wrapper->discard_changes; + is($wrapper->finished, 1, "wrapper should be finished"); + is($wrapper->buildstatus, 0, "wrapper should succeed"); +} + +done_testing; diff --git a/subprojects/hydra-tests/jobs/config.nix.in b/subprojects/hydra-tests/jobs/config.nix.in index 41776341c..6e703a7f2 100644 --- a/subprojects/hydra-tests/jobs/config.nix.in +++ b/subprojects/hydra-tests/jobs/config.nix.in @@ -1,5 +1,6 @@ rec { path = "@testPath@"; + nixBinDir = "@nixBinDir@"; mkDerivation = args: derivation ({ diff --git a/subprojects/hydra-tests/jobs/dyn-drv-non-trivial.nix b/subprojects/hydra-tests/jobs/dyn-drv-non-trivial.nix new file mode 100644 index 000000000..5a41dc551 --- /dev/null +++ b/subprojects/hydra-tests/jobs/dyn-drv-non-trivial.nix @@ -0,0 +1,113 @@ +# Adapted from https://github.com/NixOS/nix/blob/master/tests/functional/dyn-drv/non-trivial.nix +# +# A single derivation uses recursive-nix to dynamically create a DAG of +# inner derivations (a through e) via `nix derivation add`, then outputs +# the final .drv path. A wrapper depends on building that +# dynamically-produced .drv and using its output via builtins.outputOf. +let + cfg = import ./config.nix; + + makeDerivations = cfg.mkDerivation { + name = "make-derivations.drv"; + + requiredSystemFeatures = [ "recursive-nix" ]; + + builder = "/bin/sh"; + args = [ + "-c" + '' + set -e + set -u + + PATH=${cfg.nixBinDir}:$PATH + + export NIX_CONFIG='extra-experimental-features = nix-command ca-derivations dynamic-derivations' + + declare -A deps=( + [a]="" + [b]="a" + [c]="a" + [d]="b c" + [e]="b c d" + ) + + # Cannot just literally include this, or Nix will think it is the + # *outer* derivation that's trying to refer to itself, and + # substitute the string too soon. + placeholder=$(nix eval --raw --expr 'builtins.placeholder "out"') + + declare -A drvs=() + for word in a b c d e; do + inputDrvs="" + for dep in ''${deps[$word]}; do + if [[ "$inputDrvs" != "" ]]; then + inputDrvs+="," + fi + read -r -d "" line <> \"\$out\""], + "builder": "/bin/sh", + "env": { + "out": "$placeholder", + "$word": "hello, from $word!", + "PATH": ${builtins.toJSON cfg.path} + }, + "inputs": { + "drvs": { + $inputDrvs + }, + "srcs": [] + }, + "name": "build-$word", + "outputs": { + "out": { + "method": "nar", + "hashAlgo": "sha256" + } + }, + "system": "${builtins.currentSystem}", + "version": 4 + } + EOF + drvPath=$(echo "$json" | nix derivation add) + storeDir=$(dirname "$drvPath") + drvs[$word]="$(basename "$drvPath")" + done + cp "''${storeDir}/''${drvs[e]}" $out + '' + ]; + + __contentAddressed = true; + outputHashMode = "text"; + outputHashAlgo = "sha256"; + }; + +in +{ + # The dynamic derivation consumer: depends on the output of the .drv + # file that makeDerivations produces. Nix must: + # 1. Build makeDerivations (creates derivations a-e, outputs e's .drv) + # 2. Discover the .drv at its output + # 3. Build that .drv (which transitively builds a, b, c, d, e) + # 4. Use its output here + wrapper = cfg.mkContentAddressedDerivation { + name = "dyn-drv-non-trivial-wrapper"; + builder = "/bin/sh"; + args = [ + "-c" + '' + result=${builtins.outputOf makeDerivations.outPath "out"} + cat "$result" + cp -r "$result" $out + '' + ]; + }; +} diff --git a/subprojects/hydra-tests/lib/HydraTestContext.pm b/subprojects/hydra-tests/lib/HydraTestContext.pm index b3b5a6620..234ff7f59 100644 --- a/subprojects/hydra-tests/lib/HydraTestContext.pm +++ b/subprojects/hydra-tests/lib/HydraTestContext.pm @@ -98,8 +98,9 @@ sub new { rcopy(abs_path(dirname(__FILE__) . "/../jobs"), $jobsdir); my $coreutils_path = dirname(which 'install'); - replace_variable_in_file($jobsdir . "/config.nix", '@testPath@', $coreutils_path); - replace_variable_in_file($jobsdir . "/declarative/project.json", '@jobsPath@', $jobsdir); + my $nix_bin_dir = dirname(which 'nix'); + replace_variable_in_file($jobsdir . "/config.nix", '@testPath@' => $coreutils_path, '@nixBinDir@' => $nix_bin_dir); + replace_variable_in_file($jobsdir . "/declarative/project.json", '@jobsPath@' => $jobsdir); my $self = bless { _db => undef, @@ -337,13 +338,16 @@ sub write_file { } sub replace_variable_in_file { - my ($fn, $var, $val) = @_; + my ($fn, %subs) = @_; open (my $input, '<', "$fn.in") or die $!; open (my $output, '>', $fn) or die $!; while (my $line = <$input>) { - $line =~ s/$var/$val/g; + for my $var (keys %subs) { + my $val = $subs{$var}; + $line =~ s/\Q$var\E/$val/g; + } print $output $line; } } From 9c67fd6c7040a635dd0e3c75503d6c02a987600f Mon Sep 17 00:00:00 2001 From: Amaan Qureshi Date: Mon, 30 Mar 2026 15:38:13 -0400 Subject: [PATCH 2/3] queue-runner: resolve CA derivations at dispatch time Instead of resolving at StepInfo construction and carrying two drv identities through the gRPC layer, resolve in realise_drv_on_valid_machine once all deps are built. If resolution yields a different drv, the original step is marked Resolved and a new DB step is created for the resolved drv with a resolvedTo FK linking them. The builder only ever sees one drv. We create a new Step for that resolution and bunt it back to the scheduler. This grants us more flexibility in execution and the method can be used in the future for dynamic derivations, which won't map 1:1 with the original derivations. We also now only create database steps when we are sure they are necessary, reducing the number of duplicate `BuildStep` rows. In order to make tests more consistent, CA derivations will fail if they cannot be fully resolved. Otherwise, there could be inconsistent successes depending on which builder a step was performed on. As part of this, add local outputs to resolution table With the current queue-runner design, all dependency outputs of a CAFloating derivation must be recorded in the hydra database. This is true for things built or substituted by hydra, but until now not by things found on the local nix store. This may occur for outputs that are part of the system configuration. Therefore, add all local outputs that are not already in the database to the resolution table upon creating a step. This makes it possible to build derivations from `contentAddressedByDefault` nixpkgs. Co-Authored-By: Artemis Tosini Co-Authored-By: John Ericson Co-Authored-By: Amaan Qureshi --- subprojects/crates/db/src/connection.rs | 236 ++++++++++++++++- subprojects/crates/db/src/models.rs | 3 + subprojects/hydra-builder/src/state.rs | 10 +- .../hydra-queue-runner/src/state/build.rs | 2 +- .../hydra-queue-runner/src/state/machine.rs | 15 +- .../hydra-queue-runner/src/state/mod.rs | 246 ++++++++++++++++-- .../hydra-queue-runner/src/state/queue.rs | 1 + .../hydra-queue-runner/src/state/step.rs | 52 ++++ .../hydra-queue-runner/src/state/step_info.rs | 18 +- subprojects/hydra-queue-runner/src/utils.rs | 28 ++ .../hydra-tests/content-addressed/basic.t | 30 ++- .../hydra-tests/content-addressed/dyn-drv.t | 4 + .../lib/Hydra/Schema/Result/BuildSteps.pm | 11 +- subprojects/hydra/sql/hydra.sql | 10 + subprojects/hydra/sql/upgrade-86.sql | 1 + subprojects/proto/v1/streaming.proto | 2 +- 16 files changed, 599 insertions(+), 70 deletions(-) create mode 100644 subprojects/hydra/sql/upgrade-86.sql diff --git a/subprojects/crates/db/src/connection.rs b/subprojects/crates/db/src/connection.rs index 77f48e8dc..6fb27d830 100644 --- a/subprojects/crates/db/src/connection.rs +++ b/subprojects/crates/db/src/connection.rs @@ -355,12 +355,20 @@ impl Connection { CROSS JOIN LATERAL ( SELECT o.path FROM buildsteps s + -- If this step was resolved, look up outputs from + -- the resolved drv's successful buildstep instead. + -- resolvedDrvPath is a bare basename; drvPath is a + -- full path, so strip the directory prefix to compare. + LEFT JOIN buildsteps sr + ON sr.drvPath = $2 || '/' || s.resolvedDrvPath + AND sr.status = 0 JOIN buildstepoutputs o - ON s.build = o.build AND s.stepnr = o.stepnr + ON o.build = COALESCE(sr.build, s.build) + AND o.stepnr = COALESCE(sr.stepnr, s.stepnr) WHERE s.drvPath = r.drv_path AND o.name = i.chain[r.step] AND o.path IS NOT NULL - AND s.status = 0 + AND (s.status = 0 OR s.status = 13) ORDER BY s.build DESC LIMIT 1 ) sub @@ -376,6 +384,7 @@ impl Connection { ", ) .bind(&json_input) + .bind(store_dir.to_str()) .fetch_all(&mut *self.conn) .await?; @@ -693,6 +702,36 @@ impl Transaction<'_> { Ok(()) } + #[tracing::instrument(skip(self), err)] + pub async fn find_build_step_outputs( + &mut self, + store_dir: &StoreDir, + drv_path: &StorePath, + ) -> sqlx::Result> { + let drv_path = store_dir.display(drv_path).to_string(); + let items: Vec<(String, String)> = sqlx::query_as( + r"SELECT o.name, o.path + FROM buildstepoutputs o + JOIN buildsteps s ON s.build = o.build AND s.stepnr = o.stepnr + WHERE s.drvpath = $1 AND o.path IS NOT NULL", + ) + .bind(drv_path) + .fetch_all(&mut *self.tx) + .await?; + + items + .into_iter() + .map(|(name, path)| -> anyhow::Result<_> { + let name: OutputName = name.parse().context("invalid output name from DB")?; + let path: StorePath = store_dir + .parse(&path) + .context("invalid store path from DB")?; + Ok((name, path)) + }) + .collect::>() + .map_err(|e| sqlx::Error::Decode(e.into_boxed_dyn_error())) + } + #[tracing::instrument(skip(self, res), err)] pub async fn update_build_step_in_finish( &mut self, @@ -941,6 +980,83 @@ impl Transaction<'_> { Ok(step_nr) } + /// Record which resolved drv path this step was resolved to. + #[tracing::instrument(skip(self), err)] + pub async fn set_resolved_to( + &mut self, + origin_build_id: crate::models::BuildID, + origin_step_nr: i32, + resolved_drv_path: &StorePath, + ) -> sqlx::Result<()> { + sqlx::query( + r" + UPDATE buildsteps + SET resolvedDrvPath = $3 + WHERE build = $1 AND stepnr = $2 + ", + ) + .bind(origin_build_id) + .bind(origin_step_nr) + .bind(resolved_drv_path.to_string()) + .execute(&mut *self.tx) + .await?; + Ok(()) + } + + #[tracing::instrument( + skip(self, start_time, stop_time, build_id, drv_path, outputs,), + err, + ret + )] + pub async fn create_local_step( + &mut self, + store_dir: &StoreDir, + start_time: i32, + stop_time: i32, + build_id: crate::models::BuildID, + drv_path: &StorePath, + outputs: BTreeMap, + ) -> anyhow::Result { + let step_nr = loop { + if let Some(step_nr) = self + .insert_build_step( + store_dir, + InsertBuildStep { + build_id, + r#type: crate::models::BuildType::Substitution, + drv_path, + status: BuildStatus::Success, + busy: false, + start_time: Some(start_time), + stop_time: Some(stop_time), + platform: None, + propagated_from: None, + error_msg: None, + machine: "", + }, + ) + .await? + { + break step_nr; + } + }; + + let output_items: Vec<_> = outputs + .into_iter() + .map(|(name, path)| InsertBuildStepOutput { + build_id, + step_nr, + name, + path: Some(path), + }) + .collect(); + + self.insert_build_step_outputs(store_dir, &output_items) + .await?; + + Ok(step_nr) + } + #[tracing::instrument( skip(self, start_time, stop_time, build_id, drv_path, output,), err, @@ -1191,11 +1307,24 @@ mod tests { } async fn insert_step(conn: &mut Connection, build: i32, stepnr: i32, drv_path: &StorePath) { + insert_step_with_status(conn, build, stepnr, drv_path, 0, None).await; + } + + async fn insert_step_with_status( + conn: &mut Connection, + build: i32, + stepnr: i32, + drv_path: &StorePath, + status: i32, + resolved_drv_path: Option<&StorePath>, + ) { let sd = test_store_dir(); - sqlx::query("INSERT INTO BuildSteps (build, stepnr, type, busy, drvPath, status) VALUES ($1, $2, 0, 0, $3, 0)") + sqlx::query("INSERT INTO BuildSteps (build, stepnr, type, busy, drvPath, status, resolvedDrvPath) VALUES ($1, $2, 0, 0, $3, $4, $5)") .bind(build) .bind(stepnr) .bind(sd.display(drv_path).to_string()) + .bind(status) + .bind(resolved_drv_path.map(|p| p.to_string())) .execute(&mut *conn.conn) .await .unwrap(); @@ -1333,6 +1462,70 @@ mod tests { ); } + /// A step that was resolved (status=13) with `resolvedDrvPath` pointing + /// to a different drv whose successful buildstep has the outputs. + #[tokio::test] + async fn resolve_through_resolved_step() { + let (_pg, mut conn) = setup().await; + + // Step 1: unresolved ca-depending-on-ca.drv, status=Resolved(13), + // resolvedDrvPath points to the resolved drv + insert_step_with_status( + &mut conn, + 1, + 1, + &sp("unresolved.drv"), + 13, + Some(&sp("resolved.drv")), + ) + .await; + // A successful buildstep for the resolved drv (could be any build) + insert_step(&mut conn, 2, 1, &sp("resolved.drv")).await; + insert_output(&mut conn, 2, 1, "out", &sp("result")).await; + + // Looking up via the unresolved drv path should find the output + // through the resolvedDrvPath chain. + let results = conn + .resolve_drv_output_chains(&test_store_dir(), &[(&sp("unresolved.drv"), &[&on("out")])]) + .await + .unwrap(); + assert_eq!(results, vec![Some(sp("result"))]); + } + + /// A depth-2 chain where the first step was resolved: + /// unresolved.drv (status=13, resolvedDrvPath→resolved.drv) → + /// resolved.drv outputs a .drv path → that .drv has the final output. + #[tokio::test] + async fn resolve_depth_2_through_resolved_step() { + let (_pg, mut conn) = setup().await; + + // Build 1: unresolved step, resolved to bbb-resolved.drv + insert_step_with_status( + &mut conn, + 1, + 1, + &sp("unresolved.drv"), + 13, + Some(&sp("resolved.drv")), + ) + .await; + insert_step(&mut conn, 2, 1, &sp("resolved.drv")).await; + insert_output(&mut conn, 2, 1, "out", &sp("intermediate.drv")).await; + + // Build 3: the intermediate drv + insert_step(&mut conn, 3, 1, &sp("intermediate.drv")).await; + insert_output(&mut conn, 3, 1, "out", &sp("final")).await; + + let results = conn + .resolve_drv_output_chains( + &test_store_dir(), + &[(&sp("unresolved.drv"), &[&on("out"), &on("out")])], + ) + .await + .unwrap(); + assert_eq!(results, vec![Some(sp("final"))]); + } + /// Batch with ragged depths: one depth-1 (Opaque), one depth-2 (Built), /// one depth-3 (Built(Built(...))). #[tokio::test] @@ -1377,4 +1570,41 @@ mod tests { ] ); } + + /// Depth-1 lookup where the only buildstep for the drv has + /// status=Resolved(13) with `resolvedDrvPath` pointing to + /// a different drv whose successful buildstep has the outputs. + /// This matches the production scenario: ca-depending-on-ca.drv + /// was resolved to a different drv, and ca-depending-on-ca- + /// depending-on-ca needs to look up its output. + #[tokio::test] + async fn resolve_depth_1_via_resolved_step() { + let (_pg, mut conn) = setup().await; + + // Build 1, step 1: unresolved ca-depending-on-ca.drv + // status=13 (Resolved), resolvedDrvPath points to the resolved drv + insert_step_with_status( + &mut conn, + 1, + 1, + &sp("unresolved-ca-dep.drv"), + 13, + Some(&sp("resolved-ca-dep.drv")), + ) + .await; + // Build 2: the resolved drv was built successfully + insert_step(&mut conn, 2, 1, &sp("resolved-ca-dep.drv")).await; + insert_output(&mut conn, 2, 1, "out", &sp("ca-dep-output")).await; + + // Depth-1 chain: look up "out" of the unresolved drv path. + // The query should follow resolvedDrvPath to find the output. + let results = conn + .resolve_drv_output_chains( + &test_store_dir(), + &[(&sp("unresolved-ca-dep.drv"), &[&on("out")])], + ) + .await + .unwrap(); + assert_eq!(results, vec![Some(sp("ca-dep-output"))]); + } } diff --git a/subprojects/crates/db/src/models.rs b/subprojects/crates/db/src/models.rs index 2d6838246..776ee05ae 100644 --- a/subprojects/crates/db/src/models.rs +++ b/subprojects/crates/db/src/models.rs @@ -22,6 +22,8 @@ pub enum BuildStatus { LogLimitExceeded = 10, NarSizeLimitExceeded = 11, NotDeterministic = 12, + /// step was resolved to a CA derivation, see resolvedTo FK + Resolved = 13, /// not stored Busy = 100, } @@ -42,6 +44,7 @@ impl BuildStatus { 10 => Some(Self::LogLimitExceeded), 11 => Some(Self::NarSizeLimitExceeded), 12 => Some(Self::NotDeterministic), + 13 => Some(Self::Resolved), 100 => Some(Self::Busy), _ => None, } diff --git a/subprojects/hydra-builder/src/state.rs b/subprojects/hydra-builder/src/state.rs index 002798d6c..caade2538 100644 --- a/subprojects/hydra-builder/src/state.rs +++ b/subprojects/hydra-builder/src/state.rs @@ -408,8 +408,6 @@ impl State { .drv .ok_or(JobFailure::Preparing(anyhow::anyhow!("missing drv")))? .0; - let resolved_drv = m.resolved_drv.map(|v| v.0); - let maybe_resolved_drv = resolved_drv.as_ref().unwrap_or(&drv); let before_import = Instant::now(); let gcroot_prefix = uuid::Uuid::new_v4().to_string(); @@ -427,7 +425,7 @@ impl State { .await; let requisites = client .fetch_drv_requisites(FetchRequisitesRequest { - path: Some(ProtoStorePath::from(maybe_resolved_drv.clone())), + path: Some(ProtoStorePath::from(drv.clone())), include_outputs: false, }) .await @@ -440,7 +438,7 @@ impl State { store.clone(), self.metrics.clone(), &gcroot, - maybe_resolved_drv, + &drv, requisites.into_iter().map(|s| s.0), usize::try_from(self.max_concurrent_downloads.load(Ordering::Relaxed)).unwrap_or(5), self.config.use_substitutes, @@ -459,7 +457,7 @@ impl State { let before_build = Instant::now(); let (mut child, stdout, mut stderr) = nix_utils::realise_drv( &store, - maybe_resolved_drv, + &drv, &nix_utils::BuildOptions::complete(m.max_log_size, m.max_silent_time, m.build_timeout), true, ) @@ -536,7 +534,7 @@ impl State { .store_dir() .parse(&output_raw[0].drv_path) .map_err(|e: nix_utils::ParseStorePathError| JobFailure::PostProcessing(e.into()))?; - if &actual_out_drv != maybe_resolved_drv { + if actual_out_drv != drv { return Err(JobFailure::PostProcessing(anyhow::anyhow!( "Nix returned outputs for {actual_out_drv} when we expected {drv}" ))); diff --git a/subprojects/hydra-queue-runner/src/state/build.rs b/subprojects/hydra-queue-runner/src/state/build.rs index 65f56392b..0f13211ad 100644 --- a/subprojects/hydra-queue-runner/src/state/build.rs +++ b/subprojects/hydra-queue-runner/src/state/build.rs @@ -25,7 +25,7 @@ pub struct Build { pub local_priority: i32, pub global_priority: AtomicI32, - toplevel: arc_swap::ArcSwapOption, + pub toplevel: arc_swap::ArcSwapOption, pub jobset: Arc, finished_in_db: AtomicBool, diff --git a/subprojects/hydra-queue-runner/src/state/machine.rs b/subprojects/hydra-queue-runner/src/state/machine.rs index 9ab4fc89c..1851902fd 100644 --- a/subprojects/hydra-queue-runner/src/state/machine.rs +++ b/subprojects/hydra-queue-runner/src/state/machine.rs @@ -491,22 +491,16 @@ impl Machines { pub struct Job { pub internal_build_id: uuid::Uuid, pub path: nix_utils::StorePath, - pub resolved_drv: Option, pub build_id: BuildID, pub step_nr: i32, pub result: RemoteBuild, } impl Job { - pub fn new( - build_id: BuildID, - path: nix_utils::StorePath, - resolved_drv: Option, - ) -> Self { + pub fn new(build_id: BuildID, path: nix_utils::StorePath) -> Self { Self { internal_build_id: uuid::Uuid::new_v4(), path, - resolved_drv, build_id, step_nr: 0, result: RemoteBuild::new(), @@ -538,7 +532,6 @@ pub enum Message { BuildMessage { build_id: uuid::Uuid, drv: nix_utils::StorePath, - resolved_drv: Option, max_log_size: u64, max_silent_time: i32, build_timeout: i32, @@ -560,7 +553,6 @@ impl Message { Self::BuildMessage { build_id, drv, - resolved_drv, max_log_size, max_silent_time, build_timeout, @@ -568,7 +560,6 @@ impl Message { } => runner_request::Message::Build(BuildMessage { build_id: build_id.to_string(), drv: Some(shared::proto::ProtoStorePath::from(drv)), - resolved_drv: resolved_drv.map(shared::proto::ProtoStorePath::from), max_log_size, max_silent_time, build_timeout, @@ -696,15 +687,15 @@ impl Machine { pub async fn build_drv( &self, job: Job, + effective_drv: nix_utils::StorePath, opts: &nix_utils::BuildOptions, presigned_url_opts: Option, ) -> anyhow::Result<()> { - let drv = job.path.clone(); + let drv = effective_drv; self.msg_queue .send(Message::BuildMessage { build_id: job.internal_build_id, drv, - resolved_drv: job.resolved_drv.clone(), max_log_size: opts.get_max_log_size(), max_silent_time: opts.get_max_silent_time(), build_timeout: opts.get_build_timeout(), diff --git a/subprojects/hydra-queue-runner/src/state/mod.rs b/subprojects/hydra-queue-runner/src/state/mod.rs index c62128fa4..f16c6213b 100644 --- a/subprojects/hydra-queue-runner/src/state/mod.rs +++ b/subprojects/hydra-queue-runner/src/state/mod.rs @@ -10,6 +10,7 @@ mod step; mod step_info; mod uploader; +use anyhow::Context as _; pub use atomic::AtomicDateTime; pub use build::{Build, BuildOutput, BuildResultState, BuildTimings, Builds, RemoteBuild}; pub use jobset::{Jobset, JobsetID, Jobsets}; @@ -18,6 +19,7 @@ pub use queue::{BuildQueueStats, Queues}; pub use step::{Step, Steps}; pub use step_info::StepInfo; +use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use std::time::Instant; @@ -46,6 +48,8 @@ enum CreateStepResult { enum RealiseStepResult { None, + /// Created a new resolved `BuildStep` + Resolved, Valid(Arc), MaybeCancelled, CachedFailure, @@ -275,7 +279,7 @@ impl State { let drv = step_info.step.get_drv_path(); let mut build_options = nix_utils::BuildOptions::new(None); - let build_id = { + let build = { let mut dependents = HashSet::new(); let mut steps = HashSet::new(); step_info.step.get_dependents(&mut dependents, &mut steps); @@ -308,14 +312,12 @@ impl State { build_options .set_max_silent_time(biggest_max_silent_time.unwrap_or(build.max_silent_time)); build_options.set_build_timeout(biggest_build_timeout.unwrap_or(build.timeout)); - build.id + build.clone() }; - let mut job = machine::Job::new( - build_id, - drv.to_owned(), - step_info.resolved_drv_path.clone(), - ); + let build_id = build.id; + + let mut job = machine::Job::new(build_id, drv.to_owned()); job.result.set_start_time_now(); if self.check_cached_failure(step_info.step.clone()).await { job.result.step_status = BuildStatus::CachedFailure; @@ -352,33 +354,199 @@ impl State { .collect(), ) .await?; + tx.commit().await?; step_nr }; job.step_nr = step_nr; + // Try to resolve CA derivation inputs. If resolution yields a + // different drv, mark this step as Resolved in the DB and create a new + // step for the resolved drv that will be built at a later time. + { + let Some(guard) = step_info.step.get_drv() else { + // Should never happen + return Ok(RealiseStepResult::MaybeCancelled); + }; + let drv_ref = guard.as_ref().unwrap(); + + // `Some(path)` if this CA derivation was resolved to a + // different drv; `None` if resolution is not needed or is + // a no-op (same drv path). + let resolved_path = async { + // Only CA floating derivations need resolution, and only + // when they have `Built` inputs (dynamic derivation deps). + // `Opaque`-only inputs are already resolved. + let is_ca_with_built_inputs = + drv_ref.outputs.iter().all(|output| { + matches!(output.1, nix_utils::DerivationOutput::CAFloating(_)) + }) && drv_ref + .inputs + .iter() + .any(|input| matches!(input, nix_utils::SingleDerivedPath::Built { .. })); + tracing::info!( + is_ca_with_built_inputs, + ca_floating = drv_ref + .outputs + .iter() + .all(|o| matches!(o.1, nix_utils::DerivationOutput::CAFloating(_))), + has_built_inputs = drv_ref + .inputs + .iter() + .any(|i| matches!(i, nix_utils::SingleDerivedPath::Built { .. })), + "resolution check for {drv}" + ); + if !is_ca_with_built_inputs { + return Ok::<_, anyhow::Error>(None); + } + + // Resolve `Built` input placeholders to concrete store + // paths using outputs recorded in the DB. + let basic_drv = StepInfo::try_resolve(self.store.store_dir(), &self.db, drv_ref) + .await + .ok_or_else(|| { + anyhow::anyhow!("Failed to resolve CAFloating derivation {drv}") + })?; + let resolved_path = self.store.write_derivation(&basic_drv).await?; + // If resolution changed the drv, we need a two-phase + // build; otherwise just build the original directly. + Ok((&resolved_path != drv).then_some(resolved_path)) + } + .await?; + + if let Some(resolved_path) = resolved_path { + tracing::info!("resolved CA derivation {drv} -> {resolved_path}"); + + // Record the resolved drv path on the original step so + // future output lookups can follow the chain. + let mut tx = db.begin_transaction().await?; + tx.set_resolved_to(build_id, step_nr, &resolved_path) + .await?; + tx.commit().await?; + + // Finish original step as "resolved" in the DB and in-memory + step_info.step.set_finished(true); + let mut resolved_result = RemoteBuild::new(); + resolved_result.step_status = BuildStatus::Resolved; + resolved_result.set_start_time_now(); + resolved_result.set_stop_time_now(); + resolved_result.log_file.clone_from(&job.result.log_file); + finish_build_step( + &self.db, + &self.store, + build_id, + step_nr, + &resolved_result, + Some(&machine.hostname), + None, + ) + .await?; + + // Create a resolved in-memory step + // We do not need the global state because they are only relevant when making + // multiple steps. Our resolved step, by definition, has no dependencies, so + // only one step will ever be created. + // finished_drvs: A list of previously created steps within a build. + // Without this, it is possible that we will make a duplicate step if two different + // steps in the same build resolve to the same derivation, but it will not cause + // problems. + // new_steps: An output list of all created steps. Since only one is being made, the root, + // we can take the return of the function. + // new_runnable: An output list of the runnable steps. Again, only one will be made, so we + // can take the function return. + let resolved_step = match self + .create_step( + build.clone(), + resolved_path, + Some(build.clone()), + None, + Arc::new(parking_lot::RwLock::new(HashSet::new())), + Arc::new(parking_lot::RwLock::new(HashSet::new())), + Arc::new(parking_lot::RwLock::new(HashSet::new())), + ) + .await + { + CreateStepResult::None => { + return Err(anyhow::anyhow!("Could not create resolved build step")); + } + CreateStepResult::Valid(step) => step, + CreateStepResult::PreviousFailure(step) => { + self.handle_previous_failure(build.clone(), step.clone()) + .await + .with_context(|| { + format!( + "Failed to handle previous failure in resolved version of {drv}" + ) + })?; + return Ok(RealiseStepResult::CachedFailure); + } + }; + + resolved_step.make_runnable(); + + // The in-memory `Arc` objects are kept alive by having a reference + // from a `Build` (if they are the root build) or a dependant `Step` + // (if they are not). + // Therefore, we must prevent our new resolved step from being garbage + // collected by marking it as a dependency of the old step's reverse + // dependencies and, if it was the root derivation, as the new root + // derivation of the build. + + // Replace the original step with the resolved step in the + // dependency graph. Each step that depended on the original + // (unresolved) step must now depend on the resolved step + // instead, otherwise it will never become runnable (the + // original step's drv path differs from the resolved one, so + // completing the resolved step wouldn't clear the dep). + for rdep in step_info.step.clone_rdeps() { + if let Some(rdep) = rdep.upgrade() { + rdep.remove_dep(&step_info.step); + resolved_step.make_rdep(&rdep); + } + } + + // Make the resolved step the new root of the build if the old + // unresolved step was previously the root. + if *build + .toplevel + .compare_and_swap(&step_info.step, Some(resolved_step)) + == Some(step_info.step.clone()) + { + build.propagate_priorities(); + } + + // New steps runnable + self.trigger_dispatch(); + + // No more work to do, build will happen in another step + return Ok(RealiseStepResult::Resolved); + } + } + { let mut tx = db.begin_transaction().await?; tx.notify_build_started(build_id).await?; tx.commit().await?; } tracing::info!( - "Submitting build drv={drv} on machine={} hostname={} build_id={build_id} step_nr={step_nr}", + "Submitting build drv={drv} on machine={} hostname={} build_id={build_id} step_nr={}", machine.id, - machine.hostname + machine.hostname, + job.step_nr, ); self.db .get() .await? .update_build_step(db::models::UpdateBuildStep { build_id, - step_nr, + step_nr: job.step_nr, status: db::models::StepStatus::Connecting, }) .await?; machine .build_drv( job, + drv.clone(), &build_options, // TODO: cleanup if self.config.use_presigned_uploads() { @@ -952,7 +1120,7 @@ impl State { if r.atomic_state.tries.load(Ordering::Relaxed) > 0 { continue; } - let step_info = StepInfo::new(&self.store, &self.db, r.clone()).await; + let step_info = StepInfo::new(r.clone()); new_queues .entry(system) @@ -1796,17 +1964,45 @@ impl State { }) }; let output_paths = nix_utils::output_paths(&drv, self.store.store_dir()); + let known_outputs = self + .query_known_drv_outputs(&drv_path) + .await + .unwrap_or_else(|e| { + tracing::warn!("Could not query known outputs, continuing: {e}"); + BTreeMap::new() + }); + let missing_local_outputs = self.store.query_missing_outputs(output_paths.clone()).await; + // Handle paths that aren't in the database (for resolution) + // existing_local_outputs = output_paths - missing_local_outputs + // unregistered_local_outputs = existing_local_outputs - known_outputs + let unregistered_local_outputs = output_paths + .iter() + .filter(|(name, path)| { + path.is_some() + && !missing_local_outputs.contains_key(name) + && !known_outputs.contains_key(name) + }) + .map(|(name, path)| (name.clone(), path.clone())) + .collect::>(); + if !unregistered_local_outputs.is_empty() + && let Err(e) = crate::utils::make_local_step( + &self.db, + &self.store, + build.id, + &drv_path, + &unregistered_local_outputs, + ) + .await + { + tracing::warn!("Failed to mark outputs as already found, continuing: {e}"); + } + + // Handle paths that aren't in the remote store (for pushing) let missing_outputs = if let Some(ref remote_store) = remote_store { let mut missing = remote_store .query_missing_remote_outputs(output_paths.clone()) .await; - if !missing.is_empty() - && self - .store - .query_missing_outputs(output_paths.clone()) - .await - .is_empty() - { + if !missing.is_empty() && missing_local_outputs.is_empty() { // we have all paths locally, so we can just upload them to the remote_store if let Ok(log_file) = self.construct_log_file_path(&drv_path).await { let missing_paths: Vec = @@ -1940,6 +2136,18 @@ impl State { CreateStepResult::Valid(step) } + #[tracing::instrument(skip(self))] + async fn query_known_drv_outputs( + &self, + drv_path: &nix_utils::StorePath, + ) -> anyhow::Result> { + let mut db = self.db.get().await?; + let mut tx = db.begin_transaction().await?; + Ok(tx + .find_build_step_outputs(self.store.store_dir(), drv_path) + .await?) + } + #[tracing::instrument(skip(self, step), ret, level = "debug")] async fn check_cached_failure(&self, step: Arc) -> bool { let Some(drv_outputs) = step.get_output_paths(self.store.store_dir()) else { @@ -2098,7 +2306,7 @@ impl State { continue; }; - let mut job = machine::Job::new(build.id, drv.to_owned(), None); + let mut job = machine::Job::new(build.id, drv.to_owned()); job.result.set_start_and_stop(now); job.result.step_status = BuildStatus::Unsupported; job.result.error_msg = Some(format!( diff --git a/subprojects/hydra-queue-runner/src/state/queue.rs b/subprojects/hydra-queue-runner/src/state/queue.rs index b7ae35a69..ed5917249 100644 --- a/subprojects/hydra-queue-runner/src/state/queue.rs +++ b/subprojects/hydra-queue-runner/src/state/queue.rs @@ -504,6 +504,7 @@ impl Queues { nr_waiting += 1; nr_steps_waiting_all_queues += 1; } + Ok(crate::state::RealiseStepResult::Resolved) => {} Ok( crate::state::RealiseStepResult::MaybeCancelled | crate::state::RealiseStepResult::CachedFailure, diff --git a/subprojects/hydra-queue-runner/src/state/step.rs b/subprojects/hydra-queue-runner/src/state/step.rs index 83f3eeafc..b6e0a087b 100644 --- a/subprojects/hydra-queue-runner/src/state/step.rs +++ b/subprojects/hydra-queue-runner/src/state/step.rs @@ -363,6 +363,28 @@ impl Step { .store(state.deps.len() as u64, Ordering::Relaxed); } + pub fn remove_dep(&self, dep: &Arc) { + let mut state = self.state.write(); + state.deps.remove(dep); + self.atomic_state + .deps_len + .store(state.deps.len() as u64, Ordering::Relaxed); + } + + pub fn make_rdep(self: &Arc, dep: &Arc) { + dep.add_dep(self.clone()); + let mut state = self.state.write(); + state.rdeps.push(Arc::downgrade(dep)); + self.atomic_state + .rdeps_len + .store(state.rdeps.len() as u64, Ordering::Relaxed); + } + + pub fn clone_rdeps(&self) -> Vec> { + let state = self.state.read(); + state.rdeps.clone() + } + pub fn add_referring_data( &self, referring_build: Option<&Arc>, @@ -537,3 +559,33 @@ impl Steps { steps.remove(drv_path); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn drv(name: &str) -> nix_utils::StorePath { + nix_utils::parse_store_path(&format!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-{name}.drv")) + } + + #[test] + fn steps_create_and_remove() { + let steps = Steps::new(); + let (step, is_new) = steps.create(&drv("test"), None, None); + assert!(is_new); + assert_eq!(steps.len(), 1); + + steps.remove(step.get_drv_path()); + assert_eq!(steps.len(), 0); + } + + #[test] + fn steps_weak_ref_dies_without_strong_ref() { + let steps = Steps::new(); + let (step, _) = steps.create(&drv("ephemeral"), None, None); + assert_eq!(steps.len(), 1); + + drop(step); + assert_eq!(steps.len(), 0); + } +} diff --git a/subprojects/hydra-queue-runner/src/state/step_info.rs b/subprojects/hydra-queue-runner/src/state/step_info.rs index 43d80b0a7..2238cfffd 100644 --- a/subprojects/hydra-queue-runner/src/state/step_info.rs +++ b/subprojects/hydra-queue-runner/src/state/step_info.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use db::models::BuildID; -use nix_utils::BaseStore as _; use nix_utils::SingleDerivedPath; use super::Step; @@ -36,7 +35,6 @@ fn flatten_chain( #[derive(Debug)] pub struct StepInfo { pub step: Arc, - pub resolved_drv_path: Option, already_scheduled: AtomicBool, cancelled: AtomicBool, pub runnable_since: jiff::Timestamp, @@ -44,19 +42,8 @@ pub struct StepInfo { } impl StepInfo { - pub async fn new(store: &nix_utils::LocalStore, db: &db::Database, step: Arc) -> Self { + pub fn new(step: Arc) -> Self { Self { - resolved_drv_path: match step.get_drv() { - Some(guard) => { - let resolved = - Self::try_resolve(store.store_dir(), db, guard.as_ref().unwrap()).await; - match resolved { - Some(ref basic_drv) => store.write_derivation(basic_drv).await.ok(), - None => None, - } - } - None => None, - }, already_scheduled: false.into(), cancelled: false.into(), runnable_since: step.get_runnable_since(), @@ -76,7 +63,7 @@ impl StepInfo { /// /// We only need a store dir, not a store, because all the info we need comes from the Hydra /// database. - async fn try_resolve( + pub(super) async fn try_resolve( store_dir: &nix_utils::StoreDir, db: &db::Database, drv: &nix_utils::Derivation, @@ -278,7 +265,6 @@ mod tests { StepInfo { step, - resolved_drv_path: None, already_scheduled: false.into(), cancelled: false.into(), runnable_since: jiff::Timestamp::now(), diff --git a/subprojects/hydra-queue-runner/src/utils.rs b/subprojects/hydra-queue-runner/src/utils.rs index 6722647bf..5b7486493 100644 --- a/subprojects/hydra-queue-runner/src/utils.rs +++ b/subprojects/hydra-queue-runner/src/utils.rs @@ -107,3 +107,31 @@ pub async fn substitute_output( Ok(true) } + +#[tracing::instrument(skip(db, store, ), fields(%drv_path), err(level=tracing::Level::WARN))] +pub async fn make_local_step( + db: &db::Database, + store: &nix_utils::LocalStore, + build_id: BuildID, + drv_path: &StorePath, + missing: &BTreeMap>, +) -> anyhow::Result<()> { + let time = i32::try_from(jiff::Timestamp::now().as_second())?; + + let mut db = db.get().await?; + let mut tx = db.begin_transaction().await?; + tx.create_local_step( + store.store_dir(), + time, + time, + build_id, + drv_path, + missing + .iter() + .filter_map(|(name, path)| path.as_ref().map(|p| (name.clone(), p.clone()))) + .collect(), + ) + .await?; + tx.commit().await?; + Ok(()) +} diff --git a/subprojects/hydra-tests/content-addressed/basic.t b/subprojects/hydra-tests/content-addressed/basic.t index 8f3dfeff6..bc5731936 100644 --- a/subprojects/hydra-tests/content-addressed/basic.t +++ b/subprojects/hydra-tests/content-addressed/basic.t @@ -88,16 +88,26 @@ my $downstream2 = $db->resultset('Builds')->find({ my $downstream1_out = $downstream1->buildoutputs->find({ name => "out" }); my $downstream2_out = $downstream2->buildoutputs->find({ name => "out" }); is($downstream1_out->path, $downstream2_out->path, - "Both downstream builds should resolve to the same content-addressed output path"); - -# TODO: Once the queue runner deduplicates steps by resolved derivation -# path (not just original drv path), we should also verify that both -# original steps resolve to steps with the same derivation. (Might even -# be the same step, but that doesn't matter as much). -# -# If there are multiple steps for the single resolved derivation, -# additionally, only one should get built, and the other should be a -# cached successes (as is normal for duplicative build steps). + "Both downstream builds should create the same content-addressed output path"); + +my $downstream1_step = $db->resultset('BuildSteps')->find({ + build => $downstream1->id, + drvPath => $downstream1->drvpath, +}); + +my $downstream2_step = $db->resultset('BuildSteps')->find({ + build => $downstream2->id, + drvPath => $downstream2->drvpath, +}); + +ok(length($downstream1_step->resolveddrvpath) > 32, + "Downstream build should resolve to a valid store path"); + +is($downstream1_step->resolveddrvpath, $downstream2_step->resolveddrvpath, + "Both downstream builds should resolve to the same derivation"); + +ok($downstream1->iscachedbuild || $downstream2->iscachedbuild, + "One downstream build should be cached"); done_testing; diff --git a/subprojects/hydra-tests/content-addressed/dyn-drv.t b/subprojects/hydra-tests/content-addressed/dyn-drv.t index 6a6e907fc..cb6bf6c30 100644 --- a/subprojects/hydra-tests/content-addressed/dyn-drv.t +++ b/subprojects/hydra-tests/content-addressed/dyn-drv.t @@ -4,6 +4,10 @@ use warnings; use Setup; use Test2::V0; +# FIXME now that we're properly resolving things in Hydra rather than Nix, +# dynamic derivations stopped-fake working +plan skip_all => 'dynamic derivation resolution not yet implemented'; + # Based on https://github.com/NixOS/nix/blob/14ffc1787182b8702910788aea02bd5804afb32e/tests/functional/dyn-drv/text-hashed-output.nix # # A single derivation produces a .drv file as its output; another diff --git a/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm b/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm index 02e4e8c17..07a099c3c 100644 --- a/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm +++ b/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm @@ -113,6 +113,11 @@ __PACKAGE__->table("buildsteps"); data_type: 'boolean' is_nullable: 1 +=head2 resolveddrvpath + + data_type: 'text' + is_nullable: 1 + =cut __PACKAGE__->add_columns( @@ -146,6 +151,8 @@ __PACKAGE__->add_columns( { data_type => "integer", is_nullable => 1 }, "isnondeterministic", { data_type => "boolean", is_nullable => 1 }, + "resolveddrvpath", + { data_type => "text", is_nullable => 1 }, ); =head1 PRIMARY KEY @@ -215,8 +222,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-26 12:02:36 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:GzztRd7OwomaT3Xi7NB2RQ +# Created by DBIx::Class::Schema::Loader v0.07051 @ 2026-04-14 13:27:30 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Ktg5f7JBFiPfboSb0HBsMQ my %hint = ( columns => [ diff --git a/subprojects/hydra/sql/hydra.sql b/subprojects/hydra/sql/hydra.sql index 11b270138..9d2b20024 100644 --- a/subprojects/hydra/sql/hydra.sql +++ b/subprojects/hydra/sql/hydra.sql @@ -293,6 +293,16 @@ create table BuildSteps ( -- Whether this build step produced different results when repeated. isNonDeterministic boolean, + -- If this step was resolved to a different CA derivation, stores the + -- resolved drv path so outputs can be looked up by drv path. + -- + -- Note: Unlike the other store path fields, this one does *not* include + -- the store dir. Eventually, it would be nice to migrate the other fields + -- to also not include the store dir, as it is repeated / denormalizes the + -- database. There is nothing special about this field that makes it not + -- include it, it is just newer. + resolvedDrvPath text, + primary key (build, stepnr), foreign key (build) references Builds(id) on delete cascade, foreign key (propagatedFrom) references Builds(id) on delete cascade diff --git a/subprojects/hydra/sql/upgrade-86.sql b/subprojects/hydra/sql/upgrade-86.sql new file mode 100644 index 000000000..e39846ac2 --- /dev/null +++ b/subprojects/hydra/sql/upgrade-86.sql @@ -0,0 +1 @@ +ALTER TABLE BuildSteps ADD COLUMN resolvedDrvPath text; diff --git a/subprojects/proto/v1/streaming.proto b/subprojects/proto/v1/streaming.proto index a450f6630..c94986180 100644 --- a/subprojects/proto/v1/streaming.proto +++ b/subprojects/proto/v1/streaming.proto @@ -125,7 +125,7 @@ message PresignedUploadOpts { message BuildMessage { string build_id = 1; // UUIDv4 StorePath drv = 2; - optional StorePath resolved_drv = 3; + reserved 3; uint64 max_log_size = 4; int32 max_silent_time = 5; int32 build_timeout = 6; From 8472a93e92871b0cd09cdda6273822f08fcd0e8c Mon Sep 17 00:00:00 2001 From: Amaan Qureshi Date: Wed, 15 Apr 2026 12:05:41 -0400 Subject: [PATCH 3/3] web: navigate from unresolved to resolved CA build steps Resolved CA steps do not own the log users are trying to inspect, so leaving them as terminal pages makes the build view look stuck or failed in the wrong place. The log page now follows the recorded resolution chain to the step that actually performed the work, while preserving a pending page for unresolved, running, or malformed chains. The redirect banner is reconstructed from BuildSteps instead of session flash so copied log URLs and refreshes keep the same context. The JSON summary exposes the same durable resolution data for API clients that need to follow CA build steps without scraping HTML. --- .../Hydra/Controller/Build/resolved.t | 289 ++++++++++++++++++ .../hydra/lib/Hydra/Controller/Build.pm | 99 ++++++ subprojects/hydra/lib/Hydra/Helper/Nix.pm | 66 ++++ .../lib/Hydra/Schema/Result/BuildSteps.pm | 1 + subprojects/hydra/root/build.tt | 18 ++ .../hydra/root/log-resolved-pending.tt | 49 +++ subprojects/hydra/root/log.tt | 31 ++ 7 files changed, 553 insertions(+) create mode 100644 subprojects/hydra-tests/Hydra/Controller/Build/resolved.t create mode 100644 subprojects/hydra/root/log-resolved-pending.tt diff --git a/subprojects/hydra-tests/Hydra/Controller/Build/resolved.t b/subprojects/hydra-tests/Hydra/Controller/Build/resolved.t new file mode 100644 index 000000000..ac7eef11d --- /dev/null +++ b/subprojects/hydra-tests/Hydra/Controller/Build/resolved.t @@ -0,0 +1,289 @@ +use strict; +use warnings; +use Setup; +use JSON::MaybeXS qw(decode_json); +use Nix::Config; +use Test2::V0; +use HTTP::Request::Common; + +my %ctx = test_init(); +setup_catalyst_test($ctx{context}); + +my $db = $ctx{context}->db(); + +my $project = $db->resultset('Projects')->create({ + name => "tests", displayname => "", owner => "root", +}); +my $jobset = createBaseJobset($db, "basic", "basic.nix", $ctx{jobsdir}); +ok(evalSucceeds($ctx{context}, $jobset), "Evaluating basic.nix succeeds"); +my @builds = queuedBuildsForJobset($jobset); +my ($build) = grep { $_->nixname eq "empty-dir" } @builds; +ok(defined $build, "got a build out of the jobset"); + +my $resolvedBasename = "00000000000000000000000000000001-resolved.drv"; +my $storeDir = $Nix::Config::storeDir; +my $unresolvedDrv = "$storeDir/00000000000000000000000000000000-unresolved.drv"; + +$db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 101, + type => 0, + drvpath => $unresolvedDrv, + busy => 0, + status => 13, + machine => "", + resolveddrvpath => $resolvedBasename, +}); + +$db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 102, + type => 0, + drvpath => "$storeDir/$resolvedBasename", + busy => 0, + status => 0, + stoptime => 1000, + machine => "", +}); + +$db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 103, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000002-unresolved2.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000099-missing.drv", +}); + +my $buildUrl = "/build/" . $build->id; + +subtest "nixlog for a Resolved step 302s to the terminal" => sub { + my $r = request(GET "$buildUrl/nixlog/101"); + is($r->code, 302, "GET nixlog of Resolved step redirects"); + my $loc = $r->header('location') // ""; + like($loc, qr{/build/\Q@{[$build->id]}\E/nixlog/102}, "Location points at terminal step"); +}; + +subtest "nixlog for a Resolved step whose terminal is missing renders pending template" => sub { + my $r = request(GET "$buildUrl/nixlog/103"); + is($r->code, 200, "pending case returns 200, not a redirect"); + like($r->content, qr/resolved, log pending/i, "body mentions pending"); + like($r->content, qr/missing\.drv/, "body names the missing resolved drv"); + like($r->content, qr/has been scheduled yet|will refresh automatically/, + "copy describes the unscheduled state, not a running one"); +}; + +subtest "get-info JSON exposes resolvedSteps" => sub { + my $r = request(GET "$buildUrl/api/get-info", Accept => 'application/json'); + is($r->code, 200, "api/get-info responds"); + my $data = decode_json($r->content); + ok($data->{resolvedSteps}, "resolvedSteps key present"); + is(scalar @{$data->{resolvedSteps}}, 2, "two resolved steps reported"); + my ($terminalEntry) = grep { $_->{stepnr} == 101 } @{$data->{resolvedSteps}}; + is($terminalEntry->{resolvedDrvPath}, $resolvedBasename, "step 101 reports resolved basename"); + is($terminalEntry->{terminal}{stepnr}, 102, "step 101 terminal is step 102"); + is($terminalEntry->{terminal}{status}, 0, "terminal status is Success"); +}; + +subtest "cycle guard: self-referencing resolved step does not loop" => sub { + my $cycleBasename = "00000000000000000000000000000003-cycle.drv"; + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 104, + type => 0, + drvpath => "$storeDir/$cycleBasename", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => $cycleBasename, + }); + my $r = request(GET "$buildUrl/nixlog/104"); + is($r->code, 200, "self-cycle does not infinite-loop"); + like($r->content, qr/self-referential/i, + "self-cycle copy names the failure mode, not 'not yet scheduled'"); +}; + +subtest "terminal log URL is shareable (banner rendered from DB, not flash)" => sub { + my $r = request(GET "$buildUrl/nixlog/102"); + is($r->code, 200, "terminal log renders"); + like($r->content, qr/Redirected here from/, + "banner present without a preceding redirect in the session"); + like($r->content, qr/step #101/, "banner names the resolved origin"); + like($r->content, qr/Original derivation.*unresolved\.drv/s, + "banner shows the original (pre-resolution) drvpath"); +}; + +subtest "pending page with running terminal says 'currently running'" => sub { + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 200, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000020-pending-orig.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000021-busy-terminal.drv", + }); + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 201, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000021-busy-terminal.drv", + busy => 1, + status => undef, + machine => "builder-a", + starttime => 500, + }); + my $r = request(GET "$buildUrl/nixlog/200"); + is($r->code, 200, "busy-terminal case renders pending, not a redirect"); + like($r->content, qr/currently running/i, + "pending copy distinguishes 'running' from 'unscheduled'"); + like($r->content, qr/step #201/, "names the running step"); + like($r->content, qr/http-equiv="refresh"/, + "auto-refresh enabled so page updates when terminal finishes"); +}; + +subtest "pending page with unscheduled terminal does not auto-refresh self-cycle" => sub { + my $r = request(GET "$buildUrl/nixlog/104"); + unlike($r->content, qr/http-equiv="refresh"/, + "self-cycle never resolves, so no meta-refresh spin"); +}; + +subtest "cross-build scoping: chain does not reach into other builds" => sub { + my $buildB = $db->resultset('Builds')->create({ + finished => 1, + timestamp => 0, + jobset_id => $build->jobset_id, + job => "empty-dir", + nixname => "empty-dir", + drvpath => "$storeDir/00000000000000000000000000000040-other-unresolved.drv", + system => "x86_64-linux", + starttime => 1, stoptime => 1, + buildstatus => 0, iscurrent => 0, + }); + + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 301, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000040-origA.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000041-collide.drv", + }); + + $db->resultset('BuildSteps')->create({ + build => $buildB->id, + stepnr => 500, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000041-collide.drv", + busy => 0, + status => 0, + stoptime => 1000, + machine => "", + }); + + my $r = request(GET "$buildUrl/nixlog/301"); + is($r->code, 200, "no cross-build terminal -> pending, not redirect"); + like($r->content, qr/resolved, log pending/i, + "chain stops at build boundary, renders pending template"); +}; + +subtest "cycle detection: two siblings sharing resolveddrvpath don't false-cycle" => sub { + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 400, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000050-first.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000051-shared.drv", + }); + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 401, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000060-second.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000051-shared.drv", + }); + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 402, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000051-shared.drv", + busy => 0, + status => 0, + stoptime => 1000, + machine => "", + }); + + for my $origin (400, 401) { + my $r = request(GET "$buildUrl/nixlog/$origin"); + is($r->code, 302, "step $origin redirects (no false cycle)"); + like($r->header('location') // "", qr{/nixlog/402}, + "step $origin lands on the shared terminal"); + } +}; + +subtest "basename validation: resolveddrvpath with a slash is rejected" => sub { + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 500, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000070-corrupt.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "../../etc/passwd", + }); + my $r = request(GET "$buildUrl/nixlog/500"); + is($r->code, 200, "corrupt basename does not crash or chase"); + like($r->content, qr/resolved, log pending/i, + "falls through to pending page rather than following the hop"); +}; + +subtest "multi-step resolution cycle is classified as cycle, not unscheduled" => sub { + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 600, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000080-cycle-a.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000081-cycle-b.drv", + }); + $db->resultset('BuildSteps')->create({ + build => $build->id, + stepnr => 601, + type => 0, + drvpath => "$storeDir/00000000000000000000000000000081-cycle-b.drv", + busy => 0, + status => 13, + machine => "", + resolveddrvpath => "00000000000000000000000000000080-cycle-a.drv", + }); + my $r = request(GET "$buildUrl/nixlog/600"); + is($r->code, 200, "multi-step cycle renders pending, not redirect"); + like($r->content, qr/self-referential|never produce/i, + "multi-step cycle uses the cycle copy, not 'unscheduled'"); + unlike($r->content, qr/http-equiv="refresh"/, + "multi-step cycle does not auto-refresh"); +}; + +subtest "raw mode on a Resolved step does not set a lingering flash banner" => sub { + my $r1 = request(GET "$buildUrl/nixlog/101/raw"); + is($r1->code, 302, "raw on a resolved step still redirects"); + my $r2 = request(GET "/"); + unlike($r2->content, qr/Redirected here from/, + "no lingering flash banner on unrelated page"); +}; + +done_testing; diff --git a/subprojects/hydra/lib/Hydra/Controller/Build.pm b/subprojects/hydra/lib/Hydra/Controller/Build.pm index ca9a22cff..0198b68cb 100644 --- a/subprojects/hydra/lib/Hydra/Controller/Build.pm +++ b/subprojects/hydra/lib/Hydra/Controller/Build.pm @@ -116,6 +116,19 @@ sub build_GET { $c->stash->{steps} = [$build->buildsteps->search({}, {order_by => "stepnr desc"})]; + $c->stash->{resolvedTerminals} = {}; + for my $step (@{$c->stash->{steps}}) { + next unless defined $step->status && $step->status == 13 && $step->resolveddrvpath; + my ($terminal, $chain) = followResolvedChain($c, $step); + next unless $terminal; + next if $terminal->get_column('build') == $step->get_column('build') + && $terminal->stepnr == $step->stepnr; + $c->stash->{resolvedTerminals}->{$step->stepnr} = { + terminal => $terminal, + chain => $chain, + }; + } + $c->stash->{binaryCachePublicUri} = $c->config->{binary_cache_public_uri}; } @@ -139,9 +152,70 @@ sub view_nixlog : Chained('buildChain') PathPart('nixlog') { my $step = $c->stash->{build}->buildsteps->find({stepnr => $stepnr}); notFound($c, "Build doesn't have a build step $stepnr.") if !defined $step; + # Resolved steps have no log of their own. + if (defined $step->status && $step->status == 13 && $step->resolveddrvpath) { + my ($terminal, $chain) = followResolvedChain($c, $step); + my $isSelf = $terminal && + $terminal->get_column('build') == $c->stash->{build}->id && + $terminal->stepnr == $step->stepnr; + if ($terminal && !$isSelf + && defined $terminal->status && $terminal->status != 13 + && $terminal->busy == 0) + { + my @args = ($terminal->get_column('build'), 'nixlog', $terminal->stepnr); + push @args, $mode if defined $mode; + $c->res->redirect($c->uri_for('/build', @args)); + return; + } + $c->stash->{step} = $step; + my $pendingState; + if (!$isSelf) { + if ($terminal && $terminal->busy) { + $pendingState = 'running'; + } elsif ($terminal + && defined $terminal->status && $terminal->status == 13) { + $pendingState = 'dead-end'; + } else { + $pendingState = 'unscheduled'; + } + } else { + my $chainSize = scalar @{ $chain // [] }; + my $directSelfCycle = $chainSize == 1 + && $step->drvpath && $step->resolveddrvpath + && basename($step->drvpath) eq $step->resolveddrvpath; + if ($chainSize >= 2 || $directSelfCycle) { + $pendingState = 'cycle'; + } else { + $pendingState = 'unscheduled'; + } + } + $c->stash->{resolvedPending} = { + chain => $chain, + terminal => (!$isSelf ? $terminal : undef), + state => $pendingState, + }; + $c->stash->{template} = 'log-resolved-pending.tt'; + return; + } + $c->stash->{step} = $step; my $drvPath = $step->drvpath; + + # Pretty log pages derive this from the DB instead of request-local flash. + if (!defined $mode) { + my ($origin, $forwardChain) = + findRedirectingChain($c, $c->stash->{build}->id, $drvPath); + if ($origin) { + $c->stash->{redirectedFromStep} = { + build => $origin->get_column('build'), + stepnr => $origin->stepnr, + chain => $forwardChain, + origDrvPath => $origin->drvpath, + }; + } + } + my $log_uri = $c->uri_for($c->controller('Root')->action_for("log"), [WWW::Form::UrlEncoded::PP::url_encode(basename($drvPath))]); showLog($c, $mode, $log_uri); } @@ -592,6 +666,31 @@ sub get_info : Chained('buildChain') PathPart('api/get-info') Args(0) { $c->stash->{json}->{drvPath} = $build->drvpath; my $out = getMainOutput($build); $c->stash->{json}->{outPath} = $out->path if defined $out; + + my @resolved; + for my $step ($build->buildsteps->search({ status => 13 })) { + next unless $step->resolveddrvpath; + my ($terminal, $chain) = followResolvedChain($c, $step); + my $entry = { + stepnr => $step->stepnr, + resolvedDrvPath => $step->resolveddrvpath, + chain => $chain, + }; + if ($terminal + && !($terminal->get_column('build') == $build->id + && $terminal->stepnr == $step->stepnr)) + { + $entry->{terminal} = { + buildId => $terminal->get_column('build'), + stepnr => $terminal->stepnr, + status => $terminal->status, + busy => $terminal->busy, + }; + } + push @resolved, $entry; + } + $c->stash->{json}->{resolvedSteps} = \@resolved if @resolved; + $c->forward('View::JSON'); } diff --git a/subprojects/hydra/lib/Hydra/Helper/Nix.pm b/subprojects/hydra/lib/Hydra/Helper/Nix.pm index 2451d49fb..7dda7b9d1 100644 --- a/subprojects/hydra/lib/Hydra/Helper/Nix.pm +++ b/subprojects/hydra/lib/Hydra/Helper/Nix.pm @@ -8,6 +8,7 @@ use File::Basename; use Hydra::Config; use Hydra::Helper::CatalystUtils; use Hydra::Model::DB; +use Nix::Config; use Nix::Store; use Encode; use Sys::Hostname::Long; @@ -23,6 +24,8 @@ our @EXPORT = qw( cancelBuilds constructRunCommandLogPath findLog + findRedirectingChain + followResolvedChain gcRootFor getBaseUrl getDrvLogPath @@ -237,6 +240,69 @@ sub getMainOutput { } +# Returns the terminal step and the resolveddrvpath basenames followed. +# The terminal may still be Resolved when the chain dead-ends. +sub followResolvedChain { + my ($c, $step) = @_; + my %seen; + my @chain; + while ($step && (($step->status // -1) == 13) && $step->resolveddrvpath) { + # Track source rows; distinct resolved steps may share a target. + my $rowKey = $step->get_column('build') . ':' . $step->stepnr; + last if $seen{$rowKey}++; + last if scalar @chain >= 32; + my $basename = $step->resolveddrvpath; + # resolveddrvpath is a store-path basename, not a path. + last if $basename =~ m{/}; + push @chain, $basename; + # Resolution hops stay within the build that requested the work. + my $next = $c->model('DB::BuildSteps')->search( + { + build => $step->get_column('build'), + drvpath => $Nix::Config::storeDir . "/" . $basename, + }, + { order_by => [{ -asc => 'status' }, { -desc => 'stoptime' }], + rows => 1, + } + )->single; + last unless $next; + $step = $next; + } + return ($step, \@chain); +} + +# Find the resolved step that eventually redirected to $drvpath. +sub findRedirectingChain { + my ($c, $build_id, $drvpath) = @_; + my $basename = File::Basename::basename($drvpath); + my @reverseRows; + my %seen; + my $cursor = $basename; + while (1) { + my $prev = $c->model('DB::BuildSteps')->search( + { + build => $build_id, + status => 13, + resolveddrvpath => $cursor, + }, + { order_by => [{ -desc => 'stepnr' }], rows => 1 } + )->single; + last unless $prev; + my $rowKey = $prev->get_column('build') . ':' . $prev->stepnr; + last if $seen{$rowKey}++; + last if scalar @reverseRows >= 32; + push @reverseRows, $prev; + my $prevDrv = $prev->drvpath; + last unless defined $prevDrv; + $cursor = File::Basename::basename($prevDrv); + } + return (undef, []) unless @reverseRows; + my $origin = $reverseRows[-1]; + my @forwardChain = map { $_->resolveddrvpath } reverse @reverseRows; + return ($origin, \@forwardChain); +} + + sub getEvalInputs { my ($c, $eval) = @_; my @inputs = $eval->jobsetevalinputs->search( diff --git a/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm b/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm index 07a099c3c..97caf85aa 100644 --- a/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm +++ b/subprojects/hydra/lib/Hydra/Schema/Result/BuildSteps.pm @@ -232,6 +232,7 @@ my %hint = ( "stepnr", "drvpath", "starttime", + "resolveddrvpath", ], eager_relations => { build => 'id' diff --git a/subprojects/hydra/root/build.tt b/subprojects/hydra/root/build.tt index a5a817d1b..e6762508d 100644 --- a/subprojects/hydra/root/build.tt +++ b/subprojects/hydra/root/build.tt @@ -87,6 +87,24 @@ END; Output limit exceeded [% ELSIF step.status == 12 %] Non-determinism detected [% IF step.timesbuilt %] after [% HTML.escape(step.timesbuilt) %] times[% END %] + [% ELSIF step.status == 13 %] + Resolved + [% resolvedTerm = resolvedTerminals.${step.stepnr}; + IF resolvedTerm; + t = resolvedTerm.terminal; %] + -- resolved to [%+ INCLUDE renderBuildIdLink id=t.get_column('build') %], step #[% HTML.escape(t.stepnr) %] + [% IF t.busy != 0 %] + (Running) + [% ELSIF t.status == 0 %] + (Succeeded) + [% ELSIF t.status == 13 %] + (Resolved, pending) + [% ELSE %] + (Failed) + [% END; + ELSIF step.resolveddrvpath; %] + -- resolved drv [% HTML.escape(step.resolveddrvpath) %] not yet scheduled + [% END %] [% ELSIF step.errormsg %] Failed: [% HTML.escape(step.errormsg) %] [% ELSE %] diff --git a/subprojects/hydra/root/log-resolved-pending.tt b/subprojects/hydra/root/log-resolved-pending.tt new file mode 100644 index 000000000..6c5fe730c --- /dev/null +++ b/subprojects/hydra/root/log-resolved-pending.tt @@ -0,0 +1,49 @@ +[% WRAPPER layout.tt + titleHTML="Step $step.stepnr of build ${build.id}: resolved, log pending" + title="Step $step.stepnr of build ${build.id}: resolved, log pending" +%] +[% PROCESS common.tt %] + +[% # Only poll states that can still make progress. + canProgress = resolvedPending.state == 'running' || resolvedPending.state == 'unscheduled'; +%] +[% IF canProgress %] + +[% END %] + +
+

+ Step #[% HTML.escape(step.stepnr) %] of [%+ INCLUDE renderBuildIdLink id=build.id %] was marked Resolved: + its derivation [% HTML.escape(step.drvpath) %] was redirected to + [% HTML.escape(step.resolveddrvpath) %]. +

+ + [% IF resolvedPending.chain.size > 1 %] +

Resolution chain so far ([% resolvedPending.chain.size %] hops):

+
    + [% FOREACH hop IN resolvedPending.chain %] +
  1. [% HTML.escape(hop) %]
  2. + [% END %] +
+ [% END %] + + [% IF resolvedPending.state == 'running' %] +

+ The resolved step is currently running: [%+ INCLUDE renderBuildIdLink id=resolvedPending.terminal.get_column('build') %], step #[% HTML.escape(resolvedPending.terminal.stepnr) %]. This page will refresh automatically. +

+ [% ELSIF resolvedPending.state == 'dead-end' %] +

+ The chain ends at [%+ INCLUDE renderBuildIdLink id=resolvedPending.terminal.get_column('build') %], step #[% HTML.escape(resolvedPending.terminal.stepnr) %], which is itself still Resolved. The resolution graph appears truncated (maximum hop count reached); the actual build step is not reachable from here. +

+ [% ELSIF resolvedPending.state == 'cycle' %] +

+ This step's resolution graph is self-referential and will never produce a build log. This is likely a queue-runner bug — please file an issue. +

+ [% ELSE %] +

+ No build step for the resolved derivation has been scheduled yet. This page will refresh automatically once it lands. +

+ [% END %] +
+ +[% END %] diff --git a/subprojects/hydra/root/log.tt b/subprojects/hydra/root/log.tt index 783927fe8..96c5aa3b4 100644 --- a/subprojects/hydra/root/log.tt +++ b/subprojects/hydra/root/log.tt @@ -4,6 +4,37 @@ %] [% PROCESS common.tt %] +[% IF redirectedFromStep; + info = redirectedFromStep %] +
+

+ Redirected here from [%+ INCLUDE renderBuildIdLink id=info.build %] step #[% HTML.escape(info.stepnr) %], which was marked Resolved. +

+ [% IF info.origDrvPath %] +

+ Original derivation: [% HTML.escape(info.origDrvPath) %] +

+ [% END %] + [% IF info.chain.size %] +

+ Resolved + [% IF info.chain.size == 1 %] + to [% HTML.escape(info.chain.0) %]. + [% ELSE %] + through [% info.chain.size %] hops: + [% END %] +

+ [% IF info.chain.size > 1 %] +
    + [% FOREACH hop IN info.chain %] +
  1. [% HTML.escape(hop) %]
  2. + [% END %] +
+ [% END %] + [% END %] +
+[% END %] +

Below [% IF tail %]