use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use kamaji_proto::{
decode_frame, encode_frame, DrainBudget, Error as CodecError, ErrorCode, KamajiToYubaba,
ProbeStatus, ProtocolVersion, RequestId, WorkloadEntry, WorkloadId, YubabaToKamaji,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{unix::OwnedReadHalf, unix::OwnedWriteHalf, UnixStream};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
pub const DEFAULT_SOCKET: &str = "/run/yah/kamaji.sock";
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("kamaji UDS I/O: {0}")]
Io(#[from] std::io::Error),
#[error("kamaji wire codec: {0}")]
Codec(#[from] CodecError),
#[error("kamaji closed the connection mid-request")]
PeerClosed,
#[error("kamaji error: {code:?}: {message}")]
Remote { code: ErrorCode, message: String },
#[error("kamaji replied with unexpected variant: {0}")]
Unexpected(String),
#[error("kamaji rejected handshake (version {wanted:?})")]
HandshakeRefused { wanted: ProtocolVersion },
}
#[derive(Debug, Default)]
struct Shared {
pending: Mutex<HashMap<RequestId, oneshot::Sender<KamajiToYubaba>>>,
dead: AtomicBool,
}
impl Shared {
fn shutdown(&self) {
self.dead.store(true, Ordering::Release);
self.pending.lock().unwrap().clear();
}
fn fail_all(&self, code: ErrorCode, message: &str) {
let waiters: Vec<_> = self.pending.lock().unwrap().drain().collect();
for (rid, tx) in waiters {
let _ = tx.send(KamajiToYubaba::Error {
request_id: Some(rid),
code,
message: message.to_string(),
});
}
}
}
struct PendingGuard<'a> {
shared: &'a Shared,
rid: RequestId,
}
impl Drop for PendingGuard<'_> {
fn drop(&mut self) {
self.shared.pending.lock().unwrap().remove(&self.rid);
}
}
#[derive(Debug, Clone)]
pub struct ConstableInfo {
pub version: ProtocolVersion,
pub kamaji_version: String,
}
#[derive(Debug)]
pub struct KamajiClient {
socket: PathBuf,
next_request_id: AtomicU64,
info: ConstableInfo,
shared: Arc<Shared>,
outbound: mpsc::UnboundedSender<Vec<u8>>,
tasks: Vec<JoinHandle<()>>,
}
impl Drop for KamajiClient {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
impl KamajiClient {
pub async fn connect(socket: impl Into<PathBuf>) -> Result<Self, ClientError> {
let socket = socket.into();
let stream = UnixStream::connect(&socket).await?;
let (mut rd, mut wr) = stream.into_split();
let mut buf = Vec::with_capacity(4096);
let hello = YubabaToKamaji::Hello {
version: ProtocolVersion::CURRENT,
};
write_frame(&mut wr, &hello).await?;
let reply = read_frame(&mut rd, &mut buf).await?;
let info = match reply {
KamajiToYubaba::Welcome {
version,
kamaji_version,
} => ConstableInfo {
version,
kamaji_version,
},
KamajiToYubaba::Error { code, message, .. } => {
return Err(ClientError::Remote { code, message });
}
other => return Err(ClientError::Unexpected(format!("{other:?}"))),
};
info!(
socket = %socket.display(),
kamaji_version = %info.kamaji_version,
"kamaji handshake complete"
);
let shared = Arc::new(Shared::default());
let (outbound, rx) = mpsc::unbounded_channel();
let tasks = vec![
tokio::spawn(reader_loop(rd, buf, Arc::clone(&shared))),
tokio::spawn(writer_loop(wr, rx, Arc::clone(&shared))),
];
Ok(Self {
socket,
next_request_id: AtomicU64::new(1),
info,
shared,
outbound,
tasks,
})
}
pub fn socket(&self) -> &Path {
&self.socket
}
pub fn info(&self) -> &ConstableInfo {
&self.info
}
pub fn is_dead(&self) -> bool {
self.shared.dead.load(Ordering::Acquire)
}
fn next_request_id(&self) -> RequestId {
RequestId(self.next_request_id.fetch_add(1, Ordering::Relaxed))
}
pub async fn list(&self) -> Result<Vec<WorkloadEntry>, ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(YubabaToKamaji::List { request_id }, request_id)
.await?;
match reply {
KamajiToYubaba::WorkloadList { entries, .. } => Ok(entries),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
pub async fn deploy_envelope(
&self,
id: &WorkloadId,
workload: &workload_spec::Workload,
mesh: Option<&crate::MeshAssignment>,
) -> Result<(), ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::Deploy {
request_id,
id: id.clone(),
spec: workload.clone(),
mesh: mesh.map(mesh_to_proto),
},
request_id,
)
.await?;
match reply {
KamajiToYubaba::Ack {
kind: kamaji_proto::AckKind::Deploy,
..
} => Ok(()),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
pub async fn stop(&self, id: &WorkloadId) -> Result<(), ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::Stop {
request_id,
id: id.clone(),
},
request_id,
)
.await?;
match reply {
KamajiToYubaba::Ack { .. } => Ok(()),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
pub async fn drain(
&self,
id: &WorkloadId,
budget: DrainBudget,
) -> Result<(bool, Option<String>), ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::Drain {
request_id,
id: id.clone(),
budget,
},
request_id,
)
.await?;
match reply {
KamajiToYubaba::DrainAck {
accepted, reason, ..
} => Ok((accepted, reason)),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
pub async fn deploy_status(
&self,
id: &WorkloadId,
) -> Result<(kamaji_proto::WorkloadState, Option<String>), ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::DeployStatus {
request_id,
id: id.clone(),
},
request_id,
)
.await?;
match reply {
KamajiToYubaba::DeployStatusResult { state, detail, .. } => Ok((state, detail)),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
pub async fn probe(&self, id: &WorkloadId) -> Result<ProbeStatus, ClientError> {
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::Probe {
request_id,
id: id.clone(),
},
request_id,
)
.await?;
match reply {
KamajiToYubaba::ProbeResult { status, .. } => Ok(status),
other => Err(ClientError::Unexpected(format!("{other:?}"))),
}
}
async fn request(
&self,
req: YubabaToKamaji,
rid: RequestId,
) -> Result<KamajiToYubaba, ClientError> {
let bytes = encode_frame(&req)?;
let (tx, rx) = oneshot::channel();
{
let mut pending = self.shared.pending.lock().unwrap();
if self.shared.dead.load(Ordering::Acquire) {
return Err(ClientError::PeerClosed);
}
pending.insert(rid, tx);
}
let _guard = PendingGuard {
shared: &self.shared,
rid,
};
self.outbound
.send(bytes)
.map_err(|_| ClientError::PeerClosed)?;
let reply = rx.await.map_err(|_| ClientError::PeerClosed)?;
if let KamajiToYubaba::Error {
request_id,
code,
message,
} = reply
{
debug!(
?request_id,
?code,
%message,
"kamaji returned Error for request"
);
return Err(ClientError::Remote { code, message });
}
Ok(reply)
}
}
async fn reader_loop(mut rd: OwnedReadHalf, mut buf: Vec<u8>, shared: Arc<Shared>) {
loop {
let msg = match read_frame(&mut rd, &mut buf).await {
Ok(msg) => msg,
Err(e) => {
debug!(error = %e, "kamaji reader loop ended");
break;
}
};
match msg.reply_request_id() {
Some(rid) => {
let waiter = shared.pending.lock().unwrap().remove(&rid);
match waiter {
Some(tx) => {
let _ = tx.send(msg);
}
None => debug!(?rid, "kamaji reply with no waiter; dropped"),
}
}
None => match &msg {
KamajiToYubaba::Error { code, message, .. } => {
warn!(?code, %message, "kamaji error with no request_id; failing all waiters");
shared.fail_all(*code, message);
}
other => debug!(?other, "kamaji push frame; no consumer"),
},
}
}
shared.shutdown();
}
async fn writer_loop(
mut wr: OwnedWriteHalf,
mut outbound: mpsc::UnboundedReceiver<Vec<u8>>,
shared: Arc<Shared>,
) {
while let Some(bytes) = outbound.recv().await {
if let Err(e) = wr.write_all(&bytes).await {
debug!(error = %e, "kamaji writer loop ended");
break;
}
}
shared.shutdown();
}
async fn write_frame(wr: &mut OwnedWriteHalf, msg: &YubabaToKamaji) -> Result<(), ClientError> {
let bytes = encode_frame(msg)?;
wr.write_all(&bytes).await?;
Ok(())
}
async fn read_frame(
rd: &mut OwnedReadHalf,
buf: &mut Vec<u8>,
) -> Result<KamajiToYubaba, ClientError> {
let mut tmp = [0u8; 4096];
loop {
match decode_frame::<KamajiToYubaba>(buf) {
Ok((msg, consumed)) => {
buf.drain(..consumed);
return Ok(msg);
}
Err(CodecError::Truncated { .. }) => {
let n = rd.read(&mut tmp).await?;
if n == 0 {
return Err(ClientError::PeerClosed);
}
buf.extend_from_slice(&tmp[..n]);
}
Err(e) => return Err(ClientError::Codec(e)),
}
}
}
use async_trait::async_trait;
use kamaji_proto::WorkloadState as ProtoWorkloadState;
fn proto_state_to_status(s: ProtoWorkloadState) -> crate::WorkloadStatus {
match s {
ProtoWorkloadState::Pending | ProtoWorkloadState::Starting => {
crate::WorkloadStatus::Pending
}
ProtoWorkloadState::Running => crate::WorkloadStatus::Running,
ProtoWorkloadState::Draining => crate::WorkloadStatus::Stopping,
ProtoWorkloadState::Exited => crate::WorkloadStatus::Stopped,
ProtoWorkloadState::Failed => crate::WorkloadStatus::Failed {
reason: "workload exited with failure".into(),
},
_ => crate::WorkloadStatus::Failed {
reason: "unknown proto WorkloadState variant".into(),
},
}
}
pub(crate) fn mesh_to_proto(mesh: &crate::MeshAssignment) -> kamaji_proto::MeshAssignment {
kamaji_proto::MeshAssignment {
mesh_ip: mesh.mesh_ip,
wg_private_key: mesh.wg_private_key.clone(),
wg_listen_port: mesh.wg_listen_port,
peers: mesh
.peers
.iter()
.map(|p| kamaji_proto::WireguardPeer {
public_key: p.public_key.clone(),
endpoint: p.endpoint,
allowed_ips: p.allowed_ips.clone(),
})
.collect(),
netns_name: mesh.netns_name.clone(),
}
}
fn wire_mesh(mesh: &crate::MeshAssignment) -> Option<&crate::MeshAssignment> {
if !mesh.has_wireguard() && mesh.mesh_ip.is_loopback() {
None
} else {
Some(mesh)
}
}
fn entry_to_workload_state(entry: WorkloadEntry) -> crate::WorkloadState {
let ident = entry.mesh_ident.clone().unwrap_or_else(|| entry.id.0.clone());
crate::WorkloadState {
ident: crate::MeshIdent(ident),
container_id: entry.id.0,
status: proto_state_to_status(entry.state),
mesh_ip: None,
ports: if entry.named_ports.is_empty() {
crate::name_anonymous_ports(&entry.ports)
} else {
entry.named_ports
},
}
}
#[async_trait]
impl crate::Kamaji for KamajiClient {
fn backend(&self) -> crate::Backend {
crate::Backend::Native
}
async fn deploy_workload(
&self,
spec: &workload_spec::WorkloadSpec,
mesh: &crate::MeshAssignment,
) -> anyhow::Result<crate::DeployResult> {
let id = WorkloadId::new(&spec.name);
let workload_envelope = workload_spec::Workload::container(spec.clone());
self.deploy_envelope(&id, &workload_envelope, wire_mesh(mesh))
.await
.map_err(|e| anyhow::anyhow!("kamaji deploy_workload: {e}"))?;
Ok(crate::DeployResult {
container_id: id.0,
mesh_ip: mesh.mesh_ip,
task_pid: 0,
ports: Default::default(),
})
}
async fn list_workloads(&self) -> anyhow::Result<Vec<crate::WorkloadState>> {
let entries = self
.list()
.await
.map_err(|e| anyhow::anyhow!("kamaji list_workloads: {e}"))?;
Ok(entries.into_iter().map(entry_to_workload_state).collect())
}
async fn get_workload(
&self,
ident: &crate::MeshIdent,
) -> anyhow::Result<Option<crate::WorkloadState>> {
let all = self.list_workloads().await?;
Ok(all.into_iter().find(|s| s.ident == *ident))
}
async fn stream_logs(
&self,
_ident: &crate::MeshIdent,
_opts: crate::LogOpts,
) -> anyhow::Result<crate::LogStream> {
Err(anyhow::anyhow!(
"stream_logs not yet in the sibling-Kamaji wire protocol"
))
}
async fn restart_workload(&self, ident: &crate::MeshIdent) -> anyhow::Result<()> {
let id = WorkloadId::new(&ident.0);
self.stop(&id)
.await
.map_err(|e| anyhow::anyhow!("kamaji restart_workload (via stop): {e}"))
}
async fn graceful_upgrade_workload(
&self,
spec: &workload_spec::WorkloadSpec,
mesh: &crate::MeshAssignment,
) -> anyhow::Result<crate::DeployResult> {
let id = WorkloadId::new(&spec.name);
let workload_envelope = workload_spec::Workload::container(spec.clone());
let request_id = self.next_request_id();
let reply = self
.request(
YubabaToKamaji::GracefulUpgrade {
request_id,
id: id.clone(),
spec: workload_envelope,
},
request_id,
)
.await
.map_err(|e| anyhow::anyhow!("kamaji graceful_upgrade_workload: {e}"))?;
match reply {
KamajiToYubaba::Ack {
kind: kamaji_proto::AckKind::GracefulUpgrade,
..
} => {
Ok(crate::DeployResult {
container_id: id.0,
mesh_ip: mesh.mesh_ip,
task_pid: 0,
ports: Default::default(),
})
}
other => Err(anyhow::anyhow!(
"kamaji graceful_upgrade_workload: unexpected reply {other:?}"
)),
}
}
async fn teardown_workload(&self, ident: &crate::MeshIdent) -> anyhow::Result<()> {
let id = WorkloadId::new(&ident.0);
self.stop(&id)
.await
.map_err(|e| anyhow::anyhow!("kamaji teardown_workload: {e}"))
}
async fn health(&self) -> anyhow::Result<crate::RuntimeHealth> {
Ok(crate::RuntimeHealth {
ok: true,
version: Some(self.info().kamaji_version.clone()),
detail: None,
})
}
}
pub async fn connect_with_timeout(
socket: impl Into<PathBuf>,
timeout: Duration,
) -> Result<KamajiClient> {
let socket = socket.into();
let result = tokio::time::timeout(timeout, KamajiClient::connect(socket.clone()))
.await
.map_err(|_| {
anyhow!(
"timed out after {:?} connecting to kamaji at {}",
timeout,
socket.display()
)
})?;
result.with_context(|| format!("connecting to kamaji at {}", socket.display()))
}
const RECONNECT_POLL_INTERVAL: Duration = Duration::from_secs(2);
const RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(30);
#[derive(Clone)]
pub struct KamajiSibling {
socket: Arc<Path>,
rx: tokio::sync::watch::Receiver<Option<Arc<KamajiClient>>>,
}
impl KamajiSibling {
pub fn new(client: KamajiClient, socket: impl Into<PathBuf>, connect_timeout: Duration) -> Self {
let socket: Arc<Path> = socket.into().into();
let (tx, rx) = tokio::sync::watch::channel(Some(Arc::new(client)));
tokio::spawn(reconnect_watchdog(tx, socket.to_path_buf(), connect_timeout));
Self { socket, rx }
}
pub fn current(&self) -> Option<Arc<KamajiClient>> {
self.rx.borrow().clone()
}
pub fn socket(&self) -> &Path {
&self.socket
}
pub fn subscribe(&self) -> tokio::sync::watch::Receiver<Option<Arc<KamajiClient>>> {
self.rx.clone()
}
}
async fn reconnect_watchdog(
tx: tokio::sync::watch::Sender<Option<Arc<KamajiClient>>>,
socket: PathBuf,
connect_timeout: Duration,
) {
loop {
loop {
if tx.is_closed() {
return;
}
let dead = tx.borrow().as_ref().is_none_or(|c| c.is_dead());
if dead {
break;
}
tokio::time::sleep(RECONNECT_POLL_INTERVAL).await;
}
if tx.is_closed() {
return;
}
if tx.send(None).is_err() {
return;
}
warn!(socket = %socket.display(), "kamaji sibling connection lost; reconnecting");
let mut backoff = RECONNECT_INITIAL_BACKOFF;
loop {
match connect_with_timeout(socket.clone(), connect_timeout).await {
Ok(client) => {
info!(
socket = %socket.display(),
kamaji_version = %client.info().kamaji_version,
"kamaji sibling reconnected"
);
if tx.send(Some(Arc::new(client))).is_err() {
return;
}
break;
}
Err(e) => {
if tx.is_closed() {
return;
}
debug!(
socket = %socket.display(),
error = %e,
next_attempt_in = ?backoff,
"kamaji reconnect attempt failed"
);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(RECONNECT_MAX_BACKOFF);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use kamaji_proto::{AckKind, WorkloadState};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
struct ServerConn {
stream: tokio::net::UnixStream,
buf: Vec<u8>,
}
impl ServerConn {
async fn recv(&mut self) -> YubabaToKamaji {
let mut tmpbuf = [0u8; 4096];
loop {
match decode_frame::<YubabaToKamaji>(&self.buf) {
Ok((m, n)) => {
self.buf.drain(..n);
return m;
}
Err(CodecError::Truncated { .. }) => {
let n = self.stream.read(&mut tmpbuf).await.unwrap();
assert!(n > 0, "client closed the connection");
self.buf.extend_from_slice(&tmpbuf[..n]);
}
Err(e) => panic!("decode req: {e}"),
}
}
}
async fn send(&mut self, msg: &KamajiToYubaba) {
let bytes = encode_frame(msg).unwrap();
self.stream.write_all(&bytes).await.unwrap();
}
}
async fn scripted_server<F, Fut>(drive: F) -> (tempfile::TempDir, PathBuf)
where
F: FnOnce(ServerConn) -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let tmp = tempfile::tempdir().unwrap();
let sock = tmp.path().join("kamaji.sock");
let listener = UnixListener::bind(&sock).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut conn = ServerConn {
stream,
buf: Vec::with_capacity(4096),
};
assert!(matches!(conn.recv().await, YubabaToKamaji::Hello { .. }));
conn.send(&KamajiToYubaba::Welcome {
version: ProtocolVersion::CURRENT,
kamaji_version: "test-0.0.1".into(),
})
.await;
drive(conn).await;
});
(tmp, sock)
}
async fn one_shot_server(
expect_after_hello: impl FnOnce(YubabaToKamaji) -> KamajiToYubaba + Send + 'static,
) -> (tempfile::TempDir, PathBuf) {
scripted_server(|mut conn| async move {
let req = conn.recv().await;
let reply = expect_after_hello(req);
conn.send(&reply).await;
})
.await
}
fn request_id_of(req: &YubabaToKamaji) -> RequestId {
match req {
YubabaToKamaji::List { request_id }
| YubabaToKamaji::Deploy { request_id, .. }
| YubabaToKamaji::Stop { request_id, .. }
| YubabaToKamaji::Drain { request_id, .. }
| YubabaToKamaji::Probe { request_id, .. } => *request_id,
other => panic!("no request_id on {other:?}"),
}
}
fn entry(name: &str) -> WorkloadEntry {
WorkloadEntry {
mesh_ident: None,
id: WorkloadId::new(name),
state: WorkloadState::Running,
pid: Some(1),
ports: Vec::new(),
named_ports: Default::default(),
spec_digest: None,
}
}
#[tokio::test]
async fn connect_handshakes_and_captures_info() {
let (_tmp, sock) = one_shot_server(|_req| KamajiToYubaba::WorkloadList {
request_id: RequestId(1),
entries: vec![],
})
.await;
let client = KamajiClient::connect(sock).await.expect("connect");
assert_eq!(client.info().kamaji_version, "test-0.0.1");
let entries = client.list().await.expect("list");
assert!(entries.is_empty());
}
#[tokio::test]
async fn list_returns_workload_entries() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::List { request_id } => request_id,
other => panic!("expected List, got {other:?}"),
};
KamajiToYubaba::WorkloadList {
request_id: rid,
entries: vec![WorkloadEntry {
mesh_ident: None,
id: WorkloadId::new("foo"),
state: WorkloadState::Running,
pid: Some(42),
ports: Vec::new(),
named_ports: Default::default(),
spec_digest: None,
}],
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let entries = client.list().await.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, WorkloadId::new("foo"));
assert_eq!(entries[0].state, WorkloadState::Running);
assert_eq!(entries[0].pid, Some(42));
}
#[tokio::test]
async fn stop_handles_ack() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::Stop { request_id, .. } => request_id,
other => panic!("expected Stop, got {other:?}"),
};
KamajiToYubaba::Ack {
request_id: rid,
kind: AckKind::Stop,
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
client.stop(&WorkloadId::new("foo")).await.expect("stop");
}
#[tokio::test]
async fn drain_surfaces_ack_payload() {
let (_tmp, sock) = one_shot_server(|req| {
let (rid, id) = match req {
YubabaToKamaji::Drain {
request_id,
id,
budget,
} => {
assert_eq!(budget.flush_ms, 100);
assert_eq!(budget.checkpoint_ms, 200);
(request_id, id)
}
other => panic!("expected Drain, got {other:?}"),
};
KamajiToYubaba::DrainAck {
request_id: rid,
id,
accepted: true,
reason: Some("flushed in 50ms".into()),
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let (accepted, reason) = client
.drain(
&WorkloadId::new("foo"),
DrainBudget {
flush_ms: 100,
checkpoint_ms: 200,
},
)
.await
.unwrap();
assert!(accepted);
assert_eq!(reason.as_deref(), Some("flushed in 50ms"));
}
#[tokio::test]
async fn remote_error_is_surfaced_as_client_remote() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::Stop { request_id, .. } => request_id,
other => panic!("expected Stop, got {other:?}"),
};
KamajiToYubaba::Error {
request_id: Some(rid),
code: ErrorCode::UnknownWorkload,
message: "no such id".into(),
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let err = client.stop(&WorkloadId::new("foo")).await.unwrap_err();
match err {
ClientError::Remote { code, message } => {
assert_eq!(code, ErrorCode::UnknownWorkload);
assert_eq!(message, "no such id");
}
other => panic!("expected Remote, got {other:?}"),
}
}
#[tokio::test]
async fn deploy_status_result_is_routed_back_to_its_caller() {
let (_tmp, sock) = one_shot_server(|req| {
let (rid, id) = match req {
YubabaToKamaji::DeployStatus { request_id, id } => (request_id, id),
other => panic!("expected DeployStatus, got {other:?}"),
};
KamajiToYubaba::DeployStatusResult {
request_id: rid,
id,
state: WorkloadState::Failed,
detail: Some("materialize failed".into()),
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let (state, detail) = tokio::time::timeout(
Duration::from_secs(5),
client.deploy_status(&WorkloadId::new("yah-marketing")),
)
.await
.expect("deploy_status must answer, not park on a dropped reply")
.expect("deploy_status");
assert_eq!(state, WorkloadState::Failed);
assert_eq!(detail.as_deref(), Some("materialize failed"));
}
#[tokio::test]
async fn deploy_status_for_an_unadmitted_id_is_a_remote_unknown_workload() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::DeployStatus { request_id, .. } => request_id,
other => panic!("expected DeployStatus, got {other:?}"),
};
KamajiToYubaba::Error {
request_id: Some(rid),
code: ErrorCode::UnknownWorkload,
message: "no deploy on record".into(),
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let err = tokio::time::timeout(
Duration::from_secs(5),
client.deploy_status(&WorkloadId::new("nope")),
)
.await
.expect("deploy_status must answer")
.unwrap_err();
assert!(
matches!(
err,
ClientError::Remote {
code: ErrorCode::UnknownWorkload,
..
}
),
"expected Remote/UnknownWorkload, got {err:?}"
);
}
#[tokio::test]
async fn connect_with_timeout_fails_on_missing_socket() {
let tmp = tempfile::tempdir().unwrap();
let sock = tmp.path().join("does-not-exist.sock");
let err = connect_with_timeout(sock, Duration::from_millis(250))
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("connecting to kamaji"),
"error should mention the socket path: {msg}"
);
}
#[tokio::test]
async fn kamaji_sibling_reconnects_after_the_peer_restarts() {
let tmp = tempfile::tempdir().unwrap();
let sock = tmp.path().join("kamaji.sock");
let listener = UnixListener::bind(&sock).unwrap();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut conn = ServerConn {
stream,
buf: Vec::with_capacity(4096),
};
assert!(matches!(conn.recv().await, YubabaToKamaji::Hello { .. }));
conn.send(&KamajiToYubaba::Welcome {
version: ProtocolVersion::CURRENT,
kamaji_version: "gen-1".into(),
})
.await;
drop(conn);
let (stream, _) = listener.accept().await.unwrap();
let mut conn = ServerConn {
stream,
buf: Vec::with_capacity(4096),
};
assert!(matches!(conn.recv().await, YubabaToKamaji::Hello { .. }));
conn.send(&KamajiToYubaba::Welcome {
version: ProtocolVersion::CURRENT,
kamaji_version: "gen-2".into(),
})
.await;
std::future::pending::<()>().await;
});
let first = KamajiClient::connect(&sock).await.unwrap();
assert_eq!(first.info().kamaji_version, "gen-1");
let sibling = KamajiSibling::new(first, sock.clone(), Duration::from_secs(2));
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if let Some(c) = sibling.current() {
if c.info().kamaji_version == "gen-2" {
break;
}
}
assert!(
tokio::time::Instant::now() < deadline,
"KamajiSibling never reconnected to the second generation"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn a_cancelled_request_does_not_desync_the_next_one() {
let (_tmp, sock) = scripted_server(|mut conn| async move {
let first = request_id_of(&conn.recv().await);
tokio::time::sleep(Duration::from_millis(150)).await;
conn.send(&KamajiToYubaba::WorkloadList {
request_id: first,
entries: vec![entry("abandoned-first-reply")],
})
.await;
let second = request_id_of(&conn.recv().await);
conn.send(&KamajiToYubaba::WorkloadList {
request_id: second,
entries: vec![entry("correct-second-reply")],
})
.await;
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let abandoned = tokio::time::timeout(Duration::from_millis(20), client.list()).await;
assert!(abandoned.is_err(), "first call should have timed out");
let entries = client.list().await.expect("second list after a cancelled one");
assert_eq!(
entries[0].id,
WorkloadId::new("correct-second-reply"),
"the second caller was handed the abandoned reply — the connection desynced"
);
}
#[tokio::test]
async fn concurrent_requests_are_demuxed_by_request_id() {
let (_tmp, sock) = scripted_server(|mut conn| async move {
let first = request_id_of(&conn.recv().await);
let second = request_id_of(&conn.recv().await);
conn.send(&KamajiToYubaba::Ack {
request_id: second,
kind: AckKind::Stop,
})
.await;
conn.send(&KamajiToYubaba::WorkloadList {
request_id: first,
entries: vec![entry("for-the-list-caller")],
})
.await;
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let stop_id = WorkloadId::new("foo");
let (list, stop) = tokio::join!(client.list(), client.stop(&stop_id));
assert_eq!(list.unwrap()[0].id, WorkloadId::new("for-the-list-caller"));
stop.expect("stop should get its own ack");
}
#[tokio::test]
async fn a_pushed_lifecycle_event_is_not_delivered_as_a_reply() {
let (_tmp, sock) = scripted_server(|mut conn| async move {
let rid = request_id_of(&conn.recv().await);
conn.send(&KamajiToYubaba::WorkloadStarted {
id: WorkloadId::new("some-other-workload"),
pid: 7,
})
.await;
conn.send(&KamajiToYubaba::WorkloadList {
request_id: rid,
entries: vec![entry("the-actual-reply")],
})
.await;
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let entries = client.list().await.expect("push must not be read as the reply");
assert_eq!(entries[0].id, WorkloadId::new("the-actual-reply"));
}
#[tokio::test]
async fn an_uncorrelated_error_fails_the_waiter_instead_of_hanging_it() {
let (_tmp, sock) = scripted_server(|mut conn| async move {
let _ = conn.recv().await;
conn.send(&KamajiToYubaba::Error {
request_id: None,
code: ErrorCode::Internal,
message: "unhandled message kind".into(),
})
.await;
std::future::pending::<()>().await;
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let err = tokio::time::timeout(Duration::from_secs(2), client.list())
.await
.expect("caller must not park forever")
.unwrap_err();
match err {
ClientError::Remote { code, message } => {
assert_eq!(code, ErrorCode::Internal);
assert_eq!(message, "unhandled message kind");
}
other => panic!("expected Remote, got {other:?}"),
}
}
#[tokio::test]
async fn a_dead_connection_wakes_parked_callers_and_fails_later_ones() {
let (_tmp, sock) = scripted_server(|mut conn| async move {
let _ = conn.recv().await;
drop(conn);
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let err = tokio::time::timeout(Duration::from_secs(2), client.list())
.await
.expect("caller must not park forever")
.unwrap_err();
assert!(matches!(err, ClientError::PeerClosed), "got {err:?}");
let err = tokio::time::timeout(Duration::from_secs(2), client.list())
.await
.expect("a request onto a dead connection must fail fast")
.unwrap_err();
assert!(matches!(err, ClientError::PeerClosed), "got {err:?}");
}
use crate::Kamaji as ConstableTrait;
#[tokio::test]
async fn constable_backend_returns_native() {
let (_tmp, sock) = one_shot_server(|_| KamajiToYubaba::WorkloadList {
request_id: RequestId(1),
entries: vec![],
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
assert_eq!(client.backend(), crate::Backend::Native);
}
#[tokio::test]
async fn constable_list_workloads_maps_proto_entries() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::List { request_id } => request_id,
other => panic!("expected List, got {other:?}"),
};
KamajiToYubaba::WorkloadList {
request_id: rid,
entries: vec![
WorkloadEntry {
mesh_ident: None,
id: WorkloadId::new("svc-a"),
state: WorkloadState::Running,
pid: Some(1000),
ports: Vec::new(),
named_ports: Default::default(),
spec_digest: None,
},
WorkloadEntry {
mesh_ident: None,
id: WorkloadId::new("svc-b"),
state: WorkloadState::Exited,
pid: None,
ports: Vec::new(),
named_ports: Default::default(),
spec_digest: None,
},
],
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let states = client.list_workloads().await.unwrap();
assert_eq!(states.len(), 2);
assert_eq!(states[0].ident, crate::MeshIdent("svc-a".into()));
assert!(matches!(states[0].status, crate::WorkloadStatus::Running));
assert_eq!(states[1].ident, crate::MeshIdent("svc-b".into()));
assert!(matches!(states[1].status, crate::WorkloadStatus::Stopped));
}
#[tokio::test]
async fn constable_get_workload_finds_by_ident() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::List { request_id } => request_id,
other => panic!("expected List, got {other:?}"),
};
KamajiToYubaba::WorkloadList {
request_id: rid,
entries: vec![WorkloadEntry {
mesh_ident: None,
id: WorkloadId::new("target"),
state: WorkloadState::Running,
pid: Some(42),
ports: Vec::new(),
named_ports: Default::default(),
spec_digest: None,
}],
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let state = client
.get_workload(&crate::MeshIdent("target".into()))
.await
.unwrap();
assert!(state.is_some());
assert_eq!(state.unwrap().ident, crate::MeshIdent("target".into()));
}
#[tokio::test]
async fn constable_get_workload_returns_none_for_unknown_ident() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::List { request_id } => request_id,
other => panic!("expected List, got {other:?}"),
};
KamajiToYubaba::WorkloadList {
request_id: rid,
entries: vec![],
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let state = client
.get_workload(&crate::MeshIdent("nobody".into()))
.await
.unwrap();
assert!(state.is_none());
}
#[tokio::test]
async fn constable_teardown_workload_sends_stop() {
let (_tmp, sock) = one_shot_server(|req| {
let rid = match req {
YubabaToKamaji::Stop { request_id, id } => {
assert_eq!(id, WorkloadId::new("svc-to-stop"));
request_id
}
other => panic!("expected Stop, got {other:?}"),
};
KamajiToYubaba::Ack {
request_id: rid,
kind: AckKind::Stop,
}
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
client
.teardown_workload(&crate::MeshIdent("svc-to-stop".into()))
.await
.unwrap();
}
#[tokio::test]
async fn constable_health_returns_ok_with_kamaji_version() {
let (_tmp, sock) = one_shot_server(|_| KamajiToYubaba::WorkloadList {
request_id: RequestId(1),
entries: vec![],
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let health = client.health().await.unwrap();
assert!(health.ok);
assert_eq!(health.version.as_deref(), Some("test-0.0.1"));
}
#[tokio::test]
async fn constable_stream_logs_returns_not_supported_err() {
let (_tmp, sock) = one_shot_server(|_| KamajiToYubaba::WorkloadList {
request_id: RequestId(1),
entries: vec![],
})
.await;
let client = KamajiClient::connect(sock).await.unwrap();
let result = client
.stream_logs(&crate::MeshIdent("any".into()), crate::LogOpts::default())
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("expected stream_logs to return Err"),
};
assert!(err.to_string().contains("not yet in the sibling"));
}
}