Skip to content

Commit 71c8351

Browse files
authored
Merge pull request #197 from kate-goldenring/component-filtering
Component filtering
2 parents 10e5292 + b9abe16 commit 71c8351

File tree

6 files changed

+261
-2
lines changed

6 files changed

+261
-2
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.

containerd-shim-spin/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Containerd shim for running Spin workloads.
1313
[dependencies]
1414
containerd-shim-wasm = "0.6.0"
1515
containerd-shim = "0.7.1"
16+
http = "1"
1617
log = "0.4"
1718
spin-app = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
1819
spin-core = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
@@ -35,8 +36,9 @@ spin-factors-executor = { git = "https://github.com/fermyon/spin", rev = "485b04
3536
spin-telemetry = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
3637
spin-runtime-factors = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
3738
spin-factors = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
39+
spin-factor-outbound-networking = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
3840
wasmtime = "22.0"
39-
tokio = { version = "1.38", features = ["rt"] }
41+
tokio = { version = "1", features = ["rt"] }
4042
openssl = { version = "*", features = ["vendored"] }
4143
serde = "1.0"
4244
serde_json = "1.0"
@@ -49,3 +51,5 @@ ctrlc = { version = "3.4", features = ["termination"] }
4951
[dev-dependencies]
5052
wat = "1"
5153
temp-env = "0.3.6"
54+
toml = "0.8"
55+
tempfile = "3"

containerd-shim-spin/src/constants.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ pub(crate) const SPIN_MANIFEST_FILE_PATH: &str = "/spin.toml";
1818
pub(crate) const SPIN_APPLICATION_VARIABLE_PREFIX: &str = "SPIN_VARIABLE";
1919
/// Working directory for Spin applications
2020
pub(crate) const SPIN_TRIGGER_WORKING_DIR: &str = "/";
21+
/// Defines the subset of application components that should be executable by the shim
22+
/// If empty or DNE, all components will be supported
23+
pub(crate) const SPIN_COMPONENTS_TO_RETAIN_ENV: &str = "SPIN_COMPONENTS_TO_RETAIN";

containerd-shim-spin/src/engine.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,13 @@ impl SpinEngine {
136136
async fn wasm_exec_async(&self, ctx: &impl RuntimeContext) -> Result<()> {
137137
let cache = initialize_cache().await?;
138138
let app_source = Source::from_ctx(ctx, &cache).await?;
139-
let locked_app = app_source.to_locked_app(&cache).await?;
139+
let mut locked_app = app_source.to_locked_app(&cache).await?;
140+
let components_to_execute = env::var(constants::SPIN_COMPONENTS_TO_RETAIN_ENV)
141+
.ok()
142+
.map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<String>>());
143+
if let Some(components) = components_to_execute {
144+
crate::retain::retain_components(&mut locked_app, &components)?;
145+
}
140146
configure_application_variables_from_environment_variables(&locked_app)?;
141147
let trigger_cmds = get_supported_triggers(&locked_app)
142148
.with_context(|| format!("Couldn't find trigger executor for {app_source:?}"))?;

containerd-shim-spin/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use containerd_shim_wasm::{
66

77
mod constants;
88
mod engine;
9+
mod retain;
910
mod source;
1011
mod trigger;
1112
mod utils;

containerd-shim-spin/src/retain.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
//! This module contains the logic for modifying a locked app to only contain a subset of its components
2+
3+
use std::collections::HashSet;
4+
5+
use anyhow::{bail, Context, Result};
6+
use spin_app::locked::LockedApp;
7+
use spin_factor_outbound_networking::{allowed_outbound_hosts, parse_service_chaining_target};
8+
9+
/// Scrubs the locked app to only contain the given list of components
10+
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
11+
pub fn retain_components(locked_app: &mut LockedApp, retained_components: &[String]) -> Result<()> {
12+
// Create a temporary app to access parsed component and trigger information
13+
let tmp_app = spin_app::App::new("tmp", locked_app.clone());
14+
validate_retained_components_exist(&tmp_app, retained_components)?;
15+
validate_retained_components_service_chaining(&tmp_app, retained_components)?;
16+
let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = tmp_app
17+
.triggers()
18+
.filter_map(|t| match t.component() {
19+
Ok(comp) if retained_components.contains(&comp.id().to_string()) => {
20+
Some((comp.id().to_owned(), t.id().to_owned()))
21+
}
22+
_ => None,
23+
})
24+
.collect();
25+
locked_app
26+
.components
27+
.retain(|c| component_ids.contains(&c.id));
28+
locked_app.triggers.retain(|t| trigger_ids.contains(&t.id));
29+
Ok(())
30+
}
31+
32+
// Validates that all service chaining of an app will be satisfied by the
33+
// retained components.
34+
//
35+
// This does a best effort look up of components that are
36+
// allowed to be accessed through service chaining and will error early if a
37+
// component is configured to to chain to another component that is not
38+
// retained. All wildcard service chaining is disallowed and all templated URLs
39+
// are ignored.
40+
fn validate_retained_components_service_chaining(
41+
app: &spin_app::App,
42+
retained_components: &[String],
43+
) -> Result<()> {
44+
app
45+
.triggers().try_for_each(|t| {
46+
let Ok(component) = t.component() else { return Ok(()) };
47+
if retained_components.contains(&component.id().to_string()) {
48+
let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?;
49+
for host in allowed_hosts {
50+
// Templated URLs are not yet resolved at this point, so ignore unresolvable URIs
51+
if let Ok(uri) = host.parse::<http::Uri>() {
52+
if let Some(chaining_target) = parse_service_chaining_target(&uri) {
53+
if !retained_components.contains(&chaining_target) {
54+
if chaining_target == "*" {
55+
bail!("Component selected with '--component {}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id());
56+
}
57+
bail!(
58+
"Component selected with '--component {}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
59+
component.id(), chaining_target
60+
);
61+
}
62+
}
63+
}
64+
}
65+
}
66+
anyhow::Ok(())
67+
})?;
68+
69+
Ok(())
70+
}
71+
72+
// Validates that all components specified to be retained actually exist in the app
73+
fn validate_retained_components_exist(
74+
app: &spin_app::App,
75+
retained_components: &[String],
76+
) -> Result<()> {
77+
let app_components = app
78+
.components()
79+
.map(|c| c.id().to_string())
80+
.collect::<HashSet<_>>();
81+
for c in retained_components {
82+
if !app_components.contains(c) {
83+
bail!("Specified component \"{c}\" not found in application");
84+
}
85+
}
86+
Ok(())
87+
}
88+
89+
#[cfg(test)]
90+
mod test {
91+
use super::*;
92+
93+
pub async fn build_locked_app(
94+
manifest: &toml::map::Map<String, toml::Value>,
95+
) -> anyhow::Result<LockedApp> {
96+
let toml_str = toml::to_string(manifest).context("failed serializing manifest")?;
97+
let dir = tempfile::tempdir().context("failed creating tempdir")?;
98+
let path = dir.path().join("spin.toml");
99+
std::fs::write(&path, toml_str).context("failed writing manifest")?;
100+
spin_loader::from_file(&path, spin_loader::FilesMountStrategy::Direct, None).await
101+
}
102+
103+
#[tokio::test]
104+
async fn test_retain_components_filtering_for_only_component_works() {
105+
let manifest = toml::toml! {
106+
spin_manifest_version = 2
107+
108+
[application]
109+
name = "test-app"
110+
111+
[[trigger.test-trigger]]
112+
component = "empty"
113+
114+
[component.empty]
115+
source = "does-not-exist.wasm"
116+
};
117+
let mut locked_app = build_locked_app(&manifest).await.unwrap();
118+
retain_components(&mut locked_app, &["empty".to_string()]).unwrap();
119+
let components = locked_app
120+
.components
121+
.iter()
122+
.map(|c| c.id.to_string())
123+
.collect::<HashSet<_>>();
124+
assert!(components.contains("empty"));
125+
assert!(components.len() == 1);
126+
}
127+
128+
#[tokio::test]
129+
async fn test_retain_components_filtering_for_non_existent_component_fails() {
130+
let manifest = toml::toml! {
131+
spin_manifest_version = 2
132+
133+
[application]
134+
name = "test-app"
135+
136+
[[trigger.test-trigger]]
137+
component = "empty"
138+
139+
[component.empty]
140+
source = "does-not-exist.wasm"
141+
};
142+
let mut locked_app = build_locked_app(&manifest).await.unwrap();
143+
let Err(e) = retain_components(&mut locked_app, &["dne".to_string()]) else {
144+
panic!("Expected component not found error");
145+
};
146+
assert_eq!(
147+
e.to_string(),
148+
"Specified component \"dne\" not found in application"
149+
);
150+
assert!(retain_components(&mut locked_app, &["dne".to_string()]).is_err());
151+
}
152+
153+
#[tokio::test]
154+
async fn test_retain_components_app_with_service_chaining_fails() {
155+
let manifest = toml::toml! {
156+
spin_manifest_version = 2
157+
158+
[application]
159+
name = "test-app"
160+
161+
[[trigger.test-trigger]]
162+
component = "empty"
163+
164+
[component.empty]
165+
source = "does-not-exist.wasm"
166+
allowed_outbound_hosts = ["http://another.spin.internal"]
167+
168+
[[trigger.another-trigger]]
169+
component = "another"
170+
171+
[component.another]
172+
source = "does-not-exist.wasm"
173+
174+
[[trigger.third-trigger]]
175+
component = "third"
176+
177+
[component.third]
178+
source = "does-not-exist.wasm"
179+
allowed_outbound_hosts = ["http://*.spin.internal"]
180+
};
181+
let mut locked_app = build_locked_app(&manifest)
182+
.await
183+
.expect("could not build locked app");
184+
let Err(e) = retain_components(&mut locked_app, &["empty".to_string()]) else {
185+
panic!("Expected service chaining to non-retained component error");
186+
};
187+
assert_eq!(
188+
e.to_string(),
189+
"Component selected with '--component empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]"
190+
);
191+
let Err(e) = retain_components(
192+
&mut locked_app,
193+
&["third".to_string(), "another".to_string()],
194+
) else {
195+
panic!("Expected wildcard service chaining error");
196+
};
197+
assert_eq!(
198+
e.to_string(),
199+
"Component selected with '--component third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
200+
);
201+
assert!(retain_components(&mut locked_app, &["another".to_string()]).is_ok());
202+
}
203+
204+
#[tokio::test]
205+
async fn test_retain_components_app_with_templated_host_passes() {
206+
let manifest = toml::toml! {
207+
spin_manifest_version = 2
208+
209+
[application]
210+
name = "test-app"
211+
212+
[variables]
213+
host = { default = "test" }
214+
215+
[[trigger.test-trigger]]
216+
component = "empty"
217+
218+
[component.empty]
219+
source = "does-not-exist.wasm"
220+
221+
[[trigger.another-trigger]]
222+
component = "another"
223+
224+
[component.another]
225+
source = "does-not-exist.wasm"
226+
227+
[[trigger.third-trigger]]
228+
component = "third"
229+
230+
[component.third]
231+
source = "does-not-exist.wasm"
232+
allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
233+
};
234+
let mut locked_app = build_locked_app(&manifest)
235+
.await
236+
.expect("could not build locked app");
237+
assert!(
238+
retain_components(&mut locked_app, &["empty".to_string(), "third".to_string()]).is_ok()
239+
);
240+
}
241+
}

0 commit comments

Comments
 (0)