├── .gitignore ├── viewer ├── images │ └── domokun.png ├── stylesheets │ └── report.css ├── index.html └── scripts │ └── report.js ├── Gemfile ├── spec ├── web │ └── apache2_spec.rb ├── spec_helper.rb └── spec_helper_v2.rb ├── README.md └── Rakefile /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | Gemfile.local 3 | /reports/ 4 | -------------------------------------------------------------------------------- /viewer/images/domokun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vincentbernat/serverspec-example/HEAD/viewer/images/domokun.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rake" 4 | gem "serverspec", "~> 1.16.0" 5 | gem "colorize" 6 | gem 'json' 7 | 8 | -------------------------------------------------------------------------------- /spec/web/apache2_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe package('apache2') do 4 | it { should be_installed } 5 | end 6 | 7 | describe service('apache2') do 8 | it { should be_enabled } 9 | it { should be_running } 10 | end 11 | 12 | describe port(80) do 13 | it { should be_listening } 14 | end 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'serverspec' 2 | require 'pathname' 3 | require 'net/ssh' 4 | 5 | include Serverspec::Helper::Ssh 6 | include Serverspec::Helper::DetectOS 7 | 8 | RSpec.configure do |c| 9 | c.disable_sudo = true 10 | c.path = "/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin:/usr/local/sbin" 11 | c.host = ENV['TARGET_HOST'] 12 | options = Net::SSH::Config.for(c.host) 13 | user = options[:user] || Etc.getlogin 14 | c.ssh = Net::SSH.start(c.host, user, options) 15 | c.os = backend.check_os 16 | 17 | tags = (ENV['TARGET_TAGS'] || "").split(",") 18 | c.filter_run_excluding :tag => lambda { |t| 19 | not tags.include?(t) 20 | } 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper_v2.rb: -------------------------------------------------------------------------------- 1 | require 'serverspec' 2 | #require 'pathname' 3 | require 'net/ssh' 4 | 5 | #include Serverspec::Helper::Ssh 6 | #include Serverspec::Helper::DetectOS 7 | # 8 | #RSpec.configure do |c| 9 | # c.disable_sudo = true 10 | # c.path = "/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin:/usr/local/sbin" 11 | # c.host = ENV['TARGET_HOST'] 12 | # options = Net::SSH::Config.for(c.host) 13 | # user = options[:user] || Etc.getlogin 14 | # c.ssh = Net::SSH.start(c.host, user, options) 15 | # c.os = backend.check_os 16 | # 17 | # tags = (ENV['TARGET_TAGS'] || "").split(",") 18 | # c.filter_run_excluding :tag => lambda { |t| 19 | # not tags.include?(t) 20 | # } 21 | #end 22 | 23 | 24 | set :backend, :ssh 25 | 26 | options = Net::SSH::Config.for(host) 27 | 28 | set :disable_sudo, true 29 | set :path, '/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/bin:/usr/local/sbin' 30 | host = ENV['TARGET_HOST'] 31 | 32 | options[:user] ||= Etc.getlogin 33 | set :host, options[:host_name] || host 34 | 35 | set :ssh_options, options 36 | 37 | # Set environment variables 38 | set :env, :LANG => 'C', :LC_MESSAGES => 'C' 39 | 40 | -------------------------------------------------------------------------------- /viewer/stylesheets/report.css: -------------------------------------------------------------------------------- 1 | .disable-hover, .disable-hover * { 2 | pointer-events: none !important; 3 | } 4 | 5 | .dropzone { 6 | text-align: center; 7 | padding: 1em; 8 | background-repeat: no-repeat; 9 | background-image: url(../images/domokun.png); 10 | background-position: center 10em; 11 | height: 600px; 12 | 13 | } 14 | .dropzone blockquote { 15 | position: relative; 16 | padding: 15px; 17 | margin: 1em 0 2em; 18 | color: #fff; 19 | background: #075698; 20 | border-radius: 10px; 21 | font-weight: bold; 22 | } 23 | .dropzone blockquote:after { 24 | content: ""; 25 | position: absolute; 26 | bottom: -20px; 27 | left: 50px; 28 | border-width: 20px 0 0 20px; 29 | border-style: solid; 30 | border-color: #075698 transparent; 31 | display: block; 32 | width: 0; 33 | } 34 | input[type=text] { 35 | width: 90%; 36 | font-size: 2em; 37 | } 38 | input[type=file] { 39 | padding: 0 0.5em; 40 | box-shadow: inset 0 0 5px #ccc; 41 | border-radius: 10px; 42 | display: inline-block; 43 | } 44 | input[type=file] { 45 | margin-top: 350px; 46 | } 47 | 48 | .body { 49 | min-width: 100%; 50 | min-height: 100%; 51 | } 52 | 53 | .results { 54 | margin: auto; 55 | width: 100%; 56 | } 57 | .results .test.status-passed { 58 | background-color: #32cd32; 59 | } 60 | .results .test.status-missing { 61 | background-color: #b8b8b8; 62 | } 63 | .results .test.status-failed { 64 | background-color: #ff6347; 65 | } 66 | .results .test span { 67 | min-width: 0.5em; 68 | min-height: 1.5em; 69 | display: block; 70 | text-align: center; 71 | } 72 | .results .hostname { 73 | font-weight: bold; 74 | padding: 0 1em; 75 | min-height: 1.5em; 76 | text-align: right; 77 | } 78 | .results .rolesets td { 79 | font-weight: bold; 80 | padding-left: 0.8em; 81 | padding-right: 0.4em; 82 | } 83 | .results .specs { 84 | font-style: italic; 85 | text-align: center; 86 | font-size: small; 87 | } 88 | .results .specs td { 89 | padding: 0 0.3em; 90 | } 91 | 92 | .results tr.rolesets td, 93 | .results tr.specs td { 94 | border-right: black solid 2px; 95 | } 96 | .results tr.rolesets td:first-child, 97 | .results tr.specs td:first-child { 98 | border-right: none; 99 | } 100 | .results td.hostname { 101 | border-left: black solid 4px; 102 | } 103 | .results tr.specs { 104 | border-bottom: black solid 2px; 105 | } 106 | .results .test { 107 | border: grey solid 1px; 108 | } 109 | .results .rolesets td:last-child, .results .specs td:last-child { 110 | border-right: none; 111 | } 112 | 113 | .outer-results { position: relative } 114 | .inner-results { 115 | overflow-x: overlay; 116 | overflow-y: visible; 117 | margin-left: 20em; 118 | font-weight: bold; 119 | padding-bottom: 2em; 120 | } 121 | .results tr td:first-child { 122 | position: absolute; 123 | left: 0; 124 | width: 20em; 125 | font-weight: bold; 126 | box-sizing: border-box; 127 | } 128 | .results { 129 | font-weight: normal; 130 | } 131 | 132 | .wider-modal { 133 | overflow: auto; 134 | } 135 | .wider-modal .modal-dialog { 136 | width: 90%; 137 | } 138 | .wider-modal .modal-body { 139 | overflow-y: auto; 140 | } 141 | dd, dt { 142 | float: left; 143 | } 144 | dt { min-width: 8em; } 145 | dd + dt { clear: left; } 146 | dd.status-failed { 147 | color: #ff6347; 148 | font-weight: bold; 149 | } 150 | dd.status-missing { 151 | color: #b8b8b8; 152 | font-weight: bold; 153 | } 154 | dd.status-passed { 155 | color: #32cd32; 156 | font-weight: bold; 157 | } 158 | .exception { 159 | clear: both; 160 | padding-top: 1em; 161 | } 162 | .exception h4 { 163 | font-size: 1em; 164 | font-weight: normal; 165 | font-style: italic; 166 | white-space: pre; 167 | } 168 | .exception span { 169 | font-family: monospace; 170 | white-space: nowrap; 171 | display: block; 172 | font-size: 13px; 173 | } 174 | 175 | .prettyprint { 176 | clear: both; 177 | margin: 1em 0; 178 | } 179 | /* Number for all lines */ 180 | .prettyprint ol li { 181 | list-style-type: decimal; 182 | } 183 | .prettyprint ol li.highlighted { 184 | color: #b94a48; 185 | background-color: #f2dede; 186 | } 187 | 188 | .browser { 189 | width: 90%; 190 | margin: 0 auto; 191 | -moz-column-count: 3; 192 | -webkit-column-count: 3; 193 | column-count: 3; 194 | } 195 | 196 | .browser ul a { 197 | display: inline-block; 198 | vertical-align: top; 199 | } 200 | -------------------------------------------------------------------------------- /viewer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test results 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 57 | 58 | 59 | 110 | 111 | 112 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Advanced use example of serverspec 2 | ================================== 3 | 4 | This is an example of use of [serverspec][] with the following 5 | additions: 6 | 7 | - host groups with a function classifier 8 | - parallel execution using a process pool 9 | - report generation (in JSON format) 10 | - report viewer 11 | 12 | [serverspec]: http://serverspec.org/ 13 | 14 | [GoodData](http://www.gooddata.com/) also open sourced a 15 | [more complete setup](https://github.com/gooddata/serverspec-core) and 16 | moved the UI to a 17 | [server-side component](https://github.com/gooddata/serverspec-ui) 18 | which should be useful if you happen to have a lot of tests. 19 | 20 | Currently, this has not been updated to 21 | [Serverspec v2](http://serverspec.org/changes-of-v2.html). There is a 22 | `spec_helpver_v2.rb` which will allow testing of v2 functions located 23 | in `spec/` 24 | 25 | A tool similar to `serverspec` but with Python is 26 | [Testinfra](https://testinfra.readthedocs.org/en/latest/). 27 | 28 | You may also find additional information about this code in 29 | [this blog post](https://vincent.bernat.ch/en/blog/2014-serverspec-test-infrastructure). 30 | 31 | First steps 32 | ----------- 33 | 34 | Before using this example, you must provide your list of hosts in a 35 | file named `hosts`. You can also specify an alternate list of files by 36 | setting the `HOSTS` environment variable. 37 | 38 | You also need to modify the `roles()` function at the top of the 39 | `Rakefile` to derive host roles from their names. The current 40 | classifier is unlikely to work as is. 41 | 42 | To install the dependencies, use `bundle install --path .bundle`. 43 | 44 | You can then run a test session: 45 | 46 | $ bundle exec rake spec 47 | 48 | It is possible to only run tests on some hosts or to restrict to some 49 | roles: 50 | 51 | $ bundle exec rake check:role:web 52 | $ bundle exec rake check:server:blm-web-22.example.com 53 | 54 | Also note that `sudo` is disabled in `spec/spec_helper.rb`. You can 55 | enable it globally or locally, like explained [here][1]. 56 | 57 | [1]: http://serverspec.org/advanced_tips.html 58 | 59 | Classifier 60 | ---------- 61 | 62 | The classifier is currently a simple function (`roles()`) taking a 63 | hostname as first parameter and returning an array of roles. A role is 64 | just a string that should also be a subdirectory in the `spec/` 65 | directory. In this subdirectory, you can put any test that should be 66 | run for the given role. Here is a simple example of a directory 67 | structure for three roles: 68 | 69 | spec 70 | ├── all 71 | │ ├── lldpd_spec.rb 72 | │ └── network_spec.rb 73 | ├── memcache 74 | │ └── memcached_spec.rb 75 | └── web 76 | └── apache2_spec.rb 77 | 78 | Moreover, there is a `tags()` function whose purpose is to attach tags 79 | to tests. Those tags are then made available for conditional 80 | tests. You can do something like this with them: 81 | 82 | describe file('/data/images'), :tag => "paris" do 83 | it { should be_mounted.with( :type => 'nfs' ) } 84 | end 85 | 86 | This test will only be executed if `paris` is one of the tags of the 87 | current host. 88 | 89 | Parallel execution 90 | ------------------ 91 | 92 | With many hosts and many tests, serial execution can take some 93 | time. By using a pool of processes to run tests, it is possible to 94 | speed up test execution. `rake` comes with builtin support of such 95 | feature. Just execute it with `-j 10 -m`. 96 | 97 | Reports 98 | ------- 99 | 100 | Reports are automatically generated and put in `reports/` directory in 101 | JSON format. They can be examined with a simple HTML viewer provided 102 | in `viewer/` directory. Provide a report and you will get a grid view 103 | of tests executed succesfully or not. By clicking on one result, 104 | you'll get details of what happened, including the backtrace if any. 105 | 106 | There is a task `reports:view` which triggers a WebRick HTTP server 107 | on port 5000. Just open up http://localhost:5000/viewer to get quick 108 | access to the generated reports. 109 | 110 | There is a task `reports:gzip` which will gzip reports (and remove 111 | empty ones). To be able to still use them without manual unzip, you 112 | need a configuration like this in nginx to be able to serve them: 113 | 114 | server { 115 | listen 80; 116 | server_name serverspec.vbernat.deezerdev.com; 117 | 118 | location / { 119 | index index.html; 120 | root /path/to/serverspec/repo/viewer; 121 | } 122 | location /reports { 123 | autoindex on; 124 | root /path/to/serverspec/repo; 125 | gzip_static always; 126 | gzip_http_version 1.0; 127 | gunzip on; 128 | } 129 | } 130 | 131 | If your version of nginx does not support `gunzip on`, you will 132 | usually be fine without it... 133 | 134 | License 135 | ------- 136 | 137 | The code in this repository is distributed under the ISC license: 138 | 139 | > Permission to use, copy, modify, and/or distribute this software for any 140 | > purpose with or without fee is hereby granted, provided that the above 141 | > copyright notice and this permission notice appear in all copies. 142 | > 143 | > THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 144 | > WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 145 | > MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 146 | > ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 147 | > WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 148 | > ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 149 | > OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 150 | 151 | However, the file `viewer/images/domokun.png` is not covered by this 152 | license. Its whereabouts are unknown. You are free to replace it by an 153 | image of your choice. 154 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rbconfig' 3 | require 'rspec/core/rake_task' 4 | require 'colorize' 5 | require 'json' 6 | 7 | $HOSTS = File.join(".", "hosts") # List of all hosts 8 | $REPORTS = File.join(".", "reports") # Where to store JSON reports 9 | 10 | # Return all roles of a given host 11 | def roles(host) 12 | roles = [ "all" ] 13 | case host 14 | when /^blm-web-/ 15 | roles << "web" 16 | when /^blm-memc-/ 17 | roles << "memcache" 18 | when /^blm-lb-/ 19 | roles << "lb" 20 | when /^blm-bigdata-/ 21 | roles << "bigdata" 22 | when /^blm-proxy-/ 23 | roles << "proxy" 24 | end 25 | roles 26 | end 27 | 28 | # Return all tags associated to an host 29 | def tags(host) 30 | tags = [] 31 | # POP 32 | case host 33 | when /^[^.]+\.sg\./ 34 | tags << "singapore" 35 | when /^[^.]+\.sd\./ 36 | tags << "sidney" 37 | when /^[^.]+\.ny\./ 38 | tags << "newyork" 39 | else 40 | tags << "paris" 41 | end 42 | tags 43 | end 44 | 45 | # Special version of RakeTask for serverspec which comes with better 46 | # reporting 47 | class ServerspecTask < RSpec::Core::RakeTask 48 | 49 | attr_accessor :target 50 | attr_accessor :tags 51 | 52 | # Run our serverspec task. Errors are ignored. 53 | def run_task(verbose) 54 | json = File.join("#{$REPORTS}", "current", "#{target}.json") 55 | @rspec_opts = ["--format", "json", "--out", json] 56 | system("env TARGET_HOST=#{target} TARGET_TAGS=#{(tags || []).join(",")} #{spec_command}") 57 | status(target, json) if verbose 58 | end 59 | 60 | # Display status of a test from its JSON output 61 | def status(target, json) 62 | begin 63 | out = JSON.parse(File.read(json)) 64 | summary = out["summary"] 65 | total = summary["example_count"] 66 | failures = summary["failure_count"] 67 | if failures > 0 then 68 | print ("[%-3s/%-4s] " % [failures, total]).yellow, target, "\n" 69 | else 70 | print "[OK ] ".green, target, "\n" 71 | end 72 | rescue Exception => e 73 | print "[ERROR ] ".red, target, " (#{e.message})", "\n" 74 | end 75 | end 76 | 77 | end 78 | 79 | hosts = File.foreach(ENV["HOSTS"] || $HOSTS).map { |line| line.strip } 80 | hosts.map! { |host| 81 | host.strip! 82 | { 83 | :name => host, 84 | :roles => roles(host), 85 | :tags => tags(host) 86 | } 87 | } 88 | 89 | desc "Run serverspec to all hosts" 90 | task :spec => "check:server:all" 91 | 92 | namespace :check do 93 | 94 | # Per server tasks 95 | namespace :server do 96 | desc "Run serverspec to all hosts" 97 | task :all => hosts.map { |h| h[:name] } 98 | hosts.each do |host| 99 | desc "Run serverspec to host #{host[:name]}" 100 | ServerspecTask.new(host[:name].to_sym) do |t| 101 | dirs = host[:roles] + [ host[:name] ] 102 | t.target = host[:name] 103 | t.tags = host[:tags] 104 | t.pattern = File.join('.', 'spec', '{' + dirs.join(",") + '}', '*_spec.rb') 105 | end 106 | end 107 | end 108 | 109 | # Per role tasks 110 | namespace :role do 111 | roles = hosts.map {|h| h[:roles]} 112 | roles = roles.flatten.uniq 113 | roles.each do |role| 114 | desc "Run serverspec to role #{role}" 115 | task "#{role}" => hosts.select { |h| h[:roles].include? role }.map { 116 | |h| "check:server:" + h[:name] 117 | } 118 | end 119 | end 120 | end 121 | 122 | namespace :reports do 123 | desc "Clean up old partial reports" 124 | task :clean do 125 | FileUtils.rm_rf File.join("#{$REPORTS}", "current") 126 | end 127 | 128 | desc "Clean reports without results" 129 | task :housekeep do 130 | FileList.new(File.join("#{$REPORTS}", "*.json")).map { |f| 131 | content = File.read(f) 132 | if content.empty? 133 | # No content, let's remove it 134 | f 135 | else 136 | results = JSON.parse(content) 137 | if not results.include?("tests") or results["tests"].map { |t| 138 | if t.include?("results") and 139 | t["results"].include?("examples") and 140 | not t["results"]["examples"].empty? 141 | t 142 | end 143 | }.compact.empty? 144 | f 145 | end 146 | end 147 | }.compact.each { |f| 148 | FileUtils.rm f 149 | } 150 | end 151 | 152 | desc "Gzip all reports" 153 | task :gzip do 154 | FileList.new(File.join("#{$REPORTS}","*.json")).each { |f| 155 | system "gzip", f 156 | } 157 | end 158 | task :gzip => "housekeep" 159 | 160 | desc "Build final report" 161 | task :build, :tasks do |t, args| 162 | args.with_defaults(:tasks => [ "unspecified" ]) 163 | now = Time.now.strftime("%Y-%m-%dT%H-%M-%S") 164 | fname = File.join("#{$REPORTS}", "%s--%s.json" % [ args[:tasks].join("-"), now ]) 165 | if /mswin|msys|mingw|cygwin|bccwin|wince|emc/.match RbConfig::CONFIG['host_os'] 166 | # For Windows, we need to remove all those pesky ":" in filenames 167 | fname.gsub! ':', '-' 168 | end 169 | File.open(fname, "w") { |f| 170 | # Test results 171 | tests = FileList.new(File.join("#{$REPORTS}", "current", "*.json")).sort.map { |j| 172 | content = File.read(j).strip 173 | { 174 | :hostname => File.basename(j, ".json"), 175 | :results => JSON.parse(content.empty? ? "{}" : content) 176 | } 177 | }.to_a 178 | # Relevant source files 179 | sources = FileList.new(File.join("#{$REPORTS}", "current", "*.json")).sort.map { |j| 180 | content = File.read(j).strip 181 | results = JSON.parse(content.empty? ? '{"examples": []}' : content)["examples"] 182 | results.map { |r| r["file_path"] } 183 | }.to_a.flatten(1).uniq 184 | sources = sources.each_with_object(Hash.new) { |f, h| 185 | h[f] = File.readlines(f).map { |l| l.chomp }.to_a 186 | } 187 | f.puts JSON.generate({ :version => 1, 188 | :tests => tests, 189 | :sources => sources }) 190 | } 191 | end 192 | 193 | task :view do 194 | `ruby -run -e httpd . -p 5000` 195 | end 196 | end 197 | 198 | # Before starting any task, cleanup reports 199 | all_check_tasks = Rake.application.tasks.select { |task| 200 | task.name.start_with?("check:") 201 | } 202 | all_check_tasks.each { |t| 203 | t.enhance [ "reports:clean" ] 204 | } 205 | 206 | # Build final report only after last check 207 | running_check_tasks = Rake.application.top_level_tasks.select { |task| 208 | task.start_with?("check:") or task == "spec" 209 | } 210 | if not running_check_tasks.empty? then 211 | Rake::Task[running_check_tasks.last].enhance do 212 | Rake::Task["reports:build"].invoke(running_check_tasks) 213 | end 214 | running_check_tasks.each { |t| 215 | task "reports:build" => t 216 | } 217 | end 218 | -------------------------------------------------------------------------------- /viewer/scripts/report.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var reportResultsApp = angular.module("reportResultsApp", ['ngRoute', 'ngFileUpload', 'ui.bootstrap']); 4 | 5 | reportResultsApp.config([ "$routeProvider", "$locationProvider", function($routeProvider, $locationProvider) { 6 | $locationProvider.html5Mode(false); 7 | $routeProvider. 8 | when("/upload", { 9 | templateUrl: "upload.html", 10 | controller: "uploadCtrl", 11 | resolve: { 12 | files: ["$route", "AvailableReports", function($route, AvailableReports) { 13 | return AvailableReports.fetch($route.current.params.url); 14 | }] 15 | } 16 | }). 17 | 18 | when("/url/:url*", { 19 | templateUrl: "result.html", 20 | controller: "reportResultCtrl", 21 | resolve: { 22 | filename: ["$route", function($route) { 23 | return $route.current.params.url; 24 | }], 25 | data: ["$http", "$route", function($http, $route) { 26 | console.info("Loading from " + $route.current.params.url); 27 | return $http({ method: "GET", 28 | url: $route.current.params.url }).then(function(response) { 29 | return response.data; 30 | }, function() { return null }); 31 | }] 32 | } 33 | }). 34 | 35 | when("/file/:filename*", { 36 | templateUrl: "result.html", 37 | controller: "reportResultCtrl", 38 | resolve: { 39 | filename: ["$route", function($route) { 40 | return $route.current.params.filename; 41 | }], 42 | data: ["$route", "ResultData", function($route, ResultData) { 43 | var data = ResultData.fetch(); 44 | console.info("Loading from local file " + $route.current.params.filename); 45 | return data; 46 | }] 47 | } 48 | }). 49 | 50 | otherwise({ 51 | redirectTo: "/upload" 52 | }); 53 | }]); 54 | 55 | reportResultsApp.factory("AvailableReports", [ "$http", function($http) { 56 | return { 57 | "fetch": function(directory) { 58 | directory = directory || "../reports/"; 59 | console.info("Loading available JSON files from " + directory); 60 | return $http({ method: "GET", 61 | url: directory }).then(function(response) { 62 | // Extract URL from HTML source code 63 | var re = /a href="([^"]+\.json)(\.gz)?"/g; 64 | var files = []; 65 | var partial; 66 | while ((partial = re.exec(response.data)) !== null) { 67 | files.push(partial[1]); 68 | } 69 | // We assume that the files are already sorted 70 | return _.sortBy(_.map(files, function(file) { 71 | var a = document.createElement('a'); 72 | a.href = directory + file; 73 | return { 74 | path: a.href, 75 | name: decodeURIComponent(file) 76 | }; 77 | }), function(file) { 78 | // We extract the date and sort through that 79 | var mo = file.name.match(/--(.+)\.json$/); 80 | var date = mo?mo[1]:"1970-01-01T01:00:00"; 81 | return date + "---" + file; 82 | }).reverse(); 83 | }, function() { return [] }); 84 | } 85 | }; 86 | }]); 87 | 88 | // We use this service to pass data between upoadCtrl and reportResultCtrl 89 | reportResultsApp.factory("ResultData", function() { 90 | var current = null; 91 | return { 92 | "save": function(data) { current = data; return current; }, 93 | "fetch": function() { return current } 94 | }; 95 | }); 96 | 97 | reportResultsApp.controller("uploadCtrl", [ "$scope", "$location", "ResultData", "files", function($scope, $location, ResultData, files) { 98 | // Select a file 99 | $scope.files = files; 100 | $scope.visit = function(file) { 101 | $location.path("/url/" + file); 102 | }; 103 | 104 | // Upload a file 105 | $scope.onFileSelect = function($files) { 106 | if ($files.length === 0) return; 107 | var file = $files[0]; 108 | var reader = new FileReader(); 109 | reader.addEventListener("loadend", function() { 110 | $scope.$apply(function(scope) { 111 | var input = JSON.parse(reader.result); 112 | ResultData.save(input); 113 | $location.path("/file/" + file.name); 114 | }); 115 | }); 116 | reader.readAsBinaryString(file); 117 | }; 118 | 119 | // Load an URL 120 | $scope.load = function() { 121 | var target = "/url/" + encodeURI($scope.url); 122 | $location.path("/url/" + $scope.url); 123 | } 124 | }]); 125 | 126 | reportResultsApp.controller("reportResultCtrl", [ "$scope", "$modal", "$location", "data", "filename", function($scope, $modal, $location, data, filename) { 127 | if (data === null) { 128 | console.warn("No data available, go back to upload"); 129 | $location.path("/"); 130 | } else { 131 | $scope.results = formatResults(data.tests); 132 | $scope.sources = data.sources; 133 | $scope.filename = filename; 134 | } 135 | 136 | // Transform a status in a mark 137 | $scope.mark = function(status) { 138 | return { 139 | "failed": "✗", 140 | "passed": "✓" 141 | }[status] || ""; 142 | }; 143 | 144 | // Text for tooltip 145 | $scope.tooltip = function(test) { 146 | if (!test.full_description) { 147 | return ""; 148 | } 149 | return test.full_description.replace(/\//g, "/\u200d"); 150 | }; 151 | 152 | // Tabs handling 153 | $scope.select = function(rs) { rs.active = true; } 154 | $scope.deselect = function(rs) { rs.active = false; } 155 | 156 | // Details of a test 157 | $scope.details = function (hostname, result) { 158 | var modalInstance = $modal.open({ 159 | templateUrl: "details.html", 160 | windowClass: "wider-modal", 161 | controller: [ "$scope", "$modalInstance", "result", "hostname", "source", 162 | function ($scope, $modalInstance, result, hostname, source) { 163 | $scope.hostname = hostname; 164 | $scope.file_path = result.test.file_path; 165 | $scope.line_number = result.test.line_number; 166 | $scope.description = result.test.full_description; 167 | $scope.status = result.test.status; 168 | $scope.exception = result.test.exception; 169 | $scope.source_start = source.start; 170 | $scope.source_snippet = source.snippet.join("\n"); 171 | 172 | $scope.ok = function () { 173 | $modalInstance.dismiss('ok'); 174 | }; 175 | }], 176 | resolve: { 177 | result: function() { return result; }, 178 | hostname: function() { return hostname; }, 179 | source: function() { 180 | // Extract the appropriate source snippet. 181 | var file = result.test.file_path; 182 | var start = result.test.line_number; 183 | var end = result.test.line_number; 184 | var source = $scope.sources[file]; 185 | // We search for the first blank lines followed by a non-idented line 186 | while (start > 1 && 187 | (source[start - 1] !== "" || 188 | (source[start] || "").match(/^\s/) !== null)) start--; 189 | while (source[end - 1] !== undefined && 190 | (source[end - 1] !== "" || 191 | (source[end - 2] || "").match(/^\s/) !== null)) end++; 192 | start++; end--; 193 | return { 194 | "start": start, 195 | "snippet": source.slice(start - 1, end) 196 | } 197 | } 198 | } 199 | }); 200 | }; 201 | 202 | }]); 203 | 204 | reportResultsApp.directive( 205 | 'fastscroll', function($window, $timeout) { 206 | return { 207 | scope: false, 208 | replace: false, 209 | restrict: 'A', 210 | link: function($scope, $element) { 211 | var timer; 212 | angular.element($window).on('scroll', function() { 213 | if (timer) { 214 | $timeout.cancel(timer); 215 | timer = null; 216 | } 217 | $element.addClass('disable-hover'); 218 | timer = $timeout(function() { 219 | $element.removeClass('disable-hover'); 220 | }, 500); 221 | }); 222 | } 223 | }; 224 | }); 225 | 226 | reportResultsApp.directive( 227 | "prettyprint", function() { 228 | return { 229 | scope: false, 230 | replace: true, 231 | restrict: 'E', 232 | template: '
',
233 |             controller: function($scope, $element) {
234 |                 $element.html(prettyPrintOne($scope.source_snippet,
235 |                                              "ruby",
236 |                                              $scope.source_start));
237 |                 angular.element($element
238 |                                 .removeClass("highlighted")
239 |                                 .find("li")[$scope.line_number - $scope.source_start])
240 |                     .addClass("highlighted");
241 |             }
242 |         };
243 |     });
244 | 
245 | // Format results to display them more effectively
246 | var formatResults = function(input) {
247 | 
248 |     console.group("Formatting results");
249 | 
250 |     // Input is something like that:
251 |     // [{ "hostname": "....",
252 |     //    "results": { "examples": [
253 |     //          { "description": "should ...",
254 |     //            "file_path": "./spec/role/something_spec.rb",
255 |     //            "full_description": "Squid should ...",
256 |     //            "line_number": 4,
257 |     //            "status": "passed" },
258 |     //          { ... } ] } },
259 |     //  { ... }]
260 | 
261 |     // We want to display something like this:
262 |     //
263 |     //           |  all   |  web   |
264 |     //  ------------------------------------
265 |     //    web1   | ✓ ✗ ✓  | ✓ ✓ ✓  |
266 |     //    web2   | ✓ ✗ ✓  | ✓ ✓ ✓  |
267 |     //    web3   | ✓ ✗    | ✓ ✓ ✓  |
268 |     //  ------------------------------------
269 |     //           |  all   |  memcache   |
270 |     //  ------------------------------------
271 |     //    memc1  | ✓ ✓ ✓  | ✓ ✓ ✓ ✓ ✓ ✓ |
272 |     //    memc2  | ✓ ✓ ✓  | ✓     ✓     |
273 |     //    memc3  | ✓ ✓ ✓  | ✓ ✓ ✓ ✓     |
274 |     //  ------------------------------------
275 |     //           |
276 |     //  ------------------------------------
277 |     //    unkn1  |
278 |     //    unkn2  |
279 | 
280 |     // So, we need to extract:
281 |     //
282 |     //   1. The set of roles. In the example above, this is
283 |     //      (all, web), (all, memcache) and ().
284 |     //
285 |     //   2. For each set, get the list of hosts in the set. We should
286 |     //      be able to attach the number of succesfull/failed tests to
287 |     //      be able to display them as overlay or as a background
288 |     //      color.
289 |     //
290 |     //   3. For each role in each set, we should be able to have the
291 |     //      number of tests to be displayed.
292 |     //
293 |     //   4. For each host, for each role, for each spec file in the
294 |     //      role (even those not executed for this specific host), for
295 |     //      test in spec file (even those not executed), we need to
296 |     //      know the status, the description. The order should be the
297 |     //      same for each host, including the tests not run. We need
298 |     //      to ensure that a given column for a role set is always the
299 |     //      same test.
300 |     //
301 |     // In output, we get (this is a flattened output to allow easy
302 |     // iteration in AngularJS):
303 |     //
304 |     // [ { "roles": [ {name: "all", tests: 5 },
305 |     //                {name: "web", tests: 10 } ],
306 |     //     "specs": [ {role: "all", name: "lldpd", tests: 5},
307 |     //                {role: "web", name: "apache2", tests: 10 }],
308 |     //     "results": [ {name: "web1", success: 14, failure: 1,
309 |     //                   results: [{role: "all",
310 |     //                              spec: "lldpd",
311 |     //                              test: {status: "failed",
312 |     //                                     line_number: 4,
313 |     //                                     full_description: "...",
314 |     //                                     exception: {...}}]
315 | 
316 |     var output = [];
317 | 
318 |     // Get example identifier (role, spec, line number)
319 |     var exampleIdentifier = function (e) {
320 |         var matches = e.file_path.match(/^\.\/spec\/([^\/]+)\/([^\/]+)_spec\.rb$/);
321 |         if (matches) {
322 |             return [ matches[1], matches[2], e.line_number ];
323 |         }
324 |     };
325 | 
326 |     // Get role attached to an example
327 |     var exampleRole = function (e) {
328 |         var id = exampleIdentifier(e);
329 |         if (id) {
330 |             return id[0];
331 |         }
332 |     };
333 | 
334 |     // Get roles attached to a result
335 |     var resultRoles = function(r) {
336 |         return _.uniq(_.map(r.results.examples, exampleRole),
337 |                       function(x) { return JSON.stringify(x); });
338 |     };
339 | 
340 |     // Display string for a role set
341 |     var roleSetName = function(rs) {
342 |         return rs.join(", ");
343 |     };
344 | 
345 |     // Affect a color depending on the number of success and failures
346 |     var successColor = function(success, failure) {
347 |         if (success + failure === 0) {
348 |             return "black";
349 |         }
350 |         var percent = success / (success + failure*5); // failures are more important
351 |         // #32cd32
352 |         var color1 = [ 0x32, 0xcd, 0x32 ];
353 |         // #ff6347
354 |         var color2 = [ 0xff, 0x63, 0x47 ];
355 |         var target = _.zip(_.map(color1, function(x) { return x*percent }),
356 |                            _.map(color2, function(x) { return x*(1-percent) }));
357 |         target = _.map(target, function(x) {
358 |             var r = x[0] + x[1];
359 |             var s = Math.round(r).toString(16);
360 |             return s.length == 2 ? s : '0' + s;
361 |         });
362 |         return "#" + target.join("");
363 |     };
364 | 
365 |     // Provides result for a given test
366 |     var testResult = function(examples, test) {
367 |         var ts = JSON.stringify(test);
368 |         var example = _.find(examples, function(e) {
369 |             return JSON.stringify(exampleIdentifier(e)) === ts;
370 |         });
371 |         if (!example) return { "status": "missing" };
372 |         return example;
373 |     };
374 | 
375 |     // Set of roles.
376 |     var roleSets = _.sortBy(
377 |         _.uniq(_.map(input, resultRoles),
378 |                function(x) { return JSON.stringify(x); }),
379 |         function(a) { return -a.length });
380 |     console.group(roleSets.length + " role sets");
381 |     _.each(roleSets, function (rs) {
382 |         rs.name = roleSetName(rs) || "";
383 |         console.log("(" + rs.name + ")");
384 |     });
385 |     console.groupEnd();
386 | 
387 |     _.each(roleSets, function(rs) {
388 |         console.group("Process role set (" + rs.name + ")");
389 | 
390 |         // We need to get a list of all tests in a topological order
391 |         // for the current roleset. A test is a role, a spec file and
392 |         // a line number.
393 |         var tests = _.map(input, function(r) {
394 |             // Keep only examples that match our roleset
395 |             var examples = _.filter(r.results.examples, function(e) {
396 |                 return _.indexOf(rs, exampleRole(e)) != -1
397 |             });
398 |             return _.map(examples, exampleIdentifier);
399 |         });
400 | 
401 |         // Our topological sort can be done with a simple sort as we
402 |         // have everything we need.
403 |         tests = _.flatten(tests, true);
404 |         tests = _.uniq(tests, function(x) { return JSON.stringify(x); });
405 |         tests = _.filter(tests, function(t) { return t.length > 0; });
406 |         tests.sort(function(t1, t2) {
407 |             if (t1[0] < t2[0]) return -1;
408 |             if (t1[0] > t2[0]) return 1;
409 |             if (t1[1] < t2[1]) return -1;
410 |             if (t1[1] > t2[1]) return 1;
411 |             if (t1[2] < t2[2]) return -1;
412 |             if (t1[2] > t2[2]) return 1;
413 |             return 0;
414 |         });
415 | 
416 |         console.log("Tests are: ", _.map(tests, function(t) {
417 |             return t.join(":");
418 |         }));
419 | 
420 |         // List of roles with the number of tests
421 |         var roles = _.map(_.groupBy(tests, function(t) { return t[0]; }),
422 |                           function (tests, role) {
423 |                               return { "name": role,
424 |                                        "tests": tests.length,
425 |                                        "specs":  _.map(_.groupBy(tests, function(t) { return t[1]; }),
426 |                                                        function (tests, spec) {
427 |                                                            return { "name": spec,
428 |                                                                     "tests": tests.length };
429 |                                                        })};
430 |                           });
431 |         var specs = _.flatten(_.map(roles, function(role) {
432 |             var sp = role.specs;
433 |             delete role.specs;
434 |             _.map(sp, function(s) { s.role = role.name; });
435 |             return sp;
436 |         }), true);
437 | 
438 |         // Results for each host (not very efficient)
439 |         var results = _.filter(input, function(h) {
440 |             return JSON.stringify(resultRoles(h)) === JSON.stringify(rs)
441 |         });
442 |         results = _.map(results, function(h) {
443 |             var success = 0;
444 |             var failure = 0;
445 |             var rr = _.map(_.groupBy(tests, function(t) { return t[0]; }),
446 |                            function (tests, role) {
447 |                                return _.map(_.groupBy(tests, function(t) { return t[1]; }),
448 |                                             function(tests, spec) {
449 |                                                 var res = _.map(tests, function (t) {
450 |                                                     return testResult(h.results.examples, t);
451 |                                                 });
452 |                                                 failure += _.reduce(res,
453 |                                                                     function (memo, r) {
454 |                                                                         return memo + ((r.status === "failed")?1:0);
455 |                                                                     }, 0);
456 |                                                 success += _.reduce(res,
457 |                                                                     function (memo, r) {
458 |                                                                         return memo + ((r.status === "passed")?1:0);
459 |                                                                     }, 0);
460 |                                                 return _.map(res, function(r) {
461 |                                                     return {
462 |                                                         "role": role,
463 |                                                         "spec": spec,
464 |                                                         "test": r
465 |                                                     };
466 |                                                 })
467 |                                             });
468 |                            });
469 |             return { "name": h.hostname,
470 |                      "success": success,
471 |                      "failure": failure,
472 |                      "color": successColor(success, failure),
473 |                      "results": _.flatten(rr) };
474 |         });
475 | 
476 |         var success = _.reduce(results, function(memo, r) { return memo + r.success }, 0);
477 |         var failure = _.reduce(results, function(memo, r) { return memo + r.failure }, 0);
478 |         var percent = success + failure;
479 |         percent = percent?(Math.round(success * 100 / percent)):null;
480 |         output.push({"name": rs.name,
481 |                      "roles": roles,
482 |                      "percent": percent,
483 |                      "success": success,
484 |                      "failure": failure,
485 |                      "specs": specs,
486 |                      "results": results,
487 |                      "tests": tests.length});
488 |         console.groupEnd();
489 |     });
490 | 
491 |     console.groupEnd();
492 |     return output;
493 | }
494 | 
495 | /* Local variables: */
496 | /* js2-basic-offset: 4 */
497 | /* End: */
498 | 


--------------------------------------------------------------------------------