Skip to content

Commit

Permalink
Add spin up component flag to run a subset of app components
Browse files Browse the repository at this point in the history
Signed-off-by: Kate Goldenring <kate.goldenring@fermyon.com>
  • Loading branch information
kate-goldenring committed Sep 12, 2024
1 parent 8de5926 commit d8834c8
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 23 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.10"
dirs = "4.0"
futures = "0.3"
glob = "0.3.1"
http = "1.1.0"
indicatif = "0.17.3"
is-terminal = "0.4"
itertools = "0.11.0"
Expand All @@ -48,6 +49,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
18 changes: 10 additions & 8 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 config::ALLOWED_HOSTS_KEY;
use futures_util::{
future::{BoxFuture, Shared},
Expand All @@ -13,8 +11,10 @@ use spin_factor_variables::VariablesFactor;
use spin_factor_wasi::{SocketAddrUse, WasiFactor};
use spin_factors::{
anyhow::{self, Context},
ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext, RuntimeFactors,
AppComponent, ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext,
RuntimeFactors,
};
use std::{collections::HashMap, sync::Arc};

pub use config::{
is_service_chaining_host, parse_service_chaining_target, AllowedHostConfig, AllowedHostsConfig,
Expand Down Expand Up @@ -42,6 +42,12 @@ impl OutboundNetworkingFactor {
}
}

pub fn allowed_hosts(component: &AppComponent<'_>) -> anyhow::Result<Vec<String>> {
Ok(component
.get_metadata(ALLOWED_HOSTS_KEY)?
.unwrap_or_default())
}

impl Factor for OutboundNetworkingFactor {
type RuntimeConfig = RuntimeConfig;
type AppState = AppState;
Expand All @@ -58,11 +64,7 @@ impl Factor for OutboundNetworkingFactor {
.map(|component| {
Ok((
component.id().to_string(),
component
.get_metadata(ALLOWED_HOSTS_KEY)?
.unwrap_or_default()
.into_boxed_slice()
.into(),
allowed_hosts(&component)?.into_boxed_slice().into(),
))
})
.collect::<anyhow::Result<_>>()?;
Expand Down
234 changes: 219 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_app::{locked::LockedApp, AppComponent};
use spin_common::ui::quoted_path;
use spin_factor_outbound_networking::{allowed_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 All @@ -35,6 +36,9 @@ const MULTI_TRIGGER_START_OFFSET: tokio::time::Duration = tokio::time::Duration:
// any exited" check.
const MULTI_TRIGGER_LET_ALL_START: tokio::time::Duration = tokio::time::Duration::from_millis(500);

// Map of component ID to trigger ID
type ComponentTriggerMap = HashMap<String, HashSet<String>>;

/// Start the Fermyon runtime.
#[derive(Parser, Debug, Default)]
#[clap(
Expand Down Expand Up @@ -113,6 +117,11 @@ pub struct UpCommand {
#[clap(long, takes_value = false, env = ALWAYS_BUILD_ENV)]
pub build: bool,

/// Specific component to run. Can specify multiple. If omitted, all
/// components are run.
#[clap(hide = true, long = "component")]
pub components: Option<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 +173,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_command_for_resolved_app_source(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 @@ -194,6 +202,22 @@ impl UpCommand {
.load_resolved_app_source(resolved_app_source, &working_dir)
.await?;

if let Some(components) = &self.components {
retain_components(&mut locked_app, components)?;
}

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

let trigger_cmds =
trigger_command_for_resolved_app_source(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 All @@ -204,7 +228,6 @@ impl UpCommand {
working_dir,
local_app_dir,
};

let trigger_processes = self.start_trigger_processes(trigger_cmds, run_opts).await?;
let pids = get_pids(&trigger_processes);

Expand Down Expand Up @@ -630,11 +653,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_command_for_resolved_app_source(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 +666,87 @@ 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, 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());
let component_trigger_id_map = component_trigger_map(&tmp_app, components)?;
let components = component_trigger_id_map.keys().collect::<HashSet<_>>();
let triggers = component_trigger_id_map
.values()
.flatten()
.collect::<HashSet<_>>();
locked_app.components.retain(|c| components.contains(&c.id));
locked_app.triggers.retain(|t| triggers.contains(&t.id));
Ok(())
}

// Given a list of components and App, returns a map of component ID to trigger ID.
// Also validates that the components exist in the app and have valid service chaining.
fn component_trigger_map(
app: &spin_app::App,
components: &[String],
) -> Result<ComponentTriggerMap> {
let component_triggers: Vec<(AppComponent<'_>, String)> = app
.triggers()
.filter_map(|t| match t.component() {
Ok(comp) => {
if components.contains(&comp.id().to_string()) {
Some((comp, t.id().to_string()))
} else {
None
}
}
Err(_) => None,
})
.collect();

// Allow only service chaining among retained components
// All wildcard service chaining is disallowed
for (c, _) in &component_triggers {
let allowed_hosts = allowed_hosts(c)?;
allowed_hosts.iter().try_for_each(|host| {
let uri = host.parse::<http::Uri>().unwrap();
if let Some(local_component) = parse_service_chaining_target(&uri) {
if !components.contains(&local_component) {
if local_component == "*" {
bail!("Component selected with '--component {}' cannot use wildcard service chaining:
allowed_outbound_hosts = [\"http://*.spin.internal\"]", c.id());
}
bail!(
"Component selected with '--component {}' cannot use service chaining to unselected component:
allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
c.id(), local_component
);
}
}
Ok(())
})?;
}
let mut component_trigger_map: ComponentTriggerMap = HashMap::new();
component_triggers.into_iter().for_each(|(c, t)| {
component_trigger_map
.entry(c.id().to_string())
.or_insert_with(|| HashSet::from([t]));
});

// Ensure that the requested components exist in the app
let mut not_found = Vec::new();
components.iter().for_each(|c| {
if !component_trigger_map.contains_key(c) {
not_found.push(c);
}
});
ensure!(
not_found.is_empty(),
"Specified components not found in application: {:?}",
not_found
);

Ok(component_trigger_map)
}

#[cfg(test)]
mod test {
use crate::commands::up::app_source::AppSource;
Expand All @@ -658,6 +759,109 @@ mod test {
format!("{repo_base}/{path}")
}

#[tokio::test]
async fn test_filtered_and_validated_component_trigger_map_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 locked_app = build_locked_app(&manifest).await.unwrap();
let app = spin_app::App::new("test", locked_app);
let map = component_trigger_map(&app, &["empty".to_string()]).unwrap();
assert!(map.contains_key("empty"));
assert!(map.len() == 1);
}

#[tokio::test]
async fn test_filtered_and_validated_component_trigger_map_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 locked_app = build_locked_app(&manifest).await.unwrap();
let app = spin_app::App::new("test", locked_app);
assert!(component_trigger_map(&app, &["dne".to_string()]).is_err());
}

#[tokio::test]
async fn test_filtered_and_validated_component_trigger_map_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 locked_app = build_locked_app(&manifest).await.unwrap();
let app = spin_app::App::new("test", locked_app);
assert!(component_trigger_map(&app, &["another".to_string()]).is_ok());
let Err(e) = component_trigger_map(&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) = component_trigger_map(&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\"]"
);
}

// 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

0 comments on commit d8834c8

Please sign in to comment.