Skip to content

Commit d814705

Browse files
authored
Merge pull request #2892 from kate-goldenring/component-filtering-move
fix: move component filtering methods to crates
2 parents 18c06af + 785cbd2 commit d814705

File tree

8 files changed

+340
-370
lines changed

8 files changed

+340
-370
lines changed

Cargo.lock

Lines changed: 4 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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+
spin-factors-test = { path = "../factors-test" }
16+
tokio = { workspace = true }

crates/app/src/lib.rs

Lines changed: 95 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,10 @@ 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+
/// Validation function type for ensuring that applications meet requirements
33+
/// even with components filtered out.
34+
pub type ValidatorFn = dyn Fn(&App, &[&str]) -> anyhow::Result<()>;
35+
3036
/// An `App` holds loaded configuration for a Spin application.
3137
#[derive(Debug, Clone)]
3238
pub struct App {
@@ -160,6 +166,49 @@ impl App {
160166
pub fn ensure_needs_only(&self, supported: &[&str]) -> std::result::Result<(), String> {
161167
self.locked.ensure_needs_only(supported)
162168
}
169+
170+
/// Scrubs the locked app to only contain the given list of components
171+
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
172+
fn retain_components(
173+
mut self,
174+
retained_components: &[&str],
175+
validators: &[&ValidatorFn],
176+
) -> Result<LockedApp> {
177+
self.validate_retained_components_exist(retained_components)?;
178+
for validator in validators {
179+
validator(&self, retained_components).map_err(Error::ValidationError)?;
180+
}
181+
let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = self
182+
.triggers()
183+
.filter_map(|t| match t.component() {
184+
Ok(comp) if retained_components.contains(&comp.id()) => {
185+
Some((comp.id().to_owned(), t.id().to_owned()))
186+
}
187+
_ => None,
188+
})
189+
.collect();
190+
self.locked
191+
.components
192+
.retain(|c| component_ids.contains(&c.id));
193+
self.locked.triggers.retain(|t| trigger_ids.contains(&t.id));
194+
Ok(self.locked)
195+
}
196+
197+
/// Validates that all components specified to be retained actually exist in the app
198+
fn validate_retained_components_exist(&self, retained_components: &[&str]) -> Result<()> {
199+
let app_components = self
200+
.components()
201+
.map(|c| c.id().to_string())
202+
.collect::<HashSet<_>>();
203+
for c in retained_components {
204+
if !app_components.contains(*c) {
205+
return Err(Error::ValidationError(anyhow::anyhow!(
206+
"Specified component \"{c}\" not found in application"
207+
)));
208+
}
209+
}
210+
Ok(())
211+
}
163212
}
164213

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

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

Lines changed: 127 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: &[&str],
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()) {
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.as_ref()) {
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,90 @@ 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"]) 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(&app, &["third", "another"]) else {
902+
panic!("Expected wildcard service chaining error");
903+
};
904+
assert_eq!(
905+
e.to_string(),
906+
"Selected component 'third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
907+
);
908+
assert!(validate_service_chaining_for_components(&app, &["another"]).is_ok());
909+
}
910+
911+
#[tokio::test]
912+
async fn validate_service_chaining_for_components_with_templated_host_passes() {
913+
let manifest = toml::toml! {
914+
spin_manifest_version = 2
915+
916+
[application]
917+
name = "test-app"
918+
919+
[variables]
920+
host = { default = "test" }
921+
922+
[[trigger.test-trigger]]
923+
component = "empty"
924+
925+
[component.empty]
926+
source = "does-not-exist.wasm"
927+
928+
[[trigger.another-trigger]]
929+
component = "another"
930+
931+
[component.another]
932+
source = "does-not-exist.wasm"
933+
934+
[[trigger.third-trigger]]
935+
component = "third"
936+
937+
[component.third]
938+
source = "does-not-exist.wasm"
939+
allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
940+
};
941+
let locked_app = spin_factors_test::build_locked_app(&manifest)
942+
.await
943+
.expect("could not build locked app");
944+
let app = App::new("unused", locked_app);
945+
assert!(validate_service_chaining_for_components(&app, &["empty", "third"]).is_ok());
946+
}
821947
}

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)