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-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
name = "fc-common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
git2.workspace = true
|
||||
tracing.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
git2.workspace = true
|
||||
hex.workspace = true
|
||||
lettre.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
config.workspace = true
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
lettre.workspace = true
|
||||
regex.workspace = true
|
||||
uuid.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@
|
|||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::DeclarativeConfig;
|
||||
use crate::error::Result;
|
||||
use crate::models::{CreateJobset, CreateProject};
|
||||
use crate::repo;
|
||||
use crate::{
|
||||
config::DeclarativeConfig,
|
||||
error::Result,
|
||||
models::{CreateJobset, CreateProject},
|
||||
repo,
|
||||
};
|
||||
|
||||
/// Bootstrap declarative configuration into the database.
|
||||
///
|
||||
|
|
@ -17,79 +19,74 @@ use crate::repo;
|
|||
/// produces the same database state. It upserts (insert or update) all
|
||||
/// configured projects, jobsets, and API keys.
|
||||
pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
||||
if config.projects.is_empty() && config.api_keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if config.projects.is_empty() && config.api_keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let n_projects = config.projects.len();
|
||||
let n_jobsets: usize = config.projects.iter().map(|p| p.jobsets.len()).sum();
|
||||
let n_keys = config.api_keys.len();
|
||||
let n_projects = config.projects.len();
|
||||
let n_jobsets: usize = config.projects.iter().map(|p| p.jobsets.len()).sum();
|
||||
let n_keys = config.api_keys.len();
|
||||
|
||||
tracing::info!(
|
||||
projects = n_projects,
|
||||
jobsets = n_jobsets,
|
||||
api_keys = n_keys,
|
||||
"Bootstrapping declarative configuration"
|
||||
);
|
||||
|
||||
// Upsert projects and their jobsets
|
||||
for decl_project in &config.projects {
|
||||
let project = repo::projects::upsert(pool, CreateProject {
|
||||
name: decl_project.name.clone(),
|
||||
repository_url: decl_project.repository_url.clone(),
|
||||
description: decl_project.description.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
projects = n_projects,
|
||||
jobsets = n_jobsets,
|
||||
api_keys = n_keys,
|
||||
"Bootstrapping declarative configuration"
|
||||
project = %project.name,
|
||||
id = %project.id,
|
||||
"Upserted declarative project"
|
||||
);
|
||||
|
||||
// Upsert projects and their jobsets
|
||||
for decl_project in &config.projects {
|
||||
let project = repo::projects::upsert(
|
||||
pool,
|
||||
CreateProject {
|
||||
name: decl_project.name.clone(),
|
||||
repository_url: decl_project.repository_url.clone(),
|
||||
description: decl_project.description.clone(),
|
||||
},
|
||||
)
|
||||
for decl_jobset in &decl_project.jobsets {
|
||||
let jobset = repo::jobsets::upsert(pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: decl_jobset.name.clone(),
|
||||
nix_expression: decl_jobset.nix_expression.clone(),
|
||||
enabled: Some(decl_jobset.enabled),
|
||||
flake_mode: Some(decl_jobset.flake_mode),
|
||||
check_interval: Some(decl_jobset.check_interval),
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
project = %project.name,
|
||||
jobset = %jobset.name,
|
||||
"Upserted declarative jobset"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert API keys
|
||||
for decl_key in &config.api_keys {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(decl_key.key.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let api_key =
|
||||
repo::api_keys::upsert(pool, &decl_key.name, &key_hash, &decl_key.role)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
project = %project.name,
|
||||
id = %project.id,
|
||||
"Upserted declarative project"
|
||||
);
|
||||
tracing::info!(
|
||||
name = %api_key.name,
|
||||
role = %api_key.role,
|
||||
"Upserted declarative API key"
|
||||
);
|
||||
}
|
||||
|
||||
for decl_jobset in &decl_project.jobsets {
|
||||
let jobset = repo::jobsets::upsert(
|
||||
pool,
|
||||
CreateJobset {
|
||||
project_id: project.id,
|
||||
name: decl_jobset.name.clone(),
|
||||
nix_expression: decl_jobset.nix_expression.clone(),
|
||||
enabled: Some(decl_jobset.enabled),
|
||||
flake_mode: Some(decl_jobset.flake_mode),
|
||||
check_interval: Some(decl_jobset.check_interval),
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
project = %project.name,
|
||||
jobset = %jobset.name,
|
||||
"Upserted declarative jobset"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert API keys
|
||||
for decl_key in &config.api_keys {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(decl_key.key.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let api_key =
|
||||
repo::api_keys::upsert(pool, &decl_key.name, &key_hash, &decl_key.role).await?;
|
||||
|
||||
tracing::info!(
|
||||
name = %api_key.name,
|
||||
role = %api_key.role,
|
||||
"Upserted declarative API key"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Declarative bootstrap complete");
|
||||
Ok(())
|
||||
tracing::info!("Declarative bootstrap complete");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,452 +7,458 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
pub database: DatabaseConfig,
|
||||
pub server: ServerConfig,
|
||||
pub evaluator: EvaluatorConfig,
|
||||
pub queue_runner: QueueRunnerConfig,
|
||||
pub gc: GcConfig,
|
||||
pub logs: LogConfig,
|
||||
pub notifications: NotificationsConfig,
|
||||
pub cache: CacheConfig,
|
||||
pub signing: SigningConfig,
|
||||
#[serde(default)]
|
||||
pub cache_upload: CacheUploadConfig,
|
||||
pub tracing: TracingConfig,
|
||||
#[serde(default)]
|
||||
pub declarative: DeclarativeConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub server: ServerConfig,
|
||||
pub evaluator: EvaluatorConfig,
|
||||
pub queue_runner: QueueRunnerConfig,
|
||||
pub gc: GcConfig,
|
||||
pub logs: LogConfig,
|
||||
pub notifications: NotificationsConfig,
|
||||
pub cache: CacheConfig,
|
||||
pub signing: SigningConfig,
|
||||
#[serde(default)]
|
||||
pub cache_upload: CacheUploadConfig,
|
||||
pub tracing: TracingConfig,
|
||||
#[serde(default)]
|
||||
pub declarative: DeclarativeConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub min_connections: u32,
|
||||
pub connect_timeout: u64,
|
||||
pub idle_timeout: u64,
|
||||
pub max_lifetime: u64,
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub min_connections: u32,
|
||||
pub connect_timeout: u64,
|
||||
pub idle_timeout: u64,
|
||||
pub max_lifetime: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub request_timeout: u64,
|
||||
pub max_body_size: usize,
|
||||
pub api_key: Option<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub cors_permissive: bool,
|
||||
pub rate_limit_rps: Option<u64>,
|
||||
pub rate_limit_burst: Option<u32>,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub request_timeout: u64,
|
||||
pub max_body_size: usize,
|
||||
pub api_key: Option<String>,
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub cors_permissive: bool,
|
||||
pub rate_limit_rps: Option<u64>,
|
||||
pub rate_limit_burst: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct EvaluatorConfig {
|
||||
pub poll_interval: u64,
|
||||
pub git_timeout: u64,
|
||||
pub nix_timeout: u64,
|
||||
pub max_concurrent_evals: usize,
|
||||
pub work_dir: PathBuf,
|
||||
pub restrict_eval: bool,
|
||||
pub allow_ifd: bool,
|
||||
pub poll_interval: u64,
|
||||
pub git_timeout: u64,
|
||||
pub nix_timeout: u64,
|
||||
pub max_concurrent_evals: usize,
|
||||
pub work_dir: PathBuf,
|
||||
pub restrict_eval: bool,
|
||||
pub allow_ifd: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueueRunnerConfig {
|
||||
pub workers: usize,
|
||||
pub poll_interval: u64,
|
||||
pub build_timeout: u64,
|
||||
pub work_dir: PathBuf,
|
||||
pub workers: usize,
|
||||
pub poll_interval: u64,
|
||||
pub build_timeout: u64,
|
||||
pub work_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GcConfig {
|
||||
pub gc_roots_dir: PathBuf,
|
||||
pub enabled: bool,
|
||||
pub max_age_days: u64,
|
||||
pub cleanup_interval: u64,
|
||||
pub gc_roots_dir: PathBuf,
|
||||
pub enabled: bool,
|
||||
pub max_age_days: u64,
|
||||
pub cleanup_interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogConfig {
|
||||
pub log_dir: PathBuf,
|
||||
pub compress: bool,
|
||||
pub log_dir: PathBuf,
|
||||
pub compress: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct NotificationsConfig {
|
||||
pub run_command: Option<String>,
|
||||
pub github_token: Option<String>,
|
||||
pub gitea_url: Option<String>,
|
||||
pub gitea_token: Option<String>,
|
||||
pub email: Option<EmailConfig>,
|
||||
pub run_command: Option<String>,
|
||||
pub github_token: Option<String>,
|
||||
pub gitea_url: Option<String>,
|
||||
pub gitea_token: Option<String>,
|
||||
pub email: Option<EmailConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EmailConfig {
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_user: Option<String>,
|
||||
pub smtp_password: Option<String>,
|
||||
pub from_address: String,
|
||||
pub to_addresses: Vec<String>,
|
||||
pub tls: bool,
|
||||
pub on_failure_only: bool,
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_user: Option<String>,
|
||||
pub smtp_password: Option<String>,
|
||||
pub from_address: String,
|
||||
pub to_addresses: Vec<String>,
|
||||
pub tls: bool,
|
||||
pub on_failure_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheConfig {
|
||||
pub enabled: bool,
|
||||
pub secret_key_file: Option<PathBuf>,
|
||||
pub enabled: bool,
|
||||
pub secret_key_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct SigningConfig {
|
||||
pub enabled: bool,
|
||||
pub key_file: Option<PathBuf>,
|
||||
pub enabled: bool,
|
||||
pub key_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct CacheUploadConfig {
|
||||
pub enabled: bool,
|
||||
pub store_uri: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub store_uri: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
/// Declarative project/jobset/api-key definitions.
|
||||
/// These are upserted on server startup, enabling fully declarative operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct DeclarativeConfig {
|
||||
pub projects: Vec<DeclarativeProject>,
|
||||
pub api_keys: Vec<DeclarativeApiKey>,
|
||||
pub projects: Vec<DeclarativeProject>,
|
||||
pub api_keys: Vec<DeclarativeApiKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeclarativeProject {
|
||||
pub name: String,
|
||||
pub repository_url: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jobsets: Vec<DeclarativeJobset>,
|
||||
pub name: String,
|
||||
pub repository_url: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jobsets: Vec<DeclarativeJobset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeclarativeJobset {
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub flake_mode: bool,
|
||||
#[serde(default = "default_check_interval")]
|
||||
pub check_interval: i32,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub flake_mode: bool,
|
||||
#[serde(default = "default_check_interval")]
|
||||
pub check_interval: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeclarativeApiKey {
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
#[serde(default = "default_role")]
|
||||
pub role: String,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
#[serde(default = "default_role")]
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
true
|
||||
}
|
||||
|
||||
fn default_check_interval() -> i32 {
|
||||
60
|
||||
60
|
||||
}
|
||||
|
||||
fn default_role() -> String {
|
||||
"admin".to_string()
|
||||
"admin".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TracingConfig {
|
||||
pub level: String,
|
||||
pub format: String,
|
||||
pub show_targets: bool,
|
||||
pub show_timestamps: bool,
|
||||
pub level: String,
|
||||
pub format: String,
|
||||
pub show_targets: bool,
|
||||
pub show_timestamps: bool,
|
||||
}
|
||||
|
||||
impl Default for TracingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: "info".to_string(),
|
||||
format: "compact".to_string(),
|
||||
show_targets: true,
|
||||
show_timestamps: true,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
level: "info".to_string(),
|
||||
format: "compact".to_string(),
|
||||
show_targets: true,
|
||||
show_timestamps: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "postgresql://fc_ci:password@localhost/fc_ci".to_string(),
|
||||
max_connections: 20,
|
||||
min_connections: 5,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
url: "postgresql://fc_ci:password@localhost/fc_ci"
|
||||
.to_string(),
|
||||
max_connections: 20,
|
||||
min_connections: 5,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseConfig {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.url.is_empty() {
|
||||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
}
|
||||
|
||||
if !self.url.starts_with("postgresql://") && !self.url.starts_with("postgres://") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database URL must start with postgresql:// or postgres://"
|
||||
));
|
||||
}
|
||||
|
||||
if self.max_connections == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Max database connections must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
if self.min_connections > self.max_connections {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Min database connections cannot exceed max connections"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.url.is_empty() {
|
||||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
}
|
||||
|
||||
if !self.url.starts_with("postgresql://")
|
||||
&& !self.url.starts_with("postgres://")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database URL must start with postgresql:// or postgres://"
|
||||
));
|
||||
}
|
||||
|
||||
if self.max_connections == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Max database connections must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
if self.min_connections > self.max_connections {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Min database connections cannot exceed max connections"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
request_timeout: 30,
|
||||
max_body_size: 10 * 1024 * 1024, // 10MB
|
||||
api_key: None,
|
||||
allowed_origins: Vec::new(),
|
||||
cors_permissive: false,
|
||||
rate_limit_rps: None,
|
||||
rate_limit_burst: None,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
request_timeout: 30,
|
||||
max_body_size: 10 * 1024 * 1024, // 10MB
|
||||
api_key: None,
|
||||
allowed_origins: Vec::new(),
|
||||
cors_permissive: false,
|
||||
rate_limit_rps: None,
|
||||
rate_limit_burst: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EvaluatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval: 60,
|
||||
git_timeout: 600,
|
||||
nix_timeout: 1800,
|
||||
max_concurrent_evals: 4,
|
||||
work_dir: PathBuf::from("/tmp/fc-evaluator"),
|
||||
restrict_eval: true,
|
||||
allow_ifd: false,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval: 60,
|
||||
git_timeout: 600,
|
||||
nix_timeout: 1800,
|
||||
max_concurrent_evals: 4,
|
||||
work_dir: PathBuf::from("/tmp/fc-evaluator"),
|
||||
restrict_eval: true,
|
||||
allow_ifd: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QueueRunnerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workers: 4,
|
||||
poll_interval: 5,
|
||||
build_timeout: 3600,
|
||||
work_dir: PathBuf::from("/tmp/fc-queue-runner"),
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workers: 4,
|
||||
poll_interval: 5,
|
||||
build_timeout: 3600,
|
||||
work_dir: PathBuf::from("/tmp/fc-queue-runner"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GcConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gc_roots_dir: PathBuf::from("/nix/var/nix/gcroots/per-user/fc/fc-roots"),
|
||||
enabled: true,
|
||||
max_age_days: 30,
|
||||
cleanup_interval: 3600,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gc_roots_dir: PathBuf::from(
|
||||
"/nix/var/nix/gcroots/per-user/fc/fc-roots",
|
||||
),
|
||||
enabled: true,
|
||||
max_age_days: 30,
|
||||
cleanup_interval: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LogConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_dir: PathBuf::from("/var/lib/fc/logs"),
|
||||
compress: false,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_dir: PathBuf::from("/var/lib/fc/logs"),
|
||||
compress: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Default for CacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
secret_key_file: None,
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
secret_key_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let mut settings = config_crate::Config::builder();
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let mut settings = config_crate::Config::builder();
|
||||
|
||||
// Load default configuration
|
||||
settings = settings.add_source(config_crate::Config::try_from(&Self::default())?);
|
||||
// Load default configuration
|
||||
settings =
|
||||
settings.add_source(config_crate::Config::try_from(&Self::default())?);
|
||||
|
||||
// Load from config file if it exists
|
||||
if let Ok(config_path) = std::env::var("FC_CONFIG_FILE") {
|
||||
if std::path::Path::new(&config_path).exists() {
|
||||
settings = settings.add_source(config_crate::File::with_name(&config_path));
|
||||
}
|
||||
} else if std::path::Path::new("fc.toml").exists() {
|
||||
settings = settings.add_source(config_crate::File::with_name("fc").required(false));
|
||||
}
|
||||
|
||||
// Load from environment variables with FC_ prefix (highest priority)
|
||||
settings = settings.add_source(
|
||||
config_crate::Environment::with_prefix("FC")
|
||||
.separator("__")
|
||||
.try_parsing(true),
|
||||
);
|
||||
|
||||
let config = settings.build()?.try_deserialize::<Self>()?;
|
||||
|
||||
// Validate configuration
|
||||
config.validate()?;
|
||||
|
||||
Ok(config)
|
||||
// Load from config file if it exists
|
||||
if let Ok(config_path) = std::env::var("FC_CONFIG_FILE") {
|
||||
if std::path::Path::new(&config_path).exists() {
|
||||
settings =
|
||||
settings.add_source(config_crate::File::with_name(&config_path));
|
||||
}
|
||||
} else if std::path::Path::new("fc.toml").exists() {
|
||||
settings = settings
|
||||
.add_source(config_crate::File::with_name("fc").required(false));
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
// Validate database URL
|
||||
if self.database.url.is_empty() {
|
||||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
}
|
||||
// Load from environment variables with FC_ prefix (highest priority)
|
||||
settings = settings.add_source(
|
||||
config_crate::Environment::with_prefix("FC")
|
||||
.separator("__")
|
||||
.try_parsing(true),
|
||||
);
|
||||
|
||||
if !self.database.url.starts_with("postgresql://")
|
||||
&& !self.database.url.starts_with("postgres://")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database URL must start with postgresql:// or postgres://"
|
||||
));
|
||||
}
|
||||
let config = settings.build()?.try_deserialize::<Self>()?;
|
||||
|
||||
// Validate connection pool settings
|
||||
if self.database.max_connections == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Max database connections must be greater than 0"
|
||||
));
|
||||
}
|
||||
// Validate configuration
|
||||
config.validate()?;
|
||||
|
||||
if self.database.min_connections > self.database.max_connections {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Min database connections cannot exceed max connections"
|
||||
));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
// Validate server settings
|
||||
if self.server.port == 0 {
|
||||
return Err(anyhow::anyhow!("Server port must be greater than 0"));
|
||||
}
|
||||
|
||||
// Validate evaluator settings
|
||||
if self.evaluator.poll_interval == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Evaluator poll interval must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate queue runner settings
|
||||
if self.queue_runner.workers == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Queue runner workers must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate GC config
|
||||
if self.gc.enabled && self.gc.gc_roots_dir.as_os_str().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"GC roots directory cannot be empty when GC is enabled"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate log config
|
||||
if self.logs.log_dir.as_os_str().is_empty() {
|
||||
return Err(anyhow::anyhow!("Log directory cannot be empty"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
// Validate database URL
|
||||
if self.database.url.is_empty() {
|
||||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
}
|
||||
|
||||
if !self.database.url.starts_with("postgresql://")
|
||||
&& !self.database.url.starts_with("postgres://")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database URL must start with postgresql:// or postgres://"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate connection pool settings
|
||||
if self.database.max_connections == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Max database connections must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
if self.database.min_connections > self.database.max_connections {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Min database connections cannot exceed max connections"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate server settings
|
||||
if self.server.port == 0 {
|
||||
return Err(anyhow::anyhow!("Server port must be greater than 0"));
|
||||
}
|
||||
|
||||
// Validate evaluator settings
|
||||
if self.evaluator.poll_interval == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Evaluator poll interval must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate queue runner settings
|
||||
if self.queue_runner.workers == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Queue runner workers must be greater than 0"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate GC config
|
||||
if self.gc.enabled && self.gc.gc_roots_dir.as_os_str().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"GC roots directory cannot be empty when GC is enabled"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate log config
|
||||
if self.logs.log_dir.as_os_str().is_empty() {
|
||||
return Err(anyhow::anyhow!("Log directory cannot be empty"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_invalid_database_url() {
|
||||
let mut config = Config::default();
|
||||
config.database.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_port() {
|
||||
let mut config = Config::default();
|
||||
config.server.port = 0;
|
||||
assert!(config.validate().is_err());
|
||||
#[test]
|
||||
fn test_invalid_database_url() {
|
||||
let mut config = Config::default();
|
||||
config.database.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
config.server.port = 65535;
|
||||
assert!(config.validate().is_ok()); // valid port
|
||||
}
|
||||
#[test]
|
||||
fn test_invalid_port() {
|
||||
let mut config = Config::default();
|
||||
config.server.port = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
#[test]
|
||||
fn test_invalid_connections() {
|
||||
let mut config = Config::default();
|
||||
config.database.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
config.server.port = 65535;
|
||||
assert!(config.validate().is_ok()); // valid port
|
||||
}
|
||||
|
||||
config.database.max_connections = 10;
|
||||
config.database.min_connections = 15;
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn test_invalid_connections() {
|
||||
let mut config = Config::default();
|
||||
config.database.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_default_is_empty() {
|
||||
let config = DeclarativeConfig::default();
|
||||
assert!(config.projects.is_empty());
|
||||
assert!(config.api_keys.is_empty());
|
||||
}
|
||||
config.database.max_connections = 10;
|
||||
config.database.min_connections = 15;
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_deserialization() {
|
||||
let toml_str = r#"
|
||||
#[test]
|
||||
fn test_declarative_config_default_is_empty() {
|
||||
let config = DeclarativeConfig::default();
|
||||
assert!(config.projects.is_empty());
|
||||
assert!(config.api_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_deserialization() {
|
||||
let toml_str = r#"
|
||||
[[projects]]
|
||||
name = "my-project"
|
||||
repository_url = "https://github.com/test/repo"
|
||||
|
|
@ -467,77 +473,77 @@ mod tests {
|
|||
key = "fc_secret_key_123"
|
||||
role = "admin"
|
||||
"#;
|
||||
let config: DeclarativeConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.projects.len(), 1);
|
||||
assert_eq!(config.projects[0].name, "my-project");
|
||||
assert_eq!(config.projects[0].jobsets.len(), 1);
|
||||
assert_eq!(config.projects[0].jobsets[0].name, "packages");
|
||||
assert!(config.projects[0].jobsets[0].enabled); // default true
|
||||
assert!(config.projects[0].jobsets[0].flake_mode); // default true
|
||||
assert_eq!(config.api_keys.len(), 1);
|
||||
assert_eq!(config.api_keys[0].role, "admin");
|
||||
let config: DeclarativeConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.projects.len(), 1);
|
||||
assert_eq!(config.projects[0].name, "my-project");
|
||||
assert_eq!(config.projects[0].jobsets.len(), 1);
|
||||
assert_eq!(config.projects[0].jobsets[0].name, "packages");
|
||||
assert!(config.projects[0].jobsets[0].enabled); // default true
|
||||
assert!(config.projects[0].jobsets[0].flake_mode); // default true
|
||||
assert_eq!(config.api_keys.len(), 1);
|
||||
assert_eq!(config.api_keys[0].role, "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_serialization_roundtrip() {
|
||||
let config = DeclarativeConfig {
|
||||
projects: vec![DeclarativeProject {
|
||||
name: "test".to_string(),
|
||||
repository_url: "https://example.com/repo".to_string(),
|
||||
description: Some("desc".to_string()),
|
||||
jobsets: vec![DeclarativeJobset {
|
||||
name: "checks".to_string(),
|
||||
nix_expression: "checks".to_string(),
|
||||
enabled: true,
|
||||
flake_mode: true,
|
||||
check_interval: 300,
|
||||
}],
|
||||
}],
|
||||
api_keys: vec![DeclarativeApiKey {
|
||||
name: "test-key".to_string(),
|
||||
key: "fc_test".to_string(),
|
||||
role: "admin".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: DeclarativeConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.projects.len(), 1);
|
||||
assert_eq!(parsed.projects[0].jobsets[0].check_interval, 300);
|
||||
assert_eq!(parsed.api_keys[0].name, "test-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_with_main_config() {
|
||||
// Ensure declarative section is optional (default empty)
|
||||
// Use the config crate loader which provides defaults for missing fields
|
||||
let config = Config::default();
|
||||
assert!(config.declarative.projects.is_empty());
|
||||
assert!(config.declarative.api_keys.is_empty());
|
||||
// And that the Config can be serialized back with declarative section
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
let parsed: Config = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.declarative.projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_override() {
|
||||
// Test environment variable parsing directly
|
||||
unsafe {
|
||||
env::set_var("FC_DATABASE__URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("FC_SERVER__PORT", "8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_serialization_roundtrip() {
|
||||
let config = DeclarativeConfig {
|
||||
projects: vec![DeclarativeProject {
|
||||
name: "test".to_string(),
|
||||
repository_url: "https://example.com/repo".to_string(),
|
||||
description: Some("desc".to_string()),
|
||||
jobsets: vec![DeclarativeJobset {
|
||||
name: "checks".to_string(),
|
||||
nix_expression: "checks".to_string(),
|
||||
enabled: true,
|
||||
flake_mode: true,
|
||||
check_interval: 300,
|
||||
}],
|
||||
}],
|
||||
api_keys: vec![DeclarativeApiKey {
|
||||
name: "test-key".to_string(),
|
||||
key: "fc_test".to_string(),
|
||||
role: "admin".to_string(),
|
||||
}],
|
||||
};
|
||||
// Test that environment variables are being read correctly
|
||||
let db_url = std::env::var("FC_DATABASE__URL").unwrap();
|
||||
let server_port = std::env::var("FC_SERVER__PORT").unwrap();
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: DeclarativeConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.projects.len(), 1);
|
||||
assert_eq!(parsed.projects[0].jobsets[0].check_interval, 300);
|
||||
assert_eq!(parsed.api_keys[0].name, "test-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_declarative_config_with_main_config() {
|
||||
// Ensure declarative section is optional (default empty)
|
||||
// Use the config crate loader which provides defaults for missing fields
|
||||
let config = Config::default();
|
||||
assert!(config.declarative.projects.is_empty());
|
||||
assert!(config.declarative.api_keys.is_empty());
|
||||
// And that the Config can be serialized back with declarative section
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
let parsed: Config = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.declarative.projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_override() {
|
||||
// Test environment variable parsing directly
|
||||
unsafe {
|
||||
env::set_var("FC_DATABASE__URL", "postgresql://test:test@localhost/test");
|
||||
env::set_var("FC_SERVER__PORT", "8080");
|
||||
}
|
||||
|
||||
// Test that environment variables are being read correctly
|
||||
let db_url = std::env::var("FC_DATABASE__URL").unwrap();
|
||||
let server_port = std::env::var("FC_SERVER__PORT").unwrap();
|
||||
|
||||
assert_eq!(db_url, "postgresql://test:test@localhost/test");
|
||||
assert_eq!(server_port, "8080");
|
||||
|
||||
unsafe {
|
||||
env::remove_var("FC_DATABASE__URL");
|
||||
env::remove_var("FC_SERVER__PORT");
|
||||
}
|
||||
assert_eq!(db_url, "postgresql://test:test@localhost/test");
|
||||
assert_eq!(server_port, "8080");
|
||||
|
||||
unsafe {
|
||||
env::remove_var("FC_DATABASE__URL");
|
||||
env::remove_var("FC_SERVER__PORT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,65 @@
|
|||
//! Database connection and pool management
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::{PgPool, Row, postgres::PgPoolOptions};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::DatabaseConfig;
|
||||
|
||||
pub struct Database {
|
||||
pool: PgPool,
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(config: DatabaseConfig) -> anyhow::Result<Self> {
|
||||
info!("Initializing database connection pool");
|
||||
pub async fn new(config: DatabaseConfig) -> anyhow::Result<Self> {
|
||||
info!("Initializing database connection pool");
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(config.max_connections)
|
||||
.min_connections(config.min_connections)
|
||||
.acquire_timeout(Duration::from_secs(config.connect_timeout))
|
||||
.idle_timeout(Duration::from_secs(config.idle_timeout))
|
||||
.max_lifetime(Duration::from_secs(config.max_lifetime))
|
||||
.connect(&config.url)
|
||||
.await?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(config.max_connections)
|
||||
.min_connections(config.min_connections)
|
||||
.acquire_timeout(Duration::from_secs(config.connect_timeout))
|
||||
.idle_timeout(Duration::from_secs(config.idle_timeout))
|
||||
.max_lifetime(Duration::from_secs(config.max_lifetime))
|
||||
.connect(&config.url)
|
||||
.await?;
|
||||
|
||||
// Test the connection
|
||||
Self::health_check(&pool).await?;
|
||||
// Test the connection
|
||||
Self::health_check(&pool).await?;
|
||||
|
||||
info!("Database connection pool initialized successfully");
|
||||
info!("Database connection pool initialized successfully");
|
||||
|
||||
Ok(Self { pool })
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub async fn health_check(pool: &PgPool) -> anyhow::Result<()> {
|
||||
debug!("Performing database health check");
|
||||
|
||||
let result: i32 = sqlx::query_scalar("SELECT 1").fetch_one(pool).await?;
|
||||
|
||||
if result != 1 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database health check failed: unexpected result"
|
||||
));
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
debug!("Database health check passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn health_check(pool: &PgPool) -> anyhow::Result<()> {
|
||||
debug!("Performing database health check");
|
||||
pub async fn close(&self) {
|
||||
info!("Closing database connection pool");
|
||||
self.pool.close().await;
|
||||
}
|
||||
|
||||
let result: i32 = sqlx::query_scalar("SELECT 1").fetch_one(pool).await?;
|
||||
|
||||
if result != 1 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Database health check failed: unexpected result"
|
||||
));
|
||||
}
|
||||
|
||||
debug!("Database health check passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
info!("Closing database connection pool");
|
||||
self.pool.close().await;
|
||||
}
|
||||
|
||||
pub async fn get_connection_info(&self) -> anyhow::Result<ConnectionInfo> {
|
||||
let row = sqlx::query(
|
||||
r"
|
||||
pub async fn get_connection_info(&self) -> anyhow::Result<ConnectionInfo> {
|
||||
let row = sqlx::query(
|
||||
r"
|
||||
SELECT
|
||||
current_database() as database,
|
||||
current_user as user,
|
||||
|
|
@ -65,81 +67,81 @@ impl Database {
|
|||
inet_server_addr() as server_ip,
|
||||
inet_server_port() as server_port
|
||||
",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(ConnectionInfo {
|
||||
database: row.get("database"),
|
||||
user: row.get("user"),
|
||||
version: row.get("version"),
|
||||
server_ip: row.get("server_ip"),
|
||||
server_port: row.get("server_port"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_pool_stats(&self) -> PoolStats {
|
||||
let pool = &self.pool;
|
||||
|
||||
PoolStats {
|
||||
size: pool.size(),
|
||||
idle: pool.num_idle() as u32,
|
||||
active: (pool.size() - pool.num_idle() as u32),
|
||||
}
|
||||
Ok(ConnectionInfo {
|
||||
database: row.get("database"),
|
||||
user: row.get("user"),
|
||||
version: row.get("version"),
|
||||
server_ip: row.get("server_ip"),
|
||||
server_port: row.get("server_port"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_pool_stats(&self) -> PoolStats {
|
||||
let pool = &self.pool;
|
||||
|
||||
PoolStats {
|
||||
size: pool.size(),
|
||||
idle: pool.num_idle() as u32,
|
||||
active: (pool.size() - pool.num_idle() as u32),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionInfo {
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub version: String,
|
||||
pub server_ip: Option<String>,
|
||||
pub server_port: Option<i32>,
|
||||
pub database: String,
|
||||
pub user: String,
|
||||
pub version: String,
|
||||
pub server_ip: Option<String>,
|
||||
pub server_port: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolStats {
|
||||
pub size: u32,
|
||||
pub idle: u32,
|
||||
pub active: u32,
|
||||
pub size: u32,
|
||||
pub idle: u32,
|
||||
pub active: u32,
|
||||
}
|
||||
|
||||
impl Drop for Database {
|
||||
fn drop(&mut self) {
|
||||
warn!("Database connection pool dropped without explicit close");
|
||||
}
|
||||
fn drop(&mut self) {
|
||||
warn!("Database connection pool dropped without explicit close");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pool_stats() {
|
||||
let stats = PoolStats {
|
||||
size: 10,
|
||||
idle: 3,
|
||||
active: 7,
|
||||
};
|
||||
#[test]
|
||||
fn test_pool_stats() {
|
||||
let stats = PoolStats {
|
||||
size: 10,
|
||||
idle: 3,
|
||||
active: 7,
|
||||
};
|
||||
|
||||
assert_eq!(stats.size, 10);
|
||||
assert_eq!(stats.idle, 3);
|
||||
assert_eq!(stats.active, 7);
|
||||
}
|
||||
assert_eq!(stats.size, 10);
|
||||
assert_eq!(stats.idle, 3);
|
||||
assert_eq!(stats.active, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_info() {
|
||||
let info = ConnectionInfo {
|
||||
database: "test_db".to_string(),
|
||||
user: "test_user".to_string(),
|
||||
version: "PostgreSQL 14.0".to_string(),
|
||||
server_ip: Some("127.0.0.1".to_string()),
|
||||
server_port: Some(5432),
|
||||
};
|
||||
#[test]
|
||||
fn test_connection_info() {
|
||||
let info = ConnectionInfo {
|
||||
database: "test_db".to_string(),
|
||||
user: "test_user".to_string(),
|
||||
version: "PostgreSQL 14.0".to_string(),
|
||||
server_ip: Some("127.0.0.1".to_string()),
|
||||
server_port: Some(5432),
|
||||
};
|
||||
|
||||
assert_eq!(info.database, "test_db");
|
||||
assert_eq!(info.user, "test_user");
|
||||
assert_eq!(info.server_port, Some(5432));
|
||||
}
|
||||
assert_eq!(info.database, "test_db");
|
||||
assert_eq!(info.user, "test_user");
|
||||
assert_eq!(info.server_port, Some(5432));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,44 +4,44 @@ use thiserror::Error;
|
|||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CiError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("Git error: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
#[error("Git error: {0}")]
|
||||
Git(#[from] git2::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Build error: {0}")]
|
||||
Build(String),
|
||||
#[error("Build error: {0}")]
|
||||
Build(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Timeout: {0}")]
|
||||
Timeout(String),
|
||||
#[error("Timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("Nix evaluation error: {0}")]
|
||||
NixEval(String),
|
||||
#[error("Nix evaluation error: {0}")]
|
||||
NixEval(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CiError>;
|
||||
|
|
|
|||
|
|
@ -1,103 +1,113 @@
|
|||
//! GC root management - prevents nix-store --gc from deleting build outputs
|
||||
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
os::unix::fs::symlink,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Remove GC root symlinks with mtime older than max_age. Returns count removed.
|
||||
pub fn cleanup_old_roots(roots_dir: &Path, max_age: Duration) -> std::io::Result<u64> {
|
||||
if !roots_dir.exists() {
|
||||
return Ok(0);
|
||||
/// Remove GC root symlinks with mtime older than max_age. Returns count
|
||||
/// removed.
|
||||
pub fn cleanup_old_roots(
|
||||
roots_dir: &Path,
|
||||
max_age: Duration,
|
||||
) -> std::io::Result<u64> {
|
||||
if !roots_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0u64;
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
for entry in std::fs::read_dir(roots_dir)? {
|
||||
let entry = entry?;
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let modified = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Ok(age) = now.duration_since(modified)
|
||||
&& age > max_age
|
||||
{
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
warn!(
|
||||
"Failed to remove old GC root {}: {e}",
|
||||
entry.path().display()
|
||||
);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut count = 0u64;
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
for entry in std::fs::read_dir(roots_dir)? {
|
||||
let entry = entry?;
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let modified = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Ok(age) = now.duration_since(modified)
|
||||
&& age > max_age {
|
||||
if let Err(e) = std::fs::remove_file(entry.path()) {
|
||||
warn!(
|
||||
"Failed to remove old GC root {}: {e}",
|
||||
entry.path().display()
|
||||
);
|
||||
} else {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub struct GcRoots {
|
||||
roots_dir: PathBuf,
|
||||
enabled: bool,
|
||||
roots_dir: PathBuf,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl GcRoots {
|
||||
pub fn new(roots_dir: PathBuf, enabled: bool) -> std::io::Result<Self> {
|
||||
if enabled {
|
||||
std::fs::create_dir_all(&roots_dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&roots_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
}
|
||||
Ok(Self { roots_dir, enabled })
|
||||
pub fn new(roots_dir: PathBuf, enabled: bool) -> std::io::Result<Self> {
|
||||
if enabled {
|
||||
std::fs::create_dir_all(&roots_dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
&roots_dir,
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(Self { roots_dir, enabled })
|
||||
}
|
||||
|
||||
/// Register a GC root for a build output. Returns the symlink path.
|
||||
pub fn register(
|
||||
&self,
|
||||
build_id: &uuid::Uuid,
|
||||
output_path: &str,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
if !self.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
if !crate::validate::is_valid_store_path(output_path) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("Invalid store path: {output_path}"),
|
||||
));
|
||||
}
|
||||
let link_path = self.roots_dir.join(build_id.to_string());
|
||||
// Remove existing symlink if present
|
||||
if link_path.exists() || link_path.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link_path)?;
|
||||
}
|
||||
symlink(output_path, &link_path)?;
|
||||
info!(build_id = %build_id, output = output_path, "Registered GC root");
|
||||
Ok(Some(link_path))
|
||||
/// Register a GC root for a build output. Returns the symlink path.
|
||||
pub fn register(
|
||||
&self,
|
||||
build_id: &uuid::Uuid,
|
||||
output_path: &str,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
if !self.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
if !crate::validate::is_valid_store_path(output_path) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("Invalid store path: {output_path}"),
|
||||
));
|
||||
}
|
||||
let link_path = self.roots_dir.join(build_id.to_string());
|
||||
// Remove existing symlink if present
|
||||
if link_path.exists() || link_path.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link_path)?;
|
||||
}
|
||||
symlink(output_path, &link_path)?;
|
||||
info!(build_id = %build_id, output = output_path, "Registered GC root");
|
||||
Ok(Some(link_path))
|
||||
}
|
||||
|
||||
/// Remove a GC root for a build.
|
||||
pub fn remove(&self, build_id: &uuid::Uuid) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
let link_path = self.roots_dir.join(build_id.to_string());
|
||||
if let Err(e) = std::fs::remove_file(&link_path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
warn!(build_id = %build_id, "Failed to remove GC root: {e}");
|
||||
}
|
||||
} else {
|
||||
info!(build_id = %build_id, "Removed GC root");
|
||||
}
|
||||
/// Remove a GC root for a build.
|
||||
pub fn remove(&self, build_id: &uuid::Uuid) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
let link_path = self.roots_dir.join(build_id.to_string());
|
||||
if let Err(e) = std::fs::remove_file(&link_path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
warn!(build_id = %build_id, "Failed to remove GC root: {e}");
|
||||
}
|
||||
} else {
|
||||
info!(build_id = %build_id, "Removed GC root");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,64 +5,64 @@ use std::path::PathBuf;
|
|||
use uuid::Uuid;
|
||||
|
||||
pub struct LogStorage {
|
||||
log_dir: PathBuf,
|
||||
log_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl LogStorage {
|
||||
pub fn new(log_dir: PathBuf) -> std::io::Result<Self> {
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
Ok(Self { log_dir })
|
||||
}
|
||||
pub fn new(log_dir: PathBuf) -> std::io::Result<Self> {
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
Ok(Self { log_dir })
|
||||
}
|
||||
|
||||
/// Returns the filesystem path where a build's log should be stored
|
||||
pub fn log_path(&self, build_id: &Uuid) -> PathBuf {
|
||||
self.log_dir.join(format!("{}.log", build_id))
|
||||
}
|
||||
/// Returns the filesystem path where a build's log should be stored
|
||||
pub fn log_path(&self, build_id: &Uuid) -> PathBuf {
|
||||
self.log_dir.join(format!("{}.log", build_id))
|
||||
}
|
||||
|
||||
/// Returns the filesystem path for an active (in-progress) build log
|
||||
pub fn log_path_for_active(&self, build_id: &Uuid) -> PathBuf {
|
||||
self.log_dir.join(format!("{}.active.log", build_id))
|
||||
}
|
||||
/// Returns the filesystem path for an active (in-progress) build log
|
||||
pub fn log_path_for_active(&self, build_id: &Uuid) -> PathBuf {
|
||||
self.log_dir.join(format!("{}.active.log", build_id))
|
||||
}
|
||||
|
||||
/// Write build log content to file
|
||||
pub fn write_log(
|
||||
&self,
|
||||
build_id: &Uuid,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let path = self.log_path(build_id);
|
||||
let mut content = String::new();
|
||||
if !stdout.is_empty() {
|
||||
content.push_str(stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(stderr);
|
||||
}
|
||||
std::fs::write(&path, &content)?;
|
||||
tracing::debug!(build_id = %build_id, path = %path.display(), "Wrote build log");
|
||||
Ok(path)
|
||||
/// Write build log content to file
|
||||
pub fn write_log(
|
||||
&self,
|
||||
build_id: &Uuid,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let path = self.log_path(build_id);
|
||||
let mut content = String::new();
|
||||
if !stdout.is_empty() {
|
||||
content.push_str(stdout);
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(stderr);
|
||||
}
|
||||
std::fs::write(&path, &content)?;
|
||||
tracing::debug!(build_id = %build_id, path = %path.display(), "Wrote build log");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Read a build log from disk. Returns None if the file doesn't exist.
|
||||
pub fn read_log(&self, build_id: &Uuid) -> std::io::Result<Option<String>> {
|
||||
let path = self.log_path(build_id);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Ok(Some(content))
|
||||
/// Read a build log from disk. Returns None if the file doesn't exist.
|
||||
pub fn read_log(&self, build_id: &Uuid) -> std::io::Result<Option<String>> {
|
||||
let path = self.log_path(build_id);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
/// Delete a build log
|
||||
pub fn delete_log(&self, build_id: &Uuid) -> std::io::Result<()> {
|
||||
let path = self.log_path(build_id);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
/// Delete a build log
|
||||
pub fn delete_log(&self, build_id: &Uuid) -> std::io::Result<()> {
|
||||
let path = self.log_path(build_id);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,65 +5,65 @@ use tracing::{error, info, warn};
|
|||
|
||||
/// Runs database migrations and ensures the database exists
|
||||
pub async fn run_migrations(database_url: &str) -> anyhow::Result<()> {
|
||||
info!("Starting database migrations");
|
||||
info!("Starting database migrations");
|
||||
|
||||
// Check if database exists, create if it doesn't
|
||||
if !Postgres::database_exists(database_url).await? {
|
||||
warn!("Database does not exist, creating it");
|
||||
Postgres::create_database(database_url).await?;
|
||||
info!("Database created successfully");
|
||||
}
|
||||
// Check if database exists, create if it doesn't
|
||||
if !Postgres::database_exists(database_url).await? {
|
||||
warn!("Database does not exist, creating it");
|
||||
Postgres::create_database(database_url).await?;
|
||||
info!("Database created successfully");
|
||||
}
|
||||
|
||||
// Set up connection pool with retry logic, then run migrations
|
||||
let pool = create_connection_pool(database_url).await?;
|
||||
match sqlx::migrate!("./migrations").run(&pool).await {
|
||||
Ok(()) => {
|
||||
info!("Database migrations completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to run database migrations: {}", e);
|
||||
Err(anyhow::anyhow!("Migration failed: {e}"))
|
||||
}
|
||||
}
|
||||
// Set up connection pool with retry logic, then run migrations
|
||||
let pool = create_connection_pool(database_url).await?;
|
||||
match sqlx::migrate!("./migrations").run(&pool).await {
|
||||
Ok(()) => {
|
||||
info!("Database migrations completed successfully");
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => {
|
||||
error!("Failed to run database migrations: {}", e);
|
||||
Err(anyhow::anyhow!("Migration failed: {e}"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a connection pool with proper configuration
|
||||
async fn create_connection_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
||||
let pool = PgPool::connect(database_url).await?;
|
||||
let pool = PgPool::connect(database_url).await?;
|
||||
|
||||
// Test the connection
|
||||
sqlx::query("SELECT 1").fetch_one(&pool).await?;
|
||||
// Test the connection
|
||||
sqlx::query("SELECT 1").fetch_one(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Validates that all required tables exist and have the expected structure
|
||||
pub async fn validate_schema(pool: &PgPool) -> anyhow::Result<()> {
|
||||
info!("Validating database schema");
|
||||
info!("Validating database schema");
|
||||
|
||||
let required_tables = vec![
|
||||
"projects",
|
||||
"jobsets",
|
||||
"evaluations",
|
||||
"builds",
|
||||
"build_products",
|
||||
"build_steps",
|
||||
];
|
||||
let required_tables = vec![
|
||||
"projects",
|
||||
"jobsets",
|
||||
"evaluations",
|
||||
"builds",
|
||||
"build_products",
|
||||
"build_steps",
|
||||
];
|
||||
|
||||
for table in required_tables {
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = $1",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
for table in required_tables {
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = $1",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if result == 0 {
|
||||
return Err(anyhow::anyhow!("Required table '{table}' does not exist"));
|
||||
}
|
||||
if result == 0 {
|
||||
return Err(anyhow::anyhow!("Required table '{table}' does not exist"));
|
||||
}
|
||||
}
|
||||
|
||||
info!("Database schema validation passed");
|
||||
Ok(())
|
||||
info!("Database schema validation passed");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,78 +8,74 @@ use tracing_subscriber::fmt::init;
|
|||
#[command(name = "fc-migrate")]
|
||||
#[command(about = "Database migration utility for FC CI")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Run all pending migrations
|
||||
Up {
|
||||
/// Database connection URL
|
||||
database_url: String,
|
||||
},
|
||||
/// Validate the current schema
|
||||
Validate {
|
||||
/// Database connection URL
|
||||
database_url: String,
|
||||
},
|
||||
/// Create a new migration file
|
||||
Create {
|
||||
/// Migration name
|
||||
#[arg(required = true)]
|
||||
name: String,
|
||||
},
|
||||
/// Run all pending migrations
|
||||
Up {
|
||||
/// Database connection URL
|
||||
database_url: String,
|
||||
},
|
||||
/// Validate the current schema
|
||||
Validate {
|
||||
/// Database connection URL
|
||||
database_url: String,
|
||||
},
|
||||
/// Create a new migration file
|
||||
Create {
|
||||
/// Migration name
|
||||
#[arg(required = true)]
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
init();
|
||||
// Initialize logging
|
||||
init();
|
||||
|
||||
match cli.command {
|
||||
Commands::Up { database_url } => {
|
||||
info!("Running database migrations");
|
||||
crate::run_migrations(&database_url).await?;
|
||||
info!("Migrations completed successfully");
|
||||
}
|
||||
Commands::Validate { database_url } => {
|
||||
info!("Validating database schema");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await?;
|
||||
crate::validate_schema(&pool).await?;
|
||||
info!("Schema validation passed");
|
||||
}
|
||||
Commands::Create { name } => {
|
||||
create_migration(&name)?;
|
||||
}
|
||||
}
|
||||
match cli.command {
|
||||
Commands::Up { database_url } => {
|
||||
info!("Running database migrations");
|
||||
crate::run_migrations(&database_url).await?;
|
||||
info!("Migrations completed successfully");
|
||||
},
|
||||
Commands::Validate { database_url } => {
|
||||
info!("Validating database schema");
|
||||
let pool = sqlx::PgPool::connect(&database_url).await?;
|
||||
crate::validate_schema(&pool).await?;
|
||||
info!("Schema validation passed");
|
||||
},
|
||||
Commands::Create { name } => {
|
||||
create_migration(&name)?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_migration(name: &str) -> anyhow::Result<()> {
|
||||
use chrono::Utc;
|
||||
use std::fs;
|
||||
use std::fs;
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let filename = format!("{timestamp}_{name}.sql");
|
||||
let filepath = format!("crates/common/migrations/{filename}");
|
||||
use chrono::Utc;
|
||||
|
||||
let content = format!(
|
||||
"-- Migration: {}\n\
|
||||
-- Created: {}\n\
|
||||
\n\
|
||||
-- Add your migration SQL here\n\
|
||||
\n\
|
||||
-- Uncomment below for rollback SQL\n\
|
||||
-- ROLLBACK;\n",
|
||||
name,
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let filename = format!("{timestamp}_{name}.sql");
|
||||
let filepath = format!("crates/common/migrations/{filename}");
|
||||
|
||||
fs::write(&filepath, content)?;
|
||||
println!("Created migration file: {filepath}");
|
||||
let content = format!(
|
||||
"-- Migration: {}\n-- Created: {}\n\n-- Add your migration SQL here\n\n-- \
|
||||
Uncomment below for rollback SQL\n-- ROLLBACK;\n",
|
||||
name,
|
||||
Utc::now().to_rfc3339()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
fs::write(&filepath, content)?;
|
||||
println!("Created migration file: {filepath}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,395 +7,395 @@ use uuid::Uuid;
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Project {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Jobset {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: bool,
|
||||
pub flake_mode: bool,
|
||||
pub check_interval: i32,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: bool,
|
||||
pub flake_mode: bool,
|
||||
pub check_interval: i32,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Evaluation {
|
||||
pub id: Uuid,
|
||||
pub jobset_id: Uuid,
|
||||
pub commit_hash: String,
|
||||
pub evaluation_time: DateTime<Utc>,
|
||||
pub status: EvaluationStatus,
|
||||
pub error_message: Option<String>,
|
||||
pub inputs_hash: Option<String>,
|
||||
pub id: Uuid,
|
||||
pub jobset_id: Uuid,
|
||||
pub commit_hash: String,
|
||||
pub evaluation_time: DateTime<Utc>,
|
||||
pub status: EvaluationStatus,
|
||||
pub error_message: Option<String>,
|
||||
pub inputs_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||
pub enum EvaluationStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Build {
|
||||
pub id: Uuid,
|
||||
pub evaluation_id: Uuid,
|
||||
pub job_name: String,
|
||||
pub drv_path: String,
|
||||
pub status: BuildStatus,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub log_path: Option<String>,
|
||||
pub build_output_path: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub system: Option<String>,
|
||||
pub priority: i32,
|
||||
pub retry_count: i32,
|
||||
pub max_retries: i32,
|
||||
pub notification_pending_since: Option<DateTime<Utc>>,
|
||||
pub log_url: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub outputs: Option<serde_json::Value>,
|
||||
pub is_aggregate: bool,
|
||||
pub constituents: Option<serde_json::Value>,
|
||||
pub builder_id: Option<Uuid>,
|
||||
pub signed: bool,
|
||||
pub id: Uuid,
|
||||
pub evaluation_id: Uuid,
|
||||
pub job_name: String,
|
||||
pub drv_path: String,
|
||||
pub status: BuildStatus,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub log_path: Option<String>,
|
||||
pub build_output_path: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub system: Option<String>,
|
||||
pub priority: i32,
|
||||
pub retry_count: i32,
|
||||
pub max_retries: i32,
|
||||
pub notification_pending_since: Option<DateTime<Utc>>,
|
||||
pub log_url: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub outputs: Option<serde_json::Value>,
|
||||
pub is_aggregate: bool,
|
||||
pub constituents: Option<serde_json::Value>,
|
||||
pub builder_id: Option<Uuid>,
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type, PartialEq)]
|
||||
#[sqlx(type_name = "text", rename_all = "lowercase")]
|
||||
pub enum BuildStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct BuildProduct {
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub sha256_hash: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub content_type: Option<String>,
|
||||
pub is_directory: bool,
|
||||
pub gc_root_path: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub sha256_hash: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub content_type: Option<String>,
|
||||
pub is_directory: bool,
|
||||
pub gc_root_path: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct BuildStep {
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub step_number: i32,
|
||||
pub command: String,
|
||||
pub output: Option<String>,
|
||||
pub error_output: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub step_number: i32,
|
||||
pub command: String,
|
||||
pub output: Option<String>,
|
||||
pub error_output: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct BuildDependency {
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub dependency_build_id: Uuid,
|
||||
pub id: Uuid,
|
||||
pub build_id: Uuid,
|
||||
pub dependency_build_id: Uuid,
|
||||
}
|
||||
|
||||
/// Active jobset view — enabled jobsets joined with project info.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ActiveJobset {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: bool,
|
||||
pub flake_mode: bool,
|
||||
pub check_interval: i32,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub project_name: String,
|
||||
pub repository_url: String,
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: bool,
|
||||
pub flake_mode: bool,
|
||||
pub check_interval: i32,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub project_name: String,
|
||||
pub repository_url: String,
|
||||
}
|
||||
|
||||
/// Build statistics from the build_stats view.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow, Default)]
|
||||
pub struct BuildStats {
|
||||
pub total_builds: Option<i64>,
|
||||
pub completed_builds: Option<i64>,
|
||||
pub failed_builds: Option<i64>,
|
||||
pub running_builds: Option<i64>,
|
||||
pub pending_builds: Option<i64>,
|
||||
pub avg_duration_seconds: Option<f64>,
|
||||
pub total_builds: Option<i64>,
|
||||
pub completed_builds: Option<i64>,
|
||||
pub failed_builds: Option<i64>,
|
||||
pub running_builds: Option<i64>,
|
||||
pub pending_builds: Option<i64>,
|
||||
pub avg_duration_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
/// API key for authentication.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ApiKey {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub key_hash: String,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub key_hash: String,
|
||||
pub role: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Webhook configuration for a project.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct WebhookConfig {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub forge_type: String,
|
||||
pub secret_hash: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub forge_type: String,
|
||||
pub secret_hash: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Notification configuration for a project.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct NotificationConfig {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub notification_type: String,
|
||||
pub config: serde_json::Value,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub notification_type: String,
|
||||
pub config: serde_json::Value,
|
||||
pub enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Jobset input definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct JobsetInput {
|
||||
pub id: Uuid,
|
||||
pub jobset_id: Uuid,
|
||||
pub name: String,
|
||||
pub input_type: String,
|
||||
pub value: String,
|
||||
pub revision: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub jobset_id: Uuid,
|
||||
pub name: String,
|
||||
pub input_type: String,
|
||||
pub value: String,
|
||||
pub revision: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Release channel — tracks the latest "good" evaluation for a jobset.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Channel {
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub jobset_id: Uuid,
|
||||
pub current_evaluation_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub jobset_id: Uuid,
|
||||
pub current_evaluation_id: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Remote builder for multi-machine / multi-arch builds.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct RemoteBuilder {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub ssh_uri: String,
|
||||
pub systems: Vec<String>,
|
||||
pub max_jobs: i32,
|
||||
pub speed_factor: i32,
|
||||
pub supported_features: Vec<String>,
|
||||
pub mandatory_features: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub ssh_uri: String,
|
||||
pub systems: Vec<String>,
|
||||
pub max_jobs: i32,
|
||||
pub speed_factor: i32,
|
||||
pub supported_features: Vec<String>,
|
||||
pub mandatory_features: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.limit.unwrap_or(50).min(200).max(1)
|
||||
}
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.limit.unwrap_or(50).min(200).max(1)
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PaginationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: Some(50),
|
||||
offset: Some(0),
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: Some(50),
|
||||
offset: Some(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
// --- DTO structs for creation and updates ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateProject {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateProject {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateJobset {
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: Option<bool>,
|
||||
pub flake_mode: Option<bool>,
|
||||
pub check_interval: Option<i32>,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: Option<i32>,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub enabled: Option<bool>,
|
||||
pub flake_mode: Option<bool>,
|
||||
pub check_interval: Option<i32>,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateJobset {
|
||||
pub name: Option<String>,
|
||||
pub nix_expression: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub flake_mode: Option<bool>,
|
||||
pub check_interval: Option<i32>,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: Option<i32>,
|
||||
pub name: Option<String>,
|
||||
pub nix_expression: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub flake_mode: Option<bool>,
|
||||
pub check_interval: Option<i32>,
|
||||
pub branch: Option<String>,
|
||||
pub scheduling_shares: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateEvaluation {
|
||||
pub jobset_id: Uuid,
|
||||
pub commit_hash: String,
|
||||
pub jobset_id: Uuid,
|
||||
pub commit_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateBuild {
|
||||
pub evaluation_id: Uuid,
|
||||
pub job_name: String,
|
||||
pub drv_path: String,
|
||||
pub system: Option<String>,
|
||||
pub outputs: Option<serde_json::Value>,
|
||||
pub is_aggregate: Option<bool>,
|
||||
pub constituents: Option<serde_json::Value>,
|
||||
pub evaluation_id: Uuid,
|
||||
pub job_name: String,
|
||||
pub drv_path: String,
|
||||
pub system: Option<String>,
|
||||
pub outputs: Option<serde_json::Value>,
|
||||
pub is_aggregate: Option<bool>,
|
||||
pub constituents: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateBuildProduct {
|
||||
pub build_id: Uuid,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub sha256_hash: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub content_type: Option<String>,
|
||||
pub is_directory: bool,
|
||||
pub build_id: Uuid,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub sha256_hash: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub content_type: Option<String>,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateBuildStep {
|
||||
pub build_id: Uuid,
|
||||
pub step_number: i32,
|
||||
pub command: String,
|
||||
pub build_id: Uuid,
|
||||
pub step_number: i32,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateWebhookConfig {
|
||||
pub project_id: Uuid,
|
||||
pub forge_type: String,
|
||||
pub secret: Option<String>,
|
||||
pub project_id: Uuid,
|
||||
pub forge_type: String,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateNotificationConfig {
|
||||
pub project_id: Uuid,
|
||||
pub notification_type: String,
|
||||
pub config: serde_json::Value,
|
||||
pub project_id: Uuid,
|
||||
pub notification_type: String,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateChannel {
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub jobset_id: Uuid,
|
||||
pub project_id: Uuid,
|
||||
pub name: String,
|
||||
pub jobset_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateChannel {
|
||||
pub name: Option<String>,
|
||||
pub jobset_id: Option<Uuid>,
|
||||
pub name: Option<String>,
|
||||
pub jobset_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateRemoteBuilder {
|
||||
pub name: String,
|
||||
pub ssh_uri: String,
|
||||
pub systems: Vec<String>,
|
||||
pub max_jobs: Option<i32>,
|
||||
pub speed_factor: Option<i32>,
|
||||
pub supported_features: Option<Vec<String>>,
|
||||
pub mandatory_features: Option<Vec<String>>,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
pub name: String,
|
||||
pub ssh_uri: String,
|
||||
pub systems: Vec<String>,
|
||||
pub max_jobs: Option<i32>,
|
||||
pub speed_factor: Option<i32>,
|
||||
pub supported_features: Option<Vec<String>>,
|
||||
pub mandatory_features: Option<Vec<String>>,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateRemoteBuilder {
|
||||
pub name: Option<String>,
|
||||
pub ssh_uri: Option<String>,
|
||||
pub systems: Option<Vec<String>>,
|
||||
pub max_jobs: Option<i32>,
|
||||
pub speed_factor: Option<i32>,
|
||||
pub supported_features: Option<Vec<String>>,
|
||||
pub mandatory_features: Option<Vec<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub ssh_uri: Option<String>,
|
||||
pub systems: Option<Vec<String>>,
|
||||
pub max_jobs: Option<i32>,
|
||||
pub speed_factor: Option<i32>,
|
||||
pub supported_features: Option<Vec<String>>,
|
||||
pub mandatory_features: Option<Vec<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
pub public_host_key: Option<String>,
|
||||
pub ssh_key_file: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary of system status for the admin API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SystemStatus {
|
||||
pub projects_count: i64,
|
||||
pub jobsets_count: i64,
|
||||
pub evaluations_count: i64,
|
||||
pub builds_pending: i64,
|
||||
pub builds_running: i64,
|
||||
pub builds_completed: i64,
|
||||
pub builds_failed: i64,
|
||||
pub remote_builders: i64,
|
||||
pub channels_count: i64,
|
||||
pub projects_count: i64,
|
||||
pub jobsets_count: i64,
|
||||
pub evaluations_count: i64,
|
||||
pub builds_pending: i64,
|
||||
pub builds_running: i64,
|
||||
pub builds_completed: i64,
|
||||
pub builds_failed: i64,
|
||||
pub remote_builders: i64,
|
||||
pub channels_count: i64,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,41 +2,40 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::CiError;
|
||||
use crate::error::Result;
|
||||
use crate::{CiError, error::Result};
|
||||
|
||||
/// Result of probing a flake repository.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlakeProbeResult {
|
||||
pub is_flake: bool,
|
||||
pub outputs: Vec<FlakeOutput>,
|
||||
pub suggested_jobsets: Vec<SuggestedJobset>,
|
||||
pub metadata: FlakeMetadata,
|
||||
pub error: Option<String>,
|
||||
pub is_flake: bool,
|
||||
pub outputs: Vec<FlakeOutput>,
|
||||
pub suggested_jobsets: Vec<SuggestedJobset>,
|
||||
pub metadata: FlakeMetadata,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A discovered flake output attribute.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlakeOutput {
|
||||
pub path: String,
|
||||
pub output_type: String,
|
||||
pub systems: Vec<String>,
|
||||
pub path: String,
|
||||
pub output_type: String,
|
||||
pub systems: Vec<String>,
|
||||
}
|
||||
|
||||
/// A suggested jobset configuration based on discovered outputs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SuggestedJobset {
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub description: String,
|
||||
pub priority: u8,
|
||||
pub name: String,
|
||||
pub nix_expression: String,
|
||||
pub description: String,
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
/// Metadata extracted from the flake.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FlakeMetadata {
|
||||
pub description: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Maximum output size we'll parse from `nix flake show --json` (10 MB).
|
||||
|
|
@ -49,383 +48,405 @@ const MAX_OUTPUT_SIZE: usize = 10 * 1024 * 1024;
|
|||
/// `git+` prefix so nix clones via git rather than trying to unpack an
|
||||
/// archive. URLs that are already valid flake refs are returned as-is.
|
||||
fn to_flake_ref(url: &str) -> String {
|
||||
let url_trimmed = url.trim().trim_end_matches('/');
|
||||
let url_trimmed = url.trim().trim_end_matches('/');
|
||||
|
||||
// Already a flake ref (github:, gitlab:, git+, path:, sourcehut:, etc.)
|
||||
if url_trimmed.contains(':')
|
||||
&& !url_trimmed.starts_with("http://")
|
||||
&& !url_trimmed.starts_with("https://")
|
||||
{
|
||||
return url_trimmed.to_string();
|
||||
}
|
||||
// Already a flake ref (github:, gitlab:, git+, path:, sourcehut:, etc.)
|
||||
if url_trimmed.contains(':')
|
||||
&& !url_trimmed.starts_with("http://")
|
||||
&& !url_trimmed.starts_with("https://")
|
||||
{
|
||||
return url_trimmed.to_string();
|
||||
}
|
||||
|
||||
// Extract host + path from HTTP(S) URLs
|
||||
let without_scheme = url_trimmed
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url_trimmed.strip_prefix("http://"))
|
||||
.unwrap_or(url_trimmed);
|
||||
let without_dotgit = without_scheme.trim_end_matches(".git");
|
||||
// Extract host + path from HTTP(S) URLs
|
||||
let without_scheme = url_trimmed
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url_trimmed.strip_prefix("http://"))
|
||||
.unwrap_or(url_trimmed);
|
||||
let without_dotgit = without_scheme.trim_end_matches(".git");
|
||||
|
||||
// github.com/owner/repo → github:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("github.com/") {
|
||||
return format!("github:{path}");
|
||||
}
|
||||
// github.com/owner/repo → github:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("github.com/") {
|
||||
return format!("github:{path}");
|
||||
}
|
||||
|
||||
// gitlab.com/owner/repo → gitlab:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("gitlab.com/") {
|
||||
return format!("gitlab:{path}");
|
||||
}
|
||||
// gitlab.com/owner/repo → gitlab:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("gitlab.com/") {
|
||||
return format!("gitlab:{path}");
|
||||
}
|
||||
|
||||
// Any other HTTPS/HTTP URL: prefix with git+ so nix clones it
|
||||
if url_trimmed.starts_with("https://") || url_trimmed.starts_with("http://") {
|
||||
return format!("git+{url_trimmed}");
|
||||
}
|
||||
// Any other HTTPS/HTTP URL: prefix with git+ so nix clones it
|
||||
if url_trimmed.starts_with("https://") || url_trimmed.starts_with("http://") {
|
||||
return format!("git+{url_trimmed}");
|
||||
}
|
||||
|
||||
url_trimmed.to_string()
|
||||
url_trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Probe a flake repository to discover its outputs and suggest jobsets.
|
||||
pub async fn probe_flake(repo_url: &str, revision: Option<&str>) -> Result<FlakeProbeResult> {
|
||||
let base_ref = to_flake_ref(repo_url);
|
||||
let flake_ref = if let Some(rev) = revision {
|
||||
format!("{base_ref}?rev={rev}")
|
||||
} else {
|
||||
base_ref
|
||||
};
|
||||
pub async fn probe_flake(
|
||||
repo_url: &str,
|
||||
revision: Option<&str>,
|
||||
) -> Result<FlakeProbeResult> {
|
||||
let base_ref = to_flake_ref(repo_url);
|
||||
let flake_ref = if let Some(rev) = revision {
|
||||
format!("{base_ref}?rev={rev}")
|
||||
} else {
|
||||
base_ref
|
||||
};
|
||||
|
||||
let output = tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
||||
tokio::process::Command::new("nix")
|
||||
.args([
|
||||
"--extra-experimental-features",
|
||||
"nix-command flakes",
|
||||
"flake",
|
||||
"show",
|
||||
"--json",
|
||||
"--no-write-lock-file",
|
||||
&flake_ref,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
let output =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
||||
tokio::process::Command::new("nix")
|
||||
.args([
|
||||
"--extra-experimental-features",
|
||||
"nix-command flakes",
|
||||
"flake",
|
||||
"show",
|
||||
"--json",
|
||||
"--no-write-lock-file",
|
||||
&flake_ref,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CiError::Timeout("Flake probe timed out after 60s".to_string()))?
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to run nix flake show: {e}")))?;
|
||||
.map_err(|_| {
|
||||
CiError::Timeout("Flake probe timed out after 60s".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to run nix flake show: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Check for common non-flake case
|
||||
if stderr.contains("does not provide attribute") || stderr.contains("has no 'flake.nix'") {
|
||||
return Ok(FlakeProbeResult {
|
||||
is_flake: false,
|
||||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some("Repository does not contain a flake.nix".to_string()),
|
||||
});
|
||||
}
|
||||
if stderr.contains("denied")
|
||||
|| stderr.contains("not accessible")
|
||||
|| stderr.contains("authentication")
|
||||
{
|
||||
return Err(CiError::NixEval(
|
||||
"Repository not accessible. Check URL and permissions.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(CiError::NixEval(format!("nix flake show failed: {stderr}")));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.len() > MAX_OUTPUT_SIZE {
|
||||
// For huge repos like nixpkgs, we still parse but only top-level
|
||||
tracing::warn!(
|
||||
"Flake show output exceeds {}MB, parsing top-level only",
|
||||
MAX_OUTPUT_SIZE / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
|
||||
let raw: serde_json::Value = serde_json::from_str(&stdout[..stdout.len().min(MAX_OUTPUT_SIZE)])
|
||||
.map_err(|e| CiError::NixEval(format!("Failed to parse flake show output: {e}")))?;
|
||||
|
||||
let top = match raw.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => {
|
||||
return Err(CiError::NixEval(
|
||||
"Unexpected flake show output format".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
let mut suggested_jobsets = Vec::new();
|
||||
|
||||
// Known output types and their detection
|
||||
let output_types: &[(&str, &str, &str, u8)] = &[
|
||||
("hydraJobs", "derivation", "CI Jobs (hydraJobs)", 10),
|
||||
("checks", "derivation", "Checks", 7),
|
||||
("packages", "derivation", "Packages", 6),
|
||||
("devShells", "derivation", "Development Shells", 3),
|
||||
(
|
||||
"nixosConfigurations",
|
||||
"configuration",
|
||||
"NixOS Configurations",
|
||||
4,
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Check for common non-flake case
|
||||
if stderr.contains("does not provide attribute")
|
||||
|| stderr.contains("has no 'flake.nix'")
|
||||
{
|
||||
return Ok(FlakeProbeResult {
|
||||
is_flake: false,
|
||||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some(
|
||||
"Repository does not contain a flake.nix".to_string(),
|
||||
),
|
||||
("nixosModules", "module", "NixOS Modules", 2),
|
||||
("overlays", "overlay", "Overlays", 1),
|
||||
(
|
||||
"legacyPackages",
|
||||
"derivation",
|
||||
"Legacy Packages (nixpkgs-style)",
|
||||
5,
|
||||
),
|
||||
];
|
||||
|
||||
for &(key, output_type, description, priority) in output_types {
|
||||
if let Some(val) = top.get(key) {
|
||||
let systems = extract_systems(val);
|
||||
outputs.push(FlakeOutput {
|
||||
path: key.to_string(),
|
||||
output_type: output_type.to_string(),
|
||||
systems: systems.clone(),
|
||||
});
|
||||
|
||||
// Generate suggested jobset
|
||||
let nix_expression = match key {
|
||||
"hydraJobs" => "hydraJobs".to_string(),
|
||||
"checks" => "checks".to_string(),
|
||||
"packages" => "packages".to_string(),
|
||||
"devShells" => "devShells".to_string(),
|
||||
"legacyPackages" => "legacyPackages".to_string(),
|
||||
_ => continue, // Don't suggest jobsets for non-buildable outputs
|
||||
};
|
||||
|
||||
suggested_jobsets.push(SuggestedJobset {
|
||||
name: key.to_string(),
|
||||
nix_expression,
|
||||
description: description.to_string(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if stderr.contains("denied")
|
||||
|| stderr.contains("not accessible")
|
||||
|| stderr.contains("authentication")
|
||||
{
|
||||
return Err(CiError::NixEval(
|
||||
"Repository not accessible. Check URL and permissions.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(CiError::NixEval(format!("nix flake show failed: {stderr}")));
|
||||
}
|
||||
|
||||
// Sort jobsets by priority (highest first)
|
||||
suggested_jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.len() > MAX_OUTPUT_SIZE {
|
||||
// For huge repos like nixpkgs, we still parse but only top-level
|
||||
tracing::warn!(
|
||||
"Flake show output exceeds {}MB, parsing top-level only",
|
||||
MAX_OUTPUT_SIZE / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract metadata from the flake
|
||||
let metadata = FlakeMetadata {
|
||||
description: top
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
url: Some(repo_url.to_string()),
|
||||
};
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_str(&stdout[..stdout.len().min(MAX_OUTPUT_SIZE)])
|
||||
.map_err(|e| {
|
||||
CiError::NixEval(format!("Failed to parse flake show output: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(FlakeProbeResult {
|
||||
is_flake: true,
|
||||
outputs,
|
||||
suggested_jobsets,
|
||||
metadata,
|
||||
error: None,
|
||||
})
|
||||
let top = match raw.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => {
|
||||
return Err(CiError::NixEval(
|
||||
"Unexpected flake show output format".to_string(),
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
let mut suggested_jobsets = Vec::new();
|
||||
|
||||
// Known output types and their detection
|
||||
let output_types: &[(&str, &str, &str, u8)] = &[
|
||||
("hydraJobs", "derivation", "CI Jobs (hydraJobs)", 10),
|
||||
("checks", "derivation", "Checks", 7),
|
||||
("packages", "derivation", "Packages", 6),
|
||||
("devShells", "derivation", "Development Shells", 3),
|
||||
(
|
||||
"nixosConfigurations",
|
||||
"configuration",
|
||||
"NixOS Configurations",
|
||||
4,
|
||||
),
|
||||
("nixosModules", "module", "NixOS Modules", 2),
|
||||
("overlays", "overlay", "Overlays", 1),
|
||||
(
|
||||
"legacyPackages",
|
||||
"derivation",
|
||||
"Legacy Packages (nixpkgs-style)",
|
||||
5,
|
||||
),
|
||||
];
|
||||
|
||||
for &(key, output_type, description, priority) in output_types {
|
||||
if let Some(val) = top.get(key) {
|
||||
let systems = extract_systems(val);
|
||||
outputs.push(FlakeOutput {
|
||||
path: key.to_string(),
|
||||
output_type: output_type.to_string(),
|
||||
systems: systems.clone(),
|
||||
});
|
||||
|
||||
// Generate suggested jobset
|
||||
let nix_expression = match key {
|
||||
"hydraJobs" => "hydraJobs".to_string(),
|
||||
"checks" => "checks".to_string(),
|
||||
"packages" => "packages".to_string(),
|
||||
"devShells" => "devShells".to_string(),
|
||||
"legacyPackages" => "legacyPackages".to_string(),
|
||||
_ => continue, // Don't suggest jobsets for non-buildable outputs
|
||||
};
|
||||
|
||||
suggested_jobsets.push(SuggestedJobset {
|
||||
name: key.to_string(),
|
||||
nix_expression,
|
||||
description: description.to_string(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort jobsets by priority (highest first)
|
||||
suggested_jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
|
||||
// Extract metadata from the flake
|
||||
let metadata = FlakeMetadata {
|
||||
description: top
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
url: Some(repo_url.to_string()),
|
||||
};
|
||||
|
||||
Ok(FlakeProbeResult {
|
||||
is_flake: true,
|
||||
outputs,
|
||||
suggested_jobsets,
|
||||
metadata,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract system names from a flake output value (e.g., `packages.x86_64-linux`).
|
||||
/// Extract system names from a flake output value (e.g.,
|
||||
/// `packages.x86_64-linux`).
|
||||
pub(crate) fn extract_systems(val: &serde_json::Value) -> Vec<String> {
|
||||
let mut systems = Vec::new();
|
||||
if let Some(obj) = val.as_object() {
|
||||
for key in obj.keys() {
|
||||
// System names follow the pattern `arch-os` (e.g., x86_64-linux, aarch64-darwin)
|
||||
if key.contains('-') && (key.contains("linux") || key.contains("darwin")) {
|
||||
systems.push(key.clone());
|
||||
}
|
||||
}
|
||||
let mut systems = Vec::new();
|
||||
if let Some(obj) = val.as_object() {
|
||||
for key in obj.keys() {
|
||||
// System names follow the pattern `arch-os` (e.g., x86_64-linux,
|
||||
// aarch64-darwin)
|
||||
if key.contains('-') && (key.contains("linux") || key.contains("darwin"))
|
||||
{
|
||||
systems.push(key.clone());
|
||||
}
|
||||
}
|
||||
systems.sort();
|
||||
systems
|
||||
}
|
||||
systems.sort();
|
||||
systems
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_extract_systems_typical_flake() {
|
||||
let val = json!({
|
||||
"x86_64-linux": { "hello": {} },
|
||||
"aarch64-linux": { "hello": {} },
|
||||
"x86_64-darwin": { "hello": {} }
|
||||
});
|
||||
let systems = extract_systems(&val);
|
||||
assert_eq!(
|
||||
systems,
|
||||
vec!["aarch64-linux", "x86_64-darwin", "x86_64-linux"]
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_systems_empty_object() {
|
||||
let val = json!({});
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn test_extract_systems_typical_flake() {
|
||||
let val = json!({
|
||||
"x86_64-linux": { "hello": {} },
|
||||
"aarch64-linux": { "hello": {} },
|
||||
"x86_64-darwin": { "hello": {} }
|
||||
});
|
||||
let systems = extract_systems(&val);
|
||||
assert_eq!(systems, vec![
|
||||
"aarch64-linux",
|
||||
"x86_64-darwin",
|
||||
"x86_64-linux"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_systems_non_system_keys_ignored() {
|
||||
let val = json!({
|
||||
"x86_64-linux": {},
|
||||
"default": {},
|
||||
"lib": {},
|
||||
"overlay": {}
|
||||
});
|
||||
let systems = extract_systems(&val);
|
||||
assert_eq!(systems, vec!["x86_64-linux"]);
|
||||
}
|
||||
#[test]
|
||||
fn test_extract_systems_empty_object() {
|
||||
let val = json!({});
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_systems_non_object_value() {
|
||||
let val = json!("string");
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
#[test]
|
||||
fn test_extract_systems_non_system_keys_ignored() {
|
||||
let val = json!({
|
||||
"x86_64-linux": {},
|
||||
"default": {},
|
||||
"lib": {},
|
||||
"overlay": {}
|
||||
});
|
||||
let systems = extract_systems(&val);
|
||||
assert_eq!(systems, vec!["x86_64-linux"]);
|
||||
}
|
||||
|
||||
let val = json!(null);
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn test_extract_systems_non_object_value() {
|
||||
let val = json!("string");
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
|
||||
#[test]
|
||||
fn test_flake_probe_result_serialization() {
|
||||
let result = FlakeProbeResult {
|
||||
is_flake: true,
|
||||
outputs: vec![FlakeOutput {
|
||||
path: "packages".to_string(),
|
||||
output_type: "derivation".to_string(),
|
||||
systems: vec!["x86_64-linux".to_string()],
|
||||
}],
|
||||
suggested_jobsets: vec![SuggestedJobset {
|
||||
name: "packages".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
description: "Packages".to_string(),
|
||||
priority: 6,
|
||||
}],
|
||||
metadata: FlakeMetadata {
|
||||
description: Some("A test flake".to_string()),
|
||||
url: Some("https://github.com/test/repo".to_string()),
|
||||
},
|
||||
error: None,
|
||||
};
|
||||
let val = json!(null);
|
||||
assert!(extract_systems(&val).is_empty());
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let parsed: FlakeProbeResult = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_flake);
|
||||
assert_eq!(parsed.outputs.len(), 1);
|
||||
assert_eq!(parsed.suggested_jobsets.len(), 1);
|
||||
assert_eq!(parsed.suggested_jobsets[0].priority, 6);
|
||||
assert_eq!(parsed.metadata.description.as_deref(), Some("A test flake"));
|
||||
}
|
||||
#[test]
|
||||
fn test_flake_probe_result_serialization() {
|
||||
let result = FlakeProbeResult {
|
||||
is_flake: true,
|
||||
outputs: vec![FlakeOutput {
|
||||
path: "packages".to_string(),
|
||||
output_type: "derivation".to_string(),
|
||||
systems: vec!["x86_64-linux".to_string()],
|
||||
}],
|
||||
suggested_jobsets: vec![SuggestedJobset {
|
||||
name: "packages".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
description: "Packages".to_string(),
|
||||
priority: 6,
|
||||
}],
|
||||
metadata: FlakeMetadata {
|
||||
description: Some("A test flake".to_string()),
|
||||
url: Some("https://github.com/test/repo".to_string()),
|
||||
},
|
||||
error: None,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_flake_probe_result_not_a_flake() {
|
||||
let result = FlakeProbeResult {
|
||||
is_flake: false,
|
||||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some("Repository does not contain a flake.nix".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let parsed: FlakeProbeResult = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_flake);
|
||||
assert_eq!(parsed.outputs.len(), 1);
|
||||
assert_eq!(parsed.suggested_jobsets.len(), 1);
|
||||
assert_eq!(parsed.suggested_jobsets[0].priority, 6);
|
||||
assert_eq!(parsed.metadata.description.as_deref(), Some("A test flake"));
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let parsed: FlakeProbeResult = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.is_flake);
|
||||
assert!(parsed.error.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn test_flake_probe_result_not_a_flake() {
|
||||
let result = FlakeProbeResult {
|
||||
is_flake: false,
|
||||
outputs: Vec::new(),
|
||||
suggested_jobsets: Vec::new(),
|
||||
metadata: FlakeMetadata::default(),
|
||||
error: Some(
|
||||
"Repository does not contain a flake.nix".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_to_flake_ref_github_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/notashelf/rags"),
|
||||
"github:notashelf/rags"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/NixOS/nixpkgs"),
|
||||
"github:NixOS/nixpkgs"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/owner/repo.git"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("http://github.com/owner/repo"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/owner/repo/"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
}
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let parsed: FlakeProbeResult = serde_json::from_str(&json).unwrap();
|
||||
assert!(!parsed.is_flake);
|
||||
assert!(parsed.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_flake_ref_gitlab_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://gitlab.com/owner/repo"),
|
||||
"gitlab:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://gitlab.com/group/subgroup/repo.git"),
|
||||
"gitlab:group/subgroup/repo"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_to_flake_ref_github_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/notashelf/rags"),
|
||||
"github:notashelf/rags"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/NixOS/nixpkgs"),
|
||||
"github:NixOS/nixpkgs"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/owner/repo.git"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("http://github.com/owner/repo"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://github.com/owner/repo/"),
|
||||
"github:owner/repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_flake_ref_already_flake_ref() {
|
||||
assert_eq!(to_flake_ref("github:owner/repo"), "github:owner/repo");
|
||||
assert_eq!(to_flake_ref("gitlab:owner/repo"), "gitlab:owner/repo");
|
||||
assert_eq!(
|
||||
to_flake_ref("git+https://example.com/repo.git"),
|
||||
"git+https://example.com/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("path:/some/local/path"),
|
||||
"path:/some/local/path"
|
||||
);
|
||||
assert_eq!(to_flake_ref("sourcehut:~user/repo"), "sourcehut:~user/repo");
|
||||
}
|
||||
#[test]
|
||||
fn test_to_flake_ref_gitlab_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://gitlab.com/owner/repo"),
|
||||
"gitlab:owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://gitlab.com/group/subgroup/repo.git"),
|
||||
"gitlab:group/subgroup/repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_flake_ref_other_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://codeberg.org/owner/repo"),
|
||||
"git+https://codeberg.org/owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://sr.ht/~user/repo"),
|
||||
"git+https://sr.ht/~user/repo"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_to_flake_ref_already_flake_ref() {
|
||||
assert_eq!(to_flake_ref("github:owner/repo"), "github:owner/repo");
|
||||
assert_eq!(to_flake_ref("gitlab:owner/repo"), "gitlab:owner/repo");
|
||||
assert_eq!(
|
||||
to_flake_ref("git+https://example.com/repo.git"),
|
||||
"git+https://example.com/repo.git"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("path:/some/local/path"),
|
||||
"path:/some/local/path"
|
||||
);
|
||||
assert_eq!(to_flake_ref("sourcehut:~user/repo"), "sourcehut:~user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggested_jobset_ordering() {
|
||||
let mut jobsets = vec![
|
||||
SuggestedJobset {
|
||||
name: "packages".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
description: "Packages".to_string(),
|
||||
priority: 6,
|
||||
},
|
||||
SuggestedJobset {
|
||||
name: "hydraJobs".to_string(),
|
||||
nix_expression: "hydraJobs".to_string(),
|
||||
description: "CI Jobs".to_string(),
|
||||
priority: 10,
|
||||
},
|
||||
SuggestedJobset {
|
||||
name: "checks".to_string(),
|
||||
nix_expression: "checks".to_string(),
|
||||
description: "Checks".to_string(),
|
||||
priority: 7,
|
||||
},
|
||||
];
|
||||
#[test]
|
||||
fn test_to_flake_ref_other_https() {
|
||||
assert_eq!(
|
||||
to_flake_ref("https://codeberg.org/owner/repo"),
|
||||
"git+https://codeberg.org/owner/repo"
|
||||
);
|
||||
assert_eq!(
|
||||
to_flake_ref("https://sr.ht/~user/repo"),
|
||||
"git+https://sr.ht/~user/repo"
|
||||
);
|
||||
}
|
||||
|
||||
jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
assert_eq!(jobsets[0].name, "hydraJobs");
|
||||
assert_eq!(jobsets[1].name, "checks");
|
||||
assert_eq!(jobsets[2].name, "packages");
|
||||
}
|
||||
#[test]
|
||||
fn test_suggested_jobset_ordering() {
|
||||
let mut jobsets = vec![
|
||||
SuggestedJobset {
|
||||
name: "packages".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
description: "Packages".to_string(),
|
||||
priority: 6,
|
||||
},
|
||||
SuggestedJobset {
|
||||
name: "hydraJobs".to_string(),
|
||||
nix_expression: "hydraJobs".to_string(),
|
||||
description: "CI Jobs".to_string(),
|
||||
priority: 10,
|
||||
},
|
||||
SuggestedJobset {
|
||||
name: "checks".to_string(),
|
||||
nix_expression: "checks".to_string(),
|
||||
description: "Checks".to_string(),
|
||||
priority: 7,
|
||||
},
|
||||
];
|
||||
|
||||
jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
assert_eq!(jobsets[0].name, "hydraJobs");
|
||||
assert_eq!(jobsets[1].name, "checks");
|
||||
assert_eq!(jobsets[2].name, "packages");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,294 +1,313 @@
|
|||
//! Notification dispatch for build events
|
||||
|
||||
use crate::config::{EmailConfig, NotificationsConfig};
|
||||
use crate::models::{Build, BuildStatus, Project};
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
config::{EmailConfig, NotificationsConfig},
|
||||
models::{Build, BuildStatus, Project},
|
||||
};
|
||||
|
||||
/// Dispatch all configured notifications for a completed build.
|
||||
pub async fn dispatch_build_finished(
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
commit_hash: &str,
|
||||
config: &NotificationsConfig,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
commit_hash: &str,
|
||||
config: &NotificationsConfig,
|
||||
) {
|
||||
// 1. Run command notification
|
||||
if let Some(ref cmd) = config.run_command {
|
||||
run_command_notification(cmd, build, project).await;
|
||||
}
|
||||
// 1. Run command notification
|
||||
if let Some(ref cmd) = config.run_command {
|
||||
run_command_notification(cmd, build, project).await;
|
||||
}
|
||||
|
||||
// 2. GitHub commit status
|
||||
if let Some(ref token) = config.github_token
|
||||
&& project.repository_url.contains("github.com") {
|
||||
set_github_status(token, &project.repository_url, commit_hash, build).await;
|
||||
}
|
||||
// 2. GitHub commit status
|
||||
if let Some(ref token) = config.github_token
|
||||
&& project.repository_url.contains("github.com")
|
||||
{
|
||||
set_github_status(token, &project.repository_url, commit_hash, build).await;
|
||||
}
|
||||
|
||||
// 3. Gitea/Forgejo commit status
|
||||
if let (Some(url), Some(token)) = (&config.gitea_url, &config.gitea_token) {
|
||||
set_gitea_status(url, token, &project.repository_url, commit_hash, build).await;
|
||||
}
|
||||
// 3. Gitea/Forgejo commit status
|
||||
if let (Some(url), Some(token)) = (&config.gitea_url, &config.gitea_token) {
|
||||
set_gitea_status(url, token, &project.repository_url, commit_hash, build)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 4. Email notification
|
||||
if let Some(ref email_config) = config.email
|
||||
&& (!email_config.on_failure_only || build.status == BuildStatus::Failed) {
|
||||
send_email_notification(email_config, build, project).await;
|
||||
}
|
||||
// 4. Email notification
|
||||
if let Some(ref email_config) = config.email
|
||||
&& (!email_config.on_failure_only || build.status == BuildStatus::Failed)
|
||||
{
|
||||
send_email_notification(email_config, build, project).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_command_notification(cmd: &str, build: &Build, project: &Project) {
|
||||
let status_str = match build.status {
|
||||
BuildStatus::Completed => "success",
|
||||
BuildStatus::Failed => "failure",
|
||||
BuildStatus::Cancelled => "cancelled",
|
||||
_ => "unknown",
|
||||
};
|
||||
let status_str = match build.status {
|
||||
BuildStatus::Completed => "success",
|
||||
BuildStatus::Failed => "failure",
|
||||
BuildStatus::Cancelled => "cancelled",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
let result = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.env("FC_BUILD_ID", build.id.to_string())
|
||||
.env("FC_BUILD_STATUS", status_str)
|
||||
.env("FC_BUILD_JOB", &build.job_name)
|
||||
.env("FC_BUILD_DRV", &build.drv_path)
|
||||
.env("FC_PROJECT_NAME", &project.name)
|
||||
.env("FC_PROJECT_URL", &project.repository_url)
|
||||
.env(
|
||||
"FC_BUILD_OUTPUT",
|
||||
build.build_output_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
.output()
|
||||
.await;
|
||||
let result = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.env("FC_BUILD_ID", build.id.to_string())
|
||||
.env("FC_BUILD_STATUS", status_str)
|
||||
.env("FC_BUILD_JOB", &build.job_name)
|
||||
.env("FC_BUILD_DRV", &build.drv_path)
|
||||
.env("FC_PROJECT_NAME", &project.name)
|
||||
.env("FC_PROJECT_URL", &project.repository_url)
|
||||
.env(
|
||||
"FC_BUILD_OUTPUT",
|
||||
build.build_output_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(build_id = %build.id, "RunCommand failed: {stderr}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "RunCommand completed successfully");
|
||||
}
|
||||
}
|
||||
Err(e) => error!(build_id = %build.id, "RunCommand execution failed: {e}"),
|
||||
}
|
||||
match result {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(build_id = %build.id, "RunCommand failed: {stderr}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "RunCommand completed successfully");
|
||||
}
|
||||
},
|
||||
Err(e) => error!(build_id = %build.id, "RunCommand execution failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_github_status(token: &str, repo_url: &str, commit: &str, build: &Build) {
|
||||
// Parse owner/repo from URL
|
||||
let (owner, repo) = match parse_github_repo(repo_url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("Cannot parse GitHub owner/repo from {repo_url}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
async fn set_github_status(
|
||||
token: &str,
|
||||
repo_url: &str,
|
||||
commit: &str,
|
||||
build: &Build,
|
||||
) {
|
||||
// Parse owner/repo from URL
|
||||
let (owner, repo) = match parse_github_repo(repo_url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("Cannot parse GitHub owner/repo from {repo_url}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let (state, description) = match build.status {
|
||||
BuildStatus::Completed => ("success", "Build succeeded"),
|
||||
BuildStatus::Failed => ("failure", "Build failed"),
|
||||
BuildStatus::Running => ("pending", "Build in progress"),
|
||||
BuildStatus::Pending => ("pending", "Build queued"),
|
||||
BuildStatus::Cancelled => ("error", "Build cancelled"),
|
||||
};
|
||||
let (state, description) = match build.status {
|
||||
BuildStatus::Completed => ("success", "Build succeeded"),
|
||||
BuildStatus::Failed => ("failure", "Build failed"),
|
||||
BuildStatus::Running => ("pending", "Build in progress"),
|
||||
BuildStatus::Pending => ("pending", "Build queued"),
|
||||
BuildStatus::Cancelled => ("error", "Build cancelled"),
|
||||
};
|
||||
|
||||
let url = format!("https://api.github.com/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let body = serde_json::json!({
|
||||
"state": state,
|
||||
"description": description,
|
||||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
let url =
|
||||
format!("https://api.github.com/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let body = serde_json::json!({
|
||||
"state": state,
|
||||
"description": description,
|
||||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.header("User-Agent", "fc-ci")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("GitHub status API returned {status}: {text}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "Set GitHub commit status: {state}");
|
||||
}
|
||||
}
|
||||
Err(e) => error!("GitHub status API request failed: {e}"),
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.header("User-Agent", "fc-ci")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("GitHub status API returned {status}: {text}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "Set GitHub commit status: {state}");
|
||||
}
|
||||
},
|
||||
Err(e) => error!("GitHub status API request failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_gitea_status(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
repo_url: &str,
|
||||
commit: &str,
|
||||
build: &Build,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
repo_url: &str,
|
||||
commit: &str,
|
||||
build: &Build,
|
||||
) {
|
||||
// Parse owner/repo from URL (try to extract from the gitea URL)
|
||||
let (owner, repo) = match parse_gitea_repo(repo_url, base_url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("Cannot parse Gitea owner/repo from {repo_url}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Parse owner/repo from URL (try to extract from the gitea URL)
|
||||
let (owner, repo) = match parse_gitea_repo(repo_url, base_url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("Cannot parse Gitea owner/repo from {repo_url}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
let (state, description) = match build.status {
|
||||
BuildStatus::Completed => ("success", "Build succeeded"),
|
||||
BuildStatus::Failed => ("failure", "Build failed"),
|
||||
BuildStatus::Running => ("pending", "Build in progress"),
|
||||
BuildStatus::Pending => ("pending", "Build queued"),
|
||||
BuildStatus::Cancelled => ("error", "Build cancelled"),
|
||||
};
|
||||
let (state, description) = match build.status {
|
||||
BuildStatus::Completed => ("success", "Build succeeded"),
|
||||
BuildStatus::Failed => ("failure", "Build failed"),
|
||||
BuildStatus::Running => ("pending", "Build in progress"),
|
||||
BuildStatus::Pending => ("pending", "Build queued"),
|
||||
BuildStatus::Cancelled => ("error", "Build cancelled"),
|
||||
};
|
||||
|
||||
let url = format!("{base_url}/api/v1/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let body = serde_json::json!({
|
||||
"state": state,
|
||||
"description": description,
|
||||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
let url = format!("{base_url}/api/v1/repos/{owner}/{repo}/statuses/{commit}");
|
||||
let body = serde_json::json!({
|
||||
"state": state,
|
||||
"description": description,
|
||||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Gitea status API returned {status}: {text}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "Set Gitea commit status: {state}");
|
||||
}
|
||||
}
|
||||
Err(e) => error!("Gitea status API request failed: {e}"),
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!("Gitea status API returned {status}: {text}");
|
||||
} else {
|
||||
info!(build_id = %build.id, "Set Gitea commit status: {state}");
|
||||
}
|
||||
},
|
||||
Err(e) => error!("Gitea status API request failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_github_repo(url: &str) -> Option<(String, String)> {
|
||||
// Handle https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
let url = url.trim_end_matches(".git");
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
// Handle https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
||||
let url = url.trim_end_matches(".git");
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("git@github.com:") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("git@github.com:") {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_gitea_repo(repo_url: &str, base_url: &str) -> Option<(String, String)> {
|
||||
let url = repo_url.trim_end_matches(".git");
|
||||
let base = base_url.trim_end_matches('/');
|
||||
if let Some(rest) = url.strip_prefix(&format!("{base}/")) {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
fn parse_gitea_repo(
|
||||
repo_url: &str,
|
||||
base_url: &str,
|
||||
) -> Option<(String, String)> {
|
||||
let url = repo_url.trim_end_matches(".git");
|
||||
let base = base_url.trim_end_matches('/');
|
||||
if let Some(rest) = url.strip_prefix(&format!("{base}/")) {
|
||||
let parts: Vec<&str> = rest.splitn(2, '/').collect();
|
||||
if parts.len() == 2 {
|
||||
return Some((parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn send_email_notification(config: &EmailConfig, build: &Build, project: &Project) {
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
async fn send_email_notification(
|
||||
config: &EmailConfig,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
) {
|
||||
use lettre::{
|
||||
AsyncSmtpTransport,
|
||||
AsyncTransport,
|
||||
Message,
|
||||
Tokio1Executor,
|
||||
message::header::ContentType,
|
||||
transport::smtp::authentication::Credentials,
|
||||
};
|
||||
|
||||
let status_str = match build.status {
|
||||
BuildStatus::Completed => "SUCCESS",
|
||||
BuildStatus::Failed => "FAILURE",
|
||||
BuildStatus::Cancelled => "CANCELLED",
|
||||
_ => "UNKNOWN",
|
||||
let status_str = match build.status {
|
||||
BuildStatus::Completed => "SUCCESS",
|
||||
BuildStatus::Failed => "FAILURE",
|
||||
BuildStatus::Cancelled => "CANCELLED",
|
||||
_ => "UNKNOWN",
|
||||
};
|
||||
|
||||
let subject = format!(
|
||||
"[FC] {} - {} ({})",
|
||||
status_str, build.job_name, project.name
|
||||
);
|
||||
|
||||
let body = format!(
|
||||
"Build notification from FC CI\n\nProject: {}\nJob: {}\nStatus: \
|
||||
{}\nDerivation: {}\nOutput: {}\nBuild ID: {}\n",
|
||||
project.name,
|
||||
build.job_name,
|
||||
status_str,
|
||||
build.drv_path,
|
||||
build.build_output_path.as_deref().unwrap_or("N/A"),
|
||||
build.id,
|
||||
);
|
||||
|
||||
for to_addr in &config.to_addresses {
|
||||
let email = match Message::builder()
|
||||
.from(match config.from_address.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
error!("Invalid from address '{}': {e}", config.from_address);
|
||||
return;
|
||||
},
|
||||
})
|
||||
.to(match to_addr.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!("Invalid to address '{to_addr}': {e}");
|
||||
continue;
|
||||
},
|
||||
})
|
||||
.subject(&subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.clone())
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!("Failed to build email: {e}");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
let subject = format!(
|
||||
"[FC] {} - {} ({})",
|
||||
status_str, build.job_name, project.name
|
||||
);
|
||||
let mut mailer_builder = if config.tls {
|
||||
match AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host) {
|
||||
Ok(b) => b.port(config.smtp_port),
|
||||
Err(e) => {
|
||||
error!("Failed to create SMTP transport: {e}");
|
||||
return;
|
||||
},
|
||||
}
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host)
|
||||
.port(config.smtp_port)
|
||||
};
|
||||
|
||||
let body = format!(
|
||||
"Build notification from FC CI\n\n\
|
||||
Project: {}\n\
|
||||
Job: {}\n\
|
||||
Status: {}\n\
|
||||
Derivation: {}\n\
|
||||
Output: {}\n\
|
||||
Build ID: {}\n",
|
||||
project.name,
|
||||
build.job_name,
|
||||
status_str,
|
||||
build.drv_path,
|
||||
build.build_output_path.as_deref().unwrap_or("N/A"),
|
||||
build.id,
|
||||
);
|
||||
|
||||
for to_addr in &config.to_addresses {
|
||||
let email = match Message::builder()
|
||||
.from(match config.from_address.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
error!("Invalid from address '{}': {e}", config.from_address);
|
||||
return;
|
||||
}
|
||||
})
|
||||
.to(match to_addr.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
warn!("Invalid to address '{to_addr}': {e}");
|
||||
continue;
|
||||
}
|
||||
})
|
||||
.subject(&subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.clone())
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!("Failed to build email: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut mailer_builder = if config.tls {
|
||||
match AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host) {
|
||||
Ok(b) => b.port(config.smtp_port),
|
||||
Err(e) => {
|
||||
error!("Failed to create SMTP transport: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host)
|
||||
.port(config.smtp_port)
|
||||
};
|
||||
|
||||
if let (Some(user), Some(pass)) = (&config.smtp_user, &config.smtp_password) {
|
||||
mailer_builder =
|
||||
mailer_builder.credentials(Credentials::new(user.clone(), pass.clone()));
|
||||
}
|
||||
|
||||
let mailer = mailer_builder.build();
|
||||
|
||||
match mailer.send(email).await {
|
||||
Ok(_) => {
|
||||
info!(build_id = %build.id, to = to_addr, "Email notification sent");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(build_id = %build.id, to = to_addr, "Failed to send email: {e}");
|
||||
}
|
||||
}
|
||||
if let (Some(user), Some(pass)) = (&config.smtp_user, &config.smtp_password)
|
||||
{
|
||||
mailer_builder = mailer_builder
|
||||
.credentials(Credentials::new(user.clone(), pass.clone()));
|
||||
}
|
||||
|
||||
let mailer = mailer_builder.build();
|
||||
|
||||
match mailer.send(email).await {
|
||||
Ok(_) => {
|
||||
info!(build_id = %build.id, to = to_addr, "Email notification sent");
|
||||
},
|
||||
Err(e) => {
|
||||
error!(build_id = %build.id, to = to_addr, "Failed to send email: {e}");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,89 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::ApiKey;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::ApiKey,
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, name: &str, key_hash: &str, role: &str) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(key_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict("API key with this hash already exists".to_string())
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
key_hash: &str,
|
||||
role: &str,
|
||||
) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(key_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict("API key with this hash already exists".to_string())
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, name: &str, key_hash: &str, role: &str) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (key_hash) DO UPDATE SET \
|
||||
name = EXCLUDED.name, \
|
||||
role = EXCLUDED.role \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
key_hash: &str,
|
||||
role: &str,
|
||||
) -> Result<ApiKey> {
|
||||
sqlx::query_as::<_, ApiKey>(
|
||||
"INSERT INTO api_keys (name, key_hash, role) VALUES ($1, $2, $3) ON \
|
||||
CONFLICT (key_hash) DO UPDATE SET name = EXCLUDED.name, role = \
|
||||
EXCLUDED.role RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(key_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get_by_hash(
|
||||
pool: &PgPool,
|
||||
key_hash: &str,
|
||||
) -> Result<Option<ApiKey>> {
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE key_hash = $1")
|
||||
.bind(key_hash)
|
||||
.bind(role)
|
||||
.fetch_one(pool)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get_by_hash(pool: &PgPool, key_hash: &str) -> Result<Option<ApiKey>> {
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE key_hash = $1")
|
||||
.bind(key_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<ApiKey>> {
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM api_keys WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("API key {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
let result = sqlx::query("DELETE FROM api_keys WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("API key {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn touch_last_used(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,92 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::BuildDependency;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::BuildDependency,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
dependency_build_id: Uuid,
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
dependency_build_id: Uuid,
|
||||
) -> Result<BuildDependency> {
|
||||
sqlx::query_as::<_, BuildDependency>(
|
||||
"INSERT INTO build_dependencies (build_id, dependency_build_id) VALUES ($1, $2) RETURNING *",
|
||||
)
|
||||
.bind(build_id)
|
||||
.bind(dependency_build_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Dependency from {build_id} to {dependency_build_id} already exists"
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildDependency>> {
|
||||
sqlx::query_as::<_, BuildDependency>("SELECT * FROM build_dependencies WHERE build_id = $1")
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Batch check if all dependency builds are completed for multiple builds at once.
|
||||
/// Returns a map from build_id to whether all deps are completed.
|
||||
pub async fn check_deps_for_builds(
|
||||
pool: &PgPool,
|
||||
build_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, bool>> {
|
||||
if build_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
sqlx::query_as::<_, BuildDependency>(
|
||||
"INSERT INTO build_dependencies (build_id, dependency_build_id) VALUES \
|
||||
($1, $2) RETURNING *",
|
||||
)
|
||||
.bind(build_id)
|
||||
.bind(dependency_build_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Dependency from {build_id} to {dependency_build_id} already exists"
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Find build_ids that have incomplete deps
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT bd.build_id FROM build_dependencies bd \
|
||||
JOIN builds b ON bd.dependency_build_id = b.id \
|
||||
WHERE bd.build_id = ANY($1) AND b.status != 'completed'",
|
||||
)
|
||||
.bind(build_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildDependency>> {
|
||||
sqlx::query_as::<_, BuildDependency>(
|
||||
"SELECT * FROM build_dependencies WHERE build_id = $1",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
let incomplete: std::collections::HashSet<Uuid> = rows.into_iter().map(|(id,)| id).collect();
|
||||
/// Batch check if all dependency builds are completed for multiple builds at
|
||||
/// once. Returns a map from build_id to whether all deps are completed.
|
||||
pub async fn check_deps_for_builds(
|
||||
pool: &PgPool,
|
||||
build_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, bool>> {
|
||||
if build_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
Ok(build_ids
|
||||
.iter()
|
||||
.map(|id| (*id, !incomplete.contains(id)))
|
||||
.collect())
|
||||
// Find build_ids that have incomplete deps
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT DISTINCT bd.build_id FROM build_dependencies bd JOIN builds b ON \
|
||||
bd.dependency_build_id = b.id WHERE bd.build_id = ANY($1) AND b.status \
|
||||
!= 'completed'",
|
||||
)
|
||||
.bind(build_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
let incomplete: std::collections::HashSet<Uuid> =
|
||||
rows.into_iter().map(|(id,)| id).collect();
|
||||
|
||||
Ok(
|
||||
build_ids
|
||||
.iter()
|
||||
.map(|id| (*id, !incomplete.contains(id)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if all dependency builds for a given build are completed.
|
||||
pub async fn all_deps_completed(pool: &PgPool, build_id: Uuid) -> Result<bool> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM build_dependencies bd \
|
||||
JOIN builds b ON bd.dependency_build_id = b.id \
|
||||
WHERE bd.build_id = $1 AND b.status != 'completed'",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM build_dependencies bd JOIN builds b ON \
|
||||
bd.dependency_build_id = b.id WHERE bd.build_id = $1 AND b.status != \
|
||||
'completed'",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(row.0 == 0)
|
||||
Ok(row.0 == 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,51 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{BuildProduct, CreateBuildProduct};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{BuildProduct, CreateBuildProduct},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuildProduct) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"INSERT INTO build_products (build_id, name, path, sha256_hash, file_size, content_type, is_directory) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.path)
|
||||
.bind(&input.sha256_hash)
|
||||
.bind(input.file_size)
|
||||
.bind(&input.content_type)
|
||||
.bind(input.is_directory)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildProduct,
|
||||
) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"INSERT INTO build_products (build_id, name, path, sha256_hash, \
|
||||
file_size, content_type, is_directory) VALUES ($1, $2, $3, $4, $5, $6, \
|
||||
$7) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.path)
|
||||
.bind(&input.sha256_hash)
|
||||
.bind(input.file_size)
|
||||
.bind(&input.content_type)
|
||||
.bind(input.is_directory)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>("SELECT * FROM build_products WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build product {id} not found")))
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build product {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildProduct>> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE build_id = $1 ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildProduct>> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE build_id = $1 ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,66 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{BuildStep, CreateBuildStep};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{BuildStep, CreateBuildStep},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuildStep) -> Result<BuildStep> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"INSERT INTO build_steps (build_id, step_number, command) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(input.step_number)
|
||||
.bind(&input.command)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build step {} already exists for this build",
|
||||
input.step_number
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildStep,
|
||||
) -> Result<BuildStep> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"INSERT INTO build_steps (build_id, step_number, command) VALUES ($1, $2, \
|
||||
$3) RETURNING *",
|
||||
)
|
||||
.bind(input.build_id)
|
||||
.bind(input.step_number)
|
||||
.bind(&input.command)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build step {} already exists for this build",
|
||||
input.step_number
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
exit_code: i32,
|
||||
output: Option<&str>,
|
||||
error_output: Option<&str>,
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
exit_code: i32,
|
||||
output: Option<&str>,
|
||||
error_output: Option<&str>,
|
||||
) -> Result<BuildStep> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"UPDATE build_steps SET completed_at = NOW(), exit_code = $1, output = $2, error_output = $3 WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(exit_code)
|
||||
.bind(output)
|
||||
.bind(error_output)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build step {id} not found")))
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"UPDATE build_steps SET completed_at = NOW(), exit_code = $1, output = \
|
||||
$2, error_output = $3 WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(exit_code)
|
||||
.bind(output)
|
||||
.bind(error_output)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build step {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_build(pool: &PgPool, build_id: Uuid) -> Result<Vec<BuildStep>> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"SELECT * FROM build_steps WHERE build_id = $1 ORDER BY step_number ASC",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
) -> Result<Vec<BuildStep>> {
|
||||
sqlx::query_as::<_, BuildStep>(
|
||||
"SELECT * FROM build_steps WHERE build_id = $1 ORDER BY step_number ASC",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,316 +1,335 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{Build, BuildStats, BuildStatus, CreateBuild};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{Build, BuildStats, BuildStatus, CreateBuild},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateBuild) -> Result<Build> {
|
||||
let is_aggregate = input.is_aggregate.unwrap_or(false);
|
||||
sqlx::query_as::<_, Build>(
|
||||
"INSERT INTO builds (evaluation_id, job_name, drv_path, status, system, outputs, is_aggregate, constituents) \
|
||||
VALUES ($1, $2, $3, 'pending', $4, $5, $6, $7) RETURNING *",
|
||||
)
|
||||
.bind(input.evaluation_id)
|
||||
.bind(&input.job_name)
|
||||
.bind(&input.drv_path)
|
||||
.bind(&input.system)
|
||||
.bind(&input.outputs)
|
||||
.bind(is_aggregate)
|
||||
.bind(&input.constituents)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build for job '{}' already exists in this evaluation",
|
||||
input.job_name
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
let is_aggregate = input.is_aggregate.unwrap_or(false);
|
||||
sqlx::query_as::<_, Build>(
|
||||
"INSERT INTO builds (evaluation_id, job_name, drv_path, status, system, \
|
||||
outputs, is_aggregate, constituents) VALUES ($1, $2, $3, 'pending', $4, \
|
||||
$5, $6, $7) RETURNING *",
|
||||
)
|
||||
.bind(input.evaluation_id)
|
||||
.bind(&input.job_name)
|
||||
.bind(&input.drv_path)
|
||||
.bind(&input.system)
|
||||
.bind(&input.outputs)
|
||||
.bind(is_aggregate)
|
||||
.bind(&input.constituents)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Build for job '{}' already exists in this evaluation",
|
||||
input.job_name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_completed_by_drv_path(pool: &PgPool, drv_path: &str) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE drv_path = $1 AND status = 'completed' LIMIT 1",
|
||||
)
|
||||
.bind(drv_path)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn get_completed_by_drv_path(
|
||||
pool: &PgPool,
|
||||
drv_path: &str,
|
||||
) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE drv_path = $1 AND status = 'completed' LIMIT 1",
|
||||
)
|
||||
.bind(drv_path)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>("SELECT * FROM builds WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_evaluation(pool: &PgPool, evaluation_id: Uuid) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE evaluation_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_pending(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
WHERE b.status = 'pending' \
|
||||
ORDER BY b.priority DESC, j.scheduling_shares DESC, b.created_at ASC \
|
||||
LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Atomically claim a pending build by setting it to running.
|
||||
/// Returns `None` if the build was already claimed by another worker.
|
||||
pub async fn start(pool: &PgPool, id: Uuid) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'running', started_at = NOW() WHERE id = $1 AND status = 'pending' RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
status: BuildStatus,
|
||||
log_path: Option<&str>,
|
||||
build_output_path: Option<&str>,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = $1, completed_at = NOW(), log_path = $2, build_output_path = $3, error_message = $4 WHERE id = $5 RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(log_path)
|
||||
.bind(build_output_path)
|
||||
.bind(error_message)
|
||||
sqlx::query_as::<_, Build>("SELECT * FROM builds WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_recent(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>("SELECT * FROM builds ORDER BY created_at DESC LIMIT $1")
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_evaluation(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE evaluation_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b \
|
||||
JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id \
|
||||
WHERE j.project_id = $1 \
|
||||
ORDER BY b.created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_pending(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id WHERE b.status = 'pending' ORDER BY \
|
||||
b.priority DESC, j.scheduling_shares DESC, b.created_at ASC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Atomically claim a pending build by setting it to running.
|
||||
/// Returns `None` if the build was already claimed by another worker.
|
||||
pub async fn start(pool: &PgPool, id: Uuid) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'running', started_at = NOW() WHERE id = $1 \
|
||||
AND status = 'pending' RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
status: BuildStatus,
|
||||
log_path: Option<&str>,
|
||||
build_output_path: Option<&str>,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = $1, completed_at = NOW(), log_path = $2, \
|
||||
build_output_path = $3, error_message = $4 WHERE id = $5 RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(log_path)
|
||||
.bind(build_output_path)
|
||||
.bind(error_message)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_recent(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds ORDER BY created_at DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT b.* FROM builds b JOIN evaluations e ON b.evaluation_id = e.id \
|
||||
JOIN jobsets j ON e.jobset_id = j.id WHERE j.project_id = $1 ORDER BY \
|
||||
b.created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get_stats(pool: &PgPool) -> Result<BuildStats> {
|
||||
sqlx::query_as::<_, BuildStats>("SELECT * FROM build_stats")
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Reset builds that were left in 'running' state (orphaned by a crashed runner).
|
||||
/// Limited to 50 builds per call to prevent thundering herd.
|
||||
pub async fn reset_orphaned(pool: &PgPool, older_than_secs: i64) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL \
|
||||
WHERE id IN (SELECT id FROM builds WHERE status = 'running' \
|
||||
AND started_at < NOW() - make_interval(secs => $1) LIMIT 50)",
|
||||
)
|
||||
.bind(older_than_secs)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List builds with optional evaluation_id, status, system, and job_name filters, with pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
system: Option<&str>,
|
||||
job_name: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds \
|
||||
WHERE ($1::uuid IS NULL OR evaluation_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%') \
|
||||
ORDER BY created_at DESC LIMIT $5 OFFSET $6",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
.bind(system)
|
||||
.bind(job_name)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
sqlx::query_as::<_, BuildStats>("SELECT * FROM build_stats")
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Reset builds that were left in 'running' state (orphaned by a crashed
|
||||
/// runner). Limited to 50 builds per call to prevent thundering herd.
|
||||
pub async fn reset_orphaned(
|
||||
pool: &PgPool,
|
||||
older_than_secs: i64,
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL WHERE id IN \
|
||||
(SELECT id FROM builds WHERE status = 'running' AND started_at < NOW() - \
|
||||
make_interval(secs => $1) LIMIT 50)",
|
||||
)
|
||||
.bind(older_than_secs)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List builds with optional evaluation_id, status, system, and job_name
|
||||
/// filters, with pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
system: Option<&str>,
|
||||
job_name: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE ($1::uuid IS NULL OR evaluation_id = $1) AND \
|
||||
($2::text IS NULL OR status = $2) AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%') ORDER BY \
|
||||
created_at DESC LIMIT $5 OFFSET $6",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
.bind(system)
|
||||
.bind(job_name)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
system: Option<&str>,
|
||||
job_name: Option<&str>,
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
system: Option<&str>,
|
||||
job_name: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM builds \
|
||||
WHERE ($1::uuid IS NULL OR evaluation_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
AND ($3::text IS NULL OR system = $3) \
|
||||
AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%')",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
.bind(system)
|
||||
.bind(job_name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM builds WHERE ($1::uuid IS NULL OR evaluation_id = \
|
||||
$1) AND ($2::text IS NULL OR status = $2) AND ($3::text IS NULL OR \
|
||||
system = $3) AND ($4::text IS NULL OR job_name ILIKE '%' || $4 || '%')",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(status)
|
||||
.bind(system)
|
||||
.bind(job_name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn cancel(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'cancelled', completed_at = NOW() WHERE id = $1 AND status IN ('pending', 'running') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CiError::NotFound(format!(
|
||||
"Build {id} not found or not in a cancellable state"
|
||||
))
|
||||
})
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'cancelled', completed_at = NOW() WHERE id = \
|
||||
$1 AND status IN ('pending', 'running') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CiError::NotFound(format!(
|
||||
"Build {id} not found or not in a cancellable state"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancel a build and all its transitive dependents.
|
||||
pub async fn cancel_cascade(pool: &PgPool, id: Uuid) -> Result<Vec<Build>> {
|
||||
let mut cancelled = Vec::new();
|
||||
let mut cancelled = Vec::new();
|
||||
|
||||
// Cancel the target build
|
||||
if let Ok(build) = cancel(pool, id).await {
|
||||
// Cancel the target build
|
||||
if let Ok(build) = cancel(pool, id).await {
|
||||
cancelled.push(build);
|
||||
}
|
||||
|
||||
// Find and cancel all dependents recursively
|
||||
let mut to_cancel: Vec<Uuid> = vec![id];
|
||||
while let Some(build_id) = to_cancel.pop() {
|
||||
let dependents: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT build_id FROM build_dependencies WHERE dependency_build_id = $1",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
for (dep_id,) in dependents {
|
||||
if let Ok(build) = cancel(pool, dep_id).await {
|
||||
to_cancel.push(dep_id);
|
||||
cancelled.push(build);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find and cancel all dependents recursively
|
||||
let mut to_cancel: Vec<Uuid> = vec![id];
|
||||
while let Some(build_id) = to_cancel.pop() {
|
||||
let dependents: Vec<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT build_id FROM build_dependencies WHERE dependency_build_id = $1",
|
||||
)
|
||||
.bind(build_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
for (dep_id,) in dependents {
|
||||
if let Ok(build) = cancel(pool, dep_id).await {
|
||||
to_cancel.push(dep_id);
|
||||
cancelled.push(build);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cancelled)
|
||||
Ok(cancelled)
|
||||
}
|
||||
|
||||
/// Restart a build by resetting it to pending state.
|
||||
/// Only works for failed, completed, or cancelled builds.
|
||||
pub async fn restart(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, completed_at = NULL, \
|
||||
log_path = NULL, build_output_path = NULL, error_message = NULL, \
|
||||
retry_count = retry_count + 1 \
|
||||
WHERE id = $1 AND status IN ('failed', 'completed', 'cancelled') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CiError::NotFound(format!(
|
||||
"Build {id} not found or not in a restartable state"
|
||||
))
|
||||
})
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, completed_at = \
|
||||
NULL, log_path = NULL, build_output_path = NULL, error_message = NULL, \
|
||||
retry_count = retry_count + 1 WHERE id = $1 AND status IN ('failed', \
|
||||
'completed', 'cancelled') RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
CiError::NotFound(format!(
|
||||
"Build {id} not found or not in a restartable state"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a build's outputs as signed.
|
||||
pub async fn mark_signed(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE builds SET signed = true WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
sqlx::query("UPDATE builds SET signed = true WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Batch-fetch completed builds by derivation paths.
|
||||
/// Returns a map from drv_path to Build for deduplication.
|
||||
pub async fn get_completed_by_drv_paths(
|
||||
pool: &PgPool,
|
||||
drv_paths: &[String],
|
||||
pool: &PgPool,
|
||||
drv_paths: &[String],
|
||||
) -> Result<std::collections::HashMap<String, Build>> {
|
||||
if drv_paths.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT DISTINCT ON (drv_path) * FROM builds \
|
||||
WHERE drv_path = ANY($1) AND status = 'completed' \
|
||||
ORDER BY drv_path, completed_at DESC",
|
||||
)
|
||||
.bind(drv_paths)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
if drv_paths.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT DISTINCT ON (drv_path) * FROM builds WHERE drv_path = ANY($1) AND \
|
||||
status = 'completed' ORDER BY drv_path, completed_at DESC",
|
||||
)
|
||||
.bind(drv_paths)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(builds
|
||||
.into_iter()
|
||||
.map(|b| (b.drv_path.clone(), b))
|
||||
.collect())
|
||||
Ok(
|
||||
builds
|
||||
.into_iter()
|
||||
.map(|b| (b.drv_path.clone(), b))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the builder_id for a build.
|
||||
pub async fn set_builder(pool: &PgPool, id: Uuid, builder_id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE builds SET builder_id = $1 WHERE id = $2")
|
||||
.bind(builder_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
pub async fn set_builder(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
builder_id: Uuid,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE builds SET builder_id = $1 WHERE id = $2")
|
||||
.bind(builder_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +1,129 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{Channel, CreateChannel};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{Channel, CreateChannel},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateChannel) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channels (project_id, name, jobset_id) \
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(input.jobset_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => CiError::Conflict(
|
||||
format!("Channel '{}' already exists for this project", input.name),
|
||||
),
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channels (project_id, name, jobset_id) VALUES ($1, $2, $3) \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(input.jobset_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Channel '{}' already exists for this project",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Channel {id} not found")))
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Channel {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE project_id = $1 ORDER BY name")
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"SELECT * FROM channels WHERE project_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &PgPool) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Promote an evaluation to a channel (set it as the current evaluation).
|
||||
pub async fn promote(pool: &PgPool, channel_id: Uuid, evaluation_id: Uuid) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"UPDATE channels SET current_evaluation_id = $1, updated_at = NOW() \
|
||||
WHERE id = $2 RETURNING *",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Channel {channel_id} not found")))
|
||||
pub async fn promote(
|
||||
pool: &PgPool,
|
||||
channel_id: Uuid,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"UPDATE channels SET current_evaluation_id = $1, updated_at = NOW() WHERE \
|
||||
id = $2 RETURNING *",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Channel {channel_id} not found")))
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Channel {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the channel for a jobset and auto-promote if all builds in the evaluation succeeded.
|
||||
pub async fn auto_promote_if_complete(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<()> {
|
||||
// Check if all builds for this evaluation are completed
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'completed') \
|
||||
FROM builds WHERE evaluation_id = $1",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.fetch_one(pool)
|
||||
let result = sqlx::query("DELETE FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
let (total, completed) = row;
|
||||
if total == 0 || total != completed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// All builds completed — promote to any channels tracking this jobset
|
||||
let channels = sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE jobset_id = $1")
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
for channel in channels {
|
||||
let _ = promote(pool, channel.id, evaluation_id).await;
|
||||
tracing::info!(
|
||||
channel = %channel.name,
|
||||
evaluation_id = %evaluation_id,
|
||||
"Auto-promoted evaluation to channel"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Channel {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the channel for a jobset and auto-promote if all builds in the
|
||||
/// evaluation succeeded.
|
||||
pub async fn auto_promote_if_complete(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
evaluation_id: Uuid,
|
||||
) -> Result<()> {
|
||||
// Check if all builds for this evaluation are completed
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'completed') FROM \
|
||||
builds WHERE evaluation_id = $1",
|
||||
)
|
||||
.bind(evaluation_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
let (total, completed) = row;
|
||||
if total == 0 || total != completed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// All builds completed — promote to any channels tracking this jobset
|
||||
let channels =
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE jobset_id = $1")
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
for channel in channels {
|
||||
let _ = promote(pool, channel.id, evaluation_id).await;
|
||||
tracing::info!(
|
||||
channel = %channel.name,
|
||||
evaluation_id = %evaluation_id,
|
||||
"Auto-promoted evaluation to channel"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,167 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateEvaluation, Evaluation, EvaluationStatus};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateEvaluation, Evaluation, EvaluationStatus},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateEvaluation) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"INSERT INTO evaluations (jobset_id, commit_hash, status) VALUES ($1, $2, 'pending') RETURNING *",
|
||||
)
|
||||
.bind(input.jobset_id)
|
||||
.bind(&input.commit_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Evaluation for commit '{}' already exists in this jobset",
|
||||
input.commit_hash
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateEvaluation,
|
||||
) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"INSERT INTO evaluations (jobset_id, commit_hash, status) VALUES ($1, $2, \
|
||||
'pending') RETURNING *",
|
||||
)
|
||||
.bind(input.jobset_id)
|
||||
.bind(&input.commit_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Evaluation for commit '{}' already exists in this jobset",
|
||||
input.commit_hash
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>("SELECT * FROM evaluations WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_jobset(pool: &PgPool, jobset_id: Uuid) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time DESC",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List evaluations with optional jobset_id and status filters, with pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations \
|
||||
WHERE ($1::uuid IS NULL OR jobset_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2) \
|
||||
ORDER BY evaluation_time DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM evaluations \
|
||||
WHERE ($1::uuid IS NULL OR jobset_id = $1) \
|
||||
AND ($2::text IS NULL OR status = $2)",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
status: EvaluationStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"UPDATE evaluations SET status = $1, error_message = $2 WHERE id = $3 RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(error_message)
|
||||
sqlx::query_as::<_, Evaluation>("SELECT * FROM evaluations WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn get_latest(pool: &PgPool, jobset_id: Uuid) -> Result<Option<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time \
|
||||
DESC",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List evaluations with optional jobset_id and status filters, with
|
||||
/// pagination.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE ($1::uuid IS NULL OR jobset_id = $1) AND \
|
||||
($2::text IS NULL OR status = $2) ORDER BY evaluation_time DESC LIMIT $3 \
|
||||
OFFSET $4",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM evaluations WHERE ($1::uuid IS NULL OR jobset_id = \
|
||||
$1) AND ($2::text IS NULL OR status = $2)",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(status)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
status: EvaluationStatus,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"UPDATE evaluations SET status = $1, error_message = $2 WHERE id = $3 \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(error_message)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn get_latest(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Option<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 ORDER BY evaluation_time \
|
||||
DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Set the inputs hash for an evaluation (used for eval caching).
|
||||
pub async fn set_inputs_hash(pool: &PgPool, id: Uuid, hash: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE evaluations SET inputs_hash = $1 WHERE id = $2")
|
||||
.bind(hash)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
pub async fn set_inputs_hash(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
hash: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE evaluations SET inputs_hash = $1 WHERE id = $2")
|
||||
.bind(hash)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an evaluation with the same inputs_hash already exists for this jobset.
|
||||
/// Check if an evaluation with the same inputs_hash already exists for this
|
||||
/// jobset.
|
||||
pub async fn get_by_inputs_hash(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
inputs_hash: &str,
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
inputs_hash: &str,
|
||||
) -> Result<Option<Evaluation>> {
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 AND inputs_hash = $2 \
|
||||
AND status = 'completed' ORDER BY evaluation_time DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(inputs_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, Evaluation>(
|
||||
"SELECT * FROM evaluations WHERE jobset_id = $1 AND inputs_hash = $2 AND \
|
||||
status = 'completed' ORDER BY evaluation_time DESC LIMIT 1",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(inputs_hash)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,62 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::JobsetInput;
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::JobsetInput,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
name: &str,
|
||||
input_type: &str,
|
||||
value: &str,
|
||||
revision: Option<&str>,
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
name: &str,
|
||||
input_type: &str,
|
||||
value: &str,
|
||||
revision: Option<&str>,
|
||||
) -> Result<JobsetInput> {
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(name)
|
||||
.bind(input_type)
|
||||
.bind(value)
|
||||
.bind(revision)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Input '{name}' already exists in this jobset"))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"INSERT INTO jobset_inputs (jobset_id, name, input_type, value, revision) \
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.bind(name)
|
||||
.bind(input_type)
|
||||
.bind(value)
|
||||
.bind(revision)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Input '{name}' already exists in this jobset"
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_jobset(pool: &PgPool, jobset_id: Uuid) -> Result<Vec<JobsetInput>> {
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"SELECT * FROM jobset_inputs WHERE jobset_id = $1 ORDER BY name ASC",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
) -> Result<Vec<JobsetInput>> {
|
||||
sqlx::query_as::<_, JobsetInput>(
|
||||
"SELECT * FROM jobset_inputs WHERE jobset_id = $1 ORDER BY name ASC",
|
||||
)
|
||||
.bind(jobset_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM jobset_inputs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Jobset input {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
let result = sqlx::query("DELETE FROM jobset_inputs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Jobset input {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +1,169 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{ActiveJobset, CreateJobset, Jobset, UpdateJobset};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{ActiveJobset, CreateJobset, Jobset, UpdateJobset},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let enabled = input.enabled.unwrap_or(true);
|
||||
let flake_mode = input.flake_mode.unwrap_or(true);
|
||||
let check_interval = input.check_interval.unwrap_or(60);
|
||||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
let enabled = input.enabled.unwrap_or(true);
|
||||
let flake_mode = input.flake_mode.unwrap_or(true);
|
||||
let check_interval = input.check_interval.unwrap_or(60);
|
||||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&input.branch)
|
||||
.bind(scheduling_shares)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Jobset '{}' already exists in this project", input.name))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, \
|
||||
flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, \
|
||||
$3, $4, $5, $6, $7, $8) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&input.branch)
|
||||
.bind(scheduling_shares)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Jobset '{}' already exists in this project",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Jobset> {
|
||||
sqlx::query_as::<_, Jobset>("SELECT * FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Jobset {id} not found")))
|
||||
sqlx::query_as::<_, Jobset>("SELECT * FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Jobset {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Jobset>> {
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"SELECT * FROM jobsets WHERE project_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"SELECT * FROM jobsets WHERE project_id = $1 ORDER BY created_at DESC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE project_id = $1")
|
||||
.bind(project_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: UpdateJobset,
|
||||
) -> Result<Jobset> {
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
let nix_expression = input.nix_expression.unwrap_or(existing.nix_expression);
|
||||
let enabled = input.enabled.unwrap_or(existing.enabled);
|
||||
let flake_mode = input.flake_mode.unwrap_or(existing.flake_mode);
|
||||
let check_interval = input.check_interval.unwrap_or(existing.check_interval);
|
||||
let branch = input.branch.or(existing.branch);
|
||||
let scheduling_shares = input
|
||||
.scheduling_shares
|
||||
.unwrap_or(existing.scheduling_shares);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"UPDATE jobsets SET name = $1, nix_expression = $2, enabled = $3, \
|
||||
flake_mode = $4, check_interval = $5, branch = $6, scheduling_shares = \
|
||||
$7 WHERE id = $8 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&branch)
|
||||
.bind(scheduling_shares)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Jobset '{name}' already exists in this project"
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Jobset {id} not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let enabled = input.enabled.unwrap_or(true);
|
||||
let flake_mode = input.flake_mode.unwrap_or(true);
|
||||
let check_interval = input.check_interval.unwrap_or(60);
|
||||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, \
|
||||
flake_mode, check_interval, branch, scheduling_shares) VALUES ($1, $2, \
|
||||
$3, $4, $5, $6, $7, $8) ON CONFLICT (project_id, name) DO UPDATE SET \
|
||||
nix_expression = EXCLUDED.nix_expression, enabled = EXCLUDED.enabled, \
|
||||
flake_mode = EXCLUDED.flake_mode, check_interval = \
|
||||
EXCLUDED.check_interval, branch = EXCLUDED.branch, scheduling_shares = \
|
||||
EXCLUDED.scheduling_shares RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&input.branch)
|
||||
.bind(scheduling_shares)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_active(pool: &PgPool) -> Result<Vec<ActiveJobset>> {
|
||||
sqlx::query_as::<_, ActiveJobset>("SELECT * FROM active_jobsets")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE project_id = $1")
|
||||
.bind(project_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, input: UpdateJobset) -> Result<Jobset> {
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
let nix_expression = input.nix_expression.unwrap_or(existing.nix_expression);
|
||||
let enabled = input.enabled.unwrap_or(existing.enabled);
|
||||
let flake_mode = input.flake_mode.unwrap_or(existing.flake_mode);
|
||||
let check_interval = input.check_interval.unwrap_or(existing.check_interval);
|
||||
let branch = input.branch.or(existing.branch);
|
||||
let scheduling_shares = input
|
||||
.scheduling_shares
|
||||
.unwrap_or(existing.scheduling_shares);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"UPDATE jobsets SET name = $1, nix_expression = $2, enabled = $3, flake_mode = $4, check_interval = $5, branch = $6, scheduling_shares = $7 WHERE id = $8 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&branch)
|
||||
.bind(scheduling_shares)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Jobset '{name}' already exists in this project"))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Jobset {id} not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let enabled = input.enabled.unwrap_or(true);
|
||||
let flake_mode = input.flake_mode.unwrap_or(true);
|
||||
let check_interval = input.check_interval.unwrap_or(60);
|
||||
let scheduling_shares = input.scheduling_shares.unwrap_or(100);
|
||||
|
||||
sqlx::query_as::<_, Jobset>(
|
||||
"INSERT INTO jobsets (project_id, name, nix_expression, enabled, flake_mode, check_interval, branch, scheduling_shares) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET \
|
||||
nix_expression = EXCLUDED.nix_expression, \
|
||||
enabled = EXCLUDED.enabled, \
|
||||
flake_mode = EXCLUDED.flake_mode, \
|
||||
check_interval = EXCLUDED.check_interval, \
|
||||
branch = EXCLUDED.branch, \
|
||||
scheduling_shares = EXCLUDED.scheduling_shares \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.nix_expression)
|
||||
.bind(enabled)
|
||||
.bind(flake_mode)
|
||||
.bind(check_interval)
|
||||
.bind(&input.branch)
|
||||
.bind(scheduling_shares)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_active(pool: &PgPool) -> Result<Vec<ActiveJobset>> {
|
||||
sqlx::query_as::<_, ActiveJobset>("SELECT * FROM active_jobsets")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,60 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateNotificationConfig, NotificationConfig};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateNotificationConfig, NotificationConfig},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateNotificationConfig) -> Result<NotificationConfig> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"INSERT INTO notification_configs (project_id, notification_type, config) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.notification_type)
|
||||
.bind(&input.config)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Notification config '{}' already exists for this project",
|
||||
input.notification_type
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateNotificationConfig,
|
||||
) -> Result<NotificationConfig> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"INSERT INTO notification_configs (project_id, notification_type, config) \
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.notification_type)
|
||||
.bind(&input.config)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Notification config '{}' already exists for this project",
|
||||
input.notification_type
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<NotificationConfig>> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"SELECT * FROM notification_configs WHERE project_id = $1 AND enabled = true ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<NotificationConfig>> {
|
||||
sqlx::query_as::<_, NotificationConfig>(
|
||||
"SELECT * FROM notification_configs WHERE project_id = $1 AND enabled = \
|
||||
true ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM notification_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!(
|
||||
"Notification config {id} not found"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
let result = sqlx::query("DELETE FROM notification_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!(
|
||||
"Notification config {id} not found"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +1,125 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateProject, Project, UpdateProject};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateProject, Project, UpdateProject},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.repository_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{}' already exists", input.name))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
$3) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.repository_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{}' already exists", input.name))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Project {id} not found")))
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Project {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn get_by_name(pool: &PgPool, name: &str) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE name = $1")
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Project '{name}' not found")))
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE name = $1")
|
||||
.bind(name)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Project '{name}' not found")))
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Project>> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Project>> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(pool: &PgPool, id: Uuid, input: UpdateProject) -> Result<Project> {
|
||||
// Build dynamic update — only set provided fields
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
let description = input.description.or(existing.description);
|
||||
let repository_url = input.repository_url.unwrap_or(existing.repository_url);
|
||||
|
||||
sqlx::query_as::<_, Project>(
|
||||
"UPDATE projects SET name = $1, description = $2, repository_url = $3 WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
.bind(&repository_url)
|
||||
.bind(id)
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{name}' already exists"))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: UpdateProject,
|
||||
) -> Result<Project> {
|
||||
// Build dynamic update — only set provided fields
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
let description = input.description.or(existing.description);
|
||||
let repository_url = input.repository_url.unwrap_or(existing.repository_url);
|
||||
|
||||
sqlx::query_as::<_, Project>(
|
||||
"UPDATE projects SET name = $1, description = $2, repository_url = $3 \
|
||||
WHERE id = $4 RETURNING *",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
.bind(&repository_url)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Project '{name}' already exists"))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (name) DO UPDATE SET \
|
||||
description = EXCLUDED.description, \
|
||||
repository_url = EXCLUDED.repository_url \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.repository_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
$3) ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description, \
|
||||
repository_url = EXCLUDED.repository_url RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.description)
|
||||
.bind(&input.repository_url)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Project {id} not found")));
|
||||
}
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Project {id} not found")));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +1,135 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateRemoteBuilder, RemoteBuilder};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateRemoteBuilder, RemoteBuilder},
|
||||
};
|
||||
|
||||
pub async fn create(pool: &PgPool, input: CreateRemoteBuilder) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, speed_factor, \
|
||||
supported_features, mandatory_features, public_host_key, ssh_key_file) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
.bind(&input.systems)
|
||||
.bind(input.max_jobs.unwrap_or(1))
|
||||
.bind(input.speed_factor.unwrap_or(1))
|
||||
.bind(input.supported_features.as_deref().unwrap_or(&[]))
|
||||
.bind(input.mandatory_features.as_deref().unwrap_or(&[]))
|
||||
.bind(&input.public_host_key)
|
||||
.bind(&input.ssh_key_file)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!("Remote builder '{}' already exists", input.name))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateRemoteBuilder,
|
||||
) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, \
|
||||
speed_factor, supported_features, mandatory_features, public_host_key, \
|
||||
ssh_key_file) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
.bind(&input.systems)
|
||||
.bind(input.max_jobs.unwrap_or(1))
|
||||
.bind(input.speed_factor.unwrap_or(1))
|
||||
.bind(input.supported_features.as_deref().unwrap_or(&[]))
|
||||
.bind(input.mandatory_features.as_deref().unwrap_or(&[]))
|
||||
.bind(&input.public_host_key)
|
||||
.bind(&input.ssh_key_file)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Remote builder '{}' already exists",
|
||||
input.name
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>("SELECT * FROM remote_builders WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders ORDER BY speed_factor DESC, name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders ORDER BY speed_factor DESC, name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_enabled(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true ORDER BY speed_factor DESC, name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true ORDER BY speed_factor \
|
||||
DESC, name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Find a suitable builder for the given system.
|
||||
pub async fn find_for_system(pool: &PgPool, system: &str) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true AND $1 = ANY(systems) \
|
||||
ORDER BY speed_factor DESC",
|
||||
)
|
||||
.bind(system)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn find_for_system(
|
||||
pool: &PgPool,
|
||||
system: &str,
|
||||
) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true AND $1 = ANY(systems) \
|
||||
ORDER BY speed_factor DESC",
|
||||
)
|
||||
.bind(system)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: crate::models::UpdateRemoteBuilder,
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: crate::models::UpdateRemoteBuilder,
|
||||
) -> Result<RemoteBuilder> {
|
||||
// Build dynamic update — use COALESCE pattern
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"UPDATE remote_builders SET \
|
||||
name = COALESCE($1, name), \
|
||||
ssh_uri = COALESCE($2, ssh_uri), \
|
||||
systems = COALESCE($3, systems), \
|
||||
max_jobs = COALESCE($4, max_jobs), \
|
||||
speed_factor = COALESCE($5, speed_factor), \
|
||||
supported_features = COALESCE($6, supported_features), \
|
||||
mandatory_features = COALESCE($7, mandatory_features), \
|
||||
enabled = COALESCE($8, enabled), \
|
||||
public_host_key = COALESCE($9, public_host_key), \
|
||||
ssh_key_file = COALESCE($10, ssh_key_file) \
|
||||
WHERE id = $11 RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
.bind(&input.systems)
|
||||
.bind(input.max_jobs)
|
||||
.bind(input.speed_factor)
|
||||
.bind(&input.supported_features)
|
||||
.bind(&input.mandatory_features)
|
||||
.bind(input.enabled)
|
||||
.bind(&input.public_host_key)
|
||||
.bind(&input.ssh_key_file)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
// Build dynamic update — use COALESCE pattern
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"UPDATE remote_builders SET name = COALESCE($1, name), ssh_uri = \
|
||||
COALESCE($2, ssh_uri), systems = COALESCE($3, systems), max_jobs = \
|
||||
COALESCE($4, max_jobs), speed_factor = COALESCE($5, speed_factor), \
|
||||
supported_features = COALESCE($6, supported_features), \
|
||||
mandatory_features = COALESCE($7, mandatory_features), enabled = \
|
||||
COALESCE($8, enabled), public_host_key = COALESCE($9, public_host_key), \
|
||||
ssh_key_file = COALESCE($10, ssh_key_file) WHERE id = $11 RETURNING *",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.ssh_uri)
|
||||
.bind(&input.systems)
|
||||
.bind(input.max_jobs)
|
||||
.bind(input.speed_factor)
|
||||
.bind(&input.supported_features)
|
||||
.bind(&input.mandatory_features)
|
||||
.bind(input.enabled)
|
||||
.bind(&input.public_host_key)
|
||||
.bind(&input.ssh_key_file)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM remote_builders WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Remote builder {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
let result = sqlx::query("DELETE FROM remote_builders WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Remote builder {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM remote_builders")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM remote_builders")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,85 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{CiError, Result};
|
||||
use crate::models::{CreateWebhookConfig, WebhookConfig};
|
||||
use crate::{
|
||||
error::{CiError, Result},
|
||||
models::{CreateWebhookConfig, WebhookConfig},
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateWebhookConfig,
|
||||
secret_hash: Option<&str>,
|
||||
pool: &PgPool,
|
||||
input: CreateWebhookConfig,
|
||||
secret_hash: Option<&str>,
|
||||
) -> Result<WebhookConfig> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"INSERT INTO webhook_configs (project_id, forge_type, secret_hash) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.forge_type)
|
||||
.bind(secret_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Webhook config for forge '{}' already exists for this project",
|
||||
input.forge_type
|
||||
))
|
||||
}
|
||||
_ => CiError::Database(e),
|
||||
})
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"INSERT INTO webhook_configs (project_id, forge_type, secret_hash) VALUES \
|
||||
($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(input.project_id)
|
||||
.bind(&input.forge_type)
|
||||
.bind(secret_hash)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match &e {
|
||||
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => {
|
||||
CiError::Conflict(format!(
|
||||
"Webhook config for forge '{}' already exists for this project",
|
||||
input.forge_type
|
||||
))
|
||||
},
|
||||
_ => CiError::Database(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<WebhookConfig> {
|
||||
sqlx::query_as::<_, WebhookConfig>("SELECT * FROM webhook_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Webhook config {id} not found")))
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| CiError::NotFound(format!("Webhook config {id} not found")))
|
||||
}
|
||||
|
||||
pub async fn list_for_project(pool: &PgPool, project_id: Uuid) -> Result<Vec<WebhookConfig>> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<WebhookConfig>> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 ORDER BY created_at \
|
||||
DESC",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn get_by_project_and_forge(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
forge_type: &str,
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
forge_type: &str,
|
||||
) -> Result<Option<WebhookConfig>> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 AND forge_type = $2 AND enabled = true",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(forge_type)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE project_id = $1 AND forge_type = $2 \
|
||||
AND enabled = true",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(forge_type)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM webhook_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Webhook config {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
let result = sqlx::query("DELETE FROM webhook_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(CiError::NotFound(format!("Webhook config {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Tracing initialization helper for all FC daemons.
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt;
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
use crate::config::TracingConfig;
|
||||
|
||||
|
|
@ -10,42 +9,42 @@ use crate::config::TracingConfig;
|
|||
/// Respects `RUST_LOG` environment variable as an override. If `RUST_LOG` is
|
||||
/// not set, falls back to the configured level.
|
||||
pub fn init_tracing(config: &TracingConfig) {
|
||||
let env_filter =
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.level));
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.level));
|
||||
|
||||
match config.format.as_str() {
|
||||
"json" => {
|
||||
let builder = fmt()
|
||||
.json()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
"full" => {
|
||||
let builder = fmt()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// "compact" or any other value
|
||||
let builder = fmt()
|
||||
.compact()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
}
|
||||
}
|
||||
match config.format.as_str() {
|
||||
"json" => {
|
||||
let builder = fmt()
|
||||
.json()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
},
|
||||
"full" => {
|
||||
let builder = fmt()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// "compact" or any other value
|
||||
let builder = fmt()
|
||||
.compact()
|
||||
.with_target(config.show_targets)
|
||||
.with_env_filter(env_filter);
|
||||
if config.show_timestamps {
|
||||
builder.init();
|
||||
} else {
|
||||
builder.without_time().init();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,195 +1,207 @@
|
|||
//! Database integration tests
|
||||
|
||||
use fc_common::config::DatabaseConfig;
|
||||
use fc_common::*;
|
||||
use fc_common::{config::DatabaseConfig, *};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_connection() -> anyhow::Result<()> {
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
|
||||
// Try to connect, skip test if database is not available
|
||||
let db = match Database::new(config).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_connection: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Try to connect, skip test if database is not available
|
||||
let db = match Database::new(config).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_connection: no PostgreSQL instance available \
|
||||
- {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
// Test health check
|
||||
Database::health_check(db.pool()).await?;
|
||||
// Test health check
|
||||
Database::health_check(db.pool()).await?;
|
||||
|
||||
// Test connection info
|
||||
let info = db.get_connection_info().await?;
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
// Test connection info
|
||||
let info = db.get_connection_info().await?;
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
assert!(stats.size >= 1);
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
assert!(stats.size >= 1);
|
||||
|
||||
db.close().await;
|
||||
db.close().await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_health_check() -> anyhow::Result<()> {
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect("postgresql://postgres:password@localhost/test").await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_health_check: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect(
|
||||
"postgresql://postgres:password@localhost/test",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_health_check: no PostgreSQL instance \
|
||||
available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
// Should succeed
|
||||
Database::health_check(&pool).await?;
|
||||
// Should succeed
|
||||
Database::health_check(&pool).await?;
|
||||
|
||||
pool.close().await;
|
||||
Ok(())
|
||||
pool.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_info() -> anyhow::Result<()> {
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect("postgresql://postgres:password@localhost/test").await {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Try to connect, skip test if database is not available
|
||||
let pool = match PgPool::connect(
|
||||
"postgresql://postgres:password@localhost/test",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: database connection failed - {}",
|
||||
e
|
||||
);
|
||||
pool.close().await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: database connection failed - {}",
|
||||
e
|
||||
);
|
||||
pool.close().await;
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
let info = db.get_connection_info().await?;
|
||||
let info = db.get_connection_info().await?;
|
||||
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
assert!(info.version.contains("PostgreSQL"));
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
assert!(info.version.contains("PostgreSQL"));
|
||||
|
||||
db.close().await;
|
||||
pool.close().await;
|
||||
db.close().await;
|
||||
pool.close().await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pool_stats() -> anyhow::Result<()> {
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_pool_stats: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let db = match Database::new(DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_pool_stats: no PostgreSQL instance available - {}",
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
let stats = db.get_pool_stats().await;
|
||||
let stats = db.get_pool_stats().await;
|
||||
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
assert_eq!(stats.size, stats.idle + stats.active);
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
assert_eq!(stats.size, stats.idle + stats.active);
|
||||
|
||||
db.close().await;
|
||||
db.close().await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_database_config_validation() -> anyhow::Result<()> {
|
||||
// Valid config
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 10,
|
||||
min_connections: 2,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
// Valid config
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 10,
|
||||
min_connections: 2,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Invalid URL
|
||||
let mut config = config.clone();
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
// Invalid URL
|
||||
let mut config = config.clone();
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Empty URL
|
||||
config.url = "".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
// Empty URL
|
||||
config.url = "".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Zero max connections
|
||||
config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 0,
|
||||
min_connections: 1,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
// Zero max connections
|
||||
config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 0,
|
||||
min_connections: 1,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Min > max
|
||||
config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 10,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
// Min > max
|
||||
config = DatabaseConfig {
|
||||
url: "postgresql://user:pass@localhost/db".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 10,
|
||||
connect_timeout: 30,
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +1,151 @@
|
|||
//! Integration tests for database and configuration
|
||||
|
||||
use fc_common::Database;
|
||||
use fc_common::config::{Config, DatabaseConfig};
|
||||
use fc_common::{
|
||||
Database,
|
||||
config::{Config, DatabaseConfig},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_connection_full() -> anyhow::Result<()> {
|
||||
// This test requires a running PostgreSQL instance
|
||||
// Skip if no database is available
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/fc_ci_test".to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
// This test requires a running PostgreSQL instance
|
||||
// Skip if no database is available
|
||||
let config = DatabaseConfig {
|
||||
url: "postgresql://postgres:password@localhost/fc_ci_test"
|
||||
.to_string(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
connect_timeout: 5, // Short timeout for test
|
||||
idle_timeout: 600,
|
||||
max_lifetime: 1800,
|
||||
};
|
||||
|
||||
// Try to connect, skip test if database is not available
|
||||
let db = match Database::new(config).await {
|
||||
Ok(db) => db,
|
||||
Err(_) => {
|
||||
println!("Skipping database test: no PostgreSQL instance available");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Try to connect, skip test if database is not available
|
||||
let db = match Database::new(config).await {
|
||||
Ok(db) => db,
|
||||
Err(_) => {
|
||||
println!("Skipping database test: no PostgreSQL instance available");
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
// Test health check
|
||||
Database::health_check(db.pool()).await?;
|
||||
// Test health check
|
||||
Database::health_check(db.pool()).await?;
|
||||
|
||||
// Test connection info
|
||||
let info = db.get_connection_info().await?;
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
// Test connection info
|
||||
let info = db.get_connection_info().await?;
|
||||
assert!(!info.database.is_empty());
|
||||
assert!(!info.user.is_empty());
|
||||
assert!(!info.version.is_empty());
|
||||
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
assert_eq!(stats.size, stats.idle + stats.active);
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
assert_eq!(stats.size, stats.idle + stats.active);
|
||||
|
||||
db.close().await;
|
||||
db.close().await;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_loading() -> anyhow::Result<()> {
|
||||
// Test default config loading
|
||||
let config = Config::load()?;
|
||||
assert!(config.validate().is_ok());
|
||||
// Test default config loading
|
||||
let config = Config::load()?;
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test that defaults are reasonable
|
||||
assert_eq!(config.database.max_connections, 20);
|
||||
assert_eq!(config.database.min_connections, 5);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
assert_eq!(config.evaluator.poll_interval, 60);
|
||||
assert_eq!(config.queue_runner.workers, 4);
|
||||
// Test that defaults are reasonable
|
||||
assert_eq!(config.database.max_connections, 20);
|
||||
assert_eq!(config.database.min_connections, 5);
|
||||
assert_eq!(config.server.port, 3000);
|
||||
assert_eq!(config.evaluator.poll_interval, 60);
|
||||
assert_eq!(config.queue_runner.workers, 4);
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation() -> anyhow::Result<()> {
|
||||
// Test valid config
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
// Test valid config
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test invalid database URL
|
||||
let mut config = config.clone();
|
||||
config.database.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid database URL
|
||||
let mut config = config.clone();
|
||||
config.database.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid port
|
||||
let mut config = config.clone();
|
||||
config.server.port = 0;
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid port
|
||||
let mut config = config.clone();
|
||||
config.server.port = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid connections
|
||||
let mut config = config.clone();
|
||||
config.database.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid connections
|
||||
let mut config = config.clone();
|
||||
config.database.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
config.database.max_connections = 10;
|
||||
config.database.min_connections = 15;
|
||||
assert!(config.validate().is_err());
|
||||
config.database.max_connections = 10;
|
||||
config.database.min_connections = 15;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid evaluator settings
|
||||
let mut config = config.clone();
|
||||
config.evaluator.poll_interval = 0;
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid evaluator settings
|
||||
let mut config = config.clone();
|
||||
config.evaluator.poll_interval = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid queue runner settings
|
||||
let mut config = config.clone();
|
||||
config.queue_runner.workers = 0;
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid queue runner settings
|
||||
let mut config = config.clone();
|
||||
config.queue_runner.workers = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_database_config_validation() -> anyhow::Result<()> {
|
||||
// Test valid config
|
||||
let config = DatabaseConfig::default();
|
||||
assert!(config.validate().is_ok());
|
||||
// Test valid config
|
||||
let config = DatabaseConfig::default();
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test invalid URL
|
||||
let mut config = config.clone();
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
// Test invalid URL
|
||||
let mut config = config.clone();
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test empty URL
|
||||
config.url = "".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
// Test empty URL
|
||||
config.url = "".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test zero max connections
|
||||
config = DatabaseConfig::default();
|
||||
config.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
// Test zero max connections
|
||||
config = DatabaseConfig::default();
|
||||
config.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test min > max
|
||||
config = DatabaseConfig::default();
|
||||
config.max_connections = 5;
|
||||
config.min_connections = 10;
|
||||
assert!(config.validate().is_err());
|
||||
// Test min > max
|
||||
config = DatabaseConfig::default();
|
||||
config.max_connections = 5;
|
||||
config.min_connections = 10;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization() -> anyhow::Result<()> {
|
||||
let config = Config::default();
|
||||
let config = Config::default();
|
||||
|
||||
// Test TOML serialization
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
let parsed: Config = toml::from_str(&toml_str)?;
|
||||
assert_eq!(config.database.url, parsed.database.url);
|
||||
assert_eq!(config.server.port, parsed.server.port);
|
||||
// Test TOML serialization
|
||||
let toml_str = toml::to_string_pretty(&config)?;
|
||||
let parsed: Config = toml::from_str(&toml_str)?;
|
||||
assert_eq!(config.database.url, parsed.database.url);
|
||||
assert_eq!(config.server.port, parsed.server.port);
|
||||
|
||||
// Test JSON serialization
|
||||
let json_str = serde_json::to_string_pretty(&config)?;
|
||||
let parsed: Config = serde_json::from_str(&json_str)?;
|
||||
assert_eq!(config.database.url, parsed.database.url);
|
||||
assert_eq!(config.server.port, parsed.server.port);
|
||||
// Test JSON serialization
|
||||
let json_str = serde_json::to_string_pretty(&config)?;
|
||||
let parsed: Config = serde_json::from_str(&json_str)?;
|
||||
assert_eq!(config.database.url, parsed.database.url);
|
||||
assert_eq!(config.server.port, parsed.server.port);
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue