odysseus-core 0.3.2 → 0.10.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 +508 -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 +174 -45
- 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 +93 -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
|
@@ -118,7 +118,7 @@ module Odysseus
|
|
|
118
118
|
executor = build_executor
|
|
119
119
|
output = executor.call("docker images -q #{image} 2>/dev/null || echo ''")
|
|
120
120
|
!output.strip.empty?
|
|
121
|
-
rescue
|
|
121
|
+
rescue StandardError
|
|
122
122
|
false
|
|
123
123
|
end
|
|
124
124
|
|
|
@@ -145,14 +145,18 @@ module Odysseus
|
|
|
145
145
|
arch: config[:arch] || config['arch'],
|
|
146
146
|
platforms: config[:platforms] || config['platforms'] || [],
|
|
147
147
|
build_args: config[:build_args] || config['build_args'] || {},
|
|
148
|
-
cache: config.key?(:cache)
|
|
148
|
+
cache: if config.key?(:cache)
|
|
149
|
+
config[:cache]
|
|
150
|
+
else
|
|
151
|
+
(config.key?('cache') ? config['cache'] : true)
|
|
152
|
+
end,
|
|
149
153
|
push: config[:push] || config['push'] || false,
|
|
150
154
|
multiarch: config[:multiarch] || config['multiarch'] || false
|
|
151
155
|
}
|
|
152
156
|
end
|
|
153
157
|
|
|
154
158
|
def build_local(context_path:, image:)
|
|
155
|
-
@logger.info(
|
|
159
|
+
@logger.info('Building locally...')
|
|
156
160
|
|
|
157
161
|
cmd = build_docker_command(context_path: context_path, image: image)
|
|
158
162
|
@logger.debug(cmd) if @logger.respond_to?(:debug)
|
|
@@ -161,7 +165,7 @@ module Odysseus
|
|
|
161
165
|
output = execute_local(cmd, context_path)
|
|
162
166
|
|
|
163
167
|
{ success: true, image: image, strategy: :local, output: output }
|
|
164
|
-
rescue => e
|
|
168
|
+
rescue StandardError => e
|
|
165
169
|
@logger.error("Local build failed: #{e.message}")
|
|
166
170
|
{ success: false, strategy: :local, error: e.message }
|
|
167
171
|
end
|
|
@@ -169,9 +173,7 @@ module Odysseus
|
|
|
169
173
|
def build_remote(context_path:, image:)
|
|
170
174
|
@logger.info("Building on remote host: #{@config[:host]}")
|
|
171
175
|
|
|
172
|
-
unless @config[:host]
|
|
173
|
-
raise BuildError, "Remote build strategy requires 'host' to be configured"
|
|
174
|
-
end
|
|
176
|
+
raise BuildError, "Remote build strategy requires 'host' to be configured" unless @config[:host]
|
|
175
177
|
|
|
176
178
|
ssh = connect_to_build_host
|
|
177
179
|
|
|
@@ -181,7 +183,7 @@ module Odysseus
|
|
|
181
183
|
ssh.execute("mkdir -p #{remote_dir}")
|
|
182
184
|
|
|
183
185
|
# Upload build context
|
|
184
|
-
@logger.info(
|
|
186
|
+
@logger.info('Uploading build context...')
|
|
185
187
|
ssh.upload(context_path, remote_dir)
|
|
186
188
|
|
|
187
189
|
# Determine the actual context directory on remote
|
|
@@ -243,9 +245,8 @@ module Odysseus
|
|
|
243
245
|
# Execute command locally using system
|
|
244
246
|
Dir.chdir(working_dir) do
|
|
245
247
|
output = `#{cmd} 2>&1`
|
|
246
|
-
unless $?.success?
|
|
247
|
-
|
|
248
|
-
end
|
|
248
|
+
raise BuildError, "Build command failed: #{output}" unless $?.success?
|
|
249
|
+
|
|
249
250
|
output
|
|
250
251
|
end
|
|
251
252
|
end
|
|
@@ -264,9 +265,8 @@ module Odysseus
|
|
|
264
265
|
|
|
265
266
|
def execute_local_command(cmd)
|
|
266
267
|
output = `#{cmd} 2>&1`
|
|
267
|
-
unless $?.success?
|
|
268
|
-
|
|
269
|
-
end
|
|
268
|
+
raise BuildError, "Command failed: #{output}" unless $?.success?
|
|
269
|
+
|
|
270
270
|
output
|
|
271
271
|
end
|
|
272
272
|
|
|
@@ -284,7 +284,7 @@ module Odysseus
|
|
|
284
284
|
l.define_singleton_method(:info) { |msg| puts msg }
|
|
285
285
|
l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
|
|
286
286
|
l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
|
|
287
|
-
l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if @verbose }
|
|
287
|
+
l.define_singleton_method(:debug) { |msg| puts " > #{Odysseus::CommandRedaction.redact(msg)}" if @verbose }
|
|
288
288
|
end
|
|
289
289
|
end
|
|
290
290
|
end
|
|
@@ -1,13 +1,19 @@
|
|
|
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
|
|
12
|
+
|
|
13
|
+
# The image's own CMD. Repeated here because --resume has to be appended
|
|
14
|
+
# to it: passing only --resume would drop the Caddyfile fallback that
|
|
15
|
+
# creates srv0 on a host with no autosave yet.
|
|
16
|
+
CADDY_RUN_CMD = 'caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'.freeze
|
|
11
17
|
|
|
12
18
|
# @param ssh [Odysseus::Deployer::SSH] SSH connection to server
|
|
13
19
|
# @param docker [Odysseus::Docker::Client] Docker client
|
|
@@ -17,11 +23,43 @@ module Odysseus
|
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
# Ensure Caddy is running
|
|
26
|
+
#
|
|
27
|
+
# There are three states, not two: running, absent, and stopped-but-
|
|
28
|
+
# present. Docker refuses `docker run --name odysseus-caddy` while a
|
|
29
|
+
# container by that name already exists, so a stopped Caddy would
|
|
30
|
+
# otherwise fail every deploy after it, forever.
|
|
31
|
+
#
|
|
32
|
+
# A stopped container is removed rather than `docker start`ed. It
|
|
33
|
+
# carries whatever configuration it was *created* with, including its
|
|
34
|
+
# volume mount — and this branch changed Caddy's data directory from a
|
|
35
|
+
# fixed system path to one derived from the deploy user (see
|
|
36
|
+
# Odysseus::HostPaths#caddy_dir). `docker start` would silently
|
|
37
|
+
# resurrect a container mounting the old path while everything else
|
|
38
|
+
# believes it moved. Recreating always applies current configuration.
|
|
39
|
+
# Nothing is lost: certificates live in the mounted volume, and routes
|
|
40
|
+
# are re-added by the deploy that follows.
|
|
41
|
+
#
|
|
42
|
+
# Started with --resume, so the routes added through the admin API by
|
|
43
|
+
# previous deploys come back with the container rather than having to be
|
|
44
|
+
# re-added by redeploying every service on the host.
|
|
45
|
+
#
|
|
46
|
+
# If that start fails the autosave is the only new thing that could have
|
|
47
|
+
# caused it -- CADDY_IMAGE is a moving tag, so a newer Caddy can meet an
|
|
48
|
+
# autosave it will not parse. Since WebDeploy aborts when Caddy does not
|
|
49
|
+
# come up, leaving that unhandled would fail every deploy on the host
|
|
50
|
+
# until someone deleted the file by hand. The retry gives up the routes,
|
|
51
|
+
# which is exactly the behaviour that existed before --resume.
|
|
52
|
+
#
|
|
20
53
|
# @return [Boolean] true if caddy is running
|
|
21
54
|
def ensure_running
|
|
22
55
|
return true if running?
|
|
23
56
|
|
|
57
|
+
@docker.remove(CONTAINER_NAME) if @docker.container_exists?(CONTAINER_NAME)
|
|
58
|
+
|
|
24
59
|
start_caddy
|
|
60
|
+
return true if running?
|
|
61
|
+
|
|
62
|
+
restart_without_autosave
|
|
25
63
|
running?
|
|
26
64
|
end
|
|
27
65
|
|
|
@@ -32,29 +70,53 @@ module Odysseus
|
|
|
32
70
|
end
|
|
33
71
|
|
|
34
72
|
# Start Caddy container
|
|
35
|
-
|
|
73
|
+
# @param resume [Boolean] reload the autosaved config (see #ensure_running)
|
|
74
|
+
def start_caddy(resume: true)
|
|
36
75
|
# Create network if not exists (with label to protect from prune)
|
|
37
|
-
@ssh.execute(
|
|
76
|
+
@ssh.execute('docker network create --label odysseus.managed=true odysseus 2>/dev/null || true')
|
|
38
77
|
|
|
39
|
-
# Create data directory for certificates
|
|
40
|
-
|
|
78
|
+
# Create data directory for certificates, and the config directory
|
|
79
|
+
# holding the autosave. Two directories because the image sets
|
|
80
|
+
# XDG_DATA_HOME=/data and XDG_CONFIG_HOME=/config, and Caddy writes the
|
|
81
|
+
# autosave under the latter -- mounting only /data persisted the
|
|
82
|
+
# certificates and threw the routes away with the container.
|
|
83
|
+
@ssh.execute("mkdir -p #{Shellwords.escape(host_paths.caddy_dir)}")
|
|
84
|
+
@ssh.execute("mkdir -p #{Shellwords.escape(host_paths.caddy_config_dir)}")
|
|
41
85
|
|
|
42
86
|
# Run Caddy with admin API enabled and persistent storage for certs
|
|
87
|
+
#
|
|
88
|
+
# The mkdir above and this mount must name the same directory, or
|
|
89
|
+
# Caddy starts against an empty one and silently has no certificates.
|
|
90
|
+
# Docker::Client interpolates `-v` values into its command line
|
|
91
|
+
# unescaped, so the directory is escaped here rather than there.
|
|
92
|
+
#
|
|
93
|
+
# The admin API is published on loopback only: it can rewrite the
|
|
94
|
+
# proxy config for every service on the host, and #api_request only
|
|
95
|
+
# ever reaches it via `curl localhost` over SSH, so nothing needs it
|
|
96
|
+
# exposed beyond the host itself. CADDY_ADMIN must stay bound to
|
|
97
|
+
# 0.0.0.0 *inside* the container regardless — that is what the
|
|
98
|
+
# published port maps to, and binding it to 127.0.0.1 there would put
|
|
99
|
+
# it behind the container's own loopback, unreachable even from the
|
|
100
|
+
# host.
|
|
43
101
|
@docker.run(
|
|
44
102
|
name: CONTAINER_NAME,
|
|
45
103
|
image: CADDY_IMAGE,
|
|
46
104
|
options: {
|
|
47
105
|
service: 'odysseus-proxy',
|
|
48
|
-
ports: ['80:80', '443:443', "
|
|
106
|
+
ports: ['80:80', '443:443', "127.0.0.1:#{ADMIN_API_PORT}:#{ADMIN_API_PORT}"],
|
|
49
107
|
network: 'odysseus',
|
|
50
108
|
restart: 'unless-stopped',
|
|
51
|
-
volumes: [
|
|
109
|
+
volumes: [
|
|
110
|
+
"#{Shellwords.escape(host_paths.caddy_dir)}:/data",
|
|
111
|
+
"#{Shellwords.escape(host_paths.caddy_config_dir)}:/config"
|
|
112
|
+
],
|
|
52
113
|
env: {
|
|
53
114
|
'CADDY_ADMIN' => "0.0.0.0:#{ADMIN_API_PORT}"
|
|
54
115
|
},
|
|
55
116
|
labels: {
|
|
56
117
|
'odysseus.managed' => 'true'
|
|
57
|
-
}
|
|
118
|
+
},
|
|
119
|
+
cmd: resume ? "#{CADDY_RUN_CMD} --resume" : CADDY_RUN_CMD
|
|
58
120
|
}
|
|
59
121
|
)
|
|
60
122
|
|
|
@@ -74,7 +136,7 @@ module Odysseus
|
|
|
74
136
|
enable_tls_for_hosts(hosts, email: ssl_email) if ssl
|
|
75
137
|
|
|
76
138
|
# Check if route already exists for this service
|
|
77
|
-
routes = api_request('GET',
|
|
139
|
+
routes = api_request('GET', '/config/apps/http/servers/srv0/routes') || []
|
|
78
140
|
existing_idx = routes.find_index { |r| r['@id'] == "route-#{service}" }
|
|
79
141
|
|
|
80
142
|
if existing_idx
|
|
@@ -88,7 +150,8 @@ module Odysseus
|
|
|
88
150
|
current_upstreams = routes[existing_idx].dig('handle', 0, 'upstreams') || []
|
|
89
151
|
unless current_upstreams.any? { |u| u['dial'] == upstream }
|
|
90
152
|
current_upstreams << { 'dial' => upstream }
|
|
91
|
-
api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/handle/0/upstreams",
|
|
153
|
+
api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/handle/0/upstreams",
|
|
154
|
+
current_upstreams)
|
|
92
155
|
end
|
|
93
156
|
else
|
|
94
157
|
# Create new route - prepend at index 0 so it matches before default routes
|
|
@@ -98,7 +161,7 @@ module Odysseus
|
|
|
98
161
|
upstreams: [upstream],
|
|
99
162
|
healthcheck: healthcheck
|
|
100
163
|
)
|
|
101
|
-
api_request('PUT',
|
|
164
|
+
api_request('PUT', '/config/apps/http/servers/srv0/routes/0', config)
|
|
102
165
|
end
|
|
103
166
|
end
|
|
104
167
|
|
|
@@ -107,12 +170,12 @@ module Odysseus
|
|
|
107
170
|
# @param upstream [String, nil] upstream to remove, or nil to remove entire route
|
|
108
171
|
def remove_upstream(service:, upstream:)
|
|
109
172
|
# Get current config
|
|
110
|
-
routes = api_request('GET',
|
|
173
|
+
routes = api_request('GET', '/config/apps/http/servers/srv0/routes')
|
|
111
174
|
return unless routes
|
|
112
175
|
|
|
113
176
|
# Find route for this service and remove the upstream
|
|
114
177
|
routes.each_with_index do |route, idx|
|
|
115
|
-
next unless route
|
|
178
|
+
next unless route['@id'] == "route-#{service}"
|
|
116
179
|
|
|
117
180
|
# If no specific upstream, remove the entire route
|
|
118
181
|
if upstream.nil?
|
|
@@ -195,7 +258,7 @@ module Odysseus
|
|
|
195
258
|
service: service_name,
|
|
196
259
|
hosts: hosts,
|
|
197
260
|
upstreams: upstreams.map { |u| u['dial'] },
|
|
198
|
-
has_healthcheck: route.dig('handle', 0, 'health_checks').nil?
|
|
261
|
+
has_healthcheck: !route.dig('handle', 0, 'health_checks').nil?
|
|
199
262
|
}
|
|
200
263
|
end
|
|
201
264
|
end
|
|
@@ -240,9 +303,11 @@ module Odysseus
|
|
|
240
303
|
# @param hosts [Array<String>] domain hosts
|
|
241
304
|
# @param email [String] email for Let's Encrypt
|
|
242
305
|
def enable_tls_for_hosts(hosts, email: nil)
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
306
|
+
# nil means Caddy has no tls app yet, whether it answered with an error or
|
|
307
|
+
# a null body for the missing path. That distinction picks the verb below.
|
|
308
|
+
existing_tls = api_request('GET', '/config/apps/tls')
|
|
309
|
+
existing_automation = existing_tls&.dig('automation') || {}
|
|
310
|
+
existing_policies = existing_automation['policies'] || []
|
|
246
311
|
|
|
247
312
|
# Collect all existing subjects
|
|
248
313
|
all_subjects = existing_policies.flat_map { |p| p['subjects'] || [] }
|
|
@@ -256,20 +321,23 @@ module Odysseus
|
|
|
256
321
|
issuer = { 'module' => 'acme' }
|
|
257
322
|
issuer['email'] = email if email
|
|
258
323
|
|
|
259
|
-
#
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
}
|
|
324
|
+
# One policy covering every domain, merged onto whatever else the tls app
|
|
325
|
+
# holds: writing only our automation block would drop sibling settings
|
|
326
|
+
# such as explicit certificate loaders or on_demand limits that other
|
|
327
|
+
# services on this host may depend on.
|
|
328
|
+
tls_config = (existing_tls || {}).merge(
|
|
329
|
+
'automation' => existing_automation.merge(
|
|
330
|
+
'policies' => [{ 'subjects' => all_subjects, 'issuers' => [issuer] }]
|
|
331
|
+
)
|
|
332
|
+
)
|
|
270
333
|
|
|
271
|
-
#
|
|
272
|
-
|
|
334
|
+
# Caddy's PUT creates and answers 409 if the key is already there; PATCH
|
|
335
|
+
# replaces and fails if it is not. Neither one is an upsert on its own.
|
|
336
|
+
if existing_tls.nil?
|
|
337
|
+
api_request('PUT', '/config/apps/tls', tls_config)
|
|
338
|
+
else
|
|
339
|
+
api_request('PATCH', '/config/apps/tls', tls_config)
|
|
340
|
+
end
|
|
273
341
|
|
|
274
342
|
# Ensure HTTPS server exists and listens on 443
|
|
275
343
|
ensure_https_server
|
|
@@ -277,18 +345,45 @@ module Odysseus
|
|
|
277
345
|
|
|
278
346
|
private
|
|
279
347
|
|
|
348
|
+
def restart_without_autosave
|
|
349
|
+
@docker.remove(CONTAINER_NAME) if @docker.container_exists?(CONTAINER_NAME)
|
|
350
|
+
discard_autosave
|
|
351
|
+
start_caddy(resume: false)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Caddy creates /config/caddy as root with mode 0700, so a non-root deploy
|
|
355
|
+
# user cannot move the file from the host -- it has to be moved from
|
|
356
|
+
# inside a container. CADDY_IMAGE is guaranteed to be on the host: it is
|
|
357
|
+
# the image that just failed to start.
|
|
358
|
+
#
|
|
359
|
+
# Renamed rather than deleted, because if --resume was not the reason
|
|
360
|
+
# Caddy failed then this file is the only copy of the host's routes.
|
|
361
|
+
# Tolerates its own failure: a Caddy that died for some other reason may
|
|
362
|
+
# have no autosave at all, and that must not abort the retry.
|
|
363
|
+
def discard_autosave
|
|
364
|
+
mount = "#{Shellwords.escape(host_paths.caddy_config_dir)}:/config"
|
|
365
|
+
@ssh.execute(
|
|
366
|
+
"docker run --rm -v #{mount} #{CADDY_IMAGE} " \
|
|
367
|
+
'mv /config/caddy/autosave.json /config/caddy/autosave.json.rejected 2>/dev/null || true'
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def host_paths
|
|
372
|
+
@host_paths ||= Odysseus::HostPaths.new(@ssh)
|
|
373
|
+
end
|
|
374
|
+
|
|
280
375
|
def ensure_https_server
|
|
281
376
|
# Check if we have an HTTPS server configured
|
|
282
377
|
servers = api_request('GET', '/config/apps/http/servers') || {}
|
|
283
378
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
379
|
+
return if servers['srv0']&.dig('listen')&.include?(':443')
|
|
380
|
+
|
|
381
|
+
# Add :443 to listen addresses
|
|
382
|
+
current_listen = servers.dig('srv0', 'listen') || [':80']
|
|
383
|
+
return if current_listen.include?(':443')
|
|
384
|
+
|
|
385
|
+
current_listen << ':443'
|
|
386
|
+
api_request('PATCH', '/config/apps/http/servers/srv0/listen', current_listen)
|
|
292
387
|
end
|
|
293
388
|
|
|
294
389
|
def build_route_config(service:, hosts:, upstreams:, healthcheck: nil)
|
|
@@ -325,19 +420,53 @@ module Odysseus
|
|
|
325
420
|
route
|
|
326
421
|
end
|
|
327
422
|
|
|
423
|
+
# Call Caddy's admin API over SSH.
|
|
424
|
+
#
|
|
425
|
+
# curl exits 0 for HTTP errors, so the status code is appended to the
|
|
426
|
+
# response with -w and split back off here. A rejected write leaves the
|
|
427
|
+
# proxy in a state the caller must know about, so it raises; a failed read
|
|
428
|
+
# returns nil, because Caddy answers 500 for config paths that simply do
|
|
429
|
+
# not exist yet (no tls app on a freshly booted proxy, for instance).
|
|
430
|
+
#
|
|
431
|
+
# @raise [Odysseus::ProxyApiError] if a mutating request returns HTTP >= 400
|
|
328
432
|
def api_request(method, path, body = nil)
|
|
329
|
-
|
|
330
|
-
cmd = "curl -s -X #{method} "
|
|
433
|
+
cmd = "curl -s -w '\\n%{http_code}' -X #{method} "
|
|
331
434
|
cmd += "-H 'Content-Type: application/json' "
|
|
332
435
|
cmd += "-d '#{body.to_json}' " if body
|
|
333
436
|
cmd += "http://localhost:#{ADMIN_API_PORT}#{path}"
|
|
334
437
|
|
|
335
|
-
|
|
336
|
-
return nil if output.strip.empty?
|
|
438
|
+
response_body, status = split_status(@ssh.execute(cmd))
|
|
337
439
|
|
|
338
|
-
|
|
440
|
+
if status && status >= 400
|
|
441
|
+
return nil if method == 'GET'
|
|
442
|
+
|
|
443
|
+
raise Odysseus::ProxyApiError, api_error_message(method, path, status, response_body)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
return nil if response_body.strip.empty?
|
|
447
|
+
|
|
448
|
+
JSON.parse(response_body)
|
|
339
449
|
rescue JSON::ParserError
|
|
340
|
-
|
|
450
|
+
response_body
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Split curl's trailing '%{http_code}' line off the response body.
|
|
454
|
+
# Returns a nil status when no status line is present.
|
|
455
|
+
def split_status(output)
|
|
456
|
+
text = output.to_s.sub(/\s+\z/, '')
|
|
457
|
+
newline_idx = text.rindex("\n")
|
|
458
|
+
trailer = newline_idx ? text[(newline_idx + 1)..] : text
|
|
459
|
+
|
|
460
|
+
return [output.to_s, nil] unless trailer&.match?(/\A\d{3}\z/)
|
|
461
|
+
|
|
462
|
+
[newline_idx ? text[0...newline_idx] : '', trailer.to_i]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def api_error_message(method, path, status, response_body)
|
|
466
|
+
message = "Caddy admin API #{method} #{path} failed with HTTP #{status}"
|
|
467
|
+
details = response_body.to_s.strip
|
|
468
|
+
message += ": #{details}" unless details.empty?
|
|
469
|
+
message
|
|
341
470
|
end
|
|
342
471
|
end
|
|
343
472
|
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
|