// now: Nix-based distributed command runner // Copyright (C) 2026 Eric Rodrigues Pires // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS // FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for // more details. // // You should have received a copy of the GNU Affero General Public License along // with this program. If not, see . use std::{ collections::BTreeSet, fmt::{Display, Write}, num::NonZeroUsize, path::PathBuf, pin::Pin, process::Stdio, time::Duration, }; use ahash::{HashMap, HashSet, HashSetExt}; use color_eyre::{Section, eyre::eyre}; use futures::stream::FuturesUnordered; use petgraph::{matrix_graph::NodeIndex, visit::EdgeRef}; use serde::{Deserialize, Serialize}; use smol::{channel::Receiver, process::Command, stream::StreamExt}; use tracing::{debug, info, instrument, warn}; use crate::{ builder::{NowBuilder, local::LocalBuilder}, environment::{NowEnvironment, eval_id}, graph::{DagNode, NowWorkflowGraph}, job::{JobError, JobResult}, serde::now_job_timeout, utils::{parse_bool_from_bytes, wait_for_output, write_output_to_stderr}, }; #[derive(Debug, Serialize, Deserialize)] pub(crate) struct NowWorkflow { pub(crate) name: Option, pub(crate) default: Option>, pub(crate) jobs: HashMap, } #[derive(Debug)] pub(crate) enum NowJobContainer { Single(NowJob), Multiple(Vec), } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum NowCheckout { None, Default, Clone, All, CloneAll, } #[derive(Debug, Serialize, Deserialize)] pub(crate) struct NowJob { pub(crate) name: String, #[serde(rename = "buildSystem")] pub(crate) build_system: String, #[serde(rename = "hostSystem")] pub(crate) host_system: String, #[serde(rename = "requiredSystemFeatures")] pub(crate) required_system_features: HashSet, pub(crate) checkout: NowCheckout, #[serde(default, with = "now_job_timeout")] pub(crate) timeout: Option, pub(crate) strategy: Option, pub(crate) needs: Option>, pub(crate) steps: Vec, } #[derive(Debug, Serialize, Deserialize)] pub(crate) struct NowStrategy { #[serde(rename = "failFast")] pub(crate) fail_fast: bool, } #[derive(Clone, Debug)] pub(crate) struct NowStep { pub(crate) name: String, pub(crate) run_drv: PathBuf, pub(crate) teardown_drv: Option, pub(crate) env: HashMap, pub(crate) output_var: Option, pub(crate) upload_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub(crate) enum NowStepEnvVar { Plain(String), Secret(NowStepSecret), Download(NowStepDownload), } #[derive(Clone, Debug)] pub(crate) struct NowStepSecret { pub(crate) secret_name: String, } #[derive(Clone, Debug)] pub(crate) struct NowStepDownload { pub(crate) download_name: String, } #[derive(Debug, Serialize, Deserialize)] pub(crate) enum WorkflowSource { Path(PathBuf), Flake { path: String, attribute: String }, } impl Display for WorkflowSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { WorkflowSource::Path(path_buf) => f.write_str(&path_buf.to_string_lossy()), WorkflowSource::Flake { path, attribute } => { f.write_str(path)?; f.write_char('#')?; f.write_str(attribute) } } } } impl WorkflowSource { pub(crate) fn nix_expression(&self) -> color_eyre::Result { match self { WorkflowSource::Path(path) => { debug_assert!(path.is_absolute()); let workflow_str = path .to_str() .ok_or_else(|| eyre!("Workflow path is not UTF-8"))?; Ok(format!("(/. + {})", serde_json::to_string(&workflow_str)?)) } WorkflowSource::Flake { path, attribute } => { Ok(format!("(builtins.getFlake \"{path}\").{attribute}")) } } } pub(crate) fn nix_source_expression(&self) -> color_eyre::Result { match self { WorkflowSource::Path(_) => self.nix_expression(), WorkflowSource::Flake { path, .. } => { Ok(format!("(builtins.getFlake \"{path}\").outPath")) } } } } impl From<&WorkflowSource> for String { fn from(value: &WorkflowSource) -> Self { match value { WorkflowSource::Path(path) => path.to_string_lossy().into_owned(), WorkflowSource::Flake { path, attribute } => format!("{path}#{attribute}"), } } } pub(crate) enum NowJobsToRun { Default, Selected(Vec), All, } pub(crate) enum NowRunMode { All, LocalOnly, RemoteOnly, } pub(crate) struct NowWorkflowParams { pub(crate) workflow: WorkflowSource, pub(crate) ctrl_c: Receiver<()>, pub(crate) dry_run: bool, pub(crate) abort: bool, pub(crate) timeout: Option, pub(crate) keep_alive: Option, pub(crate) jobs_to_run: NowJobsToRun, pub(crate) builders: Option, pub(crate) cores: Option, pub(crate) run_mode: NowRunMode, pub(crate) skip: bool, } impl NowEnvironment { #[instrument( skip_all, fields( workflow, abort, timeout, jobs, all_jobs, builders, cores, local_only, remote_only, skip, ) )] pub(crate) async fn run_workflow( &mut self, NowWorkflowParams { workflow, ctrl_c, dry_run, abort, timeout, keep_alive, jobs_to_run, builders, cores, run_mode, skip, }: NowWorkflowParams, ) -> color_eyre::Result<()> { let builder = LocalBuilder::new(self, builders, run_mode, cores, keep_alive).await?; let runner = builder.get_name(); info!( runner, is_remote = false, "Evaluating workflow '{}'...", String::from(&workflow) ); let workflow = self.evaluate_workflow(&workflow).await?; debug!("$duper.workflow" = duper::serde::ser::to_string_compact(&workflow)?); if let Some(name) = workflow.name.as_ref() { info!(runner, is_remote = false, "Building tree for '{}'...", name); } else { info!(runner, is_remote = false, "Building tree for workflow..."); } let mut graph = workflow.into_graph()?; graph.prune(jobs_to_run)?; let NowWorkflowGraph { dag: mut tree, mut nodes, .. } = graph; debug!("$duper.graph" = duper::serde::ser::to_string_compact(tree.inner())?); let (timed_out_sender, timed_out_receiver) = smol::channel::bounded::<()>(1); let executor = smol::LocalExecutor::new(); let builder_ref = &builder; let abort_task = executor.spawn(async move { smol::future::race( async { if ctrl_c.recv().await.is_ok() { builder_ref.cancel_builders(); } }, async { if let Some(timeout) = timeout { smol::Timer::after(timeout).await; builder_ref.cancel_builders(); timed_out_sender.close(); } else { smol::future::pending().await } }, ) .await; smol::future::pending::>().await }); let workflow_task = executor.spawn(async { let mut futures = FuturesUnordered::>>>::new(); let mut result = Ok(()); let mut aborted = false; let mut current_nodes: HashSet> = HashSet::new(); for node in tree.nodes_iter() { if tree .edges_directed(node, petgraph::Direction::Incoming) .next() .is_none() { current_nodes.insert(node); } } debug_assert!(!current_nodes.is_empty()); loop { for node_index in current_nodes { let node_weight = &tree[node_index]; match node_weight { DagNode::Root => { debug_assert_eq!(tree.node_count(), 1); } DagNode::Job(_) => match nodes.remove(&node_index) { Some(NowJobContainer::Single(job)) => { futures.push(self.run_job_single(&builder, job, node_index, dry_run)); } Some(NowJobContainer::Multiple(job_vec)) => { futures.push(self.run_jobs_multiple(&builder, job_vec, node_index, dry_run)); } None => (), }, } } loop { if let Some((node_index, future_result)) = futures.next().await { match future_result { Ok(()) => { current_nodes = HashSet::new(); let possible_next_nodes: Vec<_> = tree .edges_directed(node_index, petgraph::Direction::Outgoing) .map(|edge| edge.target()) .collect(); tree.remove_node(node_index); for node in possible_next_nodes { if tree .edges_directed(node, petgraph::Direction::Incoming) .next() .is_none() { current_nodes.insert(node); } } break; } Err( JobError::NoMatchingBuilders { job_name, build_system, required_system_features, } ) if skip => { warn!( runner, is_remote = false, job_name, build_system, ?required_system_features, "No builders match for job '{job_name}' (buildSystem = {build_system}, requiredSystemFeatures = {required_system_features:?}); skipping." ); skip_dependent_nodes(&runner, &mut tree, &mut nodes, node_index); } Err( JobError::NoMatchingRunners { job_name, host_system, required_system_features, } ) if skip => { warn!( runner, is_remote = false, job_name, host_system, ?required_system_features, "No runners match for job '{job_name}' (hostSystem = {host_system}, requiredSystemFeatures = {required_system_features:?}); skipping." ); skip_dependent_nodes(&runner, &mut tree, &mut nodes, node_index); } Err(JobError::RunnerAborted) => { aborted = true; } Err(JobError::JobAborted(job)) => unreachable!("Job {job} was aborted without root cause"), Err(error) => { if abort { builder.cancel_builders(); } result = match result { Ok(()) => match error { JobError::Other(report) => Err(report), _ => Err(color_eyre::Report::from(error)), } Err(report) => Err(report.error(error)), } } } } else { if let Some(timeout) = timeout { if timed_out_receiver.is_closed() { return Err(eyre!( "Workflow timed out after {}", humantime::Duration::from(timeout) )); } } if result.is_ok() { if aborted { return Err(JobError::RunnerAborted.into()); } info!(runner, is_remote = false, "Done."); } return result } } } }); executor .run(smol::future::or(workflow_task, abort_task)) .await } #[instrument(skip(self))] pub(crate) async fn evaluate_workflow( &self, workflow: &WorkflowSource, ) -> color_eyre::Result { let workflow_path = workflow.nix_expression()?; let nix_workflow = self.nix_project_source.as_ref().join("now/workflow.nix"); let nix_workflow_canonical = smol::fs::canonicalize(&nix_workflow).await?; let nix_workflow_str = nix_workflow_canonical .to_str() .expect("project source path should be UTF-8"); let nix_workflow_path = format!("(/. + {})", serde_json::to_string(&nix_workflow_str)?); let vars_json = serde_json::to_string(&serde_json::to_string(&self.vars)?)?; let eval_id = serde_json::to_string(eval_id())?; let with_local_step = option_env!("NOW_WITH_LOCAL_STEP").is_some_and(parse_bool_from_bytes); let termination_timeout = self.termination_timeout.as_secs(); let nix_command = format!( "(import {nix_workflow_path} {{ \ withLocalStep = {with_local_step}; \ }}) {{ \ workflow = {workflow_path}; \ vars = builtins.fromJSON {vars_json}; \ terminationTimeout = {termination_timeout}; \ evalId = {eval_id}; \ }}" ); let mut command = Command::new("nix"); command .env("NIXPKGS_ALLOW_UNSUPPORTED_SYSTEM", "1") .args([ "--extra-experimental-features", "nix-command flakes", "eval", "--impure", "--json", "--keep-derivations", "--expr", ]) .arg(nix_command) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn()?; let output = wait_for_output(&mut child, None).await?; if output.status.success() { Ok(serde_json::from_slice(&output.stdout)?) } else { write_output_to_stderr(&output)?; Err(eyre!("Failed to evaluate workflow at {}", workflow)) } } } fn skip_dependent_nodes( runner: &str, tree: &mut petgraph::acyclic::Acyclic>, nodes: &mut HashMap, NowJobContainer>, node_index: NodeIndex, ) { let mut nodes_to_skip: Vec<_> = vec![node_index].into_iter().collect(); while !nodes_to_skip.is_empty() { while let Some(node_index) = nodes_to_skip.pop() { let new_nodes_to_skip: Vec<_> = tree .edges_directed(node_index, petgraph::Direction::Outgoing) .map(|edge| edge.target()) .collect(); match tree.remove_node(node_index) { Some(DagNode::Job(_)) => match nodes.remove(&node_index) { Some(NowJobContainer::Single(job)) => { warn!( runner, is_remote = false, "... also skipping dependent job '{}'.", job.name ); } Some(NowJobContainer::Multiple(job_vec)) => { let job_names: BTreeSet<_> = job_vec.iter().map(|job| &job.name).collect(); warn!( runner, is_remote = false, "... also skipping dependent job set '{:?}'.", job_names ); } None => (), }, Some(DagNode::Root) | None => {} } nodes_to_skip.extend(new_nodes_to_skip); } } }