Skip to main content

workload_spec/
validate.rs

1//! Shape validators for [`WorkloadSpec`].
2//!
3//! Called by clients (desktop, agent, CLI) before sending a spec over RPC.
4//! Sync, no I/O. Returns `Ok(warnings)` on pass or `Err(ShapeError)` on the
5//! first hard constraint violation.
6//!
7//! Layers: shape (this file, no I/O) → semantic (yubaba-side, R090-F3) →
8//! environment (deploy-time, R090-F4).
9
10use std::fmt;
11use std::sync::OnceLock;
12
13use regex::Regex;
14use thiserror::Error;
15
16use crate::{
17    EnvValue, EnvVar, ImageRef, MachineId, MeshIdent, MeshLookup, RestartPolicy, SecretRef,
18    SecretTarget, StaticAssetWorkload, VolumeSource, WorkloadSpec,
19};
20
21// ── Field paths ───────────────────────────────────────────────────────────────
22
23/// Identifies the field that caused a shape error or warning.
24///
25/// Structured as an enum so promoting to all-errors mode (collecting into
26/// `Vec<FieldError>` instead of returning on the first hit) is mechanical.
27#[derive(Debug, Clone, PartialEq)]
28pub enum FieldPath {
29    Name,
30    MeshIdentity,
31    TailscaleTag,
32    Replicas,
33    ImageTag,
34    Tier,
35    /// `volumes[index].<sub>` — e.g. `Volume(0, "source")`.
36    Volume(usize, &'static str),
37    /// Public port not found in `expose.mesh.ports`.
38    ExposeMeshPort(u16),
39    /// `secrets[index].<sub>` — e.g. `Secret(0, "target.path")`.
40    Secret(usize, &'static str),
41    /// `healthcheck.<sub>`.
42    Healthcheck(&'static str),
43    RestartPolicy,
44    /// `image` — registry says the image/tag is unknown.
45    Image,
46    /// `depends_on[index]` — mesh ident is not a known deployed workload.
47    DependsOn(usize),
48    /// `expose.public.hostname` — hostname is not in an owned CF zone.
49    Hostname,
50    /// `resources` — machine lacks sufficient capacity.
51    Resources,
52    /// `aliases[key]` — alias target filename is not in the `[[asset]]` catalog.
53    AssetAlias(String),
54    /// `asset[index].<sub>` — e.g. `Asset(0, "source")` for the XOR rule.
55    Asset(usize, &'static str),
56}
57
58impl fmt::Display for FieldPath {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            FieldPath::Name => write!(f, "name"),
62            FieldPath::MeshIdentity => write!(f, "expose.mesh.identity"),
63            FieldPath::TailscaleTag => write!(f, "expose.operator.tailscale_tag"),
64            FieldPath::Replicas => write!(f, "replicas"),
65            FieldPath::ImageTag => write!(f, "image.tag"),
66            FieldPath::Tier => write!(f, "tier"),
67            FieldPath::Volume(i, sub) => write!(f, "volumes[{i}].{sub}"),
68            FieldPath::ExposeMeshPort(port) => write!(f, "expose.public.port ({port})"),
69            FieldPath::Secret(i, sub) => write!(f, "secrets[{i}].{sub}"),
70            FieldPath::Healthcheck(sub) => write!(f, "healthcheck.{sub}"),
71            FieldPath::RestartPolicy => write!(f, "restart_policy"),
72            FieldPath::Image => write!(f, "image"),
73            FieldPath::DependsOn(i) => write!(f, "depends_on[{i}]"),
74            FieldPath::Hostname => write!(f, "expose.public.hostname"),
75            FieldPath::Resources => write!(f, "resources"),
76            FieldPath::AssetAlias(key) => write!(f, "aliases[{key}]"),
77            FieldPath::Asset(i, sub) => write!(f, "asset[{i}].{sub}"),
78        }
79    }
80}
81
82// ── Hard errors ───────────────────────────────────────────────────────────────
83
84/// A hard constraint violation that makes a spec impossible to deploy.
85///
86/// V1 surfaces the first error found. When the UI needs per-field
87/// highlighting, wrap in `Vec<ShapeError>` and collect instead of returning
88/// early — the `FieldPath` enum is already the common currency.
89#[derive(Debug, Error, PartialEq)]
90pub enum ShapeError {
91    #[error("field {path}: {reason}")]
92    Field { path: FieldPath, reason: String },
93}
94
95// ── Soft warnings ─────────────────────────────────────────────────────────────
96
97/// A soft check that passed but may indicate misconfiguration.
98#[derive(Debug, Clone, PartialEq)]
99pub struct ShapeWarning {
100    pub path: FieldPath,
101    pub message: String,
102}
103
104impl fmt::Display for ShapeWarning {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(f, "warning at {}: {}", self.path, self.message)
107    }
108}
109
110// ── Internal helpers ──────────────────────────────────────────────────────────
111
112/// V1 known tier values. Unknown tiers produce a warning, not an error
113/// (cluster config may add custom tiers).
114const KNOWN_TIERS: &[&str] = &["public", "tenant", "private", "infra"];
115
116fn dns_label_re() -> &'static Regex {
117    static RE: OnceLock<Regex> = OnceLock::new();
118    RE.get_or_init(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap())
119}
120
121fn env_name_re() -> &'static Regex {
122    static RE: OnceLock<Regex> = OnceLock::new();
123    RE.get_or_init(|| Regex::new(r"^[A-Z_][A-Z0-9_]*$").unwrap())
124}
125
126/// Validates a single DNS label: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤ 63 chars.
127fn check_dns_label(value: &str, path: FieldPath) -> Result<(), ShapeError> {
128    if value.len() > 63 {
129        return Err(ShapeError::Field {
130            path,
131            reason: format!("length {} exceeds maximum 63", value.len()),
132        });
133    }
134    if !dns_label_re().is_match(value) {
135        return Err(ShapeError::Field {
136            path,
137            reason: format!(
138                "{:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
139                value
140            ),
141        });
142    }
143    Ok(())
144}
145
146/// Validates a dot-separated mesh identity where each segment is a DNS label.
147/// Total length ≤ 63. Example valid value: `"noisetable-api.pdx"`.
148fn check_mesh_ident(value: &str, path: FieldPath) -> Result<(), ShapeError> {
149    if value.len() > 63 {
150        return Err(ShapeError::Field {
151            path,
152            reason: format!("length {} exceeds maximum 63", value.len()),
153        });
154    }
155    for segment in value.split('.') {
156        if !dns_label_re().is_match(segment) {
157            return Err(ShapeError::Field {
158                path,
159                reason: format!(
160                    "segment {:?} in {:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
161                    segment, value
162                ),
163            });
164        }
165    }
166    Ok(())
167}
168
169// ── Public API ────────────────────────────────────────────────────────────────
170
171/// Run shape validation — sync, no I/O.
172///
173/// Returns `Ok(warnings)` when all hard constraints pass; the `Vec` is empty
174/// for a clean spec. Returns `Err` on the first hard constraint violation.
175/// Callers that only need hard errors can discard the Ok value with
176/// `.map(|_| ())`.
177///
178/// Hard constraints checked:
179/// - `name`, `expose.mesh.identity`: DNS-label format, ≤ 63 chars.
180/// - `expose.operator.tailscale_tag`: `"tag:<dns-label>"`, ≤ 63 chars.
181/// - `replicas`: 0–100.
182/// - `image.tag`: non-empty when `digest` is `None`.
183/// - `volumes[*].source = Bind`: only allowed when `tier = "infra"`.
184/// - `expose.public.port`: must appear in `expose.mesh.ports`.
185/// - `secrets[*].target`: file paths must be absolute; env-var names must
186///   match `^[A-Z_][A-Z0-9_]*$`.
187///
188/// Soft checks (produce warnings, not errors):
189/// - Unknown tier value.
190/// - `RestartPolicy::Never` without `annotations["yah.forge"] = "true"`.
191/// - `healthcheck.initial_delay < stop_policy.grace_period * 2`.
192pub fn shape(spec: &WorkloadSpec) -> Result<Vec<ShapeWarning>, ShapeError> {
193    let mut warnings: Vec<ShapeWarning> = Vec::new();
194
195    // name: single DNS label, ≤ 63 chars
196    check_dns_label(&spec.name, FieldPath::Name)?;
197
198    // expose.mesh.identity: dot-separated DNS name, ≤ 63 total
199    check_mesh_ident(&spec.expose.mesh.identity.0, FieldPath::MeshIdentity)?;
200
201    // expose.operator.tailscale_tag: "tag:<dns-label>", ≤ 63 chars (optional)
202    if let Some(op) = &spec.expose.operator {
203        let tag = &op.tailscale_tag;
204        if tag.len() > 63 {
205            return Err(ShapeError::Field {
206                path: FieldPath::TailscaleTag,
207                reason: format!("length {} exceeds maximum 63", tag.len()),
208            });
209        }
210        let rest = tag.strip_prefix("tag:").ok_or_else(|| ShapeError::Field {
211            path: FieldPath::TailscaleTag,
212            reason: format!("{:?} must start with \"tag:\"", tag),
213        })?;
214        if !dns_label_re().is_match(rest) {
215            return Err(ShapeError::Field {
216                path: FieldPath::TailscaleTag,
217                reason: format!(
218                    "the part after \"tag:\" in {:?} must match \
219                     ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
220                    tag
221                ),
222            });
223        }
224    }
225
226    // replicas: 0..=100
227    if spec.replicas > 100 {
228        return Err(ShapeError::Field {
229            path: FieldPath::Replicas,
230            reason: format!("{} exceeds maximum 100", spec.replicas),
231        });
232    }
233
234    // image.tag: non-empty (informational identifier; digest is the source of
235    // truth and is structurally required at the type level).
236    if spec.image.tag.is_empty() {
237        return Err(ShapeError::Field {
238            path: FieldPath::ImageTag,
239            reason: "tag is empty; provide a human-readable tag alongside the digest".into(),
240        });
241    }
242
243    // tier: warn on unknown (cluster config may add custom tiers)
244    if !KNOWN_TIERS.contains(&spec.tier.0.as_str()) {
245        warnings.push(ShapeWarning {
246            path: FieldPath::Tier,
247            message: format!(
248                "\"{}\" is not in the known tier set (public/tenant/private/infra); \
249                 yubaba may reject it if the cluster config does not include this tier",
250                spec.tier.0
251            ),
252        });
253    }
254
255    // volumes[*]: Bind rejected unless tier = "infra"
256    for (i, vol) in spec.volumes.iter().enumerate() {
257        if matches!(&vol.source, VolumeSource::Bind { .. }) && spec.tier.0 != "infra" {
258            return Err(ShapeError::Field {
259                path: FieldPath::Volume(i, "source"),
260                reason: format!(
261                    "Bind mounts are only allowed when tier = \"infra\" \
262                     (current tier: {:?})",
263                    spec.tier.0
264                ),
265            });
266        }
267    }
268
269    // expose.public.port must appear in expose.mesh.ports
270    if let Some(public) = &spec.expose.public {
271        if !spec.expose.mesh.ports.contains(&public.port) {
272            return Err(ShapeError::Field {
273                path: FieldPath::ExposeMeshPort(public.port),
274                reason: format!(
275                    "port {} must appear in expose.mesh.ports {:?} \
276                     before it can be exposed publicly",
277                    public.port, spec.expose.mesh.ports
278                ),
279            });
280        }
281    }
282
283    // secrets[*]: target paths absolute; env-var names valid identifiers
284    for (i, secret) in spec.secrets.iter().enumerate() {
285        match &secret.target {
286            SecretTarget::File { path, .. } => {
287                if !path.is_absolute() {
288                    return Err(ShapeError::Field {
289                        path: FieldPath::Secret(i, "target.path"),
290                        reason: format!("{:?} is not an absolute path", path),
291                    });
292                }
293            }
294            SecretTarget::EnvVar { name } => {
295                if !env_name_re().is_match(name) {
296                    return Err(ShapeError::Field {
297                        path: FieldPath::Secret(i, "target.name"),
298                        reason: format!(
299                            "{:?} is not a valid env-var identifier (^[A-Z_][A-Z0-9_]*$)",
300                            name
301                        ),
302                    });
303                }
304            }
305        }
306    }
307
308    // soft: RestartPolicy::Never without yah.forge=true annotation
309    if matches!(spec.restart_policy, RestartPolicy::Never) {
310        let is_forge = spec
311            .annotations
312            .get("yah.forge")
313            .map(|v| v == "true")
314            .unwrap_or(false);
315        if !is_forge {
316            warnings.push(ShapeWarning {
317                path: FieldPath::RestartPolicy,
318                message: "restart_policy=Never is intended for forge runs; \
319                          add annotation yah.forge=true to suppress this warning"
320                    .into(),
321            });
322        }
323    }
324
325    // soft: healthcheck.initial_delay >= stop_policy.grace_period * 2
326    if let Some(hc) = &spec.healthcheck {
327        let min_recommended = spec.stop_policy.grace_period.as_ms().saturating_mul(2);
328        if hc.initial_delay.as_ms() < min_recommended {
329            warnings.push(ShapeWarning {
330                path: FieldPath::Healthcheck("initial_delay"),
331                message: format!(
332                    "initial_delay ({}ms) is less than stop_policy.grace_period * 2 ({}ms); \
333                     a SIGTERM during startup may catch a still-initialising container",
334                    hc.initial_delay.as_ms(),
335                    min_recommended
336                ),
337            });
338        }
339    }
340
341    Ok(warnings)
342}
343
344// ── StaticAsset validator ─────────────────────────────────────────────────────
345
346/// Shape-validate a `kind = "static-asset"` workload.
347///
348/// Enforces the closed-catalog invariant: every value in `[aliases]` must be a
349/// `filename` present in `[[asset]]`. A mirror's `[asset_aliases]` overrides
350/// are bound by the same rule and are validated separately at sync time when
351/// both the workload and mirror are loaded together.
352pub fn shape_static_asset(workload: &StaticAssetWorkload) -> Result<(), ShapeError> {
353    // XOR rule (W164 / R438-T2): every [[asset]] row must set exactly one of
354    // `source` (legacy local bytes) or `derive` (fetch + optional transform).
355    // Both-set is ambiguous (which one wins?); neither-set leaves the
356    // reconciler with no bytes to upload.
357    for (i, entry) in workload.assets.iter().enumerate() {
358        match (entry.source.is_some(), entry.derive.is_some()) {
359            (true, true) => {
360                return Err(ShapeError::Field {
361                    path: FieldPath::Asset(i, "source"),
362                    reason: format!(
363                        "asset {:?}: both `source` and `derive` are set; pick exactly one",
364                        entry.filename
365                    ),
366                });
367            }
368            (false, false) => {
369                return Err(ShapeError::Field {
370                    path: FieldPath::Asset(i, "source"),
371                    reason: format!(
372                        "asset {:?}: neither `source` nor `derive` is set; pick exactly one",
373                        entry.filename
374                    ),
375                });
376            }
377            _ => {}
378        }
379    }
380
381    let filenames: std::collections::HashSet<&str> =
382        workload.assets.iter().map(|a| a.filename.as_str()).collect();
383
384    for (alias_key, alias_target) in &workload.aliases {
385        if !filenames.contains(alias_target.as_str()) {
386            return Err(ShapeError::Field {
387                path: FieldPath::AssetAlias(alias_key.clone()),
388                reason: format!(
389                    "alias target {:?} is not present in the [[asset]] catalog; \
390                     add a matching [[asset]] row or correct the filename",
391                    alias_target
392                ),
393            });
394        }
395    }
396
397    Ok(())
398}
399
400// ── Semantic layer ────────────────────────────────────────────────────────────
401
402/// Transient error from a [`ValidationContext`] lookup.
403///
404/// Distinct from a semantic "resource not found" failure. `ContextError` means
405/// the lookup itself could not complete (network timeout, auth failure, etc.),
406/// not that the resource is definitively absent.
407#[derive(Debug, Error, Clone, PartialEq)]
408#[error("context lookup failed: {0}")]
409pub struct ContextError(pub String);
410
411/// A semantic constraint violation: the spec references a resource that is not
412/// known to the cluster at validation time.
413#[derive(Debug, Error, PartialEq)]
414pub enum SemanticError {
415    #[error("field {path}: {reason}")]
416    Unknown { path: FieldPath, reason: String },
417}
418
419/// Top-level validation error spanning both shape and semantic layers.
420///
421/// `Shape` always wins: if the spec is structurally invalid, semantic checks
422/// never run.
423#[derive(Debug, Error, PartialEq)]
424pub enum WorkloadValidationError {
425    /// Hard shape constraint failed — spec is structurally invalid.
426    #[error("shape: {0}")]
427    Shape(ShapeError),
428
429    /// Semantic check failed — spec references an unknown cluster resource.
430    #[error("semantic: {0}")]
431    Semantic(SemanticError),
432
433    /// Transient ValidationContext lookup failure — the check itself failed.
434    #[error("context: {0}")]
435    Context(ContextError),
436}
437
438impl From<ShapeError> for WorkloadValidationError {
439    fn from(e: ShapeError) -> Self { WorkloadValidationError::Shape(e) }
440}
441
442impl From<ContextError> for WorkloadValidationError {
443    fn from(e: ContextError) -> Self { WorkloadValidationError::Context(e) }
444}
445
446/// Read-only view of yubaba state used for semantic validation.
447///
448/// Defined here so clients (desktop, CLI, agents) can run semantic checks
449/// without depending on the yubaba crate. Yubaba implements this trait.
450///
451/// Each method returns `Result<bool, ContextError>` so transient failures are
452/// distinguishable from definitive "not found" answers.
453pub trait ValidationContext {
454    /// True when the registry confirms the image exists.
455    fn image_exists(&self, image: &ImageRef) -> Result<bool, ContextError>;
456
457    /// True when the named secret exists in the yubaba secret store.
458    fn secret_exists(&self, secret: &SecretRef) -> Result<bool, ContextError>;
459
460    /// True when `ident` is a known deployed workload OR appears in `batch`
461    /// (the set of specs co-deployed in the same request — allows forward
462    /// references within a single deployment batch).
463    fn mesh_ident_known(&self, ident: &MeshIdent, batch: &[MeshIdent]) -> Result<bool, ContextError>;
464
465    /// True when `hostname` falls under a Cloudflare zone owned by this cluster.
466    fn cf_zone_owned(&self, hostname: &str) -> Result<bool, ContextError>;
467
468    /// True when `tag` (e.g. `"tag:noisetable-ops"`) is in the cluster's
469    /// Tailscale ACL tag list.
470    fn tailscale_tag_known(&self, tag: &str) -> Result<bool, ContextError>;
471
472    /// True when `machine_id` has sufficient remaining capacity to host the
473    /// given spec's resource requirements.
474    fn capacity_for(&self, spec: &WorkloadSpec, machine_id: &MachineId) -> Result<bool, ContextError>;
475}
476
477/// Run semantic validation — requires yubaba state via [`ValidationContext`].
478///
479/// Shape validation is NOT run here. Callers MUST run [`shape`] first; use
480/// [`all`] to enforce this automatically.
481///
482/// `machine_id` is the target machine for admission-control capacity checks.
483/// `batch` is the set of mesh idents being co-deployed (pass `&[]` for
484/// single-spec deployment); these count as "known" for `depends_on` resolution.
485pub fn semantic(
486    spec: &WorkloadSpec,
487    ctx: &dyn ValidationContext,
488    machine_id: &MachineId,
489    batch: &[MeshIdent],
490) -> Result<(), WorkloadValidationError> {
491    if !ctx.image_exists(&spec.image)? {
492        return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
493            path: FieldPath::Image,
494            reason: format!(
495                "image {}/{}:{} not found in registry",
496                spec.image.registry, spec.image.repository, spec.image.tag
497            ),
498        }));
499    }
500
501    for (i, secret) in spec.secrets.iter().enumerate() {
502        if !ctx.secret_exists(&secret.source)? {
503            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
504                path: FieldPath::Secret(i, "source"),
505                reason: format!("secret source at index {i} not found in yubaba secret store"),
506            }));
507        }
508    }
509
510    for (i, dep) in spec.depends_on.iter().enumerate() {
511        if !ctx.mesh_ident_known(dep, batch)? {
512            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
513                path: FieldPath::DependsOn(i),
514                reason: format!("mesh ident {:?} is not a known deployed workload", dep.0),
515            }));
516        }
517    }
518
519    if let Some(public) = &spec.expose.public {
520        if !ctx.cf_zone_owned(&public.hostname)? {
521            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
522                path: FieldPath::Hostname,
523                reason: format!(
524                    "hostname {:?} is not under a Cloudflare zone owned by this cluster",
525                    public.hostname
526                ),
527            }));
528        }
529    }
530
531    if let Some(op) = &spec.expose.operator {
532        if !ctx.tailscale_tag_known(&op.tailscale_tag)? {
533            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
534                path: FieldPath::TailscaleTag,
535                reason: format!(
536                    "tailscale tag {:?} is not in the cluster's ACL tag list",
537                    op.tailscale_tag
538                ),
539            }));
540        }
541    }
542
543    if !ctx.capacity_for(spec, machine_id)? {
544        return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
545            path: FieldPath::Resources,
546            reason: format!(
547                "machine {:?} lacks capacity (memory={}MB cpu_shares={} ephemeral={}MB)",
548                machine_id.0,
549                spec.resources.memory_mb,
550                spec.resources.cpu_shares,
551                spec.resources.ephemeral_storage_mb
552            ),
553        }));
554    }
555
556    Ok(())
557}
558
559// ── Mesh resolution layer ─────────────────────────────────────────────────────
560
561/// Failure surface for [`MeshResolver`] lookups.
562///
563/// `NotDeployed` means the dependency hasn't been observed in mesh state yet
564/// (yubaba's deploy step waits on this — see [`crate::EnvValue::FromMesh`]).
565/// `NoPorts` means the dependency is deployed but its `MeshExpose.ports`
566/// list is empty, so a port-based lookup can't render a value. `Lookup`
567/// covers transient failures from the underlying state read.
568#[derive(Debug, Error, Clone, PartialEq)]
569pub enum MeshError {
570    #[error("mesh ident {ident:?} is not yet deployed")]
571    NotDeployed { ident: String },
572
573    #[error(
574        "mesh ident {ident:?} exposes no ports; {lookup:?} requires at least one"
575    )]
576    NoPorts { ident: String, lookup: MeshLookup },
577
578    #[error("mesh state lookup failed: {0}")]
579    Lookup(String),
580}
581
582/// Resolve [`crate::EnvValue::FromMesh`] references to literal env values.
583///
584/// Defined in workload-spec so clients (agents, desktop, CLI) can render
585/// specs against fake mesh state without depending on the yubaba crate.
586/// Yubaba's production implementation (in `yubaba::deploy::mesh_resolve`)
587/// reads from raft state.
588///
589/// **Resolution rules** match the arch doc §"Mesh-derived env":
590/// - [`MeshLookup::Url`] — `"http://<ident>:<port>"`, where `port` is the
591///   first entry in the referenced workload's `MeshExpose.ports`.
592/// - [`MeshLookup::Host`] — the bare DNS-ish identifier as authored (e.g.
593///   `"noisetable-db.pdx"`).
594/// - [`MeshLookup::Port`] — the first port stringified, e.g. `"5432"`.
595///
596/// Implementations should perform the port lookup atomically — a `Url` and
597/// `Port` resolved in the same deploy must agree on which port was first.
598pub trait MeshResolver {
599    fn resolve(&self, ident: &MeshIdent, kind: MeshLookup) -> Result<String, MeshError>;
600}
601
602/// Render every [`EnvValue::FromMesh`] entry in `env` to a [`EnvValue::Literal`]
603/// using `resolver`; pass through `Literal` and `FromSecret` values unchanged.
604///
605/// Returns the first resolution error encountered. Callers should run this
606/// after yubaba's stage-3 mesh peering completes (see
607/// `yubaba::deploy::env_validate::run` doc), at containerd-spec assembly.
608///
609/// `FromSecret` values are deliberately untouched here — secret resolution
610/// is the secrets layer's job (R090-F5), not the mesh resolver's.
611pub fn resolve_env_from_mesh(
612    env: &[EnvVar],
613    resolver: &dyn MeshResolver,
614) -> Result<Vec<EnvVar>, MeshError> {
615    env.iter()
616        .map(|var| match &var.value {
617            EnvValue::FromMesh { ident, kind } => {
618                let value = resolver.resolve(ident, *kind)?;
619                Ok(EnvVar {
620                    name: var.name.clone(),
621                    value: EnvValue::Literal { value },
622                })
623            }
624            _ => Ok(var.clone()),
625        })
626        .collect()
627}
628
629/// Run shape then semantic validation in the correct order.
630///
631/// Shape always runs first. If shape fails, `WorkloadValidationError::Shape`
632/// is returned and semantic checks are skipped — callers never see a
633/// `Semantic` error for a structurally invalid spec.
634///
635/// `machine_id` is forwarded to the capacity admission-control check.
636/// `batch` is the set of co-deployed mesh idents for forward-reference
637/// resolution; pass `&[]` for single-spec deployment.
638pub fn all(
639    spec: &WorkloadSpec,
640    ctx: &dyn ValidationContext,
641    machine_id: &MachineId,
642    batch: &[MeshIdent],
643) -> Result<(), WorkloadValidationError> {
644    shape(spec)?;
645    semantic(spec, ctx, machine_id, batch)
646}