1use crate::id::types::ChildId;
9use crate::policy::task_role_defaults::{SeverityClass, SidecarConfig, TaskRole};
10use crate::spec::child::{
11 BackoffPolicy, ChildSpec, CommandPermissions, Criticality, EnvVar, HealthCheckConfig,
12 HealthPolicy, ReadinessConfig, ResourceLimits, RestartPolicy, SecretRef, ShutdownPolicy,
13 TaskKind,
14};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use std::collections::HashSet;
18use uuid::Uuid;
19
20fn is_valid_identifier(s: &str) -> bool {
22 if s.is_empty() {
23 return false;
24 }
25 let first = s.chars().next().unwrap();
26 if !first.is_ascii_alphabetic() && first != '_' {
27 return false;
28 }
29 s.chars()
30 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
31}
32
33fn is_valid_secret_placeholder(s: &str) -> bool {
35 if !s.starts_with("${") || !s.ends_with('}') || s.len() < 4 {
36 return false;
37 }
38 let inner = &s[2..s.len() - 1];
39 if inner.is_empty() {
40 return false;
41 }
42 let first = inner.chars().next().unwrap();
43 if !first.is_ascii_alphabetic() && first != '_' {
44 return false;
45 }
46 inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub struct ChildDeclaration {
52 pub name: String,
54 #[serde(default)]
56 pub kind: TaskKind,
57 #[serde(default)]
59 pub criticality: Criticality,
60 #[serde(default)]
62 pub tags: Vec<String>,
63 #[serde(default)]
65 pub task_role: Option<TaskRole>,
66 #[serde(default)]
68 pub sidecar_config: Option<SidecarConfig>,
69 #[serde(default)]
71 pub severity: Option<SeverityClass>,
72 #[serde(default)]
74 pub group: Option<String>,
75 #[serde(default)]
77 pub restart_policy: RestartPolicy,
78 #[serde(default)]
80 pub dependencies: Vec<String>,
81 #[serde(default)]
83 pub health_check: Option<HealthCheckConfig>,
84 #[serde(default)]
86 pub readiness: Option<ReadinessConfig>,
87 #[serde(default)]
89 pub resource_limits: Option<ResourceLimits>,
90 #[serde(default)]
92 pub command_permissions: Option<CommandPermissions>,
93 #[serde(default)]
95 pub environment: Vec<EnvVar>,
96 #[serde(default)]
98 pub secrets: Vec<SecretRef>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum Phase {
105 Parsed,
107 Validated,
109 Registered,
111 Started,
113 Audited,
115 Committed,
117 Compensating,
119 Compensated,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PendingChild {
126 pub transaction_id: Uuid,
128 pub declaration: ChildDeclaration,
130 pub child_spec: Box<ChildSpec>,
132 pub phase: Phase,
134 pub created_at_unix_nanos: u128,
136}
137
138impl PartialEq for PendingChild {
141 fn eq(&self, other: &Self) -> bool {
143 self.transaction_id == other.transaction_id
144 && self.declaration == other.declaration
145 && self.phase == other.phase
146 && self.created_at_unix_nanos == other.created_at_unix_nanos
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct CompensatingRecord {
153 pub transaction_id: Uuid,
155 pub operation: String,
157 pub state: String,
159 pub child_name: String,
161 pub declaration_hash: String,
163 pub error: Option<String>,
165 pub correlation_id: Option<String>,
167 pub child_id: Option<String>,
169 pub created_at_unix_nanos: u128,
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct ValidationError {
176 pub field_path: String,
178 pub reason: String,
180 pub hint: Option<String>,
182}
183
184impl TryFrom<ChildDeclaration> for ChildSpec {
186 type Error = ValidationError;
187
188 fn try_from(decl: ChildDeclaration) -> Result<Self, Self::Error> {
202 let child_id = ChildId::new(&decl.name);
203 let kind = decl.kind;
204 let criticality = decl.criticality;
205 let restart_policy = decl.restart_policy;
206
207 let dependencies: Vec<ChildId> = decl.dependencies.iter().map(ChildId::new).collect();
209
210 let health_policy = match &decl.health_check {
212 Some(hc) => HealthPolicy::new(
213 std::time::Duration::from_secs(hc.check_interval_secs),
214 std::time::Duration::from_secs(hc.timeout_secs),
215 ),
216 None => HealthPolicy::new(
217 std::time::Duration::from_secs(10),
218 std::time::Duration::from_secs(5),
219 ),
220 };
221
222 let readiness_policy = crate::readiness::signal::ReadinessPolicy::Immediate;
224
225 let command_permissions = decl.command_permissions.unwrap_or_default();
226
227 Ok(Self {
228 id: child_id,
229 name: decl.name,
230 kind,
231 factory: None,
232 restart_policy,
233 shutdown_policy: ShutdownPolicy::new(
234 std::time::Duration::from_secs(5),
235 std::time::Duration::from_secs(1),
236 ),
237 health_policy,
238 readiness_policy,
239 backoff_policy: BackoffPolicy::new(
240 std::time::Duration::from_millis(10),
241 std::time::Duration::from_secs(1),
242 0.0,
243 ),
244 dependencies,
245 tags: decl.tags,
246 criticality,
247 task_role: decl.task_role,
248 sidecar_config: decl.sidecar_config,
249 severity: decl.severity,
250 group: decl.group,
251 health_check: decl.health_check,
252 readiness: decl.readiness,
253 resource_limits: decl.resource_limits,
254 command_permissions,
255 environment: decl.environment,
256 secrets: decl.secrets,
257 })
258 }
259}
260
261pub fn validate_child_declaration(
276 declaration: &ChildDeclaration,
277 all_names: &HashSet<String>,
278) -> Result<(), ValidationError> {
279 if !is_valid_identifier(&declaration.name) {
281 return Err(ValidationError {
282 field_path: "name".to_string(),
283 reason: format!(
284 "Child name '{}' contains invalid characters",
285 declaration.name
286 ),
287 hint: Some("Names must match ^[a-zA-Z_][a-zA-Z0-9_-]*$".to_string()),
288 });
289 }
290
291 for dep in &declaration.dependencies {
293 if !all_names.contains(dep) {
294 return Err(ValidationError {
295 field_path: format!("dependencies[{dep}]"),
296 reason: format!("Dependency '{dep}' does not exist in the children list"),
297 hint: Some(format!(
298 "Add a child named '{dep}' or remove the dependency"
299 )),
300 });
301 }
302 }
303
304 for secret in &declaration.secrets {
306 let placeholder = format!("${{{}}}", secret.name);
307 if !is_valid_secret_placeholder(&placeholder) {
308 return Err(ValidationError {
309 field_path: format!("secrets[{}].name", secret.name),
310 reason: format!(
311 "Secret name '{}' contains invalid characters for placeholder",
312 secret.name
313 ),
314 hint: Some("Secret names must match ^[A-Za-z_][A-Za-z0-9_]*$".to_string()),
315 });
316 }
317 }
318 for env in &declaration.environment {
319 if let Some(ref secret_ref) = env.secret_ref
320 && !is_valid_secret_placeholder(secret_ref)
321 {
322 return Err(ValidationError {
323 field_path: format!("environment[{}].secret_ref", env.name),
324 reason: format!("Secret reference '{}' has invalid syntax", secret_ref),
325 hint: Some(
326 "Secret references must match ^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$".to_string(),
327 ),
328 });
329 }
330 }
331
332 for env in &declaration.environment {
334 if env.value.is_some() && env.secret_ref.is_some() {
335 return Err(ValidationError {
336 field_path: format!("environment[{}]", env.name),
337 reason: format!(
338 "Environment variable '{}' has both value and secret_ref set",
339 env.name
340 ),
341 hint: Some("Set either 'value' or 'secret_ref', not both".to_string()),
342 });
343 }
344 }
345
346 Ok(())
347}