├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── alerty.gemspec ├── bin └── alerty ├── example.yml ├── lib ├── alerty.rb └── alerty │ ├── cli.rb │ ├── command.rb │ ├── config.rb │ ├── error.rb │ ├── logger.rb │ ├── plugin │ ├── exec.rb │ ├── file.rb │ └── stdout.rb │ ├── plugin_factory.rb │ ├── string_util.rb │ └── version.rb ├── log └── .gitkeep └── spec ├── cli_spec.rb ├── cli_spec.yml ├── command_spec.rb ├── config_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .bundle 3 | .pryrc 4 | .rvmrc 5 | .rbenv-version 6 | .ruby-version 7 | .yardoc 8 | .DS_Store 9 | Gemfile.lock 10 | coverage 11 | doc 12 | log 13 | tmp 14 | vendor 15 | *.swp 16 | pkg/ 17 | .env 18 | log/alerty.log 19 | spec/cli_spec.out 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.1.* 4 | - 2.2.* 5 | - 2.3.* 6 | - 2.4.* 7 | before_install: 8 | - gem update bundler 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.4.0 (2017/09/29) 2 | 3 | Enhancements: 4 | 5 | * [experimental] send alert from stdin 6 | 7 | # 0.3.0 (2017/03/20) 8 | 9 | Enhancements: 10 | 11 | * Use popen2e rather than `2>&1` 12 | 13 | # 0.2.3 (2017/01/20) 14 | 15 | Enhancements: 16 | 17 | * Enrich debug print 18 | * Add --version option 19 | 20 | # 0.2.2 (2017/01/20) 21 | 22 | Fixes: 23 | 24 | * Fix log_shift_age and log_shift_size config were not working (instead, shift_age and shift_size were working) 25 | 26 | # 0.2.1 (2016/09/25) 27 | 28 | Enhancements: 29 | 30 | * Add --dotenv option to load environment variables from .env file 31 | 32 | # 0.2.0 (2016/09/25) 33 | 34 | Enhancements: 35 | 36 | * Allow erb in config yaml 37 | 38 | # 0.1.1 (2016/09/05) 39 | 40 | Enhancements: 41 | 42 | * Bundler.with_clean_env 43 | 44 | # 0.1.0 (2016/03/28) 45 | 46 | Enhancements: 47 | 48 | * Add --log-shift-age option 49 | * Add --log-shift-size option 50 | 51 | # 0.0.9 (2015/11/23) 52 | 53 | Enhancements: 54 | 55 | * Support retry 56 | 57 | # 0.0.8 (2015/08/15) 58 | 59 | Enhancements: 60 | 61 | * Send alert if timeout or locked occurs 62 | 63 | # 0.0.7 (2015/08/15) 64 | 65 | Enhancements: 66 | 67 | * Change -l option from --lock to --log-level 68 | * Add -d (--debug) option, same with -l debug 69 | 70 | # 0.0.6 (2015/08/14) 71 | 72 | Enhancements: 73 | 74 | * Add info log to show command result 75 | 76 | # 0.0.5 (2015/08/14) 77 | 78 | Changes: 79 | 80 | * Change default conf path from /etc/sysconfig/alerty to /etc/alerty/alerty.yml 81 | 82 | # 0.0.4 (2015/08/14) 83 | 84 | Enchancements: 85 | 86 | * Add exec plugin 87 | * Pass `hostname` to plugins 88 | 89 | # 0.0.3 (2015/08/14) 90 | 91 | Enchancements: 92 | 93 | * Pass more information to plugins 94 | * command 95 | * exitstatus 96 | * output 97 | * started_at 98 | * duration 99 | 100 | # 0.0.2 (2015/08/13) 101 | 102 | Enchancements: 103 | 104 | * Allow to configure timeout and lock_path in config file (options outcome) 105 | * Allow to configure log_path and log_level as command options (options outcome) 106 | 107 | # 0.0.1 (2015/08/13) 108 | 109 | first version 110 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Naotoshi Seo 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # alerty 2 | 3 | A pluggable CLI utility to send an alert if a given command failed. 4 | 5 | ## How Useful? 6 | 7 | I use `alerty` to run commands in cron to send alerts if cron commands fail. 8 | 9 | ``` 10 | 0 * * * * alerty -c /etc/alerty/alerty.yml -- /path/to/script --foo FOO --bar 11 | ``` 12 | 13 | ## Installation 14 | 15 | ``` 16 | gem install alerty 17 | ``` 18 | 19 | ## Configuration 20 | 21 | You can write a configuration file located at `/etc/alerty/alerty.yml` (You can configure this path by `ALERTY_CONFIG_FILE` environment variable, or `-c` option): 22 | 23 | ``` 24 | log_path: /var/tmp/alerty.log 25 | log_level: 'info' 26 | log_shift_age: 10 27 | log_shift_size: 10485760 28 | timeout: 10 29 | lock_path: /tmp/lock 30 | retry_limit: 2 31 | retry_wait: 10 32 | plugins: 33 | - type: stdout 34 | ``` 35 | 36 | [example.yml](./example.yml) 37 | 38 | ### CLI Example 39 | 40 | ``` 41 | $ alerty -c example.yml -- ls -l /something_not_exist 42 | ``` 43 | 44 | ### CLI Help 45 | 46 | ``` 47 | Usage: alerty [options] -- command 48 | -c, --config CONFIG_FILE config file path (default: /etc/alerty/alerty.yml) 49 | --log LOG_FILE log file path (default: STDOUT) 50 | -l, --log-level LOG_LEVEL log level (default: warn) 51 | --log-shift-age SHIFT_AGE Number of old log files to keep (default: 0 which means no log rotation) 52 | --log-shift-size SHIFT_SIZE Maximum logfile size in bytes (default: 1048576) 53 | -t, --timeout SECONDS timeout the command (default: no timeout) 54 | --lock LOCK_FILE exclusive lock file to prevent running a command duplicatedly (default: no lock) 55 | --retry-limit NUMBER number of retries (default: 0) 56 | --retry-wait SECONDS retry interval = retry wait +/- 12.5% randomness (default: 1.0) 57 | -d, --debug debug mode 58 | --dotenv Load environment variables from .env file with dotenv 59 | ``` 60 | 61 | ### Experimental: Send alert from STDIN 62 | 63 | This interface allows us to send notification even if a command does not fail. 64 | 65 | CLI Example: 66 | 67 | ``` 68 | $ echo 'this is a test' | alerty -c example.yml 69 | ``` 70 | 71 | ## Plugins 72 | 73 | Following plugins are available: 74 | 75 | * [stdout](./lib/alerty/plugin/stdout.rb) 76 | * [file](./lib/alerty/plugin/file.rb) 77 | * [exec](./lib/alerty/plugin/exec.rb) 78 | * [sonots/alerty-plugin-ikachan](https://github.com/sonots/alerty-plugin-ikachan) 79 | * [sonots/alerty-plugin-amazon_sns](https://github.com/sonots/alerty-plugin-amazon_sns) 80 | * [sonots/alerty-plugin-slack](https://github.com/sonots/alerty-plugin-slack) 81 | * [civitaspo/alerty-plugin-mail](https://github.com/civitaspo/alerty-plugin-mail) 82 | * [inokappa/alerty-plugin-datadog_event](https://github.com/inokappa/alerty-plugin-datadog_event) 83 | * [inokappa/alerty-plugin-amazon_cloudwatch_logs](https://github.com/inokappa/alerty-plugin-amazon_cloudwatch_logs) 84 | * [inokappa/alerty-plugin-post_im_kayac](https://github.com/inokappa/alerty-plugin-post_im_kayac) 85 | * [potato2003/alerty-plugin-stackdriver](https://github.com/potato2003/alerty-plugin-stackdriver) 86 | * [is2ei/alerty-plugin-typetalk](https://github.com/is2ei/alerty-plugin-typetalk) 87 | 88 | ## Plugin Architecture 89 | 90 | ### Naming Convention 91 | 92 | You must follow following naming conventions: 93 | 94 | * gem name: alerty-plugin-xxx (xxx_yyy) 95 | * file name: lib/alerty/plugin/xxx.rb (xxx_yyy.rb) 96 | * class name: Alerty::Plugin:Xxx (XxxYyy) 97 | 98 | ### Interface 99 | 100 | What you have to implement is `#initialize` and `#alert` methods. Here is an example of `file` plugin: 101 | 102 | ```ruby 103 | require 'json' 104 | 105 | class Alerty 106 | class Plugin 107 | class File 108 | def initialize(config) 109 | raise ConfigError.new('file: path is not configured') unless config.path 110 | @path = config.path 111 | end 112 | 113 | def alert(record) 114 | ::File.open(@path, 'a') do |io| 115 | io.puts record.to_json 116 | end 117 | end 118 | end 119 | end 120 | end 121 | ``` 122 | 123 | ### config 124 | 125 | `config` is created from the configuration file: 126 | 127 | ``` 128 | plugins: 129 | - type: foobar 130 | key1: val1 131 | key2: val2 132 | ``` 133 | 134 | `config.key1` and `config.key2` are availabe in the above config. 135 | 136 | ### record 137 | 138 | `record` is a hash whose keys are symbols of followings: 139 | 140 | * hostname: hostname 141 | * command: the executed command 142 | * exitstatus: the exit status of the executed command 143 | * output: the output of the exectued command 144 | * started_at: the time when command executed in epoch time. 145 | * duration: the duration which the command execution has taken in seconds. 146 | * retries: the number of retries 147 | 148 | ## ChangeLog 149 | 150 | See [CHANGELOG.md](CHANGELOG.md) for details. 151 | 152 | ### Licenses 153 | 154 | See [LICENSE](LICENSE) 155 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; -*- 2 | require "bundler/gem_tasks" 3 | 4 | begin 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new :spec do |spec| 7 | spec.pattern = FileList['spec/**/*_spec.rb'] 8 | end 9 | task default: :spec 10 | rescue LoadError, NameError 11 | # OK, they can be absent on non-development mode. 12 | end 13 | 14 | desc "irb console" 15 | task :console do 16 | require_relative "lib/alerty" 17 | require 'irb' 18 | require 'irb/completion' 19 | ARGV.clear 20 | IRB.start 21 | end 22 | task :c => :console 23 | -------------------------------------------------------------------------------- /alerty.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/alerty/version' 2 | 3 | Gem::Specification.new do |gem| 4 | gem.name = "alerty" 5 | gem.version = Alerty::VERSION 6 | gem.author = ['Naotoshi Seo'] 7 | gem.email = ['sonots@gmail.com'] 8 | gem.homepage = 'https://github.com/sonots/alerty' 9 | gem.summary = "Send an alert if a given command failed" 10 | gem.description = "Send an alert if a given command failed" 11 | gem.licenses = ['MIT'] 12 | 13 | gem.files = `git ls-files`.split("\n") 14 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 15 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 16 | gem.require_paths = ["lib"] 17 | 18 | gem.add_runtime_dependency 'hashie' 19 | gem.add_runtime_dependency 'oneline_log_formatter' 20 | gem.add_runtime_dependency 'frontkick', '>= 0.5.5' 21 | gem.add_runtime_dependency 'dotenv' 22 | 23 | gem.add_development_dependency 'rspec' 24 | gem.add_development_dependency 'pry' 25 | gem.add_development_dependency 'pry-nav' 26 | gem.add_development_dependency 'rake' 27 | gem.add_development_dependency 'bundler' 28 | end 29 | -------------------------------------------------------------------------------- /bin/alerty: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../lib/alerty/cli' 4 | Alerty::CLI.new.run 5 | -------------------------------------------------------------------------------- /example.yml: -------------------------------------------------------------------------------- 1 | log_path: STDOUT 2 | log_level: debug 3 | log_shift_age: 5 4 | log_shift_size: 10485760 5 | timeout: 10 6 | lock_path: log/lock 7 | plugins: 8 | - type: stdout 9 | - type: file 10 | path: log/<%= ENV['FILE'] || 'file' %>.log 11 | - type: exec 12 | command: cat > log/exec.log 13 | -------------------------------------------------------------------------------- /lib/alerty.rb: -------------------------------------------------------------------------------- 1 | require_relative 'alerty/version' 2 | require_relative 'alerty/error' 3 | require_relative 'alerty/config' 4 | require_relative 'alerty/command' 5 | require_relative 'alerty/logger' 6 | require_relative 'alerty/cli' 7 | require_relative 'alerty/string_util' 8 | require_relative 'alerty/plugin_factory' 9 | 10 | class Alerty 11 | def self.logger 12 | @logger ||= Logger.new(Config.log_path, Config.log_shift_age, Config.log_shift_size).tap do |logger| 13 | logger.level = Config.log_level 14 | end 15 | end 16 | 17 | # @param [Hash] record 18 | # @option record [String] :hostname 19 | # @option record [String] :command 20 | # @option record [Integer] :exitstatus 21 | # @option record [String] :output 22 | # @option record [Float] :started_at unix timestamp 23 | # @option record [Float] :duration 24 | # @option record [Integer] :retries number of being retried 25 | def self.send(record) 26 | PluginFactory.plugins.each do |plugin| 27 | begin 28 | plugin.alert(record) 29 | rescue => e 30 | puts "#{e.class} #{e.message} #{e.backtrace.join("\n")}" if Config.debug? 31 | Alerty.logger.warn "#{e.class} #{e.message} #{e.backtrace.join("\n")}" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/alerty/cli.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'socket' 3 | require_relative '../alerty' 4 | 5 | class Alerty 6 | class CLI 7 | def parse_options(argv = ARGV) 8 | op = OptionParser.new 9 | op.banner += ' -- command' 10 | 11 | (class< e 68 | usage e.message 69 | end 70 | 71 | Config.configure(opts) 72 | PluginFactory.plugins # load plugins in early stage 73 | 74 | if !opts[:command].empty? 75 | command = Command.new(command: opts[:command]) 76 | record = command.run 77 | unless record[:exitstatus] == 0 78 | Alerty.send(record) 79 | exit record[:exitstatus] 80 | end 81 | else 82 | begin 83 | stdin = $stdin.read_nonblock(100 * 1024 * 1024) 84 | record = {hostname: Socket.gethostname, output: stdin} 85 | Alerty.send(record) 86 | rescue IO::EAGAINWaitReadable => e 87 | usage 'command argument or STDIN is required' 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/alerty/command.rb: -------------------------------------------------------------------------------- 1 | require 'frontkick' 2 | require 'socket' 3 | require 'json' 4 | 5 | class Alerty 6 | class Command 7 | def initialize(command:) 8 | @command = command 9 | @opts = { timeout: Config.timeout, exclusive: Config.lock_path, popen2e: true } 10 | @hostname = Socket.gethostname 11 | end 12 | 13 | def run 14 | record = {} 15 | with_retry do |retries| 16 | started_at = Time.now 17 | begin 18 | result = with_clean_env { Frontkick.exec(@command, @opts) } 19 | rescue Frontkick::Timeout => e 20 | record = { 21 | hostname: @hostname, 22 | command: @command, 23 | exitstatus: 1, 24 | output: "`#{@command}` is timeout (#{@opts[:timeout]} sec)", 25 | started_at: started_at.to_f, 26 | duration: @opts[:timeout], 27 | retries: retries, 28 | } 29 | rescue Frontkick::Locked => e 30 | record = { 31 | hostname: @hostname, 32 | command: @command, 33 | exitstatus: 1, 34 | output: "`#{@opts[:exclusive]}` is locked by another process", 35 | started_at: started_at.to_f, 36 | duration: 0, 37 | retries: retries, 38 | } 39 | else 40 | record = { 41 | hostname: @hostname, 42 | command: @command, 43 | exitstatus: result.exitstatus, 44 | output: result.output, 45 | started_at: started_at.to_f, 46 | duration: result.duration, 47 | retries: retries, 48 | } 49 | end 50 | Alerty.logger.info { "result: #{record.to_json}" } 51 | record 52 | end 53 | record 54 | end 55 | 56 | private 57 | 58 | def with_clean_env 59 | if defined?(Bundler) 60 | Bundler.with_clean_env do 61 | yield 62 | end 63 | else 64 | yield 65 | end 66 | end 67 | 68 | def with_retry 69 | retries = 0 70 | while true 71 | record = yield(retries) 72 | break if record[:exitstatus] == 0 73 | break if retries >= Config.retry_limit 74 | retries += 1 75 | sleep Config.retry_interval 76 | end 77 | end 78 | 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/alerty/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'erb' 3 | require 'hashie/mash' 4 | 5 | class Alerty 6 | class Config 7 | class << self 8 | attr_reader :opts 9 | 10 | def configure(opts) 11 | @opts = opts 12 | end 13 | 14 | def config_path 15 | @config_path ||= opts[:config_path] || ENV['ALERTY_CONFIG_PATH'] || '/etc/alerty/alerty.yml' 16 | end 17 | 18 | def config 19 | return @config if @config 20 | if dotenv? 21 | require 'dotenv' 22 | Dotenv.load 23 | end 24 | content = File.read(config_path) 25 | erb = ERB.new(content, nil, '-') 26 | erb_content = erb.result 27 | if debug? 28 | puts '=== Erb evaluated config ===' 29 | puts erb_content 30 | end 31 | yaml = YAML.load(erb_content) 32 | @config = Hashie::Mash.new(yaml) 33 | if debug? 34 | puts '=== Recognized config ===' 35 | puts({ 36 | 'log_path' => log_path, 37 | 'log_level' => log_level, 38 | 'log_shift_age' => log_shift_age, 39 | 'log_shift_size' => log_shift_size, 40 | 'timeout' => timeout, 41 | 'lock_path' => lock_path, 42 | 'retry_limit' => retry_limit, 43 | 'retry_wait' => retry_wait, 44 | 'plugins' => yaml['plugins'], 45 | }.to_yaml) 46 | end 47 | @config 48 | end 49 | 50 | def log_path 51 | opts[:log_path] || config.log_path || 'STDOUT' 52 | end 53 | 54 | def log_level 55 | opts[:log_level] || config.log_level || 'warn' 56 | end 57 | 58 | def log_shift_age 59 | # config.shift_age is for old version compatibility 60 | opts[:log_shift_age] || config.shift_age || config.log_shift_age || 0 61 | end 62 | 63 | def log_shift_size 64 | # config.log_shift_age is for old version compatibility 65 | opts[:log_shift_size] || config.shift_size || config.log_shift_size || 1048576 66 | end 67 | 68 | def timeout 69 | opts[:timeout] || config.timeout 70 | end 71 | 72 | def lock_path 73 | opts[:lock_path] || config.lock_path 74 | end 75 | 76 | def retry_limit 77 | opts[:retry_limit] || config.retry_limit || 0 78 | end 79 | 80 | def retry_wait 81 | opts[:retry_wait] || config.retry_wait || 1.0 82 | end 83 | 84 | def debug? 85 | !!opts[:debug] 86 | end 87 | 88 | def dotenv? 89 | !!opts[:dotenv] 90 | end 91 | 92 | def retry_interval 93 | @random ||= Random.new 94 | randomness = retry_wait * 0.125 95 | retry_wait + @random.rand(-randomness .. randomness) 96 | end 97 | 98 | def plugins 99 | @plugins ||= config.fetch('plugins') 100 | end 101 | 102 | # for debug 103 | def reset 104 | @config_path = nil 105 | @config = nil 106 | @plugins = nil 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/alerty/error.rb: -------------------------------------------------------------------------------- 1 | class Alerty 2 | class Error < StandardError 3 | end 4 | class ConfigError < Error 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/alerty/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'oneline_log_formatter' 3 | 4 | class Alerty 5 | class Logger < ::Logger 6 | def initialize(logdev, shift_age = 0, shift_size = 1048576) 7 | logdev = STDOUT if logdev == 'STDOUT' 8 | super(logdev, shift_age, shift_size) 9 | @formatter = OnelineLogFormatter.new 10 | end 11 | 12 | def level=(level) 13 | level = eval("::Logger::#{level.upcase}") if level.is_a?(String) 14 | super(level) 15 | end 16 | 17 | def write(msg) 18 | @logdev.write msg 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/alerty/plugin/exec.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'frontkick' 3 | require 'shellwords' 4 | 5 | class Alerty 6 | class Plugin 7 | class Exec 8 | def initialize(config) 9 | raise ConfigError.new("exec: command is not configured") unless config.command 10 | @command = config.command 11 | end 12 | 13 | def alert(record) 14 | Alerty.logger.info "exec: echo #{record.to_json.shellescape} | #{@command}" 15 | Frontkick.exec("echo #{record.to_json.shellescape} | #{@command}") 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/alerty/plugin/file.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class Alerty 4 | class Plugin 5 | class File 6 | def initialize(config) 7 | raise ConfigError.new('file: path is not configured') unless config.path 8 | @path = config.path 9 | end 10 | 11 | def alert(record) 12 | ::File.open(@path, 'a') do |io| 13 | io.puts record.to_json 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/alerty/plugin/stdout.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class Alerty 4 | class Plugin 5 | class Stdout 6 | def initialize(config) 7 | end 8 | 9 | def alert(record) 10 | $stdout.puts record.to_json 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/alerty/plugin_factory.rb: -------------------------------------------------------------------------------- 1 | class Alerty 2 | class PluginFactory 3 | class << self 4 | def plugins 5 | @plugins ||= Config.plugins.map {|config| new_plugin(config) } 6 | end 7 | 8 | def new_plugin(config) 9 | require "alerty/plugin/#{config.type}" 10 | class_name = "Alerty::Plugin::#{StringUtil.camelize(config.type)}" 11 | Object.const_get(class_name).new(config) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/alerty/string_util.rb: -------------------------------------------------------------------------------- 1 | class Alerty 2 | class StringUtil 3 | def self.camelize(string) 4 | string = string.sub(/^[a-z\d]*/) { $&.capitalize } 5 | string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { $2.capitalize } 6 | string.gsub!(/\//, '::') 7 | string 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/alerty/version.rb: -------------------------------------------------------------------------------- 1 | class Alerty 2 | VERSION = '0.4.0' 3 | end 4 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonots/alerty/f6c7928bd1727aaaf5b984e819ca2c1c8e0f2510/log/.gitkeep -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'fileutils' 3 | 4 | describe Alerty::CLI do 5 | CONFIG_PATH = File.join(File.dirname(__FILE__), 'cli_spec.yml') 6 | BIN_DIR = File.join(ROOT, 'bin') 7 | OUTPUT_FILE = File.join(File.dirname(__FILE__), 'cli_spec.out') 8 | 9 | describe '#parse_options' do 10 | it 'command' do 11 | expect(Alerty::CLI.new.parse_options(['--', 'ls', '-l'])[:command]).to eql('ls -l') 12 | end 13 | 14 | it 'config' do 15 | expect(Alerty::CLI.new.parse_options(['-c', 'config.yml', '--', 'ls'])[:config_path]).to eql('config.yml') 16 | end 17 | end 18 | 19 | describe '#run' do 20 | context 'with command' do 21 | context 'with success' do 22 | before :all do 23 | FileUtils.rm(OUTPUT_FILE, force: true) 24 | system("#{File.join(BIN_DIR, 'alerty')} -c #{CONFIG_PATH} -- echo foo") 25 | sleep 0.1 26 | end 27 | 28 | it 'should not output' do 29 | expect(File.size?(OUTPUT_FILE)).to be_falsey 30 | end 31 | 32 | it { expect($?.exitstatus).to eq(0) } 33 | end 34 | 35 | context 'with failure' do 36 | before :all do 37 | FileUtils.rm(OUTPUT_FILE, force: true) 38 | system("#{File.join(BIN_DIR, 'alerty')} -c #{CONFIG_PATH} -- [ a = b ]") 39 | sleep 0.1 40 | end 41 | 42 | it 'should output' do 43 | expect(File.size?(OUTPUT_FILE)).to be_truthy 44 | end 45 | 46 | it { expect($?.exitstatus).to eq(1) } 47 | end 48 | end 49 | 50 | context 'with stdin' do 51 | before :all do 52 | FileUtils.rm(OUTPUT_FILE, force: true) 53 | open("| #{File.join(BIN_DIR, 'alerty')} -c #{CONFIG_PATH}", 'w') {|f| f.puts 'test' } 54 | sleep 0.1 55 | end 56 | 57 | it 'should output' do 58 | expect(File.size?(OUTPUT_FILE)).to be_truthy 59 | end 60 | 61 | it { expect($?.exitstatus).to eq(0) } 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/cli_spec.yml: -------------------------------------------------------------------------------- 1 | log_path: STDOUT 2 | log_level: debug 3 | timeout: 10 4 | plugins: 5 | - type: file 6 | path: spec/cli_spec.out 7 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Alerty::Command do 4 | describe 'run' do 5 | context 'Frontkick.exec' do 6 | before do 7 | Alerty::Config.configure( 8 | log_path: '/tmp/foo', 9 | log_level: 'fatal', 10 | timeout: 20, 11 | lock_path: '/tmp/lock', 12 | config_path: File.join(ROOT, 'spec/cli_spec.yml'), 13 | ) 14 | end 15 | 16 | let(:command) { Alerty::Command.new(command: 'ls') } 17 | 18 | it do 19 | expect(Frontkick).to receive(:exec).with("ls", { 20 | timeout: 20, 21 | exclusive: '/tmp/lock', 22 | popen2e: true, 23 | }).and_return(Frontkick::Result.new(exit_code: 0)) 24 | command.run 25 | end 26 | end 27 | 28 | context 'plugins.alert' do 29 | before do 30 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 31 | plugins: [{ 32 | type: 'stdout', 33 | }] 34 | )) 35 | Alerty::Config.configure( 36 | log_path: '/tmp/foo', 37 | log_level: 'fatal', 38 | timeout: nil, 39 | lock_path: nil, 40 | ) 41 | end 42 | 43 | let(:command) { Alerty::Command.new(command: 'echo foo') } 44 | 45 | it do 46 | expect(Frontkick).to receive(:exec).with("echo foo", { 47 | timeout: nil, 48 | exclusive: nil, 49 | popen2e: true, 50 | }).and_return(Frontkick::Result.new(stdout: 'foo', exit_code: 1)) 51 | record = command.run 52 | expect(record[:output]).to eql("foo") 53 | end 54 | end 55 | 56 | context 'timeout' do 57 | before do 58 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 59 | plugins: [{ 60 | type: 'stdout', 61 | }] 62 | )) 63 | Alerty::Config.configure( 64 | log_path: '/tmp/foo', 65 | log_level: 'fatal', 66 | timeout: 0.1, 67 | lock_path: '/tmp/lock', 68 | ) 69 | end 70 | 71 | let(:command) { Alerty::Command.new(command: 'sleep 1') } 72 | 73 | it do 74 | expect(Frontkick).to receive(:exec).with("sleep 1", { 75 | timeout: 0.1, 76 | exclusive: '/tmp/lock', 77 | popen2e: true, 78 | }).and_raise(Frontkick::Timeout.new(111, 'sleep 1', true)) 79 | record = command.run 80 | expect(record[:output]).to include("timeout") 81 | end 82 | end 83 | 84 | context 'lock' do 85 | before do 86 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 87 | plugins: [{ 88 | type: 'stdout', 89 | }] 90 | )) 91 | Alerty::Config.configure( 92 | log_path: '/tmp/foo', 93 | log_level: 'fatal', 94 | timeout: 20, 95 | lock_path: '/tmp/lock', 96 | ) 97 | end 98 | 99 | let(:command) { Alerty::Command.new(command: 'sleep 1') } 100 | 101 | it do 102 | expect(Frontkick).to receive(:exec).with("sleep 1", { 103 | timeout: 20, 104 | exclusive: '/tmp/lock', 105 | popen2e: true, 106 | }).and_raise(Frontkick::Locked) 107 | record = command.run 108 | expect(record[:output]).to include("lock") 109 | end 110 | end 111 | 112 | context 'retry' do 113 | before do 114 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 115 | plugins: [{ 116 | type: 'stdout', 117 | }] 118 | )) 119 | Alerty::Config.configure( 120 | log_path: '/tmp/foo', 121 | log_level: 'fatal', 122 | retry_limit: 1, 123 | ) 124 | end 125 | 126 | let(:command) { Alerty::Command.new(command: 'echo foo') } 127 | 128 | it do 129 | expect(Frontkick).to receive(:exec).twice.with("echo foo", { 130 | timeout: nil, 131 | exclusive: nil, 132 | popen2e: true, 133 | }).and_return(Frontkick::Result.new(stdout: 'foo', exit_code: 1)) 134 | record = command.run 135 | expect(record[:retries]).to eql(1) 136 | end 137 | end 138 | 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Alerty::Config do 4 | describe 'configure' do 5 | before do 6 | Alerty::Config.configure( 7 | log_path: '/tmp/foo', 8 | log_level: 'fatal', 9 | timeout: 20, 10 | lock_path: '/tmp/lock', 11 | retry_limit: 5, 12 | retry_wait: 10, 13 | ) 14 | end 15 | 16 | it { expect(Alerty::Config.log_path).to eql('/tmp/foo') } 17 | it { expect(Alerty::Config.log_level).to eql('fatal') } 18 | it { expect(Alerty::Config.timeout).to eql(20) } 19 | it { expect(Alerty::Config.lock_path).to eql('/tmp/lock') } 20 | it { expect(Alerty::Config.retry_limit).to eql(5) } 21 | it { expect(Alerty::Config.retry_wait).to eql(10) } 22 | end 23 | 24 | describe 'config' do 25 | before do 26 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 27 | log_path: '/tmp/foo', 28 | log_level: 'fatal', 29 | timeout: 20, 30 | lock_path: '/tmp/lock', 31 | retry_limit: 5, 32 | retry_wait: 10, 33 | )) 34 | end 35 | 36 | it { expect(Alerty::Config.log_path).to eql('/tmp/foo') } 37 | it { expect(Alerty::Config.log_level).to eql('fatal') } 38 | it { expect(Alerty::Config.timeout).to eql(20) } 39 | it { expect(Alerty::Config.lock_path).to eql('/tmp/lock') } 40 | it { expect(Alerty::Config.retry_limit).to eql(5) } 41 | it { expect(Alerty::Config.retry_wait).to eql(10) } 42 | end 43 | 44 | describe '#retry_interval' do 45 | before do 46 | Alerty::Config.configure( 47 | retry_wait: 10, 48 | ) 49 | end 50 | 51 | it do 52 | # retry_wait +/- 12.5% randomness 53 | expect(Alerty::Config.retry_interval).to be >= 10 - 1.25 54 | expect(Alerty::Config.retry_interval).to be <= 10 + 1.25 55 | end 56 | end 57 | 58 | describe 'plugins' do 59 | before do 60 | Alerty::Config.reset 61 | Alerty::Config.instance_variable_set(:@config, Hashie::Mash.new( 62 | log_path: '/tmp/foo', 63 | log_level: 'fatal', 64 | timeout: 20, 65 | lock_path: '/tmp/lock', 66 | plugins: [ 67 | { 68 | type: 'stdout', 69 | }, 70 | { 71 | type: 'file', 72 | path: 'STDOUT', 73 | } 74 | ] 75 | )) 76 | end 77 | 78 | it do 79 | expect(Alerty::Config.plugins[0]['type']).to eql('stdout') 80 | expect(Alerty::Config.plugins[1]['type']).to eql('file') 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | Bundler.require :test 3 | 4 | require 'pry' 5 | require 'alerty' 6 | 7 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 8 | 9 | ROOT = File.dirname(File.dirname(__FILE__)) 10 | def capture_stdout 11 | out = StringIO.new 12 | $stdout = out 13 | yield 14 | return out.string 15 | ensure 16 | $stdout = STDOUT 17 | end 18 | 19 | RSpec.configure do |config| 20 | end 21 | --------------------------------------------------------------------------------