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}
40
41impl Default for ContainerProviderBuilder {
42    fn default() -> Self {
43        Self {
44            image: "kennycallado/deno-runner:latest".to_string(),
45            boot_timeout: std::time::Duration::from_secs(10),
46            pull_policy: PullPolicy::IfMissing,
47            instance_id: None,
48        }
49    }
50}
51
52impl ContainerProviderBuilder {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn image(mut self, image: impl Into<String>) -> Self {
58        self.image = image.into();
59        self
60    }
61
62    pub fn boot_timeout(mut self, timeout: std::time::Duration) -> Self {
63        self.boot_timeout = timeout;
64        self
65    }
66
67    pub fn pull_policy(mut self, policy: PullPolicy) -> Self {
68        self.pull_policy = policy;
69        self
70    }
71
72    pub fn instance_id(mut self, id: impl Into<String>) -> Self {
73        self.instance_id = Some(id.into());
74        self
75    }
76
77    pub fn build(self) -> Result<ContainerProvider, ProviderError> {
78        let docker = bollard::Docker::connect_with_local_defaults()
79            .map_err(|e| ProviderError::SpawnFailed(format!("docker connect: {e}")))?;
80        let instance_id = self.instance_id.unwrap_or_else(|| {
81            let hash = blake3::hash(
82                format!(
83                    "{}-{}",
84                    self.image,
85                    std::time::SystemTime::now()
86                        .duration_since(std::time::UNIX_EPOCH)
87                        .unwrap_or_default()
88                        .as_nanos()
89                )
90                .as_bytes(),
91            );
92            format!("inst-{}", &hash.to_hex()[..12])
93        });
94        Ok(ContainerProvider {
95            docker,
96            image: self.image,
97            boot_timeout: self.boot_timeout,
98            pull_policy: self.pull_policy,
99            client: ProtocolClient::new(),
100            containers_by_handle: DashMap::new(),
101            instance_id,
102            log_forwarder_handles: std::sync::Mutex::new(Vec::new()),
103        })
104    }
105}
106
107pub struct ContainerProvider {
108    docker: bollard::Docker,
109    image: String,
110    instance_id: String,
111    boot_timeout: std::time::Duration,
112    pull_policy: PullPolicy,
113    client: ProtocolClient,
114    containers_by_handle: DashMap<String, ContainerEntry>,
115    log_forwarder_handles: std::sync::Mutex<Vec<JoinHandle<()>>>,
116}
117
118impl ContainerProvider {
119    pub fn builder() -> ContainerProviderBuilder {
120        ContainerProviderBuilder::new()
121    }
122
123    pub async fn cleanup_all(&self) {
124        let entries: Vec<(String, String)> = self
125            .containers_by_handle
126            .iter()
127            .map(|e| (e.key().clone(), e.container_id.clone()))
128            .collect();
129        for (handle_id, container_id) in entries {
130            self.stop_and_remove_container(&container_id).await;
131            self.containers_by_handle.remove(&handle_id);
132        }
133    }
134
135    pub fn instance_id(&self) -> &str {
136        &self.instance_id
137    }
138
139    pub async fn is_clean(&self) -> bool {
140        self.list_instance_containers().await.is_empty()
141    }
142
143    pub async fn list_instance_containers(&self) -> Vec<String> {
144        let options = bollard::query_parameters::ListContainersOptions {
145            filters: Some(std::collections::HashMap::from([(
146                "label".to_string(),
147                vec![format!("camel.function.instance={}", self.instance_id)],
148            )])),
149            ..Default::default()
150        };
151        match self.docker.list_containers(Some(options)).await {
152            Ok(containers) => containers.into_iter().filter_map(|c| c.id).collect(),
153            Err(_) => vec![],
154        }
155    }
156
157    async fn stop_and_remove_container(&self, container_id: &str) {
158        let _ = self.docker.stop_container(container_id, None).await;
159        match self
160            .docker
161            .remove_container(
162                container_id,
163                Some(bollard::query_parameters::RemoveContainerOptions {
164                    force: true,
165                    ..Default::default()
166                }),
167            )
168            .await
169        {
170            Ok(()) => {}
171            Err(bollard::errors::Error::DockerResponseServerError {
172                status_code: 404, ..
173            }) => {}
174            Err(e) => {
175                tracing::warn!(target: "camel_function::container", %container_id, "remove error: {e}");
176            }
177        }
178    }
179
180    async fn allocate_host_port(&self) -> Result<u16, ProviderError> {
181        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
182            .await
183            .map_err(|e| ProviderError::SpawnFailed(format!("allocate port: {e}")))?;
184        let port = listener
185            .local_addr()
186            .map_err(|e| ProviderError::SpawnFailed(format!("get port: {e}")))?
187            .port();
188        drop(listener);
189        Ok(port)
190    }
191
192    pub async fn spawn_runner(&self, runtime: &str) -> Result<RunnerHandle, ProviderError> {
193        let key = RunnerPoolKey {
194            runtime: runtime.to_string(),
195        };
196        FunctionProvider::spawn(self, &key).await
197    }
198
199    pub async fn shutdown_runner(&self, handle: RunnerHandle) -> Result<(), ProviderError> {
200        FunctionProvider::shutdown(self, handle).await
201    }
202
203    pub async fn health_runner(
204        &self,
205        handle: &RunnerHandle,
206    ) -> Result<FunctionHealthStatus, ProviderError> {
207        FunctionProvider::health(self, handle).await
208    }
209
210    pub async fn register_function(
211        &self,
212        handle: &RunnerHandle,
213        def: &FunctionDefinition,
214    ) -> Result<(), ProviderError> {
215        FunctionProvider::register(self, handle, def).await
216    }
217
218    pub async fn unregister_function(
219        &self,
220        handle: &RunnerHandle,
221        id: &FunctionId,
222    ) -> Result<(), ProviderError> {
223        FunctionProvider::unregister(self, handle, id).await
224    }
225
226    pub async fn invoke_function(
227        &self,
228        handle: &RunnerHandle,
229        function_id: &FunctionId,
230        exchange: &Exchange,
231    ) -> Result<ExchangePatch, ProviderError> {
232        FunctionProvider::invoke(
233            self,
234            handle,
235            function_id,
236            exchange,
237            std::time::Duration::from_millis(5000),
238        )
239        .await
240    }
241
242    /// Pull the configured image according to the current [PullPolicy].
243    async fn pull_image_if_needed(&self) -> Result<(), ProviderError> {
244        match self.pull_policy {
245            PullPolicy::Never => {}
246            PullPolicy::Always => {
247                self.pull_image().await?;
248            }
249            PullPolicy::IfMissing => match self.docker.inspect_image(&self.image).await {
250                Ok(_) => {}
251                Err(bollard::errors::Error::DockerResponseServerError {
252                    status_code: 404, ..
253                }) => {
254                    self.pull_image().await?;
255                }
256                Err(e) => {
257                    return Err(inspect_error_to_spawn_failed(&self.image, e));
258                }
259            },
260        }
261        Ok(())
262    }
263}
264
265/// Converts a non-404 Docker inspect error into a [`ProviderError::SpawnFailed`].
266///
267/// Extracted as a free function so the classification logic can be unit-tested
268/// without a live Docker daemon.
269fn inspect_error_to_spawn_failed(image: &str, e: bollard::errors::Error) -> ProviderError {
270    ProviderError::SpawnFailed(format!("failed to inspect image '{image}': {e}"))
271}
272
273/// Build the container creation request with security hardening.
274fn build_container_config(
275    image: &str,
276    host_port: u16,
277    instance_id: &str,
278    handle_id: &str,
279) -> bollard::models::ContainerCreateBody {
280    let labels = std::collections::HashMap::from([
281        ("camel.function.runner".to_string(), "true".to_string()),
282        ("camel.function.context".to_string(), handle_id.to_string()),
283        (
284            "camel.function.instance".to_string(),
285            instance_id.to_string(),
286        ),
287    ]);
288
289    bollard::models::ContainerCreateBody {
290        image: Some(image.to_string()),
291        env: Some(vec![
292            "PORT=8080".to_string(),
293            "DENO_NO_PROMPT=1".to_string(),
294            "DENO_DIR=/tmp/deno".to_string(),
295        ]),
296        labels: Some(labels),
297        exposed_ports: Some(vec!["8080/tcp".to_string()]),
298        host_config: Some(bollard::models::HostConfig {
299            port_bindings: Some(std::collections::HashMap::from([(
300                "8080/tcp".to_string(),
301                Some(vec![bollard::models::PortBinding {
302                    host_ip: Some("127.0.0.1".to_string()),
303                    host_port: Some(host_port.to_string()),
304                }]),
305            )])),
306            init: Some(true),
307            auto_remove: Some(false),
308            cap_drop: Some(vec!["ALL".to_string()]),
309            security_opt: Some(vec!["no-new-privileges".to_string()]),
310            readonly_rootfs: Some(true),
311            memory: Some(256 * 1024 * 1024),
312            nano_cpus: Some(1_000_000_000),
313            pids_limit: Some(100),
314            tmpfs: Some(std::collections::HashMap::from([(
315                "/tmp".to_string(),
316                String::new(),
317            )])),
318            dns_search: Some(vec![".".to_string()]),
319            ..Default::default()
320        }),
321        ..Default::default()
322    }
323}
324
325impl ContainerProvider {
326    /// Execute the image pull via bollard's `create_image` API.
327    async fn pull_image(&self) -> Result<(), ProviderError> {
328        use futures::StreamExt;
329
330        let options = bollard::query_parameters::CreateImageOptionsBuilder::default()
331            .from_image(&self.image)
332            .build();
333
334        let mut stream = self.docker.create_image(Some(options), None, None);
335        while let Some(item) = stream.next().await {
336            match item {
337                Ok(_) => {}
338                Err(e) => {
339                    return Err(ProviderError::SpawnFailed(format!(
340                        "image pull failed for '{}': {e}",
341                        self.image
342                    )));
343                }
344            }
345        }
346        Ok(())
347    }
348
349    fn spawn_log_forwarder(&self, container_id: String) {
350        use futures::StreamExt;
351
352        let docker = self.docker.clone();
353        let handle = tokio::spawn(async move {
354            let options = bollard::query_parameters::LogsOptions {
355                follow: true,
356                stdout: true,
357                stderr: true,
358                ..Default::default()
359            };
360            let mut stream = docker.logs(&container_id, Some(options));
361
362            while let Some(msg) = stream.next().await {
363                match msg {
364                    Ok(log_output) => {
365                        let text = match &log_output {
366                            bollard::container::LogOutput::StdOut { message } => {
367                                String::from_utf8_lossy(message).into_owned()
368                            }
369                            bollard::container::LogOutput::StdErr { message } => {
370                                String::from_utf8_lossy(message).into_owned()
371                            }
372                            _ => continue,
373                        };
374                        let trimmed = text.trim_end();
375                        if trimmed.is_empty() {
376                            continue;
377                        }
378                        match &log_output {
379                            bollard::container::LogOutput::StdOut { .. } => {
380                                tracing::info!(target: "camel_function::runner", "{trimmed}");
381                            }
382                            bollard::container::LogOutput::StdErr { .. } => {
383                                tracing::warn!(target: "camel_function::runner", "{trimmed}");
384                            }
385                            _ => {}
386                        }
387                    }
388                    Err(e) => {
389                        tracing::debug!(target: "camel_function::container", "log stream error: {e}");
390                        break;
391                    }
392                }
393            }
394        });
395        // allow-unwrap: Mutex cannot be poisoned in normal operation
396        self.log_forwarder_handles
397            .lock()
398            .expect("log_fwd mutex poisoned") // allow-unwrap
399            .push(handle);
400    }
401}
402
403impl super::sealed::Sealed for ContainerProvider {}
404
405#[async_trait::async_trait]
406impl FunctionProvider for ContainerProvider {
407    async fn spawn(&self, _key: &RunnerPoolKey) -> Result<RunnerHandle, ProviderError> {
408        let hash = blake3::hash(
409            format!(
410                "{}",
411                std::time::SystemTime::now()
412                    .duration_since(std::time::UNIX_EPOCH)
413                    .unwrap_or_default()
414                    .as_nanos()
415            )
416            .as_bytes(),
417        )
418        .to_hex();
419        let handle_id = format!("deno-{}", &hash[..16]);
420        let host_port = self.allocate_host_port().await?;
421
422        tracing::debug!(
423            target: "camel_function::container",
424            %handle_id,
425            image = %self.image,
426            "spawning container"
427        );
428
429        self.pull_image_if_needed().await?;
430
431        let config = build_container_config(&self.image, host_port, &self.instance_id, &handle_id);
432
433        let create_opts = bollard::query_parameters::CreateContainerOptions {
434            name: Some(handle_id.clone()),
435            ..Default::default()
436        };
437
438        let create_result = self
439            .docker
440            .create_container(Some(create_opts), config)
441            .await
442            .map_err(|e| ProviderError::SpawnFailed(format!("create container: {e}")))?;
443
444        let container_id = create_result.id;
445
446        if let Err(e) = self.docker.start_container(&container_id, None).await {
447            let _ = self.stop_and_remove_container(&container_id).await;
448            return Err(ProviderError::SpawnFailed(format!("start container: {e}")));
449        }
450
451        let endpoint = format!("http://127.0.0.1:{host_port}");
452
453        // Wait for the container to become healthy within boot_timeout.
454        let boot_timeout = self.boot_timeout;
455        let client = &self.client;
456        let endpoint_clone = endpoint.clone();
457        let ready_result = tokio::time::timeout(boot_timeout, async {
458            loop {
459                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
460                if client.health(&endpoint_clone).await.is_ok() {
461                    return;
462                }
463            }
464        })
465        .await;
466
467        if ready_result.is_err() {
468            let _ = self.stop_and_remove_container(&container_id).await;
469            return Err(ProviderError::SpawnFailed("container boot timeout".into()));
470        }
471
472        self.spawn_log_forwarder(container_id.clone());
473
474        self.containers_by_handle.insert(
475            handle_id.clone(),
476            ContainerEntry {
477                container_id,
478                endpoint,
479            },
480        );
481
482        Ok(RunnerHandle {
483            id: handle_id,
484            state: Arc::new(std::sync::Mutex::new(crate::pool::RunnerState::Booting)),
485            cancel: CancellationToken::new(),
486        })
487    }
488
489    async fn shutdown(&self, handle: RunnerHandle) -> Result<(), ProviderError> {
490        handle.cancel.cancel();
491        let entry = match self.containers_by_handle.remove(&handle.id) {
492            Some((_, entry)) => entry,
493            None => return Ok(()),
494        };
495        let _ = self.client.shutdown(&entry.endpoint).await;
496        self.stop_and_remove_container(&entry.container_id).await;
497        Ok(())
498    }
499
500    async fn health(&self, handle: &RunnerHandle) -> Result<FunctionHealthStatus, ProviderError> {
501        let endpoint = self
502            .containers_by_handle
503            .get(&handle.id)
504            .ok_or_else(|| ProviderError::HealthFailed(format!("unknown handle {}", handle.id)))?
505            .endpoint
506            .clone();
507        self.client.health(&endpoint).await
508    }
509
510    async fn register(
511        &self,
512        handle: &RunnerHandle,
513        def: &FunctionDefinition,
514    ) -> Result<(), ProviderError> {
515        let endpoint = self
516            .containers_by_handle
517            .get(&handle.id)
518            .ok_or_else(|| ProviderError::RegisterFailed(format!("unknown handle {}", handle.id)))?
519            .endpoint
520            .clone();
521        self.client.register(&endpoint, def).await
522    }
523
524    async fn unregister(
525        &self,
526        handle: &RunnerHandle,
527        id: &FunctionId,
528    ) -> Result<(), ProviderError> {
529        let endpoint = self
530            .containers_by_handle
531            .get(&handle.id)
532            .ok_or_else(|| {
533                ProviderError::UnregisterFailed(format!("unknown handle {}", handle.id))
534            })?
535            .endpoint
536            .clone();
537        self.client.unregister(&endpoint, id).await
538    }
539
540    async fn invoke(
541        &self,
542        handle: &RunnerHandle,
543        id: &FunctionId,
544        ex: &Exchange,
545        timeout: std::time::Duration,
546    ) -> Result<ExchangePatch, ProviderError> {
547        let endpoint = self
548            .containers_by_handle
549            .get(&handle.id)
550            .ok_or_else(|| ProviderError::InvokeFailed(format!("unknown handle {}", handle.id)))?
551            .endpoint
552            .clone();
553        let resp = self.client.invoke(&endpoint, id, ex, timeout).await?;
554        if resp.ok {
555            let patch = resp.patch.unwrap_or_default();
556            Ok(patch
557                .to_exchange_patch()
558                .map_err(|e| ProviderError::InvokeFailed(e.to_string()))?)
559        } else {
560            let err = resp.error.unwrap_or_else(|| crate::protocol::ErrorWire {
561                kind: "unknown".into(),
562                message: "no error body".into(),
563                stack: None,
564            });
565            Err(ProviderError::InvokeFailed(format!(
566                "{}: {}",
567                err.kind, err.message
568            )))
569        }
570    }
571}
572
573impl Drop for ContainerProvider {
574    fn drop(&mut self) {
575        // allow-unwrap: Mutex cannot be poisoned in normal operation
576        let handles = std::mem::take(
577            &mut *self
578                .log_forwarder_handles
579                .lock()
580                .expect("log_fwd mutex poisoned"), // allow-unwrap
581        );
582        for handle in handles {
583            handle.abort();
584        }
585
586        if self.containers_by_handle.is_empty() {
587            return;
588        }
589        let docker = self.docker.clone();
590        let container_ids: Vec<String> = self
591            .containers_by_handle
592            .iter()
593            .map(|e| e.container_id.clone())
594            .collect();
595        match tokio::runtime::Handle::try_current() {
596            Ok(handle) => {
597                drop(handle.spawn(async move {
598                    for id in container_ids {
599                        let _ = docker.stop_container(&id, None).await;
600                        let _ = docker
601                            .remove_container(
602                                &id,
603                                Some(bollard::query_parameters::RemoveContainerOptions {
604                                    force: true,
605                                    ..Default::default()
606                                }),
607                            )
608                            .await;
609                    }
610                }));
611            }
612            Err(_) => {
613                tracing::warn!(target: "camel_function::container", "container cleanup skipped: no tokio runtime");
614            }
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[tokio::test]
624    async fn test_log_forwarder_handles_aborted_on_drop() {
625        // Test the handle-tracking mechanism in isolation (no Docker required).
626        // The ContainerProvider will use Mutex<Vec<JoinHandle>> + abort on Drop.
627        // Verify that pattern works: spawn task, abort it, handle finishes.
628        let handle = tokio::spawn(async {
629            std::future::pending::<()>().await;
630        });
631
632        // Abort the handle (simulating what Drop on ContainerProvider will do)
633        handle.abort();
634
635        // Give abort time to propagate
636        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
637        assert!(
638            handle.is_finished(),
639            "aborted join handle should report finished"
640        );
641    }
642
643    #[test]
644    fn inspect_non_404_becomes_spawn_failed() {
645        let err = bollard::errors::Error::DockerResponseServerError {
646            status_code: 500,
647            message: "internal server error".into(),
648        };
649        let result = inspect_error_to_spawn_failed("my-image:latest", err);
650        assert!(
651            matches!(result, ProviderError::SpawnFailed(ref msg) if msg.contains("my-image:latest")),
652            "expected SpawnFailed with image name, got: {result:?}"
653        );
654    }
655
656    #[test]
657    fn inspect_permission_denied_becomes_spawn_failed() {
658        let err = bollard::errors::Error::DockerResponseServerError {
659            status_code: 403,
660            message: "permission denied".into(),
661        };
662        let result = inspect_error_to_spawn_failed("private/image:1.0", err);
663        assert!(matches!(
664            result,
665            ProviderError::SpawnFailed(ref msg) if msg.contains("private/image:1.0")
666        ));
667    }
668
669    #[test]
670    fn container_config_has_security_hardening() {
671        let config =
672            build_container_config("denoland/deno:2.1.4", 8080, "test-instance", "test-handle");
673
674        let hc = config.host_config.expect("host_config must be set");
675
676        let cap_drop = hc.cap_drop.expect("cap_drop must be set");
677        assert!(
678            cap_drop.contains(&"ALL".to_string()),
679            "cap_drop must include ALL"
680        );
681
682        let security_opt = hc.security_opt.expect("security_opt must be set");
683        assert!(
684            security_opt.contains(&"no-new-privileges".to_string()),
685            "security_opt must include no-new-privileges"
686        );
687
688        assert_eq!(
689            hc.readonly_rootfs,
690            Some(true),
691            "readonly_rootfs must be true"
692        );
693
694        let mem = hc.memory.expect("memory limit must be set");
695        assert!(
696            mem > 0 && mem <= 512 * 1024 * 1024,
697            "memory must be a sane limit (got {mem})"
698        );
699
700        let cpus = hc.nano_cpus.expect("nano_cpus must be set");
701        assert!(cpus > 0, "nano_cpus must be positive");
702
703        let pids = hc.pids_limit.expect("pids_limit must be set");
704        assert!(
705            pids > 0 && pids <= 500,
706            "pids_limit must be a sane value (got {pids})"
707        );
708
709        let tmpfs = hc.tmpfs.expect("tmpfs must be set");
710        assert!(tmpfs.contains_key("/tmp"), "tmpfs must mount /tmp");
711
712        let dns_search = hc.dns_search.expect("dns_search must be set");
713        assert!(
714            dns_search.contains(&".".to_string()),
715            "dns_search must suppress defaults"
716        );
717
718        let env = config.env.expect("env must be set");
719        assert!(
720            env.iter().any(|e| e.contains("DENO_DIR=/tmp")),
721            "env must set DENO_DIR=/tmp for readonly rootfs"
722        );
723    }
724}