chore: format with updated rustfmt and taplo rules
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie9ef5fc421fa20071946cf1073f7920c6a6a6964
This commit is contained in:
parent
605b1a5181
commit
c306383d27
72 changed files with 11217 additions and 10487 deletions
|
|
@ -1,29 +1,29 @@
|
|||
[package]
|
||||
name = "fc-evaluator"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
name = "fc-evaluator"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
futures.workspace = true
|
||||
git2.workspace = true
|
||||
hex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
git2.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
futures.workspace = true
|
||||
toml.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
# Our crates
|
||||
fc-common.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,312 +1,345 @@
|
|||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use fc_common::{
|
||||
config::EvaluatorConfig,
|
||||
models::{CreateBuild, CreateEvaluation, EvaluationStatus, JobsetInput},
|
||||
repo,
|
||||
};
|
||||
use futures::stream::{self, StreamExt};
|
||||
|
||||
use fc_common::config::EvaluatorConfig;
|
||||
use fc_common::models::{CreateBuild, CreateEvaluation, EvaluationStatus, JobsetInput};
|
||||
use fc_common::repo;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn run(pool: PgPool, config: EvaluatorConfig) -> anyhow::Result<()> {
|
||||
let poll_interval = Duration::from_secs(config.poll_interval);
|
||||
let nix_timeout = Duration::from_secs(config.nix_timeout);
|
||||
let git_timeout = Duration::from_secs(config.git_timeout);
|
||||
let poll_interval = Duration::from_secs(config.poll_interval);
|
||||
let nix_timeout = Duration::from_secs(config.nix_timeout);
|
||||
let git_timeout = Duration::from_secs(config.git_timeout);
|
||||
|
||||
loop {
|
||||
if let Err(e) = run_cycle(&pool, &config, nix_timeout, git_timeout).await {
|
||||
tracing::error!("Evaluation cycle failed: {e}");
|
||||
}
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
loop {
|
||||
if let Err(e) = run_cycle(&pool, &config, nix_timeout, git_timeout).await {
|
||||
tracing::error!("Evaluation cycle failed: {e}");
|
||||
}
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_cycle(
|
||||
pool: &PgPool,
|
||||
config: &EvaluatorConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
pool: &PgPool,
|
||||
config: &EvaluatorConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let active = repo::jobsets::list_active(pool).await?;
|
||||
tracing::info!("Found {} active jobsets", active.len());
|
||||
let active = repo::jobsets::list_active(pool).await?;
|
||||
tracing::info!("Found {} active jobsets", active.len());
|
||||
|
||||
let max_concurrent = config.max_concurrent_evals;
|
||||
let max_concurrent = config.max_concurrent_evals;
|
||||
|
||||
stream::iter(active)
|
||||
.for_each_concurrent(max_concurrent, |jobset| async move {
|
||||
if let Err(e) = evaluate_jobset(pool, &jobset, config, nix_timeout, git_timeout).await {
|
||||
tracing::error!(
|
||||
jobset_id = %jobset.id,
|
||||
jobset_name = %jobset.name,
|
||||
"Failed to evaluate jobset: {e}"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
stream::iter(active)
|
||||
.for_each_concurrent(max_concurrent, |jobset| {
|
||||
async move {
|
||||
if let Err(e) =
|
||||
evaluate_jobset(pool, &jobset, config, nix_timeout, git_timeout).await
|
||||
{
|
||||
tracing::error!(
|
||||
jobset_id = %jobset.id,
|
||||
jobset_name = %jobset.name,
|
||||
"Failed to evaluate jobset: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn evaluate_jobset(
|
||||
pool: &PgPool,
|
||||
jobset: &fc_common::models::ActiveJobset,
|
||||
config: &EvaluatorConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
pool: &PgPool,
|
||||
jobset: &fc_common::models::ActiveJobset,
|
||||
config: &EvaluatorConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = jobset.repository_url.clone();
|
||||
let work_dir = config.work_dir.clone();
|
||||
let project_name = jobset.project_name.clone();
|
||||
let branch = jobset.branch.clone();
|
||||
let url = jobset.repository_url.clone();
|
||||
let work_dir = config.work_dir.clone();
|
||||
let project_name = jobset.project_name.clone();
|
||||
let branch = jobset.branch.clone();
|
||||
|
||||
// Clone/fetch in a blocking task (git2 is sync) with timeout
|
||||
let (repo_path, commit_hash) = tokio::time::timeout(
|
||||
git_timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::clone_or_fetch(&url, &work_dir, &project_name, branch.as_deref())
|
||||
}),
|
||||
)
|
||||
// Clone/fetch in a blocking task (git2 is sync) with timeout
|
||||
let (repo_path, commit_hash) = tokio::time::timeout(
|
||||
git_timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::git::clone_or_fetch(
|
||||
&url,
|
||||
&work_dir,
|
||||
&project_name,
|
||||
branch.as_deref(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("Git operation timed out after {git_timeout:?}")
|
||||
})???;
|
||||
|
||||
// Query jobset inputs
|
||||
let inputs = repo::jobset_inputs::list_for_jobset(pool, jobset.id)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Git operation timed out after {git_timeout:?}"))???;
|
||||
.unwrap_or_default();
|
||||
|
||||
// Query jobset inputs
|
||||
let inputs = repo::jobset_inputs::list_for_jobset(pool, jobset.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Compute inputs hash for eval caching (commit + all input values/revisions)
|
||||
let inputs_hash = compute_inputs_hash(&commit_hash, &inputs);
|
||||
|
||||
// Compute inputs hash for eval caching (commit + all input values/revisions)
|
||||
let inputs_hash = compute_inputs_hash(&commit_hash, &inputs);
|
||||
|
||||
// Check if this exact combination was already evaluated (eval caching)
|
||||
if let Ok(Some(cached)) =
|
||||
repo::evaluations::get_by_inputs_hash(pool, jobset.id, &inputs_hash).await
|
||||
{
|
||||
tracing::debug!(
|
||||
jobset = %jobset.name,
|
||||
commit = %commit_hash,
|
||||
cached_eval = %cached.id,
|
||||
"Inputs unchanged (hash: {}), skipping evaluation",
|
||||
&inputs_hash[..16],
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Also skip if commit hasn't changed (backward compat)
|
||||
if let Some(latest) = repo::evaluations::get_latest(pool, jobset.id).await?
|
||||
&& latest.commit_hash == commit_hash && latest.inputs_hash.as_deref() == Some(&inputs_hash)
|
||||
{
|
||||
tracing::debug!(
|
||||
jobset = %jobset.name,
|
||||
commit = %commit_hash,
|
||||
"Already evaluated, skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
// Check if this exact combination was already evaluated (eval caching)
|
||||
if let Ok(Some(cached)) =
|
||||
repo::evaluations::get_by_inputs_hash(pool, jobset.id, &inputs_hash).await
|
||||
{
|
||||
tracing::debug!(
|
||||
jobset = %jobset.name,
|
||||
commit = %commit_hash,
|
||||
"Starting evaluation"
|
||||
cached_eval = %cached.id,
|
||||
"Inputs unchanged (hash: {}), skipping evaluation",
|
||||
&inputs_hash[..16],
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create evaluation record
|
||||
let eval = repo::evaluations::create(
|
||||
// Also skip if commit hasn't changed (backward compat)
|
||||
if let Some(latest) = repo::evaluations::get_latest(pool, jobset.id).await?
|
||||
&& latest.commit_hash == commit_hash
|
||||
&& latest.inputs_hash.as_deref() == Some(&inputs_hash)
|
||||
{
|
||||
tracing::debug!(
|
||||
jobset = %jobset.name,
|
||||
commit = %commit_hash,
|
||||
"Already evaluated, skipping"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
jobset = %jobset.name,
|
||||
commit = %commit_hash,
|
||||
"Starting evaluation"
|
||||
);
|
||||
|
||||
// Create evaluation record
|
||||
let eval = repo::evaluations::create(pool, CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit_hash.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Mark as running and set inputs hash
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Running,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::evaluations::set_inputs_hash(pool, eval.id, &inputs_hash).await;
|
||||
|
||||
// Check for declarative config in repo
|
||||
check_declarative_config(pool, &repo_path, jobset.project_id).await;
|
||||
|
||||
// Run nix evaluation
|
||||
match crate::nix::evaluate(
|
||||
&repo_path,
|
||||
&jobset.nix_expression,
|
||||
jobset.flake_mode,
|
||||
nix_timeout,
|
||||
config,
|
||||
&inputs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(eval_result) => {
|
||||
tracing::info!(
|
||||
jobset = %jobset.name,
|
||||
count = eval_result.jobs.len(),
|
||||
errors = eval_result.error_count,
|
||||
"Evaluation discovered jobs"
|
||||
);
|
||||
|
||||
// Create build records, tracking drv_path -> build_id for dependency
|
||||
// resolution
|
||||
let mut drv_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
let mut name_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
for job in &eval_result.jobs {
|
||||
let outputs_json = job
|
||||
.outputs
|
||||
.as_ref()
|
||||
.map(|o| serde_json::to_value(o).unwrap_or_default());
|
||||
let constituents_json = job
|
||||
.constituents
|
||||
.as_ref()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default());
|
||||
let is_aggregate = job.constituents.is_some();
|
||||
|
||||
let build = repo::builds::create(pool, CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: job.name.clone(),
|
||||
drv_path: job.drv_path.clone(),
|
||||
system: job.system.clone(),
|
||||
outputs: outputs_json,
|
||||
is_aggregate: Some(is_aggregate),
|
||||
constituents: constituents_json,
|
||||
})
|
||||
.await?;
|
||||
|
||||
drv_to_build.insert(job.drv_path.clone(), build.id);
|
||||
name_to_build.insert(job.name.clone(), build.id);
|
||||
}
|
||||
|
||||
// Resolve dependencies
|
||||
for job in &eval_result.jobs {
|
||||
let build_id = match drv_to_build.get(&job.drv_path) {
|
||||
Some(id) => *id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Input derivation dependencies
|
||||
if let Some(ref input_drvs) = job.input_drvs {
|
||||
for dep_drv in input_drvs.keys() {
|
||||
if let Some(&dep_build_id) = drv_to_build.get(dep_drv)
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate constituent dependencies
|
||||
if let Some(ref constituents) = job.constituents {
|
||||
for constituent_name in constituents {
|
||||
if let Some(&dep_build_id) = name_to_build.get(constituent_name)
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
CreateEvaluation {
|
||||
jobset_id: jobset.id,
|
||||
commit_hash: commit_hash.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
eval.id,
|
||||
EvaluationStatus::Completed,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
},
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(jobset = %jobset.name, "Evaluation failed: {msg}");
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
EvaluationStatus::Failed,
|
||||
Some(&msg),
|
||||
)
|
||||
.await?;
|
||||
},
|
||||
}
|
||||
|
||||
// Mark as running and set inputs hash
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Running, None).await?;
|
||||
let _ = repo::evaluations::set_inputs_hash(pool, eval.id, &inputs_hash).await;
|
||||
|
||||
// Check for declarative config in repo
|
||||
check_declarative_config(pool, &repo_path, jobset.project_id).await;
|
||||
|
||||
// Run nix evaluation
|
||||
match crate::nix::evaluate(
|
||||
&repo_path,
|
||||
&jobset.nix_expression,
|
||||
jobset.flake_mode,
|
||||
nix_timeout,
|
||||
config,
|
||||
&inputs,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(eval_result) => {
|
||||
tracing::info!(
|
||||
jobset = %jobset.name,
|
||||
count = eval_result.jobs.len(),
|
||||
errors = eval_result.error_count,
|
||||
"Evaluation discovered jobs"
|
||||
);
|
||||
|
||||
// Create build records, tracking drv_path -> build_id for dependency resolution
|
||||
let mut drv_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
let mut name_to_build: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
for job in &eval_result.jobs {
|
||||
let outputs_json = job
|
||||
.outputs
|
||||
.as_ref()
|
||||
.map(|o| serde_json::to_value(o).unwrap_or_default());
|
||||
let constituents_json = job
|
||||
.constituents
|
||||
.as_ref()
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default());
|
||||
let is_aggregate = job.constituents.is_some();
|
||||
|
||||
let build = repo::builds::create(
|
||||
pool,
|
||||
CreateBuild {
|
||||
evaluation_id: eval.id,
|
||||
job_name: job.name.clone(),
|
||||
drv_path: job.drv_path.clone(),
|
||||
system: job.system.clone(),
|
||||
outputs: outputs_json,
|
||||
is_aggregate: Some(is_aggregate),
|
||||
constituents: constituents_json,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
drv_to_build.insert(job.drv_path.clone(), build.id);
|
||||
name_to_build.insert(job.name.clone(), build.id);
|
||||
}
|
||||
|
||||
// Resolve dependencies
|
||||
for job in &eval_result.jobs {
|
||||
let build_id = match drv_to_build.get(&job.drv_path) {
|
||||
Some(id) => *id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Input derivation dependencies
|
||||
if let Some(ref input_drvs) = job.input_drvs {
|
||||
for dep_drv in input_drvs.keys() {
|
||||
if let Some(&dep_build_id) = drv_to_build.get(dep_drv)
|
||||
&& dep_build_id != build_id {
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate constituent dependencies
|
||||
if let Some(ref constituents) = job.constituents {
|
||||
for constituent_name in constituents {
|
||||
if let Some(&dep_build_id) = name_to_build.get(constituent_name)
|
||||
&& dep_build_id != build_id {
|
||||
let _ =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Completed, None)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(jobset = %jobset.name, "Evaluation failed: {msg}");
|
||||
repo::evaluations::update_status(pool, eval.id, EvaluationStatus::Failed, Some(&msg))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute a deterministic hash over the commit and all jobset inputs.
|
||||
/// Used for evaluation caching — skip re-eval when inputs haven't changed.
|
||||
fn compute_inputs_hash(commit_hash: &str, inputs: &[JobsetInput]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(commit_hash.as_bytes());
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(commit_hash.as_bytes());
|
||||
|
||||
// Sort inputs by name for deterministic hashing
|
||||
let mut sorted_inputs: Vec<&JobsetInput> = inputs.iter().collect();
|
||||
sorted_inputs.sort_by_key(|i| &i.name);
|
||||
// Sort inputs by name for deterministic hashing
|
||||
let mut sorted_inputs: Vec<&JobsetInput> = inputs.iter().collect();
|
||||
sorted_inputs.sort_by_key(|i| &i.name);
|
||||
|
||||
for input in sorted_inputs {
|
||||
hasher.update(input.name.as_bytes());
|
||||
hasher.update(input.input_type.as_bytes());
|
||||
hasher.update(input.value.as_bytes());
|
||||
if let Some(ref rev) = input.revision {
|
||||
hasher.update(rev.as_bytes());
|
||||
}
|
||||
for input in sorted_inputs {
|
||||
hasher.update(input.name.as_bytes());
|
||||
hasher.update(input.input_type.as_bytes());
|
||||
hasher.update(input.value.as_bytes());
|
||||
if let Some(ref rev) = input.revision {
|
||||
hasher.update(rev.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
hex::encode(hasher.finalize())
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Check for declarative project config (.fc.toml or .fc/config.toml) in the repo.
|
||||
async fn check_declarative_config(pool: &PgPool, repo_path: &std::path::Path, project_id: Uuid) {
|
||||
let config_path = repo_path.join(".fc.toml");
|
||||
let alt_config_path = repo_path.join(".fc/config.toml");
|
||||
/// Check for declarative project config (.fc.toml or .fc/config.toml) in the
|
||||
/// repo.
|
||||
async fn check_declarative_config(
|
||||
pool: &PgPool,
|
||||
repo_path: &std::path::Path,
|
||||
project_id: Uuid,
|
||||
) {
|
||||
let config_path = repo_path.join(".fc.toml");
|
||||
let alt_config_path = repo_path.join(".fc/config.toml");
|
||||
|
||||
let path = if config_path.exists() {
|
||||
config_path
|
||||
} else if alt_config_path.exists() {
|
||||
alt_config_path
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let path = if config_path.exists() {
|
||||
config_path
|
||||
} else if alt_config_path.exists() {
|
||||
alt_config_path
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read declarative config {}: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to read declarative config {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeConfig {
|
||||
jobsets: Option<Vec<DeclarativeJobset>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeJobset {
|
||||
name: String,
|
||||
nix_expression: String,
|
||||
flake_mode: Option<bool>,
|
||||
check_interval: Option<i32>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let config: DeclarativeConfig = match toml::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse declarative config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(jobsets) = config.jobsets {
|
||||
for js in jobsets {
|
||||
let input = fc_common::models::CreateJobset {
|
||||
project_id,
|
||||
name: js.name,
|
||||
nix_expression: js.nix_expression,
|
||||
enabled: js.enabled,
|
||||
flake_mode: js.flake_mode,
|
||||
check_interval: js.check_interval,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
};
|
||||
if let Err(e) = repo::jobsets::upsert(pool, input).await {
|
||||
tracing::warn!("Failed to upsert declarative jobset: {e}");
|
||||
}
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeConfig {
|
||||
jobsets: Option<Vec<DeclarativeJobset>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeJobset {
|
||||
name: String,
|
||||
nix_expression: String,
|
||||
flake_mode: Option<bool>,
|
||||
check_interval: Option<i32>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let config: DeclarativeConfig = match toml::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse declarative config: {e}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(jobsets) = config.jobsets {
|
||||
for js in jobsets {
|
||||
let input = fc_common::models::CreateJobset {
|
||||
project_id,
|
||||
name: js.name,
|
||||
nix_expression: js.nix_expression,
|
||||
enabled: js.enabled,
|
||||
flake_mode: js.flake_mode,
|
||||
check_interval: js.check_interval,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
};
|
||||
if let Err(e) = repo::jobsets::upsert(pool, input).await {
|
||||
tracing::warn!("Failed to upsert declarative jobset: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,43 +5,45 @@ use git2::Repository;
|
|||
|
||||
/// Clone or fetch a repository. Returns (repo_path, commit_hash).
|
||||
///
|
||||
/// If `branch` is `Some`, resolve `refs/remotes/origin/<branch>` instead of HEAD.
|
||||
/// If `branch` is `Some`, resolve `refs/remotes/origin/<branch>` instead of
|
||||
/// HEAD.
|
||||
#[tracing::instrument(skip(work_dir))]
|
||||
pub fn clone_or_fetch(
|
||||
url: &str,
|
||||
work_dir: &Path,
|
||||
project_name: &str,
|
||||
branch: Option<&str>,
|
||||
url: &str,
|
||||
work_dir: &Path,
|
||||
project_name: &str,
|
||||
branch: Option<&str>,
|
||||
) -> Result<(PathBuf, String)> {
|
||||
let repo_path = work_dir.join(project_name);
|
||||
let repo_path = work_dir.join(project_name);
|
||||
|
||||
let repo = if repo_path.exists() {
|
||||
let repo = Repository::open(&repo_path)?;
|
||||
// Fetch origin — scope the borrow so `remote` is dropped before we move `repo`
|
||||
{
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
remote.fetch(&["refs/heads/*:refs/remotes/origin/*"], None, None)?;
|
||||
}
|
||||
repo
|
||||
} else {
|
||||
Repository::clone(url, &repo_path)?
|
||||
};
|
||||
let repo = if repo_path.exists() {
|
||||
let repo = Repository::open(&repo_path)?;
|
||||
// Fetch origin — scope the borrow so `remote` is dropped before we move
|
||||
// `repo`
|
||||
{
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
remote.fetch(&["refs/heads/*:refs/remotes/origin/*"], None, None)?;
|
||||
}
|
||||
repo
|
||||
} else {
|
||||
Repository::clone(url, &repo_path)?
|
||||
};
|
||||
|
||||
// Resolve commit: use specific branch ref or fall back to HEAD
|
||||
let hash = if let Some(branch_name) = branch {
|
||||
let refname = format!("refs/remotes/origin/{branch_name}");
|
||||
let reference = repo.find_reference(&refname).map_err(|e| {
|
||||
fc_common::error::CiError::NotFound(format!(
|
||||
"Branch '{branch_name}' not found ({refname}): {e}"
|
||||
))
|
||||
})?;
|
||||
let commit = reference.peel_to_commit()?;
|
||||
commit.id().to_string()
|
||||
} else {
|
||||
let head = repo.head()?;
|
||||
let commit = head.peel_to_commit()?;
|
||||
commit.id().to_string()
|
||||
};
|
||||
// Resolve commit: use specific branch ref or fall back to HEAD
|
||||
let hash = if let Some(branch_name) = branch {
|
||||
let refname = format!("refs/remotes/origin/{branch_name}");
|
||||
let reference = repo.find_reference(&refname).map_err(|e| {
|
||||
fc_common::error::CiError::NotFound(format!(
|
||||
"Branch '{branch_name}' not found ({refname}): {e}"
|
||||
))
|
||||
})?;
|
||||
let commit = reference.peel_to_commit()?;
|
||||
commit.id().to_string()
|
||||
} else {
|
||||
let head = repo.head()?;
|
||||
let commit = head.peel_to_commit()?;
|
||||
commit.id().to_string()
|
||||
};
|
||||
|
||||
Ok((repo_path, hash))
|
||||
Ok((repo_path, hash))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,67 +5,67 @@ use fc_common::{Config, Database};
|
|||
#[command(name = "fc-evaluator")]
|
||||
#[command(about = "CI Evaluator - Git polling and Nix evaluation")]
|
||||
struct Cli {
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let _cli = Cli::parse();
|
||||
let _cli = Cli::parse();
|
||||
|
||||
let config = Config::load()?;
|
||||
fc_common::init_tracing(&config.tracing);
|
||||
let config = Config::load()?;
|
||||
fc_common::init_tracing(&config.tracing);
|
||||
|
||||
tracing::info!("Starting CI Evaluator");
|
||||
tracing::info!("Configuration loaded");
|
||||
tracing::info!("Starting CI Evaluator");
|
||||
tracing::info!("Configuration loaded");
|
||||
|
||||
// Ensure work directory exists
|
||||
tokio::fs::create_dir_all(&config.evaluator.work_dir).await?;
|
||||
tracing::info!(work_dir = %config.evaluator.work_dir.display(), "Work directory ready");
|
||||
// Ensure work directory exists
|
||||
tokio::fs::create_dir_all(&config.evaluator.work_dir).await?;
|
||||
tracing::info!(work_dir = %config.evaluator.work_dir.display(), "Work directory ready");
|
||||
|
||||
let db = Database::new(config.database.clone()).await?;
|
||||
tracing::info!("Database connection established");
|
||||
let db = Database::new(config.database.clone()).await?;
|
||||
tracing::info!("Database connection established");
|
||||
|
||||
let pool = db.pool().clone();
|
||||
let eval_config = config.evaluator;
|
||||
let pool = db.pool().clone();
|
||||
let eval_config = config.evaluator;
|
||||
|
||||
tokio::select! {
|
||||
result = fc_evaluator::eval_loop::run(pool, eval_config) => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Evaluator loop failed: {e}");
|
||||
}
|
||||
}
|
||||
() = shutdown_signal() => {
|
||||
tracing::info!("Shutdown signal received");
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
result = fc_evaluator::eval_loop::run(pool, eval_config) => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Evaluator loop failed: {e}");
|
||||
}
|
||||
}
|
||||
() = shutdown_signal() => {
|
||||
tracing::info!("Shutdown signal received");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Evaluator shutting down, closing database pool");
|
||||
db.close().await;
|
||||
tracing::info!("Evaluator shutting down, closing database pool");
|
||||
db.close().await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
() = ctrl_c => {},
|
||||
() = terminate => {},
|
||||
}
|
||||
tokio::select! {
|
||||
() = ctrl_c => {},
|
||||
() = terminate => {},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,74 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, path::Path, time::Duration};
|
||||
|
||||
use fc_common::CiError;
|
||||
use fc_common::config::EvaluatorConfig;
|
||||
use fc_common::error::Result;
|
||||
use fc_common::models::JobsetInput;
|
||||
use fc_common::{
|
||||
CiError,
|
||||
config::EvaluatorConfig,
|
||||
error::Result,
|
||||
models::JobsetInput,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NixJob {
|
||||
pub name: String,
|
||||
#[serde(alias = "drvPath")]
|
||||
pub drv_path: String,
|
||||
pub system: Option<String>,
|
||||
pub outputs: Option<HashMap<String, String>>,
|
||||
#[serde(alias = "inputDrvs")]
|
||||
pub input_drvs: Option<HashMap<String, serde_json::Value>>,
|
||||
pub constituents: Option<Vec<String>>,
|
||||
pub name: String,
|
||||
#[serde(alias = "drvPath")]
|
||||
pub drv_path: String,
|
||||
pub system: Option<String>,
|
||||
pub outputs: Option<HashMap<String, String>>,
|
||||
#[serde(alias = "inputDrvs")]
|
||||
pub input_drvs: Option<HashMap<String, serde_json::Value>>,
|
||||
pub constituents: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// An error reported by nix-eval-jobs for a single job.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct NixEvalError {
|
||||
#[serde(alias = "attr")]
|
||||
name: Option<String>,
|
||||
error: String,
|
||||
#[serde(alias = "attr")]
|
||||
name: Option<String>,
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// Result of evaluating nix expressions.
|
||||
pub struct EvalResult {
|
||||
pub jobs: Vec<NixJob>,
|
||||
pub error_count: usize,
|
||||
pub jobs: Vec<NixJob>,
|
||||
pub error_count: usize,
|
||||
}
|
||||
|
||||
/// Parse nix-eval-jobs output lines into jobs and error counts.
|
||||
/// Extracted as a testable function from the inline parsing loops.
|
||||
pub fn parse_eval_output(stdout: &str) -> EvalResult {
|
||||
let mut jobs = Vec::new();
|
||||
let mut error_count = 0;
|
||||
let mut jobs = Vec::new();
|
||||
let mut error_count = 0;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line)
|
||||
&& parsed.get("error").is_some() {
|
||||
if let Ok(eval_err) = serde_json::from_str::<NixEvalError>(line) {
|
||||
let name = eval_err.name.as_deref().unwrap_or("<unknown>");
|
||||
tracing::warn!(
|
||||
job = name,
|
||||
"nix-eval-jobs reported error: {}",
|
||||
eval_err.error
|
||||
);
|
||||
error_count += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<NixJob>(line) {
|
||||
Ok(job) => jobs.push(job),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse nix-eval-jobs line: {e}");
|
||||
}
|
||||
}
|
||||
for line in stdout.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
EvalResult { jobs, error_count }
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line)
|
||||
&& parsed.get("error").is_some()
|
||||
{
|
||||
if let Ok(eval_err) = serde_json::from_str::<NixEvalError>(line) {
|
||||
let name = eval_err.name.as_deref().unwrap_or("<unknown>");
|
||||
tracing::warn!(
|
||||
job = name,
|
||||
"nix-eval-jobs reported error: {}",
|
||||
eval_err.error
|
||||
);
|
||||
error_count += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<NixJob>(line) {
|
||||
Ok(job) => jobs.push(job),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse nix-eval-jobs line: {e}");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
EvalResult { jobs, error_count }
|
||||
}
|
||||
|
||||
/// Evaluate nix expressions and return discovered jobs.
|
||||
|
|
@ -75,214 +76,229 @@ pub fn parse_eval_output(stdout: &str) -> EvalResult {
|
|||
/// If flake_mode is false, evaluates a legacy expression file.
|
||||
#[tracing::instrument(skip(config, inputs), fields(flake_mode, nix_expression))]
|
||||
pub async fn evaluate(
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
flake_mode: bool,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
flake_mode: bool,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
) -> Result<EvalResult> {
|
||||
if flake_mode {
|
||||
evaluate_flake(repo_path, nix_expression, timeout, config, inputs).await
|
||||
} else {
|
||||
evaluate_legacy(repo_path, nix_expression, timeout, config, inputs).await
|
||||
}
|
||||
if flake_mode {
|
||||
evaluate_flake(repo_path, nix_expression, timeout, config, inputs).await
|
||||
} else {
|
||||
evaluate_legacy(repo_path, nix_expression, timeout, config, inputs).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(config, inputs))]
|
||||
async fn evaluate_flake(
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
) -> Result<EvalResult> {
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
cmd.arg("--flake").arg(&flake_ref);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
cmd.arg("--flake").arg(&flake_ref);
|
||||
if config.restrict_eval {
|
||||
cmd.args(["--option", "restrict-eval", "true"]);
|
||||
}
|
||||
if !config.allow_ifd {
|
||||
cmd.args(["--option", "allow-import-from-derivation", "false"]);
|
||||
}
|
||||
for input in inputs {
|
||||
if input.input_type == "git" {
|
||||
cmd.args(["--override-input", &input.name, &input.value]);
|
||||
}
|
||||
}
|
||||
|
||||
if config.restrict_eval {
|
||||
cmd.args(["--option", "restrict-eval", "true"]);
|
||||
}
|
||||
if !config.allow_ifd {
|
||||
cmd.args(["--option", "allow-import-from-derivation", "false"]);
|
||||
}
|
||||
for input in inputs {
|
||||
if input.input_type == "git" {
|
||||
cmd.args(["--override-input", &input.name, &input.value]);
|
||||
}
|
||||
let output = cmd.output().await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() || !out.stdout.is_empty() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let result = parse_eval_output(&stdout);
|
||||
|
||||
if result.error_count > 0 {
|
||||
tracing::warn!(
|
||||
error_count = result.error_count,
|
||||
"nix-eval-jobs reported errors for some jobs"
|
||||
);
|
||||
}
|
||||
|
||||
let output = cmd.output().await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() || !out.stdout.is_empty() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let result = parse_eval_output(&stdout);
|
||||
|
||||
if result.error_count > 0 {
|
||||
tracing::warn!(
|
||||
error_count = result.error_count,
|
||||
"nix-eval-jobs reported errors for some jobs"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("nix-eval-jobs unavailable, falling back to nix eval");
|
||||
let jobs = evaluate_with_nix_eval(repo_path, nix_expression).await?;
|
||||
Ok(EvalResult {
|
||||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}")))?
|
||||
Ok(result)
|
||||
},
|
||||
_ => {
|
||||
tracing::info!("nix-eval-jobs unavailable, falling back to nix eval");
|
||||
let jobs = evaluate_with_nix_eval(repo_path, nix_expression).await?;
|
||||
Ok(EvalResult {
|
||||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}"))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Legacy (non-flake) evaluation: import the nix expression file and evaluate it.
|
||||
/// Legacy (non-flake) evaluation: import the nix expression file and evaluate
|
||||
/// it.
|
||||
#[tracing::instrument(skip(config, inputs))]
|
||||
async fn evaluate_legacy(
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
timeout: Duration,
|
||||
config: &EvaluatorConfig,
|
||||
inputs: &[JobsetInput],
|
||||
) -> Result<EvalResult> {
|
||||
let expr_path = repo_path.join(nix_expression);
|
||||
let expr_path = repo_path.join(nix_expression);
|
||||
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
// Try nix-eval-jobs without --flake for legacy expressions
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
cmd.arg(&expr_path);
|
||||
|
||||
tokio::time::timeout(timeout, async {
|
||||
// Try nix-eval-jobs without --flake for legacy expressions
|
||||
let mut cmd = tokio::process::Command::new("nix-eval-jobs");
|
||||
cmd.arg(&expr_path);
|
||||
if config.restrict_eval {
|
||||
cmd.args(["--option", "restrict-eval", "true"]);
|
||||
}
|
||||
if !config.allow_ifd {
|
||||
cmd.args(["--option", "allow-import-from-derivation", "false"]);
|
||||
}
|
||||
for input in inputs {
|
||||
if input.input_type == "string" || input.input_type == "path" {
|
||||
cmd.args(["--arg", &input.name, &input.value]);
|
||||
}
|
||||
}
|
||||
|
||||
if config.restrict_eval {
|
||||
cmd.args(["--option", "restrict-eval", "true"]);
|
||||
let output = cmd.output().await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() || !out.stdout.is_empty() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
Ok(parse_eval_output(&stdout))
|
||||
},
|
||||
_ => {
|
||||
// Fallback: nix eval on the legacy import
|
||||
tracing::info!(
|
||||
"nix-eval-jobs unavailable for legacy expr, using nix-instantiate"
|
||||
);
|
||||
let output = tokio::process::Command::new("nix-instantiate")
|
||||
.arg(&expr_path)
|
||||
.arg("--strict")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("nix-instantiate failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CiError::NixEval(format!(
|
||||
"nix-instantiate failed: {stderr}"
|
||||
)));
|
||||
}
|
||||
if !config.allow_ifd {
|
||||
cmd.args(["--option", "allow-import-from-derivation", "false"]);
|
||||
}
|
||||
for input in inputs {
|
||||
if input.input_type == "string" || input.input_type == "path" {
|
||||
cmd.args(["--arg", &input.name, &input.value]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// nix-instantiate --json outputs the derivation path(s)
|
||||
let drv_paths: Vec<String> =
|
||||
serde_json::from_str(&stdout).unwrap_or_default();
|
||||
let jobs: Vec<NixJob> = drv_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, drv_path)| {
|
||||
NixJob {
|
||||
name: format!("job-{i}"),
|
||||
drv_path,
|
||||
system: None,
|
||||
outputs: None,
|
||||
input_drvs: None,
|
||||
constituents: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = cmd.output().await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() || !out.stdout.is_empty() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
Ok(parse_eval_output(&stdout))
|
||||
}
|
||||
_ => {
|
||||
// Fallback: nix eval on the legacy import
|
||||
tracing::info!("nix-eval-jobs unavailable for legacy expr, using nix-instantiate");
|
||||
let output = tokio::process::Command::new("nix-instantiate")
|
||||
.arg(&expr_path)
|
||||
.arg("--strict")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CiError::NixEval(format!("nix-instantiate failed: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CiError::NixEval(format!(
|
||||
"nix-instantiate failed: {stderr}"
|
||||
)));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// nix-instantiate --json outputs the derivation path(s)
|
||||
let drv_paths: Vec<String> = serde_json::from_str(&stdout).unwrap_or_default();
|
||||
let jobs: Vec<NixJob> = drv_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, drv_path)| NixJob {
|
||||
name: format!("job-{i}"),
|
||||
drv_path,
|
||||
system: None,
|
||||
outputs: None,
|
||||
input_drvs: None,
|
||||
constituents: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(EvalResult {
|
||||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}")))?
|
||||
Ok(EvalResult {
|
||||
jobs,
|
||||
error_count: 0,
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CiError::Timeout(format!("Nix evaluation timed out after {timeout:?}"))
|
||||
})?
|
||||
}
|
||||
|
||||
async fn evaluate_with_nix_eval(repo_path: &Path, nix_expression: &str) -> Result<Vec<NixJob>> {
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
async fn evaluate_with_nix_eval(
|
||||
repo_path: &Path,
|
||||
nix_expression: &str,
|
||||
) -> Result<Vec<NixJob>> {
|
||||
let flake_ref = format!("{}#{}", repo_path.display(), nix_expression);
|
||||
|
||||
let output = tokio::process::Command::new("nix")
|
||||
.args(["eval", "--json", &flake_ref])
|
||||
let output = tokio::process::Command::new("nix")
|
||||
.args(["eval", "--json", &flake_ref])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to run nix eval: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CiError::NixEval(format!("nix eval failed: {stderr}")));
|
||||
}
|
||||
|
||||
// Parse the JSON output - expecting an attrset of name -> derivation
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let attrs: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to parse nix eval output: {e}"))
|
||||
})?;
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
if let serde_json::Value::Object(map) = attrs {
|
||||
for (name, _value) in map {
|
||||
// Get derivation path via nix derivation show
|
||||
let drv_ref =
|
||||
format!("{}#{}.{}", repo_path.display(), nix_expression, name);
|
||||
let drv_output = tokio::process::Command::new("nix")
|
||||
.args(["derivation", "show", &drv_ref])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to run nix eval: {e}")))?;
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to get derivation for {name}: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CiError::NixEval(format!("nix eval failed: {stderr}")));
|
||||
}
|
||||
|
||||
// Parse the JSON output - expecting an attrset of name -> derivation
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let attrs: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to parse nix eval output: {e}")))?;
|
||||
|
||||
let mut jobs = Vec::new();
|
||||
if let serde_json::Value::Object(map) = attrs {
|
||||
for (name, _value) in map {
|
||||
// Get derivation path via nix derivation show
|
||||
let drv_ref = format!("{}#{}.{}", repo_path.display(), nix_expression, name);
|
||||
let drv_output = tokio::process::Command::new("nix")
|
||||
.args(["derivation", "show", &drv_ref])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to get derivation for {name}: {e}"))
|
||||
})?;
|
||||
|
||||
if drv_output.status.success() {
|
||||
let drv_stdout = String::from_utf8_lossy(&drv_output.stdout);
|
||||
if let Ok(drv_json) = serde_json::from_str::<serde_json::Value>(&drv_stdout)
|
||||
&& let Some((drv_path, drv_val)) =
|
||||
drv_json.as_object().and_then(|o| o.iter().next())
|
||||
{
|
||||
let system = drv_val
|
||||
.get("system")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
jobs.push(NixJob {
|
||||
name: name.clone(),
|
||||
drv_path: drv_path.clone(),
|
||||
system,
|
||||
outputs: None,
|
||||
input_drvs: None,
|
||||
constituents: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
if drv_output.status.success() {
|
||||
let drv_stdout = String::from_utf8_lossy(&drv_output.stdout);
|
||||
if let Ok(drv_json) =
|
||||
serde_json::from_str::<serde_json::Value>(&drv_stdout)
|
||||
&& let Some((drv_path, drv_val)) =
|
||||
drv_json.as_object().and_then(|o| o.iter().next())
|
||||
{
|
||||
let system = drv_val
|
||||
.get("system")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
jobs.push(NixJob {
|
||||
name: name.clone(),
|
||||
drv_path: drv_path.clone(),
|
||||
system,
|
||||
outputs: None,
|
||||
input_drvs: None,
|
||||
constituents: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(jobs)
|
||||
Ok(jobs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,95 +3,95 @@
|
|||
|
||||
#[test]
|
||||
fn test_parse_valid_job() {
|
||||
let line = r#"{"name":"hello","drvPath":"/nix/store/abc123-hello.drv","system":"x86_64-linux","outputs":{"out":"/nix/store/abc123-hello"}}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
assert_eq!(result.error_count, 0);
|
||||
assert_eq!(result.jobs[0].name, "hello");
|
||||
assert_eq!(result.jobs[0].drv_path, "/nix/store/abc123-hello.drv");
|
||||
assert_eq!(result.jobs[0].system.as_deref(), Some("x86_64-linux"));
|
||||
let line = r#"{"name":"hello","drvPath":"/nix/store/abc123-hello.drv","system":"x86_64-linux","outputs":{"out":"/nix/store/abc123-hello"}}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
assert_eq!(result.error_count, 0);
|
||||
assert_eq!(result.jobs[0].name, "hello");
|
||||
assert_eq!(result.jobs[0].drv_path, "/nix/store/abc123-hello.drv");
|
||||
assert_eq!(result.jobs[0].system.as_deref(), Some("x86_64-linux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_jobs() {
|
||||
let output = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv","system":"x86_64-linux"}
|
||||
let output = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv","system":"x86_64-linux"}
|
||||
{"name":"world","drvPath":"/nix/store/def-world.drv","system":"aarch64-linux"}"#;
|
||||
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 2);
|
||||
assert_eq!(result.error_count, 0);
|
||||
assert_eq!(result.jobs[0].name, "hello");
|
||||
assert_eq!(result.jobs[1].name, "world");
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 2);
|
||||
assert_eq!(result.error_count, 0);
|
||||
assert_eq!(result.jobs[0].name, "hello");
|
||||
assert_eq!(result.jobs[1].name, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_error_lines() {
|
||||
let output = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv"}
|
||||
let output = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv"}
|
||||
{"attr":"broken","error":"attribute 'broken' missing"}
|
||||
{"name":"world","drvPath":"/nix/store/def-world.drv"}"#;
|
||||
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 2);
|
||||
assert_eq!(result.error_count, 1);
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 2);
|
||||
assert_eq!(result.error_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_output() {
|
||||
let result = fc_evaluator::nix::parse_eval_output("");
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 0);
|
||||
let result = fc_evaluator::nix::parse_eval_output("");
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_blank_lines_ignored() {
|
||||
let output = "\n \n\n";
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 0);
|
||||
let output = "\n \n\n";
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_malformed_json_skipped() {
|
||||
let output =
|
||||
"not json at all\n{invalid json}\n{\"name\":\"ok\",\"drvPath\":\"/nix/store/x-ok.drv\"}";
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
assert_eq!(result.jobs[0].name, "ok");
|
||||
let output = "not json at all\n{invalid \
|
||||
json}\n{\"name\":\"ok\",\"drvPath\":\"/nix/store/x-ok.drv\"}";
|
||||
let result = fc_evaluator::nix::parse_eval_output(output);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
assert_eq!(result.jobs[0].name, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_job_with_input_drvs() {
|
||||
let line = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv","inputDrvs":{"/nix/store/dep1.drv":["out"],"/nix/store/dep2.drv":["out"]}}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
let input_drvs = result.jobs[0].input_drvs.as_ref().unwrap();
|
||||
assert_eq!(input_drvs.len(), 2);
|
||||
let line = r#"{"name":"hello","drvPath":"/nix/store/abc-hello.drv","inputDrvs":{"/nix/store/dep1.drv":["out"],"/nix/store/dep2.drv":["out"]}}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
let input_drvs = result.jobs[0].input_drvs.as_ref().unwrap();
|
||||
assert_eq!(input_drvs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_job_with_constituents() {
|
||||
let line = r#"{"name":"aggregate","drvPath":"/nix/store/abc-aggregate.drv","constituents":["hello","world"]}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
let constituents = result.jobs[0].constituents.as_ref().unwrap();
|
||||
assert_eq!(constituents.len(), 2);
|
||||
assert_eq!(constituents[0], "hello");
|
||||
assert_eq!(constituents[1], "world");
|
||||
let line = r#"{"name":"aggregate","drvPath":"/nix/store/abc-aggregate.drv","constituents":["hello","world"]}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 1);
|
||||
let constituents = result.jobs[0].constituents.as_ref().unwrap();
|
||||
assert_eq!(constituents.len(), 2);
|
||||
assert_eq!(constituents[0], "hello");
|
||||
assert_eq!(constituents[1], "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_error_without_name() {
|
||||
let line = r#"{"error":"some eval error"}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 1);
|
||||
let line = r#"{"error":"some eval error"}"#;
|
||||
let result = fc_evaluator::nix::parse_eval_output(line);
|
||||
assert_eq!(result.jobs.len(), 0);
|
||||
assert_eq!(result.error_count, 1);
|
||||
}
|
||||
|
||||
// --- Inputs hash computation ---
|
||||
|
||||
#[test]
|
||||
fn test_inputs_hash_deterministic() {
|
||||
// The compute_inputs_hash function is in eval_loop which is not easily testable
|
||||
// as a standalone function since it's not public. We test the nix parsing above
|
||||
// and trust the hash logic is correct since it uses sha2.
|
||||
// The compute_inputs_hash function is in eval_loop which is not easily
|
||||
// testable as a standalone function since it's not public. We test the nix
|
||||
// parsing above and trust the hash logic is correct since it uses sha2.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,85 +6,100 @@ use tempfile::TempDir;
|
|||
|
||||
#[test]
|
||||
fn test_clone_or_fetch_clones_new_repo() {
|
||||
let upstream_dir = TempDir::new().unwrap();
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
let upstream_dir = TempDir::new().unwrap();
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a non-bare repo to clone from (bare repos have no HEAD by default)
|
||||
let upstream = Repository::init(upstream_dir.path()).unwrap();
|
||||
// Create initial commit
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
// Create a non-bare repo to clone from (bare repos have no HEAD by default)
|
||||
let upstream = Repository::init(upstream_dir.path()).unwrap();
|
||||
// Create initial commit
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let url = format!("file://{}", upstream_dir.path().display());
|
||||
let result = fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None);
|
||||
let url = format!("file://{}", upstream_dir.path().display());
|
||||
let result = fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"clone_or_fetch should succeed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let (repo_path, hash): (std::path::PathBuf, String) = result.unwrap();
|
||||
assert!(repo_path.exists());
|
||||
assert!(!hash.is_empty());
|
||||
assert_eq!(hash.len(), 40); // full SHA-1
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"clone_or_fetch should succeed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let (repo_path, hash): (std::path::PathBuf, String) = result.unwrap();
|
||||
assert!(repo_path.exists());
|
||||
assert!(!hash.is_empty());
|
||||
assert_eq!(hash.len(), 40); // full SHA-1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_or_fetch_fetches_existing() {
|
||||
let upstream_dir = TempDir::new().unwrap();
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
let upstream_dir = TempDir::new().unwrap();
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
|
||||
let upstream = Repository::init(upstream_dir.path()).unwrap();
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
let upstream = Repository::init(upstream_dir.path()).unwrap();
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let url = format!("file://{}", upstream_dir.path().display());
|
||||
let url = format!("file://{}", upstream_dir.path().display());
|
||||
|
||||
// First clone
|
||||
let (_, hash1): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None)
|
||||
.expect("first clone failed");
|
||||
// First clone
|
||||
let (_, hash1): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
)
|
||||
.expect("first clone failed");
|
||||
|
||||
// Make another commit upstream
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
let head = upstream.head().unwrap().peel_to_commit().unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "second", &tree, &[&head])
|
||||
.unwrap();
|
||||
}
|
||||
// Make another commit upstream
|
||||
{
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let tree_id = upstream.index().unwrap().write_tree().unwrap();
|
||||
let tree = upstream.find_tree(tree_id).unwrap();
|
||||
let head = upstream.head().unwrap().peel_to_commit().unwrap();
|
||||
upstream
|
||||
.commit(Some("HEAD"), &sig, &sig, "second", &tree, &[&head])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Second fetch
|
||||
let (_, hash2): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(&url, work_dir.path(), "test-project", None)
|
||||
.expect("second fetch failed");
|
||||
// Second fetch
|
||||
let (_, hash2): (std::path::PathBuf, String) =
|
||||
fc_evaluator::git::clone_or_fetch(
|
||||
&url,
|
||||
work_dir.path(),
|
||||
"test-project",
|
||||
None,
|
||||
)
|
||||
.expect("second fetch failed");
|
||||
|
||||
assert!(!hash1.is_empty());
|
||||
assert!(!hash2.is_empty());
|
||||
assert!(!hash1.is_empty());
|
||||
assert!(!hash2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_invalid_url_returns_error() {
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
let result = fc_evaluator::git::clone_or_fetch(
|
||||
"file:///nonexistent/repo",
|
||||
work_dir.path(),
|
||||
"bad-proj",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let work_dir = TempDir::new().unwrap();
|
||||
let result = fc_evaluator::git::clone_or_fetch(
|
||||
"file:///nonexistent/repo",
|
||||
work_dir.path(),
|
||||
"bad-proj",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue