dry-dock 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +6 -0
  3. data/.pryrc +1 -0
  4. data/.rspec +2 -0
  5. data/Dockerfile +69 -0
  6. data/Gemfile +20 -0
  7. data/Gemfile.lock +102 -0
  8. data/LICENSE +22 -0
  9. data/README.md +75 -0
  10. data/Rakefile +53 -0
  11. data/VERSION +1 -0
  12. data/bin/drydock +45 -0
  13. data/bin/json-test-consumer.rb +11 -0
  14. data/bin/json-test-producer.rb +25 -0
  15. data/bin/test-tar-writer-digest.rb +27 -0
  16. data/dry-dock.gemspec +135 -0
  17. data/examples/ruby-dsl.rb +14 -0
  18. data/examples/ruby-node-app-dsl.rb +128 -0
  19. data/examples/test-dsl.rb +9 -0
  20. data/examples/test.rb +46 -0
  21. data/lib/drydock/cli_flags.rb +46 -0
  22. data/lib/drydock/container_config.rb +75 -0
  23. data/lib/drydock/docker_api_patch.rb +176 -0
  24. data/lib/drydock/drydock.rb +65 -0
  25. data/lib/drydock/errors.rb +6 -0
  26. data/lib/drydock/file_manager.rb +26 -0
  27. data/lib/drydock/formatters.rb +13 -0
  28. data/lib/drydock/ignorefile_definition.rb +61 -0
  29. data/lib/drydock/image_repository.rb +50 -0
  30. data/lib/drydock/logger.rb +61 -0
  31. data/lib/drydock/object_caches/base.rb +24 -0
  32. data/lib/drydock/object_caches/filesystem_cache.rb +88 -0
  33. data/lib/drydock/object_caches/in_memory_cache.rb +52 -0
  34. data/lib/drydock/object_caches/no_cache.rb +38 -0
  35. data/lib/drydock/phase.rb +50 -0
  36. data/lib/drydock/phase_chain.rb +233 -0
  37. data/lib/drydock/plugins/apk.rb +31 -0
  38. data/lib/drydock/plugins/base.rb +15 -0
  39. data/lib/drydock/plugins/npm.rb +16 -0
  40. data/lib/drydock/plugins/package_manager.rb +30 -0
  41. data/lib/drydock/plugins/rubygems.rb +30 -0
  42. data/lib/drydock/project.rb +427 -0
  43. data/lib/drydock/runtime_options.rb +79 -0
  44. data/lib/drydock/stream_monitor.rb +54 -0
  45. data/lib/drydock/tar_writer.rb +36 -0
  46. data/lib/drydock.rb +35 -0
  47. data/spec/assets/MANIFEST +4 -0
  48. data/spec/assets/hello-world.txt +1 -0
  49. data/spec/assets/sample.tar +0 -0
  50. data/spec/assets/test.sh +3 -0
  51. data/spec/drydock/cli_flags_spec.rb +38 -0
  52. data/spec/drydock/container_config_spec.rb +230 -0
  53. data/spec/drydock/docker_api_patch_spec.rb +103 -0
  54. data/spec/drydock/drydock_spec.rb +25 -0
  55. data/spec/drydock/file_manager_spec.rb +53 -0
  56. data/spec/drydock/formatters_spec.rb +26 -0
  57. data/spec/drydock/ignorefile_definition_spec.rb +123 -0
  58. data/spec/drydock/image_repository_spec.rb +54 -0
  59. data/spec/drydock/object_caches/base_spec.rb +28 -0
  60. data/spec/drydock/object_caches/filesystem_cache_spec.rb +48 -0
  61. data/spec/drydock/object_caches/no_cache_spec.rb +62 -0
  62. data/spec/drydock/phase_chain_spec.rb +118 -0
  63. data/spec/drydock/phase_spec.rb +67 -0
  64. data/spec/drydock/plugins/apk_spec.rb +49 -0
  65. data/spec/drydock/plugins/base_spec.rb +13 -0
  66. data/spec/drydock/plugins/npm_spec.rb +26 -0
  67. data/spec/drydock/plugins/package_manager_spec.rb +12 -0
  68. data/spec/drydock/plugins/rubygems_spec.rb +53 -0
  69. data/spec/drydock/project_import_spec.rb +39 -0
  70. data/spec/drydock/project_spec.rb +156 -0
  71. data/spec/drydock/runtime_options_spec.rb +31 -0
  72. data/spec/drydock/stream_monitor_spec.rb +41 -0
  73. data/spec/drydock/tar_writer_spec.rb +27 -0
  74. data/spec/spec_helper.rb +47 -0
  75. data/spec/support/shared_examples/base_class.rb +3 -0
  76. data/spec/support/shared_examples/container_config.rb +12 -0
  77. data/spec/support/shared_examples/drydockfile.rb +6 -0
  78. metadata +223 -0
@@ -0,0 +1,61 @@
1
+
2
+ require 'logger'
3
+
4
+ module Drydock
5
+ class Logger < ::Logger
6
+
7
+ def add(severity, message = nil, progname = nil, &block)
8
+ annotation = nil
9
+ indent = 0
10
+
11
+ if message.nil?
12
+ if block_given?
13
+ message = yield
14
+ else
15
+ message = progname
16
+ progname = @progname
17
+ end
18
+ end
19
+
20
+ if message.respond_to?(:key?)
21
+ indent = message[:indent].to_i + 1
22
+ annotation = message.fetch(:annotation, '-->')
23
+ if message.key?(:message)
24
+ messages = Array(message[:message])
25
+ elsif message.key?(:messages)
26
+ messages = Array(message[:messages])
27
+ end
28
+ else
29
+ messages = [message]
30
+ end
31
+
32
+ annotation << " " if annotation
33
+ indentation = ' ' * indent
34
+
35
+ messages.each do |m|
36
+ m.to_s.split(/\n/).each do |line|
37
+ if annotation
38
+ super(severity, "#{indentation}#{annotation} #{line}", progname)
39
+ else
40
+ super(severity, line, progname)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ alias log add
47
+
48
+ end
49
+
50
+ class Formatter < ::Logger::Formatter
51
+
52
+ def call(severity, time, program, message)
53
+ "%s [%s] %s\n" % [
54
+ severity.slice(0, 1),
55
+ time.strftime('%H:%M:%S'),
56
+ msg2str(message)
57
+ ]
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,24 @@
1
+
2
+ module Drydock
3
+ module ObjectCaches
4
+ class Base
5
+
6
+ def clear
7
+ raise NotImplementedError, '#clear must be overridden in the subclass'
8
+ end
9
+
10
+ def fetch(key, &blk)
11
+ raise NotImplementedError, '#fetch must be overridden in the subclass'
12
+ end
13
+
14
+ def get(key, &blk)
15
+ raise NotImplementedError, '#get must be overridden in the subclass'
16
+ end
17
+
18
+ def set(key, value = nil, &blk)
19
+ raise NotImplementedError, '#set must be overridden in the subclass'
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module ObjectCaches
6
+ class FilesystemCache < Base
7
+
8
+ def initialize(dir = "~/.drydock")
9
+ @dir = File.expand_path(File.join(dir.to_s, 'cache'))
10
+ FileUtils.mkdir_p(@dir)
11
+ end
12
+
13
+ def clear
14
+ begin
15
+ FileUtils.remove_entry(@dir)
16
+ true
17
+ rescue => e
18
+ Drydock.logger.error("Cannot clear #{self.class} at #{@dir.inspect}: #{e}")
19
+ return false
20
+ end
21
+ end
22
+
23
+ def fetch(key, &blk)
24
+ filename = build_path(key)
25
+
26
+ if File.exist?(filename)
27
+ File.read(filename)
28
+ else
29
+ dirname = File.dirname(filename)
30
+ FileUtils.mkdir_p(dirname)
31
+
32
+ blk.call.tap do |contents|
33
+ File.open(filename, 'w') do |file|
34
+ file.write contents
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ def get(key, &blk)
41
+ filename = build_path(key)
42
+ if File.exist?(filename)
43
+ if blk.nil?
44
+ File.read(filename)
45
+ else
46
+ File.open(filename) do |file|
47
+ blk.call file
48
+ end
49
+ end
50
+ else
51
+ nil
52
+ end
53
+ end
54
+
55
+ def key?(key)
56
+ File.exist?(build_path(key))
57
+ end
58
+
59
+ def set(key, value = nil, &blk)
60
+ filename = build_path(key)
61
+ dirname = File.dirname(filename)
62
+ FileUtils.mkdir_p(dirname)
63
+
64
+ File.open(filename, 'w') do |file|
65
+ if blk.nil?
66
+ file.write value
67
+ else
68
+ blk.call file
69
+ end
70
+ end
71
+
72
+ nil
73
+ end
74
+
75
+ private
76
+ attr_reader :dir
77
+
78
+ def build_path(key)
79
+ digest = Digest::SHA2.hexdigest(key)
80
+ subdir1 = digest.slice(0, 2)
81
+ subdir2 = digest.slice(2, 2)
82
+ filename = digest.slice(4, digest.length - 4)
83
+ File.join(dir, subdir1, subdir2, filename)
84
+ end
85
+
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,52 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module ObjectCaches
6
+ class InMemoryCache < Base
7
+
8
+ def initialize
9
+ @mem = {}
10
+ end
11
+
12
+ def clear
13
+ @mem.clear
14
+ true
15
+ end
16
+
17
+ def fetch(key, &blk)
18
+ @mem.fetch(key, &blk)
19
+ end
20
+
21
+ def get(key, &blk)
22
+ if @mem.key?(key)
23
+ if blk.nil?
24
+ @mem[key]
25
+ else
26
+ blk.call(StringIO.new(@mem[key]))
27
+ end
28
+ else
29
+ nil
30
+ end
31
+ end
32
+
33
+ def key?(key)
34
+ @mem.key?(key)
35
+ end
36
+
37
+ def set(key, value = nil, &blk)
38
+ if blk
39
+ buffer = StringIO.new
40
+ blk.call buffer
41
+ buffer.rewind
42
+ @mem[key] = buffer.string
43
+ else
44
+ @mem[key] = value
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module ObjectCaches
6
+ class NoCache < Base
7
+
8
+ def clear
9
+ true
10
+ end
11
+
12
+ def fetch(key, &blk)
13
+ blk.call
14
+ end
15
+
16
+ def get(key, &blk)
17
+ nil
18
+ end
19
+
20
+ def key?(key)
21
+ false
22
+ end
23
+
24
+ def set(key, value = nil, &blk)
25
+ if blk
26
+ File.open('/dev/null', 'w') do |file|
27
+ blk.call file
28
+ end
29
+ else
30
+ # :noop:
31
+ end
32
+
33
+ nil
34
+ end
35
+
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,50 @@
1
+
2
+ module Drydock
3
+ class Phase < Struct.new(:source_image, :build_container, :result_image)
4
+
5
+ alias_method :build, :build_container
6
+ alias_method :build=, :build_container=
7
+
8
+ alias_method :result, :result_image
9
+ alias_method :result=, :result_image=
10
+
11
+ alias_method :source, :source_image
12
+ alias_method :source=, :source_image=
13
+
14
+ def self.from(hsh)
15
+ h = hsh.to_h
16
+ extra_keys = h.keys - members
17
+ raise ArgumentError, "unknown options: #{extra_keys.join(', ')}" unless extra_keys.empty?
18
+ new(*h.values_at(*members))
19
+ end
20
+
21
+ def built?
22
+ !cached?
23
+ end
24
+
25
+ def cached?
26
+ build_container.nil?
27
+ end
28
+
29
+ def destroy!(force: false)
30
+ if result_image
31
+ begin
32
+ result_image.remove(force: force)
33
+ rescue Docker::Error::NotFoundError => e
34
+ # Ignore, because the image could have been deleted by another phase in
35
+ # another derived chain.
36
+ end
37
+ end
38
+
39
+ build_container.remove(force: force) if built?
40
+ self
41
+ end
42
+
43
+ def finalize!(force: false)
44
+ return self unless built?
45
+ build_container.remove(force: force)
46
+ self
47
+ end
48
+
49
+ end
50
+ end
@@ -0,0 +1,233 @@
1
+
2
+ module Drydock
3
+ class PhaseChain
4
+ extend Forwardable
5
+ include Enumerable
6
+
7
+ def_delegators :@chain, :<<, :at, :empty?, :last, :length, :push, :size
8
+
9
+ def self.build_commit_opts(opts = {})
10
+ {}.tap do |commit|
11
+ if opts.key?(:command)
12
+ commit['run'] = {
13
+ Cmd: opts[:command]
14
+ }
15
+ end
16
+
17
+ commit[:author] = opts.fetch(:author, '') if opts.key?(:author)
18
+ commit[:comment] = opts.fetch(:comment, '') if opts.key?(:comment)
19
+ end
20
+ end
21
+
22
+ def self.build_container_opts(image_id, cmd, opts = {})
23
+ cmd = ['/bin/sh', '-c', cmd.to_s] unless cmd.is_a?(Array)
24
+
25
+ ContainerConfig.from(
26
+ Cmd: cmd,
27
+ Tty: opts.fetch(:tty, false),
28
+ Image: image_id
29
+ ).tap do |cc|
30
+ env = Array(opts[:env])
31
+ cc[:Env].push(*env) unless env.empty?
32
+
33
+ if opts.key?(:expose)
34
+ cc[:ExposedPorts] ||= {}
35
+ opts[:expose].each do |port|
36
+ cc[:ExposedPorts][port] = {}
37
+ end
38
+ end
39
+
40
+ (cc[:OnBuild] ||= []).push(opts[:on_build]) if opts.key?(:on_build)
41
+ end
42
+ end
43
+
44
+ def self.build_pull_opts(repo, tag = nil)
45
+ if tag
46
+ {fromImage: repo, tag: tag}
47
+ else
48
+ {fromImage: repo}
49
+ end
50
+ end
51
+
52
+ def self.create_container(cfg, timeout: nil, &blk)
53
+ timeout ||= Excon.defaults[:read_timeout]
54
+
55
+ Docker::Container.create(cfg).tap do |c|
56
+ t = Thread.new do
57
+ begin
58
+ c.attach(stream: true, stdout: true, stderr: true) do |stream, chunk|
59
+ case stream
60
+ when :stdout
61
+ Drydock.logger.info(message: chunk, annotation: '(O)')
62
+ when :stderr
63
+ Drydock.logger.info(message: chunk, annotation: '(E)')
64
+ else
65
+ Drydock.logger.info(message: chunk, annotation: '(?)')
66
+ end
67
+ end
68
+ rescue Docker::Error::TimeoutError
69
+ Drydock.logger.warn(message: "Lost connection to stream; retrying")
70
+ retry
71
+ end
72
+ end
73
+
74
+ c.start
75
+
76
+ blk.call(c) if blk
77
+
78
+ c.wait(timeout)
79
+ t.join
80
+ end
81
+ end
82
+
83
+ def self.from_repo(repo, tag = 'latest')
84
+ new(Docker::Image.create(build_pull_opts(repo, tag)))
85
+ end
86
+
87
+ def initialize(from, parent = nil)
88
+ @chain = []
89
+ @from = from
90
+ @parent = parent
91
+ @children = []
92
+
93
+ @ephemeral_containers = []
94
+
95
+ if parent
96
+ parent.children << self
97
+ end
98
+ end
99
+
100
+ def children
101
+ @children
102
+ end
103
+
104
+ def containers
105
+ map(&:build_container)
106
+ end
107
+
108
+ def depth
109
+ @parent ? @parent.depth + 1 : 1
110
+ end
111
+
112
+ def derive
113
+ self.class.new(last_image, self)
114
+ end
115
+
116
+ def destroy!(force: false)
117
+ return self if frozen?
118
+ children.reverse_each { |c| c.destroy!(force: force) } if children
119
+ ephemeral_containers.map { |c| c.remove(force: force) }
120
+
121
+ reverse_each { |c| c.destroy!(force: force) }
122
+ freeze
123
+ end
124
+
125
+ def each(&blk)
126
+ @chain.each(&blk)
127
+ end
128
+
129
+ def ephemeral_containers
130
+ @ephemeral_containers
131
+ end
132
+
133
+ def finalize!(force: false)
134
+ return self if frozen?
135
+
136
+ children.map { |c| c.finalize!(force: force) } if children
137
+ ephemeral_containers.map { |c| c.remove(force: force) }
138
+
139
+ Drydock.logger.info("##{serial}: Final image ID is #{last_image.id}") unless empty?
140
+ map { |p| p.finalize!(force: force) }
141
+ freeze
142
+ end
143
+
144
+ def images
145
+ [root_image] + map(&:result_image)
146
+ end
147
+
148
+ def last_image
149
+ @chain.last ? @chain.last.result_image : nil
150
+ end
151
+
152
+ def root_image
153
+ @from
154
+ end
155
+
156
+ def run(cmd, opts = {}, &blk)
157
+ src_image = last ? last.result_image : @from
158
+ no_commit = opts.fetch(:no_commit, false)
159
+
160
+ no_cache = opts.fetch(:no_cache, false)
161
+ no_cache = true if no_commit
162
+
163
+ build_config = self.class.build_container_opts(src_image.id, cmd, opts)
164
+ Drydock.logger.info(build_config.inspect)
165
+ cached_image = ImageRepository.find_by_config(build_config)
166
+
167
+ if cached_image && !no_cache
168
+ Drydock.logger.info(message: "Using cached image ID #{cached_image.id.slice(0, 12)}")
169
+
170
+ if no_commit
171
+ Drydock.logger.info(message: "Skipping commit phase")
172
+ else
173
+ self << Phase.from(
174
+ source_image: src_image,
175
+ result_image: cached_image
176
+ )
177
+ end
178
+ else
179
+ if cached_image && no_commit
180
+ Drydock.logger.info(message: "Found cached image ID #{cached_image.id.slice(0, 12)}, but skipping due to :no_commit")
181
+ elsif cached_image && no_cache
182
+ Drydock.logger.info(message: "Found cached image ID #{cached_image.id.slice(0, 12)}, but skipping due to :no_cache")
183
+ end
184
+
185
+ container = self.class.create_container(build_config)
186
+ yield container if block_given?
187
+
188
+ if no_commit
189
+ Drydock.logger.info(message: "Skipping commit phase")
190
+ ephemeral_containers << container
191
+ else
192
+ if opts.key?(:command)
193
+ Drydock.logger.info("Command override: #{opts[:command].inspect}")
194
+ else
195
+ src_image.refresh!
196
+ if src_image.info && src_image.info.key?('Config')
197
+ src_image_config = src_image.info['Config']
198
+ opts[:command] = src_image_config['Cmd'] if src_image_config.key?('Cmd')
199
+ end
200
+
201
+ Drydock.logger.info("Command retrieval: #{opts[:command].inspect}")
202
+ Drydock.logger.info("Source image info: #{src_image.info.class} #{src_image.info.inspect}")
203
+ Drydock.logger.info("Source image config: #{src_image.info['Config'].inspect}")
204
+ end
205
+
206
+ commit_config = self.class.build_commit_opts(opts)
207
+ Drydock.logger.info(opts.inspect)
208
+ Drydock.logger.info(commit_config.inspect)
209
+
210
+ result = container.commit(commit_config)
211
+ Drydock.logger.info(message: "Committed image ID #{result.id.slice(0, 12)}")
212
+
213
+ self << Phase.from(
214
+ source_image: src_image,
215
+ build_container: container,
216
+ result_image: result
217
+ )
218
+ end
219
+ end
220
+
221
+ self
222
+ end
223
+
224
+ def serial
225
+ @parent ? "#{@parent.serial}.#{@parent.children.index(self) + 1}.#{size + 1}" : "#{size + 1}"
226
+ end
227
+
228
+ def tag(repo, tag = 'latest', force: false)
229
+ last_image.tag(repo: repo, tag: tag, force: force)
230
+ end
231
+
232
+ end
233
+ end
@@ -0,0 +1,31 @@
1
+
2
+ require_relative 'package_manager'
3
+
4
+ module Drydock
5
+ module Plugins
6
+ class APK < PackageManager
7
+
8
+ def add(*pkgs)
9
+ opts = pkgs.last.is_a?(Hash) ? pkgs.pop : {}
10
+ project.run "apk add #{pkgs.join(' ')}", opts
11
+ end
12
+
13
+ def clean
14
+ project.run "rm -rf /var/cache/apk/*"
15
+ end
16
+
17
+ def remove(*pkgs)
18
+ project.run "apk del #{pkgs.join(' ')}"
19
+ end
20
+
21
+ def update
22
+ project.run "apk update"
23
+ end
24
+
25
+ def upgrade
26
+ project.run "apk upgrade"
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+
2
+ module Drydock
3
+ module Plugins
4
+ class Base
5
+
6
+ attr_reader :project
7
+
8
+ def initialize(project)
9
+ @project = project
10
+ end
11
+
12
+ end
13
+ end
14
+ end
15
+
@@ -0,0 +1,16 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module Plugins
6
+ class NPM < Base
7
+
8
+ def install(*pkgs)
9
+ opts = pkgs.last.is_a?(Hash) ? pkgs.pop : {}
10
+ flags = CliFlags.new(opts)
11
+ project.run("npm install #{flags}#{pkgs.join(' ')}")
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module Plugins
6
+ class PackageManager < Base
7
+
8
+ def add(*pkgs)
9
+ raise NotImplementedError, '#add must be overridde in the subclass'
10
+ end
11
+
12
+ def clean
13
+ raise NotImplementedError, '#clean must be overridde in the subclass'
14
+ end
15
+
16
+ def remove(*pkgs)
17
+ raise NotImplementedError, '#remove must be overridde in the subclass'
18
+ end
19
+
20
+ def update
21
+ raise NotImplementedError, '#update must be overridde in the subclass'
22
+ end
23
+
24
+ def upgrade
25
+ raise NotImplementedError, '#upgrade must be overridde in the subclass'
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+
2
+ require_relative 'base'
3
+
4
+ module Drydock
5
+ module Plugins
6
+ class Rubygems < Base
7
+
8
+ def add_source(uri)
9
+ project.run("gem sources --add #{uri}")
10
+ end
11
+
12
+ def install(pkg, opts = {})
13
+ timeout = opts.delete(:timeout) || 120
14
+ flags = CliFlags.new(opts)
15
+ project.run("gem install #{pkg} #{flags}", timeout: timeout)
16
+ end
17
+
18
+ def remove_source(uri)
19
+ project.run("gem sources --remove #{uri}")
20
+ end
21
+
22
+ def update_system(opts = {})
23
+ timeout = opts.delete(:timeout) || 300
24
+ flags = CliFlags.new(opts)
25
+ project.run("gem update --system #{flags}", timeout: timeout)
26
+ end
27
+
28
+ end
29
+ end
30
+ end