Skip to main content

rust_supervisor/spec/
child_declaration.rs

1//! Child declaration model for YAML loading and add_child RPC.
2//!
3//! This module owns the declarative representation of child declarations as
4//! they appear in YAML configuration files or runtime add_child payloads. It
5//! also defines the transaction phase enum, pending child state, and
6//! compensating records used by the add_child transaction pipeline.
7
8use 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
23/// Valid characters for child names and secret names: alphanumeric, underscore, hyphen.
24fn 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
36/// Validates a `${SECRET_NAME}` placeholder syntax.
37fn 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/// Declarative child specification loaded from YAML or received via add_child RPC.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Config, JsonSchema)]
54pub struct ChildDeclaration {
55    /// Unique child name used for ChildId generation.
56    pub name: String,
57    /// Task kind.
58    #[config(default = "async_worker")]
59    #[serde(default)]
60    pub kind: TaskKind,
61    /// Child criticality.
62    #[config(default = "optional")]
63    #[serde(default)]
64    pub criticality: Criticality,
65    /// Low-cardinality tags used for grouping and diagnostics.
66    #[config(default = [])]
67    #[serde(default)]
68    pub tags: Vec<String>,
69    /// Optional task role that selects default lifecycle semantics.
70    ///
71    /// Defaults to `worker` when omitted.
72    #[serde(default)]
73    #[schemars(default = "default_task_role_for_schema")]
74    pub task_role: Option<TaskRole>,
75    /// Optional task factory registry key used for worker children.
76    #[schemars(!default)]
77    #[serde(default)]
78    pub factory_key: Option<String>,
79    /// Optional sidecar binding used when the role is `sidecar`.
80    #[schemars(!default)]
81    #[serde(default)]
82    pub sidecar_config: Option<SidecarConfig>,
83    /// Optional severity classification that overrides the role default.
84    #[schemars(!default)]
85    #[serde(default)]
86    pub severity: Option<SeverityClass>,
87    /// Optional group name for group-level isolation and budget tracking.
88    #[schemars(!default)]
89    #[serde(default)]
90    pub group: Option<String>,
91    /// Restart policy.
92    #[config(default = "permanent")]
93    #[serde(default)]
94    pub restart_policy: RestartPolicy,
95    /// Child dependencies by name.
96    #[config(default = [])]
97    #[serde(default)]
98    pub dependencies: Vec<String>,
99    /// Optional health check configuration.
100    #[schemars(!default)]
101    #[serde(default)]
102    pub health_check: Option<HealthCheckConfig>,
103    /// Optional readiness policy; defaults to immediate readiness.
104    #[schemars(!default)]
105    #[serde(default)]
106    pub readiness: Option<ReadinessPolicy>,
107    /// Optional command permissions.
108    #[schemars(!default)]
109    #[serde(default)]
110    pub command_permissions: Option<CommandPermissions>,
111    /// Environment variables.
112    #[config(default = [])]
113    #[serde(default)]
114    pub environment: Vec<EnvVar>,
115    /// Secret references.
116    #[config(default = [])]
117    #[serde(default)]
118    pub secrets: Vec<SecretRef>,
119}
120
121/// Returns the schema default shown for omitted child `task_role` values.
122fn default_task_role_for_schema() -> Option<TaskRole> {
123    Some(TaskRole::Worker)
124}
125
126/// Split-friendly nested section for child declarations.
127///
128/// Single-file configs use `children: [...]`. Split `children.yaml` files contain
129/// only the child declaration sequence for this section.
130#[derive(Debug, Clone, PartialEq, Config)]
131pub struct ChildrenConfigSection {
132    /// Child declarations loaded from the `children` configuration section.
133    #[config(default = [{ "name": "worker" }])]
134    pub items: Vec<ChildDeclaration>,
135}
136
137impl Default for ChildrenConfigSection {
138    /// Returns an empty child declaration section.
139    fn default() -> Self {
140        Self { items: Vec::new() }
141    }
142}
143
144impl JsonSchema for ChildrenConfigSection {
145    /// Returns the schema name used for split child sections.
146    fn schema_name() -> Cow<'static, str> {
147        Cow::Borrowed("ChildrenConfigSection")
148    }
149
150    /// Returns the transparent array schema for child declarations.
151    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
152        Vec::<ChildDeclaration>::json_schema(generator)
153    }
154}
155
156impl Serialize for ChildrenConfigSection {
157    /// Serializes child declarations as a transparent array.
158    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    /// Deserializes child declarations from a transparent array.
168    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    /// Returns child declarations as a slice.
180    pub fn as_slice(&self) -> &[ChildDeclaration] {
181        &self.items
182    }
183
184    /// Returns the number of child declarations.
185    pub fn len(&self) -> usize {
186        self.items.len()
187    }
188
189    /// Returns whether this section contains no child declarations.
190    pub fn is_empty(&self) -> bool {
191        self.items.is_empty()
192    }
193}
194
195impl From<ChildrenConfigSection> for Vec<ChildDeclaration> {
196    /// Converts a child section into its transparent declaration vector.
197    fn from(section: ChildrenConfigSection) -> Self {
198        section.items
199    }
200}
201
202/// Phase of an add_child transaction.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum Phase {
206    /// Parsing completed.
207    Parsed,
208    /// Validation passed.
209    Validated,
210    /// Registered in the topology.
211    Registered,
212    /// Child has been started.
213    Started,
214    /// Audit has been persisted.
215    Audited,
216    /// Transaction committed successfully.
217    Committed,
218    /// Transaction failed, compensation in progress.
219    Compensating,
220    /// Compensation completed.
221    Compensated,
222}
223
224/// Pending child entry in the add_child transaction staging area.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct PendingChild {
227    /// Unique transaction identifier.
228    pub transaction_id: Uuid,
229    /// Original child declaration.
230    pub declaration: ChildDeclaration,
231    /// Converted runtime child specification.
232    pub child_spec: Box<ChildSpec>,
233    /// Current transaction phase.
234    pub phase: Phase,
235    /// Creation timestamp in Unix nanoseconds.
236    pub created_at_unix_nanos: u128,
237}
238
239// Manual PartialEq — skips child_spec because ChildSpec contains
240// Arc<dyn TaskFactory> which does not implement PartialEq.
241impl PartialEq for PendingChild {
242    /// Compares two PendingChild values, skipping `child_spec`.
243    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/// Compensating record stored in the audit channel.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CompensatingRecord {
254    /// Unique transaction identifier.
255    pub transaction_id: Uuid,
256    /// Operation type (e.g. "add_child").
257    pub operation: String,
258    /// Compensation state: "pending", "committed", or "compensated".
259    pub state: String,
260    /// Child name.
261    pub child_name: String,
262    /// SHA-256 hash of the ChildDeclaration.
263    pub declaration_hash: String,
264    /// Optional error reason.
265    pub error: Option<String>,
266    /// Optional correlation id for linking to 006-5 event chains.
267    pub correlation_id: Option<String>,
268    /// Optional runtime ChildId, if assigned.
269    pub child_id: Option<String>,
270    /// Creation timestamp in Unix nanoseconds.
271    pub created_at_unix_nanos: u128,
272}
273
274/// Validation error for a child declaration.
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
276pub struct ValidationError {
277    /// JSON Pointer field path.
278    pub field_path: String,
279    /// Human-readable failure reason.
280    pub reason: String,
281    /// Optional actionable hint.
282    pub hint: Option<String>,
283}
284
285/// Converts a ChildDeclaration into a ChildSpec.
286impl TryFrom<ChildDeclaration> for ChildSpec {
287    type Error = ValidationError;
288
289    /// Converts a child declaration into a runtime child specification.
290    ///
291    /// # Arguments
292    ///
293    /// - `decl`: The child declaration to convert.
294    ///
295    /// # Returns
296    ///
297    /// Returns a [`ChildSpec`] with mapped fields.
298    ///
299    /// # Errors
300    ///
301    /// Returns a [`ValidationError`] when the declaration cannot be converted.
302    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        // Convert dependency names to ChildIds.
309        let dependencies: Vec<ChildId> = decl.dependencies.iter().map(ChildId::new).collect();
310
311        // Map health_check to health_policy.
312        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
362/// Validates a child declaration against the given set of existing child names.
363///
364/// # Arguments
365///
366/// - `declaration`: The child declaration to validate.
367/// - `all_names`: Set of existing child names for dependency existence checks.
368///
369/// # Returns
370///
371/// Returns `Ok(())` when all validation rules pass.
372///
373/// # Errors
374///
375/// Returns a [`ValidationError`] describing the first rule violation found.
376pub fn validate_child_declaration(
377    declaration: &ChildDeclaration,
378    all_names: &HashSet<String>,
379) -> Result<(), ValidationError> {
380    // Rule 1: name is non-empty and matches identifier pattern.
381    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    // Rule 1b: factory_key uses the same stable identifier surface as child names.
393    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    // Rule 2: dependencies exist in all_names.
404    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    // Rule 4: secret placeholder syntax validation.
417    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    // Rule 5: value and secret_ref are mutually exclusive.
445    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}