odysseus-core 0.3.2 → 0.9.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 +4 -4
- data/CHANGELOG.md +485 -1
- data/LICENSE.txt +25 -7
- data/README.md +12 -3
- data/lib/odysseus/builder/client.rb +15 -15
- data/lib/odysseus/caddy/client.rb +119 -42
- data/lib/odysseus/command_redaction.rb +38 -0
- data/lib/odysseus/config/parser.rb +43 -18
- data/lib/odysseus/core/deploy_versioning.rb +42 -0
- data/lib/odysseus/core/environment.rb +63 -0
- data/lib/odysseus/core/version.rb +1 -1
- data/lib/odysseus/core/volume_namespacer.rb +0 -0
- data/lib/odysseus/core.rb +2 -2
- data/lib/odysseus/deploy_log.rb +120 -0
- data/lib/odysseus/deploy_version.rb +9 -0
- data/lib/odysseus/deployer/dependency_manager.rb +121 -0
- data/lib/odysseus/deployer/executor.rb +213 -161
- data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
- data/lib/odysseus/deployer/ssh.rb +92 -18
- data/lib/odysseus/docker/client.rb +240 -48
- data/lib/odysseus/docker/labels.rb +43 -0
- data/lib/odysseus/errors.rb +10 -0
- data/lib/odysseus/git.rb +70 -0
- data/lib/odysseus/host_paths.rb +79 -0
- data/lib/odysseus/host_providers/base.rb +1 -1
- data/lib/odysseus/host_providers/static.rb +1 -1
- data/lib/odysseus/host_providers.rb +3 -4
- data/lib/odysseus/host_verifier.rb +156 -0
- data/lib/odysseus/host_versions.rb +59 -0
- data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
- data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
- data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
- data/lib/odysseus/plugins.rb +72 -0
- data/lib/odysseus/retention_plan.rb +14 -0
- data/lib/odysseus/retention_planner.rb +58 -0
- data/lib/odysseus/rollback_plan.rb +20 -0
- data/lib/odysseus/rollback_planner.rb +107 -0
- data/lib/odysseus/sails.rb +0 -0
- data/lib/odysseus/secrets/encrypted_file.rb +5 -7
- data/lib/odysseus/secrets/loader.rb +1 -3
- data/lib/odysseus/setup/docker_apt.rb +178 -0
- data/lib/odysseus/setup/escalation.rb +73 -0
- data/lib/odysseus/setup/preparer.rb +372 -0
- data/lib/odysseus/setup/public_key.rb +127 -0
- data/lib/odysseus/validators/config.rb +38 -25
- data/lib/odysseus/version_resolver.rb +80 -0
- data/lib/odysseus.rb +0 -0
- data/sig/odysseus/core.rbs +0 -0
- metadata +48 -27
- data/Rakefile +0 -12
- data/lib/odysseus/version.rb +0 -5
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# lib/odysseus/caddy/client.rb
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
4
5
|
|
|
5
6
|
module Odysseus
|
|
6
7
|
module Caddy
|
|
7
8
|
class Client
|
|
8
9
|
ADMIN_API_PORT = 2019
|
|
9
|
-
CONTAINER_NAME = 'odysseus-caddy'
|
|
10
|
-
CADDY_IMAGE = 'caddy:2-alpine'
|
|
10
|
+
CONTAINER_NAME = 'odysseus-caddy'.freeze
|
|
11
|
+
CADDY_IMAGE = 'caddy:2-alpine'.freeze
|
|
11
12
|
|
|
12
13
|
# @param ssh [Odysseus::Deployer::SSH] SSH connection to server
|
|
13
14
|
# @param docker [Odysseus::Docker::Client] Docker client
|
|
@@ -17,10 +18,28 @@ module Odysseus
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# Ensure Caddy is running
|
|
21
|
+
#
|
|
22
|
+
# There are three states, not two: running, absent, and stopped-but-
|
|
23
|
+
# present. Docker refuses `docker run --name odysseus-caddy` while a
|
|
24
|
+
# container by that name already exists, so a stopped Caddy would
|
|
25
|
+
# otherwise fail every deploy after it, forever.
|
|
26
|
+
#
|
|
27
|
+
# A stopped container is removed rather than `docker start`ed. It
|
|
28
|
+
# carries whatever configuration it was *created* with, including its
|
|
29
|
+
# volume mount — and this branch changed Caddy's data directory from a
|
|
30
|
+
# fixed system path to one derived from the deploy user (see
|
|
31
|
+
# Odysseus::HostPaths#caddy_dir). `docker start` would silently
|
|
32
|
+
# resurrect a container mounting the old path while everything else
|
|
33
|
+
# believes it moved. Recreating always applies current configuration.
|
|
34
|
+
# Nothing is lost: certificates live in the mounted volume, and routes
|
|
35
|
+
# are re-added by the deploy that follows.
|
|
36
|
+
#
|
|
20
37
|
# @return [Boolean] true if caddy is running
|
|
21
38
|
def ensure_running
|
|
22
39
|
return true if running?
|
|
23
40
|
|
|
41
|
+
@docker.remove(CONTAINER_NAME) if @docker.container_exists?(CONTAINER_NAME)
|
|
42
|
+
|
|
24
43
|
start_caddy
|
|
25
44
|
running?
|
|
26
45
|
end
|
|
@@ -34,21 +53,35 @@ module Odysseus
|
|
|
34
53
|
# Start Caddy container
|
|
35
54
|
def start_caddy
|
|
36
55
|
# Create network if not exists (with label to protect from prune)
|
|
37
|
-
@ssh.execute(
|
|
56
|
+
@ssh.execute('docker network create --label odysseus.managed=true odysseus 2>/dev/null || true')
|
|
38
57
|
|
|
39
58
|
# Create data directory for certificates
|
|
40
|
-
@ssh.execute("mkdir -p
|
|
59
|
+
@ssh.execute("mkdir -p #{Shellwords.escape(host_paths.caddy_dir)}")
|
|
41
60
|
|
|
42
61
|
# Run Caddy with admin API enabled and persistent storage for certs
|
|
62
|
+
#
|
|
63
|
+
# The mkdir above and this mount must name the same directory, or
|
|
64
|
+
# Caddy starts against an empty one and silently has no certificates.
|
|
65
|
+
# Docker::Client interpolates `-v` values into its command line
|
|
66
|
+
# unescaped, so the directory is escaped here rather than there.
|
|
67
|
+
#
|
|
68
|
+
# The admin API is published on loopback only: it can rewrite the
|
|
69
|
+
# proxy config for every service on the host, and #api_request only
|
|
70
|
+
# ever reaches it via `curl localhost` over SSH, so nothing needs it
|
|
71
|
+
# exposed beyond the host itself. CADDY_ADMIN must stay bound to
|
|
72
|
+
# 0.0.0.0 *inside* the container regardless — that is what the
|
|
73
|
+
# published port maps to, and binding it to 127.0.0.1 there would put
|
|
74
|
+
# it behind the container's own loopback, unreachable even from the
|
|
75
|
+
# host.
|
|
43
76
|
@docker.run(
|
|
44
77
|
name: CONTAINER_NAME,
|
|
45
78
|
image: CADDY_IMAGE,
|
|
46
79
|
options: {
|
|
47
80
|
service: 'odysseus-proxy',
|
|
48
|
-
ports: ['80:80', '443:443', "
|
|
81
|
+
ports: ['80:80', '443:443', "127.0.0.1:#{ADMIN_API_PORT}:#{ADMIN_API_PORT}"],
|
|
49
82
|
network: 'odysseus',
|
|
50
83
|
restart: 'unless-stopped',
|
|
51
|
-
volumes: [
|
|
84
|
+
volumes: ["#{Shellwords.escape(host_paths.caddy_dir)}:/data"],
|
|
52
85
|
env: {
|
|
53
86
|
'CADDY_ADMIN' => "0.0.0.0:#{ADMIN_API_PORT}"
|
|
54
87
|
},
|
|
@@ -74,7 +107,7 @@ module Odysseus
|
|
|
74
107
|
enable_tls_for_hosts(hosts, email: ssl_email) if ssl
|
|
75
108
|
|
|
76
109
|
# Check if route already exists for this service
|
|
77
|
-
routes = api_request('GET',
|
|
110
|
+
routes = api_request('GET', '/config/apps/http/servers/srv0/routes') || []
|
|
78
111
|
existing_idx = routes.find_index { |r| r['@id'] == "route-#{service}" }
|
|
79
112
|
|
|
80
113
|
if existing_idx
|
|
@@ -88,7 +121,8 @@ module Odysseus
|
|
|
88
121
|
current_upstreams = routes[existing_idx].dig('handle', 0, 'upstreams') || []
|
|
89
122
|
unless current_upstreams.any? { |u| u['dial'] == upstream }
|
|
90
123
|
current_upstreams << { 'dial' => upstream }
|
|
91
|
-
api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/handle/0/upstreams",
|
|
124
|
+
api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/handle/0/upstreams",
|
|
125
|
+
current_upstreams)
|
|
92
126
|
end
|
|
93
127
|
else
|
|
94
128
|
# Create new route - prepend at index 0 so it matches before default routes
|
|
@@ -98,7 +132,7 @@ module Odysseus
|
|
|
98
132
|
upstreams: [upstream],
|
|
99
133
|
healthcheck: healthcheck
|
|
100
134
|
)
|
|
101
|
-
api_request('PUT',
|
|
135
|
+
api_request('PUT', '/config/apps/http/servers/srv0/routes/0', config)
|
|
102
136
|
end
|
|
103
137
|
end
|
|
104
138
|
|
|
@@ -107,12 +141,12 @@ module Odysseus
|
|
|
107
141
|
# @param upstream [String, nil] upstream to remove, or nil to remove entire route
|
|
108
142
|
def remove_upstream(service:, upstream:)
|
|
109
143
|
# Get current config
|
|
110
|
-
routes = api_request('GET',
|
|
144
|
+
routes = api_request('GET', '/config/apps/http/servers/srv0/routes')
|
|
111
145
|
return unless routes
|
|
112
146
|
|
|
113
147
|
# Find route for this service and remove the upstream
|
|
114
148
|
routes.each_with_index do |route, idx|
|
|
115
|
-
next unless route
|
|
149
|
+
next unless route['@id'] == "route-#{service}"
|
|
116
150
|
|
|
117
151
|
# If no specific upstream, remove the entire route
|
|
118
152
|
if upstream.nil?
|
|
@@ -195,7 +229,7 @@ module Odysseus
|
|
|
195
229
|
service: service_name,
|
|
196
230
|
hosts: hosts,
|
|
197
231
|
upstreams: upstreams.map { |u| u['dial'] },
|
|
198
|
-
has_healthcheck: route.dig('handle', 0, 'health_checks').nil?
|
|
232
|
+
has_healthcheck: !route.dig('handle', 0, 'health_checks').nil?
|
|
199
233
|
}
|
|
200
234
|
end
|
|
201
235
|
end
|
|
@@ -240,9 +274,11 @@ module Odysseus
|
|
|
240
274
|
# @param hosts [Array<String>] domain hosts
|
|
241
275
|
# @param email [String] email for Let's Encrypt
|
|
242
276
|
def enable_tls_for_hosts(hosts, email: nil)
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
# nil means Caddy has no tls app yet, whether it answered with an error or
|
|
278
|
+
# a null body for the missing path. That distinction picks the verb below.
|
|
279
|
+
existing_tls = api_request('GET', '/config/apps/tls')
|
|
280
|
+
existing_automation = existing_tls&.dig('automation') || {}
|
|
281
|
+
existing_policies = existing_automation['policies'] || []
|
|
246
282
|
|
|
247
283
|
# Collect all existing subjects
|
|
248
284
|
all_subjects = existing_policies.flat_map { |p| p['subjects'] || [] }
|
|
@@ -256,20 +292,23 @@ module Odysseus
|
|
|
256
292
|
issuer = { 'module' => 'acme' }
|
|
257
293
|
issuer['email'] = email if email
|
|
258
294
|
|
|
259
|
-
#
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
}
|
|
295
|
+
# One policy covering every domain, merged onto whatever else the tls app
|
|
296
|
+
# holds: writing only our automation block would drop sibling settings
|
|
297
|
+
# such as explicit certificate loaders or on_demand limits that other
|
|
298
|
+
# services on this host may depend on.
|
|
299
|
+
tls_config = (existing_tls || {}).merge(
|
|
300
|
+
'automation' => existing_automation.merge(
|
|
301
|
+
'policies' => [{ 'subjects' => all_subjects, 'issuers' => [issuer] }]
|
|
302
|
+
)
|
|
303
|
+
)
|
|
270
304
|
|
|
271
|
-
#
|
|
272
|
-
|
|
305
|
+
# Caddy's PUT creates and answers 409 if the key is already there; PATCH
|
|
306
|
+
# replaces and fails if it is not. Neither one is an upsert on its own.
|
|
307
|
+
if existing_tls.nil?
|
|
308
|
+
api_request('PUT', '/config/apps/tls', tls_config)
|
|
309
|
+
else
|
|
310
|
+
api_request('PATCH', '/config/apps/tls', tls_config)
|
|
311
|
+
end
|
|
273
312
|
|
|
274
313
|
# Ensure HTTPS server exists and listens on 443
|
|
275
314
|
ensure_https_server
|
|
@@ -277,18 +316,22 @@ module Odysseus
|
|
|
277
316
|
|
|
278
317
|
private
|
|
279
318
|
|
|
319
|
+
def host_paths
|
|
320
|
+
@host_paths ||= Odysseus::HostPaths.new(@ssh)
|
|
321
|
+
end
|
|
322
|
+
|
|
280
323
|
def ensure_https_server
|
|
281
324
|
# Check if we have an HTTPS server configured
|
|
282
325
|
servers = api_request('GET', '/config/apps/http/servers') || {}
|
|
283
326
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
327
|
+
return if servers['srv0']&.dig('listen')&.include?(':443')
|
|
328
|
+
|
|
329
|
+
# Add :443 to listen addresses
|
|
330
|
+
current_listen = servers.dig('srv0', 'listen') || [':80']
|
|
331
|
+
return if current_listen.include?(':443')
|
|
332
|
+
|
|
333
|
+
current_listen << ':443'
|
|
334
|
+
api_request('PATCH', '/config/apps/http/servers/srv0/listen', current_listen)
|
|
292
335
|
end
|
|
293
336
|
|
|
294
337
|
def build_route_config(service:, hosts:, upstreams:, healthcheck: nil)
|
|
@@ -325,19 +368,53 @@ module Odysseus
|
|
|
325
368
|
route
|
|
326
369
|
end
|
|
327
370
|
|
|
371
|
+
# Call Caddy's admin API over SSH.
|
|
372
|
+
#
|
|
373
|
+
# curl exits 0 for HTTP errors, so the status code is appended to the
|
|
374
|
+
# response with -w and split back off here. A rejected write leaves the
|
|
375
|
+
# proxy in a state the caller must know about, so it raises; a failed read
|
|
376
|
+
# returns nil, because Caddy answers 500 for config paths that simply do
|
|
377
|
+
# not exist yet (no tls app on a freshly booted proxy, for instance).
|
|
378
|
+
#
|
|
379
|
+
# @raise [Odysseus::ProxyApiError] if a mutating request returns HTTP >= 400
|
|
328
380
|
def api_request(method, path, body = nil)
|
|
329
|
-
|
|
330
|
-
cmd = "curl -s -X #{method} "
|
|
381
|
+
cmd = "curl -s -w '\\n%{http_code}' -X #{method} "
|
|
331
382
|
cmd += "-H 'Content-Type: application/json' "
|
|
332
383
|
cmd += "-d '#{body.to_json}' " if body
|
|
333
384
|
cmd += "http://localhost:#{ADMIN_API_PORT}#{path}"
|
|
334
385
|
|
|
335
|
-
|
|
336
|
-
|
|
386
|
+
response_body, status = split_status(@ssh.execute(cmd))
|
|
387
|
+
|
|
388
|
+
if status && status >= 400
|
|
389
|
+
return nil if method == 'GET'
|
|
390
|
+
|
|
391
|
+
raise Odysseus::ProxyApiError, api_error_message(method, path, status, response_body)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
return nil if response_body.strip.empty?
|
|
337
395
|
|
|
338
|
-
JSON.parse(
|
|
396
|
+
JSON.parse(response_body)
|
|
339
397
|
rescue JSON::ParserError
|
|
340
|
-
|
|
398
|
+
response_body
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Split curl's trailing '%{http_code}' line off the response body.
|
|
402
|
+
# Returns a nil status when no status line is present.
|
|
403
|
+
def split_status(output)
|
|
404
|
+
text = output.to_s.sub(/\s+\z/, '')
|
|
405
|
+
newline_idx = text.rindex("\n")
|
|
406
|
+
trailer = newline_idx ? text[(newline_idx + 1)..] : text
|
|
407
|
+
|
|
408
|
+
return [output.to_s, nil] unless trailer&.match?(/\A\d{3}\z/)
|
|
409
|
+
|
|
410
|
+
[newline_idx ? text[0...newline_idx] : '', trailer.to_i]
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def api_error_message(method, path, status, response_body)
|
|
414
|
+
message = "Caddy admin API #{method} #{path} failed with HTTP #{status}"
|
|
415
|
+
details = response_body.to_s.strip
|
|
416
|
+
message += ": #{details}" unless details.empty?
|
|
417
|
+
message
|
|
341
418
|
end
|
|
342
419
|
end
|
|
343
420
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# lib/odysseus/command_redaction.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
# Hides secrets in a command string before it is printed.
|
|
5
|
+
#
|
|
6
|
+
# This runs on the way to the terminal only -- the command executed is always
|
|
7
|
+
# the real one. It lives in core rather than in the CLI's UI because the layer
|
|
8
|
+
# that echoes commands under --debug/-v is SSH#execute, and it echoes every
|
|
9
|
+
# command, not the handful the CLI wraps in its own redacting IO.
|
|
10
|
+
#
|
|
11
|
+
# The case that prompted it: registry login builds
|
|
12
|
+
# `echo '<password>' | docker login <server> -u <user> --password-stdin`
|
|
13
|
+
# (builder/client.rb). --password-stdin exists to keep a password off the
|
|
14
|
+
# argv, and interpolating it into `echo '...'` puts it straight back into the
|
|
15
|
+
# command string -- with no -p and no --password, so every pattern written to
|
|
16
|
+
# catch those misses it entirely.
|
|
17
|
+
module CommandRedaction
|
|
18
|
+
PLACEHOLDER = '[REDACTED]'.freeze
|
|
19
|
+
|
|
20
|
+
# Anything piped into a command that reads a secret from stdin. Matched by
|
|
21
|
+
# what the pipe FEEDS rather than by the flag, since the flag's whole point
|
|
22
|
+
# is that the secret is not one of its arguments.
|
|
23
|
+
STDIN_PIPE = /(echo\s+)(['"]).*?\2(\s*\|\s*\S*(?:docker\s+login|--password-stdin))/m
|
|
24
|
+
SHORT_FLAG = /(\s-p\s+)\S+/
|
|
25
|
+
LONG_FLAG = /(--password[= ])\S+/
|
|
26
|
+
ASSIGNMENT = /((?:KEY|TOKEN|SECRET|PASSWORD|MASTER_KEY|API_KEY|CREDENTIALS)\s*=\s*)\S+/i
|
|
27
|
+
|
|
28
|
+
# @param command [String] the command as it would be sent
|
|
29
|
+
# @return [String] the command as it is safe to print
|
|
30
|
+
def self.redact(command)
|
|
31
|
+
command.to_s
|
|
32
|
+
.gsub(STDIN_PIPE) { "#{Regexp.last_match(1)}'#{PLACEHOLDER}'#{Regexp.last_match(3)}" }
|
|
33
|
+
.gsub(SHORT_FLAG, "\\1#{PLACEHOLDER}")
|
|
34
|
+
.gsub(LONG_FLAG, "\\1#{PLACEHOLDER}")
|
|
35
|
+
.gsub(ASSIGNMENT, "\\1#{PLACEHOLDER}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -5,6 +5,11 @@ require 'yaml'
|
|
|
5
5
|
module Odysseus
|
|
6
6
|
module Config
|
|
7
7
|
class Parser
|
|
8
|
+
# Distinct versions of a service's image kept on each host. Five is enough
|
|
9
|
+
# to roll back through a bad week; the images share layers, so the cost of
|
|
10
|
+
# keeping a few is small.
|
|
11
|
+
DEFAULT_RETAIN_VERSIONS = 5
|
|
12
|
+
|
|
8
13
|
# @param config_path [String] Path to deploy.yml
|
|
9
14
|
def initialize(config_path)
|
|
10
15
|
@config_path = config_path
|
|
@@ -15,6 +20,7 @@ module Odysseus
|
|
|
15
20
|
# @raise [Odysseus::ConfigError] if invalid
|
|
16
21
|
def parse
|
|
17
22
|
raw_config = load_yaml
|
|
23
|
+
Odysseus::Plugins.load!(raw_config)
|
|
18
24
|
validate!(raw_config)
|
|
19
25
|
normalize(raw_config)
|
|
20
26
|
rescue Psych::SyntaxError => e
|
|
@@ -46,9 +52,10 @@ module Odysseus
|
|
|
46
52
|
env: parse_env(config['env']),
|
|
47
53
|
secrets_file: config['secrets_file'],
|
|
48
54
|
ssh: parse_ssh(config['ssh']),
|
|
49
|
-
|
|
55
|
+
dependencies: parse_dependencies(dependencies_config(config)),
|
|
50
56
|
builder: parse_builder(config['builder']),
|
|
51
|
-
registry: parse_registry(config['registry'])
|
|
57
|
+
registry: parse_registry(config['registry']),
|
|
58
|
+
retain_versions: config['retain_versions'] || DEFAULT_RETAIN_VERSIONS
|
|
52
59
|
}
|
|
53
60
|
end
|
|
54
61
|
|
|
@@ -96,14 +103,14 @@ module Odysseus
|
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
# Parse deploy-level health check (HTTP polling with threshold)
|
|
99
|
-
def parse_deploy_health_check(
|
|
100
|
-
return nil unless
|
|
106
|
+
def parse_deploy_health_check(health_check)
|
|
107
|
+
return nil unless health_check
|
|
101
108
|
|
|
102
109
|
{
|
|
103
|
-
path:
|
|
104
|
-
interval:
|
|
105
|
-
threshold:
|
|
106
|
-
timeout:
|
|
110
|
+
path: health_check['path'] || '/up',
|
|
111
|
+
interval: health_check['interval'] || 2,
|
|
112
|
+
threshold: health_check['threshold'] || 3,
|
|
113
|
+
timeout: health_check['timeout'] || 5
|
|
107
114
|
}
|
|
108
115
|
end
|
|
109
116
|
|
|
@@ -179,11 +186,29 @@ module Odysseus
|
|
|
179
186
|
}
|
|
180
187
|
end
|
|
181
188
|
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
# `accessories:` is the former name, still accepted. Both present is an
|
|
190
|
+
# error rather than a silent preference: a config with two contradictory
|
|
191
|
+
# lists should say so.
|
|
192
|
+
def dependencies_config(config)
|
|
193
|
+
if config.key?('dependencies') && config.key?('accessories')
|
|
194
|
+
raise Odysseus::ConfigError,
|
|
195
|
+
'deploy.yml has both `dependencies:` and `accessories:` — use one; ' \
|
|
196
|
+
'`accessories:` is the former name'
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
config['dependencies'] || config['accessories']
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Parse dependencies config — the supporting services a deploy needs
|
|
203
|
+
# (a database, a cache, a search index).
|
|
204
|
+
#
|
|
205
|
+
# Accepts the former `accessories:` key as well; the caller resolves
|
|
206
|
+
# which one was present. Kept because renaming a deploy.yml key should
|
|
207
|
+
# not be a flag day for every app repository.
|
|
208
|
+
def parse_dependencies(dependencies)
|
|
209
|
+
return {} unless dependencies
|
|
185
210
|
|
|
186
|
-
|
|
211
|
+
dependencies.each_with_object({}) do |(name, config), acc|
|
|
187
212
|
acc[name.to_sym] = {
|
|
188
213
|
image: config['image'],
|
|
189
214
|
hosts: config['hosts'],
|
|
@@ -191,14 +216,14 @@ module Odysseus
|
|
|
191
216
|
ports: config['ports'],
|
|
192
217
|
volumes: config['volumes'],
|
|
193
218
|
env: parse_env(config['env']),
|
|
194
|
-
healthcheck:
|
|
195
|
-
proxy:
|
|
219
|
+
healthcheck: parse_dependency_healthcheck(config['healthcheck']),
|
|
220
|
+
proxy: parse_dependency_proxy(config['proxy'])
|
|
196
221
|
}
|
|
197
222
|
end
|
|
198
223
|
end
|
|
199
224
|
|
|
200
|
-
# Parse
|
|
201
|
-
def
|
|
225
|
+
# Parse dependency healthcheck
|
|
226
|
+
def parse_dependency_healthcheck(healthcheck)
|
|
202
227
|
return nil unless healthcheck
|
|
203
228
|
|
|
204
229
|
{
|
|
@@ -209,8 +234,8 @@ module Odysseus
|
|
|
209
234
|
}
|
|
210
235
|
end
|
|
211
236
|
|
|
212
|
-
# Parse
|
|
213
|
-
def
|
|
237
|
+
# Parse dependency proxy config
|
|
238
|
+
def parse_dependency_proxy(proxy)
|
|
214
239
|
return nil unless proxy
|
|
215
240
|
|
|
216
241
|
{
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# lib/odysseus/core/deploy_versioning.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module Core
|
|
5
|
+
# The identity a deployed container carries: which version it is, which
|
|
6
|
+
# commit it came from, and when it was deployed.
|
|
7
|
+
#
|
|
8
|
+
# Shared rather than duplicated because `status`, `rollback` and image
|
|
9
|
+
# retention all read these labels, so an orchestrator that invents its own
|
|
10
|
+
# scheme becomes invisible to them. odysseus-sail-rolling did exactly that
|
|
11
|
+
# — it stamped odysseus.version with a timestamp, which meant retention's
|
|
12
|
+
# in-use guard could never match a version from the deploy log.
|
|
13
|
+
#
|
|
14
|
+
# The including class must expose @config.
|
|
15
|
+
#
|
|
16
|
+
# Both helpers are private, as they were before the extraction: they are
|
|
17
|
+
# how an orchestrator labels its own containers, not something to call on
|
|
18
|
+
# one from outside. VolumeNamespacer keeps its internals private the same
|
|
19
|
+
# way.
|
|
20
|
+
module DeployVersioning
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# The version this deploy identifies. Falls back to the tag in the image
|
|
24
|
+
# reference so a caller passing --image still gets a self-describing name.
|
|
25
|
+
def deploy_version_tag(image)
|
|
26
|
+
resolved = @config[:deploy_version]
|
|
27
|
+
return resolved.version if resolved
|
|
28
|
+
|
|
29
|
+
image.to_s.split(':').last
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# deployed_at replaces the timestamp odysseus.version used to hold;
|
|
33
|
+
# git_ref is only known when the version came from a commit.
|
|
34
|
+
def version_labels
|
|
35
|
+
labels = { 'odysseus.deployed_at' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') }
|
|
36
|
+
resolved = @config[:deploy_version]
|
|
37
|
+
labels['odysseus.git_ref'] = resolved.ref if resolved&.ref
|
|
38
|
+
labels
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# lib/odysseus/core/environment.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module Core
|
|
5
|
+
# The environment a container starts with: the clear values from deploy.yml
|
|
6
|
+
# merged with the secret ones, each read from the encrypted secrets file
|
|
7
|
+
# when one is configured and from the deploy target's own environment
|
|
8
|
+
# otherwise. A secret that is set in neither place is left out rather than
|
|
9
|
+
# injected empty.
|
|
10
|
+
#
|
|
11
|
+
# A class, where the neighbouring DeployVersioning and VolumeNamespacer are
|
|
12
|
+
# mixins. Those are only ever included by orchestrators, which all carry
|
|
13
|
+
# @config, @ssh and @secrets_loader, so a mixin costs them nothing. This one
|
|
14
|
+
# has a third caller: the CLI's one-off commands (`app exec`, `shell`,
|
|
15
|
+
# `console`) need the same environment, and the CLI is not an orchestrator —
|
|
16
|
+
# it has none of those ivars. Passing the collaborators in is what makes the
|
|
17
|
+
# code reusable from there; a mixin would make the CLI grow state it has no
|
|
18
|
+
# other use for just to satisfy this module's expectations.
|
|
19
|
+
class Environment
|
|
20
|
+
# @param config [Hash] parsed deploy config; only config[:env] is read
|
|
21
|
+
# @param secrets_loader [Odysseus::Secrets::Loader, nil] encrypted secrets, when configured
|
|
22
|
+
# @param ssh [Odysseus::Deployer::SSH] connection used to read the host's own environment
|
|
23
|
+
def initialize(config:, secrets_loader:, ssh:)
|
|
24
|
+
@config = config
|
|
25
|
+
@secrets_loader = secrets_loader
|
|
26
|
+
@ssh = ssh
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Hash{String => String}] variables to inject into the container
|
|
30
|
+
def build
|
|
31
|
+
env_config = @config[:env] || {}
|
|
32
|
+
env = {}
|
|
33
|
+
|
|
34
|
+
# Clear env vars (hardcoded values)
|
|
35
|
+
env_config[:clear]&.each do |key, value|
|
|
36
|
+
env[key.to_s] = value.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Secret env vars - first try encrypted file, then server environment
|
|
40
|
+
env_config[:secret]&.each do |key|
|
|
41
|
+
value = secret_value(key)
|
|
42
|
+
env[key.to_s] = value if value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
env
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @return [String, nil] the secret's value, nil when it is set nowhere
|
|
51
|
+
def secret_value(key)
|
|
52
|
+
if @secrets_loader&.configured?
|
|
53
|
+
value = @secrets_loader.get(key)
|
|
54
|
+
return value.to_s if value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Fall back to server's environment
|
|
58
|
+
value = @ssh.execute("echo $#{key}").strip
|
|
59
|
+
value unless value.empty?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
File without changes
|