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::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
20/// Valid characters for child names and secret names: alphanumeric, underscore, hyphen.
21fn 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
33/// Validates a `${SECRET_NAME}` placeholder syntax.
34fn 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/// Declarative child specification loaded from YAML or received via add_child RPC.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub struct ChildDeclaration {
52    /// Unique child name used for ChildId generation.
53    pub name: String,
54    /// Task kind.
55    #[serde(default)]
56    pub kind: TaskKind,
57    /// Child criticality.
58    #[serde(default)]
59    pub criticality: Criticality,
60    /// Low-cardinality tags used for grouping and diagnostics.
61    #[serde(default)]
62    pub tags: Vec<String>,
63    /// Optional task role that selects default lifecycle semantics.
64    #[serde(default)]
65    pub task_role: Option<TaskRole>,
66    /// Optional sidecar binding used when the role is `sidecar`.
67    #[serde(default)]
68    pub sidecar_config: Option<SidecarConfig>,
69    /// Optional severity classification that overrides the role default.
70    #[serde(default)]
71    pub severity: Option<SeverityClass>,
72    /// Optional group name for group-level isolation and budget tracking.
73    #[serde(default)]
74    pub group: Option<String>,
75    /// Restart policy.
76    #[serde(default)]
77    pub restart_policy: RestartPolicy,
78    /// Child dependencies by name.
79    #[serde(default)]
80    pub dependencies: Vec<String>,
81    /// Optional health check configuration.
82    #[serde(default)]
83    pub health_check: Option<HealthCheckConfig>,
84    /// Optional readiness check configuration.
85    #[serde(default)]
86    pub readiness: Option<ReadinessConfig>,
87    /// Optional resource limits.
88    #[serde(default)]
89    pub resource_limits: Option<ResourceLimits>,
90    /// Optional command permissions.
91    #[serde(default)]
92    pub command_permissions: Option<CommandPermissions>,
93    /// Environment variables.
94    #[serde(default)]
95    pub environment: Vec<EnvVar>,
96    /// Secret references.
97    #[serde(default)]
98    pub secrets: Vec<SecretRef>,
99}
100
101/// Phase of an add_child transaction.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum Phase {
105    /// Parsing completed.
106    Parsed,
107    /// Validation passed.
108    Validated,
109    /// Registered in the topology.
110    Registered,
111    /// Child has been started.
112    Started,
113    /// Audit has been persisted.
114    Audited,
115    /// Transaction committed successfully.
116    Committed,
117    /// Transaction failed, compensation in progress.
118    Compensating,
119    /// Compensation completed.
120    Compensated,
121}
122
123/// Pending child entry in the add_child transaction staging area.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct PendingChild {
126    /// Unique transaction identifier.
127    pub transaction_id: Uuid,
128    /// Original child declaration.
129    pub declaration: ChildDeclaration,
130    /// Converted runtime child specification.
131    pub child_spec: Box<ChildSpec>,
132    /// Current transaction phase.
133    pub phase: Phase,
134    /// Creation timestamp in Unix nanoseconds.
135    pub created_at_unix_nanos: u128,
136}
137
138// Manual PartialEq — skips child_spec because ChildSpec contains
139// Arc<dyn TaskFactory> which does not implement PartialEq.
140impl PartialEq for PendingChild {
141    /// Compares two PendingChild values, skipping `child_spec`.
142    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/// Compensating record stored in the audit channel.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct CompensatingRecord {
153    /// Unique transaction identifier.
154    pub transaction_id: Uuid,
155    /// Operation type (e.g. "add_child").
156    pub operation: String,
157    /// Compensation state: "pending", "committed", or "compensated".
158    pub state: String,
159    /// Child name.
160    pub child_name: String,
161    /// SHA-256 hash of the ChildDeclaration.
162    pub declaration_hash: String,
163    /// Optional error reason.
164    pub error: Option<String>,
165    /// Optional correlation id for linking to 006-5 event chains.
166    pub correlation_id: Option<String>,
167    /// Optional runtime ChildId, if assigned.
168    pub child_id: Option<String>,
169    /// Creation timestamp in Unix nanoseconds.
170    pub created_at_unix_nanos: u128,
171}
172
173/// Validation error for a child declaration.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct ValidationError {
176    /// JSON Pointer field path.
177    pub field_path: String,
178    /// Human-readable failure reason.
179    pub reason: String,
180    /// Optional actionable hint.
181    pub hint: Option<String>,
182}
183
184/// Converts a ChildDeclaration into a ChildSpec.
185impl TryFrom<ChildDeclaration> for ChildSpec {
186    type Error = ValidationError;
187
188    /// Converts a child declaration into a runtime child specification.
189    ///
190    /// # Arguments
191    ///
192    /// - `decl`: The child declaration to convert.
193    ///
194    /// # Returns
195    ///
196    /// Returns a [`ChildSpec`] with mapped fields.
197    ///
198    /// # Errors
199    ///
200    /// Returns a [`ValidationError`] when the declaration cannot be converted.
201    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        // Convert dependency names to ChildIds.
208        let dependencies: Vec<ChildId> = decl.dependencies.iter().map(ChildId::new).collect();
209
210        // Map health_check to health_policy.
211        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        // Map readiness using the existing ReadinessPolicy::Immediate as default.
223        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
261/// Validates a child declaration against the given set of existing child names.
262///
263/// # Arguments
264///
265/// - `declaration`: The child declaration to validate.
266/// - `all_names`: Set of existing child names for dependency existence checks.
267///
268/// # Returns
269///
270/// Returns `Ok(())` when all validation rules pass.
271///
272/// # Errors
273///
274/// Returns a [`ValidationError`] describing the first rule violation found.
275pub fn validate_child_declaration(
276    declaration: &ChildDeclaration,
277    all_names: &HashSet<String>,
278) -> Result<(), ValidationError> {
279    // Rule 1: name is non-empty and matches identifier pattern.
280    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    // Rule 2: dependencies exist in all_names.
292    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    // Rule 4: secret placeholder syntax validation.
305    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    // Rule 5: value and secret_ref are mutually exclusive.
333    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}