odysseus-core 0.3.1 → 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 +125 -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.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/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
- 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,15 +107,22 @@ 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
|
|
114
|
+
# Update hosts if they changed
|
|
115
|
+
current_hosts = routes[existing_idx].dig('match', 0, 'host') || []
|
|
116
|
+
if current_hosts.sort != hosts.sort
|
|
117
|
+
api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/match/0/host", hosts)
|
|
118
|
+
end
|
|
119
|
+
|
|
81
120
|
# Update existing route's upstreams
|
|
82
121
|
current_upstreams = routes[existing_idx].dig('handle', 0, 'upstreams') || []
|
|
83
122
|
unless current_upstreams.any? { |u| u['dial'] == upstream }
|
|
84
123
|
current_upstreams << { 'dial' => upstream }
|
|
85
|
-
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)
|
|
86
126
|
end
|
|
87
127
|
else
|
|
88
128
|
# Create new route - prepend at index 0 so it matches before default routes
|
|
@@ -92,7 +132,7 @@ module Odysseus
|
|
|
92
132
|
upstreams: [upstream],
|
|
93
133
|
healthcheck: healthcheck
|
|
94
134
|
)
|
|
95
|
-
api_request('PUT',
|
|
135
|
+
api_request('PUT', '/config/apps/http/servers/srv0/routes/0', config)
|
|
96
136
|
end
|
|
97
137
|
end
|
|
98
138
|
|
|
@@ -101,12 +141,12 @@ module Odysseus
|
|
|
101
141
|
# @param upstream [String, nil] upstream to remove, or nil to remove entire route
|
|
102
142
|
def remove_upstream(service:, upstream:)
|
|
103
143
|
# Get current config
|
|
104
|
-
routes = api_request('GET',
|
|
144
|
+
routes = api_request('GET', '/config/apps/http/servers/srv0/routes')
|
|
105
145
|
return unless routes
|
|
106
146
|
|
|
107
147
|
# Find route for this service and remove the upstream
|
|
108
148
|
routes.each_with_index do |route, idx|
|
|
109
|
-
next unless route
|
|
149
|
+
next unless route['@id'] == "route-#{service}"
|
|
110
150
|
|
|
111
151
|
# If no specific upstream, remove the entire route
|
|
112
152
|
if upstream.nil?
|
|
@@ -189,7 +229,7 @@ module Odysseus
|
|
|
189
229
|
service: service_name,
|
|
190
230
|
hosts: hosts,
|
|
191
231
|
upstreams: upstreams.map { |u| u['dial'] },
|
|
192
|
-
has_healthcheck: route.dig('handle', 0, 'health_checks').nil?
|
|
232
|
+
has_healthcheck: !route.dig('handle', 0, 'health_checks').nil?
|
|
193
233
|
}
|
|
194
234
|
end
|
|
195
235
|
end
|
|
@@ -234,9 +274,11 @@ module Odysseus
|
|
|
234
274
|
# @param hosts [Array<String>] domain hosts
|
|
235
275
|
# @param email [String] email for Let's Encrypt
|
|
236
276
|
def enable_tls_for_hosts(hosts, email: nil)
|
|
237
|
-
#
|
|
238
|
-
|
|
239
|
-
|
|
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'] || []
|
|
240
282
|
|
|
241
283
|
# Collect all existing subjects
|
|
242
284
|
all_subjects = existing_policies.flat_map { |p| p['subjects'] || [] }
|
|
@@ -250,20 +292,23 @@ module Odysseus
|
|
|
250
292
|
issuer = { 'module' => 'acme' }
|
|
251
293
|
issuer['email'] = email if email
|
|
252
294
|
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
}
|
|
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
|
+
)
|
|
264
304
|
|
|
265
|
-
#
|
|
266
|
-
|
|
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
|
|
267
312
|
|
|
268
313
|
# Ensure HTTPS server exists and listens on 443
|
|
269
314
|
ensure_https_server
|
|
@@ -271,18 +316,22 @@ module Odysseus
|
|
|
271
316
|
|
|
272
317
|
private
|
|
273
318
|
|
|
319
|
+
def host_paths
|
|
320
|
+
@host_paths ||= Odysseus::HostPaths.new(@ssh)
|
|
321
|
+
end
|
|
322
|
+
|
|
274
323
|
def ensure_https_server
|
|
275
324
|
# Check if we have an HTTPS server configured
|
|
276
325
|
servers = api_request('GET', '/config/apps/http/servers') || {}
|
|
277
326
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
335
|
end
|
|
287
336
|
|
|
288
337
|
def build_route_config(service:, hosts:, upstreams:, healthcheck: nil)
|
|
@@ -319,19 +368,53 @@ module Odysseus
|
|
|
319
368
|
route
|
|
320
369
|
end
|
|
321
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
|
|
322
380
|
def api_request(method, path, body = nil)
|
|
323
|
-
|
|
324
|
-
cmd = "curl -s -X #{method} "
|
|
381
|
+
cmd = "curl -s -w '\\n%{http_code}' -X #{method} "
|
|
325
382
|
cmd += "-H 'Content-Type: application/json' "
|
|
326
383
|
cmd += "-d '#{body.to_json}' " if body
|
|
327
384
|
cmd += "http://localhost:#{ADMIN_API_PORT}#{path}"
|
|
328
385
|
|
|
329
|
-
|
|
330
|
-
|
|
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?
|
|
331
395
|
|
|
332
|
-
JSON.parse(
|
|
396
|
+
JSON.parse(response_body)
|
|
333
397
|
rescue JSON::ParserError
|
|
334
|
-
|
|
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
|
|
335
418
|
end
|
|
336
419
|
end
|
|
337
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
|