1use std::collections::HashMap;
37use std::path::Path;
38use std::pin::Pin;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::time::{Duration, Instant, SystemTime};
41
42use bollard::Docker;
43use bollard::container::LogOutput;
44use bollard::models::{
45 ContainerCreateBody, ContainerSummaryStateEnum, EndpointSettings, HealthConfig, HostConfig,
46 NetworkCreateRequest, NetworkingConfig, PortBinding as BollardPortBinding,
47};
48use bollard::query_parameters::{
49 BuildImageOptionsBuilder, BuilderVersion, CreateContainerOptionsBuilder,
50 CreateImageOptionsBuilder, ListContainersOptionsBuilder, LogsOptionsBuilder,
51 RemoveContainerOptionsBuilder, StartContainerOptions, StopContainerOptionsBuilder,
52};
53use bytes::Bytes;
54use futures::stream::{Stream, StreamExt};
55
56use crate::error::{Result, RuntimeError};
57use crate::runtime::{
58 ContainerId, ContainerRuntime, ContainerStatus, LogChunk, LogChunkStream, LogStream,
59};
60use lightshuttle_spec::{
61 ContainerSpec, HealthcheckSpec, ImageSource, PortBinding, VolumeBinding, VolumeSource,
62};
63
64const POLL_INTERVAL: Duration = Duration::from_millis(500);
65
66pub struct DockerRuntime {
83 client: Docker,
84}
85
86impl DockerRuntime {
87 pub fn connect() -> Result<Self> {
92 let client = Docker::connect_with_local_defaults().map_err(RuntimeError::Connect)?;
93 Ok(Self { client })
94 }
95
96 #[must_use]
101 pub fn from_client(client: Docker) -> Self {
102 Self { client }
103 }
104
105 async fn ensure_image(&self, image: &str) -> Result<()> {
106 let (from_image, tag) = split_image_ref(image);
107 let options = CreateImageOptionsBuilder::default()
108 .from_image(from_image)
109 .tag(tag)
110 .build();
111 let mut stream = self.client.create_image(Some(options), None, None);
112 while let Some(event) = stream.next().await {
113 event.map_err(|e| RuntimeError::ImagePull {
114 image: image.to_owned(),
115 source: e,
116 })?;
117 }
118 Ok(())
119 }
120
121 pub async fn list_managed(&self, project: &str) -> Result<Vec<ManagedContainer>> {
142 let label_filter = format!("{LABEL_PROJECT}={project}");
143 let mut filters: HashMap<String, Vec<String>> = HashMap::new();
144 filters.insert("label".to_owned(), vec![label_filter]);
145 let options = ListContainersOptionsBuilder::default()
146 .all(true)
147 .filters(&filters)
148 .build();
149 let summaries = self
150 .client
151 .list_containers(Some(options))
152 .await
153 .map_err(|source| RuntimeError::Inspect {
154 id: format!("project={project}"),
155 source,
156 })?;
157
158 let mut out = Vec::with_capacity(summaries.len());
159 for summary in summaries {
160 let Some(id) = summary.id else { continue };
161 let resource = summary
162 .labels
163 .as_ref()
164 .and_then(|labels| labels.get(LABEL_RESOURCE))
165 .cloned()
166 .unwrap_or_else(|| "<unknown>".to_owned());
167 let status = parse_summary_state(summary.state.as_ref());
168 out.push(ManagedContainer {
169 id: ContainerId::new(id),
170 resource,
171 status,
172 });
173 }
174 out.sort_by(|a, b| a.resource.cmp(&b.resource));
175 Ok(out)
176 }
177
178 async fn build_image(
179 &self,
180 context: &str,
181 dockerfile: &str,
182 build_args: &HashMap<String, String>,
183 target: Option<&str>,
184 tag: &str,
185 ) -> Result<()> {
186 static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
189
190 let context_owned = context.to_owned();
191 let tar_bytes =
192 tokio::task::spawn_blocking(move || build_tar_archive(Path::new(&context_owned)))
193 .await
194 .map_err(|join_err| {
195 RuntimeError::InvalidSpec(format!("tar build task panicked: {join_err}"))
196 })?
197 .map_err(|io_err| {
198 RuntimeError::InvalidSpec(format!("failed to build tar archive: {io_err}"))
199 })?;
200
201 let session_id = format!(
202 "lightshuttle-build-{}",
203 SESSION_COUNTER.fetch_add(1, Ordering::Relaxed)
204 );
205
206 let options = BuildImageOptionsBuilder::default()
207 .dockerfile(dockerfile)
208 .t(tag)
209 .rm(true)
210 .buildargs(build_args)
211 .target(target.unwrap_or(""))
212 .version(BuilderVersion::BuilderBuildKit)
213 .session(&session_id)
214 .build();
215
216 let mut stream = self.client.build_image(
217 options,
218 None,
219 Some(bollard::body_full(Bytes::from(tar_bytes))),
220 );
221 while let Some(event) = stream.next().await {
222 let info = event.map_err(RuntimeError::Build)?;
223 if let Some(detail) = info.error_detail {
224 let message = detail
225 .message
226 .unwrap_or_else(|| "unknown build error".to_owned());
227 return Err(RuntimeError::BuildFailed(message));
228 }
229 }
230 Ok(())
231 }
232}
233
234fn build_tar_archive(context: &Path) -> std::io::Result<Vec<u8>> {
237 use ignore::WalkBuilder;
238
239 let mut buf: Vec<u8> = Vec::new();
240 {
241 let mut builder = tar::Builder::new(&mut buf);
242 builder.follow_symlinks(false);
243
244 let walker = WalkBuilder::new(context)
245 .add_custom_ignore_filename(".dockerignore")
246 .git_ignore(false)
247 .git_exclude(false)
248 .git_global(false)
249 .hidden(false)
250 .build();
251
252 for entry in walker {
253 let entry = entry.map_err(|e| std::io::Error::other(format!("walk error: {e}")))?;
254 let path = entry.path();
255 let relative = match path.strip_prefix(context) {
256 Ok(p) if !p.as_os_str().is_empty() => p,
257 _ => continue,
258 };
259 let Some(file_type) = entry.file_type() else {
260 continue;
261 };
262 if file_type.is_dir() {
263 builder.append_dir(relative, path)?;
264 } else if file_type.is_file() {
265 let mut file = std::fs::File::open(path)?;
266 builder.append_file(relative, &mut file)?;
267 }
268 }
269 builder.finish()?;
270 }
271 Ok(buf)
272}
273
274fn network_name(project: &str) -> String {
279 let sanitized: String = project
280 .chars()
281 .map(|c| {
282 if c.is_alphanumeric() || c == '-' {
283 c
284 } else {
285 '-'
286 }
287 })
288 .collect::<String>()
289 .to_lowercase();
290 format!("lightshuttle-{sanitized}")
291}
292
293impl ContainerRuntime for DockerRuntime {
294 async fn ensure_project_network(&self, project: &str) -> Result<()> {
295 let name = network_name(project);
296
297 match self.client.inspect_network(&name, None).await {
298 Ok(_) => return Ok(()),
299 Err(bollard::errors::Error::DockerResponseServerError {
300 status_code: 404, ..
301 }) => {}
302 Err(e) => return Err(RuntimeError::NetworkCreate { name, source: e }),
303 }
304
305 let mut labels = HashMap::new();
306 labels.insert(LABEL_PROJECT.to_owned(), project.to_owned());
307 let config = NetworkCreateRequest {
308 name: name.clone(),
309 driver: Some("bridge".to_owned()),
310 labels: Some(labels),
311 ..Default::default()
312 };
313 match self.client.create_network(config).await {
314 Ok(_)
316 | Err(bollard::errors::Error::DockerResponseServerError {
317 status_code: 409, ..
318 }) => Ok(()),
319 Err(e) => Err(RuntimeError::NetworkCreate { name, source: e }),
320 }
321 }
322
323 async fn teardown_project_network(&self, project: &str) -> Result<()> {
324 let name = network_name(project);
325 match self.client.remove_network(&name).await {
326 Ok(())
327 | Err(bollard::errors::Error::DockerResponseServerError {
328 status_code: 404, ..
329 }) => Ok(()),
330 Err(e) => Err(RuntimeError::NetworkRemove { name, source: e }),
331 }
332 }
333
334 async fn start(&self, spec: &ContainerSpec) -> Result<ContainerId> {
335 let image_ref = match &spec.image {
336 ImageSource::Pull(image) => {
337 self.ensure_image(image).await?;
338 image.clone()
339 }
340 ImageSource::Build {
341 context,
342 dockerfile,
343 build_args,
344 target,
345 tag,
346 } => {
347 self.build_image(context, dockerfile, build_args, target.as_deref(), tag)
348 .await?;
349 tag.clone()
350 }
351 };
352
353 self.ensure_project_network(&spec.project).await?;
354
355 let net = network_name(&spec.project);
356 let mut endpoints = HashMap::new();
357 endpoints.insert(
358 net,
359 EndpointSettings {
360 aliases: Some(vec![spec.resource.clone()]),
361 ..Default::default()
362 },
363 );
364 let networking_config = NetworkingConfig {
365 endpoints_config: Some(endpoints),
366 };
367
368 let host_config = build_host_config(&spec.ports, &spec.volumes);
369 let exposed_ports = build_exposed_ports(&spec.ports);
370 let env = build_env(&spec.env);
371 let healthcheck = spec.healthcheck.as_ref().map(build_healthcheck);
372 let labels = build_labels(&spec.project, &spec.resource);
373
374 let config = ContainerCreateBody {
375 image: Some(image_ref),
376 env: Some(env),
377 entrypoint: spec.entrypoint.clone(),
378 cmd: spec.command.clone(),
379 working_dir: spec.working_dir.clone(),
380 host_config: Some(host_config),
381 exposed_ports: Some(exposed_ports),
382 healthcheck,
383 labels: Some(labels),
384 networking_config: Some(networking_config),
385 ..Default::default()
386 };
387
388 let create_options = CreateContainerOptionsBuilder::default()
389 .name(&spec.name)
390 .build();
391
392 let created = self
393 .client
394 .create_container(Some(create_options), config)
395 .await
396 .map_err(RuntimeError::Start)?;
397
398 self.client
399 .start_container(&created.id, None::<StartContainerOptions>)
400 .await
401 .map_err(RuntimeError::Start)?;
402
403 Ok(ContainerId::new(created.id))
404 }
405
406 async fn stop(&self, id: &ContainerId, grace: Duration) -> Result<()> {
407 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
408 let options = StopContainerOptionsBuilder::default()
409 .t(grace.as_secs() as i32)
410 .build();
411 match self.client.stop_container(id.as_str(), Some(options)).await {
412 Ok(())
413 | Err(bollard::errors::Error::DockerResponseServerError {
414 status_code: 304 | 404,
415 ..
416 }) => Ok(()),
417 Err(e) => Err(RuntimeError::Stop {
418 id: id.to_string(),
419 source: e,
420 }),
421 }
422 }
423
424 async fn remove(&self, name: &str) -> Result<()> {
425 let options = RemoveContainerOptionsBuilder::default().force(true).build();
426 match self.client.remove_container(name, Some(options)).await {
427 Ok(())
428 | Err(bollard::errors::Error::DockerResponseServerError {
429 status_code: 404, ..
430 }) => Ok(()),
431 Err(e) => Err(RuntimeError::Remove {
432 name: name.to_owned(),
433 source: e,
434 }),
435 }
436 }
437
438 async fn inspect(&self, id: &ContainerId) -> Result<ContainerStatus> {
439 let info = self
440 .client
441 .inspect_container(id.as_str(), None)
442 .await
443 .map_err(|e| match e {
444 bollard::errors::Error::DockerResponseServerError {
445 status_code: 404, ..
446 } => RuntimeError::NotFound(id.to_string()),
447 other => RuntimeError::Inspect {
448 id: id.to_string(),
449 source: other,
450 },
451 })?;
452
453 let state = info.state.as_ref();
454 let Some(state) = state else {
455 return Ok(ContainerStatus::Starting);
456 };
457
458 if matches!(state.running, Some(true)) {
459 if let Some(health) = &state.health {
460 return Ok(match health.status {
461 Some(bollard::models::HealthStatusEnum::HEALTHY) => ContainerStatus::Healthy,
462 Some(bollard::models::HealthStatusEnum::UNHEALTHY) => {
463 ContainerStatus::Unhealthy
464 }
465 _ => ContainerStatus::Running,
466 });
467 }
468 return Ok(ContainerStatus::Running);
469 }
470
471 if matches!(state.dead, Some(true))
472 || state.status == Some(bollard::models::ContainerStateStatusEnum::EXITED)
473 {
474 #[allow(clippy::cast_possible_truncation)]
475 let exit_code = state.exit_code.map(|c| c as i32);
476 return Ok(ContainerStatus::Stopped { exit_code });
477 }
478
479 Ok(ContainerStatus::Starting)
480 }
481
482 async fn wait_healthy(&self, id: &ContainerId, timeout: Duration) -> Result<()> {
483 let deadline = Instant::now() + timeout;
484 loop {
485 match self.inspect(id).await? {
486 ContainerStatus::Healthy | ContainerStatus::Running => return Ok(()),
487 ContainerStatus::Unhealthy => {
488 if Instant::now() >= deadline {
489 return Err(RuntimeError::Timeout {
490 operation: "wait_healthy",
491 after: timeout,
492 });
493 }
494 }
495 ContainerStatus::Starting => {}
496 ContainerStatus::Stopped { exit_code } => {
497 return Err(RuntimeError::InvalidSpec(format!(
498 "container `{id}` exited with code {exit_code:?} before becoming healthy"
499 )));
500 }
501 }
502 if Instant::now() >= deadline {
503 return Err(RuntimeError::Timeout {
504 operation: "wait_healthy",
505 after: timeout,
506 });
507 }
508 tokio::time::sleep(POLL_INTERVAL).await;
509 }
510 }
511
512 async fn logs(&self, id: &ContainerId, follow: bool) -> Result<LogChunkStream> {
513 let options = LogsOptionsBuilder::default()
514 .follow(follow)
515 .stdout(true)
516 .stderr(true)
517 .timestamps(true)
518 .build();
519 let stream = self.client.logs(id.as_str(), Some(options));
520 let mapped: Pin<Box<dyn Stream<Item = Result<LogChunk>> + Send>> =
521 Box::pin(stream.map(map_log_item));
522 Ok(mapped)
523 }
524}
525
526fn split_image_ref(image: &str) -> (&str, &str) {
527 image.split_once(':').unwrap_or((image, "latest"))
528}
529
530fn build_env(env: &HashMap<String, String>) -> Vec<String> {
531 env.iter().map(|(k, v)| format!("{k}={v}")).collect()
532}
533
534fn build_labels(project: &str, resource: &str) -> HashMap<String, String> {
535 let mut labels = HashMap::with_capacity(2);
536 labels.insert(LABEL_PROJECT.to_owned(), project.to_owned());
537 labels.insert(LABEL_RESOURCE.to_owned(), resource.to_owned());
538 labels
539}
540
541pub const LABEL_PROJECT: &str = "lightshuttle.project";
544
545pub const LABEL_RESOURCE: &str = "lightshuttle.resource";
548
549#[derive(Debug, Clone)]
551pub struct ManagedContainer {
552 pub id: ContainerId,
554 pub resource: String,
556 pub status: ContainerStatus,
558}
559
560fn parse_summary_state(state: Option<&ContainerSummaryStateEnum>) -> ContainerStatus {
561 match state {
562 Some(ContainerSummaryStateEnum::RUNNING) => ContainerStatus::Running,
563 Some(ContainerSummaryStateEnum::EXITED | ContainerSummaryStateEnum::DEAD) => {
564 ContainerStatus::Stopped { exit_code: None }
565 }
566 _ => ContainerStatus::Starting,
567 }
568}
569
570fn build_exposed_ports(ports: &[PortBinding]) -> Vec<String> {
571 ports
572 .iter()
573 .map(|p| format!("{}/tcp", p.container_port))
574 .collect()
575}
576
577const DEFAULT_HOST_BIND_ADDRESS: &str = "127.0.0.1";
584
585fn build_host_config(ports: &[PortBinding], volumes: &[VolumeBinding]) -> HostConfig {
586 let port_bindings = ports
587 .iter()
588 .map(|p| {
589 let host_ip = p
590 .host_address
591 .clone()
592 .unwrap_or_else(|| DEFAULT_HOST_BIND_ADDRESS.to_owned());
593 let bindings = vec![BollardPortBinding {
594 host_ip: Some(host_ip),
595 host_port: Some(p.host_port.to_string()),
596 }];
597 (format!("{}/tcp", p.container_port), Some(bindings))
598 })
599 .collect::<HashMap<_, _>>();
600
601 let binds: Vec<String> = volumes
602 .iter()
603 .filter_map(|v| match &v.source {
604 VolumeSource::HostPath(path) => Some(format!("{path}:{}", v.target)),
605 VolumeSource::Named(name) => Some(format!("{name}:{}", v.target)),
606 VolumeSource::Anonymous => None,
607 })
608 .collect();
609
610 HostConfig {
611 port_bindings: Some(port_bindings),
612 binds: if binds.is_empty() { None } else { Some(binds) },
613 ..Default::default()
614 }
615}
616
617fn build_healthcheck(hc: &HealthcheckSpec) -> HealthConfig {
618 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
619 HealthConfig {
620 test: Some(hc.test.clone()),
621 interval: Some(hc.interval.as_nanos() as i64),
622 timeout: Some(hc.timeout.as_nanos() as i64),
623 retries: Some(i64::from(hc.retries)),
624 start_period: Some(hc.start_period.as_nanos() as i64),
625 ..Default::default()
626 }
627}
628
629fn map_log_item(item: std::result::Result<LogOutput, bollard::errors::Error>) -> Result<LogChunk> {
630 match item {
631 Ok(LogOutput::StdErr { message }) => Ok(log_chunk(LogStream::Stderr, &message)),
632 Ok(
633 LogOutput::StdOut { message }
634 | LogOutput::Console { message }
635 | LogOutput::StdIn { message },
636 ) => Ok(log_chunk(LogStream::Stdout, &message)),
637 Err(e) => Err(RuntimeError::LogStream(e)),
638 }
639}
640
641fn log_chunk(stream: LogStream, message: &[u8]) -> LogChunk {
644 let (timestamp, bytes) = split_docker_timestamp(message);
645 LogChunk {
646 stream,
647 timestamp,
648 bytes,
649 }
650}
651
652fn split_docker_timestamp(message: &[u8]) -> (SystemTime, Vec<u8>) {
660 if let Some(space) = message.iter().position(|&b| b == b' ')
661 && let Ok(prefix) = std::str::from_utf8(&message[..space])
662 && let Ok(ts) = prefix.parse::<jiff::Timestamp>()
663 && let Some(system_time) = timestamp_to_system_time(ts)
664 {
665 let payload = message.get(space + 1..).unwrap_or(&[]).to_vec();
666 return (system_time, payload);
667 }
668 (SystemTime::now(), message.to_vec())
669}
670
671fn timestamp_to_system_time(ts: jiff::Timestamp) -> Option<SystemTime> {
674 let nanos = ts.as_nanosecond();
675 if nanos < 0 {
676 return None;
677 }
678 let secs = u64::try_from(nanos / 1_000_000_000).ok()?;
679 let subsec = u32::try_from(nanos % 1_000_000_000).ok()?;
680 Some(SystemTime::UNIX_EPOCH + Duration::new(secs, subsec))
681}
682
683#[cfg(test)]
684mod tests {
685 use super::{PortBinding, build_host_config};
686
687 fn host_ip_for(ports: &[PortBinding], key: &str) -> Option<String> {
688 let config = build_host_config(ports, &[]);
689 config
690 .port_bindings
691 .and_then(|map| map.get(key).cloned())
692 .flatten()
693 .and_then(|bindings| bindings.into_iter().next())
694 .and_then(|binding| binding.host_ip)
695 }
696
697 #[test]
698 fn unspecified_address_binds_to_loopback() {
699 let ports = vec![PortBinding {
700 container_port: 5432,
701 host_address: None,
702 host_port: 5432,
703 }];
704 assert_eq!(
705 host_ip_for(&ports, "5432/tcp").as_deref(),
706 Some("127.0.0.1")
707 );
708 }
709
710 #[test]
711 fn explicit_address_is_preserved() {
712 let ports = vec![PortBinding {
713 container_port: 80,
714 host_address: Some("0.0.0.0".to_owned()),
715 host_port: 8080,
716 }];
717 assert_eq!(host_ip_for(&ports, "80/tcp").as_deref(), Some("0.0.0.0"));
718 }
719
720 #[test]
721 fn timestamped_line_parses_emission_time_and_strips_prefix() {
722 use std::time::SystemTime;
723
724 let (ts, payload) =
725 super::split_docker_timestamp(b"2024-01-01T12:34:56.789012345Z hello world");
726
727 let elapsed = ts
728 .duration_since(SystemTime::UNIX_EPOCH)
729 .expect("post-epoch");
730 assert_eq!(elapsed.as_secs(), 1_704_112_496);
731 assert_eq!(elapsed.subsec_micros(), 789_012);
734 assert_eq!(payload, b"hello world");
735 }
736
737 #[test]
738 fn timestamped_line_without_payload_yields_empty_bytes() {
739 let (_ts, payload) = super::split_docker_timestamp(b"2024-01-01T00:00:00Z \n");
741 assert_eq!(payload, b"\n");
742 }
743
744 #[test]
745 fn untimestamped_line_is_forwarded_verbatim() {
746 let input = b"not-a-timestamp hello world";
749 let (_ts, payload) = super::split_docker_timestamp(input);
750 assert_eq!(payload, input);
751 }
752
753 #[test]
754 fn line_without_space_is_forwarded_verbatim() {
755 let input = b"singletoken";
756 let (_ts, payload) = super::split_docker_timestamp(input);
757 assert_eq!(payload, input);
758 }
759}