use crate::id::types::ChildId;
use crate::policy::task_role_defaults::{SeverityClass, SidecarConfig, TaskRole};
use crate::spec::child::{
BackoffPolicy, ChildSpec, CommandPermissions, Criticality, EnvVar, HealthCheckConfig,
HealthPolicy, ReadinessConfig, ResourceLimits, RestartPolicy, SecretRef, ShutdownPolicy,
TaskKind,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let first = s.chars().next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn is_valid_secret_placeholder(s: &str) -> bool {
if !s.starts_with("${") || !s.ends_with('}') || s.len() < 4 {
return false;
}
let inner = &s[2..s.len() - 1];
if inner.is_empty() {
return false;
}
let first = inner.chars().next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ChildDeclaration {
pub name: String,
#[serde(default)]
pub kind: TaskKind,
#[serde(default)]
pub criticality: Criticality,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub task_role: Option<TaskRole>,
#[serde(default)]
pub sidecar_config: Option<SidecarConfig>,
#[serde(default)]
pub severity: Option<SeverityClass>,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub restart_policy: RestartPolicy,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub health_check: Option<HealthCheckConfig>,
#[serde(default)]
pub readiness: Option<ReadinessConfig>,
#[serde(default)]
pub resource_limits: Option<ResourceLimits>,
#[serde(default)]
pub command_permissions: Option<CommandPermissions>,
#[serde(default)]
pub environment: Vec<EnvVar>,
#[serde(default)]
pub secrets: Vec<SecretRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Parsed,
Validated,
Registered,
Started,
Audited,
Committed,
Compensating,
Compensated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingChild {
pub transaction_id: Uuid,
pub declaration: ChildDeclaration,
pub child_spec: Box<ChildSpec>,
pub phase: Phase,
pub created_at_unix_nanos: u128,
}
impl PartialEq for PendingChild {
fn eq(&self, other: &Self) -> bool {
self.transaction_id == other.transaction_id
&& self.declaration == other.declaration
&& self.phase == other.phase
&& self.created_at_unix_nanos == other.created_at_unix_nanos
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompensatingRecord {
pub transaction_id: Uuid,
pub operation: String,
pub state: String,
pub child_name: String,
pub declaration_hash: String,
pub error: Option<String>,
pub correlation_id: Option<String>,
pub child_id: Option<String>,
pub created_at_unix_nanos: u128,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValidationError {
pub field_path: String,
pub reason: String,
pub hint: Option<String>,
}
impl TryFrom<ChildDeclaration> for ChildSpec {
type Error = ValidationError;
fn try_from(decl: ChildDeclaration) -> Result<Self, Self::Error> {
let child_id = ChildId::new(&decl.name);
let kind = decl.kind;
let criticality = decl.criticality;
let restart_policy = decl.restart_policy;
let dependencies: Vec<ChildId> = decl.dependencies.iter().map(ChildId::new).collect();
let health_policy = match &decl.health_check {
Some(hc) => HealthPolicy::new(
std::time::Duration::from_secs(hc.check_interval_secs),
std::time::Duration::from_secs(hc.timeout_secs),
),
None => HealthPolicy::new(
std::time::Duration::from_secs(10),
std::time::Duration::from_secs(5),
),
};
let readiness_policy = crate::readiness::signal::ReadinessPolicy::Immediate;
let command_permissions = decl.command_permissions.unwrap_or_default();
Ok(Self {
id: child_id,
name: decl.name,
kind,
factory: None,
restart_policy,
shutdown_policy: ShutdownPolicy::new(
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(1),
),
health_policy,
readiness_policy,
backoff_policy: BackoffPolicy::new(
std::time::Duration::from_millis(10),
std::time::Duration::from_secs(1),
0.0,
),
dependencies,
tags: decl.tags,
criticality,
task_role: decl.task_role,
sidecar_config: decl.sidecar_config,
severity: decl.severity,
group: decl.group,
health_check: decl.health_check,
readiness: decl.readiness,
resource_limits: decl.resource_limits,
command_permissions,
environment: decl.environment,
secrets: decl.secrets,
})
}
}
pub fn validate_child_declaration(
declaration: &ChildDeclaration,
all_names: &HashSet<String>,
) -> Result<(), ValidationError> {
if !is_valid_identifier(&declaration.name) {
return Err(ValidationError {
field_path: "name".to_string(),
reason: format!(
"Child name '{}' contains invalid characters",
declaration.name
),
hint: Some("Names must match ^[a-zA-Z_][a-zA-Z0-9_-]*$".to_string()),
});
}
for dep in &declaration.dependencies {
if !all_names.contains(dep) {
return Err(ValidationError {
field_path: format!("dependencies[{dep}]"),
reason: format!("Dependency '{dep}' does not exist in the children list"),
hint: Some(format!(
"Add a child named '{dep}' or remove the dependency"
)),
});
}
}
for secret in &declaration.secrets {
let placeholder = format!("${{{}}}", secret.name);
if !is_valid_secret_placeholder(&placeholder) {
return Err(ValidationError {
field_path: format!("secrets[{}].name", secret.name),
reason: format!(
"Secret name '{}' contains invalid characters for placeholder",
secret.name
),
hint: Some("Secret names must match ^[A-Za-z_][A-Za-z0-9_]*$".to_string()),
});
}
}
for env in &declaration.environment {
if let Some(ref secret_ref) = env.secret_ref
&& !is_valid_secret_placeholder(secret_ref)
{
return Err(ValidationError {
field_path: format!("environment[{}].secret_ref", env.name),
reason: format!("Secret reference '{}' has invalid syntax", secret_ref),
hint: Some(
"Secret references must match ^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$".to_string(),
),
});
}
}
for env in &declaration.environment {
if env.value.is_some() && env.secret_ref.is_some() {
return Err(ValidationError {
field_path: format!("environment[{}]", env.name),
reason: format!(
"Environment variable '{}' has both value and secret_ref set",
env.name
),
hint: Some("Set either 'value' or 'secret_ref', not both".to_string()),
});
}
}
Ok(())
}