Skip to content

Commit fd45f2f

Browse files
committed
WIT-based environment syntax
Signed-off-by: itowlson <[email protected]>
1 parent 3baee56 commit fd45f2f

File tree

4 files changed

+161
-112
lines changed

4 files changed

+161
-112
lines changed

Cargo.lock

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

crates/environments/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ edition = { workspace = true }
77
[dependencies]
88
anyhow = { workspace = true }
99
async-trait = "0.1"
10+
bytes = "1.1"
1011
futures = "0.3"
12+
futures-util = "0.3"
13+
id-arena = "2"
1114
indexmap = "2.2.6"
1215
miette = "7.2.0"
1316
oci-distribution = { git = "https://github.com/fermyon/oci-distribution", rev = "7e4ce9be9bcd22e78a28f06204931f10c44402ba" }
@@ -26,6 +29,8 @@ wac-parser = "0.6.0"
2629
wac-resolver = "0.6.0"
2730
wac-types = "0.6.0"
2831
wasm-pkg-loader = "0.4.1"
32+
wit-component = "0.217.0"
33+
wit-parser = "0.217.0"
2934

3035
[lints]
3136
workspace = true

crates/environments/src/environment_definition.rs

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,112 @@
1-
use wasm_pkg_loader::PackageRef;
1+
use anyhow::Context;
22

3-
#[derive(Debug, serde::Deserialize)]
4-
pub struct TargetEnvironment {
5-
pub name: String,
6-
pub environments: std::collections::HashMap<TriggerType, TargetWorld>,
7-
}
3+
pub async fn load_environment(env_id: &str) -> anyhow::Result<TargetEnvironment> {
4+
use futures_util::TryStreamExt;
5+
6+
let (pkg_name, pkg_ver) = env_id.split_once('@').unwrap();
7+
8+
let mut client = wasm_pkg_loader::Client::with_global_defaults()?;
9+
10+
let package = pkg_name.to_owned().try_into().context("pkg ref parse")?;
11+
let version = wasm_pkg_loader::Version::parse(pkg_ver).context("pkg ver parse")?;
12+
13+
let release = client
14+
.get_release(&package, &version)
15+
.await
16+
.context("get release")?;
17+
let stm = client
18+
.stream_content(&package, &release)
19+
.await
20+
.context("stream content")?;
21+
let bytes = stm
22+
.try_collect::<bytes::BytesMut>()
23+
.await
24+
.context("collect stm")?
25+
.to_vec();
826

9-
#[derive(Debug, Eq, Hash, PartialEq, serde::Deserialize)]
10-
pub struct TargetWorld {
11-
wit_package: PackageRef,
12-
package_ver: String, // TODO: tidy to semver::Version
13-
world_name: WorldNames,
27+
TargetEnvironment::new(env_id.to_owned(), bytes)
1428
}
1529

16-
#[derive(Debug, Eq, Hash, PartialEq, serde::Deserialize)]
17-
#[serde(untagged)]
18-
enum WorldNames {
19-
Exactly(String),
20-
AnyOf(Vec<String>),
30+
pub struct TargetEnvironment {
31+
name: String,
32+
decoded: wit_parser::decoding::DecodedWasm,
33+
package: wit_parser::Package, // saves unwrapping it every time
34+
package_id: id_arena::Id<wit_parser::Package>,
35+
package_bytes: Vec<u8>,
2136
}
2237

23-
impl TargetWorld {
24-
fn versioned_name(&self, world_name: &str) -> String {
25-
format!("{}/{}@{}", self.wit_package, world_name, self.package_ver)
38+
impl TargetEnvironment {
39+
fn new(name: String, bytes: Vec<u8>) -> anyhow::Result<Self> {
40+
let decoded = wit_component::decode(&bytes).context("decode wasm")?;
41+
let package_id = decoded.package();
42+
let package = decoded
43+
.resolve()
44+
.packages
45+
.get(package_id)
46+
.context("should had a package")?
47+
.clone();
48+
49+
Ok(Self {
50+
name,
51+
decoded,
52+
package,
53+
package_id,
54+
package_bytes: bytes,
55+
})
56+
}
57+
58+
pub fn is_world_for(&self, trigger_type: &TriggerType, world: &wit_parser::World) -> bool {
59+
world.name.starts_with(&format!("trigger-{trigger_type}"))
60+
&& world.package.is_some_and(|p| p == self.package_id)
61+
}
62+
63+
pub fn supports_trigger_type(&self, trigger_type: &TriggerType) -> bool {
64+
self.decoded
65+
.resolve()
66+
.worlds
67+
.iter()
68+
.any(|(_, world)| self.is_world_for(trigger_type, world))
69+
}
70+
71+
pub fn worlds(&self, trigger_type: &TriggerType) -> Vec<String> {
72+
self.decoded
73+
.resolve()
74+
.worlds
75+
.iter()
76+
.filter(|(_, world)| self.is_world_for(trigger_type, world))
77+
.map(|(_, world)| self.world_qname(world))
78+
.collect()
79+
}
80+
81+
/// Fully qualified world name (e.g. fermyon:spin/[email protected])
82+
fn world_qname(&self, world: &wit_parser::World) -> String {
83+
let version_suffix = match self.package_version() {
84+
Some(version) => format!("@{version}"),
85+
None => "".to_owned(),
86+
};
87+
format!(
88+
"{}/{}{version_suffix}",
89+
self.package_namespaced_name(),
90+
world.name,
91+
)
92+
}
93+
94+
/// The environment name for UI purposes
95+
pub fn name(&self) -> &str {
96+
&self.name
97+
}
98+
99+
/// Namespaced but unversioned package name (e.g. spin:cli)
100+
pub fn package_namespaced_name(&self) -> String {
101+
format!("{}:{}", self.package.name.namespace, self.package.name.name)
102+
}
103+
104+
pub fn package_version(&self) -> Option<&semver::Version> {
105+
self.package.name.version.as_ref()
26106
}
27107

28-
pub fn versioned_names(&self) -> Vec<String> {
29-
match &self.world_name {
30-
WorldNames::Exactly(name) => vec![self.versioned_name(name)],
31-
WorldNames::AnyOf(names) => {
32-
names.iter().map(|name| self.versioned_name(name)).collect()
33-
}
34-
}
108+
pub fn package_bytes(&self) -> &[u8] {
109+
&self.package_bytes
35110
}
36111
}
37112

crates/environments/src/lib.rs

Lines changed: 50 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
use anyhow::{anyhow, Context};
1+
use anyhow::anyhow;
22

33
mod environment_definition;
44
mod loader;
55

6-
use environment_definition::{TargetEnvironment, TargetWorld, TriggerType};
6+
use environment_definition::{load_environment, TargetEnvironment, TriggerType};
77
pub use loader::ResolutionContext;
88
use loader::{load_and_resolve_all, ComponentToValidate};
99

@@ -12,57 +12,22 @@ pub async fn validate_application_against_environment_ids(
1212
app: &spin_manifest::schema::v2::AppManifest,
1313
resolution_context: &ResolutionContext,
1414
) -> anyhow::Result<()> {
15-
let envs = join_all_result(env_ids.map(resolve_environment_id)).await?;
15+
let envs = join_all_result(env_ids.map(load_environment)).await?;
1616
validate_application_against_environments(&envs, app, resolution_context).await
1717
}
1818

19-
async fn resolve_environment_id(id: &str) -> anyhow::Result<TargetEnvironment> {
20-
let (name, ver) = id.split_once('@').ok_or(anyhow!(
21-
"Target environment '{id}' does not specify a version"
22-
))?;
23-
let client = oci_distribution::Client::default();
24-
let auth = oci_distribution::secrets::RegistryAuth::Anonymous;
25-
let env_def_ref =
26-
oci_distribution::Reference::try_from(format!("ghcr.io/itowlson/spinenvs/{name}:{ver}"))?;
27-
let (man, _digest) = client
28-
.pull_manifest(&env_def_ref, &auth)
29-
.await
30-
.with_context(|| format!("Failed to find environment '{id}' in registry"))?;
31-
let im = match man {
32-
oci_distribution::manifest::OciManifest::Image(im) => im,
33-
oci_distribution::manifest::OciManifest::ImageIndex(_ind) => {
34-
anyhow::bail!("Environment '{id}' definition is unusable - stored in registry in incorrect format")
35-
}
36-
};
37-
let the_layer = &im.layers[0];
38-
let mut out = Vec::with_capacity(the_layer.size.try_into().unwrap_or_default());
39-
client
40-
.pull_blob(&env_def_ref, the_layer, &mut out)
41-
.await
42-
.with_context(|| {
43-
format!("Failed to download environment '{id}' definition from registry")
44-
})?;
45-
let te = serde_json::from_slice(&out).with_context(|| {
46-
format!("Failed to load environment '{id}' definition - invalid JSON schema")
47-
})?;
48-
Ok(te)
49-
}
50-
51-
pub async fn validate_application_against_environments(
19+
async fn validate_application_against_environments(
5220
envs: &[TargetEnvironment],
5321
app: &spin_manifest::schema::v2::AppManifest,
5422
resolution_context: &ResolutionContext,
5523
) -> anyhow::Result<()> {
5624
use futures::FutureExt;
5725

5826
for trigger_type in app.triggers.keys() {
59-
if let Some(env) = envs
60-
.iter()
61-
.find(|e| !e.environments.contains_key(trigger_type))
62-
{
27+
if let Some(env) = envs.iter().find(|e| !e.supports_trigger_type(trigger_type)) {
6328
anyhow::bail!(
6429
"Environment {} does not support trigger type {trigger_type}",
65-
env.name
30+
env.name()
6631
);
6732
}
6833
}
@@ -87,28 +52,9 @@ async fn validate_component_against_environments(
8752
trigger_type: &TriggerType,
8853
component: &ComponentToValidate<'_>,
8954
) -> anyhow::Result<()> {
90-
let worlds = envs
91-
.iter()
92-
.map(|e| {
93-
e.environments
94-
.get(trigger_type)
95-
.ok_or(anyhow!(
96-
"Environment '{}' doesn't support trigger type {trigger_type}",
97-
e.name
98-
))
99-
.map(|w| (e.name.as_str(), w))
100-
})
101-
.collect::<Result<std::collections::HashSet<_>, _>>()?;
102-
validate_component_against_worlds(worlds.into_iter(), component).await?;
103-
Ok(())
104-
}
105-
106-
async fn validate_component_against_worlds(
107-
target_worlds: impl Iterator<Item = (&str, &TargetWorld)>,
108-
component: &ComponentToValidate<'_>,
109-
) -> anyhow::Result<()> {
110-
for (env_name, target_world) in target_worlds {
111-
validate_wasm_against_any_world(env_name, target_world, component).await?;
55+
for env in envs {
56+
let worlds = env.worlds(trigger_type);
57+
validate_wasm_against_any_world(env, &worlds, component).await?;
11258
}
11359

11460
tracing::info!(
@@ -120,21 +66,21 @@ async fn validate_component_against_worlds(
12066
}
12167

12268
async fn validate_wasm_against_any_world(
123-
env_name: &str,
124-
target_world: &TargetWorld,
69+
env: &TargetEnvironment,
70+
world_names: &[String],
12571
component: &ComponentToValidate<'_>,
12672
) -> anyhow::Result<()> {
12773
let mut result = Ok(());
128-
for target_str in target_world.versioned_names() {
129-
tracing::info!(
130-
"Trying component {} {} against target world {target_str}",
74+
for target_world in world_names {
75+
tracing::debug!(
76+
"Trying component {} {} against target world {target_world}",
13177
component.id(),
13278
component.source_description(),
13379
);
134-
match validate_wasm_against_world(env_name, &target_str, component).await {
80+
match validate_wasm_against_world(env, target_world, component).await {
13581
Ok(()) => {
13682
tracing::info!(
137-
"Validated component {} {} against target world {target_str}",
83+
"Validated component {} {} against target world {target_world}",
13884
component.id(),
13985
component.source_description(),
14086
);
@@ -143,7 +89,7 @@ async fn validate_wasm_against_any_world(
14389
Err(e) => {
14490
// Record the error, but continue in case a different world succeeds
14591
tracing::info!(
146-
"Rejecting component {} {} for target world {target_str} because {e:?}",
92+
"Rejecting component {} {} for target world {target_world} because {e:?}",
14793
component.id(),
14894
component.source_description(),
14995
);
@@ -155,34 +101,52 @@ async fn validate_wasm_against_any_world(
155101
}
156102

157103
async fn validate_wasm_against_world(
158-
env_name: &str,
159-
target_str: &str,
104+
env: &TargetEnvironment,
105+
target_world: &str,
160106
component: &ComponentToValidate<'_>,
161107
) -> anyhow::Result<()> {
162-
let comp_name = "root:component";
108+
// Because we are abusing a composition tool to do validation, we have to
109+
// provide a name by which to refer to the component in the dummy composition.
110+
let component_name = "root:component";
111+
let component_key = wac_types::BorrowedPackageKey::from_name_and_version(component_name, None);
112+
113+
// wac is going to get the world from the environment package bytes.
114+
// This constructs a key for that mapping.
115+
let env_pkg_name = env.package_namespaced_name();
116+
let env_pkg_key =
117+
wac_types::BorrowedPackageKey::from_name_and_version(&env_pkg_name, env.package_version());
118+
119+
let env_name = env.name();
163120

164121
let wac_text = format!(
165122
r#"
166-
package validate:[email protected] targets {target_str};
167-
let c = new {comp_name} {{ ... }};
123+
package validate:[email protected] targets {target_world};
124+
let c = new {component_name} {{ ... }};
168125
export c...;
169126
"#
170127
);
171128

172129
let doc = wac_parser::Document::parse(&wac_text)?;
173130

174-
let compkey = wac_types::BorrowedPackageKey::from_name_and_version(comp_name, None);
131+
// TODO: if we end up needing the registry, we need to do this dance
132+
// for things we are providing separately, or the registry will try to
133+
// hoover them up and will fail.
134+
// let mut refpkgs = wac_resolver::packages(&doc)?;
135+
// refpkgs.shift_remove(&env_pkg_key);
136+
// refpkgs.shift_remove(&component_key);
175137

176-
let mut refpkgs = wac_resolver::packages(&doc)?;
177-
refpkgs.retain(|k, _| k != &compkey);
138+
// TODO: determine if this is needed in circumstances other than the simple test
139+
// let reg_resolver = wac_resolver::RegistryPackageResolver::new(Some("wa.dev"), None).await?;
140+
// let mut packages = reg_resolver
141+
// .resolve(&refpkgs)
142+
// .await
143+
// .context("reg_resolver.resolve failed")?;
178144

179-
let reg_resolver = wac_resolver::RegistryPackageResolver::new(Some("wa.dev"), None).await?;
180-
let mut packages = reg_resolver
181-
.resolve(&refpkgs)
182-
.await
183-
.context("reg_resolver.resolve failed")?;
145+
let mut packages: indexmap::IndexMap<wac_types::BorrowedPackageKey, Vec<u8>> =
146+
Default::default();
184147

185-
packages.insert(compkey, component.wasm_bytes().to_vec());
148+
packages.insert(env_pkg_key, env.package_bytes().to_vec());
149+
packages.insert(component_key, component.wasm_bytes().to_vec());
186150

187151
match doc.resolve(packages) {
188152
Ok(_) => Ok(()),
@@ -195,7 +159,7 @@ async fn validate_wasm_against_world(
195159
}
196160
Err(wac_parser::resolution::Error::PackageMissingExport { export, .. }) => {
197161
// TODO: The export here seems wrong - it seems to contain the world name rather than the interface name
198-
Err(anyhow!("Component {} ({}) can't run in environment {env_name} because world {target_str} requires an export named {export}, which the component does not provide", component.id(), component.source_description()))
162+
Err(anyhow!("Component {} ({}) can't run in environment {env_name} because world {target_world} requires an export named {export}, which the component does not provide", component.id(), component.source_description()))
199163
}
200164
Err(wac_parser::resolution::Error::ImportNotInTarget { name, world, .. }) => {
201165
Err(anyhow!("Component {} ({}) can't run in environment {env_name} because world {world} does not provide an import named {name}, which the component requires", component.id(), component.source_description()))

0 commit comments

Comments
 (0)