diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0e36154b270..a3d07207b4a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -67,9 +67,7 @@ stages: workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "schedule"' - when: never - - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "external_pull_request_event"' + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "external_pull_request_event" || $CI_PIPELINE_SOURCE == "schedule"' - if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "external_pull_request_event" || $CI_PIPELINE_SOURCE == "web"' - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "stages/prod"' - if: '$CI_MERGE_REQUEST_IID || $CI_EXTERNAL_PULL_REQUEST_IID' @@ -341,6 +339,7 @@ js_tests: - *yarn_install - yarn test +# Can be removed once pinpoint_check_scheduled is confirmed pinpoint-check: needs: - job: install @@ -353,6 +352,7 @@ pinpoint-check: - *yarn_install - make lint_country_dialing_codes +# Can be removed once audit_packages_scheduled is confirmed audit_packages: needs: - job: install @@ -838,3 +838,51 @@ ecr-scan-ci: stage: scan variables: ecr_repo: idp/ci + +pinpoint_check_scheduled: + needs: + - job: install + cache: + - <<: *ruby_cache + - <<: *yarn_cache + script: + - *bundle_install + - *yarn_install + - make lint_country_dialing_codes + after_script: + - |- + if [ "$CI_JOB_STATUS" != "success" ]; then + ./scripts/notify-slack \ + --icon ":gitlab:" \ + --username "gitlab-notify" \ + --channel "#login-appdev" \ + --webhook "${SLACK_WEBHOOK}" \ + --raise \ + --text "Pinpoint supported countries check in GitLab failed.\nBuild Results: ${CI_JOB_URL}.\nCheck results locally with 'make lint_country_dialing_codes'" + fi + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + +audit_packages_scheduled: + needs: + - job: install + cache: + - <<: *ruby_cache + - <<: *yarn_cache + script: + - *bundle_install + - *yarn_install + - make audit + after_script: + - |- + if [ "$CI_JOB_STATUS" != "success" ]; then + ./scripts/notify-slack \ + --icon ":gitlab:" \ + --username "gitlab-notify" \ + --channel "#login-appdev" \ + --webhook "${SLACK_WEBHOOK}" \ + --raise \ + --text "Dependencies audit in GitLab failed.\nBuild Results: ${CI_JOB_URL}\nCheck results locally with 'make audit'" + fi + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" diff --git a/scripts/notify-slack b/scripts/notify-slack new file mode 100755 index 00000000000..52066f45598 --- /dev/null +++ b/scripts/notify-slack @@ -0,0 +1,144 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'optparse' + +# Posts a message to Slack via webhook +class NotifySlack + def run(argv:, stdin:, stdout:) + channel = nil + username = nil + text = nil + webhook = nil + icon = ':login-gov:' + raise_on_failure = false + + program_name = File.basename($PROGRAM_NAME) + + parser = OptionParser.new do |opts| + opts.banner = <<~STR + #{program_name} [options] + + Usage: + + * Using arguments + + #{program_name} --text "my *message*" \\ + --channel "#some-channel" \\ + --webhook https://example.com/webhook + + * Passing text over STDIN + + echo "my *message*" | #{program_name} --text - \\ + --channel "#some-channel" \\ + --webhook https://example.com/webhook + + Options: + STR + + opts.on('--channel CHANNEL', 'which channel to notify') do |channel_v| + channel = channel_v + end + + opts.on('--webhook WEBHOOK', 'Slack webhook URL') do |webhook_v| + webhook = webhook_v + end + + opts.on('--username USERNAME', 'which username to notify as') do |username_v| + username = username_v + end + + opts.on('--text TEXT', 'text of notification, pass - to read from STDIN') do |text_v| + if text_v == '-' + if stdin.tty? + stdout.print 'please enter text of message: ' + text = stdin.gets + else + text = stdin.read + end + else + text = text_v + end + end + + opts.on('--icon ICON', 'slack emoji to use as icon (optional)') do |icon_v| + icon = icon_v + end + + opts.on('--[no-]raise', <<~EOS) do |raise_v| + raise errors/exit uncleanly if the webhook fails. defaults to not raising + EOS + raise_on_failure = raise_v + end + + opts.on('--help') do + puts opts + exit 0 + end + end + + parser.parse!(argv) + + if !channel || !username || !text || !webhook + stdout.puts parser + exit 1 + end + + notify( + webhook: webhook, + channel: channel, + username: username, + text: text, + icon: format_icon(icon), + ) + stdout.puts 'OK' + rescue Net::HTTPClientException => err + stdout.puts "#{program_name} HTTP ERROR: #{err.response.code}" + raise err if raise_on_failure + rescue => err + stdout.puts "#{program_name} ERROR: #{err.message}" + raise err if raise_on_failure + end + + # @raise [Net::HTTPClientException] throws an error for non-successful response + # @return [Net::HTTPResponse] + def notify(webhook:, channel:, username:, text:, icon:) + url = URI(webhook) + + req = Net::HTTP::Post.new(url) + req.form_data = { + 'payload' => { + channel: channel, + username: username, + text: text, + icon_emoji: icon, + }.to_json, + } + + Net::HTTP.start( + url.hostname, + url.port, + use_ssl: url.scheme == 'https', + open_timeout: 1, + read_timeout: 1, + write_timeout: 1, + ssl_timeout: 1, + ) do |http| + http.request(req) + end.value + end + + def format_icon(icon) + if icon.start_with?(':') && icon.end_with?(':') + icon + else + ":#{icon}:" + end + end +end + +if $PROGRAM_NAME == __FILE__ + NotifySlack.new.run(argv: ARGV, stdin: STDIN, stdout: STDOUT) +end diff --git a/spec/scripts/notify-slack_spec.rb b/spec/scripts/notify-slack_spec.rb new file mode 100644 index 00000000000..6ddf1903e4a --- /dev/null +++ b/spec/scripts/notify-slack_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' +require 'rack/utils' +load File.expand_path('../../scripts/notify-slack', __dir__) + +RSpec.describe NotifySlack do + subject(:notifier) { described_class.new } + + let(:webhook) { 'https://slack.example.com/abcdef/ghijkl' } + let(:channel) { '#fun-channel' } + let(:username) { 'notifier-bot' } + let(:text) { 'my message' } + let(:icon) { ':red_circle:' } + + describe '#run' do + let(:argv) do + [ + '--webhook', + webhook, + '--channel', + channel, + '--username', + username, + '--text', + text, + '--icon', icon + ] + end + let(:stdin) { StringIO.new } + let(:stdout) { StringIO.new } + + subject(:run) do + notifier.run(argv:, stdin:, stdout:) + end + + before do + allow(notifier).to receive(:exit) + end + + context 'missing required argument' do + before do + argv.delete('--webhook') + argv.delete(webhook) + end + + it 'prints help and exits uncleanly' do + expect(notifier).to receive(:exit).with(1) + + run + + expect(stdout.string).to include('Usage') + end + end + + it 'notifies' do + post_request = stub_request(:post, webhook) + + run + + expect(post_request).to have_been_made + end + + context 'network error' do + before do + stub_request(:post, webhook).to_return(status: 500) + end + + it 'prints an error and exits cleanly' do + expect(notifier).to_not receive(:exit) + + run + + expect(stdout.string).to include('ERROR: 500') + end + + context 'with --raise' do + before { argv << '--raise' } + + it 'raises an error' do + expect { run }.to raise_error(Net::HTTPExceptions) + end + end + end + end + + describe '#notify' do + subject(:notify) do + notifier.notify(webhook:, channel:, username:, text:, icon:) + end + + it 'POSTs JSON inside of form encoding to the webhook' do + post_request = stub_request(:post, webhook).with( + headers: { + content_type: 'application/x-www-form-urlencoded', + }, + ) do |req| + form = Rack::Utils.parse_query(req.body) + expect(JSON.parse(form['payload'], symbolize_names: true)).to eq( + channel:, + username:, + text:, + icon_emoji: icon, + ) + end + + notify + + expect(post_request).to have_been_made + end + end + + describe '#format_icon' do + it 'adds colons around icon names if missing' do + expect(notifier.format_icon('joy')).to eq(':joy:') + end + + it 'leaves colons around icon names if present' do + expect(notifier.format_icon(':sob:')).to eq(':sob:') + end + end +end