tdi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|