🛰️ export from upstream (18069bd)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "thumper"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "In-container forwarding client for sandcage"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,311 @@
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::protocol::{ForwardRequest, ResponseFrame, StreamKind};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Connect(io::Error),
|
||||
Send(io::Error),
|
||||
Receive(io::Error),
|
||||
Parse(serde_json::Error),
|
||||
UnexpectedEof,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientError::Connect(e) => write!(f, "failed to connect to forwarding socket: {e}"),
|
||||
ClientError::Send(e) => write!(f, "failed to send request: {e}"),
|
||||
ClientError::Receive(e) => write!(f, "failed to read response: {e}"),
|
||||
ClientError::Parse(e) => write!(f, "malformed response frame: {e}"),
|
||||
ClientError::UnexpectedEof => write!(f, "connection closed without exit frame"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
|
||||
pub fn forward(socket_path: &Path, request: &ForwardRequest) -> Result<i32, ClientError> {
|
||||
let stream = UnixStream::connect(socket_path).map_err(ClientError::Connect)?;
|
||||
|
||||
// Send request as a single JSON line.
|
||||
let mut writer = io::BufWriter::new(&stream);
|
||||
serde_json::to_writer(&mut writer, request).map_err(|e| ClientError::Send(e.into()))?;
|
||||
writer.write_all(b"\n").map_err(ClientError::Send)?;
|
||||
writer.flush().map_err(ClientError::Send)?;
|
||||
|
||||
// Read response frames line by line.
|
||||
let reader = BufReader::new(&stream);
|
||||
for line_result in reader.lines() {
|
||||
let line = line_result.map_err(ClientError::Receive)?;
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let frame: ResponseFrame = serde_json::from_str(&line).map_err(ClientError::Parse)?;
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
|
||||
print!("{data}");
|
||||
}
|
||||
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
|
||||
eprint!("{data}");
|
||||
}
|
||||
ResponseFrame::Exit { exit } => {
|
||||
return Ok(exit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ClientError::UnexpectedEof)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use imp::*;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
mod imp {
|
||||
use std::path::Path;
|
||||
|
||||
use crate::protocol::ForwardRequest;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "thumper is only supported on Unix platforms")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {}
|
||||
|
||||
pub fn forward(_socket_path: &Path, _request: &ForwardRequest) -> Result<i32, ClientError> {
|
||||
Err(ClientError::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub use imp::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::RequestKind;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
|
||||
fn mock_server(
|
||||
socket_path: &Path,
|
||||
handler: impl FnOnce(UnixStream) + Send + 'static,
|
||||
) -> thread::JoinHandle<()> {
|
||||
let listener = UnixListener::bind(socket_path).unwrap();
|
||||
thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
handler(stream);
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_request_as_single_json_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(parsed["kind"], "task");
|
||||
assert_eq!(parsed["name"], "build");
|
||||
assert_eq!(parsed["args"][0], "--release");
|
||||
assert_eq!(parsed["cwd"], "/workspace/app");
|
||||
|
||||
// Send exit frame so forward() returns
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_stdout_and_stderr_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap(); // consume request
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"out1\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"stream":"stderr","data":"err1\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"out2\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "test".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_exit_is_relayed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stderr","data":"error: build failed\n"}}"#).unwrap();
|
||||
writeln!(writer, r#"{{"exit":1}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 1);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_eof_when_no_exit_frame() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
|
||||
let mut writer = reader.into_inner();
|
||||
writeln!(writer, r#"{{"stream":"stdout","data":"partial"}}"#).unwrap();
|
||||
let _ = writer; // close without exit frame
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "test".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let err = forward(&sock, &req).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ClientError::UnexpectedEof),
|
||||
"expected UnexpectedEof, got {err}"
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_roundtrip_simulating_cargo_build() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut req_line = String::new();
|
||||
reader.read_line(&mut req_line).unwrap();
|
||||
|
||||
// Validate the request
|
||||
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
|
||||
assert_eq!(req["kind"], "task");
|
||||
assert_eq!(req["name"], "build");
|
||||
assert_eq!(req["args"], serde_json::json!(["--release"]));
|
||||
|
||||
// Simulate cargo build output
|
||||
let writer = reader.into_inner();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling sandcage v0.1.0\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling thumper v0.1.0\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"stream":"stderr","data":" Finished release [optimized]\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_request_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("cmd.sock");
|
||||
|
||||
let handle = mock_server(&sock, |stream| {
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut req_line = String::new();
|
||||
reader.read_line(&mut req_line).unwrap();
|
||||
|
||||
let req: serde_json::Value = serde_json::from_str(&req_line).unwrap();
|
||||
assert_eq!(req["kind"], "tool");
|
||||
assert_eq!(req["name"], "docker");
|
||||
|
||||
let writer = reader.into_inner();
|
||||
writeln!(&*writer, r#"{{"stream":"stdout","data":"container-id-abc123\n"}}"#).unwrap();
|
||||
writeln!(&*writer, r#"{{"exit":0}}"#).unwrap();
|
||||
});
|
||||
|
||||
let req = crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Tool,
|
||||
name: "docker".to_string(),
|
||||
args: vec!["compose".to_string(), "up".to_string(), "-d".to_string()],
|
||||
cwd: "/workspace/app".to_string(),
|
||||
};
|
||||
let exit = forward(&sock, &req).unwrap();
|
||||
assert_eq!(exit, 0);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_error_on_missing_socket() {
|
||||
let err = forward(
|
||||
Path::new("/tmp/sandcage-nonexistent-test.sock"),
|
||||
&crate::protocol::ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "x".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/workspace".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ClientError::Connect(_)), "expected Connect, got {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
mod client;
|
||||
mod protocol;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use protocol::{ForwardRequest, RequestKind};
|
||||
|
||||
const DEFAULT_SOCKET: &str = "/run/sandcage/cmd.sock";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if args.len() < 2 || args[1] == "--help" || args[1] == "-h" {
|
||||
eprintln!("usage: thumper <task-name> [args...]");
|
||||
eprintln!();
|
||||
eprintln!("Forwards a named task to the sandcage host daemon.");
|
||||
eprintln!("The task is executed on the host and output is streamed back.");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
|
||||
let socket_path = std::env::var("SANDCAGE_SOCKET")
|
||||
.map(|s| std::path::PathBuf::from(s))
|
||||
.unwrap_or_else(|_| Path::new(DEFAULT_SOCKET).to_path_buf());
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "/workspace".to_string());
|
||||
|
||||
let request = ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: args[1].clone(),
|
||||
args: args[2..].to_vec(),
|
||||
cwd,
|
||||
};
|
||||
|
||||
match client::forward(&socket_path, &request) {
|
||||
Ok(code) => ExitCode::from(code as u8),
|
||||
Err(e) => {
|
||||
eprintln!("thumper: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ForwardRequest {
|
||||
pub kind: RequestKind,
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum RequestKind {
|
||||
Task,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// Response frames from the host-side daemon.
|
||||
///
|
||||
/// IMPORTANT: `Exit` MUST come before `Output`. Serde's `untagged` tries variants in
|
||||
/// declaration order — `{"exit":0}` would match `Output` if `Output` were first.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ResponseFrame {
|
||||
Exit { exit: i32 },
|
||||
Output { stream: StreamKind, data: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum StreamKind {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_serializes_to_expected_json() {
|
||||
let req = ForwardRequest {
|
||||
kind: RequestKind::Task,
|
||||
name: "build".to_string(),
|
||||
args: vec!["--release".to_string()],
|
||||
cwd: "/workspace".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["kind"], "task");
|
||||
assert_eq!(json["name"], "build");
|
||||
assert_eq!(json["args"][0], "--release");
|
||||
assert_eq!(json["cwd"], "/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_request_kind_serializes() {
|
||||
let req = ForwardRequest {
|
||||
kind: RequestKind::Tool,
|
||||
name: "grep".to_string(),
|
||||
args: vec![],
|
||||
cwd: "/".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(json["kind"], "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdout_frame_deserializes() {
|
||||
let frame: ResponseFrame =
|
||||
serde_json::from_str(r#"{"stream":"stdout","data":"hello\n"}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stdout, data } => {
|
||||
assert_eq!(data, "hello\n");
|
||||
}
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_frame_deserializes() {
|
||||
let frame: ResponseFrame =
|
||||
serde_json::from_str(r#"{"stream":"stderr","data":"warning: unused var"}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Output { stream: StreamKind::Stderr, data } => {
|
||||
assert_eq!(data, "warning: unused var");
|
||||
}
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_frame_deserializes() {
|
||||
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":42}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Exit { exit } => assert_eq!(exit, 42),
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_zero_frame() {
|
||||
// Regression guard: serde untagged ordering — Exit must come before Output
|
||||
// so {"exit":0} does not accidentally match the Output variant.
|
||||
let frame: ResponseFrame = serde_json::from_str(r#"{"exit":0}"#).unwrap();
|
||||
match frame {
|
||||
ResponseFrame::Exit { exit } => assert_eq!(exit, 0),
|
||||
other => panic!("unexpected variant: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user