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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +485 -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 +119 -42
  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 +79 -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
@@ -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("docker network create --label odysseus.managed=true odysseus 2>/dev/null || true")
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 /var/lib/odysseus/caddy")
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', "#{ADMIN_API_PORT}:#{ADMIN_API_PORT}"],
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: ['/var/lib/odysseus/caddy:/data'],
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', "/config/apps/http/servers/srv0/routes") || []
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", current_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', "/config/apps/http/servers/srv0/routes/0", config)
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', "/config/apps/http/servers/srv0/routes")
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.dig('@id') == "route-#{service}"
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? ? false : true
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
- # Get existing TLS config to merge with
244
- existing_tls = api_request('GET', '/config/apps/tls') || {}
245
- existing_policies = existing_tls.dig('automation', 'policies') || []
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
- # 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
- }
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
- # Use PUT to create/replace TLS config
272
- api_request('PUT', '/config/apps/tls', tls_config)
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
- 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
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
- # Build curl command to hit Caddy's admin API
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
- output = @ssh.execute(cmd)
336
- return nil if output.strip.empty?
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(output)
396
+ JSON.parse(response_body)
339
397
  rescue JSON::ParserError
340
- output
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
- 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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Odysseus
4
4
  module Core
5
- VERSION = "0.3.2"
5
+ VERSION = '0.9.0'
6
6
  end
7
7
  end
File without changes
data/lib/odysseus/core.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "core/version"
4
- require_relative "core/volume_namespacer"
3
+ require_relative 'core/version'
4
+ require_relative 'core/volume_namespacer'
5
5
 
6
6
  module Odysseus
7
7
  module Core