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}