use crate::id::types::ChildId;
use crate::policy::task_role_defaults::{SeverityClass, SidecarConfig, TaskRole};
use crate::readiness::signal::ReadinessPolicy;
use crate::spec::child::{
BackoffPolicy, ChildSpec, CommandPermissions, Criticality, EnvVar, HealthCheckConfig,
HealthPolicy, RestartPolicy, SecretRef, TaskKind,
};
use crate::spec::shutdown::ShutdownBudget;
use confique::Config;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
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, Config, JsonSchema)]
pub struct ChildDeclaration {
pub name: String,
#[config(default = "async_worker")]
#[serde(default)]
pub kind: TaskKind,
#[config(default = "optional")]
#[serde(default)]
pub criticality: Criticality,
#[config(default = [])]
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
#[schemars(default = "default_task_role_for_schema")]
pub task_role: Option<TaskRole>,
#[schemars(!default)]
#[serde(default)]
pub factory_key: Option<String>,
#[schemars(!default)]
#[serde(default)]
pub sidecar_config: Option<SidecarConfig>,
#[schemars(!default)]
#[serde(default)]
pub severity: Option<SeverityClass>,
#[schemars(!default)]
#[serde(default)]
pub group: Option<String>,
#[config(default = "permanent")]
#[serde(default)]
pub restart_policy: RestartPolicy,
#[config(default = [])]
#[serde(default)]
pub dependencies: Vec<String>,
#[schemars(!default)]
#[serde(default)]
pub health_check: Option<HealthCheckConfig>,
#[schemars(!default)]
#[serde(default)]
pub readiness: Option<ReadinessPolicy>,
#[schemars(!default)]
#[serde(default)]
pub command_permissions: Option<CommandPermissions>,
#[config(default = [])]
#[serde(default)]
pub environment: Vec<EnvVar>,
#[config(default = [])]
#[serde(default)]
pub secrets: Vec<SecretRef>,
}
fn default_task_role_for_schema() -> Option<TaskRole> {
Some(TaskRole::Worker)
}
#[derive(Debug, Clone, PartialEq, Config)]
pub struct ChildrenConfigSection {
#[config(default = [{ "name": "worker" }])]
pub items: Vec<ChildDeclaration>,
}
impl Default for ChildrenConfigSection {
fn default() -> Self {
Self { items: Vec::new() }
}
}
impl JsonSchema for ChildrenConfigSection {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("ChildrenConfigSection")
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
Vec::<ChildDeclaration>::json_schema(generator)
}
}
impl Serialize for ChildrenConfigSection {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: confique::serde::Serializer,
{
self.items.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ChildrenConfigSection {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: confique::serde::Deserializer<'de>,
{
Ok(Self {
items: Vec::<ChildDeclaration>::deserialize(deserializer)?,
})
}
}
impl ChildrenConfigSection {
pub fn as_slice(&self) -> &[ChildDeclaration] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
impl From<ChildrenConfigSection> for Vec<ChildDeclaration> {
fn from(section: ChildrenConfigSection) -> Self {
section.items
}
}
#[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 = decl.readiness.unwrap_or(ReadinessPolicy::Immediate);
let command_permissions = decl.command_permissions.unwrap_or_default();
Ok(Self {
id: child_id,
name: decl.name,
kind,
factory: None,
factory_key: decl.factory_key,
restart_policy,
shutdown_budget: ShutdownBudget::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,
command_permissions,
environment: decl.environment,
secrets: decl.secrets,
isolation: crate::spec::child::Isolation::AsyncWorker,
cleanup_paths: Vec::new(),
})
}
}
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()),
});
}
if let Some(factory_key) = declaration.factory_key.as_deref()
&& !is_valid_identifier(factory_key)
{
return Err(ValidationError {
field_path: "factory_key".to_string(),
reason: format!("Factory key '{factory_key}' contains invalid characters"),
hint: Some("Factory keys 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 '{secret_ref}' has invalid syntax"),
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(())
}