From 72414e4fe43c5e3f82b5afb77d9efe6d205378a9 Mon Sep 17 00:00:00 2001
From: Geoffrey Arthaud <geoffrey.arthaud@developpement-durable.gouv.fr>
Date: Fri, 26 Aug 2022 12:00:14 +0200
Subject: [PATCH] Changed jobs analysis to pipelines analysis

---
 src/cli.rs                         |  68 ++++++++++---
 src/diagnosis.rs                   |   9 +-
 src/diagnosis/artifact_size.rs     | 102 --------------------
 src/diagnosis/gitlab_connection.rs |   1 +
 src/diagnosis/pipeline_analysis.rs |  99 +++++++++++++++++++
 src/diagnosis/pipeline_clean.rs    | 149 +++++++++++++++++++++++++++++
 src/main.rs                        |  26 +++--
 7 files changed, 329 insertions(+), 125 deletions(-)
 delete mode 100644 src/diagnosis/artifact_size.rs
 create mode 100644 src/diagnosis/pipeline_analysis.rs
 create mode 100644 src/diagnosis/pipeline_clean.rs

diff --git a/src/cli.rs b/src/cli.rs
index 9899e25..175a252 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,9 +1,10 @@
 use std::fmt::Write;
-use std::process;
+use std::{panic, process};
 use std::time::Duration;
 
 use console::style;
-use indicatif::ProgressBar;
+use dialoguer::{Confirm, Input};
+use indicatif::{ProgressBar, ProgressStyle};
 use structopt::StructOpt;
 
 use crate::{Reportable, ReportPending, ReportStatus};
@@ -58,13 +59,14 @@ fn console_report_status(buffer: &mut String, report_status: &ReportStatus, inde
     };
 }
 
-fn console_report_statuses(report_statuses: &[ReportStatus]) -> String {
+pub fn console_report_statuses(report_statuses: &[ReportStatus], initial_indent: usize) -> String {
+    eprint!("\r");
     let mut result = String::new();
     if report_statuses.is_empty() {
         return result;
     }
     let mut statuses_iter = report_statuses.iter();
-    console_report_status(&mut result, statuses_iter.next().unwrap(), 0);
+    console_report_status(&mut result, statuses_iter.next().unwrap(), initial_indent);
     for report_status in statuses_iter {
         console_report_status(&mut result, report_status, 2);
     }
@@ -72,11 +74,55 @@ fn console_report_statuses(report_statuses: &[ReportStatus]) -> String {
 }
 
 pub fn display_report_pending<T: Reportable>(report_pending: ReportPending<T>) -> T {
-    let pb = ProgressBar::new_spinner();
-    pb.enable_steady_tick(Duration::from_millis(100));
-    pb.set_message(format!(" [*] {}", &report_pending.pending_msg));
-    let result = report_pending.job.join().unwrap();
-    pb.finish_with_message(console_report_statuses(&result.report()));
-    eprint!("\r");
-    result
+    let pb;
+    let initial_indent : usize;
+    if report_pending.progress.is_some() && report_pending.total.is_some() {
+        initial_indent = 2;
+        let sty = ProgressStyle::with_template(
+            "{msg} {bar:40.cyan/blue} {pos:>7}/{len:7} ({eta})")
+            .unwrap()
+            .progress_chars("#>-");
+
+        pb = ProgressBar::new(report_pending.total.unwrap() as u64);
+        pb.set_style(sty);
+        pb.set_message(report_pending.pending_msg.to_string());
+        let rx = report_pending.progress.unwrap();
+        while let Ok(received) = rx.recv() {
+            pb.set_position(received as u64);
+        }
+    } else {
+        initial_indent = 0;
+        pb = ProgressBar::new_spinner();
+        pb.enable_steady_tick(Duration::from_millis(100));
+        pb.set_message(format!(" [*] {}", &report_pending.pending_msg));
+    }
+
+    let result_join = report_pending.job.join();
+    match result_join {
+        Ok(result) => {
+            pb.finish_with_message(console_report_statuses(&result.report(), initial_indent));
+            eprint!("\r");
+            result
+        }
+        Err(e) => panic::resume_unwind(e)
+    }
 }
+
+pub fn input_clean_artifacts() -> Option<i64> {
+    if Confirm::new().with_prompt("Delete old pipelines ?").interact().unwrap_or(false) {
+        let input: i64 = Input::new()
+            .with_prompt("From which age in days ?")
+            .default("30".into())
+            .interact_text()
+            .unwrap_or_else(|_| "0".to_string())
+            .parse()
+            .unwrap_or(0);
+        if input > 0 {
+            Some(input)
+        } else {
+            None
+        }
+    } else {
+        None
+    }
+}
\ No newline at end of file
diff --git a/src/diagnosis.rs b/src/diagnosis.rs
index 269a3a7..fddef4e 100644
--- a/src/diagnosis.rs
+++ b/src/diagnosis.rs
@@ -1,8 +1,9 @@
 use std::sync::mpsc::Receiver;
 use std::thread::JoinHandle;
 
-pub mod artifact_size;
 pub mod gitlab_connection;
+pub mod pipeline_analysis;
+pub mod pipeline_clean;
 
 pub const STORAGE_LIMIT: u64 = 2_000_000_000;
 pub const REPO_LIMIT: u64 = 100_000_000;
@@ -12,6 +13,9 @@ pub const ARTIFACT_JOBS_DAYS_LIMIT: i64 = 30;
 pub const PACKAGE_REGISTRY_LIMIT: u64 = 1_000_000_000;
 pub const DOCKER_REGISTRY_LIMIT: u64 = 5_000_000_000;
 
+pub const GITLAB_403_ERROR: &str = "403 Forbidden";
+pub const GITLAB_SCOPE_ERROR: &str = "insufficient_scope";
+
 #[derive(Clone)]
 pub enum ReportStatus {
     OK(String),
@@ -23,7 +27,8 @@ pub enum ReportStatus {
 pub struct ReportPending<T> {
     pub pending_msg: String,
     pub job: JoinHandle<T>,
-    pub progress: Option<Receiver<u64>>,
+    pub progress: Option<Receiver<usize>>,
+    pub total: Option<usize>,
 }
 
 pub trait ReportJob {
diff --git a/src/diagnosis/artifact_size.rs b/src/diagnosis/artifact_size.rs
deleted file mode 100644
index 24ef91a..0000000
--- a/src/diagnosis/artifact_size.rs
+++ /dev/null
@@ -1,102 +0,0 @@
-use chrono::{DateTime, Duration, Local};
-use gitlab::api::{Pagination, projects, Query};
-use gitlab::api::paged;
-use gitlab::Gitlab;
-use human_bytes::human_bytes;
-use serde::Deserialize;
-
-use crate::{Reportable, ReportJob, ReportPending};
-use crate::diagnosis::{
-    ARTIFACT_JOBS_DAYS_LIMIT, ReportStatus,
-};
-use crate::diagnosis::gitlab_connection::{GitlabRepository, Project};
-
-#[derive(Debug, Deserialize)]
-pub struct Artifact {
-    pub size: u64,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct GitlabJob {
-    pub created_at: DateTime<Local>,
-    pub artifacts: Vec<Artifact>,
-}
-
-pub struct ArtifactSizeJob {
-    pub gitlab: Gitlab,
-    pub project: Project,
-}
-
-pub struct ArtifactReport {
-    pub gitlab_jobs: Vec<GitlabJob>,
-    pub report_status: Vec<ReportStatus>,
-    pub bytes_savable: u64,
-}
-
-impl Reportable for ArtifactReport {
-    fn report(&self) -> Vec<ReportStatus> {
-        self.report_status.clone()
-    }
-}
-
-impl ReportJob for ArtifactSizeJob {
-    type Diagnosis = ArtifactReport;
-
-    fn diagnose(self) -> ReportPending<Self::Diagnosis> {
-        ReportPending::<Self::Diagnosis> {
-            pending_msg: "Analysing Gitlab jobs...".to_string(),
-            job: {
-                std::thread::spawn(move || {
-                    if !self.project.jobs_enabled {
-                        return ArtifactReport {
-                            report_status: vec![
-                                ReportStatus::NA("No CI/CD configured on this project".to_string())],
-                            gitlab_jobs: vec![],
-                            bytes_savable: 0,
-                        };
-                    }
-                    let endpoint = projects::jobs::Jobs::builder()
-                        .project(self.project.id)
-                        .build()
-                        .unwrap();
-                    let jobs: Vec<GitlabJob> = paged(endpoint, Pagination::All).query(&self.gitlab).unwrap();
-                    let (report, bytes_savable) = ArtifactSizeJob::_number_jobs(&jobs);
-                    ArtifactReport {
-                        report_status: vec![report],
-                        gitlab_jobs: jobs,
-                        bytes_savable,
-                    }
-                })
-            },
-            progress: None,
-        }
-    }
-}
-
-impl ArtifactSizeJob {
-    pub fn from(gitlab: &GitlabRepository) -> ArtifactSizeJob {
-        ArtifactSizeJob {
-            gitlab: gitlab.gitlab.clone(),
-            project: gitlab.project.clone(),
-        }
-    }
-
-    fn _number_jobs(jobs: &[GitlabJob]) -> (ReportStatus, u64) {
-        let ref_date = Local::now() - Duration::days(ARTIFACT_JOBS_DAYS_LIMIT);
-        let mut old_count: usize = 0;
-        let mut old_size: u64 = 0;
-        for job in jobs.iter() {
-            let artifact_size: u64 = job.artifacts.iter().map(|a| a.size).sum();
-            if job.created_at.le(&ref_date) {
-                old_count += 1;
-                old_size += artifact_size;
-            }
-        }
-        (ReportStatus::NA(format!(
-            "{} jobs ({}) are older than {} days",
-            old_count,
-            human_bytes(old_size as f64),
-            ARTIFACT_JOBS_DAYS_LIMIT
-        )), old_size)
-    }
-}
diff --git a/src/diagnosis/gitlab_connection.rs b/src/diagnosis/gitlab_connection.rs
index 6cd8f34..250ff04 100644
--- a/src/diagnosis/gitlab_connection.rs
+++ b/src/diagnosis/gitlab_connection.rs
@@ -61,6 +61,7 @@ impl ReportJob for ConnectionJob {
                 })
             },
             progress: None,
+            total: None
         }
     }
 }
diff --git a/src/diagnosis/pipeline_analysis.rs b/src/diagnosis/pipeline_analysis.rs
new file mode 100644
index 0000000..88d0a82
--- /dev/null
+++ b/src/diagnosis/pipeline_analysis.rs
@@ -0,0 +1,99 @@
+use chrono::{DateTime, Duration, Local};
+use gitlab::api::{Pagination, Query};
+use gitlab::Gitlab;
+use serde::Deserialize;
+
+use crate::{Reportable, ReportJob, ReportPending, ReportStatus};
+use crate::diagnosis::ARTIFACT_JOBS_DAYS_LIMIT;
+use crate::diagnosis::gitlab_connection::{GitlabRepository, Project};
+
+#[derive(Debug, Deserialize)]
+pub struct GitlabPipeline {
+    pub id: u64,
+    pub created_at: DateTime<Local>,
+}
+
+pub struct PipelineAnalysisJob {
+    pub gitlab: Gitlab,
+    pub project: Project,
+}
+
+pub struct PipelineAnalysisReport {
+    pub gitlab: Gitlab,
+    pub project: Project,
+    pub pipelines: Vec<GitlabPipeline>,
+    pub report_status: Vec<ReportStatus>,
+}
+
+impl Reportable for PipelineAnalysisReport {
+    fn report(&self) -> Vec<ReportStatus> {
+        self.report_status.clone()
+    }
+}
+
+impl PipelineAnalysisJob {
+    fn to_report(self, report_status: Vec<ReportStatus>, pipelines: Vec<GitlabPipeline>)
+        -> PipelineAnalysisReport {
+        PipelineAnalysisReport {
+            gitlab: self.gitlab,
+            project: self.project,
+            pipelines,
+            report_status
+        }
+    }
+}
+impl ReportJob for PipelineAnalysisJob {
+    type Diagnosis = PipelineAnalysisReport;
+
+    fn diagnose(self) -> ReportPending<Self::Diagnosis> {
+        ReportPending::<Self::Diagnosis> {
+            pending_msg: "Analysis of pipelines...".to_string(),
+            job: std::thread::spawn(move || {
+                if !self.project.jobs_enabled {
+                    return self.to_report(
+                        vec![ReportStatus::NA("No CI/CD configured on this project".to_string())],
+                        vec![]);
+                }
+
+                let endpoint = gitlab::api::projects::pipelines::Pipelines::builder()
+                    .project(self.project.id)
+                    .build()
+                    .unwrap();
+                let query: Result<Vec<GitlabPipeline>, _> =
+                    gitlab::api::paged(endpoint, Pagination::All).query(&self.gitlab);
+                match query {
+                    Err(e) => {
+                        self.to_report(
+                            vec![ReportStatus::ERROR(format!("Error : {}", e.to_string()))],
+                            vec![]
+                        )
+                    }
+                    Ok(mut pipelines) => {
+                        let ref_date = Local::now() - Duration::days(ARTIFACT_JOBS_DAYS_LIMIT);
+                        pipelines.sort_by(|a, b| a.created_at.partial_cmp(&b.created_at).unwrap());
+                        self.to_report(
+                            vec![ReportStatus::NA(
+                                format!("{} pipelines. {} pipelines are older than {} days",
+                                    pipelines.len(),
+                                    pipelines.iter()
+                                        .position(|e| e.created_at > ref_date)
+                                        .unwrap_or(pipelines.len()),
+                                    ARTIFACT_JOBS_DAYS_LIMIT))],
+                                    pipelines)
+                    }
+                }
+            }),
+            progress: None,
+            total: None
+        }
+    }
+}
+
+impl PipelineAnalysisJob {
+    pub fn from(gitlab: &GitlabRepository) -> PipelineAnalysisJob {
+        PipelineAnalysisJob {
+            gitlab: gitlab.gitlab.clone(),
+            project: gitlab.project.clone(),
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/diagnosis/pipeline_clean.rs b/src/diagnosis/pipeline_clean.rs
new file mode 100644
index 0000000..622f9fb
--- /dev/null
+++ b/src/diagnosis/pipeline_clean.rs
@@ -0,0 +1,149 @@
+use std::cmp::max;
+use std::sync::mpsc;
+
+use chrono::{Duration, Local};
+use gitlab::api::{ApiError, Query};
+use gitlab::Gitlab;
+use human_bytes::human_bytes;
+
+use crate::{Reportable, ReportPending, ReportStatus};
+use crate::diagnosis::{GITLAB_SCOPE_ERROR, RemedyJob};
+use crate::diagnosis::gitlab_connection::Project;
+use crate::diagnosis::pipeline_analysis::{GitlabPipeline, PipelineAnalysisReport};
+
+pub struct PipelineCleanJob {
+    pub pipeline_report: PipelineAnalysisReport,
+    pub days: i64,
+}
+
+pub struct PipelineCleanReport {
+    pub saved_bytes: u64,
+    pub deleted_pipelines: Vec<GitlabPipeline>,
+    pub report_status: Vec<ReportStatus>,
+}
+
+impl Reportable for PipelineCleanReport {
+    fn report(&self) -> Vec<ReportStatus> {
+        self.report_status.clone()
+    }
+}
+
+impl PipelineCleanReport {
+    fn fatal_error(id: u64, msg: &str) -> Self {
+        Self {
+            saved_bytes: 0,
+            deleted_pipelines: vec![],
+            report_status: vec![ReportStatus::ERROR(format!("Pipeline {} - Error : {}", id, msg))],
+        }
+    }
+}
+
+impl RemedyJob for PipelineCleanJob {
+    type Report = PipelineCleanReport;
+
+    fn remedy(self) -> ReportPending<Self::Report> {
+        let (tx, rx) = mpsc::channel();
+        let ref_date = Local::now() - Duration::days(self.days);
+        let count = self.pipeline_report.pipelines.iter()
+            .filter(|a| a.created_at <= ref_date)
+            .count();
+        ReportPending {
+            pending_msg: "Deleting old pipelines".to_string(),
+            job: std::thread::spawn(move || {
+                let mut deleted_pipelines = vec![];
+
+                for (i, pipeline) in self.pipeline_report.pipelines.into_iter().enumerate() {
+                    if pipeline.created_at > ref_date {
+                        break;
+                    }
+                    let mut retry = 0;
+                    loop {
+                        let endpoint = gitlab::api::projects::pipelines::DeletePipeline::builder()
+                            .project(self.pipeline_report.project.id)
+                            .pipeline(pipeline.id)
+                            .build()
+                            .unwrap();
+                        let query = gitlab::api::ignore(endpoint)
+                            .query(&self.pipeline_report.gitlab);
+                        match query {
+                            Ok(_) => {
+                                deleted_pipelines.push(pipeline);
+                                break;
+                            }
+                            Err(e) => {
+                                match e {
+                                    ApiError::Gitlab { msg } => {
+                                        return match msg.as_str() {
+                                            msg if msg.contains(GITLAB_SCOPE_ERROR) => {
+                                                PipelineCleanReport::fatal_error(
+                                                    pipeline.id,
+                                                    "Your token has insufficient privileges to delete pipelines")
+                                            }
+                                            other => {
+                                                PipelineCleanReport::fatal_error(
+                                                    pipeline.id,
+                                                    other)
+                                            }
+                                        }
+                                    }
+                                    ApiError::Client {source} => {
+                                        retry += 1;
+                                        if retry >= 3 {
+                                            return PipelineCleanReport::fatal_error(
+                                                pipeline.id,
+                                                source.to_string().as_str());
+                                        }
+                                    }
+                                    _ => {
+                                        return PipelineCleanReport::fatal_error(
+                                            pipeline.id,
+                                            e.to_string().as_str());
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    let _ = tx.send(i);
+                }
+                let saved_bytes = PipelineCleanJob::_compute_saved_bytes(
+                    &self.pipeline_report.gitlab,
+                    &self.pipeline_report.project);
+                PipelineCleanReport {
+                    saved_bytes,
+                    report_status: vec![ReportStatus::OK(format!("Deleted {} pipelines, {} saved.",
+                                                                 deleted_pipelines.len(),
+                                                                 human_bytes(saved_bytes as f64)))],
+                    deleted_pipelines,
+                }
+            }),
+            progress: Some(rx),
+            total: Some(count),
+        }
+    }
+}
+
+impl PipelineCleanJob {
+    pub fn from(pipeline_report: PipelineAnalysisReport, days: i64) -> Self {
+        if days < 0 {
+            panic!("Number of days must be 1 or superior")
+        }
+        Self {
+            pipeline_report,
+            days
+        }
+    }
+
+    fn _compute_saved_bytes(gitlab: &Gitlab, project: &Project) -> u64 {
+        let old_size = project.statistics.job_artifacts_size;
+        let endpoint = gitlab::api::projects::Project::builder()
+            .project(project.id)
+            .statistics(true)
+            .build()
+            .unwrap();
+
+        let new_size = endpoint.query(gitlab)
+            .map(|p: Project| p.statistics.job_artifacts_size)
+            .unwrap_or(old_size);
+        max(0, old_size - new_size)
+    }
+}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 7699a9b..d4b445a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,11 @@
-use std::sync::mpsc;
-use std::thread;
-use std::time::Duration;
-
-use dialoguer::Input;
-use indicatif::{ProgressBar, ProgressStyle};
 use structopt::StructOpt;
 
 use cli::Args;
 
-use crate::diagnosis::{Reportable, ReportJob, ReportPending};
-use crate::diagnosis::artifact_size::ArtifactSizeJob;
+use crate::diagnosis::{RemedyJob, Reportable, ReportJob, ReportPending};
 use crate::diagnosis::gitlab_connection::ConnectionJob;
+use crate::diagnosis::pipeline_analysis::PipelineAnalysisJob;
+use crate::diagnosis::pipeline_clean::PipelineCleanJob;
 use crate::diagnosis::ReportStatus;
 
 pub mod diagnosis;
@@ -36,7 +31,18 @@ fn main() {
     let connection_data = cli::fatal_if_none(connection.data, "Diagnosis stops here.");
 
     // Analysis of artifacts
-    let report_pending = ArtifactSizeJob::from(&connection_data).diagnose();
-    let _ = cli::display_report_pending(report_pending);
+    let report_pending = PipelineAnalysisJob::from(&connection_data).diagnose();
+    let pipeline_report = cli::display_report_pending(report_pending);
+    if !pipeline_report.pipelines.is_empty() {
+        if let Some(days) = cli::input_clean_artifacts() {
+            let report_pending = PipelineCleanJob::from(pipeline_report, days).remedy();
+            let _ = cli::display_report_pending(report_pending);
+        } else {
+            cli::console_report_statuses(
+                &[ReportStatus::WARNING("Jobs deletion cancelled".to_string())],
+                2);
+        }
+    }
+
 
 }
-- 
GitLab