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