use crate::version::{VersionPattern, CompareOutcome}; use crate::docker::DockerRef; use crate::db::{Db, Image}; use crate::registry::Registries; use crate::metrics; use chrono::{DateTime, Utc}; use std::ops::Range; #[derive(Debug)] pub struct FilePatch { pub position: Range, pub content: String, } pub struct FileInput { pub path: std::path::PathBuf, pub content: String, pub images: Vec>, } pub fn update_images(now: &DateTime, db: &dyn Db, reg: &dyn Registries, files: &[FileInput], outcomes: &mut Vec, String>>) { let mut file_indices: Vec = vec![]; let mut image_ranges: Vec> = vec![]; let mut imgs: Vec = vec![]; let mut images: Vec = vec![]; for (file_idx, file) in files.iter().enumerate() { for image in &file.images { let img = DockerRef::parse(&file.content, image); let registry = img.registry.as_ref() .map(|x| &file.content[x.clone()]) .unwrap_or("registry.hub.docker.com"); let image_name = &file.content[img.image.clone()]; let image_record = match db.get_image(registry, image_name) { Some(x) => x, None => { let mut new_image = Image { id: 0, registry: registry.to_string(), image: image_name.to_string(), expires_at: DateTime::::UNIX_EPOCH, }; db.insert_image(&mut new_image); new_image }, }; file_indices.push(file_idx); image_ranges.push(image.clone()); imgs.push(img); images.push(image_record); } } // Cache refresh loop: refresh all expired images in the database let mut image_errors: std::collections::HashMap = std::collections::HashMap::new(); let mut refreshed_images: std::collections::HashSet = std::collections::HashSet::new(); for expired in db.get_expired_images(now) { let cache_max_age = reg.get_cache_ttl(&expired.registry); let cached_tags = db.get_tags_sorted(expired.id); match reg.get_tags(&expired.registry, &expired.image) { Some(mut fetched_tags) => { fetched_tags.sort(); let mut existing = cached_tags.iter().peekable(); for t in &fetched_tags { while existing.peek().is_some_and(|e| *e < t) { let gone = existing.next().unwrap(); db.delete_tag(expired.id, gone); } if existing.peek() == Some(&t) { existing.next(); } else { db.insert_tag(expired.id, t, now); } } for gone in existing { db.delete_tag(expired.id, gone); } let new_expires_at = *now + cache_max_age; db.set_expires_at(expired.id, &new_expires_at); refreshed_images.insert(expired.id); } None => { let new_expires_at = *now + cache_max_age; db.set_expires_at(expired.id, &new_expires_at); image_errors.insert(expired.id, format!("image not found: {}", expired.image)); } } } let mut file_patches: Vec> = Vec::with_capacity(files.len()); let mut file_errors: Vec> = Vec::with_capacity(files.len()); for _ in 0..files.len() { file_patches.push(vec![]); file_errors.push(None); } for i in 0..imgs.len() { let file_idx = file_indices[i]; let file = &files[file_idx]; if file_errors[file_idx].is_some() { continue; } let img = &imgs[i]; let registry = img.registry.as_ref() .map(|x| &file.content[x.clone()]) .unwrap_or("registry.hub.docker.com"); let mut tag = img.tag.as_ref().map(|x| file.content[x.clone()].to_string()); let image_record = &images[i]; let image_id = image_record.id; println!("Checking image {}", &file.content[image_ranges[i].clone()]); metrics::get().images_checked.with_label_values(&[registry]).inc(); if let Some(err) = image_errors.get(&image_id) { file_errors[file_idx] = Some(err.clone()); continue; } let tags = db.get_tags_sorted(image_id); let tag_for_digest = tag.as_deref().unwrap_or("latest"); let cached_digest = if refreshed_images.contains(&image_id) { None // Cache was stale, ignore cached digest } else { db.get_tag_digest(image_id, tag_for_digest) }; if let Some(ref tag_str) = tag { let mut current = VersionPattern::parse(&tag_str); let mut new_tag = None; for candidate_str in &tags { let candidate = VersionPattern::parse(&candidate_str); match current.compare(&candidate) { CompareOutcome::Higher => { new_tag = Some(candidate_str.clone()); current = candidate; }, CompareOutcome::Lower => {}, CompareOutcome::Incompatible => {}, CompareOutcome::Identical => {}, } } if let Some(new_tag) = new_tag { tag = Some(new_tag.clone()); file_patches[file_idx].push(FilePatch { position: img.tag.clone().unwrap(), content: new_tag, }); metrics::get().images_updated.with_label_values(&[registry]).inc(); } } if let Some(ref digest) = img.digest { let tag_for_digest = tag.as_deref().unwrap_or("latest"); let digest_result = match cached_digest { Some(d) => Ok(d), None => { match reg.get_digest(registry, &image_record.image, tag_for_digest) { Some(fetched) => { db.update_tag_digest(image_id, tag_for_digest, &fetched, now); Ok(fetched) } None => Err(format!("digest not found for {}:{}", image_record.image, tag_for_digest)), } } }; let digest_string = match digest_result { Ok(d) => d, Err(msg) => { file_errors[file_idx] = Some(msg); continue; } }; if file.content[digest.clone()] != digest_string { file_patches[file_idx].push(FilePatch { position: img.digest.clone().unwrap(), content: digest_string, }); metrics::get().images_updated.with_label_values(&[registry]).inc(); } } } for file_idx in 0..files.len() { if let Some(msg) = file_errors[file_idx].take() { outcomes.push(Err(msg)); } else { let mut patches = std::mem::take(&mut file_patches[file_idx]); patches.sort_by_key(|e| e.position.start); outcomes.push(Ok(patches)); } } } #[cfg(test)] mod tests { use super::*; use crate::db::StubDb; use crate::registry::{StubRegistry, StubTag}; use chrono::TimeZone; #[test] fn stale_cache_fetches_from_registry() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let past_expiry = now - chrono::Duration::days(1); let mut img = Image { id: 0, registry: "registry.hub.docker.com".into(), image: "nginx".into(), expires_at: past_expiry }; db.insert_image(&mut img); db.insert_tag(img.id, "1.20", &past_expiry); db.insert_tag(img.id, "1.21", &past_expiry); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert_eq!(db.get_tags_sorted(img.id), vec!["1.21", "1.22"]); let got = db.get_image("registry.hub.docker.com", "nginx").unwrap(); assert!(got.expires_at > now); } #[test] fn fresh_cache_uses_cached_tags() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![]); let future_expiry = now + chrono::Duration::minutes(30); let mut img = Image { id: 0, registry: "registry.hub.docker.com".into(), image: "nginx".into(), expires_at: future_expiry }; db.insert_image(&mut img); db.insert_tag(img.id, "1.21", &now); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].as_ref().unwrap().is_empty()); } #[test] fn no_patch_when_at_highest() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].as_ref().unwrap().is_empty()); } #[test] fn patch_to_higher_version() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert_eq!(outcomes[0].as_ref().unwrap()[0].content, "1.22"); } #[test] fn picks_highest_of_multiple() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.25".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000003".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert_eq!(outcomes[0].as_ref().unwrap()[0].content, "1.25"); } #[test] fn no_tag_skips_comparison() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "latest".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx@sha256:0000000000000000000000000000000000000000000000000000000000000002".into(), images: vec![7..84] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0].content, "sha256:0000000000000000000000000000000000000000000000000000000000000001"); } #[test] fn uses_cached_digest_when_fresh() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![]); let future_expiry = now + chrono::Duration::minutes(30); let mut img = Image { id: 0, registry: "registry.hub.docker.com".into(), image: "nginx".into(), expires_at: future_expiry }; db.insert_image(&mut img); db.insert_tag(img.id, "1.21", &now); db.update_tag_digest(img.id, "1.21", "sha256:0000000000000000000000000000000000000000000000000000000000000003", &now); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0].content, "sha256:0000000000000000000000000000000000000000000000000000000000000003"); } #[test] fn fetches_digest_when_not_cached() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000004".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0].content, "sha256:0000000000000000000000000000000000000000000000000000000000000004"); } #[test] fn no_patch_when_digest_matches() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000002".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].as_ref().unwrap().is_empty()); } #[test] fn patch_when_digest_differs() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0].content, "sha256:0000000000000000000000000000000000000000000000000000000000000002"); } #[test] fn digest_uses_updated_tag() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 2); assert_eq!(patches[0].content, "1.22"); assert_eq!(patches[1].content, "sha256:0000000000000000000000000000000000000000000000000000000000000002"); } #[test] fn error_when_image_not_found() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].is_err()); assert!(outcomes[0].as_ref().unwrap_err().contains("image not found")); let got = db.get_image("registry.hub.docker.com", "nginx").unwrap(); assert!(got.expires_at > now); } #[test] fn error_when_digest_not_found() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.22@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].is_err()); } #[test] fn multiple_images_produce_patches() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.0".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000003".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.2".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000004".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21\nimage: redis:6.0".into(), images: vec![7..17, 25..34] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches.len(), 2); } #[test] fn patches_sorted_by_position() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.0".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000003".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.2".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000004".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21\nimage: redis:6.0".into(), images: vec![7..17, 25..34] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert!(patches[0].position.start < patches[1].position.start); } #[test] fn multiple_files_independent() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![ FileInput { path: "/a".into(), content: "image: missing:1.0".into(), images: vec![7..18] }, FileInput { path: "/b".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }, ]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].is_err()); assert!(outcomes[1].is_ok()); assert_eq!(outcomes[1].as_ref().unwrap()[0].content, "1.22"); } #[test] fn stale_cache_ignores_cached_digest() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000005".into() }, ]); let past_expiry = now - chrono::Duration::days(1); let mut img = Image { id: 0, registry: "registry.hub.docker.com".into(), image: "nginx".into(), expires_at: past_expiry }; db.insert_image(&mut img); db.insert_tag(img.id, "1.21", &past_expiry); db.update_tag_digest(img.id, "1.21", "sha256:0000000000000000000000000000000000000000000000000000000000000003", &past_expiry); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21@sha256:0000000000000000000000000000000000000000000000000000000000000001".into(), images: vec![7..89] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); let patches = outcomes[0].as_ref().unwrap(); assert_eq!(patches[0].content, "sha256:0000000000000000000000000000000000000000000000000000000000000005"); } #[test] fn error_in_first_image_skips_remaining() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.0".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "redis".into(), tag: "6.2".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![FileInput { path: "/test".into(), content: "image: nginx:1.21\nimage: redis:6.0".into(), images: vec![7..17, 25..34] }]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert!(outcomes[0].is_err()); assert!(db.get_image("registry.hub.docker.com", "redis").is_some()); // Redis tags ARE populated (cache refresh happens before processing), but no patches are produced assert_eq!(db.get_tags_sorted(2), vec!["6.0", "6.2"]); } #[test] fn same_image_multiple_files_fetches_twice() { crate::metrics::init(); let now = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap(); let db = StubDb::default(); let reg = StubRegistry::new(vec![ StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.21".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001".into() }, StubTag { registry: "registry.hub.docker.com".into(), image: "nginx".into(), tag: "1.22".into(), digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002".into() }, ]); let files = vec![ FileInput { path: "/a".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }, FileInput { path: "/b".into(), content: "image: nginx:1.21".into(), images: vec![7..17] }, ]; let mut outcomes = vec![]; update_images(&now, &db, ®, &files, &mut outcomes); assert_eq!(outcomes[0].as_ref().unwrap()[0].content, "1.22"); assert_eq!(outcomes[1].as_ref().unwrap()[0].content, "1.22"); assert_eq!(reg.tags_calls.borrow()[&("registry.hub.docker.com".to_string(), "nginx".to_string())], 1); } }