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,56 @@
1
+ module MobyDerp
2
+ class Mount
3
+ attr_reader :source, :target, :readonly
4
+
5
+ def initialize(source:, target:, readonly: false)
6
+ @source, @target, @readonly = source, target, readonly
7
+
8
+ validate_source
9
+ validate_target
10
+ validate_readonly
11
+ end
12
+
13
+ private
14
+
15
+ def validate_source
16
+ unless @source.is_a?(String)
17
+ raise ConfigurationError,
18
+ "mount source must be a string (got #{@source.inspect})"
19
+ end
20
+
21
+ if @source =~ %r{(^|/)\.\.($|/)}
22
+ raise ConfigurationError,
23
+ "path traversal detected -- nice try, buddy"
24
+ end
25
+
26
+ if @source =~ %r{^(/|~)}
27
+ raise ConfigurationError,
28
+ "mount sources can only be relative paths"
29
+ end
30
+ end
31
+
32
+ def validate_target
33
+ unless @target.is_a?(String)
34
+ raise ConfigurationError,
35
+ "mount target must be a string (got #{@target.inspect})"
36
+ end
37
+
38
+ if @target =~ %r{(^|/)\.\.($|/)}
39
+ raise ConfigurationError,
40
+ "target path must not contain '../'"
41
+ end
42
+
43
+ if @target !~ %r{^/}
44
+ raise ConfigurationError,
45
+ "mount target must be an absolute path"
46
+ end
47
+ end
48
+
49
+ def validate_readonly
50
+ unless @readonly == true || @readonly == false
51
+ raise ConfigurationError,
52
+ "readonly flag must be either true or false (got #{@readonly.inspect})"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,89 @@
1
+ require_relative "./container"
2
+ require_relative "./logging_helpers"
3
+
4
+ module MobyDerp
5
+ class Pod
6
+ include LoggingHelpers
7
+
8
+ attr_reader :logger
9
+
10
+ def initialize(pod_config)
11
+ @config = pod_config
12
+ @logger = pod_config.logger
13
+ end
14
+
15
+ def run
16
+ @logger.info(logloc) { "Checking root container" }
17
+ @root_container_id = root_container.run
18
+
19
+ @logger.debug(logloc) { "Root container ID is #{@root_container_id}" }
20
+
21
+ @config.containers.each do |cfg|
22
+ @logger.info(logloc) { "Checking container #{cfg.name}" }
23
+
24
+ begin
25
+ MobyDerp::Container.new(pod: self, container_config: cfg).run
26
+ rescue MobyDerp::ContainerError => ex
27
+ raise MobyDerp::ContainerError,
28
+ "error while running container #{cfg.name}: #{ex.message}",
29
+ ex.backtrace
30
+ end
31
+ end
32
+ end
33
+
34
+ def name
35
+ @config.name
36
+ end
37
+
38
+ def root_container_id
39
+ if @root_container_id.nil?
40
+ raise MobyDerp::BugError,
41
+ "root_container_id requested before root container was spawned"
42
+ else
43
+ @root_container_id
44
+ end
45
+ end
46
+
47
+ def common_labels
48
+ @config.common_labels
49
+ end
50
+
51
+ def common_environment
52
+ @config.common_environment
53
+ end
54
+
55
+ def common_mounts
56
+ @config.common_mounts
57
+ end
58
+
59
+ def mount_root
60
+ @config.mount_root
61
+ end
62
+
63
+ def network_name
64
+ @config.network_name
65
+ end
66
+
67
+ def hostname
68
+ @config.hostname
69
+ end
70
+
71
+ private
72
+
73
+ def root_container
74
+ MobyDerp::Container.new(pod: self, container_config: root_container_config, root_container: true)
75
+ end
76
+
77
+ def root_container_config
78
+ MobyDerp::ContainerConfig.new(
79
+ system_config: @config.system_config,
80
+ pod_config: @config,
81
+ container_name: "root",
82
+ image: "gcr.io/google_containers/pause-amd64:3.0",
83
+ labels: @config.root_labels,
84
+ readonly: true,
85
+ restart: "always",
86
+ )
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,235 @@
1
+ require_relative "./config_file"
2
+ require_relative "./container_config"
3
+ require_relative "./logging_helpers"
4
+ require_relative "./mount"
5
+
6
+ require "safe_yaml"
7
+ require "socket"
8
+
9
+ module MobyDerp
10
+ class PodConfig < ConfigFile
11
+ include LoggingHelpers
12
+
13
+ attr_reader :name,
14
+ :containers,
15
+ :hostname,
16
+ :common_environment,
17
+ :common_labels,
18
+ :root_labels,
19
+ :common_mounts,
20
+ :expose,
21
+ :publish,
22
+ :publish_all,
23
+ :mount_root,
24
+ :system_config,
25
+ :logger
26
+
27
+ def initialize(filename, system_config)
28
+ @logger = system_config.logger
29
+
30
+ super(filename)
31
+
32
+ @system_config = system_config
33
+
34
+ @name = File.basename(filename, ".*")
35
+ validate_name
36
+
37
+
38
+ unless @config.has_key?("containers")
39
+ raise ConfigurationError,
40
+ "no containers defined"
41
+ end
42
+ @containers = @config.fetch("containers")
43
+ validate_containers
44
+
45
+ @logger.debug(logloc) { "Hostname is #{Socket.gethostname}" }
46
+ @hostname = @config.fetch("hostname", "#{@name.gsub("_", "-")}-#{Socket.gethostname}")
47
+
48
+ @common_environment = @config.fetch("common_environment", {})
49
+ @common_labels = @config.fetch("common_labels", {})
50
+ @root_labels = @config.fetch("root_labels", {})
51
+ validate_common_environment
52
+ validate_hash(:common_labels)
53
+ validate_hash(:root_labels)
54
+
55
+ @common_mounts = @config.fetch("common_mounts", [])
56
+ @expose = @config.fetch("expose", [])
57
+ @publish = @config.fetch("publish", [])
58
+ validate_common_mounts
59
+ validate_expose
60
+ validate_publish
61
+
62
+ @publish_all = @config.fetch("publish_all", false)
63
+ validate_publish_all
64
+
65
+ @mount_root = File.join(system_config.mount_root, @name)
66
+ end
67
+
68
+ def network_name
69
+ @system_config.network_name
70
+ end
71
+
72
+ private
73
+
74
+ def validate_name
75
+ @logger.debug(logloc) { "@name: #{@name.inspect}" }
76
+ unless @name =~ /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
77
+ raise ConfigurationError,
78
+ "pod name is invalid (must start with an alphanumeric character, and only contain alphanumerics, underscores, and hyphens)"
79
+ end
80
+ end
81
+
82
+ def validate_containers
83
+ @logger.debug(logloc) { "@containers: #{@containers.inspect}" }
84
+ unless @containers.is_a?(Hash)
85
+ raise ConfigurationError,
86
+ "containers must be a map of container names and container data"
87
+ end
88
+
89
+ @containers.each do |name, data|
90
+ unless name =~ /\A[A-Za-z0-9_-]+\z/
91
+ raise ConfigurationError,
92
+ "container name #{name.inspect} is invalid (must contain only alphanumerics, underscores, and hyphens)"
93
+ end
94
+ end
95
+
96
+ begin
97
+ @containers = @containers.map { |k, v| ContainerConfig.new(system_config: @system_config, pod_config: self, container_name: k, **symbolize_keys(v)) }
98
+ rescue ArgumentError => ex
99
+ case ex.message
100
+ when /unknown keywords?: (.*)$/
101
+ raise ConfigurationError,
102
+ "unknown mount option(s): #{$1}"
103
+ when /missing keywords?: (.*)$/
104
+ raise ConfigurationError,
105
+ "missing mount option(s): #{$1}"
106
+ else
107
+ #:nocov:
108
+ raise
109
+ #:nocov:
110
+ end
111
+ end
112
+ end
113
+
114
+ def validate_common_environment
115
+ validate_hash(:common_environment)
116
+
117
+ if (bad_vars = @common_environment.keys.select { |k| k =~ /=/ }) != []
118
+ raise ConfigurationError,
119
+ "environment variable names cannot include equals signs: #{bad_vars.inspect}"
120
+ end
121
+ end
122
+
123
+ def validate_hash(name)
124
+ h = instance_variable_get(:"@#{name}")
125
+ @logger.debug(logloc) { "@#{name}: #{h.inspect}" }
126
+
127
+ unless h.is_a?(Hash)
128
+ raise ConfigurationError,
129
+ "#{h} is not a map"
130
+ end
131
+
132
+ unless (bad_keys = h.keys.select { |k| !k.is_a?(String) }) == []
133
+ raise ConfigurationError,
134
+ "#{h} contains non-string key(s): #{bad_keys.inspect}"
135
+ end
136
+
137
+ unless (bad_values = h.values.select { |v| !v.is_a?(String) }) == []
138
+ raise ConfigurationError,
139
+ "#{h} contains non-string value(s): #{bad_values.inspect}"
140
+ end
141
+ end
142
+
143
+ def validate_common_mounts
144
+ @logger.debug(logloc) { "@common_mounts: #{@common_mounts.inspect}" }
145
+ unless @common_mounts.is_a?(Array)
146
+ raise ConfigurationError,
147
+ "common_mounts must be an array"
148
+ end
149
+
150
+ begin
151
+ @common_mounts.map! { |m| Mount.new(**symbolize_keys(m)) }
152
+ rescue ArgumentError => ex
153
+ case ex.message
154
+ when /unknown keywords?: (.*)$/
155
+ raise ConfigurationError,
156
+ "unknown mount option(s): #{$1}"
157
+ when /missing keywords?: (.*)$/
158
+ raise ConfigurationError,
159
+ "missing mount option(s): #{$1}"
160
+ else
161
+ #:nocov:
162
+ raise
163
+ #:nocov:
164
+ end
165
+ end
166
+ end
167
+
168
+ def validate_expose
169
+ @logger.debug(logloc) { "@expose: #{@expose.inspect}" }
170
+ unless @expose.is_a?(Array)
171
+ raise ConfigurationError,
172
+ "expose must be an array"
173
+ end
174
+
175
+ @expose.map!(&:to_s)
176
+
177
+ @expose.each do |e|
178
+ unless e.is_a?(String) && e =~ %r{\A\d+(/(tcp|udp))?\z}
179
+ raise ConfigurationError,
180
+ "exposed ports must be integers, with an optional protocol specifier (got #{e.inspect})"
181
+ end
182
+
183
+ if e.to_i < 1 || e.to_i > 65535
184
+ raise ConfigurationError,
185
+ "exposed port #{e} is out of range (expected 1-65535)"
186
+ end
187
+ end
188
+ end
189
+
190
+ def validate_publish
191
+ @logger.debug(logloc) { "@publish: #{@publish.inspect}" }
192
+ unless @publish.is_a?(Array)
193
+ raise ConfigurationError,
194
+ "publish must be an array"
195
+ end
196
+
197
+ unless @publish.all? { |p| String === p }
198
+ raise ConfigurationError,
199
+ "publish elements must be strings"
200
+ end
201
+
202
+ @publish.each do |e|
203
+ unless e =~ %r{\A(\d+(?:-\d+)?)?:(\d+)(?:-\d+)?(/(tcp|udp))?\z}
204
+ raise ConfigurationError,
205
+ "invalid publish port spec #{e.inspect}"
206
+ end
207
+
208
+ if $2.to_i < 1 || $2.to_i > 65535
209
+ raise ConfigurationError,
210
+ "publish port spec #{e} is out of range (expected 1-65535)"
211
+ end
212
+
213
+ if $1 && @system_config.port_whitelist[$1] != @name
214
+ raise ConfigurationError,
215
+ "cannot bind to a non-whitelisted host port"
216
+ end
217
+ end
218
+ end
219
+
220
+ def validate_publish_all
221
+ unless @publish_all == true || @publish_all == false
222
+ raise ConfigurationError,
223
+ "publish_all must be either true or false"
224
+ end
225
+ end
226
+
227
+ def symbolize_keys(h)
228
+ {}.tap do |res|
229
+ h.keys.each do |k|
230
+ res[k.to_sym] = h[k]
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,52 @@
1
+ require_relative "./config_file"
2
+
3
+ require "safe_yaml"
4
+
5
+ module MobyDerp
6
+ class SystemConfig < ConfigFile
7
+ attr_reader :mount_root, :port_whitelist, :network_name, :cpu_count, :cpu_bits
8
+
9
+ def initialize(filename, moby_info, logger)
10
+ @logger = logger
11
+
12
+ super(filename)
13
+
14
+ @mount_root = @config["mount_root"]
15
+ @port_whitelist = stringify_keys(@config["port_whitelist"] || {})
16
+ @network_name = @config["network_name"] || "bridge"
17
+
18
+ @cpu_count = moby_info["NCPU"]
19
+ # As far as I can tell, the only 32-bit platform Moby supports is
20
+ # armhf; if that turns out to be incorrect, amend the list below.
21
+ @cpu_bits = %w{armhf}.include?(moby_info["Architecture"]) ? 32 : 64
22
+
23
+ unless @mount_root.is_a?(String)
24
+ raise ConfigurationError,
25
+ "mount_root must be a string"
26
+ end
27
+
28
+ unless @mount_root =~ /\A\//
29
+ raise ConfigurationError,
30
+ "mount_root must be an absolute path"
31
+ end
32
+
33
+ unless @network_name.is_a?(String)
34
+ raise ConfigurationError,
35
+ "network_name must be a string"
36
+ end
37
+
38
+ unless File.directory?(@mount_root)
39
+ raise ConfigurationError,
40
+ "mount_root #{@mount_root} must exist and be a directory"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def stringify_keys(h)
47
+ {}.tap do |res|
48
+ h.keys.each { |k| res[k.to_s] = h[k] }
49
+ end
50
+ end
51
+ end
52
+ end
data/moby-derp.gemspec ADDED
@@ -0,0 +1,39 @@
1
+ begin
2
+ require 'git-version-bump'
3
+ rescue LoadError
4
+ nil
5
+ end
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "moby-derp"
9
+
10
+ s.version = GVB.version rescue "0.0.0.1.NOGVB"
11
+ s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d")
12
+
13
+ s.platform = Gem::Platform::RUBY
14
+
15
+ s.summary = "A simple management system for a pod of moby containers"
16
+
17
+ s.authors = ["Matt Palmer"]
18
+ s.email = ["theshed+moby-derp@hezmatt.org"]
19
+ s.homepage = "http://github.com/mpalmer/moby-derp"
20
+
21
+ s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ }
22
+
23
+ s.required_ruby_version = ">= 2.3.0"
24
+
25
+ s.add_runtime_dependency "docker-api"
26
+ s.add_runtime_dependency "json-canonicalization"
27
+ s.add_runtime_dependency "safe_yaml"
28
+
29
+ s.add_development_dependency 'bundler'
30
+ s.add_development_dependency 'deep_merge'
31
+ s.add_development_dependency 'github-release'
32
+ s.add_development_dependency 'git-version-bump'
33
+ s.add_development_dependency 'guard-rspec'
34
+ s.add_development_dependency 'rake', '~> 12'
35
+ s.add_development_dependency 'redcarpet'
36
+ s.add_development_dependency 'rspec'
37
+ s.add_development_dependency 'simplecov'
38
+ s.add_development_dependency 'yard'
39
+ end
@@ -0,0 +1,18 @@
1
+ load test_helper
2
+
3
+ @test "Minimal pod creation" {
4
+ config_file <<-'EOF'
5
+ containers:
6
+ bob:
7
+ image: busybox:latest
8
+ command: sleep 600
9
+ common_labels:
10
+ moby-derp-smoke-test: ayup
11
+ EOF
12
+
13
+ run $MOBY_DERP_BIN $TEST_CONFIG_FILE
14
+
15
+ [ "$status" = "0" ]
16
+ container_running "mdst"
17
+ container_running "mdst.bob"
18
+ }
@@ -0,0 +1,7 @@
1
+ load test_helper
2
+
3
+ @test "No config file specified" {
4
+ run $MOBY_DERP_BIN
5
+ [ "$status" = "1" ]
6
+ [[ "$output" =~ No\ config\ file\ specified ]]
7
+ }
@@ -0,0 +1,29 @@
1
+ : "${MOBY_DERP_BIN:=$BATS_TEST_DIRNAME/../bin/moby-derp}"
2
+
3
+ config_file() {
4
+ TEST_CONFIG_FILE="$BATS_TMPDIR/mdst.yml"
5
+ cat >"${BATS_TMPDIR}/mdst.yml"
6
+ }
7
+
8
+ container_running() {
9
+ [ "$(docker container inspect "$1" --format='{{.State.Running}}')" = "true" ]
10
+ }
11
+
12
+ setup() {
13
+ TEST_SYSTEM_CONFIG_FILE="$BATS_TMPDIR/moby_derp_system_config.yml"
14
+ mkdir -p $BATS_TMPDIR/docker
15
+
16
+ cat <<EOF >"$TEST_SYSTEM_CONFIG_FILE"
17
+ mount_root: $BATS_TMPDIR/docker
18
+ EOF
19
+
20
+ export MOBY_DERP_SYSTEM_CONFIG_FILE="$TEST_SYSTEM_CONFIG_FILE"
21
+ }
22
+
23
+ teardown() {
24
+ for i in $(docker ps -a --format='{{.Names}}'); do
25
+ if docker container inspect $i --format='{{.Config.Labels}}' | grep -q moby-derp-smoke-test; then
26
+ docker rm -f $i
27
+ fi
28
+ done
29
+ }