diff options
author | Jason Goodman <jgoodman@gitlab.com> | 2019-04-26 21:08:41 +0000 |
---|---|---|
committer | Douglas Barbosa Alexandre <dbalexandre@gmail.com> | 2019-04-26 21:08:41 +0000 |
commit | fe86890b9d7a720648e7570d227c438d6ca7f25a (patch) | |
tree | 1c583b22cea6404de37ae6217f320d93cdf7cde9 | |
parent | 265b789476479c29ea88db174aca1d80ddf24358 (diff) | |
download | gitlab-ce-fe86890b9d7a720648e7570d227c438d6ca7f25a.tar.gz |
Add deployment events to chat notification services
This enables sending a chat message to Slack or Mattermost
upon a successful, failed, or canceled deployment
27 files changed, 451 insertions, 5 deletions
diff --git a/app/models/deployment.rb b/app/models/deployment.rb index d847a0a11e4..f5fdf285522 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -47,6 +47,12 @@ class Deployment < ApplicationRecord Deployments::SuccessWorker.perform_async(id) end end + + after_transition any => [:success, :failed, :canceled] do |deployment| + deployment.run_after_commit do + Deployments::FinishedWorker.perform_async(id) + end + end end enum status: { @@ -82,6 +88,11 @@ class Deployment < ApplicationRecord project.deployment_platform(environment: environment.name)&.cluster end + def execute_hooks + deployment_data = Gitlab::DataBuilder::Deployment.build(self) + project.execute_services(deployment_data, :deployment_hooks) + end + def last? self == environment.last_deployment end diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb new file mode 100644 index 00000000000..656a3e6ab4b --- /dev/null +++ b/app/models/project_services/chat_message/deployment_message.rb @@ -0,0 +1,69 @@ +# 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 diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index c10ee07ccf4..7c9ecc6b821 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -33,7 +33,7 @@ class ChatNotificationService < Service def self.supported_events %w[push issue confidential_issue merge_request note confidential_note tag_push - pipeline wiki_page] + pipeline wiki_page deployment] end def fields @@ -122,6 +122,8 @@ class ChatNotificationService < Service ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" ChatMessage::WikiPageMessage.new(data) + when "deployment" + ChatMessage::DeploymentMessage.new(data) end end diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb index 405676792de..4385834ed0a 100644 --- a/app/models/project_services/discord_service.rb +++ b/app/models/project_services/discord_service.rb @@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService # No-op. end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index 272cd0f4e47..699cf1659d1 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService 'https://chat.googleapis.com/v1/spaces…' end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index c34078f13c1..c22a6dc26f6 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService def default_channel_placeholder end + def self.supported_events + %w[push issue confidential_issue merge_request note confidential_note tag_push + pipeline wiki_page] + end + def default_fields [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, diff --git a/app/models/service.rb b/app/models/service.rb index de549becf71..9896aa12e90 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -50,6 +50,7 @@ class Service < ApplicationRecord scope :job_hooks, -> { where(job_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 :deployment_hooks, -> { where(deployment_events: true, active: true) } scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } scope :deployment, -> { where(category: 'deployment') } @@ -335,6 +336,8 @@ class Service < ApplicationRecord "Event will be triggered when a wiki page is created/updated" when "commit", "commit_events" "Event will be triggered when a commit is created/updated" + when "deployment" + "Event will be triggered when a deployment finishes" end end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index f9b2e698fc9..d01bbbf269e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -83,6 +83,7 @@ - pipeline_processing:ci_build_schedule - deployment:deployments_success +- deployment:deployments_finished - repository_check:repository_check_clear - repository_check:repository_check_batch diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb new file mode 100644 index 00000000000..c9d448d5d18 --- /dev/null +++ b/app/workers/deployments/finished_worker.rb @@ -0,0 +1,13 @@ +# 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 diff --git a/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml b/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml new file mode 100644 index 00000000000..3f0a96ad50e --- /dev/null +++ b/changelogs/unreleased/issue-42692-deployment-chat-notifications.yml @@ -0,0 +1,5 @@ +--- +title: Add deployment events to chat notification services +merge_request: 27338 +author: +type: added diff --git a/db/migrate/20190426180107_add_deployment_events_to_services.rb b/db/migrate/20190426180107_add_deployment_events_to_services.rb new file mode 100644 index 00000000000..1fb137fb5f9 --- /dev/null +++ b/db/migrate/20190426180107_add_deployment_events_to_services.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index 23566a31a9d..a38c07a491e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -1999,6 +1999,7 @@ ActiveRecord::Schema.define(version: 20190422082247) do t.boolean "commit_events", default: true, null: false t.boolean "job_events", default: false, null: false 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 ["template"], name: "index_services_on_template", using: :btree t.index ["type"], name: "index_services_on_type", using: :btree diff --git a/lib/gitlab/data_builder/deployment.rb b/lib/gitlab/data_builder/deployment.rb new file mode 100644 index 00000000000..26705dd1f6f --- /dev/null +++ b/lib/gitlab/data_builder/deployment.rb @@ -0,0 +1,23 @@ +# 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 diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index f86d599e4cb..169ce8ab026 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -30,6 +30,8 @@ module Gitlab snippet_url(object) when Milestone milestone_url(object) + when ::Ci::Build + project_job_url(object.project, object) else raise NotImplementedError.new("No URL builder defined for #{object.class}") end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index 011c98599a3..db438ad32d3 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :deployment, class: Deployment do - sha '97de212e80737a608d939f648d959671fb0a0142' + sha 'b83d6e391c22777fca1ed3012fce84f633d7fed0' ref 'master' tag false user nil diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 04f39b807d7..3c9a9d61756 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -230,6 +230,13 @@ describe 'Admin updates settings' do expect(find_field('Username').value).to eq 'test_user' expect(find('#service_push_channel').value).to eq '#test_channel' 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 context 'CI/CD page' do @@ -373,10 +380,14 @@ describe 'Admin updates settings' do def check_all_events page.check('Active') page.check('Push') - page.check('Tag push') - page.check('Note') page.check('Issue') + page.check('Confidential issue') page.check('Merge request') + page.check('Note') + page.check('Confidential note') + page.check('Tag push') page.check('Pipeline') + page.check('Wiki page') + page.check('Deployment') end end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb new file mode 100644 index 00000000000..b89a44e178b --- /dev/null +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -0,0 +1,36 @@ +# 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 diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 591a3d296c2..9093d21647a 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -423,6 +423,7 @@ Service: - wiki_page_events - confidential_issues_events - confidential_note_events +- deployment_events ProjectHook: - id - url diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 3a7d20a58c8..339483d4f7d 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -856,6 +856,10 @@ describe Ci::Build do let(:deployment) { build.deployment } let(:environment) { deployment.environment } + before do + allow(Deployments::FinishedWorker).to receive(:perform_async) + end + it 'has deployments record with created status' do expect(deployment).to be_created expect(environment.name).to eq('review/master') diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index d9170d5fa07..0ca758429b8 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -102,6 +102,13 @@ describe Deployment do deployment.succeed! end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.succeed! + end end context 'when deployment failed' do @@ -115,6 +122,13 @@ describe Deployment do expect(deployment.finished_at).to be_like_time(Time.now) end end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.drop! + end end context 'when deployment was canceled' do @@ -128,6 +142,13 @@ describe Deployment do expect(deployment.finished_at).to be_like_time(Time.now) end end + + it 'executes Deployments::FinishedWorker asynchronously' do + expect(Deployments::FinishedWorker) + .to receive(:perform_async).with(deployment.id) + + deployment.cancel! + end end end diff --git a/spec/models/project_services/chat_message/deployment_message_spec.rb b/spec/models/project_services/chat_message/deployment_message_spec.rb new file mode 100644 index 00000000000..86565ce8b01 --- /dev/null +++ b/spec/models/project_services/chat_message/deployment_message_spec.rb @@ -0,0 +1,150 @@ +# 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 diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb index 521d5265753..c025d7c882e 100644 --- a/spec/models/project_services/microsoft_teams_service_spec.rb +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -30,6 +30,12 @@ describe MicrosoftTeamsService do 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 let(:user) { create(:user) } set(:project) { create(:project, :repository, :wiki_repo) } diff --git a/spec/services/update_deployment_service_spec.rb b/spec/services/update_deployment_service_spec.rb index c664bac39fc..7dc52f6816a 100644 --- a/spec/services/update_deployment_service_spec.rb +++ b/spec/services/update_deployment_service_spec.rb @@ -22,6 +22,7 @@ describe UpdateDeploymentService do subject(:service) { described_class.new(deployment) } before do + allow(Deployments::FinishedWorker).to receive(:perform_async) job.success! # Create/Succeed deployment end diff --git a/spec/support/shared_examples/models/chat_service_spec.rb b/spec/support/shared_examples/models/chat_service_spec.rb index cf1d52a9616..9d3ce5e2be1 100644 --- a/spec/support/shared_examples/models/chat_service_spec.rb +++ b/spec/support/shared_examples/models/chat_service_spec.rb @@ -25,6 +25,12 @@ shared_examples_for "chat service" do |service_name| 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 let(:user) { create(:user) } let(:project) { create(:project, :repository) } diff --git a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb index 940c24c8d67..c31346374f4 100644 --- a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb @@ -106,6 +106,14 @@ RSpec.shared_examples 'slack or mattermost notifications' do expect(WebMock).to have_requested(:post, webhook_url).once 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 allow(chat_service).to receive(:username).and_return(username) diff --git a/spec/workers/build_success_worker_spec.rb b/spec/workers/build_success_worker_spec.rb index 065aeaf2b65..ffe8796ded9 100644 --- a/spec/workers/build_success_worker_spec.rb +++ b/spec/workers/build_success_worker_spec.rb @@ -15,6 +15,7 @@ describe BuildSuccessWorker do let!(:build) { create(:ci_build, :deploy_to_production) } before do + allow(Deployments::FinishedWorker).to receive(:perform_async) Deployment.delete_all build.reload end diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb new file mode 100644 index 00000000000..df62821e2cd --- /dev/null +++ b/spec/workers/deployments/finished_worker_spec.rb @@ -0,0 +1,39 @@ +# 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 |