From 2756f682fbcbe4a98419bb15e461b478d06105c7 Mon Sep 17 00:00:00 2001 From: "Mark S." Date: Thu, 5 Dec 2024 21:10:40 -0500 Subject: [PATCH] feat: add support for user-configured labels (#756) PR adds support for applying user-configured labels to containers started by `testcontainers`. > [!NOTE] > This PR is a precursor to future support of [reusable containers](https://github.com/testcontainers/testcontainers-rs/issues/742) --- testcontainers/src/core/containers/request.rs | 7 +++ testcontainers/src/core/image/image_ext.rs | 40 ++++++++++++ testcontainers/src/runners/async_runner.rs | 63 ++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/testcontainers/src/core/containers/request.rs b/testcontainers/src/core/containers/request.rs index 81ceffaa..24595565 100644 --- a/testcontainers/src/core/containers/request.rs +++ b/testcontainers/src/core/containers/request.rs @@ -25,6 +25,7 @@ pub struct ContainerRequest { pub(crate) image_tag: Option, pub(crate) container_name: Option, pub(crate) network: Option, + pub(crate) labels: BTreeMap, pub(crate) env_vars: BTreeMap, pub(crate) hosts: BTreeMap, pub(crate) mounts: Vec, @@ -74,6 +75,10 @@ impl ContainerRequest { &self.network } + pub fn labels(&self) -> &BTreeMap { + &self.labels + } + pub fn container_name(&self) -> &Option { &self.container_name } @@ -190,6 +195,7 @@ impl From for ContainerRequest { image_tag: None, container_name: None, network: None, + labels: BTreeMap::default(), env_vars: BTreeMap::default(), hosts: BTreeMap::default(), mounts: Vec::new(), @@ -235,6 +241,7 @@ impl Debug for ContainerRequest { .field("image_tag", &self.image_tag) .field("container_name", &self.container_name) .field("network", &self.network) + .field("labels", &self.labels) .field("env_vars", &self.env_vars) .field("hosts", &self.hosts) .field("mounts", &self.mounts) diff --git a/testcontainers/src/core/image/image_ext.rs b/testcontainers/src/core/image/image_ext.rs index bdbd12af..ff0b3356 100644 --- a/testcontainers/src/core/image/image_ext.rs +++ b/testcontainers/src/core/image/image_ext.rs @@ -48,6 +48,23 @@ pub trait ImageExt { /// Sets the network the container will be connected to. fn with_network(self, network: impl Into) -> ContainerRequest; + /// Adds the specified label to the container. + /// + /// **Note**: all keys in the `org.testcontainers.*` namespace should be regarded + /// as reserved by `testcontainers` internally, and should not be expected or relied + /// upon to be applied correctly if supplied as a value for `key`. + fn with_label(self, key: impl Into, value: impl Into) -> ContainerRequest; + + /// Adds the specified labels to the container. + /// + /// **Note**: all keys in the `org.testcontainers.*` namespace should be regarded + /// as reserved by `testcontainers` internally, and should not be expected or relied + /// upon to be applied correctly if they are included in `labels`. + fn with_labels( + self, + labels: impl IntoIterator, impl Into)>, + ) -> ContainerRequest; + /// Adds an environment variable to the container. fn with_env_var(self, name: impl Into, value: impl Into) -> ContainerRequest; @@ -164,6 +181,29 @@ impl>, I: Image> ImageExt for RI { } } + fn with_label(self, key: impl Into, value: impl Into) -> ContainerRequest { + let mut container_req = self.into(); + + container_req.labels.insert(key.into(), value.into()); + + container_req + } + + fn with_labels( + self, + labels: impl IntoIterator, impl Into)>, + ) -> ContainerRequest { + let mut container_req = self.into(); + + container_req.labels.extend( + labels + .into_iter() + .map(|(key, value)| (key.into(), value.into())), + ); + + container_req + } + fn with_env_var( self, name: impl Into, diff --git a/testcontainers/src/runners/async_runner.rs b/testcontainers/src/runners/async_runner.rs index 47e9942e..870ab0fc 100644 --- a/testcontainers/src/runners/async_runner.rs +++ b/testcontainers/src/runners/async_runner.rs @@ -63,8 +63,20 @@ where .map(|(key, value)| format!("{key}:{value}")) .collect(); + let labels = HashMap::::from_iter( + container_req + .labels() + .iter() + .map(|(key, value)| (key.into(), value.into())) + .chain([( + "org.testcontainers.managed-by".into(), + "testcontainers".into(), + )]), + ); + let mut config: Config = Config { image: Some(container_req.descriptor()), + labels: Some(labels), host_config: Some(HostConfig { privileged: Some(container_req.privileged()), extra_hosts: Some(extra_hosts), @@ -297,6 +309,51 @@ mod tests { ImageExt, }; + /// Test that all user-supplied labels are added to containers started by `AsyncRunner::start` + #[tokio::test] + async fn async_start_should_apply_expected_labels() -> anyhow::Result<()> { + let mut labels = HashMap::from([ + ("foo".to_string(), "bar".to_string()), + ("baz".to_string(), "qux".to_string()), + ( + "org.testcontainers.managed-by".to_string(), + "the-time-wizard".to_string(), + ), + ]); + + let container = GenericImage::new("hello-world", "latest") + .with_labels(&labels) + .start() + .await?; + + let client = Client::lazy_client().await?; + + let container_labels = client + .inspect(container.id()) + .await? + .config + .unwrap_or_default() + .labels + .unwrap_or_default(); + + // the created labels and container labels shouldn't actually be identical, as the + // `org.testcontainers.managed-by: testcontainers` label is always unconditionally + // applied to all containers by `AsyncRunner::start`, with the value `testcontainers` + // being applied *last* explicitly so that even user-supplied values of the + // `org.testcontainers.managed-by` key will be overwritten + assert_ne!(&labels, &container_labels); + + // If we add the expected `managed-by` value though, they should then match + labels.insert( + "org.testcontainers.managed-by".to_string(), + "testcontainers".to_string(), + ); + + assert_eq!(labels, container_labels); + + Ok(()) + } + #[tokio::test] async fn async_run_command_should_expose_all_ports_if_no_explicit_mapping_requested( ) -> anyhow::Result<()> { @@ -500,7 +557,7 @@ mod tests { } // containers have been dropped, should clean up networks - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + tokio::time::sleep(Duration::from_secs(1)).await; let client = Client::lazy_client().await?; assert!(!client.network_exists("awesome-net-2").await?); Ok(()) @@ -596,7 +653,7 @@ mod tests { assert_eq!( expected_capability, - capabilities.get(0).expect("No capabilities added"), + capabilities.first().expect("No capabilities added"), "cap_add must contain {expected_capability}" ); @@ -623,7 +680,7 @@ mod tests { assert_eq!( expected_capability, - capabilities.get(0).expect("No capabilities dropped"), + capabilities.first().expect("No capabilities dropped"), "cap_drop must contain {expected_capability}" );