Commit 90cb4c69 authored by Rubén Dávila's avatar Rubén Dávila Committed by Mayra Cabrera

Send email to Namespace owners when CI minutes have been consumed

An email is sent to all owners when all CI minutes have been used, this
also includes extra CI minutes.
parent 749ea08e
...@@ -39,3 +39,5 @@ module RunnersHelper ...@@ -39,3 +39,5 @@ module RunnersHelper
end end
end end
end end
RunnersHelper.prepend(EE::RunnersHelper)
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddLastCiMinutesNotificationAtToNamespaces < ActiveRecord::Migration[5.1]
DOWNTIME = false
def change
add_column :namespaces, :last_ci_minutes_notification_at, :datetime_with_timezone
end
end
...@@ -2052,6 +2052,7 @@ ActiveRecord::Schema.define(version: 20190611161641) do ...@@ -2052,6 +2052,7 @@ ActiveRecord::Schema.define(version: 20190611161641) do
t.integer "custom_project_templates_group_id" t.integer "custom_project_templates_group_id"
t.boolean "auto_devops_enabled" t.boolean "auto_devops_enabled"
t.integer "extra_shared_runners_minutes_limit" t.integer "extra_shared_runners_minutes_limit"
t.datetime_with_timezone "last_ci_minutes_notification_at"
t.index ["created_at"], name: "index_namespaces_on_created_at", using: :btree t.index ["created_at"], name: "index_namespaces_on_created_at", using: :btree
t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)", using: :btree t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)", using: :btree
t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id", using: :btree t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id", using: :btree
......
...@@ -134,6 +134,14 @@ to a different Group. ...@@ -134,6 +134,14 @@ to a different Group.
be deducted from your Additional Minutes quota immediately after your purchase of additional be deducted from your Additional Minutes quota immediately after your purchase of additional
minutes. minutes.
## What happens when my CI minutes quota run out
When the CI minutes quota run out, an email is sent automatically to notifies the owner(s) of the group/namespace which
includes a link to [purchase more minutes](https://customers.gitlab.com/plans).
If you are not the owner of the group, you will need to contact them to let them know they need to
[purchase more minutes](https://customers.gitlab.com/plans).
## Archive jobs **[CORE ONLY]** ## Archive jobs **[CORE ONLY]**
Archiving jobs is useful for reducing the CI/CD footprint on the system by Archiving jobs is useful for reducing the CI/CD footprint on the system by
...@@ -160,4 +168,4 @@ questions that you know someone might ask. ...@@ -160,4 +168,4 @@ questions that you know someone might ask.
Each scenario can be a third-level heading, e.g. `### Getting error message X`. Each scenario can be a third-level heading, e.g. `### Getting error message X`.
If you have none to add when creating a doc, leave this section in place If you have none to add when creating a doc, leave this section in place
but commented out to help encourage others to add to it in the future. --> but commented out to help encourage others to add to it in the future. -->
\ No newline at end of file
# frozen_string_literal: true
module EE
module RunnersHelper
def purchase_shared_runner_minutes_link(user, project)
if ::Gitlab.com? && can?(user, :admin_project, project)
link_to(_("Click here"), EE::SUBSCRIPTIONS_PLANS_URL, target: '_blank', rel: 'noopener') + s_("Pipelines| to purchase more minutes.")
else
s_("Pipelines|Pipelines will not run anymore on shared Runners.")
end
end
end
end
# frozen_string_literal: true
class CiMinutesUsageMailer < BaseMailer
def notify(namespace_name, contact_email)
@namespace_name = namespace_name
mail(
to: contact_email,
subject: "GitLab CI Runner Minutes quota for #{namespace_name} has run out"
)
end
end
...@@ -28,6 +28,8 @@ module EE ...@@ -28,6 +28,8 @@ module EE
PLANS = NAMESPACE_PLANS_TO_LICENSE_PLANS.keys.freeze PLANS = NAMESPACE_PLANS_TO_LICENSE_PLANS.keys.freeze
prepended do prepended do
include EachBatch
belongs_to :plan belongs_to :plan
has_one :namespace_statistics has_one :namespace_statistics
......
# frozen_string_literal: true
class CiMinutesUsageNotifyService < BaseService
def execute
return unless namespace.shared_runners_minutes_used? && namespace.last_ci_minutes_notification_at.nil?
namespace.update_columns(last_ci_minutes_notification_at: Time.now)
owners.each do |user|
CiMinutesUsageMailer.notify(namespace.name, user.email).deliver_later
end
end
private
def namespace
@namespace ||= project.shared_runners_limit_namespace
end
def owners
namespace.user? ? [namespace.owner] : namespace.owners
end
end
%p
This is an automated notification to let you know that your CI Runner Minutes quota for "#{@namespace_name}" has run out.
%p
Click #{link_to('here', EE::SUBSCRIPTIONS_PLANS_URL)} to purchase more minutes.
%p
If you need assistance, please contact #{link_to('GitLab support', 'https://support.gitlab.com')}.
This is an automated notification to let you know that your CI Runner Minutes quota for "<%= @namespace_name %>" has run out.
Please visit <%= EE::SUBSCRIPTIONS_PLANS_URL %> to purchase more minutes.
If you need assistance, please contact GitLab support (https://support.gitlab.com).
...@@ -3,10 +3,11 @@ ...@@ -3,10 +3,11 @@
- scope = (project || namespace).full_path - scope = (project || namespace).full_path
- has_limit = (project || namespace).shared_runners_minutes_limit_enabled? - has_limit = (project || namespace).shared_runners_minutes_limit_enabled?
- can_see_status = project.nil? || can?(current_user, :create_pipeline, project) - can_see_status = project.nil? || can?(current_user, :create_pipeline, project)
- if cookies[:hide_shared_runner_quota_message].blank? && has_limit && namespace.shared_runners_minutes_used? && can_see_status - if cookies[:hide_shared_runner_quota_message].blank? && has_limit && namespace.shared_runners_minutes_used? && can_see_status
.shared-runner-quota-message.alert.alert-warning.d-none.d-sm-block{ data: { scope: scope } } .shared-runner-quota-message.alert.alert-warning.d-none.d-sm-block{ data: { scope: scope } }
= namespace.name = namespace.name
has exceeded their pipeline minutes quota. Pipelines will not run anymore on shared Runners. has exceeded its pipeline minutes quota. #{purchase_shared_runner_minutes_link(current_user, project)}
.float-right .float-right
= link_to 'Remind later', '#', class: 'hide-shared-runner-limit-message alert-link' = link_to 'Remind later', '#', class: 'hide-shared-runner-limit-message alert-link'
...@@ -19,6 +19,10 @@ class ClearSharedRunnersMinutesWorker ...@@ -19,6 +19,10 @@ class ClearSharedRunnersMinutesWorker
.update_all("extra_shared_runners_minutes_limit = #{extra_minutes_left_sql} FROM namespace_statistics") .update_all("extra_shared_runners_minutes_limit = #{extra_minutes_left_sql} FROM namespace_statistics")
end end
Namespace.where.not(last_ci_minutes_notification_at: nil).each_batch do |relation|
relation.update_all(last_ci_minutes_notification_at: nil)
end
NamespaceStatistics.where.not(shared_runners_seconds: 0) NamespaceStatistics.where.not(shared_runners_seconds: 0)
.update_all( .update_all(
shared_runners_seconds: 0, shared_runners_seconds: 0,
......
...@@ -4,6 +4,9 @@ module EE ...@@ -4,6 +4,9 @@ module EE
module BuildFinishedWorker module BuildFinishedWorker
def process_build(build) def process_build(build)
UpdateBuildMinutesService.new(build.project, nil).execute(build) UpdateBuildMinutesService.new(build.project, nil).execute(build)
# We need to use `reset` on `project` because their AR associations have been cached
# and `Namespace#namespace_statistics` will return stale data.
CiMinutesUsageNotifyService.new(build.project.reset).execute
super super
end end
......
---
title: Notify users when their CI minutes quota has run out
merge_request: 13735
author:
type: added
...@@ -18,6 +18,17 @@ module EE ...@@ -18,6 +18,17 @@ module EE
def custom_namespace_present_options def custom_namespace_present_options
{ requested_hosted_plan: params[:requested_hosted_plan] } { requested_hosted_plan: params[:requested_hosted_plan] }
end end
def update_namespace(namespace)
update_attrs = declared_params(include_missing: false)
# Reset last_ci_minutes_notification_at if customer purchased extra CI minutes.
if params[:extra_shared_runners_minutes_limit].present?
update_attrs[:last_ci_minutes_notification_at] = nil
end
namespace.update(update_attrs)
end
end end
resource :namespaces do resource :namespaces do
...@@ -48,7 +59,7 @@ module EE ...@@ -48,7 +59,7 @@ module EE
break not_found!('Namespace') unless namespace break not_found!('Namespace') unless namespace
if namespace.update(declared_params(include_missing: false)) if update_namespace(namespace)
present namespace, with: ::API::Entities::Namespace, current_user: current_user present namespace, with: ::API::Entities::Namespace, current_user: current_user
else else
render_validation_error!(namespace) render_validation_error!(namespace)
......
...@@ -32,12 +32,12 @@ describe 'CI shared runner limits' do ...@@ -32,12 +32,12 @@ describe 'CI shared runner limits' do
it 'displays a warning message on project homepage' do it 'displays a warning message on project homepage' do
visit_project_home visit_project_home
expect_quota_exceeded_alert("#{group.name} has exceeded their pipeline minutes quota.") expect_quota_exceeded_alert("#{group.name} has exceeded its pipeline minutes quota.")
end end
it 'displays a warning message on pipelines page' do it 'displays a warning message on pipelines page' do
visit_project_pipelines visit_project_pipelines
expect_quota_exceeded_alert("#{group.name} has exceeded their pipeline minutes quota.") expect_quota_exceeded_alert("#{group.name} has exceeded its pipeline minutes quota.")
end end
end end
......
...@@ -156,6 +156,18 @@ describe API::Namespaces do ...@@ -156,6 +156,18 @@ describe API::Namespaces do
expect(json_response['message']).to eq('plan' => ['is not included in the list']) expect(json_response['message']).to eq('plan' => ['is not included in the list'])
end end
end end
context 'when namespace has a value for last_ci_minutes_notification_at' do
before do
group1.update_attribute(:last_ci_minutes_notification_at, Time.now)
end
it 'resets that value when assigning extra CI minutes' do
expect do
put api("/namespaces/#{group1.full_path}", admin), params: { plan: 'silver', extra_shared_runners_minutes_limit: 1000 }
end.to change { group1.reload.last_ci_minutes_notification_at }.to(nil)
end
end
end end
describe 'POST :id/gitlab_subscription' do describe 'POST :id/gitlab_subscription' do
......
# frozen_string_literal: true
require 'spec_helper'
describe CiMinutesUsageNotifyService do
shared_examples 'namespace with available CI minutes' do
context 'when usage is below the quote' do
it 'does not send the email' do
expect(CiMinutesUsageMailer).not_to receive(:notify)
subject
end
end
end
shared_examples 'namespace with all CI minutes used' do
context 'when usage is over the quote' do
it 'sends the email to the owner' do
expect(CiMinutesUsageMailer).to receive(:notify).once.with(namespace.name, user.email).and_return(spy)
subject
end
end
end
describe '#execute' do
let(:extra_ci_minutes) { 0 }
let(:namespace) do
create(:namespace, shared_runners_minutes_limit: 2000, extra_shared_runners_minutes_limit: extra_ci_minutes)
end
let(:project) { create(:project, namespace: namespace) }
let(:user) { create(:user) }
let(:user_2) { create(:user) }
let(:ci_minutes_used) { 0 }
let!(:namespace_statistics) do
create(:namespace_statistics, namespace: namespace, shared_runners_seconds: ci_minutes_used * 60)
end
subject { described_class.new(project).execute }
context 'with a personal namespace' do
before do
namespace.update(owner_id: user.id)
end
it_behaves_like 'namespace with available CI minutes' do
let(:ci_minutes_used) { 1900 }
end
it_behaves_like 'namespace with all CI minutes used' do
let(:ci_minutes_used) { 2500 }
end
end
context 'with a Group' do
let!(:namespace) do
create(:group, shared_runners_minutes_limit: 2000, extra_shared_runners_minutes_limit: extra_ci_minutes)
end
context 'with a single owner' do
before do
namespace.add_owner(user)
end
it_behaves_like 'namespace with available CI minutes' do
let(:ci_minutes_used) { 1900 }
end
it_behaves_like 'namespace with all CI minutes used' do
let(:ci_minutes_used) { 2500 }
end
context 'with extra CI minutes' do
let(:extra_ci_minutes) { 1000 }
it_behaves_like 'namespace with available CI minutes' do
let(:ci_minutes_used) { 2500 }
end
it_behaves_like 'namespace with all CI minutes used' do
let(:ci_minutes_used) { 3100 }
end
end
end
context 'with multiple owners' do
before do
namespace.add_owner(user)
namespace.add_owner(user_2)
end
it_behaves_like 'namespace with available CI minutes' do
let(:ci_minutes_used) { 1900 }
end
context 'when usage is over the quote' do
let(:ci_minutes_used) { 2001 }
it 'sends the email to all the owners' do
expect(CiMinutesUsageMailer).to receive(:notify).with(namespace.name, user.email).and_return(spy)
expect(CiMinutesUsageMailer).to receive(:notify).with(namespace.name, user_2.email).and_return(spy)
subject
end
context 'when last_ci_minutes_notification_at has a value' do
before do
namespace.update_attribute(:last_ci_minutes_notification_at, Time.now)
end
it 'does not notify owners' do
expect(CiMinutesUsageMailer).not_to receive(:notify)
subject
end
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe BuildFinishedWorker do
let(:ci_runner) { create(:ci_runner) }
let(:build) { create(:ee_ci_build, :success, runner: ci_runner) }
let(:project) { build.project }
let(:namespace) { project.shared_runners_limit_namespace }
subject do
described_class.new.perform(build.id)
end
def namespace_stats
namespace.namespace_statistics || namespace.create_namespace_statistics
end
def project_stats
project.statistics || project.create_statistics(namespace: project.namespace)
end
describe '#perform' do
before do
allow_any_instance_of(EE::Project).to receive(:shared_runners_minutes_limit_enabled?).and_return(true)
end
it 'updates the project stats' do
expect { subject }.to change { project_stats.reload.shared_runners_seconds }
end
it 'updates the namespace stats' do
expect { subject }.to change { namespace_stats.reload.shared_runners_seconds }
end
it 'notifies the owners of Groups' do
namespace.update_attribute(:shared_runners_minutes_limit, 2000)
namespace_stats.update_attribute(:shared_runners_seconds, 2100 * 60)
expect(CiMinutesUsageMailer).to receive(:notify).once.with(namespace.name, namespace.owner.email).and_return(spy)
subject
end
end
end
...@@ -2645,6 +2645,9 @@ msgstr "" ...@@ -2645,6 +2645,9 @@ msgstr ""
msgid "Click any <strong>project name</strong> in the project list below to navigate to the project milestone." msgid "Click any <strong>project name</strong> in the project list below to navigate to the project milestone."
msgstr "" msgstr ""
msgid "Click here"
msgstr ""
msgid "Click the <strong>Download</strong> button and wait for downloading to complete." msgid "Click the <strong>Download</strong> button and wait for downloading to complete."
msgstr "" msgstr ""
...@@ -9443,6 +9446,9 @@ msgstr "" ...@@ -9443,6 +9446,9 @@ msgstr ""
msgid "Pipelines settings for '%{project_name}' were successfully updated." msgid "Pipelines settings for '%{project_name}' were successfully updated."
msgstr "" msgstr ""
msgid "Pipelines| to purchase more minutes."
msgstr ""
msgid "Pipelines|API" msgid "Pipelines|API"
msgstr "" msgstr ""
...@@ -9464,6 +9470,9 @@ msgstr "" ...@@ -9464,6 +9470,9 @@ msgstr ""
msgid "Pipelines|Loading Pipelines" msgid "Pipelines|Loading Pipelines"
msgstr "" msgstr ""
msgid "Pipelines|Pipelines will not run anymore on shared Runners."
msgstr ""
msgid "Pipelines|Project cache successfully reset." msgid "Pipelines|Project cache successfully reset."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment