use models::ci::ci_deployment::{self, DeploymentState}; use models::ci::ci_environment; use models::ci::ci_secret::{self, CiSecretScope}; use models::space::spaces; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use time::OffsetDateTime; use uuid::Uuid; use crate::errors::{CiError, CiResult}; use crate::secrets::{decrypt, encrypt}; use crate::service::CiService; #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct SecretDto { pub id: Uuid, pub name: String, pub scope: String, pub repo_id: Option, pub space_id: Option, pub environment_id: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct EnvironmentDto { pub id: Uuid, pub space_id: Uuid, pub name: String, pub description: Option, pub required_labels: Vec, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct DeploymentDto { pub id: Uuid, pub environment_id: Uuid, pub environment_name: String, pub run_id: Uuid, pub job_id: Uuid, pub repo_id: Uuid, pub commit_sha: String, pub git_ref: String, pub state: String, pub actor_id: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, } impl From for SecretDto { fn from(m: ci_secret::Model) -> Self { Self { id: m.id, name: m.name, scope: match m.scope { CiSecretScope::Repo => "repo", CiSecretScope::Space => "space", CiSecretScope::Environment => "environment", } .to_string(), repo_id: m.repo_id, space_id: m.space_id, environment_id: m.environment_id, created_at: m.created_at, updated_at: m.updated_at, } } } impl From for EnvironmentDto { fn from(m: ci_environment::Model) -> Self { Self { id: m.id, space_id: m.space_id, name: m.name, description: m.description, required_labels: serde_json::from_value(m.required_labels).unwrap_or_default(), created_at: m.created_at, updated_at: m.updated_at, } } } impl CiService { pub async fn list_repo_secrets(&self, repo_id: Uuid) -> CiResult> { let rows = ci_secret::Entity::find() .filter(ci_secret::Column::RepoId.eq(repo_id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Repo)) .order_by_asc(ci_secret::Column::Name) .all(self.db_read()) .await?; Ok(rows.into_iter().map(SecretDto::from).collect()) } pub async fn list_space_secrets(&self, space_id: Uuid) -> CiResult> { let rows = ci_secret::Entity::find() .filter(ci_secret::Column::SpaceId.eq(space_id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Space)) .order_by_asc(ci_secret::Column::Name) .all(self.db_read()) .await?; Ok(rows.into_iter().map(SecretDto::from).collect()) } pub async fn list_environment_secrets(&self, environment_id: Uuid) -> CiResult> { let rows = ci_secret::Entity::find() .filter(ci_secret::Column::EnvironmentId.eq(environment_id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment)) .order_by_asc(ci_secret::Column::Name) .all(self.db_read()) .await?; Ok(rows.into_iter().map(SecretDto::from).collect()) } pub async fn upsert_repo_secret( &self, repo_id: Uuid, name: &str, value: &str, actor: Option, ) -> CiResult { self.upsert_secret(SecretScopeTarget::Repo(repo_id), name, value, actor) .await } pub async fn upsert_space_secret( &self, space_id: Uuid, name: &str, value: &str, actor: Option, ) -> CiResult { self.upsert_secret(SecretScopeTarget::Space(space_id), name, value, actor) .await } pub async fn upsert_environment_secret( &self, environment_id: Uuid, name: &str, value: &str, actor: Option, ) -> CiResult { self.upsert_secret( SecretScopeTarget::Environment(environment_id), name, value, actor, ) .await } async fn upsert_secret( &self, target: SecretScopeTarget, name: &str, value: &str, actor: Option, ) -> CiResult { let name = validate_secret_name(name)?; if value.is_empty() { return Err(CiError::InvalidArgument( "secret value must not be empty".to_string(), )); } if value.len() > 64 * 1024 { return Err(CiError::InvalidArgument( "secret value exceeds the 64KiB limit".to_string(), )); } let ciphertext = encrypt(self.encryption_key(), value)?; let now = OffsetDateTime::now_utc(); let existing = match &target { SecretScopeTarget::Repo(repo_id) => { ci_secret::Entity::find() .filter(ci_secret::Column::RepoId.eq(*repo_id)) .filter(ci_secret::Column::Name.eq(name.clone())) .one(self.db_read()) .await? } SecretScopeTarget::Space(space_id) => { ci_secret::Entity::find() .filter(ci_secret::Column::SpaceId.eq(*space_id)) .filter(ci_secret::Column::Name.eq(name.clone())) .one(self.db_read()) .await? } SecretScopeTarget::Environment(environment_id) => { ci_secret::Entity::find() .filter(ci_secret::Column::EnvironmentId.eq(*environment_id)) .filter(ci_secret::Column::Name.eq(name.clone())) .one(self.db_read()) .await? } }; let model = match existing { Some(row) => { let mut am: ci_secret::ActiveModel = row.into(); am.value_ciphertext = Set(ciphertext); am.updated_at = Set(now); am.update(self.db_write()).await? } None => { let (scope, repo_id, space_id, environment_id) = match &target { SecretScopeTarget::Repo(id) => ( CiSecretScope::Repo, Some(*id), Some(self.repo_space(*id).await?), None, ), SecretScopeTarget::Space(id) => (CiSecretScope::Space, None, Some(*id), None), SecretScopeTarget::Environment(id) => { let row = ci_environment::Entity::find_by_id(*id) .one(self.db_read()) .await? .ok_or(CiError::NotFound)?; ( CiSecretScope::Environment, None, Some(row.space_id), Some(*id), ) } }; ci_secret::ActiveModel { id: Set(Uuid::now_v7()), scope: Set(scope), repo_id: Set(repo_id), space_id: Set(space_id), environment_id: Set(environment_id), name: Set(name.clone()), value_ciphertext: Set(ciphertext), created_by: Set(actor), created_at: Set(now), updated_at: Set(now), } .insert(self.db_write()) .await? } }; Ok(SecretDto::from(model)) } async fn repo_space(&self, repo_id: Uuid) -> CiResult { let repo = self.repo_by_id(repo_id).await?; Ok(self.space_of_repo(&repo).await?.id) } pub async fn delete_secret(&self, id: Uuid) -> CiResult<()> { let updated = ci_secret::Entity::delete_by_id(id) .exec(self.db_write()) .await?; if updated.rows_affected == 0 { return Err(CiError::NotFound); } Ok(()) } /// Merges environment overriding repo overriding space secrets; later /// entries win when names collide. pub async fn resolved_secrets( &self, repo_id: Uuid, space_id: Uuid, environment: Option<&str>, ) -> CiResult> { let mut merged: std::collections::BTreeMap = std::collections::BTreeMap::new(); let space_rows = ci_secret::Entity::find() .filter(ci_secret::Column::SpaceId.eq(space_id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Space)) .all(self.db_read()) .await?; for row in space_rows { let value = decrypt(self.encryption_key(), &row.value_ciphertext)?; merged.insert(row.name, value); } let repo_rows = ci_secret::Entity::find() .filter(ci_secret::Column::RepoId.eq(repo_id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Repo)) .all(self.db_read()) .await?; for row in repo_rows { let value = decrypt(self.encryption_key(), &row.value_ciphertext)?; merged.insert(row.name, value); } if let Some(environment) = environment { let env_row = ci_environment::Entity::find() .filter(ci_environment::Column::SpaceId.eq(space_id)) .filter(ci_environment::Column::Name.eq(environment.to_string())) .one(self.db_read()) .await?; if let Some(env_row) = env_row { let env_secrets = ci_secret::Entity::find() .filter(ci_secret::Column::EnvironmentId.eq(env_row.id)) .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment)) .all(self.db_read()) .await?; for row in env_secrets { let value = decrypt(self.encryption_key(), &row.value_ciphertext)?; merged.insert(row.name, value); } } } Ok(merged.into_iter().collect()) } } enum SecretScopeTarget { Repo(Uuid), Space(Uuid), Environment(Uuid), } fn validate_secret_name(name: &str) -> CiResult { let trimmed = name.trim(); if trimmed.is_empty() || trimmed.len() > 128 { return Err(CiError::InvalidArgument( "secret name must be 1-128 characters".to_string(), )); } let valid = trimmed .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); if !valid { return Err(CiError::InvalidArgument( "secret name may only contain alphanumerics, `_` and `-`".to_string(), )); } Ok(trimmed.to_string()) } impl CiService { pub async fn list_environments(&self, space: &spaces::Model) -> CiResult> { let rows = ci_environment::Entity::find() .filter(ci_environment::Column::SpaceId.eq(space.id)) .order_by_asc(ci_environment::Column::Name) .all(self.db_read()) .await?; Ok(rows.into_iter().map(EnvironmentDto::from).collect()) } pub async fn upsert_environment( &self, space_id: Uuid, name: &str, description: Option, required_labels: Vec, ) -> CiResult { let name = name.trim(); if name.is_empty() || name.len() > 128 || name.contains('/') || name.contains('\\') { return Err(CiError::InvalidArgument( "environment name must be 1-128 characters without slashes".to_string(), )); } if required_labels.iter().any(|l| l.trim().is_empty()) { return Err(CiError::InvalidArgument( "environment labels must not be empty".to_string(), )); } let now = OffsetDateTime::now_utc(); let existing = ci_environment::Entity::find() .filter(ci_environment::Column::SpaceId.eq(space_id)) .filter(ci_environment::Column::Name.eq(name.to_string())) .one(self.db_read()) .await?; let model = match existing { Some(row) => { let mut am: ci_environment::ActiveModel = row.into(); am.description = Set(description); am.required_labels = Set(serde_json::to_value(&required_labels)?); am.updated_at = Set(now); am.update(self.db_write()).await? } None => { ci_environment::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(space_id), name: Set(name.to_string()), description: Set(description), required_labels: Set(serde_json::to_value(&required_labels)?), created_at: Set(now), updated_at: Set(now), } .insert(self.db_write()) .await? } }; Ok(EnvironmentDto::from(model)) } pub async fn delete_environment(&self, id: Uuid) -> CiResult<()> { let _ = ci_secret::Entity::delete_many() .filter(ci_secret::Column::EnvironmentId.eq(id)) .exec(self.db_write()) .await; let _ = ci_deployment::Entity::delete_many() .filter(ci_deployment::Column::EnvironmentId.eq(id)) .exec(self.db_write()) .await; let updated = ci_environment::Entity::delete_by_id(id) .exec(self.db_write()) .await?; if updated.rows_affected == 0 { return Err(CiError::NotFound); } Ok(()) } pub async fn list_deployments( &self, environment_id: Uuid, limit: u64, ) -> CiResult> { let names: std::collections::HashMap = ci_environment::Entity::find() .all(self.db_read()) .await? .into_iter() .map(|row| (row.id, row.name)) .collect(); let rows = ci_deployment::Entity::find() .filter(ci_deployment::Column::EnvironmentId.eq(environment_id)) .order_by_desc(ci_deployment::Column::CreatedAt) .limit(limit.clamp(1, 100)) .all(self.db_read()) .await?; Ok(rows .into_iter() .map(|row| DeploymentDto { id: row.id, environment_id: row.environment_id, environment_name: names.get(&row.environment_id).cloned().unwrap_or_default(), run_id: row.run_id, job_id: row.job_id, repo_id: row.repo_id, commit_sha: row.commit_sha, git_ref: row.git_ref, state: match row.state { DeploymentState::Pending => "pending", DeploymentState::Running => "running", DeploymentState::Success => "success", DeploymentState::Failure => "failure", DeploymentState::Cancelled => "cancelled", } .to_string(), actor_id: row.actor_id, created_at: row.created_at, updated_at: row.updated_at, }) .collect()) } pub async fn touch_environment_deployments(&self) -> CiResult<()> { let _ = Expr::value(0); Ok(()) } }