Merge v3-t40: world box CLI, port of v2's box plugin (T40, P4)
This commit is contained in:
Generated
+22
@@ -304,6 +304,18 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "box-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"url",
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
@@ -4883,6 +4895,16 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init"]
|
||||
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box"]
|
||||
|
||||
# WASI compatibility patches: git/vendor/PATCHES.md.
|
||||
[patch.crates-io]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "box-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "box"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
wasi = "0.14"
|
||||
url = "2"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
@@ -0,0 +1,118 @@
|
||||
# box
|
||||
|
||||
`box` runs builds and tests in disposable containers through the containers service at `citadel.vpn.surma.technology:8093`.
|
||||
All network operations use standard `wasi:http`. The component has no Sausage host imports.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
box create [--flake reference] [--ttl seconds]
|
||||
box exec ID [--timeout seconds] [--inline-bytes bytes] -- COMMAND ARGUMENTS...
|
||||
box put ID WORLD_PATH BOX_PATH
|
||||
box get ID BOX_PATH WORLD_PATH
|
||||
box logs ID
|
||||
box ls
|
||||
box rm ID
|
||||
```
|
||||
|
||||
Every command accepts `--host hostname[:port]`. The default port is 8093.
|
||||
Create remembers the host in `/var/lib/box/hosts/<id>`. Separate files prevent unrelated creates from replacing each other’s mappings.
|
||||
Other commands use the remembered host unless `--host` overrides it. Unknown IDs use the default service.
|
||||
Host mappings remain after deletion for diagnostics. They contain no credentials.
|
||||
|
||||
Create prints the ID and waits up to 240 seconds for readiness.
|
||||
Without a flake, it uses v2’s Node/NixOS configuration with Node, npm, Git, Chromium, fonts, and tar.
|
||||
A flake selects a service-side configuration, for example `github:OWNER/REPO#container`.
|
||||
TTL is positive seconds from creation. The service defaults to 3600 seconds.
|
||||
A failed or timed-out create reports its accepted ID. Do not repeat create blindly. Inspect logs and remove that ID.
|
||||
|
||||
Exec runs as root. It passes an argv array without shell interpolation.
|
||||
For shell syntax, use `-- sh -c 'COMMAND'`. The timeout defaults to 60 seconds and accepts 1 through 1800 seconds.
|
||||
Exec prints stdout and stderr separately and returns the command’s exit code.
|
||||
It does not consume local stdin. Use put for inputs.
|
||||
|
||||
Put detects a local directory. Get detects a remote directory.
|
||||
File transfers support binary bytes and a 64 MiB limit. Downloads replace a destination file only after complete validation.
|
||||
Tree transfers use an uncompressed tar with a 64 MiB limit. Put merges into a box directory.
|
||||
Get requires a new world directory and publishes it only after complete extraction.
|
||||
The port preserves v2’s portable file modes and hardlink materialization.
|
||||
Get rejects symlinks because the world VFS cannot create them. Export a dereferenced tree when necessary.
|
||||
Special files and paths outside the destination fail explicitly.
|
||||
Temporary tar files disappear after a transfer, including failures where cleanup remains possible.
|
||||
|
||||
## Build a pnpm project in a box
|
||||
|
||||
```sh
|
||||
id=$(box create --ttl 1800)
|
||||
box put "$id" /repos/project /work
|
||||
box exec "$id" --timeout 600 -- sh -c 'cd /work && npm install --global pnpm && pnpm install --frozen-lockfile && pnpm test && pnpm build'
|
||||
box get "$id" /work/dist /repos/project-dist
|
||||
box logs "$id"
|
||||
box rm "$id"
|
||||
```
|
||||
|
||||
Use the same ID to inspect failed builds. Always remove disposable boxes when the work ends.
|
||||
Boxes have open network access. The world’s host credential boundary remains unchanged.
|
||||
|
||||
## Output retention
|
||||
|
||||
The CLI preserves v2’s full output archives independently of presentation.
|
||||
It downloads both raw streams for new-service executions, including small and binary output.
|
||||
Files live under `/logs/box/YYYY-MM-DD/<id>-<exec>.stdout` and `.stderr`.
|
||||
Collision snapshots receive unique suffixes. Existing archives never change.
|
||||
Result JSON retains original service excerpts before the CLI bounds presentation.
|
||||
The CLI prints archive paths and availability notices on stderr.
|
||||
|
||||
Each displayed stream defaults to a 16 KiB UTF-8 head and tail budget.
|
||||
The omission marker is outside that budget. `--inline-bytes 0` prints only the marker for nonempty output.
|
||||
A larger budget reads archives, not an already-truncated service excerpt. UTF-8 replacement remains bounded.
|
||||
Full archives have no retention cap. Transfer limits do not cap execution archives.
|
||||
|
||||
Downloads request 1 MiB ranges and validate every Content-Range page and exact byte total.
|
||||
They also accept a complete 200 response from a server that ignores Range.
|
||||
A shared deadline allows 60 seconds plus one second per 256 KiB of combined output, capped at 30 minutes.
|
||||
Unknown totals receive five minutes. Only immutable archive GETs retry. Exec POSTs never retry.
|
||||
Incomplete archives retain `.partial` names. Only complete, flushed downloads receive published paths.
|
||||
|
||||
Build logs try `/log/full`. Only 404 or 405 triggers `/log` fallback.
|
||||
The CLI retains all available raw build bytes and displays at most their last 16 KiB.
|
||||
A fallback warning states that earlier bytes may be unavailable.
|
||||
Failed creates archive build diagnostics. Explicit removal attempts a build snapshot but proceeds if that snapshot fails.
|
||||
|
||||
Old services expose only JSON stream text. The CLI saves all available text before presentation limits.
|
||||
It warns when upstream truncation discarded bytes. Those missing bytes cannot be restored.
|
||||
Archive failures return the completed command and an availability notice. Never replay a command to repair its archive.
|
||||
|
||||
## Build and validation
|
||||
|
||||
Build the shared startup object from the repository root:
|
||||
|
||||
```sh
|
||||
nix develop -c nu --no-config-file userland/cwd-init/build.nu
|
||||
```
|
||||
|
||||
Run the following commands from `userland/` so Cargo loads the shared startup and numeric exit support:
|
||||
|
||||
```sh
|
||||
df -h /
|
||||
export CARGO_TARGET_DIR=/home/surma/src/gitea.surma.technology/surma/sausage/target/p1-tools
|
||||
nix develop -c cargo build -p box-cli --target wasm32-wasip2 --release --locked
|
||||
nix develop -c kache stats
|
||||
nix develop -c nu box/test.nu
|
||||
```
|
||||
|
||||
The existing `userland/bundle.nu` discovers the workspace member and copies its `box` binary into `/bin`.
|
||||
It applies the same cwd initializer as other bundled programs.
|
||||
|
||||
Keep the same target directory for tests. Do not override `CARGO_HOME` or `RUSTC_WRAPPER`.
|
||||
Stop before a build if disk use exceeds 85%.
|
||||
Run pure unit tests with the native target from `rustc -vV`, for example:
|
||||
|
||||
```sh
|
||||
nix develop -c cargo test -p box-cli --target aarch64-unknown-linux-gnu --locked
|
||||
```
|
||||
|
||||
The real-service script tests every required command and removes every created box, including after an assertion failure.
|
||||
It round-trips files and nested trees, runs a tiny Node project through npm test, and retrieves its result.
|
||||
It also verifies large binary archive retention and WASI-only component imports.
|
||||
The script preserves its temporary world and archives as evidence.
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Raw archive downloads. Range pages are validated before advancing; no byte caps.
|
||||
use crate::{Result, error, http, retention};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use wasi::http::types::Method;
|
||||
|
||||
pub struct Download {
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
// Only immutable archive GETs are retried, never the command POST. Divide the
|
||||
// remaining total budget so a stalled first attempt cannot consume every retry.
|
||||
// Failed partial files remain explicitly partial; only verified downloads publish.
|
||||
pub fn exec_download(
|
||||
h: &str,
|
||||
url: &str,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
deadline: u64,
|
||||
expected: Option<u64>,
|
||||
) -> Result<Download> {
|
||||
let mut last = error("exec archive deadline exceeded");
|
||||
for remaining in (1..=3).rev() {
|
||||
let now = http::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
let attempt_deadline = now.saturating_add((deadline - now) / remaining);
|
||||
match download_verified(h, url, name, extension, attempt_deadline, false, expected) {
|
||||
Ok(Some(saved)) => return Ok(saved),
|
||||
Ok(None) => return Err(error("missing exec archive")),
|
||||
Err(e) => {
|
||||
// Validation/local storage errors are not transient network failures.
|
||||
let retry = [
|
||||
"HTTP failed:",
|
||||
"HTTP start:",
|
||||
"archive download failed:",
|
||||
"deadline exceeded",
|
||||
"incomplete archive page",
|
||||
"HTTP 500",
|
||||
"HTTP 502",
|
||||
"HTTP 503",
|
||||
"HTTP 504",
|
||||
]
|
||||
.iter()
|
||||
.any(|message| e.message.contains(message));
|
||||
if !retry {
|
||||
return Err(e);
|
||||
}
|
||||
eprintln!(
|
||||
"box archive GET attempt {} failed: {}",
|
||||
4 - remaining,
|
||||
e.message
|
||||
);
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
pub fn download(
|
||||
h: &str,
|
||||
url: &str,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
deadline: u64,
|
||||
missing: bool,
|
||||
) -> Result<Option<Download>> {
|
||||
download_verified(h, url, name, extension, deadline, missing, None)
|
||||
}
|
||||
|
||||
pub fn download_verified(
|
||||
h: &str,
|
||||
url: &str,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
deadline: u64,
|
||||
missing: bool,
|
||||
expected: Option<u64>,
|
||||
) -> Result<Option<Download>> {
|
||||
let mut offset = 0u64;
|
||||
let mut total = None;
|
||||
let mut output = None;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let range = format!("bytes={offset}-{}", offset.saturating_add(1024 * 1024 - 1));
|
||||
let mut response = http::request_options(
|
||||
h,
|
||||
Method::Get,
|
||||
url,
|
||||
None,
|
||||
30,
|
||||
Some(deadline),
|
||||
Some(&range),
|
||||
missing && offset == 0,
|
||||
)?;
|
||||
if matches!(response.status, 404 | 405) {
|
||||
return Ok(None);
|
||||
}
|
||||
let page = if response.status == 206 {
|
||||
let (start, end, size) =
|
||||
parse_range(response.content_range.as_deref().unwrap_or(""))?;
|
||||
if start != offset || total.is_some_and(|t| t != size) {
|
||||
return Err(error("inconsistent archive range"));
|
||||
}
|
||||
total = Some(size);
|
||||
Some(end - start + 1)
|
||||
} else if response.status == 200 && offset == 0 {
|
||||
None
|
||||
} else {
|
||||
return Err(error("unexpected archive response status"));
|
||||
};
|
||||
if output.is_none() {
|
||||
output = Some(retention::create(name, &format!("{extension}.partial"))?);
|
||||
}
|
||||
let (path, file) = output
|
||||
.as_mut()
|
||||
.ok_or_else(|| error("archive file unavailable"))?;
|
||||
let count = std::io::copy(&mut response, file).map_err(|e| {
|
||||
error(format!(
|
||||
"archive download failed: {e}; partial file: {path}"
|
||||
))
|
||||
})?;
|
||||
if page.is_some_and(|n| n != count) {
|
||||
return Err(error(format!(
|
||||
"incomplete archive page; partial file: {path}"
|
||||
)));
|
||||
}
|
||||
offset = offset
|
||||
.checked_add(count)
|
||||
.ok_or_else(|| error("archive size overflow"))?;
|
||||
if page.is_none() || total == Some(offset) {
|
||||
if expected.is_some_and(|n| n != offset) {
|
||||
return Err(error("archive byte total mismatch"));
|
||||
}
|
||||
file.flush()?;
|
||||
// Reserve a collision-safe final name; replace only our own empty reservation.
|
||||
let (complete, reservation) = retention::create(name, extension)?;
|
||||
drop(reservation);
|
||||
if let Err(e) = std::fs::rename(&*path, &complete) {
|
||||
let _ = std::fs::remove_file(&complete);
|
||||
return Err(e.into());
|
||||
}
|
||||
return Ok(Some(Download {
|
||||
path: complete,
|
||||
bytes: offset,
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
result.map_err(|mut e: crate::Error| {
|
||||
if let Some((path, _)) = output {
|
||||
e.message.push_str(&format!("; incomplete archive: {path}"));
|
||||
}
|
||||
e
|
||||
})
|
||||
}
|
||||
fn parse_range(value: &str) -> Result<(u64, u64, u64)> {
|
||||
let parsed = (|| {
|
||||
let (span, size) = value.strip_prefix("bytes ")?.split_once('/')?;
|
||||
let (start, end) = span.split_once('-')?;
|
||||
Some((
|
||||
start.parse::<u64>().ok()?,
|
||||
end.parse::<u64>().ok()?,
|
||||
size.parse::<u64>().ok()?,
|
||||
))
|
||||
})();
|
||||
match parsed {
|
||||
Some((s, e, n)) if s <= e && e < n => Ok((s, e, n)),
|
||||
_ => Err(error("invalid archive Content-Range")),
|
||||
}
|
||||
}
|
||||
|
||||
// Presentation limit only: the complete raw response is always retained on disk.
|
||||
pub const BUILD_EXCERPT_BYTES: u64 = 16 * 1024;
|
||||
fn build_result(full: Download, service_truncated: bool) -> Result<crate::Log> {
|
||||
let mut file = std::fs::File::open(&full.path)?;
|
||||
let start = full.bytes.saturating_sub(BUILD_EXCERPT_BYTES);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(BUILD_EXCERPT_BYTES).read_to_end(&mut bytes)?;
|
||||
let mut notices = Vec::new();
|
||||
if service_truncated {
|
||||
notices.push("Full build-log endpoint unavailable (unsupported service or missing archive); legacy endpoint may return only the last 64 KiB, so earlier bytes may be unavailable.".to_owned());
|
||||
}
|
||||
if start > 0 {
|
||||
notices.push(format!("Inline tail shows raw byte range {start}..{} (end exclusive); full {} bytes retained at {}.", full.bytes, full.bytes, full.path));
|
||||
}
|
||||
Ok(crate::Log {
|
||||
text: String::from_utf8_lossy(&bytes).into_owned(),
|
||||
path: full.path,
|
||||
bytes: full.bytes,
|
||||
service_truncated,
|
||||
notice: if notices.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(notices.join(" "))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build(h: &str, id: &str, deadline: u64) -> Result<crate::Log> {
|
||||
let base = crate::base(id)?;
|
||||
let name = format!("{id}-build");
|
||||
if let Some(full) = download(h, &format!("{base}/log/full"), &name, "log", deadline, true)? {
|
||||
return build_result(full, false);
|
||||
}
|
||||
let legacy = download(h, &format!("{base}/log"), &name, "log", deadline, false)?
|
||||
.ok_or_else(|| error("missing legacy build log"))?;
|
||||
build_result(legacy, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn range_pages_require_valid_totals() {
|
||||
assert_eq!(
|
||||
parse_range("bytes 0-1048575/1200003").unwrap(),
|
||||
(0, 1048575, 1200003)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_range("bytes 1048576-1200002/1200003").unwrap(),
|
||||
(1048576, 1200002, 1200003)
|
||||
);
|
||||
for invalid in [
|
||||
"",
|
||||
"bytes 1-0/2",
|
||||
"bytes 0-2/2",
|
||||
"bytes 0-0/0",
|
||||
"bytes 0-1/*",
|
||||
] {
|
||||
assert!(parse_range(invalid).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Command arguments and presentation. Raw execution archives never depend on the inline budget.
|
||||
use crate::*;
|
||||
use clap::{Parser, Subcommand};
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "box", about = "Disposable build and test boxes")]
|
||||
pub struct Args {
|
||||
#[arg(long, global = true, help = "Containers service hostname[:port]")]
|
||||
pub host: Option<String>,
|
||||
#[command(subcommand)]
|
||||
pub command: Operation,
|
||||
}
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Operation {
|
||||
Create {
|
||||
#[arg(long)]
|
||||
flake: Option<String>,
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
ttl: Option<u32>,
|
||||
},
|
||||
Exec {
|
||||
id: String,
|
||||
#[arg(long, default_value_t = 60, value_parser = clap::value_parser!(u32).range(1..=1800))]
|
||||
timeout: u32,
|
||||
#[arg(long, default_value_t = excerpt::DEFAULT_BYTES)]
|
||||
inline_bytes: u32,
|
||||
#[arg(last = true, required = true)]
|
||||
cmd: Vec<String>,
|
||||
},
|
||||
Put {
|
||||
id: String,
|
||||
world_path: String,
|
||||
box_path: String,
|
||||
},
|
||||
Get {
|
||||
id: String,
|
||||
box_path: String,
|
||||
world_path: String,
|
||||
},
|
||||
Logs {
|
||||
id: String,
|
||||
},
|
||||
Ls,
|
||||
Rm {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
fn notices(result: &Executed) {
|
||||
for (name, path) in [
|
||||
("stdout", &result.stdout_path),
|
||||
("stderr", &result.stderr_path),
|
||||
("result", &result.output_log),
|
||||
] {
|
||||
if let Some(path) = path {
|
||||
eprintln!("box {name} archive: {path}");
|
||||
}
|
||||
}
|
||||
if let Some(notice) = &result.output_notice {
|
||||
eprintln!("box: {notice}");
|
||||
}
|
||||
if result.timed_out {
|
||||
eprintln!("box: command timed out");
|
||||
}
|
||||
}
|
||||
pub fn run(args: Args) -> Result<i32> {
|
||||
let h = args.host;
|
||||
let service = BoxPlugin;
|
||||
match args.command {
|
||||
Operation::Create { flake, ttl } => {
|
||||
let config = Config {
|
||||
nixos: flake.is_none().then(|| Nixos::Text(NODE_MODULE.into())),
|
||||
flake,
|
||||
system: None,
|
||||
};
|
||||
let c = service.create(Create {
|
||||
config,
|
||||
ttl_secs: ttl,
|
||||
owner: Some("box CLI".into()),
|
||||
host: h,
|
||||
})?;
|
||||
println!("{}", c.id);
|
||||
if c.state != "running" {
|
||||
if let Some(tail) = c.build_log_tail {
|
||||
eprintln!("{tail}");
|
||||
}
|
||||
if let Some(path) = c.build_log_path {
|
||||
eprintln!("box build archive: {path}");
|
||||
}
|
||||
return Err(error(format!(
|
||||
"box {} failed: {}; remove with box rm",
|
||||
c.id,
|
||||
c.error.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Operation::Exec {
|
||||
id,
|
||||
timeout,
|
||||
inline_bytes,
|
||||
cmd,
|
||||
} => {
|
||||
let result = service.exec(Exec {
|
||||
id,
|
||||
cmd: Command::Argv(cmd),
|
||||
stdin: None,
|
||||
timeout_secs: Some(timeout),
|
||||
inline_bytes: Some(inline_bytes),
|
||||
host: h,
|
||||
})?;
|
||||
std::io::stdout().write_all(result.stdout.as_bytes())?;
|
||||
std::io::stderr().write_all(result.stderr.as_bytes())?;
|
||||
notices(&result);
|
||||
return Ok(result.exit);
|
||||
}
|
||||
Operation::Put {
|
||||
id,
|
||||
world_path,
|
||||
box_path,
|
||||
} => {
|
||||
if Path::new(&world_path).is_dir() {
|
||||
service.put_tree(PutTree {
|
||||
id,
|
||||
from_vfs_dir: world_path,
|
||||
to_dir: box_path,
|
||||
host: h,
|
||||
})?;
|
||||
} else {
|
||||
service.put_file(PutFile {
|
||||
id,
|
||||
path: box_path,
|
||||
text: None,
|
||||
from_vfs: Some(world_path),
|
||||
host: h,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Operation::Get {
|
||||
id,
|
||||
box_path,
|
||||
world_path,
|
||||
} => {
|
||||
let host = resolve(&id, h.as_deref())?;
|
||||
let kind = checked(
|
||||
&host,
|
||||
&id,
|
||||
json!([
|
||||
"sh",
|
||||
"-c",
|
||||
"if [ -d \"$1\" ]; then printf directory; elif [ -f \"$1\" ]; then printf file; else exit 1; fi",
|
||||
"sh",
|
||||
box_path
|
||||
]),
|
||||
)?;
|
||||
if kind == "directory" {
|
||||
service.get_tree(GetTree {
|
||||
id,
|
||||
from_dir: box_path,
|
||||
to_vfs_dir: world_path,
|
||||
host: h,
|
||||
})?;
|
||||
} else {
|
||||
service.get_file(GetFile {
|
||||
id,
|
||||
path: box_path,
|
||||
to_vfs: Some(world_path),
|
||||
host: h,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Operation::Logs { id } => {
|
||||
let log = service.log(Id { id, host: h })?;
|
||||
print!("{}", log.text);
|
||||
eprintln!("box build archive: {} ({} bytes)", log.path, log.bytes);
|
||||
if let Some(notice) = log.notice {
|
||||
eprintln!("box: {notice}");
|
||||
}
|
||||
}
|
||||
Operation::Ls => {
|
||||
let list = service.list(List { host: h })?;
|
||||
for c in list.containers {
|
||||
println!("{}\t{}\t{}", c.id, c.state, c.expires_at);
|
||||
}
|
||||
}
|
||||
Operation::Rm { id } => {
|
||||
service.delete(Id { id, host: h })?;
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn argument_forms_and_validation() {
|
||||
for args in [
|
||||
vec!["box", "create"],
|
||||
vec![
|
||||
"box",
|
||||
"create",
|
||||
"--flake",
|
||||
"github:example/project#container",
|
||||
"--ttl",
|
||||
"600",
|
||||
],
|
||||
vec![
|
||||
"box",
|
||||
"exec",
|
||||
"tc-abc123",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--",
|
||||
"sh",
|
||||
"-c",
|
||||
"exit 7",
|
||||
],
|
||||
vec!["box", "put", "tc-abc123", "dir", "/work"],
|
||||
vec!["box", "get", "tc-abc123", "/work", "out"],
|
||||
vec!["box", "logs", "tc-abc123"],
|
||||
vec!["box", "ls"],
|
||||
vec!["box", "rm", "tc-abc123"],
|
||||
vec!["box", "--host", "localhost:8093", "ls"],
|
||||
] {
|
||||
assert!(Args::try_parse_from(args).is_ok());
|
||||
}
|
||||
for args in [
|
||||
vec!["box", "create", "--ttl", "0"],
|
||||
vec!["box", "exec", "tc-abc123", "--"],
|
||||
vec![
|
||||
"box",
|
||||
"exec",
|
||||
"tc-abc123",
|
||||
"--timeout",
|
||||
"1801",
|
||||
"--",
|
||||
"true",
|
||||
],
|
||||
vec!["box", "exec", "tc-abc123", "true"],
|
||||
vec!["box", "put", "tc-abc123", "a"],
|
||||
vec!["box", "unknown"],
|
||||
] {
|
||||
assert!(Args::try_parse_from(args).is_err());
|
||||
}
|
||||
let args = Args::try_parse_from([
|
||||
"box",
|
||||
"exec",
|
||||
"tc-abc123",
|
||||
"--inline-bytes",
|
||||
"0",
|
||||
"--",
|
||||
"echo",
|
||||
"--flag",
|
||||
"space here",
|
||||
])
|
||||
.unwrap();
|
||||
match args.command {
|
||||
Operation::Exec {
|
||||
cmd, inline_bytes, ..
|
||||
} => {
|
||||
assert_eq!(cmd, ["echo", "--flag", "space here"]);
|
||||
assert_eq!(inline_bytes, 0);
|
||||
}
|
||||
_ => panic!("wrong operation"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Presentation only. Never rewrite archives or change internal command output.
|
||||
use crate::{Executed, Result};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
pub const DEFAULT_BYTES: u32 = 16 * 1024;
|
||||
const MARKER: &str =
|
||||
"\n... [inline output omitted; see stream archive path for full available output] ...\n";
|
||||
|
||||
fn head(text: &str, bytes: usize) -> &str {
|
||||
let mut end = bytes.min(text.len());
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&text[..end]
|
||||
}
|
||||
fn tail(text: &str, bytes: usize) -> &str {
|
||||
let mut start = text.len().saturating_sub(bytes);
|
||||
while !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
&text[start..]
|
||||
}
|
||||
fn bounded(text: &str, budget: usize) -> (String, bool) {
|
||||
if text.len() <= budget {
|
||||
return (text.to_owned(), false);
|
||||
}
|
||||
let first = budget.div_ceil(2);
|
||||
(
|
||||
format!(
|
||||
"{}{MARKER}{}",
|
||||
head(text, first),
|
||||
tail(text, budget - first)
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
fn from_file(path: &str, budget: usize) -> Result<(String, bool)> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let size = file.metadata()?.len();
|
||||
if size <= budget as u64 {
|
||||
let mut bytes = Vec::new();
|
||||
file.take(budget as u64).read_to_end(&mut bytes)?;
|
||||
return Ok(bounded(&String::from_utf8_lossy(&bytes), budget));
|
||||
}
|
||||
let first = budget.div_ceil(2);
|
||||
let last = budget - first;
|
||||
let mut begin = Vec::new();
|
||||
(&mut file).take(first as u64).read_to_end(&mut begin)?;
|
||||
file.seek(SeekFrom::Start(size - last as u64))?;
|
||||
let mut end = Vec::new();
|
||||
file.take(last as u64).read_to_end(&mut end)?;
|
||||
// UTF-8 replacement can expand binary input; bound displayed UTF-8 too.
|
||||
Ok((
|
||||
format!(
|
||||
"{}{MARKER}{}",
|
||||
head(&String::from_utf8_lossy(&begin), first),
|
||||
tail(&String::from_utf8_lossy(&end), last)
|
||||
),
|
||||
true,
|
||||
))
|
||||
}
|
||||
pub fn apply(result: &mut Executed, budget: u32) {
|
||||
let service_cut = result.truncated;
|
||||
result.truncated = result.service_truncated;
|
||||
for (stream, text, path) in [
|
||||
("stdout", &mut result.stdout, &result.stdout_path),
|
||||
("stderr", &mut result.stderr, &result.stderr_path),
|
||||
] {
|
||||
let excerpt = path.as_deref().map(|p| from_file(p, budget as usize));
|
||||
let (display, cut) = match excerpt {
|
||||
Some(Ok(value)) => value,
|
||||
Some(Err(e)) => {
|
||||
result
|
||||
.output_notice
|
||||
.get_or_insert_with(String::new)
|
||||
.push_str(&format!(
|
||||
"{stream} excerpt could not read archive: {}; showing service excerpt. ",
|
||||
e.message
|
||||
));
|
||||
let (display, cut) = bounded(text, budget as usize);
|
||||
(display, cut || service_cut)
|
||||
}
|
||||
None => {
|
||||
let (display, cut) = bounded(text, budget as usize);
|
||||
(display, cut || service_cut)
|
||||
}
|
||||
};
|
||||
*text = display;
|
||||
result.truncated |= cut;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn utf8_and_tiny_budgets_are_bounded() {
|
||||
for budget in 0..40 {
|
||||
let text = "🦀é".repeat(20);
|
||||
let (value, cut) = bounded(&text, budget);
|
||||
assert!(cut);
|
||||
assert!(value.len() <= budget + MARKER.len());
|
||||
assert!(value.contains(MARKER));
|
||||
}
|
||||
assert_eq!(bounded("", 0), (String::new(), false));
|
||||
assert_eq!(bounded("small", 5), ("small".into(), false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Small synchronous wasi:http adapter, following git's stream lifecycle.
|
||||
use crate::{Result, error};
|
||||
use std::io::{self, Read};
|
||||
use wasi::http::{outgoing_handler, types::*};
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { pub static SERVER: std::cell::RefCell<Option<Box<dyn FnMut()>>> = Default::default(); }
|
||||
|
||||
// A call shares one monotonic deadline across response headers/body.
|
||||
pub fn now() -> u64 {
|
||||
wasi::clocks::monotonic_clock::now()
|
||||
}
|
||||
fn wait(ready: &wasi::io::poll::Pollable, deadline: Option<u64>) -> io::Result<()> {
|
||||
if let Some(deadline) = deadline {
|
||||
if now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"box HTTP deadline exceeded",
|
||||
));
|
||||
}
|
||||
let timer = wasi::clocks::monotonic_clock::subscribe_instant(deadline);
|
||||
wasi::io::poll::poll(&[ready, &timer]);
|
||||
if now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"box HTTP deadline exceeded",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
ready.block();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub struct Incoming {
|
||||
pub status: u16,
|
||||
pub content_range: Option<String>,
|
||||
stream: Option<wasi::io::streams::InputStream>,
|
||||
body: Option<IncomingBody>,
|
||||
deadline: Option<u64>,
|
||||
}
|
||||
impl Read for Incoming {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let Some(stream) = self.stream.as_ref() else {
|
||||
return Ok(0);
|
||||
};
|
||||
loop {
|
||||
wait(&stream.subscribe(), self.deadline)?;
|
||||
match stream.read(buf.len().min(65536) as u64) {
|
||||
Ok(b) if b.is_empty() => continue,
|
||||
Ok(b) => {
|
||||
buf[..b.len()].copy_from_slice(&b);
|
||||
return Ok(b.len());
|
||||
}
|
||||
Err(wasi::io::streams::StreamError::Closed) => {
|
||||
self.stream.take();
|
||||
if let Some(body) = self.body.take() {
|
||||
let trailers = IncomingBody::finish(body);
|
||||
wait(&trailers.subscribe(), self.deadline)?;
|
||||
if !matches!(trailers.get(), Some(Ok(Ok(_)))) {
|
||||
return Err(io::Error::other("HTTP response body failed"));
|
||||
}
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
Err(_) => return Err(io::Error::other("HTTP read failed or timed out")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Incoming {
|
||||
fn drop(&mut self) {
|
||||
self.stream.take();
|
||||
self.body.take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request(
|
||||
host: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
input: Option<(&mut dyn Read, u64)>,
|
||||
secs: u32,
|
||||
) -> Result<Incoming> {
|
||||
request_until(host, method, path, input, secs, None)
|
||||
}
|
||||
pub fn request_until(
|
||||
host: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
input: Option<(&mut dyn Read, u64)>,
|
||||
secs: u32,
|
||||
deadline: Option<u64>,
|
||||
) -> Result<Incoming> {
|
||||
request_options(host, method, path, input, secs, deadline, None, false)
|
||||
}
|
||||
// Keep the wire options explicit, as in the v2 adapter.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn request_options(
|
||||
host: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
mut input: Option<(&mut dyn Read, u64)>,
|
||||
secs: u32,
|
||||
deadline: Option<u64>,
|
||||
range: Option<&str>,
|
||||
allow_missing: bool,
|
||||
) -> Result<Incoming> {
|
||||
let fields = Fields::new();
|
||||
if let Some(range) = range {
|
||||
fields
|
||||
.append("range", range.as_bytes())
|
||||
.map_err(|_| error("invalid range"))?;
|
||||
}
|
||||
fields
|
||||
.append(
|
||||
"content-length",
|
||||
&input
|
||||
.as_ref()
|
||||
.map_or(0, |(_, len)| *len)
|
||||
.to_string()
|
||||
.into_bytes(),
|
||||
)
|
||||
.map_err(|_| error("invalid length"))?;
|
||||
if input.is_some() {
|
||||
fields
|
||||
.append("content-type", b"application/json")
|
||||
.map_err(|_| error("invalid content type"))?;
|
||||
}
|
||||
let req = OutgoingRequest::new(fields);
|
||||
req.set_method(&method)
|
||||
.map_err(|_| error("invalid method"))?;
|
||||
req.set_scheme(Some(&Scheme::Http))
|
||||
.map_err(|_| error("invalid scheme"))?;
|
||||
req.set_authority(Some(host))
|
||||
.map_err(|_| error("invalid host"))?;
|
||||
req.set_path_with_query(Some(path))
|
||||
.map_err(|_| error("invalid path"))?;
|
||||
let body = req
|
||||
.body()
|
||||
.map_err(|_| error("HTTP request body unavailable"))?;
|
||||
let options = RequestOptions::new();
|
||||
options
|
||||
.set_connect_timeout(Some(5_000_000_000))
|
||||
.map_err(|_| error("connect timeout unsupported"))?;
|
||||
options
|
||||
.set_first_byte_timeout(Some((secs as u64 + 30) * 1_000_000_000))
|
||||
.map_err(|_| error("timeout unsupported"))?;
|
||||
options
|
||||
.set_between_bytes_timeout(Some(30_000_000_000))
|
||||
.map_err(|_| error("timeout unsupported"))?;
|
||||
let future = outgoing_handler::handle(req, Some(options))
|
||||
.map_err(|e| error(format!("HTTP start: {e:?}")))?;
|
||||
if let Some((reader, _)) = input.as_mut() {
|
||||
let stream = body.write().map_err(|_| error("HTTP body not writable"))?;
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
{
|
||||
let mut offset = 0;
|
||||
while offset < n {
|
||||
wait(&stream.subscribe(), deadline)?;
|
||||
let available = stream
|
||||
.check_write()
|
||||
.map_err(|_| error("HTTP write failed"))?
|
||||
as usize;
|
||||
let count = available.min(n - offset);
|
||||
if count > 0 {
|
||||
stream
|
||||
.write(&buf[offset..offset + count])
|
||||
.map_err(|_| error("HTTP write failed"))?;
|
||||
offset += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(stream);
|
||||
}
|
||||
OutgoingBody::finish(body, None).map_err(|_| error("HTTP finish failed"))?;
|
||||
#[cfg(test)]
|
||||
SERVER.with(|s| {
|
||||
if let Some(f) = s.borrow_mut().as_mut() {
|
||||
f();
|
||||
}
|
||||
});
|
||||
wait(&future.subscribe(), deadline)?;
|
||||
let response = future
|
||||
.get()
|
||||
.ok_or_else(|| error("HTTP not ready"))?
|
||||
.map_err(|_| error("HTTP already consumed"))?
|
||||
.map_err(|e| error(format!("HTTP failed: {e:?}")))?;
|
||||
let status = response.status();
|
||||
let content_range = response
|
||||
.headers()
|
||||
.get("content-range")
|
||||
.first()
|
||||
.map(|v| String::from_utf8_lossy(v).into_owned());
|
||||
let body = response
|
||||
.consume()
|
||||
.map_err(|_| error("HTTP body unavailable"))?;
|
||||
let stream = body
|
||||
.stream()
|
||||
.map_err(|_| error("HTTP stream unavailable"))?;
|
||||
let mut incoming = Incoming {
|
||||
status,
|
||||
content_range,
|
||||
stream: Some(stream),
|
||||
body: Some(body),
|
||||
deadline,
|
||||
};
|
||||
if !(200..300).contains(&status) && !(allow_missing && matches!(status, 404 | 405)) {
|
||||
let mut bytes = Vec::new();
|
||||
incoming.read_to_end(&mut bytes)?;
|
||||
return match crate::retention::save("box", "http-error", &bytes) {
|
||||
Ok(path) => {
|
||||
let reason = if bytes.len() <= 1024 {
|
||||
format!("; {}", String::from_utf8_lossy(&bytes))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Err(error(format!(
|
||||
"box service {host}: HTTP {status}{reason}; {} bytes; full response: {path}",
|
||||
bytes.len()
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(error(format!(
|
||||
"box service {host}: HTTP {status}; archive failed: {e}; full response: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
))),
|
||||
};
|
||||
}
|
||||
Ok(incoming)
|
||||
}
|
||||
|
||||
pub fn json<T: serde::de::DeserializeOwned>(
|
||||
host: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
value: Option<serde_json::Value>,
|
||||
secs: u32,
|
||||
) -> Result<T> {
|
||||
json_until(host, method, path, value, secs, None)
|
||||
}
|
||||
pub fn json_until<T: serde::de::DeserializeOwned>(
|
||||
host: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
value: Option<serde_json::Value>,
|
||||
secs: u32,
|
||||
deadline: Option<u64>,
|
||||
) -> Result<T> {
|
||||
let data = value.map(|v| serde_json::to_vec(&v)).transpose()?;
|
||||
let mut cursor = std::io::Cursor::new(data.as_deref().unwrap_or_default());
|
||||
let body = data
|
||||
.as_ref()
|
||||
.map(|d| (&mut cursor as &mut dyn Read, d.len() as u64));
|
||||
Ok(serde_json::from_reader(request_until(
|
||||
host, method, path, body, secs, deadline,
|
||||
)?)?)
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
//! Containers API, raw output archives, and bounded world transfers.
|
||||
pub mod cli;
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for Error {}
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
error(e.to_string())
|
||||
}
|
||||
}
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
error(e.to_string())
|
||||
}
|
||||
}
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
fn error(s: impl Into<String>) -> Error {
|
||||
Error {
|
||||
message: s.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
fn pid() -> String {
|
||||
std::env::var("SAUSAGE_PID").unwrap_or_else(|_| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
};
|
||||
use wasi::http::types::Method;
|
||||
mod archive;
|
||||
mod excerpt;
|
||||
mod http;
|
||||
mod retention;
|
||||
mod transfer;
|
||||
|
||||
const DEFAULT_HOST: &str = "citadel.vpn.surma.technology:8093";
|
||||
const MAX_FILE: u64 = 64 << 20;
|
||||
pub const NODE_MODULE: &str = r#"{ pkgs, ... }: {
|
||||
environment.systemPackages = with pkgs; [ nodejs git chromium fontconfig gnutar ];
|
||||
fonts.packages = [ pkgs.dejavu_fonts ];
|
||||
environment.variables = {
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH = "${pkgs.chromium}/bin/chromium";
|
||||
FONTCONFIG_FILE = "${pkgs.fontconfig.out}/etc/fonts/fonts.conf";
|
||||
FONTCONFIG_PATH = "${pkgs.fontconfig.out}/etc/fonts";
|
||||
};
|
||||
}"#;
|
||||
|
||||
// The service has two genuine JSON unions. Keep these wire types separate from
|
||||
// #[derive(serde::Serialize, serde::Deserialize)] structs; the SDK subset cannot express unions. No arbitrary result JSON.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Command {
|
||||
Shell(String),
|
||||
Argv(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Nixos {
|
||||
Text(String),
|
||||
Options(std::collections::BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
/// Exactly one Nix configuration source.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Config {
|
||||
/// Flake reference, optionally #configuration (default container).
|
||||
flake: Option<String>,
|
||||
/// NixOS module text or JSON option object.
|
||||
nixos: Option<Nixos>,
|
||||
/// Already built /nix/store system path on the service host.
|
||||
system: Option<String>,
|
||||
}
|
||||
/// Create a disposable container and wait for readiness.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Create {
|
||||
/// Exactly one of flake, nixos, system.
|
||||
config: Config,
|
||||
/// Lifetime from creation, service default 3600 seconds.
|
||||
ttl_secs: Option<u32>,
|
||||
/// Informational owner label.
|
||||
owner: Option<String>,
|
||||
/// Service hostname or hostname:port, default citadel:8093.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Select a service for listing boxes.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct List {
|
||||
/// Hostname or hostname:port; default citadel:8093.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Select a box; remembered host from create is used by default.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Id {
|
||||
/// Service-issued tc-xxxxxx ID.
|
||||
id: String,
|
||||
/// Override remembered host; default citadel:8093 for unknown IDs.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Service metadata, with build-log tail attached to failed creates.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Container {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// creating, running, updating or failed.
|
||||
state: String,
|
||||
/// Private container IP, accessible from service host.
|
||||
address: String,
|
||||
/// UTC creation timestamp.
|
||||
created_at: String,
|
||||
/// UTC expiry timestamp.
|
||||
expires_at: String,
|
||||
/// Informational owner.
|
||||
owner: String,
|
||||
/// Configuration used to create this container.
|
||||
config: Config,
|
||||
/// Service failure message.
|
||||
error: Option<String>,
|
||||
/// Bounded build-log tail when creation fails; full available bytes are at build_log_path.
|
||||
#[serde(default)]
|
||||
build_log_tail: Option<String>,
|
||||
/// Permanent archive of the build diagnostic returned by the service.
|
||||
#[serde(default)]
|
||||
build_log_path: Option<String>,
|
||||
/// Exact archived byte count, not a total for old-service tails.
|
||||
#[serde(default)]
|
||||
build_log_bytes: Option<u64>,
|
||||
/// True when the legacy endpoint was used and may have returned only a tail.
|
||||
#[serde(default)]
|
||||
build_log_service_truncated: Option<bool>,
|
||||
/// Inline-tail byte range or compatibility warning, if applicable.
|
||||
#[serde(default)]
|
||||
build_log_notice: Option<String>,
|
||||
}
|
||||
/// Service inventory.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Containers {
|
||||
/// Containers currently known to this host.
|
||||
containers: Vec<Container>,
|
||||
}
|
||||
/// Extend expiry to now plus ttl_secs (not additive).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Extend {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// Positive remaining lifetime.
|
||||
ttl_secs: u32,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Execute as root inside a running box.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Exec {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// Shell string (sh -c) or nonempty argv array.
|
||||
cmd: Command,
|
||||
/// UTF-8 standard input.
|
||||
stdin: Option<String>,
|
||||
/// Default 60, maximum 1800 seconds.
|
||||
timeout_secs: Option<u32>,
|
||||
/// Inline UTF-8 byte budget per stream (default 16384), excluding the omission marker. Zero omits stream text. Full archives are unaffected.
|
||||
inline_bytes: Option<u32>,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Command completion, including timeout/output-limit status.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Executed {
|
||||
/// Process exit code.
|
||||
exit: i32,
|
||||
/// Bounded stdout head and tail; full available bytes are in stdout_path.
|
||||
stdout: String,
|
||||
/// Bounded stderr head and tail; full available bytes are in stderr_path.
|
||||
stderr: String,
|
||||
/// Service killed the command after its timeout.
|
||||
timed_out: bool,
|
||||
/// Inline output was cut by the service or plugin; service_truncated distinguishes upstream loss.
|
||||
truncated: bool,
|
||||
/// Permanent archive of service result metadata and inline excerpts.
|
||||
#[serde(default)]
|
||||
output_log: Option<String>,
|
||||
/// Service execution identifier (new services only).
|
||||
#[serde(default)]
|
||||
exec_id: Option<String>,
|
||||
/// Exact raw stdout total on new services; available UTF-8 bytes on old services (which may have lost output).
|
||||
#[serde(default)]
|
||||
stdout_bytes: Option<u64>,
|
||||
/// Exact raw stderr total on new services; available UTF-8 bytes on old services (which may have lost output).
|
||||
#[serde(default)]
|
||||
stderr_bytes: Option<u64>,
|
||||
/// Service download reference when stdout exceeded the inline limit.
|
||||
#[serde(default)]
|
||||
stdout_url: Option<String>,
|
||||
/// Service download reference when stderr exceeded the inline limit.
|
||||
#[serde(default)]
|
||||
stderr_url: Option<String>,
|
||||
/// Permanent full raw stdout archive (available UTF-8 text on old services).
|
||||
#[serde(default)]
|
||||
stdout_path: Option<String>,
|
||||
/// Permanent full raw stderr archive (available UTF-8 text on old services).
|
||||
#[serde(default)]
|
||||
stderr_path: Option<String>,
|
||||
/// True if the old service discarded output beyond its cap.
|
||||
#[serde(default)]
|
||||
service_truncated: bool,
|
||||
/// Output availability or archival failure; execution already completed, do not replay.
|
||||
#[serde(default)]
|
||||
output_notice: Option<String>,
|
||||
}
|
||||
/// A successful mutation.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct OkResult {
|
||||
/// True on success.
|
||||
ok: bool,
|
||||
}
|
||||
/// Build log response.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct Log {
|
||||
/// At most the last 16 KiB of raw build bytes, displayed with UTF-8 replacement; full bytes are at path.
|
||||
text: String,
|
||||
/// Permanent world archive path.
|
||||
path: String,
|
||||
/// Exact byte count in the archived file.
|
||||
#[serde(default)]
|
||||
bytes: u64,
|
||||
/// Legacy endpoint used; the full build log may be unavailable.
|
||||
#[serde(default)]
|
||||
service_truncated: bool,
|
||||
/// Inline-tail byte range or compatibility warning, if applicable.
|
||||
#[serde(default)]
|
||||
notice: Option<String>,
|
||||
}
|
||||
/// Upload UTF-8 text or stream a VFS file.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PutFile {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// Absolute path inside the box.
|
||||
path: String,
|
||||
/// Inline text; exactly one of text and from_vfs is required.
|
||||
text: Option<String>,
|
||||
/// World file to stream, including binary data, at most 64 MiB.
|
||||
from_vfs: Option<String>,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Download a file, optionally streaming to the world.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct GetFile {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// Absolute file path inside the box (not a directory).
|
||||
path: String,
|
||||
/// World destination; atomically replaced only on successful complete download.
|
||||
to_vfs: Option<String>,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// File transfer result; payload omitted for VFS transfers.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct FileResult {
|
||||
/// Bytes transferred.
|
||||
bytes: u64,
|
||||
/// UTF-8 contents for inline downloads, capped at 64 KiB.
|
||||
text: Option<String>,
|
||||
/// Destination world path, if any.
|
||||
to_vfs: Option<String>,
|
||||
}
|
||||
/// Copy a world directory into the box using an uncompressed tar.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PutTree {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// World source directory.
|
||||
from_vfs_dir: String,
|
||||
/// Absolute destination inside the box. Existing files may be overwritten.
|
||||
to_dir: String,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Copy a box directory into a new world directory using tar.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct GetTree {
|
||||
/// Container ID.
|
||||
id: String,
|
||||
/// Absolute source directory inside the box.
|
||||
from_dir: String,
|
||||
/// New world destination directory; must not already exist.
|
||||
to_vfs_dir: String,
|
||||
/// Override remembered host.
|
||||
host: Option<String>,
|
||||
}
|
||||
/// Ready-to-use JavaScript and Chromium container.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct NodeBox {
|
||||
/// Lifetime in seconds; service default 3600.
|
||||
ttl_secs: Option<u32>,
|
||||
}
|
||||
/// Disposable build/test boxes. No background workers or startup side effects.
|
||||
pub struct BoxPlugin;
|
||||
|
||||
fn host(input: Option<&str>) -> Result<String> {
|
||||
let h = input.unwrap_or(DEFAULT_HOST);
|
||||
let h = if h.contains(':') {
|
||||
h.to_owned()
|
||||
} else {
|
||||
format!("{h}:8093")
|
||||
};
|
||||
let u =
|
||||
url::Url::parse(&format!("http://{h}")).map_err(|e| error(format!("invalid host: {e}")))?;
|
||||
if u.host_str().is_none() || u.path() != "/" || u.query().is_some() || u.fragment().is_some() {
|
||||
return Err(error("host must be hostname or hostname:port"));
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
fn base(id: &str) -> Result<String> {
|
||||
if id.len() != 9
|
||||
|| !id.starts_with("tc-")
|
||||
|| !id[3..]
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
|
||||
{
|
||||
return Err(error("invalid box ID; expected tc-xxxxxx"));
|
||||
}
|
||||
Ok(format!("/containers/{id}"))
|
||||
}
|
||||
const HOSTS: &str = "/var/lib/box/hosts";
|
||||
fn remember(id: &str, h: &str) -> Result<()> {
|
||||
base(id)?;
|
||||
std::fs::create_dir_all(HOSTS)?;
|
||||
let path = Path::new(HOSTS).join(id);
|
||||
if let Ok(old) = std::fs::read_to_string(&path) {
|
||||
if old != h {
|
||||
return Err(error(format!(
|
||||
"box ID collision for {id}; specify --host explicitly"
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let temp = path.with_extension(pid());
|
||||
std::fs::write(&temp, h)?;
|
||||
std::fs::rename(temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
fn resolve(id: &str, h: Option<&str>) -> Result<String> {
|
||||
base(id)?;
|
||||
if let Some(h) = h {
|
||||
return host(Some(h));
|
||||
}
|
||||
match std::fs::read_to_string(Path::new(HOSTS).join(id)) {
|
||||
Ok(h) => Ok(h),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DEFAULT_HOST.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
fn get(h: &str, id: &str) -> Result<Container> {
|
||||
http::json(h, Method::Get, &base(id)?, None, 30)
|
||||
}
|
||||
fn log(h: &str, id: &str) -> Result<Log> {
|
||||
archive::build(h, id, http::now().saturating_add(60_000_000_000))
|
||||
}
|
||||
// Shared stdout/stderr budget: 60 s overhead plus 1 s per 256 KiB, capped at
|
||||
// 30 min. Unknown totals get 5 min. HTTP idle timeouts still catch stalled peers.
|
||||
fn archive_budget_ns(stdout: Option<u64>, stderr: Option<u64>) -> u64 {
|
||||
let seconds = match (stdout, stderr) {
|
||||
(Some(out), Some(err)) => {
|
||||
60u64.saturating_add(out.saturating_add(err).div_ceil(256 * 1024))
|
||||
}
|
||||
_ => 300,
|
||||
};
|
||||
seconds.min(1800) * 1_000_000_000
|
||||
}
|
||||
fn execute(h: &str, id: &str, cmd: Value, stdin: Option<String>, secs: u32) -> Result<Executed> {
|
||||
let mut result: Executed = http::json(
|
||||
h,
|
||||
Method::Post,
|
||||
&format!("{}/exec", base(id)?),
|
||||
Some(
|
||||
json!({"cmd":cmd,"stdin":stdin.unwrap_or_default(),"timeout_secs":secs.clamp(1,1800)}),
|
||||
),
|
||||
secs.clamp(1, 1800),
|
||||
)?;
|
||||
result.service_truncated = result.truncated && result.exec_id.is_none();
|
||||
if result.service_truncated {
|
||||
result.output_notice = Some("Old service cut output at its stream cap; bytes beyond the inline output are unavailable.".into());
|
||||
}
|
||||
// Fetch raw streams, never concatenate JSON excerpts (which can replace binary/UTF-8 bytes).
|
||||
if let Some(exec_id) = result.exec_id.as_deref() {
|
||||
let deadline =
|
||||
http::now().saturating_add(archive_budget_ns(result.stdout_bytes, result.stderr_bytes));
|
||||
for (stream, url, count, path) in [
|
||||
(
|
||||
"stdout",
|
||||
&result.stdout_url,
|
||||
&mut result.stdout_bytes,
|
||||
&mut result.stdout_path,
|
||||
),
|
||||
(
|
||||
"stderr",
|
||||
&result.stderr_url,
|
||||
&mut result.stderr_bytes,
|
||||
&mut result.stderr_path,
|
||||
),
|
||||
] {
|
||||
// Known-empty streams need no network roundtrip (even if a URL was supplied).
|
||||
// Keep the usual on-disk contract for callers reading stdout_path.
|
||||
let name = format!("{id}-{exec_id}");
|
||||
let saved = if *count == Some(0) {
|
||||
retention::create(&name, stream)
|
||||
.map(|(path, _)| archive::Download { path, bytes: 0 })
|
||||
.map_err(Into::into)
|
||||
} else {
|
||||
// Small nonempty streams still need raw bytes: JSON may replace invalid UTF-8.
|
||||
let endpoint = url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("/containers/{id}/exec/{exec_id}/{stream}"));
|
||||
archive::exec_download(h, &endpoint, &name, stream, deadline, *count)
|
||||
};
|
||||
match saved {
|
||||
Ok(d) => {
|
||||
*path = Some(d.path);
|
||||
*count = Some(d.bytes);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"box {id} {stream} archive failed: {e}; command completed, do not replay"
|
||||
);
|
||||
let notice = result.output_notice.get_or_insert_with(String::new);
|
||||
notice.push_str(&format!(
|
||||
"{stream} archive unavailable: {}; command completed, do not replay. ",
|
||||
e.message
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.exec_id.is_none() {
|
||||
// Legacy services expose only JSON text. Preserve all available text before
|
||||
// bounding presentation, without claiming bytes discarded upstream exist.
|
||||
for (stream, text, path, count) in [
|
||||
(
|
||||
"stdout",
|
||||
&result.stdout,
|
||||
&mut result.stdout_path,
|
||||
&mut result.stdout_bytes,
|
||||
),
|
||||
(
|
||||
"stderr",
|
||||
&result.stderr,
|
||||
&mut result.stderr_path,
|
||||
&mut result.stderr_bytes,
|
||||
),
|
||||
] {
|
||||
*count = Some(text.len() as u64);
|
||||
match retention::save("box", &format!("{id}-{stream}"), text.as_bytes()) {
|
||||
Ok(saved) => *path = Some(saved),
|
||||
Err(e) => {
|
||||
let notice = result.output_notice.get_or_insert_with(String::new);
|
||||
notice.push_str(&format!(
|
||||
"{stream} archive unavailable: {e}; command completed, do not replay. "
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&result)?;
|
||||
match retention::save("box", id, &bytes) {
|
||||
Ok(path) => result.output_log = Some(path),
|
||||
Err(e) => eprintln!(
|
||||
"box {id} output archive failed: {e}; command completed, full result returned; do not replay"
|
||||
),
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
fn checked(h: &str, id: &str, cmd: Value) -> Result<String> {
|
||||
let r = execute(h, id, cmd, None, 300)?;
|
||||
if r.exit != 0 || r.timed_out || r.service_truncated {
|
||||
return Err(error(format!(
|
||||
"box command failed (exit {}, timeout {}, truncated {}): {}; {}",
|
||||
r.exit,
|
||||
r.timed_out,
|
||||
r.truncated,
|
||||
r.stderr,
|
||||
r.output_notice.as_deref().unwrap_or("")
|
||||
)));
|
||||
}
|
||||
if let Some(path) = r.stdout_path {
|
||||
return Ok(std::fs::read_to_string(path)?);
|
||||
}
|
||||
// Archival failure is not execution failure. Helpers can use complete inline
|
||||
// text (especially empty mutation output); never parse a cut/binary excerpt.
|
||||
if r.exec_id.is_some()
|
||||
&& (r.stdout_bytes != Some(r.stdout.len() as u64) || r.stdout.contains('\u{fffd}'))
|
||||
{
|
||||
return Err(error(format!(
|
||||
"command completed; required stdout unavailable; {}; do not replay",
|
||||
r.output_notice
|
||||
.as_deref()
|
||||
.unwrap_or("incomplete inline stdout")
|
||||
)));
|
||||
}
|
||||
Ok(r.stdout)
|
||||
}
|
||||
// The person explicitly approved bounded polling within create/node_box because
|
||||
// this service has no readiness events. No worker or polling survives the RPC.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Readiness {
|
||||
timeout_ns: u64,
|
||||
step_ns: u64,
|
||||
max_sleep_ns: u64,
|
||||
}
|
||||
const READINESS: Readiness = Readiness {
|
||||
timeout_ns: 240_000_000_000,
|
||||
step_ns: 1_000_000_000,
|
||||
max_sleep_ns: 3_000_000_000,
|
||||
};
|
||||
fn accepted_error(mut e: Error, id: &str, h: &str) -> Error {
|
||||
e.message = format!(
|
||||
"box {id} on {h}: {}; continue with box.get, do not retry create",
|
||||
e.message
|
||||
);
|
||||
e.data = Some(json!({"id":id,"host":h}));
|
||||
e
|
||||
}
|
||||
fn wait_ready(h: &str, mut c: Container, timing: Readiness, deadline: u64) -> Result<Container> {
|
||||
let mut sleep_ns = timing.step_ns;
|
||||
loop {
|
||||
match c.state.as_str() {
|
||||
"running" => return Ok(c),
|
||||
"failed" => {
|
||||
// Preserve terminal metadata even if the log endpoint is unavailable.
|
||||
let tail = archive::build(h, &c.id, deadline);
|
||||
match tail {
|
||||
Ok(log) => {
|
||||
c.build_log_tail = Some(log.text);
|
||||
c.build_log_path = Some(log.path);
|
||||
c.build_log_bytes = Some(log.bytes);
|
||||
c.build_log_service_truncated = Some(log.service_truncated);
|
||||
c.build_log_notice = log.notice;
|
||||
}
|
||||
Err(e) => {
|
||||
c.build_log_tail = Some(format!("build log unavailable: {}", e.message))
|
||||
}
|
||||
}
|
||||
return Ok(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let now = http::now();
|
||||
if now >= deadline {
|
||||
return Err(error("readiness timed out after 240 s"));
|
||||
}
|
||||
wasi::clocks::monotonic_clock::subscribe_instant(
|
||||
now.saturating_add(sleep_ns).min(deadline),
|
||||
)
|
||||
.block();
|
||||
if http::now() >= deadline {
|
||||
return Err(error("readiness timed out after 240 s"));
|
||||
}
|
||||
c = http::json_until(h, Method::Get, &base(&c.id)?, None, 30, Some(deadline))?;
|
||||
sleep_ns = sleep_ns
|
||||
.saturating_add(timing.step_ns)
|
||||
.min(timing.max_sleep_ns);
|
||||
}
|
||||
}
|
||||
impl BoxPlugin {
|
||||
pub fn create_box(&self, p: Create) -> Result<Container> {
|
||||
self.create_box_with_wait(p, READINESS)
|
||||
}
|
||||
fn create_box_with_wait(&self, p: Create, timing: Readiness) -> Result<Container> {
|
||||
let n = usize::from(p.config.flake.is_some())
|
||||
+ usize::from(p.config.nixos.is_some())
|
||||
+ usize::from(p.config.system.is_some());
|
||||
if n != 1 || p.ttl_secs == Some(0) {
|
||||
return Err(error(
|
||||
"provide exactly one config source and a positive TTL",
|
||||
));
|
||||
}
|
||||
let h = host(p.host.as_deref())?;
|
||||
let deadline = http::now().saturating_add(timing.timeout_ns);
|
||||
let c: Container = http::json_until(
|
||||
&h,
|
||||
Method::Post,
|
||||
"/containers",
|
||||
Some(
|
||||
json!({"config":p.config,"ttl_secs":p.ttl_secs,"owner":p.owner.unwrap_or_default()}),
|
||||
),
|
||||
30,
|
||||
Some(deadline),
|
||||
)?;
|
||||
let id = c.id.clone();
|
||||
remember(&id, &h).map_err(|e| accepted_error(e, &id, &h))?;
|
||||
eprintln!("box {id} accepted on {h}; waiting up to 240 s for readiness");
|
||||
wait_ready(&h, c, timing, deadline).map_err(|e| accepted_error(e, &id, &h))
|
||||
}
|
||||
}
|
||||
|
||||
impl BoxPlugin {
|
||||
/// Create a box and wait up to 240 seconds for running/failed. Timeout errors carry id/host for box.get; never retry create blindly.
|
||||
pub fn create(&self, p: Create) -> Result<Container> {
|
||||
self.create_box(p)
|
||||
}
|
||||
/// List boxes known to one service host.
|
||||
pub fn list(&self, p: List) -> Result<Containers> {
|
||||
http::json(
|
||||
&host(p.host.as_deref())?,
|
||||
Method::Get,
|
||||
"/containers",
|
||||
None,
|
||||
30,
|
||||
)
|
||||
}
|
||||
/// Fetch current metadata; expired/deleted IDs yield a clear service 404.
|
||||
pub fn get(&self, p: Id) -> Result<Container> {
|
||||
get(&resolve(&p.id, p.host.as_deref())?, &p.id)
|
||||
}
|
||||
/// Fetch and archive the full build log; old services fall back to their tail with a warning.
|
||||
pub fn log(&self, p: Id) -> Result<Log> {
|
||||
log(&resolve(&p.id, p.host.as_deref())?, &p.id)
|
||||
}
|
||||
/// Reset remaining lifetime to ttl_secs from now.
|
||||
pub fn extend(&self, p: Extend) -> Result<Container> {
|
||||
if p.ttl_secs == 0 {
|
||||
return Err(error("ttl_secs must be positive"));
|
||||
}
|
||||
http::json(
|
||||
&resolve(&p.id, p.host.as_deref())?,
|
||||
Method::Post,
|
||||
&format!("{}/extend", base(&p.id)?),
|
||||
Some(json!({"ttl_secs":p.ttl_secs})),
|
||||
30,
|
||||
)
|
||||
}
|
||||
/// Destroy a box. Unknown IDs error even though upstream delete is idempotent.
|
||||
pub fn delete(&self, p: Id) -> Result<OkResult> {
|
||||
let h = resolve(&p.id, p.host.as_deref())?;
|
||||
get(&h, &p.id)?;
|
||||
// Best-effort byte preservation must never make explicit cleanup impossible.
|
||||
if let Err(e) = log(&h, &p.id) {
|
||||
eprintln!(
|
||||
"box {} build log not saved before deletion: {}",
|
||||
p.id, e.message
|
||||
);
|
||||
}
|
||||
http::json(&h, Method::Delete, &base(&p.id)?, None, 30)
|
||||
}
|
||||
/// Run a shell string or argv command as root inside the box.
|
||||
pub fn exec(&self, p: Exec) -> Result<Executed> {
|
||||
match &p.cmd {
|
||||
Command::Shell(s) if s.is_empty() => return Err(error("cmd must not be empty")),
|
||||
Command::Argv(a) if a.is_empty() => return Err(error("argv must not be empty")),
|
||||
_ => {}
|
||||
}
|
||||
let mut result = execute(
|
||||
&resolve(&p.id, p.host.as_deref())?,
|
||||
&p.id,
|
||||
serde_json::to_value(p.cmd)?,
|
||||
p.stdin,
|
||||
p.timeout_secs.unwrap_or(60),
|
||||
)?;
|
||||
excerpt::apply(
|
||||
&mut result,
|
||||
p.inline_bytes.unwrap_or(excerpt::DEFAULT_BYTES),
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
/// Stream text or a world file to a box, enforcing the service's 64 MiB cap.
|
||||
pub fn put_file(&self, p: PutFile) -> Result<FileResult> {
|
||||
let h = resolve(&p.id, p.host.as_deref())?;
|
||||
let bytes = match (p.text, p.from_vfs) {
|
||||
(Some(text), None) => {
|
||||
transfer::put(&h, &p.id, &p.path, &mut text.as_bytes(), text.len() as u64)?
|
||||
}
|
||||
(None, Some(path)) => {
|
||||
let mut f = File::open(path)?;
|
||||
let len = f.metadata()?.len();
|
||||
transfer::put(&h, &p.id, &p.path, &mut f, len)?
|
||||
}
|
||||
_ => return Err(error("provide exactly one of text and from_vfs")),
|
||||
};
|
||||
Ok(FileResult {
|
||||
bytes,
|
||||
text: None,
|
||||
to_vfs: None,
|
||||
})
|
||||
}
|
||||
/// Download into VFS without returning file contents; inline text is capped at 64 KiB.
|
||||
pub fn get_file(&self, p: GetFile) -> Result<FileResult> {
|
||||
transfer::get_file(&resolve(&p.id, p.host.as_deref())?, p)
|
||||
}
|
||||
/// Tar a world directory and stream it to the box. Merges into to_dir.
|
||||
pub fn put_tree(&self, p: PutTree) -> Result<OkResult> {
|
||||
transfer::put_tree(&resolve(&p.id, p.host.as_deref())?, p)
|
||||
}
|
||||
/// Stream a tar from a box and unpack into a new world directory.
|
||||
pub fn get_tree(&self, p: GetTree) -> Result<OkResult> {
|
||||
transfer::get_tree(&resolve(&p.id, p.host.as_deref())?, p)
|
||||
}
|
||||
/// Create a node/npm/git/Chromium/fontconfig box with Playwright environment defaults.
|
||||
pub fn node_box(&self, p: NodeBox) -> Result<Container> {
|
||||
self.create_box(Create {
|
||||
config: Config {
|
||||
flake: None,
|
||||
nixos: Some(Nixos::Text(NODE_MODULE.into())),
|
||||
system: None,
|
||||
},
|
||||
ttl_secs: p.ttl_secs,
|
||||
owner: Some("box.node_box".into()),
|
||||
host: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn retention_deadline_scales_without_a_byte_cap() {
|
||||
assert_eq!(archive_budget_ns(Some(0), Some(0)), 60_000_000_000);
|
||||
assert_eq!(archive_budget_ns(Some(256 * 1024), Some(1)), 62_000_000_000);
|
||||
assert_eq!(archive_budget_ns(None, Some(0)), 300_000_000_000);
|
||||
assert_eq!(
|
||||
archive_budget_ns(Some(u64::MAX), Some(u64::MAX)),
|
||||
1_800_000_000_000
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! The world box command. All network operations use standard wasi:http.
|
||||
use clap::Parser;
|
||||
fn main() {
|
||||
let code = match box_cli::cli::run(box_cli::cli::Args::parse()) {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("box: {error}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Append-only diagnostic files, independent of resumable state.
|
||||
use std::io::{self, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn date() -> String {
|
||||
let days = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
/ 86400;
|
||||
date_from_days(days as i64)
|
||||
}
|
||||
fn date_from_days(days: i64) -> String {
|
||||
// Gregorian civil date from Unix days (400-year eras).
|
||||
let z = days + 719468;
|
||||
let era = z / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = mp + if mp < 10 { 3 } else { -9 };
|
||||
format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2))
|
||||
}
|
||||
|
||||
pub fn create(name: &str, extension: &str) -> io::Result<(String, std::fs::File)> {
|
||||
let dir = format!("/logs/box/{}", date());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let name: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut path = format!("{dir}/{name}.{extension}");
|
||||
loop {
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(f) => return Ok((path, f)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
path = format!(
|
||||
"{dir}/{name}-{}-{}.{extension}",
|
||||
crate::pid(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(area: &str, name: &str, bytes: &[u8]) -> io::Result<String> {
|
||||
let dir = format!("/logs/{area}/{}", date());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
loop {
|
||||
let path = format!(
|
||||
"{dir}/{name}-{}-{}.log",
|
||||
crate::pid(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
f.write_all(bytes)?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn archive_calendar_dates() {
|
||||
assert_eq!(date_from_days(0), "1970-01-01");
|
||||
assert_eq!(date_from_days(19782), "2024-02-29");
|
||||
assert_eq!(date_from_days(20635), "2026-07-01");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Bounded streaming transfers. Payloads stay in HTTP/VFS, not RPC responses.
|
||||
use super::*;
|
||||
|
||||
pub fn file_path(id: &str, path: &str) -> Result<String> {
|
||||
if !path.starts_with('/') || path == "/" {
|
||||
return Err(error("expected an absolute file path inside the box"));
|
||||
}
|
||||
let mut u = url::Url::parse("http://box.invalid").map_err(|e| error(e.to_string()))?;
|
||||
{
|
||||
let mut segments = u.path_segments_mut().map_err(|_| error("bad file path"))?;
|
||||
segments.clear().push("containers").push(id).push("files");
|
||||
for s in path[1..].split('/') {
|
||||
segments.push(s);
|
||||
}
|
||||
}
|
||||
Ok(u.path().into())
|
||||
}
|
||||
pub fn put(h: &str, id: &str, path: &str, source: &mut dyn Read, len: u64) -> Result<u64> {
|
||||
if len > MAX_FILE {
|
||||
return Err(error("service file limit is 64 MiB; split larger files"));
|
||||
}
|
||||
let mut body = http::request(
|
||||
h,
|
||||
Method::Put,
|
||||
&file_path(id, path)?,
|
||||
Some((source, len)),
|
||||
300,
|
||||
)?;
|
||||
let ok: OkResult = serde_json::from_reader(&mut body)?;
|
||||
if !ok.ok {
|
||||
return Err(error("service file write did not succeed"));
|
||||
}
|
||||
Ok(len)
|
||||
}
|
||||
fn size(h: &str, id: &str, path: &str) -> Result<u64> {
|
||||
// stat only regular files: service otherwise returns directory listings or
|
||||
// can silently truncate at 64 MiB. Keep flags and user paths separate.
|
||||
let out = checked(
|
||||
h,
|
||||
id,
|
||||
json!([
|
||||
"sh",
|
||||
"-c",
|
||||
"[ -f \"$1\" ] && stat -Lc %s -- \"$1\"",
|
||||
"sh",
|
||||
path
|
||||
]),
|
||||
)?;
|
||||
let n: u64 = out
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| error("invalid file size from box"))?;
|
||||
if n > MAX_FILE {
|
||||
return Err(error("service file limit is 64 MiB; split larger files"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
fn download(h: &str, id: &str, path: &str, out: &mut dyn Write, expected: u64) -> Result<u64> {
|
||||
let mut body = http::request(h, Method::Get, &file_path(id, path)?, None, 300)?;
|
||||
let n = std::io::copy(&mut (&mut body).take(MAX_FILE + 1), out)?;
|
||||
if n != expected {
|
||||
return Err(error(format!(
|
||||
"file changed or response truncated: expected {expected} bytes, got {n}"
|
||||
)));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Private disk spools, removed on both success and error. Not persistent state.
|
||||
struct Spool {
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
impl Spool {
|
||||
fn new() -> Result<Self> {
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let dir = std::path::PathBuf::from(format!(
|
||||
"/var/lib/box/transfers/{}-{}",
|
||||
crate::pid(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(dir.parent().ok_or_else(|| error("no spool parent"))?)?;
|
||||
std::fs::create_dir(&dir)?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
fn path(&self) -> std::path::PathBuf {
|
||||
self.dir.join("archive.tar")
|
||||
}
|
||||
}
|
||||
impl Drop for Spool {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_file(h: &str, p: GetFile) -> Result<FileResult> {
|
||||
let len = size(h, &p.id, &p.path)?;
|
||||
if let Some(dest) = &p.to_vfs {
|
||||
let target = Path::new(dest);
|
||||
let parent = target
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let temp = parent.join(format!(".box-download-{}", crate::pid()));
|
||||
// Use create_new: never truncate an unrelated temporary file.
|
||||
let mut f = File::options().write(true).create_new(true).open(&temp)?;
|
||||
let result = (|| -> Result<()> {
|
||||
download(h, &p.id, &p.path, &mut f, len)?;
|
||||
f.flush()?;
|
||||
drop(f);
|
||||
std::fs::rename(&temp, target)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
}
|
||||
result?;
|
||||
Ok(FileResult {
|
||||
bytes: len,
|
||||
text: None,
|
||||
to_vfs: p.to_vfs,
|
||||
})
|
||||
} else {
|
||||
if len > 65536 {
|
||||
return Err(error(
|
||||
"inline file limit is 64 KiB; use to_vfs to stream larger files",
|
||||
));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
download(h, &p.id, &p.path, &mut data, len)?;
|
||||
Ok(FileResult {
|
||||
bytes: len,
|
||||
text: Some(
|
||||
String::from_utf8(data).map_err(|_| error("file is not UTF-8; use to_vfs"))?,
|
||||
),
|
||||
to_vfs: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
fn dir(s: &str) -> Result<()> {
|
||||
if !s.starts_with('/') {
|
||||
return Err(error("box directory must be absolute"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn remote_tmp() -> String {
|
||||
format!("/tmp/sausage-box-{}.tar", crate::pid())
|
||||
}
|
||||
fn cleanup(h: &str, id: &str, path: &str) {
|
||||
if let Err(e) = checked(h, id, json!(["rm", "-f", "--", path])) {
|
||||
eprintln!("box transfer cleanup: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// tar::Header::set_metadata panics on wasm32-wasip2. Construct portable
|
||||
// headers explicitly; WASI does not expose Unix executable mode bits.
|
||||
fn append_tree(tar: &mut tar::Builder<File>, source: &Path, name: &Path) -> Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(source)?;
|
||||
let payload = if metadata.is_file() {
|
||||
metadata.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if tar
|
||||
.get_ref()
|
||||
.metadata()?
|
||||
.len()
|
||||
.saturating_add(payload)
|
||||
.saturating_add(4096)
|
||||
> MAX_FILE
|
||||
{
|
||||
return Err(error("tree tar exceeds 64 MiB service limit"));
|
||||
}
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_mtime(
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
if metadata.is_dir() {
|
||||
header.set_entry_type(tar::EntryType::Directory);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, name, std::io::empty())?;
|
||||
let mut entries = std::fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
append_tree(tar, &entry.path(), &name.join(entry.file_name()))?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
if metadata.len() > MAX_FILE {
|
||||
return Err(error("tree exceeds 64 MiB service limit"));
|
||||
}
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header.set_size(metadata.len());
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, name, File::open(source)?)?;
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
header.set_entry_type(tar::EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o777);
|
||||
header.set_cksum();
|
||||
tar.append_link(&mut header, name, std::fs::read_link(source)?)?;
|
||||
} else {
|
||||
return Err(error(format!(
|
||||
"cannot copy special file {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn put_tree(h: &str, p: PutTree) -> Result<OkResult> {
|
||||
dir(&p.to_dir)?;
|
||||
if !Path::new(&p.from_vfs_dir).is_dir() {
|
||||
return Err(error("from_vfs_dir is not a directory"));
|
||||
}
|
||||
let spool = Spool::new()?;
|
||||
let f = File::create(spool.path())?;
|
||||
let mut tar = tar::Builder::new(f);
|
||||
tar.follow_symlinks(false);
|
||||
append_tree(&mut tar, Path::new(&p.from_vfs_dir), Path::new("."))?;
|
||||
let mut output = tar.into_inner()?;
|
||||
output.flush()?;
|
||||
drop(output);
|
||||
let mut f = File::open(spool.path())?;
|
||||
let len = f.metadata()?.len();
|
||||
let remote = remote_tmp();
|
||||
let result = (|| -> Result<()> {
|
||||
put(h, &p.id, &remote, &mut f, len)?;
|
||||
checked(
|
||||
h,
|
||||
&p.id,
|
||||
json!([
|
||||
"sh",
|
||||
"-c",
|
||||
format!(
|
||||
"mkdir -p -- {0} && tar -xf {1} -C {0}",
|
||||
quote(&p.to_dir),
|
||||
quote(&remote)
|
||||
)
|
||||
]),
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
cleanup(h, &p.id, &remote);
|
||||
result?;
|
||||
Ok(OkResult { ok: true })
|
||||
}
|
||||
// tar's generic unpack attempts unsupported Unix filesystem operations on
|
||||
// WASI. Copy contents explicitly, avoiding chmod/utime. Symlink creation is
|
||||
// unsupported by the world VFS and must fail without publishing a partial tree.
|
||||
fn unpack_tree(file: File, root: &Path) -> Result<()> {
|
||||
let mut archive = tar::Archive::new(file);
|
||||
let mut hardlinks = Vec::new();
|
||||
let mut expanded = 0u64;
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let name = entry.path()?.into_owned();
|
||||
let safe = |p: &Path| {
|
||||
p.components().all(|c| {
|
||||
matches!(
|
||||
c,
|
||||
std::path::Component::Normal(_) | std::path::Component::CurDir
|
||||
)
|
||||
})
|
||||
};
|
||||
if !safe(&name) {
|
||||
return Err(error("archive has a path outside the destination"));
|
||||
}
|
||||
let path = root.join(&name);
|
||||
// Do not accidentally write through an earlier archive symlink.
|
||||
for parent in name.ancestors().skip(1) {
|
||||
if std::fs::symlink_metadata(root.join(parent))
|
||||
.is_ok_and(|m| m.file_type().is_symlink())
|
||||
{
|
||||
return Err(error("archive writes through a symlink directory"));
|
||||
}
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let kind = entry.header().entry_type();
|
||||
if kind.is_dir() {
|
||||
std::fs::create_dir_all(&path)?;
|
||||
} else if kind.is_file() {
|
||||
expanded = expanded.saturating_add(entry.size());
|
||||
if expanded > MAX_FILE {
|
||||
return Err(error("expanded tree exceeds 64 MiB transfer limit"));
|
||||
}
|
||||
let mut out = File::options().write(true).create_new(true).open(&path)?;
|
||||
std::io::copy(&mut entry, &mut out)?;
|
||||
} else if kind.is_symlink() {
|
||||
return Err(error(
|
||||
"world VFS does not support creating symlinks; export a dereferenced tar/tree from the box instead",
|
||||
));
|
||||
} else if kind.is_hard_link() {
|
||||
let target = entry
|
||||
.link_name()?
|
||||
.ok_or_else(|| error("archive hardlink has no target"))?
|
||||
.into_owned();
|
||||
if !safe(&target) {
|
||||
return Err(error("archive hardlink target is outside destination"));
|
||||
}
|
||||
hardlinks.push((path, root.join(target)));
|
||||
} else {
|
||||
return Err(error("archive contains an unsupported special file"));
|
||||
}
|
||||
}
|
||||
// Materialize hardlinks as independent files: VFS has no hardlink contract.
|
||||
for (path, target) in hardlinks {
|
||||
let metadata = std::fs::symlink_metadata(&target)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(error("archive hardlink target is not a regular file"));
|
||||
}
|
||||
expanded = expanded.saturating_add(metadata.len());
|
||||
if expanded > MAX_FILE {
|
||||
return Err(error("expanded tree exceeds 64 MiB transfer limit"));
|
||||
}
|
||||
let mut out = File::options().write(true).create_new(true).open(path)?;
|
||||
std::io::copy(&mut File::open(target)?, &mut out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tree(h: &str, p: GetTree) -> Result<OkResult> {
|
||||
dir(&p.from_dir)?;
|
||||
let dest = Path::new(&p.to_vfs_dir);
|
||||
if dest.try_exists()? {
|
||||
return Err(error("to_vfs_dir already exists; choose a new directory"));
|
||||
}
|
||||
let spool = Spool::new()?;
|
||||
let remote = remote_tmp();
|
||||
let result = (|| -> Result<()> {
|
||||
checked(
|
||||
h,
|
||||
&p.id,
|
||||
json!(["tar", "-cf", remote, "-C", p.from_dir, "."]),
|
||||
)?;
|
||||
let len = size(h, &p.id, &remote)?;
|
||||
let mut f = File::create(spool.path())?;
|
||||
download(h, &p.id, &remote, &mut f, len)?;
|
||||
drop(f);
|
||||
// Stage alongside destination so rename is atomic and cannot cross mounts.
|
||||
let parent = dest
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let staging = parent.join(format!(".box-tree-{}", crate::pid()));
|
||||
std::fs::create_dir(&staging)?;
|
||||
let unpack = (|| -> Result<()> {
|
||||
unpack_tree(File::open(spool.path())?, &staging)?;
|
||||
// Recheck to avoid accidentally replacing an existing empty directory.
|
||||
if dest.try_exists()? {
|
||||
return Err(error("destination appeared during transfer"));
|
||||
}
|
||||
std::fs::rename(&staging, dest)?;
|
||||
Ok(())
|
||||
})();
|
||||
if unpack.is_err() {
|
||||
let _ = std::fs::remove_dir_all(staging);
|
||||
}
|
||||
unpack
|
||||
})();
|
||||
cleanup(h, &p.id, &remote);
|
||||
result?;
|
||||
Ok(OkResult { ok: true })
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env nu
|
||||
# Real service acceptance tests. Every created box is removed, including failures.
|
||||
def check [condition: bool, message: string] {
|
||||
if not $condition { error make {msg: $message} }
|
||||
print $'ok - ($message)'
|
||||
}
|
||||
def call [component: path, root: path, args: list<string>] {
|
||||
^wasmtime run -S http -S cli-exit-with-code=y --dir $'($root)::/' $component ...$args | complete
|
||||
}
|
||||
def require [] {
|
||||
let result = $in
|
||||
if $result.exit_code != 0 { error make {msg: $'box failed: ($result.stderr)'} }
|
||||
$result
|
||||
}
|
||||
def main [component: path = '/home/surma/src/gitea.surma.technology/surma/sausage/target/p1-tools/wasm32-wasip2/release/box.wasm'] {
|
||||
let component = ($component | path expand)
|
||||
let root = (^mktemp -d /tmp/box-test-XXXXXXXX | str trim)
|
||||
mkdir ($root | path join project sub)
|
||||
'{"scripts":{"test":"node sub/test.js"}}' | save ($root | path join project package.json)
|
||||
'const fs = require("node:fs"); require("node:assert/strict").equal(2 + 2, 4); fs.writeFileSync("result.txt", "npm test passed\n");' | save ($root | path join project sub test.js)
|
||||
'binary-safe file payload' | save ($root | path join input.txt)
|
||||
let created = (call $component $root [create --ttl 600])
|
||||
let id = if $created.stdout =~ 'tc-[a-z0-9]{6}' { $created.stdout | str trim } else { $created.stderr | parse --regex '(?P<id>tc-[a-z0-9]{6})' | get id | first | default '' }
|
||||
if $id == '' { error make {msg: $'create returned no recoverable id: ($created.stderr); test files: ($root)'} }
|
||||
let outcome = try {
|
||||
$created | require | ignore
|
||||
check ($id =~ '^tc-[a-z0-9]{6}$') 'create returns id'
|
||||
let inventory = (call $component $root [ls] | require)
|
||||
check ($inventory.stdout | str contains $id) 'ls contains box'
|
||||
let exec = (call $component $root [exec $id -- sh -c 'printf hello; printf problem >&2; exit 7'])
|
||||
check ($exec.exit_code == 7 and $exec.stdout == 'hello' and ($exec.stderr | str contains 'problem')) 'exec preserves both streams and exit 7'
|
||||
call $component $root [put $id /input.txt /tmp/file.txt] | require | ignore
|
||||
call $component $root [get $id /tmp/file.txt /returned.txt] | require | ignore
|
||||
check ((open --raw ($root | path join returned.txt)) == (open --raw ($root | path join input.txt))) 'file put/get round trip'
|
||||
call $component $root [put $id /project /work] | require | ignore
|
||||
call $component $root [get $id /work /tree] | require | ignore
|
||||
check ((open --raw ($root | path join tree sub test.js)) == (open --raw ($root | path join project sub test.js))) 'tree round trip retains subfolder'
|
||||
let npm = (call $component $root [exec $id --timeout 120 -- sh -c 'cd /work && npm test'] | require)
|
||||
check ($npm.stdout | str contains 'node sub/test.js') 'npm test runs in box'
|
||||
call $component $root [get $id /work/result.txt /result.txt] | require | ignore
|
||||
check ((open --raw ($root | path join result.txt)) == "npm test passed\n") 'Node result returns to world'
|
||||
let large = (call $component $root [exec $id -- node -e 'process.stdout.write(Buffer.alloc(1200003,255)); process.stderr.write("raw stderr");'] | require)
|
||||
check (($large.stdout | str length --utf-8-bytes) < 16600) 'large binary inline output stays bounded'
|
||||
let archive = ($large.stderr | parse --regex 'box stdout archive: (?P<path>[^\n]+)' | get path | first)
|
||||
let bytes = (open --raw ($root | path join ($archive | str trim --left --char '/')))
|
||||
check (($bytes | bytes length) == 1200003) 'full binary archive retains byte total'
|
||||
let repeated = (call $component $root [exec $id -- sh -c 'printf collision-check'] | require)
|
||||
check ($repeated.stdout == 'collision-check') 'later exec returns output'
|
||||
check ((open --raw ($root | path join ($archive | str trim --left --char '/'))) == $bytes) 'later archive leaves earlier output intact'
|
||||
let log = (call $component $root [logs $id] | require)
|
||||
check ($log.stderr | str contains 'box build archive:') 'logs retains build archive'
|
||||
let wit = (^wasm-tools component wit $component | complete)
|
||||
check ($wit.exit_code == 0) 'component WIT inspection'
|
||||
print $wit.stdout
|
||||
let imports = ($wit.stdout | lines | where {|line| $line | str trim | str starts-with 'import ' })
|
||||
check ($imports | all {|line| $line | str trim | str starts-with 'import wasi:' }) 'component imports only WASI interfaces'
|
||||
null
|
||||
} catch {|failure| $failure }
|
||||
let removed = (call $component $root [rm $id])
|
||||
print $removed.stderr
|
||||
check ($removed.exit_code == 0) $'rm removes ($id)'
|
||||
let after = (call $component $root [ls] | require)
|
||||
check (not ($after.stdout | str contains $id)) 'removed box absent from ls'
|
||||
if $outcome != null { error make {msg: $'acceptance failed: ($outcome); retained evidence: ($root)'} }
|
||||
print $'PASS; world files and archives: ($root)'
|
||||
}
|
||||
Reference in New Issue
Block a user