diff --git a/Cargo.lock b/Cargo.lock index 35fbfbb..4e2cebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,7 @@ dependencies = [ "sha2", "sqlx", "subtle", + "tar", "thiserror 2.0.18", "tokio", "tokio-util", @@ -921,6 +922,18 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "xz2", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", ] [[package]] @@ -1685,6 +1698,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2992,6 +3016,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -3992,6 +4027,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yaml-rust2" version = "0.10.4" diff --git a/Cargo.toml b/Cargo.toml index b521339..8122f1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ regex = "1.12.3" reqwest = { version = "0.13.2", default-features = false, features = [ "json", "rustls" ] } serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" +tar = "0.4" sha2 = "0.10.9" sqlx = { version = "0.8.6", features = [ "runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate" ] } subtle = "2.6.1" @@ -63,6 +64,7 @@ tower-http = { version = "0.6.8", features = [ "cors", "trace", "limit", "fs", " tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = [ "env-filter", "json" ] } urlencoding = "2.1.3" +xz2 = "0.1" uuid = { version = "1.18.1", features = [ "v4", "serde" ] } [profile.release] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 90696e2..7020716 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -26,6 +26,7 @@ serde_json.workspace = true sha2.workspace = true sqlx.workspace = true subtle.workspace = true +tar.workspace = true thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true @@ -34,6 +35,7 @@ tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true uuid.workspace = true +xz2.workspace = true # Our crates fc-common.workspace = true diff --git a/crates/server/src/routes/builds.rs b/crates/server/src/routes/builds.rs index d9cace7..ae5839a 100644 --- a/crates/server/src/routes/builds.rs +++ b/crates/server/src/routes/builds.rs @@ -5,7 +5,7 @@ use axum::{ extract::{Path, Query, State}, http::{Extensions, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{get, post, put}, }; use fc_common::{ Build, @@ -302,6 +302,24 @@ async fn download_build_product( } } +async fn set_keep_flag( + _auth: crate::auth_middleware::RequireAdmin, + State(state): State, + Path((id, value)): Path<(Uuid, bool)>, +) -> Result, ApiError> { + let build = fc_common::repo::builds::set_keep(&state.pool, id, value) + .await + .map_err(ApiError)?; + + tracing::info!( + build_id = %id, + keep = value, + "Build keep flag updated" + ); + + Ok(Json(build)) +} + pub fn router() -> Router { Router::new() .route("/builds", get(list_builds)) @@ -311,6 +329,7 @@ pub fn router() -> Router { .route("/builds/{id}/cancel", post(cancel_build)) .route("/builds/{id}/restart", post(restart_build)) .route("/builds/{id}/bump", post(bump_build)) + .route("/builds/{id}/keep/{value}", put(set_keep_flag)) .route("/builds/{id}/steps", get(list_build_steps)) .route("/builds/{id}/products", get(list_build_products)) .route( diff --git a/crates/server/src/routes/channels.rs b/crates/server/src/routes/channels.rs index 2e72f3a..f89aa02 100644 --- a/crates/server/src/routes/channels.rs +++ b/crates/server/src/routes/channels.rs @@ -1,12 +1,17 @@ +use std::fmt::Write; + use axum::{ Json, Router, + body::Body, extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, routing::{get, post}, }; use fc_common::{ Validate, - models::{Channel, CreateChannel}, + models::{BuildStatus, Channel, CreateChannel}, }; use uuid::Uuid; @@ -102,10 +107,129 @@ async fn promote_channel( Ok(Json(channel)) } +/// Generate and serve `nixexprs.tar.xz` for Nix channel compatibility. +/// Contains a `default.nix` with fake derivations pointing at store paths, +/// enabling `nix-channel --add `. +async fn nixexprs_tarball( + State(state): State, + Path(id): Path, +) -> Result { + let channel = fc_common::repo::channels::get(&state.pool, id) + .await + .map_err(ApiError)?; + + let evaluation_id = channel.current_evaluation_id.ok_or_else(|| { + ApiError(fc_common::CiError::NotFound( + "Channel has no current evaluation".to_string(), + )) + })?; + + let builds = + fc_common::repo::builds::list_for_evaluation(&state.pool, evaluation_id) + .await + .map_err(ApiError)?; + + let succeeded: Vec<_> = builds + .iter() + .filter(|b| b.status == BuildStatus::Succeeded) + .collect(); + + if succeeded.is_empty() { + return Err(ApiError(fc_common::CiError::NotFound( + "No succeeded builds in current evaluation".to_string(), + ))); + } + + // Generate default.nix + let approx_size = 256 + succeeded.len() * 200; + let mut nix_src = String::with_capacity(approx_size); + let _ = writeln!(nix_src, "{{ system ? builtins.currentSystem }}:"); + let _ = writeln!(nix_src, "let"); + let _ = writeln!(nix_src, " mkFakeDerivation = attrs:"); + let _ = writeln!( + nix_src, + " let d = derivation (attrs // {{ builder = \"builtin:fetchurl\"; \ + preferLocalBuild = true; }});" + ); + let _ = writeln!( + nix_src, + " in d // {{ type = \"derivation\"; inherit (d) outPath drvPath name \ + system; outputSpecified = true; }};" + ); + let _ = writeln!(nix_src, "in {{"); + + for build in &succeeded { + let output_path = match &build.build_output_path { + Some(p) => p, + None => continue, + }; + let system = build.system.as_deref().unwrap_or("x86_64-linux"); + // Sanitize job_name for use as a Nix attribute (replace dots/slashes) + let attr_name = build.job_name.replace('.', "-").replace('/', "-"); + let _ = writeln!( + nix_src, + " \"{attr_name}\" = mkFakeDerivation {{ name = \"{}\"; system = \ + \"{system}\"; outPath = \"{output_path}\"; }};", + build.job_name.replace('"', "\\\""), + ); + } + + let _ = writeln!(nix_src, "}}"); + + // Build tar.xz archive in memory + let xz_data = + tokio::task::spawn_blocking(move || -> Result, String> { + let mut xz_buf = Vec::new(); + { + let xz_writer = xz2::write::XzEncoder::new(&mut xz_buf, 6); + let mut tar_builder = tar::Builder::new(xz_writer); + + let nix_bytes = nix_src.as_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(nix_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + + tar_builder + .append_data(&mut header, "default.nix", nix_bytes) + .map_err(|e| format!("Failed to append to tar: {e}"))?; + + let xz_writer = tar_builder + .into_inner() + .map_err(|e| format!("Failed to finish tar: {e}"))?; + xz_writer + .finish() + .map_err(|e| format!("Failed to finish xz: {e}"))?; + } + Ok(xz_buf) + }) + .await + .map_err(|e| { + ApiError(fc_common::CiError::Build(format!("Task join error: {e}"))) + })? + .map_err(|e| ApiError(fc_common::CiError::Build(e)))?; + + Ok( + ( + StatusCode::OK, + [ + ("content-type", "application/x-xz"), + ( + "content-disposition", + "attachment; filename=\"nixexprs.tar.xz\"", + ), + ], + Body::from(xz_data), + ) + .into_response(), + ) +} + pub fn router() -> Router { Router::new() .route("/channels", get(list_channels).post(create_channel)) .route("/channels/{id}", get(get_channel).delete(delete_channel)) + .route("/channels/{id}/nixexprs.tar.xz", get(nixexprs_tarball)) .route( "/channels/{channel_id}/promote/{eval_id}", post(promote_channel),