mod utils;
use std::fmt;
use bollard::{image::BuildImageOptions, service::BuildInfo, Docker};
use futures::stream::StreamExt;
use thiserror::Error;
use tracing::{event, Level};
use crate::utils::tarball;
#[derive(Error, Debug)]
pub enum DockerfileError<'a> {
#[error("Building image via Docker API failed: DockerfileImage: {dockerfile_image}")]
BuildImage {
error: String,
dockerfile_image: &'a DockerfileImage,
},
}
pub struct DockerfileImage {
repository: String,
tag: String,
path: String,
name: String,
#[cfg(feature = "dockertest")]
image: dockertest::Image,
}
impl std::fmt::Debug for DockerfileImage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DockerfileImage")
.field("repository", &self.repository)
.field("tag", &self.tag)
.field("path", &self.path)
.field("name", &self.name)
.field("image", &self.to_string())
.finish()
}
}
impl fmt::Display for DockerfileImage {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"repository: {}, tag: {}, path: {}, name: {}",
self.repository, self.tag, self.path, self.name
)
}
}
impl DockerfileImage {
pub fn with_dockerfile<T: ToString>(
repository: T,
tag: Option<T>,
path: Option<T>,
name: Option<T>,
) -> DockerfileImage {
DockerfileImage {
repository: repository.to_string(),
tag: tag.map_or("latest".to_string(), |tag| tag.to_string()),
path: path.map_or("./dockerfile".to_string(), |path| path.to_string()),
name: name.map_or("Dockerfile".to_string(), |name| name.to_string()),
#[cfg(feature = "dockertest")]
image: dockertest::Image::with_repository(repository),
}
}
#[cfg(feature = "dockertest")]
pub(crate) fn image(&self) -> &dockertest::Image {
&self.image
}
pub async fn build(&self, client: &Docker) -> Result<(), DockerfileError> {
event!(
Level::INFO,
"building image: {}:{}",
&self.repository,
&self.tag
);
let options = BuildImageOptions::<&str> {
dockerfile: &self.name,
t: &format!("{}:{}", &self.repository, &self.tag), rm: true,
..Default::default()
};
let buf = tarball(&self.path, &self.name).unwrap();
let mut stream = client.build_image(options, None, Some(buf.into()));
while let Some(result) = stream.next().await {
match result {
Ok(intermitten_result) => {
let BuildInfo {
id,
stream: _,
error,
error_detail,
status,
progress,
progress_detail,
aux: _,
} = intermitten_result;
if error.is_some() {
event!(
Level::ERROR,
"build error {} {:?}",
error.clone().unwrap(),
error_detail.clone().unwrap()
);
} else {
event!(
Level::TRACE,
"build progress {} {:?} {:?} {:?}",
status.clone().unwrap_or_default(),
id.clone().unwrap_or_default(),
progress.clone().unwrap_or_default(),
progress_detail.clone().unwrap_or_default(),
);
};
}
Err(e) => {
let msg = e.to_string();
return Err(DockerfileError::BuildImage {
error: msg,
dockerfile_image: self,
});
}
}
}
event!(Level::DEBUG, "successfully built image");
Ok(())
}
}
#[cfg(test)]
mod tests {
use bollard::Docker;
use wiremock::{matchers::method, matchers::path, Mock, MockServer, ResponseTemplate};
use crate::DockerfileImage;
#[tokio::test]
async fn it_works() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/build"))
.respond_with(ResponseTemplate::new(200))
.expect(1..)
.mount(&mock_server)
.await;
let client =
Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
let img = DockerfileImage::with_dockerfile(
"dockertest-dockerfile/hello",
None,
Some("./dockerfiles/hello.dockerfile"),
None,
);
img.build(&client).await.unwrap();
let received_requests = mock_server.received_requests().await.unwrap();
let request = received_requests.get(0).unwrap();
dbg!(request);
let mut params = request.url.query_pairs();
assert!(
params.any(|x| x.0.eq("t")
&& x.1.contains("dockertest-dockerfile")
&& x.1.contains("hello")
&& x.1.contains("latest")),
"failure checking build image request contains tag"
);
}
#[tokio::test]
async fn it_works_custom_tag() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/build"))
.respond_with(ResponseTemplate::new(200))
.expect(1..)
.mount(&mock_server)
.await;
let client =
Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
let img = DockerfileImage::with_dockerfile(
"dockertest-dockerfile/hello",
Some("stable"),
Some("./dockerfiles/hello.dockerfile"),
None,
);
img.build(&client).await.unwrap();
let received_requests = mock_server.received_requests().await.unwrap();
let request = received_requests.get(0).unwrap();
dbg!(request);
let mut params = request.url.query_pairs();
assert!(
params.any(|x| x.0.eq("t")
&& x.1.contains("dockertest-dockerfile")
&& x.1.contains("hello")
&& x.1.contains("stable")),
"failure checking build image request contains tag"
);
}
#[tokio::test]
async fn handle_error() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/build"))
.respond_with(
ResponseTemplate::new(400)
.set_body_string(r#"[{"message":"Something went wrong!"}]"#),
)
.expect(1..)
.mount(&mock_server)
.await;
let client =
Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
let img = DockerfileImage::with_dockerfile(
"dockertest-dockerfile/hello",
Some("stable"),
Some("./dockerfiles/hello.dockerfile"),
None,
);
img.build(&client)
.await
.expect_err("Expected DockerfileError::BuildImage");
}
}