aduana/
lib.rs

1//! A Simple Crate to Extract Image Details from a Docker Registry
2//!
3//! This crate provides a simple interface to retrieve all the images
4//! stored on a private registry, and retrieve the details per image as
5//! needed.
6//!
7//! Example:
8//! ```no_run
9//! use aduana::*;
10//!
11//! #[tokio::main]
12//! pub async fn main() -> Result<(), AduanaError> {
13//!
14//!     // Create an inspector instance pointing to your registry
15//!     let inspector = AduanaInspector::new("http://localhost:5000");
16//!     // Retrieve a list of images on the registry
17//!     let images = inspector.images().await?;
18//!
19//!     // Loop over the retrieved images
20//!     for image in images {
21//!         // For each tag of an image
22//!         for tag in image.tags() {
23//!             // Retrieve its details
24//!             let details = image.details(tag).await?;
25//!             println!("{:#?}", details);
26//!         }
27//!     }
28//!
29//!     Ok(())
30//! }
31//! ```
32
33mod registry;
34
35use std::collections::HashMap;
36
37use anyhow::{anyhow, Context, Result};
38use reqwest::{header::ACCEPT, Certificate, Client};
39use thiserror::Error;
40
41use registry::*;
42
43#[derive(Error, Debug)]
44pub enum AduanaError {
45    #[error("Cannot connect to {url}: {reason}")]
46    Connection { url: String, reason: String },
47    #[error(transparent)]
48    Runtime(#[from] anyhow::Error),
49}
50
51impl From<reqwest::Error> for AduanaError {
52    fn from(error: reqwest::Error) -> Self {
53        if error.is_connect() || error.is_builder() {
54            let url = error
55                .url()
56                .map(|url| url.to_string())
57                .unwrap_or_else(|| "invalid".to_string());
58            AduanaError::Connection {
59                url,
60                reason: error.to_string(),
61            }
62        } else {
63            AduanaError::Runtime(anyhow!(
64                "Failed to get images from {:?}. {:?}",
65                error.url(),
66                error
67            ))
68        }
69    }
70}
71
72#[derive(Debug, Clone)]
73pub struct AduanaImage<'a> {
74    inspector: &'a AduanaInspector,
75    name: String,
76    tags: Vec<String>,
77}
78
79#[derive(Debug, Clone)]
80pub struct ImageDetails {
81    pub name: String,
82    pub tag: String,
83    pub user: Option<String>,
84    pub env: Vec<String>,
85    pub cmd: Vec<String>,
86    pub working_dir: Option<String>,
87    pub labels: HashMap<String, String>,
88    pub arch: String,
89    pub created: String,
90}
91
92fn client(pem: &Option<Vec<u8>>) -> Result<Client, AduanaError> {
93    let mut builder = reqwest::Client::builder();
94
95    if let Some(bytes) = pem {
96        let cert = Certificate::from_pem(bytes)
97            .with_context(|| "Failed to parse PEM certificate".to_string())?;
98        builder = builder.add_root_certificate(cert);
99    }
100
101    let client = builder
102        .build()
103        .with_context(|| "Failed to build reqwest client!")?;
104
105    Ok(client)
106}
107
108impl<'a> AduanaImage<'a> {
109    /// The name of an image
110    pub fn name(&self) -> &str {
111        &self.name
112    }
113
114    /// The tags of this image
115    pub fn tags(&self) -> &[String] {
116        &self.tags
117    }
118
119    /// Retrieve the image details for a specific tag.
120    pub async fn details(&self, tag: &str) -> Result<ImageDetails, AduanaError> {
121        let url = format!(
122            "{}/v2/{}/manifests/{}",
123            &self.inspector.url, &self.name, tag
124        );
125        let client = client(&self.inspector.cert)?;
126        let response = client
127            .get(&url)
128            .header(
129                ACCEPT,
130                "application/vnd.docker.distribution.manifest.v2+json",
131            )
132            .send()
133            .await?;
134        let manifest: ResponseManifest = response.json().await?;
135        let blob = self.retrieve_blob(&manifest.config.digest).await?;
136
137        let result = ImageDetails {
138            name: self.name.clone(),
139            tag: tag.to_string(),
140            user: blob.config.user,
141            env: blob.config.env,
142            cmd: blob.config.cmd,
143            working_dir: blob.config.working_dir,
144            labels: blob.config.labels,
145            arch: blob.architecture,
146            created: blob.created,
147        };
148
149        Ok(result)
150    }
151
152    async fn retrieve_blob(&self, digest: &str) -> Result<ResponseConfigBlob, AduanaError> {
153        let url = format!("{}/v2/{}/blobs/{}", &self.inspector.url, &self.name, digest);
154        let client = client(&self.inspector.cert)?;
155        let response = client.get(&url).send().await?;
156        let details: ResponseConfigBlob = response.json().await?;
157        Ok(details)
158    }
159}
160
161#[derive(Clone)]
162pub struct AduanaInspector {
163    url: String,
164    cert: Option<Vec<u8>>,
165}
166
167impl std::fmt::Debug for AduanaInspector {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        write!(
170            f,
171            "AduanaInspector {{ url: {}, cert: {} }}",
172            &self.url,
173            self.cert.is_some()
174        )
175    }
176}
177
178impl AduanaInspector {
179    pub fn new(url: &str) -> Self {
180        AduanaInspector {
181            url: url.to_string(),
182            cert: None,
183        }
184    }
185
186    pub fn with_cert(mut self, pem: Vec<u8>) -> Self {
187        self.cert = Some(pem);
188        self
189    }
190
191    pub fn url(&self) -> &str {
192        &self.url
193    }
194
195    pub async fn images(&'_ self) -> Result<Vec<AduanaImage<'_>>, AduanaError> {
196        let url = format!("{}/v2/_catalog", &self.url);
197        let client = client(&self.cert)?;
198        let response = client.get(&url).send().await?;
199
200        let mut images = Vec::new();
201        let catalog: ResponseCatalog = response
202            .json()
203            .await
204            .with_context(|| "Failed to parse catalog response")?;
205        for name in catalog.repositories {
206            let image = self.retrieve_image(&name).await?;
207            let image = AduanaImage {
208                inspector: self,
209                name: image.name,
210                tags: image.tags,
211            };
212            images.push(image);
213        }
214        Ok(images)
215    }
216
217    async fn retrieve_image(&self, name: &str) -> Result<ResponseImage, AduanaError> {
218        let url = format!("{}/v2/{}/tags/list", &self.url, name);
219        let client = client(&self.cert)?;
220        let response = client.get(&url).send().await?;
221        let image: ResponseImage = response.json().await?;
222        Ok(image)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228
229    use std::fs::File;
230    use std::io::Read;
231
232    use super::*;
233
234    #[tokio::test]
235    #[ignore="requires a running local registry"]
236    async fn test_images() {
237        let mut pem = Vec::new();
238        let mut file = File::open("certs/ca.crt").unwrap();
239        file.read_to_end(&mut pem).unwrap();
240
241        let inspector = AduanaInspector::new("https://localhost:5000").with_cert(pem);
242        let images = inspector.images().await.unwrap();
243        println!("{:#?}", images);
244    }
245
246    #[tokio::test]
247    #[ignore="requires a running local registry"]
248    async fn test_details() {
249        let mut pem = Vec::new();
250        let mut file = File::open("certs/ca.crt").unwrap();
251        file.read_to_end(&mut pem).unwrap();
252
253        let inspector = AduanaInspector::new("https://localhost:5000").with_cert(pem);
254        let images = inspector.images().await.unwrap();
255
256        for image in images {
257            for tag in image.tags() {
258                let details = image.details(tag).await.unwrap();
259                println!("{:#?}", details);
260            }
261        }
262    }
263
264    #[tokio::test]
265    async fn wrong_url() {
266        let inspector = AduanaInspector::new(":xx:x");
267        match inspector.images().await {
268            Err(AduanaError::Connection { url, reason: _ }) => {
269                assert_eq!(&url, "invalid");
270            }
271            Err(other) => panic!("Unexpected error! {:#?}", other),
272            Ok(result) => panic!("Should not get result back! {:#?}", result),
273        }
274    }
275}