use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use base64::Engine as _;
use futures_util::future::BoxFuture;
use microvms_core::error::{Error, WireKind};
use microvms_core::prelude::*;
use microvms_core::protocol::exec as wire;
use microvms_core::session::{
ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, Session,
};
use tokio::time::Instant;
#[derive(Clone, Copy, Debug, Default)]
pub struct Ending {
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub timed_out: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum StreamMode {
#[default]
Exit,
Cut,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KillMode {
Ends(Duration),
Fails,
}
impl Default for KillMode {
fn default() -> Self {
KillMode::Ends(Duration::ZERO)
}
}
#[derive(Clone, Debug, Default)]
pub struct Script {
pub stdout: String,
pub finishes_after: Option<Duration>,
pub ending: Ending,
pub truncated: bool,
pub stream: StreamMode,
pub kill: KillMode,
pub ack_failures: u32,
pub poll_failures: u32,
}
#[derive(Debug, Default)]
struct Live {
started_at: Option<Instant>,
exec_id: String,
killed_at: Option<Instant>,
acked: bool,
ack_failures: u32,
poll_failures: u32,
}
pub struct SimDaemon {
inner: Arc<Inner>,
}
pub struct Inner {
script: Script,
live: Mutex<Live>,
log: Mutex<Vec<String>>,
}
impl std::ops::Deref for SimDaemon {
type Target = Inner;
fn deref(&self) -> &Inner {
&self.inner
}
}
impl SimDaemon {
pub fn new(script: Script) -> Arc<Self> {
Arc::new(Self {
inner: Arc::new(Inner {
live: Mutex::new(Live {
ack_failures: script.ack_failures,
poll_failures: script.poll_failures,
..Live::default()
}),
script,
log: Mutex::new(Vec::new()),
}),
})
}
pub fn session(self: &Arc<Self>) -> Session {
Session::builder("https://sim.invalid", "sim-agent-token")
.with_backend(Arc::clone(self) as Arc<dyn HttpBackend>)
.build()
.expect("a session over the simulator")
}
}
impl Inner {
pub fn acked(&self) -> bool {
self.live().acked
}
pub fn log(&self) -> Vec<String> {
self.log
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn note(&self, request: &HttpRequest) {
let path = request.path.split('?').next().unwrap_or_default();
self.log
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(format!("{} {path}", request.method));
}
fn live(&self) -> std::sync::MutexGuard<'_, Live> {
self.live.lock().unwrap_or_else(PoisonError::into_inner)
}
fn outcome(&self, now: Instant) -> Option<wire::Outcome> {
let live = self.live();
let started = live.started_at?;
let own_end = self.script.finishes_after.map(|after| started + after);
let kill_end = live.killed_at;
let (ending, _) = match (own_end, kill_end) {
(Some(own), Some(kill)) if kill < own => (killed_ending(), kill),
(Some(own), _) => (self.script.ending, own),
(None, Some(kill)) => (killed_ending(), kill),
(None, None) => return None,
};
let finished_at = match (own_end, kill_end) {
(Some(own), Some(kill)) => own.min(kill),
(Some(own), None) => own,
(None, Some(kill)) => kill,
(None, None) => unreachable!(),
};
(now >= finished_at).then(|| wire::Outcome {
exit_code: ending.exit_code,
signal: ending.signal,
timed_out: ending.timed_out,
stdout: self.script.stdout.clone(),
stderr: String::new(),
truncated: self.script.truncated,
writers_may_be_alive: false,
})
}
fn poll_body(&self, now: Instant, acked: bool) -> wire::PollResponse {
let exec_id = self.live().exec_id.clone();
match self.outcome(now) {
Some(_) if acked => wire::PollResponse {
exec_id,
phase: wire::Phase::Acked,
result: None,
},
Some(outcome) => wire::PollResponse {
exec_id,
phase: wire::Phase::Exited,
result: Some(outcome),
},
None => wire::PollResponse {
exec_id,
phase: wire::Phase::Running,
result: None,
},
}
}
fn answer(&self, request: &HttpRequest) -> Result<HttpResponse, Error> {
let now = Instant::now();
let path = request.path.split('?').next().unwrap_or_default();
match (request.method, path) {
("POST", "/v1/exec/start") => {
let start: wire::StartRequest =
serde_json::from_slice(&request.body).expect("a start body");
let mut live = self.live();
live.started_at.get_or_insert(now);
live.exec_id = start.exec_id.clone();
drop(live);
ok(&wire::StartResponse {
exec_id: start.exec_id,
phase: wire::Phase::Running,
})
}
("POST", ack) if ack.ends_with("/ack") => {
let mut live = self.live();
if live.ack_failures > 0 {
live.ack_failures -= 1;
return Err(Error::wire(WireKind::Transport, "sim: ack reset"));
}
drop(live);
match self.outcome(now) {
None => status(409, "not_exited", "the exec is still running"),
Some(_) if self.live().acked => {
status(409, "already_acked", "an earlier ack released the output")
}
Some(outcome) => {
self.live().acked = true;
ok(&wire::PollResponse {
exec_id: self.live().exec_id.clone(),
phase: wire::Phase::Acked,
result: Some(outcome),
})
}
}
}
("POST", kill) if kill.ends_with("/kill") => match self.script.kill {
KillMode::Fails => Err(Error::wire(WireKind::Transport, "sim: kill reset")),
KillMode::Ends(after) => {
let exec_id = self.live().exec_id.clone();
let alive = self.outcome(now).is_none();
if alive {
let mut live = self.live();
live.killed_at.get_or_insert(now + after);
}
ok(&wire::KillResponse {
exec_id,
killed: alive,
})
}
},
("GET", poll) if poll.starts_with("/v1/exec/") => {
let mut live = self.live();
if live.poll_failures > 0 {
live.poll_failures -= 1;
return Err(Error::wire(WireKind::Transport, "sim: poll reset"));
}
let acked = live.acked;
drop(live);
ok(&self.poll_body(now, acked))
}
(method, path) => panic!("the simulator has no route {method} {path}"),
}
}
}
fn killed_ending() -> Ending {
Ending {
exit_code: None,
signal: Some(15),
timed_out: false,
}
}
fn ok(body: &impl serde::Serialize) -> Result<HttpResponse, Error> {
Ok(HttpResponse {
status: 200,
headers: HashMap::new(),
body: serde_json::to_vec(body).expect("a body"),
})
}
fn status(code: u16, error: &'static str, detail: &str) -> Result<HttpResponse, Error> {
Ok(HttpResponse {
status: code,
headers: HashMap::new(),
body: serde_json::to_vec(&wire::ErrorBody {
error: error.into(),
detail: detail.into(),
})
.expect("a body"),
})
}
impl HttpBackend for SimDaemon {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
self.note(&request);
let answer = self.answer(&request);
Box::pin(async move { answer })
}
fn open_stream(
&self,
request: HttpRequest,
_idle_timeout: Duration,
) -> BoxFuture<'_, Result<OpenStream, Error>> {
self.note(&request);
let offset: u64 = request
.path
.split("offset=")
.nth(1)
.and_then(|offset| offset.parse().ok())
.unwrap_or(0);
Box::pin(async move {
let head = HttpResponse {
status: 200,
headers: HashMap::new(),
body: Vec::new(),
};
Ok((
head,
Box::new(Attach {
daemon: Arc::clone(&self.inner),
offset,
sent_output: false,
done: false,
}) as Box<dyn ChunkSource>,
))
})
}
}
struct Attach {
daemon: Arc<Inner>,
offset: u64,
sent_output: bool,
done: bool,
}
impl ChunkSource for Attach {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async move {
if self.done {
return Ok(None);
}
let stdout = self.daemon.script.stdout.as_bytes();
if !self.sent_output {
self.sent_output = true;
let from = (self.offset as usize).min(stdout.len());
if from < stdout.len() {
let encoded = base64::engine::general_purpose::STANDARD.encode(&stdout[from..]);
return Ok(Some(
format!(
"event: output\ndata: {{\"offset\":{from},\"stream\":\"stdout\",\
\"output\":\"{encoded}\"}}\n\n"
)
.into_bytes(),
));
}
}
if self.daemon.script.stream == StreamMode::Cut {
self.done = true;
return Ok(None);
}
loop {
if let Some(outcome) = self.daemon.outcome(Instant::now()) {
self.done = true;
let exit = wire::ExitEvent {
exit_code: outcome.exit_code,
signal: outcome.signal,
timed_out: outcome.timed_out,
truncated: outcome.truncated,
writers_may_be_alive: false,
offset: stdout.len() as u64,
};
let data = serde_json::to_string(&exit).expect("an exit event");
return Ok(Some(format!("event: exit\ndata: {data}\n\n").into_bytes()));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
}
}