use std::collections::{HashMap, HashSet};
#[cfg(not(target_family = "wasm"))]
use std::path::Path;
use std::sync::OnceLock;
use anyhow::{Context as _, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{
CapabilityProperties, ComponentProperties, LinkProperty, Manifest, Properties, Trait,
TraitProperty, DEFAULT_LINK_NAME, LATEST_VERSION,
};
type KnownInterfaceLookup = HashMap<String, HashMap<String, HashMap<String, ()>>>;
static KNOWN_INTERFACE_LOOKUP: OnceLock<KnownInterfaceLookup> = OnceLock::new();
const SECRET_POLICY_TYPE: &str = "policy.secret.wasmcloud.dev/v1alpha1";
fn get_known_interface_lookup() -> &'static KnownInterfaceLookup {
KNOWN_INTERFACE_LOOKUP.get_or_init(|| {
HashMap::from([
(
"wrpc".into(),
HashMap::from([
(
"blobstore".into(),
HashMap::from([("blobstore".into(), ())]),
),
(
"keyvalue".into(),
HashMap::from([("atomics".into(), ()), ("store".into(), ())]),
),
(
"http".into(),
HashMap::from([
("incoming-handler".into(), ()),
("outgoing-handler".into(), ()),
]),
),
]),
),
(
"wasi".into(),
HashMap::from([
(
"blobstore".into(),
HashMap::from([("blobstore".into(), ())]),
),
("config".into(), HashMap::from([("runtime".into(), ())])),
(
"keyvalue".into(),
HashMap::from([
("atomics".into(), ()),
("store".into(), ()),
("batch".into(), ()),
("watch".into(), ()),
]),
),
(
"http".into(),
HashMap::from([
("incoming-handler".into(), ()),
("outgoing-handler".into(), ()),
]),
),
("logging".into(), HashMap::from([("logging".into(), ())])),
]),
),
(
"wasmcloud".into(),
HashMap::from([(
"messaging".into(),
HashMap::from([("consumer".into(), ()), ("handler".into(), ())]),
)]),
),
])
})
}
static MANIFEST_NAME_REGEX_STR: &str = r"^[-\w]+$";
static MANIFEST_NAME_REGEX: OnceLock<Regex> = OnceLock::new();
fn get_manifest_name_regex() -> &'static Regex {
MANIFEST_NAME_REGEX.get_or_init(|| {
Regex::new(MANIFEST_NAME_REGEX_STR)
.context("failed to parse manifest name regex")
.unwrap()
})
}
pub fn validate_manifest_name(name: &str) -> impl ValidationOutput {
let mut errors = Vec::new();
if !get_manifest_name_regex().is_match(name) {
errors.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("manifest name [{name}] is not allowed (should match regex [{MANIFEST_NAME_REGEX_STR}])"),
))
}
errors
}
pub fn is_valid_manifest_name(name: &str) -> bool {
validate_manifest_name(name).valid()
}
pub fn validate_manifest_version(version: &str) -> impl ValidationOutput {
let mut errors = Vec::new();
if version == LATEST_VERSION {
errors.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("{LATEST_VERSION} is not allowed in wadm"),
))
}
errors
}
pub fn is_valid_manifest_version(version: &str) -> bool {
validate_manifest_version(version).valid()
}
fn is_invalid_known_interface(
namespace: &str,
package: &str,
interface: &str,
) -> Vec<ValidationFailure> {
let known_interfaces = get_known_interface_lookup();
let Some(pkg_lookup) = known_interfaces.get(namespace) else {
return vec![];
};
let Some(iface_lookup) = pkg_lookup.get(package) else {
return vec![ValidationFailure::new(
ValidationFailureLevel::Warning,
format!("unrecognized interface [{namespace}:{package}/{interface}]"),
)];
};
if !iface_lookup.contains_key(interface) {
return vec![ValidationFailure::new(
ValidationFailureLevel::Warning,
format!("unrecognized interface [{namespace}:{package}/{interface}]"),
)];
}
Vec::new()
}
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ValidationFailureLevel {
#[default]
Warning,
Error,
}
impl core::fmt::Display for ValidationFailureLevel {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{}",
match self {
Self::Warning => "warning",
Self::Error => "error",
}
)
}
}
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ValidationFailure {
pub level: ValidationFailureLevel,
pub msg: String,
}
impl ValidationFailure {
fn new(level: ValidationFailureLevel, msg: String) -> Self {
ValidationFailure { level, msg }
}
}
impl core::fmt::Display for ValidationFailure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "[{}] {}", self.level, self.msg)
}
}
pub trait ValidationOutput {
fn valid(&self) -> bool;
fn warnings(&self) -> Vec<&ValidationFailure>;
fn errors(&self) -> Vec<&ValidationFailure>;
}
impl ValidationOutput for [ValidationFailure] {
fn valid(&self) -> bool {
self.errors().is_empty()
}
fn warnings(&self) -> Vec<&ValidationFailure> {
self.iter()
.filter(|m| m.level == ValidationFailureLevel::Warning)
.collect()
}
fn errors(&self) -> Vec<&ValidationFailure> {
self.iter()
.filter(|m| m.level == ValidationFailureLevel::Error)
.collect()
}
}
impl ValidationOutput for Vec<ValidationFailure> {
fn valid(&self) -> bool {
self.as_slice().valid()
}
fn warnings(&self) -> Vec<&ValidationFailure> {
self.iter()
.filter(|m| m.level == ValidationFailureLevel::Warning)
.collect()
}
fn errors(&self) -> Vec<&ValidationFailure> {
self.iter()
.filter(|m| m.level == ValidationFailureLevel::Error)
.collect()
}
}
#[cfg(not(target_family = "wasm"))]
pub async fn validate_manifest_file(
path: impl AsRef<Path>,
) -> Result<(Manifest, Vec<ValidationFailure>)> {
let content = tokio::fs::read_to_string(path.as_ref())
.await
.with_context(|| format!("failed to read manifest @ [{}]", path.as_ref().display()))?;
validate_manifest_bytes(&content).await.with_context(|| {
format!(
"failed to parse YAML manifest [{}]",
path.as_ref().display()
)
})
}
pub async fn validate_manifest_bytes(
content: impl AsRef<[u8]>,
) -> Result<(Manifest, Vec<ValidationFailure>)> {
let raw_yaml_content = content.as_ref();
let manifest =
serde_yaml::from_slice(content.as_ref()).context("failed to parse manifest content")?;
let mut failures = validate_manifest(&manifest).await?;
let mut yaml_issues = validate_raw_yaml(raw_yaml_content)?;
failures.append(&mut yaml_issues);
Ok((manifest, failures))
}
pub async fn validate_manifest(manifest: &Manifest) -> Result<Vec<ValidationFailure>> {
let mut failures = Vec::new();
failures.extend(
validate_manifest_name(&manifest.metadata.name)
.errors()
.into_iter()
.cloned(),
);
failures.extend(
validate_manifest_version(manifest.version())
.errors()
.into_iter()
.cloned(),
);
failures.extend(core_validation(manifest));
failures.extend(check_misnamed_interfaces(manifest));
failures.extend(check_dangling_links(manifest));
failures.extend(validate_policies(manifest));
failures.extend(ensure_no_custom_traits(manifest));
failures.extend(validate_component_properties(manifest));
failures.extend(check_duplicate_links(manifest));
failures.extend(validate_link_configs(manifest));
Ok(failures)
}
pub fn validate_raw_yaml(content: &[u8]) -> Result<Vec<ValidationFailure>> {
let mut failures = Vec::new();
let raw_content: serde_yaml::Value =
serde_yaml::from_slice(content).context("failed read raw yaml content")?;
failures.extend(validate_components_configs(&raw_content));
Ok(failures)
}
fn core_validation(manifest: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
let mut name_registry: HashSet<String> = HashSet::new();
let mut id_registry: HashSet<String> = HashSet::new();
let mut required_capability_components: HashSet<String> = HashSet::new();
for label in manifest.metadata.labels.iter() {
if !valid_oam_label(label) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Invalid OAM label: {:?}", label),
));
}
}
for annotation in manifest.metadata.annotations.iter() {
if !valid_oam_label(annotation) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Invalid OAM annotation: {:?}", annotation),
));
}
}
for component in manifest.spec.components.iter() {
if !name_registry.insert(component.name.clone()) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Duplicate component name in manifest: {}", component.name),
));
}
if let Properties::Capability {
properties:
CapabilityProperties {
id: Some(component_id),
config: _capability_config,
..
},
} = &component.properties
{
if !id_registry.insert(component_id.to_string()) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"Duplicate component identifier in manifest: {}",
component_id
),
));
}
}
if let Properties::Component {
properties: ComponentProperties { id: Some(id), .. },
} = &component.properties
{
if !id_registry.insert(id.to_string()) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Duplicate component identifier in manifest: {}", id),
));
}
}
if let Some(traits_vec) = &component.traits {
for trait_item in traits_vec.iter() {
if let Trait {
properties: TraitProperty::Link(LinkProperty { target, .. }),
..
} = &trait_item
{
required_capability_components.insert(target.name.to_string());
}
}
}
}
let missing_capability_components = required_capability_components
.difference(&name_registry)
.collect::<Vec<&String>>();
if !missing_capability_components.is_empty() {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"The following capability component(s) are missing from the manifest: {:?}",
missing_capability_components
),
));
};
failures
}
fn check_misnamed_interfaces(manifest: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
for link_trait in manifest.links() {
if let TraitProperty::Link(LinkProperty {
namespace,
package,
interfaces,
target: _target,
source: _source,
..
}) = &link_trait.properties
{
for interface in interfaces {
failures.extend(is_invalid_known_interface(namespace, package, interface))
}
}
}
failures
}
fn ensure_no_custom_traits(manifest: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
for component in manifest.components() {
if let Some(traits) = &component.traits {
for trait_item in traits {
match &trait_item.properties {
TraitProperty::Custom(trt) if trait_item.is_link() => failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Link trait deserialized as custom trait, ensure fields are correct: {}", trt),
)),
TraitProperty::Custom(trt) if trait_item.is_scaler() => failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Scaler trait deserialized as custom trait, ensure fields are correct: {}", trt),
)),
_ => (),
}
}
}
}
failures
}
fn check_dangling_links(manifest: &Manifest) -> Vec<ValidationFailure> {
let lookup = manifest.component_lookup();
let mut failures = Vec::new();
for link_trait in manifest.links() {
match &link_trait.properties {
TraitProperty::Custom(obj) => {
if obj.get("target").is_none() {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
"custom link is missing 'target' property".into(),
));
continue;
}
match obj["target"]["name"].as_str() {
Some(target) if !lookup.contains_key(&String::from(target)) => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Warning,
format!("custom link target [{target}] is not a listed component"),
))
}
Some(_) => {}
None => failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
"custom link is missing 'target' name property".into(),
)),
}
}
TraitProperty::Link(LinkProperty { name, target, .. }) => {
let link_identifier = name
.as_ref()
.map(|n| format!("(name [{n}])"))
.unwrap_or_else(|| format!("(target [{}])", target.name));
if !lookup.contains_key(&target.name) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Warning,
format!(
"link {link_identifier} target [{}] is not a listed component",
target.name
),
))
}
}
_ => unreachable!("manifest.links() should only return links"),
}
}
failures
}
fn validate_policies(manifest: &Manifest) -> Vec<ValidationFailure> {
let policies = manifest.policy_lookup();
let mut failures = Vec::new();
for c in manifest.components() {
for secret in c.secrets() {
match policies.get(&secret.properties.policy) {
Some(policy) if policy.policy_type != SECRET_POLICY_TYPE => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"secret '{}' is mapped to policy '{}' which is not a secret policy. Expected type '{SECRET_POLICY_TYPE}'",
secret.name, secret.properties.policy
),
))
}
Some(policy) => {
if !policy.properties.contains_key("backend") {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"secret '{}' is mapped to policy '{}' which does not include a 'backend' property",
secret.name, secret.properties.policy
),
))
}
}
None => failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"secret '{}' is mapped to unknown policy '{}'",
secret.name, secret.properties.policy
),
)),
}
}
}
failures
}
pub fn validate_component_properties(application: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
for component in application.spec.components.iter() {
match &component.properties {
Properties::Component {
properties:
ComponentProperties {
image,
application,
config,
secrets,
..
},
}
| Properties::Capability {
properties:
CapabilityProperties {
image,
application,
config,
secrets,
..
},
} => match (image, application) {
(Some(_), Some(_)) => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
"Component cannot have both 'image' and 'application' properties".into(),
));
}
(None, None) => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
"Component must have either 'image' or 'application' property".into(),
));
}
(None, Some(shared_properties)) if !config.is_empty() => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"Shared component '{}' cannot specify additional 'config'",
shared_properties.name
),
));
}
(None, Some(shared_properties)) if !secrets.is_empty() => {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"Shared component '{}' cannot specify additional 'secrets'",
shared_properties.name
),
));
}
(None, Some(shared_properties))
if component
.traits
.as_ref()
.is_some_and(|traits| traits.iter().any(|trt| trt.is_scaler())) =>
{
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"Shared component '{}' cannot include a scaler trait",
shared_properties.name
),
));
}
_ => {}
},
}
}
failures
}
pub fn validate_link_configs(manifest: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
let mut link_config_names = HashSet::new();
for link_trait in manifest.links() {
if let TraitProperty::Link(LinkProperty { target, source, .. }) = &link_trait.properties {
for config in &target.config {
if config.properties.is_none() {
continue;
}
if !link_config_names.insert(config.name.clone()) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Duplicate link config name found: '{}'", config.name),
));
}
}
if let Some(source) = source {
for config in &source.config {
if config.properties.is_none() {
continue;
}
if !link_config_names.insert(config.name.clone()) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!("Duplicate link config name found: '{}'", config.name),
));
}
}
}
}
}
failures
}
pub fn validate_components_configs(application: &serde_yaml::Value) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
if let Some(specs) = application.get("spec") {
if let Some(components) = specs.get("components") {
if let Some(components_sequence) = components.as_sequence() {
for component in components_sequence.iter() {
failures.extend(get_deprecated_configs(component));
}
}
}
}
failures
}
fn get_deprecated_configs(component: &serde_yaml::Value) -> Vec<ValidationFailure> {
let mut failures = vec![];
if let Some(traits) = component.get("traits") {
if let Some(traits_sequence) = traits.as_sequence() {
for trait_ in traits_sequence.iter() {
if let Some(trait_type) = trait_.get("type") {
if trait_type.ne("link") {
continue;
}
}
if let Some(trait_properties) = trait_.get("properties") {
if trait_properties.get("source_config").is_some() {
failures.push(ValidationFailure {
level: ValidationFailureLevel::Warning,
msg: "one of the components' link trait contains a source_config key, please use source:config: rather".to_string(),
});
}
if trait_properties.get("target_config").is_some() {
failures.push(ValidationFailure {
level: ValidationFailureLevel::Warning,
msg: "one of the components' link trait contains a target_config key, please use target:config: rather".to_string(),
});
}
}
}
}
}
failures
}
pub fn valid_oam_label(label: (&String, &String)) -> bool {
let (key, _) = label;
match key.split_once('/') {
Some((prefix, name)) => is_valid_dns_subdomain(prefix) && is_valid_label_name(name),
None => is_valid_label_name(key),
}
}
pub fn is_valid_dns_subdomain(s: &str) -> bool {
if s.is_empty() || s.len() > 253 {
return false;
}
s.split('.').all(|part| {
!part.is_empty()
&& part.len() <= 63
&& part.starts_with(|c: char| c.is_ascii_alphabetic())
&& part.ends_with(|c: char| c.is_ascii_alphanumeric())
&& part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
}
pub fn is_valid_label_name(name: &str) -> bool {
if name.is_empty() || name.len() > 63 {
return false;
}
name.starts_with(|c: char| c.is_ascii_alphanumeric())
&& name.ends_with(|c: char| c.is_ascii_alphanumeric())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
fn check_duplicate_links(manifest: &Manifest) -> Vec<ValidationFailure> {
let mut failures = Vec::new();
for component in manifest.components() {
let mut link_ids = HashSet::new();
for link in component.links() {
if let TraitProperty::Link(LinkProperty {
name,
namespace,
package,
interfaces,
..
}) = &link.properties
{
for interface in interfaces {
if !link_ids.insert((
name.clone()
.unwrap_or_else(|| DEFAULT_LINK_NAME.to_string()),
namespace,
package,
interface,
)) {
failures.push(ValidationFailure::new(
ValidationFailureLevel::Error,
format!(
"Duplicate link found inside component '{}': {} ({}:{}/{})",
component.name,
name.clone()
.unwrap_or_else(|| DEFAULT_LINK_NAME.to_string()),
namespace,
package,
interface
),
));
};
}
}
}
}
failures
}
#[cfg(test)]
mod tests {
use super::is_valid_manifest_name;
const VALID_MANIFEST_NAMES: [&str; 4] = [
"mymanifest",
"my-manifest",
"my_manifest",
"mymanifest-v2-v3-final",
];
const INVALID_MANIFEST_NAMES: [&str; 2] = ["my.manifest", "my manifest"];
#[test]
fn manifest_names_valid() {
for valid in VALID_MANIFEST_NAMES {
assert!(is_valid_manifest_name(valid));
}
}
#[test]
fn manifest_names_invalid() {
for invalid in INVALID_MANIFEST_NAMES {
assert!(!is_valid_manifest_name(invalid))
}
}
}