codebuild-notifier 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ require 'active_support'
19
+ require 'active_support/core_ext'
20
+ require 'aws-sdk-dynamodb'
21
+ require 'hashie'
22
+
23
+ module CodeBuildNotifier
24
+ class BuildHistory
25
+ attr_reader :config, :current_build
26
+
27
+ delegate :dynamo_table, to: :config
28
+ delegate :launched_by_retry?, to: :current_build
29
+
30
+ def initialize(config, current_build)
31
+ @config = config
32
+ @current_build = current_build
33
+ end
34
+
35
+ def last_entry
36
+ # If this build was launched using the Retry command from the console
37
+ # or api we don't have a Pull Request or branch name to use in the
38
+ # primary key, so we query by commit hash and project instead.
39
+ item = launched_by_retry? ? find_by_commit_and_project : find_by_id
40
+
41
+ # Provide .dot access to hash values from Dynamo item.
42
+ item && Hashie::Mash.new(item)
43
+ end
44
+
45
+ def write_entry(source_id)
46
+ updates = hash_to_dynamo_update(new_entry).merge(
47
+ key: { source_id: source_id }
48
+ )
49
+
50
+ yield updates if block_given?
51
+
52
+ dynamo_client.update_item(
53
+ updates.merge(table_name: dynamo_table)
54
+ )
55
+ end
56
+
57
+ # The commit hash and project code are used to find which Pull Request
58
+ # or branch the current build belongs to, and the previous build status
59
+ # for that Pull Request or branch.
60
+ private def find_by_commit_and_project
61
+ dynamo_client.query(
62
+ expression_attribute_values: {
63
+ ':commit_hash' => current_build.commit_hash,
64
+ ':project_code' => current_build.project_code
65
+ },
66
+ filter_expression: 'project_code = :project_code',
67
+ index_name: 'commit_hash_index',
68
+ key_condition_expression: 'commit_hash = :commit_hash',
69
+ table_name: dynamo_table
70
+ ).items.first
71
+ end
72
+
73
+ private def find_by_id
74
+ dynamo_client.get_item(
75
+ key: { 'source_id' => current_build.source_id },
76
+ table_name: dynamo_table
77
+ ).item
78
+ end
79
+
80
+ private def new_entry
81
+ {
82
+ commit_hash: current_build.commit_hash,
83
+ project_code: current_build.project_code,
84
+ status: current_build.status
85
+ }.tap do |memo|
86
+ # If launched via manual re-try instead of via a webhook, we don't
87
+ # want to overwrite the current source_ref value that tells us which
88
+ # branch or pull request originally created the dynamo record.
89
+ memo[:source_ref] = current_build.trigger unless launched_by_retry?
90
+ end
91
+ end
92
+
93
+ private def hash_to_dynamo_update(hash)
94
+ update = hash.each_with_object(
95
+ expression_attribute_names: {},
96
+ expression_attribute_values: {},
97
+ update_expression: []
98
+ ) do |(key, value), memo|
99
+ memo[:expression_attribute_names]["##{key}"] = key.to_s
100
+ memo[:expression_attribute_values][":#{key}"] = value
101
+ memo[:update_expression] << "##{key} = :#{key}"
102
+ end
103
+ update.merge(update_expression: "SET #{update[:update_expression].join(', ')}")
104
+ end
105
+
106
+ private def dynamo_client
107
+ @dynamo_client || Aws::DynamoDB::Client.new(region: config.region)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,52 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ module CodeBuildNotifier
19
+ class Config
20
+ DEFAULT_WHITELIST = %w[master release]
21
+
22
+ attr_reader :additional_channel, :dynamo_table, :region, :slack_admins,
23
+ :slack_secret_name, :whitelist_branches
24
+
25
+ # Configuration values specific to CodeBuild Notifier. CBN_ prefix is
26
+ # used because ENV vars with CODEBUILD_ prefix are reserved for use by AWS.
27
+ def initialize(
28
+ additional_channel: ENV['CBN_ADDITIONAL_CHANNEL'],
29
+ dynamo_table: ENV['CBN_DYNAMO_TABLE'] || 'branch-build-status',
30
+ region: ENV['CBN_AWS_REGION'] || ENV['AWS_REGION'],
31
+ slack_admins: ENV['CBN_SLACK_ADMIN_USERNAMES'],
32
+ slack_secret_name: ENV['CBN_SLACK_SECRET_NAME'] || 'slack/codebuild',
33
+ whitelist_branches: ENV['CBN_WHITELIST_BRANCHES']
34
+ )
35
+ @additional_channel = additional_channel
36
+ @dynamo_table = dynamo_table
37
+ @region = region
38
+ @slack_admins = slack_admins&.split(',') || []
39
+ @slack_secret_name = slack_secret_name
40
+ @whitelist_branches = whitelist_branches&.split(',') || DEFAULT_WHITELIST
41
+ end
42
+
43
+ # Match the format of the CodeBuild trigger variable
44
+ def non_pr_branch_ids
45
+ whitelist_branches.map { |name| "branch/#{name}" }
46
+ end
47
+
48
+ def whitelist
49
+ whitelist_branches.join(', ')
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,69 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ module CodeBuildNotifier
19
+ class CurrentBuild
20
+ attr_reader :build_id, :commit_hash, :git_repo_url, :status_code, :trigger
21
+
22
+ # Default values are extracted from CODEBUILD_* ENV vars present in each
23
+ # CodeBuild # job container.
24
+ def initialize(
25
+ build_id: ENV['CODEBUILD_BUILD_ID'],
26
+ commit_hash: ENV['CODEBUILD_RESOLVED_SOURCE_VERSION'],
27
+ git_repo: ENV['CODEBUILD_SOURCE_REPO_URL'],
28
+ status_code: ENV['CODEBUILD_BUILD_SUCCEEDING'],
29
+ trigger: ENV['CODEBUILD_WEBHOOK_TRIGGER']
30
+ )
31
+ @build_id = build_id
32
+ @commit_hash = commit_hash
33
+ # Handle repos specified with and without optional .git suffix.
34
+ @git_repo_url = git_repo.to_s.gsub(/\.git\z/, '')
35
+ @status_code = status_code
36
+ @trigger = trigger
37
+ end
38
+
39
+ def status
40
+ status_code.to_s == '1' ? 'SUCCEEDED' : 'FAILED'
41
+ end
42
+
43
+ def project_code
44
+ @project_code ||= build_id.split(':').first
45
+ end
46
+
47
+ # If trigger is empty, this build was launched using the Retry command from
48
+ # the console or api.
49
+ def launched_by_retry?
50
+ trigger.to_s.empty?
51
+ end
52
+
53
+ def for_pr?
54
+ %r{^pr/}.match?(trigger.to_s)
55
+ end
56
+
57
+ # source_id, the primary key, is a composite of project_code and
58
+ # trigger.
59
+ # e.g.:
60
+ # my-app_ruby2-4:branch/master
61
+ # my-app_ruby2-3:pr/4056
62
+ # project_code forms part of the key to support having repos with
63
+ # multiple projects, for example, with different buildspec files for
64
+ # different ruby versions, or for rspec vs cucumber.
65
+ def source_id
66
+ "#{project_code}:#{trigger}"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ module CodeBuildNotifier
19
+ module Git
20
+ def current_commit
21
+ `git show -s --format='%h|%aN|%aE|%cE|%s'`.chomp.split('|')
22
+ end
23
+ module_function :current_commit
24
+ end
25
+ end
@@ -0,0 +1,98 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ module CodeBuildNotifier
19
+ class SlackMessage
20
+ attr_reader :author_email, :author_name, :build, :committer_email,
21
+ :commit_message_subject, :config, :short_hash, :source_ref
22
+
23
+ def initialize(build, config, source_ref)
24
+ @build = build
25
+ @config = config
26
+ @source_ref = source_ref
27
+ @short_hash, @author_name, @author_email,
28
+ @committer_email, @commit_message_subject = git_info
29
+ end
30
+
31
+ def payload
32
+ {
33
+ color: slack_color,
34
+ fallback: [title, body].join("\n"),
35
+ title: title,
36
+ text: body
37
+ }
38
+ end
39
+
40
+ def recipients
41
+ [author_email, committer_email].uniq
42
+ end
43
+
44
+ def additional_channel
45
+ !build.for_pr? && config.additional_channel
46
+ end
47
+
48
+ private def git_info
49
+ Git.current_commit
50
+ end
51
+
52
+ private def slack_color
53
+ {
54
+ 'FAILED' => 'danger',
55
+ 'SUCCEEDED' => 'good'
56
+ }[build.status]
57
+ end
58
+
59
+ private def title
60
+ "#{slack_icon} #{author_name}'s " \
61
+ "<#{details_url}|#{build.project_code} build> - " \
62
+ "#{build.status.downcase}"
63
+ end
64
+
65
+ private def slack_icon
66
+ {
67
+ 'FAILED' => ':broken_heart:',
68
+ 'SUCCEEDED' => ':green_heart:'
69
+ }[build.status]
70
+ end
71
+
72
+ private def details_url
73
+ 'https://console.aws.amazon.com/codesuite/codebuild/projects/' \
74
+ "#{build.project_code}/build/#{build.build_id}/log?region=#{config.region}"
75
+ end
76
+
77
+ private def body
78
+ "commit #{commit_link} (#{commit_message_subject}) in " \
79
+ "#{source_ref_link}"
80
+ end
81
+
82
+ private def commit_link
83
+ "<#{build.git_repo_url}/commit/#{build.commit_hash}|#{short_hash}>"
84
+ end
85
+
86
+ private def source_ref_link
87
+ "<#{build.git_repo_url}/#{url_path}|#{source_ref}>"
88
+ end
89
+
90
+ private def url_path
91
+ if %r{\Apr/}.match?(source_ref)
92
+ "pull/#{source_ref[3..-1]}"
93
+ else
94
+ "tree/#{source_ref.gsub(%r{\Abranch/}, '')}"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,97 @@
1
+ # codebuild-notifier
2
+ # Copyright © 2018 Adam Alboyadjian <adam@cassia.tech>
3
+ # Copyright © 2018 Vista Higher Learning, Inc.
4
+ #
5
+ # codebuild-notifier is free software: you can redistribute it
6
+ # and/or modify it under the terms of the GNU General Public
7
+ # License as published by the Free Software Foundation, either
8
+ # version 3 of the License, or (at your option) any later version.
9
+ #
10
+ # codebuild-notifier is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13
+ # General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with codebuild-notifier. If not, see <http://www.gnu.org/licenses/>.
17
+
18
+ require 'aws-sdk-secretsmanager'
19
+ require 'slack-ruby-client'
20
+
21
+ module CodeBuildNotifier
22
+ class SlackSender
23
+ attr_reader :config
24
+
25
+ def initialize(config)
26
+ @config = config
27
+ end
28
+
29
+ def send(message)
30
+ Slack.configure { |slack_config| slack_config.token = app_token }
31
+ channel = message.additional_channel
32
+ if channel
33
+ channel = "##{channel}" unless /\A#/.match?(channel)
34
+ post_message(message, channel)
35
+ end
36
+
37
+ message.recipients.each do |email|
38
+ slack_user_id = find_slack_user(email)
39
+ slack_user_id && post_message(message, slack_user_id)
40
+ end
41
+ end
42
+
43
+ private def post_message(message, channel)
44
+ slack_client.chat_postMessage(
45
+ as_user: app_is_bot_user?,
46
+ attachments: [message.payload],
47
+ channel: channel
48
+ )
49
+ end
50
+
51
+ private def admin_send(message)
52
+ config.slack_admins.each do |username|
53
+ username = "@#{username}" unless /\A@/.match?(username)
54
+ slack_client.chat_postMessage(
55
+ as_user: false,
56
+ text: message,
57
+ channel: username
58
+ )
59
+ end
60
+ end
61
+
62
+ private def find_slack_user(email)
63
+ lookup_response = slack_client.users_lookupByEmail(email: email)
64
+ lookup_response.user.id
65
+ rescue Slack::Web::Api::Errors::SlackError => e
66
+ admin_send(
67
+ "Slack user lookup by email for #{email} failed with " \
68
+ "error: #{e.message}"
69
+ )
70
+ nil
71
+ end
72
+
73
+ # If the app token starts with xoxb- then it is a Bot User Oauth token
74
+ # and slack notifications should be posted with as_user: true. If it
75
+ # starts with xoxp- then it's an app token not associated with a user,
76
+ # and as_user: should be false.
77
+ private def app_is_bot_user?
78
+ /\Axoxb/.match?(app_token)
79
+ end
80
+
81
+ private def secrets_client
82
+ Aws::SecretsManager::Client.new(region: config.region)
83
+ end
84
+
85
+ private def slack_client
86
+ @slack_client ||= Slack::Web::Client.new
87
+ end
88
+
89
+ private def app_token
90
+ @app_token ||= JSON.parse(secret.secret_string)['token']
91
+ end
92
+
93
+ private def secret
94
+ secrets_client.get_secret_value(secret_id: config.slack_secret_name)
95
+ end
96
+ end
97
+ end