@@ -8,7 +8,7 @@ use std::sync::Arc;
88use clap:: { Parser , Subcommand , ValueEnum } ;
99use tinybox_core:: {
1010 BoxId , BoxInfo , BoxSpec , Clock , Error , ExecRequest , Host , HostRef , NetworkPolicy ,
11- PassthroughSandbox , Placement , PortMapping , Sandbox , SandboxRef , SnapshotId , Store ,
11+ PassthroughSandbox , Placement , PortMapping , ProcessId , Sandbox , SandboxRef , SnapshotId , Store ,
1212 SystemClock , TemplateName , Templates , WorkspaceSource , passthrough,
1313} ;
1414use tinybox_docker:: DockerSandbox ;
@@ -177,6 +177,47 @@ enum Command {
177177 #[ arg( trailing_var_arg = true , required = true , value_name = "COMMAND" ) ]
178178 argv : Vec < String > ,
179179 } ,
180+ /// Start a command in a box and leave it running.
181+ ///
182+ /// Where `exec` waits, this returns a process id as soon as the command is
183+ /// started. It is how a server gets into a box; `exec` would never return.
184+ Spawn {
185+ /// Which box to start it in.
186+ id : String ,
187+ /// The command and its arguments.
188+ #[ arg( trailing_var_arg = true , required = true , value_name = "COMMAND" ) ]
189+ argv : Vec < String > ,
190+ } ,
191+ /// Report whether a spawned process is still running.
192+ Ps {
193+ /// Which box it was started in.
194+ id : String ,
195+ /// The process id `spawn` printed.
196+ process : String ,
197+ } ,
198+ /// Stop a spawned process.
199+ ///
200+ /// Succeeds when it has already exited: stopping something already stopped
201+ /// is the outcome the caller wanted.
202+ Kill {
203+ /// Which box it was started in.
204+ id : String ,
205+ /// The process id `spawn` printed.
206+ process : String ,
207+ } ,
208+ /// Make a port on the box's machine reachable from this one.
209+ ///
210+ /// Publishing a port (`create -p`) puts it on the machine the box runs on.
211+ /// When that is somewhere else, this is what closes the gap. The tunnel
212+ /// lasts as long as the command runs, so it holds until interrupted.
213+ Forward {
214+ /// The port on the box's machine.
215+ port : u16 ,
216+ /// The address to reach it at over there. Defaults to loopback, which
217+ /// is where a published port lands.
218+ #[ arg( long, value_name = "IP" , default_value = "127.0.0.1" ) ]
219+ address : std:: net:: IpAddr ,
220+ } ,
180221 /// List every box.
181222 #[ command( alias = "list" ) ]
182223 Ls ,
@@ -350,12 +391,11 @@ impl Cli {
350391 ) ?;
351392 announce ( & sandbox. create ( & spec) . await ?, sandbox. as_ref ( ) , out, err)
352393 }
353- Command :: Exec { id, argv } => {
354- let id = BoxId :: new ( id) ?;
355- let sandbox = build ( sandbox_of ( & store, & id) ?) ?;
356- let output = sandbox. exec ( & id, & ExecRequest :: new ( argv) ) . await ?;
357- report ( & output, out, err)
358- }
394+ Command :: Exec { id, argv } => exec ( & store, & backends, id, argv, out, err) . await ,
395+ Command :: Spawn { id, argv } => spawn ( & store, & backends, id, argv, out) . await ,
396+ Command :: Ps { id, process } => probe ( & store, & backends, id, & process, out) . await ,
397+ Command :: Kill { id, process } => kill ( & store, & backends, id, & process, out) . await ,
398+ Command :: Forward { port, address } => forward ( reach. as_ref ( ) , address, port, out) . await ,
359399 // Listing is the store's business, not the sandbox's: the store is
360400 // what owns the set of records.
361401 Command :: Ls => text ( out, & render_listing ( & store. list ( ) ?) ) ,
@@ -570,6 +610,37 @@ fn line(out: &mut dyn Write, value: &str) -> tinybox_core::Result<u8> {
570610 text ( out, & format ! ( "{value}\n " ) )
571611}
572612
613+ /// Open a tunnel to `remote` and hold it until the process is interrupted.
614+ ///
615+ /// The forward is a guard, so it exists for exactly as long as this function
616+ /// runs. There is no daemon to hand it to and no state file that could
617+ /// describe a tunnel this process is no longer holding open, so blocking is
618+ /// the honest shape: the command running *is* the forward existing.
619+ ///
620+ /// # Errors
621+ ///
622+ /// Returns whatever the host reports when the tunnel cannot be opened —
623+ /// [`Error::Unsupported`] from a host that cannot tunnel at all.
624+ async fn forward (
625+ reach : & dyn Host ,
626+ address : std:: net:: IpAddr ,
627+ port : u16 ,
628+ out : & mut dyn Write ,
629+ ) -> tinybox_core:: Result < u8 > {
630+ let forwarded = reach. forward ( ( address, port) . into ( ) ) . await ?;
631+ line ( out, & forwarded. local_addr ( ) . to_string ( ) ) ?;
632+
633+ if forwarded. is_direct ( ) {
634+ // Nothing is being held open, so there is nothing to hold *for*.
635+ // Blocking here would look like a working tunnel and be a hang.
636+ return Ok ( 0 ) ;
637+ }
638+ // Park until the terminal interrupts us; dropping `forwarded` on the way
639+ // out closes the tunnel.
640+ std:: future:: pending :: < ( ) > ( ) . await ;
641+ Ok ( 0 )
642+ }
643+
573644/// Forward a finished command's output and status to the caller.
574645///
575646/// # Errors
@@ -783,6 +854,94 @@ fn render_sync(outcome: &tinybox_sync::Sync) -> String {
783854/// Returns [`Error::InvalidIdentifier`] when a Docker namespace is not a valid
784855/// identifier.
785856/// Destroy one box and print its identifier back.
857+ /// Run a command in a box, mirroring its output and exit status.
858+ ///
859+ /// # Errors
860+ ///
861+ /// Returns whatever the backend reports when the command could not be started.
862+ /// A command that runs and exits non-zero is **not** an error: its status
863+ /// becomes this process's.
864+ async fn exec (
865+ store : & Arc < dyn Store > ,
866+ backends : & Backends < ' _ > ,
867+ id : String ,
868+ argv : Vec < String > ,
869+ out : & mut dyn Write ,
870+ err : & mut dyn Write ,
871+ ) -> tinybox_core:: Result < u8 > {
872+ let id = BoxId :: new ( id) ?;
873+ let sandbox = backends. get ( sandbox_of ( store, & id) ?) ?;
874+ let output = sandbox. exec ( & id, & ExecRequest :: new ( argv) ) . await ?;
875+ report ( & output, out, err)
876+ }
877+
878+ /// Start a command in a box and print the identifier for asking about it.
879+ ///
880+ /// # Errors
881+ ///
882+ /// Returns [`Error::Unsupported`] when the box's sandbox cannot host a process
883+ /// between commands, and whatever the backend reports when the command could
884+ /// not be started.
885+ async fn spawn (
886+ store : & Arc < dyn Store > ,
887+ backends : & Backends < ' _ > ,
888+ id : String ,
889+ argv : Vec < String > ,
890+ out : & mut dyn Write ,
891+ ) -> tinybox_core:: Result < u8 > {
892+ let id = BoxId :: new ( id) ?;
893+ let sandbox = backends. get ( sandbox_of ( store, & id) ?) ?;
894+ let process = sandbox. spawn ( & id, & ExecRequest :: new ( argv) ) . await ?;
895+ line ( out, process. as_ref ( ) )
896+ }
897+
898+ /// Report whether a spawned process is still running.
899+ ///
900+ /// A process that has exited prints `gone` and exits zero: that it finished is
901+ /// an answer, and reporting it as a failure would be indistinguishable from an
902+ /// unreachable box.
903+ ///
904+ /// # Errors
905+ ///
906+ /// Returns [`Error::Unsupported`] when the box's sandbox does not track
907+ /// detached processes, and a backend error when the box cannot be reached.
908+ async fn probe (
909+ store : & Arc < dyn Store > ,
910+ backends : & Backends < ' _ > ,
911+ id : String ,
912+ process : & str ,
913+ out : & mut dyn Write ,
914+ ) -> tinybox_core:: Result < u8 > {
915+ let id = BoxId :: new ( id) ?;
916+ let sandbox = backends. get ( sandbox_of ( store, & id) ?) ?;
917+ let running = sandbox
918+ . is_running ( & id, & ProcessId :: new ( process. to_owned ( ) ) ?)
919+ . await ?;
920+ line ( out, if running { "running" } else { "gone" } )
921+ }
922+
923+ /// Stop a spawned process.
924+ ///
925+ /// # Errors
926+ ///
927+ /// Returns [`Error::Unsupported`] when the box's sandbox does not track
928+ /// detached processes, and a backend error when the box cannot be reached. A
929+ /// process that had already exited is not an error.
930+ async fn kill (
931+ store : & Arc < dyn Store > ,
932+ backends : & Backends < ' _ > ,
933+ id : String ,
934+ process : & str ,
935+ out : & mut dyn Write ,
936+ ) -> tinybox_core:: Result < u8 > {
937+ let id = BoxId :: new ( id) ?;
938+ let sandbox = backends. get ( sandbox_of ( store, & id) ?) ?;
939+ sandbox
940+ . stop ( & id, & ProcessId :: new ( process. to_owned ( ) ) ?)
941+ . await ?;
942+ line ( out, "stopped" )
943+ }
944+
786945async fn remove (
787946 store : & Arc < dyn Store > ,
788947 backends : & Backends < ' _ > ,
0 commit comments