├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── example ├── Gemfile └── example.rb ├── lib ├── puppet_theatre.rb └── puppet_theatre │ ├── checkers.rb │ ├── checkers │ ├── console.rb │ ├── puppet_noop.rb │ └── rspec.rb │ ├── configurable.rb │ ├── findable.rb │ ├── hosts.rb │ ├── hosts │ ├── array.rb │ ├── getent.rb │ └── mackerel.rb │ ├── notifiers.rb │ ├── notifiers │ ├── console.rb │ └── takosan.rb │ ├── reporters.rb │ ├── reporters │ ├── console.rb │ ├── html.erb │ ├── html.rb │ └── summary.rb │ ├── runner.rb │ └── version.rb ├── puppet-theatre.gemspec ├── spec ├── checkers │ └── rspec_spec.rb ├── hosts │ └── getent_spec.rb ├── notifiers │ ├── console_spec.rb │ └── takosan_spec.rb ├── reporter │ └── html_spec.rb ├── runner_spec.rb └── spec_helper.rb └── test-fixtures ├── .bundle └── config ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── bin └── getent └── spec ├── flawed_spec.rb └── flawless_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .ruby-version 11 | 12 | /example/Gemfile.lock 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | --color 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0-p598 # for CentOS 7 :) 4 | - 2.3.0 5 | - 2.4.2 6 | - ruby-head 7 | 8 | before_install: 9 | - gem install bundler -v 1.11.2 10 | 11 | matrix: 12 | allow_failures: 13 | - rvm: ruby-head 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'takosan', '~> 1.1.0' 7 | 8 | if Gem::Version.create(RUBY_VERSION) < Gem::Version.create('2.2.2') 9 | gem 'activesupport', '>= 4.0.0', '< 5.0.0' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kasumi Hanazuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PuppetTheatre 2 | 3 | PuppetTheatre is an integration framework for configuration management, infrastructure testing and monitoring. 4 | It runs several checks and tests on a set of servers and generates a report for a quick overview how well the servers are configured. 5 | 6 | ## Configuration 7 | 8 | Configuration is written as a Ruby script with PuppetTheatre DSL. See `example/example.rb` in this repository for an example. 9 | 10 | You can configure four kinds of pluggable components: a host generator, checkers, reporters, notifiers. 11 | 12 | - A host generator fetches list of available hosts. 13 | - A checker runs a set of tests or checks which evaluates the status of each host and returns a result. 14 | - A reporter generates a report for humans from the results from the checkers for all the hosts. 15 | - A notifier sends a notification for a new report through a configured channel. 16 | 17 | ## Components 18 | 19 | ### Host generators 20 | #### array 21 | 22 | ```ruby 23 | c.hosts_from :array, hosts: %w[host1.example.com host2.example.com] 24 | ``` 25 | 26 | Returns a list of hosts from an array. 27 | 28 | #### getent 29 | 30 | ```ruby 31 | c.hosts_from :getent, pattern: /\.lan\z/ 32 | ``` 33 | 34 | Returns a list of hosts available in the result of `getent hosts`, which is usually read from `/etc/hosts` file. 35 | 36 | #### mackerel 37 | 38 | ```ruby 39 | c.hosts_from :mackerel, api_key: 'XXXYYYZZZ', service: 'your-awesome-service' 40 | ``` 41 | 42 | Returns a list of hosts registred to [Mackerel](https://mackerel.io). Requires `mackerel-rb` gem. 43 | 44 | ### Checkers 45 | #### console 46 | 47 | ```ruby 48 | c.add_checker :console 49 | ``` 50 | 51 | Prints the name of each host to the standard output. This is a dummy checker that returns no result for the host and can be used to check the progress of running checks. 52 | 53 | #### puppet\_noop 54 | 55 | ```ruby 56 | c.add_checker :puppet_noop 57 | ``` 58 | 59 | Runs [Puppet](https://puppetlabs.com/puppet) on each host and checks that the configuration of the host is in sync with the Puppet manifest. 60 | Currently only agent-less configuration with master server is supported and SSH access to the hosts is required. 61 | 62 | #### rspec 63 | 64 | ```ruby 65 | c.add_checker :rspec, 66 | bundler: '/usr/local/bin/bundle', 67 | workdir: '/path/to/your/specs' 68 | args: {'pattern' => 'spec/**/*_spec.rb'}, 69 | env: {'ENV_NAME' => 'value'} 70 | ``` 71 | 72 | Run [Rspec](http://rspec.info). You can use any Rspec extensions including [Serverspec](http://serverspec.org/). 73 | 74 | ### Reporters 75 | #### console 76 | 77 | ```ruby 78 | c.add_reporter :console 79 | ``` 80 | 81 | Dumps the check results to the standard output. 82 | 83 | #### summary 84 | 85 | ```ruby 86 | c.add_reporter :summary 87 | ``` 88 | 89 | Notifies a summary of the check results. 90 | 91 | #### html 92 | 93 | ```ruby 94 | c.add_reporter :html, path: '/var/www/html', uri: 'https://ops.example.com/' 95 | ``` 96 | 97 | Generates a HTML report at the specified directory, which is expected to be served by a web server. 98 | URI to the report will be notified (for example, `https://ops.example.com/20160102T030405.html`) 99 | 100 | ### Notifiers 101 | #### console 102 | 103 | ```ruby 104 | c.add_notifier :console 105 | ``` 106 | 107 | Writes any notification to the standard output. 108 | 109 | #### takosan 110 | 111 | ```ruby 112 | c.add_notifier :takosan, 113 | url: 'https://takosan.example.com/', 114 | channel: '#ops', 115 | name: 'puppet theatre', 116 | icon: ':innocent:' 117 | ``` 118 | 119 | Sends notifications to a [Takosan](https://github.com/kentaro/takosan) server, a web-to-Slack gateway. Requires `takosan` gem. 120 | 121 | ### Threads 122 | 123 | ```ruby 124 | c.in_threads 3 125 | ``` 126 | 127 | You can specify the number of active threads. 128 | 129 | ### Custom Components 130 | 131 | You can create a custom component by defining a class in the corresponding modules (`PuppetTheatre::Hosts`, `PuppetTheatre::Checkers`, `PuppetTheatre::Reporters` and `PuppetTheatre::Notifiers`). 132 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task :default => :spec 8 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'puppet-theatre', github: 'hanazuki/puppet-theatre' 4 | gem 'takosan' 5 | -------------------------------------------------------------------------------- /example/example.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'bundler/setup' 3 | require 'puppet_theatre' 4 | 5 | module PuppetTheatre::Checkers 6 | 7 | # exmaple of custom checker 8 | class Serverspec < find_class(:rspec) 9 | def rspec_args(host) 10 | super.merge( 11 | 'pattern' => "spec/#{role(host)}/**/{,*/}**/*_spec.rb" 12 | ) 13 | end 14 | 15 | def environment(host) 16 | super.merge( 17 | 'TARGET_HOST' => host, 18 | ) 19 | end 20 | 21 | private 22 | 23 | def shorthost(host) 24 | host.split('.')[0] 25 | end 26 | 27 | def role(host) 28 | shorthost(host).gsub(/\d+\z/, '') 29 | end 30 | end 31 | end 32 | 33 | PuppetTheatre.run do |c| 34 | c.hosts_from :array, hosts: ['www1.example.com', 'www2.example.com'] 35 | 36 | c.add_checker :puppet_noop 37 | c.add_checker :serverspec, bundler: '/usr/local/bin/bundler', workdir: '/var/app/serverspec' 38 | 39 | c.add_reporter :summary 40 | c.add_reporter :html, path: '/srv/puppet_theatre', uri: 'https://status.example.com/' 41 | 42 | c.add_notifier :console 43 | c.add_notifier :takosan, url: 'http://takosan.example.com:4979', channel: '#dev', name: 'Tako', icon: ':octopus:' 44 | end 45 | -------------------------------------------------------------------------------- /lib/puppet_theatre.rb: -------------------------------------------------------------------------------- 1 | require 'puppet_theatre/version' 2 | require 'puppet_theatre/hosts' 3 | require 'puppet_theatre/checkers' 4 | require 'puppet_theatre/reporters' 5 | require 'puppet_theatre/notifiers' 6 | require 'puppet_theatre/runner' 7 | 8 | module PuppetTheatre 9 | def self.configure(&block) 10 | Runner.new(&block) 11 | end 12 | 13 | def self.run(&block) 14 | configure(&block).run 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/puppet_theatre/checkers.rb: -------------------------------------------------------------------------------- 1 | require 'sshkit' 2 | require_relative 'configurable' 3 | require_relative 'findable' 4 | 5 | module PuppetTheatre 6 | module Checkers 7 | extend Findable 8 | 9 | class Base 10 | include SSHKit::DSL 11 | include Configurable 12 | end 13 | 14 | def self.find_class(name) 15 | super(name, self, 'puppet_theatre/checkers') 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/puppet_theatre/checkers/console.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Checkers 3 | class Console < Base 4 | def call(env, host) 5 | puts "Checking #{host}..." 6 | 7 | return nil 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/puppet_theatre/checkers/puppet_noop.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'shellwords' 3 | require 'yaml' 4 | 5 | module PuppetTheatre 6 | module Checkers 7 | class PuppetNoop < Base 8 | 9 | class Result 10 | def initialize(result) 11 | @result = self.class.load_plain_yaml(result) 12 | end 13 | 14 | def alert? 15 | status == 'failed' || out_of_sync_count > 0 16 | end 17 | 18 | def summary 19 | if status == 'failed' 20 | 'Failed' 21 | elsif out_of_sync_count > 0 22 | "Out of sync (#{out_of_sync_count})" 23 | else 24 | 'Up to date' 25 | end 26 | end 27 | 28 | def details 29 | @result['logs'].map {|log| 30 | "[%s] %s: %s" % [log['level'], log['source'], log['message']] 31 | } 32 | end 33 | 34 | private 35 | 36 | def status 37 | @result['status'] 38 | end 39 | 40 | def out_of_sync_count 41 | @result['metrics']['resources']['values'].find {|r| r[0] == 'out_of_sync' }[2] 42 | end 43 | 44 | class TagStrip < Psych::Visitors::DepthFirst 45 | def initialize 46 | super ->(o) { o.tag = nil if o.respond_to?(:tag=); o } 47 | end 48 | end 49 | 50 | def self.load_plain_yaml(s) 51 | TagStrip.new.accept(YAML.parse(s)).to_ruby 52 | end 53 | end 54 | 55 | def call(env, host) 56 | cmd = [ 57 | %{tmp=$(mktemp)}, 58 | %{sudo puppet agent -t --noop --lastrunreport "$tmp" >/dev/null 2>&1}, 59 | %{cat "$tmp"}, 60 | ].join(?;) 61 | 62 | result = nil 63 | on(host) do |host| 64 | result = Result.new(capture(cmd)) 65 | end 66 | 67 | result 68 | end 69 | 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/puppet_theatre/checkers/rspec.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'open3' 3 | require 'shellwords' 4 | 5 | module PuppetTheatre 6 | module Checkers 7 | class Rspec < Base 8 | 9 | class Result 10 | def initialize(result) 11 | @result = JSON.parse(result) 12 | end 13 | 14 | def alert? 15 | failure_count > 0 16 | end 17 | 18 | def summary 19 | if failure_count > 0 20 | "Failed (failed=#{failure_count}, total=#{example_count})" 21 | else 22 | "OK (failed=0, total=#{example_count})" 23 | end 24 | end 25 | 26 | def details 27 | @result['examples'].map {|example| 28 | if example['status'] == 'failed' 29 | "%s:%s %s" % [ 30 | example['file_path'], 31 | example['line_number'], 32 | example['full_description'], 33 | ] 34 | end 35 | }.compact 36 | end 37 | 38 | private 39 | 40 | def failure_count 41 | @result['summary']['failure_count'] 42 | end 43 | 44 | def example_count 45 | @result['summary']['example_count'] 46 | end 47 | end 48 | 49 | def call(env, host) 50 | Dir.chdir(workdir(host)) do 51 | Bundler.with_clean_env do 52 | stdout, stderr, status = Open3.capture3(environment(host), command(host)) 53 | return Result.new(stdout) 54 | end 55 | end 56 | end 57 | 58 | private 59 | 60 | def workdir(host) 61 | config[:workdir] || '.' 62 | end 63 | 64 | def environment(host) 65 | config[:env] || {} 66 | end 67 | 68 | def rspec_args(host) 69 | config[:args] || {} 70 | end 71 | 72 | def command(host) 73 | cmd = [] 74 | cmd.push(config[:bundler], 'exec') if config[:bundler] 75 | cmd.push('rspec') 76 | 77 | rspec_args(host).each_pair do |k, v| 78 | cmd.push("--#{k}", v) 79 | end 80 | 81 | [ 82 | %{tmp=$(mktemp)}, 83 | %{#{cmd.shelljoin} --format json --out "$tmp" >/dev/null 2>&1}, 84 | %{cat "$tmp"}, 85 | ].join(?;) 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/puppet_theatre/configurable.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Configurable 3 | attr_reader :config 4 | 5 | def initialize(config) 6 | @config = config || {} 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/puppet_theatre/findable.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Findable 3 | def find_class(name, mod, path) 4 | klsname = name.to_s.gsub(/(?:\A|_)./) {|s| s[-1].upcase }.intern 5 | begin 6 | return mod.const_get(klsname, false) 7 | rescue NameError 8 | require [path, name].join(?/) 9 | return mod.const_get(klsname, false) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/puppet_theatre/hosts.rb: -------------------------------------------------------------------------------- 1 | require_relative 'configurable' 2 | require_relative 'findable' 3 | 4 | module PuppetTheatre 5 | module Hosts 6 | extend Findable 7 | 8 | class Base 9 | include Enumerable 10 | include Configurable 11 | end 12 | 13 | def self.find_class(name) 14 | super(name, self, 'puppet_theatre/hosts') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/puppet_theatre/hosts/array.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Hosts 3 | class Array < Base 4 | def each(&block) 5 | config.fetch(:hosts).each(&block) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/puppet_theatre/hosts/getent.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Hosts 3 | class Getent < Base 4 | def each 5 | pattern = config.fetch(:pattern, //) 6 | IO.popen('getent hosts', 'r') do |f| 7 | f.each_line do |l| 8 | l.chomp.split[1..-1].each do |host| 9 | yield host if host =~ pattern 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/puppet_theatre/hosts/mackerel.rb: -------------------------------------------------------------------------------- 1 | require 'mackerel' 2 | 3 | module PuppetTheatre 4 | module Hosts 5 | class Mackerel < Base 6 | def each 7 | client.hosts(service: config.fetch(:service)).each do |host| 8 | yield host.name 9 | end 10 | end 11 | 12 | private 13 | 14 | def client 15 | @client ||= ::Mackerel::Client.new.tap do |client| 16 | client.configure do |c| 17 | c.api_key = config.fetch(:api_key) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/puppet_theatre/notifiers.rb: -------------------------------------------------------------------------------- 1 | require_relative 'configurable' 2 | require_relative 'findable' 3 | 4 | module PuppetTheatre 5 | module Notifiers 6 | extend Findable 7 | 8 | class Base 9 | include Configurable 10 | end 11 | 12 | def self.find_class(name) 13 | super(name, self, 'puppet_theatre/notifiers') 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/puppet_theatre/notifiers/console.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Notifiers 3 | class Console < Base 4 | def call(msg) 5 | puts msg 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/puppet_theatre/notifiers/takosan.rb: -------------------------------------------------------------------------------- 1 | require 'takosan' 2 | 3 | module PuppetTheatre 4 | module Notifiers 5 | class Takosan < Base 6 | def call(msg) 7 | %w[url channel name icon].each do |t| 8 | ::Takosan.send("#{t}=", config[t.to_sym]) if config[t.to_sym] 9 | end 10 | ::Takosan.privmsg(msg) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/puppet_theatre/reporters.rb: -------------------------------------------------------------------------------- 1 | require_relative 'configurable' 2 | require_relative 'findable' 3 | 4 | module PuppetTheatre 5 | module Reporters 6 | extend Findable 7 | 8 | class Base 9 | include Configurable 10 | end 11 | 12 | def self.find_class(name) 13 | super(name, self, 'puppet_theatre/reporters') 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/puppet_theatre/reporters/console.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Reporters 3 | class Console < Base 4 | def call(env, results) 5 | results.each do |host, checks| 6 | puts ">> #{host}" 7 | checks.each do |name, check| 8 | puts "!! #{name}: #{check.summary}" 9 | end 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/puppet_theatre/reporters/html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @timestamp.strftime('%F %T') %> 4 |

<%= @timestamp.strftime('%F %T') %>

5 |

Summary

6 | <% check_names = @results.flat_map {|_, checks| checks.keys }.uniq -%> 7 | 8 | 9 | 10 | <%- check_names.each do |check_name| -%> 11 | 12 | <%- end -%> 13 | 14 | <%- @results.each do |host, checks| -%> 15 | 16 | <%- check_names.each do |check_name| -%> 17 | 18 | <%- end -%> 19 | <%- end -%> 20 |
Host<%= check_name.encode(xml: :text) %>
><%= host.encode(xml: :text) %><%= checks[check_name] ? checks[check_name].alert? ? "❗" : "✔" : "-" %>
21 |

Hosts

22 | <%- @results.each do |host, checks| -%> 23 |

>><%= host.encode(xml: :text) %>

24 | <%- checks.each do |name, check| -%> 25 |

<%= name.encode(xml: :text) %>

26 |

<%= check.summary.encode(xml: :text) %>

27 | <% unless check.details.empty? -%> 28 |
<%= check.details.join("\n").encode(xml: :text) %>
29 | <% end -%> 30 | <% end -%> 31 | <% end -%> 32 | -------------------------------------------------------------------------------- /lib/puppet_theatre/reporters/html.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | 3 | module PuppetTheatre 4 | module Reporters 5 | class Html < Base 6 | Template = ERB.new(IO.read(File.join(File.dirname(__FILE__), 'html.erb')), nil, '-') 7 | 8 | class RenderData 9 | def initialize(results, timestamp) 10 | @results = results 11 | @timestamp = timestamp 12 | end 13 | end 14 | 15 | def call(env, results) 16 | timestamp = Time.now 17 | filename = "#{timestamp.strftime('%Y%m%dT%H%M%S')}.html" 18 | 19 | IO.write(File.join(config.fetch(:path), filename), render(results, timestamp)) 20 | 21 | uri = URI.join(config.fetch(:uri), filename) 22 | env.notify("Report: #{uri}") 23 | end 24 | 25 | private 26 | 27 | def render(results, timestamp) 28 | RenderData.new(results, timestamp).instance_eval { Template.result(binding) } 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/puppet_theatre/reporters/summary.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | module Reporters 3 | class Summary < Base 4 | OK = "\u{2714}" # HEAVY CHECK MARK 5 | NG = "\u{2757}" # HEAVY EXCLAMATION MARK 6 | 7 | def call(env, results) 8 | summary = {} 9 | 10 | results.each do |host, checks| 11 | checks.each do |name, check| 12 | summary[name] ||= 0 13 | summary[name] += 1 if check.alert? 14 | end 15 | end 16 | 17 | message = summary.map {|name, failures| 18 | "%s: %s" % [name, failures > 0 ? NG + "(#{failures})" : OK] 19 | }.join('; ') 20 | 21 | env.notify(message) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/puppet_theatre/runner.rb: -------------------------------------------------------------------------------- 1 | require 'parallel' 2 | 3 | module PuppetTheatre 4 | class Runner 5 | def initialize(&block) 6 | @config = Config.new.tap {|o| o.instance_eval(&block) } 7 | end 8 | 9 | def config(s) 10 | @config.public_send(s) 11 | end 12 | 13 | class Config 14 | attr_accessor :hosts, :checkers, :reporters, :notifiers, :threads 15 | 16 | def initialize 17 | @hosts = [] 18 | @checkers = {} 19 | @reporters = {} 20 | @notifiers = {} 21 | @threads = 1 22 | end 23 | 24 | def hosts_from(klass, opts = {}) 25 | klass = Hosts.find_class(klass) if klass.is_a?(Symbol) 26 | obj = klass.respond_to?(:new) ? klass.new(opts) : klass.call(opts) 27 | @hosts = obj 28 | end 29 | 30 | def add_checker(klass, opts = {}) 31 | klass = Checkers.find_class(klass) if klass.is_a?(Symbol) 32 | obj = klass.respond_to?(:new) ? klass.new(opts) : klass.call(opts) 33 | @checkers[opts[:name] || klass.name.split('::')[-1]] = obj 34 | end 35 | 36 | def add_reporter(klass, opts = {}) 37 | klass = Reporters.find_class(klass) if klass.is_a?(Symbol) 38 | obj = klass.respond_to?(:new) ? klass.new(opts) : klass.call(opts) 39 | @reporters[opts[:name] || klass.name.split('::')[-1]] = obj 40 | end 41 | 42 | def add_notifier(klass, opts = {}) 43 | klass = Notifiers.find_class(klass) if klass.is_a?(Symbol) 44 | obj = klass.respond_to?(:new) ? klass.new(opts) : klass.call(opts) 45 | @notifiers[opts[:name] || klass.name.split('::')[-1]] = obj 46 | end 47 | 48 | def in_threads(v) 49 | @threads = v 50 | end 51 | end 52 | 53 | class ExceptionalResult 54 | def initialize(exn) 55 | @exn = exn 56 | end 57 | 58 | def alert? 59 | true 60 | end 61 | 62 | def details 63 | @exn.backtrace 64 | end 65 | 66 | def summary 67 | @exn.to_s 68 | end 69 | end 70 | 71 | def call 72 | results = Hash.new {|h, k| h[k] = {} } 73 | 74 | Parallel.map(config(:hosts).sort, in_threads: config(:threads)) do |host| 75 | config(:checkers).each_pair do |name, checker| 76 | result = 77 | begin 78 | checker.call(self, host) 79 | rescue 80 | ExceptionalResult.new($!) 81 | end 82 | 83 | results[host][name] = result if result 84 | end 85 | end 86 | 87 | config(:reporters).each do |_, reporter| 88 | begin 89 | reporter.call(self, results) 90 | rescue 91 | warn $! 92 | end 93 | end 94 | end 95 | 96 | alias_method :run, :call 97 | 98 | def notify(msg) 99 | config(:notifiers).each do |_, notifier| 100 | begin 101 | notifier.call(msg) 102 | rescue 103 | warn $! 104 | end 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/puppet_theatre/version.rb: -------------------------------------------------------------------------------- 1 | module PuppetTheatre 2 | VERSION = '0.1.0' 3 | end 4 | -------------------------------------------------------------------------------- /puppet-theatre.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'puppet_theatre/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'puppet-theatre' 8 | spec.version = PuppetTheatre::VERSION 9 | spec.authors = ['Kasumi Hanazuki'] 10 | spec.email = ['kasumi@rollingapple.net'] 11 | 12 | spec.summary = %q{Automation framework for continuous infra testing} 13 | spec.description = %q{Automation framework for continuous infra testing} 14 | spec.homepage = 'https://github.com/hanazuki/puppet-theatre' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split(?\0).reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = 'exe' 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_dependency 'sshkit' 23 | spec.add_dependency 'parallel' 24 | 25 | spec.add_development_dependency 'bundler', '~> 1.11' 26 | spec.add_development_dependency 'rake', '~> 11.0' 27 | spec.add_development_dependency 'rspec', '~> 3.0' 28 | spec.add_development_dependency 'timecop', '~> 0.8' 29 | end 30 | -------------------------------------------------------------------------------- /spec/checkers/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | require 'puppet_theatre/checkers/rspec' 2 | 3 | describe PuppetTheatre::Checkers::Rspec do 4 | 5 | before(:context) do 6 | Dir.chdir('./test-fixtures') do 7 | Bundler.clean_system('bundle install --quiet --deployment') 8 | end 9 | end 10 | 11 | subject { described_class.new(config) } 12 | 13 | let(:runner) { double } 14 | 15 | describe '#call' do 16 | 17 | let(:config) do 18 | {bundler: `which bundle`.chomp, workdir: './test-fixtures'} 19 | end 20 | 21 | context 'When some specs fail' do 22 | 23 | it 'returns unsuccessful result' do 24 | result = subject.call(runner, 'www1.example.com') 25 | expect(result.summary).to match /Failed/ 26 | expect(result.details).to match [%r{\A./spec/flawed_spec.rb:\d+ Something flawed fails\z}] 27 | expect(result).to be_alert 28 | end 29 | end 30 | 31 | context 'When all specs pass' do 32 | 33 | before do 34 | config[:args] = {'pattern' => 'spec/flawless_spec.rb'} 35 | end 36 | 37 | it 'returns successful result' do 38 | result = subject.call(runner, 'www1.example.com') 39 | expect(result.summary).to match /OK/ 40 | expect(result.details).to be_empty 41 | expect(result).not_to be_alert 42 | end 43 | 44 | end 45 | 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /spec/hosts/getent_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PuppetTheatre::Hosts.find_class(:getent) do 4 | 5 | it 'includes Enumerable module' do 6 | expect(described_class).to include Enumerable 7 | end 8 | 9 | shared_context 'use stub getent' do 10 | around do |example| 11 | env = { 12 | 'PATH' => ["#{Dir.pwd}/test-fixtures/bin", '/bin', '/usr/bin'].join(?:), 13 | } 14 | 15 | hosts.each_with_index do |host, i| 16 | env["TEST_HOSTS_#{i + 1}"] = host 17 | end 18 | 19 | orig_env = ENV.to_h 20 | begin 21 | ENV.update(env) 22 | example.run 23 | ensure 24 | ENV.replace(orig_env) 25 | end 26 | end 27 | end 28 | 29 | subject do 30 | described_class.new(pattern: pattern) 31 | end 32 | 33 | describe '#each' do 34 | include_context 'use stub getent' do 35 | let(:hosts) { ['www001.example.com', 'www002.example.com', 'api001.example.com'] } 36 | end 37 | 38 | context 'When a match-all pattern is given' do 39 | let(:pattern) { // } 40 | 41 | it 'yields all the available hosts' do 42 | expect {|b| subject.each(&b) }.to yield_successive_args(*hosts) 43 | end 44 | end 45 | 46 | context 'When a pattern is given' do 47 | let(:pattern) { /\Aapi\d+\./ } 48 | 49 | it 'yields only matching hosts' do 50 | expect {|b| subject.each(&b) }.to yield_successive_args('api001.example.com') 51 | end 52 | end 53 | 54 | context 'When a line contain multiple host names' do 55 | let(:pattern) { // } 56 | let(:hosts) { ['www001.example.com www.001.example.net'] } 57 | 58 | it 'yields each host' do 59 | expect {|b| subject.each(&b) }.to yield_successive_args(*hosts.first.split) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/notifiers/console_spec.rb: -------------------------------------------------------------------------------- 1 | require 'puppet_theatre/notifiers/console' 2 | 3 | describe PuppetTheatre::Notifiers::Console do 4 | 5 | subject { described_class.new({}) } 6 | 7 | describe '#call' do 8 | it 'outputs given argument to stdout' do 9 | expect { subject.call('TESTTEST') }.to output("TESTTEST\n").to_stdout 10 | end 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /spec/notifiers/takosan_spec.rb: -------------------------------------------------------------------------------- 1 | require 'puppet_theatre/notifiers/takosan' 2 | 3 | describe PuppetTheatre::Notifiers::Takosan do 4 | 5 | subject { described_class.new(config) } 6 | 7 | describe '#call' do 8 | let(:config) { 9 | {url: 'https://takosan.example.com:4649', channel: '#example', icon: ':simple_smile:', name: 'ikachan'} 10 | } 11 | 12 | it 'sends message to takosan' do 13 | takosan = class_double('Takosan').as_stubbed_const 14 | 15 | expect(takosan).to receive(:url=).with(config[:url]) 16 | expect(takosan).to receive(:channel=).with(config[:channel]) 17 | expect(takosan).to receive(:icon=).with(config[:icon]) 18 | expect(takosan).to receive(:name=).with(config[:name]) 19 | expect(takosan).to receive(:privmsg).with('TESTTEST') 20 | 21 | subject.call('TESTTEST') 22 | end 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /spec/reporter/html_spec.rb: -------------------------------------------------------------------------------- 1 | require 'puppet_theatre/reporters/html' 2 | 3 | describe PuppetTheatre::Reporters::Html, with_tmpdir: true do 4 | 5 | subject { described_class.new(path: tmpdir, uri: 'https://example.net/path/') } 6 | 7 | let(:runner) { instance_double('PuppetTheatre::Runner') } 8 | 9 | it 'generates report in HTML' do 10 | results = { 11 | 'www1.example.com' => { 12 | 'Check' => double.tap do |stub| 13 | expect(stub).to receive(:alert?).at_least(:once).with(no_args).and_return(true) 14 | expect(stub).to receive(:summary).at_least(:once).with(no_args).and_return('Something failed') 15 | expect(stub).to receive(:details).at_least(:once).with(no_args).and_return(['Test1 failed', 'Test2 failed']) 16 | end 17 | }, 18 | 'www2.example.com' => { 19 | 'Check' => double.tap do |stub| 20 | expect(stub).to receive(:alert?).at_least(:once).with(no_args).and_return(false) 21 | expect(stub).to receive(:summary).at_least(:once).with(no_args).and_return('OK') 22 | expect(stub).to receive(:details).at_least(:once).with(no_args).and_return([]) 23 | end 24 | }, 25 | } 26 | 27 | now = Time.new(2010, 2, 3, 4, 5, 6) 28 | 29 | expect(runner).to receive(:notify).with(a_string_matching(%r{https://example.net/path/20100203T040506.html})) 30 | 31 | Timecop.freeze(now) do 32 | subject.call(runner, results) 33 | end 34 | 35 | file = @tmpdir + '20100203T040506.html' 36 | expect(file).to exist 37 | 38 | content = File.read(file) 39 | expect(content).to include('Something failed').and include("Test1 failed\nTest2 failed") 40 | end 41 | 42 | end 43 | -------------------------------------------------------------------------------- /spec/runner_spec.rb: -------------------------------------------------------------------------------- 1 | describe PuppetTheatre::Runner do 2 | 3 | let(:stub_hosts) do 4 | class_double('PuppetTheatre::Hosts::Stub').as_stubbed_const 5 | end 6 | 7 | let(:stub_checker) do 8 | class_double('PuppetTheatre::Checkers::Stub').as_stubbed_const 9 | end 10 | 11 | let(:stub_reporter) do 12 | class_double('PuppetTheatre::Reporters::Stub').as_stubbed_const 13 | end 14 | 15 | let(:stub_notifier) do 16 | class_double('PuppetTheatre::Notifiers::Stub').as_stubbed_const 17 | end 18 | 19 | describe '#run' do 20 | subject do 21 | described_class.new do |c| 22 | c.hosts_from :stub 23 | c.add_checker :stub, name: 'Stub1' 24 | c.add_checker :stub, name: 'Stub2' 25 | c.add_reporter :stub 26 | end 27 | end 28 | 29 | it 'runs' do 30 | expect(stub_hosts).to receive(:new) do 31 | double.tap do |stub| 32 | expect(stub).to receive(:each).and_yield('www1.example.com').and_yield('www2.example.com') 33 | stub.extend(Enumerable) 34 | end 35 | end 36 | 37 | expect(stub_checker).to receive(:new).twice do 38 | double.tap do |stub| 39 | expect(stub).to receive(:call).twice do |env, host| 40 | expect(env).to be subject 41 | {} 42 | end 43 | end 44 | end 45 | 46 | expect(stub_reporter).to receive(:new) do 47 | double.tap do |stub| 48 | expect(stub).to receive(:call) do |env, results| 49 | expect(env).to be subject 50 | expect(results).to match( 51 | 'www1.example.com' => matching('Stub1' => be, 'Stub2' => be), 52 | 'www2.example.com' => matching('Stub1' => be, 'Stub2' => be), 53 | ) 54 | end 55 | end 56 | end 57 | 58 | subject.run 59 | end 60 | end 61 | 62 | describe '#notify' do 63 | subject do 64 | described_class.new do |c| 65 | c.add_notifier :stub, name: 'stub1' 66 | c.add_notifier :stub, name: 'stub2' 67 | end 68 | end 69 | 70 | it 'sends given message to all the notifiers' do 71 | expect(stub_notifier).to receive(:new).twice do 72 | double.tap do |stub| 73 | expect(stub).to receive(:call).with('TESTTEST') 74 | expect(stub).to receive(:call).with('TESTTESTTEST') 75 | end 76 | end 77 | 78 | subject.notify('TESTTEST') 79 | subject.notify('TESTTESTTEST') 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'puppet_theatre' 3 | require 'timecop' 4 | require 'pathname' 5 | require 'tmpdir' 6 | 7 | shared_context with_tmpdir: true do 8 | 9 | attr_reader :tmpdir 10 | 11 | around do |example| 12 | Dir.mktmpdir do |dir| 13 | @tmpdir = Pathname.new(dir) 14 | example.run 15 | end 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /test-fixtures/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_FROZEN: '1' 3 | BUNDLE_PATH: vendor/bundle 4 | BUNDLE_DISABLE_SHARED_GEMS: '1' 5 | -------------------------------------------------------------------------------- /test-fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/bundle -------------------------------------------------------------------------------- /test-fixtures/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rspec', '~> 3.4.0' 4 | 5 | -------------------------------------------------------------------------------- /test-fixtures/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | diff-lcs (1.2.5) 5 | rspec (3.4.0) 6 | rspec-core (~> 3.4.0) 7 | rspec-expectations (~> 3.4.0) 8 | rspec-mocks (~> 3.4.0) 9 | rspec-core (3.4.4) 10 | rspec-support (~> 3.4.0) 11 | rspec-expectations (3.4.0) 12 | diff-lcs (>= 1.2.0, < 2.0) 13 | rspec-support (~> 3.4.0) 14 | rspec-mocks (3.4.1) 15 | diff-lcs (>= 1.2.0, < 2.0) 16 | rspec-support (~> 3.4.0) 17 | rspec-support (3.4.1) 18 | 19 | PLATFORMS 20 | ruby 21 | 22 | DEPENDENCIES 23 | rspec (~> 3.4.0) 24 | 25 | BUNDLED WITH 26 | 1.11.2 27 | -------------------------------------------------------------------------------- /test-fixtures/bin/getent: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $1 != hosts ]]; then 4 | echo "Unknown database: $1" >&2 5 | exit 1 6 | fi 7 | 8 | i=1 9 | while :; do 10 | env=TEST_HOSTS_${i} 11 | [[ -z ${!env} ]] && break 12 | echo -e "127.0.0.$i\t${!env}" 13 | 14 | let i+=1 15 | done 16 | -------------------------------------------------------------------------------- /test-fixtures/spec/flawed_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Something flawed' do 2 | 3 | it 'passes' do 4 | expect(true).to be 5 | end 6 | 7 | it 'fails' do 8 | expect(false).to be 9 | end 10 | 11 | it 'is pending' do 12 | pending 13 | expect(false).to be 14 | end 15 | 16 | end 17 | -------------------------------------------------------------------------------- /test-fixtures/spec/flawless_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Something flawless' do 2 | 3 | it 'passes' do 4 | expect(true).to be 5 | end 6 | 7 | it 'passes too' do 8 | expect(true).to be 9 | end 10 | 11 | end 12 | --------------------------------------------------------------------------------