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
@@ -6,6 +6,10 @@ module Odysseus
6
6
  module Orchestrator
7
7
  class WebDeploy
8
8
  include Odysseus::Core::VolumeNamespacer
9
+ include Odysseus::Core::DeployVersioning
10
+
11
+ # Probed when a proxy is configured without an explicit healthcheck block.
12
+ DEFAULT_HEALTHCHECK_PATH = '/'.freeze
9
13
 
10
14
  # @param ssh [Odysseus::Deployer::SSH] SSH connection
11
15
  # @param config [Hash] parsed deploy config
@@ -31,13 +35,22 @@ module Odysseus
31
35
  log "Deploying #{service} (role: #{role})"
32
36
  log " Image: #{image}"
33
37
 
38
+ # A web role is proxied by Caddy, which needs a port to route to. Without
39
+ # one the container gets no health command and the deploy would sit in
40
+ # health checks until it timed out, so say so up front.
41
+ unless app_port
42
+ raise Odysseus::ConfigError,
43
+ "proxy.app_port is required to deploy the '#{role}' role — " \
44
+ 'set it to the port your app listens on inside the container'
45
+ end
46
+
34
47
  # Step 1: Ensure Caddy is running
35
- log "Ensuring Caddy proxy is running..."
48
+ log 'Ensuring Caddy proxy is running...'
36
49
  if @caddy.running?
37
- log " Caddy already running"
50
+ log ' Caddy already running'
38
51
  else
39
- @caddy.ensure_running
40
- log " Caddy started"
52
+ ensure_caddy!
53
+ log ' Caddy started'
41
54
  end
42
55
 
43
56
  # Step 2: Find existing containers
@@ -45,7 +58,7 @@ module Odysseus
45
58
  log " Found #{old_containers.size} existing container(s)"
46
59
 
47
60
  # Step 3: Start new container
48
- log "Starting new container..."
61
+ log 'Starting new container...'
49
62
  new_container_id = start_new_container(image: image, role: role)
50
63
  log " Container started: #{new_container_id[0..11]}"
51
64
 
@@ -55,21 +68,21 @@ module Odysseus
55
68
  unless wait_for_healthy(new_container_id)
56
69
  log_health_failure(new_container_id)
57
70
  handle_failed_deploy(new_container_id, old_containers)
58
- raise Odysseus::DeployError, "Container failed health checks"
71
+ raise Odysseus::DeployError, 'Container failed health checks'
59
72
  end
60
- log " Health check passed"
73
+ log ' Health check passed'
61
74
 
62
75
  # Step 5: Add new container to Caddy
63
76
  proxy_hosts = @config[:proxy][:hosts]&.join(', ')
64
77
  log "Adding to Caddy proxy (hosts: #{proxy_hosts})..."
65
78
  add_to_caddy(new_container_id)
66
- log " Caddy routing configured"
79
+ log ' Caddy routing configured'
67
80
 
68
81
  # Step 6: Remove old containers from Caddy and stop them
69
82
  old_containers.each do |old|
70
83
  log "Draining old container #{old['ID'][0..11]}..."
71
84
  drain_and_remove(old['ID'])
72
- log " Old container removed"
85
+ log ' Old container removed'
73
86
  end
74
87
 
75
88
  # Step 7: Cleanup old stopped containers
@@ -96,15 +109,15 @@ module Odysseus
96
109
  private
97
110
 
98
111
  def ensure_caddy!
99
- unless @caddy.ensure_running
100
- raise Odysseus::DeployError, "Failed to start Caddy proxy"
101
- end
112
+ return if @caddy.ensure_running
113
+
114
+ raise Odysseus::DeployError, 'Failed to start Caddy proxy'
102
115
  end
103
116
 
104
117
  def start_new_container(image:, role:)
105
118
  service = @config[:service]
106
- timestamp = Time.now.strftime('%Y%m%d%H%M%S')
107
- container_name = "#{service}-#{timestamp}"
119
+ timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
120
+ container_name = "#{service}-#{deploy_version_tag(image)}-#{timestamp}"
108
121
 
109
122
  server_config = @config[:servers][role] || {}
110
123
  options = server_config[:options] || {}
@@ -114,9 +127,7 @@ module Odysseus
114
127
  log " Environment: #{env.size} variable(s) injected"
115
128
 
116
129
  volumes = namespace_volumes(server_config[:volumes], service: service)
117
- if volumes&.any?
118
- log " Volumes: #{volumes.join(', ')}"
119
- end
130
+ log " Volumes: #{volumes.join(', ')}" if volumes&.any?
120
131
 
121
132
  if options[:memory] || options[:cpus]
122
133
  log " Resources: memory=#{options[:memory] || 'default'}, cpus=#{options[:cpus] || 'default'}"
@@ -127,7 +138,8 @@ module Odysseus
127
138
  image: image,
128
139
  options: {
129
140
  service: service,
130
- version: timestamp,
141
+ version: deploy_version_tag(image),
142
+ labels: version_labels,
131
143
  ports: internal_port_mapping(proxy_config[:app_port]),
132
144
  env: env,
133
145
  volumes: volumes,
@@ -152,39 +164,27 @@ module Odysseus
152
164
  nil
153
165
  end
154
166
 
167
+ # Shared with JobDeploy and with the one-off containers the CLI runs, so
168
+ # every path injects the same environment. Kept private here: it is how
169
+ # this orchestrator fills in its own container's env, not a service to
170
+ # call on it from outside.
155
171
  def build_environment
156
- env = {}
157
-
158
- # Clear env vars (hardcoded values)
159
- @config[:env][:clear]&.each do |key, value|
160
- env[key.to_s] = value.to_s
161
- end
162
-
163
- # Secret env vars - first try encrypted file, then server environment
164
- @config[:env][:secret]&.each do |key|
165
- # Try encrypted secrets file first
166
- if @secrets_loader&.configured?
167
- value = @secrets_loader.get(key)
168
- if value
169
- env[key.to_s] = value.to_s
170
- next
171
- end
172
- end
173
-
174
- # Fall back to server's environment
175
- value = @ssh.execute("echo $#{key}").strip
176
- env[key.to_s] = value unless value.empty?
177
- end
178
-
179
- env
172
+ Odysseus::Core::Environment.new(config: @config, secrets_loader: @secrets_loader, ssh: @ssh).build
180
173
  end
181
174
 
175
+ # Build the container-level health check Docker polls.
176
+ #
177
+ # The deploy gates on Docker reporting the container healthy, so a web
178
+ # container always needs a health command — without one the status stays
179
+ # 'none' forever and every deploy times out. When no healthcheck block is
180
+ # configured we probe the app port at DEFAULT_HEALTHCHECK_PATH, matching
181
+ # the default Config::Parser applies to an empty healthcheck block.
182
182
  def build_healthcheck(hc_config)
183
- return nil unless hc_config && hc_config[:path]
183
+ port = app_port
184
+ return nil unless port
184
185
 
185
- port = @config[:proxy][:app_port]
186
- path = hc_config[:path]
187
- expect_status = hc_config[:expect_status]
186
+ path = (hc_config && hc_config[:path]) || DEFAULT_HEALTHCHECK_PATH
187
+ expect_status = hc_config && hc_config[:expect_status]
188
188
 
189
189
  # Build curl command based on expected status
190
190
  cmd = if expect_status
@@ -254,7 +254,7 @@ module Odysseus
254
254
  end
255
255
 
256
256
  def handle_failed_deploy(new_container_id, old_containers)
257
- log "Rolling back failed deploy...", :warn
257
+ log 'Rolling back failed deploy...', :warn
258
258
 
259
259
  # Remove the failed new container
260
260
  @docker.stop(new_container_id)
@@ -264,7 +264,7 @@ module Odysseus
264
264
  if old_containers.any?
265
265
  log "Rollback complete — #{old_containers.size} old container(s) still serving traffic"
266
266
  else
267
- log "Rollback complete — no previous containers to fall back to", :warn
267
+ log 'Rollback complete — no previous containers to fall back to', :warn
268
268
  end
269
269
  end
270
270
 
@@ -275,7 +275,7 @@ module Odysseus
275
275
  begin
276
276
  recent_logs = @docker.logs(container_id, tail: 30)
277
277
  unless recent_logs.strip.empty?
278
- log " Container logs (last 30 lines):", :error
278
+ log ' Container logs (last 30 lines):', :error
279
279
  recent_logs.each_line { |line| log " #{line.rstrip}", :error }
280
280
  end
281
281
  rescue StandardError => e
@@ -292,17 +292,23 @@ module Odysseus
292
292
  end
293
293
 
294
294
  def describe_healthcheck(hc_config)
295
- return "(no health check configured)" unless hc_config && hc_config[:path]
295
+ port = app_port
296
+ path = (hc_config && hc_config[:path]) || DEFAULT_HEALTHCHECK_PATH
297
+ interval = (hc_config && hc_config[:interval]) || 10
296
298
 
297
- port = @config[:proxy][:app_port]
298
- "(GET http://localhost:#{port}#{hc_config[:path]}, interval: #{hc_config[:interval] || 10}s)"
299
+ "(GET http://localhost:#{port}#{path}, interval: #{interval}s)"
300
+ end
301
+
302
+ # Port the app listens on inside the container, nil when no proxy is configured.
303
+ def app_port
304
+ @config[:proxy] && @config[:proxy][:app_port]
299
305
  end
300
306
 
301
307
  def default_logger
302
308
  @default_logger ||= Object.new.tap do |l|
303
- def l.info(msg); puts msg; end
304
- def l.warn(msg); puts "[WARN] #{msg}"; end
305
- def l.error(msg); puts "[ERROR] #{msg}"; end
309
+ def l.info(msg) = puts(msg)
310
+ def l.warn(msg) = puts("[WARN] #{msg}")
311
+ def l.error(msg) = puts("[ERROR] #{msg}")
306
312
  end
307
313
  end
308
314
 
@@ -0,0 +1,72 @@
1
+ # lib/odysseus/plugins.rb
2
+
3
+ module Odysseus
4
+ # Loads the gems named in deploy.yml's `plugins:` (or `sails:`) list, so that
5
+ # the sail and host-provider registries have something in them.
6
+ #
7
+ # Runs before config validation, because the validator is what asks whether a
8
+ # named strategy is registered. That ordering is why this validates its own
9
+ # shape rather than leaving it to Validators::Config like every other key.
10
+ #
11
+ # Requiring a gem name read from a config file is a real capability, and is
12
+ # documented as such: deploy.yml already runs arbitrary docker commands as
13
+ # root on the target hosts, so this widens visibility rather than trust.
14
+ module Plugins
15
+ # `sails:` matches the project's own vocabulary; `plugins:` is what someone
16
+ # guesses without reading the docs. Both work, but not together.
17
+ KEYS = %w[plugins sails].freeze
18
+
19
+ # @param raw_config [Hash] the string-keyed hash straight from YAML
20
+ # @raise [Odysseus::ConfigError] on an ambiguous pair, a bad shape, or a
21
+ # gem that will not load
22
+ def self.load!(raw_config)
23
+ key, names = names_from(raw_config)
24
+ return if key.nil?
25
+
26
+ unless names.is_a?(Array) && names.all?(String)
27
+ raise Odysseus::ConfigError,
28
+ "`#{key}:` must be a list of gem names, got #{names.inspect}"
29
+ end
30
+
31
+ names.each { |name| require_plugin(name, key) }
32
+ nil
33
+ end
34
+
35
+ # Returns the key the config actually used alongside its value, so every
36
+ # diagnostic can quote the key the user wrote rather than whichever of the
37
+ # two aliases we happen to name first.
38
+ #
39
+ # @return [Array(String, Object)] the key and its value, or [nil, nil]
40
+ def self.names_from(raw_config)
41
+ present = KEYS.select { |key| raw_config.key?(key) }
42
+
43
+ if present.length > 1
44
+ raise Odysseus::ConfigError,
45
+ 'deploy.yml has both `plugins:` and `sails:` — use one; they name the same thing'
46
+ end
47
+
48
+ present.empty? ? [nil, nil] : [present.first, raw_config[present.first]]
49
+ end
50
+
51
+ def self.require_plugin(name, key)
52
+ require name
53
+ rescue LoadError => e
54
+ raise Odysseus::ConfigError, load_failure_message(name, key, e)
55
+ end
56
+
57
+ # A plugin whose own `require` fails is installed already, so repeating the
58
+ # install advice sends the user after a gem they have. The two cases read
59
+ # differently, and both carry the file that was actually missing — without
60
+ # it, a sail missing its SDK is indistinguishable from a sail missing.
61
+ def self.load_failure_message(name, key, error)
62
+ if error.path && error.path != name
63
+ "The plugin `#{name}` named in deploy.yml is installed but failed to load: #{error.message}."
64
+ else
65
+ "Could not load the plugin `#{name}` named in deploy.yml (#{error.message}). Add it to your " \
66
+ "Gemfile or run `gem install #{name}`, or remove it from `#{key}:`."
67
+ end
68
+ end
69
+
70
+ private_class_method :names_from, :require_plugin, :load_failure_message
71
+ end
72
+ end
@@ -0,0 +1,14 @@
1
+ # lib/odysseus/retention_plan.rb
2
+
3
+ module Odysseus
4
+ # What retention decided for one host.
5
+ #
6
+ # remove versions whose images should be deleted, **oldest first**, so a
7
+ # partial failure leaves the newest behind
8
+ # keep versions retained, newest first — reported so an operator can see
9
+ # what the window covers without re-deriving it
10
+ #
11
+ # Both hold versions, not image references; the caller pairs them with the
12
+ # configured image name.
13
+ RetentionPlan = Data.define(:remove, :keep)
14
+ end
@@ -0,0 +1,58 @@
1
+ # lib/odysseus/retention_planner.rb
2
+
3
+ module Odysseus
4
+ # Chooses which of a service's image tags to delete from one host.
5
+ #
6
+ # Pure: handed what the host reports, returns a decision. That matters more
7
+ # here than anywhere else in odysseus, because the decision deletes data on a
8
+ # production host — so the rules are testable without a connection, and three
9
+ # independent things have to agree before anything is removed: the retain
10
+ # window, the set of versions containers still reference, and (at the caller)
11
+ # docker's own refusal to remove an image in use.
12
+ class RetentionPlanner
13
+ # Never removed automatically: a moving pointer, and pre-0.4.2 deploys were
14
+ # built from it, so something may still reference it.
15
+ PROTECTED_TAGS = ['latest'].freeze
16
+
17
+ # @param history [Array<Odysseus::DeployLog::Entry>] oldest first
18
+ # @param available [Array<String>] tags present on the host
19
+ # @param in_use [Array<String>] versions containers still reference
20
+ # @param retain [Integer] distinct versions to keep
21
+ def initialize(history:, available:, in_use:, retain:)
22
+ @history = history
23
+ @available = available
24
+ @in_use = in_use
25
+ @retain = retain
26
+ end
27
+
28
+ # @return [Odysseus::RetentionPlan]
29
+ def plan
30
+ RetentionPlan.new(remove: removable, keep: keep)
31
+ end
32
+
33
+ private
34
+
35
+ # Logged versions, newest deploy first, de-duplicated keeping the newest
36
+ # occurrence. Timestamps are fixed-width UTC ISO 8601, so they sort
37
+ # lexicographically. Same ordering RollbackPlanner ranks candidates by.
38
+ def ranked
39
+ @ranked ||= @history.sort_by(&:at).reverse.map(&:version).uniq
40
+ end
41
+
42
+ def keep
43
+ ranked.take(@retain)
44
+ end
45
+
46
+ # Oldest first: if the fifth removal fails, the four already gone were the
47
+ # least likely to be wanted.
48
+ def removable
49
+ (ranked - keep).reverse.select { |version| removable?(version) }
50
+ end
51
+
52
+ def removable?(version)
53
+ @available.include?(version) &&
54
+ !@in_use.include?(version) &&
55
+ !PROTECTED_TAGS.include?(version)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,20 @@
1
+ # lib/odysseus/rollback_plan.rb
2
+
3
+ module Odysseus
4
+ # The decision a rollback acts on, settled before any host is touched.
5
+ #
6
+ # version the tag every host will run
7
+ # ref the commit that tag was built from, recovered from the host's
8
+ # deploy log; nil when no log recorded it
9
+ # approximate true when no host had a deploy log and the ordering came from
10
+ # image creation time, which is build time rather than deploy time
11
+ # replacing host name => the version it is currently running (nil if none),
12
+ # so each host's log records what it actually came from
13
+ RollbackPlan = Data.define(:version, :ref, :approximate, :replacing) do
14
+ # @param host [String]
15
+ # @return [String, nil] the version this host is rolling back from
16
+ def from_for(host)
17
+ replacing[host]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,107 @@
1
+ # lib/odysseus/rollback_planner.rb
2
+
3
+ module Odysseus
4
+ # Chooses the version a rollback will deploy, from what the hosts report.
5
+ #
6
+ # Pure by design: it is handed surveys and returns a plan or raises. No SSH,
7
+ # no config, no git — so every rule below is testable with plain values, and
8
+ # the fleet pre-flight cannot be accidentally bypassed by a caller that
9
+ # already holds a connection.
10
+ class RollbackPlanner
11
+ # @param surveys [Array<Odysseus::HostVersions>] one per host, all roles
12
+ # @raise [Odysseus::RollbackError] when there are no hosts to act on
13
+ def initialize(surveys)
14
+ raise Odysseus::RollbackError, 'No hosts are configured, so there is nothing to roll back' if surveys.empty?
15
+
16
+ @surveys = surveys
17
+ end
18
+
19
+ # @param version [String, nil] explicit target, or nil for the previous one
20
+ # @return [Odysseus::RollbackPlan]
21
+ # @raise [Odysseus::RollbackError] when no target can be rolled back to
22
+ def plan(version: nil)
23
+ target = version || previous_version
24
+ raise Odysseus::RollbackError, no_candidate_message if target.nil?
25
+
26
+ ensure_present_everywhere!(target)
27
+
28
+ RollbackPlan.new(
29
+ version: target,
30
+ ref: ref_for(target),
31
+ approximate: logged_versions.empty?,
32
+ replacing: @surveys.to_h { |survey| [survey.host, survey.current] }
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ # The newest version every host can run and none is already running.
39
+ def previous_version
40
+ candidates.find { |version| present_everywhere?(version) && !serving_anywhere?(version) }
41
+ end
42
+
43
+ # Versions to consider, best first.
44
+ #
45
+ # The deploy log is preferred because image creation time is *build* time:
46
+ # images can reach a host out of order, and a rebuilt host can hold images
47
+ # it never served. With no log anywhere, docker's newest-first image order
48
+ # is the only signal available, and the plan is marked approximate.
49
+ def candidates
50
+ return logged_versions unless logged_versions.empty?
51
+
52
+ # No deploy log exists anywhere to name a specific version, so this is
53
+ # the fallback path. 'latest' is a moving pointer, not a version — the
54
+ # image tagged 'latest' today need not be the one that was running
55
+ # yesterday — so naming it as a rollback target is exactly the
56
+ # ambiguity this whole design exists to remove. Only excluded here: a
57
+ # version the log actually recorded is real and stays a candidate even
58
+ # if, unusually, it is literally tagged 'latest'.
59
+ @surveys.first.available.reject { |version| version == 'latest' }
60
+ end
61
+
62
+ # Every logged version across all hosts, newest deploy first, de-duplicated.
63
+ # DeployLog timestamps are fixed-width UTC ISO 8601, so they sort
64
+ # lexicographically; uniq keeps the newest occurrence of each version.
65
+ def logged_versions
66
+ @logged_versions ||= entries.sort_by(&:at).reverse.map(&:version).uniq
67
+ end
68
+
69
+ def entries
70
+ @entries ||= @surveys.flat_map(&:history)
71
+ end
72
+
73
+ def present_everywhere?(version)
74
+ @surveys.all? { |survey| survey.available?(version) }
75
+ end
76
+
77
+ def serving_anywhere?(version)
78
+ @surveys.any? { |survey| survey.current == version }
79
+ end
80
+
81
+ # The commit the target was built from, per the most recent log entry that
82
+ # mentions it. DeployLog writes '-' for a field it had no value for, which
83
+ # must read back as absent rather than as a commit called '-'.
84
+ def ref_for(target)
85
+ ref = entries.select { |entry| entry.version == target }.max_by(&:at)&.ref
86
+ ref unless ref.nil? || ref == '-'
87
+ end
88
+
89
+ def ensure_present_everywhere!(target)
90
+ missing = @surveys.reject { |survey| survey.available?(target) }.map(&:host)
91
+ return if missing.empty?
92
+
93
+ raise Odysseus::RollbackError,
94
+ "Image #{target} is missing on #{missing.join(', ')}, so rolling back would leave the " \
95
+ 'fleet on mixed versions. Nothing was changed. Run `odysseus rollback --list` to see ' \
96
+ 'what each host has.'
97
+ end
98
+
99
+ def no_candidate_message
100
+ running = @surveys.map { |survey| "#{survey.host} is running #{survey.current || 'nothing'}" }
101
+
102
+ "No version to roll back to (#{running.join('; ')}). A rollback needs a version that every " \
103
+ 'host has an image for and that is not already serving. Run `odysseus rollback --list` to ' \
104
+ 'see each host, or name a version explicitly.'
105
+ end
106
+ end
107
+ end
File without changes
@@ -8,8 +8,8 @@ require 'securerandom'
8
8
  module Odysseus
9
9
  module Secrets
10
10
  class EncryptedFile
11
- CIPHER = 'aes-256-gcm'
12
- KEY_ENV_VAR = 'ODYSSEUS_MASTER_KEY'
11
+ CIPHER = 'aes-256-gcm'.freeze
12
+ KEY_ENV_VAR = 'ODYSSEUS_MASTER_KEY'.freeze
13
13
 
14
14
  class DecryptionError < Odysseus::Error; end
15
15
  class MissingKeyError < Odysseus::Error; end
@@ -56,7 +56,7 @@ module Odysseus
56
56
  private
57
57
 
58
58
  def master_key
59
- ENV[KEY_ENV_VAR]
59
+ ENV.fetch(KEY_ENV_VAR, nil)
60
60
  end
61
61
 
62
62
  def encrypt(data, key)
@@ -76,9 +76,7 @@ module Odysseus
76
76
  auth_tag = cipher.auth_tag
77
77
 
78
78
  # Combine: iv + auth_tag + encrypted_data, all base64 encoded
79
- combined = Base64.strict_encode64(iv) + "\n" +
80
- Base64.strict_encode64(auth_tag) + "\n" +
81
- Base64.strict_encode64(encrypted)
79
+ combined = "#{Base64.strict_encode64(iv)}\n#{Base64.strict_encode64(auth_tag)}\n#{Base64.strict_encode64(encrypted)}"
82
80
 
83
81
  "# Odysseus encrypted secrets\n# Do not edit this file directly\n\n#{combined}"
84
82
  end
@@ -87,7 +85,7 @@ module Odysseus
87
85
  # Remove comments and empty lines
88
86
  lines = content.lines.reject { |l| l.start_with?('#') || l.strip.empty? }
89
87
 
90
- raise DecryptionError, "Invalid encrypted file format" if lines.size < 3
88
+ raise DecryptionError, 'Invalid encrypted file format' if lines.size < 3
91
89
 
92
90
  iv = Base64.strict_decode64(lines[0].strip)
93
91
  auth_tag = Base64.strict_decode64(lines[1].strip)
@@ -22,9 +22,7 @@ module Odysseus
22
22
  path = resolve_path(secrets_file)
23
23
  encrypted = EncryptedFile.new(path)
24
24
 
25
- unless encrypted.exists?
26
- raise Odysseus::ConfigError, "Secrets file not found: #{path}"
27
- end
25
+ raise Odysseus::ConfigError, "Secrets file not found: #{path}" unless encrypted.exists?
28
26
 
29
27
  @cached_secrets = encrypted.read
30
28
  end