use std::fmt;
use std::sync::OnceLock;
use regex::Regex;
use thiserror::Error;
use crate::{
EnvValue, EnvVar, ImageRef, MachineId, MeshIdent, MeshLookup, RestartPolicy, SecretRef,
SecretTarget, StaticAssetWorkload, VolumeSource, WorkloadSpec,
};
#[derive(Debug, Clone, PartialEq)]
pub enum FieldPath {
Name,
MeshIdentity,
TailscaleTag,
Replicas,
ImageTag,
Tier,
Volume(usize, &'static str),
ExposeMeshPort(u16),
Secret(usize, &'static str),
Healthcheck(&'static str),
RestartPolicy,
Image,
DependsOn(usize),
Hostname,
Resources,
AssetAlias(String),
Asset(usize, &'static str),
}
impl fmt::Display for FieldPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FieldPath::Name => write!(f, "name"),
FieldPath::MeshIdentity => write!(f, "expose.mesh.identity"),
FieldPath::TailscaleTag => write!(f, "expose.operator.tailscale_tag"),
FieldPath::Replicas => write!(f, "replicas"),
FieldPath::ImageTag => write!(f, "image.tag"),
FieldPath::Tier => write!(f, "tier"),
FieldPath::Volume(i, sub) => write!(f, "volumes[{i}].{sub}"),
FieldPath::ExposeMeshPort(port) => write!(f, "expose.public.port ({port})"),
FieldPath::Secret(i, sub) => write!(f, "secrets[{i}].{sub}"),
FieldPath::Healthcheck(sub) => write!(f, "healthcheck.{sub}"),
FieldPath::RestartPolicy => write!(f, "restart_policy"),
FieldPath::Image => write!(f, "image"),
FieldPath::DependsOn(i) => write!(f, "depends_on[{i}]"),
FieldPath::Hostname => write!(f, "expose.public.hostname"),
FieldPath::Resources => write!(f, "resources"),
FieldPath::AssetAlias(key) => write!(f, "aliases[{key}]"),
FieldPath::Asset(i, sub) => write!(f, "asset[{i}].{sub}"),
}
}
}
#[derive(Debug, Error, PartialEq)]
pub enum ShapeError {
#[error("field {path}: {reason}")]
Field { path: FieldPath, reason: String },
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShapeWarning {
pub path: FieldPath,
pub message: String,
}
impl fmt::Display for ShapeWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "warning at {}: {}", self.path, self.message)
}
}
const KNOWN_TIERS: &[&str] = &["public", "tenant", "private", "infra"];
fn dns_label_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$").unwrap())
}
fn env_name_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[A-Z_][A-Z0-9_]*$").unwrap())
}
fn check_dns_label(value: &str, path: FieldPath) -> Result<(), ShapeError> {
if value.len() > 63 {
return Err(ShapeError::Field {
path,
reason: format!("length {} exceeds maximum 63", value.len()),
});
}
if !dns_label_re().is_match(value) {
return Err(ShapeError::Field {
path,
reason: format!(
"{:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
value
),
});
}
Ok(())
}
fn check_mesh_ident(value: &str, path: FieldPath) -> Result<(), ShapeError> {
if value.len() > 63 {
return Err(ShapeError::Field {
path,
reason: format!("length {} exceeds maximum 63", value.len()),
});
}
for segment in value.split('.') {
if !dns_label_re().is_match(segment) {
return Err(ShapeError::Field {
path,
reason: format!(
"segment {:?} in {:?} must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
segment, value
),
});
}
}
Ok(())
}
pub fn shape(spec: &WorkloadSpec) -> Result<Vec<ShapeWarning>, ShapeError> {
let mut warnings: Vec<ShapeWarning> = Vec::new();
check_dns_label(&spec.name, FieldPath::Name)?;
check_mesh_ident(&spec.expose.mesh.identity.0, FieldPath::MeshIdentity)?;
if let Some(op) = &spec.expose.operator {
let tag = &op.tailscale_tag;
if tag.len() > 63 {
return Err(ShapeError::Field {
path: FieldPath::TailscaleTag,
reason: format!("length {} exceeds maximum 63", tag.len()),
});
}
let rest = tag.strip_prefix("tag:").ok_or_else(|| ShapeError::Field {
path: FieldPath::TailscaleTag,
reason: format!("{:?} must start with \"tag:\"", tag),
})?;
if !dns_label_re().is_match(rest) {
return Err(ShapeError::Field {
path: FieldPath::TailscaleTag,
reason: format!(
"the part after \"tag:\" in {:?} must match \
^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
tag
),
});
}
}
if spec.replicas > 100 {
return Err(ShapeError::Field {
path: FieldPath::Replicas,
reason: format!("{} exceeds maximum 100", spec.replicas),
});
}
if spec.image.tag.is_empty() {
return Err(ShapeError::Field {
path: FieldPath::ImageTag,
reason: "tag is empty; provide a human-readable tag alongside the digest".into(),
});
}
if !KNOWN_TIERS.contains(&spec.tier.0.as_str()) {
warnings.push(ShapeWarning {
path: FieldPath::Tier,
message: format!(
"\"{}\" is not in the known tier set (public/tenant/private/infra); \
yubaba may reject it if the cluster config does not include this tier",
spec.tier.0
),
});
}
for (i, vol) in spec.volumes.iter().enumerate() {
if matches!(&vol.source, VolumeSource::Bind { .. }) && spec.tier.0 != "infra" {
return Err(ShapeError::Field {
path: FieldPath::Volume(i, "source"),
reason: format!(
"Bind mounts are only allowed when tier = \"infra\" \
(current tier: {:?})",
spec.tier.0
),
});
}
}
if let Some(public) = &spec.expose.public {
if !spec.expose.mesh.ports.contains(&public.port) {
return Err(ShapeError::Field {
path: FieldPath::ExposeMeshPort(public.port),
reason: format!(
"port {} must appear in expose.mesh.ports {:?} \
before it can be exposed publicly",
public.port, spec.expose.mesh.ports
),
});
}
}
for (i, secret) in spec.secrets.iter().enumerate() {
match &secret.target {
SecretTarget::File { path, .. } => {
if !path.is_absolute() {
return Err(ShapeError::Field {
path: FieldPath::Secret(i, "target.path"),
reason: format!("{:?} is not an absolute path", path),
});
}
}
SecretTarget::EnvVar { name } => {
if !env_name_re().is_match(name) {
return Err(ShapeError::Field {
path: FieldPath::Secret(i, "target.name"),
reason: format!(
"{:?} is not a valid env-var identifier (^[A-Z_][A-Z0-9_]*$)",
name
),
});
}
}
}
}
if matches!(spec.restart_policy, RestartPolicy::Never) {
let is_forge = spec
.annotations
.get("yah.forge")
.map(|v| v == "true")
.unwrap_or(false);
if !is_forge {
warnings.push(ShapeWarning {
path: FieldPath::RestartPolicy,
message: "restart_policy=Never is intended for forge runs; \
add annotation yah.forge=true to suppress this warning"
.into(),
});
}
}
if let Some(hc) = &spec.healthcheck {
let min_recommended = spec.stop_policy.grace_period.as_ms().saturating_mul(2);
if hc.initial_delay.as_ms() < min_recommended {
warnings.push(ShapeWarning {
path: FieldPath::Healthcheck("initial_delay"),
message: format!(
"initial_delay ({}ms) is less than stop_policy.grace_period * 2 ({}ms); \
a SIGTERM during startup may catch a still-initialising container",
hc.initial_delay.as_ms(),
min_recommended
),
});
}
}
Ok(warnings)
}
pub fn shape_static_asset(workload: &StaticAssetWorkload) -> Result<(), ShapeError> {
for (i, entry) in workload.assets.iter().enumerate() {
match (entry.source.is_some(), entry.derive.is_some()) {
(true, true) => {
return Err(ShapeError::Field {
path: FieldPath::Asset(i, "source"),
reason: format!(
"asset {:?}: both `source` and `derive` are set; pick exactly one",
entry.filename
),
});
}
(false, false) => {
return Err(ShapeError::Field {
path: FieldPath::Asset(i, "source"),
reason: format!(
"asset {:?}: neither `source` nor `derive` is set; pick exactly one",
entry.filename
),
});
}
_ => {}
}
}
let filenames: std::collections::HashSet<&str> =
workload.assets.iter().map(|a| a.filename.as_str()).collect();
for (alias_key, alias_target) in &workload.aliases {
if !filenames.contains(alias_target.as_str()) {
return Err(ShapeError::Field {
path: FieldPath::AssetAlias(alias_key.clone()),
reason: format!(
"alias target {:?} is not present in the [[asset]] catalog; \
add a matching [[asset]] row or correct the filename",
alias_target
),
});
}
}
Ok(())
}
#[derive(Debug, Error, Clone, PartialEq)]
#[error("context lookup failed: {0}")]
pub struct ContextError(pub String);
#[derive(Debug, Error, PartialEq)]
pub enum SemanticError {
#[error("field {path}: {reason}")]
Unknown { path: FieldPath, reason: String },
}
#[derive(Debug, Error, PartialEq)]
pub enum WorkloadValidationError {
#[error("shape: {0}")]
Shape(ShapeError),
#[error("semantic: {0}")]
Semantic(SemanticError),
#[error("context: {0}")]
Context(ContextError),
}
impl From<ShapeError> for WorkloadValidationError {
fn from(e: ShapeError) -> Self { WorkloadValidationError::Shape(e) }
}
impl From<ContextError> for WorkloadValidationError {
fn from(e: ContextError) -> Self { WorkloadValidationError::Context(e) }
}
pub trait ValidationContext {
fn image_exists(&self, image: &ImageRef) -> Result<bool, ContextError>;
fn secret_exists(&self, secret: &SecretRef) -> Result<bool, ContextError>;
fn mesh_ident_known(&self, ident: &MeshIdent, batch: &[MeshIdent]) -> Result<bool, ContextError>;
fn cf_zone_owned(&self, hostname: &str) -> Result<bool, ContextError>;
fn tailscale_tag_known(&self, tag: &str) -> Result<bool, ContextError>;
fn capacity_for(&self, spec: &WorkloadSpec, machine_id: &MachineId) -> Result<bool, ContextError>;
}
pub fn semantic(
spec: &WorkloadSpec,
ctx: &dyn ValidationContext,
machine_id: &MachineId,
batch: &[MeshIdent],
) -> Result<(), WorkloadValidationError> {
if !ctx.image_exists(&spec.image)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::Image,
reason: format!(
"image {}/{}:{} not found in registry",
spec.image.registry, spec.image.repository, spec.image.tag
),
}));
}
for (i, secret) in spec.secrets.iter().enumerate() {
if !ctx.secret_exists(&secret.source)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::Secret(i, "source"),
reason: format!("secret source at index {i} not found in yubaba secret store"),
}));
}
}
for (i, dep) in spec.depends_on.iter().enumerate() {
if !ctx.mesh_ident_known(dep, batch)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::DependsOn(i),
reason: format!("mesh ident {:?} is not a known deployed workload", dep.0),
}));
}
}
if let Some(public) = &spec.expose.public {
if !ctx.cf_zone_owned(&public.hostname)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::Hostname,
reason: format!(
"hostname {:?} is not under a Cloudflare zone owned by this cluster",
public.hostname
),
}));
}
}
if let Some(op) = &spec.expose.operator {
if !ctx.tailscale_tag_known(&op.tailscale_tag)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::TailscaleTag,
reason: format!(
"tailscale tag {:?} is not in the cluster's ACL tag list",
op.tailscale_tag
),
}));
}
}
if !ctx.capacity_for(spec, machine_id)? {
return Err(WorkloadValidationError::Semantic(SemanticError::Unknown {
path: FieldPath::Resources,
reason: format!(
"machine {:?} lacks capacity (memory={}MB cpu_shares={} ephemeral={}MB)",
machine_id.0,
spec.resources.memory_mb,
spec.resources.cpu_shares,
spec.resources.ephemeral_storage_mb
),
}));
}
Ok(())
}
#[derive(Debug, Error, Clone, PartialEq)]
pub enum MeshError {
#[error("mesh ident {ident:?} is not yet deployed")]
NotDeployed { ident: String },
#[error(
"mesh ident {ident:?} exposes no ports; {lookup:?} requires at least one"
)]
NoPorts { ident: String, lookup: MeshLookup },
#[error("mesh state lookup failed: {0}")]
Lookup(String),
}
pub trait MeshResolver {
fn resolve(&self, ident: &MeshIdent, kind: MeshLookup) -> Result<String, MeshError>;
}
pub fn resolve_env_from_mesh(
env: &[EnvVar],
resolver: &dyn MeshResolver,
) -> Result<Vec<EnvVar>, MeshError> {
env.iter()
.map(|var| match &var.value {
EnvValue::FromMesh { ident, kind } => {
let value = resolver.resolve(ident, *kind)?;
Ok(EnvVar {
name: var.name.clone(),
value: EnvValue::Literal { value },
})
}
_ => Ok(var.clone()),
})
.collect()
}
pub fn all(
spec: &WorkloadSpec,
ctx: &dyn ValidationContext,
machine_id: &MachineId,
batch: &[MeshIdent],
) -> Result<(), WorkloadValidationError> {
shape(spec)?;
semantic(spec, ctx, machine_id, batch)
}