1
1
mod app_source;
2
2
3
3
use std:: {
4
- collections:: HashMap ,
4
+ collections:: { HashMap , HashSet } ,
5
5
ffi:: OsString ,
6
6
fmt:: Debug ,
7
7
path:: { Path , PathBuf } ,
8
8
process:: Stdio ,
9
9
} ;
10
10
11
- use anyhow:: { anyhow, bail, Context , Result } ;
11
+ use anyhow:: { anyhow, bail, ensure , Context , Result } ;
12
12
use clap:: { CommandFactory , Parser } ;
13
13
use reqwest:: Url ;
14
14
use spin_app:: locked:: LockedApp ;
15
15
use spin_common:: ui:: quoted_path;
16
+ use spin_factor_outbound_networking:: { allowed_outbound_hosts, parse_service_chaining_target} ;
16
17
use spin_loader:: FilesMountStrategy ;
17
18
use spin_oci:: OciLoader ;
18
19
use spin_trigger:: cli:: { LaunchMetadata , SPIN_LOCAL_APP_DIR , SPIN_LOCKED_URL , SPIN_WORKING_DIR } ;
@@ -113,6 +114,10 @@ pub struct UpCommand {
113
114
#[ clap( long, takes_value = false , env = ALWAYS_BUILD_ENV ) ]
114
115
pub build : bool ,
115
116
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
+
116
121
/// All other args, to be passed through to the trigger
117
122
#[ clap( hide = true ) ]
118
123
pub trigger_args : Vec < OsString > ,
@@ -164,13 +169,12 @@ impl UpCommand {
164
169
. context ( "Could not canonicalize working directory" ) ?;
165
170
166
171
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
-
173
172
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 ;
174
178
if is_multi {
175
179
// For now, only common flags are allowed on multi-trigger apps.
176
180
let mut child = self
@@ -189,10 +193,25 @@ impl UpCommand {
189
193
if self . build {
190
194
app_source. build ( ) . await ?;
191
195
}
192
-
193
196
let mut locked_app = self
194
197
. 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 ;
196
215
197
216
self . update_locked_app ( & mut locked_app) ;
198
217
let locked_url = self . write_locked_app ( & locked_app, & working_dir) . await ?;
@@ -630,11 +649,8 @@ fn trigger_command(trigger_type: &str) -> Vec<String> {
630
649
vec ! [ "trigger" . to_owned( ) , trigger_type. to_owned( ) ]
631
650
}
632
651
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
638
654
. iter ( )
639
655
. map ( |& t| match t {
640
656
"http" | "redis" => Ok ( trigger_command ( t) ) ,
@@ -646,6 +662,86 @@ fn trigger_command_for_resolved_app_source(
646
662
. collect ( )
647
663
}
648
664
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
+
649
745
#[ cfg( test) ]
650
746
mod test {
651
747
use crate :: commands:: up:: app_source:: AppSource ;
@@ -658,6 +754,156 @@ mod test {
658
754
format ! ( "{repo_base}/{path}" )
659
755
}
660
756
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
+
661
907
#[ test]
662
908
fn can_infer_files ( ) {
663
909
let file = repo_path ( "examples/http-rust/spin.toml" ) ;
0 commit comments