diff options
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | src/main.rs | 255 | ||||
| -rw-r--r-- | src/metrics.rs | 18 | ||||
| -rw-r--r-- | src/registry.rs | 481 |
4 files changed, 496 insertions, 261 deletions
@@ -22,5 +22,8 @@ tiny_http = "0.12" ureq = "3.1.4" yaml-rust2 = "0.10.4" +[features] +integration = [] + [dev-dependencies] tempfile = "3" diff --git a/src/main.rs b/src/main.rs index 946a99d..4f42d45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,17 +5,16 @@ mod manifest; mod dockerfile; mod metrics; mod db; +mod registry; -use crate::parser::*; use crate::version::{VersionPattern, CompareOutcome}; use crate::docker::DockerRef; use crate::manifest::ManifestFile; use crate::dockerfile::DockerfileFile; use crate::db::{Db, SqliteDb}; +use crate::registry::{Registry, HttpRegistry, AuthInfo, basic_auth}; -use base64::prelude::*; use rand::distr::{Alphanumeric, SampleString}; -use std::collections::HashMap; use std::ops::Range; use std::io::Read; use std::io::Seek; @@ -35,199 +34,6 @@ Options: ); } -#[derive(Debug)] -enum AuthMethod { - Basic, - Bearer { realm: String, service: String, scope: String }, -} - -impl AuthMethod { - fn from_header(header: &ureq::http::HeaderValue) -> Option<Self> { - let header_str = header.to_str().unwrap(); - let parse = parse_authenticate_header(header_str).unwrap(); - - if &header_str[parse.scheme.clone()] == "Basic" { - return Some(AuthMethod::Basic); - } - - if &header_str[parse.scheme.clone()] == "Bearer" { - return Some(AuthMethod::Bearer { - realm: header_str[parse.realm.unwrap()].to_string(), - scope: header_str[parse.scope.unwrap()].to_string(), - service: header_str[parse.service.unwrap()].to_string(), - }); - } - - return None; - } -} - -fn basic_auth(user: &str, pass: &str) -> String { - return BASE64_STANDARD.encode(format!("{}:{}", user, pass)); -} - -struct AuthInfo { - host: String, - auth: String, -} - -enum AuthStage { - Idle, - Authorized(String), -} - -struct AuthState { - info: AuthInfo, - stage: AuthStage, -} - -struct Auth { - states: HashMap<String, AuthState> -} - -impl Auth { - fn new(infos: Vec<AuthInfo>) -> Self { - let mut states = HashMap::new(); - for info in infos { - states.insert(info.host.clone(), AuthState { - info: info, - stage: AuthStage::Idle, - }); - } - - return Auth { - states - } - } -} - -impl AuthState{ - fn add_to_request<T>(&self, req: ureq::RequestBuilder<T>) -> ureq::RequestBuilder<T> { - if let AuthStage::Authorized(x) = &self.stage { - return req.header("Authorization", x); - } - - return req - } - - fn authenticate(&mut self, response: &ureq::http::Response<ureq::Body>) -> Result<(), String> { - match self.stage { - AuthStage::Authorized(_) => - // The token must have expired - self.stage = AuthStage::Idle, - AuthStage::Idle => {}, - } - - let auth_header = response.headers().get("www-authenticate").unwrap(); - match AuthMethod::from_header(auth_header) { - Some(AuthMethod::Basic) => { - let basic_auth = format!("Basic {}", self.info.auth); - self.stage = AuthStage::Authorized(basic_auth); - - return Ok(()); - }, - Some(AuthMethod::Bearer{realm, scope, service}) => { - let basic_auth = format!("Basic {}", self.info.auth); - - let url = format!("{}?service={}&scope={}", realm, service, scope); - - let body = ureq::get(url) - .config().http_status_as_error(false).build() - .header("Authorization", basic_auth) - .call(); - - let body = body.map_err(|x| format!("Server {} authentication request failed: {}", self.info.host, x.to_string()))? - .body_mut().read_to_string().expect(format!("Server {} responded with something non-string like", self.info.host).as_str()); - - let body = body.parse::<tinyjson::JsonValue>() - .unwrap(); - - let token = body["token"].get::<String>().unwrap(); - self.stage = AuthStage::Authorized(format!("Bearer {}", token)); - return Ok(()); - - }, - None => return Err(format!("Server {} provided us with a challenge, but we didn't understand it", self.info.host)), - } - } -} - -#[derive(Debug)] -enum RegistryError { - NotFound, - Other(String), -} - -fn perform_registry_request(registry: &str, url: &str, accept: &'static str, auth: &mut Auth) -> Result<ureq::http::Response<ureq::Body>, RegistryError> { - let mut state = auth.states.get_mut(registry); - - let url = format!("https://{}{}", registry, &url); - - let mut authentication_retry = false; - - loop { - let mut request = ureq::get(&url) - .header("Accept", accept) - .config().http_status_as_error(false).build(); - - if let Some(ref state) = state { - request = state.add_to_request(request); - } - - let start_time = std::time::Instant::now(); - let response = request.call().unwrap(); - let duration = start_time.elapsed().as_secs_f64(); - metrics::get().http_request_duration.with_label_values(&[registry]).observe(duration); - - if response.status() == 401 { - if !authentication_retry { - if let Some(ref mut state) = state { - match state.authenticate(&response) { - Ok(()) => { - metrics::get().auth_attempts.with_label_values(&[registry, "success"]).inc(); - authentication_retry = true; - continue; - } - Err(e) => { - metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc(); - return Err(RegistryError::Other(e)); - } - } - } else { - metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); - return Err(RegistryError::Other(format!("Server {} returned 401 but we have no credentials", registry))); - } - } else { - metrics::get().auth_attempts.with_label_values(&[registry, "failure"]).inc(); - metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); - return Err(RegistryError::Other(format!("Authentication failed"))); - } - } - - if response.status() == 429 { - metrics::get().rate_limits.with_label_values(&[registry]).inc(); - metrics::get().state.set(metrics::STATE_THROTTLED); - println!("Too many requests"); - std::thread::sleep(std::time::Duration::from_secs(8)); - metrics::get().state.set(metrics::STATE_ACTIVE); - continue; - } - - if response.status() == 404 { - metrics::get().http_request.with_label_values(&[registry, "not_found"]).inc(); - return Err(RegistryError::NotFound); - } - - if response.status() != 200 { - metrics::get().http_request.with_label_values(&[registry, "error"]).inc(); - return Err(RegistryError::Other(format!("Unexpected status code: {}", response.status()))); - } - - metrics::get().http_request.with_label_values(&[registry, "success"]).inc(); - return Ok(response); - } -} - struct Repository { url: String, key: Option<std::path::PathBuf>, @@ -244,7 +50,7 @@ struct FilePatch { } -fn update_images(db: &dyn Db, file: &str, auth: &mut Auth, img: DockerRef, edits: &mut Vec<FilePatch>) -> Result<(), String> { +fn update_images(db: &dyn Db, reg: &dyn Registry, file: &str, img: DockerRef, edits: &mut Vec<FilePatch>) -> Result<(), String> { let registry = img.registry.map(|x| &file[x]).unwrap_or("registry.hub.docker.com"); let mut tag = img.tag.as_ref().map(|x| file[x.clone()].to_string()); let image = &file[img.image.clone()]; @@ -268,38 +74,8 @@ fn update_images(db: &dyn Db, file: &str, auth: &mut Auth, img: DockerRef, edits let (tags, cached_digest): (Vec<String>, Option<String>) = if cache_stale { println!("Invalid in cache"); - let mut url = format!("/v2/{}/tags/list", &image); - let mut fetched_tags = vec![]; - loop { - let mut response = match perform_registry_request(registry, &url, "application/vnd.oci.image.index.v1+json", auth) { - Ok(r) => r, - Err(RegistryError::NotFound) => { - println!("Warning: image not found, skipping: {}", image); - return Ok(()); - }, - Err(RegistryError::Other(msg)) => return Err(msg), - }; - - let content_type = response.headers()["Content-Type"].to_str().unwrap(); - if !content_type.starts_with("application/json") { - return Err(format!("Unexpected Content-Type: {}", content_type)); - } - - let body = response.body_mut().read_to_string().unwrap(); - let body = body.parse::<tinyjson::JsonValue>().unwrap(); - - for it in body["tags"].get::<Vec<tinyjson::JsonValue>>().unwrap().iter() { - fetched_tags.push(it.get::<String>().unwrap().clone()); - } - - if let Some(link_header) = response.headers().get("link") { - let link_str = link_header.to_str().unwrap(); - let link = extract_next_page(&link_str).unwrap(); - url = link_str[link.next_uri.unwrap().clone()].to_string(); - } else { - break; - } - } + let mut fetched_tags = reg.get_tags(registry, image) + .ok_or_else(|| format!("image not found: {}", image))?; fetched_tags.sort(); let mut existing = cached_tags.iter().peekable(); @@ -361,17 +137,8 @@ fn update_images(db: &dyn Db, file: &str, auth: &mut Auth, img: DockerRef, edits let digest_string = match cached_digest { Some(d) => d, None => { - let url = format!("/v2/{}/manifests/{}", &file[img.image.clone()], tag_for_digest); - let response = match perform_registry_request(registry, &url, "application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json", auth) { - Ok(r) => r, - Err(RegistryError::NotFound) => { - println!("Warning: image not found, skipping: {}", image); - return Ok(()); - }, - Err(RegistryError::Other(msg)) => return Err(msg), - }; - - let fetched = response.headers()["docker-content-digest"].to_str().unwrap().to_string(); + let fetched = reg.get_digest(registry, image, tag_for_digest) + .ok_or_else(|| format!("digest not found for {}:{}", image, tag_for_digest))?; db.update_tag_digest(image_id, tag_for_digest, &fetched, now); @@ -481,7 +248,7 @@ fn is_yaml(path: &std::path::Path) -> bool { return false; } -fn run_tool(db: &dyn Db, auth: &mut Auth, repos: &Vec<Repository>, mut infile_paths: Vec<std::path::PathBuf>, overwrite: bool) { +fn run_tool(db: &dyn Db, reg: &dyn Registry, repos: &Vec<Repository>, mut infile_paths: Vec<std::path::PathBuf>, overwrite: bool) { for repo in repos { if repo.dest.exists() { let mut gitcmd = std::process::Command::new("git"); @@ -597,7 +364,7 @@ fn run_tool(db: &dyn Db, auth: &mut Auth, repos: &Vec<Repository>, mut infile_pa for ref image in images { println!("Checking image {}", &file_content[image.clone()]); let image_ref = DockerRef::parse(&file_content, image); - if let Err(msg) = update_images(db, &file_content, auth, image_ref, &mut edits) { + if let Err(msg) = update_images(db, reg, &file_content, image_ref, &mut edits) { failed = Some(msg); break; } @@ -952,7 +719,7 @@ fn main() { } } - let mut auth = Auth::new(auths); + let registry = HttpRegistry::new(auths); metrics::init(); metrics::get().state.set(metrics::STATE_IDLE); @@ -964,7 +731,7 @@ fn main() { metrics::get().state.set(metrics::STATE_ACTIVE); let run_start = std::time::Instant::now(); - run_tool(&sqlite_db, &mut auth, &repos, infile_paths.clone(), overwrite); + run_tool(&sqlite_db, ®istry, &repos, infile_paths.clone(), overwrite); let run_duration = run_start.elapsed().as_secs_f64(); metrics::get().run_duration.set(run_duration); diff --git a/src/metrics.rs b/src/metrics.rs index 95a4782..a7e8f11 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -22,8 +22,6 @@ pub struct Metrics { pub images_updated: CounterVec, pub http_request: CounterVec, pub http_request_duration: HistogramVec, - pub rate_limits: CounterVec, - pub auth_attempts: CounterVec, pub run_duration: Gauge, pub git_operations: CounterVec, pub db_query_duration: Histogram, @@ -53,7 +51,7 @@ impl Metrics { let http_request = CounterVec::new( Opts::new("vbump_http_request_total", "Total HTTP requests to registries"), - &["registry", "outcome"], + &["registry"], ).unwrap(); registry.register(Box::new(http_request.clone())).unwrap(); @@ -67,18 +65,6 @@ impl Metrics { ).unwrap(); registry.register(Box::new(http_request_duration.clone())).unwrap(); - let rate_limits = CounterVec::new( - Opts::new("vbump_rate_limits_total", "Rate limit (429) responses received"), - &["registry"], - ).unwrap(); - registry.register(Box::new(rate_limits.clone())).unwrap(); - - let auth_attempts = CounterVec::new( - Opts::new("vbump_auth_attempts_total", "Auth attempts (success/failure)"), - &["registry", "outcome"], - ).unwrap(); - registry.register(Box::new(auth_attempts.clone())).unwrap(); - let run_duration = Gauge::with_opts( Opts::new("vbump_run_duration_seconds", "Duration of last complete scan"), ).unwrap(); @@ -105,8 +91,6 @@ impl Metrics { images_updated, http_request, http_request_duration, - rate_limits, - auth_attempts, run_duration, git_operations, db_query_duration, diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..afd2794 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,481 @@ +use crate::parser::{parse_authenticate_header, extract_next_page}; +use base64::prelude::*; +use std::cell::RefCell; +use std::collections::HashMap; + +pub trait Registry { + fn get_tags(&self, registry: &str, image: &str) -> Option<Vec<String>>; + fn get_digest(&self, registry: &str, image: &str, tag: &str) -> Option<String>; +} + +#[derive(Debug)] +enum AuthMethod { + Basic, + Bearer { realm: String, service: String, scope: String }, +} + +impl AuthMethod { + fn from_header(header: &ureq::http::HeaderValue) -> Option<Self> { + let header_str = header.to_str().unwrap(); + let parse = parse_authenticate_header(header_str).unwrap(); + + if &header_str[parse.scheme.clone()] == "Basic" { + return Some(AuthMethod::Basic); + } + + if &header_str[parse.scheme.clone()] == "Bearer" { + return Some(AuthMethod::Bearer { + realm: header_str[parse.realm.unwrap()].to_string(), + scope: header_str[parse.scope.unwrap()].to_string(), + service: header_str[parse.service.unwrap()].to_string(), + }); + } + + return None; + } +} + +pub fn basic_auth(user: &str, pass: &str) -> String { + return BASE64_STANDARD.encode(format!("{}:{}", user, pass)); +} + +pub struct AuthInfo { + pub host: String, + pub auth: String, +} + +enum AuthStage { + Idle, + Authorized(String), +} + +struct AuthState { + info: AuthInfo, + stage: AuthStage, +} + +struct Auth { + states: HashMap<String, AuthState>, +} + +impl Auth { + fn new(infos: Vec<AuthInfo>) -> Self { + let mut states = HashMap::new(); + for info in infos { + states.insert(info.host.clone(), AuthState { + info: info, + stage: AuthStage::Idle, + }); + } + + return Auth { states }; + } +} + +impl AuthState { + fn add_to_request<T>(&self, req: ureq::RequestBuilder<T>) -> ureq::RequestBuilder<T> { + if let AuthStage::Authorized(x) = &self.stage { + return req.header("Authorization", x); + } + + return req; + } + + fn authenticate(&mut self, response: &ureq::http::Response<ureq::Body>) -> Result<(), String> { + match self.stage { + AuthStage::Authorized(_) => + // The token must have expired + self.stage = AuthStage::Idle, + AuthStage::Idle => {} + } + + let auth_header = response.headers().get("www-authenticate").unwrap(); + match AuthMethod::from_header(auth_header) { + Some(AuthMethod::Basic) => { + let basic_auth = format!("Basic {}", self.info.auth); + self.stage = AuthStage::Authorized(basic_auth); + + return Ok(()); + } + Some(AuthMethod::Bearer { realm, scope, service }) => { + let basic_auth = format!("Basic {}", self.info.auth); + + let url = format!("{}?service={}&scope={}", realm, service, scope); + + let body = ureq::get(url) + .config() + .http_status_as_error(false) + .build() + .header("Authorization", basic_auth) + .call(); + + let body = body + .map_err(|x| { + format!( + "Server {} authentication request failed: {}", + self.info.host, + x.to_string() + ) + })? + .body_mut() + .read_to_string() + .expect( + format!( + "Server {} responded with something non-string like", + self.info.host + ) + .as_str(), + ); + + let body = body.parse::<tinyjson::JsonValue>().unwrap(); + + let token = body["token"].get::<String>().unwrap(); + self.stage = AuthStage::Authorized(format!("Bearer {}", token)); + return Ok(()); + } + None => { + return Err(format!( + "Server {} provided us with a challenge, but we didn't understand it", + self.info.host + )) + } + } + } +} + +#[derive(Debug)] +enum RegistryError { + NotFound, + Other(String), +} + +pub struct HttpRegistry { + auth: RefCell<Auth>, +} + +impl HttpRegistry { + pub fn new(infos: Vec<AuthInfo>) -> Self { + return HttpRegistry { + auth: RefCell::new(Auth::new(infos)), + }; + } + + fn perform_request( + &self, + registry: &str, + url: &str, + accept: &'static str, + ) -> Result<ureq::http::Response<ureq::Body>, RegistryError> { + let mut auth = self.auth.borrow_mut(); + let mut state = auth.states.get_mut(registry); + + let url = format!("https://{}{}", registry, &url); + + let mut authentication_retry = false; + + loop { + let mut request = ureq::get(&url) + .header("Accept", accept) + .config() + .http_status_as_error(false) + .build(); + + if let Some(ref state) = state { + request = state.add_to_request(request); + } + + let response = { + let _timer = crate::metrics::get() + .http_request_duration + .with_label_values(&[registry]) + .start_timer(); + request.call().unwrap() + }; + crate::metrics::get() + .http_request + .with_label_values(&[registry]) + .inc(); + + if response.status() == 401 { + if !authentication_retry { + if let Some(ref mut state) = state { + match state.authenticate(&response) { + Ok(()) => { + authentication_retry = true; + continue; + } + Err(e) => { + return Err(RegistryError::Other(e)); + } + } + } else { + return Err(RegistryError::Other(format!( + "Server {} returned 401 but we have no credentials", + registry + ))); + } + } else { + return Err(RegistryError::Other(format!("Authentication failed"))); + } + } + + if response.status() == 429 { + crate::metrics::get().state.set(crate::metrics::STATE_THROTTLED); + println!("Too many requests"); + std::thread::sleep(std::time::Duration::from_secs(8)); + crate::metrics::get().state.set(crate::metrics::STATE_ACTIVE); + continue; + } + + if response.status() == 404 { + return Err(RegistryError::NotFound); + } + + if response.status() != 200 { + return Err(RegistryError::Other(format!( + "Unexpected status code: {}", + response.status() + ))); + } + + return Ok(response); + } + } +} + +impl Registry for HttpRegistry { + fn get_tags(&self, registry: &str, image: &str) -> Option<Vec<String>> { + let mut url = format!("/v2/{}/tags/list", image); + let mut tags = vec![]; + + loop { + let mut response = match self.perform_request( + registry, + &url, + "application/vnd.oci.image.index.v1+json", + ) { + Ok(r) => r, + Err(RegistryError::NotFound) => return None, + Err(RegistryError::Other(msg)) => { + println!("Error fetching tags: {}", msg); + return None; + } + }; + + let content_type = response.headers()["Content-Type"].to_str().unwrap(); + if !content_type.starts_with("application/json") { + println!("Unexpected Content-Type: {}", content_type); + return None; + } + + let body = response.body_mut().read_to_string().unwrap(); + let body = body.parse::<tinyjson::JsonValue>().unwrap(); + + for it in body["tags"].get::<Vec<tinyjson::JsonValue>>().unwrap().iter() { + tags.push(it.get::<String>().unwrap().clone()); + } + + if let Some(link_header) = response.headers().get("link") { + let link_str = link_header.to_str().unwrap(); + let link = extract_next_page(&link_str).unwrap(); + url = link_str[link.next_uri.unwrap().clone()].to_string(); + } else { + break; + } + } + + return Some(tags); + } + + fn get_digest(&self, registry: &str, image: &str, tag: &str) -> Option<String> { + let url = format!("/v2/{}/manifests/{}", image, tag); + let response = match self.perform_request( + registry, + &url, + "application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json", + ) { + Ok(r) => r, + Err(RegistryError::NotFound) => return None, + Err(RegistryError::Other(msg)) => { + println!("Error fetching digest: {}", msg); + return None; + } + }; + + let digest = response.headers()["docker-content-digest"] + .to_str() + .unwrap() + .to_string(); + return Some(digest); + } +} + +#[cfg(test)] +struct StubTag { + registry: String, + image: String, + tag: String, + digest: String, +} + +#[cfg(test)] +pub struct StubRegistry { + entries: RefCell<Vec<StubTag>>, +} + +#[cfg(test)] +impl Default for StubRegistry { + fn default() -> Self { + return StubRegistry { + entries: RefCell::new(vec![]), + }; + } +} + +#[cfg(test)] +impl StubRegistry { + pub fn add_tag(&self, registry: &str, image: &str, tag: &str, digest: &str) { + self.entries.borrow_mut().push(StubTag { + registry: registry.to_string(), + image: image.to_string(), + tag: tag.to_string(), + digest: digest.to_string(), + }); + } + + pub fn set_digest(&self, registry: &str, image: &str, tag: &str, digest: &str) { + for entry in self.entries.borrow_mut().iter_mut() { + if entry.registry == registry && entry.image == image && entry.tag == tag { + entry.digest = digest.to_string(); + return; + } + } + } +} + +#[cfg(test)] +impl Registry for StubRegistry { + fn get_tags(&self, registry: &str, image: &str) -> Option<Vec<String>> { + let mut tags = vec![]; + let mut found = false; + + for entry in self.entries.borrow().iter() { + if entry.registry == registry && entry.image == image { + tags.push(entry.tag.clone()); + found = true; + } + } + + if !found { + return None; + } + + return Some(tags); + } + + fn get_digest(&self, registry: &str, image: &str, tag: &str) -> Option<String> { + for entry in self.entries.borrow().iter() { + if entry.registry == registry && entry.image == image && entry.tag == tag { + return Some(entry.digest.clone()); + } + } + return None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_get_tags_returns_none_for_unknown(reg: &dyn Registry) { + assert_eq!(reg.get_tags("docker.io", "unknown"), None); + } + + fn test_get_tags_returns_all(reg: &dyn Registry, stub: &StubRegistry) { + stub.add_tag("docker.io", "nginx", "1.21", "sha256:abc"); + stub.add_tag("docker.io", "nginx", "1.20", "sha256:def"); + stub.add_tag("docker.io", "nginx", "latest", "sha256:ghi"); + let mut tags = reg.get_tags("docker.io", "nginx").unwrap(); + + tags.sort(); + assert_eq!(tags, vec!["1.20", "1.21", "latest"]); + } + + fn test_get_digest_returns_none_for_unknown(reg: &dyn Registry) { + assert_eq!(reg.get_digest("docker.io", "nginx", "1.21"), None); + } + + fn test_get_digest_returns_digest(reg: &dyn Registry, stub: &StubRegistry) { + stub.add_tag("docker.io", "nginx", "1.21", "sha256:abc123"); + + assert_eq!(reg.get_digest("docker.io", "nginx", "1.21"), Some("sha256:abc123".to_string())); + } + + fn test_set_digest_updates_existing(reg: &dyn Registry, stub: &StubRegistry) { + stub.add_tag("docker.io", "nginx", "1.21", "sha256:old"); + stub.set_digest("docker.io", "nginx", "1.21", "sha256:new"); + + assert_eq!(reg.get_digest("docker.io", "nginx", "1.21"), Some("sha256:new".to_string())); + } + + #[test] + fn conformance_stub_get_tags_unknown() { + let stub = StubRegistry::default(); + test_get_tags_returns_none_for_unknown(&stub); + } + + #[test] + fn conformance_stub_get_tags_all() { + let stub = StubRegistry::default(); + test_get_tags_returns_all(&stub, &stub); + } + + #[test] + fn conformance_stub_get_digest_unknown() { + let stub = StubRegistry::default(); + test_get_digest_returns_none_for_unknown(&stub); + } + + #[test] + fn conformance_stub_get_digest() { + let stub = StubRegistry::default(); + test_get_digest_returns_digest(&stub, &stub); + } + + #[test] + fn conformance_stub_set_digest() { + let stub = StubRegistry::default(); + test_set_digest_updates_existing(&stub, &stub); + } + + #[cfg(feature = "integration")] + fn test_http_get_tags(reg: &dyn Registry) { + let tags = reg.get_tags("registry.hub.docker.com", "library/nginx"); + assert!(tags.is_some()); + let tags = tags.unwrap(); + assert!(tags.contains(&"1.21".to_string())); + } + + #[cfg(feature = "integration")] + fn test_http_get_digest(reg: &dyn Registry) { + let digest = reg.get_digest("registry.hub.docker.com", "library/nginx", "1.21"); + assert!(digest.is_some()); + assert!(digest.unwrap().starts_with("sha256:")); + } + + #[test] + #[cfg(feature = "integration")] + fn conformance_http_get_tags() { + crate::metrics::init(); + let reg = HttpRegistry::new(vec![]); + test_http_get_tags(®); + } + + #[test] + #[cfg(feature = "integration")] + fn conformance_http_get_digest() { + crate::metrics::init(); + let reg = HttpRegistry::new(vec![]); + test_http_get_digest(®); + } +} |
