odysseus-cli 0.3.0 → 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.
@@ -4,10 +4,19 @@ require 'odysseus'
4
4
  require 'yaml'
5
5
  require 'tempfile'
6
6
  require_relative 'ui'
7
+ require_relative 'rollback_commands'
8
+ require_relative 'interactive_commands'
9
+ require_relative 'doctor_commands'
10
+ require_relative 'setup_commands'
7
11
 
8
12
  module Odysseus
9
13
  module CLI
10
14
  class CLI
15
+ include RollbackCommands
16
+ include InteractiveCommands
17
+ include DoctorCommands
18
+ include SetupCommands
19
+
11
20
  def initialize(debug: false)
12
21
  @ui = UI.new(debug: debug)
13
22
  end
@@ -15,7 +24,7 @@ module Odysseus
15
24
  # Deploy command
16
25
  def deploy(options = {})
17
26
  config_file = options[:config] || 'deploy.yml'
18
- image_tag = options[:image] || 'latest'
27
+ image_tag = options[:image]
19
28
  should_build = options[:build] || false
20
29
  dry_run = options[:'dry-run'] || false
21
30
  verbose = options[:verbose] || @ui.debug?
@@ -23,41 +32,37 @@ module Odysseus
23
32
  config = load_config(config_file)
24
33
  uses_registry = config[:registry] && config[:registry][:server]
25
34
 
26
- distribution = uses_registry ? "registry (#{config[:registry][:server]})" : "pussh (SSH)"
35
+ executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
36
+ resolved = executor.deploy_version(image_tag)
37
+
38
+ distribution = uses_registry ? "registry (#{config[:registry][:server]})" : 'pussh (SSH)'
27
39
  @ui.deploy_header(
28
40
  service: config[:service],
29
41
  image: config[:image],
30
- image_tag: image_tag,
42
+ image_tag: resolved.version,
31
43
  build: should_build,
32
44
  distribution: distribution
33
45
  )
34
46
 
35
- executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
36
47
  start_time = Time.now
37
48
 
38
49
  if should_build
39
50
  # Build phase — captured as streaming steps
40
- @ui.stream_steps(title: "Building and distributing") do
51
+ @ui.stream_steps(title: 'Building and distributing') do
41
52
  build_result = executor.build_and_distribute(image_tag: image_tag)
42
53
 
43
- unless build_result[:build][:success]
44
- raise Odysseus::BuildError, "Build failed: #{build_result[:build][:error]}"
45
- end
54
+ raise Odysseus::BuildError, "Build failed: #{build_result[:build][:error]}" unless build_result[:build][:success]
46
55
 
47
56
  if uses_registry
48
- unless build_result[:push][:success]
49
- raise Odysseus::BuildError, "Push to registry failed"
50
- end
57
+ raise Odysseus::BuildError, 'Push to registry failed' unless build_result[:push][:success]
51
58
  else
52
- unless build_result[:pussh][:success]
53
- raise Odysseus::BuildError, "Image distribution failed"
54
- end
59
+ raise Odysseus::BuildError, 'Image distribution failed' unless build_result[:pussh][:success]
55
60
  end
56
61
  end
57
62
  end
58
63
 
59
64
  # Deploy phase — each orchestrator log line becomes a sub-step
60
- @ui.stream_steps(title: "Deploying service") do
65
+ @ui.stream_steps(title: 'Deploying service') do
61
66
  executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
62
67
  end
63
68
 
@@ -71,27 +76,28 @@ module Odysseus
71
76
  # Build command
72
77
  def build(options = {})
73
78
  config_file = options[:config] || 'deploy.yml'
74
- image_tag = options[:image] || 'latest'
79
+ image_tag = options[:image]
75
80
  push = options[:push] || false
76
81
  context_path = options[:context]
77
82
  verbose = options[:verbose] || @ui.debug?
78
83
 
79
84
  config = load_config(config_file)
80
85
 
81
- @ui.header "Odysseus Build"
82
- @ui.info "Image", "#{config[:image]}:#{image_tag}"
83
- @ui.info "Strategy", (config.dig(:builder, :strategy) || :local).to_s
84
- @ui.blank
85
-
86
86
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
87
+ resolved = executor.deploy_version(image_tag)
88
+
89
+ @ui.header 'Odysseus Build'
90
+ @ui.info 'Image', "#{config[:image]}:#{resolved.version}"
91
+ @ui.info 'Strategy', (config.dig(:builder, :strategy) || :local).to_s
92
+ @ui.blank
87
93
 
88
- result = @ui.spin_step("Building image #{config[:image]}:#{image_tag}") do
94
+ result = @ui.spin_step("Building image #{config[:image]}:#{resolved.version}") do
89
95
  executor.build(image_tag: image_tag, push: push, context_path: context_path)
90
96
  end
91
97
 
92
98
  if result[:success]
93
- @ui.step_ok "Pushed to registry" if result[:pushed]
94
- @ui.step_ok "Build complete"
99
+ @ui.step_ok 'Pushed to registry' if result[:pushed]
100
+ @ui.step_ok 'Build complete'
95
101
  else
96
102
  @ui.step_fail "Build failed: #{result[:error]}"
97
103
  exit 1
@@ -104,20 +110,21 @@ module Odysseus
104
110
  # Pussh command
105
111
  def pussh(options = {})
106
112
  config_file = options[:config] || 'deploy.yml'
107
- image_tag = options[:image] || 'latest'
113
+ image_tag = options[:image]
108
114
  should_build = options[:build] || false
109
115
  verbose = options[:verbose] || @ui.debug?
110
116
 
111
117
  config = load_config(config_file)
112
118
 
113
- @ui.header "Odysseus Pussh"
114
- @ui.info "Image", "#{config[:image]}:#{image_tag}"
115
- @ui.blank
116
-
117
119
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
120
+ resolved = executor.deploy_version(image_tag)
121
+
122
+ @ui.header 'Odysseus Pussh'
123
+ @ui.info 'Image', "#{config[:image]}:#{resolved.version}"
124
+ @ui.blank
118
125
 
119
126
  if should_build
120
- result = @ui.spin_step("Building image #{config[:image]}:#{image_tag}") do
127
+ result = @ui.spin_step("Building image #{config[:image]}:#{resolved.version}") do
121
128
  executor.build_and_pussh(image_tag: image_tag)
122
129
  end
123
130
 
@@ -127,7 +134,7 @@ module Odysseus
127
134
  end
128
135
  pussh_result = result[:pussh]
129
136
  else
130
- pussh_result = @ui.spin_step("Pushing image via SSH...") do
137
+ pussh_result = @ui.spin_step('Pushing image via SSH...') do
131
138
  executor.pussh(image_tag: image_tag)
132
139
  end
133
140
  end
@@ -135,7 +142,7 @@ module Odysseus
135
142
  if pussh_result[:success]
136
143
  @ui.step_ok "Pussh complete (#{pussh_result[:results]&.size || 0} host(s))"
137
144
  else
138
- @ui.step_fail "Pussh failed"
145
+ @ui.step_fail 'Pussh failed'
139
146
  exit 1
140
147
  end
141
148
  rescue Odysseus::Error => e
@@ -149,9 +156,9 @@ module Odysseus
149
156
  config = load_config(config_file)
150
157
  service_name = config[:service]
151
158
 
152
- @ui.header "Odysseus Status"
153
- @ui.info "Service", service_name
154
- @ui.info "Server", server
159
+ @ui.header 'Odysseus Status'
160
+ @ui.info 'Service', service_name
161
+ @ui.info 'Server', server
155
162
  @ui.blank
156
163
 
157
164
  ssh = connect_to_server(server, config)
@@ -161,24 +168,21 @@ module Odysseus
161
168
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
162
169
 
163
170
  # Web containers
164
- @ui.section "Web"
171
+ @ui.section 'Web'
165
172
  web_containers = docker.list(service: service_name)
166
173
  if web_containers.empty?
167
- @ui.step "(no containers running)"
174
+ @ui.step '(no containers running)'
168
175
  else
169
- rows = web_containers.map do |c|
170
- health = c['Status'].include?('healthy') ? '✓' : ''
171
- [c['Names'], c['State'], c['Image'], health]
172
- end
173
- @ui.table(headers: ['Name', 'State', 'Image', 'Health'], rows: rows)
176
+ rows = web_containers.map { |c| web_container_row(c) }
177
+ @ui.table(headers: %w[Version Ref Deployed Image State Health], rows: rows)
174
178
  end
175
179
 
176
180
  caddy_status = caddy.status
177
181
  if caddy_status[:running]
178
182
  svc_route = caddy_status[:services].find { |s| s[:service] == service_name }
179
183
  if svc_route
180
- @ui.info "Proxy", svc_route[:hosts].join(', ')
181
- @ui.info "Upstreams", svc_route[:upstreams].join(', ')
184
+ @ui.info 'Proxy', svc_route[:hosts].join(', ')
185
+ @ui.info 'Upstreams', svc_route[:upstreams].join(', ')
182
186
  end
183
187
  end
184
188
  @ui.blank
@@ -186,7 +190,7 @@ module Odysseus
186
190
  # Workers
187
191
  non_web_roles = config[:servers].keys.reject { |r| r == :web }
188
192
  if non_web_roles.any?
189
- @ui.section "Workers"
193
+ @ui.section 'Workers'
190
194
  rows = []
191
195
  non_web_roles.each do |role|
192
196
  role_service = "#{service_name}-#{role}"
@@ -199,14 +203,14 @@ module Odysseus
199
203
  end
200
204
  end
201
205
  end
202
- @ui.table(headers: ['Role', 'State', 'Name', 'Image'], rows: rows)
206
+ @ui.table(headers: %w[Role State Name Image], rows: rows)
203
207
  @ui.blank
204
208
  end
205
209
 
206
- # Accessories
207
- if config[:accessories]&.any?
208
- @ui.section "Accessories"
209
- rows = config[:accessories].map do |name, acc_config|
210
+ # Dependencies
211
+ if config[:dependencies]&.any?
212
+ @ui.section 'Dependencies'
213
+ rows = config[:dependencies].map do |name, acc_config|
210
214
  acc_service = "#{service_name}-#{name}"
211
215
  containers = docker.list(service: acc_service, all: true)
212
216
  running = containers.find { |c| c['State'] == 'running' }
@@ -217,27 +221,27 @@ module Odysseus
217
221
  [name.to_s, 'stopped', acc_config[:image], '-', '']
218
222
  end
219
223
  end
220
- @ui.table(headers: ['Name', 'State', 'Image', 'Container', 'Health'], rows: rows)
224
+ @ui.table(headers: %w[Name State Image Container Health], rows: rows)
221
225
  @ui.blank
222
226
  end
223
227
 
224
228
  # TLS
225
229
  if config[:proxy][:hosts]&.any?
226
- @ui.section "TLS"
230
+ @ui.section 'TLS'
227
231
  if caddy_status[:running] && caddy_status[:tls][:enabled]
228
232
  service_hosts = config[:proxy][:hosts]
229
233
  relevant_policy = caddy_status[:tls][:policies].find do |p|
230
- (p[:subjects] & service_hosts).any?
234
+ p[:subjects].intersect?(service_hosts)
231
235
  end
232
236
  if relevant_policy
233
- @ui.info "Domains", (relevant_policy[:subjects] & service_hosts).join(', ')
234
- @ui.info "Issuer", relevant_policy[:issuer]
235
- @ui.info "Email", relevant_policy[:email] || '(not set)'
237
+ @ui.info 'Domains', (relevant_policy[:subjects] & service_hosts).join(', ')
238
+ @ui.info 'Issuer', relevant_policy[:issuer]
239
+ @ui.info 'Email', relevant_policy[:email] || '(not set)'
236
240
  else
237
- @ui.step "(not configured for this service)"
241
+ @ui.step '(not configured for this service)'
238
242
  end
239
243
  else
240
- @ui.step "(Caddy not running or TLS not configured)"
244
+ @ui.step '(Caddy not running or TLS not configured)'
241
245
  end
242
246
  end
243
247
  ensure
@@ -252,8 +256,8 @@ module Odysseus
252
256
  def containers(server, options = {})
253
257
  config_file = options[:config] || 'deploy.yml'
254
258
 
255
- @ui.header "Odysseus Containers"
256
- @ui.info "Server", server
259
+ @ui.header 'Odysseus Containers'
260
+ @ui.info 'Server', server
257
261
  @ui.blank
258
262
 
259
263
  config = load_config(config_file)
@@ -270,7 +274,7 @@ module Odysseus
270
274
  rows = containers.map do |c|
271
275
  [c['ID'][0..11], c['State'], c['Names'], c['Image'], c['Status']]
272
276
  end
273
- @ui.table(headers: ['ID', 'State', 'Name', 'Image', 'Status'], rows: rows)
277
+ @ui.table(headers: %w[ID State Name Image Status], rows: rows)
274
278
  end
275
279
  ensure
276
280
  ssh.close
@@ -287,107 +291,107 @@ module Odysseus
287
291
  @ui.header "Validating #{config_file}"
288
292
  config = load_config(config_file)
289
293
 
290
- @ui.success "Configuration is valid"
291
- @ui.info "Service", config[:service]
292
- @ui.info "Image", config[:image]
293
- @ui.info "Servers", config[:servers].keys.join(', ')
294
- @ui.info "Proxy", config[:proxy][:hosts]&.join(', ')
295
- @ui.info "Accessories", config[:accessories].keys.join(', ') if config[:accessories]&.any?
294
+ @ui.success 'Configuration is valid'
295
+ @ui.info 'Service', config[:service]
296
+ @ui.info 'Image', config[:image]
297
+ @ui.info 'Servers', config[:servers].keys.join(', ')
298
+ @ui.info 'Proxy', config[:proxy][:hosts]&.join(', ')
299
+ @ui.info 'Dependencies', config[:dependencies].keys.join(', ') if config[:dependencies]&.any?
296
300
  rescue Odysseus::Error => e
297
301
  @ui.error "Validation failed: #{e.message}"
298
302
  exit 1
299
303
  end
300
304
 
301
- # Accessory commands
302
- def accessory_boot(options = {})
305
+ # Dependency commands
306
+ def dependency_boot(options = {})
303
307
  config_file = options[:config] || 'deploy.yml'
304
308
  name = require_name!(options)
305
309
 
306
- @ui.header "Accessory Boot"
307
- @ui.info "Accessory", name
310
+ @ui.header 'Dependency Boot'
311
+ @ui.info 'Dependency', name
308
312
  @ui.blank
309
313
 
310
314
  executor = Odysseus::Deployer::Executor.new(config_file)
311
- @ui.spin_step("Booting #{name}...") { executor.deploy_accessory(name: name) }
312
- @ui.step_ok "Accessory #{name} deployed"
315
+ @ui.spin_step("Booting #{name}...") { executor.deploy_dependency(name: name) }
316
+ @ui.step_ok "Dependency #{name} deployed"
313
317
  rescue Odysseus::Error => e
314
318
  @ui.step_fail e.message
315
319
  exit 1
316
320
  end
317
321
 
318
- def accessory_boot_all(options = {})
322
+ def dependency_boot_all(options = {})
319
323
  config_file = options[:config] || 'deploy.yml'
320
324
 
321
- @ui.header "Accessory Boot All"
325
+ @ui.header 'Dependency Boot All'
322
326
  @ui.blank
323
327
 
324
328
  executor = Odysseus::Deployer::Executor.new(config_file)
325
- @ui.spin_step("Booting all accessories...") { executor.boot_accessories }
326
- @ui.step_ok "All accessories deployed"
329
+ @ui.spin_step('Booting all dependencies...') { executor.boot_dependencies }
330
+ @ui.step_ok 'All dependencies deployed'
327
331
  rescue Odysseus::Error => e
328
332
  @ui.step_fail e.message
329
333
  exit 1
330
334
  end
331
335
 
332
- def accessory_remove(options = {})
336
+ def dependency_remove(options = {})
333
337
  config_file = options[:config] || 'deploy.yml'
334
338
  name = require_name!(options)
335
339
 
336
- @ui.header "Accessory Remove"
337
- @ui.info "Accessory", name
340
+ @ui.header 'Dependency Remove'
341
+ @ui.info 'Dependency', name
338
342
  @ui.blank
339
343
 
340
344
  executor = Odysseus::Deployer::Executor.new(config_file)
341
- @ui.spin_step("Removing #{name}...") { executor.remove_accessory(name: name) }
342
- @ui.step_ok "Accessory #{name} removed"
345
+ @ui.spin_step("Removing #{name}...") { executor.remove_dependency(name: name) }
346
+ @ui.step_ok "Dependency #{name} removed"
343
347
  rescue Odysseus::Error => e
344
348
  @ui.step_fail e.message
345
349
  exit 1
346
350
  end
347
351
 
348
- def accessory_restart(options = {})
352
+ def dependency_restart(options = {})
349
353
  config_file = options[:config] || 'deploy.yml'
350
354
  name = require_name!(options)
351
355
 
352
- @ui.header "Accessory Restart"
353
- @ui.info "Accessory", name
356
+ @ui.header 'Dependency Restart'
357
+ @ui.info 'Dependency', name
354
358
  @ui.blank
355
359
 
356
360
  executor = Odysseus::Deployer::Executor.new(config_file)
357
- @ui.spin_step("Restarting #{name}...") { executor.restart_accessory(name: name) }
358
- @ui.step_ok "Accessory #{name} restarted"
361
+ @ui.spin_step("Restarting #{name}...") { executor.restart_dependency(name: name) }
362
+ @ui.step_ok "Dependency #{name} restarted"
359
363
  rescue Odysseus::Error => e
360
364
  @ui.step_fail e.message
361
365
  exit 1
362
366
  end
363
367
 
364
- def accessory_upgrade(options = {})
368
+ def dependency_upgrade(options = {})
365
369
  config_file = options[:config] || 'deploy.yml'
366
370
  name = require_name!(options)
367
371
 
368
- @ui.header "Accessory Upgrade"
369
- @ui.info "Accessory", name
372
+ @ui.header 'Dependency Upgrade'
373
+ @ui.info 'Dependency', name
370
374
  @ui.blank
371
375
 
372
376
  executor = Odysseus::Deployer::Executor.new(config_file)
373
- @ui.spin_step("Upgrading #{name}...") { executor.upgrade_accessory(name: name) }
374
- @ui.step_ok "Accessory #{name} upgraded"
377
+ @ui.spin_step("Upgrading #{name}...") { executor.upgrade_dependency(name: name) }
378
+ @ui.step_ok "Dependency #{name} upgraded"
375
379
  rescue Odysseus::Error => e
376
380
  @ui.step_fail e.message
377
381
  exit 1
378
382
  end
379
383
 
380
- def accessory_status(options = {})
384
+ def dependency_status(options = {})
381
385
  config_file = options[:config] || 'deploy.yml'
382
386
 
383
- @ui.header "Accessory Status"
387
+ @ui.header 'Dependency Status'
384
388
  @ui.blank
385
389
 
386
390
  executor = Odysseus::Deployer::Executor.new(config_file)
387
- statuses = executor.accessory_status
391
+ statuses = executor.dependency_status
388
392
 
389
393
  if statuses.empty?
390
- @ui.step "No accessories configured"
394
+ @ui.step 'No dependencies configured'
391
395
  else
392
396
  rows = statuses.map do |s|
393
397
  state = s[:running] ? 'running' : 'stopped'
@@ -395,7 +399,7 @@ module Odysseus
395
399
  proxy = s[:has_proxy] ? '✓' : ''
396
400
  [s[:name].to_s, s[:host], state, s[:image], container, proxy]
397
401
  end
398
- @ui.table(headers: ['Name', 'Host', 'State', 'Image', 'Container', 'Proxy'], rows: rows)
402
+ @ui.table(headers: %w[Name Host State Image Container Proxy], rows: rows)
399
403
  end
400
404
  rescue Odysseus::Error => e
401
405
  @ui.error e.message
@@ -411,27 +415,20 @@ module Odysseus
411
415
  since = options[:since]
412
416
 
413
417
  config = load_config(config_file)
414
- service_name = role == :web ? config[:service] : "#{config[:service]}-#{role}"
418
+ service_name = Odysseus::Docker::Labels.service_for(service: config[:service], role: role)
415
419
 
416
420
  @ui.header "Logs: #{service_name}"
417
- @ui.info "Server", server
421
+ @ui.info 'Server', server
418
422
  @ui.blank
419
423
 
420
424
  ssh = connect_to_server(server, config)
421
425
 
422
426
  begin
423
427
  docker = Odysseus::Docker::Client.new(ssh)
424
- containers = docker.list(service: service_name)
425
-
426
- if containers.empty?
427
- @ui.warn "No running containers found for #{service_name}"
428
- return
429
- end
430
-
431
- container_id = containers.first['ID']
428
+ container_id = log_container_id!(docker, service_name, server, config, role: role)
432
429
 
433
430
  if follow
434
- @ui.step "Following logs (Ctrl+C to stop)..."
431
+ @ui.step 'Following logs (Ctrl+C to stop)...'
435
432
  @ui.blank
436
433
  docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
437
434
  else
@@ -445,8 +442,8 @@ module Odysseus
445
442
  exit 1
446
443
  end
447
444
 
448
- # Accessory logs
449
- def accessory_logs(server, options = {})
445
+ # Dependency logs
446
+ def dependency_logs(server, options = {})
450
447
  config_file = options[:config] || 'deploy.yml'
451
448
  name = require_name!(options)
452
449
  follow = options[:follow] || false
@@ -456,25 +453,18 @@ module Odysseus
456
453
  config = load_config(config_file)
457
454
  service_name = "#{config[:service]}-#{name}"
458
455
 
459
- @ui.header "Accessory Logs: #{service_name}"
460
- @ui.info "Server", server
456
+ @ui.header "Dependency Logs: #{service_name}"
457
+ @ui.info 'Server', server
461
458
  @ui.blank
462
459
 
463
460
  ssh = connect_to_server(server, config)
464
461
 
465
462
  begin
466
463
  docker = Odysseus::Docker::Client.new(ssh)
467
- containers = docker.list(service: service_name)
468
-
469
- if containers.empty?
470
- @ui.warn "No running containers found for #{service_name}"
471
- return
472
- end
473
-
474
- container_id = containers.first['ID']
464
+ container_id = log_container_id!(docker, service_name, server, config)
475
465
 
476
466
  if follow
477
- @ui.step "Following logs (Ctrl+C to stop)..."
467
+ @ui.step 'Following logs (Ctrl+C to stop)...'
478
468
  @ui.blank
479
469
  docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
480
470
  else
@@ -491,27 +481,28 @@ module Odysseus
491
481
  # App exec
492
482
  def app_exec(server, options = {})
493
483
  config_file = options[:config] || 'deploy.yml'
484
+ role = (options[:role] || 'web').to_sym
494
485
  command = options[:command]
495
486
 
496
487
  unless command
497
- @ui.error "Command required (--command)"
488
+ @ui.error 'Command required (--command)'
498
489
  exit 1
499
490
  end
500
491
 
501
492
  config = load_config(config_file)
502
- image = "#{config[:image]}:latest"
493
+ image = running_image(server, config, role)
503
494
 
504
- @ui.header "App Exec"
505
- @ui.info "Server", server
506
- @ui.info "Command", command
495
+ @ui.header 'App Exec'
496
+ @ui.info 'Server', server
497
+ @ui.info 'Role', role
498
+ @ui.info 'Command', command
507
499
  @ui.blank
508
500
 
509
501
  ssh = connect_to_server(server, config)
510
502
 
511
503
  begin
512
504
  docker = Odysseus::Docker::Client.new(ssh)
513
- env = {}
514
- config[:env][:clear]&.each { |k, v| env[k.to_s] = v.to_s }
505
+ env = build_environment(config, config_file, ssh)
515
506
 
516
507
  puts docker.run_once(image: image, command: command, options: { env: env, network: 'odysseus' })
517
508
  ensure
@@ -522,84 +513,23 @@ module Odysseus
522
513
  exit 1
523
514
  end
524
515
 
525
- # App shell
526
- def app_shell(server, options = {})
527
- config_file = options[:config] || 'deploy.yml'
528
- config = load_config(config_file)
529
- image = "#{config[:image]}:latest"
530
-
531
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
532
- env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
533
-
534
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} /bin/sh'")
535
- rescue Odysseus::Error => e
536
- @ui.error e.message
537
- exit 1
538
- end
539
-
540
- # App console
541
- def app_console(server, options = {})
542
- config_file = options[:config] || 'deploy.yml'
543
- console_cmd = options[:cmd] || '/bin/sh'
544
- config = load_config(config_file)
545
- image = "#{config[:image]}:latest"
546
-
547
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
548
- env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
549
-
550
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} #{console_cmd}'")
551
- rescue Odysseus::Error => e
552
- @ui.error e.message
553
- exit 1
554
- end
555
-
556
- # Accessory shell
557
- def accessory_shell(server, options = {})
558
- config_file = options[:config] || 'deploy.yml'
559
- name = require_name!(options)
560
-
561
- config = load_config(config_file)
562
- service_name = "#{config[:service]}-#{name}"
563
-
564
- ssh = connect_to_server(server, config)
565
- begin
566
- docker = Odysseus::Docker::Client.new(ssh)
567
- containers = docker.list(service: service_name)
568
-
569
- if containers.empty?
570
- @ui.error "No running containers found for #{service_name}"
571
- exit 1
572
- end
573
-
574
- container_id = containers.first['ID']
575
- ensure
576
- ssh.close
577
- end
578
-
579
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
580
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker exec -it #{container_id} /bin/sh'")
581
- rescue Odysseus::Error => e
582
- @ui.error e.message
583
- exit 1
584
- end
585
-
586
- # Accessory exec
587
- def accessory_exec(server, options = {})
516
+ # Dependency exec
517
+ def dependency_exec(server, options = {})
588
518
  config_file = options[:config] || 'deploy.yml'
589
519
  name = require_name!(options)
590
520
  command = options[:command]
591
521
 
592
522
  unless command
593
- @ui.error "Command required (--command)"
523
+ @ui.error 'Command required (--command)'
594
524
  exit 1
595
525
  end
596
526
 
597
527
  config = load_config(config_file)
598
528
  service_name = "#{config[:service]}-#{name}"
599
529
 
600
- @ui.header "Accessory Exec"
601
- @ui.info "Accessory", name
602
- @ui.info "Command", command
530
+ @ui.header 'Dependency Exec'
531
+ @ui.info 'Dependency', name
532
+ @ui.info 'Command', command
603
533
  @ui.blank
604
534
 
605
535
  ssh = connect_to_server(server, config)
@@ -630,9 +560,9 @@ module Odysseus
630
560
  config = load_config(config_file)
631
561
  service_name = config[:service]
632
562
 
633
- @ui.header "Odysseus Cleanup"
634
- @ui.info "Service", service_name
635
- @ui.info "Server", server
563
+ @ui.header 'Odysseus Cleanup'
564
+ @ui.info 'Service', service_name
565
+ @ui.info 'Server', server
636
566
  @ui.blank
637
567
 
638
568
  ssh = connect_to_server(server, config)
@@ -642,14 +572,14 @@ module Odysseus
642
572
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
643
573
 
644
574
  if @ui.debug?
645
- @ui.section "Disk usage (before)"
575
+ @ui.section 'Disk usage (before)'
646
576
  puts docker.disk_usage
647
577
  @ui.blank
648
578
  end
649
579
 
650
580
  service_names = [service_name]
651
581
  config[:servers].keys.reject { |r| r == :web }.each { |role| service_names << "#{service_name}-#{role}" }
652
- config[:accessories]&.each_key { |name| service_names << "#{service_name}-#{name}" }
582
+ config[:dependencies]&.each_key { |name| service_names << "#{service_name}-#{name}" }
653
583
 
654
584
  total_removed = 0
655
585
  service_names.each do |svc|
@@ -665,30 +595,46 @@ module Odysseus
665
595
 
666
596
  # Clean Caddy routes
667
597
  if caddy.running?
668
- caddy.remove_upstream(service: service_name, upstream: nil) rescue nil
669
- config[:accessories]&.each do |name, acc_config|
670
- if acc_config[:proxy]
671
- caddy.remove_upstream(service: "#{service_name}-#{name}", upstream: nil) rescue nil
598
+ begin
599
+ caddy.remove_upstream(service: service_name, upstream: nil)
600
+ rescue StandardError
601
+ nil
602
+ end
603
+ config[:dependencies]&.each do |name, acc_config|
604
+ next unless acc_config[:proxy]
605
+
606
+ begin
607
+ caddy.remove_upstream(service: "#{service_name}-#{name}", upstream: nil)
608
+ rescue StandardError
609
+ nil
672
610
  end
673
611
  end
674
612
 
675
613
  remaining = caddy.list_services.reject { |s| s[:service] == 'unknown' || s[:service].empty? }
676
614
  if remaining.empty?
677
- docker.stop('odysseus-caddy', timeout: 10) rescue nil
678
- docker.remove('odysseus-caddy', force: true) rescue nil
679
- @ui.step "Caddy removed (no other services)"
615
+ begin
616
+ docker.stop('odysseus-caddy', timeout: 10)
617
+ rescue StandardError
618
+ nil
619
+ end
620
+ begin
621
+ docker.remove('odysseus-caddy', force: true)
622
+ rescue StandardError
623
+ nil
624
+ end
625
+ @ui.step 'Caddy removed (no other services)'
680
626
  else
681
627
  @ui.step "Caddy kept (#{remaining.size} other service(s))"
682
628
  end
683
629
  end
684
630
 
685
631
  if prune_images
686
- @ui.step "Pruning dangling images..."
632
+ @ui.step 'Pruning dangling images...'
687
633
  docker.prune(containers: false, images: true, volumes: false, networks: false)
688
634
  end
689
635
 
690
636
  @ui.blank
691
- @ui.success "Cleanup complete"
637
+ @ui.success 'Cleanup complete'
692
638
  ensure
693
639
  ssh.close
694
640
  end
@@ -699,16 +645,16 @@ module Odysseus
699
645
 
700
646
  # Secrets commands
701
647
  def secrets_generate_key(_options = {})
702
- @ui.header "Secrets: Generate Key"
648
+ @ui.header 'Secrets: Generate Key'
703
649
  @ui.blank
704
650
 
705
651
  key = Odysseus::Secrets::EncryptedFile.generate_key
706
- @ui.success "Generated master key:"
652
+ @ui.success 'Generated master key:'
707
653
  @ui.blank
708
654
  puts " #{key}"
709
655
  @ui.blank
710
- @ui.warn "Save this key securely!"
711
- @ui.step "Set as ODYSSEUS_MASTER_KEY environment variable"
656
+ @ui.warn 'Save this key securely!'
657
+ @ui.step 'Set as ODYSSEUS_MASTER_KEY environment variable'
712
658
  end
713
659
 
714
660
  def secrets_encrypt(options = {})
@@ -716,7 +662,7 @@ module Odysseus
716
662
  output_file = options[:file] || 'secrets.yml.enc'
717
663
 
718
664
  unless input_file
719
- @ui.error "Input file required (--input)"
665
+ @ui.error 'Input file required (--input)'
720
666
  exit 1
721
667
  end
722
668
 
@@ -725,9 +671,9 @@ module Odysseus
725
671
  exit 1
726
672
  end
727
673
 
728
- @ui.header "Secrets: Encrypt"
729
- @ui.info "Input", input_file
730
- @ui.info "Output", output_file
674
+ @ui.header 'Secrets: Encrypt'
675
+ @ui.info 'Input', input_file
676
+ @ui.info 'Output', output_file
731
677
  @ui.blank
732
678
 
733
679
  secrets = YAML.load_file(input_file)
@@ -737,7 +683,7 @@ module Odysseus
737
683
  @ui.success "Secrets encrypted to #{output_file}"
738
684
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
739
685
  @ui.error e.message
740
- @ui.step "Generate a key with: odysseus secrets generate-key"
686
+ @ui.step 'Generate a key with: odysseus secrets generate-key'
741
687
  exit 1
742
688
  rescue Odysseus::Error => e
743
689
  @ui.error e.message
@@ -752,8 +698,8 @@ module Odysseus
752
698
  exit 1
753
699
  end
754
700
 
755
- @ui.header "Secrets: Decrypt"
756
- @ui.info "File", secrets_file
701
+ @ui.header 'Secrets: Decrypt'
702
+ @ui.info 'File', secrets_file
757
703
  @ui.blank
758
704
 
759
705
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
@@ -764,7 +710,7 @@ module Odysseus
764
710
  @ui.info key, masked
765
711
  end
766
712
  @ui.blank
767
- @ui.step "(values masked)"
713
+ @ui.step '(values masked)'
768
714
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError, Odysseus::Secrets::EncryptedFile::DecryptionError => e
769
715
  @ui.error e.message
770
716
  exit 1
@@ -774,9 +720,9 @@ module Odysseus
774
720
  secrets_file = options[:file] || 'secrets.yml.enc'
775
721
  editor = ENV['EDITOR'] || 'vi'
776
722
 
777
- @ui.header "Secrets: Edit"
778
- @ui.info "File", secrets_file
779
- @ui.info "Editor", editor
723
+ @ui.header 'Secrets: Edit'
724
+ @ui.info 'File', secrets_file
725
+ @ui.info 'Editor', editor
780
726
  @ui.blank
781
727
 
782
728
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
@@ -792,13 +738,13 @@ module Odysseus
792
738
  edited_secrets = YAML.load_file(temp_file.path)
793
739
  encrypted_file.write(edited_secrets)
794
740
 
795
- @ui.success "Secrets updated and encrypted"
741
+ @ui.success 'Secrets updated and encrypted'
796
742
  ensure
797
743
  temp_file.unlink
798
744
  end
799
745
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
800
746
  @ui.error e.message
801
- @ui.step "Generate a key with: odysseus secrets generate-key"
747
+ @ui.step 'Generate a key with: odysseus secrets generate-key'
802
748
  exit 1
803
749
  rescue Odysseus::Error => e
804
750
  @ui.error e.message
@@ -812,19 +758,137 @@ module Odysseus
812
758
  parser.parse
813
759
  end
814
760
 
815
- def connect_to_server(server, config)
761
+ def web_container_row(container)
762
+ labels = Odysseus::Docker::Labels.parse(container['Labels'])
763
+ version = labels['odysseus.version'] || '(unlabelled)'
764
+ ref = labels['odysseus.git_ref'] || '-'
765
+ deployed_at = labels['odysseus.deployed_at'] || '-'
766
+ health = container['Status'].include?('(healthy)') ? '✓' : ''
767
+ [version, ref, deployed_at, container['Image'], container['State'], health]
768
+ end
769
+
770
+ # The image reference that is actually serving, so a one-off container runs
771
+ # the same code as the deployed one. Prefers the container's own Image
772
+ # field, which docker ps reports directly, over reconstructing a tag from
773
+ # the odysseus.version label: a container deployed before this branch
774
+ # carries a deploy timestamp in that label and was built from an image
775
+ # tagged `latest`, so reconstruction would name a tag that was never
776
+ # pushed. Falling back to reconstruction only covers the case where
777
+ # Image is somehow absent from docker's own output.
778
+ #
779
+ # The lookup goes through Labels.service_for because only the web role is
780
+ # labelled with the bare service name. Asking for that name on a jobs host
781
+ # matches nothing, and on a service with no web role it matches nothing
782
+ # anywhere — which is what these commands did before they took a role.
783
+ def running_image(server, config, role)
784
+ service_name = Odysseus::Docker::Labels.service_for(service: config[:service], role: role)
785
+ ssh = connect_to_server(server, config)
786
+
787
+ begin
788
+ docker = Odysseus::Docker::Client.new(ssh)
789
+ container = docker.list(service: service_name).first
790
+
791
+ # No search of other roles: running a command against a role other
792
+ # than the one asked for is worse than being told what to type.
793
+ no_container!(server, config, service_name, role: role, state: 'running') unless container
794
+
795
+ container['Image'] || "#{config[:image]}:#{Odysseus::Docker::Labels.version_of(container)}"
796
+ ensure
797
+ ssh.close
798
+ end
799
+ end
800
+
801
+ # The container to read logs from, chosen from everything carrying the
802
+ # service label — stopped containers included. `docker ps` without -a
803
+ # hides the container that has just exited, which is precisely the one
804
+ # whose logs you came for.
805
+ #
806
+ # Note what that container is NOT: a previous version. A deploy stops AND
807
+ # removes the container it replaces (web_deploy.rb:252-253), and the
808
+ # keep: 2 sweep selects only containers already in state `exited`
809
+ # (docker/client.rb:299-301) — so what it retains is crashes, not
810
+ # history. A stopped container here means something exited on its own,
811
+ # which is exactly the case worth reading logs for. `status` and
812
+ # `cleanup` already read with all: true.
813
+ #
814
+ # Finding nothing is a failed request for logs, not a success, so it
815
+ # exits non-zero. And when the only match is stopped, say so: otherwise
816
+ # the log just ends and the reader has no way to know why.
817
+ #
818
+ # That notice goes to stderr. This command's stdout is a log stream —
819
+ # `odysseus logs web1 > app.log`, or a pipe into something that parses it
820
+ # — so a line about the logs must not arrive inside them, while still
821
+ # reaching the terminal of whoever ran the command.
822
+ #
823
+ # @param role [Symbol, nil] the role the label came from, or nil for a
824
+ # dependency, which is named with --name rather than --role
825
+ def log_container_id!(docker, service_name, server, config, role: nil)
826
+ containers = docker.list(service: service_name, all: true)
827
+ no_container!(server, config, service_name, role: role, state: 'running or stopped') if containers.empty?
828
+
829
+ container = containers.find { |c| c['State'] == 'running' } || containers.first
830
+ unless container['State'] == 'running'
831
+ @ui.warn "No running container for #{service_name}: showing logs from " \
832
+ "#{container['State']} container #{container['ID'][0..11]}", io: $stderr
833
+ end
834
+
835
+ container['ID']
836
+ end
837
+
838
+ # What a container lookup that found nothing says, for every command that
839
+ # finds one by its odysseus.service label. A mistyped --role is the
840
+ # ordinary reason nothing matches, so the message names the role, the
841
+ # exact label searched, the option that changes it and the roles this
842
+ # config declares. `logs` used to say only `No containers found for
843
+ # myapp-jbos on w1 (stopped ones included)`, naming a label the reader
844
+ # never typed: one job, two messages, and only one of them any use.
845
+ #
846
+ # A dependency passes no role. It is chosen with --name, and advising
847
+ # --role would send the reader to an option that command does not have.
848
+ def no_container!(server, config, service_name, role: nil, state: 'running')
849
+ subject = role ? "role '#{role}'" : service_name
850
+ @ui.error "No #{state} container for #{subject} on #{server} " \
851
+ "(nothing labelled odysseus.service=#{service_name})", io: $stderr
852
+ if role
853
+ @ui.step "Name the role with --role. Roles in this config: #{config[:servers].keys.join(', ')}",
854
+ io: $stderr
855
+ end
856
+ exit 1
857
+ end
858
+
859
+ # The environment a one-off container starts with, built by the same class
860
+ # the deploy paths use. These commands used to inject env.clear alone, so
861
+ # `app exec … --command "rails db:migrate"` ran against a container with
862
+ # no DATABASE_URL while the container deployed seconds earlier had one.
863
+ #
864
+ # The secrets loader resolves a relative secrets_file against the
865
+ # directory holding deploy.yml rather than the working directory, which is
866
+ # what Executor does and what `--config ../other/deploy.yml` needs.
867
+ def build_environment(config, config_file, ssh)
868
+ loader = Odysseus::Secrets::Loader.new(config, config_dir: File.dirname(config_file))
869
+ Odysseus::Core::Environment.new(config: config, secrets_loader: loader, ssh: ssh).build
870
+ end
871
+
872
+ # --debug (or ODYSSEUS_DEBUG=1) reaches the SSH layer here rather than
873
+ # only the UI, so every command built on this connection can show the
874
+ # commands it actually sent. Deploy, build and pussh already did this
875
+ # via `options[:verbose] || @ui.debug?`; the connection builder itself
876
+ # did not, which left doctor, status, containers and logs unable to
877
+ # show anything under --debug however loudly it was asked.
878
+ def connect_to_server(server, config, verbose: @ui.debug?)
816
879
  Odysseus::Deployer::SSH.new(
817
880
  host: server,
818
881
  user: config[:ssh][:user],
819
882
  keys: config[:ssh][:keys],
820
- use_tailscale: true
883
+ use_tailscale: true,
884
+ verbose: verbose
821
885
  )
822
886
  end
823
887
 
824
888
  def require_name!(options)
825
889
  name = options[:name]
826
890
  unless name
827
- @ui.error "Accessory name required (--name)"
891
+ @ui.error 'Dependency name required (--name)'
828
892
  exit 1
829
893
  end
830
894
  name