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 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 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 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
285fn inspect_error_to_spawn_failed(image: &str, e: bollard::errors::Error) -> ProviderError {
290 ProviderError::SpawnFailed(format!("failed to inspect image '{image}': {e}"))
291}
292
293fn 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
309fn 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 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 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 self.log_forwarder_handles
445 .lock()
446 .expect("log_fwd mutex poisoned") .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 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 let handles = std::mem::take(
631 &mut *self
632 .log_forwarder_handles
633 .lock()
634 .expect("log_fwd mutex poisoned"), );
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 let handle = tokio::spawn(async {
683 std::future::pending::<()>().await;
684 });
685
686 handle.abort();
688
689 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 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 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 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}