1use 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#[derive(Debug, Clone, PartialEq)]
28pub enum FieldPath {
29 Name,
30 MeshIdentity,
31 TailscaleTag,
32 Replicas,
33 ImageTag,
34 Tier,
35 Volume(usize, &'static str),
37 ExposeMeshPort(u16),
39 Secret(usize, &'static str),
41 Healthcheck(&'static str),
43 RestartPolicy,
44 Image,
46 DependsOn(usize),
48 Hostname,
50 Resources,
52 AssetAlias(String),
54 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#[derive(Debug, Error, PartialEq)]
90pub enum ShapeError {
91 #[error("field {path}: {reason}")]
92 Field { path: FieldPath, reason: String },
93}
94
95#[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
110const 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
126fn 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
146fn 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
169pub fn shape(spec: &WorkloadSpec) -> Result<Vec<ShapeWarning>, ShapeError> {
193 let mut warnings: Vec<ShapeWarning> = Vec::new();
194
195 check_dns_label(&spec.name, FieldPath::Name)?;
197
198 check_mesh_ident(&spec.expose.mesh.identity.0, FieldPath::MeshIdentity)?;
200
201 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 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 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 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 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 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 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 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 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
344pub fn shape_static_asset(workload: &StaticAssetWorkload) -> Result<(), ShapeError> {
353 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#[derive(Debug, Error, Clone, PartialEq)]
408#[error("context lookup failed: {0}")]
409pub struct ContextError(pub String);
410
411#[derive(Debug, Error, PartialEq)]
414pub enum SemanticError {
415 #[error("field {path}: {reason}")]
416 Unknown { path: FieldPath, reason: String },
417}
418
419#[derive(Debug, Error, PartialEq)]
424pub enum WorkloadValidationError {
425 #[error("shape: {0}")]
427 Shape(ShapeError),
428
429 #[error("semantic: {0}")]
431 Semantic(SemanticError),
432
433 #[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
446pub trait ValidationContext {
454 fn image_exists(&self, image: &ImageRef) -> Result<bool, ContextError>;
456
457 fn secret_exists(&self, secret: &SecretRef) -> Result<bool, ContextError>;
459
460 fn mesh_ident_known(&self, ident: &MeshIdent, batch: &[MeshIdent]) -> Result<bool, ContextError>;
464
465 fn cf_zone_owned(&self, hostname: &str) -> Result<bool, ContextError>;
467
468 fn tailscale_tag_known(&self, tag: &str) -> Result<bool, ContextError>;
471
472 fn capacity_for(&self, spec: &WorkloadSpec, machine_id: &MachineId) -> Result<bool, ContextError>;
475}
476
477pub 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_millis={} ephemeral={}MB)",
548 machine_id.0,
549 spec.resources.memory_mb,
550 spec.resources.cpu_millis,
551 spec.resources.ephemeral_storage_mb
552 ),
553 }));
554 }
555
556 Ok(())
557}
558
559#[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
582pub trait MeshResolver {
599 fn resolve(&self, ident: &MeshIdent, kind: MeshLookup) -> Result<String, MeshError>;
600}
601
602pub 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
629pub 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}