-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(validation): add manifest validation
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
1 parent
fde119f
commit 32004ba
Showing
8 changed files
with
504 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.