1use std::collections::BTreeMap;
8
9use async_trait::async_trait;
10use futures::stream::{self, BoxStream};
11
12use crate::error::{ErrorData, Result};
13use crate::providers::sandbox::{guard_for, Bounded, TimeoutReport};
14use crate::traits::{
15 Binding, CommandOutput, CreateSandboxRequest, JobPoll, JobStart, PreviewCapability,
16 ResolvedSandbox, RunCommandRequest, Sandbox, SandboxInstance, SandboxState,
17};
18use alien_azure_clients::azure::sandbox_data_plane::{
19 CreateSandbox, EgressHostRule, EgressPolicy, SandboxDataPlaneApi,
20};
21use alien_client_core::ErrorData as ClientErrorData;
22use alien_core::{
23 azure_disk_image_label, classify_azure_sandbox_image, AzureSandboxImage, SandboxCapabilities,
24 SandboxCapability, SandboxEgress, AZURE_DISK_IMAGE_LABEL,
25};
26use alien_error::{AlienError, ContextError};
27use tracing::warn;
28
29#[derive(Debug)]
31pub struct AzureSandbox {
32 client: std::sync::Arc<dyn SandboxDataPlaneApi>,
33 sandbox_group: String,
34 disk_image: String,
36 disk_image_id: std::sync::Mutex<Option<String>>,
40 egress: SandboxEgress,
42 idle_pause_seconds: Option<u32>,
44 cpu: String,
47 memory: String,
48 disk: Option<String>,
49}
50
51impl AzureSandbox {
52 pub fn new(
54 client: std::sync::Arc<dyn SandboxDataPlaneApi>,
55 sandbox_group: String,
56 disk_image: String,
57 egress: SandboxEgress,
58 idle_pause_seconds: Option<u32>,
59 cpu: String,
60 memory: String,
61 disk: Option<String>,
62 ) -> Self {
63 Self {
64 client,
65 sandbox_group,
66 disk_image,
67 disk_image_id: std::sync::Mutex::new(None),
68 egress,
69 idle_pause_seconds,
70 cpu,
71 memory,
72 disk,
73 }
74 }
75
76 async fn disk_image_id(&self) -> Result<Option<String>> {
79 let Some(AzureSandboxImage::Registry(reference)) =
80 classify_azure_sandbox_image(&self.disk_image)
81 else {
82 return Ok(None);
83 };
84 if let Some(id) = self.cached_disk_image_id() {
85 return Ok(Some(id));
86 }
87 let label = azure_disk_image_label(reference);
88 let images = self
89 .client
90 .list_disk_images(&self.sandbox_group)
91 .await
92 .map_err(|error| {
93 if is_refusal(&error) {
96 Self::failed(CREATE, error)
97 } else {
98 error.context(ErrorData::SandboxUnreachable {
99 operation: CREATE.to_string(),
100 reason: format!(
101 "could not list the disk images of sandbox group '{}'",
102 self.sandbox_group
103 ),
104 })
105 }
106 })?;
107 let ours: Vec<_> = images
108 .into_iter()
109 .filter(|image| image.labels.get(AZURE_DISK_IMAGE_LABEL) == Some(&label))
110 .collect();
111 let Some(id) = ours
112 .iter()
113 .find(|image| image.state() == Some("Ready"))
114 .map(|image| image.id.clone())
115 else {
116 if !ours.is_empty() && ours.iter().all(|image| image.state() == Some("Failed")) {
119 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
120 failure: "diskImageFailed".to_string(),
121 reason: format!(
122 "the disk image built from '{reference}' in sandbox group '{}' failed; \
123 the sandbox resource reports why",
124 self.sandbox_group
125 ),
126 }));
127 }
128 return Err(self.image_not_ready(reference));
129 };
130 *self.disk_image_id.lock().expect("disk image id lock") = Some(id.clone());
131 Ok(Some(id))
132 }
133
134 fn image_not_ready(&self, reference: &str) -> AlienError<ErrorData> {
135 AlienError::new(ErrorData::SandboxUnreachable {
136 operation: CREATE.to_string(),
137 reason: format!(
138 "no Ready disk image built from '{reference}' is in sandbox group '{}'",
139 self.sandbox_group
140 ),
141 })
142 }
143
144 fn cached_disk_image_id(&self) -> Option<String> {
145 self.disk_image_id
146 .lock()
147 .expect("disk image id lock")
148 .clone()
149 }
150
151 async fn create_from_image(
155 &self,
156 request: CreateSandbox,
157 ) -> Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox> {
158 let first = self
159 .client
160 .create_sandbox(&self.sandbox_group, request.clone())
161 .await;
162 match first {
163 Err(error) if request.disk_image_id.is_some() && disk_image_gone(&error) => {
164 *self.disk_image_id.lock().expect("disk image id lock") = None;
165 let id = self.disk_image_id().await?;
166 match self
167 .client
168 .create_sandbox(
169 &self.sandbox_group,
170 CreateSandbox {
171 disk_image_id: id,
172 ..request
173 },
174 )
175 .await
176 {
177 Err(error) if disk_image_gone(&error) => {
180 *self.disk_image_id.lock().expect("disk image id lock") = None;
181 Err(error.context(ErrorData::SandboxUnreachable {
182 operation: CREATE.to_string(),
183 reason: format!(
184 "the disk image of sandbox group '{}' was replaced; no \
185 replacement is listed yet",
186 self.sandbox_group
187 ),
188 }))
189 }
190 other => other.map_err(|error| Self::failed(CREATE, error)),
191 }
192 }
193 other => other.map_err(|error| Self::failed(CREATE, error)),
194 }
195 }
196
197 #[cfg(test)]
200 pub(crate) fn disk_image(&self) -> &str {
201 &self.disk_image
202 }
203
204 fn checked_sandbox_id(operation: &str, sandbox_id: &str) -> Result<()> {
210 let usable = !sandbox_id.is_empty()
211 && sandbox_id.len() <= MAX_SANDBOX_ID
212 && sandbox_id
213 .chars()
214 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
215
216 if usable {
217 return Ok(());
218 }
219
220 Err(AlienError::new(ErrorData::InvalidInput {
221 operation_context: operation.to_string(),
222 details: format!(
223 "sandbox id '{sandbox_id}' must hold only letters, digits, '-' and '_', at most \
224 {MAX_SANDBOX_ID} characters"
225 ),
226 field_name: Some("sandboxId".to_string()),
227 }))
228 }
229
230 fn unsupported(&self, capability: &str, reason: &str) -> AlienError<ErrorData> {
233 AlienError::new(ErrorData::OperationNotSupported {
234 operation: capability.to_string(),
235 reason: reason.to_string(),
236 })
237 }
238
239 fn failed(operation: &str, error: AlienError<ClientErrorData>) -> AlienError<ErrorData> {
248 if is_refusal(&error) {
249 return error.context(ErrorData::SandboxCommandFailed {
250 failure: "dataPlaneRefused".to_string(),
251 reason: format!("{operation} was refused; the cause carries which side refused"),
252 });
253 }
254
255 if operation == RUN_COMMAND || operation == CREATE {
256 return error.context(ErrorData::SandboxOutcomeUnknown {
257 operation: operation.to_string(),
258 reason: "the Azure sandbox data plane did not complete the call".to_string(),
259 });
260 }
261
262 error.context(ErrorData::SandboxUnreachable {
263 operation: operation.to_string(),
264 reason: "the Azure sandbox data plane did not complete the call".to_string(),
265 })
266 }
267}
268
269impl Binding for AzureSandbox {}
270
271fn disk_image_gone(error: &AlienError<ClientErrorData>) -> bool {
273 match &error.error {
274 Some(ClientErrorData::InvalidInput { message, .. }) => {
275 message.contains("DiskImageNotFound")
276 }
277 Some(ClientErrorData::RemoteResourceNotFound { .. }) => true,
278 _ => false,
279 }
280}
281
282fn refuse_unsupported_create_fields(request: &CreateSandboxRequest, operation: &str) -> Result<()> {
286 if request.tenant_key.is_some() {
287 return Err(AlienError::new(ErrorData::OperationNotSupported {
288 operation: operation.to_string(),
289 reason: "Azure sandboxes take no tenantKey; create one sandbox per tenant instead"
290 .to_string(),
291 }));
292 }
293 if request.timeout_ms.is_some() {
294 return Err(AlienError::new(ErrorData::OperationNotSupported {
295 operation: SandboxCapability::SandboxLifetime.as_str().to_string(),
296 reason: "Azure sandboxes have auto-suspend and auto-delete but no wall-clock \
297 ceiling; terminate the sandbox when the caller is done with it"
298 .to_string(),
299 }));
300 }
301 Ok(())
302}
303
304#[async_trait]
305impl Sandbox for AzureSandbox {
306 fn capabilities(&self) -> SandboxCapabilities {
310 SandboxCapabilities::azure()
311 }
312
313 async fn create(&self, request: CreateSandboxRequest) -> Result<SandboxInstance> {
314 checked_sandbox_env(CREATE, &request.env)?;
315 refuse_unsupported_create_fields(&request, CREATE)?;
316
317 let asked = egress_policy(&self.egress);
318 let disk_image_id = self.disk_image_id().await?;
319 let sandbox = self
320 .create_from_image(CreateSandbox {
321 disk_image: self.disk_image.clone(),
322 disk_image_id,
323 cpu: self.cpu.clone(),
324 memory: self.memory.clone(),
325 disk: self.disk.clone(),
326 environment: request.env,
327 egress: asked.clone(),
328 idle_pause_seconds: self.idle_pause_seconds,
329 })
330 .await?;
331
332 let _ = request.sandbox_id;
337 if Self::checked_sandbox_id(CREATE, &sandbox.id).is_err() {
338 let unreadable = AlienError::new(ErrorData::UnexpectedResponseFormat {
339 provider: "azure".to_string(),
340 binding_name: CREATE.to_string(),
341 field: "id".to_string(),
342 response_json: format!("{:?}", sandbox.id),
343 });
344
345 let addressable = !sandbox.id.is_empty()
352 && sandbox
353 .id
354 .chars()
355 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
356
357 return Err(if !addressable {
358 warn!(
359 sandbox = %sandbox.id,
360 "the data plane minted an id this client will not send; the sandbox is \
361 running and cannot be deleted through this binding"
362 );
363 unreadable
364 } else {
365 self.discard(&sandbox.id, unreadable).await
366 });
367 }
368
369 match self.settle(&sandbox).await {
372 Ok(sandbox) => Ok(sandbox),
373 Err(error) => Err(self.discard(&sandbox.id, error).await),
374 }
375 }
376
377 async fn get(&self, sandbox_id: &str) -> Result<Option<SandboxInstance>> {
378 Self::checked_sandbox_id("sandbox.get", sandbox_id)?;
379 let Some(sandbox) = self.read_sandbox("sandbox.get", sandbox_id).await? else {
382 return Ok(None);
383 };
384
385 let state = sandbox_state("sandbox.get", sandbox.state.as_deref())?;
386
387 self.judge_if_judgeable(&sandbox)?;
392
393 Ok(Some(SandboxInstance {
394 sandbox_id: sandbox.id,
395 state,
396 generation: 1,
397 }))
398 }
399
400 async fn get_or_create(&self, request: CreateSandboxRequest) -> Result<ResolvedSandbox> {
401 if let Some(id) = request.sandbox_id.as_deref() {
402 match self.reconnect(id).await {
406 Ok(sandbox) => return Ok(ResolvedSandbox::found(sandbox)),
407 Err(error)
417 if error.code == "SANDBOX_NOT_AS_DECLARED"
418 || matches!(
419 &error.error,
420 Some(ErrorData::SandboxCommandFailed { failure, .. })
421 if failure == "sandboxGone" || failure == "sandboxTerminated"
422 ) => {}
423 Err(error) => return Err(error),
424 }
425 }
426
427 self.create(request).await.map(ResolvedSandbox::created)
428 }
429
430 async fn list(&self) -> Result<Vec<SandboxInstance>> {
431 Err(self.unsupported(
432 "sandbox.list",
433 "enumerating sandboxes is a control-plane read the data-plane role does not carry; \
434 reach a known sandbox with get",
435 ))
436 }
437
438 async fn run_command(
439 &self,
440 sandbox_id: &str,
441 request: RunCommandRequest,
442 ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
443 Self::checked_sandbox_id(RUN_COMMAND, sandbox_id)?;
444 if request.timeout.is_zero() {
445 return Err(AlienError::new(ErrorData::OperationNotSupported {
446 operation: "sandbox.runCommand".to_string(),
447 reason: "a command must carry a non-zero timeout".to_string(),
448 }));
449 }
450
451 self.judged_sandbox(RUN_COMMAND, sandbox_id).await?;
456
457 if request.command.is_empty() {
469 return Err(AlienError::new(ErrorData::InvalidInput {
470 operation_context: RUN_COMMAND.to_string(),
471 details: "a command must name a program to run".to_string(),
472 field_name: Some("command".to_string()),
473 }));
474 }
475
476 for name in request.env.keys() {
477 checked_env_name(RUN_COMMAND, name)?;
478 }
479 if !request.env.is_empty() && request.command.contains('=') {
484 let program = &request.command;
485 return Err(AlienError::new(ErrorData::InvalidInput {
486 operation_context: RUN_COMMAND.to_string(),
487 details: format!(
488 "command '{program}' cannot carry '=' in its name while the call also \
489 declares environment variables"
490 ),
491 field_name: Some("command".to_string()),
492 }));
493 }
494 let shell = bounded_shell(&request.argv(), &request.env, request.timeout);
495
496 let result = self.execute_within(sandbox_id, &shell, &request).await?;
497 let (timeout_exceeded, stderr) = match TimeoutReport::read(result.exit_code, &result.stderr)
499 {
500 Bounded::Ran { killed, stderr } => (killed, stderr),
501 Bounded::NotRun { reason } => {
502 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
503 failure: "commandNotBounded".to_string(),
504 reason,
505 }))
506 }
507 };
508
509 let mut frames: Vec<Result<CommandOutput>> = Vec::new();
513 if !result.stdout.is_empty() {
514 frames.push(Ok(CommandOutput::Stdout {
515 seq: 0,
516 data: result.stdout.into_bytes(),
517 }));
518 }
519 if !stderr.is_empty() {
520 frames.push(Ok(CommandOutput::Stderr {
521 seq: frames.len() as u64,
522 data: stderr.into_bytes(),
523 }));
524 }
525
526 if timeout_exceeded {
527 frames.push(Err(AlienError::new(ErrorData::SandboxCommandFailed {
530 failure: "timeoutExceeded".to_string(),
531 reason: format!(
532 "the command exceeded its {}ms timeout and was killed; the sandbox is still usable",
533 request.timeout.as_millis()
534 ),
535 })));
536 } else {
537 match result.exit_code {
538 Some(code) => frames.push(Ok(CommandOutput::Exit {
539 code,
540 truncated: false,
541 })),
542 None => frames.push(Err(AlienError::new(ErrorData::SandboxOutcomeUnknown {
545 operation: RUN_COMMAND.to_string(),
546 reason: "the data plane returned no exit code for the command".to_string(),
547 }))),
548 }
549 }
550
551 Ok(Box::pin(stream::iter(frames)))
552 }
553
554 async fn read_file(&self, sandbox_id: &str, path: &str) -> Result<Vec<u8>> {
558 Self::checked_sandbox_id("sandbox.readFile", sandbox_id)?;
559 let path = &checked_path("sandbox.readFile", path)?;
560
561 self.client
562 .read_file(&self.sandbox_group, sandbox_id, path)
563 .await
564 .map_err(|error| Self::failed("sandbox.readFile", error))
565 }
566
567 async fn write_files(&self, sandbox_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
568 Self::checked_sandbox_id("sandbox.writeFiles", sandbox_id)?;
569 let files = files
573 .into_iter()
574 .map(|(path, contents)| Ok((checked_path("sandbox.writeFiles", &path)?, contents)))
575 .collect::<Result<Vec<_>>>()?;
576
577 self.judged_sandbox("sandbox.writeFiles", sandbox_id)
581 .await?;
582
583 for (path, contents) in files {
586 self.client
587 .write_file(&self.sandbox_group, sandbox_id, &path, contents)
588 .await
589 .map_err(|error| Self::failed("sandbox.writeFiles", error))?;
590 }
591
592 Ok(())
593 }
594
595 async fn preview(&self, _sandbox_id: &str, _port: u16) -> Result<PreviewCapability> {
596 Err(self.unsupported(
597 "sandbox.preview",
598 "an Azure sandbox port is either published to the internet or gated on an interactive \
599 Entra login; neither is a port-scoped credential with an expiry",
600 ))
601 }
602
603 async fn pause(&self, sandbox_id: &str) -> Result<()> {
604 Self::checked_sandbox_id("sandbox.pause", sandbox_id)?;
605 const OPERATION: &str = "sandbox.pause";
606 let Err(error) = self
610 .client
611 .stop_sandbox(&self.sandbox_group, sandbox_id)
612 .await
613 else {
614 return Ok(());
615 };
616
617 match self.read_sandbox(OPERATION, sandbox_id).await? {
621 None => Ok(()),
622 Some(found) => match sandbox_state(OPERATION, found.state.as_deref())? {
623 SandboxState::Paused => Ok(()),
624 _ => Err(Self::failed(OPERATION, error)),
625 },
626 }
627 }
628
629 async fn resume(&self, sandbox_id: &str) -> Result<()> {
630 Self::checked_sandbox_id("sandbox.resume", sandbox_id)?;
631 const OPERATION: &str = "sandbox.resume";
632
633 let Some(found) = self.read_sandbox(OPERATION, sandbox_id).await? else {
634 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
635 failure: "sandboxGone".to_string(),
636 reason: format!("{OPERATION}: sandbox '{sandbox_id}' does not exist"),
637 }));
638 };
639
640 self.judge_if_judgeable(&found)?;
643
644 let mut resumed_here = false;
647 let woken = self
648 .await_running(OPERATION, sandbox_id, &mut resumed_here)
649 .await;
650
651 let refusal = match woken {
652 Err(error) => error,
653 Ok(running) => match self.policy_must_hold(&running) {
654 Ok(()) => return Ok(()),
655 Err(error) => error,
656 },
657 };
658 Err(self.put_back(sandbox_id, resumed_here, refusal).await)
659 }
660
661 async fn snapshot(&self, _sandbox_id: &str) -> Result<String> {
662 Err(self.unsupported(
663 "sandbox.snapshot",
664 "this client sends no snapshot request, and nothing owns the artifact once taken",
665 ))
666 }
667
668 async fn start_job(&self, _sandbox_id: &str, _request: RunCommandRequest) -> Result<JobStart> {
669 Err(self.unsupported("sandbox.jobStart", NO_JOB_HOST))
670 }
671
672 async fn poll_job(
673 &self,
674 _sandbox_id: &str,
675 _job_id: &str,
676 _since_seq: Option<u64>,
677 ) -> Result<JobPoll> {
678 Err(self.unsupported("sandbox.jobPoll", NO_JOB_HOST))
679 }
680
681 async fn cancel_job(&self, _sandbox_id: &str, _job_id: &str) -> Result<()> {
682 Err(self.unsupported("sandbox.jobCancel", NO_JOB_HOST))
683 }
684
685 async fn terminate(&self, sandbox_id: &str) -> Result<()> {
686 Self::checked_sandbox_id("sandbox.terminate", sandbox_id)?;
687 self.accept_delete(sandbox_id).await?;
688
689 for _ in 0..TERMINATE_POLL_ATTEMPTS {
697 if let Err(error) = self
701 .client
702 .get_sandbox(&self.sandbox_group, sandbox_id)
703 .await
704 {
705 if is_not_found(&error) {
706 return Ok(());
707 }
708 warn!(sandbox = %sandbox_id, %error, "could not confirm a sandbox is gone");
709 }
710 tokio::time::sleep(TERMINATE_POLL_INTERVAL).await;
711 }
712
713 Err(AlienError::new(ErrorData::SandboxUnreachable {
714 operation: "sandbox.terminate".to_string(),
715 reason: format!(
716 "deletion of '{sandbox_id}' was accepted but the sandbox was still present after {}s; it may still be running",
717 TERMINATE_POLL_ATTEMPTS * TERMINATE_POLL_INTERVAL.as_secs() as u32
718 ),
719 }))
720 }
721
722 fn as_any(&self) -> &dyn std::any::Any {
723 self
724 }
725}
726
727impl AzureSandbox {
728 async fn reconnect(&self, sandbox_id: &str) -> Result<SandboxInstance> {
735 let gone = || {
736 AlienError::new(ErrorData::SandboxCommandFailed {
737 failure: "sandboxGone".to_string(),
738 reason: format!("{GET_OR_CREATE}: sandbox '{sandbox_id}' cannot take work"),
739 })
740 };
741
742 let found = match self.read_sandbox(GET_OR_CREATE, sandbox_id).await? {
743 Some(sandbox) if sandbox.state.as_deref() == Some("Failed") => {
746 return Err(self.discard(sandbox_id, gone()).await)
747 }
748 Some(sandbox) if sandbox.state.as_deref() != Some("Deleting") => sandbox,
749 _ => return Err(gone()),
750 };
751
752 self.judge_if_judgeable(&found)?;
756
757 let mut resumed_here = false;
760 let running = match self
761 .await_running(GET_OR_CREATE, sandbox_id, &mut resumed_here)
762 .await
763 {
764 Ok(running) => running,
765 Err(error) => return Err(self.put_back(sandbox_id, resumed_here, error).await),
766 };
767 if let Err(error) = self.policy_must_hold(&running) {
768 return Err(self.put_back(sandbox_id, resumed_here, error).await);
769 }
770
771 Ok(SandboxInstance {
772 sandbox_id: running.id,
773 state: SandboxState::Running,
774 generation: 1,
775 })
776 }
777
778 async fn judged_sandbox(&self, operation: &str, sandbox_id: &str) -> Result<()> {
789 let refuse = |failure: &str, why: &str| {
790 Err(AlienError::new(ErrorData::SandboxCommandFailed {
791 failure: failure.to_string(),
792 reason: format!("{operation}: sandbox '{sandbox_id}' {why}"),
793 }))
794 };
795
796 let Some(sandbox) = self.read_sandbox(operation, sandbox_id).await? else {
797 return refuse("sandboxGone", "does not exist");
798 };
799
800 match sandbox.state.as_deref() {
801 Some("Running") => {}
802 Some("Creating" | "Resuming") => {
803 return refuse("sandboxNotReady", "is still starting; wait for it to run")
804 }
805 Some("Deleting") => return refuse("sandboxGone", "is being deleted"),
806 Some("Failed") => return refuse("sandboxGone", "has failed"),
807 Some("Stopping") => return refuse("sandboxPaused", "is stopping; wait for it"),
808 Some("Stopped" | "Suspended" | "Idle") => {
809 return refuse("sandboxPaused", "is suspended; resume it first")
810 }
811 other => {
815 sandbox_state(operation, other)?;
816 return refuse("sandboxNotReady", "is in a state this client cannot read");
817 }
818 }
819
820 self.policy_must_hold(&sandbox)
821 }
822
823 async fn read_sandbox(
825 &self,
826 operation: &str,
827 sandbox_id: &str,
828 ) -> Result<Option<alien_azure_clients::azure::sandbox_data_plane::Sandbox>> {
829 match self
830 .client
831 .get_sandbox(&self.sandbox_group, sandbox_id)
832 .await
833 {
834 Ok(sandbox) => Ok(Some(sandbox)),
835 Err(error) if is_not_found(&error) => Ok(None),
836 Err(error) => Err(Self::failed(operation, error)),
837 }
838 }
839
840 async fn resume_unchecked(&self, sandbox_id: &str) -> Result<()> {
842 self.client
843 .resume_sandbox(&self.sandbox_group, sandbox_id)
844 .await
845 .map_err(|error| Self::failed("sandbox.resume", error))
846 }
847
848 fn policy_must_hold(
854 &self,
855 sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
856 ) -> Result<()> {
857 let Some(asked) = egress_policy(&self.egress) else {
858 return Ok(());
859 };
860 if policy_holds(&asked, sandbox.egress_policy.as_ref()) {
861 return Ok(());
862 }
863
864 Err(AlienError::new(ErrorData::SandboxNotAsDeclared {
865 sandbox_id: sandbox.id.clone(),
866 restriction: "egress policy".to_string(),
867 reason: format!(
868 "it is running {} where the declaration asks for {}",
869 describe(sandbox.egress_policy.as_ref()),
870 describe(Some(&asked))
871 ),
872 }))
873 }
874
875 async fn settle(
880 &self,
881 sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
882 ) -> Result<SandboxInstance> {
883 let mut resumed_here = false;
887 let running = self
888 .await_running(CREATE, &sandbox.id, &mut resumed_here)
889 .await?;
890
891 self.policy_must_hold(&running)?;
894
895 Ok(SandboxInstance {
896 sandbox_id: running.id,
897 state: SandboxState::Running,
898 generation: 1,
899 })
900 }
901
902 async fn await_running(
912 &self,
913 operation: &str,
914 sandbox_id: &str,
915 resumed_here: &mut bool,
916 ) -> Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox> {
917 let deadline = std::time::Instant::now() + SANDBOX_READY_TIMEOUT;
918 let mut refusal: Option<String> = None;
919
920 loop {
921 let Some(sandbox) = self.read_sandbox(operation, sandbox_id).await? else {
922 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
923 failure: "sandboxGone".to_string(),
924 reason: format!("{operation}: sandbox '{sandbox_id}' disappeared while it was being waited for"),
925 }));
926 };
927
928 match sandbox.state.as_deref() {
931 Some("Running") => return Ok(sandbox),
932 Some("Creating" | "Resuming") => {}
933 Some("Stopping") => {}
936 Some("Stopped" | "Suspended" | "Idle") => {
940 match self.resume_unchecked(sandbox_id).await {
941 Ok(()) => {
942 refusal = None;
943 *resumed_here = true;
944 }
945 Err(error) => {
946 let failure = match &error.error {
947 Some(ErrorData::SandboxCommandFailed { failure, .. }) => {
948 failure.clone()
949 }
950 _ => error.code.clone(),
951 };
952 if failure != "dataPlaneRefused" {
956 *resumed_here = true;
957 }
958 warn!(sandbox = %sandbox_id, %error, "resume was refused; still waiting");
959 refusal = Some(failure);
960 }
961 }
962 }
963 other => {
966 let state = sandbox_state(operation, other)?;
967 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
968 failure: "sandboxTerminated".to_string(),
969 reason: format!(
970 "sandbox '{sandbox_id}' reached {state:?} and will not run again"
971 ),
972 }));
973 }
974 }
975
976 if std::time::Instant::now() >= deadline {
977 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
980 failure: "sandboxNotReady".to_string(),
981 reason: match refusal {
982 Some(code) => format!(
983 "sandbox '{sandbox_id}' was still not running after {}s; the last \
984 resume was refused with {code}",
985 SANDBOX_READY_TIMEOUT.as_secs()
986 ),
987 None => format!(
988 "sandbox '{sandbox_id}' was still not running after {}s",
989 SANDBOX_READY_TIMEOUT.as_secs()
990 ),
991 },
992 }));
993 }
994 tokio::time::sleep(SANDBOX_READY_INTERVAL).await;
995 }
996 }
997
998 fn judgeable(sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox) -> bool {
1005 match sandbox.state.as_deref() {
1006 Some("Running") => true,
1007 Some("Stopping" | "Stopped" | "Suspended" | "Idle") => sandbox.egress_policy.is_some(),
1008 _ => false,
1011 }
1012 }
1013
1014 fn judge_if_judgeable(
1015 &self,
1016 sandbox: &alien_azure_clients::azure::sandbox_data_plane::Sandbox,
1017 ) -> Result<()> {
1018 if Self::judgeable(sandbox) {
1019 self.policy_must_hold(sandbox)?;
1020 }
1021 Ok(())
1022 }
1023
1024 async fn put_back(
1031 &self,
1032 sandbox_id: &str,
1033 resumed_here: bool,
1034 reason: AlienError<ErrorData>,
1035 ) -> AlienError<ErrorData> {
1036 if !resumed_here {
1037 return reason;
1038 }
1039 let Err(failed) = self
1040 .client
1041 .stop_sandbox(&self.sandbox_group, sandbox_id)
1042 .await
1043 else {
1044 return reason;
1045 };
1046 if is_not_found(&failed) {
1049 return reason;
1050 }
1051
1052 warn!(sandbox = %sandbox_id, error = %failed, "could not re-suspend a sandbox this call woke");
1053 reason.context(ErrorData::SandboxCommandFailed {
1054 failure: "sandboxLeftAwake".to_string(),
1055 reason: format!(
1056 "sandbox '{sandbox_id}' was woken by this call, could not be handed back, and \
1057 could not be put to sleep again"
1058 ),
1059 })
1060 }
1061
1062 async fn discard(
1068 &self,
1069 sandbox_id: &str,
1070 reason: AlienError<ErrorData>,
1071 ) -> AlienError<ErrorData> {
1072 let Err(error) = self.accept_delete(sandbox_id).await else {
1073 return reason;
1074 };
1075
1076 warn!(
1077 sandbox = %sandbox_id,
1078 %error,
1079 "could not delete a sandbox that was never handed to its caller"
1080 );
1081 reason.context(ErrorData::SandboxCommandFailed {
1086 failure: "sandboxLeftBehind".to_string(),
1087 reason: format!(
1088 "sandbox '{sandbox_id}' was not handed to its caller and could not be deleted, \
1089 so it is still running"
1090 ),
1091 })
1092 }
1093
1094 async fn execute_within(
1103 &self,
1104 sandbox_id: &str,
1105 command: &str,
1106 request: &RunCommandRequest,
1107 ) -> Result<alien_azure_clients::azure::sandbox_data_plane::ExecResult> {
1108 match tokio::time::timeout(
1109 guard_for(request.timeout)?,
1110 self.client.execute_shell_command(
1111 &self.sandbox_group,
1112 sandbox_id,
1113 command,
1114 request.cwd.clone(),
1115 ),
1116 )
1117 .await
1118 {
1119 Ok(inner) => inner.map_err(|error| Self::failed(RUN_COMMAND, error)),
1120 Err(_) => Err(match self.terminate(sandbox_id).await {
1121 Ok(()) => AlienError::new(ErrorData::SandboxCommandFailed {
1122 failure: "timeoutExceeded".to_string(),
1123 reason: format!(
1124 "the command exceeded its {}ms timeout and the sandbox could not end it, so the sandbox was terminated",
1125 request.timeout.as_millis()
1126 ),
1127 }),
1128 Err(error) => error.context(ErrorData::SandboxOutcomeUnknown {
1133 operation: "sandbox.runCommand".to_string(),
1134 reason: format!(
1135 "the command exceeded its {}ms timeout and the sandbox could not end it",
1136 request.timeout.as_millis()
1137 ),
1138 }),
1139 }),
1140 }
1141 }
1142
1143 async fn accept_delete(&self, sandbox_id: &str) -> Result<()> {
1149 match self
1150 .client
1151 .delete_sandbox(&self.sandbox_group, sandbox_id)
1152 .await
1153 {
1154 Ok(_) => Ok(()),
1155 Err(error) if is_not_found(&error) => Ok(()),
1156 Err(error) => Err(Self::failed("sandbox.terminate", error)),
1157 }
1158 }
1159}
1160
1161const TERMINATE_POLL_ATTEMPTS: u32 = 15;
1167const TERMINATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
1168
1169fn bounded_shell(
1175 command: &[String],
1176 env: &BTreeMap<String, String>,
1177 timeout: std::time::Duration,
1178) -> String {
1179 let escape = |value: &str| value.replace('\'', "'\\''");
1180
1181 let mut argv = Vec::with_capacity(command.len() + env.len() + 1);
1185 if !env.is_empty() {
1186 argv.push("env".to_string());
1187 argv.extend(env.iter().map(|(name, value)| format!("{name}={value}")));
1188 }
1189 argv.extend(command.iter().cloned());
1190
1191 let arguments = argv
1192 .iter()
1193 .map(|argument| format!(" '{}'", escape(argument)))
1194 .collect::<String>();
1195 format!(
1196 "sh -c '{}' sh{arguments}",
1197 escape(&TimeoutReport::bounded_program(timeout))
1198 )
1199}
1200
1201fn checked_sandbox_env(operation: &str, env: &BTreeMap<String, String>) -> Result<()> {
1212 for name in env.keys() {
1213 checked_env_name(operation, name)?;
1214 if matches!(name.as_str(), "PATH" | "IFS" | "SHELLOPTS" | "BASHOPTS")
1215 || name.starts_with("LD_")
1216 {
1217 return Err(AlienError::new(ErrorData::InvalidInput {
1218 operation_context: operation.to_string(),
1219 details: format!(
1220 "'{name}' cannot be set for the whole sandbox, because the wrapper that holds \
1221 a command to its deadline inherits it; declare it on the command instead"
1222 ),
1223 field_name: Some("env".to_string()),
1224 }));
1225 }
1226 }
1227 Ok(())
1228}
1229
1230fn checked_env_name(operation: &str, name: &str) -> Result<()> {
1236 let usable = !name.is_empty()
1237 && !name.starts_with(|c: char| c.is_ascii_digit())
1238 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
1239 if usable {
1240 return Ok(());
1241 }
1242 Err(AlienError::new(ErrorData::InvalidInput {
1243 operation_context: operation.to_string(),
1244 details: format!(
1245 "environment variable name '{name}' is not a shell name: letters, digits and \
1246 underscores only, and not starting with a digit"
1247 ),
1248 field_name: Some("env".to_string()),
1249 }))
1250}
1251
1252fn checked_path(operation: &str, path: &str) -> Result<String> {
1260 let refused = |details: &str| {
1261 Err(AlienError::new(ErrorData::InvalidInput {
1262 operation_context: operation.to_string(),
1263 details: format!("path '{path}' {details}"),
1264 field_name: Some("path".to_string()),
1265 }))
1266 };
1267
1268 if path.ends_with('/') {
1271 return refused("must not end in '/'");
1272 }
1273 let relative = path.trim_start_matches('/');
1277 if relative.is_empty() {
1278 return refused("is empty");
1279 }
1280 if relative.contains('\0') {
1281 return refused("contains a null byte");
1282 }
1283 if relative
1284 .split('/')
1285 .any(|part| part == ".." || part.is_empty())
1286 {
1287 return refused("must not traverse");
1288 }
1289
1290 Ok(relative.to_string())
1291}
1292
1293fn egress_policy(egress: &SandboxEgress) -> Option<EgressPolicy> {
1301 let bounded = |host_rules| {
1302 Some(EgressPolicy {
1303 default_action: DENY.to_string(),
1304 unmodelled: Default::default(),
1305 rules: Vec::new(),
1306 host_rules,
1307 traffic_inspection: Some(FULL_INSPECTION.to_string()),
1308 })
1309 };
1310
1311 match egress {
1312 SandboxEgress::Allow => None,
1313 SandboxEgress::Deny => bounded(vec![EgressHostRule {
1318 pattern: EVERY_HOST.to_string(),
1319 action: DENY.to_string(),
1320 }]),
1321 SandboxEgress::AllowDomains { domains } => bounded(
1322 domains
1323 .iter()
1324 .map(|domain| EgressHostRule {
1325 pattern: domain.clone(),
1326 action: ALLOW.to_string(),
1327 })
1328 .collect(),
1329 ),
1330 }
1331}
1332
1333fn policy_holds(asked: &EgressPolicy, effective: Option<&EgressPolicy>) -> bool {
1344 let Some(effective) = effective else {
1345 return false;
1346 };
1347
1348 let asked_for = |host: &str| {
1349 asked
1350 .host_rules
1351 .iter()
1352 .any(|rule| rule.action.eq_ignore_ascii_case(ALLOW) && rule.pattern == host)
1353 };
1354
1355 effective.default_action.eq_ignore_ascii_case(&asked.default_action)
1356 && effective
1357 .traffic_inspection
1358 .as_deref()
1359 .is_some_and(|mode| mode.eq_ignore_ascii_case(FULL_INSPECTION))
1360 && asked.host_rules.iter().all(|asked_rule| {
1361 effective.host_rules.iter().any(|rule| {
1362 rule.pattern == asked_rule.pattern
1363 && rule.action.eq_ignore_ascii_case(&asked_rule.action)
1364 })
1365 })
1366 && effective.host_rules.iter().all(|rule| {
1370 rule.action.eq_ignore_ascii_case(DENY)
1371 || (rule.action.eq_ignore_ascii_case(ALLOW) && asked_for(&rule.pattern))
1372 })
1373 && effective.rules.iter().all(|rule| {
1376 rule.action
1377 .as_ref()
1378 .is_some_and(|action| action.action_type.eq_ignore_ascii_case(DENY))
1379 })
1380 && effective.unmodelled.is_empty()
1382}
1383
1384fn describe(effective: Option<&EgressPolicy>) -> String {
1386 match effective {
1387 None => "no policy at all".to_string(),
1388 Some(policy) if !policy.unmodelled.is_empty() => format!(
1389 "a policy carrying {}, which this client cannot weigh",
1390 policy
1391 .unmodelled
1392 .keys()
1393 .map(String::as_str)
1394 .collect::<Vec<_>>()
1395 .join(", ")
1396 ),
1397 Some(policy) => format!(
1398 "default action '{}' under {} inspection, {} host rules and {} match rules",
1399 policy.default_action,
1400 policy.traffic_inspection.as_deref().unwrap_or("unstated"),
1401 policy.host_rules.len(),
1402 policy.rules.len()
1403 ),
1404 }
1405}
1406
1407fn sandbox_state(operation: &str, state: Option<&str>) -> Result<SandboxState> {
1413 match state {
1414 Some("Running") => Ok(SandboxState::Running),
1415 Some("Creating" | "Resuming") => Ok(SandboxState::Starting),
1416 Some("Stopping" | "Stopped" | "Suspended" | "Idle") => Ok(SandboxState::Paused),
1424 Some("Deleting" | "Failed") => Ok(SandboxState::Terminated),
1425 other => Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
1426 provider: "azure".to_string(),
1427 binding_name: operation.to_string(),
1428 field: "state".to_string(),
1429 response_json: other
1430 .map_or_else(|| "absent".to_string(), |state| format!("\"{state}\"")),
1431 })),
1432 }
1433}
1434
1435const DENY: &str = "Deny";
1438const ALLOW: &str = "Allow";
1439const FULL_INSPECTION: &str = "Full";
1440
1441const EVERY_HOST: &str = "*";
1443
1444const MAX_SANDBOX_ID: usize = 63;
1449
1450const RUN_COMMAND: &str = "sandbox.runCommand";
1455const CREATE: &str = "sandbox.create";
1456const GET_OR_CREATE: &str = "sandbox.getOrCreate";
1457
1458const NO_JOB_HOST: &str = "Azure sandboxes run no in-guest agent to own a job between calls";
1459
1460const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1462const SANDBOX_READY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
1463
1464fn is_refusal(error: &AlienError<ClientErrorData>) -> bool {
1470 matches!(
1474 &error.error,
1475 Some(
1476 ClientErrorData::RemoteResourceNotFound { .. }
1477 | ClientErrorData::RemoteAccessDenied { .. }
1478 | ClientErrorData::InvalidInput { .. }
1479 )
1480 ) || matches!(
1481 &error.error,
1482 Some(ClientErrorData::HttpResponseError { http_status, .. }) if (400..500).contains(http_status)
1483 )
1484}
1485
1486fn is_not_found(error: &AlienError<ClientErrorData>) -> bool {
1492 matches!(
1497 &error.error,
1498 Some(ClientErrorData::RemoteResourceNotFound { .. })
1499 ) || matches!(
1500 &error.error,
1501 Some(ClientErrorData::HttpResponseError { http_status, .. }) if *http_status == 404
1502 )
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use alien_azure_clients::azure::sandbox_data_plane::ExecResult;
1509 use alien_azure_clients::azure::sandbox_data_plane::MockSandboxDataPlaneApi;
1510 use alien_azure_clients::azure::sandbox_data_plane::{DiskImage, DiskImageStatus};
1511 use alien_azure_clients::azure::sandbox_data_plane::{
1512 EgressRule, EgressRuleAction, EgressRuleMatch,
1513 };
1514 use alien_core::Platform;
1515 use futures::StreamExt;
1516
1517 fn http_error(status: u16, body: &str) -> AlienError<ClientErrorData> {
1518 AlienError::new(ClientErrorData::HttpResponseError {
1519 message: "Azure ADC sandbox.get failed".to_string(),
1520 url: "https://example.invalid/sandboxes/s1".to_string(),
1521 http_status: status,
1522 http_request_text: None,
1523 http_response_text: Some(body.to_string()),
1524 })
1525 }
1526
1527 fn settles_running(client: &mut MockSandboxDataPlaneApi, egress: Option<EgressPolicy>) {
1529 client
1530 .expect_get_sandbox()
1531 .returning(move |_, id| Ok(running(id, egress.clone())));
1532 }
1533
1534 fn sandbox_with(client: MockSandboxDataPlaneApi) -> AzureSandbox {
1535 AzureSandbox::new(
1536 std::sync::Arc::new(client),
1537 "grp".to_string(),
1538 "ubuntu".to_string(),
1539 SandboxEgress::Allow,
1540 None,
1541 "1000m".to_string(),
1542 "2048Mi".to_string(),
1543 None,
1544 )
1545 }
1546
1547 #[tokio::test]
1553 async fn the_declared_image_reaches_the_create_call() {
1554 let mut client = MockSandboxDataPlaneApi::new();
1555 client
1556 .expect_create_sandbox()
1557 .withf(|_, request| request.disk_image == "my-toolchain")
1558 .times(1)
1559 .returning(|_, _| {
1560 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1561 id: "s1".to_string(),
1562 egress_policy: None,
1563 state: Some("Running".to_string()),
1564 })
1565 });
1566 settles_running(&mut client, None);
1567
1568 let sandbox = AzureSandbox::new(
1569 std::sync::Arc::new(client),
1570 "grp".to_string(),
1571 "my-toolchain".to_string(),
1572 SandboxEgress::Allow,
1573 None,
1574 "1000m".to_string(),
1575 "2048Mi".to_string(),
1576 None,
1577 );
1578
1579 sandbox
1580 .create(CreateSandboxRequest::default())
1581 .await
1582 .expect("create succeeds");
1583 }
1584
1585 fn disk_image(id: &str, label: &str, state: &str) -> DiskImage {
1586 DiskImage {
1587 id: id.to_string(),
1588 labels: BTreeMap::from([(AZURE_DISK_IMAGE_LABEL.to_string(), label.to_string())]),
1589 status: Some(DiskImageStatus {
1590 state: Some(state.to_string()),
1591 error_message: None,
1592 }),
1593 }
1594 }
1595
1596 fn registry_sandbox(client: MockSandboxDataPlaneApi) -> AzureSandbox {
1597 AzureSandbox::new(
1598 std::sync::Arc::new(client),
1599 "grp".to_string(),
1600 "docker.io/library/python:3.14-slim".to_string(),
1601 SandboxEgress::Allow,
1602 None,
1603 "1000m".to_string(),
1604 "2048Mi".to_string(),
1605 None,
1606 )
1607 }
1608
1609 #[tokio::test]
1613 async fn a_registry_image_starts_from_the_disk_image_built_from_it() {
1614 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1615 let mut client = MockSandboxDataPlaneApi::new();
1616 let listed = label.clone();
1617 client
1618 .expect_list_disk_images()
1619 .times(1)
1620 .returning(move |_| {
1621 Ok(vec![
1622 disk_image(
1623 "other",
1624 &azure_disk_image_label("docker.io/library/node:22"),
1625 "Ready",
1626 ),
1627 disk_image("building", &listed, "Building"),
1628 disk_image("built", &listed, "Ready"),
1629 ])
1630 });
1631 client
1632 .expect_create_sandbox()
1633 .withf(|_, request| request.disk_image_id.as_deref() == Some("built"))
1634 .times(2)
1635 .returning(|_, _| {
1636 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1637 id: "s1".to_string(),
1638 egress_policy: None,
1639 state: Some("Running".to_string()),
1640 })
1641 });
1642 settles_running(&mut client, None);
1643
1644 let sandbox = registry_sandbox(client);
1645 for _ in 0..2 {
1646 sandbox
1647 .create(CreateSandboxRequest::default())
1648 .await
1649 .expect("create succeeds");
1650 }
1651 }
1652
1653 #[tokio::test]
1656 async fn a_retired_cached_image_is_looked_up_again() {
1657 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1658 let mut client = MockSandboxDataPlaneApi::new();
1659 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1660 let counter = seen.clone();
1661 client
1662 .expect_list_disk_images()
1663 .times(2)
1664 .returning(move |_| {
1665 let id = if counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1666 "old"
1667 } else {
1668 "new"
1669 };
1670 Ok(vec![disk_image(id, &label, "Ready")])
1671 });
1672 client
1673 .expect_create_sandbox()
1674 .withf(|_, request| request.disk_image_id.as_deref() == Some("old"))
1675 .times(1)
1676 .returning(|_, _| {
1677 Err(AlienError::new(ClientErrorData::InvalidInput {
1678 message: r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1679 .to_string(),
1680 field_name: None,
1681 }))
1682 });
1683 client
1684 .expect_create_sandbox()
1685 .withf(|_, request| request.disk_image_id.as_deref() == Some("new"))
1686 .times(1)
1687 .returning(|_, _| {
1688 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1689 id: "s1".to_string(),
1690 egress_policy: None,
1691 state: Some("Running".to_string()),
1692 })
1693 });
1694 settles_running(&mut client, None);
1695
1696 registry_sandbox(client)
1697 .create(CreateSandboxRequest::default())
1698 .await
1699 .expect("the replacement image serves");
1700 assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
1701 }
1702
1703 #[tokio::test]
1706 async fn a_retired_image_whose_replacement_still_builds_is_retryable() {
1707 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1708 let mut client = MockSandboxDataPlaneApi::new();
1709 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1710 let counter = seen.clone();
1711 client
1712 .expect_list_disk_images()
1713 .times(2)
1714 .returning(move |_| {
1715 let state = if counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1716 "Ready"
1717 } else {
1718 "Building"
1719 };
1720 Ok(vec![disk_image("img", &label, state)])
1721 });
1722 client.expect_create_sandbox().times(1).returning(|_, _| {
1723 Err(AlienError::new(ClientErrorData::InvalidInput {
1724 message:
1725 r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1726 .to_string(),
1727 field_name: None,
1728 }))
1729 });
1730
1731 let error = registry_sandbox(client)
1732 .create(CreateSandboxRequest::default())
1733 .await
1734 .expect_err("nothing is Ready to start from");
1735
1736 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1737 assert!(error.retryable, "{error}");
1738 }
1739
1740 #[tokio::test]
1743 async fn a_disk_image_list_lost_in_transport_is_retryable() {
1744 let mut client = MockSandboxDataPlaneApi::new();
1745 client.expect_list_disk_images().times(1).returning(|_| {
1746 Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
1747 message: "Service unavailable".to_string(),
1748 }))
1749 });
1750 client.expect_create_sandbox().times(0);
1751
1752 let error = registry_sandbox(client)
1753 .create(CreateSandboxRequest::default())
1754 .await
1755 .expect_err("the list failed");
1756
1757 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1758 assert!(error.retryable, "{error}");
1759 }
1760
1761 #[tokio::test]
1764 async fn a_list_still_naming_the_deleted_image_is_retryable() {
1765 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1766 let mut client = MockSandboxDataPlaneApi::new();
1767 client
1768 .expect_list_disk_images()
1769 .times(2)
1770 .returning(move |_| Ok(vec![disk_image("old", &label, "Ready")]));
1771 client.expect_create_sandbox().times(2).returning(|_, _| {
1772 Err(AlienError::new(ClientErrorData::InvalidInput {
1773 message:
1774 r#"Bad request for Resource 'grp': {"title":"DiskImageNotFound","status":400}"#
1775 .to_string(),
1776 field_name: None,
1777 }))
1778 });
1779 let sandbox = registry_sandbox(client);
1780
1781 let error = sandbox
1782 .create(CreateSandboxRequest::default())
1783 .await
1784 .expect_err("the listed image is gone");
1785
1786 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1787 assert!(error.retryable, "{error}");
1788 assert!(sandbox.cached_disk_image_id().is_none());
1789 }
1790
1791 #[tokio::test]
1793 async fn a_denied_disk_image_list_is_a_refusal() {
1794 let mut client = MockSandboxDataPlaneApi::new();
1795 client.expect_list_disk_images().times(1).returning(|_| {
1796 Err(AlienError::new(ClientErrorData::RemoteAccessDenied {
1797 resource_type: "Resource".to_string(),
1798 resource_name: "grp".to_string(),
1799 }))
1800 });
1801 client.expect_create_sandbox().times(0);
1802
1803 let error = registry_sandbox(client)
1804 .create(CreateSandboxRequest::default())
1805 .await
1806 .expect_err("the list is denied");
1807
1808 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1809 assert!(!error.retryable, "{error}");
1810 }
1811
1812 #[tokio::test]
1815 async fn a_label_with_only_failed_builds_is_not_retryable() {
1816 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1817 let mut client = MockSandboxDataPlaneApi::new();
1818 client
1819 .expect_list_disk_images()
1820 .times(1)
1821 .returning(move |_| Ok(vec![disk_image("failed", &label, "Failed")]));
1822 client.expect_create_sandbox().times(0);
1823
1824 let error = registry_sandbox(client)
1825 .create(CreateSandboxRequest::default())
1826 .await
1827 .expect_err("the only build failed");
1828
1829 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1830 assert!(!error.retryable, "{error}");
1831 }
1832
1833 #[tokio::test]
1836 async fn an_image_not_built_yet_fails_the_create_and_is_looked_up_again() {
1837 let label = azure_disk_image_label("docker.io/library/python:3.14-slim");
1838 let mut client = MockSandboxDataPlaneApi::new();
1839 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1840 let seen = calls.clone();
1841 client
1842 .expect_list_disk_images()
1843 .times(2)
1844 .returning(move |_| {
1845 if seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
1846 Ok(Vec::new())
1847 } else {
1848 Ok(vec![disk_image("built", &label, "Ready")])
1849 }
1850 });
1851 client
1852 .expect_create_sandbox()
1853 .withf(|_, request| request.disk_image_id.as_deref() == Some("built"))
1854 .times(1)
1855 .returning(|_, _| {
1856 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1857 id: "s1".to_string(),
1858 egress_policy: None,
1859 state: Some("Running".to_string()),
1860 })
1861 });
1862 settles_running(&mut client, None);
1863
1864 let sandbox = registry_sandbox(client);
1865 let error = sandbox
1866 .create(CreateSandboxRequest::default())
1867 .await
1868 .expect_err("no image is built yet");
1869 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "{error}");
1870 assert!(error.to_string().contains("python:3.14-slim"), "{error}");
1871
1872 sandbox
1873 .create(CreateSandboxRequest::default())
1874 .await
1875 .expect("the image is found once it is Ready");
1876 }
1877
1878 #[tokio::test(start_paused = true)]
1882 async fn a_termination_that_never_completes_is_reported_as_unconfirmed() {
1883 let mut client = MockSandboxDataPlaneApi::new();
1884 client.expect_delete_sandbox().returning(|_, _| Ok(()));
1885 client.expect_get_sandbox().returning(|_, id| {
1886 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
1887 id: id.to_string(),
1888 egress_policy: None,
1889 state: Some("Running".to_string()),
1890 })
1891 });
1892
1893 let error = sandbox_with(client)
1894 .terminate("s1")
1895 .await
1896 .expect_err("a sandbox still present after the poll is not contained");
1897 assert!(
1898 error.to_string().contains("may still be running"),
1899 "says what is not known: {error}"
1900 );
1901 }
1902
1903 #[tokio::test(start_paused = true)]
1905 async fn a_termination_is_confirmed_once_the_sandbox_is_gone() {
1906 let mut client = MockSandboxDataPlaneApi::new();
1907 client.expect_delete_sandbox().returning(|_, _| Ok(()));
1908 client
1909 .expect_get_sandbox()
1910 .returning(|_, _| Err(http_error(404, "SandboxNotFound")));
1911
1912 sandbox_with(client)
1913 .terminate("s1")
1914 .await
1915 .expect("an absent sandbox is a confirmed termination");
1916 }
1917
1918 #[test]
1922 fn only_the_status_decides_whether_a_sandbox_is_gone() {
1923 assert!(is_not_found(&http_error(404, "SandboxNotFound")));
1924
1925 assert!(
1929 is_not_found(&AlienError::new(ClientErrorData::RemoteResourceNotFound {
1930 resource_type: "Sandbox".to_string(),
1931 resource_name: "s1".to_string(),
1932 })),
1933 "a wrapped 404 is how the client reports an absent sandbox"
1934 );
1935
1936 assert!(
1937 !is_not_found(&http_error(429, "throttled; see trace 404abc")),
1938 "a throttle is not a missing sandbox"
1939 );
1940 assert!(
1941 !is_not_found(&http_error(403, "denied on /sandboxes/404/read")),
1942 "a path containing 404 is not a missing sandbox"
1943 );
1944 assert!(
1945 !is_not_found(&http_error(500, "internal error 404")),
1946 "a server failure is not a missing sandbox"
1947 );
1948 }
1949
1950 #[derive(Debug)]
1953 struct ScriptedExec {
1954 deleted: std::sync::Arc<std::sync::atomic::AtomicBool>,
1955 delete_refuses: std::sync::atomic::AtomicBool,
1956 commands: std::sync::Mutex<Vec<String>>,
1957 results: std::sync::Mutex<
1959 std::collections::VecDeque<alien_azure_clients::azure::sandbox_data_plane::ExecResult>,
1960 >,
1961 }
1962
1963 impl ScriptedExec {
1964 fn new(
1965 results: Vec<alien_azure_clients::azure::sandbox_data_plane::ExecResult>,
1966 ) -> std::sync::Arc<Self> {
1967 std::sync::Arc::new(Self {
1968 deleted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1969 delete_refuses: std::sync::atomic::AtomicBool::new(false),
1970 commands: std::sync::Mutex::new(Vec::new()),
1971 results: std::sync::Mutex::new(results.into_iter().collect()),
1972 })
1973 }
1974
1975 fn exec_result(
1976 exit_code: i32,
1977 stdout: &str,
1978 stderr: &str,
1979 ) -> alien_azure_clients::azure::sandbox_data_plane::ExecResult {
1980 alien_azure_clients::azure::sandbox_data_plane::ExecResult {
1981 stdout: stdout.to_string(),
1982 stderr: stderr.to_string(),
1983 exit_code: Some(exit_code),
1984 }
1985 }
1986 }
1987
1988 #[async_trait]
1989 impl SandboxDataPlaneApi for ScriptedExec {
1990 async fn stop_sandbox(
1991 &self,
1992 _group: &str,
1993 _sandbox_id: &str,
1994 ) -> alien_client_core::Result<()> {
1995 unreachable!("the command paths never pause")
1996 }
1997
1998 async fn resume_sandbox(
1999 &self,
2000 _group: &str,
2001 _sandbox_id: &str,
2002 ) -> alien_client_core::Result<()> {
2003 unreachable!("the command paths never resume")
2004 }
2005
2006 async fn read_file(
2007 &self,
2008 _group: &str,
2009 _sandbox_id: &str,
2010 _path: &str,
2011 ) -> alien_client_core::Result<Vec<u8>> {
2012 unreachable!("the command paths never read files")
2013 }
2014
2015 async fn write_file(
2016 &self,
2017 _group: &str,
2018 _sandbox_id: &str,
2019 _path: &str,
2020 _contents: Vec<u8>,
2021 ) -> alien_client_core::Result<()> {
2022 unreachable!("the command paths never write files")
2023 }
2024
2025 async fn mkdir(
2026 &self,
2027 _group: &str,
2028 _sandbox_id: &str,
2029 _path: &str,
2030 ) -> alien_client_core::Result<()> {
2031 unreachable!("the command paths never create directories")
2032 }
2033
2034 async fn create_sandbox(
2035 &self,
2036 _group: &str,
2037 _request: CreateSandbox,
2038 ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox>
2039 {
2040 unreachable!("the command paths never create")
2041 }
2042
2043 async fn get_sandbox(
2044 &self,
2045 _group: &str,
2046 sandbox_id: &str,
2047 ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::Sandbox>
2048 {
2049 if self.deleted.load(std::sync::atomic::Ordering::SeqCst) {
2050 return Err(http_error(404, "SandboxNotFound"));
2051 }
2052 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2053 id: sandbox_id.to_string(),
2054 egress_policy: None,
2055 state: Some("Running".to_string()),
2056 })
2057 }
2058
2059 async fn delete_sandbox(
2060 &self,
2061 _group: &str,
2062 _sandbox_id: &str,
2063 ) -> alien_client_core::Result<()> {
2064 if self
2065 .delete_refuses
2066 .load(std::sync::atomic::Ordering::SeqCst)
2067 {
2068 return Err(http_error(503, "gateway timeout"));
2069 }
2070 self.deleted
2071 .store(true, std::sync::atomic::Ordering::SeqCst);
2072 Ok(())
2073 }
2074
2075 async fn execute_shell_command(
2076 &self,
2077 _group: &str,
2078 _sandbox_id: &str,
2079 command: &str,
2080 _working_directory: Option<String>,
2081 ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::ExecResult>
2082 {
2083 self.commands
2084 .lock()
2085 .expect("commands lock")
2086 .push(command.to_string());
2087 let next = self.results.lock().expect("results lock").pop_front();
2088 match next {
2089 Some(result) => Ok(ExecResult {
2093 stderr: as_sandbox_stderr(&result.stderr),
2094 ..result
2095 }),
2096 None => std::future::pending().await,
2097 }
2098 }
2099
2100 async fn create_disk_image(
2101 &self,
2102 _group: &str,
2103 _request: alien_azure_clients::azure::sandbox_data_plane::CreateDiskImage,
2104 ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::DiskImage>
2105 {
2106 unreachable!("the command paths never build images")
2107 }
2108
2109 async fn get_disk_image(
2110 &self,
2111 _group: &str,
2112 _image_id: &str,
2113 ) -> alien_client_core::Result<alien_azure_clients::azure::sandbox_data_plane::DiskImage>
2114 {
2115 unreachable!("the command paths never read images")
2116 }
2117
2118 async fn list_disk_images(
2119 &self,
2120 _group: &str,
2121 ) -> alien_client_core::Result<Vec<alien_azure_clients::azure::sandbox_data_plane::DiskImage>>
2122 {
2123 unreachable!("the command paths never list images")
2124 }
2125
2126 async fn delete_disk_image(
2127 &self,
2128 _group: &str,
2129 _image_id: &str,
2130 ) -> alien_client_core::Result<()> {
2131 unreachable!("the command paths never delete images")
2132 }
2133 }
2134
2135 const TIMEOUT_PLACEHOLDER: &str = "<timeout>";
2137 const SANDBOX_NONCE: &str = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
2143
2144 fn as_sandbox_stderr(stderr: &str) -> String {
2146 match stderr {
2147 TIMEOUT_PLACEHOLDER => format!("{SANDBOX_NONCE}\npartial-err{SANDBOX_NONCE}"),
2148 other => format!("{SANDBOX_NONCE}\n{other}"),
2149 }
2150 }
2151
2152 fn provider(client: std::sync::Arc<ScriptedExec>) -> AzureSandbox {
2153 AzureSandbox::new(
2154 client,
2155 "grp".to_string(),
2156 "ubuntu".to_string(),
2157 SandboxEgress::Allow,
2158 None,
2159 "1000m".to_string(),
2160 "2048Mi".to_string(),
2161 None,
2162 )
2163 }
2164
2165 fn command(timeout_secs: u64) -> RunCommandRequest {
2166 RunCommandRequest {
2167 command: "sleep".to_string(),
2168 args: vec!["forever".to_string()],
2169 cwd: None,
2170 env: BTreeMap::new(),
2171 timeout: std::time::Duration::from_secs(timeout_secs),
2172 }
2173 }
2174
2175 #[tokio::test]
2182 async fn a_command_past_its_timeout_is_killed_in_place_and_the_sandbox_survives() {
2183 let client = ScriptedExec::new(vec![ScriptedExec::exec_result(
2184 137,
2185 "partial\n",
2186 TIMEOUT_PLACEHOLDER,
2187 )]);
2188 let sandbox = provider(client.clone());
2189
2190 let frames: Vec<Result<CommandOutput>> = sandbox
2191 .run_command("s1", command(30))
2192 .await
2193 .expect("the call itself succeeds; the deadline is reported in the stream")
2194 .collect()
2195 .await;
2196
2197 assert!(
2198 matches!(&frames[0], Ok(CommandOutput::Stdout { data, .. }) if data == b"partial\n"),
2199 "output produced before the deadline is kept: {frames:?}"
2200 );
2201 let terminal = frames
2202 .last()
2203 .expect("frames")
2204 .as_ref()
2205 .expect_err("the stream must end in the deadline error, not an exit frame");
2206 assert!(
2207 terminal.to_string().contains("timeoutExceeded"),
2208 "the caller has to be able to tell this apart from a command that failed: {terminal}"
2209 );
2210 assert!(
2211 !client.deleted.load(std::sync::atomic::Ordering::SeqCst),
2212 "the sandbox survives an in-sandbox kill"
2213 );
2214 let sent = client.commands.lock().expect("commands lock").clone();
2215 assert_eq!(sent.len(), 1, "one command: {sent:?}");
2216 assert!(
2217 sent[0].starts_with("sh -c '") && sent[0].ends_with("' sh 'sleep' 'forever'"),
2218 "the command is passed as arguments, not pasted into the program: {}",
2219 sent[0]
2220 );
2221 assert!(sent[0].contains("sleep 30"), "{}", sent[0]);
2222 }
2223
2224 #[tokio::test]
2227 async fn a_command_exiting_124_of_its_own_accord_is_an_exit_not_a_timeout() {
2228 let client = ScriptedExec::new(vec![ScriptedExec::exec_result(124, "done\n", "")]);
2229 let sandbox = provider(client.clone());
2230
2231 let frames: Vec<Result<CommandOutput>> = sandbox
2232 .run_command("s1", command(300))
2233 .await
2234 .expect("runs")
2235 .collect()
2236 .await;
2237
2238 assert!(matches!(
2239 frames.last().expect("frames"),
2240 Ok(CommandOutput::Exit { code: 124, .. })
2241 ));
2242 }
2243
2244 #[tokio::test(start_paused = true)]
2249 async fn a_command_the_sandbox_cannot_end_takes_the_sandbox_with_it() {
2250 let client = ScriptedExec::new(Vec::new());
2251 let sandbox = provider(client.clone());
2252
2253 let error = sandbox
2254 .run_command("s1", command(30))
2255 .await
2256 .err()
2257 .expect("a command that outran its deadline has not succeeded");
2258
2259 assert!(
2260 error.to_string().contains("timeoutExceeded"),
2261 "the caller has to be able to tell this apart from a command that failed: {error}"
2262 );
2263 assert!(
2264 client.deleted.load(std::sync::atomic::Ordering::SeqCst),
2265 "the sandbox must actually be deleted, not merely reported as terminated"
2266 );
2267 }
2268
2269 #[tokio::test(start_paused = true)]
2274 async fn a_terminate_that_fails_does_not_hide_the_unknown_outcome() {
2275 let client = ScriptedExec::new(Vec::new());
2276 client
2277 .delete_refuses
2278 .store(true, std::sync::atomic::Ordering::SeqCst);
2279 let sandbox = provider(client.clone());
2280
2281 let error = sandbox
2282 .run_command("s1", command(30))
2283 .await
2284 .err()
2285 .expect("a command that outran its deadline has not succeeded");
2286
2287 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "{error}");
2288 assert!(
2289 !error.retryable,
2290 "a retry would run the command a second time beside the first: {error}"
2291 );
2292 assert!(
2293 error.to_string().contains("could not end it"),
2294 "the deadline stays the headline: {error}"
2295 );
2296 }
2297
2298 #[test]
2301 fn the_bounded_shell_passes_arguments_untouched() {
2302 let wrapped = bounded_shell(
2303 &[
2304 "echo".to_string(),
2305 "it's".to_string(),
2306 "&&".to_string(),
2307 "sleep 5".to_string(),
2308 ],
2309 &BTreeMap::new(),
2310 std::time::Duration::from_millis(1500),
2311 );
2312 assert!(wrapped.contains("sleep 1.500"), "{wrapped}");
2313 assert!(
2314 wrapped.ends_with("' sh 'echo' 'it'\\''s' '&&' 'sleep 5'"),
2315 "{wrapped}"
2316 );
2317 }
2318
2319 #[test]
2324 fn the_bounded_shell_carries_variables_as_data() {
2325 let wrapped = bounded_shell(
2326 &["printenv".to_string(), "TOKEN".to_string()],
2327 &BTreeMap::from([("TOKEN".to_string(), "a'; rm -rf /".to_string())]),
2328 std::time::Duration::from_millis(1500),
2329 );
2330
2331 assert!(
2332 wrapped.ends_with("' sh 'env' 'TOKEN=a'\\''; rm -rf /' 'printenv' 'TOKEN'"),
2333 "the value has to survive as one argument to env: {wrapped}"
2334 );
2335 }
2336
2337 #[test]
2343 fn a_caller_cannot_repoint_the_wrappers_own_path() {
2344 let wrapped = bounded_shell(
2345 &["sleep".to_string(), "forever".to_string()],
2346 &BTreeMap::from([("PATH".to_string(), "/tmp/attacker".to_string())]),
2347 std::time::Duration::from_millis(1500),
2348 );
2349
2350 let (wrapper, argv) = wrapped
2351 .split_once("' sh ")
2352 .expect("the wrapper's program ends where its arguments begin");
2353 assert!(
2354 !wrapper.contains("PATH"),
2355 "the wrapper has to resolve its own tools: {wrapper}"
2356 );
2357 assert_eq!(
2358 argv, "'env' 'PATH=/tmp/attacker' 'sleep' 'forever'",
2359 "the variable belongs to the command, not to the shell that bounds it"
2360 );
2361 }
2362
2363 #[test]
2373 #[cfg(unix)]
2374 fn the_wrapper_this_builds_runs_with_the_variable_set() {
2375 use std::os::unix::fs::PermissionsExt;
2376
2377 let bin = std::env::temp_dir().join(format!("alien-azure-shell-{}", std::process::id()));
2378 std::fs::create_dir_all(&bin).expect("a directory for the stand-in");
2379 let setsid = bin.join("setsid");
2380 std::fs::write(&setsid, "#!/bin/sh\nexec \"$@\"\n").expect("the stand-in is written");
2381 std::fs::set_permissions(&setsid, std::fs::Permissions::from_mode(0o755))
2382 .expect("the stand-in is executable");
2383 let path = format!(
2384 "{}:{}",
2385 bin.display(),
2386 std::env::var("PATH").unwrap_or_default()
2387 );
2388
2389 let command = [
2391 "/bin/sh".to_string(),
2392 "-c".to_string(),
2393 "printf %s \"$TOKEN\"".to_string(),
2394 ];
2395
2396 let run = |env: BTreeMap<String, String>| {
2397 let shell = bounded_shell(&command, &env, std::time::Duration::from_secs(5));
2398 std::process::Command::new("/bin/sh")
2399 .arg("-c")
2400 .arg(shell)
2401 .env("PATH", &path)
2402 .output()
2403 .expect("a shell runs")
2404 };
2405
2406 let plain = run(BTreeMap::from([(
2407 "TOKEN".to_string(),
2408 "reached".to_string(),
2409 )]));
2410 assert_eq!(
2411 String::from_utf8_lossy(&plain.stdout),
2412 "reached",
2413 "the variable has to reach the command; stderr {:?}",
2414 String::from_utf8_lossy(&plain.stderr)
2415 );
2416
2417 let repointed = run(BTreeMap::from([
2420 ("TOKEN".to_string(), "reached".to_string()),
2421 ("PATH".to_string(), "/nonexistent".to_string()),
2422 ]));
2423 assert_eq!(
2424 String::from_utf8_lossy(&repointed.stdout),
2425 "reached",
2426 "a caller's PATH must not break the wrapper; stderr {:?}",
2427 String::from_utf8_lossy(&repointed.stderr)
2428 );
2429
2430 std::fs::remove_dir_all(&bin).ok();
2431 }
2432
2433 #[tokio::test]
2440 async fn a_sandbox_cannot_set_what_the_timeout_wrapper_reads() {
2441 for name in [
2444 "PATH",
2445 "IFS",
2446 "LD_PRELOAD",
2447 "LD_LIBRARY_PATH",
2448 "LD_AUDIT",
2449 "LD_DEBUG",
2450 "LD_BIND_NOW",
2451 "SHELLOPTS",
2452 "BASHOPTS",
2453 ] {
2454 let mut client = MockSandboxDataPlaneApi::new();
2455 client.expect_create_sandbox().never();
2456
2457 let error = sandbox_with(client)
2458 .create(CreateSandboxRequest {
2459 sandbox_id: None,
2460 tenant_key: None,
2461 env: BTreeMap::from([(name.to_string(), "/tmp/attacker".to_string())]),
2462 ..Default::default()
2463 })
2464 .await
2465 .expect_err("a sandbox that could forge its own deadline must not be created");
2466
2467 assert_eq!(error.code, "INVALID_INPUT", "{name}: {error}");
2468 }
2469
2470 let mut client = MockSandboxDataPlaneApi::new();
2472 client
2473 .expect_create_sandbox()
2474 .times(1)
2475 .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t"))
2476 .returning(|_, _| Ok(running("s1", None)));
2477 client
2478 .expect_get_sandbox()
2479 .returning(|_, id| Ok(running(id, None)));
2480
2481 sandbox_with(client)
2482 .create(CreateSandboxRequest {
2483 sandbox_id: None,
2484 tenant_key: None,
2485 env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]),
2486 ..Default::default()
2487 })
2488 .await
2489 .expect("an ordinary variable is still carried");
2490 }
2491
2492 #[tokio::test]
2498 async fn a_command_naming_no_program_is_refused() {
2499 let mut client = MockSandboxDataPlaneApi::new();
2500 client
2501 .expect_get_sandbox()
2502 .returning(|_, id| Ok(running(id, None)));
2503 client.expect_execute_shell_command().never();
2504
2505 let mut request = command(5);
2506 request.command = String::new();
2507 request.env = BTreeMap::from([("SECRET".to_string(), "hunter2".to_string())]);
2508
2509 let error = match sandbox_with(client).run_command("s1", request).await {
2510 Ok(_) => panic!("a command with no program must not run"),
2511 Err(error) => error,
2512 };
2513
2514 assert_eq!(error.code, "INVALID_INPUT", "{error}");
2515 }
2516
2517 #[tokio::test]
2523 async fn a_program_name_env_would_swallow_is_refused() {
2524 let mut client = MockSandboxDataPlaneApi::new();
2525 client
2526 .expect_get_sandbox()
2527 .returning(|_, id| Ok(running(id, None)));
2528 client.expect_execute_shell_command().never();
2529
2530 let mut request = command(5);
2531 request.command = "FOO=bar".to_string();
2532 request.args = vec!["printenv".to_string()];
2533 request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]);
2534
2535 let error = match sandbox_with(client).run_command("s1", request).await {
2536 Ok(_) => panic!("a command env would swallow must not be sent"),
2537 Err(error) => error,
2538 };
2539
2540 assert_eq!(error.code, "INVALID_INPUT", "{error}");
2541 }
2542
2543 #[test]
2545 fn a_variable_name_that_is_not_a_name_is_refused() {
2546 for name in ["", "A B", "A;rm", "1A", "A=B", "A-B"] {
2547 let error = checked_env_name("sandbox.runCommand", name)
2548 .expect_err("a name the shell would not read as a name must be refused");
2549 assert_eq!(error.code, "INVALID_INPUT", "name '{name}': {error}");
2550 }
2551 for name in ["A", "_a", "TOKEN_1"] {
2552 checked_env_name("sandbox.runCommand", name)
2553 .unwrap_or_else(|error| panic!("name '{name}' is a shell name: {error}"));
2554 }
2555 }
2556
2557 #[tokio::test]
2563 async fn a_path_that_could_escape_never_reaches_the_data_plane() {
2564 let mut client = MockSandboxDataPlaneApi::new();
2565 client.expect_read_file().never();
2566 client.expect_write_file().never();
2567 let sandbox = sandbox_with(client);
2568
2569 for path in [
2570 "../etc/shadow",
2571 "",
2572 "/",
2573 "work/",
2574 "a//b",
2575 "a/../../b",
2576 "/../escape",
2577 ] {
2578 let error = sandbox
2579 .read_file("s1", path)
2580 .await
2581 .expect_err(&format!("'{path}' must be refused"));
2582 assert_eq!(error.code, "INVALID_INPUT", "{path}: {error}");
2583
2584 sandbox
2585 .write_files("s1", BTreeMap::from([(path.to_string(), vec![1u8])]))
2586 .await
2587 .expect_err(&format!("'{path}' must be refused on write too"));
2588 }
2589
2590 let mut client = MockSandboxDataPlaneApi::new();
2594 client
2595 .expect_read_file()
2596 .withf(|_, _, path| !path.starts_with('/'))
2597 .times(3)
2598 .returning(|_, _, _| Ok(Vec::new()));
2599 let sandbox = sandbox_with(client);
2600 for path in ["app.py", "src/app.py", "/work/app.py"] {
2601 sandbox
2602 .read_file("s1", path)
2603 .await
2604 .unwrap_or_else(|error| panic!("'{path}' is a normal path: {error}"));
2605 }
2606 }
2607
2608 #[tokio::test]
2611 async fn a_read_carries_the_sandbox_and_path_to_the_data_plane() {
2612 let mut client = MockSandboxDataPlaneApi::new();
2613 client
2614 .expect_read_file()
2615 .withf(|group, sandbox_id, path| {
2616 group == "grp" && sandbox_id == "s1" && path == "src/app.py"
2617 })
2618 .times(1)
2619 .returning(|_, _, _| Ok(b"print(1)\n".to_vec()));
2620
2621 let contents = sandbox_with(client)
2622 .read_file("s1", "src/app.py")
2623 .await
2624 .expect("the read should succeed");
2625
2626 assert_eq!(contents, b"print(1)\n");
2627 }
2628
2629 #[tokio::test]
2634 async fn a_batch_with_an_unusable_path_writes_nothing() {
2635 let mut client = MockSandboxDataPlaneApi::new();
2636 client.expect_write_file().never();
2637
2638 let error = sandbox_with(client)
2639 .write_files(
2640 "s1",
2641 BTreeMap::from([
2642 ("a.txt".to_string(), vec![1u8]),
2643 ("b/../../escape".to_string(), vec![2u8]),
2644 ]),
2645 )
2646 .await
2647 .expect_err("a path that could escape must fail the batch");
2648
2649 assert_eq!(error.code, "INVALID_INPUT", "{error}");
2650 }
2651
2652 #[tokio::test]
2655 async fn a_failed_write_stops_the_ones_behind_it() {
2656 let mut client = MockSandboxDataPlaneApi::new();
2657 settles_running(&mut client, None);
2658 client
2659 .expect_write_file()
2660 .times(1)
2661 .returning(|_, _, path, _| {
2662 assert_eq!(
2663 path, "a.txt",
2664 "the first path in order is the one attempted"
2665 );
2666 Err(AlienError::new(ClientErrorData::RemoteAccessDenied {
2667 resource_type: "sandbox".to_string(),
2668 resource_name: "s1".to_string(),
2669 }))
2670 });
2671
2672 let error = sandbox_with(client)
2673 .write_files(
2674 "s1",
2675 BTreeMap::from([
2676 ("a.txt".to_string(), vec![1u8]),
2677 ("b.txt".to_string(), vec![2u8]),
2678 ]),
2679 )
2680 .await
2681 .expect_err("a refused write must fail the call");
2682
2683 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
2684 }
2685
2686 #[tokio::test]
2691 async fn only_the_operations_that_are_safe_to_repeat_are_marked_retryable() {
2692 let mut client = MockSandboxDataPlaneApi::new();
2693 client.expect_read_file().times(1).returning(|_, _, _| {
2694 Err(AlienError::new(ClientErrorData::RemoteResourceNotFound {
2695 resource_type: "file".to_string(),
2696 resource_name: "missing.txt".to_string(),
2697 }))
2698 });
2699 let refused = sandbox_with(client)
2700 .read_file("s1", "missing.txt")
2701 .await
2702 .expect_err("a missing file is an error");
2703 assert_eq!(refused.code, "SANDBOX_COMMAND_FAILED", "{refused}");
2704 assert!(
2705 !refused.retryable,
2706 "repeating a refusal repeats it: {refused}"
2707 );
2708
2709 let mut client = MockSandboxDataPlaneApi::new();
2710 client.expect_read_file().times(1).returning(|_, _, _| {
2711 Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
2712 message: "the data plane is unavailable".to_string(),
2713 }))
2714 });
2715 let unreachable = sandbox_with(client)
2716 .read_file("s1", "app.py")
2717 .await
2718 .expect_err("an unavailable data plane is an error");
2719 assert_eq!(unreachable.code, "SANDBOX_UNREACHABLE", "{unreachable}");
2720 assert!(
2721 unreachable.retryable,
2722 "a read is safe to repeat: {unreachable}"
2723 );
2724
2725 let mut client = MockSandboxDataPlaneApi::new();
2726 settles_running(&mut client, None);
2727 client
2728 .expect_execute_shell_command()
2729 .times(1)
2730 .returning(|_, _, _, _| {
2731 Err(AlienError::new(ClientErrorData::RemoteServiceUnavailable {
2732 message: "the data plane is unavailable".to_string(),
2733 }))
2734 });
2735 let command = match sandbox_with(client).run_command("s1", command(5)).await {
2736 Ok(_) => panic!("an unavailable data plane is an error"),
2737 Err(error) => error,
2738 };
2739 assert_eq!(command.code, "SANDBOX_OUTCOME_UNKNOWN", "{command}");
2740 assert!(
2741 !command.retryable,
2742 "the command may already be running, so a retry would run it twice: {command}"
2743 );
2744 }
2745
2746 #[tokio::test]
2751 async fn a_sandbox_reports_the_state_the_data_plane_gave_it() {
2752 for (reported, expected) in [
2753 ("Running", SandboxState::Running),
2754 ("Creating", SandboxState::Starting),
2755 ("Resuming", SandboxState::Starting),
2756 ("Stopping", SandboxState::Paused),
2758 ("Stopped", SandboxState::Paused),
2759 ("Suspended", SandboxState::Paused),
2760 ("Idle", SandboxState::Paused),
2761 ("Deleting", SandboxState::Terminated),
2762 ] {
2763 let mut client = MockSandboxDataPlaneApi::new();
2764 let state = reported.to_string();
2765 client.expect_get_sandbox().times(1).returning(move |_, _| {
2766 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2767 id: "s1".to_string(),
2768 egress_policy: None,
2769 state: Some(state.clone()),
2770 })
2771 });
2772
2773 let sandbox = sandbox_with(client)
2774 .get("s1")
2775 .await
2776 .unwrap_or_else(|error| panic!("{reported}: {error}"))
2777 .unwrap_or_else(|| panic!("{reported}: the sandbox exists"));
2778
2779 assert_eq!(sandbox.state, expected, "state {reported}");
2780 }
2781 }
2782
2783 #[tokio::test]
2786 async fn an_unknown_state_is_an_error_rather_than_a_guess() {
2787 for reported in [Some("Hibernated"), None] {
2788 let mut client = MockSandboxDataPlaneApi::new();
2789 let state = reported.map(str::to_string);
2790 client.expect_get_sandbox().times(1).returning(move |_, _| {
2791 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2792 id: "s1".to_string(),
2793 egress_policy: None,
2794 state: state.clone(),
2795 })
2796 });
2797
2798 let error = sandbox_with(client)
2799 .get("s1")
2800 .await
2801 .expect_err("an unreadable state must not become a sandbox");
2802
2803 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
2804 }
2805 }
2806
2807 #[tokio::test]
2810 async fn the_declared_variables_reach_the_create_call() {
2811 let mut client = MockSandboxDataPlaneApi::new();
2812 client
2813 .expect_create_sandbox()
2814 .withf(|_, request| request.environment.get("TOKEN").map(String::as_str) == Some("t"))
2815 .times(1)
2816 .returning(|_, _| {
2817 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2818 id: "s1".to_string(),
2819 egress_policy: None,
2820 state: Some("Creating".to_string()),
2821 })
2822 });
2823
2824 client
2828 .expect_get_sandbox()
2829 .times(1)
2830 .returning(|_, _| Ok(running("s1", None)));
2831
2832 let sandbox = sandbox_with(client)
2833 .create(CreateSandboxRequest {
2834 sandbox_id: None,
2835 tenant_key: None,
2836 env: BTreeMap::from([("TOKEN".to_string(), "t".to_string())]),
2837 ..Default::default()
2838 })
2839 .await
2840 .expect("the create should succeed");
2841
2842 assert_eq!(sandbox.state, SandboxState::Running);
2843 }
2844
2845 fn running(
2846 id: &str,
2847 egress: Option<EgressPolicy>,
2848 ) -> alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2849 alien_azure_clients::azure::sandbox_data_plane::Sandbox {
2850 id: id.to_string(),
2851 egress_policy: egress,
2852 state: Some("Running".to_string()),
2853 }
2854 }
2855
2856 fn sandbox_denying(client: MockSandboxDataPlaneApi, egress: SandboxEgress) -> AzureSandbox {
2857 AzureSandbox::new(
2858 std::sync::Arc::new(client),
2859 "grp".to_string(),
2860 "ubuntu".to_string(),
2861 egress,
2862 None,
2863 "1000m".to_string(),
2864 "2048Mi".to_string(),
2865 None,
2866 )
2867 }
2868
2869 #[tokio::test]
2876 async fn each_declared_mode_is_created_with_the_policy_that_realises_it() {
2877 let mut client = MockSandboxDataPlaneApi::new();
2878 client
2879 .expect_create_sandbox()
2880 .times(1)
2881 .returning(|_, request| {
2882 let policy = request.egress.expect("deny must send a policy");
2883 assert_eq!(policy.default_action, "Deny");
2884 assert_eq!(
2885 policy.traffic_inspection.as_deref(),
2886 Some("Full"),
2887 "only Full inspection blocks non-HTTP traffic"
2888 );
2889 assert_eq!(
2890 policy.host_rules,
2891 vec![EgressHostRule {
2892 pattern: "*".to_string(),
2893 action: "Deny".to_string(),
2894 }],
2895 "deny is written as a rule too, so it does not rest on how the proxy treats a \
2896 policy with no rules"
2897 );
2898 Ok(running("s1", Some(policy)))
2899 });
2900 settles_running(
2901 &mut client,
2902 Some(EgressPolicy {
2903 default_action: "Deny".to_string(),
2904 host_rules: vec![EgressHostRule {
2905 pattern: "*".to_string(),
2906 action: "Deny".to_string(),
2907 }],
2908 rules: Vec::new(),
2909 unmodelled: Default::default(),
2910 traffic_inspection: Some("Full".to_string()),
2911 }),
2912 );
2913 sandbox_denying(client, SandboxEgress::Deny)
2914 .create(CreateSandboxRequest::default())
2915 .await
2916 .expect("deny should create");
2917
2918 let mut client = MockSandboxDataPlaneApi::new();
2919 client
2920 .expect_create_sandbox()
2921 .times(1)
2922 .returning(|_, request| {
2923 let policy = request.egress.expect("allowDomains must send a policy");
2924 assert_eq!(policy.default_action, "Deny", "anything unlisted is denied");
2925 assert_eq!(policy.traffic_inspection.as_deref(), Some("Full"));
2926 assert_eq!(
2927 policy.host_rules,
2928 vec![EgressHostRule {
2929 pattern: "api.example.com".to_string(),
2930 action: "Allow".to_string(),
2931 }]
2932 );
2933 Ok(running("s1", Some(policy)))
2934 });
2935 settles_running(
2936 &mut client,
2937 Some(EgressPolicy {
2938 default_action: "Deny".to_string(),
2939 host_rules: vec![EgressHostRule {
2940 pattern: "api.example.com".to_string(),
2941 action: "Allow".to_string(),
2942 }],
2943 rules: Vec::new(),
2944 unmodelled: Default::default(),
2945 traffic_inspection: Some("Full".to_string()),
2946 }),
2947 );
2948 sandbox_denying(
2949 client,
2950 SandboxEgress::AllowDomains {
2951 domains: vec!["api.example.com".to_string()],
2952 },
2953 )
2954 .create(CreateSandboxRequest::default())
2955 .await
2956 .expect("allowDomains should create");
2957
2958 let mut client = MockSandboxDataPlaneApi::new();
2959 client
2960 .expect_create_sandbox()
2961 .times(1)
2962 .returning(|_, request| {
2963 assert!(
2964 request.egress.is_none(),
2965 "an open sandbox sends no policy: Full inspection would block non-HTTP traffic"
2966 );
2967 Ok(running("s1", None))
2968 });
2969 settles_running(&mut client, None);
2970 sandbox_denying(client, SandboxEgress::Allow)
2971 .create(CreateSandboxRequest::default())
2972 .await
2973 .expect("allow should create");
2974 }
2975
2976 #[tokio::test]
2979 async fn a_sandbox_that_came_up_without_its_policy_is_deleted_rather_than_handed_back() {
2980 for came_up_with in [
2981 None,
2982 Some(EgressPolicy {
2984 default_action: "Deny".to_string(),
2985 unmodelled: Default::default(),
2986 rules: Vec::new(),
2987 host_rules: Vec::new(),
2988 traffic_inspection: Some("Partial".to_string()),
2989 }),
2990 Some(EgressPolicy {
2992 default_action: "Allow".to_string(),
2993 unmodelled: Default::default(),
2994 rules: Vec::new(),
2995 host_rules: Vec::new(),
2996 traffic_inspection: Some("Full".to_string()),
2997 }),
2998 ] {
2999 let mut client = MockSandboxDataPlaneApi::new();
3000 let effective = came_up_with.clone();
3001 client
3002 .expect_create_sandbox()
3003 .times(1)
3004 .returning(move |_, _| Ok(running("s1", effective.clone())));
3005 settles_running(&mut client, came_up_with.clone());
3006 client
3007 .expect_delete_sandbox()
3008 .withf(|_, id| id == "s1")
3009 .times(1)
3010 .returning(|_, _| Ok(()));
3011
3012 let error = sandbox_denying(client, SandboxEgress::Deny)
3013 .create(CreateSandboxRequest::default())
3014 .await
3015 .expect_err("a sandbox without its policy must not be handed back");
3016
3017 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3018 }
3019 }
3020
3021 #[tokio::test]
3025 async fn a_missing_host_rule_fails_the_create() {
3026 let mut client = MockSandboxDataPlaneApi::new();
3027 let elsewhere = EgressPolicy {
3028 default_action: "Deny".to_string(),
3029 unmodelled: Default::default(),
3030 rules: Vec::new(),
3031 host_rules: vec![EgressHostRule {
3032 pattern: "elsewhere.example.com".to_string(),
3033 action: "Allow".to_string(),
3034 }],
3035 traffic_inspection: Some("Full".to_string()),
3036 };
3037 let echoed = elsewhere.clone();
3038 client
3039 .expect_create_sandbox()
3040 .times(1)
3041 .returning(move |_, _| Ok(running("s1", Some(echoed.clone()))));
3042 settles_running(&mut client, Some(elsewhere));
3043 client
3044 .expect_delete_sandbox()
3045 .times(1)
3046 .returning(|_, _| Ok(()));
3047
3048 let error = sandbox_denying(
3049 client,
3050 SandboxEgress::AllowDomains {
3051 domains: vec!["api.example.com".to_string()],
3052 },
3053 )
3054 .create(CreateSandboxRequest::default())
3055 .await
3056 .expect_err("a host the declaration named must be in the effective policy");
3057
3058 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3059 }
3060
3061 #[tokio::test]
3066 async fn a_terminated_sandbox_is_replaced_rather_than_reconnected_to() {
3067 let mut client = MockSandboxDataPlaneApi::new();
3068 client.expect_get_sandbox().times(1).returning(|_, id| {
3069 Ok(running(id, None)).map(
3070 |mut sandbox: alien_azure_clients::azure::sandbox_data_plane::Sandbox| {
3071 sandbox.state = Some("Deleting".to_string());
3072 sandbox
3073 },
3074 )
3075 });
3076 client
3077 .expect_create_sandbox()
3078 .times(1)
3079 .returning(|_, request| Ok(running("fresh", request.egress)));
3080 settles_running(
3081 &mut client,
3082 Some(EgressPolicy {
3083 default_action: "Deny".to_string(),
3084 host_rules: vec![EgressHostRule {
3085 pattern: "*".to_string(),
3086 action: "Deny".to_string(),
3087 }],
3088 rules: Vec::new(),
3089 unmodelled: Default::default(),
3090 traffic_inspection: Some("Full".to_string()),
3091 }),
3092 );
3093
3094 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3097 .get_or_create(CreateSandboxRequest {
3098 sandbox_id: Some("going-away".to_string()),
3099 tenant_key: None,
3100 env: BTreeMap::new(),
3101 ..Default::default()
3102 })
3103 .await
3104 .expect("a new sandbox should be created");
3105
3106 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3107 assert!(sandbox.created, "a replacement is a sandbox this call made");
3108 }
3109
3110 #[tokio::test]
3116 async fn a_permission_nobody_asked_for_fails_the_create() {
3117 let asked_for = || SandboxEgress::AllowDomains {
3118 domains: vec!["api.example.com".to_string()],
3119 };
3120 let declared = EgressHostRule {
3121 pattern: "api.example.com".to_string(),
3122 action: "Allow".to_string(),
3123 };
3124
3125 for came_up_with in [
3126 EgressPolicy {
3128 default_action: "Deny".to_string(),
3129 unmodelled: Default::default(),
3130 host_rules: vec![
3131 declared.clone(),
3132 EgressHostRule {
3133 pattern: "exfil.example.com".to_string(),
3134 action: "Allow".to_string(),
3135 },
3136 ],
3137 rules: Vec::new(),
3138 traffic_inspection: Some("Full".to_string()),
3139 },
3140 EgressPolicy {
3142 default_action: "Deny".to_string(),
3143 unmodelled: Default::default(),
3144 host_rules: vec![declared.clone()],
3145 rules: vec![EgressRule {
3146 name: None,
3147 r#match: Some(EgressRuleMatch {
3148 host: "*".to_string(),
3149 path: None,
3150 methods: None,
3151 }),
3152 action: Some(EgressRuleAction {
3153 action_type: "Allow".to_string(),
3154 host: None,
3155 path: None,
3156 scheme: None,
3157 headers: None,
3158 }),
3159 }],
3160 traffic_inspection: Some("Full".to_string()),
3161 },
3162 ] {
3163 let mut client = MockSandboxDataPlaneApi::new();
3164 let effective = came_up_with.clone();
3165 client
3166 .expect_create_sandbox()
3167 .times(1)
3168 .returning(move |_, _| Ok(running("s1", Some(effective.clone()))));
3169 settles_running(&mut client, Some(came_up_with.clone()));
3170 client
3171 .expect_delete_sandbox()
3172 .times(1)
3173 .returning(|_, _| Ok(()));
3174
3175 let error = sandbox_denying(client, asked_for())
3176 .create(CreateSandboxRequest::default())
3177 .await
3178 .expect_err("a permission nobody asked for must fail the create");
3179
3180 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3181 }
3182
3183 let mut client = MockSandboxDataPlaneApi::new();
3186 client
3187 .expect_create_sandbox()
3188 .times(1)
3189 .returning(move |_, _| {
3190 Ok(running(
3191 "s1",
3192 Some(EgressPolicy {
3193 default_action: "Deny".to_string(),
3194 unmodelled: Default::default(),
3195 host_rules: vec![EgressHostRule {
3196 pattern: "api.example.com".to_string(),
3197 action: "Allow".to_string(),
3198 }],
3199 rules: Vec::new(),
3200 traffic_inspection: Some("Full".to_string()),
3201 }),
3202 ))
3203 });
3204 settles_running(
3205 &mut client,
3206 Some(EgressPolicy {
3207 default_action: "Deny".to_string(),
3208 unmodelled: Default::default(),
3209 host_rules: vec![EgressHostRule {
3210 pattern: "api.example.com".to_string(),
3211 action: "Allow".to_string(),
3212 }],
3213 rules: Vec::new(),
3214 traffic_inspection: Some("Full".to_string()),
3215 }),
3216 );
3217 sandbox_denying(client, asked_for())
3218 .create(CreateSandboxRequest::default())
3219 .await
3220 .expect("the policy that was asked for should create");
3221 }
3222
3223 #[tokio::test]
3229 async fn pause_and_resume_reach_their_own_verbs() {
3230 let mut client = MockSandboxDataPlaneApi::new();
3231 client
3232 .expect_stop_sandbox()
3233 .withf(|group, id| group == "grp" && id == "s1")
3234 .times(1)
3235 .returning(|_, _| Ok(()));
3236 client.expect_resume_sandbox().never();
3237 sandbox_with(client)
3238 .pause("s1")
3239 .await
3240 .expect("pause should be accepted");
3241
3242 let mut client = MockSandboxDataPlaneApi::new();
3245 let mut reads = 0;
3246 client.expect_get_sandbox().returning(move |_, id| {
3247 reads += 1;
3248 let mut sandbox = running(id, None);
3249 if reads < 3 {
3250 sandbox.state = Some("Stopped".to_string());
3251 }
3252 Ok(sandbox)
3253 });
3254 client
3255 .expect_resume_sandbox()
3256 .withf(|group, id| group == "grp" && id == "s1")
3257 .times(1)
3258 .returning(|_, _| Ok(()));
3259 client.expect_stop_sandbox().never();
3260 sandbox_with(client)
3261 .resume("s1")
3262 .await
3263 .expect("resume should reach a running sandbox");
3264 }
3265
3266 #[tokio::test]
3269 async fn pause_owns_a_lost_stop_when_the_sandbox_comes_back_suspended() {
3270 let mut client = MockSandboxDataPlaneApi::new();
3272 client
3273 .expect_stop_sandbox()
3274 .times(1)
3275 .returning(|_, _| Err(http_error(503, "gateway timeout")));
3276 client.expect_get_sandbox().returning(|_, id| {
3277 let mut sandbox = running(id, None);
3278 sandbox.state = Some("Stopped".to_string());
3279 Ok(sandbox)
3280 });
3281 sandbox_with(client)
3282 .pause("s1")
3283 .await
3284 .expect("a stop that landed is success even when its response was lost");
3285
3286 let mut client = MockSandboxDataPlaneApi::new();
3288 client
3289 .expect_stop_sandbox()
3290 .times(1)
3291 .returning(|_, _| Err(http_error(503, "gateway timeout")));
3292 client
3293 .expect_get_sandbox()
3294 .returning(|_, id| Ok(running(id, None)));
3295 sandbox_with(client)
3296 .pause("s1")
3297 .await
3298 .expect_err("a stop that did not land must surface the failure");
3299 }
3300
3301 #[tokio::test]
3307 async fn a_declared_idle_suspend_reaches_the_create_call() {
3308 let mut client = MockSandboxDataPlaneApi::new();
3309 client
3310 .expect_create_sandbox()
3311 .withf(|_, request| request.idle_pause_seconds == Some(900))
3312 .times(1)
3313 .returning(|_, _| Ok(running("s1", None)));
3314 settles_running(&mut client, None);
3315
3316 AzureSandbox::new(
3317 std::sync::Arc::new(client),
3318 "grp".to_string(),
3319 "ubuntu".to_string(),
3320 SandboxEgress::Allow,
3321 Some(900),
3322 "1000m".to_string(),
3323 "2048Mi".to_string(),
3324 None,
3325 )
3326 .create(CreateSandboxRequest::default())
3327 .await
3328 .expect("the create should succeed");
3329 }
3330
3331 #[tokio::test]
3337 async fn a_reconnect_to_a_sandbox_built_under_another_policy_is_refused() {
3338 let mut client = MockSandboxDataPlaneApi::new();
3339 client.expect_get_sandbox().times(1).returning(|_, id| {
3340 Ok(running(id, None))
3342 });
3343
3344 let error = sandbox_denying(client, SandboxEgress::Deny)
3345 .get("built-under-allow")
3346 .await
3347 .expect_err("a sandbox without the declared policy must not be handed back");
3348
3349 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3350 }
3351
3352 #[tokio::test]
3357 async fn a_create_that_cannot_be_read_deletes_what_it_made() {
3358 let mut client = MockSandboxDataPlaneApi::new();
3359 let unreadable = || {
3360 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
3361 id: "orphan".to_string(),
3362 egress_policy: None,
3363 state: Some("Hibernated".to_string()),
3364 })
3365 };
3366 client
3367 .expect_create_sandbox()
3368 .times(1)
3369 .returning(move |_, _| unreadable());
3370 client
3371 .expect_get_sandbox()
3372 .returning(move |_, _| unreadable());
3373 client
3374 .expect_delete_sandbox()
3375 .withf(|_, id| id == "orphan")
3376 .times(1)
3377 .returning(|_, _| Ok(()));
3378
3379 let error = sandbox_with(client)
3380 .create(CreateSandboxRequest::default())
3381 .await
3382 .expect_err("an unreadable state must fail the create");
3383
3384 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3385 }
3386
3387 #[tokio::test]
3389 async fn a_policy_this_client_cannot_read_whole_fails_the_create() {
3390 let declared = || SandboxEgress::Deny;
3391 let catch_all = EgressHostRule {
3392 pattern: "*".to_string(),
3393 action: "Deny".to_string(),
3394 };
3395
3396 for came_up_with in [
3397 EgressPolicy {
3400 default_action: "Deny".to_string(),
3401 host_rules: vec![
3402 catch_all.clone(),
3403 EgressHostRule {
3404 pattern: "api.example.com".to_string(),
3405 action: "Transform".to_string(),
3406 },
3407 ],
3408 rules: Vec::new(),
3409 unmodelled: Default::default(),
3410 traffic_inspection: Some("Full".to_string()),
3411 },
3412 EgressPolicy {
3414 default_action: "Deny".to_string(),
3415 host_rules: vec![catch_all.clone()],
3416 rules: Vec::new(),
3417 unmodelled: BTreeMap::from([(
3418 "bypassList".to_string(),
3419 serde_json::json!(["exfil.example.com"]),
3420 )]),
3421 traffic_inspection: Some("Full".to_string()),
3422 },
3423 ] {
3424 let mut client = MockSandboxDataPlaneApi::new();
3425 let effective = came_up_with.clone();
3426 client
3427 .expect_create_sandbox()
3428 .times(1)
3429 .returning(move |_, _| Ok(running("s1", Some(effective.clone()))));
3430 settles_running(&mut client, Some(came_up_with.clone()));
3431 client
3432 .expect_delete_sandbox()
3433 .times(1)
3434 .returning(|_, _| Ok(()));
3435
3436 let error = sandbox_denying(client, declared())
3437 .create(CreateSandboxRequest::default())
3438 .await
3439 .expect_err("a policy this client cannot read whole must fail the create");
3440
3441 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3442 }
3443
3444 let mut client = MockSandboxDataPlaneApi::new();
3446 client.expect_create_sandbox().times(1).returning(|_, _| {
3447 Ok(running(
3448 "s1",
3449 Some(EgressPolicy {
3450 default_action: "deny".to_string(),
3451 host_rules: vec![EgressHostRule {
3452 pattern: "*".to_string(),
3453 action: "deny".to_string(),
3454 }],
3455 rules: Vec::new(),
3456 unmodelled: Default::default(),
3457 traffic_inspection: Some("full".to_string()),
3458 }),
3459 ))
3460 });
3461 settles_running(
3462 &mut client,
3463 Some(EgressPolicy {
3464 default_action: "deny".to_string(),
3465 host_rules: vec![EgressHostRule {
3466 pattern: "*".to_string(),
3467 action: "deny".to_string(),
3468 }],
3469 rules: Vec::new(),
3470 unmodelled: Default::default(),
3471 traffic_inspection: Some("full".to_string()),
3472 }),
3473 );
3474 sandbox_denying(client, declared())
3475 .create(CreateSandboxRequest::default())
3476 .await
3477 .expect("a normalised echo of the same policy is the same policy");
3478 }
3479
3480 #[tokio::test]
3486 async fn a_stale_policy_sandbox_is_replaced_rather_than_refused_forever() {
3487 let mut client = MockSandboxDataPlaneApi::new();
3488 client.expect_get_sandbox().returning(move |_, id| {
3491 if id == "built-under-allow" {
3492 return Ok(running(id, None));
3493 }
3494 Ok(running(
3495 id,
3496 Some(EgressPolicy {
3497 default_action: "Deny".to_string(),
3498 host_rules: vec![EgressHostRule {
3499 pattern: "*".to_string(),
3500 action: "Deny".to_string(),
3501 }],
3502 rules: Vec::new(),
3503 unmodelled: Default::default(),
3504 traffic_inspection: Some("Full".to_string()),
3505 }),
3506 ))
3507 });
3508 client.expect_delete_sandbox().never();
3509 client
3510 .expect_create_sandbox()
3511 .times(1)
3512 .returning(|_, request| Ok(running("fresh", request.egress)));
3513
3514 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3515 .get_or_create(CreateSandboxRequest {
3516 sandbox_id: Some("built-under-allow".to_string()),
3517 tenant_key: None,
3518 env: BTreeMap::new(),
3519 ..Default::default()
3520 })
3521 .await
3522 .expect("a stale sandbox is replaced");
3523
3524 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3525 assert!(sandbox.created, "a replacement is a sandbox this call made");
3526 }
3527
3528 #[tokio::test]
3531 async fn a_traversing_sandbox_id_never_reaches_the_data_plane() {
3532 let mut client = MockSandboxDataPlaneApi::new();
3533 client.expect_get_sandbox().never();
3534 client.expect_delete_sandbox().never();
3535 client.expect_execute_shell_command().never();
3536 let sandbox = sandbox_with(client);
3537
3538 for id in ["../../other-group/sandboxes/theirs", "a/b", "", "has space"] {
3539 assert_eq!(
3540 sandbox
3541 .get(id)
3542 .await
3543 .expect_err(&format!("'{id}' must be refused"))
3544 .code,
3545 "INVALID_INPUT"
3546 );
3547 sandbox
3548 .terminate(id)
3549 .await
3550 .expect_err(&format!("'{id}' must be refused on every verb"));
3551 }
3552 }
3553
3554 #[tokio::test]
3559 async fn a_stale_policy_sandbox_cannot_run_a_command() {
3560 let mut client = MockSandboxDataPlaneApi::new();
3561 client
3562 .expect_get_sandbox()
3563 .times(1)
3564 .returning(|_, id| Ok(running(id, None)));
3565 client.expect_delete_sandbox().never();
3568 client.expect_execute_shell_command().never();
3569
3570 let error = match sandbox_denying(client, SandboxEgress::Deny)
3571 .run_command("built-under-allow", command(5))
3572 .await
3573 {
3574 Ok(_) => panic!("a sandbox without the declared policy must not run code"),
3575 Err(error) => error,
3576 };
3577
3578 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3579 }
3580
3581 #[tokio::test]
3587 async fn a_policy_that_changed_during_suspension_is_caught_on_reconnect() {
3588 let declared = EgressPolicy {
3589 default_action: "Deny".to_string(),
3590 host_rules: vec![EgressHostRule {
3591 pattern: "*".to_string(),
3592 action: "Deny".to_string(),
3593 }],
3594 rules: Vec::new(),
3595 unmodelled: Default::default(),
3596 traffic_inspection: Some("Full".to_string()),
3597 };
3598
3599 let mut client = MockSandboxDataPlaneApi::new();
3600 let mut reads = 0;
3601 let stopped = declared.clone();
3602 client.expect_get_sandbox().returning(move |_, id| {
3603 if id != "was-suspended" {
3605 return Ok(running(id, Some(stopped.clone())));
3606 }
3607 reads += 1;
3608 Ok(match reads {
3609 1 | 2 => {
3612 let mut sandbox = running(id, Some(stopped.clone()));
3613 sandbox.state = Some("Stopped".to_string());
3614 sandbox
3615 }
3616 _ => running(
3618 id,
3619 Some(EgressPolicy {
3620 host_rules: vec![
3621 EgressHostRule {
3622 pattern: "*".to_string(),
3623 action: "Deny".to_string(),
3624 },
3625 EgressHostRule {
3626 pattern: "exfil.example.com".to_string(),
3627 action: "Allow".to_string(),
3628 },
3629 ],
3630 ..stopped.clone()
3631 }),
3632 ),
3633 })
3634 });
3635 client.expect_resume_sandbox().returning(|_, _| Ok(()));
3638 client
3639 .expect_stop_sandbox()
3640 .withf(|_, id| id == "was-suspended")
3641 .times(1)
3642 .returning(|_, _| Ok(()));
3643 client.expect_delete_sandbox().never();
3644 client
3645 .expect_create_sandbox()
3646 .times(1)
3647 .returning(|_, request| Ok(running("fresh", request.egress)));
3648
3649 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3650 .get_or_create(CreateSandboxRequest {
3651 sandbox_id: Some("was-suspended".to_string()),
3652 tenant_key: None,
3653 env: BTreeMap::new(),
3654 ..Default::default()
3655 })
3656 .await
3657 .expect("a caller asking for a sandbox gets a usable one");
3658
3659 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
3663 assert!(sandbox.created, "a replacement is a sandbox this call made");
3664 }
3665
3666 #[tokio::test]
3672 async fn a_sandbox_left_behind_does_not_publish_the_response_body() {
3673 const SECRET: &str = "tenant-only-detail";
3674
3675 let mut client = MockSandboxDataPlaneApi::new();
3676 client
3677 .expect_create_sandbox()
3678 .times(1)
3679 .returning(|_, _| Ok(running("s1", None)));
3680 client
3683 .expect_get_sandbox()
3684 .returning(|_, _| Err(http_error(403, SECRET)));
3685 client
3686 .expect_delete_sandbox()
3687 .returning(|_, _| Err(http_error(403, SECRET)));
3688
3689 let error = sandbox_with(client)
3690 .create(CreateSandboxRequest::default())
3691 .await
3692 .expect_err("a create that cannot be confirmed must fail");
3693
3694 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
3695 assert!(
3696 error.internal,
3697 "the wrapper must inherit the cloud error's visibility: {error}"
3698 );
3699 }
3700
3701 #[tokio::test]
3705 async fn a_stale_policy_sandbox_cannot_be_resumed() {
3706 let mut client = MockSandboxDataPlaneApi::new();
3707 let mut reads = 0;
3709 client.expect_get_sandbox().returning(move |_, id| {
3710 reads += 1;
3711 let mut sandbox = running(id, None);
3712 if reads <= 2 {
3715 sandbox.state = Some("Stopped".to_string());
3716 }
3717 Ok(sandbox)
3718 });
3719 client.expect_resume_sandbox().returning(|_, _| Ok(()));
3720 client.expect_delete_sandbox().never();
3723 client
3724 .expect_stop_sandbox()
3725 .times(1)
3726 .returning(|_, _| Ok(()));
3727
3728 let error = sandbox_denying(client, SandboxEgress::Deny)
3729 .resume("built-under-allow")
3730 .await
3731 .expect_err("a sandbox without the declared policy must not be woken");
3732
3733 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3734 }
3735
3736 #[tokio::test]
3742 async fn a_sandbox_this_call_did_not_wake_is_left_running() {
3743 let mut client = MockSandboxDataPlaneApi::new();
3744 client
3745 .expect_get_sandbox()
3746 .returning(|_, id| Ok(running(id, None)));
3747 client.expect_resume_sandbox().never();
3748 client.expect_stop_sandbox().never();
3749 client.expect_delete_sandbox().never();
3750
3751 let error = sandbox_denying(client, SandboxEgress::Deny)
3752 .resume("someone-elses-sandbox")
3753 .await
3754 .expect_err("a sandbox without the declared policy must not be handed back");
3755
3756 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3757 }
3758
3759 #[tokio::test]
3765 async fn a_sandbox_that_came_up_on_its_own_is_not_suspended() {
3766 let mut client = MockSandboxDataPlaneApi::new();
3767 let mut reads = 0;
3768 client.expect_get_sandbox().returning(move |_, id| {
3769 reads += 1;
3770 let mut sandbox = running(id, None);
3771 if reads <= 2 {
3772 sandbox.state = Some("Creating".to_string());
3773 }
3774 Ok(sandbox)
3775 });
3776 client.expect_resume_sandbox().never();
3777 client.expect_stop_sandbox().never();
3778 client.expect_delete_sandbox().never();
3779
3780 let error = sandbox_denying(client, SandboxEgress::Deny)
3781 .resume("created-by-another-revision")
3782 .await
3783 .expect_err("a sandbox without the declared policy must not be handed back");
3784
3785 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3786 }
3787
3788 #[tokio::test]
3793 async fn a_suspended_sandbox_reporting_no_policy_is_not_a_mismatch() {
3794 let mut client = MockSandboxDataPlaneApi::new();
3795 client.expect_get_sandbox().times(1).returning(|_, id| {
3796 let mut sandbox = running(id, None);
3797 sandbox.state = Some("Stopped".to_string());
3798 Ok(sandbox)
3799 });
3800
3801 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
3802 .get("asleep")
3803 .await
3804 .expect("a sleeping sandbox must still be readable")
3805 .expect("the sandbox exists");
3806
3807 assert_eq!(sandbox.state, SandboxState::Paused);
3808 }
3809
3810 #[tokio::test]
3815 async fn a_sleeping_sandbox_with_a_wrong_policy_is_never_woken() {
3816 let mut client = MockSandboxDataPlaneApi::new();
3817 client.expect_get_sandbox().times(1).returning(|_, id| {
3818 let mut sandbox = running(
3819 id,
3820 Some(EgressPolicy {
3821 default_action: "Allow".to_string(),
3822 host_rules: Vec::new(),
3823 rules: Vec::new(),
3824 unmodelled: Default::default(),
3825 traffic_inspection: Some("Full".to_string()),
3826 }),
3827 );
3828 sandbox.state = Some("Stopped".to_string());
3829 Ok(sandbox)
3830 });
3831 client.expect_resume_sandbox().never();
3832 client.expect_stop_sandbox().never();
3833 client.expect_delete_sandbox().never();
3834
3835 let error = sandbox_denying(client, SandboxEgress::Deny)
3836 .resume("built-under-allow")
3837 .await
3838 .expect_err("a stored policy that already fails must not be woken");
3839
3840 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
3841 }
3842
3843 #[tokio::test]
3848 async fn a_sandbox_woken_by_a_wait_that_then_failed_is_put_back() {
3849 let mut client = MockSandboxDataPlaneApi::new();
3850 let mut reads = 0;
3851 client.expect_get_sandbox().returning(move |_, id| {
3852 reads += 1;
3853 let mut sandbox = running(id, None);
3854 sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string());
3856 Ok(sandbox)
3857 });
3858 client
3859 .expect_resume_sandbox()
3860 .times(1)
3861 .returning(|_, _| Ok(()));
3862 client
3863 .expect_stop_sandbox()
3864 .times(1)
3865 .returning(|_, _| Ok(()));
3866
3867 let error = sandbox_denying(client, SandboxEgress::Deny)
3868 .resume("wakes-then-breaks")
3869 .await
3870 .expect_err("a wait that cannot finish must not report a resumed sandbox");
3871
3872 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3873 }
3874
3875 #[tokio::test]
3881 async fn a_sandbox_woken_by_a_failed_reconnect_is_put_back() {
3882 let mut client = MockSandboxDataPlaneApi::new();
3883 let mut reads = 0;
3884 client.expect_get_sandbox().returning(move |_, id| {
3885 reads += 1;
3886 let mut sandbox = running(id, None);
3887 sandbox.state = Some(if reads <= 2 { "Stopped" } else { "Hibernated" }.to_string());
3888 Ok(sandbox)
3889 });
3890 client
3891 .expect_resume_sandbox()
3892 .times(1)
3893 .returning(|_, _| Ok(()));
3894 client
3895 .expect_stop_sandbox()
3896 .withf(|_, id| id == "woken-then-unreadable")
3897 .times(1)
3898 .returning(|_, _| Ok(()));
3899 client.expect_delete_sandbox().never();
3900
3901 let error = sandbox_with(client)
3902 .get_or_create(CreateSandboxRequest {
3903 sandbox_id: Some("woken-then-unreadable".to_string()),
3904 tenant_key: None,
3905 env: BTreeMap::new(),
3906 ..Default::default()
3907 })
3908 .await
3909 .expect_err("a state this client cannot read is not a sandbox");
3910
3911 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
3912 }
3913
3914 #[tokio::test]
3921 async fn the_program_leads_its_arguments_inside_the_wrapper() {
3922 let mut client = MockSandboxDataPlaneApi::new();
3923 client
3924 .expect_get_sandbox()
3925 .returning(|_, id| Ok(running(id, None)));
3926 client
3927 .expect_execute_shell_command()
3928 .times(1)
3929 .withf(|_, _, shell, cwd| {
3930 shell.ends_with("' sh 'python' '-u' 'main.py'") && cwd.as_deref() == Some("/work")
3931 })
3932 .returning(|_, _, _, _| {
3933 Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult {
3934 exit_code: Some(0),
3935 stdout: String::new(),
3936 stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(),
3938 })
3939 });
3940
3941 let mut request = command(5);
3942 request.command = "python".to_string();
3943 request.args = vec!["-u".to_string(), "main.py".to_string()];
3944 request.cwd = Some("/work".to_string());
3945
3946 let frames: Vec<Result<CommandOutput>> = sandbox_with(client)
3947 .run_command("s1", request)
3948 .await
3949 .expect("the command runs")
3950 .collect()
3951 .await;
3952
3953 assert!(
3954 matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0),
3955 "the command has to reach its exit: {frames:?}"
3956 );
3957 }
3958
3959 #[tokio::test]
3964 async fn a_declared_variable_reaches_the_command() {
3965 let mut client = MockSandboxDataPlaneApi::new();
3966 client
3967 .expect_get_sandbox()
3968 .returning(|_, id| Ok(running(id, None)));
3969 client
3970 .expect_execute_shell_command()
3971 .times(1)
3972 .withf(|_, _, shell, _| shell.ends_with("' sh 'env' 'TOKEN=t' 'sleep' 'forever'"))
3973 .returning(|_, _, _, _| {
3974 Ok(alien_azure_clients::azure::sandbox_data_plane::ExecResult {
3975 exit_code: Some(0),
3976 stdout: String::new(),
3977 stderr: "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4\n".to_string(),
3979 })
3980 });
3981
3982 let mut request = command(5);
3983 request.env = BTreeMap::from([("TOKEN".to_string(), "t".to_string())]);
3984
3985 let frames: Vec<Result<CommandOutput>> = sandbox_with(client)
3986 .run_command("s1", request)
3987 .await
3988 .expect("a command declaring a variable must run")
3989 .collect()
3990 .await;
3991
3992 assert!(
3993 matches!(frames.last(), Some(Ok(CommandOutput::Exit { code, .. })) if *code == 0),
3994 "the command has to reach its exit: {frames:?}"
3995 );
3996 }
3997
3998 #[tokio::test]
4003 async fn a_command_carrying_an_unusable_variable_name_runs_nothing() {
4004 let mut client = MockSandboxDataPlaneApi::new();
4005 client
4006 .expect_get_sandbox()
4007 .returning(|_, id| Ok(running(id, None)));
4008 client.expect_execute_shell_command().never();
4009
4010 let mut request = command(5);
4011 request.env = BTreeMap::from([("X; curl evil".to_string(), "1".to_string())]);
4012
4013 let error = match sandbox_with(client).run_command("s1", request).await {
4014 Ok(_) => panic!("a name the shell would run must not reach the shell"),
4015 Err(error) => error,
4016 };
4017
4018 assert_eq!(error.code, "INVALID_INPUT", "{error}");
4019 }
4020
4021 #[tokio::test]
4027 async fn a_resume_that_may_have_landed_is_owned() {
4028 let mut client = MockSandboxDataPlaneApi::new();
4029 let mut reads = 0;
4030 client.expect_get_sandbox().returning(move |_, id| {
4031 reads += 1;
4032 let mut sandbox = running(id, None);
4033 if reads <= 2 {
4034 sandbox.state = Some("Stopped".to_string());
4035 }
4036 Ok(sandbox)
4037 });
4038 client
4040 .expect_resume_sandbox()
4041 .returning(|_, _| Err(http_error(503, "GatewayTimeout")));
4042 client
4043 .expect_stop_sandbox()
4044 .times(1)
4045 .returning(|_, _| Ok(()));
4046
4047 let error = sandbox_denying(client, SandboxEgress::Deny)
4048 .resume("woke-or-did-not")
4049 .await
4050 .expect_err("a sandbox that came up uncontained is not a resumed sandbox");
4051
4052 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4053 }
4054
4055 #[tokio::test]
4060 async fn a_refused_resume_leaves_someone_elses_sandbox_alone() {
4061 let mut client = MockSandboxDataPlaneApi::new();
4062 let mut reads = 0;
4063 client.expect_get_sandbox().returning(move |_, id| {
4064 reads += 1;
4065 let mut sandbox = running(id, None);
4066 if reads <= 2 {
4067 sandbox.state = Some("Stopped".to_string());
4068 }
4069 Ok(sandbox)
4070 });
4071 client
4073 .expect_resume_sandbox()
4074 .returning(|_, _| Err(http_error(409, "SandboxNotStopped")));
4075 client.expect_stop_sandbox().never();
4076 client.expect_delete_sandbox().never();
4077
4078 let error = sandbox_denying(client, SandboxEgress::Deny)
4079 .resume("someone-elses-sandbox")
4080 .await
4081 .expect_err("a sandbox without the declared policy must not be handed back");
4082
4083 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4084 }
4085
4086 #[tokio::test]
4092 async fn a_sandbox_that_vanished_is_not_reported_as_left_awake() {
4093 let mut client = MockSandboxDataPlaneApi::new();
4094 let mut reads = 0;
4095 client.expect_get_sandbox().returning(move |_, id| {
4096 reads += 1;
4097 let mut sandbox = running(id, None);
4098 if reads <= 2 {
4099 sandbox.state = Some("Stopped".to_string());
4100 }
4101 Ok(sandbox)
4102 });
4103 client.expect_resume_sandbox().returning(|_, _| Ok(()));
4104 client
4105 .expect_stop_sandbox()
4106 .times(1)
4107 .returning(|_, _| Err(http_error(404, "SandboxNotFound")));
4108
4109 let error = sandbox_denying(client, SandboxEgress::Deny)
4110 .resume("gone-by-then")
4111 .await
4112 .expect_err("the refusal still travels");
4113
4114 assert!(
4115 !error.to_string().contains("sandboxLeftAwake"),
4116 "a sandbox the data plane says is gone was not left awake: {error}"
4117 );
4118 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4119 }
4120
4121 #[tokio::test]
4128 async fn a_sandbox_being_deleted_takes_no_new_work() {
4129 for outcome in ["Deleting", "gone"] {
4130 let mut client = MockSandboxDataPlaneApi::new();
4131 let deleting = outcome == "Deleting";
4132 client.expect_get_sandbox().returning(move |_, id| {
4133 if deleting {
4134 let mut sandbox = running(id, None);
4135 sandbox.state = Some("Deleting".to_string());
4136 Ok(sandbox)
4137 } else {
4138 Err(http_error(404, "SandboxNotFound"))
4139 }
4140 });
4141 client.expect_execute_shell_command().never();
4142 client.expect_resume_sandbox().never();
4143 let sandbox = sandbox_denying(client, SandboxEgress::Deny);
4144
4145 let ran = match sandbox.run_command("on-its-way-out", command(5)).await {
4146 Ok(_) => panic!("{outcome}: a sandbox that cannot take work must not run code"),
4147 Err(error) => error,
4148 };
4149 assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {ran}");
4150
4151 let woken = sandbox
4152 .resume("on-its-way-out")
4153 .await
4154 .expect_err("a sandbox that cannot take work must not be resumed");
4155 assert_eq!(woken.code, "SANDBOX_COMMAND_FAILED", "{outcome}: {woken}");
4156 }
4157 }
4158
4159 #[tokio::test]
4165 async fn an_unaddressable_minted_id_is_reaped_unless_the_id_is_the_hazard() {
4166 let minted = |id: &'static str| {
4167 let mut client = MockSandboxDataPlaneApi::new();
4168 client
4169 .expect_create_sandbox()
4170 .times(1)
4171 .returning(move |_, _| Ok(running(id, None)));
4172 client
4173 };
4174
4175 let mut client = minted("x".repeat(80).leak());
4177 client
4178 .expect_delete_sandbox()
4179 .times(1)
4180 .returning(|_, _| Ok(()));
4181 let error = sandbox_with(client)
4182 .create(CreateSandboxRequest::default())
4183 .await
4184 .expect_err("an id this client will not send must fail the create");
4185 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4186
4187 let mut client = minted("../../other-group/sandboxes/theirs");
4189 client.expect_delete_sandbox().never();
4190 let error = sandbox_with(client)
4191 .create(CreateSandboxRequest::default())
4192 .await
4193 .expect_err("a traversing id must fail the create");
4194 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4195 }
4196
4197 #[tokio::test]
4203 async fn a_sandbox_that_is_still_coming_up_is_not_a_policy_mismatch() {
4204 let mut client = MockSandboxDataPlaneApi::new();
4205 client.expect_get_sandbox().times(1).returning(|_, id| {
4206 let mut sandbox = running(id, None);
4207 sandbox.state = Some("Creating".to_string());
4208 Ok(sandbox)
4209 });
4210 client.expect_delete_sandbox().never();
4211
4212 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4213 .get("still-booting")
4214 .await
4215 .expect("a booting sandbox is not a contained-ness failure")
4216 .expect("the sandbox exists");
4217
4218 assert_eq!(sandbox.state, SandboxState::Starting);
4219 }
4220
4221 #[tokio::test]
4227 async fn a_stale_policy_sandbox_takes_no_written_files() {
4228 let mut client = MockSandboxDataPlaneApi::new();
4229 client
4230 .expect_get_sandbox()
4231 .times(1)
4232 .returning(|_, id| Ok(running(id, None)));
4233 client.expect_delete_sandbox().never();
4234 client.expect_write_file().never();
4235
4236 let error = sandbox_denying(client, SandboxEgress::Deny)
4237 .write_files(
4238 "built-under-allow",
4239 BTreeMap::from([("app.py".to_string(), vec![1u8])]),
4240 )
4241 .await
4242 .expect_err("a sandbox without the declared policy must take no content");
4243
4244 assert_eq!(error.code, "SANDBOX_NOT_AS_DECLARED", "{error}");
4245 }
4246
4247 #[tokio::test]
4253 async fn a_refused_resume_is_tried_again() {
4254 let mut client = MockSandboxDataPlaneApi::new();
4255 let mut reads = 0;
4256 client.expect_get_sandbox().returning(move |_, id| {
4257 reads += 1;
4258 let mut sandbox = running(id, None);
4259 sandbox.state = Some(
4261 match reads {
4262 1 => "Stopping",
4263 2 | 3 => "Stopped",
4264 _ => "Running",
4265 }
4266 .to_string(),
4267 );
4268 Ok(sandbox)
4269 });
4270
4271 let mut attempts = 0;
4272 client
4273 .expect_resume_sandbox()
4274 .times(2)
4275 .returning(move |_, _| {
4276 attempts += 1;
4277 if attempts == 1 {
4278 Err(http_error(409, "SandboxNotStopped"))
4280 } else {
4281 Ok(())
4282 }
4283 });
4284
4285 sandbox_with(client)
4286 .resume("racing-the-idle-policy")
4287 .await
4288 .expect("a refused first resume must not doom the wait");
4289 }
4290
4291 #[tokio::test]
4296 async fn a_suspended_sandbox_is_refused_rather_than_woken() {
4297 let mut client = MockSandboxDataPlaneApi::new();
4298 client.expect_get_sandbox().returning(|_, id| {
4299 let mut sandbox = running(id, None);
4300 sandbox.state = Some("Stopped".to_string());
4301 Ok(sandbox)
4302 });
4303 client.expect_resume_sandbox().never();
4304 client.expect_write_file().never();
4305 client.expect_execute_shell_command().never();
4306 let sandbox = sandbox_denying(client, SandboxEgress::Deny);
4307
4308 let wrote = sandbox
4309 .write_files(
4310 "asleep",
4311 BTreeMap::from([("app.py".to_string(), vec![1u8])]),
4312 )
4313 .await
4314 .expect_err("a suspended sandbox takes no content");
4315 assert_eq!(wrote.code, "SANDBOX_COMMAND_FAILED", "{wrote}");
4316
4317 let ran = match sandbox.run_command("asleep", command(5)).await {
4318 Ok(_) => panic!("a suspended sandbox runs no code"),
4319 Err(error) => error,
4320 };
4321 assert_eq!(ran.code, "SANDBOX_COMMAND_FAILED", "{ran}");
4322 }
4323
4324 #[tokio::test]
4330 async fn a_stopped_sandbox_is_judged_before_it_is_woken() {
4331 let mut client = MockSandboxDataPlaneApi::new();
4332 let declared = EgressPolicy {
4333 default_action: "Deny".to_string(),
4334 host_rules: vec![EgressHostRule {
4335 pattern: "*".to_string(),
4336 action: "Deny".to_string(),
4337 }],
4338 rules: Vec::new(),
4339 unmodelled: Default::default(),
4340 traffic_inspection: Some("Full".to_string()),
4341 };
4342 client.expect_get_sandbox().returning(move |_, id| {
4343 if id == "fresh" {
4344 return Ok(running(id, Some(declared.clone())));
4345 }
4346 let mut sandbox = running(
4348 id,
4349 Some(EgressPolicy {
4350 default_action: "Allow".to_string(),
4351 host_rules: Vec::new(),
4352 rules: Vec::new(),
4353 unmodelled: Default::default(),
4354 traffic_inspection: Some("Full".to_string()),
4355 }),
4356 );
4357 sandbox.state = Some("Stopped".to_string());
4358 Ok(sandbox)
4359 });
4360 client.expect_resume_sandbox().never();
4361 client.expect_delete_sandbox().never();
4363 client.expect_stop_sandbox().never();
4364 client
4365 .expect_create_sandbox()
4366 .times(1)
4367 .returning(|_, request| Ok(running("fresh", request.egress)));
4368
4369 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4370 .get_or_create(CreateSandboxRequest {
4371 sandbox_id: Some("asleep-under-allow".to_string()),
4372 tenant_key: None,
4373 env: BTreeMap::new(),
4374 ..Default::default()
4375 })
4376 .await
4377 .expect("a caller asking for a sandbox gets a usable one");
4378
4379 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4380 assert!(sandbox.created, "a replacement is a sandbox this call made");
4381 }
4382
4383 #[tokio::test]
4389 async fn a_failed_sandbox_is_replaced() {
4390 let mut client = MockSandboxDataPlaneApi::new();
4391 client.expect_get_sandbox().returning(|_, id| {
4392 if id == "fresh" {
4393 return Ok(running(id, None));
4394 }
4395 let mut sandbox = running(id, None);
4396 sandbox.state = Some("Failed".to_string());
4397 Ok(sandbox)
4398 });
4399 client
4402 .expect_delete_sandbox()
4403 .withf(|_, id| id == "broken")
4404 .times(1)
4405 .returning(|_, _| Ok(()));
4406 client
4407 .expect_create_sandbox()
4408 .times(1)
4409 .returning(|_, _| Ok(running("fresh", None)));
4410
4411 let sandbox = sandbox_with(client)
4412 .get_or_create(CreateSandboxRequest {
4413 sandbox_id: Some("broken".to_string()),
4414 tenant_key: None,
4415 env: BTreeMap::new(),
4416 ..Default::default()
4417 })
4418 .await
4419 .expect("a failed sandbox is replaced rather than returned");
4420
4421 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4422 assert!(sandbox.created, "a replacement is a sandbox this call made");
4423 }
4424
4425 #[tokio::test]
4430 async fn a_failed_sandbox_reads_as_terminated() {
4431 let mut client = MockSandboxDataPlaneApi::new();
4432 client.expect_get_sandbox().times(1).returning(|_, id| {
4433 let mut sandbox = running(id, None);
4434 sandbox.state = Some("Failed".to_string());
4435 Ok(sandbox)
4436 });
4437
4438 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4439 .get("broken")
4440 .await
4441 .expect("a failed sandbox is a state, not an unreadable response")
4442 .expect("the sandbox exists");
4443
4444 assert_eq!(sandbox.state, SandboxState::Terminated);
4445 }
4446
4447 #[tokio::test]
4452 async fn a_sandbox_that_dies_during_the_wait_is_replaced() {
4453 let mut client = MockSandboxDataPlaneApi::new();
4454 let mut reads = 0;
4455 client.expect_get_sandbox().returning(move |_, id| {
4456 if id == "fresh" {
4457 return Ok(running(id, None));
4458 }
4459 reads += 1;
4460 let mut sandbox = running(id, None);
4461 sandbox.state = Some(if reads == 1 { "Stopped" } else { "Deleting" }.to_string());
4463 Ok(sandbox)
4464 });
4465 client
4466 .expect_create_sandbox()
4467 .times(1)
4468 .returning(|_, _| Ok(running("fresh", None)));
4469
4470 let sandbox = sandbox_with(client)
4471 .get_or_create(CreateSandboxRequest {
4472 sandbox_id: Some("dying".to_string()),
4473 tenant_key: None,
4474 env: BTreeMap::new(),
4475 ..Default::default()
4476 })
4477 .await
4478 .expect("a sandbox that died mid-wait is replaced");
4479
4480 assert_eq!(sandbox.sandbox.sandbox_id, "fresh");
4481 assert!(sandbox.created, "a replacement is a sandbox this call made");
4482 }
4483
4484 #[tokio::test]
4490 async fn a_sleeping_sandbox_that_still_matches_is_kept() {
4491 let declared = EgressPolicy {
4492 default_action: "Deny".to_string(),
4493 host_rules: vec![EgressHostRule {
4494 pattern: "*".to_string(),
4495 action: "Deny".to_string(),
4496 }],
4497 rules: Vec::new(),
4498 unmodelled: Default::default(),
4499 traffic_inspection: Some("Full".to_string()),
4500 };
4501
4502 let mut client = MockSandboxDataPlaneApi::new();
4503 let mut reads = 0;
4504 let carried = declared.clone();
4505 client.expect_get_sandbox().returning(move |_, id| {
4506 reads += 1;
4507 let mut sandbox = running(id, Some(carried.clone()));
4508 if reads <= 2 {
4511 sandbox.state = Some("Stopped".to_string());
4512 }
4513 Ok(sandbox)
4514 });
4515 client
4516 .expect_resume_sandbox()
4517 .times(1)
4518 .returning(|_, _| Ok(()));
4519 client.expect_delete_sandbox().never();
4520 client.expect_create_sandbox().never();
4521
4522 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4523 .get_or_create(CreateSandboxRequest {
4524 sandbox_id: Some("asleep-and-fine".to_string()),
4525 tenant_key: None,
4526 env: BTreeMap::new(),
4527 ..Default::default()
4528 })
4529 .await
4530 .expect("a compliant sleeping sandbox is woken and returned");
4531
4532 assert_eq!(sandbox.sandbox.sandbox_id, "asleep-and-fine");
4533 assert!(
4534 !sandbox.created,
4535 "waking a sleeping sandbox is not creating one"
4536 );
4537 }
4538
4539 #[tokio::test]
4545 async fn a_sleeping_sandbox_with_no_policy_is_woken_before_it_is_judged() {
4546 let declared = EgressPolicy {
4547 default_action: "Deny".to_string(),
4548 host_rules: vec![EgressHostRule {
4549 pattern: "*".to_string(),
4550 action: "Deny".to_string(),
4551 }],
4552 rules: Vec::new(),
4553 unmodelled: Default::default(),
4554 traffic_inspection: Some("Full".to_string()),
4555 };
4556
4557 let mut client = MockSandboxDataPlaneApi::new();
4558 let mut reads = 0;
4559 let carried = declared.clone();
4560 client.expect_get_sandbox().returning(move |_, id| {
4561 reads += 1;
4562 if reads <= 2 {
4563 let mut asleep = running(id, None);
4564 asleep.state = Some("Stopped".to_string());
4565 return Ok(asleep);
4566 }
4567 Ok(running(id, Some(carried.clone())))
4568 });
4569 client
4570 .expect_resume_sandbox()
4571 .times(1)
4572 .returning(|_, _| Ok(()));
4573 client.expect_delete_sandbox().never();
4574 client.expect_create_sandbox().never();
4575
4576 let sandbox = sandbox_denying(client, SandboxEgress::Deny)
4577 .get_or_create(CreateSandboxRequest {
4578 sandbox_id: Some("asleep-without-a-record".to_string()),
4579 tenant_key: None,
4580 env: BTreeMap::new(),
4581 ..Default::default()
4582 })
4583 .await
4584 .expect("an absent policy on a sleeping record is unknown, not a mismatch");
4585
4586 assert_eq!(sandbox.sandbox.sandbox_id, "asleep-without-a-record");
4587 assert!(
4588 !sandbox.created,
4589 "waking a sleeping sandbox is not creating one"
4590 );
4591 }
4592
4593 #[tokio::test]
4598 async fn a_sandbox_that_cannot_be_put_back_is_reported_as_left_awake() {
4599 let mut client = MockSandboxDataPlaneApi::new();
4600 let mut reads = 0;
4601 client.expect_get_sandbox().returning(move |_, id| {
4602 reads += 1;
4603 let mut sandbox = running(id, None);
4604 if reads <= 2 {
4607 sandbox.state = Some("Stopped".to_string());
4608 }
4609 Ok(sandbox)
4610 });
4611 client.expect_resume_sandbox().returning(|_, _| Ok(()));
4612 client
4613 .expect_stop_sandbox()
4614 .times(1)
4615 .returning(|_, _| Err(http_error(500, "SuspendFailed")));
4616
4617 let error = sandbox_denying(client, SandboxEgress::Deny)
4618 .resume("built-under-allow")
4619 .await
4620 .expect_err("a sandbox that woke up uncontained must not be reported as resumed");
4621
4622 assert!(
4623 error.to_string().contains("sandboxLeftAwake"),
4624 "a sandbox left awake has to be named, not folded into the refusal: {error}"
4625 );
4626 }
4627
4628 #[tokio::test]
4633 async fn an_unreadable_state_takes_no_work_and_is_not_called_suspended() {
4634 let mut client = MockSandboxDataPlaneApi::new();
4635 client.expect_get_sandbox().times(1).returning(|_, _| {
4636 Ok(alien_azure_clients::azure::sandbox_data_plane::Sandbox {
4637 id: "s1".to_string(),
4638 egress_policy: None,
4639 state: Some("Hibernated".to_string()),
4640 })
4641 });
4642 client.expect_execute_shell_command().never();
4643 client.expect_resume_sandbox().never();
4644
4645 let error = match sandbox_with(client).run_command("s1", command(5)).await {
4646 Ok(_) => panic!("an unreadable state must not take work"),
4647 Err(error) => error,
4648 };
4649
4650 assert_eq!(error.code, "UNEXPECTED_RESPONSE_FORMAT", "{error}");
4651 }
4652
4653 #[test]
4656 fn capabilities_describe_the_backend_not_this_declaration() {
4657 let platform =
4658 SandboxCapabilities::for_platform(Platform::Azure).expect("Azure has a backend");
4659
4660 assert_eq!(
4661 sandbox_with(MockSandboxDataPlaneApi::new()).capabilities(),
4662 platform
4663 );
4664
4665 let listed = AzureSandbox::new(
4666 std::sync::Arc::new(MockSandboxDataPlaneApi::new()),
4667 "grp".to_string(),
4668 "ubuntu".to_string(),
4669 SandboxEgress::AllowDomains {
4670 domains: vec!["api.example.com".to_string()],
4671 },
4672 None,
4673 "1000m".to_string(),
4674 "2048Mi".to_string(),
4675 None,
4676 );
4677 assert_eq!(
4678 listed.capabilities(),
4679 platform,
4680 "the declaration is not the row"
4681 );
4682 assert!(
4683 platform.domain_egress_rules,
4684 "Azure does host-pattern egress"
4685 );
4686 }
4687
4688 #[tokio::test]
4692 async fn a_tenant_key_is_refused_rather_than_dropped() {
4693 let mut client = MockSandboxDataPlaneApi::new();
4694 client.expect_create_sandbox().never();
4695
4696 let error = sandbox_with(client)
4697 .create(CreateSandboxRequest {
4698 tenant_key: Some("tenant-1".to_string()),
4699 ..Default::default()
4700 })
4701 .await
4702 .expect_err("a tenant key Azure cannot honour is refused");
4703
4704 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED", "{error}");
4705 assert!(
4706 error.to_string().contains("tenantKey"),
4707 "the refusal has to name the field a caller must remove: {error}"
4708 );
4709 }
4710}