From aea4041ce96f18afea70da15af3cbe1be4fa1f94 Mon Sep 17 00:00:00 2001
From: Kamil Trzcinski <ayufan@ayufan.eu>
Date: Wed, 18 May 2016 15:21:51 -0500
Subject: [PATCH] Allow to expire build artifacts

---
 Gemfile                                        |  3 +++
 Gemfile.lock                                   |  4 ++++
 app/controllers/projects/builds_controller.rb  |  6 ++++++
 app/models/ci/build.rb                         | 18 ++++++++++++++++--
 app/views/projects/builds/_sidebar.html.haml   |  9 +++++++++
 app/workers/expire_build_artifacts.rb          | 12 ++++++++++++
 config/gitlab.yml.example                      |  3 +++
 config/initializers/1_settings.rb              |  3 +++
 config/routes.rb                               |  1 +
 ...1_add_artifacts_expire_date_to_ci_builds.rb |  5 +++++
 lib/ci/api/builds.rb                           |  2 ++
 lib/ci/gitlab_ci_yaml_processor.rb             |  2 +-
 12 files changed, 65 insertions(+), 3 deletions(-)
 create mode 100644 app/workers/expire_build_artifacts.rb
 create mode 100644 db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb

diff --git a/Gemfile b/Gemfile
index b2660144f2b76..f56daa099a275 100644
--- a/Gemfile
+++ b/Gemfile
@@ -210,6 +210,9 @@ gem 'mousetrap-rails', '~> 1.4.6'
 # Detect and convert string character encoding
 gem 'charlock_holmes', '~> 0.7.3'
 
+# Parse duration
+gem 'chronic_duration', '~> 0.10.6'
+
 gem "sass-rails", '~> 5.0.0'
 gem "coffee-rails", '~> 4.1.0'
 gem "uglifier", '~> 2.7.2'
diff --git a/Gemfile.lock b/Gemfile.lock
index dfc1570049459..2b2e2d2bb0773 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -133,6 +133,8 @@ GEM
       mime-types (>= 1.16)
     cause (0.1)
     charlock_holmes (0.7.3)
+    chronic_duration (0.10.6)
+      numerizer (~> 0.1.1)
     chunky_png (1.3.5)
     cliver (0.3.2)
     coderay (1.1.0)
@@ -424,6 +426,7 @@ GEM
     nokogiri (1.6.8)
       mini_portile2 (~> 2.1.0)
       pkg-config (~> 1.1.7)
+    numerizer (0.1.1)
     oauth (0.4.7)
     oauth2 (1.0.0)
       faraday (>= 0.8, < 0.10)
@@ -857,6 +860,7 @@ DEPENDENCIES
   capybara-screenshot (~> 1.0.0)
   carrierwave (~> 0.10.0)
   charlock_holmes (~> 0.7.3)
+  chronic_duration (~> 0.10.6)
   coffee-rails (~> 4.1.0)
   connection_pool (~> 2.0)
   coveralls (~> 0.8.2)
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 14c828263428e..514f1b507fef9 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -78,6 +78,12 @@ def raw
     end
   end
 
+  def keep_artifacts
+    @build.keep_artifacts
+    redirect_to namespace_project_build_path(project.namespace, project, @build),
+                notice: "Artifacts will not be removed!"
+  end
+
   private
 
   def build
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 6a64ca451f77f..74084b650cfb8 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -11,6 +11,8 @@ class Build < CommitStatus
 
     scope :unstarted, ->() { where(runner_id: nil) }
     scope :ignore_failures, ->() { where(allow_failure: false) }
+    scope :with_artifacts, ->() { where.not(artifacts_file: nil) }
+    scope :with_artifacts_expired, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
 
     mount_uploader :artifacts_file, ArtifactUploader
     mount_uploader :artifacts_metadata, ArtifactUploader
@@ -328,11 +330,15 @@ def artifacts_metadata_entry(path, **options)
       Gitlab::Ci::Build::Artifacts::Metadata.new(artifacts_metadata.path, path, **options).to_entry
     end
 
+    def erase_artifacts!
+      remove_artifacts_file!
+      remove_artifacts_metadata!
+    end
+
     def erase(opts = {})
       return false unless erasable?
 
-      remove_artifacts_file!
-      remove_artifacts_metadata!
+      erase_artifacts!
       erase_trace!
       update_erased!(opts[:erased_by])
     end
@@ -345,6 +351,14 @@ def erased?
       !self.erased_at.nil?
     end
 
+    def artifacts_expired?
+      self.artifacts_expire_at < Time.now && !artifacts?
+    end
+
+    def keep_artifacts
+      self.update(artifacts_expire_at: nil)
+    end
+
     private
 
     def erase_trace!
diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml
index 5d931389dfbf5..d1a0da29ef762 100644
--- a/app/views/projects/builds/_sidebar.html.haml
+++ b/app/views/projects/builds/_sidebar.html.haml
@@ -44,6 +44,15 @@
       %p.build-detail-row
         %span.build-light-text Erased:
         #{time_ago_with_tooltip(@build.erased_at)}
+    - elsif @build.artifacts_expired?
+      %p.build-detail-row.artifacts-expired.alert.alert-warning
+        The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)}
+    - elsif @build.artifacts_expire_at
+      %p.build-detail-row.artifacts-expired.alert.alert-info
+        The artifacts will be removed at #{time_ago_with_tooltip(@build.artifacts_expire_at)}
+        .pull-right
+          = link_to keep_artifacts_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do
+            Keep
     %p.build-detail-row
       %span.build-light-text Runner:
       - if @build.runner && current_user && current_user.admin
diff --git a/app/workers/expire_build_artifacts.rb b/app/workers/expire_build_artifacts.rb
new file mode 100644
index 0000000000000..3d809d8ab6b0d
--- /dev/null
+++ b/app/workers/expire_build_artifacts.rb
@@ -0,0 +1,12 @@
+class ExpireBuildArtifacts
+  include Sidekiq::Worker
+
+  def perform
+    Rails.logger.info 'Cleaning old build artifacts'
+
+    builds = Ci::Build.with_artifacts_expired
+    builds.find_each(batch_size: 50).each do |build|
+      build.erase_artifacts!
+    end
+  end
+end
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 1048ef6e243bf..7b37e92ed46ff 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -164,6 +164,9 @@ production: &base
     # Flag stuck CI builds as failed
     stuck_ci_builds_worker:
       cron: "0 0 * * *"
+    # Remove old artifacts
+    expire_build_artifacts:
+      cron: "50 * * * *"
     # Periodically run 'git fsck' on all repositories. If started more than
     # once per hour you will have concurrent 'git fsck' jobs.
     repository_check_worker:
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 436751b9d168f..b412d1e098173 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -279,6 +279,9 @@ def host(url)
 Settings.cron_jobs['stuck_ci_builds_worker'] ||= Settingslogic.new({})
 Settings.cron_jobs['stuck_ci_builds_worker']['cron'] ||= '0 0 * * *'
 Settings.cron_jobs['stuck_ci_builds_worker']['job_class'] = 'StuckCiBuildsWorker'
+Settings.cron_jobs['expire_build_artifacts'] ||= Settingslogic.new({})
+Settings.cron_jobs['expire_build_artifacts']['cron'] ||= '0 0 * * *'
+Settings.cron_jobs['expire_build_artifacts']['job_class'] = 'ExpireBuildArtifacts'
 Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({})
 Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *'
 Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::BatchWorker'
diff --git a/config/routes.rb b/config/routes.rb
index 95fbe7dd9df9c..3d092d98c8ec5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -714,6 +714,7 @@
             post :cancel
             post :retry
             post :erase
+            post :keep_artifacts
             get :trace
             get :raw
           end
diff --git a/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb
new file mode 100644
index 0000000000000..915167b038d48
--- /dev/null
+++ b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb
@@ -0,0 +1,5 @@
+class AddArtifactsExpireDateToCiBuilds < ActiveRecord::Migration
+  def change
+    add_column :ci_builds, :artifacts_expire_at, :timestamp
+  end
+end
diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb
index 607359769d153..54f5626c7d7dc 100644
--- a/lib/ci/api/builds.rb
+++ b/lib/ci/api/builds.rb
@@ -114,6 +114,7 @@ class Builds < Grape::API
         #   id (required) - The ID of a build
         #   token (required) - The build authorization token
         #   file (required) - Artifacts file
+        #   expire_in (optional) - Specify when artifacts should expire (ex. 7d)
         # Parameters (accelerated by GitLab Workhorse):
         #   file.path - path to locally stored body (generated by Workhorse)
         #   file.name - real filename as send in Content-Disposition
@@ -145,6 +146,7 @@ class Builds < Grape::API
 
           build.artifacts_file = artifacts
           build.artifacts_metadata = metadata
+          build.artifacts_expire_at = Time.now + ChronicDuration.parse(params['expire_in'])
 
           if build.save
             present(build, with: Entities::BuildDetails)
diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb
index 15d57a46eb010..b1297565ebed1 100644
--- a/lib/ci/gitlab_ci_yaml_processor.rb
+++ b/lib/ci/gitlab_ci_yaml_processor.rb
@@ -9,7 +9,7 @@ class ValidationError < StandardError; end
                         :allow_failure, :type, :stage, :when, :artifacts, :cache,
                         :dependencies, :before_script, :after_script, :variables]
     ALLOWED_CACHE_KEYS = [:key, :untracked, :paths]
-    ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when]
+    ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when, :expire_in]
 
     attr_reader :before_script, :after_script, :image, :services, :path, :cache
 
-- 
GitLab