Skip to content
Clone
194c44c
462 lines 16.1 KB Rust
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<Uuid>,
pub space_id: Option<Uuid>,
pub environment_id: Option<Uuid>,
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<String>,
pub required_labels: Vec<String>,
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<i64>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
impl From<ci_secret::Model> 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<ci_environment::Model> 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<Vec<SecretDto>> {
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<Vec<SecretDto>> {
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<Vec<SecretDto>> {
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<i64>,
) -> CiResult<SecretDto> {
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<i64>,
) -> CiResult<SecretDto> {
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<i64>,
) -> CiResult<SecretDto> {
self.upsert_secret(
SecretScopeTarget::Environment(environment_id),
name,
value,
actor,
)
.await
}
async fn upsert_secret(
&self,
target: SecretScopeTarget,
name: &str,
value: &str,
actor: Option<i64>,
) -> CiResult<SecretDto> {
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<Uuid> {
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<Vec<(String, String)>> {
let mut merged: std::collections::BTreeMap<String, String> =
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 = self
.find_actions_environment(space_id, repo_id, environment)
.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<String> {
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<Vec<EnvironmentDto>> {
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<String>,
required_labels: Vec<String>,
) -> CiResult<EnvironmentDto> {
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 {
repo_id: Set(None),
rules: Set(None),
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<Vec<DeploymentDto>> {
let names: std::collections::HashMap<Uuid, String> = 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(())
}
}