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
@@ -16,14 +16,27 @@ module Odysseus
16
16
  @secrets_loader = Odysseus::Secrets::Loader.new(@config, config_dir: @config_dir)
17
17
  end
18
18
 
19
+ # The identity of the deploy: version, ref and deployer.
20
+ #
21
+ # Resolved once per tag so a multi-role, multi-host deploy cannot end up
22
+ # with two versions, and so the git commands run once rather than per host.
23
+ #
24
+ # @param image_tag [String, nil] explicit tag, or nil to resolve from git
25
+ # @return [Odysseus::DeployVersion]
26
+ def deploy_version(image_tag = nil)
27
+ @deploy_versions ||= {}
28
+ @deploy_versions[image_tag] ||= version_resolver.resolve(image_tag: image_tag)
29
+ end
30
+
19
31
  # Build Docker image
20
- # @param image_tag [String] docker image tag (e.g., "v1.0.0")
32
+ # @param image_tag [String, nil] docker image tag (e.g., "v1.0.0"), or nil to resolve from git
21
33
  # @param push [Boolean] push to registry after build
22
34
  # @param context_path [String] path to build context (defaults to config directory)
23
35
  # @return [Hash] build result
24
- def build(image_tag:, push: false, context_path: nil)
36
+ def build(image_tag: nil, push: false, context_path: nil)
37
+ resolved = deploy_version(image_tag)
25
38
  context = context_path || resolve_build_context
26
- full_image = "#{@config[:image]}:#{image_tag}"
39
+ full_image = "#{@config[:image]}:#{resolved.version}"
27
40
 
28
41
  builder = build_builder
29
42
 
@@ -39,17 +52,15 @@ module Odysseus
39
52
  end
40
53
 
41
54
  # Build and deploy in one step
42
- # @param image_tag [String] docker image tag
55
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
43
56
  # @param context_path [String] path to build context
44
57
  # @param dry_run [Boolean] if true, don't actually deploy
45
58
  # @return [Hash] results
46
- def build_and_deploy(image_tag:, context_path: nil, dry_run: false)
59
+ def build_and_deploy(image_tag: nil, context_path: nil, dry_run: false)
47
60
  # First, build the image
48
61
  build_result = build(image_tag: image_tag, push: true, context_path: context_path)
49
62
 
50
- unless build_result[:success]
51
- return { build: build_result, deploy: nil, success: false }
52
- end
63
+ return { build: build_result, deploy: nil, success: false } unless build_result[:success]
53
64
 
54
65
  # Then deploy
55
66
  deploy_results = deploy_all(image_tag: image_tag, dry_run: dry_run)
@@ -62,15 +73,14 @@ module Odysseus
62
73
  end
63
74
 
64
75
  # Push image to all configured hosts via SSH (using docker pussh)
65
- # @param image_tag [String] docker image tag
76
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
66
77
  # @return [Hash] pussh results
67
- def pussh(image_tag:)
68
- full_image = "#{@config[:image]}:#{image_tag}"
78
+ def pussh(image_tag: nil)
79
+ resolved = deploy_version(image_tag)
80
+ full_image = "#{@config[:image]}:#{resolved.version}"
69
81
  hosts = collect_all_hosts
70
82
 
71
- if hosts.empty?
72
- return { success: false, error: "No hosts configured" }
73
- end
83
+ return { success: false, error: 'No hosts configured' } if hosts.empty?
74
84
 
75
85
  builder = build_builder
76
86
  builder.pussh_to_hosts(
@@ -81,16 +91,14 @@ module Odysseus
81
91
  end
82
92
 
83
93
  # Build and pussh to all hosts (no registry needed)
84
- # @param image_tag [String] docker image tag
94
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
85
95
  # @param context_path [String] path to build context
86
96
  # @return [Hash] build and pussh results
87
- def build_and_pussh(image_tag:, context_path: nil)
97
+ def build_and_pussh(image_tag: nil, context_path: nil)
88
98
  # First, build the image locally
89
99
  build_result = build(image_tag: image_tag, push: false, context_path: context_path)
90
100
 
91
- unless build_result[:success]
92
- return { build: build_result, pussh: nil, success: false }
93
- end
101
+ return { build: build_result, pussh: nil, success: false } unless build_result[:success]
94
102
 
95
103
  # Then pussh to all hosts
96
104
  pussh_result = pussh(image_tag: image_tag)
@@ -104,10 +112,10 @@ module Odysseus
104
112
 
105
113
  # Build and distribute image based on config
106
114
  # Uses registry if configured, otherwise pussh to hosts
107
- # @param image_tag [String] docker image tag
115
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
108
116
  # @param context_path [String] path to build context
109
117
  # @return [Hash] build and distribution results
110
- def build_and_distribute(image_tag:, context_path: nil)
118
+ def build_and_distribute(image_tag: nil, context_path: nil)
111
119
  if uses_registry?
112
120
  build_and_push_to_registry(image_tag: image_tag, context_path: context_path)
113
121
  else
@@ -122,10 +130,10 @@ module Odysseus
122
130
  end
123
131
 
124
132
  # Build and push to registry
125
- # @param image_tag [String] docker image tag
133
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
126
134
  # @param context_path [String] path to build context
127
135
  # @return [Hash] build and push results
128
- def build_and_push_to_registry(image_tag:, context_path: nil)
136
+ def build_and_push_to_registry(image_tag: nil, context_path: nil)
129
137
  build_result = build(image_tag: image_tag, push: true, context_path: context_path)
130
138
 
131
139
  {
@@ -136,9 +144,9 @@ module Odysseus
136
144
  end
137
145
 
138
146
  # Deploy all roles to their configured hosts
139
- # @param image_tag [String] docker image tag
147
+ # @param image_tag [String, nil] docker image tag, or nil to resolve from git
140
148
  # @param dry_run [Boolean] if true, don't actually deploy
141
- def deploy_all(image_tag:, dry_run: false)
149
+ def deploy_all(image_tag: nil, dry_run: false)
142
150
  results = {}
143
151
 
144
152
  @config[:servers].each do |role, role_config|
@@ -149,188 +157,239 @@ module Odysseus
149
157
  end
150
158
  end
151
159
 
152
- results
153
- end
160
+ prune_old_images unless dry_run
154
161
 
155
- # Deploy a single role to a specific host
156
- # @param host [String] target host (from config)
157
- # @param image_tag [String] docker image tag (e.g., "v1.2.3")
158
- # @param dry_run [Boolean] if true, don't actually deploy
159
- # @param role [Symbol] server role
160
- def deploy_role(host:, image_tag:, dry_run: false, role:)
161
- if dry_run
162
- puts "Dry run - would deploy #{@config[:image]}:#{image_tag} to #{host}"
163
- puts "Service: #{@config[:service]}"
164
- puts "Role: #{role}"
165
- if role == WEB_ROLE
166
- puts "Proxy hosts: #{@config[:proxy][:hosts].join(', ')}"
167
- end
168
- return { success: true, dry_run: true }
169
- end
170
-
171
- ssh = connect_to_server(host)
172
-
173
- begin
174
- orchestrator = build_orchestrator(ssh, role)
175
- orchestrator.deploy(image_tag: image_tag, role: role)
176
- ensure
177
- ssh.close
178
- end
162
+ results
179
163
  end
180
164
 
181
- # Deploy an accessory to all its configured hosts
182
- # @param name [Symbol] accessory name
183
- def deploy_accessory(name:)
184
- acc_config = get_accessory_config(name)
185
- hosts = acc_config[:hosts] || []
186
-
187
- raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
188
-
189
- results = {}
190
- hosts.each do |host|
191
- puts "Deploying accessory #{name} to #{host}..."
165
+ # What every host reports about this service's versions.
166
+ #
167
+ # One entry per unique host across all roles, so a host serving two roles
168
+ # is surveyed once. Connections are opened and closed per host.
169
+ #
170
+ # @return [Array<Odysseus::HostVersions>]
171
+ def version_survey
172
+ host_roles.map do |host, roles|
192
173
  ssh = connect_to_server(host)
193
174
 
194
175
  begin
195
- orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
196
- results[host] = orchestrator.deploy(name: name.to_sym)
176
+ Odysseus::HostVersions.read(
177
+ host: host, ssh: ssh, service: @config[:service], image: @config[:image], roles: roles
178
+ )
197
179
  ensure
198
180
  ssh.close
199
181
  end
200
182
  end
201
- results
202
183
  end
203
184
 
204
- # Remove an accessory from all its configured hosts
205
- # @param name [Symbol] accessory name
206
- def remove_accessory(name:)
207
- acc_config = get_accessory_config(name)
208
- hosts = acc_config[:hosts] || []
209
-
210
- raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
185
+ # Decide what a rollback would do, without doing it.
186
+ #
187
+ # Surveys the fleet and returns the plan, or raises RollbackError with a
188
+ # message naming the hosts at fault. Separate from rollback_all so the
189
+ # caller can show the target — and any approximate-ordering warning —
190
+ # before anything is touched, and so the survey runs once.
191
+ #
192
+ # @param version [String, nil] explicit target, or nil for the previous one
193
+ # @return [Odysseus::RollbackPlan]
194
+ def rollback_plan(version: nil)
195
+ Odysseus::RollbackPlanner.new(version_survey).plan(version: version)
196
+ end
211
197
 
198
+ # Roll every role on every host back to the planned version.
199
+ #
200
+ # Reuses the deploy path unchanged, so health gating, proxy handling and
201
+ # zero-downtime behaviour are shared with deploy rather than
202
+ # reimplemented. Sequential, and inheriting deploy's partial-failure
203
+ # semantics: the plan's pre-flight rules out the common cause of a
204
+ # half-rolled-back fleet — a missing image — but does not make the roll
205
+ # atomic.
206
+ #
207
+ # @param plan [Odysseus::RollbackPlan] from #rollback_plan
208
+ # @return [Hash] results keyed "role@host"
209
+ def rollback_all(plan)
210
+ resolved = Odysseus::DeployVersion.new(
211
+ version: plan.version, ref: plan.ref, deployer: version_resolver.deployer
212
+ )
212
213
  results = {}
213
- hosts.each do |host|
214
- puts "Removing accessory #{name} from #{host}..."
215
- ssh = connect_to_server(host)
216
214
 
217
- begin
218
- orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
219
- results[host] = orchestrator.remove(name: name.to_sym)
220
- ensure
221
- ssh.close
215
+ @config[:servers].each do |role, role_config|
216
+ resolve_hosts(role_config).each do |host|
217
+ puts "\n=== Rolling back #{role} on #{host} to #{plan.version} ==="
218
+ results["#{role}@#{host}"] = run_deploy(
219
+ host: host, role: role, resolved: resolved,
220
+ kind: 'rolled-back', from: plan.from_for(host)
221
+ )
222
222
  end
223
223
  end
224
+
224
225
  results
225
226
  end
226
227
 
227
- # Restart an accessory on all its configured hosts
228
- # @param name [Symbol] accessory name
229
- def restart_accessory(name:)
230
- acc_config = get_accessory_config(name)
231
- hosts = acc_config[:hosts] || []
232
-
233
- raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
228
+ # Delete a service's images that no host needs any more.
229
+ #
230
+ # @return [Hash{String => Array<String>}] versions removed, keyed by host
231
+ def prune_old_images
232
+ retention_sweeper.sweep(host_roles)
233
+ end
234
234
 
235
- results = {}
236
- hosts.each do |host|
237
- puts "Restarting accessory #{name} on #{host}..."
238
- ssh = connect_to_server(host)
235
+ # Deploy a single role to a specific host
236
+ # @param host [String] target host (from config)
237
+ # @param image_tag [String, nil] docker image tag (e.g., "v1.2.3"), or nil to resolve from git
238
+ # @param dry_run [Boolean] if true, don't actually deploy
239
+ # @param role [Symbol] server role
240
+ def deploy_role(host:, role:, image_tag: nil, dry_run: false)
241
+ resolved = deploy_version(image_tag)
239
242
 
240
- begin
241
- orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
242
- results[host] = orchestrator.restart(name: name.to_sym)
243
- ensure
244
- ssh.close
245
- end
243
+ if dry_run
244
+ puts "Dry run - would deploy #{@config[:image]}:#{resolved.version} to #{host}"
245
+ puts "Service: #{@config[:service]}"
246
+ puts "Role: #{role}"
247
+ puts "Proxy hosts: #{@config[:proxy][:hosts].join(', ')}" if role == WEB_ROLE
248
+ return { success: true, dry_run: true }
246
249
  end
247
- results
250
+
251
+ run_deploy(host: host, role: role, resolved: resolved)
248
252
  end
249
253
 
250
- # Upgrade an accessory to a new image version on all its configured hosts
251
- # @param name [Symbol] accessory name
252
- def upgrade_accessory(name:)
253
- acc_config = get_accessory_config(name)
254
- hosts = acc_config[:hosts] || []
254
+ # Deploy an dependency to all its configured hosts
255
+ # @param name [Symbol] dependency name
256
+ def deploy_dependency(name:)
257
+ dependency_manager.deploy(name: name)
258
+ end
255
259
 
256
- raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
260
+ # Remove an dependency from all its configured hosts
261
+ # @param name [Symbol] dependency name
262
+ def remove_dependency(name:)
263
+ dependency_manager.remove(name: name)
264
+ end
257
265
 
258
- results = {}
259
- hosts.each do |host|
260
- puts "Upgrading accessory #{name} on #{host}..."
261
- ssh = connect_to_server(host)
266
+ # Restart an dependency on all its configured hosts
267
+ # @param name [Symbol] dependency name
268
+ def restart_dependency(name:)
269
+ dependency_manager.restart(name: name)
270
+ end
262
271
 
263
- begin
264
- orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
265
- results[host] = orchestrator.upgrade(name: name.to_sym)
266
- ensure
267
- ssh.close
268
- end
269
- end
270
- results
272
+ # Upgrade an dependency to a new image version on all its configured hosts
273
+ # @param name [Symbol] dependency name
274
+ def upgrade_dependency(name:)
275
+ dependency_manager.upgrade(name: name)
271
276
  end
272
277
 
273
- # List accessory status on all configured hosts
274
- def accessory_status
275
- return [] unless @config[:accessories]&.any?
278
+ # List dependency status on all configured hosts
279
+ def dependency_status
280
+ dependency_manager.status
281
+ end
276
282
 
277
- all_statuses = []
278
- @config[:accessories].each do |name, acc_config|
279
- hosts = acc_config[:hosts] || []
280
- hosts.each do |host|
281
- ssh = connect_to_server(host)
282
- begin
283
- orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
284
- status = orchestrator.get_status(name: name.to_sym)
285
- status[:host] = host
286
- all_statuses << status
287
- ensure
288
- ssh.close
289
- end
290
- end
291
- end
292
- all_statuses
283
+ # Boot all dependencies to their configured hosts
284
+ def boot_dependencies
285
+ dependency_manager.boot_all
293
286
  end
294
287
 
295
- # Boot all accessories to their configured hosts
296
- def boot_accessories
297
- return {} unless @config[:accessories]&.any?
288
+ # Every configured host mapped to the roles it serves, each in config
289
+ # order. A host serving two roles (e.g. web and cron on the same box)
290
+ # gets both, in the order its roles are declared in deploy.yml — the
291
+ # order #version_survey depends on to know which role's containers to
292
+ # look for first.
293
+ #
294
+ # Public API, not just an internal helper: the CLI asks this exact
295
+ # question too — which hosts does this config target, and which roles
296
+ # on each — to visit every host once regardless of how many roles it
297
+ # serves. `odysseus doctor` depends on it.
298
+ #
299
+ # @return [Hash{String => Array<Symbol>}]
300
+ def host_roles
301
+ roles_by_host = {}
298
302
 
299
- results = {}
300
- @config[:accessories].each_key do |name|
301
- puts "\n=== Booting accessory: #{name} ==="
302
- results[name] = deploy_accessory(name: name)
303
+ @config[:servers].each do |role, role_config|
304
+ resolve_hosts(role_config).each { |host| (roles_by_host[host] ||= []) << role }
303
305
  end
304
- results
306
+
307
+ roles_by_host
305
308
  end
306
309
 
307
310
  private
308
311
 
309
- def get_accessory_config(name)
310
- name_sym = name.to_sym
311
- acc_config = @config[:accessories]&.[](name_sym)
312
- raise Odysseus::ConfigError, "Accessory '#{name}' not found in config" unless acc_config
313
- acc_config
312
+ # Dependency verbs are a distinct concern from deploy/rollback; see
313
+ # Odysseus::Deployer::DependencyManager.
314
+ def dependency_manager
315
+ @dependency_manager ||= Odysseus::Deployer::DependencyManager.new(
316
+ config: @config, secrets_loader: @secrets_loader, connector: method(:connect_to_server)
317
+ )
318
+ end
319
+
320
+ def version_resolver
321
+ @version_resolver ||= Odysseus::VersionResolver.new(config_dir: @config_dir, logger: build_logger)
322
+ end
323
+
324
+ # Image retention is a distinct concern from deploy/rollback; see
325
+ # Odysseus::Deployer::RetentionSweeper.
326
+ def retention_sweeper
327
+ @retention_sweeper ||= Odysseus::Deployer::RetentionSweeper.new(
328
+ config: @config, connector: method(:connect_to_server), logger: build_logger
329
+ )
330
+ end
331
+
332
+ # Sails are constructed with a fixed keyword set, so version metadata
333
+ # travels in the config hash rather than as a new keyword argument.
334
+ def orchestrator_config(resolved)
335
+ @config.merge(deploy_version: resolved)
336
+ end
337
+
338
+ # One role on one host: connect, hand off to the orchestrator, record the
339
+ # outcome on the host, close. Shared by deploy and rollback so both get
340
+ # identical health gating, proxy handling and audit trail.
341
+ #
342
+ # @param kind [String] 'deployed' or 'rolled-back'
343
+ # @param from [String, nil] the version being replaced, for a rollback
344
+ def run_deploy(host:, role:, resolved:, kind: 'deployed', from: nil)
345
+ ssh = connect_to_server(host)
346
+
347
+ begin
348
+ orchestrator = build_orchestrator(ssh, role, resolved)
349
+ result = orchestrator.deploy(image_tag: resolved.version, role: role)
350
+ record_deploy(ssh: ssh, host: host, role: role, resolved: resolved, kind: kind, from: from)
351
+ result
352
+ ensure
353
+ ssh.close
354
+ end
314
355
  end
315
356
 
316
- def build_orchestrator(ssh, role)
357
+ # The host's own record of what it is running, written only after the
358
+ # orchestrator reports success.
359
+ #
360
+ # Best effort, and deliberately rescuing StandardError rather than
361
+ # Odysseus::Error: SSH#execute can also raise Net::SSH::Disconnect,
362
+ # IOError or Net::SSH::ChannelOpenFailed, none of which with_connection
363
+ # translates. Traffic has already switched by this point, so any of them
364
+ # escaping here would turn a completed deploy into a reported failure.
365
+ def record_deploy(ssh:, host:, role:, resolved:, kind:, from:)
366
+ Odysseus::DeployLog.new(ssh: ssh, service: @config[:service]).append(
367
+ version: resolved.version, role: role, ref: resolved.ref,
368
+ deployer: resolved.deployer, kind: kind, from: from
369
+ )
370
+ rescue StandardError => e
371
+ build_logger.warn("Could not record the deploy on #{host}: #{e.message}")
372
+ end
373
+
374
+ def build_orchestrator(ssh, role, resolved)
317
375
  logger = build_logger
318
376
  role_config = @config[:servers][role] || {}
319
377
  strategy = role_config.dig(:deploy, :strategy)
378
+ config = orchestrator_config(resolved)
320
379
 
321
380
  # Check if a sail plugin provides this strategy
322
381
  if strategy && Odysseus::Sails.registered?(strategy)
323
382
  sail_klass = Odysseus::Sails.resolve(strategy)
324
383
  sail_klass.new(
325
- ssh: ssh, config: @config, logger: logger, secrets_loader: @secrets_loader
384
+ ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
326
385
  )
327
386
  elsif role == WEB_ROLE
328
387
  Odysseus::Orchestrator::WebDeploy.new(
329
- ssh: ssh, config: @config, logger: logger, secrets_loader: @secrets_loader
388
+ ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
330
389
  )
331
390
  else
332
391
  Odysseus::Orchestrator::JobDeploy.new(
333
- ssh: ssh, config: @config, logger: logger, secrets_loader: @secrets_loader
392
+ ssh: ssh, config: config, logger: logger, secrets_loader: @secrets_loader
334
393
  )
335
394
  end
336
395
  end
@@ -341,7 +400,7 @@ module Odysseus
341
400
  l.define_singleton_method(:info) { |msg| puts msg }
342
401
  l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
343
402
  l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
344
- l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if verbose }
403
+ l.define_singleton_method(:debug) { |msg| puts " > #{Odysseus::CommandRedaction.redact(msg)}" if verbose }
345
404
  l.define_singleton_method(:verbose?) { verbose }
346
405
  end
347
406
  end
@@ -377,14 +436,7 @@ module Odysseus
377
436
  end
378
437
 
379
438
  def collect_all_hosts
380
- hosts = []
381
-
382
- @config[:servers].each do |_role, role_config|
383
- role_hosts = resolve_hosts(role_config)
384
- hosts.concat(role_hosts)
385
- end
386
-
387
- hosts.uniq
439
+ host_roles.keys
388
440
  end
389
441
 
390
442
  # Resolve hosts for a role using the appropriate provider
@@ -0,0 +1,94 @@
1
+ # lib/odysseus/deployer/retention_sweeper.rb
2
+
3
+ module Odysseus
4
+ module Deployer
5
+ # Removes a service's images that no host needs any more, split out of
6
+ # Executor for the same reason DependencyManager was: it is a distinct
7
+ # concern, sharing only the config and a way to open a connection.
8
+ #
9
+ # Best effort throughout. This runs after a deploy has already succeeded and
10
+ # switched traffic, so nothing here may raise into the caller: every removal
11
+ # is attempted on its own, and a host that cannot be read at all is skipped
12
+ # with a warning.
13
+ class RetentionSweeper
14
+ # @param config [Hash] parsed deploy.yml
15
+ # @param connector [#call] returns an open SSH connection for a host
16
+ # @param logger [Object] responds to #info and #warn
17
+ def initialize(config:, connector:, logger:)
18
+ @config = config
19
+ @connector = connector
20
+ @logger = logger
21
+ end
22
+
23
+ # @param host_roles [Hash{String => Array<Symbol>}] hosts and the roles each serves
24
+ # @return [Hash{String => Array<String>}] versions removed, keyed by host
25
+ def sweep(host_roles)
26
+ host_roles.to_h { |host, roles| [host, sweep_host(host, roles)] }
27
+ end
28
+
29
+ private
30
+
31
+ def sweep_host(host, roles)
32
+ ssh = @connector.call(host)
33
+
34
+ begin
35
+ docker = Odysseus::Docker::Client.new(ssh)
36
+ plan = retention_plan(ssh, docker, host, roles)
37
+ return [] if plan.nil?
38
+
39
+ @logger.info(" Keeping #{plan.keep.join(', ')} on #{host}")
40
+ plan.remove.select { |version| prune_image(docker, host, version) }
41
+ rescue StandardError => e
42
+ @logger.warn("Could not prune images on #{host}: #{e.message}")
43
+ []
44
+ ensure
45
+ ssh.close
46
+ end
47
+ end
48
+
49
+ # nil when the host has no deploy log to authorise removals. Falling back
50
+ # to image creation time would risk deleting a version someone still
51
+ # wants: creation time is *build* time, and images can reach a host out of
52
+ # order.
53
+ def retention_plan(ssh, docker, host, roles)
54
+ history = Odysseus::DeployLog.new(ssh: ssh, service: @config[:service]).entries
55
+
56
+ if history.empty?
57
+ @logger.info(" No deploy log on #{host} yet, so nothing is pruned")
58
+ return nil
59
+ end
60
+
61
+ Odysseus::RetentionPlanner.new(
62
+ history: history,
63
+ available: docker.image_tags(@config[:image]),
64
+ in_use: docker.versions_in_use(container_labels(roles)),
65
+ retain: @config[:retain_versions]
66
+ ).plan
67
+ end
68
+
69
+ # The odysseus.service label values this host's containers carry — one per
70
+ # role. Built with Labels.service_for so reading them back cannot disagree
71
+ # with how WebDeploy and JobDeploy write them.
72
+ def container_labels(roles)
73
+ roles.map { |role| Odysseus::Docker::Labels.service_for(service: @config[:service], role: role) }
74
+ end
75
+
76
+ # True when the image is gone. Named prune_image, not remove_image, so it
77
+ # cannot be misread as Docker::Client#remove_image, which it calls.
78
+ #
79
+ # Rescues StandardError rather than Odysseus::Error: docker refuses to
80
+ # remove an image a container still references, and SSH#execute can also
81
+ # raise Net::SSH::Disconnect, IOError or Net::SSH::ChannelOpenFailed
82
+ # untranslated.
83
+ def prune_image(docker, host, version)
84
+ image = "#{@config[:image]}:#{version}"
85
+ docker.remove_image(image)
86
+ @logger.info(" Pruned #{image} on #{host}")
87
+ true
88
+ rescue StandardError => e
89
+ @logger.info(" Kept #{image} on #{host}: #{e.message}")
90
+ false
91
+ end
92
+ end
93
+ end
94
+ end