Commit fe86890b authored by Jason Goodman's avatar Jason Goodman Committed by Douglas Barbosa Alexandre

Add deployment events to chat notification services

This enables sending a chat message to Slack or Mattermost
  upon a successful, failed, or canceled deployment
parent 265b7894
...@@ -47,6 +47,12 @@ class Deployment < ApplicationRecord ...@@ -47,6 +47,12 @@ class Deployment < ApplicationRecord
Deployments::SuccessWorker.perform_async(id) Deployments::SuccessWorker.perform_async(id)
end end
end end
after_transition any => [:success, :failed, :canceled] do |deployment|
deployment.run_after_commit do
Deployments::FinishedWorker.perform_async(id)
end
end
end end
enum status: { enum status: {
...@@ -82,6 +88,11 @@ class Deployment < ApplicationRecord ...@@ -82,6 +88,11 @@ class Deployment < ApplicationRecord
project.deployment_platform(environment: environment.name)&.cluster project.deployment_platform(environment: environment.name)&.cluster
end end
def execute_hooks
deployment_data = Gitlab::DataBuilder::Deployment.build(self)
project.execute_services(deployment_data, :deployment_hooks)
end
def last? def last?
self == environment.last_deployment self == environment.last_deployment
end end
......
# frozen_string_literal: true
module ChatMessage
class DeploymentMessage < BaseMessage
attr_reader :commit_url
attr_reader :deployable_id
attr_reader :deployable_url
attr_reader :environment
attr_reader :short_sha
attr_reader :status
def initialize(data)
super
@commit_url = data[:commit_url]
@deployable_id = data[:deployable_id]
@deployable_url = data[:deployable_url]
@environment = data[:environment]
@short_sha = data[:short_sha]
@status = data[:status]
end
def attachments
[{
text: "#{project_link}\n#{deployment_link}, SHA #{commit_link}, by #{user_combined_name}",
color: color
}]
end
def activity
{}
end
private
def message
"Deploy to #{environment} #{humanized_status}"
end
def color
case status
when 'success'
'good'
when 'canceled'
'warning'
when 'failed'
'danger'
else
'#334455'
end
end
def project_link
link(project_name, project_url)
end
def deployment_link
link("Job ##{deployable_id}", deployable_url)
end
def commit_link
link(short_sha, commit_url)
end
def humanized_status
status == 'success' ? 'succeeded' : status
end
end
end
...@@ -33,7 +33,7 @@ class ChatNotificationService < Service ...@@ -33,7 +33,7 @@ class ChatNotificationService < Service
def self.supported_events def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push %w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page] pipeline wiki_page deployment]
end end
def fields def fields
...@@ -122,6 +122,8 @@ class ChatNotificationService < Service ...@@ -122,6 +122,8 @@ class ChatNotificationService < Service
ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data)
when "wiki_page" when "wiki_page"
ChatMessage::WikiPageMessage.new(data) ChatMessage::WikiPageMessage.new(data)
when "deployment"
ChatMessage::DeploymentMessage.new(data)
end end
end end
......
...@@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService ...@@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService
# No-op. # No-op.
end end
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
end
def default_fields def default_fields
[ [
{ type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
......
...@@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService ...@@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService
'https://chat.googleapis.com/v1/spaces…' 'https://chat.googleapis.com/v1/spaces…'
end end
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
end
def default_fields def default_fields
[ [
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
......
...@@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService ...@@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService
def default_channel_placeholder def default_channel_placeholder
end end
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
end
def default_fields def default_fields
[ [
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
......
...@@ -50,6 +50,7 @@ class Service < ApplicationRecord ...@@ -50,6 +50,7 @@ class Service < ApplicationRecord
scope :job_hooks, -> { where(job_events: true, active: true) } scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) } scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
scope :deployment, -> { where(category: 'deployment') } scope :deployment, -> { where(category: 'deployment') }
...@@ -335,6 +336,8 @@ class Service < ApplicationRecord ...@@ -335,6 +336,8 @@ class Service < ApplicationRecord
"Event will be triggered when a wiki page is created/updated" "Event will be triggered when a wiki page is created/updated"
when "commit", "commit_events" when "commit", "commit_events"
"Event will be triggered when a commit is created/updated" "Event will be triggered when a commit is created/updated"
when "deployment"
"Event will be triggered when a deployment finishes"
end end
end end
......
...@@ -83,6 +83,7 @@ ...@@ -83,6 +83,7 @@
- pipeline_processing:ci_build_schedule - pipeline_processing:ci_build_schedule
- deployment:deployments_success - deployment:deployments_success
- deployment:deployments_finished
- repository_check:repository_check_clear - repository_check:repository_check_clear
- repository_check:repository_check_batch - repository_check:repository_check_batch
......
# frozen_string_literal: true
module Deployments
class FinishedWorker
include ApplicationWorker
queue_namespace :deployment
def perform(deployment_id)
Deployment.find_by_id(deployment_id).try(:execute_hooks)
end
end
end
---
title: Add deployment events to chat notification services
merge_request: 27338
author:
type: added
# frozen_string_literal: true
class AddDeploymentEventsToServices < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column_with_default(:services, :deployment_events, :boolean, default: false, allow_null: false)
end
def down
remove_column(:services, :deployment_events)
end
end
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20190422082247) do ActiveRecord::Schema.define(version: 20190426180107) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -1999,6 +1999,7 @@ ActiveRecord::Schema.define(version: 20190422082247) do ...@@ -1999,6 +1999,7 @@ ActiveRecord::Schema.define(version: 20190422082247) do
t.boolean "commit_events", default: true, null: false t.boolean "commit_events", default: true, null: false
t.boolean "job_events", default: false, null: false t.boolean "job_events", default: false, null: false
t.boolean "confidential_note_events", default: true t.boolean "confidential_note_events", default: true
t.boolean "deployment_events", default: false, null: false
t.index ["project_id"], name: "index_services_on_project_id", using: :btree t.index ["project_id"], name: "index_services_on_project_id", using: :btree
t.index ["template"], name: "index_services_on_template", using: :btree t.index ["template"], name: "index_services_on_template", using: :btree
t.index ["type"], name: "index_services_on_type", using: :btree t.index ["type"], name: "index_services_on_type", using: :btree
......
# frozen_string_literal: true
module Gitlab
module DataBuilder
module Deployment
extend self
def build(deployment)
{
object_kind: 'deployment',
status: deployment.status,
deployable_id: deployment.deployable_id,
deployable_url: Gitlab::UrlBuilder.build(deployment.deployable),
environment: deployment.environment.name,
project: deployment.project.hook_attrs,
short_sha: deployment.short_sha,
user: deployment.user.hook_attrs,
commit_url: Gitlab::UrlBuilder.build(deployment.commit)
}
end
end
end
end
...@@ -30,6 +30,8 @@ module Gitlab ...@@ -30,6 +30,8 @@ module Gitlab
snippet_url(object) snippet_url(object)
when Milestone when Milestone
milestone_url(object) milestone_url(object)
when ::Ci::Build
project_job_url(object.project, object)
else else
raise NotImplementedError.new("No URL builder defined for #{object.class}") raise NotImplementedError.new("No URL builder defined for #{object.class}")
end end
......
FactoryBot.define do FactoryBot.define do
factory :deployment, class: Deployment do factory :deployment, class: Deployment do
sha '97de212e80737a608d939f648d959671fb0a0142' sha 'b83d6e391c22777fca1ed3012fce84f633d7fed0'
ref 'master' ref 'master'
tag false tag false
user nil user nil
......
...@@ -230,6 +230,13 @@ describe 'Admin updates settings' do ...@@ -230,6 +230,13 @@ describe 'Admin updates settings' do
expect(find_field('Username').value).to eq 'test_user' expect(find_field('Username').value).to eq 'test_user'
expect(find('#service_push_channel').value).to eq '#test_channel' expect(find('#service_push_channel').value).to eq '#test_channel'
end end
it 'defaults Deployment events to false for chat notification template settings' do
first(:link, 'Service Templates').click
click_link 'Slack notifications'
expect(find_field('Deployment')).not_to be_checked
end
end end
context 'CI/CD page' do context 'CI/CD page' do
...@@ -373,10 +380,14 @@ describe 'Admin updates settings' do ...@@ -373,10 +380,14 @@ describe 'Admin updates settings' do
def check_all_events def check_all_events
page.check('Active') page.check('Active')
page.check('Push') page.check('Push')
page.check('Tag push')
page.check('Note')
page.check('Issue') page.check('Issue')
page.check('Confidential issue')
page.check('Merge request') page.check('Merge request')
page.check('Note')
page.check('Confidential note')
page.check('Tag push')
page.check('Pipeline') page.check('Pipeline')
page.check('Wiki page')
page.check('Deployment')
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::DataBuilder::Deployment do
describe '.build' do
it 'returns the object kind for a deployment' do
deployment = build(:deployment)
data = described_class.build(deployment)
expect(data[:object_kind]).to eq('deployment')
end
it 'returns data for the given build' do
environment = create(:environment, name: "somewhere")
project = create(:project, :repository, name: 'myproj')
commit = project.commit('HEAD')
deployment = create(:deployment, status: :failed, environment: environment, sha: commit.sha, project: project)
deployable = deployment.deployable
expected_deployable_url = Gitlab::Routing.url_helpers.project_job_url(deployable.project, deployable)
expected_commit_url = Gitlab::UrlBuilder.build(commit)
data = described_class.build(deployment)
expect(data[:status]).to eq('failed')
expect(data[:deployable_id]).to eq(deployable.id)
expect(data[:deployable_url]).to eq(expected_deployable_url)
expect(data[:environment]).to eq("somewhere")
expect(data[:project]).to eq(project.hook_attrs)
expect(data[:short_sha]).to eq(deployment.short_sha)
expect(data[:user]).to eq(deployment.user.hook_attrs)
expect(data[:commit_url]).to eq(expected_commit_url)
end
end
end
...@@ -423,6 +423,7 @@ Service: ...@@ -423,6 +423,7 @@ Service:
- wiki_page_events - wiki_page_events
- confidential_issues_events - confidential_issues_events
- confidential_note_events - confidential_note_events
- deployment_events
ProjectHook: ProjectHook:
- id - id
- url - url
......
...@@ -856,6 +856,10 @@ describe Ci::Build do ...@@ -856,6 +856,10 @@ describe Ci::Build do
let(:deployment) { build.deployment } let(:deployment) { build.deployment }
let(:environment) { deployment.environment } let(:environment) { deployment.environment }
before do
allow(Deployments::FinishedWorker).to receive(:perform_async)
end
it 'has deployments record with created status' do it 'has deployments record with created status' do
expect(deployment).to be_created expect(deployment).to be_created
expect(environment.name).to eq('review/master') expect(environment.name).to eq('review/master')
......
...@@ -102,6 +102,13 @@ describe Deployment do ...@@ -102,6 +102,13 @@ describe Deployment do
deployment.succeed! deployment.succeed!
end end
it 'executes Deployments::FinishedWorker asynchronously' do
expect(Deployments::FinishedWorker)
.to receive(:perform_async).with(deployment.id)
deployment.succeed!
end
end end
context 'when deployment failed' do context 'when deployment failed' do
...@@ -115,6 +122,13 @@ describe Deployment do ...@@ -115,6 +122,13 @@ describe Deployment do
expect(deployment.finished_at).to be_like_time(Time.now) expect(deployment.finished_at).to be_like_time(Time.now)
end end
end end
it 'executes Deployments::FinishedWorker asynchronously' do
expect(Deployments::FinishedWorker)
.to receive(:perform_async).with(deployment.id)
deployment.drop!
end
end end
context 'when deployment was canceled' do context 'when deployment was canceled' do
...@@ -128,6 +142,13 @@ describe Deployment do ...@@ -128,6 +142,13 @@ describe Deployment do
expect(deployment.finished_at).to be_like_time(Time.now) expect(deployment.finished_at).to be_like_time(Time.now)
end end
end end
it 'executes Deployments::FinishedWorker asynchronously' do
expect(Deployments::FinishedWorker)
.to receive(:perform_async).with(deployment.id)
deployment.cancel!
end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe ChatMessage::DeploymentMessage do
describe '#pretext' do
it 'returns a message with the data returned by the deployment data builder' do
environment = create(:environment, name: "myenvironment")
project = create(:project, :repository)
commit = project.commit('HEAD')
deployment = create(:deployment, status: :success, environment: environment, project: project, sha: commit.sha)
data = Gitlab::DataBuilder::Deployment.build(deployment)
message = described_class.new(data)
expect(message.pretext).to eq("Deploy to myenvironment succeeded")
end
it 'returns a message for a successful deployment' do
data = {
status: 'success',
environment: 'production'
}
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to production succeeded')
end
it 'returns a message for a failed deployment' do
data = {
status: 'failed',
environment: 'production'
}
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to production failed')
end
it 'returns a message for a canceled deployment' do
data = {
status: 'canceled',
environment: 'production'
}
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to production canceled')
end
it 'returns a message for a deployment to another environment' do
data = {
status: 'success',
environment: 'staging'
}
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to staging succeeded')
end
it 'returns a message for a deployment with any other status' do
data = {
status: 'unknown',
environment: 'staging'
}
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to staging unknown')
end
end
describe '#attachments' do
def deployment_data(params)
{
object_kind: "deployment",
status: "success",
deployable_id: 3,
deployable_url: "deployable_url",
environment: "sandbox",
project: {
name: "greatproject",
web_url: "project_web_url",
path_with_namespace: "project_path_with_namespace"
},
user: {
name: "Jane Person",
username: "jane"
},
short_sha: "12345678",
commit_url: "commit_url"
}.merge(params)
end
it 'returns attachments with the data returned by the deployment data builder' do
user = create(:user, name: "John Smith", username: "smith")
namespace = create(:namespace, name: "myspace")
project = create(:project, :repository, namespace: namespace, name: "myproject")
commit = project.commit('HEAD')
environment = create(:environment, name: "myenvironment", project: project)
ci_build = create(:ci_build, project: project)
deployment = create(:deployment, :success, deployable: ci_build, environment: environment, project: project, user: user, sha: commit.sha)
job_url = Gitlab::Routing.url_helpers.project_job_url(project, ci_build)
commit_url = Gitlab::UrlBuilder.build(deployment.commit)
data = Gitlab::DataBuilder::Deployment.build(deployment)
message = described_class.new(data)
expect(message.attachments).to eq([{
text: "[myspace/myproject](#{project.web_url})\n[Job ##{ci_build.id}](#{job_url}), SHA [#{deployment.short_sha}](#{commit_url}), by John Smith (smith)",
color: "good"
}])
end
it 'returns attachments for a failed deployment' do
data = deployment_data(status: 'failed')
message = described_class.new(data)
expect(message.attachments).to eq([{
text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)",
color: "danger"
}])
end
it 'returns attachments for a canceled deployment' do
data = deployment_data(status: 'canceled')
message = described_class.new(data)
expect(message.attachments).to eq([{
text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)",
color: "warning"
}])
end
it 'uses a neutral color for a deployment with any other status' do
data = deployment_data(status: 'some-new-status-we-make-in-the-future')
message = described_class.new(data)
expect(message.attachments).to eq([{
text: "[project_path_with_namespace](project_web_url)\n[Job #3](deployable_url), SHA [12345678](commit_url), by Jane Person (jane)",
color: "#334455"
}])
end
end
end
...@@ -30,6 +30,12 @@ describe MicrosoftTeamsService do ...@@ -30,6 +30,12 @@ describe MicrosoftTeamsService do
end end
end end
describe '.supported_events' do
it 'does not support deployment_events' do
expect(described_class.supported_events).not_to include('deployment')
end
end
describe "#execute" do describe "#execute" do
let(:user) { create(:user) } let(:user) { create(:user) }
set(:project) { create(:project, :repository, :wiki_repo) } set(:project) { create(:project, :repository, :wiki_repo) }
......
...@@ -22,6 +22,7 @@ describe UpdateDeploymentService do ...@@ -22,6 +22,7 @@ describe UpdateDeploymentService do
subject(:service) { described_class.new(deployment) } subject(:service) { described_class.new(deployment) }
before do before do
allow(Deployments::FinishedWorker).to receive(:perform_async)
job.success! # Create/Succeed deployment job.success! # Create/Succeed deployment
end end
......
...@@ -25,6 +25,12 @@ shared_examples_for "chat service" do |service_name| ...@@ -25,6 +25,12 @@ shared_examples_for "chat service" do |service_name|
end end
end end
describe '.supported_events' do
it 'does not support deployment_events' do
expect(described_class.supported_events).not_to include('deployment')
end
end
describe "#execute" do describe "#execute" do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
......
...@@ -106,6 +106,14 @@ RSpec.shared_examples 'slack or mattermost notifications' do ...@@ -106,6 +106,14 @@ RSpec.shared_examples 'slack or mattermost notifications' do
expect(WebMock).to have_requested(:post, webhook_url).once expect(WebMock).to have_requested(:post, webhook_url).once
end end
it "calls Slack/Mattermost API for deployment events" do
deployment_event_data = { object_kind: 'deployment' }
chat_service.execute(deployment_event_data)
expect(WebMock).to have_requested(:post, webhook_url).once
end
it 'uses the username as an option for slack when configured' do it 'uses the username as an option for slack when configured' do
allow(chat_service).to receive(:username).and_return(username) allow(chat_service).to receive(:username).and_return(username)
......
...@@ -15,6 +15,7 @@ describe BuildSuccessWorker do ...@@ -15,6 +15,7 @@ describe BuildSuccessWorker do
let!(:build) { create(:ci_build, :deploy_to_production) } let!(:build) { create(:ci_build, :deploy_to_production) }
before do before do
allow(Deployments::FinishedWorker).to receive(:perform_async)
Deployment.delete_all Deployment.delete_all
build.reload build.reload
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Deployments::FinishedWorker do
let(:worker) { described_class.new }
describe '#perform' do
before do
allow(ProjectServiceWorker).to receive(:perform_async)
end
it 'executes project services for deployment_hooks' do
deployment = create(:deployment)
project = deployment.project
service = create(:service, type: 'SlackService', project: project, deployment_events: true, active: true)
worker.perform(deployment.id)
expect(ProjectServiceWorker).to have_received(:perform_async).with(service.id, an_instance_of(Hash))
end
it 'does not execute an inactive service' do
deployment = create(:deployment)
project = deployment.project
create(:service, type: 'SlackService', project: project, deployment_events: true, active: false)
worker.perform(deployment.id)
expect(ProjectServiceWorker).not_to have_received(:perform_async)
end
it 'does nothing if a deployment with the given id does not exist' do
worker.perform(0)
expect(ProjectServiceWorker).not_to have_received(:perform_async)
end
end
end
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