Skip to content

Commit 3c94860

Browse files
committed
Dependencies by component ref
Signed-off-by: itowlson <[email protected]>
1 parent e4a1600 commit 3c94860

File tree

9 files changed

+320
-5
lines changed

9 files changed

+320
-5
lines changed

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/manifest.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pub async fn component_build_configs(manifest_file: impl AsRef<Path>) -> Result<
7070
let manifest = spin_manifest::manifest_from_file(&manifest_file);
7171
match manifest {
7272
Ok(mut manifest) => {
73-
spin_manifest::normalize::normalize_manifest(&mut manifest);
73+
spin_manifest::normalize::normalize_manifest(&mut manifest)?;
7474
let components = build_configs_from_manifest(&manifest);
7575
let deployment_targets = deployment_targets_from_manifest(&manifest);
7676
Ok(ManifestBuildInfo::Loadable {

crates/environments/src/loader.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ impl spin_compose::DependencyLike for WrappedComponentDependency {
272272
spin_manifest::schema::v2::ComponentDependency::Package { export, .. } => export,
273273
spin_manifest::schema::v2::ComponentDependency::Local { export, .. } => export,
274274
spin_manifest::schema::v2::ComponentDependency::HTTP { export, .. } => export,
275+
spin_manifest::schema::v2::ComponentDependency::AppComponent { export, .. } => export,
275276
}
276277
}
277278
}

crates/loader/src/local.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ impl LocalLoader {
7373

7474
// Load the given manifest into a LockedApp, ready for execution.
7575
pub(crate) async fn load_manifest(&self, mut manifest: AppManifest) -> Result<LockedApp> {
76-
spin_manifest::normalize::normalize_manifest(&mut manifest);
76+
spin_manifest::normalize::normalize_manifest(&mut manifest)?;
7777

7878
manifest.validate_dependencies()?;
7979

@@ -867,6 +867,9 @@ impl WasmLoader {
867867
let content = self.load_http_source(&url, &digest).await?;
868868
Ok((content, export))
869869
}
870+
v2::ComponentDependency::AppComponent { .. } => {
871+
panic!("Internal error: component ID dependency was not resolved to a source");
872+
}
870873
}
871874
}
872875
}

crates/manifest/src/normalize.rs

Lines changed: 273 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
//! Manifest normalization functions.
22
3-
use std::collections::HashSet;
3+
use std::{collections::HashSet, path::PathBuf};
44

55
use crate::schema::v2::{AppManifest, ComponentSpec, KebabId};
6+
use anyhow::Context;
67

78
/// Normalizes some optional [`AppManifest`] features into a canonical form:
89
/// - Inline components in trigger configs are moved into top-level
910
/// components and replaced with a reference.
1011
/// - Any triggers without an ID are assigned a generated ID.
11-
pub fn normalize_manifest(manifest: &mut AppManifest) {
12+
pub fn normalize_manifest(manifest: &mut AppManifest) -> anyhow::Result<()> {
1213
normalize_trigger_ids(manifest);
1314
normalize_inline_components(manifest);
15+
normalize_dependency_component_refs(manifest)?;
16+
Ok(())
1417
}
1518

1619
fn normalize_inline_components(manifest: &mut AppManifest) {
@@ -103,3 +106,271 @@ fn normalize_trigger_ids(manifest: &mut AppManifest) {
103106
}
104107
}
105108
}
109+
110+
use crate::schema::v2::{Component, ComponentDependency, ComponentSource};
111+
112+
fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> {
113+
// `clone` a snapshot, because we are about to mutate collection elements,
114+
// and the borrow checker gets mad at us if we try to index into the collection
115+
// while that's happening.
116+
let components = manifest.components.clone();
117+
118+
for (depender_id, component) in &mut manifest.components {
119+
for dependency in component.dependencies.inner.values_mut() {
120+
if let ComponentDependency::AppComponent {
121+
component: depended_on_id,
122+
export,
123+
} = dependency
124+
{
125+
let depended_on = components
126+
.get(depended_on_id)
127+
.with_context(|| format!("dependency ID {depended_on_id} does not exist"))?;
128+
ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id)?;
129+
*dependency = component_source_to_dependency(&depended_on.source, export.clone());
130+
}
131+
}
132+
}
133+
134+
Ok(())
135+
}
136+
137+
fn component_source_to_dependency(
138+
source: &ComponentSource,
139+
export: Option<String>,
140+
) -> ComponentDependency {
141+
match source {
142+
ComponentSource::Local(path) => ComponentDependency::Local {
143+
path: PathBuf::from(path),
144+
export,
145+
},
146+
ComponentSource::Remote { url, digest } => ComponentDependency::HTTP {
147+
url: url.clone(),
148+
digest: digest.clone(),
149+
export,
150+
},
151+
ComponentSource::Registry {
152+
registry,
153+
package,
154+
version,
155+
} => ComponentDependency::Package {
156+
version: version.clone(),
157+
registry: registry.as_ref().map(|r| r.to_string()),
158+
package: Some(package.to_string()),
159+
export,
160+
},
161+
}
162+
}
163+
164+
/// If a dependency has things like files or KV stores or network access...
165+
/// those won't apply when it's composed, and that's likely to be surprising,
166+
/// and developers hate surprises.
167+
fn ensure_is_acceptable_dependency(
168+
component: &Component,
169+
depended_on_id: &KebabId,
170+
depender_id: &KebabId,
171+
) -> anyhow::Result<()> {
172+
let mut surprises = vec![];
173+
174+
// Explicitly discard fields we don't need to check (do *not* .. them away). This
175+
// way, the compiler will give us a heads up if a new field is added so we can
176+
// decide whether or not we need to check it.
177+
#[allow(deprecated)]
178+
let Component {
179+
source: _,
180+
description: _,
181+
variables,
182+
environment,
183+
files,
184+
exclude_files: _,
185+
allowed_http_hosts,
186+
allowed_outbound_hosts,
187+
key_value_stores,
188+
sqlite_databases,
189+
ai_models,
190+
build: _,
191+
tool: _,
192+
dependencies_inherit_configuration: _,
193+
dependencies,
194+
} = component;
195+
196+
if !ai_models.is_empty() {
197+
surprises.push("ai_models");
198+
}
199+
if !allowed_http_hosts.is_empty() {
200+
surprises.push("allowed_http_hosts");
201+
}
202+
if !allowed_outbound_hosts.is_empty() {
203+
surprises.push("allowed_outbound_hosts");
204+
}
205+
if !dependencies.inner.is_empty() {
206+
surprises.push("dependencies");
207+
}
208+
if !environment.is_empty() {
209+
surprises.push("environment");
210+
}
211+
if !files.is_empty() {
212+
surprises.push("files");
213+
}
214+
if !key_value_stores.is_empty() {
215+
surprises.push("key_value_stores");
216+
}
217+
if !sqlite_databases.is_empty() {
218+
surprises.push("sqlite_databases");
219+
}
220+
if !variables.is_empty() {
221+
surprises.push("variables");
222+
}
223+
224+
if surprises.is_empty() {
225+
Ok(())
226+
} else {
227+
anyhow::bail!("Dependencies may not have their own resources or permissions. Component {depended_on_id} cannot be used as a dependency of {depender_id} because it specifies: {}", surprises.join(", "));
228+
}
229+
}
230+
231+
#[cfg(test)]
232+
mod test {
233+
use super::*;
234+
235+
use serde::Deserialize;
236+
use toml::toml;
237+
238+
fn package_name(name: &str) -> spin_serde::DependencyName {
239+
let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap();
240+
spin_serde::DependencyName::Package(dpn)
241+
}
242+
243+
#[test]
244+
fn can_resolve_dependency_on_file_source() {
245+
let mut manifest = AppManifest::deserialize(toml! {
246+
spin_manifest_version = 2
247+
248+
[application]
249+
name = "dummy"
250+
251+
[[trigger.dummy]]
252+
component = "a"
253+
254+
[component.a]
255+
source = "a.wasm"
256+
[component.a.dependencies]
257+
"b:b" = { component = "b" }
258+
259+
[component.b]
260+
source = "b.wasm"
261+
})
262+
.unwrap();
263+
264+
normalize_manifest(&mut manifest).unwrap();
265+
266+
let dep = manifest
267+
.components
268+
.get("a")
269+
.unwrap()
270+
.dependencies
271+
.inner
272+
.get(&package_name("b:b"))
273+
.unwrap();
274+
275+
let ComponentDependency::Local { path, export } = dep else {
276+
panic!("should have normalised to local dep");
277+
};
278+
279+
assert_eq!(&PathBuf::from("b.wasm"), path);
280+
assert_eq!(&None, export);
281+
}
282+
283+
#[test]
284+
fn can_resolve_dependency_on_http_source() {
285+
let mut manifest = AppManifest::deserialize(toml! {
286+
spin_manifest_version = 2
287+
288+
[application]
289+
name = "dummy"
290+
291+
[[trigger.dummy]]
292+
component = "a"
293+
294+
[component.a]
295+
source = "a.wasm"
296+
[component.a.dependencies]
297+
"b:b" = { component = "b", export = "c:d/e" }
298+
299+
[component.b]
300+
source = { url = "http://example.com/b.wasm", digest = "12345" }
301+
})
302+
.unwrap();
303+
304+
normalize_manifest(&mut manifest).unwrap();
305+
306+
let dep = manifest
307+
.components
308+
.get("a")
309+
.unwrap()
310+
.dependencies
311+
.inner
312+
.get(&package_name("b:b"))
313+
.unwrap();
314+
315+
let ComponentDependency::HTTP {
316+
url,
317+
digest,
318+
export,
319+
} = dep
320+
else {
321+
panic!("should have normalised to HTTP dep");
322+
};
323+
324+
assert_eq!("http://example.com/b.wasm", url);
325+
assert_eq!("12345", digest);
326+
assert_eq!("c:d/e", export.as_ref().unwrap());
327+
}
328+
329+
#[test]
330+
fn can_resolve_dependency_on_package() {
331+
let mut manifest = AppManifest::deserialize(toml! {
332+
spin_manifest_version = 2
333+
334+
[application]
335+
name = "dummy"
336+
337+
[[trigger.dummy]]
338+
component = "a"
339+
340+
[component.a]
341+
source = "a.wasm"
342+
[component.a.dependencies]
343+
"b:b" = { component = "b" }
344+
345+
[component.b]
346+
source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" }
347+
})
348+
.unwrap();
349+
350+
normalize_manifest(&mut manifest).unwrap();
351+
352+
let dep = manifest
353+
.components
354+
.get("a")
355+
.unwrap()
356+
.dependencies
357+
.inner
358+
.get(&package_name("b:b"))
359+
.unwrap();
360+
361+
let ComponentDependency::Package {
362+
version,
363+
registry,
364+
package,
365+
export,
366+
} = dep
367+
else {
368+
panic!("should have normalised to HTTP dep");
369+
};
370+
371+
assert_eq!("1.2.3", version);
372+
assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap());
373+
assert_eq!("bb:bb", package.as_ref().unwrap());
374+
assert_eq!(&None, export);
375+
}
376+
}

crates/manifest/src/schema/v2.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,13 @@ impl TryFrom<toml::Value> for ComponentSpec {
187187
///
188188
/// Example: `"my:dependency" = { path = "path/to/component.wasm", export = "my-export" }`
189189
///
190+
/// - A component in the application. The referenced component binary is composed: additional
191+
/// configuration such as files, networking, storage, etc. are ignored. This is intended
192+
/// primarily as a convenience for including dependencies in the manifest so that they
193+
/// can be built using `spin build`.
194+
///
195+
/// Example: `"my:dependency" = { component = "my-dependency", export = "my-export" }`
196+
///
190197
/// - A package from an HTTP URL.
191198
///
192199
/// Example: `"my:import" = { url = "https://example.com/component.wasm", sha256 = "sha256:..." }`
@@ -268,6 +275,22 @@ pub enum ComponentDependency {
268275
/// Learn more: https://spinframework.dev/writing-apps#dependencies-from-a-url
269276
export: Option<String>,
270277
},
278+
/// `... = { component = "my-dependency" }`
279+
#[schemars(description = "")] // schema docs are on the parent
280+
AppComponent {
281+
/// The ID of the component which implements the dependency.
282+
///
283+
/// Example: `"my:dep/import" = { component = "my-dependency" }`
284+
///
285+
/// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
286+
component: KebabId,
287+
/// The name of the export in the package. If omitted, this defaults to the name of the import.
288+
///
289+
/// Example: `"my:dep/import" = { export = "your:impl/export", component = "my-dependency" }`
290+
///
291+
/// Learn more: https://spinframework.dev/writing-apps#using-component-dependencies
292+
export: Option<String>,
293+
},
271294
}
272295

273296
/// A Spin component.

crates/manifest/tests/ui.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,6 @@ fn run_v1_to_v2_test(input: &Path) -> Result<String, Failed> {
4545

4646
fn run_normalization_test(input: impl AsRef<Path>) -> Result<String, Failed> {
4747
let mut manifest = spin_manifest::manifest_from_file(input)?;
48-
normalize_manifest(&mut manifest);
48+
normalize_manifest(&mut manifest)?;
4949
Ok(toml::to_string(&manifest).expect("serialization should work"))
5050
}

crates/serde/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ schemars = { version = "0.8.21", features = ["indexmap2", "semver"] }
1111
semver = { workspace = true, features = ["serde"] }
1212
serde = { workspace = true }
1313
wasm-pkg-common = { workspace = true }
14+
15+
[dev-dependencies]
16+
indexmap = { workspace = true }

0 commit comments

Comments
 (0)