Skip to content

Commit

Permalink
feat: add support for running a subset of app components
Browse files Browse the repository at this point in the history
Signed-off-by: Kate Goldenring <[email protected]>
  • Loading branch information
kate-goldenring committed Oct 10, 2024
1 parent 10e5292 commit b9abe16
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 2 deletions.
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()
);
}
}

0 comments on commit b9abe16

Please sign in to comment.