use anyhow::{Context, Result};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use hyper_util::server::graceful::GracefulShutdown;
use hyper_util::service::TowerToHyperService;
use tokio::net::UnixListener;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info};
const MAX_HTTP2_STREAMS: u32 = 200;
pub async fn serve(listener: UnixListener, app: axum::Router, shutdown: CancellationToken) {
let graceful = GracefulShutdown::new();
let mut builder = auto::Builder::new(TokioExecutor::new());
builder.http2().max_concurrent_streams(MAX_HTTP2_STREAMS);
loop {
let stream = tokio::select! {
() = shutdown.cancelled() => break,
accepted = listener.accept() => match accepted {
Ok((stream, _addr)) => stream,
Err(e) => {
debug!(error = %e, "control-plane accept failed");
continue;
}
},
};
let service = TowerToHyperService::new(app.clone());
let conn = builder.serve_connection_with_upgrades(TokioIo::new(stream), service);
let conn = graceful.watch(conn.into_owned());
drop(tokio::spawn(async move {
if let Err(e) = conn.await {
debug!(error = %e, "control-plane connection ended");
}
}));
}
info!("control plane draining in-flight connections");
graceful.shutdown().await;
}
pub fn bind(socket_path: &std::path::Path) -> Result<UnixListener> {
let _ = std::fs::remove_file(socket_path);
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create socket directory")?;
}
UnixListener::bind(socket_path).context(format!(
"Failed to bind gRPC socket: {}",
socket_path.display()
))
}
pub fn connect_router(
runtime: arcbox_api::SharedRuntime,
system: arcbox_api::SystemServiceImpl,
) -> Result<connectrpc::Router> {
let reflector = connectrpc_reflection::Reflector::from_descriptor_set_bytes(
arcbox_connect::FILE_DESCRIPTOR_SET,
)
.context("parsing the embedded file descriptor set for reflection")?;
Ok(connectrpc_reflection::install(
arcbox_api::connect::router_with_system(runtime, system),
reflector,
))
}
pub fn into_app(router: connectrpc::Router) -> axum::Router {
router.into_axum_router()
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, OnceLock};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::net::UnixStream;
use super::*;
async fn spawn_control_plane() -> (tempfile::TempDir, std::path::PathBuf, CancellationToken) {
let dir = tempfile::tempdir().expect("temp dir");
let socket = dir.path().join("arcbox.sock");
let runtime: arcbox_api::SharedRuntime = Arc::new(OnceLock::new());
let system = arcbox_api::SystemServiceImpl::new(
Arc::new(arcbox_api::SetupState::new()),
Arc::clone(&runtime),
Arc::clone(&runtime),
);
let app = into_app(connect_router(runtime, system).expect("connect router"));
let listener = bind(&socket).expect("bind temp socket");
let shutdown = CancellationToken::new();
drop(tokio::spawn(serve(listener, app, shutdown.clone())));
(dir, socket, shutdown)
}
async fn http1_request(socket: &std::path::Path, request: &str) -> String {
let mut stream = UnixStream::connect(socket).await.expect("connect");
stream
.write_all(request.as_bytes())
.await
.expect("write request");
stream.flush().await.expect("flush");
let mut response = Vec::new();
let deadline = std::time::Duration::from_secs(5);
tokio::time::timeout(deadline, async {
let mut buf = [0u8; 4096];
loop {
let n = stream.read(&mut buf).await.expect("read response");
if n == 0 {
break;
}
response.extend_from_slice(&buf[..n]);
if response.windows(4).any(|w| w == b"\r\n\r\n")
&& String::from_utf8_lossy(&response).contains('}')
{
break;
}
}
})
.await
.expect("daemon did not answer within 5s");
String::from_utf8_lossy(&response).into_owned()
}
fn dechunk(response: &str) -> String {
let Some((_, mut rest)) = response.split_once("\r\n\r\n") else {
return String::new();
};
let mut body = String::new();
while let Some((size, tail)) = rest.split_once("\r\n") {
let Ok(size) = usize::from_str_radix(size.trim(), 16) else {
break;
};
if size == 0 || tail.len() < size {
break;
}
body.push_str(&tail[..size]);
rest = tail[size..].trim_start_matches("\r\n");
}
body
}
#[tokio::test]
async fn one_endpoint_answers_connect_grpc_and_grpc_web() {
let (_dir, socket, shutdown) = spawn_control_plane().await;
let path = "/arcbox.sandbox.v1.SandboxService/ListExposedPorts";
let response = http1_request(
&socket,
&format!(
"POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n\
Content-Length: 2\r\n\r\n{{}}"
),
)
.await;
assert!(
response.contains("HTTP/1.1 503"),
"Connect JSON should map unavailable to HTTP 503, got: {response}"
);
let body = dechunk(&response);
let json: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("Connect errors are a JSON body: {e}; got {body:?}"));
assert_eq!(
json.get("code").and_then(serde_json::Value::as_str),
Some("unavailable"),
"Connect puts the error code in the JSON body: {json}"
);
let channel = tonic::transport::Endpoint::try_from("http://localhost")
.expect("endpoint")
.connect_with_connector(tower::service_fn({
let socket = socket.clone();
move |_: tonic::transport::Uri| {
let socket = socket.clone();
async move {
Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(socket).await?))
}
}
}))
.await
.expect("gRPC connect over the same socket");
let status = arcbox_grpc::SandboxServiceClient::new(channel)
.list_exposed_ports(tonic::Request::new(
arcbox_grpc::arcbox_protocol::sandbox_v1::ListExposedPortsRequest::default(),
))
.await
.expect_err("empty runtime is unavailable");
assert_eq!(
status.code(),
tonic::Code::Unavailable,
"gRPC must reach the same handler and carry the same code"
);
let body = [0u8, 0, 0, 0, 0];
let mut request = format!(
"POST {path} HTTP/1.1\r\nHost: localhost\r\n\
Content-Type: application/grpc-web+proto\r\nContent-Length: {}\r\n\r\n",
body.len()
)
.into_bytes();
request.extend_from_slice(&body);
let mut stream = UnixStream::connect(&socket).await.expect("connect");
stream.write_all(&request).await.expect("write");
let mut response = Vec::new();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(5),
stream.read_to_end(&mut response),
)
.await;
let response = String::from_utf8_lossy(&response);
assert!(
response.contains("HTTP/1.1 200"),
"gRPC-Web carries its status in the body, not the HTTP status: {response}"
);
assert!(
response.contains("grpc-status: 14") || response.contains("grpc-status:14"),
"gRPC-Web trailers should report unavailable: {response}"
);
shutdown.cancel();
}
#[test]
fn migrated_daemon_services_are_registered_on_the_connect_router() {
let runtime: arcbox_api::SharedRuntime = Arc::new(OnceLock::new());
let system = arcbox_api::SystemServiceImpl::new(
Arc::new(arcbox_api::SetupState::new()),
Arc::clone(&runtime),
Arc::clone(&runtime),
);
let router = connect_router(runtime, system).expect("connect router");
for path in [
"arcbox.v1.IconService/GetImageIcon",
"arcbox.v1.StatsService/Watch",
"arcbox.v1.SystemService/WatchSetupStatus",
"arcbox.v1.SystemService/SetSystemVmResources",
"arcbox.v1.KubernetesService/Status",
"arcbox.v1.MigrationService/RunMigration",
"arcbox.v1.MachineService/ExecSession",
"arcbox.sandbox.v1.TemplateService/Get",
#[cfg(target_os = "macos")]
"arcbox.v1.MacosService/List",
] {
assert!(
router.has_method(path),
"{path} has moved to Connect; available: {:?}",
router.methods().collect::<Vec<_>>()
);
}
}
#[tokio::test]
async fn reflection_answers_through_the_real_router() {
let (_dir, socket, shutdown) = spawn_control_plane().await;
let channel = tonic::transport::Endpoint::try_from("http://localhost")
.expect("endpoint")
.connect_with_connector(tower::service_fn({
let socket = socket.clone();
move |_: tonic::transport::Uri| {
let socket = socket.clone();
async move {
Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(socket).await?))
}
}
}))
.await
.expect("connect");
let mut client =
tonic_reflection::pb::v1::server_reflection_client::ServerReflectionClient::new(
channel,
);
let request = tonic_reflection::pb::v1::ServerReflectionRequest {
host: String::new(),
message_request: Some(
tonic_reflection::pb::v1::server_reflection_request::MessageRequest::ListServices(
String::new(),
),
),
};
let mut stream = client
.server_reflection_info(tokio_stream::iter(vec![request]))
.await
.expect("reflection call")
.into_inner();
let response = tokio_stream::StreamExt::next(&mut stream)
.await
.expect("a reflection response")
.expect("reflection is not an error");
let services = match response.message_response {
Some(tonic_reflection::pb::v1::server_reflection_response::MessageResponse::ListServicesResponse(r)) => r.service,
other => panic!("expected a service listing, got {other:?}"),
};
let names: Vec<_> = services.into_iter().map(|s| s.name).collect();
assert!(
names
.iter()
.any(|n| n == "arcbox.sandbox.v1.SandboxService"),
"the sandbox services must stay discoverable: {names:?}"
);
shutdown.cancel();
}
#[tokio::test]
async fn a_split_bidi_stream_reaches_the_exec_session_handler() {
use arcbox_connect::v1 as pb;
let (_dir, socket, shutdown) = spawn_control_plane().await;
let authority = "http://localhost".parse().expect("static authority parses");
let transport =
connectrpc::client::Http2Connection::lazy_unix(&socket, authority).shared(4);
let config = connectrpc::client::ClientConfig::new(
"http://localhost".parse().expect("static base URI parses"),
);
let client = pb::MachineServiceClient::new(transport, config);
let (mut send, mut recv) = client
.exec_session()
.await
.expect("open the bidi stream")
.into_split();
let sender = tokio::spawn(async move {
let init = pb::MachineExecInput {
payload: Some(pb::machine_exec_input::Payload::Init(Box::new(
pb::MachineExecRequest {
id: "default".into(),
tty: true,
..Default::default()
},
))),
..Default::default()
};
let _ = send.send(init).await;
send.close_send();
});
let err = recv
.message::<pb::MachineExecOutput>()
.await
.expect_err("an empty runtime answers every call with an error");
assert_eq!(
err.code,
connectrpc::ErrorCode::Unavailable,
"the handler should consume Init and fail on readiness: {err:?}"
);
sender.await.expect("sender task");
shutdown.cancel();
}
}