Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion containerd-shim-spin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Containerd shim for running Spin workloads.
[dependencies]
containerd-shim-wasm = "0.6.0"
containerd-shim = "0.7.1"
http = "1"
log = "0.4"
spin-app = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
spin-core = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
Expand All @@ -35,8 +36,9 @@ spin-factors-executor = { git = "https://github.com/fermyon/spin", rev = "485b04
spin-telemetry = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
spin-runtime-factors = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
spin-factors = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
spin-factor-outbound-networking = { git = "https://github.com/fermyon/spin", rev = "485b04090644ecfda4d0034891a5feca9a90332c" }
wasmtime = "22.0"
tokio = { version = "1.38", features = ["rt"] }
tokio = { version = "1", features = ["rt"] }
openssl = { version = "*", features = ["vendored"] }
serde = "1.0"
serde_json = "1.0"
Expand All @@ -49,3 +51,5 @@ ctrlc = { version = "3.4", features = ["termination"] }
[dev-dependencies]
wat = "1"
temp-env = "0.3.6"
toml = "0.8"
tempfile = "3"
3 changes: 3 additions & 0 deletions containerd-shim-spin/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ pub(crate) const SPIN_MANIFEST_FILE_PATH: &str = "/spin.toml";
pub(crate) const SPIN_APPLICATION_VARIABLE_PREFIX: &str = "SPIN_VARIABLE";
/// Working directory for Spin applications
pub(crate) const SPIN_TRIGGER_WORKING_DIR: &str = "/";
/// Defines the subset of application components that should be executable by the shim
/// If empty or DNE, all components will be supported
pub(crate) const SPIN_COMPONENTS_TO_RETAIN_ENV: &str = "SPIN_COMPONENTS_TO_RETAIN";
8 changes: 7 additions & 1 deletion containerd-shim-spin/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,13 @@ impl SpinEngine {
async fn wasm_exec_async(&self, ctx: &impl RuntimeContext) -> Result<()> {
let cache = initialize_cache().await?;
let app_source = Source::from_ctx(ctx, &cache).await?;
let locked_app = app_source.to_locked_app(&cache).await?;
let mut locked_app = app_source.to_locked_app(&cache).await?;
let components_to_execute = env::var(constants::SPIN_COMPONENTS_TO_RETAIN_ENV)
.ok()
.map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<String>>());
if let Some(components) = components_to_execute {
crate::retain::retain_components(&mut locked_app, &components)?;
}
configure_application_variables_from_environment_variables(&locked_app)?;
let trigger_cmds = get_supported_triggers(&locked_app)
.with_context(|| format!("Couldn't find trigger executor for {app_source:?}"))?;
Expand Down
1 change: 1 addition & 0 deletions containerd-shim-spin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use containerd_shim_wasm::{

mod constants;
mod engine;
mod retain;
mod source;
mod trigger;
mod utils;
Expand Down
241 changes: 241 additions & 0 deletions containerd-shim-spin/src/retain.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//! This module contains the logic for modifying a locked app to only contain a subset of its components

use std::collections::HashSet;

use anyhow::{bail, Context, Result};
use spin_app::locked::LockedApp;
use spin_factor_outbound_networking::{allowed_outbound_hosts, parse_service_chaining_target};

/// Scrubs the locked app to only contain the given list of components
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
pub fn retain_components(locked_app: &mut LockedApp, retained_components: &[String]) -> Result<()> {
// Create a temporary app to access parsed component and trigger information
let tmp_app = spin_app::App::new("tmp", locked_app.clone());
validate_retained_components_exist(&tmp_app, retained_components)?;
validate_retained_components_service_chaining(&tmp_app, retained_components)?;
let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = tmp_app
.triggers()
.filter_map(|t| match t.component() {
Ok(comp) if retained_components.contains(&comp.id().to_string()) => {
Some((comp.id().to_owned(), t.id().to_owned()))
}
_ => None,
})
.collect();
locked_app
.components
.retain(|c| component_ids.contains(&c.id));
locked_app.triggers.retain(|t| trigger_ids.contains(&t.id));
Ok(())
}

// Validates that all service chaining of an app will be satisfied by the
// retained components.
//
// This does a best effort look up of components that are
// allowed to be accessed through service chaining and will error early if a
// component is configured to to chain to another component that is not
// retained. All wildcard service chaining is disallowed and all templated URLs
// are ignored.
fn validate_retained_components_service_chaining(
app: &spin_app::App,
retained_components: &[String],
) -> Result<()> {
app
.triggers().try_for_each(|t| {
let Ok(component) = t.component() else { return Ok(()) };
if retained_components.contains(&component.id().to_string()) {
let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?;
for host in allowed_hosts {
// Templated URLs are not yet resolved at this point, so ignore unresolvable URIs
if let Ok(uri) = host.parse::<http::Uri>() {
if let Some(chaining_target) = parse_service_chaining_target(&uri) {
if !retained_components.contains(&chaining_target) {
if chaining_target == "*" {
bail!("Component selected with '--component {}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id());
}
bail!(
"Component selected with '--component {}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
component.id(), chaining_target
);
}
}
}
}
}
anyhow::Ok(())
})?;

Ok(())
}

// Validates that all components specified to be retained actually exist in the app
fn validate_retained_components_exist(
app: &spin_app::App,
retained_components: &[String],
) -> Result<()> {
let app_components = app
.components()
.map(|c| c.id().to_string())
.collect::<HashSet<_>>();
for c in retained_components {
if !app_components.contains(c) {
bail!("Specified component \"{c}\" not found in application");
}
}
Ok(())
}

#[cfg(test)]
mod test {
use super::*;

pub async fn build_locked_app(
manifest: &toml::map::Map<String, toml::Value>,
) -> anyhow::Result<LockedApp> {
let toml_str = toml::to_string(manifest).context("failed serializing manifest")?;
let dir = tempfile::tempdir().context("failed creating tempdir")?;
let path = dir.path().join("spin.toml");
std::fs::write(&path, toml_str).context("failed writing manifest")?;
spin_loader::from_file(&path, spin_loader::FilesMountStrategy::Direct, None).await
}

#[tokio::test]
async fn test_retain_components_filtering_for_only_component_works() {
let manifest = toml::toml! {
spin_manifest_version = 2

[application]
name = "test-app"

[[trigger.test-trigger]]
component = "empty"

[component.empty]
source = "does-not-exist.wasm"
};
let mut locked_app = build_locked_app(&manifest).await.unwrap();
retain_components(&mut locked_app, &["empty".to_string()]).unwrap();
let components = locked_app
.components
.iter()
.map(|c| c.id.to_string())
.collect::<HashSet<_>>();
assert!(components.contains("empty"));
assert!(components.len() == 1);
}

#[tokio::test]
async fn test_retain_components_filtering_for_non_existent_component_fails() {
let manifest = toml::toml! {
spin_manifest_version = 2

[application]
name = "test-app"

[[trigger.test-trigger]]
component = "empty"

[component.empty]
source = "does-not-exist.wasm"
};
let mut locked_app = build_locked_app(&manifest).await.unwrap();
let Err(e) = retain_components(&mut locked_app, &["dne".to_string()]) else {
panic!("Expected component not found error");
};
assert_eq!(
e.to_string(),
"Specified component \"dne\" not found in application"
);
assert!(retain_components(&mut locked_app, &["dne".to_string()]).is_err());
}

#[tokio::test]
async fn test_retain_components_app_with_service_chaining_fails() {
let manifest = toml::toml! {
spin_manifest_version = 2

[application]
name = "test-app"

[[trigger.test-trigger]]
component = "empty"

[component.empty]
source = "does-not-exist.wasm"
allowed_outbound_hosts = ["http://another.spin.internal"]

[[trigger.another-trigger]]
component = "another"

[component.another]
source = "does-not-exist.wasm"

[[trigger.third-trigger]]
component = "third"

[component.third]
source = "does-not-exist.wasm"
allowed_outbound_hosts = ["http://*.spin.internal"]
};
let mut locked_app = build_locked_app(&manifest)
.await
.expect("could not build locked app");
let Err(e) = retain_components(&mut locked_app, &["empty".to_string()]) else {
panic!("Expected service chaining to non-retained component error");
};
assert_eq!(
e.to_string(),
"Component selected with '--component empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]"
);
let Err(e) = retain_components(
&mut locked_app,
&["third".to_string(), "another".to_string()],
) else {
panic!("Expected wildcard service chaining error");
};
assert_eq!(
e.to_string(),
"Component selected with '--component third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
);
assert!(retain_components(&mut locked_app, &["another".to_string()]).is_ok());
}

#[tokio::test]
async fn test_retain_components_app_with_templated_host_passes() {
let manifest = toml::toml! {
spin_manifest_version = 2

[application]
name = "test-app"

[variables]
host = { default = "test" }

[[trigger.test-trigger]]
component = "empty"

[component.empty]
source = "does-not-exist.wasm"

[[trigger.another-trigger]]
component = "another"

[component.another]
source = "does-not-exist.wasm"

[[trigger.third-trigger]]
component = "third"

[component.third]
source = "does-not-exist.wasm"
allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
};
let mut locked_app = build_locked_app(&manifest)
.await
.expect("could not build locked app");
assert!(
retain_components(&mut locked_app, &["empty".to_string(), "third".to_string()]).is_ok()
);
}
}