use crate::machine;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[expect(
clippy::large_enum_variant,
reason = "one request per connection, matched once: boxing the spec saves nothing"
)]
pub enum Request {
Status {},
Shutdown {
#[serde(default)]
force: bool,
},
MachineStart {
name: String,
spec: machine::Spec,
},
MachineStop {
name: String,
},
MachineStatus {
name: String,
},
MachineList {},
MachineResolve {
name: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Reply<T> {
Done(T),
Error {
message: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Status {
pub pid: u32,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MachineStart {
Started(machine::Status),
AlreadyRunning(machine::Status),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MachineStop {
Stopped,
NotRunning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MachineStatus {
pub status: machine::Status,
pub marker: Option<machine::Spec>,
}
#[cfg(test)]
mod tests {
use crate::config::{Egress, Pattern};
use crate::image::{Digest, Image};
use crate::machine::State::Running;
use super::*;
#[test]
fn request_status_and_shutdown_payloads() {
let status = Request::Status {};
let shutdown_force_false = Request::Shutdown { force: false };
let shutdown_force_true = Request::Shutdown { force: true };
assert_eq!(serde_json::to_string(&status).unwrap(), r#"{"status":{}}"#);
assert_eq!(
serde_json::to_string(&shutdown_force_false).unwrap(),
r#"{"shutdown":{"force":false}}"#
);
assert_eq!(
serde_json::to_string(&shutdown_force_true).unwrap(),
r#"{"shutdown":{"force":true}}"#
);
}
#[test]
fn shutdown_without_force_still_parses() {
let shutdown: Request = serde_json::from_str(r#"{"shutdown":{}}"#).unwrap();
assert_eq!(shutdown, Request::Shutdown { force: false });
let forced: Request = serde_json::from_str(r#"{"shutdown":{"force":true}}"#).unwrap();
assert_eq!(forced, Request::Shutdown { force: true });
}
#[test]
fn reply_done_and_error_payloads() {
let done = Reply::Done(Status {
pid: 1234,
version: "0.1.0".to_string(),
});
let error: Reply<Status> = Reply::Error {
message: "boom".to_string(),
};
assert_eq!(
serde_json::to_string(&done).unwrap(),
r#"{"done":{"pid":1234,"version":"0.1.0"}}"#
);
assert_eq!(
serde_json::to_string(&error).unwrap(),
r#"{"error":{"message":"boom"}}"#
);
}
#[test]
fn machine_requests_serialize_to_the_documented_lines() {
let machine_status = Request::MachineStatus {
name: "x".to_string(),
};
assert_eq!(
serde_json::to_string(&machine_status).unwrap(),
r#"{"machine_status":{"name":"x"}}"#
);
let machine_start = Request::MachineStart {
name: "x".to_string(),
spec: machine::Spec::default(),
};
let line = serde_json::to_string(&machine_start).unwrap();
assert!(line.starts_with(r#"{"machine_start":{"name":"x","spec":{"#));
assert_eq!(
serde_json::from_str::<Request>(&line).unwrap(),
machine_start
);
let machine_start_with_egress = Request::MachineStart {
name: "x".to_string(),
spec: machine::Spec {
egress: Egress {
allow: vec![Pattern::parse("api.github.com").unwrap()],
deny: vec![],
},
..machine::Spec::default()
},
};
let line = serde_json::to_string(&machine_start_with_egress).unwrap();
assert!(
line.contains(r#""egress":{"allow":["api.github.com"],"deny":[]}"#),
"{line}"
);
assert_eq!(
serde_json::from_str::<Request>(&line).unwrap(),
machine_start_with_egress
);
let machine_stop = Request::MachineStop {
name: "x".to_string(),
};
let line = serde_json::to_string(&machine_stop).unwrap();
assert_eq!(line, r#"{"machine_stop":{"name":"x"}}"#);
assert_eq!(
serde_json::from_str::<Request>(&line).unwrap(),
machine_stop
);
let machine_list = Request::MachineList {};
let line = serde_json::to_string(&machine_list).unwrap();
assert_eq!(line, r#"{"machine_list":{}}"#);
assert_eq!(
serde_json::from_str::<Request>(&line).unwrap(),
machine_list
);
let machine_resolve = Request::MachineResolve {
name: "x".to_string(),
};
let line = serde_json::to_string(&machine_resolve).unwrap();
assert_eq!(line, r#"{"machine_resolve":{"name":"x"}}"#);
assert_eq!(
serde_json::from_str::<Request>(&line).unwrap(),
machine_resolve
);
}
#[test]
fn machine_stop_outcomes_serialize_as_words() {
let stopped = Reply::Done(MachineStop::Stopped);
assert_eq!(
serde_json::to_string(&stopped).unwrap(),
r#"{"done":"stopped"}"#
);
let not_running = Reply::Done(MachineStop::NotRunning);
assert_eq!(
serde_json::to_string(¬_running).unwrap(),
r#"{"done":"not_running"}"#
);
}
#[test]
fn machine_start_outcomes_serialize_to_the_documented_shapes() {
let status = machine::Status {
name: "x".to_string(),
state: Running,
pid: Some(4242),
};
let done = Reply::Done(MachineStart::Started(status.clone()));
assert_eq!(
serde_json::to_string(&done).unwrap(),
r#"{"done":{"started":{"name":"x","state":"running","pid":4242}}}"#
);
let done = Reply::Done(MachineStart::AlreadyRunning(status));
assert_eq!(
serde_json::to_string(&done).unwrap(),
r#"{"done":{"already_running":{"name":"x","state":"running","pid":4242}}}"#
);
}
#[test]
fn machine_status_reply_carries_the_spec() {
let status = machine::Status {
name: "x".to_string(),
state: Running,
pid: Some(4242),
};
let done = Reply::Done(MachineStatus {
status: status.clone(),
marker: None,
});
assert_eq!(
serde_json::to_string(&done).unwrap(),
r#"{"done":{"status":{"name":"x","state":"running","pid":4242},"marker":null}}"#
);
let done = Reply::Done(MachineStatus {
status,
marker: Some(machine::Spec {
image: machine::ImageSpec {
name: Image::parse("ghcr.io/org/name:latest").unwrap(),
digest: Some(Digest::parse(&format!("sha256:{}", "a".repeat(64))).unwrap()),
},
..machine::Spec::default()
}),
});
let line = serde_json::to_string(&done).unwrap();
assert!(
line.contains(&format!(
r#""marker":{{"image":{{"name":"ghcr.io/org/name:latest","digest":"sha256:{}"}}"#,
"a".repeat(64)
)),
"{line}"
);
}
}