navo 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -78,11 +78,20 @@ module Navo
78
78
  # Access the configuration as if it were a hash.
79
79
  #
80
80
  # @param key [String, Symbol]
81
- # @return [Array, Hash, Number, String]
81
+ # @return [Array, Hash, Number, String, Symbol]
82
82
  def [](key)
83
83
  @options[key.to_s]
84
84
  end
85
85
 
86
+ # Set the configuration as if it were a hash.
87
+ #
88
+ # @param key [String, Symbol]
89
+ # @param value [Array, Hash, Number, String, Symbol]
90
+ # @return [Array, Hash, Number, String]
91
+ def []=(key, value)
92
+ @options[key.to_s] = value
93
+ end
94
+
86
95
  # Access the configuration as if it were a hash.
87
96
  #
88
97
  # @param key [String, Symbol]
@@ -0,0 +1,121 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'logger'
4
+
5
+ module Navo
6
+ # Manages the display of output and writing of log files.
7
+ #
8
+ # The goal is to make the tool easier to read when running from the command
9
+ # line, but preserving all useful information in log files as well so in-depth
10
+ # debugging can be performed.
11
+ #
12
+ # Each test suite creates its own {Output} instance to write to so individual
13
+ # log lines go to their own files, but they all share a common destination
14
+ # that is synchronized when writing to stdout/stderr for the application as a
15
+ # whole.
16
+ class Logger
17
+ UI_COLORS = {
18
+ unknown: 35, # purple
19
+ fatal: 31, # red
20
+ error: 31, # red
21
+ warn: 33, # yellow
22
+ info: nil, # normal
23
+ debug: 90, # gray
24
+ }
25
+
26
+ class << self
27
+ attr_reader :logger
28
+
29
+ attr_reader :level
30
+
31
+ attr_reader :mutex
32
+
33
+ def output=(out)
34
+ @logger = ::Logger.new(out)
35
+ @mutex = Mutex.new
36
+ end
37
+
38
+ def level=(level)
39
+ level = level ? ::Logger.const_get(level.upcase) : ::Logger::INFO
40
+ @level = level
41
+ @logger.level = level
42
+ end
43
+ end
44
+
45
+ def initialize(suite: nil)
46
+ @suite = suite
47
+
48
+ if suite
49
+ log_file = File.open(suite.log_file, File::CREAT | File::WRONLY | File::APPEND)
50
+ @logger = ::Logger.new(log_file)
51
+ @logger.level = self.class.level
52
+ end
53
+
54
+ @color_hash = {}
55
+ end
56
+
57
+ def console(message, severity: :info, flush: true)
58
+ # In order to improve the output displayed, we don't want to print a line
59
+ # for every chunk of log received--only logs which have newlines.
60
+ # Thus we buffer the output and flush it once we have the full amount.
61
+ @buffer_severity ||= severity
62
+ @buffer_level ||= ::Logger.const_get(severity.upcase)
63
+ @buffer ||= ''
64
+ @buffer += message
65
+
66
+ flush_buffer if flush
67
+ end
68
+
69
+ def log(severity, message, flush: true)
70
+ level = ::Logger.const_get(severity.upcase)
71
+ @logger.add(level, message) if @logger
72
+ console(message, severity: severity, flush: flush)
73
+ end
74
+
75
+ %i[unknown fatal error warn info debug].each do |severity|
76
+ define_method severity do |msg|
77
+ log(severity, msg, flush: true)
78
+ end
79
+ end
80
+
81
+ def flush_buffer
82
+ if @buffer
83
+ # This is shared amongst potentially many threads, so serialize access
84
+ if @buffer_level >= self.class.level
85
+ self.class.mutex.synchronize do
86
+ self.class.logger << pretty_message(@buffer_severity, @buffer)
87
+ end
88
+ end
89
+
90
+ @buffer = nil
91
+ @buffer_severity = nil
92
+ @buffer_level = nil
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def pretty_message(severity, message)
99
+ color_code = UI_COLORS[severity]
100
+
101
+ prefix = "[#{@suite.name}] " if @suite
102
+ colored_prefix = "\e[#{color_for_string(@suite.name)}m#{prefix}\e[0m" if prefix
103
+ message = message.to_s
104
+ message = "\e[#{color_code}m#{message}\e[0m" if color_code
105
+
106
+ message = indent_output(prefix, colored_prefix, "#{colored_prefix}#{message}")
107
+ message += "\n" unless message.end_with?("\n")
108
+ message
109
+ end
110
+
111
+ # Returns a deterministic color code for the given string.
112
+ def color_for_string(string)
113
+ @color_hash[string] ||= (Digest::MD5.hexdigest(string)[0..8].to_i(16) % 6) + 31
114
+ end
115
+
116
+ def indent_output(prefix, colored_prefix, message)
117
+ return message unless prefix
118
+ message.gsub(/\n(?!$)/, "\n#{colored_prefix}")
119
+ end
120
+ end
121
+ end
@@ -1,4 +1,3 @@
1
- require 'berkshelf'
2
1
  require 'fileutils'
3
2
  require 'pathname'
4
3
 
@@ -9,13 +8,13 @@ module Navo
9
8
  # needed to run a test within the suite's container. A temporary directory on
10
9
  # the host is maintained
11
10
  class Sandbox
12
- def initialize(suite:)
11
+ def initialize(suite:, logger:)
13
12
  @suite = suite
13
+ @logger = logger
14
14
  end
15
15
 
16
16
  def update_chef_config
17
17
  install_cookbooks
18
- install_chef_directories
19
18
  install_chef_config
20
19
  end
21
20
 
@@ -23,6 +22,11 @@ module Navo
23
22
  test_files_dir = File.join(@suite.repo_root, %w[test integration])
24
23
  suite_dir = File.join(test_files_dir, @suite.name)
25
24
 
25
+ unless File.exist?(suite_dir)
26
+ @logger.warn "No test files found at #{suite_dir} for #{@suite.name} suite"
27
+ return
28
+ end
29
+
26
30
  # serverspec, bats, etc.
27
31
  frameworks = Pathname.new(suite_dir).children
28
32
  .select(&:directory?)
@@ -44,12 +48,12 @@ module Navo
44
48
  # between host and container (test-kitchen does the same thing).
45
49
  helpers_directory = File.join(test_files_dir, 'helpers', framework)
46
50
  if File.directory?(helpers_directory)
47
- puts "Transferring #{framework} test suite helpers..."
51
+ @logger.info "Transferring #{framework} test suite helpers..."
48
52
  @suite.copy(from: File.join(helpers_directory, '.'),
49
53
  to: container_framework_dir)
50
54
  end
51
55
 
52
- puts "Transferring #{framework} tests..."
56
+ @logger.info "Transferring #{framework} tests..."
53
57
  @suite.copy(from: File.join(host_framework_dir, '.'),
54
58
  to: container_framework_dir)
55
59
  end
@@ -61,45 +65,53 @@ module Navo
61
65
  def install_cookbooks
62
66
  @suite.exec!(%w[mkdir -p] + [@suite.chef_config_dir, @suite.chef_run_dir])
63
67
 
64
- vendored_cookbooks_dir = File.join(storage_directory, 'cookbooks')
65
- berksfile = File.expand_path(@suite['chef']['berksfile'], @suite.repo_root)
68
+ host_cookbooks_dir = File.join(@suite.repo_root, 'cookbooks')
69
+ container_cookbooks_dir = File.join(@suite.chef_run_dir, 'cookbooks')
66
70
 
67
- puts 'Resolving Berksfile...'
68
- @suite.exec!(%w[rm -rf] + [vendored_cookbooks_dir])
69
- Berkshelf::Berksfile.from_file(berksfile).vendor(vendored_cookbooks_dir)
71
+ Navo.synchronize do
72
+ Berksfile.load
70
73
 
71
- @suite.copy(from: File.join(storage_directory, 'cookbooks', '.'),
72
- to: File.join(@suite.chef_run_dir, 'cookbooks'))
73
- end
74
+ # Check all files first so we calculate the hashes
75
+ berksfile_changed = @suite.path_changed?(Berksfile.path)
76
+ lockfile_changed = @suite.path_changed?(Berksfile.lockfile_path)
77
+ cookbooks_changed = @suite.path_changed?(host_cookbooks_dir)
74
78
 
75
- def install_chef_directories
76
- %w[data_bags environments roles].each do |dir|
77
- puts "Preparing #{dir} directory..."
78
- host_dir = File.join(@suite.repo_root, dir)
79
- container_dir = File.join(@suite.chef_run_dir, dir)
79
+ if (berksfile_changed || lockfile_changed || cookbooks_changed) ||
80
+ @suite.path_changed?(Berksfile.vendor_directory)
80
81
 
81
- @suite.exec(%w[rm -rf] + [container_dir])
82
- @suite.copy(from: host_dir, to: container_dir)
82
+ @logger.info 'Vendoring cookbooks...'
83
+ Berksfile.vendor(logger: @logger)
84
+ # Recalculate new hash
85
+ @suite.path_changed?(Berksfile.vendor_directory)
86
+ else
87
+ @logger.info 'No cookbooks changed; nothing new to install'
88
+ end
83
89
  end
84
90
  end
85
91
 
86
92
  def install_chef_config
87
93
  secret_file = File.expand_path(@suite['chef']['secret'], @suite.repo_root)
88
94
  secret_file_basename = File.basename(secret_file)
89
- @suite.copy(from: secret_file,
90
- to: File.join(@suite.chef_config_dir, secret_file_basename))
95
+ @logger.info "Preparing #{secret_file_basename}"
96
+ @suite.copy_if_changed(from: secret_file,
97
+ to: File.join(@suite.chef_config_dir, secret_file_basename))
91
98
 
92
- puts 'Preparing solo.rb'
99
+ @logger.info 'Preparing solo.rb'
93
100
  @suite.write(file: File.join(@suite.chef_config_dir, 'solo.rb'),
94
101
  content: @suite.chef_solo_config)
95
- puts 'Preparing first-boot.json'
102
+ @logger.info 'Preparing first-boot.json'
96
103
  @suite.write(file: File.join(@suite.chef_config_dir, 'first-boot.json'),
97
104
  content: @suite.node_attributes.to_json)
105
+
106
+ @logger.debug 'Installing custom formatter'
107
+ formatter_file = File.expand_path('chef_formatter.rb', File.dirname(__FILE__))
108
+ @suite.copy(from: formatter_file, to: @suite.chef_config_dir)
98
109
  end
99
110
 
100
111
  def storage_directory
101
112
  @storage_directory ||=
102
113
  @suite.storage_directory.tap do |path|
114
+ @logger.debug("Ensuring storage directory #{path} exists")
103
115
  FileUtils.mkdir_p(path)
104
116
  end
105
117
  end
@@ -0,0 +1,78 @@
1
+ require 'fileutils'
2
+ require 'monitor'
3
+ require 'yaml'
4
+
5
+ module Navo
6
+ # Stores persisted state.
7
+ #
8
+ # This allows information to carry forward between different invocations of
9
+ # the tool, e.g. remembering a previously-started Docker container.
10
+ class StateFile
11
+ def initialize(file:, logger:)
12
+ @file = file
13
+ @logger = logger
14
+ @mutex = Monitor.new
15
+ end
16
+
17
+ # Access the state as if it were a hash.
18
+ #
19
+ # @param key [String, Symbol]
20
+ # @return [Array, Hash, Number, String]
21
+ def [](key)
22
+ @mutex.synchronize do
23
+ @hash[key.to_s]
24
+ end
25
+ end
26
+
27
+ # Set the state as if it were a hash.
28
+ #
29
+ # @param key [String, Symbol]
30
+ # @param value [Array, Hash, Number, String]
31
+ def []=(key, value)
32
+ @mutex.synchronize do
33
+ @logger.debug "Updating state '#{key}' to #{value.inspect}"
34
+ @hash[key.to_s] = value
35
+ save unless @modifying
36
+ value
37
+ end
38
+ end
39
+
40
+ def modify(&block)
41
+ @mutex.synchronize do
42
+ @modifying = true
43
+ begin
44
+ result = block.call(self)
45
+ save
46
+ result
47
+ ensure
48
+ @modifying = false
49
+ end
50
+ end
51
+ end
52
+
53
+ # Loads persisted state.
54
+ def load
55
+ @hash =
56
+ if File.exist?(@file) && yaml = YAML.load_file(@file)
57
+ @logger.debug "Loading state from #{@file}"
58
+ yaml.to_hash
59
+ else
60
+ @logger.debug "No state file #{@file} exists; assuming empty state"
61
+ {} # Handle empty files
62
+ end
63
+ end
64
+
65
+ # Persists state to disk.
66
+ def save
67
+ @logger.debug "Saving state to #{@file}"
68
+ File.open(@file, 'w') { |f| f.write(@hash.to_yaml) }
69
+ end
70
+
71
+ # Destroy persisted state.
72
+ def destroy
73
+ @logger.debug "Removing state from #{@file}"
74
+ @hash = {}
75
+ FileUtils.rm_f(@file)
76
+ end
77
+ end
78
+ end
@@ -8,9 +8,15 @@ module Navo
8
8
  class Suite
9
9
  attr_reader :name
10
10
 
11
- def initialize(name:, config:)
11
+ def initialize(name:, config:, global_state:)
12
12
  @name = name
13
13
  @config = config
14
+ @logger = Navo::Logger.new(suite: self)
15
+ @global_state = global_state
16
+
17
+ state.modify do |local|
18
+ local['files'] ||= {}
19
+ end
14
20
  end
15
21
 
16
22
  def repo_root
@@ -35,35 +41,89 @@ module Navo
35
41
 
36
42
  # Copy file/directory from host to container.
37
43
  def copy(from:, to:)
44
+ @logger.debug("Copying file #{from} on host to file #{to} in container")
38
45
  system("docker cp #{from} #{container.id}:#{to}")
39
46
  end
40
47
 
48
+ def copy_if_changed(from:, to:, replace: false)
49
+ if File.directory?(from)
50
+ exec(%w[mkdir -p] + [to])
51
+ else
52
+ exec(%w[mkdir -p] + [File.dirname(to)])
53
+ end
54
+
55
+ current_hash = Utils.path_hash(from)
56
+ state['files'] ||= {}
57
+ old_hash = state['files'][from.to_s]
58
+
59
+ if !old_hash || current_hash != old_hash
60
+ if old_hash
61
+ @logger.debug "Previous hash recorded for #{from} (#{old_hash}) " \
62
+ "does not match current hash (#{current_hash})"
63
+ else
64
+ @logger.debug "No previous hash recorded for #{from}"
65
+ end
66
+
67
+ state.modify do |local|
68
+ local['files'][from.to_s] = current_hash
69
+ end
70
+
71
+ exec(%w[rm -rf] + [to]) if replace
72
+ copy(from: from, to: to)
73
+ return true
74
+ end
75
+
76
+ false
77
+ end
78
+
79
+ # TODO: Move to a separate class, since this isn't really suite-specific,
80
+ # but global to the entire repository.
81
+ def path_changed?(path)
82
+ current_hash = Utils.path_hash(path)
83
+ @global_state['files'] ||= {}
84
+ old_hash = @global_state['files'][path.to_s]
85
+
86
+ @logger.debug("Old hash of #{path.to_s}: #{old_hash}")
87
+ @logger.debug("Current hash of #{path.to_s}: #{current_hash}")
88
+
89
+ @global_state.modify do |local|
90
+ local['files'][path.to_s] = current_hash
91
+ end
92
+
93
+ !old_hash || current_hash != old_hash
94
+ end
95
+
41
96
  # Write contents to a file on the container.
42
97
  def write(file:, content:)
98
+ @logger.debug("Writing content #{content.inspect} to file #{file} in container")
43
99
  container.exec(%w[bash -c] + ["cat > #{file}"], stdin: StringIO.new(content))
44
100
  end
45
101
 
46
102
  # Execte a command on the container.
47
- def exec(args)
103
+ def exec(args, severity: :debug)
48
104
  container.exec(args) do |_stream, chunk|
49
- STDOUT.print chunk
105
+ @logger.log(severity, chunk, flush: chunk.to_s.end_with?("\n"))
50
106
  end
51
107
  end
52
108
 
53
- # Execute a command on the container, raising an error if it exists
109
+ # Execute a command on the container, raising an error if it exits
54
110
  # unsuccessfully.
55
- def exec!(args)
56
- out, err, status = exec(args)
111
+ def exec!(args, severity: :debug)
112
+ out, err, status = exec(args, severity: severity)
57
113
  raise Error::ExecutionError, "STDOUT:#{out}\nSTDERR:#{err}" unless status == 0
58
114
  [out, err, status]
59
115
  end
60
116
 
61
117
  def login
62
- Kernel.exec('docker', 'exec', '-it', container.id, *@config['docker']['shell-command'])
118
+ Kernel.exec('docker', 'exec', '-it', container.id,
119
+ *@config['docker'].fetch('shell_command', ['/bin/bash']))
63
120
  end
64
121
 
65
122
  def chef_solo_config
66
123
  return <<-CONF
124
+ load '/etc/chef/chef_formatter.rb'
125
+ formatter :navo
126
+
67
127
  node_name #{name.inspect}
68
128
  environment #{@config['chef']['environment'].inspect}
69
129
  file_cache_path #{File.join(chef_run_dir, 'cache').inspect}
@@ -78,59 +138,100 @@ module Navo
78
138
 
79
139
  def node_attributes
80
140
  suite_config = @config['suites'][name]
141
+
142
+ unless (run_list = Array(suite_config['run_list'])).any?
143
+ raise Navo::Errors::ConfigurationError,
144
+ "No `run_list` specified for suite #{name}!"
145
+ end
146
+
81
147
  @config['chef']['attributes']
82
148
  .merge(suite_config.fetch('attributes', {}))
83
- .merge(run_list: suite_config['run-list'])
149
+ .merge(run_list: suite_config['run_list'])
84
150
  end
85
151
 
86
152
  def create
153
+ @logger.info "=====> Creating #{name}"
154
+ container
155
+ @logger.info "=====> Created #{name} in container #{container.id}"
87
156
  container
88
157
  end
89
158
 
90
159
  def converge
91
160
  create
92
161
 
162
+ @logger.info "=====> Converging #{name}"
93
163
  sandbox.update_chef_config
94
164
 
95
165
  _, _, status = exec(%W[
96
166
  /opt/chef/embedded/bin/chef-solo
97
167
  --config=#{File.join(chef_config_dir, 'solo.rb')}
98
168
  --json-attributes=#{File.join(chef_config_dir, 'first-boot.json')}
169
+ --format=navo
99
170
  --force-formatter
100
- ])
171
+ ], severity: :info)
101
172
 
102
- state['converged'] = status == 0
103
- state.save
104
- state['converged']
173
+ status == 0
105
174
  end
106
175
 
107
176
  def verify
108
177
  create
109
178
 
179
+ @logger.info "=====> Verifying #{name}"
110
180
  sandbox.update_test_config
111
181
 
112
- _, _, status = exec(['/usr/bin/env'] + busser_env + %W[#{busser_bin} test])
182
+ _, _, status = exec(['/usr/bin/env'] + busser_env + %W[#{busser_bin} test],
183
+ severity: :info)
113
184
  status == 0
114
185
  end
115
186
 
116
187
  def test
117
- return false unless converge
118
- verify
188
+ return false unless destroy
189
+ passed = converge && verify
190
+
191
+ should_destroy =
192
+ case @config['destroy']
193
+ when 'passing'
194
+ passed
195
+ when 'always'
196
+ true
197
+ when 'never'
198
+ false
199
+ end
200
+
201
+ should_destroy ? destroy : passed
119
202
  end
120
203
 
121
204
  def destroy
122
- if @config['docker']['stop-command']
123
- exec(@config['docker']['stop-command'])
124
- container.wait(@config['docker'].fetch('stop-timeout', 10))
125
- else
126
- container.stop
205
+ @logger.info "=====> Destroying #{name}"
206
+
207
+ if state['container']
208
+ begin
209
+ if @config['docker']['stop_command']
210
+ @logger.info "Stopping container #{container.id} via command #{@config['docker']['stop_command']}"
211
+ exec(@config['docker']['stop_command'])
212
+ container.wait(@config['docker'].fetch('stop_timeout', 10))
213
+ else
214
+ @logger.info "Stopping container #{container.id}..."
215
+ container.stop
216
+ end
217
+ rescue Docker::Error::TimeoutError => ex
218
+ @logger.warn ex.message
219
+ ensure
220
+ begin
221
+ @logger.info("Removing container #{container.id}")
222
+ container.remove(force: true)
223
+ rescue Docker::Error::ServerError => ex
224
+ @logger.warn ex.message
225
+ end
226
+ end
127
227
  end
128
228
 
129
- container.remove(force: true)
229
+ true
230
+ ensure
231
+ @container = nil
232
+ state.destroy
130
233
 
131
- state['converged'] = false
132
- state['container'] = nil
133
- state.save
234
+ @logger.info "=====> Destroyed #{name}"
134
235
  end
135
236
 
136
237
  # Returns the {Docker::Image} used by this test suite, building it if
@@ -140,25 +241,35 @@ module Navo
140
241
  def image
141
242
  @image ||=
142
243
  begin
143
- state['images'] ||= {}
244
+ @global_state.modify do |global|
245
+ global['images'] ||= {}
246
+ end
144
247
 
145
248
  # Build directory is wherever the Dockerfile is located
146
249
  dockerfile = File.expand_path(@config['docker']['dockerfile'], repo_root)
147
250
  build_dir = File.dirname(dockerfile)
148
251
 
149
252
  dockerfile_hash = Digest::SHA256.new.hexdigest(File.read(dockerfile))
150
- image_id = state['images'][dockerfile_hash]
253
+ @logger.debug "Dockerfile hash is #{dockerfile_hash}"
254
+ image_id = @global_state['images'][dockerfile_hash]
151
255
 
152
256
  if image_id && Docker::Image.exist?(image_id)
257
+ @logger.debug "Previous image #{image_id} matching Dockerfile already exists"
258
+ @logger.debug "Using image #{image_id} instead of building new image"
153
259
  Docker::Image.get(image_id)
154
260
  else
261
+ @logger.debug "No image exists for #{dockerfile}"
262
+ @logger.debug "Building a new image with #{dockerfile} " \
263
+ "using #{build_dir} as build context directory"
264
+
155
265
  Docker::Image.build_from_dir(build_dir) do |chunk|
156
266
  if (log = JSON.parse(chunk)) && log.has_key?('stream')
157
- STDOUT.print log['stream']
267
+ @logger.info log['stream']
158
268
  end
159
269
  end.tap do |image|
160
- state['images'][dockerfile_hash] = image.id
161
- state.save
270
+ @global_state.modify do |global|
271
+ global['images'][dockerfile_hash] = image.id
272
+ end
162
273
  end
163
274
  end
164
275
  end
@@ -171,39 +282,68 @@ module Navo
171
282
  def container
172
283
  @container ||=
173
284
  begin
285
+ # Dummy reference so we build the image first (ensuring its log output
286
+ # appears before the container creation log output)
287
+ image
288
+
174
289
  if state['container']
175
290
  begin
176
291
  container = Docker::Container.get(state['container'])
292
+ @logger.debug "Loaded existing container #{container.id}"
177
293
  rescue Docker::Error::NotFoundError
178
- # Continue creating the container since it doesn't exist
294
+ @logger.debug "Container #{state['container']} no longer exists"
179
295
  end
180
296
  end
181
297
 
182
298
  if !container
299
+ @logger.info "Building a new container from image #{image.id}"
300
+
183
301
  container = Docker::Container.create(
184
302
  'Image' => image.id,
185
303
  'OpenStdin' => true,
186
304
  'StdinOnce' => true,
187
305
  'HostConfig' => {
188
306
  'Privileged' => @config['docker']['privileged'],
189
- 'Binds' => @config['docker']['volumes'],
307
+ 'Binds' => @config['docker']['volumes'] + %W[
308
+ #{Berksfile.vendor_directory}:#{File.join(chef_run_dir, 'cookbooks')}
309
+ #{File.join(repo_root, 'data_bags')}:#{File.join(chef_run_dir, 'data_bags')}
310
+ #{File.join(repo_root, 'environments')}:#{File.join(chef_run_dir, 'environments')}
311
+ #{File.join(repo_root, 'roles')}:#{File.join(chef_run_dir, 'roles')}
312
+ ],
190
313
  },
191
314
  )
192
315
 
193
316
  state['container'] = container.id
194
- state.save
195
317
  end
196
318
 
197
- container.start
319
+ unless started?(container.id)
320
+ @logger.info "Starting container #{container.id}"
321
+ container.start
322
+ else
323
+ @logger.debug "Container #{container.id} already running"
324
+ end
325
+
326
+ container
198
327
  end
199
328
  end
200
329
 
330
+ def started?(container_id)
331
+ # There does not appear to be a simple "status" API we can use for an
332
+ # individual container
333
+ Docker::Container.all(all: true,
334
+ filters: { id: [container_id],
335
+ status: ['running'] }.to_json).any?
336
+ end
337
+
201
338
  def sandbox
202
- @sandbox ||= Sandbox.new(suite: self)
339
+ @sandbox ||= Sandbox.new(suite: self, logger: @logger)
203
340
  end
204
341
 
205
342
  def storage_directory
206
- File.join(repo_root, '.navo', 'suites', name)
343
+ @storage_directory ||=
344
+ File.join(repo_root, '.navo', 'suites', name).tap do |path|
345
+ FileUtils.mkdir_p(path)
346
+ end
207
347
  end
208
348
 
209
349
  def busser_directory
@@ -224,7 +364,12 @@ module Navo
224
364
  end
225
365
 
226
366
  def state
227
- @state ||= SuiteState.new(suite: self).tap(&:load)
367
+ @state ||= StateFile.new(file: File.join(storage_directory, 'state.yaml'),
368
+ logger: @logger).tap(&:load)
369
+ end
370
+
371
+ def log_file
372
+ @log_file ||= File.join(storage_directory, 'log.log')
228
373
  end
229
374
  end
230
375
  end