odysseus-cli 0.2.0 → 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.
@@ -1,267 +1,165 @@
1
1
  # odysseus-cli/lib/odysseus/cli/cli.rb
2
2
 
3
3
  require 'odysseus'
4
- require 'pastel'
5
4
  require 'yaml'
6
5
  require 'tempfile'
7
- require_relative 'gum'
6
+ require_relative 'ui'
7
+ require_relative 'rollback_commands'
8
+ require_relative 'interactive_commands'
9
+ require_relative 'doctor_commands'
10
+ require_relative 'setup_commands'
8
11
 
9
12
  module Odysseus
10
13
  module CLI
11
14
  class CLI
12
- def initialize(charm_mode: false)
13
- @pastel = Pastel.new
14
- @charm = charm_mode && Gum.available?
15
- if charm_mode && !Gum.available?
16
- warn @pastel.yellow("Warning: --charm mode requested but 'gum' is not installed")
17
- warn @pastel.dim("Install gum: https://github.com/charmbracelet/gum")
18
- end
19
- end
15
+ include RollbackCommands
16
+ include InteractiveCommands
17
+ include DoctorCommands
18
+ include SetupCommands
20
19
 
21
- def charm?
22
- @charm
20
+ def initialize(debug: false)
21
+ @ui = UI.new(debug: debug)
23
22
  end
24
23
 
25
- # Deploy command - deploys all roles to their configured hosts
26
- # Usage: odysseus deploy [--config FILE] [--image TAG] [--build] [--dry-run] [--verbose]
24
+ # Deploy command
27
25
  def deploy(options = {})
28
26
  config_file = options[:config] || 'deploy.yml'
29
- image_tag = options[:image] || 'latest'
27
+ image_tag = options[:image]
30
28
  should_build = options[:build] || false
31
29
  dry_run = options[:'dry-run'] || false
32
- verbose = options[:verbose] || false
30
+ verbose = options[:verbose] || @ui.debug?
33
31
 
34
32
  config = load_config(config_file)
35
33
  uses_registry = config[:registry] && config[:registry][:server]
36
34
 
37
- # Header
38
- if charm?
39
- puts Gum.style("⛵ Odysseus Deploy", border: 'rounded', foreground: '212', padding: '0 1')
40
- else
41
- puts @pastel.cyan("Odysseus Deploy")
42
- end
43
- puts @pastel.blue("Service: #{config[:service]}")
44
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
45
- if should_build
46
- distribution = uses_registry ? "registry (#{config[:registry][:server]})" : "pussh (SSH)"
47
- puts @pastel.blue("Build & distribute via: #{distribution}")
48
- end
49
- puts ""
50
-
51
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)'
39
+ @ui.deploy_header(
40
+ service: config[:service],
41
+ image: config[:image],
42
+ image_tag: resolved.version,
43
+ build: should_build,
44
+ distribution: distribution
45
+ )
46
+
47
+ start_time = Time.now
52
48
 
53
- # Build and distribute image if requested
54
49
  if should_build
55
- if charm?
56
- result = Gum.spin(title: 'Building and distributing image...') do
57
- executor.build_and_distribute(image_tag: image_tag)
58
- end
59
- else
60
- puts @pastel.cyan("=== Building and distributing image ===")
61
- result = executor.build_and_distribute(image_tag: image_tag)
62
- end
50
+ # Build phase — captured as streaming steps
51
+ @ui.stream_steps(title: 'Building and distributing') do
52
+ build_result = executor.build_and_distribute(image_tag: image_tag)
63
53
 
64
- if result[:build][:success]
65
- puts @pastel.green("✓ Build complete!")
66
- else
67
- puts @pastel.red("✗ Build failed: #{result[:build][:error]}")
68
- exit 1
69
- end
54
+ raise Odysseus::BuildError, "Build failed: #{build_result[:build][:error]}" unless build_result[:build][:success]
70
55
 
71
- # Handle distribution result (either pussh or registry push)
72
- if uses_registry
73
- if result[:push][:success]
74
- puts @pastel.green("✓ Pushed to registry!")
75
- else
76
- puts @pastel.red("✗ Push to registry failed!")
77
- exit 1
78
- end
79
- else
80
- if result[:pussh][:success]
81
- puts @pastel.green("✓ Pussh complete!")
82
- result[:pussh][:results]&.each do |host, host_result|
83
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
84
- puts " #{status} #{host}"
85
- end
56
+ if uses_registry
57
+ raise Odysseus::BuildError, 'Push to registry failed' unless build_result[:push][:success]
86
58
  else
87
- puts @pastel.red("✗ Pussh failed!")
88
- result[:pussh][:results]&.each do |host, host_result|
89
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
90
- puts " #{status} #{host}"
91
- puts " #{host_result[:error]}" unless host_result[:success]
92
- end
93
- exit 1
59
+ raise Odysseus::BuildError, 'Image distribution failed' unless build_result[:pussh][:success]
94
60
  end
95
61
  end
96
- puts ""
97
62
  end
98
63
 
99
- if charm?
100
- Gum.spin(title: 'Deploying to servers...') do
101
- executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
102
- end
103
- else
64
+ # Deploy phase — each orchestrator log line becomes a sub-step
65
+ @ui.stream_steps(title: 'Deploying service') do
104
66
  executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
105
67
  end
106
68
 
107
- puts ""
108
- if charm?
109
- puts Gum.style("✓ Deploy complete!", foreground: '82', bold: true)
110
- else
111
- puts @pastel.green("Deploy complete!")
112
- end
69
+ duration = (Time.now - start_time).round(1)
70
+ @ui.deploy_complete(duration: duration)
113
71
  rescue Odysseus::Error => e
114
- puts @pastel.red("Error: #{e.message}")
72
+ @ui.step_fail e.message
115
73
  exit 1
116
74
  end
117
75
 
118
- # Build command - builds Docker image locally or on a build host
119
- # Usage: odysseus build [--config FILE] [--image TAG] [--push] [--context PATH] [--verbose]
76
+ # Build command
120
77
  def build(options = {})
121
78
  config_file = options[:config] || 'deploy.yml'
122
- image_tag = options[:image] || 'latest'
79
+ image_tag = options[:image]
123
80
  push = options[:push] || false
124
81
  context_path = options[:context]
125
- verbose = options[:verbose] || false
82
+ verbose = options[:verbose] || @ui.debug?
126
83
 
127
84
  config = load_config(config_file)
128
- builder_config = config[:builder] || {}
129
- strategy = builder_config[:strategy] || :local
130
-
131
- if charm?
132
- puts Gum.style("🔨 Odysseus Build", border: 'rounded', foreground: '212', padding: '0 1')
133
- else
134
- puts @pastel.cyan("Odysseus Build")
135
- end
136
- puts @pastel.blue("Service: #{config[:service]}")
137
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
138
- puts @pastel.blue("Strategy: #{strategy}")
139
- puts @pastel.blue("Build host: #{builder_config[:host]}") if builder_config[:host]
140
- puts @pastel.blue("Push: #{push ? 'yes' : 'no'}")
141
- puts ""
142
85
 
143
86
  executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
87
+ resolved = executor.deploy_version(image_tag)
144
88
 
145
- if charm?
146
- result = Gum.spin(title: 'Building image...') do
147
- executor.build(image_tag: image_tag, push: push, context_path: context_path)
148
- end
149
- else
150
- result = executor.build(image_tag: image_tag, push: push, context_path: context_path)
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
93
+
94
+ result = @ui.spin_step("Building image #{config[:image]}:#{resolved.version}") do
95
+ executor.build(image_tag: image_tag, push: push, context_path: context_path)
151
96
  end
152
97
 
153
98
  if result[:success]
154
- puts ""
155
- if charm?
156
- puts Gum.style("✓ Build complete!", foreground: '82', bold: true)
157
- else
158
- puts @pastel.green("Build complete!")
159
- end
160
- puts @pastel.blue("Image: #{result[:image]}")
161
- if result[:pushed]
162
- puts @pastel.blue("Pushed to registry: yes")
163
- end
99
+ @ui.step_ok 'Pushed to registry' if result[:pushed]
100
+ @ui.step_ok 'Build complete'
164
101
  else
165
- puts ""
166
- puts @pastel.red("✗ Build failed: #{result[:error]}")
102
+ @ui.step_fail "Build failed: #{result[:error]}"
167
103
  exit 1
168
104
  end
169
105
  rescue Odysseus::Error => e
170
- puts @pastel.red("Error: #{e.message}")
106
+ @ui.step_fail e.message
171
107
  exit 1
172
108
  end
173
109
 
174
- # Pussh command - push image to hosts via SSH (no registry needed)
175
- # Usage: odysseus pussh [--config FILE] [--image TAG] [--build] [--verbose]
110
+ # Pussh command
176
111
  def pussh(options = {})
177
112
  config_file = options[:config] || 'deploy.yml'
178
- image_tag = options[:image] || 'latest'
113
+ image_tag = options[:image]
179
114
  should_build = options[:build] || false
180
- verbose = options[:verbose] || false
115
+ verbose = options[:verbose] || @ui.debug?
181
116
 
182
117
  config = load_config(config_file)
183
118
 
184
- if charm?
185
- puts Gum.style("📦 Odysseus Pussh", border: 'rounded', foreground: '212', padding: '0 1')
186
- else
187
- puts @pastel.cyan("Odysseus Pussh")
188
- end
189
- puts @pastel.blue("Service: #{config[:service]}")
190
- puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
191
- puts @pastel.blue("Build first: #{should_build ? 'yes' : 'no'}")
192
- puts ""
193
-
194
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
195
125
 
196
126
  if should_build
197
- if charm?
198
- result = Gum.spin(title: 'Building and pushing image via SSH...') do
199
- executor.build_and_pussh(image_tag: image_tag)
200
- end
201
- else
202
- result = executor.build_and_pussh(image_tag: image_tag)
127
+ result = @ui.spin_step("Building image #{config[:image]}:#{resolved.version}") do
128
+ executor.build_and_pussh(image_tag: image_tag)
203
129
  end
204
130
 
205
- if result[:build][:success]
206
- puts @pastel.green("Build complete!")
207
- else
208
- puts @pastel.red("✗ Build failed: #{result[:build][:error]}")
131
+ unless result[:build][:success]
132
+ @ui.step_fail "Build failed: #{result[:build][:error]}"
209
133
  exit 1
210
134
  end
135
+ pussh_result = result[:pussh]
211
136
  else
212
- if charm?
213
- result = Gum.spin(title: 'Pushing image via SSH...') do
214
- executor.pussh(image_tag: image_tag)
215
- end
216
- else
217
- result = executor.pussh(image_tag: image_tag)
137
+ pussh_result = @ui.spin_step('Pushing image via SSH...') do
138
+ executor.pussh(image_tag: image_tag)
218
139
  end
219
140
  end
220
141
 
221
- pussh_result = should_build ? result[:pussh] : result
222
-
223
142
  if pussh_result[:success]
224
- puts ""
225
- if charm?
226
- puts Gum.style("✓ Pussh complete!", foreground: '82', bold: true)
227
- else
228
- puts @pastel.green("Pussh complete!")
229
- end
230
- pussh_result[:results]&.each do |host, host_result|
231
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
232
- puts " #{status} #{host}"
233
- end
143
+ @ui.step_ok "Pussh complete (#{pussh_result[:results]&.size || 0} host(s))"
234
144
  else
235
- puts ""
236
- puts @pastel.red("✗ Pussh failed!")
237
- pussh_result[:results]&.each do |host, host_result|
238
- status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
239
- puts " #{status} #{host}"
240
- puts " #{host_result[:error]}" unless host_result[:success]
241
- end
145
+ @ui.step_fail 'Pussh failed'
242
146
  exit 1
243
147
  end
244
148
  rescue Odysseus::Error => e
245
- puts @pastel.red("Error: #{e.message}")
149
+ @ui.step_fail e.message
246
150
  exit 1
247
151
  end
248
152
 
249
- # Status command - show full service status on a server
250
- # Usage: odysseus status <server> [--config FILE]
153
+ # Status command
251
154
  def status(server, options = {})
252
155
  config_file = options[:config] || 'deploy.yml'
253
-
254
156
  config = load_config(config_file)
255
157
  service_name = config[:service]
256
158
 
257
- if charm?
258
- puts Gum.style("⛵ Odysseus Status", border: 'rounded', foreground: '212', padding: '0 1')
259
- else
260
- puts @pastel.cyan("Odysseus Status: #{service_name}")
261
- end
262
- puts @pastel.blue("Service: #{service_name}")
263
- puts @pastel.blue("Server: #{server}")
264
- puts ""
159
+ @ui.header 'Odysseus Status'
160
+ @ui.info 'Service', service_name
161
+ @ui.info 'Server', server
162
+ @ui.blank
265
163
 
266
164
  ssh = connect_to_server(server, config)
267
165
 
@@ -270,162 +168,97 @@ module Odysseus
270
168
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
271
169
 
272
170
  # Web containers
273
- if charm?
274
- puts Gum.style(" Web ", foreground: '212', bold: true)
275
- else
276
- puts @pastel.cyan("Web:")
277
- end
171
+ @ui.section 'Web'
278
172
  web_containers = docker.list(service: service_name)
279
173
  if web_containers.empty?
280
- puts " (no containers running)"
281
- elsif charm?
282
- rows = web_containers.map do |c|
283
- health = c['Status'].include?('healthy') ? '✓' : ''
284
- [c['Names'], c['State'], c['Image'], health]
285
- end
286
- puts Gum.table(headers: ['Name', 'State', 'Image', 'Health'], rows: rows)
174
+ @ui.step '(no containers running)'
287
175
  else
288
- web_containers.each do |c|
289
- status_color = c['State'] == 'running' ? :green : :red
290
- health = c['Status'].include?('healthy') ? ' (healthy)' : ''
291
- puts " #{@pastel.send(status_color, c['State'])} #{c['Names']}#{health}"
292
- puts " Image: #{c['Image']}"
293
- end
176
+ rows = web_containers.map { |c| web_container_row(c) }
177
+ @ui.table(headers: %w[Version Ref Deployed Image State Health], rows: rows)
294
178
  end
295
179
 
296
- # Show Caddy route for this service
297
180
  caddy_status = caddy.status
298
181
  if caddy_status[:running]
299
182
  svc_route = caddy_status[:services].find { |s| s[:service] == service_name }
300
183
  if svc_route
301
- puts " Proxy: #{svc_route[:hosts].join(', ')}"
302
- puts " Upstreams: #{svc_route[:upstreams].join(', ')}"
184
+ @ui.info 'Proxy', svc_route[:hosts].join(', ')
185
+ @ui.info 'Upstreams', svc_route[:upstreams].join(', ')
303
186
  end
304
187
  end
188
+ @ui.blank
305
189
 
306
- puts ""
307
-
308
- # Job/worker containers (non-web roles)
190
+ # Workers
309
191
  non_web_roles = config[:servers].keys.reject { |r| r == :web }
310
192
  if non_web_roles.any?
311
- if charm?
312
- puts Gum.style(" Jobs/Workers ", foreground: '212', bold: true)
313
- else
314
- puts @pastel.cyan("Jobs/Workers:")
315
- end
316
-
317
- if charm?
318
- rows = []
319
- non_web_roles.each do |role|
320
- role_service = "#{service_name}-#{role}"
321
- containers = docker.list(service: role_service)
322
- if containers.empty?
323
- rows << [role.to_s, 'stopped', '-', '-']
324
- else
325
- containers.each do |c|
326
- rows << [role.to_s, c['State'], c['Names'], c['Image']]
327
- end
328
- end
329
- end
330
- puts Gum.table(headers: ['Role', 'State', 'Name', 'Image'], rows: rows)
331
- else
332
- non_web_roles.each do |role|
333
- role_service = "#{service_name}-#{role}"
334
- containers = docker.list(service: role_service)
335
- if containers.empty?
336
- puts " #{@pastel.yellow(role.to_s)}: #{@pastel.red('not running')}"
337
- else
338
- containers.each do |c|
339
- status_color = c['State'] == 'running' ? :green : :red
340
- puts " #{@pastel.yellow(role.to_s)}: #{@pastel.send(status_color, c['State'])} #{c['Names']}"
341
- puts " Image: #{c['Image']}"
342
- end
193
+ @ui.section 'Workers'
194
+ rows = []
195
+ non_web_roles.each do |role|
196
+ role_service = "#{service_name}-#{role}"
197
+ containers = docker.list(service: role_service)
198
+ if containers.empty?
199
+ rows << [role.to_s, 'stopped', '-', '-']
200
+ else
201
+ containers.each do |c|
202
+ rows << [role.to_s, c['State'], c['Names'], c['Image']]
343
203
  end
344
204
  end
345
205
  end
346
- puts ""
347
- end
348
-
349
- # Accessories
350
- if config[:accessories]&.any?
351
- if charm?
352
- puts Gum.style(" Accessories ", foreground: '212', bold: true)
353
- rows = config[:accessories].map do |name, acc_config|
354
- acc_service = "#{service_name}-#{name}"
355
- containers = docker.list(service: acc_service, all: true)
356
- running = containers.find { |c| c['State'] == 'running' }
357
- if running
358
- health = running['Status'].include?('healthy') ? '✓' : ''
359
- [name.to_s, 'running', acc_config[:image], running['ID'][0..11], health]
360
- else
361
- [name.to_s, 'stopped', acc_config[:image], '-', '']
362
- end
363
- end
364
- puts Gum.table(headers: ['Name', 'State', 'Image', 'Container', 'Health'], rows: rows)
365
- else
366
- puts @pastel.cyan("Accessories:")
367
- config[:accessories].each do |name, acc_config|
368
- acc_service = "#{service_name}-#{name}"
369
- containers = docker.list(service: acc_service, all: true)
370
- running = containers.find { |c| c['State'] == 'running' }
371
-
372
- if running
373
- health = running['Status'].include?('healthy') ? ' (healthy)' : ''
374
- puts " #{@pastel.yellow(name.to_s)}: #{@pastel.green('running')}#{health}"
375
- puts " Image: #{acc_config[:image]}"
376
- puts " Container: #{running['ID'][0..11]}"
377
- else
378
- puts " #{@pastel.yellow(name.to_s)}: #{@pastel.red('stopped')}"
379
- puts " Image: #{acc_config[:image]}"
380
- end
206
+ @ui.table(headers: %w[Role State Name Image], rows: rows)
207
+ @ui.blank
208
+ end
209
+
210
+ # Dependencies
211
+ if config[:dependencies]&.any?
212
+ @ui.section 'Dependencies'
213
+ rows = config[:dependencies].map do |name, acc_config|
214
+ acc_service = "#{service_name}-#{name}"
215
+ containers = docker.list(service: acc_service, all: true)
216
+ running = containers.find { |c| c['State'] == 'running' }
217
+ if running
218
+ health = running['Status'].include?('healthy') ? '✓' : ''
219
+ [name.to_s, 'running', acc_config[:image], running['ID'][0..11], health]
220
+ else
221
+ [name.to_s, 'stopped', acc_config[:image], '-', '']
381
222
  end
382
223
  end
383
- puts ""
224
+ @ui.table(headers: %w[Name State Image Container Health], rows: rows)
225
+ @ui.blank
384
226
  end
385
227
 
386
- # TLS status for this service's domains
228
+ # TLS
387
229
  if config[:proxy][:hosts]&.any?
388
- if charm?
389
- puts Gum.style(" TLS ", foreground: '212', bold: true)
390
- else
391
- puts @pastel.cyan("TLS:")
392
- end
230
+ @ui.section 'TLS'
393
231
  if caddy_status[:running] && caddy_status[:tls][:enabled]
394
232
  service_hosts = config[:proxy][:hosts]
395
233
  relevant_policy = caddy_status[:tls][:policies].find do |p|
396
- (p[:subjects] & service_hosts).any?
234
+ p[:subjects].intersect?(service_hosts)
397
235
  end
398
236
  if relevant_policy
399
- puts " Domains: #{(relevant_policy[:subjects] & service_hosts).join(', ')}"
400
- puts " Issuer: #{relevant_policy[:issuer]}"
401
- puts " 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)'
402
240
  else
403
- puts " (not configured for this service)"
241
+ @ui.step '(not configured for this service)'
404
242
  end
405
243
  else
406
- puts " (Caddy not running or TLS not configured)"
244
+ @ui.step '(Caddy not running or TLS not configured)'
407
245
  end
408
246
  end
409
247
  ensure
410
248
  ssh.close
411
249
  end
412
250
  rescue Odysseus::Error => e
413
- puts @pastel.red("Error: #{e.message}")
251
+ @ui.error e.message
414
252
  exit 1
415
253
  end
416
254
 
417
- # Containers command - list running containers for a service
418
- # Usage: odysseus containers <server> [--config FILE] [--service NAME]
255
+ # Containers command
419
256
  def containers(server, options = {})
420
257
  config_file = options[:config] || 'deploy.yml'
421
258
 
422
- if charm?
423
- puts Gum.style("📦 Odysseus Containers", border: 'rounded', foreground: '212', padding: '0 1')
424
- else
425
- puts @pastel.cyan("Odysseus Containers")
426
- end
427
- puts @pastel.blue("Server: #{server}")
428
- puts ""
259
+ @ui.header 'Odysseus Containers'
260
+ @ui.info 'Server', server
261
+ @ui.blank
429
262
 
430
263
  config = load_config(config_file)
431
264
  service_name = options[:service] || config[:service]
@@ -436,270 +269,144 @@ module Odysseus
436
269
  containers = docker.list(service: service_name)
437
270
 
438
271
  if containers.empty?
439
- puts "No containers found for service: #{service_name}"
440
- elsif charm?
441
- puts Gum.style(" #{service_name} ", foreground: '212', bold: true)
272
+ @ui.step "No containers found for #{service_name}"
273
+ else
442
274
  rows = containers.map do |c|
443
275
  [c['ID'][0..11], c['State'], c['Names'], c['Image'], c['Status']]
444
276
  end
445
- puts Gum.table(headers: ['ID', 'State', 'Name', 'Image', 'Status'], rows: rows)
446
- else
447
- puts @pastel.cyan("Containers for #{service_name}:")
448
- containers.each do |c|
449
- status_color = c['State'] == 'running' ? :green : :red
450
- puts " #{c['ID'][0..11]} #{@pastel.send(status_color, c['State'])} #{c['Names']} (#{c['Image']})"
451
- puts " Created: #{c['CreatedAt']}"
452
- puts " Status: #{c['Status']}"
453
- end
277
+ @ui.table(headers: %w[ID State Name Image Status], rows: rows)
454
278
  end
455
279
  ensure
456
280
  ssh.close
457
281
  end
458
282
  rescue Odysseus::Error => e
459
- puts @pastel.red("Error: #{e.message}")
283
+ @ui.error e.message
460
284
  exit 1
461
285
  end
462
286
 
463
287
  # Validate command
464
- # Usage: odysseus validate [--config FILE]
465
288
  def validate(options = {})
466
289
  config_file = options[:config] || 'deploy.yml'
467
290
 
468
- puts @pastel.cyan("Validating #{config_file}...")
469
-
291
+ @ui.header "Validating #{config_file}"
470
292
  config = load_config(config_file)
471
293
 
472
- puts @pastel.green("Configuration is valid!")
473
- puts ""
474
- puts "Service: #{config[:service]}"
475
- puts "Image: #{config[:image]}"
476
- puts "Servers: #{config[:servers].keys.join(', ')}"
477
- puts "Proxy hosts: #{config[:proxy][:hosts]&.join(', ')}"
478
- if config[:accessories]&.any?
479
- puts "Accessories: #{config[:accessories].keys.join(', ')}"
480
- end
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?
481
300
  rescue Odysseus::Error => e
482
- puts @pastel.red("Validation failed: #{e.message}")
301
+ @ui.error "Validation failed: #{e.message}"
483
302
  exit 1
484
303
  end
485
304
 
486
- # Accessory boot command
487
- # Usage: odysseus accessory boot --name NAME [--config FILE]
488
- def accessory_boot(options = {})
305
+ # Dependency commands
306
+ def dependency_boot(options = {})
489
307
  config_file = options[:config] || 'deploy.yml'
490
- name = options[:name]
491
-
492
- unless name
493
- puts @pastel.red("Error: accessory name required (--name)")
494
- exit 1
495
- end
308
+ name = require_name!(options)
496
309
 
497
- if charm?
498
- puts Gum.style("🔌 Accessory Boot", border: 'rounded', foreground: '212', padding: '0 1')
499
- else
500
- puts @pastel.cyan("Odysseus Accessory Boot")
501
- end
502
- puts @pastel.blue("Accessory: #{name}")
503
- puts ""
310
+ @ui.header 'Dependency Boot'
311
+ @ui.info 'Dependency', name
312
+ @ui.blank
504
313
 
505
314
  executor = Odysseus::Deployer::Executor.new(config_file)
506
-
507
- if charm?
508
- Gum.spin(title: "Booting #{name}...") do
509
- executor.deploy_accessory(name: name)
510
- end
511
- puts Gum.style("✓ Accessory #{name} deployed!", foreground: '82', bold: true)
512
- else
513
- executor.deploy_accessory(name: name)
514
- puts @pastel.green("Accessory #{name} deployed!")
515
- end
315
+ @ui.spin_step("Booting #{name}...") { executor.deploy_dependency(name: name) }
316
+ @ui.step_ok "Dependency #{name} deployed"
516
317
  rescue Odysseus::Error => e
517
- puts @pastel.red("Error: #{e.message}")
318
+ @ui.step_fail e.message
518
319
  exit 1
519
320
  end
520
321
 
521
- # Boot all accessories
522
- # Usage: odysseus accessory boot-all [--config FILE]
523
- def accessory_boot_all(options = {})
322
+ def dependency_boot_all(options = {})
524
323
  config_file = options[:config] || 'deploy.yml'
525
324
 
526
- if charm?
527
- puts Gum.style("🔌 Accessory Boot All", border: 'rounded', foreground: '212', padding: '0 1')
528
- else
529
- puts @pastel.cyan("Odysseus Accessory Boot All")
530
- end
531
- puts ""
325
+ @ui.header 'Dependency Boot All'
326
+ @ui.blank
532
327
 
533
328
  executor = Odysseus::Deployer::Executor.new(config_file)
534
-
535
- if charm?
536
- Gum.spin(title: 'Booting all accessories...') do
537
- executor.boot_accessories
538
- end
539
- puts Gum.style("✓ All accessories deployed!", foreground: '82', bold: true)
540
- else
541
- executor.boot_accessories
542
- puts @pastel.green("All accessories deployed!")
543
- end
329
+ @ui.spin_step('Booting all dependencies...') { executor.boot_dependencies }
330
+ @ui.step_ok 'All dependencies deployed'
544
331
  rescue Odysseus::Error => e
545
- puts @pastel.red("Error: #{e.message}")
332
+ @ui.step_fail e.message
546
333
  exit 1
547
334
  end
548
335
 
549
- # Accessory remove command
550
- # Usage: odysseus accessory remove --name NAME [--config FILE]
551
- def accessory_remove(options = {})
336
+ def dependency_remove(options = {})
552
337
  config_file = options[:config] || 'deploy.yml'
553
- name = options[:name]
338
+ name = require_name!(options)
554
339
 
555
- unless name
556
- puts @pastel.red("Error: accessory name required (--name)")
557
- exit 1
558
- end
559
-
560
- if charm?
561
- puts Gum.style("🗑️ Accessory Remove", border: 'rounded', foreground: '212', padding: '0 1')
562
- else
563
- puts @pastel.cyan("Odysseus Accessory Remove")
564
- end
565
- puts @pastel.blue("Accessory: #{name}")
566
- puts ""
567
-
568
- # Charm mode: ask for confirmation
569
- if charm?
570
- unless Gum.confirm("Remove accessory #{name}?")
571
- puts @pastel.yellow("Cancelled.")
572
- return
573
- end
574
- end
340
+ @ui.header 'Dependency Remove'
341
+ @ui.info 'Dependency', name
342
+ @ui.blank
575
343
 
576
344
  executor = Odysseus::Deployer::Executor.new(config_file)
577
-
578
- if charm?
579
- Gum.spin(title: "Removing #{name}...") do
580
- executor.remove_accessory(name: name)
581
- end
582
- puts Gum.style("✓ Accessory #{name} removed!", foreground: '82', bold: true)
583
- else
584
- executor.remove_accessory(name: name)
585
- puts @pastel.green("Accessory #{name} removed!")
586
- end
345
+ @ui.spin_step("Removing #{name}...") { executor.remove_dependency(name: name) }
346
+ @ui.step_ok "Dependency #{name} removed"
587
347
  rescue Odysseus::Error => e
588
- puts @pastel.red("Error: #{e.message}")
348
+ @ui.step_fail e.message
589
349
  exit 1
590
350
  end
591
351
 
592
- # Accessory restart command
593
- # Usage: odysseus accessory restart --name NAME [--config FILE]
594
- def accessory_restart(options = {})
352
+ def dependency_restart(options = {})
595
353
  config_file = options[:config] || 'deploy.yml'
596
- name = options[:name]
354
+ name = require_name!(options)
597
355
 
598
- unless name
599
- puts @pastel.red("Error: accessory name required (--name)")
600
- exit 1
601
- end
602
-
603
- if charm?
604
- puts Gum.style("🔄 Accessory Restart", border: 'rounded', foreground: '212', padding: '0 1')
605
- else
606
- puts @pastel.cyan("Odysseus Accessory Restart")
607
- end
608
- puts @pastel.blue("Accessory: #{name}")
609
- puts ""
356
+ @ui.header 'Dependency Restart'
357
+ @ui.info 'Dependency', name
358
+ @ui.blank
610
359
 
611
360
  executor = Odysseus::Deployer::Executor.new(config_file)
612
-
613
- if charm?
614
- Gum.spin(title: "Restarting #{name}...") do
615
- executor.restart_accessory(name: name)
616
- end
617
- puts Gum.style("✓ Accessory #{name} restarted!", foreground: '82', bold: true)
618
- else
619
- executor.restart_accessory(name: name)
620
- puts @pastel.green("Accessory #{name} restarted!")
621
- end
361
+ @ui.spin_step("Restarting #{name}...") { executor.restart_dependency(name: name) }
362
+ @ui.step_ok "Dependency #{name} restarted"
622
363
  rescue Odysseus::Error => e
623
- puts @pastel.red("Error: #{e.message}")
364
+ @ui.step_fail e.message
624
365
  exit 1
625
366
  end
626
367
 
627
- # Accessory upgrade command - upgrade to new image version (preserves volumes)
628
- # Usage: odysseus accessory upgrade --name NAME [--config FILE]
629
- def accessory_upgrade(options = {})
368
+ def dependency_upgrade(options = {})
630
369
  config_file = options[:config] || 'deploy.yml'
631
- name = options[:name]
632
-
633
- unless name
634
- puts @pastel.red("Error: accessory name required (--name)")
635
- exit 1
636
- end
370
+ name = require_name!(options)
637
371
 
638
- if charm?
639
- puts Gum.style("⬆️ Accessory Upgrade", border: 'rounded', foreground: '212', padding: '0 1')
640
- else
641
- puts @pastel.cyan("Odysseus Accessory Upgrade")
642
- end
643
- puts @pastel.blue("Accessory: #{name}")
644
- puts ""
372
+ @ui.header 'Dependency Upgrade'
373
+ @ui.info 'Dependency', name
374
+ @ui.blank
645
375
 
646
376
  executor = Odysseus::Deployer::Executor.new(config_file)
647
-
648
- if charm?
649
- Gum.spin(title: "Upgrading #{name}...") do
650
- executor.upgrade_accessory(name: name)
651
- end
652
- puts Gum.style("✓ Accessory #{name} upgraded!", foreground: '82', bold: true)
653
- else
654
- executor.upgrade_accessory(name: name)
655
- puts @pastel.green("Accessory #{name} upgraded!")
656
- end
377
+ @ui.spin_step("Upgrading #{name}...") { executor.upgrade_dependency(name: name) }
378
+ @ui.step_ok "Dependency #{name} upgraded"
657
379
  rescue Odysseus::Error => e
658
- puts @pastel.red("Error: #{e.message}")
380
+ @ui.step_fail e.message
659
381
  exit 1
660
382
  end
661
383
 
662
- # Accessory status command
663
- # Usage: odysseus accessory status [--config FILE]
664
- def accessory_status(options = {})
384
+ def dependency_status(options = {})
665
385
  config_file = options[:config] || 'deploy.yml'
666
386
 
667
- if charm?
668
- puts Gum.style("🔌 Accessory Status", border: 'rounded', foreground: '212', padding: '0 1')
669
- else
670
- puts @pastel.cyan("Odysseus Accessory Status")
671
- end
672
- puts ""
387
+ @ui.header 'Dependency Status'
388
+ @ui.blank
673
389
 
674
390
  executor = Odysseus::Deployer::Executor.new(config_file)
675
- statuses = executor.accessory_status
391
+ statuses = executor.dependency_status
676
392
 
677
393
  if statuses.empty?
678
- puts "No accessories configured"
679
- elsif charm?
680
- rows = statuses.map do |status|
681
- state = status[:running] ? 'running' : 'stopped'
682
- container = status[:container_id] ? status[:container_id][0..11] : '-'
683
- proxy = status[:has_proxy] ? '✓' : ''
684
- [status[:name].to_s, status[:host], state, status[:image], container, proxy]
685
- end
686
- puts Gum.table(headers: ['Name', 'Host', 'State', 'Image', 'Container', 'Proxy'], rows: rows)
394
+ @ui.step 'No dependencies configured'
687
395
  else
688
- statuses.each do |status|
689
- status_text = status[:running] ? @pastel.green('running') : @pastel.red('stopped')
690
- puts " #{@pastel.yellow(status[:name].to_s)} @ #{status[:host]}: #{status_text}"
691
- puts " Image: #{status[:image]}"
692
- puts " Container: #{status[:container_id] ? status[:container_id][0..11] : '(none)'}"
693
- puts " Has proxy: #{status[:has_proxy] ? 'yes' : 'no'}"
396
+ rows = statuses.map do |s|
397
+ state = s[:running] ? 'running' : 'stopped'
398
+ container = s[:container_id] ? s[:container_id][0..11] : '-'
399
+ proxy = s[:has_proxy] ? '✓' : ''
400
+ [s[:name].to_s, s[:host], state, s[:image], container, proxy]
694
401
  end
402
+ @ui.table(headers: %w[Name Host State Image Container Proxy], rows: rows)
695
403
  end
696
404
  rescue Odysseus::Error => e
697
- puts @pastel.red("Error: #{e.message}")
405
+ @ui.error e.message
698
406
  exit 1
699
407
  end
700
408
 
701
- # Logs command - tail logs for a service
702
- # Usage: odysseus logs <server> [--config FILE] [--role ROLE] [--follow] [--lines N] [--since TIME]
409
+ # Logs command
703
410
  def logs(server, options = {})
704
411
  config_file = options[:config] || 'deploy.yml'
705
412
  role = (options[:role] || 'web').to_sym
@@ -708,240 +415,144 @@ module Odysseus
708
415
  since = options[:since]
709
416
 
710
417
  config = load_config(config_file)
711
- service_name = role == :web ? config[:service] : "#{config[:service]}-#{role}"
418
+ service_name = Odysseus::Docker::Labels.service_for(service: config[:service], role: role)
712
419
 
713
- puts @pastel.cyan("Odysseus Logs: #{service_name}")
714
- puts @pastel.blue("Server: #{server}")
715
- puts ""
420
+ @ui.header "Logs: #{service_name}"
421
+ @ui.info 'Server', server
422
+ @ui.blank
716
423
 
717
424
  ssh = connect_to_server(server, config)
718
425
 
719
426
  begin
720
427
  docker = Odysseus::Docker::Client.new(ssh)
721
- containers = docker.list(service: service_name)
722
-
723
- if containers.empty?
724
- puts @pastel.yellow("No running containers found for #{service_name}")
725
- return
726
- end
727
-
728
- container = containers.first
729
- container_id = container['ID']
428
+ container_id = log_container_id!(docker, service_name, server, config, role: role)
730
429
 
731
430
  if follow
732
- puts @pastel.dim("Following logs (Ctrl+C to stop)...")
733
- puts ""
734
- docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
735
- print line
736
- end
431
+ @ui.step 'Following logs (Ctrl+C to stop)...'
432
+ @ui.blank
433
+ docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
737
434
  else
738
- output = docker.logs(container_id, tail: lines, since: since)
739
- puts output
435
+ puts docker.logs(container_id, tail: lines, since: since)
740
436
  end
741
437
  ensure
742
438
  ssh.close
743
439
  end
744
440
  rescue Odysseus::Error => e
745
- puts @pastel.red("Error: #{e.message}")
441
+ @ui.error e.message
746
442
  exit 1
747
443
  end
748
444
 
749
- # Accessory logs command
750
- # Usage: odysseus accessory logs <server> --name NAME [--config FILE] [--follow] [--lines N] [--since TIME]
751
- def accessory_logs(server, options = {})
445
+ # Dependency logs
446
+ def dependency_logs(server, options = {})
752
447
  config_file = options[:config] || 'deploy.yml'
753
- name = options[:name]
448
+ name = require_name!(options)
754
449
  follow = options[:follow] || false
755
450
  lines = options[:lines] || 100
756
451
  since = options[:since]
757
452
 
758
- unless name
759
- puts @pastel.red("Error: accessory name required (--name)")
760
- exit 1
761
- end
762
-
763
453
  config = load_config(config_file)
764
454
  service_name = "#{config[:service]}-#{name}"
765
455
 
766
- puts @pastel.cyan("Odysseus Accessory Logs: #{service_name}")
767
- puts @pastel.blue("Server: #{server}")
768
- puts ""
456
+ @ui.header "Dependency Logs: #{service_name}"
457
+ @ui.info 'Server', server
458
+ @ui.blank
769
459
 
770
460
  ssh = connect_to_server(server, config)
771
461
 
772
462
  begin
773
463
  docker = Odysseus::Docker::Client.new(ssh)
774
- containers = docker.list(service: service_name)
775
-
776
- if containers.empty?
777
- puts @pastel.yellow("No running containers found for #{service_name}")
778
- return
779
- end
780
-
781
- container = containers.first
782
- container_id = container['ID']
464
+ container_id = log_container_id!(docker, service_name, server, config)
783
465
 
784
466
  if follow
785
- puts @pastel.dim("Following logs (Ctrl+C to stop)...")
786
- puts ""
787
- docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
788
- print line
789
- end
467
+ @ui.step 'Following logs (Ctrl+C to stop)...'
468
+ @ui.blank
469
+ docker.logs(container_id, follow: true, tail: lines, since: since) { |line| print line }
790
470
  else
791
- output = docker.logs(container_id, tail: lines, since: since)
792
- puts output
471
+ puts docker.logs(container_id, tail: lines, since: since)
793
472
  end
794
473
  ensure
795
474
  ssh.close
796
475
  end
797
476
  rescue Odysseus::Error => e
798
- puts @pastel.red("Error: #{e.message}")
477
+ @ui.error e.message
799
478
  exit 1
800
479
  end
801
480
 
802
- # App exec command - run a command in a new container using the app image
803
- # Usage: odysseus app exec <server> <command> [--config FILE]
481
+ # App exec
804
482
  def app_exec(server, options = {})
805
483
  config_file = options[:config] || 'deploy.yml'
484
+ role = (options[:role] || 'web').to_sym
806
485
  command = options[:command]
807
486
 
808
487
  unless command
809
- puts @pastel.red("Error: command required")
488
+ @ui.error 'Command required (--command)'
810
489
  exit 1
811
490
  end
812
491
 
813
492
  config = load_config(config_file)
814
- image = "#{config[:image]}:latest"
493
+ image = running_image(server, config, role)
815
494
 
816
- puts @pastel.cyan("Odysseus App Exec")
817
- puts @pastel.blue("Server: #{server}")
818
- puts @pastel.blue("Image: #{image}")
819
- puts @pastel.blue("Command: #{command}")
820
- puts ""
495
+ @ui.header 'App Exec'
496
+ @ui.info 'Server', server
497
+ @ui.info 'Role', role
498
+ @ui.info 'Command', command
499
+ @ui.blank
821
500
 
822
501
  ssh = connect_to_server(server, config)
823
502
 
824
503
  begin
825
504
  docker = Odysseus::Docker::Client.new(ssh)
505
+ env = build_environment(config, config_file, ssh)
826
506
 
827
- # Build environment from config
828
- env = {}
829
- config[:env][:clear]&.each { |k, v| env[k.to_s] = v.to_s }
830
-
831
- output = docker.run_once(
832
- image: image,
833
- command: command,
834
- options: {
835
- env: env,
836
- network: 'odysseus'
837
- }
838
- )
839
-
840
- puts output
507
+ puts docker.run_once(image: image, command: command, options: { env: env, network: 'odysseus' })
841
508
  ensure
842
509
  ssh.close
843
510
  end
844
511
  rescue Odysseus::Error => e
845
- puts @pastel.red("Error: #{e.message}")
512
+ @ui.error e.message
846
513
  exit 1
847
514
  end
848
515
 
849
- # App shell command - open an interactive shell in a temporary container
850
- # Usage: odysseus app shell <server> [--config FILE]
851
- def app_shell(server, options = {})
516
+ # Dependency exec
517
+ def dependency_exec(server, options = {})
852
518
  config_file = options[:config] || 'deploy.yml'
519
+ name = require_name!(options)
520
+ command = options[:command]
853
521
 
854
- config = load_config(config_file)
855
- image = "#{config[:image]}:latest"
856
-
857
- puts @pastel.cyan("Odysseus App Shell")
858
- puts @pastel.blue("Server: #{server}")
859
- puts @pastel.blue("Image: #{image}")
860
- puts ""
861
-
862
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
863
- env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
864
-
865
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} /bin/sh'")
866
- rescue Odysseus::Error => e
867
- puts @pastel.red("Error: #{e.message}")
868
- exit 1
869
- end
870
-
871
- # App console command - run an interactive console in a new container
872
- # Usage: odysseus app console <server> [--config FILE] [--cmd COMMAND]
873
- def app_console(server, options = {})
874
- config_file = options[:config] || 'deploy.yml'
875
- console_cmd = options[:cmd] || '/bin/sh'
876
-
877
- config = load_config(config_file)
878
- image = "#{config[:image]}:latest"
879
-
880
- puts @pastel.cyan("Odysseus App Console")
881
- puts @pastel.blue("Server: #{server}")
882
- puts @pastel.blue("Image: #{image}")
883
- puts @pastel.blue("Console: #{console_cmd}")
884
- puts ""
885
- puts @pastel.dim("Note: This runs 'docker run -it' via SSH. For full interactivity, use:")
886
- puts @pastel.dim(" ssh #{config[:ssh][:user]}@#{server} -t 'docker run -it --rm --network odysseus #{image} #{console_cmd}'")
887
- puts ""
888
-
889
- # For truly interactive sessions, we need to exec through SSH directly
890
- # The CLI can't easily support full TTY passthrough
891
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
892
- env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
893
-
894
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} #{console_cmd}'")
895
- rescue Odysseus::Error => e
896
- puts @pastel.red("Error: #{e.message}")
897
- exit 1
898
- end
899
-
900
- # Accessory shell command - open an interactive shell in a running accessory container
901
- # Usage: odysseus accessory shell <server> --name NAME [--config FILE]
902
- def accessory_shell(server, options = {})
903
- config_file = options[:config] || 'deploy.yml'
904
- name = options[:name]
905
-
906
- unless name
907
- puts @pastel.red("Error: accessory name required (--name)")
522
+ unless command
523
+ @ui.error 'Command required (--command)'
908
524
  exit 1
909
525
  end
910
526
 
911
527
  config = load_config(config_file)
912
528
  service_name = "#{config[:service]}-#{name}"
913
529
 
914
- puts @pastel.cyan("Odysseus Accessory Shell")
915
- puts @pastel.blue("Server: #{server}")
916
- puts @pastel.blue("Accessory: #{name}")
917
- puts ""
530
+ @ui.header 'Dependency Exec'
531
+ @ui.info 'Dependency', name
532
+ @ui.info 'Command', command
533
+ @ui.blank
918
534
 
919
- # Get container ID first
920
535
  ssh = connect_to_server(server, config)
536
+
921
537
  begin
922
538
  docker = Odysseus::Docker::Client.new(ssh)
923
539
  containers = docker.list(service: service_name)
924
540
 
925
541
  if containers.empty?
926
- puts @pastel.red("No running containers found for #{service_name}")
542
+ @ui.error "No running containers found for #{service_name}"
927
543
  exit 1
928
544
  end
929
545
 
930
- container_id = containers.first['ID']
546
+ puts docker.exec(containers.first['ID'], command)
931
547
  ensure
932
548
  ssh.close
933
549
  end
934
-
935
- # Now exec with SSH passthrough for TTY
936
- ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
937
- system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker exec -it #{container_id} /bin/sh'")
938
550
  rescue Odysseus::Error => e
939
- puts @pastel.red("Error: #{e.message}")
551
+ @ui.error e.message
940
552
  exit 1
941
553
  end
942
554
 
943
- # Cleanup command - remove containers for this service
944
- # Usage: odysseus cleanup <server> [--config FILE] [--prune-images]
555
+ # Cleanup command
945
556
  def cleanup(server, options = {})
946
557
  config_file = options[:config] || 'deploy.yml'
947
558
  prune_images = options[:all] || false
@@ -949,14 +560,10 @@ module Odysseus
949
560
  config = load_config(config_file)
950
561
  service_name = config[:service]
951
562
 
952
- if charm?
953
- puts Gum.style("🧹 Odysseus Cleanup", border: 'rounded', foreground: '212', padding: '0 1')
954
- else
955
- puts @pastel.cyan("Odysseus Cleanup: #{service_name}")
956
- end
957
- puts @pastel.blue("Service: #{service_name}")
958
- puts @pastel.blue("Server: #{server}")
959
- puts ""
563
+ @ui.header 'Odysseus Cleanup'
564
+ @ui.info 'Service', service_name
565
+ @ui.info 'Server', server
566
+ @ui.blank
960
567
 
961
568
  ssh = connect_to_server(server, config)
962
569
 
@@ -964,38 +571,17 @@ module Odysseus
964
571
  docker = Odysseus::Docker::Client.new(ssh)
965
572
  caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
966
573
 
967
- # Show current disk usage
968
- puts @pastel.cyan("Current disk usage:")
969
- puts docker.disk_usage
970
- puts ""
971
-
972
- # Collect all service names for this deploy.yml
973
- service_names = [service_name]
974
- config[:servers].keys.reject { |r| r == :web }.each do |role|
975
- service_names << "#{service_name}-#{role}"
976
- end
977
- config[:accessories]&.each_key do |name|
978
- service_names << "#{service_name}-#{name}"
979
- end
980
-
981
- # Count containers to be removed
982
- total_to_remove = service_names.sum do |svc|
983
- docker.list(service: svc, all: true).size
574
+ if @ui.debug?
575
+ @ui.section 'Disk usage (before)'
576
+ puts docker.disk_usage
577
+ @ui.blank
984
578
  end
985
579
 
986
- # Charm mode: ask for confirmation
987
- if charm? && total_to_remove > 0
988
- unless Gum.confirm("Remove #{total_to_remove} container(s) for #{service_name}?")
989
- puts @pastel.yellow("Cleanup cancelled.")
990
- return
991
- end
992
- end
993
-
994
- puts @pastel.yellow("Removing containers for #{service_name}...")
580
+ service_names = [service_name]
581
+ config[:servers].keys.reject { |r| r == :web }.each { |role| service_names << "#{service_name}-#{role}" }
582
+ config[:dependencies]&.each_key { |name| service_names << "#{service_name}-#{name}" }
995
583
 
996
584
  total_removed = 0
997
-
998
- # Remove all containers for each service (not just old ones)
999
585
  service_names.each do |svc|
1000
586
  containers = docker.list(service: svc, all: true)
1001
587
  containers.each do |c|
@@ -1005,249 +591,163 @@ module Odysseus
1005
591
  end
1006
592
  end
1007
593
 
1008
- puts " Removed #{total_removed} container(s)"
1009
-
1010
- # Remove routes from Caddy for this service
1011
- puts ""
1012
- puts @pastel.yellow("Removing Caddy routes for #{service_name}...")
594
+ @ui.step "Removed #{total_removed} container(s)"
1013
595
 
596
+ # Clean Caddy routes
1014
597
  if caddy.running?
1015
- # Remove web service route
1016
- caddy.remove_upstream(service: service_name, upstream: nil) rescue nil
1017
-
1018
- # Remove accessory routes
1019
- config[:accessories]&.each do |name, acc_config|
1020
- if acc_config[:proxy]
1021
- acc_service = "#{service_name}-#{name}"
1022
- caddy.remove_upstream(service: acc_service, upstream: nil) rescue nil
1023
- end
598
+ begin
599
+ caddy.remove_upstream(service: service_name, upstream: nil)
600
+ rescue StandardError
601
+ nil
1024
602
  end
603
+ config[:dependencies]&.each do |name, acc_config|
604
+ next unless acc_config[:proxy]
1025
605
 
1026
- # Check if Caddy still has other services
1027
- remaining_services = caddy.list_services
1028
- remaining_services.reject! { |s| s[:service] == 'unknown' || s[:service].empty? }
606
+ begin
607
+ caddy.remove_upstream(service: "#{service_name}-#{name}", upstream: nil)
608
+ rescue StandardError
609
+ nil
610
+ end
611
+ end
1029
612
 
1030
- if remaining_services.empty?
1031
- puts @pastel.yellow("No other services using Caddy, stopping Caddy...")
1032
- docker.stop('odysseus-caddy', timeout: 10) rescue nil
1033
- docker.remove('odysseus-caddy', force: true) rescue nil
1034
- puts " Caddy removed"
613
+ remaining = caddy.list_services.reject { |s| s[:service] == 'unknown' || s[:service].empty? }
614
+ if remaining.empty?
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)'
1035
626
  else
1036
- puts @pastel.dim(" Keeping Caddy (#{remaining_services.size} other service(s) configured)")
627
+ @ui.step "Caddy kept (#{remaining.size} other service(s))"
1037
628
  end
1038
629
  end
1039
630
 
1040
- # Optionally prune dangling images
1041
631
  if prune_images
1042
- puts ""
1043
- puts @pastel.yellow("Pruning dangling images...")
1044
- results = docker.prune(containers: false, images: true, volumes: false, networks: false)
1045
- puts results[:images]
632
+ @ui.step 'Pruning dangling images...'
633
+ docker.prune(containers: false, images: true, volumes: false, networks: false)
1046
634
  end
1047
635
 
1048
- puts ""
1049
- puts @pastel.cyan("Disk usage after cleanup:")
1050
- puts docker.disk_usage
636
+ @ui.blank
637
+ @ui.success 'Cleanup complete'
1051
638
  ensure
1052
639
  ssh.close
1053
640
  end
1054
-
1055
- puts ""
1056
- if charm?
1057
- puts Gum.style("✓ Cleanup complete!", foreground: '82', bold: true)
1058
- else
1059
- puts @pastel.green("Cleanup complete!")
1060
- end
1061
641
  rescue Odysseus::Error => e
1062
- puts @pastel.red("Error: #{e.message}")
642
+ @ui.error e.message
1063
643
  exit 1
1064
644
  end
1065
645
 
1066
- # Accessory exec command - run a command in a running accessory container
1067
- # Usage: odysseus accessory exec <server> --name NAME <command> [--config FILE]
1068
- def accessory_exec(server, options = {})
1069
- config_file = options[:config] || 'deploy.yml'
1070
- name = options[:name]
1071
- command = options[:command]
1072
-
1073
- unless name
1074
- puts @pastel.red("Error: accessory name required (--name)")
1075
- exit 1
1076
- end
1077
-
1078
- unless command
1079
- puts @pastel.red("Error: command required")
1080
- exit 1
1081
- end
1082
-
1083
- config = load_config(config_file)
1084
- service_name = "#{config[:service]}-#{name}"
1085
-
1086
- puts @pastel.cyan("Odysseus Accessory Exec")
1087
- puts @pastel.blue("Server: #{server}")
1088
- puts @pastel.blue("Accessory: #{name}")
1089
- puts @pastel.blue("Command: #{command}")
1090
- puts ""
1091
-
1092
- ssh = connect_to_server(server, config)
1093
-
1094
- begin
1095
- docker = Odysseus::Docker::Client.new(ssh)
1096
- containers = docker.list(service: service_name)
1097
-
1098
- if containers.empty?
1099
- puts @pastel.red("No running containers found for #{service_name}")
1100
- exit 1
1101
- end
1102
-
1103
- container_id = containers.first['ID']
1104
- output = docker.exec(container_id, command)
1105
- puts output
1106
- ensure
1107
- ssh.close
1108
- end
1109
- rescue Odysseus::Error => e
1110
- puts @pastel.red("Error: #{e.message}")
1111
- exit 1
1112
- end
1113
-
1114
- # Generate a new master key for encrypting secrets
1115
- # Usage: odysseus secrets generate-key
646
+ # Secrets commands
1116
647
  def secrets_generate_key(_options = {})
1117
- puts @pastel.cyan("Odysseus Secrets: Generate Key")
1118
- puts ""
648
+ @ui.header 'Secrets: Generate Key'
649
+ @ui.blank
1119
650
 
1120
651
  key = Odysseus::Secrets::EncryptedFile.generate_key
1121
-
1122
- puts @pastel.green("Generated master key:")
1123
- puts ""
652
+ @ui.success 'Generated master key:'
653
+ @ui.blank
1124
654
  puts " #{key}"
1125
- puts ""
1126
- puts @pastel.yellow("Save this key securely!")
1127
- puts @pastel.dim("Set it as ODYSSEUS_MASTER_KEY environment variable for encrypt/decrypt operations.")
655
+ @ui.blank
656
+ @ui.warn 'Save this key securely!'
657
+ @ui.step 'Set as ODYSSEUS_MASTER_KEY environment variable'
1128
658
  end
1129
659
 
1130
- # Encrypt a secrets file
1131
- # Usage: odysseus secrets encrypt --input secrets.yml --file secrets.yml.enc
1132
660
  def secrets_encrypt(options = {})
1133
661
  input_file = options[:input]
1134
662
  output_file = options[:file] || 'secrets.yml.enc'
1135
663
 
1136
664
  unless input_file
1137
- puts @pastel.red("Error: input file required (--input)")
665
+ @ui.error 'Input file required (--input)'
1138
666
  exit 1
1139
667
  end
1140
668
 
1141
669
  unless File.exist?(input_file)
1142
- puts @pastel.red("Error: input file not found: #{input_file}")
670
+ @ui.error "Input file not found: #{input_file}"
1143
671
  exit 1
1144
672
  end
1145
673
 
1146
- puts @pastel.cyan("Odysseus Secrets: Encrypt")
1147
- puts @pastel.blue("Input: #{input_file}")
1148
- puts @pastel.blue("Output: #{output_file}")
1149
- puts ""
674
+ @ui.header 'Secrets: Encrypt'
675
+ @ui.info 'Input', input_file
676
+ @ui.info 'Output', output_file
677
+ @ui.blank
1150
678
 
1151
679
  secrets = YAML.load_file(input_file)
1152
680
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(output_file)
1153
681
  encrypted_file.write(secrets)
1154
682
 
1155
- puts @pastel.green("Secrets encrypted to #{output_file}")
1156
- puts ""
1157
- puts @pastel.dim("You can now delete the plaintext file: rm #{input_file}")
1158
- puts @pastel.dim("Add to deploy.yml: secrets_file: #{output_file}")
683
+ @ui.success "Secrets encrypted to #{output_file}"
1159
684
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1160
- puts @pastel.red("Error: #{e.message}")
1161
- puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
685
+ @ui.error e.message
686
+ @ui.step 'Generate a key with: odysseus secrets generate-key'
1162
687
  exit 1
1163
688
  rescue Odysseus::Error => e
1164
- puts @pastel.red("Error: #{e.message}")
689
+ @ui.error e.message
1165
690
  exit 1
1166
691
  end
1167
692
 
1168
- # Decrypt and display secrets
1169
- # Usage: odysseus secrets decrypt --file secrets.yml.enc
1170
693
  def secrets_decrypt(options = {})
1171
694
  secrets_file = options[:file] || 'secrets.yml.enc'
1172
695
 
1173
696
  unless File.exist?(secrets_file)
1174
- puts @pastel.red("Error: secrets file not found: #{secrets_file}")
697
+ @ui.error "Secrets file not found: #{secrets_file}"
1175
698
  exit 1
1176
699
  end
1177
700
 
1178
- puts @pastel.cyan("Odysseus Secrets: Decrypt")
1179
- puts @pastel.blue("File: #{secrets_file}")
1180
- puts ""
701
+ @ui.header 'Secrets: Decrypt'
702
+ @ui.info 'File', secrets_file
703
+ @ui.blank
1181
704
 
1182
705
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
1183
706
  secrets = encrypted_file.read
1184
707
 
1185
- puts @pastel.yellow("Decrypted secrets:")
1186
- puts ""
1187
708
  secrets.each do |key, value|
1188
- # Mask values for display
1189
- masked_value = value.to_s.length > 4 ? "#{value[0..3]}#{'*' * (value.length - 4)}" : '****'
1190
- puts " #{key}: #{masked_value}"
709
+ masked = value.to_s.length > 4 ? "#{value[0..3]}#{'*' * (value.length - 4)}" : '****'
710
+ @ui.info key, masked
1191
711
  end
1192
- puts ""
1193
- puts @pastel.dim("(Values are masked for security)")
1194
- rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1195
- puts @pastel.red("Error: #{e.message}")
1196
- exit 1
1197
- rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
1198
- puts @pastel.red("Error: #{e.message}")
1199
- exit 1
1200
- rescue Odysseus::Error => e
1201
- puts @pastel.red("Error: #{e.message}")
712
+ @ui.blank
713
+ @ui.step '(values masked)'
714
+ rescue Odysseus::Secrets::EncryptedFile::MissingKeyError, Odysseus::Secrets::EncryptedFile::DecryptionError => e
715
+ @ui.error e.message
1202
716
  exit 1
1203
717
  end
1204
718
 
1205
- # Edit encrypted secrets using $EDITOR
1206
- # Usage: odysseus secrets edit --file secrets.yml.enc
1207
719
  def secrets_edit(options = {})
1208
720
  secrets_file = options[:file] || 'secrets.yml.enc'
1209
721
  editor = ENV['EDITOR'] || 'vi'
1210
722
 
1211
- puts @pastel.cyan("Odysseus Secrets: Edit")
1212
- puts @pastel.blue("File: #{secrets_file}")
1213
- puts @pastel.blue("Editor: #{editor}")
1214
- puts ""
723
+ @ui.header 'Secrets: Edit'
724
+ @ui.info 'File', secrets_file
725
+ @ui.info 'Editor', editor
726
+ @ui.blank
1215
727
 
1216
728
  encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
729
+ secrets = encrypted_file.exists? ? encrypted_file.read : {}
1217
730
 
1218
- # Load existing secrets or start with empty hash
1219
- secrets = if encrypted_file.exists?
1220
- encrypted_file.read
1221
- else
1222
- {}
1223
- end
1224
-
1225
- # Write to temp file for editing
1226
731
  temp_file = Tempfile.new(['secrets', '.yml'])
1227
732
  begin
1228
733
  temp_file.write(YAML.dump(secrets))
1229
734
  temp_file.close
1230
735
 
1231
- # Open in editor
1232
736
  system("#{editor} #{temp_file.path}")
1233
737
 
1234
- # Read back and encrypt
1235
738
  edited_secrets = YAML.load_file(temp_file.path)
1236
739
  encrypted_file.write(edited_secrets)
1237
740
 
1238
- puts @pastel.green("Secrets updated and encrypted to #{secrets_file}")
741
+ @ui.success 'Secrets updated and encrypted'
1239
742
  ensure
1240
743
  temp_file.unlink
1241
744
  end
1242
745
  rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1243
- puts @pastel.red("Error: #{e.message}")
1244
- puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
1245
- exit 1
1246
- rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
1247
- puts @pastel.red("Error: #{e.message}")
746
+ @ui.error e.message
747
+ @ui.step 'Generate a key with: odysseus secrets generate-key'
1248
748
  exit 1
1249
749
  rescue Odysseus::Error => e
1250
- puts @pastel.red("Error: #{e.message}")
750
+ @ui.error e.message
1251
751
  exit 1
1252
752
  end
1253
753
 
@@ -1258,14 +758,141 @@ module Odysseus
1258
758
  parser.parse
1259
759
  end
1260
760
 
1261
- 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?)
1262
879
  Odysseus::Deployer::SSH.new(
1263
880
  host: server,
1264
881
  user: config[:ssh][:user],
1265
882
  keys: config[:ssh][:keys],
1266
- use_tailscale: true
883
+ use_tailscale: true,
884
+ verbose: verbose
1267
885
  )
1268
886
  end
887
+
888
+ def require_name!(options)
889
+ name = options[:name]
890
+ unless name
891
+ @ui.error 'Dependency name required (--name)'
892
+ exit 1
893
+ end
894
+ name
895
+ end
1269
896
  end
1270
897
  end
1271
898
  end