747 lines
26 KiB
Rust
747 lines
26 KiB
Rust
//! 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
|
|
);
|
|
}
|
|
}
|