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 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
265fn inspect_error_to_spawn_failed(image: &str, e: bollard::errors::Error) -> ProviderError {
270 ProviderError::SpawnFailed(format!("failed to inspect image '{image}': {e}"))
271}
272
273fn 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 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 self.log_forwarder_handles
397 .lock()
398 .expect("log_fwd mutex poisoned") .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 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 let handles = std::mem::take(
577 &mut *self
578 .log_forwarder_handles
579 .lock()
580 .expect("log_fwd mutex poisoned"), );
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 let handle = tokio::spawn(async {
629 std::future::pending::<()>().await;
630 });
631
632 handle.abort();
634
635 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}