diff --git a/.rubocop.yml b/.rubocop.yml index 12d38e6..03c3fea 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,16 +1,19 @@ inherit_from: .rubocop_todo.yml Metrics/AbcSize: - Max: 40 + Enabled: false Metrics/BlockLength: - Max: 300 + Enabled: false Metrics/CyclomaticComplexity: - Max: 10 + Enabled: false Metrics/MethodLength: - Max: 35 + Enabled: false Metrics/PerceivedComplexity: - Max: 10 \ No newline at end of file + Enabled: false + +Metrics/LineLength: + Enabled: false diff --git a/Gemfile b/Gemfile index 08c46ff..7d81e60 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ gem 'webpacker', '~> 4.0' # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks gem 'turbolinks', '~> 5' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder -gem 'jbuilder', '~> 2.5' +gem 'jbuilder', '~> 2.9' # Use Redis adapter to run Action Cable in production # gem 'redis', '~> 4.0' # Use Active Model has_secure_password @@ -49,9 +49,11 @@ end group :test do # Adds support for Capybara system testing and selenium driver gem 'capybara', '>= 2.15' + gem 'mocha' gem 'selenium-webdriver' # Easy installation and use of web drivers to run system tests with browsers gem 'webdrivers' + gem 'webmock' end group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index ebe9be2..b5a33dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,8 @@ GEM childprocess (1.0.1) rake (< 13.0) concurrent-ruby (1.1.5) + crack (0.4.3) + safe_yaml (~> 1.0.0) crass (1.0.4) erubi (1.8.0) et-orbi (1.2.1) @@ -92,12 +94,12 @@ GEM raabro (~> 1.1) globalid (0.4.2) activesupport (>= 4.2.0) + hashdiff (1.0.0) i18n (1.6.0) concurrent-ruby (~> 1.0) jaro_winkler (1.5.3) - jbuilder (2.8.0) + jbuilder (2.9.1) activesupport (>= 4.2.0) - multi_json (>= 1.2) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.7.0) @@ -113,13 +115,15 @@ GEM mini_mime (>= 0.1.1) marcel (0.3.3) mimemagic (~> 0.3.2) + metaclass (0.0.4) method_source (0.9.2) mimemagic (0.3.3) mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) + mocha (1.9.0) + metaclass (~> 0.0.1) msgpack (1.2.10) - multi_json (1.13.1) nio4r (2.3.1) nokogiri (1.10.3) mini_portile2 (~> 2.4.0) @@ -179,6 +183,7 @@ GEM rubyzip (1.2.2) rufus-scheduler (3.6.0) fugit (~> 1.1, >= 1.1.6) + safe_yaml (1.0.5) sass (3.7.4) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -238,6 +243,10 @@ GEM nokogiri (~> 1.6) rubyzip (~> 1.0) selenium-webdriver (~> 3.0) + webmock (3.6.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webpacker (4.0.2) activesupport (>= 4.2) rack-proxy (>= 0.6.1) @@ -248,7 +257,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.20) - zeitwerk (2.1.6) + zeitwerk (2.1.9) PLATFORMS ruby @@ -258,9 +267,10 @@ DEPENDENCIES bootstrap (~> 4.3.1) byebug capybara (>= 2.15) - jbuilder (~> 2.5) + jbuilder (~> 2.9) letter_opener listen (>= 3.0.5, < 3.2) + mocha puma (~> 3.11) rails (~> 6.0.0.rc1) rubocop @@ -276,6 +286,7 @@ DEPENDENCIES tzinfo-data web-console (>= 3.3.0) webdrivers + webmock webpacker (~> 4.0) yard @@ -283,4 +294,4 @@ RUBY VERSION ruby 2.6.3p62 BUNDLED WITH - 2.0.1 + 2.0.2 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 62fe0b6..5d832c7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,6 @@ class ApplicationController < ActionController::Base protect_from_forgery unless: -> { request.format.json? } def authenticate - # rubocop:disable Metrics/LineLength basic_auth_username = ENV.fetch('BASIC_AUTH_USERNAME', '') basic_auth_password = ENV.fetch('BASIC_AUTH_PASSWORD', '') @@ -14,6 +13,5 @@ def authenticate authenticate_or_request_with_http_basic('Ciao Application') do |username, password| username == basic_auth_username && password == basic_auth_password end - # rubocop:enable Metrics/LineLength end end diff --git a/app/lib/ciao/notifications/base.rb b/app/lib/ciao/notifications/base.rb new file mode 100644 index 0000000..0b2f3b2 --- /dev/null +++ b/app/lib/ciao/notifications/base.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ciao + module Notifications + class Base + def initialize(endpoint = nil, + payload_template = nil, + payload_renderer_cls = Ciao::Renderers::ReplaceRenderer) + @endpoint = endpoint + @payload_renderer = payload_renderer_cls.new(payload_template) + end + + def notify(_payload_data = {}) + raise NotImplementedError, + 'You can not call Ciao::Notifications::Base#notify directly' + end + end + end +end diff --git a/app/lib/ciao/notifications/mail_notification.rb b/app/lib/ciao/notifications/mail_notification.rb new file mode 100644 index 0000000..ff6932c --- /dev/null +++ b/app/lib/ciao/notifications/mail_notification.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ciao + module Notifications + class MailNotification < Base + def notify(payload_data = {}) + CheckMailer.with(payload_data).change_status_mail.deliver + end + end + end +end diff --git a/app/lib/ciao/notifications/webhook_notification.rb b/app/lib/ciao/notifications/webhook_notification.rb new file mode 100644 index 0000000..60a8614 --- /dev/null +++ b/app/lib/ciao/notifications/webhook_notification.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ciao + module Notifications + class WebhookNotification < Base + def notify(payload_data = {}) + uri = URI.parse(@endpoint) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + + request = Net::HTTP::Post.new( + uri.request_uri, + 'Content-Type' => 'application/json' + ) + request.body = @payload_renderer.render(payload_data) + http.request(request) + rescue *NET_HTTP_ERRORS => e + Rails.logger.error "Ciao::Notifications::WebhookNotification#notify Could not notify webhook(#{@endpoint}) - #{e}" + end + end + end +end diff --git a/app/lib/ciao/parsers/webhook_parser.rb b/app/lib/ciao/parsers/webhook_parser.rb new file mode 100644 index 0000000..d6f3407 --- /dev/null +++ b/app/lib/ciao/parsers/webhook_parser.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Ciao + module Parsers + class WebhookParser + WEBHOOKS_ENDPOINT_PREFIX = 'CIAO_WEBHOOK_ENDPOINT_' + WEBHOOKS_PAYLOAD_PREFIX = 'CIAO_WEBHOOK_PAYLOAD_' + + WEBHOOKS_ENDPOINT_FORMAT = "#{WEBHOOKS_ENDPOINT_PREFIX}%s" + WEBHOOKS_PAYLOAD_FORMAT = "#{WEBHOOKS_PAYLOAD_PREFIX}%s" + + WEBHOOKS_FORMAT_REGEX = /^#{WEBHOOKS_ENDPOINT_PREFIX}(?[A-Z0-9_]+)$/.freeze + + def self.webhooks + names.map do |check_name| + { + endpoint: ENV.fetch(WEBHOOKS_ENDPOINT_FORMAT % check_name, ''), + payload: ENV.fetch(WEBHOOKS_PAYLOAD_FORMAT % check_name, '') + } + end + end + + def self.names + matches.map { |match| match[:name] } + end + + def self.matches + ENV.map do |k, _v| + k.match(WEBHOOKS_FORMAT_REGEX) + end.compact + end + end + end +end diff --git a/app/lib/ciao/renderers/base.rb b/app/lib/ciao/renderers/base.rb new file mode 100644 index 0000000..4c49d71 --- /dev/null +++ b/app/lib/ciao/renderers/base.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ciao + module Renderers + class Base + def initialize(template) + @template = template + end + + def render(_data) + raise NotImplementedError, + 'You can not call Ciao::Renderers::Base#render directly' + end + end + end +end diff --git a/app/lib/ciao/renderers/replace_renderer.rb b/app/lib/ciao/renderers/replace_renderer.rb new file mode 100644 index 0000000..d6dcca0 --- /dev/null +++ b/app/lib/ciao/renderers/replace_renderer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Ciao + module Renderers + class ReplaceRenderer < Base + CHECK_NAME_PLACEHOLDER = '__name__' + STATUS_AFTER_PLACEHOLDER = '__status_after__' + STATUS_BEFORE_PLACEHOLDER = '__status_before__' + URL_PLACEHOLDER = '__url__' + CHECK_URL_PLACEHOLDER = '__check_url__' + + def render(data) + return '' if @template.nil? + + @template + .gsub(CHECK_NAME_PLACEHOLDER, data.fetch(:name, '').to_s) + .gsub(STATUS_AFTER_PLACEHOLDER, data.fetch(:status_after, '').to_s) + .gsub(STATUS_BEFORE_PLACEHOLDER, data.fetch(:status_before, '').to_s) + .gsub(URL_PLACEHOLDER, data.fetch(:url, '').to_s) + .gsub(CHECK_URL_PLACEHOLDER, data.fetch(:check_url, '').to_s) + end + end + end +end diff --git a/app/models/check.rb b/app/models/check.rb index df9d2d4..f858a40 100644 --- a/app/models/check.rb +++ b/app/models/check.rb @@ -42,7 +42,6 @@ def self.percentage_healthy end def create_job - # rubocop:disable Metrics/LineLength job = Rufus::Scheduler.singleton.cron cron, job: true do url = URI.parse(self.url) @@ -62,8 +61,16 @@ def create_job status_after = self.status end if status_before != status_after - CheckMailer.with(name: name, status_before: status_before, status_after: status_after).change_status_mail.deliver - Rails.logger.info "ciao-scheduler Sent 'changed_status' notification mail" + Rails.logger.info "ciao-scheduler Check '#{name}': Status changed from '#{status_before}' to '#{status_after}'" + NOTIFICATIONS.each do |notification| + notification.notify( + name: name, + status_before: status_before, + status_after: status_after, + url: url, + check_url: Rails.application.routes.url_helpers.check_path(self) + ) + end end end if job @@ -73,7 +80,6 @@ def create_job Rails.logger.error 'ciao-scheduler Could not create job' end job - # rubocop:enable Metrics/LineLength end def unschedule_job diff --git a/bin/bundle b/bin/bundle index 6965387..674b370 100755 --- a/bin/bundle +++ b/bin/bundle @@ -28,7 +28,7 @@ m = Module.new do bundler_version = nil update_index = nil ARGV.each_with_index do |a, i| - if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN # rubocop:disable Style/IfUnlessModifier bundler_version = a end next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ @@ -79,7 +79,7 @@ m = Module.new do end def activate_bundler(bundler_version) - if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new('2.0') + if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new('2.0') # rubocop:disable Style/IfUnlessModifier bundler_version = '< 2' end gem_error = activation_error_handling do diff --git a/config/initializers/create_background_jobs.rb b/config/initializers/create_background_jobs.rb index 487bbba..3d73a4f 100644 --- a/config/initializers/create_background_jobs.rb +++ b/config/initializers/create_background_jobs.rb @@ -2,6 +2,7 @@ # Create all Rufus Scheduler Jobs for active checks on Application Start # Prevent the initializer to be run during rake tasks -if defined?(Rails::Server) && ActiveRecord::Base.connection.table_exists?('checks') + +if defined?(Rails::Server) && ActiveRecord::Base.connection.table_exists?('checks') # rubocop:disable Style/IfUnlessModifier Check.active.each(&:create_job) end diff --git a/config/initializers/notifications.rb b/config/initializers/notifications.rb new file mode 100644 index 0000000..d08481d --- /dev/null +++ b/config/initializers/notifications.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Some time in the future Rails is not going to auto_load these for us :( +# we have to explictly require it here +Dir[Rails.root.join('app', 'lib', 'ciao', '**', '*.rb')].each { |f| require f } + +# export CIAO_WEBHOOK_ENDPOINT_$NAME=https://chat.yourhost.net/***** +# export CIAO_WEBHOOK_PAYLOAD_$NAME=#'{"username":"Brot & Games","icon_url":"https://avatars0.githubusercontent.com/u/43862266?s=400&v=4","text":"Example message","attachments":[{"title":"Rocket.Chat","title_link":"https://rocket.chat","text":"Rocket.Chat, the best open source chat","image_url":"/images/integration-attachment-example.png","color":"#764FA5"}]}' +# `$NAME` can be any word `[A-Z0-9_]+` and must be unique as it is used as an identifier + +NOTIFICATIONS = Ciao::Parsers::WebhookParser.webhooks.map do |webhook| + Ciao::Notifications::WebhookNotification.new( + webhook[:endpoint], + webhook[:payload], + Ciao::Renderers::ReplaceRenderer + ) +end + +NOTIFICATIONS << Ciao::Notifications::MailNotification.new if ENV['SMTP_ADDRESS'].present? diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c92e8e..f998bb5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,8 @@ ENV['RAILS_ENV'] ||= 'test' require_relative '../config/environment' require 'rails/test_help' +require 'mocha/minitest' +require 'webmock/minitest' module ActiveSupport class TestCase diff --git a/test/unit/ciao/notifications/webhook_notification_test.rb b/test/unit/ciao/notifications/webhook_notification_test.rb new file mode 100644 index 0000000..43243ac --- /dev/null +++ b/test/unit/ciao/notifications/webhook_notification_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Ciao + module Notifications + class WebhookNotificationTest < ActiveSupport::TestCase + test '#initialize assigns @endpoint & @payload_renderer' do + notification = Ciao::Notifications::WebhookNotification.new( + 'https://foo.bar', + '{"foo": "bar"}', + Ciao::Renderers::ReplaceRenderer + ) + assert_equal 'https://foo.bar', + notification.instance_variable_get(:@endpoint) + assert_instance_of Ciao::Renderers::ReplaceRenderer, + notification.instance_variable_get(:@payload_renderer) + end + + test '#notify' do + stub_request(:post, 'https://foo.bar/').with( + body: '{"name": "bar"}', + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Content-Type' => 'application/json', + 'User-Agent' => 'Ruby' + } + ).to_return(status: 200, body: '{"status":"success"}', headers: {}) + + notification = Ciao::Notifications::WebhookNotification.new( + 'https://foo.bar', + '{"name": "__name__"}', + Ciao::Renderers::ReplaceRenderer + ) + response = notification.notify(name: 'bar') + assert_equal '{"status":"success"}', response.body + assert_equal '200', response.code + end + end + end +end diff --git a/test/unit/ciao/parsers/webhook_parser_test.rb b/test/unit/ciao/parsers/webhook_parser_test.rb new file mode 100644 index 0000000..43c9441 --- /dev/null +++ b/test/unit/ciao/parsers/webhook_parser_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Ciao + module Parsers + class WebhookParserTest < ActiveSupport::TestCase + test 'self.matches' do + ENV['CIAO_WEBHOOK_ENDPOINT_1'] = 'https://foo.bar' + assert_equal '1', Ciao::Parsers::WebhookParser.matches.first[:name] + end + + test 'self.names' do + Ciao::Parsers::WebhookParser.expects(:matches).returns([stub(:[] => '1')]) + assert_equal ['1'], Ciao::Parsers::WebhookParser.names + end + + test 'self.webhooks' do + ENV['CIAO_WEBHOOK_ENDPOINT_1'] = 'https://foo.bar' + ENV['CIAO_WEBHOOK_PAYLOAD_1'] = '{"foo":"bar"}' + assert_equal [{ + endpoint: 'https://foo.bar', + payload: '{"foo":"bar"}' + }], Ciao::Parsers::WebhookParser.webhooks + end + end + end +end diff --git a/test/unit/ciao/renderers/replace_renderer_test.rb b/test/unit/ciao/renderers/replace_renderer_test.rb new file mode 100644 index 0000000..c8897e4 --- /dev/null +++ b/test/unit/ciao/renderers/replace_renderer_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Ciao + module Renderers + class ReplaceRendererTest < ActiveSupport::TestCase + test '#initialize assigns @template' do + renderer = ReplaceRenderer.new('{"name": "__name__"}') + assert_equal '{"name": "__name__"}', + renderer.instance_variable_get(:@template) + end + + test '#render replaces webhook placeholders' do + renderer = ReplaceRenderer.new( + '{"name": "__name__", "status_after":"__status_after__", "status_before":"__status_before__"}' + ) + assert_equal '{"name": "foo", "status_after":"500", "status_before":"200"}', + renderer.render(name: 'foo', status_after: '500', status_before: '200') + end + end + end +end diff --git a/webhook_configuration.md b/webhook_configuration.md index 24519fd..7c7b312 100644 --- a/webhook_configuration.md +++ b/webhook_configuration.md @@ -4,8 +4,8 @@ You can configure as many webhooks as you like. Each webhook consists of 2 ENV variables: -* `WEBHOOK_ENDPOINT_$NAME` -* `WEBHOOK_PAYLOAD_$NAME` +* `CIAO_WEBHOOK_ENDPOINT_$NAME` +* `CIAO_WEBHOOK_PAYLOAD_$NAME` `$NAME` can be any word `[A-Z0-9_]` and must be unique as it is used as an identifier. @@ -13,23 +13,28 @@ like: ```` # Webhook for Rocketchat -WEBHOOK_ENDPOINT_ROCKETCHAT="https://webhook.rocketchat.com/***/***" -WEBHOOK_PAYLOAD_ROCKETCHAT='"{foo=bar}"' +CIAO_WEBHOOK_ENDPOINT_ROCKETCHAT="https://webhook.rocketchat.com/***/***" +CIAO_WEBHOOK_PAYLOAD_ROCKETCHAT='"{foo=bar}"' # Webhook for Slack -WEBHOOK_ENDPOINT_SLACK="https://webhook.slack.com/***/***" -WEBHOOK_PAYLOAD_SLACK='"{foo=bar}"' +CIAO_WEBHOOK_ENDPOINT_SLACK="https://webhook.slack.com/***/***" +CIAO_WEBHOOK_PAYLOAD_SLACK='"{foo=bar}"' etc. ```` -`WEBHOOK_PAYLOAD_$NAME` ENV variable has to be a valid JSON one-liner wrapped in single quotes like `'{"text":"Example message"}'`. +`CIAO_WEBHOOK_PAYLOAD_$NAME` ENV variable has to be a valid JSON one-liner wrapped in single quotes like `'{"name":"__name__", "status_before":"__status_before__", "status_after":"__status_after__", "check_url":"__check_url__", "url":"__url__"}'`. ## Example configurations ### RocketChat -tbd. +```` +CIAO_WEBHOOK_ENDPOINT_ROCKETCHAT="https://chat.yourchat.net/hooks/****/****" + +CIAO_WEBHOOK_PAYLOAD_ROCKETCHAT='{"username":"Brot & Games","icon_url":"https://avatars0.githubusercontent.com/u/43862266?s=400&v=4","text":"[ciao] __name__: Status changed (__status_after__)"}' + +```` ### Slack