Skip to content

Commit a071b6d

Browse files
fix: move component filtering methods to crates
Signed-off-by: Kate Goldenring <[email protected]>
1 parent 2a9bf7c commit a071b6d

File tree

8 files changed

+348
-370
lines changed

8 files changed

+348
-370
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/app/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ authors = { workspace = true }
55
edition = { workspace = true }
66

77
[dependencies]
8+
anyhow = { workspace = true }
89
serde = { workspace = true }
910
serde_json = { workspace = true }
1011
spin-locked-app = { path = "../locked-app" }
12+
13+
[dev-dependencies]
14+
toml = { workspace = true }
15+
tempfile = { workspace = true }
16+
spin-factors-test = { path = "../factors-test" }
17+
tokio = { workspace = true }

crates/app/src/lib.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
77
#![deny(missing_docs)]
88

9+
use std::collections::HashSet;
10+
911
use serde::Deserialize;
1012
use serde_json::Value;
1113
use spin_locked_app::MetadataExt;
@@ -27,6 +29,8 @@ pub const APP_DESCRIPTION_KEY: MetadataKey = MetadataKey::new("description");
2729
/// MetadataKey for extracting the OCI image digest.
2830
pub const OCI_IMAGE_DIGEST_KEY: MetadataKey = MetadataKey::new("oci_image_digest");
2931

32+
type ValidatorFn = dyn Fn(&App, &[String]) -> anyhow::Result<()>;
33+
3034
/// An `App` holds loaded configuration for a Spin application.
3135
#[derive(Debug, Clone)]
3236
pub struct App {
@@ -160,6 +164,49 @@ impl App {
160164
pub fn ensure_needs_only(&self, supported: &[&str]) -> std::result::Result<(), String> {
161165
self.locked.ensure_needs_only(supported)
162166
}
167+
168+
/// Scrubs the locked app to only contain the given list of components
169+
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
170+
fn retain_components(
171+
mut self,
172+
retained_components: &[String],
173+
validators: &[&ValidatorFn],
174+
) -> Result<LockedApp> {
175+
self.validate_retained_components_exist(retained_components)?;
176+
for validator in validators {
177+
validator(&self, retained_components).map_err(Error::ValidationError)?;
178+
}
179+
let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = self
180+
.triggers()
181+
.filter_map(|t| match t.component() {
182+
Ok(comp) if retained_components.contains(&comp.id().to_string()) => {
183+
Some((comp.id().to_owned(), t.id().to_owned()))
184+
}
185+
_ => None,
186+
})
187+
.collect();
188+
self.locked
189+
.components
190+
.retain(|c| component_ids.contains(&c.id));
191+
self.locked.triggers.retain(|t| trigger_ids.contains(&t.id));
192+
Ok(self.locked)
193+
}
194+
195+
/// Validates that all components specified to be retained actually exist in the app
196+
fn validate_retained_components_exist(&self, retained_components: &[String]) -> Result<()> {
197+
let app_components = self
198+
.components()
199+
.map(|c| c.id().to_string())
200+
.collect::<HashSet<_>>();
201+
for c in retained_components {
202+
if !app_components.contains(c) {
203+
return Err(Error::ValidationError(anyhow::anyhow!(
204+
"Specified component \"{c}\" not found in application"
205+
)));
206+
}
207+
}
208+
Ok(())
209+
}
163210
}
164211

165212
/// An `AppComponent` holds configuration for a Spin application component.
@@ -266,3 +313,54 @@ impl<'a> AppTrigger<'a> {
266313
struct CommonTriggerConfig {
267314
component: Option<String>,
268315
}
316+
317+
/// Scrubs the locked app to only contain the given list of components
318+
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
319+
pub fn retain_components(
320+
locked: LockedApp,
321+
components: &[String],
322+
validators: &[&ValidatorFn],
323+
) -> Result<LockedApp> {
324+
App::new("unused", locked).retain_components(components, validators)
325+
}
326+
327+
#[cfg(test)]
328+
mod test {
329+
use spin_factors_test::build_locked_app;
330+
331+
use super::*;
332+
333+
fn does_nothing_validator(_: &App, _: &[String]) -> anyhow::Result<()> {
334+
Ok(())
335+
}
336+
337+
#[tokio::test]
338+
async fn test_retain_components_filtering_for_only_component_works() {
339+
let manifest = toml::toml! {
340+
spin_manifest_version = 2
341+
342+
[application]
343+
name = "test-app"
344+
345+
[[trigger.test-trigger]]
346+
component = "empty"
347+
348+
[component.empty]
349+
source = "does-not-exist.wasm"
350+
};
351+
let mut locked_app = build_locked_app(&manifest).await.unwrap();
352+
locked_app = retain_components(
353+
locked_app,
354+
&["empty".to_string()],
355+
&[&does_nothing_validator],
356+
)
357+
.unwrap();
358+
let components = locked_app
359+
.components
360+
.iter()
361+
.map(|c| c.id.to_string())
362+
.collect::<HashSet<_>>();
363+
assert!(components.contains("empty"));
364+
assert!(components.len() == 1);
365+
}
366+
}

crates/factor-outbound-networking/src/config.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::ops::Range;
22

33
use anyhow::{bail, ensure, Context};
4-
use spin_factors::AppComponent;
4+
use spin_factors::{App, AppComponent};
55
use spin_locked_app::MetadataKey;
66

77
const ALLOWED_HOSTS_KEY: MetadataKey<Vec<String>> = MetadataKey::new("allowed_outbound_hosts");
@@ -34,6 +34,46 @@ pub fn allowed_outbound_hosts(component: &AppComponent) -> anyhow::Result<Vec<St
3434
Ok(allowed_hosts)
3535
}
3636

37+
/// Validates that all service chaining of an app will be satisfied by the
38+
/// supplied subset of components.
39+
///
40+
/// This does a best effort look up of components that are
41+
/// allowed to be accessed through service chaining and will error early if a
42+
/// component is configured to to chain to another component that is not
43+
/// retained. All wildcard service chaining is disallowed and all templated URLs
44+
/// are ignored.
45+
pub fn validate_service_chaining_for_components(
46+
app: &App,
47+
retained_components: &[String],
48+
) -> anyhow::Result<()> {
49+
app
50+
.triggers().try_for_each(|t| {
51+
let Ok(component) = t.component() else { return Ok(()) };
52+
if retained_components.contains(&component.id().to_string()) {
53+
let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?;
54+
for host in allowed_hosts {
55+
// Templated URLs are not yet resolved at this point, so ignore unresolvable URIs
56+
if let Ok(uri) = host.parse::<http::Uri>() {
57+
if let Some(chaining_target) = parse_service_chaining_target(&uri) {
58+
if !retained_components.contains(&chaining_target) {
59+
if chaining_target == "*" {
60+
return Err(anyhow::anyhow!("Selected component '{}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id()));
61+
}
62+
return Err(anyhow::anyhow!(
63+
"Selected component '{}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
64+
component.id(), chaining_target
65+
));
66+
}
67+
}
68+
}
69+
}
70+
}
71+
anyhow::Ok(())
72+
})?;
73+
74+
Ok(())
75+
}
76+
3777
/// An address is a url-like string that contains a host, a port, and an optional scheme
3878
#[derive(Eq, Debug, Clone)]
3979
pub struct AllowedHostConfig {
@@ -818,4 +858,97 @@ mod test {
818858
AllowedHostsConfig::parse(&["*://127.0.0.1/24:63551"], &dummy_resolver()).unwrap();
819859
assert!(allowed.allows(&OutboundUrl::parse("tcp://127.0.0.1:63551", "tcp").unwrap()));
820860
}
861+
862+
#[tokio::test]
863+
async fn validate_service_chaining_for_components_fails() {
864+
let manifest = toml::toml! {
865+
spin_manifest_version = 2
866+
867+
[application]
868+
name = "test-app"
869+
870+
[[trigger.test-trigger]]
871+
component = "empty"
872+
873+
[component.empty]
874+
source = "does-not-exist.wasm"
875+
allowed_outbound_hosts = ["http://another.spin.internal"]
876+
877+
[[trigger.another-trigger]]
878+
component = "another"
879+
880+
[component.another]
881+
source = "does-not-exist.wasm"
882+
883+
[[trigger.third-trigger]]
884+
component = "third"
885+
886+
[component.third]
887+
source = "does-not-exist.wasm"
888+
allowed_outbound_hosts = ["http://*.spin.internal"]
889+
};
890+
let locked_app = spin_factors_test::build_locked_app(&manifest)
891+
.await
892+
.expect("could not build locked app");
893+
let app = App::new("unused", locked_app);
894+
let Err(e) = validate_service_chaining_for_components(&app, &["empty".to_string()]) else {
895+
panic!("Expected service chaining to non-retained component error");
896+
};
897+
assert_eq!(
898+
e.to_string(),
899+
"Selected component 'empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]"
900+
);
901+
let Err(e) = validate_service_chaining_for_components(
902+
&app,
903+
&["third".to_string(), "another".to_string()],
904+
) else {
905+
panic!("Expected wildcard service chaining error");
906+
};
907+
assert_eq!(
908+
e.to_string(),
909+
"Selected component 'third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
910+
);
911+
assert!(validate_service_chaining_for_components(&app, &["another".to_string()]).is_ok());
912+
}
913+
914+
#[tokio::test]
915+
async fn validate_service_chaining_for_components_with_templated_host_passes() {
916+
let manifest = toml::toml! {
917+
spin_manifest_version = 2
918+
919+
[application]
920+
name = "test-app"
921+
922+
[variables]
923+
host = { default = "test" }
924+
925+
[[trigger.test-trigger]]
926+
component = "empty"
927+
928+
[component.empty]
929+
source = "does-not-exist.wasm"
930+
931+
[[trigger.another-trigger]]
932+
component = "another"
933+
934+
[component.another]
935+
source = "does-not-exist.wasm"
936+
937+
[[trigger.third-trigger]]
938+
component = "third"
939+
940+
[component.third]
941+
source = "does-not-exist.wasm"
942+
allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
943+
};
944+
let locked_app = spin_factors_test::build_locked_app(&manifest)
945+
.await
946+
.expect("could not build locked app");
947+
let app = App::new("unused", locked_app);
948+
assert!(validate_service_chaining_for_components(
949+
&app,
950+
&["empty".to_string(), "third".to_string()]
951+
)
952+
.is_ok());
953+
}
821954
}

crates/factor-outbound-networking/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use std::{collections::HashMap, sync::Arc};
1616

1717
pub use config::{
1818
allowed_outbound_hosts, is_service_chaining_host, parse_service_chaining_target,
19-
AllowedHostConfig, AllowedHostsConfig, HostConfig, OutboundUrl, SERVICE_CHAINING_DOMAIN_SUFFIX,
19+
validate_service_chaining_for_components, AllowedHostConfig, AllowedHostsConfig, HostConfig,
20+
OutboundUrl, SERVICE_CHAINING_DOMAIN_SUFFIX,
2021
};
2122

2223
pub use runtime_config::ComponentTlsConfigs;

crates/factors-test/src/lib.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,14 @@ impl<T: RuntimeFactors> TestEnvironment<T> {
9191
}
9292

9393
pub async fn build_locked_app(&self) -> anyhow::Result<LockedApp> {
94-
let toml_str = toml::to_string(&self.manifest).context("failed serializing manifest")?;
95-
let dir = tempfile::tempdir().context("failed creating tempdir")?;
96-
let path = dir.path().join("spin.toml");
97-
std::fs::write(&path, toml_str).context("failed writing manifest")?;
98-
spin_loader::from_file(&path, FilesMountStrategy::Direct, None).await
94+
build_locked_app(&self.manifest).await
9995
}
10096
}
97+
98+
pub async fn build_locked_app(manifest: &toml::Table) -> anyhow::Result<LockedApp> {
99+
let toml_str = toml::to_string(manifest).context("failed serializing manifest")?;
100+
let dir = tempfile::tempdir().context("failed creating tempdir")?;
101+
let path = dir.path().join("spin.toml");
102+
std::fs::write(&path, toml_str).context("failed writing manifest")?;
103+
spin_loader::from_file(&path, FilesMountStrategy::Direct, None).await
104+
}

0 commit comments

Comments
 (0)