moby-derp 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +14 -0
- data/LICENCE +674 -0
- data/README.md +231 -0
- data/bin/moby-derp +56 -0
- data/example.yml +297 -0
- data/lib/freedom_patches/docker/image.rb +23 -0
- data/lib/moby_derp/config_file.rb +33 -0
- data/lib/moby_derp/container.rb +245 -0
- data/lib/moby_derp/container_config.rb +377 -0
- data/lib/moby_derp/error.rb +15 -0
- data/lib/moby_derp/logging_helpers.rb +26 -0
- data/lib/moby_derp/moby_info.rb +12 -0
- data/lib/moby_derp/mount.rb +56 -0
- data/lib/moby_derp/pod.rb +89 -0
- data/lib/moby_derp/pod_config.rb +235 -0
- data/lib/moby_derp/system_config.rb +52 -0
- data/moby-derp.gemspec +39 -0
- data/smoke_tests/minimal.bats +18 -0
- data/smoke_tests/no_file.bats +7 -0
- data/smoke_tests/test_helper.bash +29 -0
- metadata +247 -0
@@ -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,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
|
+
}
|