Skip to main content

camel_function/provider/
container.rs

1use crate::pool::{RunnerHandle, RunnerPoolKey};
2use crate::protocol::ProtocolClient;
3use camel_api::Exchange;
4use camel_api::function::*;
5use dashmap::DashMap;
6use std::sync::Arc;
7use tokio::task::JoinHandle;
8use tokio_util::sync::CancellationToken;
9
10use super::{FunctionHealthStatus, FunctionProvider, ProviderError};
11
12struct ContainerEntry {
13    container_id: String,
14    endpoint: String,
15}
16
17#[derive(Debug, Clone)]
18pub enum PullPolicy {
19    Always,
20    Never,
21    IfMissing,
22}
23
24impl std::fmt::Debug for ContainerProvider {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("ContainerProvider")
27            .field("image", &self.image)
28            .field("active_containers", &self.containers_by_handle.len())
29            .finish()
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct ContainerProviderBuilder {
35    image: String,
36    boot_timeout: std::time::Duration,
37    pull_policy: PullPolicy,
38    instance_id: Option<String>,
39    egress_allowlist: Vec<String>,
40}
41
42impl Default for ContainerProviderBuilder {
43    fn default() -> Self {
44        Self {
45            image: "kennycallado/deno-runner:latest".to_string(),
46            boot_timeout: std::time::Duration::from_secs(10),
47            pull_policy: PullPolicy::IfMissing,
48            instance_id: None,
49            egress_allowlist: Vec::new(),
50        }
51    }
52}
53
54impl ContainerProviderBuilder {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    pub fn image(mut self, image: impl Into<String>) -> Self {
60        self.image = image.into();
61        self
62    }
63
64    pub fn boot_timeout(mut self, timeout: std::time::Duration) -> Self {
65        self.boot_timeout = timeout;
66        self
67    }
68
69    pub fn pull_policy(mut self, policy: PullPolicy) -> Self {
70        self.pull_policy = policy;
71        self
72    }
73
74    pub fn instance_id(mut self, id: impl Into<String>) -> Self {
75        self.instance_id = Some(id.into());
76        self
77    }
78
79    /// Set the Deno runner's outbound-network allowlist (`host[:port]`
80    /// entries). Empty (the default) keeps deny-all egress.
81    ///
82    /// Entries are validated at `build()` time — fail-closed: a malformed
83    /// entry is an error, never a silently widened permission.
84    pub fn egress_allowlist(mut self, entries: Vec<String>) -> Self {
85        self.egress_allowlist = entries;
86        self
87    }
88
89    pub fn build(self) -> Result<ContainerProvider, ProviderError> {
90        // Validate before touching Docker so a bad allowlist fails fast
91        // (and unit-testably, without a daemon).
92        for entry in &self.egress_allowlist {
93            crate::config::validate_egress_allowlist_entry(entry)
94                .map_err(|e| ProviderError::InvalidConfig(e.to_string()))?;
95        }
96        let docker = bollard::Docker::connect_with_local_defaults()
97            .map_err(|e| ProviderError::SpawnFailed(format!("docker connect: {e}")))?;
98        let instance_id = self.instance_id.unwrap_or_else(|| {
99            let hash = blake3::hash(
100                format!(
101                    "{}-{}",
102                    self.image,
103                    std::time::SystemTime::now()
104                        .duration_since(std::time::UNIX_EPOCH)
105                        .unwrap_or_default()
106                        .as_nanos()
107                )
108                .as_bytes(),
109            );
110            format!("inst-{}", &hash.to_hex()[..12])
111        });
112        Ok(ContainerProvider {
113            docker,
114            image: self.image,
115            boot_timeout: self.boot_timeout,
116            pull_policy: self.pull_policy,
117            client: ProtocolClient::new()?,
118            containers_by_handle: DashMap::new(),
119            instance_id,
120            egress_allowlist: self.egress_allowlist,
121            log_forwarder_handles: std::sync::Mutex::new(Vec::new()),
122        })
123    }
124}
125
126pub struct ContainerProvider {
127    docker: bollard::Docker,
128    image: String,
129    instance_id: String,
130    boot_timeout: std::time::Duration,
131    pull_policy: PullPolicy,
132    egress_allowlist: Vec<String>,
133    client: ProtocolClient,
134    containers_by_handle: DashMap<String, ContainerEntry>,
135    log_forwarder_handles: std::sync::Mutex<Vec<JoinHandle<()>>>,
136}
137
138impl ContainerProvider {
139    pub fn builder() -> ContainerProviderBuilder {
140        ContainerProviderBuilder::new()
141    }
142
143    pub async fn cleanup_all(&self) {
144        let entries: Vec<(String, String)> = self
145            .containers_by_handle
146            .iter()
147            .map(|e| (e.key().clone(), e.container_id.clone()))
148            .collect();
149        for (handle_id, container_id) in entries {
150            self.stop_and_remove_container(&container_id).await;
151            self.containers_by_handle.remove(&handle_id);
152        }
153    }
154
155    pub fn instance_id(&self) -> &str {
156        &self.instance_id
157    }
158
159    pub async fn is_clean(&self) -> bool {
160        self.list_instance_containers().await.is_empty()
161    }
162
163    pub async fn list_instance_containers(&self) -> Vec<String> {
164        let options = bollard::query_parameters::ListContainersOptions {
165            filters: Some(std::collections::HashMap::from([(
166                "label".to_string(),
167                vec![format!("camel.function.instance={}", self.instance_id)],
168            )])),
169            ..Default::default()
170        };
171        match self.docker.list_containers(Some(options)).await {
172            Ok(containers) => containers.into_iter().filter_map(|c| c.id).collect(),
173            Err(_) => vec![],
174        }
175    }
176
177    async fn stop_and_remove_container(&self, container_id: &str) {
178        let _ = self.docker.stop_container(container_id, None).await;
179        match self
180            .docker
181            .remove_container(
182                container_id,
183                Some(bollard::query_parameters::RemoveContainerOptions {
184                    force: true,
185                    ..Default::default()
186                }),
187            )
188            .await
189        {
190            Ok(()) => {}
191            Err(bollard::errors::Error::DockerResponseServerError {
192                status_code: 404, ..
193            }) => {}
194            Err(e) => {
195                tracing::warn!(target: "camel_function::container", %container_id, "remove error: {e}");
196            }
197        }
198    }
199
200    async fn allocate_host_port(&self) -> Result<u16, ProviderError> {
201        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
202            .await
203            .map_err(|e| ProviderError::SpawnFailed(format!("allocate port: {e}")))?;
204        let port = listener
205            .local_addr()
206            .map_err(|e| ProviderError::SpawnFailed(format!("get port: {e}")))?
207            .port();
208        drop(listener);
209        Ok(port)
210    }
211
212    pub async fn spawn_runner(&self, runtime: &str) -> Result<RunnerHandle, ProviderError> {
213        let key = RunnerPoolKey {
214            runtime: runtime.to_string(),
215        };
216        FunctionProvider::spawn(self, &key).await
217    }
218
219    pub async fn shutdown_runner(&self, handle: RunnerHandle) -> Result<(), ProviderError> {
220        FunctionProvider::shutdown(self, handle).await
221    }
222
223    pub async fn health_runner(
224        &self,
225        handle: &RunnerHandle,
226    ) -> Result<FunctionHealthStatus, ProviderError> {
227        FunctionProvider::health(self, handle).await
228    }
229
230    pub async fn register_function(
231        &self,
232        handle: &RunnerHandle,
233        def: &FunctionDefinition,
234    ) -> Result<(), ProviderError> {
235        FunctionProvider::register(self, handle, def).await
236    }
237
238    pub async fn unregister_function(
239        &self,
240        handle: &RunnerHandle,
241        id: &FunctionId,
242    ) -> Result<(), ProviderError> {
243        FunctionProvider::unregister(self, handle, id).await
244    }
245
246    pub async fn invoke_function(
247        &self,
248        handle: &RunnerHandle,
249        function_id: &FunctionId,
250        exchange: &Exchange,
251    ) -> Result<ExchangePatch, ProviderError> {
252        FunctionProvider::invoke(
253            self,
254            handle,
255            function_id,
256            exchange,
257            std::time::Duration::from_millis(5000),
258        )
259        .await
260    }
261
262    /// Pull the configured image according to the current [PullPolicy].
263    async fn pull_image_if_needed(&self) -> Result<(), ProviderError> {
264        match self.pull_policy {
265            PullPolicy::Never => {}
266            PullPolicy::Always => {
267                self.pull_image().await?;
268            }
269            PullPolicy::IfMissing => match self.docker.inspect_image(&self.image).await {
270                Ok(_) => {}
271                Err(bollard::errors::Error::DockerResponseServerError {
272                    status_code: 404, ..
273                }) => {
274                    self.pull_image().await?;
275                }
276                Err(e) => {
277                    return Err(inspect_error_to_spawn_failed(&self.image, e));
278                }
279            },
280        }
281        Ok(())
282    }
283}
284
285/// Converts a non-404 Docker inspect error into a [`ProviderError::SpawnFailed`].
286///
287/// Extracted as a free function so the classification logic can be unit-tested
288/// without a live Docker daemon.
289fn inspect_error_to_spawn_failed(image: &str, e: bollard::errors::Error) -> ProviderError {
290    ProviderError::SpawnFailed(format!("failed to inspect image '{image}': {e}"))
291}
292
293/// Compute the Deno `--allow-net` value from the egress allowlist.
294///
295/// `0.0.0.0` is always first: it is the runner's HTTP bind address
296/// (inbound only) and keeps the container reachable for the control
297/// protocol. Each allowlist entry adds one exact-host outbound grant.
298/// An empty allowlist yields `0.0.0.0` alone — byte-identical to the
299/// runner image's default `CMD`, i.e. deny-all egress.
300fn deno_allow_net_value(egress_allowlist: &[String]) -> String {
301    let mut value = String::from("0.0.0.0");
302    for entry in egress_allowlist {
303        value.push(',');
304        value.push_str(entry);
305    }
306    value
307}
308
309/// Build the container creation request with security hardening.
310fn build_container_config(
311    image: &str,
312    host_port: u16,
313    instance_id: &str,
314    handle_id: &str,
315    egress_allowlist: &[String],
316) -> bollard::models::ContainerCreateBody {
317    let labels = std::collections::HashMap::from([
318        ("camel.function.runner".to_string(), "true".to_string()),
319        ("camel.function.context".to_string(), handle_id.to_string()),
320        (
321            "camel.function.instance".to_string(),
322            instance_id.to_string(),
323        ),
324    ]);
325
326    // The provider owns the full deno command line so the net permission
327    // derives from configuration, not from the image's baked-in CMD.
328    let cmd = vec![
329        "deno".to_string(),
330        "run".to_string(),
331        format!("--allow-net={}", deno_allow_net_value(egress_allowlist)),
332        "--allow-env=PORT".to_string(),
333        "main.ts".to_string(),
334    ];
335
336    bollard::models::ContainerCreateBody {
337        image: Some(image.to_string()),
338        cmd: Some(cmd),
339        env: Some(vec![
340            "PORT=8080".to_string(),
341            "DENO_NO_PROMPT=1".to_string(),
342            "DENO_DIR=/tmp/deno".to_string(),
343        ]),
344        labels: Some(labels),
345        exposed_ports: Some(vec!["8080/tcp".to_string()]),
346        host_config: Some(bollard::models::HostConfig {
347            port_bindings: Some(std::collections::HashMap::from([(
348                "8080/tcp".to_string(),
349                Some(vec![bollard::models::PortBinding {
350                    host_ip: Some("127.0.0.1".to_string()),
351                    host_port: Some(host_port.to_string()),
352                }]),
353            )])),
354            init: Some(true),
355            auto_remove: Some(false),
356            cap_drop: Some(vec!["ALL".to_string()]),
357            security_opt: Some(vec!["no-new-privileges".to_string()]),
358            readonly_rootfs: Some(true),
359            memory: Some(256 * 1024 * 1024),
360            nano_cpus: Some(1_000_000_000),
361            pids_limit: Some(100),
362            tmpfs: Some(std::collections::HashMap::from([(
363                "/tmp".to_string(),
364                String::new(),
365            )])),
366            dns_search: Some(vec![".".to_string()]),
367            ..Default::default()
368        }),
369        ..Default::default()
370    }
371}
372
373impl ContainerProvider {
374    /// Execute the image pull via bollard's `create_image` API.
375    async fn pull_image(&self) -> Result<(), ProviderError> {
376        use futures::StreamExt;
377
378        let options = bollard::query_parameters::CreateImageOptionsBuilder::default()
379            .from_image(&self.image)
380            .build();
381
382        let mut stream = self.docker.create_image(Some(options), None, None);
383        while let Some(item) = stream.next().await {
384            match item {
385                Ok(_) => {}
386                Err(e) => {
387                    return Err(ProviderError::SpawnFailed(format!(
388                        "image pull failed for '{}': {e}",
389                        self.image
390                    )));
391                }
392            }
393        }
394        Ok(())
395    }
396
397    fn spawn_log_forwarder(&self, container_id: String) {
398        use futures::StreamExt;
399
400        let docker = self.docker.clone();
401        let handle = tokio::spawn(async move {
402            let options = bollard::query_parameters::LogsOptions {
403                follow: true,
404                stdout: true,
405                stderr: true,
406                ..Default::default()
407            };
408            let mut stream = docker.logs(&container_id, Some(options));
409
410            while let Some(msg) = stream.next().await {
411                match msg {
412                    Ok(log_output) => {
413                        let text = match &log_output {
414                            bollard::container::LogOutput::StdOut { message } => {
415                                String::from_utf8_lossy(message).into_owned()
416                            }
417                            bollard::container::LogOutput::StdErr { message } => {
418                                String::from_utf8_lossy(message).into_owned()
419                            }
420                            _ => continue,
421                        };
422                        let trimmed = text.trim_end();
423                        if trimmed.is_empty() {
424                            continue;
425                        }
426                        match &log_output {
427                            bollard::container::LogOutput::StdOut { .. } => {
428                                tracing::info!(target: "camel_function::runner", "{trimmed}");
429                            }
430                            bollard::container::LogOutput::StdErr { .. } => {
431                                tracing::warn!(target: "camel_function::runner", "{trimmed}");
432                            }
433                            _ => {}
434                        }
435                    }
436                    Err(e) => {
437                        tracing::debug!(target: "camel_function::container", "log stream error: {e}");
438                        break;
439                    }
440                }
441            }
442        });
443        // allow-unwrap: Mutex cannot be poisoned in normal operation
444        self.log_forwarder_handles
445            .lock()
446            .expect("log_fwd mutex poisoned") // allow-unwrap
447            .push(handle);
448    }
449}
450
451impl super::sealed::Sealed for ContainerProvider {}
452
453#[async_trait::async_trait]
454impl FunctionProvider for ContainerProvider {
455    async fn spawn(&self, _key: &RunnerPoolKey) -> Result<RunnerHandle, ProviderError> {
456        let hash = blake3::hash(
457            format!(
458                "{}",
459                std::time::SystemTime::now()
460                    .duration_since(std::time::UNIX_EPOCH)
461                    .unwrap_or_default()
462                    .as_nanos()
463            )
464            .as_bytes(),
465        )
466        .to_hex();
467        let handle_id = format!("deno-{}", &hash[..16]);
468        let host_port = self.allocate_host_port().await?;
469
470        tracing::debug!(
471            target: "camel_function::container",
472            %handle_id,
473            image = %self.image,
474            "spawning container"
475        );
476
477        self.pull_image_if_needed().await?;
478
479        let config = build_container_config(
480            &self.image,
481            host_port,
482            &self.instance_id,
483            &handle_id,
484            &self.egress_allowlist,
485        );
486
487        let create_opts = bollard::query_parameters::CreateContainerOptions {
488            name: Some(handle_id.clone()),
489            ..Default::default()
490        };
491
492        let create_result = self
493            .docker
494            .create_container(Some(create_opts), config)
495            .await
496            .map_err(|e| ProviderError::SpawnFailed(format!("create container: {e}")))?;
497
498        let container_id = create_result.id;
499
500        if let Err(e) = self.docker.start_container(&container_id, None).await {
501            let _ = self.stop_and_remove_container(&container_id).await;
502            return Err(ProviderError::SpawnFailed(format!("start container: {e}")));
503        }
504
505        let endpoint = format!("http://127.0.0.1:{host_port}");
506
507        // Wait for the container to become healthy within boot_timeout.
508        let boot_timeout = self.boot_timeout;
509        let client = &self.client;
510        let endpoint_clone = endpoint.clone();
511        let ready_result = tokio::time::timeout(boot_timeout, async {
512            loop {
513                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
514                if client.health(&endpoint_clone).await.is_ok() {
515                    return;
516                }
517            }
518        })
519        .await;
520
521        if ready_result.is_err() {
522            let _ = self.stop_and_remove_container(&container_id).await;
523            return Err(ProviderError::SpawnFailed("container boot timeout".into()));
524        }
525
526        self.spawn_log_forwarder(container_id.clone());
527
528        self.containers_by_handle.insert(
529            handle_id.clone(),
530            ContainerEntry {
531                container_id,
532                endpoint,
533            },
534        );
535
536        Ok(RunnerHandle {
537            id: handle_id,
538            state: Arc::new(std::sync::Mutex::new(crate::pool::RunnerState::Booting)),
539            cancel: CancellationToken::new(),
540        })
541    }
542
543    async fn shutdown(&self, handle: RunnerHandle) -> Result<(), ProviderError> {
544        handle.cancel.cancel();
545        let entry = match self.containers_by_handle.remove(&handle.id) {
546            Some((_, entry)) => entry,
547            None => return Ok(()),
548        };
549        let _ = self.client.shutdown(&entry.endpoint).await;
550        self.stop_and_remove_container(&entry.container_id).await;
551        Ok(())
552    }
553
554    async fn health(&self, handle: &RunnerHandle) -> Result<FunctionHealthStatus, ProviderError> {
555        let endpoint = self
556            .containers_by_handle
557            .get(&handle.id)
558            .ok_or_else(|| ProviderError::HealthFailed(format!("unknown handle {}", handle.id)))?
559            .endpoint
560            .clone();
561        self.client.health(&endpoint).await
562    }
563
564    async fn register(
565        &self,
566        handle: &RunnerHandle,
567        def: &FunctionDefinition,
568    ) -> Result<(), ProviderError> {
569        let endpoint = self
570            .containers_by_handle
571            .get(&handle.id)
572            .ok_or_else(|| ProviderError::RegisterFailed(format!("unknown handle {}", handle.id)))?
573            .endpoint
574            .clone();
575        self.client.register(&endpoint, def).await
576    }
577
578    async fn unregister(
579        &self,
580        handle: &RunnerHandle,
581        id: &FunctionId,
582    ) -> Result<(), ProviderError> {
583        let endpoint = self
584            .containers_by_handle
585            .get(&handle.id)
586            .ok_or_else(|| {
587                ProviderError::UnregisterFailed(format!("unknown handle {}", handle.id))
588            })?
589            .endpoint
590            .clone();
591        self.client.unregister(&endpoint, id).await
592    }
593
594    async fn invoke(
595        &self,
596        handle: &RunnerHandle,
597        id: &FunctionId,
598        ex: &Exchange,
599        timeout: std::time::Duration,
600    ) -> Result<ExchangePatch, ProviderError> {
601        let endpoint = self
602            .containers_by_handle
603            .get(&handle.id)
604            .ok_or_else(|| ProviderError::InvokeFailed(format!("unknown handle {}", handle.id)))?
605            .endpoint
606            .clone();
607        let resp = self.client.invoke(&endpoint, id, ex, timeout).await?;
608        if resp.ok {
609            let patch = resp.patch.unwrap_or_default();
610            Ok(patch
611                .to_exchange_patch()
612                .map_err(|e| ProviderError::InvokeFailed(e.to_string()))?)
613        } else {
614            let err = resp.error.unwrap_or_else(|| crate::protocol::ErrorWire {
615                kind: "unknown".into(),
616                message: "no error body".into(),
617                stack: None,
618            });
619            Err(ProviderError::InvokeFailed(format!(
620                "{}: {}",
621                err.kind, err.message
622            )))
623        }
624    }
625}
626
627impl Drop for ContainerProvider {
628    fn drop(&mut self) {
629        // allow-unwrap: Mutex cannot be poisoned in normal operation
630        let handles = std::mem::take(
631            &mut *self
632                .log_forwarder_handles
633                .lock()
634                .expect("log_fwd mutex poisoned"), // allow-unwrap
635        );
636        for handle in handles {
637            handle.abort();
638        }
639
640        if self.containers_by_handle.is_empty() {
641            return;
642        }
643        let docker = self.docker.clone();
644        let container_ids: Vec<String> = self
645            .containers_by_handle
646            .iter()
647            .map(|e| e.container_id.clone())
648            .collect();
649        match tokio::runtime::Handle::try_current() {
650            Ok(handle) => {
651                drop(handle.spawn(async move {
652                    for id in container_ids {
653                        let _ = docker.stop_container(&id, None).await;
654                        let _ = docker
655                            .remove_container(
656                                &id,
657                                Some(bollard::query_parameters::RemoveContainerOptions {
658                                    force: true,
659                                    ..Default::default()
660                                }),
661                            )
662                            .await;
663                    }
664                }));
665            }
666            Err(_) => {
667                tracing::warn!(target: "camel_function::container", "container cleanup skipped: no tokio runtime");
668            }
669        }
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[tokio::test]
678    async fn test_log_forwarder_handles_aborted_on_drop() {
679        // Test the handle-tracking mechanism in isolation (no Docker required).
680        // The ContainerProvider will use Mutex<Vec<JoinHandle>> + abort on Drop.
681        // Verify that pattern works: spawn task, abort it, handle finishes.
682        let handle = tokio::spawn(async {
683            std::future::pending::<()>().await;
684        });
685
686        // Abort the handle (simulating what Drop on ContainerProvider will do)
687        handle.abort();
688
689        // Give abort time to propagate
690        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
691        assert!(
692            handle.is_finished(),
693            "aborted join handle should report finished"
694        );
695    }
696
697    #[test]
698    fn inspect_non_404_becomes_spawn_failed() {
699        let err = bollard::errors::Error::DockerResponseServerError {
700            status_code: 500,
701            message: "internal server error".into(),
702        };
703        let result = inspect_error_to_spawn_failed("my-image:latest", err);
704        assert!(
705            matches!(result, ProviderError::SpawnFailed(ref msg) if msg.contains("my-image:latest")),
706            "expected SpawnFailed with image name, got: {result:?}"
707        );
708    }
709
710    #[test]
711    fn inspect_permission_denied_becomes_spawn_failed() {
712        let err = bollard::errors::Error::DockerResponseServerError {
713            status_code: 403,
714            message: "permission denied".into(),
715        };
716        let result = inspect_error_to_spawn_failed("private/image:1.0", err);
717        assert!(matches!(
718            result,
719            ProviderError::SpawnFailed(ref msg) if msg.contains("private/image:1.0")
720        ));
721    }
722
723    #[test]
724    fn container_config_has_security_hardening() {
725        let config = build_container_config(
726            "denoland/deno:2.1.4",
727            8080,
728            "test-instance",
729            "test-handle",
730            &[],
731        );
732
733        let hc = config.host_config.expect("host_config must be set");
734
735        let cap_drop = hc.cap_drop.expect("cap_drop must be set");
736        assert!(
737            cap_drop.contains(&"ALL".to_string()),
738            "cap_drop must include ALL"
739        );
740
741        let security_opt = hc.security_opt.expect("security_opt must be set");
742        assert!(
743            security_opt.contains(&"no-new-privileges".to_string()),
744            "security_opt must include no-new-privileges"
745        );
746
747        assert_eq!(
748            hc.readonly_rootfs,
749            Some(true),
750            "readonly_rootfs must be true"
751        );
752
753        let mem = hc.memory.expect("memory limit must be set");
754        assert!(
755            mem > 0 && mem <= 512 * 1024 * 1024,
756            "memory must be a sane limit (got {mem})"
757        );
758
759        let cpus = hc.nano_cpus.expect("nano_cpus must be set");
760        assert!(cpus > 0, "nano_cpus must be positive");
761
762        let pids = hc.pids_limit.expect("pids_limit must be set");
763        assert!(
764            pids > 0 && pids <= 500,
765            "pids_limit must be a sane value (got {pids})"
766        );
767
768        let tmpfs = hc.tmpfs.expect("tmpfs must be set");
769        assert!(tmpfs.contains_key("/tmp"), "tmpfs must mount /tmp");
770
771        let dns_search = hc.dns_search.expect("dns_search must be set");
772        assert!(
773            dns_search.contains(&".".to_string()),
774            "dns_search must suppress defaults"
775        );
776
777        let env = config.env.expect("env must be set");
778        assert!(
779            env.iter().any(|e| e.contains("DENO_DIR=/tmp")),
780            "env must set DENO_DIR=/tmp for readonly rootfs"
781        );
782    }
783
784    #[test]
785    fn empty_allowlist_pins_default_deny_net_permission() {
786        // Mission pin: an empty allowlist must reproduce the runner image's
787        // baked-in CMD exactly — outbound egress denied, only the bind
788        // address is granted. The expectation is parsed FROM the Dockerfile
789        // so image drift fails here instead of silently re-widening.
790        let dockerfile = include_str!("../../runner/Dockerfile");
791        let cmd_line = dockerfile
792            .lines()
793            .find_map(|l| l.trim().strip_prefix("CMD "))
794            .expect("runner Dockerfile must declare a CMD");
795        let image_cmd: Vec<String> =
796            serde_json::from_str(cmd_line).expect("Dockerfile CMD must be a JSON string array");
797
798        let config = build_container_config(
799            "denoland/deno:2.1.4",
800            8080,
801            "test-instance",
802            "test-handle",
803            &[],
804        );
805        assert_eq!(
806            config.cmd.expect("cmd must be set"),
807            image_cmd,
808            "empty allowlist must keep the image's deny-all net permission"
809        );
810    }
811
812    #[test]
813    fn allowlist_grants_exact_hosts_only() {
814        let entries = vec![
815            "api.example.com:443".to_string(),
816            "internal".to_string(),
817            "[::1]:5432".to_string(),
818        ];
819        let config = build_container_config(
820            "denoland/deno:2.1.4",
821            8080,
822            "test-instance",
823            "test-handle",
824            &entries,
825        );
826        let cmd = config.cmd.expect("cmd must be set");
827        let net_flag = cmd
828            .iter()
829            .find(|c| c.starts_with("--allow-net="))
830            .expect("cmd must carry --allow-net");
831        assert_eq!(
832            net_flag, "--allow-net=0.0.0.0,api.example.com:443,internal,[::1]:5432",
833            "allow-net must be the bind address plus exactly the allowlisted entries"
834        );
835        // Non-allowlisted hosts stay denied (nothing else is granted).
836        assert!(!net_flag.contains("evil.com"));
837        assert!(!net_flag.contains('*'));
838    }
839
840    #[test]
841    fn builder_rejects_malformed_egress_entry_before_docker() {
842        // Fail-closed at construction; no Docker daemon may be required to
843        // surface a bad allowlist entry.
844        let result = ContainerProvider::builder()
845            .egress_allowlist(vec![
846                "api.example.com:443".to_string(),
847                "bad host".to_string(),
848            ])
849            .build();
850        assert!(
851            matches!(result, Err(ProviderError::InvalidConfig(ref msg)) if msg.contains("egress_allowlist")),
852            "expected InvalidConfig naming egress_allowlist, got: {result:?}"
853        );
854    }
855}