Skip to content

Commit

Permalink
Merge pull request #2826 from kate-goldenring/component-filter-flag
Browse files Browse the repository at this point in the history
Add experimental `spin up --component` flag to run a subset of app components
  • Loading branch information
kate-goldenring authored Sep 23, 2024
2 parents 6df7262 + 788dec1 commit 11e0d32
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 27 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dialoguer = "0.11"
dirs = { workspace = true }
futures = { workspace = true }
glob = { workspace = true }
http = { workspace = true }
indicatif = "0.17"
is-terminal = "0.4"
itertools = { workspace = true }
Expand Down Expand Up @@ -59,6 +60,7 @@ spin-build = { path = "crates/build" }
spin-common = { path = "crates/common" }
spin-doctor = { path = "crates/doctor" }
spin-expressions = { path = "crates/expressions" }
spin-factor-outbound-networking = { path = "crates/factor-outbound-networking" }
spin-http = { path = "crates/http" }
spin-loader = { path = "crates/loader" }
spin-locked-app = { path = "crates/locked-app" }
Expand Down
3 changes: 1 addition & 2 deletions crates/factor-outbound-networking/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
mod config;
pub mod runtime_config;

use std::{collections::HashMap, sync::Arc};

use futures_util::{
future::{BoxFuture, Shared},
FutureExt,
Expand All @@ -14,6 +12,7 @@ use spin_factors::{
anyhow::{self, Context},
ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext, RuntimeFactors,
};
use std::{collections::HashMap, sync::Arc};

pub use config::{
allowed_outbound_hosts, is_service_chaining_host, parse_service_chaining_target,
Expand Down
276 changes: 261 additions & 15 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
mod app_source;

use std::{
collections::HashMap,
collections::{HashMap, HashSet},
ffi::OsString,
fmt::Debug,
path::{Path, PathBuf},
process::Stdio,
};

use anyhow::{anyhow, bail, Context, Result};
use anyhow::{anyhow, bail, ensure, Context, Result};
use clap::{CommandFactory, Parser};
use reqwest::Url;
use spin_app::locked::LockedApp;
use spin_common::ui::quoted_path;
use spin_factor_outbound_networking::{allowed_outbound_hosts, parse_service_chaining_target};
use spin_loader::FilesMountStrategy;
use spin_oci::OciLoader;
use spin_trigger::cli::{LaunchMetadata, SPIN_LOCAL_APP_DIR, SPIN_LOCKED_URL, SPIN_WORKING_DIR};
Expand Down Expand Up @@ -113,6 +114,10 @@ pub struct UpCommand {
#[clap(long, takes_value = false, env = ALWAYS_BUILD_ENV)]
pub build: bool,

/// [Experimental] Component ID to run. This can be specified multiple times. The default is all components.
#[clap(hide = true, short = 'c', long = "component-id")]
pub components: Vec<String>,

/// All other args, to be passed through to the trigger
#[clap(hide = true)]
pub trigger_args: Vec<OsString>,
Expand Down Expand Up @@ -164,13 +169,12 @@ impl UpCommand {
.context("Could not canonicalize working directory")?;

let resolved_app_source = self.resolve_app_source(&app_source, &working_dir).await?;

let trigger_cmds = trigger_command_for_resolved_app_source(&resolved_app_source)
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;

let is_multi = trigger_cmds.len() > 1;

if self.help {
let trigger_cmds =
trigger_commands_for_trigger_types(resolved_app_source.trigger_types())
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;

let is_multi = trigger_cmds.len() > 1;
if is_multi {
// For now, only common flags are allowed on multi-trigger apps.
let mut child = self
Expand All @@ -189,10 +193,25 @@ impl UpCommand {
if self.build {
app_source.build().await?;
}

let mut locked_app = self
.load_resolved_app_source(resolved_app_source, &working_dir)
.await?;
.await
.context("Failed to load application")?;
if !self.components.is_empty() {
retain_components(&mut locked_app, &self.components)?;
}

let trigger_types: HashSet<&str> = locked_app
.triggers
.iter()
.map(|t| t.trigger_type.as_ref())
.collect();

ensure!(!trigger_types.is_empty(), "No triggers in app");

let trigger_cmds = trigger_commands_for_trigger_types(trigger_types.into_iter().collect())
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;
let is_multi = trigger_cmds.len() > 1;

self.update_locked_app(&mut locked_app);
let locked_url = self.write_locked_app(&locked_app, &working_dir).await?;
Expand Down Expand Up @@ -630,11 +649,8 @@ fn trigger_command(trigger_type: &str) -> Vec<String> {
vec!["trigger".to_owned(), trigger_type.to_owned()]
}

fn trigger_command_for_resolved_app_source(
resolved: &ResolvedAppSource,
) -> Result<Vec<Vec<String>>> {
let trigger_type = resolved.trigger_types()?;
trigger_type
fn trigger_commands_for_trigger_types(trigger_types: Vec<&str>) -> Result<Vec<Vec<String>>> {
trigger_types
.iter()
.map(|&t| match t {
"http" | "redis" => Ok(trigger_command(t)),
Expand All @@ -646,6 +662,86 @@ fn trigger_command_for_resolved_app_source(
.collect()
}

/// 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
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 crate::commands::up::app_source::AppSource;
Expand All @@ -658,6 +754,156 @@ mod test {
format!("{repo_base}/{path}")
}

#[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()
);
}

// Duplicate from crates/factors-test/src/lib.rs
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, FilesMountStrategy::Direct, None).await
}

#[test]
fn can_infer_files() {
let file = repo_path("examples/http-rust/spin.toml");
Expand Down
Loading

0 comments on commit 11e0d32

Please sign in to comment.