tdi 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +27 -0
- data/LICENSE.txt +674 -0
- data/README.md +46 -0
- data/Rakefile +1 -0
- data/bin/tdi +167 -0
- data/helper/acl.rb +38 -0
- data/helper/file.rb +97 -0
- data/helper/http.rb +104 -0
- data/helper/ssh.rb +58 -0
- data/lib/planner.rb +298 -0
- data/lib/rblank.rb +23 -0
- data/lib/rmerge.rb +19 -0
- data/lib/runner.rb +99 -0
- data/lib/tdi.rb +39 -0
- data/lib/tdi/version.rb +3 -0
- data/tdi.gemspec +31 -0
- metadata +170 -0
data/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# TDI
|
2
|
+
|
3
|
+
Test Driven Infrastructure acceptance helpers for validating your deployed
|
4
|
+
infrastructure and external dependencies.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
gem 'tdi'
|
11
|
+
|
12
|
+
And then execute:
|
13
|
+
|
14
|
+
$ bundle
|
15
|
+
|
16
|
+
Or install it yourself as:
|
17
|
+
|
18
|
+
$ gem install tdi
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
$ tdi -h
|
23
|
+
|
24
|
+
Usage:
|
25
|
+
|
26
|
+
tdi [options] test_plan_file
|
27
|
+
|
28
|
+
Examples:
|
29
|
+
|
30
|
+
tdi tdi.json
|
31
|
+
tdi --plan admin tdi.json
|
32
|
+
tdi --plan admin::acl tdi.json
|
33
|
+
tdi -p admin::acl,admin::file tdi.json
|
34
|
+
tdi --nofail tdi.json
|
35
|
+
tdi --shred tdi.json
|
36
|
+
tdi -v tdi.json
|
37
|
+
tdi -vv tdi.json
|
38
|
+
tdi -vvv tdi.json
|
39
|
+
|
40
|
+
Options:
|
41
|
+
|
42
|
+
-n, --nofail No fail mode.
|
43
|
+
-p, --plan Test plan list.
|
44
|
+
-s, --shred Wipe out the test plan, leaving no trace behind.
|
45
|
+
-v, --verbose Verbose mode.
|
46
|
+
-h, --help Display this help message.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
data/bin/tdi
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# == TDI
|
4
|
+
#
|
5
|
+
# Run test plans against the infrastructure, performing tasks such as validating
|
6
|
+
# for:
|
7
|
+
# - Network access (ACL).
|
8
|
+
# - File/directory RO/RW access (Local/NFS).
|
9
|
+
# - SSH session capability (Private/Public key pair based auth).
|
10
|
+
# - HTTP request/response.
|
11
|
+
# TODO: - Successful execution of an arbitrary command.
|
12
|
+
# TODO: - DNS resolution.
|
13
|
+
#
|
14
|
+
# === Authors
|
15
|
+
#
|
16
|
+
# Rogério Carvalho Schneider <rogerio.schneider@corp.globo.com>
|
17
|
+
# Leonardo Martins de Lima <leonardo.martins@corp.globo.com>
|
18
|
+
# Diogo Kiss <diogokiss@corp.globo.com>
|
19
|
+
# Francisco Corrêa <francisco@corp.globo.com>
|
20
|
+
#
|
21
|
+
# === Copyright
|
22
|
+
#
|
23
|
+
# Copyright (C) 2013-2014 Globo.com
|
24
|
+
|
25
|
+
##############
|
26
|
+
## REQUIRES ##
|
27
|
+
##############
|
28
|
+
|
29
|
+
require 'slop'
|
30
|
+
require 'json'
|
31
|
+
require 'colorize'
|
32
|
+
require_relative '../lib/planner'
|
33
|
+
require_relative '../lib/runner'
|
34
|
+
|
35
|
+
###############
|
36
|
+
## CONSTANTS ##
|
37
|
+
###############
|
38
|
+
|
39
|
+
UNMERGEABLE_KEY_LIST = %w(desc inherits notest)
|
40
|
+
UNTESTABLE_ROLE_LIST = %w(global common)
|
41
|
+
|
42
|
+
#############
|
43
|
+
## HELPERS ##
|
44
|
+
#############
|
45
|
+
|
46
|
+
# Autoload any helper file inside helper's directory.
|
47
|
+
BIN_DIR = File.dirname(File.expand_path(__FILE__))
|
48
|
+
Dir["#{BIN_DIR}/../helper/*.rb"].each do |filename|
|
49
|
+
filename.sub!(/\.rb$/, '')
|
50
|
+
require filename
|
51
|
+
end
|
52
|
+
|
53
|
+
##########
|
54
|
+
## MAIN ##
|
55
|
+
##########
|
56
|
+
|
57
|
+
def main(opts)
|
58
|
+
# Test plan input file.
|
59
|
+
filename = ARGV[0]
|
60
|
+
|
61
|
+
# Wrong number of command line arguments.
|
62
|
+
if filename.nil? and not opts.help?
|
63
|
+
puts opts
|
64
|
+
exit 1
|
65
|
+
end
|
66
|
+
|
67
|
+
# It is not an error to ask for help.
|
68
|
+
exit 0 if opts.help?
|
69
|
+
|
70
|
+
# Validation.
|
71
|
+
validate_args(opts)
|
72
|
+
|
73
|
+
# Start.
|
74
|
+
if opts[:verbose] > 0
|
75
|
+
puts "Using \"#{filename}\" as test plan input file.".cyan
|
76
|
+
puts
|
77
|
+
end
|
78
|
+
|
79
|
+
# Parse input file.
|
80
|
+
original_plan = JSON.parse(open(filename).read)
|
81
|
+
|
82
|
+
# Print input file.
|
83
|
+
if opts[:verbose] > 2
|
84
|
+
original_plan.each_pair do |role_name, role_content|
|
85
|
+
puts "Found role: #{role_name}".cyan
|
86
|
+
puts 'Role content:'.cyan
|
87
|
+
puts "* #{role_content}".yellow
|
88
|
+
puts
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Plan.
|
93
|
+
plan = planner(opts, original_plan)
|
94
|
+
|
95
|
+
# Print test plan.
|
96
|
+
if opts[:verbose] > 0
|
97
|
+
puts "Test plan built from \"#{filename}\":".cyan
|
98
|
+
puts JSON.pretty_generate(plan).yellow
|
99
|
+
puts
|
100
|
+
end
|
101
|
+
|
102
|
+
# Run tests.
|
103
|
+
runner(opts, filename, plan)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Arguments validation.
|
107
|
+
def validate_args(opts)
|
108
|
+
puts 'Validating arguments...'.cyan if opts[:verbose] > 1
|
109
|
+
|
110
|
+
if opts.plan?
|
111
|
+
if opts[:plan].nil?
|
112
|
+
puts 'ERR: When using test plan filter you must inform at least one test plan name.'.light_magenta
|
113
|
+
exit 1
|
114
|
+
end
|
115
|
+
|
116
|
+
opts[:plan].each do |plan_name|
|
117
|
+
unless /^\w+(::\w+)?$/.match(plan_name)
|
118
|
+
puts "ERR: Invalid test plan filter \"#{plan_name}\". Must match pattern \"role\" or \"role::plan\".".light_magenta
|
119
|
+
exit 1
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
if opts[:verbose] > 1
|
125
|
+
puts 'Validating arguments... done.'.green
|
126
|
+
puts
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Command line options.
|
131
|
+
begin
|
132
|
+
opts = Slop.parse(:help => true, :strict => true) do
|
133
|
+
banner <<-EOS
|
134
|
+
Usage:
|
135
|
+
|
136
|
+
tdi [options] test_plan_file
|
137
|
+
|
138
|
+
Examples:
|
139
|
+
|
140
|
+
tdi tdi.json
|
141
|
+
tdi --plan admin tdi.json
|
142
|
+
tdi --plan admin::acl tdi.json
|
143
|
+
tdi -p admin::acl,admin::file tdi.json
|
144
|
+
tdi --nofail tdi.json
|
145
|
+
tdi --shred tdi.json
|
146
|
+
tdi -v tdi.json
|
147
|
+
tdi -vv tdi.json
|
148
|
+
tdi -vvv tdi.json
|
149
|
+
|
150
|
+
Options:
|
151
|
+
EOS
|
152
|
+
on :n, :nofail, 'No fail mode.'
|
153
|
+
on :p, :plan, 'Test plan list.', :as => Array, :argument => :optional
|
154
|
+
on :s, :shred, 'Wipe out the test plan, leaving no trace behind.'
|
155
|
+
on :v, :verbose, 'Verbose mode.', :as => :count
|
156
|
+
end
|
157
|
+
rescue
|
158
|
+
puts 'ERR: Invalid option. Try -h or --help for help.'.light_magenta
|
159
|
+
exit 1
|
160
|
+
end
|
161
|
+
|
162
|
+
begin
|
163
|
+
exit main(opts)
|
164
|
+
rescue => e
|
165
|
+
puts "ERR: #{e.message}".light_magenta
|
166
|
+
exit 1
|
167
|
+
end
|
data/helper/acl.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'timeout'
|
3
|
+
require 'etc'
|
4
|
+
|
5
|
+
class TDIPlan < TDI
|
6
|
+
def acl(plan)
|
7
|
+
plan.select { |key, val|
|
8
|
+
val.is_a?(Hash)
|
9
|
+
}.each_pair do |case_name, case_content|
|
10
|
+
# Parse.
|
11
|
+
host = case_name
|
12
|
+
ports = [case_content['port']].flatten
|
13
|
+
timeout_limit = case_content['timeout'].nil? ? 1 : case_content['timeout'].to_i
|
14
|
+
|
15
|
+
# User.
|
16
|
+
user = Etc.getpwuid(Process.euid).name
|
17
|
+
|
18
|
+
# ACL.
|
19
|
+
ports.each do |port|
|
20
|
+
begin
|
21
|
+
timeout(timeout_limit) do
|
22
|
+
begin
|
23
|
+
sock = TCPSocket.open(host, port)
|
24
|
+
sock.close
|
25
|
+
success "ACL (#{user}): #{host}:#{port}"
|
26
|
+
rescue Errno::ECONNREFUSED
|
27
|
+
warning "ACL (#{user}): #{host}:#{port}"
|
28
|
+
rescue Errno::ECONNRESET, Errno::ETIMEDOUT
|
29
|
+
failure "ACL (#{user}): Connection Refused #{host}:#{port}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
rescue Timeout::Error
|
33
|
+
failure "ACL (#{user}): Timed out (#{timeout_limit}s) #{host}:#{port}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/helper/file.rb
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'etc'
|
3
|
+
|
4
|
+
class TDIPlan < TDI
|
5
|
+
def file(plan)
|
6
|
+
plan.select { |key, val|
|
7
|
+
val.is_a?(Hash)
|
8
|
+
}.each_pair do |case_name, case_content|
|
9
|
+
# Parse.
|
10
|
+
path = case_name
|
11
|
+
user = case_content['user']
|
12
|
+
perm = case_content['perm']
|
13
|
+
type = case_content['type']
|
14
|
+
location = case_content['location']
|
15
|
+
|
16
|
+
# Flag.
|
17
|
+
@flag_success = true
|
18
|
+
|
19
|
+
# Privileged user.
|
20
|
+
begin
|
21
|
+
Process.euid = 0
|
22
|
+
rescue => e
|
23
|
+
puts "ERR: Must run as root to change user credentials #{e}).".light_magenta
|
24
|
+
exit 1
|
25
|
+
end
|
26
|
+
|
27
|
+
# Change credentials to local user.
|
28
|
+
begin
|
29
|
+
Process.euid = Etc.getpwnam(user).uid
|
30
|
+
rescue => e
|
31
|
+
puts "ERR: User \"#{user}\" not found (#{e}).".light_magenta
|
32
|
+
exit 1
|
33
|
+
end
|
34
|
+
|
35
|
+
# Apply the test to a
|
36
|
+
def testPerm filename, perm, type
|
37
|
+
# Perm.
|
38
|
+
begin
|
39
|
+
FileUtils.touch(filename)
|
40
|
+
@flag_success = false if perm.eql?('ro')
|
41
|
+
rescue
|
42
|
+
@flag_success = false if perm.eql?('rw')
|
43
|
+
ensure
|
44
|
+
# Cleanup, if type is directory (remove tempfile).
|
45
|
+
FileUtils.rm(filename) if type.eql?('directory') rescue nil
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Type.
|
50
|
+
case type
|
51
|
+
when 'directory'
|
52
|
+
# Path.
|
53
|
+
filename = "#{path}/#{ENV['HOSTNAME']}.rw"
|
54
|
+
testPerm filename, perm, type
|
55
|
+
when 'file'
|
56
|
+
# Path.
|
57
|
+
filename = path
|
58
|
+
testPerm filename, perm, type
|
59
|
+
when 'link'
|
60
|
+
@flag_success = File.symlink?(path)
|
61
|
+
@flag_success = File.exist?(path) if @flag_success
|
62
|
+
else
|
63
|
+
puts "ERR: Invalid file plan format \"#{type}\". Type must be \"directory\", \"file\" or \"link\".".light_magenta
|
64
|
+
exit 1
|
65
|
+
end
|
66
|
+
|
67
|
+
# Location.
|
68
|
+
unless type.eql?('directory')
|
69
|
+
df_path = File.dirname(path)
|
70
|
+
else
|
71
|
+
df_path = path
|
72
|
+
end
|
73
|
+
fs_location_query_cmd = "df -P #{df_path} | tail -n 1 | awk '{print $1}'"
|
74
|
+
device = `#{fs_location_query_cmd}`
|
75
|
+
|
76
|
+
case location
|
77
|
+
when 'local'
|
78
|
+
@flag_success = false if device.include?(':')
|
79
|
+
when 'nfs'
|
80
|
+
@flag_success = false unless device.include?(':')
|
81
|
+
else
|
82
|
+
puts "ERR: Invalid file plan format \"#{location}\". Location must be \"local\" or \"nfs\".".light_magenta
|
83
|
+
exit 1
|
84
|
+
end
|
85
|
+
|
86
|
+
# Verdict.
|
87
|
+
if @flag_success
|
88
|
+
success "FILE (#{user}): #{path} => #{perm} #{type} #{location}"
|
89
|
+
else
|
90
|
+
failure "FILE (#{user}): #{path} => #{perm} #{type} #{location}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Change credentials back to privileged user.
|
95
|
+
Process.euid = 0
|
96
|
+
end
|
97
|
+
end
|
data/helper/http.rb
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require "net/https"
|
3
|
+
require 'timeout'
|
4
|
+
require 'etc'
|
5
|
+
require 'uri'
|
6
|
+
|
7
|
+
class TDIPlan < TDI
|
8
|
+
|
9
|
+
def _parse(uri,params)
|
10
|
+
|
11
|
+
# Normalizing
|
12
|
+
if not uri =~ /^https?:\/\//
|
13
|
+
uri = 'http://' + uri.to_s
|
14
|
+
end
|
15
|
+
|
16
|
+
# URI
|
17
|
+
_uri = URI(uri)
|
18
|
+
ssl = _uri.scheme.eql?("https")
|
19
|
+
host = _uri.host
|
20
|
+
port = _uri.port
|
21
|
+
path = _uri.path.empty? ? '/' : _uri.path
|
22
|
+
|
23
|
+
# Params
|
24
|
+
code = params['code'].nil? ? 200 : params['code'].to_i
|
25
|
+
match = params['match']
|
26
|
+
timeout_limit = params['timeout'].nil? ? 2 : params['timeout'].to_i
|
27
|
+
|
28
|
+
if not params['proxy'].nil?
|
29
|
+
proxy_addr, proxy_port = params['proxy'].split(/:/)
|
30
|
+
proxy_port = 3128 unless not proxy_port.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
return host, port, path, proxy_addr, proxy_port, code, match, ssl, timeout_limit
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
def http(plan)
|
38
|
+
plan.select { |key, val|
|
39
|
+
val.is_a?(Hash)
|
40
|
+
}.each_pair do |case_name,case_content|
|
41
|
+
|
42
|
+
host, port, path, proxy_addr, proxy_port, code, match, ssl, timeout_limit = _parse(case_name,case_content)
|
43
|
+
|
44
|
+
# User.
|
45
|
+
user = Etc.getpwuid(Process.euid).name
|
46
|
+
|
47
|
+
response = nil
|
48
|
+
|
49
|
+
if not proxy_addr.nil? and not proxy_port.nil?
|
50
|
+
|
51
|
+
http = Net::HTTP::Proxy(proxy_addr, proxy_port)
|
52
|
+
|
53
|
+
begin
|
54
|
+
timeout(timeout_limit) do
|
55
|
+
begin
|
56
|
+
http.start(host,port,:use_ssl => ssl, :verify_mode => OpenSSL::SSL::VERIFY_NONE) { |http|
|
57
|
+
response = http.get(path)
|
58
|
+
}
|
59
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET
|
60
|
+
warning "HTTP (#{user}): #{case_name} - Connection reset or refused."
|
61
|
+
end
|
62
|
+
end
|
63
|
+
rescue Timeout::Error
|
64
|
+
failure "HTTP (#{user}): #{case_name} - Timed out (#{timeout_limit}s)."
|
65
|
+
end
|
66
|
+
|
67
|
+
else
|
68
|
+
|
69
|
+
http = Net::HTTP.new(host, port)
|
70
|
+
|
71
|
+
if ssl
|
72
|
+
http.use_ssl = true
|
73
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
74
|
+
end
|
75
|
+
|
76
|
+
begin
|
77
|
+
timeout(timeout_limit) do
|
78
|
+
begin
|
79
|
+
http.start() { |http|
|
80
|
+
response = http.get(path)
|
81
|
+
}
|
82
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET
|
83
|
+
warning "HTTP (#{user}): #{case_name} - Connection reset or refused."
|
84
|
+
end
|
85
|
+
end
|
86
|
+
rescue Timeout::Error
|
87
|
+
failure "HTTP (#{user}): #{case_name} - Timed out."
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
if not response.nil?
|
93
|
+
if not match.nil? and not response.body.chomp.include?(match.chomp)
|
94
|
+
failure "HTTP (#{user}): #{case_name} - Expected string '#{match.chomp}'."
|
95
|
+
elsif not code.nil? and (response.code.to_i != code)
|
96
|
+
failure "HTTP (#{user}): #{case_name} - Expected HTTP #{code}."
|
97
|
+
else
|
98
|
+
success "HTTP (#{user}): #{case_name}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|