1pub mod complete;
33pub mod exec;
34pub mod files;
35pub mod http;
36pub mod keepalive;
37pub mod proxy;
38pub mod split;
39pub mod sse;
40mod upload;
41
42use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
43use std::sync::Arc;
44use std::time::Duration;
45
46pub use complete::{
47 ClientDeadline, CompletionOptions, CompletionPlan, DEFAULT_CLIENT_GRACE, KillAnswer,
48 NO_TIMEOUT_CEILING, OutputFlow, OutputSink,
49};
50pub use exec::{
51 DEFAULT_EXEC_WAIT, EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions,
52 TIMED_OUT_EXIT_CODE, exec_id_at,
53};
54pub use http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, SharedBackend};
55pub use keepalive::{KeepAwake, KeepAwakeEnd, KeepAwakeReport, KeepAwakeTask, RunningGate};
56pub use proxy::{
57 Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
58 PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, WS_AUTH_SUBPROTOCOL_PREFIX,
59 WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
60};
61pub use split::{GapLog, GapPolicy, OutputGap, Split, SplitItem};
62pub use sse::{ExecEvent, Frame, SseParser};
63
64use crate::error::{Error, ErrorKind, WireKind};
65
66pub const DEFAULT_BIND: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
69
70pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
72
73pub const DEFAULT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120);
77
78#[deprecated(
80 note = "renamed DEFAULT_BOOTSTRAP_TIMEOUT: `sandbox::DEFAULT_READY_TIMEOUT` shared this name \
81 for a different wait, the one for RUNNING"
82)]
83pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT;
84
85const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
87
88pub(crate) const ROOT_UID: u32 = 0;
94
95pub struct Transport {
100 backend: http::SharedBackend,
101 agent_token: String,
102 proxy: Option<Arc<ProxyAuth>>,
107 timeout: Duration,
108}
109
110impl Transport {
111 pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
113 self.proxy.as_ref()
114 }
115
116 async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
125 let mut headers = match &self.proxy {
128 Some(proxy) => proxy.headers().await?,
129 None => Vec::new(),
130 };
131 let bearer = match token {
132 Some(explicit) => explicit,
133 None => Some(self.agent_token.as_str()),
134 };
135 if let Some(bearer) = bearer {
136 headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
137 }
138 Ok(headers)
139 }
140
141 async fn request(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
143 let timeout = request.timeout.unwrap_or(self.timeout);
144 let method = request.method;
145 let path = request.path.clone();
146 tokio::time::timeout(timeout, self.request_inner(request))
147 .await
148 .map_err(|_| Error::wire(WireKind::Transport,
149 format!("{method} {path} exceeded its request timeout (including proxy authentication)")))?
150 }
151
152 async fn request_inner(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
153 let mut headers = self.headers(request_token(&request)).await?;
162 request.headers.retain(|(name, _)| name != TOKEN_INTENT);
163 headers.append(&mut request.headers);
164 request.headers = headers;
165 if request.timeout.is_none() {
166 request.timeout = Some(self.timeout);
167 }
168 self.backend.send(request).await
169 }
170
171 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
173 let (method, path) = (request.method, request.path.clone());
174 let response = self.request(request).await?;
175 response.error_for_status(method, &path)?;
176 Ok(response)
177 }
178
179 async fn send_json<T: serde::de::DeserializeOwned>(
181 &self,
182 request: HttpRequest,
183 ) -> Result<T, Error> {
184 let (method, path) = (request.method, request.path.clone());
185 let response = self.send(request).await?;
186 serde_json::from_slice(&response.body).map_err(|err| {
187 Error::wire(
191 WireKind::ProtocolError,
192 format!(
193 "{method} {path} answered a body this client cannot read: {err}; \
194 check /v1/schema for the daemon's protocol version"
195 ),
196 )
197 .with_source(err)
198 })
199 }
200}
201
202const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
209
210const TOKEN_INTENT_NONE: &str = "none";
212
213fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
214 match request.header(TOKEN_INTENT) {
215 None => None,
216 Some(TOKEN_INTENT_NONE) => Some(None),
217 Some(explicit) => Some(Some(explicit)),
218 }
219}
220
221fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
223 request
224 .headers
225 .push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
226 request
227}
228
229#[derive(Clone, Copy, Debug, Default, PartialEq)]
232pub struct LaunchMetrics {
233 pub attempt_count: u32,
234 pub backoff_seconds: f64,
235 pub running_at: Option<f64>,
236 pub ready_at: Option<f64>,
237}
238
239#[derive(Clone)]
244pub struct Session {
245 transport: Arc<Transport>,
246 pub(crate) launch_metrics: Option<Arc<std::sync::Mutex<LaunchMetrics>>>,
247 upload_identity: Arc<tokio::sync::OnceCell<(u32, u32)>>,
248 endpoint: String,
249 port: u16,
250 egress_posture: crate::control::EgressPosture,
251 clock: Arc<dyn Clock>,
254}
255
256impl Session {
257 pub fn launch_metrics(&self) -> Option<LaunchMetrics> {
259 self.launch_metrics.as_ref().map(|metrics| {
260 *metrics
261 .lock()
262 .unwrap_or_else(std::sync::PoisonError::into_inner)
263 })
264 }
265
266 pub fn agent_token(&self) -> &str {
274 &self.transport.agent_token
275 }
276
277 pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
283 SessionBuilder {
284 endpoint: endpoint.into(),
285 agent_token: agent_token.into(),
286 minter: None,
287 backend: None,
288 port: DEFAULT_AGENT_PORT,
289 timeout: DEFAULT_REQUEST_TIMEOUT,
290 proxy: None,
291 egress_posture: crate::control::EgressPosture::default(),
292 clock: None,
293 }
294 }
295
296 pub fn endpoint(&self) -> &str {
298 &self.endpoint
299 }
300
301 pub(crate) fn mint_exec_id(&self) -> String {
303 exec_id_at(self.clock.unix_now())
304 }
305
306 pub fn egress_posture(&self) -> crate::control::EgressPosture {
314 self.egress_posture
315 }
316
317 pub fn port(&self) -> u16 {
319 self.port
320 }
321
322 pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
324 self.transport.proxy()
325 }
326
327 pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
349 match self.transport.proxy() {
350 Some(proxy) => {
351 tokio::time::timeout(self.transport.timeout, proxy.headers_for_port(port))
352 .await
353 .map_err(|_| {
354 Error::wire(
355 WireKind::Transport,
356 "proxy authentication request timed out",
357 )
358 })?
359 }
360 None => Ok(Vec::new()),
361 }
362 }
363
364 pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
376 match self.transport.proxy() {
377 Some(proxy) => Ok(Some(
378 tokio::time::timeout(self.transport.timeout, proxy.subprotocols(port))
379 .await
380 .map_err(|_| {
381 Error::wire(
382 WireKind::Transport,
383 "proxy authentication request timed out",
384 )
385 })??,
386 )),
387 None => Ok(None),
388 }
389 }
390
391 pub fn rebind(&mut self, endpoint: String) {
403 self.endpoint = endpoint;
404 if let Some(proxy) = self.transport.proxy() {
405 proxy.invalidate();
406 }
407 }
408
409 pub async fn health(&self) -> Result<protocol::health::Health, Error> {
418 self.transport
419 .send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
420 .await
421 }
422
423 pub async fn discovery(&self) -> Result<Option<protocol::discovery::Discovery>, Error> {
429 let response = self
430 .transport
431 .request(HttpRequest::new("GET", "/v1/discovery"))
432 .await?;
433 if response.status == 404 {
434 return Ok(None);
435 }
436 response.error_for_status("GET", "/v1/discovery")?;
437 serde_json::from_slice(&response.body)
438 .map(Some)
439 .map_err(|err| {
440 Error::wire(
441 WireKind::ProtocolError,
442 format!("GET /v1/discovery answered an invalid response: {err}"),
443 )
444 .with_source(err)
445 })
446 }
447
448 pub async fn keep_awake(
453 &self,
454 policy: &KeepAwake,
455 stop: impl std::future::Future<Output = ()>,
456 ) -> Result<KeepAwakeReport, Error> {
457 policy
458 .run(|| async { self.health().await.map(Some) }, stop)
459 .await
460 }
461
462 pub async fn wait_until_ready(
470 &self,
471 timeout: Duration,
472 ) -> Result<protocol::health::Health, Error> {
473 let deadline = deadline_after(timeout);
474 let mut last: Option<Error> = None;
475 loop {
476 match self.health().await {
477 Ok(health) if health.bootstrapped => {
478 if let Some(metrics) = &self.launch_metrics {
479 metrics
480 .lock()
481 .unwrap_or_else(std::sync::PoisonError::into_inner)
482 .ready_at
483 .get_or_insert_with(|| self.clock.unix_now().as_secs_f64());
484 }
485 return Ok(health);
486 }
487 Ok(_) => {}
488 Err(err) if err.retryable() => last = Some(err),
489 Err(err) => return Err(err),
490 }
491 let now = tokio::time::Instant::now();
492 if now >= deadline {
493 let detail = match last {
494 Some(err) => format!(" (last error: {err})"),
495 None => String::new(),
496 };
497 return Err(Error::new(
498 ErrorKind::Timeout,
499 format!(
500 "the daemon was not bootstrapped within {}s{detail}",
501 timeout.as_secs()
502 ),
503 ));
504 }
505 tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
506 }
507 }
508
509 pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
521 if req.poweroff_on_exit {
522 let response = self
523 .transport
524 .send(unauthenticated(HttpRequest::new("GET", "/v1/health")))
525 .await?;
526 if response
527 .headers
528 .get(protocol::health::POWEROFF_ON_EXIT_HEADER)
529 .map(String::as_str)
530 != Some("1")
531 {
532 return Err(Error::invalid_arg(
533 "poweroff_on_exit requires a supporting daemon with CAP_SYS_BOOT",
534 ));
535 }
536 }
537 let requested = req.exec_id.clone();
538 let mut request = HttpRequest::new("POST", "/v1/exec/start");
539 request
540 .headers
541 .push(("content-type".into(), "application/json".into()));
542 request.body = serde_json::to_vec(&req).map_err(|err| {
543 Error::invalid_arg(format!("the start request will not serialize: {err}"))
544 })?;
545 let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
546 let exec_id = if started.exec_id.is_empty() {
551 requested
552 } else {
553 started.exec_id
554 };
555 Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
556 }
557
558 pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
560 ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
561 }
562
563 pub async fn run_sync(
565 &self,
566 req: protocol::exec::StartRequest,
567 timeout: Duration,
568 ) -> Result<ExecResult, Error> {
569 self.run(req).await?.wait_and_ack(timeout).await
570 }
571
572 pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
574 self.exec(exec_id).kill().await
575 }
576
577 pub async fn procs(&self) -> Result<protocol::exec::ProcsResponse, Error> {
585 self.transport
586 .send_json(HttpRequest::new("GET", "/v1/procs"))
587 .await
588 }
589
590 pub async fn upload_file(
594 &self,
595 path: &str,
596 data: &[u8],
597 mode: Option<&str>,
598 ) -> Result<(), Error> {
599 upload::upload(self, path, data, mode, false).await
600 }
601
602 pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
604 files::download_file(&self.transport, path).await
605 }
606
607 pub async fn download_file_lines(
612 &self,
613 path: &str,
614 start_line: Option<u64>,
615 end_line: Option<u64>,
616 ) -> Result<Vec<u8>, Error> {
617 files::download_file_lines(&self.transport, path, start_line, end_line).await
618 }
619
620 pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
622 files::file_exists(&self.transport, path).await
623 }
624
625 pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
627 upload::upload(self, remote, archive, None, true).await
628 }
629
630 pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
632 files::download_tar(&self.transport, remote).await
633 }
634}
635
636impl std::fmt::Debug for Session {
637 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638 f.debug_struct("Session")
640 .field("endpoint", &self.endpoint)
641 .field("port", &self.port)
642 .field("proxy", &self.transport.proxy())
643 .finish()
644 }
645}
646
647pub struct SessionBuilder {
649 endpoint: String,
650 agent_token: String,
651 minter: Option<Arc<dyn TokenMinter>>,
652 backend: Option<http::SharedBackend>,
653 port: u16,
654 timeout: Duration,
655 proxy: Option<Arc<ProxyAuth>>,
656 egress_posture: crate::control::EgressPosture,
657 clock: Option<Arc<dyn Clock>>,
658}
659
660impl SessionBuilder {
661 #[must_use]
663 pub fn with_egress_posture(mut self, posture: crate::control::EgressPosture) -> Self {
664 self.egress_posture = posture;
665 self
666 }
667
668 #[must_use]
670 pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
671 self.minter = Some(minter);
672 self
673 }
674
675 #[must_use]
678 pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
679 self.proxy = Some(proxy);
680 self
681 }
682
683 #[must_use]
685 pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
686 self.backend = Some(backend);
687 self
688 }
689
690 #[must_use]
692 pub fn with_port(mut self, port: u16) -> Self {
693 self.port = port;
694 self
695 }
696
697 #[must_use]
699 pub fn with_timeout(mut self, timeout: Duration) -> Self {
700 self.timeout = timeout;
701 self
702 }
703
704 #[must_use]
710 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
711 self.clock = Some(clock);
712 self
713 }
714
715 pub fn build_with(
722 mut self,
723 adapters: &dyn crate::adapters::Adapters,
724 ) -> Result<Session, Error> {
725 if self.backend.is_none() {
726 self.backend = Some(adapters.http_backend(&self.endpoint, self.timeout)?);
727 }
728 if self.clock.is_none() {
729 self.clock = Some(adapters.clock());
730 }
731 self.try_build()
732 }
733
734 pub fn try_build(self) -> Result<Session, Error> {
739 let (Some(backend), Some(clock)) = (self.backend, self.clock) else {
740 return Err(Error::new(
741 ErrorKind::Precondition,
742 "a session needs a backend and a clock: set both with with_backend and \
743 with_clock, or build through the prelude's SessionBuilder::build",
744 ));
745 };
746 let proxy = match (self.proxy, self.minter) {
747 (Some(proxy), _) => Some(proxy),
748 (None, Some(minter)) => Some(Arc::new(ProxyAuth::with_clock(
749 minter,
750 self.port,
751 Arc::clone(&clock),
752 ))),
753 (None, None) => None,
754 };
755 Ok(Session {
756 launch_metrics: None,
757 upload_identity: Arc::new(tokio::sync::OnceCell::new()),
758 transport: Arc::new(Transport {
759 backend,
760 agent_token: self.agent_token,
761 proxy,
762 timeout: self.timeout,
763 }),
764 endpoint: self.endpoint,
765 port: self.port,
766 egress_posture: self.egress_posture,
767 clock,
768 })
769 }
770}
771
772const FAR_FUTURE: Duration = Duration::from_secs(946_080_000);
776
777pub(crate) fn deadline_after(after: Duration) -> tokio::time::Instant {
785 let now = tokio::time::Instant::now();
786 now.checked_add(after).unwrap_or_else(|| now + FAR_FUTURE)
787}
788
789#[cfg(any(test, feature = "test-support"))]
790#[cfg_attr(test, mutants::skip)] pub(crate) mod testing {
792 use std::collections::HashMap;
800 use std::sync::{Mutex, PoisonError};
801
802 use futures_util::future::BoxFuture;
803
804 use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
805 use super::*;
806
807 pub enum Reply {
809 Body(u16, Vec<u8>),
811 Chunks(u16, Vec<Vec<u8>>),
813 Cut(&'static str),
815 Stalled(Vec<Vec<u8>>),
818 }
819
820 impl Reply {
821 pub fn json(status: u16, body: impl serde::Serialize) -> Self {
822 Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
823 }
824
825 pub fn ok(body: impl serde::Serialize) -> Self {
826 Reply::json(200, body)
827 }
828 }
829
830 #[derive(Default)]
831 pub struct Recorder {
832 seen: Mutex<Vec<HttpRequest>>,
833 replies: Mutex<std::collections::VecDeque<Reply>>,
834 headers: HashMap<String, String>,
836 }
837
838 impl Recorder {
839 pub fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
840 Self::with_headers(replies, [])
841 }
842
843 pub fn with_headers(
845 replies: impl IntoIterator<Item = Reply>,
846 headers: impl IntoIterator<Item = (String, String)>,
847 ) -> Arc<Self> {
848 Arc::new(Self {
849 seen: Mutex::new(Vec::new()),
850 replies: Mutex::new(replies.into_iter().collect()),
851 headers: headers.into_iter().collect(),
852 })
853 }
854
855 pub fn requests(&self) -> Vec<HttpRequest> {
857 self.seen
858 .lock()
859 .unwrap_or_else(PoisonError::into_inner)
860 .clone()
861 }
862
863 pub fn last(&self) -> HttpRequest {
864 self.requests()
865 .pop()
866 .expect("the recorder saw no request at all")
867 }
868
869 fn record(&self, request: HttpRequest) -> Reply {
870 self.seen
871 .lock()
872 .unwrap_or_else(PoisonError::into_inner)
873 .push(request.clone());
874 self.replies
875 .lock()
876 .unwrap_or_else(PoisonError::into_inner)
877 .pop_front()
878 .unwrap_or_else(|| {
879 panic!(
880 "the recorder ran out of replies at {} {}",
881 request.method, request.path
882 )
883 })
884 }
885 }
886
887 struct Queued(std::collections::VecDeque<Vec<u8>>);
888
889 impl ChunkSource for Queued {
890 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
891 Box::pin(async move { Ok(self.0.pop_front()) })
892 }
893 }
894
895 struct Stalling(std::collections::VecDeque<Vec<u8>>);
897
898 impl ChunkSource for Stalling {
899 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
900 Box::pin(async move {
901 match self.0.pop_front() {
902 Some(chunk) => Ok(Some(chunk)),
903 None => std::future::pending().await,
904 }
905 })
906 }
907 }
908
909 impl HttpBackend for Recorder {
910 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
911 let reply = self.record(request);
912 Box::pin(async move {
913 match reply {
914 Reply::Body(status, body) => Ok(HttpResponse {
915 status,
916 headers: self.headers.clone(),
917 body,
918 }),
919 Reply::Chunks(status, chunks) => Ok(HttpResponse {
920 status,
921 headers: self.headers.clone(),
922 body: chunks.concat(),
923 }),
924 Reply::Stalled(chunks) => Ok(HttpResponse {
925 status: 200,
926 headers: self.headers.clone(),
927 body: chunks.concat(),
928 }),
929 Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
930 }
931 })
932 }
933
934 fn open_stream(
935 &self,
936 request: HttpRequest,
937 _idle_timeout: Duration,
938 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
939 let reply = self.record(request);
940 Box::pin(async move {
941 let (status, chunks) = match reply {
942 Reply::Chunks(status, chunks) => (status, chunks),
943 Reply::Body(status, body) => (status, vec![body]),
944 Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
945 Reply::Stalled(chunks) => {
946 let head = HttpResponse {
947 status: 200,
948 headers: self.headers.clone(),
949 body: Vec::new(),
950 };
951 let source: Box<dyn ChunkSource> =
952 Box::new(Stalling(chunks.into_iter().collect()));
953 return Ok((head, source));
954 }
955 };
956 let head = HttpResponse {
957 status,
958 headers: self.headers.clone(),
959 body: if (200..300).contains(&status) {
962 Vec::new()
963 } else {
964 chunks.concat()
965 },
966 };
967 let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
968 Box::new(Queued(chunks.into_iter().collect()))
969 } else {
970 Box::new(Queued(std::collections::VecDeque::new()))
971 };
972 Ok((head, source))
973 })
974 }
975 }
976
977 pub fn session_with(
979 recorder: Arc<Recorder>,
980 ) -> (
981 Session,
982 Arc<ProxyAuth>,
983 Arc<crate::clock::testing::TestClock>,
984 ) {
985 let clock = Arc::new(crate::clock::testing::TestClock::default());
986 let auth = Arc::new(
987 ProxyAuth::with_refresh_after(
988 Arc::new(proxy::testing::CountingMinter::default()),
989 DEFAULT_AGENT_PORT,
990 DEFAULT_REFRESH_AFTER,
991 Arc::clone(&clock) as Arc<dyn Clock>,
992 )
993 .expect("the default interval is below the ceiling"),
994 );
995 let session = Session::builder("https://vm.example", "agent-token-abcdef")
996 .with_backend(recorder)
997 .with_proxy_auth(Arc::clone(&auth))
998 .with_clock(Arc::clone(&clock) as Arc<dyn Clock>)
999 .try_build()
1000 .expect("builds");
1001 session
1004 .upload_identity
1005 .set((0, 0))
1006 .expect("empty identity cache");
1007 (session, auth, clock)
1008 }
1009
1010 pub fn health_body(bootstrapped: bool) -> serde_json::Value {
1011 serde_json::json!({
1012 "version": "0.1.0",
1013 "bootstrapped": bootstrapped,
1014 "disk": null,
1015 "identity_degraded": false,
1016 "identity_repaired": true,
1017 })
1018 }
1019
1020 #[derive(Default)]
1028 pub struct HealthyDaemon {
1029 polls: std::sync::atomic::AtomicUsize,
1030 }
1031
1032 impl HealthyDaemon {
1033 pub fn new() -> Arc<Self> {
1034 Arc::new(Self::default())
1035 }
1036
1037 pub fn polls(&self) -> usize {
1039 self.polls.load(std::sync::atomic::Ordering::SeqCst)
1040 }
1041
1042 fn refuse(request: &HttpRequest) -> Error {
1043 Error::wire(
1044 WireKind::Transport,
1045 format!(
1046 "{} {} reached a test daemon that answers only health",
1047 request.method, request.path
1048 ),
1049 )
1050 }
1051 }
1052
1053 impl HttpBackend for HealthyDaemon {
1054 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
1055 let answer = if request.method == "GET" && request.path == "/v1/health" {
1056 self.polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1057 Ok(HttpResponse {
1058 status: 200,
1059 headers: HashMap::new(),
1060 body: health_body(true).to_string().into_bytes(),
1061 })
1062 } else {
1063 Err(Self::refuse(&request))
1064 };
1065 Box::pin(async move { answer })
1066 }
1067
1068 fn open_stream(
1069 &self,
1070 request: HttpRequest,
1071 _idle_timeout: Duration,
1072 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
1073 let refused = Self::refuse(&request);
1074 Box::pin(async move { Err(refused) })
1075 }
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::testing::{Recorder, Reply, health_body, session_with};
1082 use super::*;
1083 use crate::session::proxy::testing::CountingMinter;
1084
1085 fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
1086 protocol::exec::StartRequest::new(exec_id, vec!["/bin/true".to_string()])
1087 }
1088
1089 fn header(request: &HttpRequest, name: &str) -> Option<String> {
1090 request.header(name).map(str::to_string)
1091 }
1092
1093 fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
1095 headers
1096 .iter()
1097 .find(|(key, _)| key.eq_ignore_ascii_case(name))
1098 .map(|(_, value)| value.clone())
1099 }
1100
1101 #[test]
1108 fn try_build_needs_a_backend_and_a_clock() {
1109 let clock = Arc::new(crate::clock::testing::TestClock::new()) as Arc<dyn Clock>;
1110 let no_backend = Session::builder("https://vm.example", "token")
1111 .with_clock(Arc::clone(&clock))
1112 .try_build()
1113 .expect_err("no backend");
1114 assert_eq!(no_backend.kind(), ErrorKind::Precondition);
1115 let no_clock = Session::builder("https://vm.example", "token")
1116 .with_backend(Recorder::with([]))
1117 .try_build()
1118 .expect_err("no clock");
1119 assert_eq!(no_clock.kind(), ErrorKind::Precondition);
1120 Session::builder("https://vm.example", "token")
1121 .with_backend(Recorder::with([]))
1122 .with_clock(clock)
1123 .try_build()
1124 .expect("both set");
1125 }
1126
1127 #[tokio::test]
1133 async fn build_with_keeps_the_callers_backend() {
1134 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1135 let session = Session::builder("http://127.0.0.1:9000", "token")
1136 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1137 .build_with(&crate::testing::TestAdapters::new())
1138 .expect("builds");
1139 session.health().await.expect("the recorder answered");
1140 assert_eq!(recorder.requests().len(), 1);
1141 }
1142
1143 #[tokio::test(start_paused = true)]
1144 async fn request_timeout_also_bounds_a_blocked_proxy_mint() {
1145 struct BlockedMinter;
1146 impl TokenMinter for BlockedMinter {
1147 fn mint(&self) -> futures_util::future::BoxFuture<'_, Result<ProxyToken, Error>> {
1148 Box::pin(std::future::pending())
1149 }
1150 }
1151 let backend = Recorder::with([]);
1152 let session = Session::builder("http://127.0.0.1:9", "private")
1153 .with_backend(backend.clone())
1154 .with_minter(Arc::new(BlockedMinter))
1155 .with_timeout(Duration::from_secs(3))
1156 .build_with(&crate::testing::TestAdapters::new())
1157 .expect("session");
1158 let result = tokio::time::timeout(Duration::from_secs(4), session.health())
1159 .await
1160 .expect("the request budget must include a stalled proxy mint")
1161 .expect_err("mint never completed");
1162 assert_eq!(result.wire_kind(), Some(WireKind::Transport));
1163 assert!(result.retryable());
1164 let result = tokio::time::timeout(Duration::from_secs(4), session.connect_headers(9000))
1165 .await
1166 .expect("external transfers must bound authentication too")
1167 .expect_err("mint remains blocked");
1168 assert!(result.retryable());
1169 let result =
1170 tokio::time::timeout(Duration::from_secs(4), session.connect_subprotocols(9000))
1171 .await
1172 .expect("external sockets must bound authentication too")
1173 .expect_err("mint remains blocked");
1174 assert!(result.retryable());
1175 assert!(
1176 backend.requests().is_empty(),
1177 "no request could authenticate"
1178 );
1179 }
1180
1181 #[tokio::test]
1189 async fn a_callers_content_type_survives_the_auth_header_injection() {
1190 let recorder = Recorder::with([Reply::ok(
1191 serde_json::json!({"exec_id":"e1","phase":"running"}),
1192 )]);
1193 let (session, _, _) = session_with(Arc::clone(&recorder));
1194
1195 session.run(start_request("e1")).await.expect("start");
1196
1197 let seen = recorder.requests();
1198 assert_eq!(
1199 header(&seen[0], "content-type").as_deref(),
1200 Some("application/json"),
1201 "the exec start lost its content type to the auth header injection"
1202 );
1203 }
1204
1205 #[tokio::test]
1210 async fn the_session_hands_back_the_token_it_authenticates_with() {
1211 let recorder = Recorder::with([]);
1212 let (session, _, _) = session_with(Arc::clone(&recorder));
1213 assert_eq!(session.agent_token(), "agent-token-abcdef");
1214 }
1215
1216 #[tokio::test]
1223 async fn every_endpoint_request_carries_both_proxy_headers() {
1224 let recorder = Recorder::with([
1225 Reply::ok(health_body(true)),
1226 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
1227 ]);
1228 let (session, _, _) = session_with(Arc::clone(&recorder));
1229
1230 session.health().await.expect("health succeeds");
1231 session.run(start_request("e1")).await.expect("start");
1232
1233 let seen = recorder.requests();
1234 assert_eq!(seen.len(), 2);
1235 for request in &seen {
1236 assert_eq!(
1237 header(request, PROXY_AUTH_HEADER).as_deref(),
1238 Some(CountingMinter::value(0).as_str()),
1239 "{} {} went out without the proxy auth header",
1240 request.method,
1241 request.path
1242 );
1243 assert_eq!(
1244 header(request, PROXY_PORT_HEADER).as_deref(),
1245 Some("9000"),
1246 "{} {} went out without the proxy port header, which the proxy \
1247 rejects as if the token were bad",
1248 request.method,
1249 request.path
1250 );
1251 }
1252 }
1253
1254 #[tokio::test]
1255 async fn discovery_distinguishes_old_peers_from_root_and_named_defaults() {
1256 let recorder = Recorder::with([
1257 Reply::Body(404, Vec::new()),
1258 Reply::ok(serde_json::json!({"default_user": null})),
1259 Reply::ok(serde_json::json!({"default_user": "1000", "future": true})),
1260 Reply::Body(401, Vec::new()),
1261 Reply::Body(503, Vec::new()),
1262 Reply::ok(serde_json::json!({"default_user": 1000})),
1263 Reply::ok(serde_json::json!({})),
1264 ]);
1265 let (session, _, _) = session_with(Arc::clone(&recorder));
1266 assert_eq!(session.discovery().await.unwrap(), None);
1267 assert_eq!(
1268 session.discovery().await.unwrap().unwrap().default_user,
1269 None
1270 );
1271 assert_eq!(
1272 session
1273 .discovery()
1274 .await
1275 .unwrap()
1276 .unwrap()
1277 .default_user
1278 .as_deref(),
1279 Some("1000")
1280 );
1281 for _ in 0..4 {
1282 assert!(session.discovery().await.is_err());
1283 }
1284 for request in recorder.requests() {
1285 assert_eq!(request.method, "GET");
1286 assert_eq!(request.path, "/v1/discovery");
1287 assert_eq!(
1288 request.header("authorization"),
1289 Some("Bearer agent-token-abcdef")
1290 );
1291 }
1292 }
1293
1294 #[tokio::test]
1300 async fn health_sends_no_bearer_and_an_exec_route_does() {
1301 let recorder = Recorder::with([
1302 Reply::ok(health_body(true)),
1303 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
1304 ]);
1305 let (session, _, _) = session_with(Arc::clone(&recorder));
1306
1307 session.health().await.expect("health");
1308 let seen = recorder.requests();
1309 assert_eq!(header(&seen[0], "authorization"), None);
1310 assert_eq!(
1311 header(&seen[0], "x-microvms-core-token-intent"),
1312 None,
1313 "the internal token-intent marker must not reach the wire"
1314 );
1315
1316 session.run(start_request("e1")).await.expect("start");
1317 assert_eq!(
1318 header(&recorder.last(), "authorization").as_deref(),
1319 Some("Bearer agent-token-abcdef")
1320 );
1321 }
1322
1323 #[tokio::test(start_paused = true)]
1326 async fn keep_awake_polls_health_through_the_proxy_until_the_vm_is_idle() {
1327 let busy = |busy: bool| {
1328 let mut body = health_body(true);
1329 body["busy"] = serde_json::json!(busy);
1330 Reply::ok(body)
1331 };
1332 let recorder = Recorder::with([busy(true), busy(true), busy(false)]);
1333 let (session, _, _) = session_with(Arc::clone(&recorder));
1334 let report = session
1335 .clone()
1336 .keep_awake(
1337 &KeepAwake::new(None).while_busy(true),
1338 std::future::pending(),
1339 )
1340 .await
1341 .expect("keepalive");
1342 assert_eq!((report.end, report.polls), (KeepAwakeEnd::Idle, 3));
1343 let seen = recorder.requests();
1344 assert_eq!(seen.len(), 3);
1345 for request in &seen {
1346 assert_eq!(
1347 (request.method, request.path.as_str()),
1348 ("GET", "/v1/health")
1349 );
1350 assert_eq!(header(request, "authorization"), None);
1351 assert!(header(request, PROXY_AUTH_HEADER).is_some());
1352 assert!(header(request, PROXY_PORT_HEADER).is_some());
1353 }
1354 }
1355
1356 #[tokio::test]
1359 async fn a_direct_session_sends_no_proxy_headers() {
1360 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1361 let session = Session::builder("http://127.0.0.1:9000", "token")
1362 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1363 .build_with(&crate::testing::TestAdapters::new())
1364 .expect("builds");
1365 assert!(session.proxy_auth().is_none());
1366
1367 session.health().await.expect("health");
1368 let request = recorder.last();
1369 assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
1370 assert_eq!(header(&request, PROXY_PORT_HEADER), None);
1371 }
1372
1373 #[tokio::test]
1376 async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
1377 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1378 let auth = Arc::new(
1379 ProxyAuth::with_refresh_after(
1380 Arc::new(CountingMinter::failing(1)),
1381 DEFAULT_AGENT_PORT,
1382 DEFAULT_REFRESH_AFTER,
1383 Arc::new(proxy::testing::TestClock::default()),
1384 )
1385 .expect("interval accepted"),
1386 );
1387 let session = Session::builder("https://vm.example", "token")
1388 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1389 .with_proxy_auth(Arc::clone(&auth))
1390 .build_with(&crate::testing::TestAdapters::new())
1391 .expect("builds");
1392
1393 let err = session.health().await.expect_err("the mint fails");
1394 assert_eq!(err.kind(), ErrorKind::Retryable);
1395 assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
1396 assert!(
1397 recorder.requests().is_empty(),
1398 "a request went out without a token"
1399 );
1400
1401 session.health().await.expect("the retry succeeds");
1402 assert_eq!(auth.mint_count(), 1);
1403 }
1404
1405 #[tokio::test]
1408 async fn a_daemon_status_arrives_as_its_typed_error() {
1409 for (status, expected) in [
1410 (401, WireKind::Unauthorized),
1411 (404, WireKind::NotFound),
1412 (409, WireKind::Conflict),
1413 (503, WireKind::NotBootstrapped),
1414 ] {
1415 let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
1416 let (session, _, _) = session_with(recorder);
1417 let err = session.health().await.expect_err("a failing status");
1418 assert_eq!(err.wire_kind(), Some(expected), "status {status}");
1419 }
1420 }
1421
1422 #[tokio::test(start_paused = true)]
1425 async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
1426 let recorder = Recorder::with([
1427 Reply::Cut("connection refused"),
1428 Reply::Body(503, b"not bootstrapped".to_vec()),
1429 Reply::ok(health_body(false)),
1430 Reply::ok(health_body(true)),
1431 ]);
1432 let (session, _, _) = session_with(Arc::clone(&recorder));
1433
1434 let health = session
1435 .wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
1436 .await
1437 .expect("the daemon comes up");
1438 assert!(health.bootstrapped);
1439 assert_eq!(recorder.requests().len(), 4);
1440 }
1441
1442 #[tokio::test(start_paused = true)]
1445 async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
1446 let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
1447 let (session, _, _) = session_with(Arc::clone(&recorder));
1448
1449 let err = session
1450 .wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
1451 .await
1452 .expect_err("a 401 is fatal");
1453 assert_eq!(err.kind(), ErrorKind::Credentials);
1454 assert_eq!(
1455 recorder.requests().len(),
1456 1,
1457 "a fatal error was retried, so a wrong token costs the full timeout"
1458 );
1459 }
1460
1461 #[tokio::test(start_paused = true)]
1464 async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
1465 let recorder = Recorder::with([
1466 Reply::Cut("connection refused"),
1467 Reply::Cut("connection refused"),
1468 Reply::Cut("connection refused"),
1469 ]);
1470 let (session, _, _) = session_with(recorder);
1471
1472 let err = session
1473 .wait_until_ready(Duration::from_secs(4))
1474 .await
1475 .expect_err("the daemon never comes up");
1476 assert_eq!(err.kind(), ErrorKind::Timeout);
1477 assert!(
1478 err.to_string().contains("connection refused"),
1479 "the timeout must name what it kept hitting: {err}"
1480 );
1481 }
1482
1483 #[tokio::test]
1489 async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
1490 let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
1491 let (mut session, auth, _) = session_with(Arc::clone(&recorder));
1492
1493 session.health().await.expect("health");
1494 assert_eq!(auth.mint_count(), 1);
1495
1496 session.rebind("https://vm.example".to_string());
1497 assert!(!auth.is_cached(), "rebind did not drop the token");
1498
1499 session.health().await.expect("health");
1500 assert_eq!(auth.mint_count(), 2);
1501 assert_eq!(
1502 header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
1503 Some(CountingMinter::value(1).as_str()),
1504 "the request after a rebind carried the pre-suspend token"
1505 );
1506 }
1507
1508 #[tokio::test]
1511 async fn a_start_request_serializes_through_the_protocol_crate() {
1512 let recorder = Recorder::with([Reply::ok(
1513 serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1514 )]);
1515 let (session, _, _) = session_with(Arc::clone(&recorder));
1516
1517 let handle = session.run(start_request("e-asked")).await.expect("start");
1518 assert_eq!(
1519 handle.exec_id(),
1520 "e-confirmed",
1521 "the handle addresses the id the client asked for rather than the one the \
1522 daemon confirmed, so every later call would 404 if they diverged"
1523 );
1524
1525 let request = recorder.last();
1526 assert_eq!(request.method, "POST");
1527 assert_eq!(request.path, "/v1/exec/start");
1528 let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1529 assert_eq!(body["exec_id"], "e-asked");
1530 assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1531 }
1532
1533 #[tokio::test]
1536 async fn an_unreadable_response_body_names_the_schema_route() {
1537 let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1538 let (session, _, _) = session_with(recorder);
1539 let err = session.health().await.expect_err("the body will not parse");
1540 assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1541 assert!(err.to_string().contains("/v1/schema"), "{err}");
1542 }
1543
1544 #[tokio::test]
1565 async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1566 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1567 let (session, auth, _) = session_with(Arc::clone(&recorder));
1568
1569 session.health().await.expect("health warms the cache");
1570 assert_eq!(auth.mint_count(), 1);
1571
1572 let headers = session
1573 .connect_headers(8080)
1574 .await
1575 .expect("the cached token answers");
1576 let offered = session
1577 .connect_subprotocols(8080)
1578 .await
1579 .expect("the cached token answers")
1580 .expect("a proxied session offers subprotocols");
1581
1582 assert_eq!(
1583 header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1584 Some("8080"),
1585 "the caller asked for 8080 and would have targeted the session's port"
1586 );
1587 assert_eq!(
1589 header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1590 Some(CountingMinter::value(1).as_str()),
1591 "the header pair carried the agent-port-only token, which the proxy refuses for \
1592 8080 with 403 'Access to port denied'"
1593 );
1594 assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1595 assert_eq!(
1596 offered[1],
1597 format!(
1598 "{}{}",
1599 proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1600 CountingMinter::value(1)
1601 ),
1602 "the handshake carried a different token than the header path, so they are two \
1603 caches rather than one"
1604 );
1605 assert_eq!(offered[2], "lambda-microvms.port.8080");
1606 assert_eq!(
1607 auth.mint_count(),
1608 2,
1609 "one mint covered both the agent port and 8080, which the control plane does not \
1610 do — or the second accessor minted a third, which would be two caches"
1611 );
1612 assert!(
1613 recorder.requests().len() == 1,
1614 "building credentials sent a request to the VM: {:?}",
1615 recorder.requests()
1616 );
1617 }
1618
1619 #[tokio::test]
1630 async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1631 let recorder = Recorder::with([]);
1632 let session = Session::builder("http://127.0.0.1:9000", "token")
1633 .with_backend(recorder as http::SharedBackend)
1634 .build_with(&crate::testing::TestAdapters::new())
1635 .expect("builds");
1636
1637 assert!(
1638 session
1639 .connect_headers(9000)
1640 .await
1641 .expect("no proxy is not a failure")
1642 .is_empty(),
1643 "a direct session's requests carry no proxy headers, so neither should this"
1644 );
1645 assert!(
1646 session
1647 .connect_subprotocols(9000)
1648 .await
1649 .expect("no proxy is not a failure")
1650 .is_none(),
1651 "a handshake with the base subprotocol and no token is refused for a reason that \
1652 names neither"
1653 );
1654 }
1655
1656 #[tokio::test]
1667 async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1668 let recorder = Recorder::with([]);
1669 let (session, auth, clock) = session_with(recorder);
1670
1671 let first = session
1672 .connect_subprotocols(9000)
1673 .await
1674 .expect("mints")
1675 .expect("proxied");
1676 assert!(first[1].ends_with(&CountingMinter::value(0)));
1677
1678 clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1679 let second = session
1680 .connect_subprotocols(9000)
1681 .await
1682 .expect("re-mints")
1683 .expect("proxied");
1684
1685 assert_eq!(auth.mint_count(), 2);
1686 assert!(
1687 second[1].ends_with(&CountingMinter::value(1)),
1688 "the handshake would carry a token past its refresh window: {}",
1689 second[1]
1690 );
1691 }
1692
1693 #[tokio::test]
1703 async fn procs_is_a_bearer_get_of_the_procs_route_with_a_typed_body() {
1704 let recorder = Recorder::with([Reply::ok(serde_json::json!({
1705 "procs": [{
1706 "exec_id": "e1",
1707 "pgid": 4242,
1708 "started_at": 1_756_500_000u64,
1709 "child_exited": true,
1710 "reap": false,
1711 "pids": [4243],
1712 }]
1713 }))]);
1714 let (session, _, _) = session_with(Arc::clone(&recorder));
1715
1716 let listed = session.procs().await.expect("procs");
1717 let request = recorder.last();
1718 assert_eq!(request.method, "GET");
1719 assert_eq!(request.path, "/v1/procs");
1720 assert_eq!(
1721 header(&request, "authorization").as_deref(),
1722 Some("Bearer agent-token-abcdef"),
1723 "the procs route is bearer-guarded; without the token it is a 503 or a 401"
1724 );
1725 assert_eq!(listed.procs.len(), 1);
1726 assert_eq!(listed.procs[0].exec_id, "e1");
1727 assert_eq!(listed.procs[0].pgid, Some(4242));
1728 assert!(listed.procs[0].child_exited);
1729 assert_eq!(listed.procs[0].pids, [4243]);
1730 }
1731
1732 #[tokio::test]
1740 async fn a_start_request_carries_the_reap_flag_on_the_wire() {
1741 let recorder = Recorder::with([Reply::ok(
1742 serde_json::json!({"exec_id":"e1","phase":"running"}),
1743 )]);
1744 let (session, _, _) = session_with(Arc::clone(&recorder));
1745
1746 let mut request = start_request("e1");
1747 request.reap_group_on_exit = true;
1748 session.run(request).await.expect("start");
1749
1750 let body: serde_json::Value =
1751 serde_json::from_slice(&recorder.last().body).expect("a JSON start body");
1752 assert_eq!(
1753 body["reap_group_on_exit"],
1754 serde_json::Value::Bool(true),
1755 "the daemon defaults a missing flag to false, so a dropped key is a silent \
1756 behaviour change: {body}"
1757 );
1758 }
1759
1760 #[tokio::test]
1761 async fn poweroff_requires_explicit_peer_support_before_starting() {
1762 for value in [None, Some("0"), Some("true"), Some("2")] {
1763 let headers = value
1764 .map(|value| {
1765 (
1766 protocol::health::POWEROFF_ON_EXIT_HEADER.into(),
1767 value.into(),
1768 )
1769 })
1770 .into_iter();
1771 let recorder = Recorder::with_headers([Reply::Body(200, b"{}".to_vec())], headers);
1772 let (session, _, _) = session_with(Arc::clone(&recorder));
1773 let error = session
1774 .run(start_request("poweroff").with_poweroff_on_exit(true))
1775 .await
1776 .expect_err("a peer must explicitly support poweroff");
1777 assert!(error.to_string().contains("poweroff_on_exit"));
1778 let requests = recorder.requests();
1779 assert_eq!(requests.len(), 1, "an unsupported peer must not start work");
1780 assert_eq!(requests[0].method, "GET");
1781 assert_eq!(requests[0].path, "/v1/health");
1782 assert!(header(&requests[0], "authorization").is_none());
1783 }
1784 }
1785
1786 #[tokio::test]
1787 async fn poweroff_opt_in_is_gated_and_the_default_needs_no_extra_request() {
1788 for enabled in [false, true] {
1789 let mut replies = Vec::new();
1790 if enabled {
1791 replies.push(Reply::Body(200, b"{}".to_vec()));
1792 }
1793 replies.push(Reply::ok(
1794 serde_json::json!({"exec_id":"e1","phase":"running"}),
1795 ));
1796 let headers = enabled.then_some((
1797 protocol::health::POWEROFF_ON_EXIT_HEADER.to_owned(),
1798 "1".to_owned(),
1799 ));
1800 let recorder = Recorder::with_headers(replies, headers);
1801 let (session, _, _) = session_with(Arc::clone(&recorder));
1802 session
1803 .run(start_request("e1").with_poweroff_on_exit(enabled))
1804 .await
1805 .unwrap();
1806 assert_eq!(recorder.requests().len(), if enabled { 2 } else { 1 });
1807 let request = recorder.last();
1808 assert_eq!(request.method, "POST");
1809 assert_eq!(request.path, "/v1/exec/start");
1810 let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1811 assert_eq!(
1812 body.get("poweroff_on_exit"),
1813 enabled.then_some(&serde_json::Value::Bool(true))
1814 );
1815 }
1816 }
1817
1818 #[test]
1820 fn a_session_debug_does_not_print_the_agent_token() {
1821 let recorder = Recorder::with([]);
1822 let session = Session::builder("https://vm.example", "super-secret-token")
1823 .with_backend(recorder as http::SharedBackend)
1824 .build_with(&crate::testing::TestAdapters::new())
1825 .expect("builds");
1826 let rendered = format!("{session:?}");
1827 assert!(rendered.contains("vm.example"), "{rendered}");
1828 assert!(
1829 !rendered.contains("super-secret"),
1830 "the agent token reached a Debug string: {rendered}"
1831 );
1832 }
1833
1834 #[tokio::test(start_paused = true)]
1849 async fn a_ready_wait_longer_than_the_clock_can_hold_still_polls() {
1850 let recorder =
1851 Recorder::with([Reply::ok(health_body(false)), Reply::ok(health_body(true))]);
1852 let (session, _, _) = session_with(Arc::clone(&recorder));
1853
1854 let health = session
1855 .wait_until_ready(Duration::MAX)
1856 .await
1857 .expect("a far deadline waits for the bootstrap");
1858 assert!(health.bootstrapped);
1859 assert_eq!(
1860 recorder.requests().len(),
1861 2,
1862 "the wait polled past the first not-bootstrapped answer"
1863 );
1864 }
1865}
1866
1867#[cfg(test)]
1868mod sse_fuzz;