@@ -198,6 +198,7 @@ const HELP_TEMPLATE: &str = "\
198198
199199\x1b [1mSANDBOX COMMANDS\x1b [0m
200200 sandbox: Manage sandboxes
201+ service: Expose sandbox services
201202 forward: Manage port forwarding to a sandbox
202203 logs: View sandbox logs
203204 policy: Manage sandbox policy
@@ -272,6 +273,18 @@ const FORWARD_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
272273 $ openshell forward list
273274" ;
274275
276+ const SERVICE_EXAMPLES : & str = "\x1b [1mALIAS\x1b [0m
277+ svc
278+
279+ \x1b [1mEXAMPLES\x1b [0m
280+ $ openshell service expose my-sandbox 8080
281+ $ openshell service expose my-sandbox 8080 web
282+ $ openshell service list
283+ $ openshell service list my-sandbox
284+ $ openshell service get my-sandbox web
285+ $ openshell service delete my-sandbox web
286+ " ;
287+
275288const LOGS_EXAMPLES : & str = "\x1b [1mALIAS\x1b [0m
276289 lg
277290
@@ -410,6 +423,13 @@ enum Commands {
410423 command : Option < ForwardCommands > ,
411424 } ,
412425
426+ /// Manage sandbox services.
427+ #[ command( alias = "svc" , after_help = SERVICE_EXAMPLES , help_template = SUBCOMMAND_HELP_TEMPLATE ) ]
428+ Service {
429+ #[ command( subcommand) ]
430+ command : Option < ServiceCommands > ,
431+ } ,
432+
413433 /// View sandbox logs.
414434 #[ command( alias = "lg" , after_help = LOGS_EXAMPLES , help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
415435 Logs {
@@ -1637,6 +1657,62 @@ enum ForwardCommands {
16371657 } ,
16381658}
16391659
1660+ #[ derive( Subcommand , Debug ) ]
1661+ enum ServiceCommands {
1662+ /// Expose an HTTP service running inside a sandbox.
1663+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
1664+ Expose {
1665+ /// Sandbox name.
1666+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1667+ sandbox : String ,
1668+
1669+ /// Loopback TCP port inside the sandbox.
1670+ #[ arg( value_name = "TARGET-PORT" ) ]
1671+ target_port : u16 ,
1672+
1673+ /// Service name.
1674+ service : Option < String > ,
1675+ } ,
1676+
1677+ /// List exposed sandbox service endpoints.
1678+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
1679+ List {
1680+ /// Sandbox name.
1681+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1682+ sandbox : Option < String > ,
1683+
1684+ /// Maximum number of endpoints to return.
1685+ #[ arg( long, default_value_t = 100 ) ]
1686+ limit : u32 ,
1687+
1688+ /// Number of endpoints to skip.
1689+ #[ arg( long, default_value_t = 0 ) ]
1690+ offset : u32 ,
1691+ } ,
1692+
1693+ /// Show one exposed sandbox service endpoint.
1694+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
1695+ Get {
1696+ /// Sandbox name.
1697+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1698+ sandbox : String ,
1699+
1700+ /// Service name. Omit for the unnamed endpoint.
1701+ service : Option < String > ,
1702+ } ,
1703+
1704+ /// Delete one exposed sandbox service endpoint.
1705+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
1706+ Delete {
1707+ /// Sandbox name.
1708+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1709+ sandbox : String ,
1710+
1711+ /// Service name. Omit for the unnamed endpoint.
1712+ service : Option < String > ,
1713+ } ,
1714+ }
1715+
16401716#[ tokio:: main]
16411717#[ allow( clippy:: large_stack_frames) ] // CLI dispatch holds many futures; OK at top level.
16421718async fn main ( ) -> Result < ( ) > {
@@ -1921,6 +1997,43 @@ async fn main() -> Result<()> {
19211997 }
19221998 } ,
19231999
2000+ // -----------------------------------------------------------
2001+ // Service exposure
2002+ // -----------------------------------------------------------
2003+ Some ( Commands :: Service {
2004+ command : Some ( command) ,
2005+ } ) => {
2006+ let ctx = resolve_gateway ( & cli. gateway , & cli. gateway_endpoint ) ?;
2007+ let mut tls = tls. with_gateway_name ( & ctx. name ) ;
2008+ apply_auth ( & mut tls, & ctx. name ) ;
2009+ match command {
2010+ ServiceCommands :: Expose {
2011+ sandbox,
2012+ service,
2013+ target_port,
2014+ } => {
2015+ let service = service. unwrap_or_default ( ) ;
2016+ run:: service_expose ( & ctx. endpoint , & sandbox, & service, target_port, & tls)
2017+ . await ?;
2018+ }
2019+ ServiceCommands :: List {
2020+ sandbox,
2021+ limit,
2022+ offset,
2023+ } => {
2024+ run:: service_list ( & ctx. endpoint , sandbox. as_deref ( ) , limit, offset, & tls)
2025+ . await ?;
2026+ }
2027+ ServiceCommands :: Get { sandbox, service } => {
2028+ let service = service. unwrap_or_default ( ) ;
2029+ run:: service_get ( & ctx. endpoint , & sandbox, & service, & tls) . await ?;
2030+ }
2031+ ServiceCommands :: Delete { sandbox, service } => {
2032+ let service = service. unwrap_or_default ( ) ;
2033+ run:: service_delete ( & ctx. endpoint , & sandbox, & service, & tls) . await ?;
2034+ }
2035+ }
2036+ }
19242037 // -----------------------------------------------------------
19252038 // Top-level logs (was `sandbox logs`)
19262039 // -----------------------------------------------------------
@@ -2658,6 +2771,13 @@ async fn main() -> Result<()> {
26582771 . print_help ( )
26592772 . expect ( "Failed to print help" ) ;
26602773 }
2774+ Some ( Commands :: Service { command : None } ) => {
2775+ Cli :: command ( )
2776+ . find_subcommand_mut ( "service" )
2777+ . expect ( "service subcommand exists" )
2778+ . print_help ( )
2779+ . expect ( "Failed to print help" ) ;
2780+ }
26612781 Some ( Commands :: Policy { command : None } ) => {
26622782 Cli :: command ( )
26632783 . find_subcommand_mut ( "policy" )
@@ -3520,4 +3640,185 @@ mod tests {
35203640 }
35213641 }
35223642 }
3643+
3644+ #[ test]
3645+ fn service_expose_accepts_positional_target_port_and_service ( ) {
3646+ let cli = Cli :: try_parse_from ( [
3647+ "openshell" ,
3648+ "service" ,
3649+ "expose" ,
3650+ "my-sandbox" ,
3651+ "8080" ,
3652+ "api" ,
3653+ ] )
3654+ . expect ( "service expose positional target port should parse" ) ;
3655+
3656+ match cli. command {
3657+ Some ( Commands :: Service {
3658+ command :
3659+ Some ( ServiceCommands :: Expose {
3660+ sandbox,
3661+ target_port,
3662+ service,
3663+ } ) ,
3664+ } ) => {
3665+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3666+ assert_eq ! ( target_port, 8080 ) ;
3667+ assert_eq ! ( service. as_deref( ) , Some ( "api" ) ) ;
3668+ }
3669+ other => panic ! ( "expected service expose command, got: {other:?}" ) ,
3670+ }
3671+ }
3672+
3673+ #[ test]
3674+ fn service_expose_allows_omitted_service_name ( ) {
3675+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "expose" , "my-sandbox" , "8080" ] )
3676+ . expect ( "service expose should allow omitting the service name" ) ;
3677+
3678+ match cli. command {
3679+ Some ( Commands :: Service {
3680+ command :
3681+ Some ( ServiceCommands :: Expose {
3682+ sandbox,
3683+ target_port,
3684+ service,
3685+ } ) ,
3686+ } ) => {
3687+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3688+ assert_eq ! ( target_port, 8080 ) ;
3689+ assert_eq ! ( service, None ) ;
3690+ }
3691+ other => panic ! ( "expected service expose command, got: {other:?}" ) ,
3692+ }
3693+ }
3694+
3695+ #[ test]
3696+ fn service_alias_parses_service_commands ( ) {
3697+ let cli = Cli :: try_parse_from ( [ "openshell" , "svc" , "expose" , "my-sandbox" , "8080" ] )
3698+ . expect ( "svc alias should parse service commands" ) ;
3699+
3700+ match cli. command {
3701+ Some ( Commands :: Service {
3702+ command :
3703+ Some ( ServiceCommands :: Expose {
3704+ sandbox,
3705+ target_port,
3706+ service,
3707+ } ) ,
3708+ } ) => {
3709+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3710+ assert_eq ! ( target_port, 8080 ) ;
3711+ assert_eq ! ( service, None ) ;
3712+ }
3713+ other => panic ! ( "expected service expose command, got: {other:?}" ) ,
3714+ }
3715+ }
3716+
3717+ #[ test]
3718+ fn service_list_accepts_optional_sandbox_and_paging ( ) {
3719+ let cli = Cli :: try_parse_from ( [
3720+ "openshell" ,
3721+ "service" ,
3722+ "list" ,
3723+ "my-sandbox" ,
3724+ "--limit" ,
3725+ "10" ,
3726+ "--offset" ,
3727+ "2" ,
3728+ ] )
3729+ . expect ( "service list should parse optional sandbox and paging" ) ;
3730+
3731+ match cli. command {
3732+ Some ( Commands :: Service {
3733+ command :
3734+ Some ( ServiceCommands :: List {
3735+ sandbox,
3736+ limit,
3737+ offset,
3738+ } ) ,
3739+ } ) => {
3740+ assert_eq ! ( sandbox. as_deref( ) , Some ( "my-sandbox" ) ) ;
3741+ assert_eq ! ( limit, 10 ) ;
3742+ assert_eq ! ( offset, 2 ) ;
3743+ }
3744+ other => panic ! ( "expected service list command, got: {other:?}" ) ,
3745+ }
3746+
3747+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "list" ] )
3748+ . expect ( "service list should allow omitting sandbox" ) ;
3749+
3750+ match cli. command {
3751+ Some ( Commands :: Service {
3752+ command :
3753+ Some ( ServiceCommands :: List {
3754+ sandbox,
3755+ limit,
3756+ offset,
3757+ } ) ,
3758+ } ) => {
3759+ assert_eq ! ( sandbox, None ) ;
3760+ assert_eq ! ( limit, 100 ) ;
3761+ assert_eq ! ( offset, 0 ) ;
3762+ }
3763+ other => panic ! ( "expected service list command, got: {other:?}" ) ,
3764+ }
3765+ }
3766+
3767+ #[ test]
3768+ fn service_get_accepts_optional_service_name ( ) {
3769+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "get" , "my-sandbox" , "api" ] )
3770+ . expect ( "service get should parse service name" ) ;
3771+
3772+ match cli. command {
3773+ Some ( Commands :: Service {
3774+ command : Some ( ServiceCommands :: Get { sandbox, service } ) ,
3775+ } ) => {
3776+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3777+ assert_eq ! ( service. as_deref( ) , Some ( "api" ) ) ;
3778+ }
3779+ other => panic ! ( "expected service get command, got: {other:?}" ) ,
3780+ }
3781+
3782+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "get" , "my-sandbox" ] )
3783+ . expect ( "service get should allow omitting service name" ) ;
3784+
3785+ match cli. command {
3786+ Some ( Commands :: Service {
3787+ command : Some ( ServiceCommands :: Get { sandbox, service } ) ,
3788+ } ) => {
3789+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3790+ assert_eq ! ( service, None ) ;
3791+ }
3792+ other => panic ! ( "expected service get command, got: {other:?}" ) ,
3793+ }
3794+ }
3795+
3796+ #[ test]
3797+ fn service_delete_accepts_optional_service_name ( ) {
3798+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "delete" , "my-sandbox" , "api" ] )
3799+ . expect ( "service delete should parse service name" ) ;
3800+
3801+ match cli. command {
3802+ Some ( Commands :: Service {
3803+ command : Some ( ServiceCommands :: Delete { sandbox, service } ) ,
3804+ } ) => {
3805+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3806+ assert_eq ! ( service. as_deref( ) , Some ( "api" ) ) ;
3807+ }
3808+ other => panic ! ( "expected service delete command, got: {other:?}" ) ,
3809+ }
3810+
3811+ let cli = Cli :: try_parse_from ( [ "openshell" , "service" , "delete" , "my-sandbox" ] )
3812+ . expect ( "service delete should allow omitting service name" ) ;
3813+
3814+ match cli. command {
3815+ Some ( Commands :: Service {
3816+ command : Some ( ServiceCommands :: Delete { sandbox, service } ) ,
3817+ } ) => {
3818+ assert_eq ! ( sandbox, "my-sandbox" ) ;
3819+ assert_eq ! ( service, None ) ;
3820+ }
3821+ other => panic ! ( "expected service delete command, got: {other:?}" ) ,
3822+ }
3823+ }
35233824}
0 commit comments