Skip to content

Commit 93ba72f

Browse files
committed
initial commit
0 parents  commit 93ba72f

19 files changed

+1445
-0
lines changed

.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/target
2+
**/*.rs.bk
3+
Cargo.lock
4+
/.idea
5+
/results
6+
*.swp

.justfile

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
ta:
2+
cargo test --tests
3+
4+
tu:
5+
cargo test --lib
6+
7+
ti:
8+
cargo test --test integration
9+

Cargo.toml

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "testcontainers-async"
3+
version = "0.1.0"
4+
edition = "2021"
5+
authors = [
6+
"Jimmie Fulton <[email protected]>"
7+
]
8+
9+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
10+
11+
[dependencies]
12+
async-trait = "0.1.52"
13+
bollard = "0.11"
14+
futures = "0.3"
15+
thiserror = "1.0"
16+
tokio = {version = "1.17.0", features = ["rt","macros"]}
17+
18+
[[test]]
19+
name = "integration"
20+
path = "tests/lib.rs"

LICENSE-MIT

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Jimmie Fulton
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Testcontainers Async Rust
2+
=========================

src/container.rs

+266
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
use crate::bollard::container::{InspectContainerOptions, RemoveContainerOptions};
2+
use crate::bollard::Docker;
3+
use async_trait::async_trait;
4+
use std::collections::HashMap;
5+
6+
pub use crate::errors::TestcontainerError;
7+
use crate::{DropAction, ImageSettings, Qualifier, Task};
8+
9+
const TESTCONTAINERS_DROP_ACTION: &str = "TESTCONTAINERS_DROP_ACTION";
10+
11+
#[async_trait]
12+
pub trait Container: Sized {
13+
fn attach(handle: ContainerHandle, settings: ContainerSettings) -> Self;
14+
15+
fn handle(&self) -> &ContainerHandle;
16+
17+
fn handle_mut(&mut self) -> &mut ContainerHandle;
18+
19+
fn settings(&self) -> &ContainerSettings;
20+
21+
fn with_drop_action(mut self, drop_action: DropAction) -> Self {
22+
self.handle_mut().set_drop_action(drop_action);
23+
self
24+
}
25+
26+
async fn host_port_for(&self, port: &str) -> Result<u16, TestcontainerError> {
27+
let result = self
28+
.handle()
29+
.docker
30+
.inspect_container(&self.handle().id, None::<InspectContainerOptions>)
31+
.await?;
32+
33+
if let Some(network_settings) = result.network_settings {
34+
if let Some(port_map) = network_settings.ports {
35+
for pair in port_map.iter() {
36+
if pair.0.starts_with(port) {
37+
if let Some(bindings) = pair.1 {
38+
if let Some(binding) = bindings.iter().next() {
39+
return Ok(binding
40+
.host_port
41+
.as_ref()
42+
.map(|port| {
43+
port.parse::<u16>()
44+
.expect("Docker ports are expected to be u16")
45+
})
46+
.unwrap());
47+
}
48+
} else {
49+
return Err(TestcontainerError::UnexposedPort {
50+
portspec: port.to_owned(),
51+
});
52+
}
53+
}
54+
}
55+
}
56+
}
57+
58+
Err(TestcontainerError::UndefinedPort {
59+
portspec: port.to_owned(),
60+
})
61+
}
62+
63+
async fn execute<T, R>(&self, task: T) -> Result<R, TestcontainerError>
64+
where
65+
T: Into<Box<dyn Task<Return = R> + 'static + Send + Sync>>,
66+
T: Send,
67+
R: 'static + Send + Sync,
68+
{
69+
let task = task.into();
70+
let result = task.execute(self.handle()).await?;
71+
Ok(result)
72+
}
73+
}
74+
75+
pub struct ContainerHandle {
76+
id: String,
77+
docker: Docker,
78+
drop_action: DropAction,
79+
}
80+
81+
impl ContainerHandle {
82+
pub fn new(id: String, docker: Docker) -> ContainerHandle {
83+
ContainerHandle {
84+
id,
85+
docker,
86+
drop_action: Default::default(),
87+
}
88+
}
89+
90+
pub fn id(&self) -> &str {
91+
self.id.as_str()
92+
}
93+
94+
pub fn drop_action(&self) -> &DropAction {
95+
&self.drop_action
96+
}
97+
98+
pub fn set_drop_action(&mut self, drop_action: DropAction) -> &Self {
99+
self.drop_action = drop_action;
100+
self
101+
}
102+
103+
pub fn with_drop_action(mut self, drop_action: DropAction) -> Self {
104+
self.drop_action = drop_action;
105+
self
106+
}
107+
108+
pub fn docker(&self) -> &Docker {
109+
&self.docker
110+
}
111+
}
112+
113+
impl Drop for ContainerHandle {
114+
fn drop(&mut self) {
115+
let mut drop_action = self.drop_action.clone();
116+
117+
if let Ok(value) = std::env::var(TESTCONTAINERS_DROP_ACTION) {
118+
match value.to_lowercase().as_str() {
119+
"remove" => drop_action = DropAction::Remove,
120+
"retain" => drop_action = DropAction::Retain,
121+
"stop" => drop_action = DropAction::Stop,
122+
value => eprintln!(
123+
"'{}' is not a valid value for {}",
124+
value, TESTCONTAINERS_DROP_ACTION
125+
),
126+
}
127+
}
128+
129+
match drop_action {
130+
DropAction::Remove => {
131+
let id = self.id.clone();
132+
let docker = self.docker.clone();
133+
let (sender, receiver) = std::sync::mpsc::channel();
134+
std::thread::spawn(move || {
135+
let rt = tokio::runtime::Runtime::new().unwrap();
136+
rt.block_on(async {
137+
eprintln!("Removing container {id}");
138+
let result = docker
139+
.remove_container(
140+
id.as_str(),
141+
Some(RemoveContainerOptions {
142+
force: true,
143+
..Default::default()
144+
}),
145+
)
146+
.await;
147+
148+
match result {
149+
Ok(_) => {}
150+
Err(error) => {
151+
eprintln!("Error removing container by id '{id}': {error}");
152+
}
153+
}
154+
155+
let _ = sender.send(());
156+
});
157+
});
158+
let _ = receiver.recv();
159+
}
160+
DropAction::Retain => println!("Retaining container {}", self.id),
161+
DropAction::Stop => {
162+
let id = self.id.clone();
163+
let docker = self.docker.clone();
164+
let (sender, receiver) = std::sync::mpsc::channel();
165+
std::thread::spawn(move || {
166+
let rt = tokio::runtime::Runtime::new().unwrap();
167+
rt.block_on(async {
168+
eprintln!("Stopping container {id}");
169+
let result = docker.stop_container(id.as_str(), None).await;
170+
171+
match result {
172+
Ok(_) => {}
173+
Err(error) => {
174+
eprintln!("Error stopping container by id '{id}': {error}");
175+
}
176+
}
177+
178+
let _ = sender.send(());
179+
});
180+
});
181+
let _ = receiver.recv();
182+
}
183+
}
184+
}
185+
}
186+
187+
pub struct ContainerSettings {
188+
name: String,
189+
qualifier: Qualifier,
190+
env: HashMap<String, Option<String>>,
191+
}
192+
193+
impl ContainerSettings {
194+
pub fn name(&self) -> &str {
195+
&self.name
196+
}
197+
198+
pub fn fullname(&self) -> String {
199+
match &self.qualifier {
200+
Qualifier::Tag(tag) => format!("{}:{}", self.name, tag),
201+
Qualifier::Digest(digest) => format!("{}@{}", self.name, digest),
202+
}
203+
}
204+
205+
pub fn qualifier(&self) -> &Qualifier {
206+
&self.qualifier
207+
}
208+
209+
pub fn environment(&self) -> &HashMap<String, Option<String>> {
210+
&self.env
211+
}
212+
}
213+
214+
impl From<&ImageSettings> for ContainerSettings {
215+
fn from(settings: &ImageSettings) -> Self {
216+
ContainerSettings {
217+
name: settings.name().to_owned(),
218+
qualifier: settings.qualifier().clone(),
219+
env: settings.environment().clone(),
220+
}
221+
}
222+
}
223+
224+
#[async_trait]
225+
pub trait ServiceContainer: Container {
226+
fn internal_service_port(&self) -> &str;
227+
228+
async fn service_port(&self) -> Result<u16, TestcontainerError> {
229+
self.host_port_for(self.internal_service_port()).await
230+
}
231+
}
232+
233+
#[async_trait]
234+
pub trait AdminContainer: Container {
235+
fn internal_admin_port(&self) -> &str;
236+
237+
async fn admin_port(&self) -> Result<u16, TestcontainerError> {
238+
self.host_port_for(self.internal_admin_port()).await
239+
}
240+
}
241+
242+
#[async_trait]
243+
pub trait DatabaseContainer: ServiceContainer {
244+
async fn protocol(&self) -> Result<&str, TestcontainerError>;
245+
246+
async fn username(&self) -> Result<&str, TestcontainerError>;
247+
248+
async fn password(&self) -> Result<&str, TestcontainerError>;
249+
250+
async fn database(&self) -> Result<&str, TestcontainerError>;
251+
252+
async fn jdbc_url(&self) -> Result<String, TestcontainerError>;
253+
254+
async fn connect_cli(&self) -> Result<String, TestcontainerError>;
255+
256+
async fn connect_url(&self) -> Result<String, TestcontainerError> {
257+
let username = self.username().await?;
258+
let password = self.password().await?;
259+
let protocol = self.protocol().await?;
260+
let port = self.service_port().await?;
261+
let database = self.database().await?;
262+
Ok(format!(
263+
"{protocol}://{username}:{password}@localhost:{port}/{database}"
264+
))
265+
}
266+
}

src/errors.rs

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#[derive(thiserror::Error, Debug)]
2+
pub enum TestcontainerError {
3+
#[error("Error: {message}")]
4+
Generic { message: String },
5+
#[error("Internal port {portspec} is not exposed.")]
6+
UnexposedPort { portspec: String },
7+
#[error("Request port {portspec} is not defined for this image.")]
8+
UndefinedPort { portspec: String },
9+
#[error("Docker Error")]
10+
DockerError {
11+
#[from]
12+
source: bollard::errors::Error,
13+
},
14+
}

0 commit comments

Comments
 (0)