taste_tester 0.0.1 → 0.0.2

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.
@@ -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