Skip to content

Commit a12c2bf

Browse files
authored
Merge pull request #3375 from itowlson/heterogeneous-target-envs-joy-joy-joy
Per-component target environments
2 parents 2117eeb + b0a9f11 commit a12c2bf

11 files changed

Lines changed: 297 additions & 36 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/build/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,10 @@ mod tests {
385385

386386
let target_validation = spin_environments::validate_application_against_environment_ids(
387387
&application,
388-
&manifest.application.targets,
388+
spin_environments::Targets {
389+
default: &manifest.application.targets,
390+
overrides: std::collections::HashMap::new(),
391+
},
389392
None,
390393
manifest_file.parent().unwrap(),
391394
)
@@ -417,6 +420,7 @@ mod tests {
417420
source: Some(v2::ComponentSource::Local(format!("{id}.wasm"))),
418421
build: None,
419422
dependencies: depends_on(dep_on),
423+
targets: None,
420424
}
421425
}
422426

crates/build/src/manifest.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,23 @@ impl ManifestBuildInfo {
3333
}
3434
}
3535

36-
pub fn deployment_targets(&self) -> &[spin_manifest::schema::v2::TargetEnvironmentRef] {
36+
pub fn deployment_targets<'a>(&'a self) -> spin_environments::Targets<'a> {
3737
match self {
3838
Self::Loadable {
39-
deployment_targets, ..
40-
} => deployment_targets,
41-
Self::Unloadable { .. } => &[],
39+
deployment_targets,
40+
components,
41+
..
42+
} => {
43+
let overrides = components
44+
.iter()
45+
.filter_map(|c| c.targets.as_ref().map(|ts| (c.id.clone(), ts.as_slice())))
46+
.collect();
47+
spin_environments::Targets {
48+
default: deployment_targets,
49+
overrides,
50+
}
51+
}
52+
Self::Unloadable { .. } => Default::default(),
4253
}
4354
}
4455

@@ -110,6 +121,7 @@ fn build_configs_from_manifest(
110121
build: c.build.clone(),
111122
source: Some(c.source.clone()),
112123
dependencies: c.dependencies.clone(),
124+
targets: c.targets.clone(),
113125
})
114126
.collect()
115127
}
@@ -166,6 +178,8 @@ pub struct ComponentBuildInfo {
166178
pub build: Option<v2::ComponentBuildConfig>,
167179
#[serde(default)]
168180
pub dependencies: v2::ComponentDependencies,
181+
#[serde(default)]
182+
pub targets: Option<Vec<v2::TargetEnvironmentRef>>,
169183
}
170184

171185
#[derive(Deserialize)]

crates/environments/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ wit-component = { workspace = true }
3434
wit-parser = { workspace = true }
3535

3636
[dev-dependencies]
37+
tempfile = { workspace = true }
3738
wit-component = { workspace = true, features = ["dummy-module"] }
3839
wit-encoder = "0.235"
3940

crates/environments/src/environment.rs

Lines changed: 207 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::{collections::HashMap, path::Path};
1+
use std::{collections::HashMap, path::Path, sync::Arc};
22

33
use anyhow::Context;
44
use spin_common::ui::quoted_path;
@@ -10,6 +10,8 @@ mod lockfile;
1010

1111
use definition::WorldName;
1212

13+
use crate::Targets;
14+
1315
/// A fully realised deployment environment, e.g. Spin 2.7,
1416
/// SpinKube 3.1, Fermyon Cloud. The `TargetEnvironment` provides a mapping
1517
/// from the Spin trigger types supported in the environment to the Component Model worlds
@@ -23,18 +25,47 @@ pub struct TargetEnvironment {
2325
unknown_capabilities: Vec<String>,
2426
}
2527

28+
pub(crate) struct RealisedTargets {
29+
default: Vec<Arc<TargetEnvironment>>,
30+
overrides: HashMap<String, Vec<Arc<TargetEnvironment>>>,
31+
}
32+
33+
impl RealisedTargets {
34+
pub fn iter(&self) -> impl Iterator<Item = &Arc<TargetEnvironment>> {
35+
self.default
36+
.iter()
37+
.chain(self.overrides.values().flat_map(|v| v.iter()))
38+
}
39+
40+
pub fn get(&self, component_id: &str) -> &[Arc<TargetEnvironment>] {
41+
self.overrides.get(component_id).unwrap_or(&self.default)
42+
}
43+
}
44+
2645
impl TargetEnvironment {
2746
/// Loads the specified list of environments. This fetches all required
2847
/// environment definitions from their references, and then chases packages
2948
/// references until the entire target environment is fully loaded.
3049
/// The function also caches registry references in the application directory,
3150
/// to avoid loading from the network when the app is validated again.
32-
pub async fn load_all(
33-
env_ids: &[TargetEnvironmentRef],
51+
pub async fn load_all<'a>(
52+
targets: Targets<'a>,
3453
cache_root: Option<std::path::PathBuf>,
3554
app_dir: &std::path::Path,
36-
) -> anyhow::Result<Vec<Self>> {
37-
env_loader::load_environments(env_ids, cache_root, app_dir).await
55+
) -> anyhow::Result<RealisedTargets> {
56+
let env_ids = targets.all_refs();
57+
let envs = env_loader::load_environments(&env_ids, cache_root, app_dir).await?;
58+
59+
let env = |id: &TargetEnvironmentRef| envs[id].clone();
60+
61+
let default = targets.default.iter().map(env).collect();
62+
let overrides = targets
63+
.overrides
64+
.iter()
65+
.map(|(c, ids)| (c.clone(), ids.iter().map(env).collect()))
66+
.collect();
67+
68+
Ok(RealisedTargets { default, overrides })
3869
}
3970

4071
/// The environment name for UI purposes
@@ -236,32 +267,54 @@ mod test {
236267
}
237268

238269
/// Build an environment using the given WIT that maps the "s" trigger
239-
/// to the "spin:test/simple@1.0.0" world (and denies all other triggers).
240-
fn target_simple_world(wit_path: &Path) -> TargetEnvironment {
241-
let candidate_worlds = load_simple_world(wit_path, "spin:test/simple@1.0.0");
270+
/// to the given world (and denies all other triggers).
271+
fn target_world_unarced(env_name: &str, wit_path: &Path, world: &str) -> TargetEnvironment {
272+
let candidate_worlds = load_simple_world(wit_path, world);
242273

243274
TargetEnvironment {
244-
name: "test".to_owned(),
275+
name: env_name.to_owned(),
245276
trigger_worlds: [("s".to_owned(), candidate_worlds)].into_iter().collect(),
246277
trigger_capabilities: Default::default(),
247278
unknown_trigger: UnknownTrigger::Deny,
248279
unknown_capabilities: Default::default(),
249280
}
250281
}
251282

283+
/// Build an environment using the given WIT that maps the "s" trigger
284+
/// to the "spin:test/simple@1.0.0" world (and denies all other triggers).
285+
fn target_simple_world_unarced(wit_path: &Path) -> TargetEnvironment {
286+
target_world_unarced("test", wit_path, "spin:test/simple@1.0.0")
287+
}
288+
289+
/// Build an environment using the given WIT that maps the "s" trigger
290+
/// to the "spin:test/simple@1.0.0" world (and denies all other triggers).
291+
fn target_simple_world(wit_path: &Path) -> Arc<TargetEnvironment> {
292+
Arc::new(target_simple_world_unarced(wit_path))
293+
}
294+
295+
/// Build an environment using the given WIT that maps the "s" trigger
296+
/// to the "spin:test/not-so-simple@1.0.0" world (and denies all other triggers).
297+
fn target_not_so_simple_world(wit_path: &Path) -> Arc<TargetEnvironment> {
298+
Arc::new(target_world_unarced(
299+
"test-nss",
300+
wit_path,
301+
"spin:test/not-so-simple@1.0.0",
302+
))
303+
}
304+
252305
/// Build an environment using the given WIT that maps all triggers to
253306
/// the "spin:test/simple-import-only@1.0.0" world. (This isn't a very realistic example
254307
/// because a fallback world would usually be imports-only.)
255-
fn target_import_only_forgiving(wit_path: &Path) -> TargetEnvironment {
308+
fn target_import_only_forgiving(wit_path: &Path) -> Arc<TargetEnvironment> {
256309
let candidate_worlds = load_simple_world(wit_path, "spin:test/simple-import-only@1.0.0");
257310

258-
TargetEnvironment {
311+
Arc::new(TargetEnvironment {
259312
name: "test".to_owned(),
260313
trigger_worlds: [].into_iter().collect(),
261314
trigger_capabilities: Default::default(),
262315
unknown_trigger: UnknownTrigger::Allow(candidate_worlds),
263316
unknown_capabilities: Default::default(),
264-
}
317+
})
265318
}
266319

267320
#[tokio::test]
@@ -292,6 +345,146 @@ mod test {
292345
);
293346
}
294347

348+
#[tokio::test]
349+
async fn can_override_validation_at_component_level() {
350+
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
351+
352+
let wit_text = tokio::fs::read_to_string(wit_path.join("world.wit"))
353+
.await
354+
.unwrap();
355+
let wasm1 = generate_dummy_component(&wit_text, "spin:test/simple@1.0.0");
356+
let wasm2 = generate_dummy_component(&wit_text, "spin:test/not-so-simple@1.0.0");
357+
358+
let temp_dir = tempfile::tempdir().unwrap();
359+
std::fs::write(temp_dir.path().join("wasm1"), wasm1).unwrap();
360+
std::fs::write(temp_dir.path().join("wasm2"), wasm2).unwrap();
361+
362+
let env1 = target_simple_world(&wit_path);
363+
let env2 = target_not_so_simple_world(&wit_path);
364+
365+
// This would normally be derived from the manifest
366+
let targets = RealisedTargets {
367+
default: vec![env1],
368+
overrides: [("nss".to_string(), vec![env2])].into_iter().collect(),
369+
};
370+
371+
let manifest = spin_manifest::manifest_from_str(
372+
r#"
373+
spin_manifest_version = 2
374+
375+
[application]
376+
name = "test"
377+
targets = ["test"] # default: maps to env1 as per `targets`
378+
379+
[[trigger.s]]
380+
route = "/1"
381+
component = { source = "wasm1" }
382+
383+
[[trigger.s]]
384+
route = "/2"
385+
component = "nss"
386+
387+
[component.nss]
388+
source = "wasm2"
389+
targets = ["test-nss"] # override: maps to env2 as per `targets`
390+
"#,
391+
)
392+
.unwrap();
393+
394+
let application = crate::ApplicationToValidate::new(manifest, temp_dir.path())
395+
.await
396+
.unwrap();
397+
398+
let validation = crate::validate_application_against_environments(&application, &targets)
399+
.await
400+
.unwrap();
401+
assert!(
402+
validation.errors().is_empty(),
403+
"{}",
404+
validation
405+
.errors()
406+
.iter()
407+
.map(|e| e.to_string())
408+
.collect::<Vec<_>>()
409+
.join("\n")
410+
);
411+
assert!(validation.is_ok());
412+
}
413+
414+
#[tokio::test]
415+
async fn override_at_component_level_bad_component_is_caught() {
416+
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
417+
418+
let wit_text = tokio::fs::read_to_string(wit_path.join("world.wit"))
419+
.await
420+
.unwrap();
421+
let wasm1 = generate_dummy_component(&wit_text, "spin:test/simple@1.0.0");
422+
let wasm2 = generate_dummy_component(&wit_text, "spin:test/not-so-simple@1.0.0");
423+
424+
let temp_dir = tempfile::tempdir().unwrap();
425+
std::fs::write(temp_dir.path().join("wasm1"), wasm1).unwrap();
426+
std::fs::write(temp_dir.path().join("wasm2"), wasm2).unwrap();
427+
428+
let env1 = target_simple_world(&wit_path);
429+
let env2 = target_not_so_simple_world(&wit_path);
430+
431+
// This would normally be derived from the manifest
432+
let targets = RealisedTargets {
433+
default: vec![env2],
434+
overrides: [("nss".to_string(), vec![env1])].into_iter().collect(),
435+
};
436+
437+
let manifest = spin_manifest::manifest_from_str(
438+
r#"
439+
spin_manifest_version = 2
440+
441+
[application]
442+
name = "test"
443+
targets = ["nss-test"] # default: maps to env2 as per `targets`
444+
445+
[[trigger.s]]
446+
route = "/1"
447+
component = { source = "wasm1" }
448+
449+
[[trigger.s]]
450+
route = "/2"
451+
component = "nss"
452+
453+
[component.nss]
454+
source = "wasm2"
455+
targets = ["test"] # override: maps to env1 as per `targets`
456+
"#,
457+
)
458+
.unwrap();
459+
460+
let application = crate::ApplicationToValidate::new(manifest, temp_dir.path())
461+
.await
462+
.unwrap();
463+
464+
let validation = crate::validate_application_against_environments(&application, &targets)
465+
.await
466+
.unwrap();
467+
468+
let errs = validation.errors();
469+
assert!(!errs.is_empty());
470+
471+
let err = errs[0].to_string();
472+
assert!(
473+
err.contains("Component nss (file \"wasm2\") can't run in environment test"),
474+
"unexpected error {err}"
475+
);
476+
assert!(
477+
err.contains("requires imports named"),
478+
"unexpected error {err}"
479+
);
480+
assert!(
481+
err.contains("spin:test/evil@1.0.0"),
482+
"unexpected error {err}"
483+
);
484+
485+
assert!(!validation.is_ok());
486+
}
487+
295488
#[tokio::test]
296489
async fn can_validate_component_for_unknown_trigger() {
297490
let wit_path = PathBuf::from(SIMPLE_WIT_DIR);
@@ -335,14 +528,15 @@ mod test {
335528
.unwrap();
336529
let wasm = generate_dummy_component(&wit_text, "spin:test/simple@1.0.0");
337530

338-
let mut env = target_simple_world(&wit_path);
531+
let mut env = target_simple_world_unarced(&wit_path);
339532
env.trigger_capabilities.insert(
340533
"s".to_owned(),
341534
vec![
342535
"local_spline_reticulation".to_owned(),
343536
"nice_cup_of_tea".to_owned(),
344537
],
345538
);
539+
let env = Arc::new(env);
346540

347541
assert!(env.supports_trigger_type(&"s".to_owned()));
348542
assert!(!env.supports_trigger_type(&"t".to_owned()));

0 commit comments

Comments
 (0)