taste_tester 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,92 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ require 'mixlib/config'
4
+ require 'taste_tester/logging'
5
+ require 'between_meals/util'
6
+
7
+ module TasteTester
8
+ # Config file parser and config object
9
+ # Uses Mixlib::Config v1 syntax so it works in Chef10 omnibus...
10
+ # it's compatible with v2, so it should work in 11 too.
11
+ module Config
12
+ extend Mixlib::Config
13
+ extend TasteTester::Logging
14
+ extend BetweenMeals::Util
15
+
16
+ repo "#{ENV['HOME']}/ops"
17
+ repo_type 'git'
18
+ base_dir 'chef'
19
+ cookbook_dirs ['cookbooks']
20
+ role_dir 'roles'
21
+ databag_dir 'databags'
22
+ config_file '/etc/taste-tester-config.rb'
23
+ plugin_path '/etc/taste-tester-plugin.rb'
24
+ chef_zero_path '/opt/chef/embedded/bin/chef-zero'
25
+ verbosity Logger::WARN
26
+ timestamp false
27
+ user 'root'
28
+ ref_file "#{ENV['HOME']}/.chef/taste-tester-ref.json"
29
+ checksum_dir "#{ENV['HOME']}/.chef/checksums"
30
+ skip_repo_checks false
31
+ chef_client_command 'chef-client'
32
+ testing_time 3600
33
+ chef_port_range [5000, 5500]
34
+ tunnel_port 4001
35
+ timestamp_file '/etc/chef/test_timestamp'
36
+ use_ssh_tunnels true
37
+
38
+ skip_pre_upload_hook false
39
+ skip_post_upload_hook false
40
+ skip_pre_test_hook false
41
+ skip_post_test_hook false
42
+ skip_repo_checks_hook false
43
+
44
+ def self.cookbooks
45
+ cookbook_dirs.map do |x|
46
+ File.join(repo, base_dir, x)
47
+ end
48
+ end
49
+
50
+ def self.relative_cookbook_dirs
51
+ cookbook_dirs.map do |x|
52
+ File.join(base_dir, x)
53
+ end
54
+ end
55
+
56
+ def self.roles
57
+ File.join(repo, base_dir, role_dir)
58
+ end
59
+
60
+ def self.relative_role_dir
61
+ File.join(base_dir, role_dir)
62
+ end
63
+
64
+ def self.databags
65
+ File.join(repo, base_dir, databag_dir)
66
+ end
67
+
68
+ def self.relative_databag_dir
69
+ File.join(base_dir, databag_dir)
70
+ end
71
+
72
+ def self.chef_port
73
+ range = chef_port_range.first.to_i..chef_port_range.last.to_i
74
+ range.to_a.shuffle.each do |port|
75
+ unless port_open?(port)
76
+ return port
77
+ end
78
+ end
79
+ logger.error 'Could not find a free port in range' +
80
+ " [#{chef_port_range.first}, #{chef_port_range.last}]"
81
+ exit 1
82
+ end
83
+
84
+ def self.testing_end_time
85
+ if TasteTester::Config.testing_until
86
+ TasteTester::Config.testing_until.strftime('%y%m%d%H%M.%S')
87
+ else
88
+ (Time.now + TasteTester::Config.testing_time).strftime('%y%m%d%H%M.%S')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,52 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ require 'taste_tester/logging'
4
+ require 'between_meals/util'
5
+
6
+ module TasteTester
7
+ # Hooks placeholders
8
+ class Hooks
9
+ extend TasteTester::Logging
10
+ extend BetweenMeals::Util
11
+
12
+ # Do stuff before we upload to chef-zero
13
+ def self.pre_upload(_dryrun, _repo, _last_ref, _cur_ref)
14
+ end
15
+
16
+ # Do stuff after we upload to chef-zero
17
+ def self.post_upload(_dryrun, _repo, _last_ref, _cur_ref)
18
+ end
19
+
20
+ # Do stuff before we put hosts in test mode
21
+ def self.pre_test(_dryrun, _repo, _hosts)
22
+ end
23
+
24
+ # This should return an array of commands to execute on
25
+ # remote systems.
26
+ def self.test_remote_cmds(_dryrun, _hostname)
27
+ end
28
+
29
+ # Should return a string with extra stuff to shove
30
+ # in the remote client.rb
31
+ def self.test_remote_client_rb_extra_code(_hostname)
32
+ end
33
+
34
+ # Do stuff after we put hosts in test mode
35
+ def self.post_test(_dryrun, _repo, _hosts)
36
+ end
37
+
38
+ # Additional checks you want to do on the repo
39
+ def self.repo_checks(_dryrun, _repo)
40
+ end
41
+
42
+ def self.get(file)
43
+ path = File.expand_path(file)
44
+ logger.warn("Loading plugin at #{path}")
45
+ unless File.exists?(path)
46
+ logger.error('Plugin file not found')
47
+ exit(1)
48
+ end
49
+ class_eval(File.read(path), __FILE__, __LINE__)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,187 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ require 'fileutils'
4
+ require 'base64'
5
+ require 'open3'
6
+ require 'colorize'
7
+
8
+ require 'taste_tester/ssh'
9
+ require 'taste_tester/tunnel'
10
+
11
+ module TasteTester
12
+ # Manage state of the remote node
13
+ class Host
14
+ include TasteTester::Logging
15
+
16
+ attr_reader :name
17
+
18
+ def initialize(name, server)
19
+ @name = name
20
+ @user = ENV['USER']
21
+ @server = server
22
+ @tunnel = TasteTester::Tunnel.new(@name, @server)
23
+ end
24
+
25
+ def runchef
26
+ logger.warn("Running '#{TasteTester::Config.command}' on #{@name}")
27
+ cmd = "ssh #{TasteTester::Config.user}@#{@name} "
28
+ if TasteTester::Config.user != 'root'
29
+ cc = Base64.encode64(cmds).gsub(/\n/, '')
30
+ cmd += "\"echo '#{cc}' | base64 --decode | sudo bash -x\""
31
+ else
32
+ cmd += "\"#{cmds}\""
33
+ end
34
+ status = IO.popen(
35
+ cmd
36
+ ) do |io|
37
+ # rubocop:disable AssignmentInCondition
38
+ while line = io.gets
39
+ puts line.chomp!
40
+ end
41
+ # rubocop:enable AssignmentInCondition
42
+ io.close
43
+ $CHILD_STATUS.to_i
44
+ end
45
+ logger.warn("Finished #{TasteTester::Config.command}" +
46
+ " on #{@name} with status #{status}")
47
+ if status == 0
48
+ msg = "#{TasteTester::Config.command} was successful" +
49
+ ' - please log to the host and confirm all the intended' +
50
+ ' changes were made'
51
+ logger.error msg.upcase
52
+ end
53
+ end
54
+
55
+ def test
56
+ logger.warn("Taste-testing on #{@name}")
57
+
58
+ # Nuke any existing tunnels that may be there
59
+ TasteTester::Tunnel.kill(@name)
60
+
61
+ # Then setup the tunnel
62
+ @tunnel.run
63
+ @serialized_config = Base64.encode64(config).gsub(/\n/, '')
64
+
65
+ # Then setup the testing
66
+ ssh = TasteTester::SSH.new(@name)
67
+ ssh << 'logger -t taste-tester Moving server into taste-tester' +
68
+ " for #{@user}"
69
+ ssh << "touch -t #{TasteTester::Config.testing_end_time}" +
70
+ " #{TasteTester::Config.timestamp_file}"
71
+ ssh << "echo -n '#{@serialized_config}' | base64 --decode" +
72
+ ' > /etc/chef/client-taste-tester.rb'
73
+ ssh << 'rm -vf /etc/chef/client.rb'
74
+ ssh << '( ln -vs /etc/chef/client-taste-tester.rb' +
75
+ ' /etc/chef/client.rb; true )'
76
+ ssh.run!
77
+
78
+ # Then run any other stuff they wanted
79
+ cmds = TasteTester::Hooks.test_remote_cmds(
80
+ TasteTester::Config.dryrun,
81
+ @name
82
+ )
83
+
84
+ if cmds && cmds.any?
85
+ ssh = TasteTester::SSH.new(@name)
86
+ cmds.each { |c| ssh << c }
87
+ ssh.run!
88
+ end
89
+ end
90
+
91
+ def untest
92
+ logger.warn("Removing #{@name} from taste-tester")
93
+ ssh = TasteTester::SSH.new(@name)
94
+ TasteTester::Tunnel.kill(@name)
95
+ ssh << 'rm -vf /etc/chef/client.rb'
96
+ ssh << 'rm -vf /etc/chef/client-taste-tester.rb'
97
+ ssh << 'ln -vs /etc/chef/client-prod.rb /etc/chef/client.rb'
98
+ ssh << 'rm -vf /etc/chef/client.pem'
99
+ ssh << 'ln -vs /etc/chef/client-prod.pem /etc/chef/client.pem'
100
+ ssh << "rm -vf #{TasteTester::Config.timestamp_file}"
101
+ ssh << 'logger -t taste-tester Returning server to production'
102
+ ssh.run!
103
+ end
104
+
105
+ def who_is_testing
106
+ ssh = TasteTester::SSH.new(@name)
107
+ ssh << 'grep "^# TasteTester by" /etc/chef/client.rb'
108
+ output = ssh.run
109
+ if output.first == 0
110
+ user = output.last.match(/# TasteTester by (.*)$/)
111
+ if user
112
+ return user[1]
113
+ end
114
+ end
115
+
116
+ # Legacy FB stuff, remove after migration. Safe for everyone else.
117
+ ssh = TasteTester::SSH.new(@name)
118
+ ssh << 'file /etc/chef/client.rb'
119
+ output = ssh.run
120
+ if output.first == 0
121
+ user = output.last.match(/client-(.*)-(taste-tester|test).rb/)
122
+ if user
123
+ return user[1]
124
+ end
125
+ end
126
+
127
+ return nil
128
+ end
129
+
130
+ def in_test?
131
+ ssh = TasteTester::SSH.new(@name)
132
+ ssh << "test -f #{TasteTester::Config.timestamp_file}"
133
+ if ssh.run.first == 0 && who_is_testing && who_is_testing != ENV['USER']
134
+ true
135
+ else
136
+ false
137
+ end
138
+ end
139
+
140
+ def keeptesting
141
+ logger.warn("Renewing taste-tester on #{@name} until" +
142
+ " #{TasteTester::Config.testing_end_time}")
143
+ TasteTester::Tunnel.kill(@name)
144
+ @tunnel = TasteTester::Tunnel.new(@name, @server)
145
+ @tunnel.run
146
+ end
147
+
148
+ private
149
+
150
+ def config
151
+ if TasteTester::Config.use_ssh_tunnels
152
+ url = "http://localhost:#{@tunnel.port}"
153
+ else
154
+ url = "http://#{@server.host}:#{TasteTester::State.port}"
155
+ end
156
+ ttconfig = <<-eos
157
+ # TasteTester by #{@user}
158
+ # Prevent people from screwing up their permissions
159
+ if Process.euid != 0
160
+ puts 'Please run chef as root!'
161
+ Process.exit!
162
+ end
163
+
164
+ log_level :info
165
+ log_location STDOUT
166
+ chef_server_url '#{url}'
167
+ Ohai::Config[:plugin_path] << '/etc/chef/ohai_plugins'
168
+
169
+ eos
170
+
171
+ extra = TasteTester::Hooks.test_remote_client_rb_extra_code(@name)
172
+ if extra
173
+ ttconfig += <<-eos
174
+ # Begin user-hook specified code
175
+ #{extra}
176
+ # End user-hook secified code
177
+
178
+ eos
179
+ end
180
+
181
+ ttconfig += <<-eos
182
+ puts 'INFO: Running on #{@name} in taste-tester by #{@user}'
183
+ eos
184
+ return ttconfig
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,55 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+ # rubocop:disable ClassVars, UnusedMethodArgument, UnusedBlockArgument
3
+ require 'logger'
4
+
5
+ module TasteTester
6
+ # Logging wrapper
7
+ module Logging
8
+ @@use_log_formatter = false
9
+ @@level = Logger::WARN
10
+ @@formatter_proc = nil
11
+
12
+ def logger
13
+ logger = Logging.logger
14
+ logger.formatter = formatter
15
+ logger.level = @@level
16
+ logger
17
+ end
18
+
19
+ def self.logger
20
+ @logger ||= Logger.new(STDOUT)
21
+ end
22
+
23
+ def self.formatterproc=(p)
24
+ @@formatter_proc = p
25
+ end
26
+
27
+ def self.use_log_formatter=(use_log_formatter)
28
+ @@use_log_formatter = use_log_formatter
29
+ end
30
+
31
+ def self.verbosity=(level)
32
+ @@level = level
33
+ end
34
+
35
+ def formatter
36
+ return @@formatter_proc if @@formatter_proc
37
+ if @@use_log_formatter
38
+ proc do |severity, datetime, progname, msg|
39
+ if severity == 'ERROR'
40
+ msg = msg.red
41
+ end
42
+ "[#{datetime.strftime('%Y-%m-%dT%H:%M:%S%:z')}] #{severity}: #{msg}\n"
43
+ end
44
+ else
45
+ proc do |severity, datetime, progname, msg|
46
+ msg.to_s.prepend("#{severity}: ") unless severity == 'WARN'
47
+ if severity == 'ERROR'
48
+ msg = msg.to_s.red
49
+ end
50
+ "#{msg}\n"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,122 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ require 'fileutils'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+ require 'between_meals/util'
8
+ require 'taste_tester/config'
9
+ require 'taste_tester/state'
10
+
11
+ module TasteTester
12
+ # Stateless chef-zero server management
13
+ class Server
14
+ include TasteTester::Config
15
+ include TasteTester::Logging
16
+ extend ::BetweenMeals::Util
17
+
18
+ attr_accessor :user, :host
19
+
20
+ def initialize
21
+ @state = TasteTester::State.new
22
+ @ref_file = TasteTester::Config.ref_file
23
+ ref_dir = File.dirname(File.expand_path(@ref_file))
24
+ @zero_path = TasteTester::Config.chef_zero_path
25
+ unless File.directory?(ref_dir)
26
+ begin
27
+ FileUtils.mkpath(ref_dir)
28
+ rescue => e
29
+ logger.warn("Chef temp dir #{ref_dir} missing and can't be created")
30
+ logger.warn(e)
31
+ end
32
+ end
33
+
34
+ @user = ENV['USER']
35
+
36
+ # If we are using SSH tunneling listen on localhost, otherwise listen
37
+ # on all addresses - both v4 and v6. Note that on localhost, ::1 is
38
+ # v6-only, so we default to 127.0.0.1 instead.
39
+ @addr = TasteTester::Config.use_ssh_tunnels ? '127.0.0.1' : '::'
40
+ @host = 'localhost'
41
+ end
42
+
43
+ def start
44
+ return if TasteTester::Server.running?
45
+ @state.wipe
46
+ logger.warn('Starting taste-tester server')
47
+ write_config
48
+ start_chef_zero
49
+ end
50
+
51
+ def stop
52
+ logger.warn('Stopping taste-tester server')
53
+ stop_chef_zero
54
+ end
55
+
56
+ def restart
57
+ logger.warn('Restarting taste-tester server')
58
+ if TasteTester::Server.running?
59
+ stop_chef_zero
60
+ end
61
+ write_config
62
+ start_chef_zero
63
+ end
64
+
65
+ def port
66
+ @state.port
67
+ end
68
+
69
+ def port=(port)
70
+ @state.port = port
71
+ end
72
+
73
+ def latest_uploaded_ref
74
+ @state.ref
75
+ end
76
+
77
+ def latest_uploaded_ref=(ref)
78
+ @state.ref = ref
79
+ end
80
+
81
+ def self.running?
82
+ if TasteTester::State.port
83
+ return port_open?(TasteTester::State.port)
84
+ end
85
+ false
86
+ end
87
+
88
+ private
89
+
90
+ def write_config
91
+ knife = BetweenMeals::Knife.new(
92
+ :logger => logger,
93
+ :user => @user,
94
+ :host => @host,
95
+ :port => port,
96
+ :role_dir => TasteTester::Config.roles,
97
+ :cookbook_dirs => TasteTester::Config.cookbooks,
98
+ :checksum_dir => TasteTester::Config.checksum_dir,
99
+ )
100
+ knife.write_user_config
101
+ end
102
+
103
+ def start_chef_zero
104
+ @state.wipe
105
+ @state.port = TasteTester::Config.chef_port
106
+ logger.info("Starting chef-zero of port #{@state.port}")
107
+ Mixlib::ShellOut.new(
108
+ "/opt/chef/embedded/bin/chef-zero --host #{@addr}" +
109
+ " --port #{@state.port} -d"
110
+ ).run_command.error!
111
+ end
112
+
113
+ def stop_chef_zero
114
+ @state.wipe
115
+ logger.info('Killing your chef-zero instances')
116
+ s = Mixlib::ShellOut.new("pkill -9 -u #{ENV['USER']} -f bin/chef-zero")
117
+ s.run_command
118
+ # You have to give it a moment to stop or the stat fails
119
+ sleep(1)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,60 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ module TasteTester
4
+ # Thin ssh wrapper
5
+ class SSH
6
+ include TasteTester::Logging
7
+ include BetweenMeals::Util
8
+
9
+ attr_reader :output
10
+
11
+ def initialize(host, timeout = 5, tunnel = false)
12
+ @host = host
13
+ @timeout = timeout
14
+ @tunnel = tunnel
15
+ @cmds = []
16
+ end
17
+
18
+ def add(string)
19
+ @cmds << string
20
+ end
21
+
22
+ alias_method :<<, :add
23
+
24
+ def run
25
+ @status, @output = exec(cmd, logger)
26
+ end
27
+
28
+ def run!
29
+ @status, @output = exec!(cmd, logger)
30
+ rescue => e
31
+ # rubocop:disable LineLength
32
+ error = <<-MSG
33
+ SSH returned error while connecting to #{TasteTester::Config.user}@#{@host}
34
+ The host might be broken or your SSH access is not working properly
35
+ Try doing 'ssh -v #{TasteTester::Config.user}@#{@host}' and come back once that works
36
+ MSG
37
+ # rubocop:enable LineLength
38
+ error.lines.each { |x| logger.error x.strip }
39
+ logger.error(e.message)
40
+ end
41
+
42
+ private
43
+
44
+ def cmd
45
+ @cmds.each do |cmd|
46
+ logger.info("Will run: '#{cmd}' on #{@host}")
47
+ end
48
+ cmds = @cmds.join(' && ')
49
+ cmd = "ssh -T -o BatchMode=yes -o ConnectTimeout=#{@timeout} "
50
+ cmd += "#{TasteTester::Config.user}@#{@host} "
51
+ if TasteTester::Config.user != 'root'
52
+ cc = Base64.encode64(cmds).gsub(/\n/, '')
53
+ cmd += "\"echo '#{cc}' | base64 --decode | sudo bash -x\""
54
+ else
55
+ cmd += "\'#{cmds}\'"
56
+ end
57
+ cmd
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ require 'fileutils'
4
+ require 'socket'
5
+ require 'timeout'
6
+
7
+ require 'between_meals/util'
8
+ require 'taste_tester/config'
9
+
10
+ module TasteTester
11
+ # State of taste-tester processes
12
+ class State
13
+ include TasteTester::Config
14
+ include TasteTester::Logging
15
+ include ::BetweenMeals::Util
16
+
17
+ def initialize
18
+ ref_dir = File.dirname(File.expand_path(
19
+ TasteTester::Config.ref_file
20
+ ))
21
+ unless File.directory?(ref_dir)
22
+ begin
23
+ FileUtils.mkpath(ref_dir)
24
+ rescue => e
25
+ logger.error("Chef temp dir #{ref_dir} missing and can't be created")
26
+ logger.error(e)
27
+ exit(1)
28
+ end
29
+ end
30
+ end
31
+
32
+ def port
33
+ TasteTester::State.read(:port)
34
+ end
35
+
36
+ def port=(port)
37
+ write(:port, port)
38
+ end
39
+
40
+ def ref
41
+ TasteTester::State.read(:ref)
42
+ end
43
+
44
+ def ref=(ref)
45
+ write(:ref, ref)
46
+ end
47
+
48
+ def self.port
49
+ TasteTester::State.read(:port)
50
+ end
51
+
52
+ def wipe
53
+ if TasteTester::Config.ref_file &&
54
+ File.exists?(TasteTester::Config.ref_file)
55
+ File.delete(TasteTester::Config.ref_file)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def write(key, value)
62
+ begin
63
+ state = JSON.parse(File.read(TasteTester::Config.ref_file))
64
+ rescue
65
+ state = {}
66
+ end
67
+ state[key.to_s] = value
68
+ ff = File.open(
69
+ TasteTester::Config.ref_file,
70
+ 'w'
71
+ )
72
+ ff.write(state.to_json)
73
+ ff.close
74
+ rescue => e
75
+ logger.error('Unable to write the reffile')
76
+ logger.debug(e)
77
+ exit 0
78
+ end
79
+
80
+ def self.read(key)
81
+ JSON.parse(File.read(TasteTester::Config.ref_file))[key.to_s]
82
+ rescue => e
83
+ logger.debug(e)
84
+ nil
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,53 @@
1
+ # vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2
+
3
+ module TasteTester
4
+ # Thin ssh tunnel wrapper
5
+ class Tunnel
6
+ include TasteTester::Logging
7
+ include BetweenMeals::Util
8
+
9
+ attr_reader :port
10
+
11
+ def initialize(host, server, timeout = 5)
12
+ @host = host
13
+ @server = server
14
+ @timeout = timeout
15
+ if TasteTester::Config.testing_until
16
+ @delta_secs = TasteTester::Config.testing_until.strftime('%s').to_i -
17
+ Time.now.strftime('%s').to_i
18
+ else
19
+ @delta_secs = TasteTester::Config.testing_time
20
+ end
21
+ end
22
+
23
+ def run
24
+ @port = TasteTester::Config.tunnel_port
25
+ logger.info("Setting up tunnel on port #{@port}")
26
+ @status, @output = exec!(cmd, logger)
27
+ rescue
28
+ logger.error 'Failed bringing up ssh tunnel'
29
+ exit(1)
30
+ end
31
+
32
+ def cmd
33
+ cmds = "echo \\\$\\\$ > #{TasteTester::Config.timestamp_file} &&" +
34
+ " touch -t #{TasteTester::Config.testing_end_time}" +
35
+ " #{TasteTester::Config.timestamp_file} && sleep #{@delta_secs}"
36
+ cmd = "ssh -T -o BatchMode=yes -o ConnectTimeout=#{@timeout} " +
37
+ "-o ExitOnForwardFailure=yes -f -R #{@port}:localhost:" +
38
+ "#{@server.port} root@#{@host} \"#{cmds}\""
39
+ cmd
40
+ end
41
+
42
+ def self.kill(name)
43
+ ssh = TasteTester::SSH.new(name)
44
+ # Since commands are &&'d together, and we're using &&, we need to
45
+ # surround this in paryns, and make sure as a whole it evaluates
46
+ # to true so it doesn't mess up other things... even though this is
47
+ # the only thing we're currently executing in this SSH.
48
+ ssh << "( [ -s #{TasteTester::Config.timestamp_file} ]" +
49
+ " && kill -- -\$(cat #{TasteTester::Config.timestamp_file}); true )"
50
+ ssh.run!
51
+ end
52
+ end
53
+ end