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