diff --git a/Cargo.toml b/Cargo.toml index 04f5c88..17a2197 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,10 @@ style = { level = "warn", priority = -1 } # enable those to keep our sanity. absolute_paths = "allow" arbitrary_source_item_ordering = "allow" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" clone_on_ref_ptr = "warn" dbg_macro = "warn" empty_drop = "warn" @@ -106,10 +110,6 @@ single_call_fn = "allow" std_instead_of_core = "allow" too_long_first_doc_paragraph = "allow" too_many_lines = "allow" -cast_possible_truncation = "allow" -cast_possible_wrap = "allow" -cast_precision_loss = "allow" -cast_sign_loss = "allow" undocumented_unsafe_blocks = "warn" unnecessary_safety_comment = "warn" unused_result_ok = "warn" diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index a228fa9..1d1a0a0 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -487,6 +487,14 @@ pub struct StarredJob { pub created_at: DateTime, } +/// Normalized build output (Hydra-compatible) +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct BuildOutput { + pub build: Uuid, + pub name: String, + pub path: Option, +} + /// Project membership for per-project permissions #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct ProjectMember { diff --git a/crates/common/src/repo/build_outputs.rs b/crates/common/src/repo/build_outputs.rs new file mode 100644 index 0000000..558cbdc --- /dev/null +++ b/crates/common/src/repo/build_outputs.rs @@ -0,0 +1,92 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::{ + error::{CiError, Result}, + models::BuildOutput, +}; + +/// Create a build output record. +/// +/// # Errors +/// +/// Returns error if database insert fails or if a duplicate (build, name) pair +/// exists. +pub async fn create( + pool: &PgPool, + build: Uuid, + name: &str, + path: Option<&str>, +) -> Result { + sqlx::query_as::<_, BuildOutput>( + "INSERT INTO build_outputs (build, name, path) VALUES ($1, $2, $3) \ + RETURNING *", + ) + .bind(build) + .bind(name) + .bind(path) + .fetch_one(pool) + .await + .map_err(|e| { + if let sqlx::Error::Database(db_err) = &e { + if db_err.is_unique_violation() { + return CiError::Conflict(format!( + "Build output with name '{name}' already exists for build {build}" + )); + } + } + CiError::Database(e) + }) +} + +/// List all build outputs for a build, ordered by name. +/// +/// # Errors +/// +/// Returns error if database query fails. +pub async fn list_for_build( + pool: &PgPool, + build: Uuid, +) -> Result> { + sqlx::query_as::<_, BuildOutput>( + "SELECT * FROM build_outputs WHERE build = $1 ORDER BY name ASC", + ) + .bind(build) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// Find build outputs by path. +/// +/// # Errors +/// +/// Returns error if database query fails. +pub async fn find_by_path( + pool: &PgPool, + path: &str, +) -> Result> { + sqlx::query_as::<_, BuildOutput>( + "SELECT * FROM build_outputs WHERE path = $1 ORDER BY build, name", + ) + .bind(path) + .fetch_all(pool) + .await + .map_err(CiError::Database) +} + +/// Delete all build outputs for a build. +/// +/// # Errors +/// +/// Returns error if database query fails. +pub async fn delete_for_build(pool: &PgPool, build: Uuid) -> Result { + let result = + sqlx::query("DELETE FROM build_outputs WHERE build = $1") + .bind(build) + .execute(pool) + .await + .map_err(CiError::Database)?; + + Ok(result.rows_affected()) +} diff --git a/crates/common/src/repo/builds.rs b/crates/common/src/repo/builds.rs index d3619cf..86e6d0b 100644 --- a/crates/common/src/repo/builds.rs +++ b/crates/common/src/repo/builds.rs @@ -512,3 +512,21 @@ pub async fn set_builder( .map_err(CiError::Database)?; Ok(()) } + +/// Delete a build by ID. +/// +/// # Errors +/// +/// Returns error if database query fails or build not found. +pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM builds WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(CiError::NotFound(format!("Build {id} not found"))); + } + + Ok(()) +} diff --git a/crates/common/src/repo/mod.rs b/crates/common/src/repo/mod.rs index df7da2a..335b5df 100644 --- a/crates/common/src/repo/mod.rs +++ b/crates/common/src/repo/mod.rs @@ -1,6 +1,7 @@ pub mod api_keys; pub mod build_dependencies; pub mod build_metrics; +pub mod build_outputs; pub mod build_products; pub mod build_steps; pub mod builds; diff --git a/crates/common/tests/repo_tests.rs b/crates/common/tests/repo_tests.rs index 959f897..50f6a0d 100644 --- a/crates/common/tests/repo_tests.rs +++ b/crates/common/tests/repo_tests.rs @@ -814,3 +814,98 @@ async fn test_dedup_by_drv_path() { // Cleanup repo::projects::delete(&pool, project.id).await.ok(); } + +#[tokio::test] +async fn test_build_outputs_crud() { + let Some(pool) = get_pool().await else { + return; + }; + + // Create project, jobset, evaluation, build + let project = create_test_project(&pool, "test-project").await; + let jobset = create_test_jobset(&pool, project.id).await; + let eval = create_test_eval(&pool, jobset.id).await; + let build = create_test_build( + &pool, + eval.id, + "test-job", + "/nix/store/test.drv", + None, + ) + .await; + + // Create outputs + let _out1 = repo::build_outputs::create( + &pool, + build.id, + "out", + Some("/nix/store/abc-result"), + ) + .await + .expect("create output 1"); + + let _out2 = repo::build_outputs::create( + &pool, + build.id, + "dev", + Some("/nix/store/def-result-dev"), + ) + .await + .expect("create output 2"); + + // List outputs for build + let outputs = repo::build_outputs::list_for_build(&pool, build.id) + .await + .expect("list outputs"); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].name, "dev"); // Alphabetical order + assert_eq!(outputs[1].name, "out"); + + // Find by path + let found = repo::build_outputs::find_by_path(&pool, "/nix/store/abc-result") + .await + .expect("find by path"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].build, build.id); + assert_eq!(found[0].name, "out"); + + // Cleanup + repo::projects::delete(&pool, project.id).await.ok(); +} + +#[tokio::test] +async fn test_build_outputs_cascade_delete() { + let Some(pool) = get_pool().await else { + return; + }; + + let project = create_test_project(&pool, "test-project").await; + let jobset = create_test_jobset(&pool, project.id).await; + let eval = create_test_eval(&pool, jobset.id).await; + let build = create_test_build( + &pool, + eval.id, + "test-job", + "/nix/store/test.drv", + None, + ) + .await; + + repo::build_outputs::create(&pool, build.id, "out", Some("/nix/store/abc")) + .await + .expect("create output"); + + // Delete build + repo::builds::delete(&pool, build.id) + .await + .expect("delete build"); + + // Verify outputs cascade deleted + let outputs = repo::build_outputs::list_for_build(&pool, build.id) + .await + .expect("list outputs after delete"); + assert_eq!(outputs.len(), 0); + + // Cleanup + repo::projects::delete(&pool, project.id).await.ok(); +}