Skip to content

Commit 788dec1

Browse files
Add spin up component flag to run a subset of app components
Signed-off-by: Kate Goldenring <kate.goldenring@fermyon.com>
1 parent dada4d8 commit 788dec1

File tree

6 files changed

+269
-27
lines changed

6 files changed

+269
-27
lines changed

Diff for: Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dialoguer = "0.10"
2828
dirs = { workspace = true }
2929
futures = { workspace = true }
3030
glob = { workspace = true }
31+
http = { workspace = true }
3132
indicatif = "0.17"
3233
is-terminal = "0.4"
3334
itertools = { workspace = true }
@@ -59,6 +60,7 @@ spin-build = { path = "crates/build" }
5960
spin-common = { path = "crates/common" }
6061
spin-doctor = { path = "crates/doctor" }
6162
spin-expressions = { path = "crates/expressions" }
63+
spin-factor-outbound-networking = { path = "crates/factor-outbound-networking" }
6264
spin-http = { path = "crates/http" }
6365
spin-loader = { path = "crates/loader" }
6466
spin-locked-app = { path = "crates/locked-app" }

Diff for: crates/factor-outbound-networking/src/lib.rs

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
mod config;
22
pub mod runtime_config;
33

4-
use std::{collections::HashMap, sync::Arc};
5-
64
use futures_util::{
75
future::{BoxFuture, Shared},
86
FutureExt,
@@ -14,6 +12,7 @@ use spin_factors::{
1412
anyhow::{self, Context},
1513
ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext, RuntimeFactors,
1614
};
15+
use std::{collections::HashMap, sync::Arc};
1716

1817
pub use config::{
1918
allowed_outbound_hosts, is_service_chaining_host, parse_service_chaining_target,

Diff for: src/commands/up.rs

+261-15
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
mod app_source;
22

33
use std::{
4-
collections::HashMap,
4+
collections::{HashMap, HashSet},
55
ffi::OsString,
66
fmt::Debug,
77
path::{Path, PathBuf},
88
process::Stdio,
99
};
1010

11-
use anyhow::{anyhow, bail, Context, Result};
11+
use anyhow::{anyhow, bail, ensure, Context, Result};
1212
use clap::{CommandFactory, Parser};
1313
use reqwest::Url;
1414
use spin_app::locked::LockedApp;
1515
use spin_common::ui::quoted_path;
16+
use spin_factor_outbound_networking::{allowed_outbound_hosts, parse_service_chaining_target};
1617
use spin_loader::FilesMountStrategy;
1718
use spin_oci::OciLoader;
1819
use spin_trigger::cli::{LaunchMetadata, SPIN_LOCAL_APP_DIR, SPIN_LOCKED_URL, SPIN_WORKING_DIR};
@@ -113,6 +114,10 @@ pub struct UpCommand {
113114
#[clap(long, takes_value = false, env = ALWAYS_BUILD_ENV)]
114115
pub build: bool,
115116

117+
/// [Experimental] Component ID to run. This can be specified multiple times. The default is all components.
118+
#[clap(hide = true, short = 'c', long = "component-id")]
119+
pub components: Vec<String>,
120+
116121
/// All other args, to be passed through to the trigger
117122
#[clap(hide = true)]
118123
pub trigger_args: Vec<OsString>,
@@ -164,13 +169,12 @@ impl UpCommand {
164169
.context("Could not canonicalize working directory")?;
165170

166171
let resolved_app_source = self.resolve_app_source(&app_source, &working_dir).await?;
167-
168-
let trigger_cmds = trigger_command_for_resolved_app_source(&resolved_app_source)
169-
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;
170-
171-
let is_multi = trigger_cmds.len() > 1;
172-
173172
if self.help {
173+
let trigger_cmds =
174+
trigger_commands_for_trigger_types(resolved_app_source.trigger_types())
175+
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;
176+
177+
let is_multi = trigger_cmds.len() > 1;
174178
if is_multi {
175179
// For now, only common flags are allowed on multi-trigger apps.
176180
let mut child = self
@@ -189,10 +193,25 @@ impl UpCommand {
189193
if self.build {
190194
app_source.build().await?;
191195
}
192-
193196
let mut locked_app = self
194197
.load_resolved_app_source(resolved_app_source, &working_dir)
195-
.await?;
198+
.await
199+
.context("Failed to load application")?;
200+
if !self.components.is_empty() {
201+
retain_components(&mut locked_app, &self.components)?;
202+
}
203+
204+
let trigger_types: HashSet<&str> = locked_app
205+
.triggers
206+
.iter()
207+
.map(|t| t.trigger_type.as_ref())
208+
.collect();
209+
210+
ensure!(!trigger_types.is_empty(), "No triggers in app");
211+
212+
let trigger_cmds = trigger_commands_for_trigger_types(trigger_types.into_iter().collect())
213+
.with_context(|| format!("Couldn't find trigger executor for {app_source}"))?;
214+
let is_multi = trigger_cmds.len() > 1;
196215

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

633-
fn trigger_command_for_resolved_app_source(
634-
resolved: &ResolvedAppSource,
635-
) -> Result<Vec<Vec<String>>> {
636-
let trigger_type = resolved.trigger_types()?;
637-
trigger_type
652+
fn trigger_commands_for_trigger_types(trigger_types: Vec<&str>) -> Result<Vec<Vec<String>>> {
653+
trigger_types
638654
.iter()
639655
.map(|&t| match t {
640656
"http" | "redis" => Ok(trigger_command(t)),
@@ -646,6 +662,86 @@ fn trigger_command_for_resolved_app_source(
646662
.collect()
647663
}
648664

665+
/// Scrubs the locked app to only contain the given list of components
666+
/// Introspects the LockedApp to find and selectively retain the triggers that correspond to those components
667+
fn retain_components(locked_app: &mut LockedApp, retained_components: &[String]) -> Result<()> {
668+
// Create a temporary app to access parsed component and trigger information
669+
let tmp_app = spin_app::App::new("tmp", locked_app.clone());
670+
validate_retained_components_exist(&tmp_app, retained_components)?;
671+
validate_retained_components_service_chaining(&tmp_app, retained_components)?;
672+
let (component_ids, trigger_ids): (HashSet<String>, HashSet<String>) = tmp_app
673+
.triggers()
674+
.filter_map(|t| match t.component() {
675+
Ok(comp) if retained_components.contains(&comp.id().to_string()) => {
676+
Some((comp.id().to_owned(), t.id().to_owned()))
677+
}
678+
_ => None,
679+
})
680+
.collect();
681+
locked_app
682+
.components
683+
.retain(|c| component_ids.contains(&c.id));
684+
locked_app.triggers.retain(|t| trigger_ids.contains(&t.id));
685+
Ok(())
686+
}
687+
688+
/// Validates that all service chaining of an app will be satisfied by the
689+
/// retained components.
690+
///
691+
/// This does a best effort look up of components that are
692+
/// allowed to be accessed through service chaining and will error early if a
693+
/// component is configured to to chain to another component that is not
694+
/// retained. All wildcard service chaining is disallowed and all templated URLs
695+
/// are ignored.
696+
fn validate_retained_components_service_chaining(
697+
app: &spin_app::App,
698+
retained_components: &[String],
699+
) -> Result<()> {
700+
app
701+
.triggers().try_for_each(|t| {
702+
let Ok(component) = t.component() else { return Ok(()) };
703+
if retained_components.contains(&component.id().to_string()) {
704+
let allowed_hosts = allowed_outbound_hosts(&component).context("failed to get allowed hosts")?;
705+
for host in allowed_hosts {
706+
// Templated URLs are not yet resolved at this point, so ignore unresolvable URIs
707+
if let Ok(uri) = host.parse::<http::Uri>() {
708+
if let Some(chaining_target) = parse_service_chaining_target(&uri) {
709+
if !retained_components.contains(&chaining_target) {
710+
if chaining_target == "*" {
711+
bail!("Component selected with '--component {}' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]", component.id());
712+
}
713+
bail!(
714+
"Component selected with '--component {}' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://{}.spin.internal\"]",
715+
component.id(), chaining_target
716+
);
717+
}
718+
}
719+
}
720+
}
721+
}
722+
anyhow::Ok(())
723+
})?;
724+
725+
Ok(())
726+
}
727+
728+
/// Validates that all components specified to be retained actually exist in the app
729+
fn validate_retained_components_exist(
730+
app: &spin_app::App,
731+
retained_components: &[String],
732+
) -> Result<()> {
733+
let app_components = app
734+
.components()
735+
.map(|c| c.id().to_string())
736+
.collect::<HashSet<_>>();
737+
for c in retained_components {
738+
if !app_components.contains(c) {
739+
bail!("Specified component \"{c}\" not found in application");
740+
}
741+
}
742+
Ok(())
743+
}
744+
649745
#[cfg(test)]
650746
mod test {
651747
use crate::commands::up::app_source::AppSource;
@@ -658,6 +754,156 @@ mod test {
658754
format!("{repo_base}/{path}")
659755
}
660756

757+
#[tokio::test]
758+
async fn test_retain_components_filtering_for_only_component_works() {
759+
let manifest = toml::toml! {
760+
spin_manifest_version = 2
761+
762+
[application]
763+
name = "test-app"
764+
765+
[[trigger.test-trigger]]
766+
component = "empty"
767+
768+
[component.empty]
769+
source = "does-not-exist.wasm"
770+
};
771+
let mut locked_app = build_locked_app(&manifest).await.unwrap();
772+
retain_components(&mut locked_app, &["empty".to_string()]).unwrap();
773+
let components = locked_app
774+
.components
775+
.iter()
776+
.map(|c| c.id.to_string())
777+
.collect::<HashSet<_>>();
778+
assert!(components.contains("empty"));
779+
assert!(components.len() == 1);
780+
}
781+
782+
#[tokio::test]
783+
async fn test_retain_components_filtering_for_non_existent_component_fails() {
784+
let manifest = toml::toml! {
785+
spin_manifest_version = 2
786+
787+
[application]
788+
name = "test-app"
789+
790+
[[trigger.test-trigger]]
791+
component = "empty"
792+
793+
[component.empty]
794+
source = "does-not-exist.wasm"
795+
};
796+
let mut locked_app = build_locked_app(&manifest).await.unwrap();
797+
let Err(e) = retain_components(&mut locked_app, &["dne".to_string()]) else {
798+
panic!("Expected component not found error");
799+
};
800+
assert_eq!(
801+
e.to_string(),
802+
"Specified component \"dne\" not found in application"
803+
);
804+
assert!(retain_components(&mut locked_app, &["dne".to_string()]).is_err());
805+
}
806+
807+
#[tokio::test]
808+
async fn test_retain_components_app_with_service_chaining_fails() {
809+
let manifest = toml::toml! {
810+
spin_manifest_version = 2
811+
812+
[application]
813+
name = "test-app"
814+
815+
[[trigger.test-trigger]]
816+
component = "empty"
817+
818+
[component.empty]
819+
source = "does-not-exist.wasm"
820+
allowed_outbound_hosts = ["http://another.spin.internal"]
821+
822+
[[trigger.another-trigger]]
823+
component = "another"
824+
825+
[component.another]
826+
source = "does-not-exist.wasm"
827+
828+
[[trigger.third-trigger]]
829+
component = "third"
830+
831+
[component.third]
832+
source = "does-not-exist.wasm"
833+
allowed_outbound_hosts = ["http://*.spin.internal"]
834+
};
835+
let mut locked_app = build_locked_app(&manifest)
836+
.await
837+
.expect("could not build locked app");
838+
let Err(e) = retain_components(&mut locked_app, &["empty".to_string()]) else {
839+
panic!("Expected service chaining to non-retained component error");
840+
};
841+
assert_eq!(
842+
e.to_string(),
843+
"Component selected with '--component empty' cannot use service chaining to unselected component: allowed_outbound_hosts = [\"http://another.spin.internal\"]"
844+
);
845+
let Err(e) = retain_components(
846+
&mut locked_app,
847+
&["third".to_string(), "another".to_string()],
848+
) else {
849+
panic!("Expected wildcard service chaining error");
850+
};
851+
assert_eq!(
852+
e.to_string(),
853+
"Component selected with '--component third' cannot use wildcard service chaining: allowed_outbound_hosts = [\"http://*.spin.internal\"]"
854+
);
855+
assert!(retain_components(&mut locked_app, &["another".to_string()]).is_ok());
856+
}
857+
858+
#[tokio::test]
859+
async fn test_retain_components_app_with_templated_host_passes() {
860+
let manifest = toml::toml! {
861+
spin_manifest_version = 2
862+
863+
[application]
864+
name = "test-app"
865+
866+
[variables]
867+
host = { default = "test" }
868+
869+
[[trigger.test-trigger]]
870+
component = "empty"
871+
872+
[component.empty]
873+
source = "does-not-exist.wasm"
874+
875+
[[trigger.another-trigger]]
876+
component = "another"
877+
878+
[component.another]
879+
source = "does-not-exist.wasm"
880+
881+
[[trigger.third-trigger]]
882+
component = "third"
883+
884+
[component.third]
885+
source = "does-not-exist.wasm"
886+
allowed_outbound_hosts = ["http://{{ host }}.spin.internal"]
887+
};
888+
let mut locked_app = build_locked_app(&manifest)
889+
.await
890+
.expect("could not build locked app");
891+
assert!(
892+
retain_components(&mut locked_app, &["empty".to_string(), "third".to_string()]).is_ok()
893+
);
894+
}
895+
896+
// Duplicate from crates/factors-test/src/lib.rs
897+
pub async fn build_locked_app(
898+
manifest: &toml::map::Map<String, toml::Value>,
899+
) -> anyhow::Result<LockedApp> {
900+
let toml_str = toml::to_string(manifest).context("failed serializing manifest")?;
901+
let dir = tempfile::tempdir().context("failed creating tempdir")?;
902+
let path = dir.path().join("spin.toml");
903+
std::fs::write(&path, toml_str).context("failed writing manifest")?;
904+
spin_loader::from_file(&path, FilesMountStrategy::Direct, None).await
905+
}
906+
661907
#[test]
662908
fn can_infer_files() {
663909
let file = repo_path("examples/http-rust/spin.toml");

0 commit comments

Comments
 (0)