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    ///
475    /// Implementors: read memory via [`WorkloadSpec::memory_request_mb`], not
476    /// `spec.resources.memory_mb`. The latter is a cgroup ceiling, and using
477    /// it as a capacity floor is what made every build-worker smaller than
478    /// `for_forge`'s 32 GiB ceiling unschedulable in `admit_workload`. Only a
479    /// test implementation of this trait exists today, so the bug is not live
480    /// here — this note is to keep it from arriving with the first real one.
481    fn capacity_for(&self, spec: &WorkloadSpec, machine_id: &MachineId) -> Result<bool, ContextError>;
482}
483
484/// Run semantic validation — requires yubaba state via [`ValidationContext`].
485///
486/// Shape validation is NOT run here. Callers MUST run [`shape`] first; use
487/// [`all`] to enforce this automatically.
488///
489/// `machine_id` is the target machine for admission-control capacity checks.
490/// `batch` is the set of mesh idents being co-deployed (pass `&[]` for
491/// single-spec deployment); these count as "known" for `depends_on` resolution.
492pub fn semantic(
493    spec: &WorkloadSpec,
494    ctx: &dyn ValidationContext,
495    machine_id: &MachineId,
496    batch: &[MeshIdent],
497) -> Result<(), WorkloadValidationError> {
498    if !ctx.image_exists(&spec.image)? {
499        return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
500            path: FieldPath::Image,
501            reason: format!(
502                "image {}/{}:{} not found in registry",
503                spec.image.registry, spec.image.repository, spec.image.tag
504            ),
505        }));
506    }
507
508    for (i, secret) in spec.secrets.iter().enumerate() {
509        if !ctx.secret_exists(&secret.source)? {
510            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
511                path: FieldPath::Secret(i, "source"),
512                reason: format!("secret source at index {i} not found in yubaba secret store"),
513            }));
514        }
515    }
516
517    for (i, dep) in spec.depends_on.iter().enumerate() {
518        if !ctx.mesh_ident_known(dep, batch)? {
519            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
520                path: FieldPath::DependsOn(i),
521                reason: format!("mesh ident {:?} is not a known deployed workload", dep.0),
522            }));
523        }
524    }
525
526    if let Some(public) = &spec.expose.public {
527        if !ctx.cf_zone_owned(&public.hostname)? {
528            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
529                path: FieldPath::Hostname,
530                reason: format!(
531                    "hostname {:?} is not under a Cloudflare zone owned by this cluster",
532                    public.hostname
533                ),
534            }));
535        }
536    }
537
538    if let Some(op) = &spec.expose.operator {
539        if !ctx.tailscale_tag_known(&op.tailscale_tag)? {
540            return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
541                path: FieldPath::TailscaleTag,
542                reason: format!(
543                    "tailscale tag {:?} is not in the cluster's ACL tag list",
544                    op.tailscale_tag
545                ),
546            }));
547        }
548    }
549
550    if !ctx.capacity_for(spec, machine_id)? {
551        return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
552            path: FieldPath::Resources,
553            reason: format!(
554                "machine {:?} lacks capacity (memory={}MB cpu_millis={} ephemeral={}MB)",
555                machine_id.0,
556                spec.resources.memory_mb,
557                spec.resources.cpu_millis,
558                spec.resources.ephemeral_storage_mb
559            ),
560        }));
561    }
562
563    Ok(())
564}
565
566// ── Mesh resolution layer ─────────────────────────────────────────────────────
567
568/// Failure surface for [`MeshResolver`] lookups.
569///
570/// `NotDeployed` means the dependency hasn't been observed in mesh state yet
571/// (yubaba's deploy step waits on this — see [`crate::EnvValue::FromMesh`]).
572/// `NoPorts` means the dependency is deployed but its `MeshExpose.ports`
573/// list is empty, so a port-based lookup can't render a value. `Lookup`
574/// covers transient failures from the underlying state read.
575#[derive(Debug, Error, Clone, PartialEq)]
576pub enum MeshError {
577    #[error("mesh ident {ident:?} is not yet deployed")]
578    NotDeployed { ident: String },
579
580    #[error(
581        "mesh ident {ident:?} exposes no ports; {lookup:?} requires at least one"
582    )]
583    NoPorts { ident: String, lookup: MeshLookup },
584
585    #[error("mesh state lookup failed: {0}")]
586    Lookup(String),
587}
588
589/// Resolve [`crate::EnvValue::FromMesh`] references to literal env values.
590///
591/// Defined in workload-spec so clients (agents, desktop, CLI) can render
592/// specs against fake mesh state without depending on the yubaba crate.
593/// Yubaba's production implementation (in `yubaba::deploy::mesh_resolve`)
594/// reads from raft state.
595///
596/// **Resolution rules** match the arch doc §"Mesh-derived env":
597/// - [`MeshLookup::Url`] — `"http://<ident>:<port>"`, where `port` is the
598///   first entry in the referenced workload's `MeshExpose.ports`.
599/// - [`MeshLookup::Host`] — the bare DNS-ish identifier as authored (e.g.
600///   `"noisetable-db.pdx"`).
601/// - [`MeshLookup::Port`] — the first port stringified, e.g. `"5432"`.
602///
603/// Implementations should perform the port lookup atomically — a `Url` and
604/// `Port` resolved in the same deploy must agree on which port was first.
605pub trait MeshResolver {
606    fn resolve(&self, ident: &MeshIdent, kind: MeshLookup) -> Result<String, MeshError>;
607}
608
609/// Render every [`EnvValue::FromMesh`] entry in `env` to a [`EnvValue::Literal`]
610/// using `resolver`; pass through `Literal` and `FromSecret` values unchanged.
611///
612/// Returns the first resolution error encountered. Callers should run this
613/// after yubaba's stage-3 mesh peering completes (see
614/// `yubaba::deploy::env_validate::run` doc), at containerd-spec assembly.
615///
616/// `FromSecret` values are deliberately untouched here — secret resolution
617/// is the secrets layer's job (R090-F5), not the mesh resolver's.
618pub fn resolve_env_from_mesh(
619    env: &[EnvVar],
620    resolver: &dyn MeshResolver,
621) -> Result<Vec<EnvVar>, MeshError> {
622    env.iter()
623        .map(|var| match &var.value {
624            EnvValue::FromMesh { ident, kind } => {
625                let value = resolver.resolve(ident, *kind)?;
626                Ok(EnvVar {
627                    name: var.name.clone(),
628                    value: EnvValue::Literal { value },
629                })
630            }
631            _ => Ok(var.clone()),
632        })
633        .collect()
634}
635
636/// Run shape then semantic validation in the correct order.
637///
638/// Shape always runs first. If shape fails, `WorkloadValidationError::Shape`
639/// is returned and semantic checks are skipped — callers never see a
640/// `Semantic` error for a structurally invalid spec.
641///
642/// `machine_id` is forwarded to the capacity admission-control check.
643/// `batch` is the set of co-deployed mesh idents for forward-reference
644/// resolution; pass `&[]` for single-spec deployment.
645pub fn all(
646    spec: &WorkloadSpec,
647    ctx: &dyn ValidationContext,
648    machine_id: &MachineId,
649    batch: &[MeshIdent],
650) -> Result<(), WorkloadValidationError> {
651    shape(spec)?;
652    semantic(spec, ctx, machine_id, batch)
653}