Skip to content

Commit f706e53

Browse files
authored
Merge pull request #2 from tinyhumansai/tinybox-gateways
Add detached processes and port forwarding to the provider traits
2 parents 5299154 + aef145f commit f706e53

30 files changed

Lines changed: 1975 additions & 75 deletions

File tree

‎Cargo.lock‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,35 @@ Publish a port with `-p`:
177177
tinybox create --sandbox docker --image nginx -p 8080:80
178178
```
179179

180+
## Something that keeps running
181+
182+
`exec` waits for the command, which is right for a build and wrong for a server.
183+
`spawn` starts one and hands back an identifier instead:
184+
185+
```sh
186+
tinybox create --sandbox docker --image nginx -p 8080:80
187+
pid=$(tinybox spawn box-0 -- nginx -g 'daemon off;')
188+
tinybox ps box-0 "$pid" # -> running
189+
tinybox kill box-0 "$pid" # -> stopped
190+
```
191+
192+
The process survives between commands, which is what a sandbox declaring
193+
`Detach` is promising — `tinybox inspect` says which ones do. `passthrough` and
194+
`docker` do; `namespace` and `microvm` decline rather than background something
195+
they could not find again.
196+
197+
Publishing puts that port on the machine the box runs on. When that machine is
198+
somewhere else, `forward` closes the gap:
199+
200+
```sh
201+
tinybox --host ssh://builder@example.com forward 8080
202+
# 127.0.0.1:54321 # ...and the tunnel lasts as long as this runs
203+
```
204+
205+
Reach was always the `Host`'s question, so a tunnel is answered there too — see
206+
[ADR 0007](docs/adr/0007-reach-includes-forwarding-and-detachment.md). Nothing
207+
in `ssh` or `docker` knows about the other, here either.
208+
180209
## Without a daemon
181210

182211
`namespace` isolates a directory you already have, using Linux namespaces

‎crates/tinybox-cli/src/command/mod.rs‎

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::sync::Arc;
88
use clap::{Parser, Subcommand, ValueEnum};
99
use 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
};
1414
use 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+
786945
async fn remove(
787946
store: &Arc<dyn Store>,
788947
backends: &Backends<'_>,

‎crates/tinybox-cli/src/command/test.rs‎

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -533,13 +533,16 @@ async fn inspect_lists_what_the_sandbox_declares() -> Result<()> {
533533

534534
let inspected = invoke(dir.path(), &["inspect", "box-0"]).await;
535535

536-
// Passthrough declares nothing, and says that rather than printing an
537-
// empty list the reader has to interpret.
536+
// Passthrough declares detached processes and nothing else: a box here is
537+
// an ordinary directory on this machine, so a backgrounded process really
538+
// does survive between commands, but there is no filesystem boundary to
539+
// snapshot and no limit it can apply.
538540
assert!(
539-
inspected
540-
.out
541-
.contains("supports: nothing beyond running commands")
541+
inspected.out.contains("supports: detached processes"),
542+
"{}",
543+
inspected.out
542544
);
545+
assert!(!inspected.out.contains("filesystem snapshots"));
543546
Ok(())
544547
}
545548

@@ -1298,3 +1301,94 @@ fn write_boxes(dir: &Path, boxes: &[(&str, Option<std::time::SystemTime>)]) -> R
12981301
std::fs::write(dir.join("boxes.json"), format!("{{{}}}", records.join(",")))
12991302
.map_err(|error| Error::io("write", &error))
13001303
}
1304+
1305+
#[tokio::test]
1306+
async fn a_spawned_process_outlives_the_command_that_started_it() -> Result<()> {
1307+
// The whole point of `spawn` over `exec`: a separate invocation is standing
1308+
// in for a separate process, and the thing started by the first one is
1309+
// still there for the second to ask about.
1310+
let dir = temp_dir()?;
1311+
invoke(dir.path(), &["create", "--dir", "/tmp"]).await;
1312+
1313+
let spawned = invoke(dir.path(), &["spawn", "box-0", "sleep", "30"]).await;
1314+
assert_eq!(spawned.code, 0);
1315+
let process = spawned.out.trim().to_owned();
1316+
assert!(!process.is_empty(), "spawn prints an identifier");
1317+
1318+
let running = invoke(dir.path(), &["ps", "box-0", &process]).await;
1319+
assert_eq!(running.out.trim(), "running");
1320+
1321+
let killed = invoke(dir.path(), &["kill", "box-0", &process]).await;
1322+
assert_eq!(killed.code, 0);
1323+
1324+
let gone = invoke(dir.path(), &["ps", "box-0", &process]).await;
1325+
// `gone` on stdout with a zero exit: the process finishing is an answer,
1326+
// not a failure, and reporting it as one would be indistinguishable from
1327+
// an unreachable box.
1328+
assert_eq!(gone.code, 0);
1329+
assert_eq!(gone.out.trim(), "gone");
1330+
Ok(())
1331+
}
1332+
1333+
#[tokio::test]
1334+
async fn asking_about_a_process_that_was_never_started_answers_gone() -> Result<()> {
1335+
let dir = temp_dir()?;
1336+
invoke(dir.path(), &["create", "--dir", "/tmp"]).await;
1337+
1338+
let answer = invoke(dir.path(), &["ps", "box-0", "p1-0"]).await;
1339+
1340+
assert_eq!(answer.code, 0);
1341+
assert_eq!(answer.out.trim(), "gone");
1342+
Ok(())
1343+
}
1344+
1345+
#[tokio::test]
1346+
async fn killing_a_process_that_has_already_exited_is_not_an_error() -> Result<()> {
1347+
// Stopping something already stopped is the outcome the caller wanted.
1348+
let dir = temp_dir()?;
1349+
invoke(dir.path(), &["create", "--dir", "/tmp"]).await;
1350+
1351+
let killed = invoke(dir.path(), &["kill", "box-0", "p1-0"]).await;
1352+
1353+
assert_eq!(killed.code, 0);
1354+
Ok(())
1355+
}
1356+
1357+
#[tokio::test]
1358+
async fn a_local_forward_reports_the_address_and_returns() -> Result<()> {
1359+
// Nothing is held open on a local host, so blocking would look like a
1360+
// working tunnel and be a hang.
1361+
let dir = temp_dir()?;
1362+
1363+
let forwarded = invoke(dir.path(), &["forward", "7788"]).await;
1364+
1365+
assert_eq!(forwarded.code, 0);
1366+
assert_eq!(forwarded.out.trim(), "127.0.0.1:7788");
1367+
Ok(())
1368+
}
1369+
1370+
#[tokio::test]
1371+
async fn spawning_into_a_sandbox_that_cannot_detach_is_refused() -> Result<()> {
1372+
// A namespace box is a record and a bound directory rather than a running
1373+
// container, so a backgrounded process would not survive to be found. It
1374+
// says so instead.
1375+
let dir = temp_dir()?;
1376+
let created = invoke(
1377+
dir.path(),
1378+
&["create", "--sandbox", "namespace", "--dir", "/tmp"],
1379+
)
1380+
.await;
1381+
if created.code != 0 {
1382+
return Ok(()); // No bubblewrap on this host.
1383+
}
1384+
1385+
let spawned = invoke(dir.path(), &["spawn", "box-0", "sleep", "30"]).await;
1386+
1387+
assert_eq!(spawned.code, EXIT_TINYBOX_ERROR);
1388+
assert!(
1389+
spawned.err.contains("detached processes"),
1390+
"{}",
1391+
spawned.err
1392+
);
1393+
Ok(())
1394+
}

0 commit comments

Comments
 (0)