1use colored::{Color, Colorize};
2use serde::Deserialize;
3
4use crate::{
5 lock::{DockerTag, ResourceVersion, DEFAULT_NAMESPACE, DOCKER_HUB_HOST},
6 Dofigen, DofigenPatch, Error, Extend, ImageName, ImageVersion, Resource, Result,
7};
8use std::{
9 collections::{HashMap, HashSet},
10 fs,
11 io::Read,
12 str::FromStr,
13};
14
15const MAX_LOAD_STACK_SIZE: usize = 10;
16
17pub struct DofigenContext {
19 pub offline: bool,
20 pub update_file_resources: bool,
21 pub update_url_resources: bool,
22 pub update_docker_tags: bool,
23 pub display_updates: bool,
24 pub no_default_labels: bool,
25
26 load_resource_stack: Vec<Resource>,
28 resources: HashMap<Resource, ResourceVersion>,
29 used_resources: HashSet<Resource>,
30
31 images: HashMap<ImageName, DockerTag>,
33 used_images: HashSet<ImageName>,
34}
35
36impl DofigenContext {
37 pub(crate) fn current_resource(&self) -> Option<&Resource> {
40 self.load_resource_stack.last()
41 }
42
43 pub(crate) fn push_resource_stack(&mut self, resource: Resource) -> Result<()> {
44 let present = self.load_resource_stack.contains(&resource);
45 self.load_resource_stack.push(resource);
46
47 if present {
49 return Err(Error::Custom(format!(
50 "Circular dependency detected while loading resource {}",
51 self.load_resource_stack
52 .iter()
53 .map(|r| format!("{:?}", r))
54 .collect::<Vec<_>>()
55 .join(" -> "),
56 )));
57 }
58
59 if self.load_resource_stack.len() > MAX_LOAD_STACK_SIZE {
61 return Err(Error::Custom(format!(
62 "Max load stack size exceeded while loading resource {}",
63 self.load_resource_stack
64 .iter()
65 .map(|r| format!("{:?}", r))
66 .collect::<Vec<_>>()
67 .join(" -> "),
68 )));
69 }
70 Ok(())
71 }
72
73 pub(crate) fn pop_resource_stack(&mut self) -> Option<Resource> {
74 self.load_resource_stack.pop()
75 }
76
77 pub(crate) fn get_resource_content(&mut self, resource: Resource) -> Result<String> {
79 let load = match resource {
80 Resource::File(_) => self.update_file_resources,
81 Resource::Url(_) => self.update_url_resources,
82 } || !self.resources.contains_key(&resource);
83
84 let version = if load {
85 let version = self.load_resource_version(&resource)?;
86 let previous = self.resources.insert(resource.clone(), version.clone());
87
88 if self.display_updates {
90 let resource_name = resource.to_string();
91 if let Some(previous) = previous {
92 if previous.hash != version.hash {
93 println!(
94 "{:>20} {} {} -> {}",
95 "Update resource".color(Color::Green).bold(),
96 resource_name,
97 previous.hash,
98 version.hash
99 );
100 }
101 } else {
102 println!(
103 "{:>20} {} {}",
104 "Add resource".color(Color::Blue).bold(),
105 resource_name,
106 version.hash
107 );
108 }
109 }
110
111 version
112 } else {
113 self.resources[&resource].clone()
114 };
115
116 let content = version.content.clone();
117 self.used_resources.insert(resource);
118 Ok(content)
119 }
120
121 fn load_resource_version(&self, resource: &Resource) -> Result<ResourceVersion> {
123 let content = match resource.clone() {
124 Resource::File(path) => fs::read_to_string(path.clone())
125 .map_err(|err| Error::Custom(format!("Could not read file {:?}: {}", path, err)))?,
126 Resource::Url(url) => {
127 if self.offline {
128 return Err(Error::Custom(
129 "Offline mode can't load URL resources".to_string(),
130 ));
131 }
132 reqwest::blocking::get(url.as_ref())
133 .map_err(Error::from)?
134 .error_for_status()?
135 .text()
136 .map_err(Error::from)?
137 }
138 };
139 let version = ResourceVersion {
140 hash: sha256::digest(content.clone()),
141 content: content.clone(),
142 };
143 Ok(version)
144 }
145
146 fn clean_unused_resources(&mut self) {
147 for resource in self.resources.clone().keys() {
148 if !self.used_resources.contains(resource) {
149 let version = self.resources.remove(resource).unwrap();
150 if self.display_updates {
151 println!(
152 "{:>20} {} {}",
153 "Remove image".color(Color::Red).bold(),
154 resource.to_string(),
155 version.hash
156 );
157 }
158 }
159 }
160 }
161
162 pub(crate) fn get_image_tag(&mut self, image: &ImageName) -> Result<DockerTag> {
165 let image = image.fill();
166
167 let tag = if self.update_docker_tags || !self.images.contains_key(&image) {
168 let tag = self.load_image_tag(&image)?;
169 let previous = self.images.insert(image.clone(), tag.clone());
170
171 if self.display_updates {
173 let image_name = image.to_string();
174 if let Some(previous) = previous {
175 if previous.digest != tag.digest {
176 println!(
177 "{:>20} {} {} -> {}",
178 "Update image".color(Color::Green).bold(),
179 image_name,
180 previous.digest,
181 tag.digest
182 );
183 }
184 } else {
185 println!(
186 "{:>20} {} {}",
187 "Add image".color(Color::Blue).bold(),
188 image_name,
189 tag.digest
190 );
191 }
192 }
193
194 tag
195 } else {
196 self.images[&image].clone()
197 };
198
199 self.used_images.insert(image.clone());
200 Ok(tag)
201 }
202
203 fn load_image_tag(&mut self, image: &ImageName) -> Result<DockerTag> {
204 if self.offline {
205 return Err(Error::Custom(
206 "Offline mode can't load image tag".to_string(),
207 ));
208 }
209
210 let tag = match image
211 .version
212 .clone()
213 .ok_or(Error::Custom("No version found for image".into()))?
214 {
215 ImageVersion::Tag(tag) => tag,
216 _ => {
217 return Err(Error::Custom("Image version is not a tag".to_string()));
218 }
219 };
220
221 let host = image
222 .host
223 .clone()
224 .ok_or(Error::Custom("No host found for image".into()))?;
225 let port = image
226 .port
227 .clone()
228 .ok_or(Error::Custom("No port found for image".into()))?;
229 let port = if port == 443 {
230 String::new()
231 } else {
232 format!(":{port}")
233 };
234
235 let client = reqwest::blocking::Client::new();
236
237 let docker_tag = if self.load_from_api(host.as_str()) {
238 let mut repo = image.path.clone();
239 let namespace = if repo.contains("/") {
240 let mut parts = image.path.split("/");
241 let ret = parts.next().unwrap();
242 repo = parts.collect::<Vec<&str>>().join("/");
243 ret
244 } else {
245 DEFAULT_NAMESPACE
246 };
247 let request_url = format!(
248 "https://{DOCKER_HUB_HOST}/v2/namespaces/{namespace}/repositories/{repo}/tags/{tag}",
249 namespace = namespace,
250 repo = repo,
251 tag = tag
252 );
253 let response = client.get(&request_url).send().map_err(Error::from)?;
254
255 let response: DockerHubTagResponse = response.json().map_err(Error::from)?;
256 DockerTag {
257 digest: response
258 .digest
259 .or(response.images.get(0).map(|img| img.digest.clone()))
260 .ok_or(Error::Custom("No digest found in response".to_string()))?,
261 }
262 } else {
263 let request_url = format!(
264 "https://{host}{port}/v2/{path}/manifests/{tag}",
265 path = image.path,
266 tag = tag
267 );
268 let response = client.head(&request_url).send().map_err(Error::from)?;
269
270 let digest = response
271 .headers()
272 .get("Docker-Content-Digest")
273 .ok_or(Error::Custom("No digest found in response".to_string()))?;
274 let digest = digest
275 .to_str()
276 .map_err(|err| Error::display(err))?
277 .to_string();
278
279 DockerTag { digest }
280 };
281
282 Ok(docker_tag)
283 }
284
285 fn load_from_api(&self, host: &str) -> bool {
286 host == DOCKER_HUB_HOST || host == "docker.io"
287 }
288
289 fn clean_unused_images(&mut self) {
290 for image in self.images.clone().keys() {
291 if !self.used_images.contains(image) {
292 let tag = self.images.remove(image).unwrap();
293 if self.display_updates {
294 println!(
295 "{:>20} {} {}",
296 "Remove image".color(Color::Red).bold(),
297 image.to_string(),
298 tag.digest
299 );
300 }
301 }
302 }
303 }
304
305 pub(crate) fn used_resource_contents(&self) -> HashMap<Resource, ResourceVersion> {
308 self.used_resources
309 .iter()
310 .map(|res| (res.clone(), self.resources[res].clone()))
311 .collect()
312 }
313
314 pub(crate) fn used_image_tags(&self) -> HashMap<ImageName, DockerTag> {
315 self.used_images
316 .iter()
317 .map(|image| (image.clone(), self.images[image].clone()))
318 .collect()
319 }
320
321 pub fn image_updates(
324 &self,
325 previous: &DofigenContext,
326 ) -> Vec<UpdateCommand<ImageName, DockerTag>> {
327 let mut updates = vec![];
328
329 let mut previous_images = previous.images.clone();
330 let current_images = self.used_image_tags();
331
332 for (image, tag) in current_images {
333 if let Some(previous_tag) = previous_images.remove(&image) {
334 if tag.digest != previous_tag.digest {
335 updates.push(UpdateCommand::Update(image, tag, previous_tag))
336 }
337 } else {
338 updates.push(UpdateCommand::Add(image, tag))
339 }
340 }
341
342 for (image, tag) in previous_images {
343 updates.push(UpdateCommand::Remove(image, tag));
344 }
345
346 updates.sort();
347
348 updates
349 }
350
351 pub fn resource_updates(
352 &self,
353 previous: &DofigenContext,
354 ) -> Vec<UpdateCommand<Resource, ResourceVersion>> {
355 let mut updates = vec![];
356
357 let mut previous_resources = previous.resources.clone();
358 let current_resources = self.used_resource_contents();
359
360 for (resource, version) in current_resources
361 .into_iter()
362 .filter(|(r, _)| matches!(r, Resource::Url(_)))
363 {
364 if let Some(previous_version) = previous_resources.remove(&resource) {
365 if version.hash != previous_version.hash {
366 updates.push(UpdateCommand::Update(resource, version, previous_version))
367 }
368 } else {
369 updates.push(UpdateCommand::Add(resource, version))
370 }
371 }
372
373 for (resource, version) in previous_resources
374 .into_iter()
375 .filter(|(r, _)| matches!(r, Resource::Url(_)))
376 {
377 updates.push(UpdateCommand::Remove(resource, version));
378 }
379
380 updates.sort();
381
382 updates
383 }
384
385 pub fn parse_from_string(&mut self, input: &str) -> Result<Dofigen> {
479 self.merge_extended_image(
480 serde_yaml::from_str(input).map_err(|err| Error::Deserialize(err))?,
481 )
482 }
483
484 pub fn parse_from_reader<R: Read>(&mut self, reader: R) -> Result<Dofigen> {
577 self.merge_extended_image(
578 serde_yaml::from_reader(reader).map_err(|err| Error::Deserialize(err))?,
579 )
580 }
581
582 pub fn parse_from_resource(&mut self, resource: Resource) -> Result<Dofigen> {
607 let dofigen = resource.load(self)?;
608 self.merge_extended_image(dofigen)
609 }
610
611 fn merge_extended_image(&mut self, dofigen: Extend<DofigenPatch>) -> Result<Dofigen> {
612 Ok(dofigen.merge(self)?.into())
613 }
614
615 pub fn clean_unused(&mut self) {
616 self.clean_unused_resources();
617 self.clean_unused_images();
618 }
619
620 pub fn new() -> Self {
623 Self {
624 offline: false,
625 update_docker_tags: false,
626 update_file_resources: true,
627 update_url_resources: false,
628 display_updates: true,
629 no_default_labels: false,
630 load_resource_stack: vec![],
631 resources: HashMap::new(),
632 used_resources: HashSet::new(),
633 images: HashMap::new(),
634 used_images: HashSet::new(),
635 }
636 }
637
638 pub fn from(
639 resources: HashMap<Resource, ResourceVersion>,
640 images: HashMap<ImageName, DockerTag>,
641 ) -> Self {
642 Self {
643 offline: false,
644 update_docker_tags: false,
645 update_file_resources: true,
646 update_url_resources: false,
647 display_updates: true,
648 no_default_labels: false,
649 load_resource_stack: vec![],
650 resources,
651 used_resources: HashSet::new(),
652 images,
653 used_images: HashSet::new(),
654 }
655 }
656}
657
658#[derive(Debug, Deserialize, Clone, PartialEq, PartialOrd, Eq)]
659pub struct DockerHubTagResponse {
660 pub digest: Option<String>,
661 images: Vec<DockerTag>,
662}
663
664#[derive(PartialEq, PartialOrd, Eq)]
665pub enum UpdateCommand<K, V> {
666 Update(K, V, V),
667 Add(K, V),
668 Remove(K, V),
669}
670
671impl<K, V> Ord for UpdateCommand<K, V>
672where
673 K: Ord,
674 V: Ord,
675{
676 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
677 match (self, other) {
678 (
679 UpdateCommand::Add(a, _)
680 | UpdateCommand::Update(a, _, _)
681 | UpdateCommand::Remove(a, _),
682 UpdateCommand::Add(b, _)
683 | UpdateCommand::Update(b, _, _)
684 | UpdateCommand::Remove(b, _),
685 ) => a.cmp(b),
686 }
687 }
688}
689
690impl Ord for ImageName {
691 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
692 self.to_string().cmp(&other.to_string())
693 }
694}
695
696impl Ord for Resource {
697 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
698 match (self, other) {
699 (Resource::File(a), Resource::File(b)) => a.cmp(b),
700 (Resource::Url(a), Resource::Url(b)) => a.cmp(b),
701 (Resource::File(_), Resource::Url(_)) => std::cmp::Ordering::Less,
702 (Resource::Url(_), Resource::File(_)) => std::cmp::Ordering::Greater,
703 }
704 }
705}
706
707impl FromStr for Resource {
708 type Err = Error;
709
710 fn from_str(s: &str) -> Result<Self> {
711 if s.starts_with("http://") || s.starts_with("https://") {
712 Ok(Resource::Url(s.parse().map_err(Error::display)?))
713 } else {
714 Ok(Resource::File(s.into()))
715 }
716 }
717}