use std::borrow::Cow;
use std::collections::HashMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
Running,
Exited,
Acked,
}
impl Phase {
pub const ALL: [Phase; 3] = [Phase::Running, Phase::Exited, Phase::Acked];
pub const fn as_str(self) -> &'static str {
match self {
Phase::Running => "running",
Phase::Exited => "exited",
Phase::Acked => "acked",
}
}
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct Outcome {
pub exit_code: Option<i32>,
pub signal: Option<i32>,
#[serde(default)]
pub timed_out: bool,
pub stdout: String,
pub stderr: String,
pub truncated: bool,
pub writers_may_be_alive: bool,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamKind {
Stdout,
Stderr,
}
impl StreamKind {
pub const ALL: [StreamKind; 2] = [StreamKind::Stdout, StreamKind::Stderr];
pub const fn as_str(self) -> &'static str {
match self {
StreamKind::Stdout => "stdout",
StreamKind::Stderr => "stderr",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(untagged)]
pub enum NameOrId {
Id(u32),
Name(String),
}
impl From<u32> for NameOrId {
fn from(id: u32) -> Self {
NameOrId::Id(id)
}
}
impl From<&str> for NameOrId {
fn from(name: &str) -> Self {
NameOrId::Name(name.to_string())
}
}
impl From<String> for NameOrId {
fn from(name: String) -> Self {
NameOrId::Name(name)
}
}
impl std::str::FromStr for NameOrId {
type Err = std::convert::Infallible;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
Ok(match raw.parse::<u32>() {
Ok(id) if raw.bytes().all(|b| b.is_ascii_digit()) => NameOrId::Id(id),
_ => NameOrId::Name(raw.to_string()),
})
}
}
impl std::fmt::Display for NameOrId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NameOrId::Id(id) => write!(f, "{id}"),
NameOrId::Name(name) => f.write_str(name),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(untagged)]
pub enum Shell {
Flag(bool),
Named(String),
}
impl Default for Shell {
fn default() -> Self {
Shell::Flag(false)
}
}
impl From<bool> for Shell {
fn from(flag: bool) -> Self {
Shell::Flag(flag)
}
}
impl From<&str> for Shell {
fn from(name: &str) -> Self {
Shell::Named(name.to_string())
}
}
impl From<String> for Shell {
fn from(name: String) -> Self {
Shell::Named(name)
}
}
impl Shell {
pub fn is_shell(&self) -> bool {
!matches!(self, Shell::Flag(false))
}
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
#[non_exhaustive]
pub struct StartRequest {
pub exec_id: String,
pub command: Vec<String>,
#[serde(default)]
pub shell: Shell,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub user: Option<NameOrId>,
#[serde(default)]
pub group: Option<NameOrId>,
#[serde(default)]
pub timeout_sec: Option<f64>,
#[serde(default)]
pub stdin: bool,
#[serde(default)]
pub reap_group_on_exit: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub poweroff_on_exit: bool,
#[serde(default)]
pub inherit_image_env: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StdinRequest {
#[serde(default)]
pub data_b64: Option<String>,
#[serde(default)]
pub signal: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StdinResponse {
pub exec_id: String,
pub written: usize,
pub eof: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct StreamQuery {
#[serde(default)]
pub offset: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct OutputEvent {
pub offset: u64,
pub stream: StreamKind,
pub output: String,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct GapEvent {
pub from: u64,
pub to: u64,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ExitEvent {
pub exit_code: Option<i32>,
pub signal: Option<i32>,
#[serde(default)]
pub timed_out: bool,
pub truncated: bool,
pub writers_may_be_alive: bool,
pub offset: u64,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct StartResponse {
pub exec_id: String,
pub phase: Phase,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct KillResponse {
pub exec_id: String,
pub killed: bool,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
pub struct ProcGroup {
pub exec_id: String,
pub pgid: Option<u32>,
pub started_at: u64,
pub child_exited: bool,
pub reap: bool,
pub pids: Vec<u32>,
}
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct ProcsResponse {
pub procs: Vec<ProcGroup>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct PollResponse {
pub exec_id: String,
pub phase: Phase,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(flatten)]
pub result: Option<Outcome>,
}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct ErrorBody {
pub error: Cow<'static, str>,
pub detail: String,
}
pub const EVENT_OUTPUT: &str = "output";
pub const EVENT_GAP: &str = "gap";
pub const EVENT_EXIT: &str = "exit";
pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
pub const ERROR_UNKNOWN_EXEC: &str = "unknown_exec";
pub const ERROR_SPAWN_FAILED: &str = "spawn_failed";
pub const ERROR_POWEROFF_UNAVAILABLE: &str = "poweroff_unavailable";
pub const ERROR_STILL_RUNNING: &str = "still_running";
pub const ERROR_ALREADY_ACKED: &str = "already_acked";
pub const ERROR_STDIN_NOT_REQUESTED: &str = "stdin_not_requested";
pub const ERROR_STDIN_CLOSED: &str = "stdin_closed";
pub const ERROR_STDIN_WRITE_TIMEOUT: &str = "stdin_write_timeout";
pub const ERROR_STDIN_WRITE_TOO_LARGE: &str = "stdin_write_too_large";
pub const ERROR_STDIN_WRITE_FAILED: &str = "stdin_write_failed";
pub const ERROR_UNKNOWN_USER: &str = "unknown_user";
pub const ERROR_UNKNOWN_GROUP: &str = "unknown_group";
pub const ERROR_UNKNOWN_SHELL: &str = "unknown_shell";
fn is_false(value: &bool) -> bool {
!value
}
impl StartRequest {
pub fn new(exec_id: impl Into<String>, command: Vec<String>) -> Self {
StartRequest {
exec_id: exec_id.into(),
command,
shell: Shell::default(),
cwd: None,
env: HashMap::new(),
user: None,
group: None,
timeout_sec: None,
stdin: false,
reap_group_on_exit: false,
poweroff_on_exit: false,
inherit_image_env: false,
}
}
pub fn with_shell(mut self, shell: impl Into<Shell>) -> Self {
self.shell = shell.into();
self
}
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
pub fn with_user(mut self, user: Option<NameOrId>) -> Self {
self.user = user;
self
}
pub fn with_group(mut self, group: Option<NameOrId>) -> Self {
self.group = group;
self
}
pub fn with_timeout_sec(mut self, timeout_sec: Option<f64>) -> Self {
self.timeout_sec = timeout_sec;
self
}
pub fn with_stdin(mut self, stdin: bool) -> Self {
self.stdin = stdin;
self
}
pub fn with_reap_group_on_exit(mut self, reap: bool) -> Self {
self.reap_group_on_exit = reap;
self
}
pub fn with_poweroff_on_exit(mut self, poweroff: bool) -> Self {
self.poweroff_on_exit = poweroff;
self
}
pub fn with_inherit_image_env(mut self, inherit: bool) -> Self {
self.inherit_image_env = inherit;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase_and_stream_kind_round_trip_through_their_serde_names() {
assert_eq!(
serde_json::to_string(&Phase::Running).expect("serializes"),
"\"running\""
);
assert_eq!(
serde_json::from_str::<Phase>("\"acked\"").expect("deserializes"),
Phase::Acked
);
assert_eq!(
serde_json::to_string(&StreamKind::Stderr).expect("serializes"),
"\"stderr\""
);
}
#[test]
fn as_str_agrees_with_serde_for_every_phase_and_stream_kind() {
for phase in Phase::ALL {
assert_eq!(
serde_json::to_string(&phase).expect("serializes"),
format!("\"{}\"", phase.as_str()),
"{phase:?} spells differently through as_str and serde"
);
match phase {
Phase::Running | Phase::Exited | Phase::Acked => {}
}
}
for kind in StreamKind::ALL {
assert_eq!(
serde_json::to_string(&kind).expect("serializes"),
format!("\"{}\"", kind.as_str()),
"{kind:?} spells differently through as_str and serde"
);
match kind {
StreamKind::Stdout | StreamKind::Stderr => {}
}
}
}
#[test]
fn a_response_the_daemon_writes_deserializes_on_the_client_side() {
let written = serde_json::to_string(&StartResponse {
exec_id: "e1".into(),
phase: Phase::Running,
})
.expect("serializes");
let read: StartResponse = serde_json::from_str(&written).expect("deserializes");
assert_eq!(read.exec_id, "e1");
assert_eq!(read.phase, Phase::Running);
}
#[test]
fn a_start_request_omitting_every_defaulted_field_deserializes() {
let request: StartRequest =
serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
assert_eq!(request.shell, Shell::Flag(false));
assert!(!request.shell.is_shell());
assert!(request.user.is_none() && request.group.is_none());
assert!(
!request.inherit_image_env,
"inheriting the image env is opt-in: an old client keeps the exact launch map"
);
assert!(request.cwd.is_none());
assert!(request.env.is_empty());
assert!(request.timeout_sec.is_none());
assert!(!request.stdin);
assert!(
!request.reap_group_on_exit,
"reaping is opt-in: an old client that never heard of the flag keeps today's \
grandchild-output guarantee"
);
}
#[test]
fn the_builder_defaults_are_the_ones_serde_gives_an_omitted_field() {
let built = StartRequest::new("e1", vec!["true".into()]);
let read: StartRequest =
serde_json::from_str(r#"{"exec_id":"e1","command":["true"]}"#).expect("deserializes");
assert_eq!(
serde_json::to_value(&built).expect("serializes"),
serde_json::to_value(&read).expect("serializes"),
);
}
#[test]
fn user_group_and_shell_read_both_spellings_and_write_them_back() {
let request: StartRequest = serde_json::from_str(
r#"{"exec_id":"e1","command":["id"],"user":1000,"group":"staff","shell":"bash","inherit_image_env":true}"#,
)
.expect("deserializes");
assert_eq!(request.user, Some(NameOrId::Id(1000)));
assert_eq!(request.group, Some(NameOrId::Name("staff".into())));
assert_eq!(request.shell, Shell::Named("bash".into()));
assert!(request.inherit_image_env);
let written = serde_json::to_value(&request).expect("serializes");
assert_eq!(written["user"], 1000);
assert_eq!(written["group"], "staff");
assert_eq!(written["shell"], "bash");
let legacy: StartRequest =
serde_json::from_str(r#"{"exec_id":"e1","command":["id"],"shell":true,"user":0}"#)
.expect("deserializes");
assert_eq!(legacy.shell, Shell::Flag(true));
let written = serde_json::to_value(&legacy).expect("serializes");
assert_eq!(written["shell"], true);
assert_eq!(written["user"], 0);
}
#[test]
fn other_json_types_for_the_union_fields_are_refused() {
for body in [
r#"{"exec_id":"e1","command":["id"],"user":-1}"#,
r#"{"exec_id":"e1","command":["id"],"user":1.5}"#,
r#"{"exec_id":"e1","command":["id"],"user":["root"]}"#,
r#"{"exec_id":"e1","command":["id"],"group":{"name":"x"}}"#,
r#"{"exec_id":"e1","command":["id"],"shell":1}"#,
r#"{"exec_id":"e1","command":["id"],"shell":null}"#,
] {
assert!(
serde_json::from_str::<StartRequest>(body).is_err(),
"accepted {body}"
);
}
}
#[test]
fn a_flag_of_digits_is_an_id_and_anything_else_a_name() {
assert_eq!("1000".parse::<NameOrId>(), Ok(NameOrId::Id(1000)));
assert_eq!("0".parse::<NameOrId>(), Ok(NameOrId::Id(0)));
assert_eq!(
"agent".parse::<NameOrId>(),
Ok(NameOrId::Name("agent".into()))
);
assert_eq!("+1".parse::<NameOrId>(), Ok(NameOrId::Name("+1".into())));
assert_eq!(
"99999999999".parse::<NameOrId>(),
Ok(NameOrId::Name("99999999999".into()))
);
}
#[test]
fn a_procs_response_round_trips_with_a_missing_pgid() {
let written = serde_json::to_string(&ProcsResponse {
procs: vec![ProcGroup {
exec_id: "e1".into(),
pgid: None,
started_at: 1_756_500_000,
child_exited: true,
reap: false,
pids: Vec::new(),
}],
})
.expect("serializes");
assert_eq!(
written,
r#"{"procs":[{"exec_id":"e1","pgid":null,"started_at":1756500000,"child_exited":true,"reap":false,"pids":[]}]}"#
);
let read: ProcsResponse = serde_json::from_str(&written).expect("deserializes");
assert_eq!(read.procs.len(), 1);
assert_eq!(read.procs[0].pgid, None);
assert!(read.procs[0].child_exited);
}
#[test]
fn a_poll_response_round_trips_with_and_without_an_outcome() {
let running = serde_json::to_string(&PollResponse {
exec_id: "e1".into(),
phase: Phase::Running,
result: None,
})
.expect("serializes");
assert_eq!(running, r#"{"exec_id":"e1","phase":"running"}"#);
let read: PollResponse = serde_json::from_str(&running).expect("deserializes");
assert!(read.result.is_none());
let exited = serde_json::to_string(&PollResponse {
exec_id: "e1".into(),
phase: Phase::Exited,
result: Some(Outcome {
exit_code: Some(0),
stdout: "hi".into(),
..Outcome::default()
}),
})
.expect("serializes");
let read: PollResponse = serde_json::from_str(&exited).expect("deserializes");
let outcome = read.result.expect("the outcome is flattened into the body");
assert_eq!(outcome.exit_code, Some(0));
assert_eq!(outcome.stdout, "hi");
}
#[test]
fn an_error_body_carries_a_borrowed_slug_and_reads_back_owned() {
let written = serde_json::to_string(&ErrorBody {
error: Cow::Borrowed(ERROR_UNKNOWN_EXEC),
detail: "e1".into(),
})
.expect("serializes");
assert_eq!(written, r#"{"error":"unknown_exec","detail":"e1"}"#);
let read: ErrorBody = serde_json::from_str(&written).expect("deserializes");
assert_eq!(read.error, ERROR_UNKNOWN_EXEC);
}
#[test]
fn poweroff_defaults_to_absent_and_true_round_trips() {
let request = StartRequest::new("e1", vec!["true".into()]);
assert!(!request.poweroff_on_exit);
let body = serde_json::to_value(&request).unwrap();
assert!(body.get("poweroff_on_exit").is_none());
assert!(
!serde_json::from_value::<StartRequest>(body)
.unwrap()
.poweroff_on_exit
);
let body = serde_json::to_value(request.with_poweroff_on_exit(true)).unwrap();
assert_eq!(body["poweroff_on_exit"], true);
assert!(
serde_json::from_value::<StartRequest>(body)
.unwrap()
.poweroff_on_exit
);
}
}