moby-derp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,245 @@
1
+ require_relative "./logging_helpers"
2
+
3
+ require "digest/sha2"
4
+ require "docker-api"
5
+ require "ipaddr"
6
+ require "json/canonicalization"
7
+
8
+ module MobyDerp
9
+ class Container
10
+ include LoggingHelpers
11
+
12
+ def initialize(pod:, container_config:, root_container: false)
13
+ @logger = pod.logger
14
+
15
+ @pod, @config, @root_container = pod, container_config, root_container
16
+ end
17
+
18
+ def run
19
+ container_name = @root_container ? @pod.name : @config.name
20
+ @logger.debug(logloc) { "Calculated container name is #{container_name} (@root_container: #{@root_container.inspect}, @config.name: #{@config.name}, @pod.name: #{@pod.name}" }
21
+
22
+ begin
23
+ existing_container = Docker::Container.get(container_name)
24
+ @logger.debug(logloc) { "Config hash for existing container #{container_name} is #{existing_container.info["Config"]["Labels"]["org.hezmatt.moby-derp.config-hash"].inspect}" }
25
+ @logger.debug(logloc) { "New config hash is #{params_hash(container_creation_parameters).inspect}" }
26
+
27
+ if existing_container.info["Config"]["Labels"]["org.hezmatt.moby-derp.config-hash"] == params_hash(container_creation_parameters)
28
+ # Container is up-to-date
29
+ @logger.info(logloc) { "Container #{container_name} is up-to-date" }
30
+ return existing_container.id
31
+ end
32
+
33
+ if existing_container.info["Config"]["Labels"]["org.hezmatt.moby-derp.pod-name"] != @pod.name
34
+ raise ContainerError,
35
+ "container #{container_name} is not tagged as being part of this pod"
36
+ end
37
+ @logger.info(logloc) { "Deleting container #{container_name} (#{existing_container.id[0..11]}) because it is out-of-date" }
38
+ existing_container.delete(force: true)
39
+ @logger.info(logloc) { "Creating new container #{container_name}" }
40
+ rescue Docker::Error::NotFoundError
41
+ @logger.info(logloc) { "Container #{container_name} does not exist; creating it" }
42
+ # Container doesn't exist, need to create it
43
+ end
44
+
45
+ begin
46
+ Docker::Container.create(hash_labelled(container_creation_parameters)).start!.id
47
+ rescue Docker::Error::ClientError => ex
48
+ raise MobyDerp::ContainerError,
49
+ "moby daemon returned error: #{ex.message}"
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def container_creation_parameters
56
+ {}.tap do |params|
57
+ if @root_container
58
+ params["HostConfig"] = {
59
+ "NetworkMode" => @pod.network_name,
60
+ "Init" => true,
61
+ }
62
+ params["MacAddress"] = container_mac_address
63
+ if network_uses_ipv6?
64
+ params["NetworkingConfig"] = {
65
+ "EndpointsConfig" => {
66
+ @pod.network_name => {
67
+ "IPAMConfig" => {
68
+ "IPv6Address" => container_ipv6_address,
69
+ }
70
+ }
71
+ }
72
+ }
73
+ end
74
+ else
75
+ params["HostConfig"] = {
76
+ "NetworkMode" => "container:#{@pod.root_container_id}",
77
+ "PidMode" => "container:#{@pod.root_container_id}",
78
+ "IpcMode" => "container:#{@pod.root_container_id}",
79
+ }
80
+ end
81
+
82
+ params["HostConfig"]["RestartPolicy"] = parsed_restart_policy
83
+ params["HostConfig"]["Mounts"] = merged_mounts.map { |mount| mount_structure(mount) }
84
+
85
+ params["Env"] = @pod.common_environment.merge(@config.environment).map { |k, v| "#{k}=#{v}" }
86
+ params["Volumes"] = {}
87
+
88
+ params["name"] = @root_container ? @pod.name : @config.name
89
+ params["Image"] = image_id
90
+
91
+ params["Cmd"] = @config.command
92
+ params["StopSignal"] = @config.stop_signal
93
+ params["StopTimeout"] = @config.stop_timeout
94
+
95
+ if @config.readonly
96
+ params["HostConfig"]["ReadonlyRootfs"] = true
97
+ end
98
+
99
+ if @config.limits["cpus"]
100
+ params["HostConfig"]["NanoCPUs"] = @config.limits["cpus"] * 10 ** 9
101
+ end
102
+
103
+ {
104
+ "cpu-shares" => "CpuShares",
105
+ "oom-score-adj" => "OomScoreAdj",
106
+ "pids" => "PidsLimit",
107
+ "memory" => "Memory",
108
+ "memory-swap" => "MemorySwap",
109
+ "memory-reservation" => "MemoryReservation",
110
+ "shm-size" => "ShmSize",
111
+ }.each do |limit_name, moby_limit_name|
112
+ if @config.limits[limit_name]
113
+ params["HostConfig"][moby_limit_name] = @config.limits[limit_name]
114
+ end
115
+ end
116
+
117
+ @config.limits.keys.grep(/^ulimit-/).each do |ulimit|
118
+ params["HostConfig"]["Ulimits"] ||= []
119
+ params["HostConfig"]["Ulimits"] << ulimit_structure(ulimit)
120
+ end
121
+
122
+ params["Labels"] = @pod.common_labels.merge(@config.labels)
123
+ params["Labels"]["org.hezmatt.moby-derp.pod-name"] = @pod.name
124
+
125
+ unless @root_container
126
+ params["Labels"]["org.hezmatt.moby-derp.root-container-id"] = @pod.root_container_id
127
+ end
128
+ end
129
+ end
130
+
131
+ def hash_labelled(params)
132
+ params.tap do |params|
133
+ config_hash = params_hash(params)
134
+
135
+ params["Labels"] ||= {}
136
+ params["Labels"]["org.hezmatt.moby-derp.config-hash"] = config_hash
137
+ end
138
+ end
139
+
140
+ def params_hash(params)
141
+ "sha256:#{Digest::SHA256.hexdigest(params.to_json_c14n)}"
142
+ end
143
+
144
+ def image_id
145
+ if @config.image =~ /\A#{Docker::Image::DIGEST}\z/
146
+ @config.image
147
+ else
148
+ if @config.update_image
149
+ begin
150
+ Docker::Image.create(fromImage: @config.image).id
151
+ rescue Docker::Error::NotFoundError
152
+ raise ContainerError,
153
+ "image #{@config.image} for container #{@config.name} cannot be downloaded"
154
+ end
155
+ else
156
+ begin
157
+ Docker::Image.get(@config.image).id
158
+ rescue Docker::Error::NotFoundError
159
+ # Image doesn't exist locally, so we'll have to pull it after all
160
+ begin
161
+ Docker::Image.create(fromImage: @config.image).id
162
+ rescue Docker::Error::NotFoundError
163
+ raise ContainerError,
164
+ "image #{@config.image} for container #{@config.name} cannot be downloaded"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ def parsed_restart_policy
172
+ @config.restart =~ /\A([a-z-]+)(:(\d+))?\z/
173
+ { "Name" => $1 }.tap do |policy|
174
+ if $3
175
+ policy["MaximumRetryCount"] = $3.to_i
176
+ end
177
+ end
178
+ end
179
+
180
+ def merged_mounts
181
+ container_mount_targets = @config.mounts.map { |m| m.target }
182
+
183
+ @config.mounts + @pod.common_mounts.select { |m| !container_mount_targets.include?(m.target) }
184
+ end
185
+
186
+ def mount_structure(mount)
187
+ {
188
+ "Type" => "bind",
189
+ "Source" => "#{@pod.mount_root}/#{mount.source}",
190
+ "Target" => mount.target,
191
+ "ReadOnly" => mount.readonly,
192
+ }
193
+ end
194
+
195
+ def ulimit_structure(limit_key)
196
+ {
197
+ "Name" => limit_key.sub(/^ulimit-/, ''),
198
+ "Soft" => @config.limits[limit_key].first,
199
+ "Hard" => @config.limits[limit_key].last,
200
+ }
201
+ end
202
+
203
+ def container_mac_address
204
+ "02:" + Digest::SHA256.hexdigest(@pod.name + Socket.gethostname)[0..9].scan(/../).join(":")
205
+ end
206
+
207
+ def docker_network
208
+ begin
209
+ network = Docker::Network.get(@pod.network_name)
210
+ rescue Docker::Error::NotFoundError
211
+ raise ContainerError,
212
+ "network #{@pod.network_name} does not exist"
213
+ end
214
+ end
215
+
216
+ def network_uses_ipv6?
217
+ docker_network.info["EnableIPv6"]
218
+ end
219
+
220
+ def container_ipv6_address
221
+ network, masklen = ipv6_network.split("/", 2)
222
+ network = IPAddr.new(network)
223
+ masklen = masklen.to_i
224
+
225
+ (network | Digest::SHA256.hexdigest(container_mac_address).to_i(16) % 2**masklen).to_s
226
+ end
227
+
228
+ def ipv6_network
229
+ ipam = docker_network.info["IPAM"]
230
+ unless ipam["Driver"] == "default"
231
+ raise ContainerError,
232
+ "Unsupported IPAM driver #{ipam["Driver"]} on network #{@pod.network_name}"
233
+ end
234
+
235
+ begin
236
+ ipam["Config"].find do |cfg|
237
+ IPAddr.new(cfg["Subnet"]).ipv6?
238
+ end["Subnet"]
239
+ rescue NoMethodError
240
+ raise ContainerError,
241
+ "No IPv6 subnet found on network #{@pod.network_name}"
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,377 @@
1
+ require_relative "../freedom_patches/docker/image"
2
+ require_relative "./error"
3
+ require_relative "./mount"
4
+
5
+ require "docker-api"
6
+
7
+ module MobyDerp
8
+ class ContainerConfig
9
+ attr_reader :name, :image, :update_image, :command, :environment, :mounts,
10
+ :labels, :readonly, :stop_signal, :stop_timeout, :restart, :limits
11
+
12
+ def initialize(system_config:,
13
+ pod_config:,
14
+ container_name:,
15
+ image:,
16
+ update_image: true,
17
+ command: [],
18
+ environment: {},
19
+ mounts: [],
20
+ labels: {},
21
+ readonly: false,
22
+ stop_signal: "SIGTERM",
23
+ stop_timeout: 10,
24
+ restart: "no",
25
+ limits: {}
26
+ )
27
+ @system_config, @pod_config, @name, @image = system_config, pod_config, "#{pod_config.name}.#{container_name}", image
28
+
29
+ @update_image, @command, @environment, @mounts, @labels = update_image, command, environment, mounts, labels
30
+ @readonly, @stop_signal, @stop_timeout, @restart = readonly, stop_signal, stop_timeout, restart
31
+ @limits = limits
32
+
33
+ validate_image
34
+ validate_update_image
35
+ validate_command
36
+ validate_environment
37
+ validate_mounts
38
+ validate_labels
39
+ validate_readonly
40
+ validate_stop_signal
41
+ validate_stop_timeout
42
+ validate_restart
43
+ validate_limits
44
+ end
45
+
46
+ private
47
+
48
+ def validate_image
49
+ unless @image.is_a?(String)
50
+ raise ConfigurationError,
51
+ "image must be a string"
52
+ end
53
+
54
+ unless @image =~ Docker::Image::IMAGE_REFERENCE
55
+ raise ConfigurationError,
56
+ "image is not a valid image reference"
57
+ end
58
+ end
59
+
60
+ def validate_update_image
61
+ validate_boolean(:update_image)
62
+ end
63
+
64
+ def validate_command
65
+ case @command
66
+ when String
67
+ true
68
+ when Array
69
+ unless @command.all? { |c| String === c }
70
+ raise ConfigurationError, "all elements of the command array must be strings"
71
+ end
72
+ else
73
+ raise ConfigurationError,
74
+ "command must be string or array of strings"
75
+ end
76
+ end
77
+
78
+ def validate_environment
79
+ validate_hash(:environment)
80
+
81
+ if (bad_vars = @environment.keys.select { |k| k =~ /=/ }) != []
82
+ raise ConfigurationError,
83
+ "environment variable names cannot include equals signs: #{bad_vars.inspect}"
84
+ end
85
+ end
86
+
87
+ def validate_mounts
88
+ unless @mounts.is_a?(Array)
89
+ raise ConfigurationError,
90
+ "mounts must be an array"
91
+ end
92
+
93
+ begin
94
+ @mounts.map! { |m| Mount.new(**symbolize_keys(m)) }
95
+ rescue ArgumentError => ex
96
+ case ex.message
97
+ when /unknown keywords?: (.*)$/
98
+ raise ConfigurationError,
99
+ "unknown mount option(s): #{$1}"
100
+ when /missing keywords?: (.*)$/
101
+ raise ConfigurationError,
102
+ "missing mount option(s): #{$1}"
103
+ else
104
+ #:nocov:
105
+ raise
106
+ #:nocov:
107
+ end
108
+ end
109
+ end
110
+
111
+ def validate_labels
112
+ validate_hash(:labels)
113
+ end
114
+
115
+ def validate_readonly
116
+ validate_boolean(:readonly)
117
+ end
118
+
119
+ def validate_stop_signal
120
+ if @stop_signal.is_a?(String)
121
+ signame = @stop_signal.sub(/\ASIG/, "")
122
+ # This is not 100% accurate, because in theory moby-derp could
123
+ # be running on a different platform to the Moby server it is
124
+ # controlling, but we'll worry about that if we ever come to it.
125
+ unless Signal.list.has_key?(signame)
126
+ raise ConfigurationError,
127
+ "unknown signal name: #{@stop_signal.inspect}"
128
+ end
129
+ elsif @stop_signal.is_a?(Integer)
130
+ unless Signal.list.values.include?(@stop_signal)
131
+ raise ConfigurationError,
132
+ "unknown signal ID #{@stop_signal}"
133
+ end
134
+ else
135
+ raise ConfigurationError,
136
+ "stop_signal must be a string or integer"
137
+ end
138
+ end
139
+
140
+ def validate_stop_timeout
141
+ unless @stop_timeout.is_a?(Integer)
142
+ raise ConfigurationError,
143
+ "stop_timeout must be an integer"
144
+ end
145
+
146
+ if @stop_timeout < 0
147
+ raise ConfigurationError,
148
+ "stop_timeout cannot be negative"
149
+ end
150
+ end
151
+
152
+ def validate_restart
153
+ unless @restart.is_a?(String)
154
+ raise ConfigurationError,
155
+ "restart must be a string"
156
+ end
157
+
158
+ unless @restart =~ /\Ano|on-failure(:\d+)?|always|unless-stopped\z/
159
+ raise ConfigurationError,
160
+ "invalid value for restart parameter"
161
+ end
162
+ end
163
+
164
+ KNOWN_LIMITS = %w{
165
+ cpus cpu-shares memory memory-swap memory-reservation oom-score-adj
166
+ pids shm-size ulimit-core ulimit-cpu ulimit-data ulimit-fsize
167
+ ulimit-memlock ulimit-msgqueue ulimit-nofile ulimit-rttime ulimit-stack
168
+ }
169
+
170
+ def validate_limits
171
+ unless @limits.is_a?(Hash)
172
+ raise ConfigurationError,
173
+ "limits must be a map"
174
+ end
175
+
176
+ if (bad_keys = @limits.keys - KNOWN_LIMITS) != []
177
+ raise ConfigurationError,
178
+ "unknown limit(s): #{bad_keys.inspect}"
179
+ end
180
+
181
+ validate_cpus_limit
182
+ validate_cpushares_limit
183
+ validate_memory_limit
184
+ validate_memoryswap_limit
185
+ validate_memoryreservation_limit
186
+ validate_oomscoreadj_limit
187
+ validate_pids_limit
188
+ validate_shmsize_limit
189
+ validate_ulimits
190
+ end
191
+
192
+ def validate_cpus_limit
193
+ return unless @limits.has_key?("cpus")
194
+
195
+ unless @limits["cpus"].is_a?(Numeric)
196
+ raise ConfigurationError,
197
+ "cpus limit must be a number"
198
+ end
199
+
200
+ if @limits["cpus"] <= 0
201
+ raise ConfigurationError,
202
+ "cpus limit must be a positive number"
203
+ end
204
+
205
+ if @limits["cpus"] > @system_config.cpu_count
206
+ raise ConfigurationError,
207
+ "cannot use #{@limits["cpus"]}, as the system only has #{@system_config.cpu_count} CPUs"
208
+ end
209
+ end
210
+
211
+ def validate_cpushares_limit
212
+ return unless @limits.has_key?("cpu-shares")
213
+
214
+ unless @limits["cpu-shares"].is_a?(Integer)
215
+ raise ConfigurationError,
216
+ "cpu-shares limit must be an integer"
217
+ end
218
+
219
+ unless (2..1024).include?(@limits["cpu-shares"])
220
+ raise ConfigurationError,
221
+ "cpu-shares limit must be an integer between 2 and 1024 inclusive"
222
+ end
223
+ end
224
+
225
+ def validate_memory_limit
226
+ validate_memory_type_limit("memory")
227
+ end
228
+
229
+ def validate_memoryswap_limit
230
+ validate_memory_type_limit("memory-swap")
231
+ end
232
+
233
+ def validate_memoryreservation_limit
234
+ validate_memory_type_limit("memory-reservation")
235
+ end
236
+
237
+ def validate_oomscoreadj_limit
238
+ return unless @limits.has_key?("oom-score-adj")
239
+
240
+ unless @limits["oom-score-adj"].is_a?(Integer)
241
+ raise ConfigurationError,
242
+ "oom-score-adj limit must be an integer"
243
+ end
244
+
245
+ unless (0..1000).include?(@limits["oom-score-adj"])
246
+ raise ConfigurationError,
247
+ "oom-score-adj limit must be an integer between 0 and 1000 inclusive"
248
+ end
249
+ end
250
+
251
+ def validate_pids_limit
252
+ return unless @limits.has_key?("pids")
253
+
254
+ unless @limits["pids"].is_a?(Integer)
255
+ raise ConfigurationError,
256
+ "pids limit must be an integer"
257
+ end
258
+
259
+ # As far as I can see, the only 32-bit platform that Moby supports is
260
+ # armhf. Extend the list if required.
261
+ max_pids = @system_config.cpu_bits == 32 ? 2**15 : 2**22
262
+
263
+ unless (-1..max_pids).include?(@limits["pids"])
264
+ raise ConfigurationError,
265
+ "pids limit must be an integer between -1 and #{max_pids} inclusive"
266
+ end
267
+ end
268
+
269
+ def validate_shmsize_limit
270
+ validate_memory_type_limit("shm-size")
271
+ end
272
+
273
+ def validate_ulimits
274
+ @limits.keys.grep(/\Aulimit-.*\z/).each do |ulimit|
275
+ unless @limits[ulimit] =~ /\A(unlimited|\d+)(:(unlimited|\d+))?\z/
276
+ raise ConfigurationError,
277
+ "invalid limit syntax for #{ulimit}: must be <softlimit>[:<hardlimit>]"
278
+ end
279
+
280
+ @limits[ulimit] = [ulimit_value($1)]
281
+
282
+ if $2.nil?
283
+ @limits[ulimit][1] = @limits[ulimit][0]
284
+ else
285
+ @limits[ulimit][1] = ulimit_value($3)
286
+ end
287
+ end
288
+ end
289
+
290
+ def ulimit_value(s)
291
+ if s == "unlimited"
292
+ -1
293
+ else
294
+ s.to_i
295
+ end
296
+ end
297
+
298
+ def validate_memory_type_limit(name)
299
+ return unless @limits.has_key?(name)
300
+
301
+ case @limits[name]
302
+ when Integer
303
+ if @limits[name] < 0
304
+ raise ConfigurationError,
305
+ "#{name} limit must not be a negative number"
306
+ end
307
+ when String
308
+ unless @limits[name] =~ /\A(\d+(\.\d+)?)([kKmMgGtTpP]?)[bB]?\z/
309
+ raise ConfigurationError,
310
+ "invalid value for #{name} limit: #{@limits[name]}"
311
+ end
312
+ @limits[name] = ($1.to_f * multiplier($3)).to_i
313
+ else
314
+ raise ConfigurationError,
315
+ "#{name} limit must be a string or an integer"
316
+ end
317
+ end
318
+
319
+ def validate_boolean(name)
320
+ v = instance_variable_get(:"@#{name}")
321
+ unless v == true || v == false
322
+ raise ConfigurationError,
323
+ "#{name} setting must be a boolean"
324
+ end
325
+ end
326
+
327
+ def validate_hash(name)
328
+ h = instance_variable_get(:"@#{name}")
329
+
330
+ unless h.is_a?(Hash)
331
+ raise ConfigurationError,
332
+ "#{h} is not a map"
333
+ end
334
+
335
+ unless (bad_keys = h.keys.select { |k| !k.is_a?(String) }) == []
336
+ raise ConfigurationError,
337
+ "#{h} contains non-string key(s): #{bad_keys.inspect}"
338
+ end
339
+
340
+ unless (bad_values = h.values.select { |v| !v.is_a?(String) }) == []
341
+ raise ConfigurationError,
342
+ "#{h} contains non-string value(s): #{bad_values.inspect}"
343
+ end
344
+ end
345
+
346
+ def symbolize_keys(h)
347
+ {}.tap do |res|
348
+ h.keys.each do |k|
349
+ res[k.to_sym] = h[k]
350
+ end
351
+ end
352
+ end
353
+
354
+ def multiplier(s)
355
+ case s.upcase
356
+ when ''
357
+ 1
358
+ when 'K'
359
+ 1024
360
+ when 'M'
361
+ 1024 * 1024
362
+ when 'G'
363
+ 1024 * 1024 * 1024
364
+ when 'T'
365
+ 1024 * 1024 * 1024 * 1024
366
+ when 'P'
367
+ 1024 * 1024 * 1024 * 1024 * 1024
368
+ else
369
+ #:nocov:
370
+ raise ConfigurationError,
371
+ "Unknown suffix #{s.inspect}"
372
+ #:nocov:
373
+ end
374
+ end
375
+
376
+ end
377
+ end
@@ -0,0 +1,15 @@
1
+ module MobyDerp
2
+ # Base class for all MobyDerp-specific errors
3
+ class Error < StandardError; end
4
+
5
+ # Raised when something isn't quite right with the system or pod
6
+ # configuration
7
+ class ConfigurationError < Error; end
8
+
9
+ # Indicates there was a problem manipulating a live container
10
+ class ContainerError < Error; end
11
+
12
+ # Only appears when an inviolable assertion is invalid, and indicates
13
+ # there is a bug in the code
14
+ class BugError < Error; end
15
+ end
@@ -0,0 +1,26 @@
1
+ module MobyDerp
2
+ module LoggingHelpers
3
+ private
4
+
5
+ def log_exception(ex, progname = nil)
6
+ #:nocov:
7
+ progname ||= "#{self.class.to_s}##{caller_locations(2, 1).first.label}"
8
+
9
+ logger.error(progname) do
10
+ explanation = if block_given?
11
+ yield
12
+ else
13
+ nil
14
+ end
15
+
16
+ (["#{explanation}#{explanation ? ": " : ""}#{ex.message} (#{ex.class})"] + ex.backtrace).join("\n ")
17
+ end
18
+ #:nocov:
19
+ end
20
+
21
+ def logloc
22
+ loc = caller_locations.first
23
+ "#{self.class}##{loc.label}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ module MobyDerp
2
+ class MobyInfo
3
+ attr_reader :cpu_count, :cpu_bits
4
+
5
+ def initialize(info)
6
+ @cpu_count = info["NCPU"]
7
+ # As far as I can tell, the only 32-bit platform Moby supports is
8
+ # armhf; if that turns out to be incorrect, amend the list below.
9
+ @cpu_bits = %w{armhf}.include?(info["Architecture"]) ? 32 : 64
10
+ end
11
+ end
12
+ end