11mod app_source;
22
33use 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 } ;
1212use clap:: { CommandFactory , Parser } ;
1313use reqwest:: Url ;
1414use spin_app:: locked:: LockedApp ;
1515use spin_common:: ui:: quoted_path;
16+ use spin_factor_outbound_networking:: { allowed_outbound_hosts, parse_service_chaining_target} ;
1617use spin_loader:: FilesMountStrategy ;
1718use spin_oci:: OciLoader ;
1819use 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) ]
650746mod 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