1use crate::id::types::ChildId;
9use crate::policy::task_role_defaults::{SeverityClass, SidecarConfig, TaskRole};
10use crate::readiness::signal::ReadinessPolicy;
11use crate::spec::child::{
12 BackoffPolicy, ChildSpec, CommandPermissions, Criticality, EnvVar, HealthCheckConfig,
13 HealthPolicy, RestartPolicy, SecretRef, TaskKind,
14};
15use crate::spec::shutdown::ShutdownBudget;
16use confique::Config;
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use serde::{Deserialize, Serialize};
19use std::borrow::Cow;
20use std::collections::HashSet;
21use uuid::Uuid;
22
23fn is_valid_identifier(s: &str) -> bool {
25 if s.is_empty() {
26 return false;
27 }
28 let first = s.chars().next().unwrap();
29 if !first.is_ascii_alphabetic() && first != '_' {
30 return false;
31 }
32 s.chars()
33 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
34}
35
36fn is_valid_secret_placeholder(s: &str) -> bool {
38 if !s.starts_with("${") || !s.ends_with('}') || s.len() < 4 {
39 return false;
40 }
41 let inner = &s[2..s.len() - 1];
42 if inner.is_empty() {
43 return false;
44 }
45 let first = inner.chars().next().unwrap();
46 if !first.is_ascii_alphabetic() && first != '_' {
47 return false;
48 }
49 inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Config, JsonSchema)]
54pub struct ChildDeclaration {
55 pub name: String,
57 #[config(default = "async_worker")]
59 #[serde(default)]
60 pub kind: TaskKind,
61 #[config(default = "optional")]
63 #[serde(default)]
64 pub criticality: Criticality,
65 #[config(default = [])]
67 #[serde(default)]
68 pub tags: Vec<String>,
69 #[serde(default)]
73 #[schemars(default = "default_task_role_for_schema")]
74 pub task_role: Option<TaskRole>,
75 #[schemars(!default)]
77 #[serde(default)]
78 pub factory_key: Option<String>,
79 #[schemars(!default)]
81 #[serde(default)]
82 pub sidecar_config: Option<SidecarConfig>,
83 #[schemars(!default)]
85 #[serde(default)]
86 pub severity: Option<SeverityClass>,
87 #[schemars(!default)]
89 #[serde(default)]
90 pub group: Option<String>,
91 #[config(default = "permanent")]
93 #[serde(default)]
94 pub restart_policy: RestartPolicy,
95 #[config(default = [])]
97 #[serde(default)]
98 pub dependencies: Vec<String>,
99 #[schemars(!default)]
101 #[serde(default)]
102 pub health_check: Option<HealthCheckConfig>,
103 #[schemars(!default)]
105 #[serde(default)]
106 pub readiness: Option<ReadinessPolicy>,
107 #[schemars(!default)]
109 #[serde(default)]
110 pub command_permissions: Option<CommandPermissions>,
111 #[config(default = [])]
113 #[serde(default)]
114 pub environment: Vec<EnvVar>,
115 #[config(default = [])]
117 #[serde(default)]
118 pub secrets: Vec<SecretRef>,
119}
120
121fn default_task_role_for_schema() -> Option<TaskRole> {
123 Some(TaskRole::Worker)
124}
125
126#[derive(Debug, Clone, PartialEq, Config)]
131pub struct ChildrenConfigSection {
132 #[config(default = [{ "name": "worker" }])]
134 pub items: Vec<ChildDeclaration>,
135}
136
137impl Default for ChildrenConfigSection {
138 fn default() -> Self {
140 Self { items: Vec::new() }
141 }
142}
143
144impl JsonSchema for ChildrenConfigSection {
145 fn schema_name() -> Cow<'static, str> {
147 Cow::Borrowed("ChildrenConfigSection")
148 }
149
150 fn json_schema(generator: &mut SchemaGenerator) -> Schema {
152 Vec::<ChildDeclaration>::json_schema(generator)
153 }
154}
155
156impl Serialize for ChildrenConfigSection {
157 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
159 where
160 S: confique::serde::Serializer,
161 {
162 self.items.serialize(serializer)
163 }
164}
165
166impl<'de> Deserialize<'de> for ChildrenConfigSection {
167 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
169 where
170 D: confique::serde::Deserializer<'de>,
171 {
172 Ok(Self {
173 items: Vec::<ChildDeclaration>::deserialize(deserializer)?,
174 })
175 }
176}
177
178impl ChildrenConfigSection {
179 pub fn as_slice(&self) -> &[ChildDeclaration] {
181 &self.items
182 }
183
184 pub fn len(&self) -> usize {
186 self.items.len()
187 }
188
189 pub fn is_empty(&self) -> bool {
191 self.items.is_empty()
192 }
193}
194
195impl From<ChildrenConfigSection> for Vec<ChildDeclaration> {
196 fn from(section: ChildrenConfigSection) -> Self {
198 section.items
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum Phase {
206 Parsed,
208 Validated,
210 Registered,
212 Started,
214 Audited,
216 Committed,
218 Compensating,
220 Compensated,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct PendingChild {
227 pub transaction_id: Uuid,
229 pub declaration: ChildDeclaration,
231 pub child_spec: Box<ChildSpec>,
233 pub phase: Phase,
235 pub created_at_unix_nanos: u128,
237}
238
239impl PartialEq for PendingChild {
242 fn eq(&self, other: &Self) -> bool {
244 self.transaction_id == other.transaction_id
245 && self.declaration == other.declaration
246 && self.phase == other.phase
247 && self.created_at_unix_nanos == other.created_at_unix_nanos
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CompensatingRecord {
254 pub transaction_id: Uuid,
256 pub operation: String,
258 pub state: String,
260 pub child_name: String,
262 pub declaration_hash: String,
264 pub error: Option<String>,
266 pub correlation_id: Option<String>,
268 pub child_id: Option<String>,
270 pub created_at_unix_nanos: u128,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct ValidationError {
277 pub field_path: String,
279 pub reason: String,
281 pub hint: Option<String>,
283}
284
285impl TryFrom<ChildDeclaration> for ChildSpec {
287 type Error = ValidationError;
288
289 fn try_from(decl: ChildDeclaration) -> Result<Self, Self::Error> {
303 let child_id = ChildId::new(&decl.name);
304 let kind = decl.kind;
305 let criticality = decl.criticality;
306 let restart_policy = decl.restart_policy;
307
308 let dependencies: Vec<ChildId> = decl.dependencies.iter().map(ChildId::new).collect();
310
311 let health_policy = match &decl.health_check {
313 Some(hc) => HealthPolicy::new(
314 std::time::Duration::from_secs(hc.check_interval_secs),
315 std::time::Duration::from_secs(hc.timeout_secs),
316 ),
317 None => HealthPolicy::new(
318 std::time::Duration::from_secs(10),
319 std::time::Duration::from_secs(5),
320 ),
321 };
322
323 let readiness_policy = decl.readiness.unwrap_or(ReadinessPolicy::Immediate);
324
325 let command_permissions = decl.command_permissions.unwrap_or_default();
326
327 Ok(Self {
328 id: child_id,
329 name: decl.name,
330 kind,
331 factory: None,
332 factory_key: decl.factory_key,
333 restart_policy,
334 shutdown_budget: ShutdownBudget::new(
335 std::time::Duration::from_secs(5),
336 std::time::Duration::from_secs(1),
337 ),
338 health_policy,
339 readiness_policy,
340 backoff_policy: BackoffPolicy::new(
341 std::time::Duration::from_millis(10),
342 std::time::Duration::from_secs(1),
343 0.0,
344 ),
345 dependencies,
346 tags: decl.tags,
347 criticality,
348 task_role: decl.task_role,
349 sidecar_config: decl.sidecar_config,
350 severity: decl.severity,
351 group: decl.group,
352 health_check: decl.health_check,
353 command_permissions,
354 environment: decl.environment,
355 secrets: decl.secrets,
356 isolation: crate::spec::child::Isolation::AsyncWorker,
357 cleanup_paths: Vec::new(),
358 })
359 }
360}
361
362pub fn validate_child_declaration(
377 declaration: &ChildDeclaration,
378 all_names: &HashSet<String>,
379) -> Result<(), ValidationError> {
380 if !is_valid_identifier(&declaration.name) {
382 return Err(ValidationError {
383 field_path: "name".to_string(),
384 reason: format!(
385 "Child name '{}' contains invalid characters",
386 declaration.name
387 ),
388 hint: Some("Names must match ^[a-zA-Z_][a-zA-Z0-9_-]*$".to_string()),
389 });
390 }
391
392 if let Some(factory_key) = declaration.factory_key.as_deref()
394 && !is_valid_identifier(factory_key)
395 {
396 return Err(ValidationError {
397 field_path: "factory_key".to_string(),
398 reason: format!("Factory key '{factory_key}' contains invalid characters"),
399 hint: Some("Factory keys must match ^[a-zA-Z_][a-zA-Z0-9_-]*$".to_string()),
400 });
401 }
402
403 for dep in &declaration.dependencies {
405 if !all_names.contains(dep) {
406 return Err(ValidationError {
407 field_path: format!("dependencies[{dep}]"),
408 reason: format!("Dependency '{dep}' does not exist in the children list"),
409 hint: Some(format!(
410 "Add a child named '{dep}' or remove the dependency"
411 )),
412 });
413 }
414 }
415
416 for secret in &declaration.secrets {
418 let placeholder = format!("${{{}}}", secret.name);
419 if !is_valid_secret_placeholder(&placeholder) {
420 return Err(ValidationError {
421 field_path: format!("secrets[{}].name", secret.name),
422 reason: format!(
423 "Secret name '{}' contains invalid characters for placeholder",
424 secret.name
425 ),
426 hint: Some("Secret names must match ^[A-Za-z_][A-Za-z0-9_]*$".to_string()),
427 });
428 }
429 }
430 for env in &declaration.environment {
431 if let Some(ref secret_ref) = env.secret_ref
432 && !is_valid_secret_placeholder(secret_ref)
433 {
434 return Err(ValidationError {
435 field_path: format!("environment[{}].secret_ref", env.name),
436 reason: format!("Secret reference '{secret_ref}' has invalid syntax"),
437 hint: Some(
438 "Secret references must match ^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$".to_string(),
439 ),
440 });
441 }
442 }
443
444 for env in &declaration.environment {
446 if env.value.is_some() && env.secret_ref.is_some() {
447 return Err(ValidationError {
448 field_path: format!("environment[{}]", env.name),
449 reason: format!(
450 "Environment variable '{}' has both value and secret_ref set",
451 env.name
452 ),
453 hint: Some("Set either 'value' or 'secret_ref', not both".to_string()),
454 });
455 }
456 }
457
458 Ok(())
459}