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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +508 -1
  3. data/LICENSE.txt +25 -7
  4. data/README.md +12 -3
  5. data/lib/odysseus/builder/client.rb +15 -15
  6. data/lib/odysseus/caddy/client.rb +174 -45
  7. data/lib/odysseus/command_redaction.rb +38 -0
  8. data/lib/odysseus/config/parser.rb +43 -18
  9. data/lib/odysseus/core/deploy_versioning.rb +42 -0
  10. data/lib/odysseus/core/environment.rb +63 -0
  11. data/lib/odysseus/core/version.rb +1 -1
  12. data/lib/odysseus/core/volume_namespacer.rb +0 -0
  13. data/lib/odysseus/core.rb +2 -2
  14. data/lib/odysseus/deploy_log.rb +120 -0
  15. data/lib/odysseus/deploy_version.rb +9 -0
  16. data/lib/odysseus/deployer/dependency_manager.rb +121 -0
  17. data/lib/odysseus/deployer/executor.rb +213 -161
  18. data/lib/odysseus/deployer/retention_sweeper.rb +94 -0
  19. data/lib/odysseus/deployer/ssh.rb +92 -18
  20. data/lib/odysseus/docker/client.rb +240 -48
  21. data/lib/odysseus/docker/labels.rb +43 -0
  22. data/lib/odysseus/errors.rb +10 -0
  23. data/lib/odysseus/git.rb +70 -0
  24. data/lib/odysseus/host_paths.rb +93 -0
  25. data/lib/odysseus/host_providers/base.rb +1 -1
  26. data/lib/odysseus/host_providers/static.rb +1 -1
  27. data/lib/odysseus/host_providers.rb +3 -4
  28. data/lib/odysseus/host_verifier.rb +156 -0
  29. data/lib/odysseus/host_versions.rb +59 -0
  30. data/lib/odysseus/orchestrator/{accessory_deploy.rb → dependency_deploy.rb} +84 -88
  31. data/lib/odysseus/orchestrator/job_deploy.rb +31 -43
  32. data/lib/odysseus/orchestrator/web_deploy.rb +61 -55
  33. data/lib/odysseus/plugins.rb +72 -0
  34. data/lib/odysseus/retention_plan.rb +14 -0
  35. data/lib/odysseus/retention_planner.rb +58 -0
  36. data/lib/odysseus/rollback_plan.rb +20 -0
  37. data/lib/odysseus/rollback_planner.rb +107 -0
  38. data/lib/odysseus/sails.rb +0 -0
  39. data/lib/odysseus/secrets/encrypted_file.rb +5 -7
  40. data/lib/odysseus/secrets/loader.rb +1 -3
  41. data/lib/odysseus/setup/docker_apt.rb +178 -0
  42. data/lib/odysseus/setup/escalation.rb +73 -0
  43. data/lib/odysseus/setup/preparer.rb +372 -0
  44. data/lib/odysseus/setup/public_key.rb +127 -0
  45. data/lib/odysseus/validators/config.rb +38 -25
  46. data/lib/odysseus/version_resolver.rb +80 -0
  47. data/lib/odysseus.rb +0 -0
  48. data/sig/odysseus/core.rbs +0 -0
  49. metadata +48 -27
  50. data/Rakefile +0 -12
  51. 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) ? config[:cache] : (config.key?('cache') ? config['cache'] : true),
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("Building locally...")
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("Uploading build context...")
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
- raise BuildError, "Build command failed: #{output}"
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
- raise BuildError, "Command failed: #{output}"
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
- def start_caddy
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("docker network create --label odysseus.managed=true odysseus 2>/dev/null || true")
76
+ @ssh.execute('docker network create --label odysseus.managed=true odysseus 2>/dev/null || true')
38
77
 
39
- # Create data directory for certificates
40
- @ssh.execute("mkdir -p /var/lib/odysseus/caddy")
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', "#{ADMIN_API_PORT}:#{ADMIN_API_PORT}"],
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: ['/var/lib/odysseus/caddy:/data'],
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', "/config/apps/http/servers/srv0/routes") || []
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", current_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', "/config/apps/http/servers/srv0/routes/0", config)
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', "/config/apps/http/servers/srv0/routes")
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.dig('@id') == "route-#{service}"
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? ? false : true
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
- # Get existing TLS config to merge with
244
- existing_tls = api_request('GET', '/config/apps/tls') || {}
245
- existing_policies = existing_tls.dig('automation', 'policies') || []
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
- # Configure TLS automation with Let's Encrypt (single policy for all domains)
260
- tls_config = {
261
- 'automation' => {
262
- 'policies' => [
263
- {
264
- 'subjects' => all_subjects,
265
- 'issuers' => [issuer]
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
- # Use PUT to create/replace TLS config
272
- api_request('PUT', '/config/apps/tls', tls_config)
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
- unless servers['srv0']&.dig('listen')&.include?(':443')
285
- # Add :443 to listen addresses
286
- current_listen = servers.dig('srv0', 'listen') || [':80']
287
- unless current_listen.include?(':443')
288
- current_listen << ':443'
289
- api_request('PATCH', '/config/apps/http/servers/srv0/listen', current_listen)
290
- end
291
- end
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
- # Build curl command to hit Caddy's admin API
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
- output = @ssh.execute(cmd)
336
- return nil if output.strip.empty?
438
+ response_body, status = split_status(@ssh.execute(cmd))
337
439
 
338
- JSON.parse(output)
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
- output
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
- accessories: parse_accessories(config['accessories']),
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(hc)
100
- return nil unless hc
106
+ def parse_deploy_health_check(health_check)
107
+ return nil unless health_check
101
108
 
102
109
  {
103
- path: hc['path'] || '/up',
104
- interval: hc['interval'] || 2,
105
- threshold: hc['threshold'] || 3,
106
- timeout: hc['timeout'] || 5
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
- # Parse accessories config
183
- def parse_accessories(accessories)
184
- return {} unless accessories
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
- accessories.each_with_object({}) do |(name, config), acc|
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: parse_accessory_healthcheck(config['healthcheck']),
195
- proxy: parse_accessory_proxy(config['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 accessory healthcheck
201
- def parse_accessory_healthcheck(healthcheck)
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 accessory proxy config
213
- def parse_accessory_proxy(proxy)
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