kaiser 0.0.0 → 0.4.5

Sign up to get free protection for your applications and to get access to all the features.
data/lib/kaiser/cli.rb ADDED
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kaiser/command_runner'
4
+
5
+ module Kaiser
6
+ # The commandline
7
+ class Cli
8
+ extend Kaiser::CliOptions
9
+
10
+ def set_config
11
+ # This is here for backwards compatibility since it can be used in Kaiserfiles.
12
+ # It would be a good idea to deprecate this and make it more abstract.
13
+ @work_dir = Config.work_dir
14
+ @config_dir = Config.work_dir
15
+ @config_file = Config.config_file
16
+ @kaiserfile = Config.kaiserfile
17
+ @config = Config.config
18
+ @out = Config.out
19
+ @info_out = Config.info_out
20
+ end
21
+
22
+ # At first I did this in the constructor but the problem with that is Optimist
23
+ # will parse the entire commandline for the first Cli command registered.
24
+ # That means no matter what you call -h or --help on, it will always return the help
25
+ # for the first subcommand. Fixed this by only running define_options when
26
+ # a command is run. We can't just run the constructor at that point because
27
+ # we need each Cli class to be constructed in the beginning so we can add their
28
+ # usage text to the output of `kaiser -h`.
29
+ def define_options(global_opts = [])
30
+ # We can't just call usage within the options block because that actually shifts
31
+ # the scope to Optimist::Parser. We can still reference variables but we can't
32
+ # call instance methods of a Kaiser::Cli class.
33
+ u = usage
34
+ Optimist.options do
35
+ banner u
36
+
37
+ global_opts.each { |o| opt *o }
38
+ end
39
+ end
40
+
41
+ def self.register(name, klass)
42
+ @subcommands ||= {}
43
+ @subcommands[name] = klass.new
44
+ end
45
+
46
+ def self.run_command(name, global_opts)
47
+ cmd = @subcommands[name]
48
+ opts = cmd.define_options(global_opts + cmd.class.options)
49
+
50
+ # The define_options method has stripped all arguments from the cli so now
51
+ # all that we're left with in ARGV are the subcommand to be run and possibly
52
+ # its own subcommands. We remove the subcommand here so each subcommand can
53
+ # easily use ARGV.shift to access its own subcommands.
54
+ ARGV.shift
55
+
56
+ Kaiser::Config.load(Dir.pwd)
57
+
58
+ # We do all this work in here instead of the exe/kaiser file because we
59
+ # want -h options to output before we check if a Kaiserfile exists.
60
+ # If we do it in exe/kaiser, people won't be able to check help messages
61
+ # unless they create a Kaiserfile firest.
62
+ if opts[:quiet]
63
+ Config.out = File.open(File::NULL, 'w')
64
+ Config.info_out = File.open(File::NULL, 'w')
65
+ elsif opts[:verbose] || Config.always_verbose?
66
+ Config.out = $stderr
67
+ Config.info_out = Kaiser::AfterDotter.new(dotter: Kaiser::Dotter.new)
68
+ else
69
+ Config.out = Kaiser::Dotter.new
70
+ Config.info_out = Kaiser::AfterDotter.new(dotter: Config.out)
71
+ end
72
+
73
+ cmd.set_config
74
+
75
+ cmd.execute(opts)
76
+ end
77
+
78
+ def self.all_subcommands_usage
79
+ output = ''
80
+
81
+ @subcommands.each do |name, klass|
82
+ name_s = name.to_s
83
+
84
+ output += name_s + "\n"
85
+ output += name_s.gsub(/./, '-')
86
+ output += "\n"
87
+ output += klass.usage
88
+ output += "\n\n"
89
+ end
90
+
91
+ output
92
+ end
93
+
94
+ def down
95
+ stop_db
96
+ stop_app
97
+ delete_db_volume
98
+ end
99
+
100
+ def stop_app
101
+ Config.info_out.puts 'Stopping application'
102
+ killrm app_container_name
103
+ end
104
+
105
+ private
106
+
107
+ def ensure_db_volume
108
+ create_if_volume_not_exist db_volume_name
109
+ end
110
+
111
+ def setup_db
112
+ ensure_db_volume
113
+ start_db
114
+ return if File.exist?(default_db_image)
115
+
116
+ # Some databases keep state around, best to clean it.
117
+ stop_db
118
+ delete_db_volume
119
+ start_db
120
+
121
+ Config.info_out.puts 'Provisioning database'
122
+ killrm "#{envname}-apptemp"
123
+ CommandRunner.run! Config.out, "docker run -ti
124
+ --rm
125
+ --name #{envname}-apptemp
126
+ --network #{Config.config[:networkname]}
127
+ #{app_params}
128
+ kaiser:#{envname}-#{current_branch} #{db_reset_command}"
129
+
130
+ save_db('.default')
131
+ end
132
+
133
+ def save_db(name)
134
+ killrm db_container_name
135
+ save_db_state_from container: db_volume_name, to_file: db_image_path(name)
136
+ start_db
137
+ end
138
+
139
+ def load_db(name)
140
+ check_db_image_exists(name)
141
+ killrm db_container_name
142
+ CommandRunner.run Config.out, "docker volume rm #{db_volume_name}"
143
+ delete_db_volume
144
+ create_if_volume_not_exist db_volume_name
145
+ load_db_state_from file: db_image_path(name), to_container: db_volume_name
146
+ start_db
147
+ end
148
+
149
+ def check_db_image_exists(name)
150
+ return if File.exist?(db_image_path(name))
151
+
152
+ Optimist.die 'No saved state exists with that name'
153
+ end
154
+
155
+ def save_db_state_from(container:, to_file:)
156
+ Config.info_out.puts 'Saving database state'
157
+ File.write(to_file, '')
158
+ CommandRunner.run Config.out, "docker run --rm
159
+ -v #{container}:#{db_data_directory}
160
+ -v #{to_file}:#{to_file}
161
+ ruby:alpine
162
+ tar cvjf #{to_file} #{db_data_directory}"
163
+ end
164
+
165
+ def load_db_state_from(file:, to_container:)
166
+ Config.info_out.puts 'Loading database state'
167
+ CommandRunner.run Config.out, "docker run --rm
168
+ -v #{to_container}:#{db_data_directory}
169
+ -v #{file}:#{file}
170
+ ruby:alpine
171
+ tar xvjf #{file} -C #{db_data_directory}
172
+ --strip #{db_data_directory.scan(%r{\/}).count}"
173
+ end
174
+
175
+ def stop_db
176
+ Config.info_out.puts 'Stopping database'
177
+ killrm db_container_name
178
+ end
179
+
180
+ def start_db
181
+ Config.info_out.puts 'Starting up database'
182
+ run_if_dead db_container_name, "docker run -d
183
+ -p #{db_port}:#{db_expose}
184
+ -v #{db_volume_name}:#{db_data_directory}
185
+ --name #{db_container_name}
186
+ --network #{network_name}
187
+ #{db_params}
188
+ #{db_image}
189
+ #{db_commands}"
190
+ wait_for_db unless db_waitscript.nil?
191
+ end
192
+
193
+ def delete_db_volume
194
+ CommandRunner.run Config.out, "docker volume rm #{db_volume_name}"
195
+ end
196
+
197
+ def current_branch_db_image_dir
198
+ "#{Config.config_dir}/databases/#{envname}/#{current_branch}"
199
+ end
200
+
201
+ def db_image_path(name)
202
+ if name.start_with?('./')
203
+ path = "#{`pwd`.chomp}/#{name.sub('./', '')}"
204
+ Config.info_out.puts "Database image path is: #{path}"
205
+ return path
206
+ end
207
+ FileUtils.mkdir_p current_branch_db_image_dir
208
+ "#{current_branch_db_image_dir}/#{name}.tar.bz"
209
+ end
210
+
211
+ def default_db_image
212
+ db_image_path('.default')
213
+ end
214
+
215
+ def attach_app
216
+ cmd = (ARGV || []).join(' ')
217
+ killrm app_container_name
218
+
219
+ attach_mounts = Config.kaiserfile.attach_mounts
220
+ volumes = attach_mounts.map { |from, to| "-v #{`pwd`.chomp}/#{from}:#{to}" }.join(' ')
221
+
222
+ system "docker run -ti
223
+ --name #{app_container_name}
224
+ --network #{network_name}
225
+ --dns #{ip_of_container(Config.config[:shared_names][:dns])}
226
+ --dns-search #{http_suffix}
227
+ -p #{app_port}:#{app_expose}
228
+ -e DEV_APPLICATION_HOST=#{envname}.#{http_suffix}
229
+ -e VIRTUAL_HOST=#{envname}.#{http_suffix}
230
+ -e VIRTUAL_PORT=#{app_expose}
231
+ #{volumes}
232
+ #{app_params}
233
+ kaiser:#{envname}-#{current_branch} #{cmd}".tr("\n", ' ')
234
+
235
+ Config.out.puts 'Cleaning up...'
236
+ end
237
+
238
+ def start_app
239
+ Config.info_out.puts 'Starting up application'
240
+ killrm app_container_name
241
+ CommandRunner.run! Config.out, "docker run -d
242
+ --name #{app_container_name}
243
+ --network #{network_name}
244
+ --dns #{ip_of_container(Config.config[:shared_names][:dns])}
245
+ --dns-search #{http_suffix}
246
+ -p #{app_port}:#{app_expose}
247
+ -e DEV_APPLICATION_HOST=#{envname}.#{http_suffix}
248
+ -e VIRTUAL_HOST=#{envname}.#{http_suffix}
249
+ -e VIRTUAL_PORT=#{app_expose}
250
+ #{app_params}
251
+ kaiser:#{envname}-#{current_branch}"
252
+ wait_for_app
253
+ end
254
+
255
+ def tmp_waitscript_name
256
+ "#{Config.config_dir}/.#{envname}-dbwaitscript"
257
+ end
258
+
259
+ def tmp_dockerfile_name
260
+ "#{Config.config_dir}/.#{envname}-dockerfile"
261
+ end
262
+
263
+ def tmp_db_waiter
264
+ "#{envname}-dbwait"
265
+ end
266
+
267
+ def tmp_file_container
268
+ "#{envname}-tmpfiles"
269
+ end
270
+
271
+ def tmp_file_volume
272
+ "#{envname}-tmpfiles-vol"
273
+ end
274
+
275
+ def run_blocking_script(image, params, script, &block)
276
+ killrm tmp_db_waiter
277
+ killrm tmp_file_container
278
+
279
+ create_if_volume_not_exist tmp_file_volume
280
+
281
+ CommandRunner.run! Config.out, "docker create
282
+ -v #{tmp_file_volume}:/tmpvol
283
+ --name #{tmp_file_container} alpine"
284
+
285
+ File.write(tmp_waitscript_name, script)
286
+
287
+ CommandRunner.run! Config.out, "docker cp
288
+ #{tmp_waitscript_name}
289
+ #{tmp_file_container}:/tmpvol/wait.sh"
290
+
291
+ CommandRunner.run!(
292
+ Config.out,
293
+ "docker run --rm -ti
294
+ --name #{tmp_db_waiter}
295
+ --network #{network_name}
296
+ -v #{tmp_file_volume}:/tmpvol
297
+ #{params}
298
+ #{image} sh /tmpvol/wait.sh",
299
+ &block
300
+ )
301
+ ensure
302
+ killrm tmp_file_container
303
+ FileUtils.rm(tmp_waitscript_name)
304
+ end
305
+
306
+ def wait_for_app
307
+ return unless server_type == :http
308
+
309
+ Config.info_out.puts 'Waiting for server to start...'
310
+
311
+ http_code_extractor = "curl -s -o /dev/null -I -w \"\%{http_code}\" http://#{app_container_name}:#{app_expose}"
312
+ unreachable_test = "#{http_code_extractor} | grep -q 000"
313
+
314
+ # This waitscript runs until curl returns a non-unreachable status code
315
+ # and then checks to see if its 200. If its not, it will raise an error.
316
+ wait_script = <<-SCRIPT
317
+ apk update
318
+ apk add curl
319
+ while #{unreachable_test}; do
320
+ echo 'o'
321
+ sleep 1
322
+ done
323
+ echo '#{http_code_extractor}'
324
+ echo $(#{http_code_extractor})
325
+ if [ "$(#{http_code_extractor})" != "200" ]; then
326
+ echo $(#{http_code_extractor})
327
+ else
328
+ echo '!'
329
+ fi
330
+ SCRIPT
331
+ run_blocking_script('alpine', '', wait_script) do |line|
332
+ # This script gets run every line that gets output.
333
+ # The '!' exclamation mark means success
334
+ # Three numbers means a status code has been returned
335
+ # If curl returns an error status the script will cut out and
336
+ # the app container died error will be displayed.
337
+ raise Kaiser::Error, "Failed with HTTP status: #{line}" if line =~ /^[0-9]{3}$/ && line != '200'
338
+ raise Kaiser::Error, 'App container died. Run `kaiser logs` to see why.' if line != '!' && container_dead?(app_container_name)
339
+ end
340
+
341
+ Config.info_out.puts 'Started successfully!'
342
+ end
343
+
344
+ def wait_for_db
345
+ Config.info_out.puts 'Waiting for database to start...'
346
+ run_blocking_script(db_image, db_waitscript_params, db_waitscript)
347
+ Config.info_out.puts 'Started.'
348
+ end
349
+
350
+ def network_name
351
+ Config.config[:networkname]
352
+ end
353
+
354
+ def db_port
355
+ Config.config[:envs][envname][:db_port]
356
+ end
357
+
358
+ def db_expose
359
+ Config.kaiserfile.database[:port]
360
+ end
361
+
362
+ def db_params
363
+ eval_template Config.kaiserfile.database[:params]
364
+ end
365
+
366
+ def db_image
367
+ Config.kaiserfile.database[:image]
368
+ end
369
+
370
+ def db_commands
371
+ eval_template Config.kaiserfile.database[:commands]
372
+ end
373
+
374
+ def db_data_directory
375
+ Config.kaiserfile.database[:data_dir]
376
+ end
377
+
378
+ def server_type
379
+ Config.kaiserfile.server_type
380
+ end
381
+
382
+ def db_waitscript
383
+ eval_template Config.kaiserfile.database[:waitscript]
384
+ end
385
+
386
+ def db_waitscript_params
387
+ eval_template Config.kaiserfile.database[:waitscript_params]
388
+ end
389
+
390
+ def docker_file_contents
391
+ eval_template Config.kaiserfile.docker_file_contents
392
+ end
393
+
394
+ def docker_build_args
395
+ Config.kaiserfile.docker_build_args
396
+ end
397
+
398
+ def app_params
399
+ eval_template Config.kaiserfile.params
400
+ end
401
+
402
+ def db_reset_command
403
+ eval_template Config.kaiserfile.database_reset_command
404
+ end
405
+
406
+ def eval_template(value)
407
+ ERB.new(value).result(binding)
408
+ end
409
+
410
+ def app_port
411
+ Config.config[:envs][envname][:app_port]
412
+ end
413
+
414
+ def app_expose
415
+ Config.kaiserfile.port
416
+ end
417
+
418
+ def db_volume_name
419
+ "#{envname}-database"
420
+ end
421
+
422
+ def app_container_name
423
+ "#{envname}-app"
424
+ end
425
+
426
+ def db_container_name
427
+ "#{envname}-db"
428
+ end
429
+
430
+ def current_branch
431
+ `git branch | grep \\* | cut -d ' ' -f2`.chomp.gsub(/[^\-_0-9a-z]+/, '-')
432
+ end
433
+
434
+ def ensure_env
435
+ return unless envname.nil?
436
+
437
+ Optimist.die('No environment? Please use kaiser init <name>')
438
+ end
439
+
440
+ def http_suffix
441
+ Config.config[:http_suffix] || 'lvh.me'
442
+ end
443
+
444
+ def copy_keyfile(file)
445
+ if Config.config[:cert_source][:folder]
446
+ CommandRunner.run! Config.out, "docker run --rm
447
+ -v #{Config.config[:shared_names][:certs]}:/certs
448
+ -v #{Config.config[:cert_source][:folder]}:/cert_source
449
+ alpine cp /cert_source/#{file} /certs/#{file}"
450
+
451
+ elsif Config.config[:cert_source][:url]
452
+ CommandRunner.run! Config.out, "docker run --rm
453
+ -v #{Config.config[:shared_names][:certs]}:/certs
454
+ alpine wget #{Config.config[:cert_source][:url]}/#{file}
455
+ -O /certs/#{file}"
456
+ end
457
+ end
458
+
459
+ def prepare_cert_volume!
460
+ create_if_volume_not_exist Config.config[:shared_names][:certs]
461
+ return unless Config.config[:cert_source]
462
+
463
+ %w[
464
+ chain.pem
465
+ crt
466
+ key
467
+ ].each do |file_ext|
468
+ copy_keyfile("#{http_suffix}.#{file_ext}")
469
+ end
470
+ end
471
+
472
+ def ensure_setup
473
+ ensure_env
474
+
475
+ setup if network.nil?
476
+
477
+ create_if_network_not_exist Config.config[:networkname]
478
+ if_container_dead Config.config[:shared_names][:nginx] do
479
+ prepare_cert_volume!
480
+ end
481
+ run_if_dead(
482
+ Config.config[:shared_names][:redis],
483
+ "docker run -d
484
+ --name #{Config.config[:shared_names][:redis]}
485
+ --network #{Config.config[:networkname]}
486
+ redis:alpine"
487
+ )
488
+ run_if_dead(
489
+ Config.config[:shared_names][:chrome],
490
+ "docker run -d
491
+ -p 5900:5900
492
+ --name #{Config.config[:shared_names][:chrome]}
493
+ --network #{Config.config[:networkname]}
494
+ selenium/standalone-chrome-debug"
495
+ )
496
+ run_if_dead(
497
+ Config.config[:shared_names][:nginx],
498
+ "docker run -d
499
+ -p 80:80
500
+ -p 443:443
501
+ -v #{Config.config[:shared_names][:certs]}:/etc/nginx/certs
502
+ -v /var/run/docker.sock:/tmp/docker.sock:ro
503
+ --privileged
504
+ --name #{Config.config[:shared_names][:nginx]}
505
+ --network #{Config.config[:networkname]}
506
+ jwilder/nginx-proxy"
507
+ )
508
+ run_if_dead(
509
+ Config.config[:shared_names][:dns],
510
+ "docker run -d
511
+ --name #{Config.config[:shared_names][:dns]}
512
+ --network #{Config.config[:networkname]}
513
+ --privileged
514
+ -v /var/run/docker.sock:/docker.sock:ro
515
+ davidsiaw/docker-dns
516
+ --domain #{http_suffix}
517
+ --record :#{ip_of_container(Config.config[:shared_names][:nginx])}"
518
+ )
519
+ end
520
+
521
+ def ip_of_container(containername)
522
+ networkname = ".NetworkSettings.Networks.#{Config.config[:networkname]}.IPAddress"
523
+ `docker inspect -f '{{#{networkname}}}' #{containername}`.chomp
524
+ end
525
+
526
+ def network
527
+ `docker network inspect #{Config.config[:networkname]} 2>/dev/null`
528
+ end
529
+
530
+ def container_dead?(container)
531
+ x = JSON.parse(`docker inspect #{container} 2>/dev/null`)
532
+ return true if x.length.zero? || x[0]['State']['Running'] == false
533
+ end
534
+
535
+ def if_container_dead(container)
536
+ return unless container_dead?(container)
537
+
538
+ yield if block_given?
539
+ end
540
+
541
+ def create_if_volume_not_exist(vol)
542
+ x = JSON.parse(`docker volume inspect #{vol} 2>/dev/null`)
543
+ return unless x.length.zero?
544
+
545
+ CommandRunner.run! Config.out, "docker volume create #{vol}"
546
+ end
547
+
548
+ def create_if_network_not_exist(net)
549
+ x = JSON.parse(`docker inspect #{net} 2>/dev/null`)
550
+ return unless x.length.zero?
551
+
552
+ CommandRunner.run! Config.out, "docker network create #{net}"
553
+ end
554
+
555
+ def run_if_dead(container, command)
556
+ if_container_dead container do
557
+ Config.info_out.puts "Starting up #{container}"
558
+ killrm container
559
+ CommandRunner.run Config.out, command
560
+ end
561
+ end
562
+
563
+ def envname
564
+ Config.config[:envnames][Config.work_dir]
565
+ end
566
+
567
+ def save_config
568
+ File.write(Config.config_file, Config.config.to_yaml)
569
+ end
570
+
571
+ def killrm(container)
572
+ x = JSON.parse(`docker inspect #{container} 2>/dev/null`)
573
+ return if x.length.zero?
574
+
575
+ CommandRunner.run Config.out, "docker kill #{container}" if x[0]['State'] && x[0]['State']['Running'] == true
576
+ CommandRunner.run Config.out, "docker rm #{container}" if x[0]['State']
577
+ end
578
+ end
579
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaiser
4
+ module CliOptions
5
+ def option(*option)
6
+ @options ||= []
7
+ @options << option
8
+ end
9
+
10
+ def options
11
+ @options || []
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaiser
4
+ module Cmds
5
+ class Attach < Cli
6
+ def usage
7
+ <<~EOS
8
+ Shuts down the application container and starts it up again with the current directory bind mounted inside. This way the application will run from the source code in the current directory and any edits you make will immediately show up inside the container. This is ideal for development.
9
+
10
+ Once the attached container exits (through the use of control+c for example) it will be replaced by a regular non-attached version of the app container.
11
+
12
+ USAGE: kaiser attach
13
+ EOS
14
+ end
15
+
16
+ def execute(_opts)
17
+ ensure_setup
18
+ attach_app
19
+ start_app
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaiser
4
+ module Cmds
5
+ class DbLoad < Cli
6
+ def usage
7
+ <<~EOS
8
+ Shuts down the database docker container, *replaces* the database with the backup provided and brings the container up again.
9
+
10
+ The database will be restored from a tarball saved as \`~/.kaiser/<ENV_NAME>/<current_github_branch_name>/<DB_BACKUP_FILENAME>.tar.bz\`
11
+
12
+ Alternatively you can also load it from your current directory.
13
+
14
+ If no database name was provided, the default database stored at \`~/.kaiser/<ENV_NAME>/<current_github_branch_name>/.default.tar.bz\` will be used.
15
+
16
+ USAGE: kaiser db_load
17
+ kaiser db_load DB_BACKUP_FILENAME
18
+ kaiser db_load ./my_database.tar.bz
19
+ EOS
20
+ end
21
+
22
+ def execute(_opts)
23
+ ensure_setup
24
+ name = ARGV.shift || '.default'
25
+ load_db(name)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaiser
4
+ module Cmds
5
+ class DbReset < Cli
6
+ def usage
7
+ <<~EOS
8
+ Shuts down the database docker container, *replaces* the database with the default database image stored at \`~/.kaiser/<ENV_NAME>/<current_github_branch_name>/.default.tar.bz\` and brings the container up again.
9
+
10
+ This is the same as running \`kaiser db_load\` with no arguments.
11
+
12
+ USAGE: kaiser db_reset
13
+ EOS
14
+ end
15
+
16
+ def execute(_opts)
17
+ ensure_setup
18
+ load_db('.default')
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaiser
4
+ module Cmds
5
+ class DbResetHard < Cli
6
+ def usage
7
+ <<~EOS
8
+ Shuts down the database docker container, deletes the docker volume on which the db was stored, deletes the default database image stored at \`~/.kaiser/<ENV_NAME>/<current_github_branch_name>/.default.tar.bz\`, rebuilds the docker volume and the default database image from scratch and then brings the container up again.
9
+
10
+ USAGE: kaiser db_reset_hard
11
+ EOS
12
+ end
13
+
14
+ def execute(_opts)
15
+ ensure_setup
16
+ FileUtils.rm db_image_path('.default') if File.exist?(db_image_path('.default'))
17
+ setup_db
18
+ end
19
+ end
20
+ end
21
+ end