moby-derp 0.1.0

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,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