Skip to content

Commit e10150a

Browse files
committed
Validate host requirements as part of target environments
Signed-off-by: itowlson <ivan.towlson@fermyon.com>
1 parent eedb37d commit e10150a

File tree

8 files changed

+195
-21
lines changed

8 files changed

+195
-21
lines changed

crates/environments/src/environment.rs

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ use definition::WorldName;
1818
pub struct TargetEnvironment {
1919
name: String,
2020
trigger_worlds: HashMap<TriggerType, CandidateWorlds>,
21+
trigger_capabilities: HashMap<TriggerType, Vec<String>>,
2122
unknown_trigger: UnknownTrigger,
23+
unknown_capabilities: Vec<String>,
2224
}
2325

2426
impl TargetEnvironment {
@@ -52,6 +54,13 @@ impl TargetEnvironment {
5254
.or_else(|| self.unknown_trigger.worlds())
5355
.unwrap_or(NO_WORLDS)
5456
}
57+
58+
/// Lists all host capabilities supported for the given trigger type in this environment.
59+
pub fn capabilities(&self, trigger_type: &TriggerType) -> &[String] {
60+
self.trigger_capabilities
61+
.get(trigger_type)
62+
.unwrap_or(&self.unknown_capabilities)
63+
}
5564
}
5665

5766
/// How a `TargetEnvironment` should validate components associated with trigger types
@@ -229,7 +238,9 @@ mod test {
229238
TargetEnvironment {
230239
name: "test".to_owned(),
231240
trigger_worlds: [("s".to_owned(), candidate_worlds)].into_iter().collect(),
241+
trigger_capabilities: Default::default(),
232242
unknown_trigger: UnknownTrigger::Deny,
243+
unknown_capabilities: Default::default(),
233244
}
234245
}
235246

@@ -242,7 +253,9 @@ mod test {
242253
TargetEnvironment {
243254
name: "test".to_owned(),
244255
trigger_worlds: [].into_iter().collect(),
256+
trigger_capabilities: Default::default(),
245257
unknown_trigger: UnknownTrigger::Allow(candidate_worlds),
258+
unknown_capabilities: Default::default(),
246259
}
247260
}
248261

@@ -260,7 +273,7 @@ mod test {
260273
assert!(env.supports_trigger_type(&"s".to_owned()));
261274
assert!(!env.supports_trigger_type(&"t".to_owned()));
262275

263-
let component = crate::ComponentToValidate::new("scomp", "scomp.wasm", wasm);
276+
let component = crate::ComponentToValidate::new("scomp", "scomp.wasm", wasm, vec![]);
264277
let errs =
265278
crate::validate_component_against_environments(&[env], &"s".to_owned(), &component)
266279
.await;
@@ -291,7 +304,7 @@ mod test {
291304

292305
assert!(env.supports_trigger_type(&non_existent_trigger));
293306

294-
let component = crate::ComponentToValidate::new("comp", "comp.wasm", wasm);
307+
let component = crate::ComponentToValidate::new("comp", "comp.wasm", wasm, vec![]);
295308
let errs = crate::validate_component_against_environments(
296309
&[env],
297310
&non_existent_trigger,
@@ -308,6 +321,46 @@ mod test {
308321
);
309322
}
310323

324+
#[tokio::test]
325+
async fn can_validate_component_with_host_requirement() {
326+
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
327+
328+
let wit_text = tokio::fs::read_to_string(wit_path.join("world.wit"))
329+
.await
330+
.unwrap();
331+
let wasm = generate_dummy_component(&wit_text, "spin:test/simple@1.0.0");
332+
333+
let mut env = target_simple_world(&wit_path);
334+
env.trigger_capabilities.insert(
335+
"s".to_owned(),
336+
vec![
337+
"local_spline_reticulation".to_owned(),
338+
"nice_cup_of_tea".to_owned(),
339+
],
340+
);
341+
342+
assert!(env.supports_trigger_type(&"s".to_owned()));
343+
assert!(!env.supports_trigger_type(&"t".to_owned()));
344+
345+
let component = crate::ComponentToValidate::new(
346+
"cscomp",
347+
"cscomp.wasm",
348+
wasm,
349+
vec!["nice_cup_of_tea".to_string()],
350+
);
351+
let errs =
352+
crate::validate_component_against_environments(&[env], &"s".to_owned(), &component)
353+
.await;
354+
assert!(
355+
errs.is_empty(),
356+
"{}",
357+
errs.iter()
358+
.map(|e| e.to_string())
359+
.collect::<Vec<_>>()
360+
.join("\n")
361+
);
362+
}
363+
311364
#[tokio::test]
312365
async fn unavailable_import_invalidates_component() {
313366
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
@@ -319,7 +372,7 @@ mod test {
319372

320373
let env = target_simple_world(&wit_path);
321374

322-
let component = crate::ComponentToValidate::new("nscomp", "nscomp.wasm", wasm);
375+
let component = crate::ComponentToValidate::new("nscomp", "nscomp.wasm", wasm, vec![]);
323376
let errs =
324377
crate::validate_component_against_environments(&[env], &"s".to_owned(), &component)
325378
.await;
@@ -346,7 +399,7 @@ mod test {
346399

347400
let env = target_simple_world(&wit_path);
348401

349-
let component = crate::ComponentToValidate::new("tdscomp", "tdscomp.wasm", wasm);
402+
let component = crate::ComponentToValidate::new("tdscomp", "tdscomp.wasm", wasm, vec![]);
350403
let errs =
351404
crate::validate_component_against_environments(&[env], &"s".to_owned(), &component)
352405
.await;
@@ -359,6 +412,39 @@ mod test {
359412
);
360413
}
361414

415+
#[tokio::test]
416+
async fn unsupported_host_req_invalidates_component() {
417+
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
418+
419+
let wit_text = tokio::fs::read_to_string(wit_path.join("world.wit"))
420+
.await
421+
.unwrap();
422+
let wasm = generate_dummy_component(&wit_text, "spin:test/simple@1.0.0");
423+
424+
let env = target_simple_world(&wit_path);
425+
426+
assert!(env.supports_trigger_type(&"s".to_owned()));
427+
assert!(!env.supports_trigger_type(&"t".to_owned()));
428+
429+
let component = crate::ComponentToValidate::new(
430+
"cscomp",
431+
"cscomp.wasm",
432+
wasm,
433+
vec!["nice_cup_of_tea".to_string()],
434+
);
435+
let errs =
436+
crate::validate_component_against_environments(&[env], &"s".to_owned(), &component)
437+
.await;
438+
assert!(!errs.is_empty());
439+
440+
let err = errs[0].to_string();
441+
assert!(
442+
err.contains("Component cscomp can't run in environment test"),
443+
"unexpected error {err}"
444+
);
445+
assert!(err.contains("nice_cup_of_tea"), "unexpected error {err}");
446+
}
447+
362448
fn generate_dummy_component(wit: &str, world: &str) -> Vec<u8> {
363449
let mut resolve = wit_parser::Resolve::default();
364450
let package_id = resolve.push_str("test", wit).expect("should parse WIT");

crates/environments/src/environment/definition.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,44 @@ use anyhow::Context;
1414
/// ```ignore
1515
/// # spin-up.3.2.toml
1616
/// [triggers]
17-
/// http = ["spin:up/http-trigger@3.2.0", "spin:up/http-trigger-rc20231018@3.2.0"]
18-
/// redis = ["spin:up/redis-trigger@3.2.0"]
17+
/// http = { worlds = ["spin:up/http-trigger@3.2.0", "spin:up/http-trigger-rc20231018@3.2.0"], capabilities = ["local_service_chaining"] }
18+
/// redis = { worlds = ["spin:up/redis-trigger@3.2.0"] }
1919
/// ```
2020
#[derive(Debug, serde::Deserialize)]
2121
#[serde(deny_unknown_fields)]
2222
pub struct EnvironmentDefinition {
23-
triggers: HashMap<String, Vec<WorldRef>>,
24-
default: Option<Vec<WorldRef>>,
23+
triggers: HashMap<String, TriggerEnvironment>,
24+
#[serde(default)]
25+
default: Option<TriggerEnvironment>,
26+
}
27+
28+
/// The environment definition for a trigger, comprising the worlds which are
29+
/// compatible with that trigger and the host capabilities which the trigger
30+
/// supports.
31+
#[derive(Debug, serde::Deserialize)]
32+
#[serde(deny_unknown_fields)]
33+
pub struct TriggerEnvironment {
34+
worlds: Vec<WorldRef>,
35+
#[serde(default)]
36+
capabilities: Vec<String>,
37+
}
38+
39+
impl TriggerEnvironment {
40+
pub fn world_refs(&self) -> &[WorldRef] {
41+
&self.worlds
42+
}
43+
44+
pub fn capabilities(&self) -> Vec<String> {
45+
self.capabilities.clone()
46+
}
2547
}
2648

2749
impl EnvironmentDefinition {
28-
pub fn triggers(&self) -> &HashMap<String, Vec<WorldRef>> {
50+
pub fn triggers(&self) -> &HashMap<String, TriggerEnvironment> {
2951
&self.triggers
3052
}
3153

32-
pub fn default(&self) -> Option<&Vec<WorldRef>> {
54+
pub fn default(&self) -> Option<&TriggerEnvironment> {
3355
self.default.as_ref()
3456
}
3557
}

crates/environments/src/environment/env_loader.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,25 +128,33 @@ async fn load_environment_from_toml(
128128
let env: EnvironmentDefinition = toml::from_str(toml_text)?;
129129

130130
let mut trigger_worlds = HashMap::new();
131+
let mut trigger_capabilities = HashMap::new();
131132

132133
// TODO: parallel all the things
133134
// TODO: this loads _all_ triggers not just the ones we need
134-
for (trigger_type, world_refs) in env.triggers() {
135+
for (trigger_type, trigger_env) in env.triggers() {
135136
trigger_worlds.insert(
136137
trigger_type.to_owned(),
137-
load_worlds(world_refs, cache, lockfile).await?,
138+
load_worlds(trigger_env.world_refs(), cache, lockfile).await?,
138139
);
140+
trigger_capabilities.insert(trigger_type.to_owned(), trigger_env.capabilities());
139141
}
140142

141143
let unknown_trigger = match env.default() {
142144
None => UnknownTrigger::Deny,
143-
Some(world_refs) => UnknownTrigger::Allow(load_worlds(world_refs, cache, lockfile).await?),
145+
Some(env) => UnknownTrigger::Allow(load_worlds(env.world_refs(), cache, lockfile).await?),
146+
};
147+
let unknown_capabilities = match env.default() {
148+
None => vec![],
149+
Some(env) => env.capabilities(),
144150
};
145151

146152
Ok(TargetEnvironment {
147153
name: name.to_owned(),
148154
trigger_worlds,
155+
trigger_capabilities,
149156
unknown_trigger,
157+
unknown_capabilities,
150158
})
151159
}
152160

crates/environments/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ async fn validate_component_against_environments(
102102
{
103103
errs.push(e);
104104
}
105+
106+
let host_caps = env.capabilities(trigger_type);
107+
if let Some(e) = validate_host_reqs(env, host_caps, component).err() {
108+
errs.push(e);
109+
}
105110
}
106111

107112
if errs.is_empty() {
@@ -215,3 +220,25 @@ async fn validate_wasm_against_world(
215220
},
216221
}
217222
}
223+
224+
fn validate_host_reqs(
225+
env: &TargetEnvironment,
226+
host_caps: &[String],
227+
component: &ComponentToValidate,
228+
) -> anyhow::Result<()> {
229+
let unsatisfied: Vec<_> = component
230+
.host_requirements()
231+
.iter()
232+
.filter(|host_req| !satisfies(host_caps, host_req))
233+
.cloned()
234+
.collect();
235+
if unsatisfied.is_empty() {
236+
Ok(())
237+
} else {
238+
Err(anyhow!("Component {} can't run in environment {} because it requires the feature(s) '{}' which the environment does not support", component.id(), env.name(), unsatisfied.join(", ")))
239+
}
240+
}
241+
242+
fn satisfies(host_caps: &[String], host_req: &String) -> bool {
243+
host_caps.contains(host_req)
244+
}

crates/environments/src/loader.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub(crate) struct ComponentToValidate<'a> {
1010
id: &'a str,
1111
source_description: String,
1212
wasm: Vec<u8>,
13+
host_requirements: Vec<String>,
1314
}
1415

1516
impl ComponentToValidate<'_> {
@@ -25,12 +26,22 @@ impl ComponentToValidate<'_> {
2526
&self.wasm
2627
}
2728

29+
pub fn host_requirements(&self) -> &[String] {
30+
&self.host_requirements
31+
}
32+
2833
#[cfg(test)]
29-
pub(crate) fn new(id: &'static str, description: &str, wasm: Vec<u8>) -> Self {
34+
pub(crate) fn new(
35+
id: &'static str,
36+
description: &str,
37+
wasm: Vec<u8>,
38+
host_requirements: Vec<String>,
39+
) -> Self {
3040
Self {
3141
id,
3242
source_description: description.to_owned(),
3343
wasm,
44+
host_requirements,
3445
}
3546
}
3647
}
@@ -61,10 +72,13 @@ impl ApplicationToValidate {
6172
.component
6273
.as_ref()
6374
.ok_or_else(|| anyhow!("No component specified for trigger {}", trigger.id))?;
64-
let (id, source, dependencies) = match component_spec {
65-
spin_manifest::schema::v2::ComponentSpec::Inline(c) => {
66-
(trigger.id.as_str(), &c.source, &c.dependencies)
67-
}
75+
let (id, source, dependencies, service_chaining) = match component_spec {
76+
spin_manifest::schema::v2::ComponentSpec::Inline(c) => (
77+
trigger.id.as_str(),
78+
&c.source,
79+
&c.dependencies,
80+
spin_loader::requires_service_chaining(c),
81+
),
6882
spin_manifest::schema::v2::ComponentSpec::Reference(r) => {
6983
let id = r.as_ref();
7084
let Some(component) = self.manifest.components.get(r) else {
@@ -73,14 +87,20 @@ impl ApplicationToValidate {
7387
trigger.id
7488
);
7589
};
76-
(id, &component.source, &component.dependencies)
90+
(
91+
id,
92+
&component.source,
93+
&component.dependencies,
94+
spin_loader::requires_service_chaining(component),
95+
)
7796
}
7897
};
7998

8099
Ok(ComponentSource {
81100
id,
82101
source,
83102
dependencies: WrappedComponentDependencies::new(dependencies),
103+
requires_service_chaining: service_chaining,
84104
})
85105
}
86106

@@ -127,10 +147,17 @@ impl ApplicationToValidate {
127147

128148
let wasm = spin_compose::compose(&loader, &component).await.with_context(|| format!("Spin needed to compose dependencies for {} as part of target checking, but composition failed", component.id))?;
129149

150+
let host_requirements = if component.requires_service_chaining {
151+
vec!["local_service_chaining".to_string()]
152+
} else {
153+
vec![]
154+
};
155+
130156
Ok(ComponentToValidate {
131157
id: component.id,
132158
source_description: source_description(component.source),
133159
wasm,
160+
host_requirements,
134161
})
135162
}
136163
}
@@ -139,6 +166,7 @@ struct ComponentSource<'a> {
139166
id: &'a str,
140167
source: &'a spin_manifest::schema::v2::ComponentSource,
141168
dependencies: WrappedComponentDependencies,
169+
requires_service_chaining: bool,
142170
}
143171

144172
struct ComponentSourceLoader<'a> {

crates/loader/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod fs;
2323
mod http;
2424
mod local;
2525

26+
pub use local::requires_service_chaining;
2627
pub use local::WasmLoader;
2728

2829
/// Maximum number of files to copy (or download) concurrently

0 commit comments

Comments
 (0)