diff --git a/i18n/en-GB/gitlab_project_doctor.ftl b/i18n/en-GB/gitlab_project_doctor.ftl index 31d77eb09e4991663c4b3b95f350b30f91c55c1a..bac24ce8c4f8a666935bf215ac272821967c2752 100644 --- a/i18n/en-GB/gitlab_project_doctor.ftl +++ b/i18n/en-GB/gitlab_project_doctor.ftl @@ -36,4 +36,7 @@ conf-analysing = Analysis of package configuration duplicate-assets-option-onepackage = The number of duplicate assets to keep is 1 duplicate-assets-option-warn = The number of duplicate assets to keep is NOT 1 duplicate-assets-option-error = Cannot get the number of duplicate assets to keep option -conf-fix = Fix this : {$url} \ No newline at end of file +conf-fix = Fix this : {$url} +container-analysing = Analysis of container registry +container-report = {$image_count} images in container registry. {$old_image_count} are older than {$nb_days} days +container-summary = Container registry size: {$registry_size} \ No newline at end of file diff --git a/i18n/fr-FR/gitlab_project_doctor.ftl b/i18n/fr-FR/gitlab_project_doctor.ftl index 12c13cd9368a6d311d767a31f5d3ffdb596bd12d..98d4720f5057f4e26de43914d4c1499661559ae3 100644 --- a/i18n/fr-FR/gitlab_project_doctor.ftl +++ b/i18n/fr-FR/gitlab_project_doctor.ftl @@ -36,4 +36,7 @@ conf-analysing = Analyse de la configuration des packages duplicate-assets-option-onepackage = L'option "The number of duplicate assets to keep" vaut 1 duplicate-assets-option-warn = L'option "The number of duplicate assets to keep" ne vaut PAS 1 duplicate-assets-option-error = Cannot get the number of duplicate assets to keep option -conf-fix = Pour corriger : {$url} \ No newline at end of file +conf-fix = Pour corriger : {$url} +container-analysing = Analyse du container registry +container-report = {$image_count} images dans le container registry. {$old_image_count} datent de plus de {$nb_days} jours +container-summary = Taille du container registry : {$registry_size} \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 3265f50e44ca0208999b5c0ba728d2c8a2c4c01d..c02a403f941cc56ce3862dc289d2f8ae5b24ee41 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,3 +1,4 @@ // Add specific endpoint for gitlab client, not covered by the crate gitlab 0.15.x pub mod packages; +pub mod registry; diff --git a/src/api/registry.rs b/src/api/registry.rs new file mode 100644 index 0000000000000000000000000000000000000000..c9dcfbac2b6f0d2ec92f6e67f1bf5424dc9f5b46 --- /dev/null +++ b/src/api/registry.rs @@ -0,0 +1,14 @@ +// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or +// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Project packages API endpoints. +//! +//! These endpoints are used for querying Gitlab container registry. +pub use self::repositories::Repositories; +pub use self::tag::Tag; + +mod repositories; +mod tag; diff --git a/src/api/registry/repositories.rs b/src/api/registry/repositories.rs new file mode 100644 index 0000000000000000000000000000000000000000..fddc8dde1ec4f4e0fd2e28ffe5974de2c22383ef --- /dev/null +++ b/src/api/registry/repositories.rs @@ -0,0 +1,53 @@ +// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or +// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use derive_builder::Builder; +use gitlab::api::common::NameOrId; +use gitlab::api::endpoint_prelude::*; + +/// Query for registry repositories within a project. +#[derive(Debug, Builder)] +#[builder(setter(strip_option))] +pub struct Repositories<'a> { + /// The project to query for repositories. + #[builder(setter(into))] + project: NameOrId<'a>, + /// Includes an array of tags in the response. + #[builder(default)] + tags: Option<bool>, + /// Includes the tags count in the response + #[builder(default)] + tags_count: Option<bool>, +} + +impl<'a> Repositories<'a> { + /// Create a builder for the endpoint. + pub fn builder() -> RepositoriesBuilder<'a> { + RepositoriesBuilder::default() + } +} + +impl<'a> Endpoint for Repositories<'a> { + fn method(&self) -> Method { + Method::GET + } + + fn endpoint(&self) -> Cow<'static, str> { + format!("projects/{}/registry/repositories", self.project).into() + } + + fn parameters(&self) -> QueryParams { + let mut params = QueryParams::default(); + + params + .push_opt("tags", self.tags) + .push_opt("tags_count", self.tags_count); + + params + } +} + +impl<'a> Pageable for Repositories<'a> {} diff --git a/src/api/registry/tag.rs b/src/api/registry/tag.rs new file mode 100644 index 0000000000000000000000000000000000000000..59aeaa1131d34bb6327536c437234fc5c3beea09 --- /dev/null +++ b/src/api/registry/tag.rs @@ -0,0 +1,42 @@ +// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or +// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license +// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use derive_builder::Builder; +use gitlab::api::common::NameOrId; +use gitlab::api::endpoint_prelude::*; + +/// Query a single tag on a repository from the container registry. +#[derive(Debug, Builder)] +pub struct Tag<'a> { + /// The project to query for package. + #[builder(setter(into))] + project: NameOrId<'a>, + /// The ID of the repository. + repository: u64, + /// The name of the tag + tag_name: String, +} + +impl<'a> Tag<'a> { + /// Create a builder for the endpoint. + pub fn builder() -> TagBuilder<'a> { + TagBuilder::default() + } +} + +impl<'a> Endpoint for Tag<'a> { + fn method(&self) -> Method { + Method::GET + } + + fn endpoint(&self) -> Cow<'static, str> { + format!( + "projects/{}/registry/repositories/{}/tags/{}", + self.project, self.repository, self.tag_name + ) + .into() + } +} diff --git a/src/diagnosis.rs b/src/diagnosis.rs index 1f3d88d84e241fa1094fab8448300fbe0de69943..10b93ad18ad96e882be41e702924ae9ceb585ca2 100644 --- a/src/diagnosis.rs +++ b/src/diagnosis.rs @@ -1,6 +1,7 @@ use std::sync::mpsc::Receiver; use std::thread::JoinHandle; pub mod conf_analysis; +pub mod container_analysis; pub mod gitlab_connection; pub mod job_analysis; pub mod package_analysis; @@ -13,7 +14,10 @@ pub const REPO_LIMIT: u64 = 100_000_000; pub const ARTIFACT_JOBS_LIMIT: u64 = 500_000_000; pub const ARTIFACT_JOBS_NB_LIMIT: usize = 1_000; pub const PACKAGE_REGISTRY_LIMIT: u64 = 1_000_000_000; -pub const DOCKER_REGISTRY_LIMIT: u64 = 5_000_000_000; +pub const CONTAINER_REGISTRY_LIMIT: u64 = 5_000_000_000; +pub const CONTAINER_NB_TAGS_LIMIT: u64 = 7; +pub const CONTAINER_NB_IMAGES_LIMIT: u64 = 20; +pub const CONTAINER_DAYS_LIMIT: u64 = 90; pub const GITLAB_403_ERROR: &str = "403 Forbidden"; pub const GITLAB_SCOPE_ERROR: &str = "insufficient_scope"; diff --git a/src/diagnosis/conf_analysis.rs b/src/diagnosis/conf_analysis.rs index b59377b4e9db28b0b4d76b92f14a39e961538f94..f5b9034006a84d2cbdc2c61f283f81b9ed2dde13 100644 --- a/src/diagnosis/conf_analysis.rs +++ b/src/diagnosis/conf_analysis.rs @@ -71,7 +71,12 @@ impl ConfAnalysisJob { fn _report_container_policy(&self) -> ReportStatus { if !self.project.container_registry_enabled - || self.project.container_expiration_policy.enabled + || self + .project + .container_expiration_policy + .as_ref() + .map(|c| c.enabled) + .unwrap_or(false) { ReportStatus::OK(fl!("container-policy-enabled")) } else { diff --git a/src/diagnosis/container_analysis.rs b/src/diagnosis/container_analysis.rs new file mode 100644 index 0000000000000000000000000000000000000000..13f14a8d6017fae9031f672d939cf492f8c8fa6a --- /dev/null +++ b/src/diagnosis/container_analysis.rs @@ -0,0 +1,182 @@ +use chrono::{DateTime, Duration, Local}; +use gitlab::api::{Pagination, Query}; +use gitlab::Gitlab; +use human_bytes::human_bytes; +use serde::Deserialize; + +use crate::diagnosis::gitlab_connection::{GitlabRepository, Project}; +use crate::diagnosis::{warning_if, CONTAINER_REGISTRY_LIMIT}; +use crate::{api, fl, ReportJob, ReportPending, ReportStatus, Reportable}; + +#[derive(Debug, Deserialize)] +pub struct GitlabRawContainerRepository { + pub id: u64, + pub created_at: DateTime<Local>, + pub tags: Vec<GitlabContainerTagSummary>, +} + +#[derive(Debug, Deserialize)] +pub struct GitlabContainerRepository { + pub id: u64, + pub created_at: DateTime<Local>, + pub tags: Vec<GitlabContainerTag>, +} + +#[derive(Debug, Deserialize)] +pub struct GitlabContainerTagSummary { + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct GitlabContainerTag { + pub name: String, + pub created_at: DateTime<Local>, + pub total_size: u64, +} + +pub struct ContainerAnalysisJob { + pub gitlab: Gitlab, + pub project: Project, + pub days: usize, +} + +pub struct ContainerAnalysisReport { + pub gitlab: Gitlab, + pub project: Project, + pub containers: Vec<GitlabContainerRepository>, + pub report_status: Vec<ReportStatus>, +} + +impl Reportable for ContainerAnalysisReport { + fn report(&self) -> Vec<ReportStatus> { + self.report_status.clone() + } +} + +impl ContainerAnalysisJob { + fn to_report( + self, + report_status: Vec<ReportStatus>, + containers: Vec<GitlabContainerRepository>, + ) -> ContainerAnalysisReport { + ContainerAnalysisReport { + gitlab: self.gitlab, + project: self.project, + containers, + report_status, + } + } + + fn get_detailed_repo( + &self, + containers: &[GitlabRawContainerRepository], + ) -> Vec<GitlabContainerRepository> { + containers + .iter() + .map(|cr| GitlabContainerRepository { + id: cr.id, + created_at: cr.created_at, + tags: cr + .tags + .iter() + .map(|t| self.get_detailed_tag(t, cr.id)) + .collect(), + }) + .collect() + } + + fn get_detailed_tag( + &self, + tag: &GitlabContainerTagSummary, + repo_id: u64, + ) -> GitlabContainerTag { + let endpoint = api::registry::Tag::builder() + .project(self.project.id) + .repository(repo_id) + .tag_name(tag.name.clone()) + .build() + .unwrap(); + endpoint.query(&self.gitlab).unwrap() + } +} + +impl ReportJob for ContainerAnalysisJob { + type Diagnosis = ContainerAnalysisReport; + + fn diagnose(self) -> ReportPending<Self::Diagnosis> { + ReportPending::<Self::Diagnosis> { + pending_msg: fl!("container-analysing"), + job: std::thread::spawn(move || { + if !self.project.jobs_enabled { + return self.to_report(vec![ReportStatus::NA(fl!("no-cicd"))], vec![]); + } + + let endpoint = api::registry::Repositories::builder() + .project(self.project.id) + .tags(true) + .build() + .unwrap(); + let query: Result<Vec<GitlabRawContainerRepository>, _> = + gitlab::api::paged(endpoint, Pagination::All).query(&self.gitlab); + match query { + Err(e) => self.to_report( + vec![ReportStatus::ERROR(format!( + "{} {}", + fl!("error"), + e.to_string() + ))], + vec![], + ), + Ok(containers) => { + let container_repos = self.get_detailed_repo(&containers); + let days = self.days; + let ref_date = Local::now() - Duration::days(days as i64); + let image_count: usize = + container_repos.iter().map(|cr| cr.tags.len()).sum(); + let registry_size: u64 = container_repos + .iter() + .map(|cr| { + let res: u64 = cr.tags.iter().map(|t| t.total_size).sum(); + res + }) + .sum(); + let old_image_count: usize = container_repos + .iter() + .map(|cr| cr.tags.iter().filter(|t| t.created_at < ref_date).count()) + .sum(); + self.to_report( + vec![ + warning_if( + registry_size > CONTAINER_REGISTRY_LIMIT, + fl!( + "container-summary", + registry_size = human_bytes(registry_size as f64) + ), + ), + ReportStatus::NA(fl!( + "container-report", + image_count = image_count, + old_image_count = old_image_count, + nb_days = days + )), + ], + container_repos, + ) + } + } + }), + progress: None, + total: None, + } + } +} + +impl ContainerAnalysisJob { + pub fn from(gitlab: &GitlabRepository, days: usize) -> ContainerAnalysisJob { + ContainerAnalysisJob { + gitlab: gitlab.gitlab.clone(), + project: gitlab.project.clone(), + days, + } + } +} diff --git a/src/diagnosis/gitlab_connection.rs b/src/diagnosis/gitlab_connection.rs index c1fdbc773e0ff14b8922c421a6bef17aff11cdb2..06affbaec984977f61a5a25b5a54a5887e119620 100644 --- a/src/diagnosis/gitlab_connection.rs +++ b/src/diagnosis/gitlab_connection.rs @@ -36,7 +36,7 @@ pub struct Project { pub statistics: Statistics, pub jobs_enabled: bool, pub container_registry_enabled: bool, - pub container_expiration_policy: ContainerExpirationPolicy, + pub container_expiration_policy: Option<ContainerExpirationPolicy>, pub web_url: String, pub path_with_namespace: String, } diff --git a/src/main.rs b/src/main.rs index fdfb882a43ca8697ed3956f78e05b71965501a05..7c182ecb4d1c37ff95c918fc72970c46fb61289f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use structopt::StructOpt; use cli::Args; use crate::diagnosis::conf_analysis::{ConfAnalysisJob, ConfAnalysisReport}; +use crate::diagnosis::container_analysis::{ContainerAnalysisJob, ContainerAnalysisReport}; use crate::diagnosis::gitlab_connection::{ConnectionJob, GitlabRepository, Statistics}; use crate::diagnosis::job_analysis::{JobAnalysisJob, JobAnalysisReport}; use crate::diagnosis::package_analysis::{PackageAnalysisJob, PackageAnalysisReport}; @@ -158,6 +159,12 @@ fn _analyze_configuration(connection_data: &GitlabRepository) -> ConfAnalysisRep let report_pending = ConfAnalysisJob::from(connection_data).diagnose(); cli::display_report_pending(report_pending) } + +fn _analyze_registry(days: usize, connection_data: &GitlabRepository) -> ContainerAnalysisReport { + let report_pending = ContainerAnalysisJob::from(connection_data, days).diagnose(); + cli::display_report_pending(report_pending) +} + fn _clean_packages(report: PackageAnalysisReport) { if !report.obsolete_files.is_empty() { return; @@ -171,6 +178,7 @@ fn main() { eprintln!("Gitlab Project Doctor v{}", env!("CARGO_PKG_VERSION")); let connection_data = _connect_to_gitlab(&args); let _ = _analyze_configuration(&connection_data); + let _ = _analyze_registry(args.days, &connection_data); if args.analysis_mode { // Analysis mode