Skip to main content

lightshuttle_runtime/
docker.rs

1//! Docker container runtime backed by the `bollard` crate.
2//!
3//! Exposes [`DockerRuntime`], the first concrete implementation of
4//! [`crate::ContainerRuntime`]. All Docker I/O is async and goes through a
5//! single `bollard::Docker` client stored in the struct.
6//!
7//! ## Network model
8//!
9//! Each project gets a dedicated user-defined bridge network named
10//! `lightshuttle-<project>` (non-alphanumeric characters replaced by `-`,
11//! lower-cased). Containers are attached to that network with their resource
12//! name as a DNS alias, so `${resources.db.host}` resolves to `db` inside
13//! the network. [`DockerRuntime::connect`] uses the platform default transport
14//! (Unix socket on Linux and macOS, named pipe on Windows).
15//!
16//! ## Container naming
17//!
18//! Each container is named `<project>_<resource>` as defined by
19//! [`lightshuttle_spec::ContainerSpec`]. The lifecycle manager removes any
20//! previous container with the same name before every `start` so that a re-up
21//! never collides with a stale entry.
22//!
23//! ## Port binding
24//!
25//! Published ports bind to `127.0.0.1` by default (see the private
26//! `DEFAULT_HOST_BIND_ADDRESS` constant), so managed services are never
27//! exposed to the wider network on a developer workstation. A manifest can
28//! request a broader bind via the `address:host:container` port notation.
29//!
30//! ## Labels
31//!
32//! Every container receives two labels: [`LABEL_PROJECT`] and
33//! [`LABEL_RESOURCE`]. These let the CLI implement `ps` and `down` without
34//! relying on in-memory state.
35
36use 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
66/// Docker container runtime backed by the `bollard` crate.
67///
68/// Connects to the local Docker daemon using the platform default transport
69/// (Unix socket on Linux and macOS, named pipe on Windows). Implements
70/// [`crate::ContainerRuntime`] so it can be passed to [`crate::LifecycleManager`].
71///
72/// # Example
73///
74/// ```rust,no_run
75/// use lightshuttle_runtime::DockerRuntime;
76///
77/// # fn example() -> lightshuttle_runtime::Result<()> {
78/// let runtime = DockerRuntime::connect()?;
79/// # Ok(())
80/// # }
81/// ```
82pub struct DockerRuntime {
83    client: Docker,
84}
85
86impl DockerRuntime {
87    /// Connect to the local Docker daemon using the platform default transport.
88    ///
89    /// Returns [`crate::RuntimeError::Connect`] when the daemon socket or named
90    /// pipe cannot be reached.
91    pub fn connect() -> Result<Self> {
92        let client = Docker::connect_with_local_defaults().map_err(RuntimeError::Connect)?;
93        Ok(Self { client })
94    }
95
96    /// Wrap an existing `bollard::Docker` client.
97    ///
98    /// Useful when the caller manages the client lifetime directly or needs a
99    /// custom transport (e.g. a TLS-secured remote daemon or a test double).
100    #[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    /// List every container labelled with `lightshuttle.project=<project>`.
122    ///
123    /// Includes stopped and dead containers, so the CLI can implement `ps`
124    /// and `down` without relying on in-memory state. The result is sorted
125    /// alphabetically by resource name.
126    ///
127    /// # Example
128    ///
129    /// ```rust,no_run
130    /// use lightshuttle_runtime::DockerRuntime;
131    ///
132    /// # async fn example() -> lightshuttle_runtime::Result<()> {
133    /// let runtime = DockerRuntime::connect()?;
134    /// let containers = runtime.list_managed("myapp").await?;
135    /// for c in &containers {
136    ///     println!("{}: {:?}", c.resource, c.status);
137    /// }
138    /// # Ok(())
139    /// # }
140    /// ```
141    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        // A process-unique id keeps concurrent BuildKit builds from
187        // colliding on the same gRPC session.
188        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
234/// Build a tar archive from `context`, respecting `.dockerignore`
235/// patterns found within. Returns the raw tar bytes (uncompressed).
236fn 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
274/// Build the Docker network name for a project.
275///
276/// Non-alphanumeric characters are replaced with `-` and the result is
277/// lower-cased so the name is valid across all Docker network name rules.
278fn 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            // 409 = another concurrent start already created the network.
315            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
541/// Docker label key set on every container managed by LightShuttle to
542/// carry the manifest project name.
543pub const LABEL_PROJECT: &str = "lightshuttle.project";
544
545/// Docker label key set on every container to carry the manifest
546/// resource name.
547pub const LABEL_RESOURCE: &str = "lightshuttle.resource";
548
549/// One entry returned by [`DockerRuntime::list_managed`].
550#[derive(Debug, Clone)]
551pub struct ManagedContainer {
552    /// Container identifier.
553    pub id: ContainerId,
554    /// Resource name as declared in the manifest.
555    pub resource: String,
556    /// Current lifecycle status.
557    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
577/// Default host bind address for published ports.
578///
579/// Loopback by default so a dev machine never exposes managed services
580/// (PostgreSQL, Redis, application ports) to the wider network. A
581/// manifest that needs a broader bind must request it explicitly via
582/// the `address:host:container` port mapping form.
583const 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
641/// Build a [`LogChunk`], extracting the Docker emission timestamp from
642/// the line prefix when present.
643fn 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
652/// Split a Docker log line into its emission timestamp and payload.
653///
654/// With `timestamps: true`, Docker prepends each line with an RFC3339
655/// nanosecond timestamp and a single space. When that prefix parses,
656/// the real emission time is returned and the prefix is stripped from
657/// the forwarded bytes. Otherwise the read time is used and the line is
658/// forwarded verbatim.
659fn 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
671/// Convert a `jiff` timestamp to a `SystemTime`, returning `None` for
672/// pre-epoch instants (never produced by container logs).
673fn 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        // SystemTime resolution is platform dependent (100ns on Windows),
732        // so compare the sub-second part at microsecond granularity.
733        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        // Docker still emits the trailing space then the (empty) line.
740        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        // A leading token that is not an RFC3339 timestamp falls back to
747        // the read time and forwards every byte, including the token.
748        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}