Skip to content

Commit

Permalink
feat(validation): add manifest validation
Browse files Browse the repository at this point in the history
This commit adds validation functions along with utility accessors to
`Manifest` to enable checking WADM manifests for common errors.

As the validation functions are exposed they can be used by downstream
crates (ex. `wash`) to validate WADM manifests or try to catch
errors *before* a manifest is used.

Signed-off-by: Victor Adossi <vadossi@cosmonic.com>
  • Loading branch information
vados-cosmonic committed May 6, 2024
1 parent fde119f commit 32004ba
Show file tree
Hide file tree
Showing 8 changed files with 504 additions and 0 deletions.
41 changes: 41 additions & 0 deletions src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};

pub(crate) mod internal;
pub mod validation;

/// The default weight for a spread
pub const DEFAULT_SPREAD_WEIGHT: usize = 100;
Expand All @@ -28,6 +29,9 @@ pub const LINK_TRAIT: &str = "link";
/// for a manifest
pub const LATEST_VERSION: &str = "latest";

/// Type Alias for names of components
type ComponentName = String;

/// An OAM manifest
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct Manifest {
Expand Down Expand Up @@ -59,6 +63,38 @@ impl Manifest {
.get(DESCRIPTION_ANNOTATION_KEY)
.map(|v| v.as_str())
}

/// Returns the components in the manifest
pub fn components(&self) -> impl Iterator<Item = &Component> {
self.spec.components.iter()
}

/// Returns only the WebAssembly components in the manifest
pub fn wasm_components(&self) -> impl Iterator<Item = &Component> {
self.components()
.filter(|c| matches!(c.properties, Properties::Component { .. }))
}

/// Returns only the provider components in the manifest
pub fn capability_providers(&self) -> impl Iterator<Item = &Component> {
self.components()
.filter(|c| matches!(c.properties, Properties::Capability { .. }))
}

/// Returns only the provider components in the manifest
pub fn component_lookup(&self) -> HashMap<&ComponentName, &Component> {
self.components()
.map(|c| (&c.name, c))
.collect::<HashMap<&ComponentName, &Component>>()
}

/// Returns only links in the manifest
pub fn links(&self) -> impl Iterator<Item = &Trait> {
self.components()
.flat_map(|c| c.traits.as_ref())
.flatten()
.filter(|t| t.is_link())
}
}

/// The metadata describing the manifest
Expand Down Expand Up @@ -153,6 +189,11 @@ impl Trait {
}
}

/// Check if a trait is a link
pub fn is_link(&self) -> bool {
self.trait_type == LINK_TRAIT
}

/// Helper that creates a new spreadscaler type trait with the given properties
pub fn new_spreadscaler(props: SpreadScalerProperty) -> Trait {
Trait {
Expand Down
241 changes: 241 additions & 0 deletions src/model/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//! Logic for model ([`Manifest`]) validation
//!
use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;

use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};

use crate::model::{LinkProperty, Manifest, TraitProperty};

type NamespaceName = String;
type PackageName = String;
type InterfaceName = String;

/// A namespace -> package -> interface lookup
type KnownInterfaceLookup =
HashMap<NamespaceName, HashMap<PackageName, HashMap<InterfaceName, ()>>>;

/// Hard-coded list of known namespaces/packages and the interfaces they contain.
///
/// Using an interface that is *not* on this list is not an error --
/// custom interfaces are expected to not be on this list, but when using
/// a known namespace and package, interfaces should generally be well known.
static KNOWN_INTERFACE_LOOKUP: OnceLock<KnownInterfaceLookup> = OnceLock::new();

/// Get the static list of known interfaces
fn get_known_interface_lookup() -> &'static KnownInterfaceLookup {
KNOWN_INTERFACE_LOOKUP.get_or_init(|| {
HashMap::from([
(
"wrpc".into(),
HashMap::from([
("keyvalue".into(), HashMap::from([("atomics".into(), ())])),
("blobstore".into(), HashMap::from([("atomics".into(), ())])),
("config".into(), HashMap::from([("runtime".into(), ())])),
]),
),
(
"wasi".into(),
HashMap::from([
("keyvalue".into(), HashMap::from([("atomics".into(), ())])),
("blobstore".into(), HashMap::from([("atomics".into(), ())])),
("config".into(), HashMap::from([("runtime".into(), ())])),
]),
),
(
"wasmcloud".into(),
HashMap::from([(
"bus".into(),
HashMap::from([("lattice".into(), ()), ("host".into(), ())]),
)]),
),
])
})
}

/// Check whether a known grouping of namespace, package and interface are valid.
/// A grouping must be both known/expected and invalid to fail this test (ex. a typo).
///
/// NOTE: what is considered a valid interface known to the host depends explicitly on
/// the wasmCloud host and wasmCloud project goals/implementation. This information is
/// subject to change.
fn is_invalid_known_interface(namespace: &str, package: &str, interface: &str) -> bool {
let known_interfaces = get_known_interface_lookup();
let Some(pkg_lookup) = known_interfaces.get(namespace) else {
// This namespace isn't known, so it may be a custom interface
return false;
};
let Some(iface_lookup) = pkg_lookup.get(package) else {
// Unknown package inside a known interface we control is probably a bug
return true;
};
// Unknown interface inside known namespace and package is probably a bug
!iface_lookup.contains_key(interface)
}

/// Level of a failure related to validation
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum ValidationFailureLevel {
#[default]
Warning,
Error,
}

/// Failure detailing a validation failure, normally indicating a failure
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ValidationFailure {
pub level: ValidationFailureLevel,
pub msg: String,
}

/// Things that support output validation
pub trait ValidationOutput {
/// Whether the object is valid
fn valid(&self) -> bool;
/// Warnings returned (if any) during validation
fn warnings(&self) -> Vec<&ValidationFailure>;
/// The errors returned by the validation
fn errors(&self) -> Vec<&ValidationFailure>;
}

/// Default implementation for a list of concrete [`ValidationFailure`]s
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()
}
}

/// Validate a WADM application manifest, returning a list of validation failures
///
/// At present this can check for:
/// - unsupported interfaces (i.e. typos, etc)
/// - unsupported interfaces
///
/// Since `[ValidationFailure]` implements `ValidationOutput`, you can call `valid()` and other
/// trait methods on it:
///
/// ```rust,ignore
/// let messages = validate_manifest(some_path).await?;
/// let valid = messages.valid();
/// ```
///
/// # Arguments
///
/// * `path` - Path to the Manifest that will be read into memory and validated
pub async fn validate_manifest(
path: impl AsRef<Path>,
) -> Result<(Manifest, Vec<ValidationFailure>)> {
let contents = tokio::fs::read_to_string(path.as_ref())
.await
.with_context(|| format!("failed to read manifest @ [{}]", path.as_ref().display()))?;
let manifest: Manifest = serde_yaml::from_str(&contents).with_context(|| {
format!(
"failed to parse YAML manifest [{}]",
path.as_ref().display()
)
})?;

// Check for known failures with the manifest
let mut failures = Vec::new();
failures.extend(check_misnamed_interfaces(&manifest));
failures.extend(check_dangling_links(&manifest));

Ok((manifest, failures))
}

/// Check for misnamed host-supported interfaces in the manifest
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,
name,
target,
..
}) = &link_trait.properties
{
let link_identifier = name
.as_ref()
.map(|n| format!("(name [{n}])"))
.unwrap_or_else(|| format!("(target [{target}])"));
for interface in interfaces {
if is_invalid_known_interface(namespace, package, interface) {
failures.push(ValidationFailure {
level: ValidationFailureLevel::Error,
msg: format!("link {link_identifier} has unrecognized interface [{namespace}:{package}/{interface}]"),
})
}
}
}
}

failures
}

/// Check for "dangling" links, which contain targets that are not specified elsewhere in the
/// WADM manifest.
///
/// A problem of this type only constitutes a warning, because it is possible that the manifest
/// does not *completely* specify targets (they may be deployed/managed external to WADM or in a separte
/// manifest).
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) => {
// Ensure target property it present
match obj["target"].as_str() {
// If target is present, ensure it's pointing to a known component
Some(target) if !lookup.contains_key(&String::from(target)) => {
failures.push(ValidationFailure {
level: ValidationFailureLevel::Warning,
msg: format!("custom link target [{target}] is not a listed component"),
})
}
// For all keys where the the component is in the lookup we can do nothing
Some(_) => {}
// if target property is not present, note that it is missing
None => failures.push(ValidationFailure {
level: ValidationFailureLevel::Error,
msg: "custom link is missing 'target' property".into(),
}),
}
}

TraitProperty::Link(LinkProperty { name, target, .. }) => {
let link_identifier = name
.as_ref()
.map(|n| format!("(name [{n}])"))
.unwrap_or_else(|| format!("(target [{target}])"));
if !lookup.contains_key(target) {
failures.push(ValidationFailure {
level: ValidationFailureLevel::Warning,
msg: format!(
"link {link_identifier} target [{target}] is not a listed component"
),
})
}
}

_ => unreachable!("manifest.links() should only return links"),
}
}

failures
}
29 changes: 29 additions & 0 deletions tests/fixtures/manifests/custom-interface.wadm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: custom-interface
annotations:
version: v0.0.1
description: A component with a completely custom interface
spec:
components:
- name: counter
type: component
properties:
image: ghcr.io/wasmcloud/component-http-keyvalue-counter:0.1.0
traits:
- type: spreadscaler
properties:
replicas: 1
- type: link
properties:
target: kvredis
namespace: my-wasi
package: my-keyvalue
interfaces: [my-atomics]

- name: kvredis
type: capability
properties:
image: ghcr.io/wasmcloud/keyvalue-redis:0.24.0
23 changes: 23 additions & 0 deletions tests/fixtures/manifests/dangling-link.wadm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: dangling-link
annotations:
version: v0.0.1
description: Manifest with a dangling link
spec:
components:
- name: http-component
type: component
properties:
image: ghcr.io/wasmcloud/component-http-hello-world:0.1.0
traits:
- type: spreadscaler
properties:
replicas: 1
- type: link
properties:
target: httpserver
values:
address: 0.0.0.0:8080
29 changes: 29 additions & 0 deletions tests/fixtures/manifests/misnamed-interface.wadm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: misnamed-interface
annotations:
version: v0.0.1
description: A component with a misnamed interface
spec:
components:
- name: counter
type: component
properties:
image: ghcr.io/wasmcloud/component-http-keyvalue-counter:0.1.0
traits:
- type: spreadscaler
properties:
replicas: 1
- type: link
properties:
target: kvredis
namespace: wasi
package: keyvalue
interfaces: [atomic] # BUG: should be 'atomics'

- name: kvredis
type: capability
properties:
image: ghcr.io/wasmcloud/keyvalue-redis:0.24.0
Loading

0 comments on commit 32004ba

Please sign in to comment.