1mod utils;
2use std::fmt;
3
4use bollard::{image::BuildImageOptions, service::BuildInfo, Docker};
5use futures::stream::StreamExt;
6use thiserror::Error;
7use tracing::{event, Level};
8
9use crate::utils::tarball;
10
11#[derive(Error, Debug)]
12pub enum DockerfileError<'a> {
13 #[error("Building image via Docker API failed: DockerfileImage: {dockerfile_image}")]
14 BuildImage {
15 error: String,
16 dockerfile_image: &'a DockerfileImage,
17 },
18}
19
20pub struct DockerfileImage {
21 repository: String,
22 tag: String,
23 path: String,
24 name: String,
25 #[cfg(feature = "dockertest")]
26 image: dockertest::Image,
27}
28
29impl std::fmt::Debug for DockerfileImage {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 f.debug_struct("DockerfileImage")
32 .field("repository", &self.repository)
33 .field("tag", &self.tag)
34 .field("path", &self.path)
35 .field("name", &self.name)
36 .field("image", &self.to_string())
37 .finish()
38 }
39}
40
41impl fmt::Display for DockerfileImage {
42 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
43 write!(
44 f,
45 "repository: {}, tag: {}, path: {}, name: {}",
46 self.repository, self.tag, self.path, self.name
47 )
48 }
49}
50
51impl DockerfileImage {
52 pub fn with_dockerfile<T: ToString>(
53 repository: T,
54 tag: Option<T>,
55 path: Option<T>,
56 name: Option<T>,
57 ) -> DockerfileImage {
58 DockerfileImage {
59 repository: repository.to_string(),
60 tag: tag.map_or("latest".to_string(), |tag| tag.to_string()),
61 path: path.map_or("./dockerfile".to_string(), |path| path.to_string()),
62 name: name.map_or("Dockerfile".to_string(), |name| name.to_string()),
63 #[cfg(feature = "dockertest")]
64 image: dockertest::Image::with_repository(repository),
65 }
66 }
67
68 #[cfg(feature = "dockertest")]
69 pub(crate) fn image(&self) -> &dockertest::Image {
70 &self.image
71 }
72
73 pub async fn build(&self, client: &Docker) -> Result<(), DockerfileError> {
74 event!(
75 Level::INFO,
76 "building image: {}:{}",
77 &self.repository,
78 &self.tag
79 );
80 let options = BuildImageOptions::<&str> {
81 dockerfile: &self.name,
82 t: &format!("{}:{}", &self.repository, &self.tag), rm: true,
84 ..Default::default()
85 };
86
87 let buf = tarball(&self.path, &self.name).unwrap();
88
89 let mut stream = client.build_image(options, None, Some(buf.into()));
90 while let Some(result) = stream.next().await {
91 match result {
92 Ok(intermitten_result) => {
93 let BuildInfo {
94 id,
95 stream: _,
96 error,
97 error_detail,
98 status,
99 progress,
100 progress_detail,
101 aux: _,
102 } = intermitten_result;
103 if error.is_some() {
104 event!(
105 Level::ERROR,
106 "build error {} {:?}",
107 error.clone().unwrap(),
108 error_detail.clone().unwrap()
109 );
110 } else {
111 event!(
112 Level::TRACE,
113 "build progress {} {:?} {:?} {:?}",
114 status.clone().unwrap_or_default(),
115 id.clone().unwrap_or_default(),
116 progress.clone().unwrap_or_default(),
117 progress_detail.clone().unwrap_or_default(),
118 );
119 };
120 }
121 Err(e) => {
122 let msg = e.to_string();
123 return Err(DockerfileError::BuildImage {
124 error: msg,
125 dockerfile_image: self,
126 });
127 }
128 }
129 }
130
131 event!(Level::DEBUG, "successfully built image");
132 Ok(())
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use bollard::Docker;
139 use wiremock::{matchers::method, matchers::path, Mock, MockServer, ResponseTemplate};
140
141 use crate::DockerfileImage;
142
143 #[tokio::test]
144 async fn it_works() {
145 let mock_server = MockServer::start().await;
146
147 Mock::given(method("POST"))
148 .and(path("/build"))
149 .respond_with(ResponseTemplate::new(200))
150 .expect(1..)
151 .mount(&mock_server)
152 .await;
153 let client =
154 Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
155
156 let img = DockerfileImage::with_dockerfile(
157 "dockertest-dockerfile/hello",
158 None,
159 Some("./dockerfiles/hello.dockerfile"),
160 None,
161 );
162 img.build(&client).await.unwrap();
163
164 let received_requests = mock_server.received_requests().await.unwrap();
165 let request = received_requests.get(0).unwrap();
166 dbg!(request);
167 let mut params = request.url.query_pairs();
168 assert!(
169 params.any(|x| x.0.eq("t")
170 && x.1.contains("dockertest-dockerfile")
171 && x.1.contains("hello")
172 && x.1.contains("latest")),
173 "failure checking build image request contains tag"
174 );
175 }
176
177 #[tokio::test]
178 async fn it_works_custom_tag() {
179 let mock_server = MockServer::start().await;
180
181 Mock::given(method("POST"))
182 .and(path("/build"))
183 .respond_with(ResponseTemplate::new(200))
184 .expect(1..)
185 .mount(&mock_server)
186 .await;
187 let client =
188 Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
189
190 let img = DockerfileImage::with_dockerfile(
191 "dockertest-dockerfile/hello",
192 Some("stable"),
193 Some("./dockerfiles/hello.dockerfile"),
194 None,
195 );
196 img.build(&client).await.unwrap();
197
198 let received_requests = mock_server.received_requests().await.unwrap();
199 let request = received_requests.get(0).unwrap();
200 dbg!(request);
201 let mut params = request.url.query_pairs();
202 assert!(
203 params.any(|x| x.0.eq("t")
204 && x.1.contains("dockertest-dockerfile")
205 && x.1.contains("hello")
206 && x.1.contains("stable")),
207 "failure checking build image request contains tag"
208 );
209 }
210
211 #[tokio::test]
212 async fn handle_error() {
213 let mock_server = MockServer::start().await;
214
215 Mock::given(method("POST"))
216 .and(path("/build"))
217 .respond_with(
218 ResponseTemplate::new(400)
219 .set_body_string(r#"[{"message":"Something went wrong!"}]"#),
220 )
221 .expect(1..)
222 .mount(&mock_server)
223 .await;
224 let client =
225 Docker::connect_with_http(&mock_server.uri(), 4, bollard::API_DEFAULT_VERSION).unwrap();
226
227 let img = DockerfileImage::with_dockerfile(
228 "dockertest-dockerfile/hello",
229 Some("stable"),
230 Some("./dockerfiles/hello.dockerfile"),
231 None,
232 );
233 img.build(&client)
234 .await
235 .expect_err("Expected DockerfileError::BuildImage");
236 }
237}