use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::spec::DeploymentSpec;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredDeployment {
pub name: String,
#[schema(value_type = Object)]
pub spec: DeploymentSpec,
pub status: DeploymentStatus,
#[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2025-01-27T12:00:00Z")]
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub resolved_image_digests: HashMap<String, String>,
}
impl StoredDeployment {
#[must_use]
pub fn new(spec: DeploymentSpec) -> Self {
let now = Utc::now();
Self {
name: spec.deployment.clone(),
spec,
status: DeploymentStatus::Pending,
created_at: now,
updated_at: now,
resolved_image_digests: HashMap::new(),
}
}
pub fn update_spec(&mut self, spec: DeploymentSpec) {
self.spec = spec;
self.updated_at = Utc::now();
}
pub fn update_status(&mut self, status: DeploymentStatus) {
self.status = status;
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum DeploymentStatus {
Pending,
Deploying,
Running,
Failed {
message: String,
},
Stopped,
}
impl std::fmt::Display for DeploymentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeploymentStatus::Pending => write!(f, "pending"),
DeploymentStatus::Deploying => write!(f, "deploying"),
DeploymentStatus::Running => write!(f, "running"),
DeploymentStatus::Failed { message } => write!(f, "failed: {message}"),
DeploymentStatus::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredUser {
pub id: String,
pub email: String,
pub display_name: String,
pub role: UserRole,
pub is_active: bool,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
#[schema(value_type = Option<String>, example = "2026-04-15T12:00:00Z")]
pub last_login_at: Option<DateTime<Utc>>,
}
impl StoredUser {
#[must_use]
pub fn new(email: impl Into<String>, display_name: impl Into<String>, role: UserRole) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
email: email.into().to_lowercase(),
display_name: display_name.into(),
role,
is_active: true,
created_at: now,
updated_at: now,
last_login_at: None,
}
}
pub fn touch_login(&mut self) {
let now = Utc::now();
self.last_login_at = Some(now);
self.updated_at = now;
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum UserRole {
Admin,
User,
}
impl UserRole {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
UserRole::Admin => "admin",
UserRole::User => "user",
}
}
}
impl std::fmt::Display for UserRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredEnvironment {
pub id: String,
pub name: String,
pub project_id: Option<String>,
pub description: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredEnvironment {
#[must_use]
pub fn new(name: impl Into<String>, project_id: Option<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
project_id,
description: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredProject {
pub id: String,
pub name: String,
pub description: Option<String>,
pub git_url: Option<String>,
pub git_branch: Option<String>,
pub git_credential_id: Option<String>,
pub build_kind: Option<BuildKind>,
pub build_path: Option<String>,
#[serde(default)]
pub deploy_spec_path: Option<String>,
pub registry_credential_id: Option<String>,
pub default_environment_id: Option<String>,
pub owner_id: Option<String>,
#[serde(default)]
pub auto_deploy: bool,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredProject {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
description: None,
git_url: None,
git_branch: Some("main".to_string()),
git_credential_id: None,
build_kind: None,
build_path: None,
deploy_spec_path: None,
registry_credential_id: None,
default_environment_id: None,
owner_id: None,
auto_deploy: false,
poll_interval_secs: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum BuildKind {
Dockerfile,
Compose,
ZImagefile,
Spec,
}
impl std::fmt::Display for BuildKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BuildKind::Dockerfile => f.write_str("dockerfile"),
BuildKind::Compose => f.write_str("compose"),
BuildKind::ZImagefile => f.write_str("zimagefile"),
BuildKind::Spec => f.write_str("spec"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredVariable {
pub id: String,
pub name: String,
pub value: String,
pub scope: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredVariable {
#[must_use]
pub fn new(name: impl Into<String>, value: impl Into<String>, scope: Option<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
value: value.into(),
scope,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredSync {
pub id: String,
pub name: String,
pub project_id: Option<String>,
pub git_path: String,
#[serde(default)]
pub auto_apply: bool,
#[serde(default)]
pub delete_missing: bool,
#[serde(default)]
pub reconcile_interval_secs: Option<u64>,
pub last_applied_sha: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredSync {
#[must_use]
pub fn new(name: impl Into<String>, git_path: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
project_id: None,
git_path: git_path.into(),
auto_apply: false,
delete_missing: false,
reconcile_interval_secs: None,
last_applied_sha: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredTask {
pub id: String,
pub name: String,
pub kind: TaskKind,
pub body: String,
pub project_id: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredTask {
#[must_use]
pub fn new(
name: impl Into<String>,
kind: TaskKind,
body: impl Into<String>,
project_id: Option<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
kind,
body: body.into(),
project_id,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskKind {
Bash,
}
impl std::fmt::Display for TaskKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TaskKind::Bash => f.write_str("bash"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TaskRun {
pub id: String,
pub task_id: String,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub started_at: DateTime<Utc>,
#[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
pub finished_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredWorkflow {
pub id: String,
pub name: String,
pub steps: Vec<WorkflowStep>,
pub project_id: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredWorkflow {
#[must_use]
pub fn new(name: impl Into<String>, steps: Vec<WorkflowStep>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
steps,
project_id: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkflowStep {
pub name: String,
pub action: WorkflowAction,
#[serde(default)]
pub on_failure: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorkflowAction {
RunTask {
task_id: String,
},
BuildProject {
project_id: String,
},
DeployProject {
project_id: String,
},
ApplySync {
sync_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WorkflowRun {
pub id: String,
pub workflow_id: String,
pub status: WorkflowRunStatus,
pub step_results: Vec<StepResult>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub started_at: DateTime<Utc>,
#[schema(value_type = Option<String>, example = "2026-04-15T12:00:01Z")]
pub finished_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowRunStatus {
Pending,
Running,
Completed,
Failed,
}
impl std::fmt::Display for WorkflowRunStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WorkflowRunStatus::Pending => f.write_str("pending"),
WorkflowRunStatus::Running => f.write_str("running"),
WorkflowRunStatus::Completed => f.write_str("completed"),
WorkflowRunStatus::Failed => f.write_str("failed"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StepResult {
pub step_name: String,
pub status: String,
pub output: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredNotifier {
pub id: String,
pub name: String,
pub kind: NotifierKind,
pub config: NotifierConfig,
pub enabled: bool,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredNotifier {
#[must_use]
pub fn new(name: impl Into<String>, kind: NotifierKind, config: NotifierConfig) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
kind,
config,
enabled: true,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotifierKind {
Slack,
Discord,
Webhook,
Smtp,
}
impl std::fmt::Display for NotifierKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NotifierKind::Slack => f.write_str("slack"),
NotifierKind::Discord => f.write_str("discord"),
NotifierKind::Webhook => f.write_str("webhook"),
NotifierKind::Smtp => f.write_str("smtp"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotifierConfig {
Slack {
webhook_url: String,
},
Discord {
webhook_url: String,
},
Webhook {
url: String,
#[serde(default)]
method: Option<String>,
#[serde(default)]
headers: Option<HashMap<String, String>>,
},
Smtp {
host: String,
port: u16,
username: String,
password: String,
from: String,
to: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredUserGroup {
pub id: String,
pub name: String,
pub description: Option<String>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub updated_at: DateTime<Utc>,
}
impl StoredUserGroup {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
description: None,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubjectKind {
User,
Group,
}
impl std::fmt::Display for SubjectKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SubjectKind::User => f.write_str("user"),
SubjectKind::Group => f.write_str("group"),
}
}
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum PermissionLevel {
None,
Read,
Execute,
Write,
}
impl std::fmt::Display for PermissionLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PermissionLevel::None => f.write_str("none"),
PermissionLevel::Read => f.write_str("read"),
PermissionLevel::Execute => f.write_str("execute"),
PermissionLevel::Write => f.write_str("write"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredPermission {
pub id: String,
pub subject_kind: SubjectKind,
pub subject_id: String,
pub resource_kind: String,
pub resource_id: Option<String>,
pub level: PermissionLevel,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
}
impl StoredPermission {
#[must_use]
pub fn new(
subject_kind: SubjectKind,
subject_id: impl Into<String>,
resource_kind: impl Into<String>,
resource_id: Option<String>,
level: PermissionLevel,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
subject_kind,
subject_id: subject_id.into(),
resource_kind: resource_kind.into(),
resource_id,
level,
created_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct TokenScope {
pub resource_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
pub level: PermissionLevel,
}
impl TokenScope {
#[must_use]
pub fn new(
resource_kind: impl Into<String>,
resource_id: Option<String>,
level: PermissionLevel,
) -> Self {
Self {
resource_kind: resource_kind.into(),
resource_id,
level,
}
}
#[must_use]
pub fn satisfies(
&self,
resource_kind: &str,
resource_id: Option<&str>,
level: PermissionLevel,
) -> bool {
self.resource_kind == resource_kind
&& self.level >= level
&& match (&self.resource_id, resource_id) {
(None, _) => true, (Some(scope_id), Some(req)) => scope_id == req,
(Some(_), None) => false, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StoredAccessToken {
pub id: String,
#[serde(default)]
pub name: String,
pub subject: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub scopes: Vec<TokenScope>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub expires_at: DateTime<Utc>,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub created_at: DateTime<Utc>,
#[serde(default)]
pub created_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<String>, example = "2026-04-15T12:00:00Z")]
pub revoked_at: Option<DateTime<Utc>>,
}
impl StoredAccessToken {
#[must_use]
pub fn new(
name: impl Into<String>,
subject: impl Into<String>,
roles: Vec<String>,
scopes: Vec<TokenScope>,
expires_at: DateTime<Utc>,
created_by: impl Into<String>,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: name.into(),
subject: subject.into(),
roles,
scopes,
expires_at,
created_at: Utc::now(),
created_by: created_by.into(),
revoked_at: None,
}
}
#[must_use]
pub fn is_inactive(&self, now: DateTime<Utc>) -> bool {
self.revoked_at.is_some() || self.expires_at < now
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct OidcIdentity {
pub id: String,
pub user_id: String,
pub provider: String,
pub subject: String,
pub email_at_link: Option<String>,
#[schema(value_type = String, format = DateTime)]
pub created_at: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub updated_at: DateTime<Utc>,
}
impl OidcIdentity {
#[must_use]
pub fn new(
user_id: impl Into<String>,
provider: impl Into<String>,
subject: impl Into<String>,
email_at_link: Option<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id: user_id.into(),
provider: provider.into(),
subject: subject.into(),
email_at_link,
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct NodeIdentity {
pub node_id: String,
#[schema(value_type = String, format = "byte")]
pub secrets_pubkey: [u8; 32],
pub wg_pubkey: String,
#[schema(value_type = String, example = "2026-04-15T12:00:00Z")]
pub joined_at: DateTime<Utc>,
#[schema(value_type = Option<String>, example = "2026-04-16T12:00:00Z")]
pub revoked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WrappedDek {
pub dek_generation: u64,
pub wraps: std::collections::HashMap<String, Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReplicatedSecret {
pub storage_key: String,
pub ciphertext: Vec<u8>,
pub dek_generation: u64,
#[schema(value_type = Object)]
pub metadata: crate::secrets::SecretMetadata,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_affinity: Option<NodeAffinity>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NodeAffinity {
Nodes {
node_ids: Vec<String>,
},
Labels {
labels: std::collections::HashMap<String, String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stored_deployment_deserializes_without_resolved_image_digests() {
let json = r#"{
"name": "legacy",
"spec": {
"version": "v1",
"deployment": "legacy",
"services": {},
"externals": {},
"tunnels": {},
"api": {}
},
"status": { "state": "running" },
"created_at": "2025-01-27T12:00:00Z",
"updated_at": "2025-01-27T12:00:00Z"
}"#;
let stored: StoredDeployment =
serde_json::from_str(json).expect("legacy row without the field must deserialize");
assert_eq!(stored.name, "legacy");
assert_eq!(stored.status, DeploymentStatus::Running);
assert!(
stored.resolved_image_digests.is_empty(),
"missing field must default to an empty map"
);
}
#[test]
fn stored_deployment_round_trips_resolved_image_digests() {
let spec = crate::spec::DeploymentSpec {
version: "v1".to_string(),
deployment: "multi".to_string(),
services: HashMap::new(),
externals: HashMap::new(),
tunnels: HashMap::new(),
api: crate::spec::ApiSpec::default(),
environment: None,
project: None,
};
let mut stored = StoredDeployment::new(spec);
stored
.resolved_image_digests
.insert("web".to_string(), "sha256:aaa".to_string());
stored
.resolved_image_digests
.insert("worker".to_string(), "sha256:bbb".to_string());
let json = serde_json::to_string(&stored).expect("serialize");
let back: StoredDeployment = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.resolved_image_digests.len(), 2);
assert_eq!(
back.resolved_image_digests.get("web").map(String::as_str),
Some("sha256:aaa")
);
assert_eq!(
back.resolved_image_digests
.get("worker")
.map(String::as_str),
Some("sha256:bbb")
);
}
#[test]
fn stored_deployment_omits_empty_resolved_image_digests_on_serialize() {
let spec = crate::spec::DeploymentSpec {
version: "v1".to_string(),
deployment: "empty".to_string(),
services: HashMap::new(),
externals: HashMap::new(),
tunnels: HashMap::new(),
api: crate::spec::ApiSpec::default(),
environment: None,
project: None,
};
let stored = StoredDeployment::new(spec);
let json = serde_json::to_string(&stored).expect("serialize");
assert!(
!json.contains("resolved_image_digests"),
"empty map must be skipped on serialize, got: {json}"
);
}
}