├── .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 |
--------------------------------------------------------------------------------