treewide: format with nightly rustfmt; auto-fix Clippy lints

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If4fd0511087dbaa65afc56a34d7c2f166a6a6964
This commit is contained in:
raf 2026-02-08 21:18:42 +03:00
commit 3a03cf7b3e
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
26 changed files with 222 additions and 161 deletions

View file

@ -36,7 +36,12 @@ fn expand_path(path: &str) -> String {
if let Some(end) = result[start..].find('}') { if let Some(end) = result[start..].find('}') {
let var_name = &result[start + 2..start + end]; let var_name = &result[start + 2..start + end];
let replacement = std::env::var(var_name).unwrap_or_default(); let replacement = std::env::var(var_name).unwrap_or_default();
result = format!("{}{}{}", &result[..start], replacement, &result[start + end + 1..]); result = format!(
"{}{}{}",
&result[..start],
replacement,
&result[start + end + 1..]
);
} else { } else {
break; break;
} }
@ -112,22 +117,24 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
for decl_jobset in &decl_project.jobsets { for decl_jobset in &decl_project.jobsets {
// Parse state string to JobsetState enum // Parse state string to JobsetState enum
let state = decl_jobset.state.as_ref().map(|s| match s.as_str() { let state = decl_jobset.state.as_ref().map(|s| {
"disabled" => JobsetState::Disabled, match s.as_str() {
"enabled" => JobsetState::Enabled, "disabled" => JobsetState::Disabled,
"one_shot" => JobsetState::OneShot, "enabled" => JobsetState::Enabled,
"one_at_a_time" => JobsetState::OneAtATime, "one_shot" => JobsetState::OneShot,
_ => JobsetState::Enabled, // Default to enabled for unknown values "one_at_a_time" => JobsetState::OneAtATime,
_ => JobsetState::Enabled, // Default to enabled for unknown values
}
}); });
let jobset = repo::jobsets::upsert(pool, CreateJobset { let jobset = repo::jobsets::upsert(pool, CreateJobset {
project_id: project.id, project_id: project.id,
name: decl_jobset.name.clone(), name: decl_jobset.name.clone(),
nix_expression: decl_jobset.nix_expression.clone(), nix_expression: decl_jobset.nix_expression.clone(),
enabled: Some(decl_jobset.enabled), enabled: Some(decl_jobset.enabled),
flake_mode: Some(decl_jobset.flake_mode), flake_mode: Some(decl_jobset.flake_mode),
check_interval: Some(decl_jobset.check_interval), check_interval: Some(decl_jobset.check_interval),
branch: decl_jobset.branch.clone(), branch: decl_jobset.branch.clone(),
scheduling_shares: Some(decl_jobset.scheduling_shares), scheduling_shares: Some(decl_jobset.scheduling_shares),
state, state,
}) })
@ -141,8 +148,12 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
// Sync jobset inputs // Sync jobset inputs
if !decl_jobset.inputs.is_empty() { if !decl_jobset.inputs.is_empty() {
repo::jobset_inputs::sync_for_jobset(pool, jobset.id, &decl_jobset.inputs) repo::jobset_inputs::sync_for_jobset(
.await?; pool,
jobset.id,
&decl_jobset.inputs,
)
.await?;
tracing::info!( tracing::info!(
project = %project.name, project = %project.name,
jobset = %jobset.name, jobset = %jobset.name,
@ -192,9 +203,12 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
// Sync channels // Sync channels
if !decl_project.channels.is_empty() { if !decl_project.channels.is_empty() {
repo::channels::sync_for_project(pool, project.id, &decl_project.channels, |name| { repo::channels::sync_for_project(
jobset_map.get(name).copied() pool,
}) project.id,
&decl_project.channels,
|name| jobset_map.get(name).copied(),
)
.await?; .await?;
tracing::info!( tracing::info!(
project = %project.name, project = %project.name,
@ -202,7 +216,6 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
"Synced declarative channels" "Synced declarative channels"
); );
} }
} }
// Upsert API keys // Upsert API keys
@ -251,11 +264,11 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
if let Some(user) = existing { if let Some(user) = existing {
// Update existing user // Update existing user
let update = crate::models::UpdateUser { let update = crate::models::UpdateUser {
email: Some(decl_user.email.clone()), email: Some(decl_user.email.clone()),
full_name: decl_user.full_name.clone(), full_name: decl_user.full_name.clone(),
password, password,
role: Some(decl_user.role.clone()), role: Some(decl_user.role.clone()),
enabled: Some(decl_user.enabled), enabled: Some(decl_user.enabled),
public_dashboard: None, public_dashboard: None,
}; };
if let Err(e) = repo::users::update(pool, user.id, &update).await { if let Err(e) = repo::users::update(pool, user.id, &update).await {
@ -287,12 +300,12 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
// Set enabled status if false (users are enabled by default) // Set enabled status if false (users are enabled by default)
if !decl_user.enabled if !decl_user.enabled
&& let Err(e) = repo::users::set_enabled(pool, user.id, false).await && let Err(e) = repo::users::set_enabled(pool, user.id, false).await
{ {
tracing::warn!( tracing::warn!(
username = %user.username, username = %user.username,
"Failed to disable declarative user: {e}" "Failed to disable declarative user: {e}"
); );
} }
}, },
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@ -313,8 +326,8 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
if !config.remote_builders.is_empty() { if !config.remote_builders.is_empty() {
repo::remote_builders::sync_all(pool, &config.remote_builders).await?; repo::remote_builders::sync_all(pool, &config.remote_builders).await?;
tracing::info!( tracing::info!(
builders = config.remote_builders.len(), builders = config.remote_builders.len(),
"Synced declarative remote builders" "Synced declarative remote builders"
); );
} }
@ -332,7 +345,9 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
} }
// Get project by name (already exists from earlier upsert) // Get project by name (already exists from earlier upsert)
if let Ok(project) = repo::projects::get_by_name(pool, &decl_project.name).await { if let Ok(project) =
repo::projects::get_by_name(pool, &decl_project.name).await
{
repo::project_members::sync_for_project( repo::project_members::sync_for_project(
pool, pool,
project.id, project.id,

View file

@ -220,7 +220,8 @@ pub struct DeclarativeProject {
/// Declarative notification configuration. /// Declarative notification configuration.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeclarativeNotification { pub struct DeclarativeNotification {
/// Notification type: github_status, email, gitlab_status, gitea_status, run_command /// Notification type: github_status, email, gitlab_status, gitea_status,
/// run_command
pub notification_type: String, pub notification_type: String,
/// Type-specific configuration (JSON object) /// Type-specific configuration (JSON object)
pub config: serde_json::Value, pub config: serde_json::Value,

View file

@ -51,7 +51,7 @@ pub enum CiError {
} }
impl CiError { impl CiError {
#[must_use] #[must_use]
pub fn is_disk_full(&self) -> bool { pub fn is_disk_full(&self) -> bool {
let msg = self.to_string().to_lowercase(); let msg = self.to_string().to_lowercase();
msg.contains("no space left on device") msg.contains("no space left on device")
@ -186,19 +186,19 @@ pub struct DiskSpaceInfo {
impl DiskSpaceInfo { impl DiskSpaceInfo {
/// Check if disk space is critically low (less than 1GB available) /// Check if disk space is critically low (less than 1GB available)
#[must_use] #[must_use]
pub fn is_critical(&self) -> bool { pub fn is_critical(&self) -> bool {
self.available_gb < 1.0 self.available_gb < 1.0
} }
/// Check if disk space is low (less than 5GB available) /// Check if disk space is low (less than 5GB available)
#[must_use] #[must_use]
pub fn is_low(&self) -> bool { pub fn is_low(&self) -> bool {
self.available_gb < 5.0 self.available_gb < 5.0
} }
/// Get a human-readable summary /// Get a human-readable summary
#[must_use] #[must_use]
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
format!( format!(
"Total: {:.1}GB, Free: {:.1}GB ({:.1}%), Available: {:.1}GB", "Total: {:.1}GB, Free: {:.1}GB ({:.1}%), Available: {:.1}GB",

View file

@ -15,13 +15,13 @@ impl LogStorage {
} }
/// Returns the filesystem path where a build's log should be stored /// Returns the filesystem path where a build's log should be stored
#[must_use] #[must_use]
pub fn log_path(&self, build_id: &Uuid) -> PathBuf { pub fn log_path(&self, build_id: &Uuid) -> PathBuf {
self.log_dir.join(format!("{build_id}.log")) self.log_dir.join(format!("{build_id}.log"))
} }
/// Returns the filesystem path for an active (in-progress) build log /// Returns the filesystem path for an active (in-progress) build log
#[must_use] #[must_use]
pub fn log_path_for_active(&self, build_id: &Uuid) -> PathBuf { pub fn log_path_for_active(&self, build_id: &Uuid) -> PathBuf {
self.log_dir.join(format!("{build_id}.active.log")) self.log_dir.join(format!("{build_id}.active.log"))
} }

View file

@ -77,13 +77,13 @@ pub enum JobsetState {
impl JobsetState { impl JobsetState {
/// Returns true if this jobset state allows evaluation. /// Returns true if this jobset state allows evaluation.
#[must_use] #[must_use]
pub const fn is_evaluable(&self) -> bool { pub const fn is_evaluable(&self) -> bool {
matches!(self, Self::Enabled | Self::OneShot | Self::OneAtATime) matches!(self, Self::Enabled | Self::OneShot | Self::OneAtATime)
} }
/// Returns the database string representation of this state. /// Returns the database string representation of this state.
#[must_use] #[must_use]
pub const fn as_str(&self) -> &'static str { pub const fn as_str(&self) -> &'static str {
match self { match self {
Self::Disabled => "disabled", Self::Disabled => "disabled",
@ -339,12 +339,12 @@ pub struct PaginationParams {
} }
impl PaginationParams { impl PaginationParams {
#[must_use] #[must_use]
pub fn limit(&self) -> i64 { pub fn limit(&self) -> i64 {
self.limit.unwrap_or(50).clamp(1, 200) self.limit.unwrap_or(50).clamp(1, 200)
} }
#[must_use] #[must_use]
pub fn offset(&self) -> i64 { pub fn offset(&self) -> i64 {
self.offset.unwrap_or(0).max(0) self.offset.unwrap_or(0).max(0)
} }

View file

@ -95,28 +95,25 @@ pub async fn probe_flake(
base_ref base_ref
}; };
let output = let output = tokio::time::timeout(std::time::Duration::from_mins(1), async {
tokio::time::timeout(std::time::Duration::from_mins(1), async { tokio::process::Command::new("nix")
tokio::process::Command::new("nix") .args([
.args([ "--extra-experimental-features",
"--extra-experimental-features", "nix-command flakes",
"nix-command flakes", "flake",
"flake", "show",
"show", "--json",
"--json", "--no-write-lock-file",
"--no-write-lock-file", &flake_ref,
&flake_ref, ])
]) .output()
.output() .await
.await })
}) .await
.await .map_err(|_| CiError::Timeout("Flake probe timed out after 60s".to_string()))?
.map_err(|_| { .map_err(|e| {
CiError::Timeout("Flake probe timed out after 60s".to_string()) CiError::NixEval(format!("Failed to run nix flake show: {e}"))
})? })?;
.map_err(|e| {
CiError::NixEval(format!("Failed to run nix flake show: {e}"))
})?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);

View file

@ -90,7 +90,9 @@ async fn set_github_status(
build: &Build, build: &Build,
) { ) {
// Parse owner/repo from URL // Parse owner/repo from URL
let (owner, repo) = if let Some(v) = parse_github_repo(repo_url) { v } else { let (owner, repo) = if let Some(v) = parse_github_repo(repo_url) {
v
} else {
warn!("Cannot parse GitHub owner/repo from {repo_url}"); warn!("Cannot parse GitHub owner/repo from {repo_url}");
return; return;
}; };
@ -142,7 +144,9 @@ async fn set_gitea_status(
build: &Build, build: &Build,
) { ) {
// Parse owner/repo from URL (try to extract from the gitea URL) // Parse owner/repo from URL (try to extract from the gitea URL)
let (owner, repo) = if let Some(v) = parse_gitea_repo(repo_url, base_url) { v } else { let (owner, repo) = if let Some(v) = parse_gitea_repo(repo_url, base_url) {
v
} else {
warn!("Cannot parse Gitea owner/repo from {repo_url}"); warn!("Cannot parse Gitea owner/repo from {repo_url}");
return; return;
}; };
@ -191,7 +195,9 @@ async fn set_gitlab_status(
build: &Build, build: &Build,
) { ) {
// Parse project path from URL // Parse project path from URL
let project_path = if let Some(p) = parse_gitlab_project(repo_url, base_url) { p } else { let project_path = if let Some(p) = parse_gitlab_project(repo_url, base_url) {
p
} else {
warn!("Cannot parse GitLab project from {repo_url}"); warn!("Cannot parse GitLab project from {repo_url}");
return; return;
}; };

View file

@ -96,8 +96,8 @@ pub async fn upsert(
) -> Result<Channel> { ) -> Result<Channel> {
sqlx::query_as::<_, Channel>( sqlx::query_as::<_, Channel>(
"INSERT INTO channels (project_id, name, jobset_id) VALUES ($1, $2, $3) \ "INSERT INTO channels (project_id, name, jobset_id) VALUES ($1, $2, $3) \
ON CONFLICT (project_id, name) DO UPDATE SET jobset_id = EXCLUDED.jobset_id \ ON CONFLICT (project_id, name) DO UPDATE SET jobset_id = \
RETURNING *", EXCLUDED.jobset_id RETURNING *",
) )
.bind(project_id) .bind(project_id)
.bind(name) .bind(name)
@ -119,12 +119,14 @@ pub async fn sync_for_project(
let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect(); let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect();
// Delete channels not in declarative config // Delete channels not in declarative config
sqlx::query("DELETE FROM channels WHERE project_id = $1 AND name != ALL($2::text[])") sqlx::query(
.bind(project_id) "DELETE FROM channels WHERE project_id = $1 AND name != ALL($2::text[])",
.bind(&names) )
.execute(pool) .bind(project_id)
.await .bind(&names)
.map_err(CiError::Database)?; .execute(pool)
.await
.map_err(CiError::Database)?;
// Upsert each channel // Upsert each channel
for channel in channels { for channel in channels {

View file

@ -73,12 +73,9 @@ pub async fn upsert(
) -> Result<JobsetInput> { ) -> Result<JobsetInput> {
sqlx::query_as::<_, JobsetInput>( sqlx::query_as::<_, JobsetInput>(
"INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) \ "INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) \
VALUES ($1, $2, $3, $4, $5) \ VALUES ($1, $2, $3, $4, $5) ON CONFLICT (jobset_id, name) DO UPDATE SET \
ON CONFLICT (jobset_id, name) DO UPDATE SET \ input_type = EXCLUDED.input_type, value = EXCLUDED.value, revision = \
input_type = EXCLUDED.input_type, \ EXCLUDED.revision RETURNING *",
value = EXCLUDED.value, \
revision = EXCLUDED.revision \
RETURNING *",
) )
.bind(jobset_id) .bind(jobset_id)
.bind(name) .bind(name)
@ -102,7 +99,8 @@ pub async fn sync_for_jobset(
// Delete inputs not in declarative config // Delete inputs not in declarative config
sqlx::query( sqlx::query(
"DELETE FROM jobset_inputs WHERE jobset_id = $1 AND name != ALL($2::text[])", "DELETE FROM jobset_inputs WHERE jobset_id = $1 AND name != \
ALL($2::text[])",
) )
.bind(jobset_id) .bind(jobset_id)
.bind(&names) .bind(&names)

View file

@ -231,8 +231,8 @@ pub async fn has_running_builds(
} }
/// List jobsets that are due for evaluation based on their `check_interval`. /// List jobsets that are due for evaluation based on their `check_interval`.
/// Returns jobsets where `last_checked_at` is NULL or older than `check_interval` /// Returns jobsets where `last_checked_at` is NULL or older than
/// seconds. /// `check_interval` seconds.
pub async fn list_due_for_eval( pub async fn list_due_for_eval(
pool: &PgPool, pool: &PgPool,
limit: i64, limit: i64,

View file

@ -70,9 +70,9 @@ pub async fn upsert(
) -> Result<NotificationConfig> { ) -> Result<NotificationConfig> {
sqlx::query_as::<_, NotificationConfig>( sqlx::query_as::<_, NotificationConfig>(
"INSERT INTO notification_configs (project_id, notification_type, config, \ "INSERT INTO notification_configs (project_id, notification_type, config, \
enabled) VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, notification_type) \ enabled) VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, \
DO UPDATE SET config = EXCLUDED.config, enabled = EXCLUDED.enabled \ notification_type) DO UPDATE SET config = EXCLUDED.config, enabled = \
RETURNING *", EXCLUDED.enabled RETURNING *",
) )
.bind(project_id) .bind(project_id)
.bind(notification_type) .bind(notification_type)

View file

@ -141,9 +141,7 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(CiError::NotFound(format!( return Err(CiError::NotFound(format!("Project member {id} not found")));
"Project member {id} not found"
)));
} }
Ok(()) Ok(())
} }
@ -199,8 +197,8 @@ pub async fn upsert(
.map_err(|e| CiError::Validation(e.to_string()))?; .map_err(|e| CiError::Validation(e.to_string()))?;
sqlx::query_as::<_, ProjectMember>( sqlx::query_as::<_, ProjectMember>(
"INSERT INTO project_members (project_id, user_id, role) VALUES ($1, $2, $3) \ "INSERT INTO project_members (project_id, user_id, role) VALUES ($1, $2, \
ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role \ $3) ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role \
RETURNING *", RETURNING *",
) )
.bind(project_id) .bind(project_id)

View file

@ -152,9 +152,9 @@ pub async fn upsert(
sqlx::query_as::<_, RemoteBuilder>( sqlx::query_as::<_, RemoteBuilder>(
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, \ "INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, \
speed_factor, supported_features, mandatory_features, enabled, \ speed_factor, supported_features, mandatory_features, enabled, \
public_host_key, ssh_key_file) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, \ public_host_key, ssh_key_file) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, \
$10) ON CONFLICT (name) DO UPDATE SET ssh_uri = EXCLUDED.ssh_uri, systems = \ $9, $10) ON CONFLICT (name) DO UPDATE SET ssh_uri = EXCLUDED.ssh_uri, \
EXCLUDED.systems, max_jobs = EXCLUDED.max_jobs, speed_factor = \ systems = EXCLUDED.systems, max_jobs = EXCLUDED.max_jobs, speed_factor = \
EXCLUDED.speed_factor, supported_features = EXCLUDED.supported_features, \ EXCLUDED.speed_factor, supported_features = EXCLUDED.supported_features, \
mandatory_features = EXCLUDED.mandatory_features, enabled = \ mandatory_features = EXCLUDED.mandatory_features, enabled = \
EXCLUDED.enabled, public_host_key = COALESCE(EXCLUDED.public_host_key, \ EXCLUDED.enabled, public_host_key = COALESCE(EXCLUDED.public_host_key, \

View file

@ -364,7 +364,8 @@ async fn search_evaluations(
} }
} }
// Get count - simple count (full filter support would require building query differently) // Get count - simple count (full filter support would require building query
// differently)
let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations") let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;

View file

@ -94,10 +94,10 @@ pub async fn upsert(
enabled: bool, enabled: bool,
) -> Result<WebhookConfig> { ) -> Result<WebhookConfig> {
sqlx::query_as::<_, WebhookConfig>( sqlx::query_as::<_, WebhookConfig>(
"INSERT INTO webhook_configs (project_id, forge_type, secret_hash, enabled) \ "INSERT INTO webhook_configs (project_id, forge_type, secret_hash, \
VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, forge_type) DO UPDATE SET \ enabled) VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, forge_type) DO \
secret_hash = COALESCE(EXCLUDED.secret_hash, webhook_configs.secret_hash), \ UPDATE SET secret_hash = COALESCE(EXCLUDED.secret_hash, \
enabled = EXCLUDED.enabled RETURNING *", webhook_configs.secret_hash), enabled = EXCLUDED.enabled RETURNING *",
) )
.bind(project_id) .bind(project_id)
.bind(forge_type) .bind(forge_type)
@ -117,7 +117,8 @@ pub async fn sync_for_project(
resolve_secret: impl Fn(&DeclarativeWebhook) -> Option<String>, resolve_secret: impl Fn(&DeclarativeWebhook) -> Option<String>,
) -> Result<()> { ) -> Result<()> {
// Get forge types from declarative config // Get forge types from declarative config
let types: Vec<&str> = webhooks.iter().map(|w| w.forge_type.as_str()).collect(); let types: Vec<&str> =
webhooks.iter().map(|w| w.forge_type.as_str()).collect();
// Delete webhook configs not in declarative config // Delete webhook configs not in declarative config
sqlx::query( sqlx::query(

View file

@ -49,19 +49,19 @@ pub const VALID_PROJECT_ROLES: &[&str] = &[
]; ];
/// Check if a global role is valid /// Check if a global role is valid
#[must_use] #[must_use]
pub fn is_valid_role(role: &str) -> bool { pub fn is_valid_role(role: &str) -> bool {
VALID_ROLES.contains(&role) VALID_ROLES.contains(&role)
} }
/// Check if a project role is valid /// Check if a project role is valid
#[must_use] #[must_use]
pub fn is_valid_project_role(role: &str) -> bool { pub fn is_valid_project_role(role: &str) -> bool {
VALID_PROJECT_ROLES.contains(&role) VALID_PROJECT_ROLES.contains(&role)
} }
/// Get the highest project role (for permission checks) /// Get the highest project role (for permission checks)
#[must_use] #[must_use]
pub fn project_role_level(role: &str) -> i32 { pub fn project_role_level(role: &str) -> i32 {
match role { match role {
PROJECT_ROLE_ADMIN => 3, PROJECT_ROLE_ADMIN => 3,
@ -73,7 +73,7 @@ pub fn project_role_level(role: &str) -> i32 {
/// Check if user has required project permission /// Check if user has required project permission
/// Higher level roles automatically have lower level permissions /// Higher level roles automatically have lower level permissions
#[must_use] #[must_use]
pub fn has_project_permission(user_role: &str, required: &str) -> bool { pub fn has_project_permission(user_role: &str, required: &str) -> bool {
let user_level = project_role_level(user_role); let user_level = project_role_level(user_role);
let required_level = project_role_level(required); let required_level = project_role_level(required);

View file

@ -6,14 +6,14 @@ use regex::Regex;
/// Validate that a path is a valid nix store path. /// Validate that a path is a valid nix store path.
/// Rejects path traversal, overly long paths, and non-store paths. /// Rejects path traversal, overly long paths, and non-store paths.
#[must_use] #[must_use]
pub fn is_valid_store_path(path: &str) -> bool { pub fn is_valid_store_path(path: &str) -> bool {
path.starts_with("/nix/store/") && !path.contains("..") && path.len() < 512 path.starts_with("/nix/store/") && !path.contains("..") && path.len() < 512
} }
/// Validate that a string is a valid nix store hash (32 lowercase alphanumeric /// Validate that a string is a valid nix store hash (32 lowercase alphanumeric
/// chars). /// chars).
#[must_use] #[must_use]
pub fn is_valid_nix_hash(hash: &str) -> bool { pub fn is_valid_nix_hash(hash: &str) -> bool {
hash.len() == 32 hash.len() == 32
&& hash && hash
@ -62,7 +62,8 @@ fn validate_repository_url(url: &str) -> Result<(), String> {
} }
if !VALID_REPO_PREFIXES.iter().any(|p| url.starts_with(p)) { if !VALID_REPO_PREFIXES.iter().any(|p| url.starts_with(p)) {
return Err( return Err(
"repository_url must start with https://, http://, git://, ssh://, or file://" "repository_url must start with https://, http://, git://, ssh://, or \
file://"
.to_string(), .to_string(),
); );
} }
@ -146,7 +147,19 @@ fn validate_forge_type(forge_type: &str) -> Result<(), String> {
// --- Implementations --- // --- Implementations ---
use crate::models::{CreateProject, UpdateProject, CreateJobset, UpdateJobset, CreateEvaluation, CreateBuild, CreateChannel, UpdateChannel, CreateRemoteBuilder, UpdateRemoteBuilder, CreateWebhookConfig}; use crate::models::{
CreateBuild,
CreateChannel,
CreateEvaluation,
CreateJobset,
CreateProject,
CreateRemoteBuilder,
CreateWebhookConfig,
UpdateChannel,
UpdateJobset,
UpdateProject,
UpdateRemoteBuilder,
};
impl Validate for CreateProject { impl Validate for CreateProject {
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {

View file

@ -146,7 +146,7 @@ pub struct SubStep {
/// Parse a single nix internal JSON log line (`@nix {...}`). /// Parse a single nix internal JSON log line (`@nix {...}`).
/// Returns `Some(action, drv_path)` if the line contains a derivation action. /// Returns `Some(action, drv_path)` if the line contains a derivation action.
#[must_use] #[must_use]
pub fn parse_nix_log_line(line: &str) -> Option<(&'static str, String)> { pub fn parse_nix_log_line(line: &str) -> Option<(&'static str, String)> {
let json_str = line.strip_prefix("@nix ")?.trim(); let json_str = line.strip_prefix("@nix ")?.trim();
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?; let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;

View file

@ -31,7 +31,7 @@ pub struct WorkerPool {
impl WorkerPool { impl WorkerPool {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[must_use] #[must_use]
pub fn new( pub fn new(
db_pool: PgPool, db_pool: PgPool,
workers: usize, workers: usize,
@ -476,8 +476,10 @@ async fn run_build(
push_to_cache(&build_result.output_paths, store_uri).await; push_to_cache(&build_result.output_paths, store_uri).await;
} }
let primary_output = let primary_output = build_result
build_result.output_paths.first().map(std::string::String::as_str); .output_paths
.first()
.map(std::string::String::as_str);
repo::builds::complete( repo::builds::complete(
pool, pool,

View file

@ -76,9 +76,7 @@ pub async fn require_api_key(
&& let Some(session) = state.sessions.get(&session_id) && let Some(session) = state.sessions.get(&session_id)
{ {
// Check session expiry (24 hours) // Check session expiry (24 hours)
if session.created_at.elapsed() if session.created_at.elapsed() < std::time::Duration::from_hours(24) {
< std::time::Duration::from_hours(24)
{
// Insert both user and session data // Insert both user and session data
if let Some(ref user) = session.user { if let Some(ref user) = session.user {
request.extensions_mut().insert(user.clone()); request.extensions_mut().insert(user.clone());
@ -98,9 +96,7 @@ pub async fn require_api_key(
&& let Some(session) = state.sessions.get(&session_id) && let Some(session) = state.sessions.get(&session_id)
{ {
// Check session expiry (24 hours) // Check session expiry (24 hours)
if session.created_at.elapsed() if session.created_at.elapsed() < std::time::Duration::from_hours(24) {
< std::time::Duration::from_hours(24)
{
if let Some(ref api_key) = session.api_key { if let Some(ref api_key) = session.api_key {
request.extensions_mut().insert(api_key.clone()); request.extensions_mut().insert(api_key.clone());
} }
@ -222,9 +218,7 @@ pub async fn extract_session(
&& let Some(session) = state.sessions.get(&session_id) && let Some(session) = state.sessions.get(&session_id)
{ {
// Check session expiry // Check session expiry
if session.created_at.elapsed() if session.created_at.elapsed() < std::time::Duration::from_hours(24) {
< std::time::Duration::from_hours(24)
{
if let Some(ref user) = session.user { if let Some(ref user) = session.user {
request.extensions_mut().insert(user.clone()); request.extensions_mut().insert(user.clone());
} }
@ -242,9 +236,7 @@ pub async fn extract_session(
&& let Some(session) = state.sessions.get(&session_id) && let Some(session) = state.sessions.get(&session_id)
{ {
// Check session expiry // Check session expiry
if session.created_at.elapsed() if session.created_at.elapsed() < std::time::Duration::from_hours(24) {
< std::time::Duration::from_hours(24)
{
if let Some(ref api_key) = session.api_key { if let Some(ref api_key) = session.api_key {
request.extensions_mut().insert(api_key.clone()); request.extensions_mut().insert(api_key.clone());
} }

View file

@ -90,8 +90,8 @@ impl IntoResponse for ApiError {
StatusCode::INSUFFICIENT_STORAGE, StatusCode::INSUFFICIENT_STORAGE,
"DISK_FULL", "DISK_FULL",
format!( format!(
"Database error: {e}\n\nDISK SPACE ISSUE:\nThe server is running \ "Database error: {e}\n\nDISK SPACE ISSUE:\nThe server is \
low on disk space." running low on disk space."
), ),
) )
} else { } else {
@ -130,8 +130,8 @@ impl IntoResponse for ApiError {
StatusCode::INSUFFICIENT_STORAGE, StatusCode::INSUFFICIENT_STORAGE,
"DISK_FULL", "DISK_FULL",
format!( format!(
"IO error: {msg}\n\nDISK SPACE ISSUE DETECTED:\nThe server has run \ "IO error: {msg}\n\nDISK SPACE ISSUE DETECTED:\nThe server has \
out of disk space. Please free up space:\n- Run \ run out of disk space. Please free up space:\n- Run \
`nix-collect-garbage -d` to clean the Nix store\n- Clear the \ `nix-collect-garbage -d` to clean the Nix store\n- Clear the \
evaluator work directory: `rm -rf /tmp/fc-evaluator/*`\n- \ evaluator work directory: `rm -rf /tmp/fc-evaluator/*`\n- \
Clear build logs if configured" Clear build logs if configured"

View file

@ -29,7 +29,7 @@ pub struct ApiKeyInfo {
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
} }
#[must_use] #[must_use]
pub fn hash_api_key(key: &str) -> String { pub fn hash_api_key(key: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(key.as_bytes()); hasher.update(key.as_bytes());

View file

@ -68,7 +68,10 @@ async fn narinfo(
}; };
let nar_hash = entry.get("narHash").and_then(|v| v.as_str()).unwrap_or(""); let nar_hash = entry.get("narHash").and_then(|v| v.as_str()).unwrap_or("");
let nar_size = entry.get("narSize").and_then(serde_json::Value::as_u64).unwrap_or(0); let nar_size = entry
.get("narSize")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let store_path = entry let store_path = entry
.get("path") .get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())

View file

@ -7,7 +7,19 @@ use axum::{
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
routing::get, routing::get,
}; };
use fc_common::models::{Build, Evaluation, BuildStatus, EvaluationStatus, ApiKey, Project, Jobset, BuildStep, BuildProduct, Channel, SystemStatus}; use fc_common::models::{
ApiKey,
Build,
BuildProduct,
BuildStatus,
BuildStep,
Channel,
Evaluation,
EvaluationStatus,
Jobset,
Project,
SystemStatus,
};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
@ -699,7 +711,8 @@ async fn evaluations_page(
Ok(js) => { Ok(js) => {
let pname = let pname =
fc_common::repo::projects::get(&state.pool, js.project_id) fc_common::repo::projects::get(&state.pool, js.project_id)
.await.map_or_else(|_| "-".to_string(), |p| p.name); .await
.map_or_else(|_| "-".to_string(), |p| p.name);
(js.name, pname) (js.name, pname)
}, },
Err(_) => ("-".to_string(), "-".to_string()), Err(_) => ("-".to_string(), "-".to_string()),
@ -983,13 +996,17 @@ async fn queue_page(State(state): State<AppState>) -> Html<String> {
} else { } else {
String::new() String::new()
}; };
let builder_name = b.builder_id.and_then(|id| builder_map.get(&id).cloned()); let builder_name =
b.builder_id.and_then(|id| builder_map.get(&id).cloned());
QueueBuildView { QueueBuildView {
id: b.id, id: b.id,
job_name: b.job_name.clone(), job_name: b.job_name.clone(),
system: b.system.clone().unwrap_or_else(|| "unknown".to_string()), system: b.system.clone().unwrap_or_else(|| "unknown".to_string()),
created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(), created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(),
started_at: b.started_at.map(|t| t.format("%H:%M:%S").to_string()).unwrap_or_default(), started_at: b
.started_at
.map(|t| t.format("%H:%M:%S").to_string())
.unwrap_or_default(),
elapsed, elapsed,
priority: b.priority, priority: b.priority,
builder_name, builder_name,
@ -1002,16 +1019,18 @@ async fn queue_page(State(state): State<AppState>) -> Html<String> {
let pending_builds: Vec<QueueBuildView> = pending let pending_builds: Vec<QueueBuildView> = pending
.iter() .iter()
.enumerate() .enumerate()
.map(|(idx, b)| QueueBuildView { .map(|(idx, b)| {
id: b.id, QueueBuildView {
job_name: b.job_name.clone(), id: b.id,
system: b.system.clone().unwrap_or_else(|| "unknown".to_string()), job_name: b.job_name.clone(),
created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(), system: b.system.clone().unwrap_or_else(|| "unknown".to_string()),
started_at: String::new(), created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(),
elapsed: String::new(), started_at: String::new(),
priority: b.priority, elapsed: String::new(),
builder_name: None, priority: b.priority,
queue_pos: (idx + 1) as i64, builder_name: None,
queue_pos: (idx + 1) as i64,
}
}) })
.collect(); .collect();
@ -1153,8 +1172,10 @@ async fn admin_page(
name: k.name, name: k.name,
role: k.role, role: k.role,
created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(), created_at: k.created_at.format("%Y-%m-%d %H:%M").to_string(),
last_used_at: k last_used_at: k.last_used_at.map_or_else(
.last_used_at.map_or_else(|| "Never".to_string(), |t| t.format("%Y-%m-%d %H:%M").to_string()), || "Never".to_string(),
|t| t.format("%Y-%m-%d %H:%M").to_string(),
),
} }
}) })
.collect(); .collect();
@ -1218,7 +1239,9 @@ async fn login_action(
password: password.clone(), password: password.clone(),
}; };
if let Ok(user) = fc_common::repo::users::authenticate(&state.pool, &creds).await { if let Ok(user) =
fc_common::repo::users::authenticate(&state.pool, &creds).await
{
let session_id = Uuid::new_v4().to_string(); let session_id = Uuid::new_v4().to_string();
state state
.sessions .sessions
@ -1269,7 +1292,9 @@ async fn login_action(
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
let key_hash = hex::encode(hasher.finalize()); let key_hash = hex::encode(hasher.finalize());
if let Ok(Some(api_key)) = fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await { if let Ok(Some(api_key)) =
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
{
let session_id = Uuid::new_v4().to_string(); let session_id = Uuid::new_v4().to_string();
state state
.sessions .sessions
@ -1280,7 +1305,8 @@ async fn login_action(
}); });
let cookie = format!( let cookie = format!(
"fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400" "fc_session={session_id}; HttpOnly; SameSite=Strict; Path=/; \
Max-Age=86400"
); );
( (
[(axum::http::header::SET_COOKIE, cookie)], [(axum::http::header::SET_COOKIE, cookie)],
@ -1408,8 +1434,10 @@ async fn users_page(
role: u.role, role: u.role,
user_type: user_type.to_string(), user_type: user_type.to_string(),
enabled: u.enabled, enabled: u.enabled,
last_login_at: u last_login_at: u.last_login_at.map_or_else(
.last_login_at.map_or_else(|| "Never".to_string(), |t| t.format("%Y-%m-%d %H:%M").to_string()), || "Never".to_string(),
|t| t.format("%Y-%m-%d %H:%M").to_string(),
),
} }
}) })
.collect(); .collect();
@ -1455,12 +1483,14 @@ async fn starred_page(
// Get project name // Get project name
let project_name = let project_name =
fc_common::repo::projects::get(&state.pool, s.project_id) fc_common::repo::projects::get(&state.pool, s.project_id)
.await.map_or_else(|_| "-".to_string(), |p| p.name); .await
.map_or_else(|_| "-".to_string(), |p| p.name);
// Get jobset name // Get jobset name
let jobset_name = if let Some(js_id) = s.jobset_id { let jobset_name = if let Some(js_id) = s.jobset_id {
fc_common::repo::jobsets::get(&state.pool, js_id) fc_common::repo::jobsets::get(&state.pool, js_id)
.await.map_or_else(|_| "-".to_string(), |j| j.name) .await
.map_or_else(|_| "-".to_string(), |j| j.name)
} else { } else {
"-".to_string() "-".to_string()
}; };

View file

@ -403,7 +403,9 @@ async fn handle_gitea_push(
.map_err(ApiError)?; .map_err(ApiError)?;
// Fall back to the other type if not found // Fall back to the other type if not found
let webhook_config = if let Some(c) = webhook_config { c } else { let webhook_config = if let Some(c) = webhook_config {
c
} else {
let alt = if forge_type == "gitea" { let alt = if forge_type == "gitea" {
"forgejo" "forgejo"
} else { } else {

View file

@ -17,7 +17,7 @@ pub struct SessionData {
impl SessionData { impl SessionData {
/// Check if the session has admin role /// Check if the session has admin role
#[must_use] #[must_use]
pub fn is_admin(&self) -> bool { pub fn is_admin(&self) -> bool {
if let Some(ref user) = self.user { if let Some(ref user) = self.user {
user.role == "admin" user.role == "admin"
@ -29,7 +29,7 @@ impl SessionData {
} }
/// Check if the session has a specific role /// Check if the session has a specific role
#[must_use] #[must_use]
pub fn has_role(&self, role: &str) -> bool { pub fn has_role(&self, role: &str) -> bool {
if self.is_admin() { if self.is_admin() {
return true; return true;
@ -44,7 +44,7 @@ impl SessionData {
} }
/// Get the display name for the session (username or api key name) /// Get the display name for the session (username or api key name)
#[must_use] #[must_use]
pub fn display_name(&self) -> String { pub fn display_name(&self) -> String {
if let Some(ref user) = self.user { if let Some(ref user) = self.user {
user.username.clone() user.username.clone()
@ -56,7 +56,7 @@ impl SessionData {
} }
/// Check if this is a user session (not just API key) /// Check if this is a user session (not just API key)
#[must_use] #[must_use]
pub const fn is_user_session(&self) -> bool { pub const fn is_user_session(&self) -> bool {
self.user.is_some() self.user.is_some()
} }