diff --git a/Cargo.lock b/Cargo.lock index df1d3582..aa2a54cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1540,6 +1540,7 @@ dependencies = [ "containerd-shim-wasm", "ctrlc", "futures", + "http 1.1.0", "log", "oci-spec", "openssl", @@ -1550,6 +1551,7 @@ dependencies = [ "spin-componentize", "spin-core", "spin-expressions", + "spin-factor-outbound-networking", "spin-factors", "spin-factors-executor", "spin-loader", @@ -1561,7 +1563,9 @@ dependencies = [ "spin-trigger-http", "spin-trigger-redis", "temp-env", + "tempfile", "tokio", + "toml 0.8.19", "trigger-command", "trigger-mqtt", "trigger-sqs", diff --git a/containerd-shim-spin/Cargo.toml b/containerd-shim-spin/Cargo.toml index 8fc5e007..741e65d1 100644 --- a/containerd-shim-spin/Cargo.toml +++ b/containerd-shim-spin/Cargo.toml @@ -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" } @@ -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" @@ -49,3 +51,5 @@ ctrlc = { version = "3.4", features = ["termination"] } [dev-dependencies] wat = "1" temp-env = "0.3.6" +toml = "0.8" +tempfile = "3" diff --git a/containerd-shim-spin/src/constants.rs b/containerd-shim-spin/src/constants.rs index 8a025d7c..7be5fc4a 100644 --- a/containerd-shim-spin/src/constants.rs +++ b/containerd-shim-spin/src/constants.rs @@ -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"; diff --git a/containerd-shim-spin/src/engine.rs b/containerd-shim-spin/src/engine.rs index f9168bd6..a09fd9f6 100644 --- a/containerd-shim-spin/src/engine.rs +++ b/containerd-shim-spin/src/engine.rs @@ -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::>()); + 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:?}"))?; diff --git a/containerd-shim-spin/src/main.rs b/containerd-shim-spin/src/main.rs index 75b5915c..51ca64a4 100644 --- a/containerd-shim-spin/src/main.rs +++ b/containerd-shim-spin/src/main.rs @@ -6,6 +6,7 @@ use containerd_shim_wasm::{ mod constants; mod engine; +mod retain; mod source; mod trigger; mod utils; diff --git a/containerd-shim-spin/src/retain.rs b/containerd-shim-spin/src/retain.rs new file mode 100644 index 00000000..ba5cc45b --- /dev/null +++ b/containerd-shim-spin/src/retain.rs @@ -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, HashSet) = 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::() { + 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::>(); + 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, + ) -> anyhow::Result { + 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::>(); + 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() + ); + } +}