From 1d3a879c41f10fe01b74e5b234f6e8033a05712a Mon Sep 17 00:00:00 2001 From: Aishwarya Harpale Date: Fri, 22 Sep 2023 16:04:56 -0400 Subject: [PATCH] feat(server)!: validate put manifests with JSON schema * Added json schema for validating manifests Signed-off-by: aish-where-ya * Minor fixes Signed-off-by: aish-where-ya * Addressed review comments Signed-off-by: aish-where-ya * Added additional test case, better error messages Signed-off-by: aish-where-ya * Fixes to spread requirements Signed-off-by: aish-where-ya * allow objects in provider config Signed-off-by: Brooks Townsend * formatted JSON Signed-off-by: Brooks Townsend --------- Signed-off-by: aish-where-ya Signed-off-by: Brooks Townsend Co-authored-by: Brooks Townsend --- Cargo.lock | 189 +++++++++++++++++- Cargo.toml | 1 + oam/oam.schema.json | 306 +++++++++++++++++++++++++++++ src/server/handlers.rs | 70 +++++-- test/data/incorrect_component.yaml | 122 ++++++++++++ 5 files changed, 675 insertions(+), 13 deletions(-) create mode 100644 oam/oam.schema.json create mode 100644 test/data/incorrect_component.yaml diff --git a/Cargo.lock b/Cargo.lock index 144aa268..7bb5152f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.0.2" @@ -344,6 +357,21 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -380,6 +408,12 @@ version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +[[package]] +name = "bytecount" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" + [[package]] name = "byteorder" version = "0.5.3" @@ -774,6 +808,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e867569975c88fdf73833a30bd6e0978aa6ab6bd784b648fcece07450951ba" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -804,6 +848,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures" version = "0.3.28" @@ -910,8 +964,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1187,6 +1243,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "iso8601" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" +dependencies = [ + "nom", +] + [[package]] name = "itertools" version = "0.10.5" @@ -1211,6 +1276,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" +dependencies = [ + "ahash", + "anyhow", + "base64 0.21.2", + "bytecount", + "clap", + "fancy-regex", + "fraction", + "getrandom", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "reqwest", + "serde", + "serde_json", + "time 0.3.23", + "url", + "uuid", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1315,6 +1410,12 @@ dependencies = [ "serde", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1356,6 +1457,16 @@ dependencies = [ "signatory", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1386,11 +1497,84 @@ dependencies = [ "rand", ] +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" dependencies = [ "autocfg", ] @@ -2997,6 +3181,7 @@ dependencies = [ "cloudevents-sdk", "futures", "indexmap 1.9.3", + "jsonschema", "lazy_static", "nkeys", "opentelemetry 0.17.0", diff --git a/Cargo.toml b/Cargo.toml index 5c8ed7d0..5e4937d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ wasmcloud-control-interface = "0.28.1" semver = { version = "1.0.16", features = ["serde"] } regex = "1.9.3" base64 = "0.21.2" +jsonschema = "0.17" [dev-dependencies] serial_test = "1" diff --git a/oam/oam.schema.json b/oam/oam.schema.json new file mode 100644 index 00000000..f2efebef --- /dev/null +++ b/oam/oam.schema.json @@ -0,0 +1,306 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://oam.dev/v1/oam.application_configuration.schema.json", + "title": "Manifest", + "description": "A JSON Schema to validate manifests", + "type": "object", + "properties": { + "apiVersion": { + "type": "string", + "description": "The specific version of the Open Application Model specification in use" + }, + "kind": { + "type": "string", + "description": "The entity type being described in the manifest" + }, + "metadata": { + "type": "object", + "description": "Application configuration metadata.", + "properties": { + "name": { + "type": "string" + }, + "annotations": { + "type": "object", + "description": "A set of string key/value pairs used as arbitrary annotations on this application configuration.", + "properties": { + "version": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + } + } + }, + "spec": { + "type": "object", + "description": "Configuration attributes for various items in the lattice", + "$ref": "#/definitions/manifestSpec" + } + }, + "required": ["apiVersion", "kind", "metadata", "spec"], + "additionalProperties": false, + "definitions": { + "manifestSpec": { + "type": "object", + "properties": { + "components": { + "type": "array", + "description": "Component instance definitions.", + "items": { + "type": "object", + "anyOf": [ + { + "$ref": "#/definitions/actorInstance" + }, + { + "$ref": "#/definitions/providerInstance" + } + ] + } + } + }, + "required": ["components"], + "additionalProperties": false + }, + "opconfigVariable": { + "type": "object", + "description": "The Variables section defines variables that may be used elsewhere in the application configuration. The variable section provides a way for an application operator to specify common values that can be substituted into multiple other locations in this configuration (using the [fromVariable(VARNAME)] syntax).", + "properties": { + "name": { + "type": "string", + "description": "The parameter's name. Must be unique per configuration.", + "$comment": "Some systems have upper bounds for name length. Do we limit here?", + "maxLength": 128 + }, + "value": { + "type": "string", + "description": "The scalar value." + } + }, + "required": ["name", "value"], + "additionalProperties": false + }, + "applicationScope": { + "type": "object", + "description": "The scope section defines application scopes that will be created with this application configuration.", + "properties": { + "name": { + "type": "string", + "description": "The name of the application scope. Must be unique to the deployment environment." + }, + "type": { + "type": "string", + "description": "The fully-qualified GROUP/VERSION.KIND name of the application scope." + }, + "properties": { + "type": "object", + "description": "The properties attached to this scope.", + "$ref": "#/definitions/propertiesObject" + } + }, + "required": ["name", "type"], + "additionalProperties": false + }, + "actorInstance": { + "type": "object", + "description": "This section defines the instances of actors to create with this application configuration.", + "properties": { + "name": { + "type": "string", + "description": "The name of the actor to create an instance of." + }, + "type": { + "description": "The type of instance : actor.", + "const": "actor" + }, + "properties": { + "type": "object", + "description": "Overrides of parameters that are exposed by the application scope type defined in 'type'.", + "$ref": "#/definitions/actorProperties" + }, + "traits": { + "type": "array", + "description": "Specifies the traits to attach to this component instance.", + "items": { + "$ref": "#/definitions/trait" + } + } + }, + "required": ["name", "type", "properties"], + "additionalProperties": true + }, + "providerInstance": { + "type": "object", + "description": "This section defines the instances of providers to create with this application configuration.", + "properties": { + "name": { + "type": "string", + "description": "The name of the provider to create an instance of." + }, + "type": { + "description": "The type of instance : capability.", + "const": "capability" + }, + "properties": { + "type": "object", + "description": "Overrides of parameters that are exposed by the application scope type defined in 'type'.", + "$ref": "#/definitions/providerProperties" + }, + "traits": { + "type": "array", + "description": "Specifies the traits to attach to this component instance.", + "items": { + "$ref": "#/definitions/trait" + } + } + }, + "required": ["name", "type", "properties"], + "additionalProperties": true + }, + "actorProperties": { + "type": "object", + "description": "Values supplied to parameters that are used to override the parameters exposed by other types.", + "properties": { + "image": { + "type": "string", + "description": "The image reference to use for the actor.", + "$comment": "Some systems have upper bounds for name length. Do we limit here?", + "maxLength": 128 + } + }, + "required": ["image"], + "additionalProperties": false + }, + "providerProperties": { + "type": "object", + "description": "Values supplied to parameters that are used to override the parameters exposed by other types.", + "properties": { + "image": { + "type": "string", + "description": "The image reference to use for the provider.", + "$comment": "Some systems have upper bounds for name length. Do we limit here?", + "maxLength": 128 + }, + "contract": { + "type": "string", + "description": "The contract ID for this provider.", + "maxLength": 128 + }, + "link_name": { + "type": "string", + "description": "The linkname to be used for this capability.", + "maxLength": 128 + }, + "config": { + "anyOf": [ + { + "type": [ + "number", + "string", + "null", + "boolean", + "integer", + "object" + ], + "$comment": "Format of serde_json Value type" + }, + { + "type": "string" + } + ] + } + }, + "required": ["image", "contract"], + "additionalProperties": false + }, + "trait": { + "type": "object", + "description": "The trait section defines traits that will be used in a component instance.", + "properties": { + "type": { + "type": "string", + "description": "The trait type for the instance, whether spreadscaler or linkdef" + }, + "properties": { + "type": "object", + "description": "Overrides of parameters that are exposed by the trait type defined in 'type'.", + "anyOf": [ + { + "$ref": "#/definitions/linkdefProperties" + }, + { + "$ref": "#/definitions/spreadscalerProperties" + } + ] + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "linkdefProperties": { + "type": "object", + "description": "A properties object (for trait configuration) is an object whose structure is determined by the trait property schema. It may be a simple value, or it may be a complex object.", + "properties": { + "target": { + "type": "string" + }, + "values": { + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["target"] + }, + "spreadscalerProperties": { + "type": "object", + "description": "A properties object (for spreadscaler configuration) is an object whose structure is determined by the spreadscaler property schema. It may be a simple value, or it may be a complex object.", + "properties": { + "replicas": { + "type": "integer" + }, + "spread": { + "type": "array", + "items": { + "type": "object", + "description": "A spread object for spreading replicas.", + "properties": { + "name": { + "type": "string" + }, + "requirements": { + "additionalProperties": { + "type": "string" + } + }, + "weight": { + "type": "integer" + } + }, + "required": ["name", "requirements"] + } + } + }, + "required": ["replicas", "spread"] + }, + "propertiesObject": { + "anyOf": [ + { + "type": "object", + "description": "A properties object (for trait and scope configuration) is an object whose structure is determined by the trait or scope property schema. It may be a simple value, or it may be a complex object.", + "additionalProperties": true + }, + { + "type": "string", + "description": "A properties object (for trait and scope configuration) is an object whose structure is determined by the trait or scope property schema. It may be a simple value, or it may be a complex object." + } + ] + } + } +} diff --git a/src/server/handlers.rs b/src/server/handlers.rs index 2a2fe1a1..95fb0ea6 100644 --- a/src/server/handlers.rs +++ b/src/server/handlers.rs @@ -1,6 +1,7 @@ use anyhow::anyhow; use async_nats::{jetstream::stream::Stream, Client, Message}; use base64::{engine::general_purpose::STANDARD as B64decoder, Engine}; +use jsonschema::{Draft, JSONSchema}; use regex::Regex; use serde_json::json; use std::collections::{HashMap, HashSet}; @@ -22,8 +23,10 @@ use super::{ GetModelResponse, GetResult, ManifestNotifier, PutModelResponse, PutResult, Status, StatusInfo, StatusResponse, StatusResult, UndeployModelRequest, VersionInfo, VersionResponse, }; - +const JSON_SCHEMA: &str = include_str!("../../oam/oam.schema.json"); static MANIFEST_NAME_REGEX: OnceCell = OnceCell::const_new(); +static JSON_SCHEMA_VALUE: OnceCell = OnceCell::const_new(); +static OAM_JSON_SCHEMA: OnceCell = OnceCell::const_new(); pub(crate) struct Handler

{ pub(crate) store: ModelStorage, @@ -89,7 +92,7 @@ impl Handler

{ } }; - if let Some(error_message) = validate_manifest(manifest.clone()).err() { + if let Some(error_message) = validate_manifest(manifest.clone()).await.err() { self.send_error(msg.reply, error_message.to_string()).await; return; } @@ -808,9 +811,41 @@ impl Handler

{ } // Manifest validation -pub(crate) fn validate_manifest(manifest: Manifest) -> anyhow::Result<()> { +pub(crate) async fn validate_manifest(manifest: Manifest) -> anyhow::Result<()> { let mut name_registry: HashSet = HashSet::new(); let mut required_capability_components: HashSet = HashSet::new(); + JSON_SCHEMA_VALUE + .get_or_try_init(|| async { + serde_json::from_str(JSON_SCHEMA) + .map_err(|e| anyhow!("Unable to parse JSON schema: {}", e)) + }) + .await?; + + let ok_schema = OAM_JSON_SCHEMA + .get_or_try_init(|| async { + JSONSchema::options().with_draft(Draft::Draft7).compile( + JSON_SCHEMA_VALUE + .get() + // SAFETY: We just initialized it above + .expect("JSON schema should be initialized"), + ) + }) + .await?; + + let json_instance = serde_json::to_value(manifest.clone())?; + let validation_result = ok_schema.validate(&json_instance); + if let Err(errors) = validation_result { + let mut error_message = String::new(); + for error in errors { + error_message.push_str(&format!( + "Validation error in object: {} \nObject path: {}", + // Error instance in the JSON instance and its corresponding path in that file + error.instance, + error.instance_path + )); + } + return Err(anyhow!("Validation Error : \n{}", error_message)); + } // Map of link names to a vector of provider references with that link name let mut linkdef_map: HashMap> = HashMap::new(); @@ -932,17 +967,30 @@ mod test { Ok(yaml_string) } - #[test] - fn test_manifest_validation() { + #[tokio::test] + async fn test_manifest_validation() { let correct_manifest = deserialize_yaml("./oam/simple1.yaml").expect("Should be able to parse"); - assert!(validate_manifest(correct_manifest).is_ok()); + assert!(validate_manifest(correct_manifest).await.is_ok()); + + let manifest = deserialize_yaml("./test/data/incorrect_component.yaml") + .expect("Should be able to parse"); + + match validate_manifest(manifest).await { + Ok(()) => panic!("Should have detected incorrect component"), + Err(e) => { + assert!(e + .to_string() + // The 0th component in the spec list is incorrect and should be detected (indexing starts from 0) + .contains("Object path: /spec/components/0")) + } + } let manifest = deserialize_yaml("./test/data/duplicate_component.yaml") .expect("Should be able to parse"); - match validate_manifest(manifest) { + match validate_manifest(manifest).await { Ok(()) => panic!("Should have detected duplicate component"), Err(e) => assert!(e .to_string() @@ -952,7 +1000,7 @@ mod test { let manifest = deserialize_yaml("./test/data/duplicate_imageref1.yaml") .expect("Should be able to parse"); - match validate_manifest(manifest) { + match validate_manifest(manifest).await { Ok(()) => panic!("Should have detected duplicate image reference for link name in provider properties"), Err(e) => assert!(e .to_string() @@ -962,7 +1010,7 @@ mod test { let manifest = deserialize_yaml("./test/data/duplicate_imageref2.yaml") .expect("Should be able to parse"); - match validate_manifest(manifest) { + match validate_manifest(manifest).await { Ok(()) => panic!("Should have detected duplicate image reference for actor"), Err(e) => assert!(e .to_string() @@ -972,7 +1020,7 @@ mod test { let manifest = deserialize_yaml("./test/data/duplicate_linkdef.yaml") .expect("Should be able to parse"); - match validate_manifest(manifest) { + match validate_manifest(manifest).await { Ok(()) => panic!("Should have detected duplicate linkdef"), Err(e) => assert!(e.to_string().contains("Duplicate target")), } @@ -980,7 +1028,7 @@ mod test { let manifest = deserialize_yaml("./test/data/missing_capability_component.yaml") .expect("Should be able to parse"); - match validate_manifest(manifest) { + match validate_manifest(manifest).await { Ok(()) => panic!("Should have detected missing capability component"), Err(e) => assert!(e .to_string() diff --git a/test/data/incorrect_component.yaml b/test/data/incorrect_component.yaml new file mode 100644 index 00000000..f43ad398 --- /dev/null +++ b/test/data/incorrect_component.yaml @@ -0,0 +1,122 @@ +apiVersion: core.oam.dev/v1beta1 +kind: Application +metadata: + name: petclinic + annotations: + version: v0.0.1 + description: "wasmCloud Pet Clinic Sample" +spec: + components: + - name: ui + type: actor + properties: + image: wasmcloud.azurecr.io/ui:0.3.2 + traits: + - type: spreadscaler + properties: + spread: + - name: uiclinicapp + requirements: + app: petclinic + + - name: ui + type: actor + properties: + image: wasmcloud.azurecr.io/customers:0.3.1 + traits: + - type: linkdef + properties: + target: postgres + values: + uri: postgres://user:pass@your.db.host.com/petclinic + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: customersclinicapp + requirements: + app: petclinic + + - name: vets + type: actor + properties: + image: wasmcloud.azurecr.io/vets:0.3.1 + traits: + - type: linkdef + properties: + target: postgres + values: + uri: postgres://user:pass@your.db.host.com/petclinic + foo: bar + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: vetsclinicapp + requirements: + app: petclinic + + - name: vets + type: actor + properties: + image: wasmcloud.azurecr.io/visits:0.3.1 + traits: + - type: linkdef + properties: + target: postgres + values: + uri: postgres://user:pass@your.db.host.com/petclinic + + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: visitsclinicapp + requirements: + app: petclinic + + - name: clinicapi + type: actor + properties: + image: wasmcloud.azurecr.io/clinicapi:0.3.1 + traits: + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: clinicapp + requirements: + app: petclinic + - type: linkdef + properties: + target: httpserver + values: + address: "0.0.0.0:8080" + + - name: httpserver + type: capability + properties: + image: wasmcloud.azurecr.io/httpserver:0.16.2 + contract: wasmcloud:httpserver + traits: + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: httpserverspread + requirements: + app: petclinic + + - name: postgres + type: capability + properties: + image: wasmcloud.azurecr.io/sqldb-postgres:0.3.1 + contract: wasmcloud:sqldb + traits: + - type: spreadscaler + properties: + replicas: 1 + spread: + - name: postgresspread + requirements: + app: petclinic