odysseus-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +482 -0
  3. data/bin/odysseus +285 -0
  4. data/lib/odysseus/cli/cli.rb +1042 -0
  5. metadata +100 -0
@@ -0,0 +1,1042 @@
1
+ # odysseus-cli/lib/odysseus/cli/cli.rb
2
+
3
+ require 'odysseus'
4
+ require 'pastel'
5
+ require 'yaml'
6
+ require 'tempfile'
7
+
8
+ module Odysseus
9
+ module CLI
10
+ class CLI
11
+ def initialize
12
+ @pastel = Pastel.new
13
+ end
14
+
15
+ # Deploy command - deploys all roles to their configured hosts
16
+ # Usage: odysseus deploy [--config FILE] [--image TAG] [--build] [--dry-run] [--verbose]
17
+ def deploy(options = {})
18
+ config_file = options[:config] || 'deploy.yml'
19
+ image_tag = options[:image] || 'latest'
20
+ should_build = options[:build] || false
21
+ dry_run = options[:'dry-run'] || false
22
+ verbose = options[:verbose] || false
23
+
24
+ config = load_config(config_file)
25
+ uses_registry = config[:registry] && config[:registry][:server]
26
+
27
+ puts @pastel.cyan("Odysseus Deploy")
28
+ puts @pastel.blue("Service: #{config[:service]}")
29
+ puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
30
+ if should_build
31
+ distribution = uses_registry ? "registry (#{config[:registry][:server]})" : "pussh (SSH)"
32
+ puts @pastel.blue("Build & distribute via: #{distribution}")
33
+ end
34
+ puts ""
35
+
36
+ executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
37
+
38
+ # Build and distribute image if requested
39
+ if should_build
40
+ puts @pastel.cyan("=== Building and distributing image ===")
41
+ result = executor.build_and_distribute(image_tag: image_tag)
42
+
43
+ if result[:build][:success]
44
+ puts @pastel.green("Build complete!")
45
+ else
46
+ puts @pastel.red("Build failed: #{result[:build][:error]}")
47
+ exit 1
48
+ end
49
+
50
+ # Handle distribution result (either pussh or registry push)
51
+ if uses_registry
52
+ if result[:push][:success]
53
+ puts @pastel.green("Pushed to registry!")
54
+ else
55
+ puts @pastel.red("Push to registry failed!")
56
+ exit 1
57
+ end
58
+ else
59
+ if result[:pussh][:success]
60
+ puts @pastel.green("Pussh complete!")
61
+ result[:pussh][:results]&.each do |host, host_result|
62
+ status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
63
+ puts " #{status} #{host}"
64
+ end
65
+ else
66
+ puts @pastel.red("Pussh failed!")
67
+ result[:pussh][:results]&.each do |host, host_result|
68
+ status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
69
+ puts " #{status} #{host}"
70
+ puts " #{host_result[:error]}" unless host_result[:success]
71
+ end
72
+ exit 1
73
+ end
74
+ end
75
+ puts ""
76
+ end
77
+
78
+ executor.deploy_all(image_tag: image_tag, dry_run: dry_run)
79
+
80
+ puts ""
81
+ puts @pastel.green("Deploy complete!")
82
+ rescue Odysseus::Error => e
83
+ puts @pastel.red("Error: #{e.message}")
84
+ exit 1
85
+ end
86
+
87
+ # Build command - builds Docker image locally or on a build host
88
+ # Usage: odysseus build [--config FILE] [--image TAG] [--push] [--context PATH] [--verbose]
89
+ def build(options = {})
90
+ config_file = options[:config] || 'deploy.yml'
91
+ image_tag = options[:image] || 'latest'
92
+ push = options[:push] || false
93
+ context_path = options[:context]
94
+ verbose = options[:verbose] || false
95
+
96
+ config = load_config(config_file)
97
+ builder_config = config[:builder] || {}
98
+ strategy = builder_config[:strategy] || :local
99
+
100
+ puts @pastel.cyan("Odysseus Build")
101
+ puts @pastel.blue("Service: #{config[:service]}")
102
+ puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
103
+ puts @pastel.blue("Strategy: #{strategy}")
104
+ puts @pastel.blue("Build host: #{builder_config[:host]}") if builder_config[:host]
105
+ puts @pastel.blue("Push: #{push ? 'yes' : 'no'}")
106
+ puts ""
107
+
108
+ executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
109
+ result = executor.build(image_tag: image_tag, push: push, context_path: context_path)
110
+
111
+ if result[:success]
112
+ puts ""
113
+ puts @pastel.green("Build complete!")
114
+ puts @pastel.blue("Image: #{result[:image]}")
115
+ if result[:pushed]
116
+ puts @pastel.blue("Pushed to registry: yes")
117
+ end
118
+ else
119
+ puts ""
120
+ puts @pastel.red("Build failed: #{result[:error]}")
121
+ exit 1
122
+ end
123
+ rescue Odysseus::Error => e
124
+ puts @pastel.red("Error: #{e.message}")
125
+ exit 1
126
+ end
127
+
128
+ # Pussh command - push image to hosts via SSH (no registry needed)
129
+ # Usage: odysseus pussh [--config FILE] [--image TAG] [--build] [--verbose]
130
+ def pussh(options = {})
131
+ config_file = options[:config] || 'deploy.yml'
132
+ image_tag = options[:image] || 'latest'
133
+ should_build = options[:build] || false
134
+ verbose = options[:verbose] || false
135
+
136
+ config = load_config(config_file)
137
+
138
+ puts @pastel.cyan("Odysseus Pussh")
139
+ puts @pastel.blue("Service: #{config[:service]}")
140
+ puts @pastel.blue("Image: #{config[:image]}:#{image_tag}")
141
+ puts @pastel.blue("Build first: #{should_build ? 'yes' : 'no'}")
142
+ puts ""
143
+
144
+ executor = Odysseus::Deployer::Executor.new(config_file, verbose: verbose)
145
+
146
+ if should_build
147
+ result = executor.build_and_pussh(image_tag: image_tag)
148
+
149
+ if result[:build][:success]
150
+ puts @pastel.green("Build complete!")
151
+ else
152
+ puts @pastel.red("Build failed: #{result[:build][:error]}")
153
+ exit 1
154
+ end
155
+ else
156
+ result = executor.pussh(image_tag: image_tag)
157
+ end
158
+
159
+ pussh_result = should_build ? result[:pussh] : result
160
+
161
+ if pussh_result[:success]
162
+ puts ""
163
+ puts @pastel.green("Pussh complete!")
164
+ pussh_result[:results]&.each do |host, host_result|
165
+ status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
166
+ puts " #{status} #{host}"
167
+ end
168
+ else
169
+ puts ""
170
+ puts @pastel.red("Pussh failed!")
171
+ pussh_result[:results]&.each do |host, host_result|
172
+ status = host_result[:success] ? @pastel.green('✓') : @pastel.red('✗')
173
+ puts " #{status} #{host}"
174
+ puts " #{host_result[:error]}" unless host_result[:success]
175
+ end
176
+ exit 1
177
+ end
178
+ rescue Odysseus::Error => e
179
+ puts @pastel.red("Error: #{e.message}")
180
+ exit 1
181
+ end
182
+
183
+ # Status command - show full service status on a server
184
+ # Usage: odysseus status <server> [--config FILE]
185
+ def status(server, options = {})
186
+ config_file = options[:config] || 'deploy.yml'
187
+
188
+ config = load_config(config_file)
189
+ service_name = config[:service]
190
+
191
+ puts @pastel.cyan("Odysseus Status: #{service_name}")
192
+ puts @pastel.blue("Server: #{server}")
193
+ puts ""
194
+
195
+ ssh = connect_to_server(server, config)
196
+
197
+ begin
198
+ docker = Odysseus::Docker::Client.new(ssh)
199
+ caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
200
+
201
+ # Web containers
202
+ puts @pastel.cyan("Web:")
203
+ web_containers = docker.list(service: service_name)
204
+ if web_containers.empty?
205
+ puts " (no containers running)"
206
+ else
207
+ web_containers.each do |c|
208
+ status_color = c['State'] == 'running' ? :green : :red
209
+ health = c['Status'].include?('healthy') ? ' (healthy)' : ''
210
+ puts " #{@pastel.send(status_color, c['State'])} #{c['Names']}#{health}"
211
+ puts " Image: #{c['Image']}"
212
+ end
213
+ end
214
+
215
+ # Show Caddy route for this service
216
+ caddy_status = caddy.status
217
+ if caddy_status[:running]
218
+ svc_route = caddy_status[:services].find { |s| s[:service] == service_name }
219
+ if svc_route
220
+ puts " Proxy: #{svc_route[:hosts].join(', ')}"
221
+ puts " Upstreams: #{svc_route[:upstreams].join(', ')}"
222
+ end
223
+ end
224
+
225
+ puts ""
226
+
227
+ # Job/worker containers (non-web roles)
228
+ non_web_roles = config[:servers].keys.reject { |r| r == :web }
229
+ if non_web_roles.any?
230
+ puts @pastel.cyan("Jobs/Workers:")
231
+ non_web_roles.each do |role|
232
+ role_service = "#{service_name}-#{role}"
233
+ containers = docker.list(service: role_service)
234
+ if containers.empty?
235
+ puts " #{@pastel.yellow(role.to_s)}: #{@pastel.red('not running')}"
236
+ else
237
+ containers.each do |c|
238
+ status_color = c['State'] == 'running' ? :green : :red
239
+ puts " #{@pastel.yellow(role.to_s)}: #{@pastel.send(status_color, c['State'])} #{c['Names']}"
240
+ puts " Image: #{c['Image']}"
241
+ end
242
+ end
243
+ end
244
+ puts ""
245
+ end
246
+
247
+ # Accessories
248
+ if config[:accessories]&.any?
249
+ puts @pastel.cyan("Accessories:")
250
+ config[:accessories].each do |name, acc_config|
251
+ acc_service = "#{service_name}-#{name}"
252
+ containers = docker.list(service: acc_service, all: true)
253
+ running = containers.find { |c| c['State'] == 'running' }
254
+
255
+ if running
256
+ health = running['Status'].include?('healthy') ? ' (healthy)' : ''
257
+ puts " #{@pastel.yellow(name.to_s)}: #{@pastel.green('running')}#{health}"
258
+ puts " Image: #{acc_config[:image]}"
259
+ puts " Container: #{running['ID'][0..11]}"
260
+ else
261
+ puts " #{@pastel.yellow(name.to_s)}: #{@pastel.red('stopped')}"
262
+ puts " Image: #{acc_config[:image]}"
263
+ end
264
+ end
265
+ puts ""
266
+ end
267
+
268
+ # TLS status for this service's domains
269
+ if config[:proxy][:hosts]&.any?
270
+ puts @pastel.cyan("TLS:")
271
+ if caddy_status[:running] && caddy_status[:tls][:enabled]
272
+ service_hosts = config[:proxy][:hosts]
273
+ relevant_policy = caddy_status[:tls][:policies].find do |p|
274
+ (p[:subjects] & service_hosts).any?
275
+ end
276
+ if relevant_policy
277
+ puts " Domains: #{(relevant_policy[:subjects] & service_hosts).join(', ')}"
278
+ puts " Issuer: #{relevant_policy[:issuer]}"
279
+ puts " Email: #{relevant_policy[:email] || '(not set)'}"
280
+ else
281
+ puts " (not configured for this service)"
282
+ end
283
+ else
284
+ puts " (Caddy not running or TLS not configured)"
285
+ end
286
+ end
287
+ ensure
288
+ ssh.close
289
+ end
290
+ rescue Odysseus::Error => e
291
+ puts @pastel.red("Error: #{e.message}")
292
+ exit 1
293
+ end
294
+
295
+ # Containers command - list running containers for a service
296
+ # Usage: odysseus containers <server> [--config FILE] [--service NAME]
297
+ def containers(server, options = {})
298
+ config_file = options[:config] || 'deploy.yml'
299
+
300
+ puts @pastel.cyan("Odysseus Containers")
301
+ puts @pastel.blue("Server: #{server}")
302
+ puts ""
303
+
304
+ config = load_config(config_file)
305
+ service_name = options[:service] || config[:service]
306
+ ssh = connect_to_server(server, config)
307
+
308
+ begin
309
+ docker = Odysseus::Docker::Client.new(ssh)
310
+ containers = docker.list(service: service_name)
311
+
312
+ if containers.empty?
313
+ puts "No containers found for service: #{service_name}"
314
+ else
315
+ puts @pastel.cyan("Containers for #{service_name}:")
316
+ containers.each do |c|
317
+ status_color = c['State'] == 'running' ? :green : :red
318
+ puts " #{c['ID'][0..11]} #{@pastel.send(status_color, c['State'])} #{c['Names']} (#{c['Image']})"
319
+ puts " Created: #{c['CreatedAt']}"
320
+ puts " Status: #{c['Status']}"
321
+ end
322
+ end
323
+ ensure
324
+ ssh.close
325
+ end
326
+ rescue Odysseus::Error => e
327
+ puts @pastel.red("Error: #{e.message}")
328
+ exit 1
329
+ end
330
+
331
+ # Validate command
332
+ # Usage: odysseus validate [--config FILE]
333
+ def validate(options = {})
334
+ config_file = options[:config] || 'deploy.yml'
335
+
336
+ puts @pastel.cyan("Validating #{config_file}...")
337
+
338
+ config = load_config(config_file)
339
+
340
+ puts @pastel.green("Configuration is valid!")
341
+ puts ""
342
+ puts "Service: #{config[:service]}"
343
+ puts "Image: #{config[:image]}"
344
+ puts "Servers: #{config[:servers].keys.join(', ')}"
345
+ puts "Proxy hosts: #{config[:proxy][:hosts]&.join(', ')}"
346
+ if config[:accessories]&.any?
347
+ puts "Accessories: #{config[:accessories].keys.join(', ')}"
348
+ end
349
+ rescue Odysseus::Error => e
350
+ puts @pastel.red("Validation failed: #{e.message}")
351
+ exit 1
352
+ end
353
+
354
+ # Accessory boot command
355
+ # Usage: odysseus accessory boot --name NAME [--config FILE]
356
+ def accessory_boot(options = {})
357
+ config_file = options[:config] || 'deploy.yml'
358
+ name = options[:name]
359
+
360
+ unless name
361
+ puts @pastel.red("Error: accessory name required (--name)")
362
+ exit 1
363
+ end
364
+
365
+ puts @pastel.cyan("Odysseus Accessory Boot")
366
+ puts @pastel.blue("Accessory: #{name}")
367
+ puts ""
368
+
369
+ executor = Odysseus::Deployer::Executor.new(config_file)
370
+ executor.deploy_accessory(name: name)
371
+
372
+ puts @pastel.green("Accessory #{name} deployed!")
373
+ rescue Odysseus::Error => e
374
+ puts @pastel.red("Error: #{e.message}")
375
+ exit 1
376
+ end
377
+
378
+ # Boot all accessories
379
+ # Usage: odysseus accessory boot-all [--config FILE]
380
+ def accessory_boot_all(options = {})
381
+ config_file = options[:config] || 'deploy.yml'
382
+
383
+ puts @pastel.cyan("Odysseus Accessory Boot All")
384
+ puts ""
385
+
386
+ executor = Odysseus::Deployer::Executor.new(config_file)
387
+ executor.boot_accessories
388
+
389
+ puts @pastel.green("All accessories deployed!")
390
+ rescue Odysseus::Error => e
391
+ puts @pastel.red("Error: #{e.message}")
392
+ exit 1
393
+ end
394
+
395
+ # Accessory remove command
396
+ # Usage: odysseus accessory remove --name NAME [--config FILE]
397
+ def accessory_remove(options = {})
398
+ config_file = options[:config] || 'deploy.yml'
399
+ name = options[:name]
400
+
401
+ unless name
402
+ puts @pastel.red("Error: accessory name required (--name)")
403
+ exit 1
404
+ end
405
+
406
+ puts @pastel.cyan("Odysseus Accessory Remove")
407
+ puts @pastel.blue("Accessory: #{name}")
408
+ puts ""
409
+
410
+ executor = Odysseus::Deployer::Executor.new(config_file)
411
+ executor.remove_accessory(name: name)
412
+
413
+ puts @pastel.green("Accessory #{name} removed!")
414
+ rescue Odysseus::Error => e
415
+ puts @pastel.red("Error: #{e.message}")
416
+ exit 1
417
+ end
418
+
419
+ # Accessory restart command
420
+ # Usage: odysseus accessory restart --name NAME [--config FILE]
421
+ def accessory_restart(options = {})
422
+ config_file = options[:config] || 'deploy.yml'
423
+ name = options[:name]
424
+
425
+ unless name
426
+ puts @pastel.red("Error: accessory name required (--name)")
427
+ exit 1
428
+ end
429
+
430
+ puts @pastel.cyan("Odysseus Accessory Restart")
431
+ puts @pastel.blue("Accessory: #{name}")
432
+ puts ""
433
+
434
+ executor = Odysseus::Deployer::Executor.new(config_file)
435
+ executor.restart_accessory(name: name)
436
+
437
+ puts @pastel.green("Accessory #{name} restarted!")
438
+ rescue Odysseus::Error => e
439
+ puts @pastel.red("Error: #{e.message}")
440
+ exit 1
441
+ end
442
+
443
+ # Accessory upgrade command - upgrade to new image version (preserves volumes)
444
+ # Usage: odysseus accessory upgrade --name NAME [--config FILE]
445
+ def accessory_upgrade(options = {})
446
+ config_file = options[:config] || 'deploy.yml'
447
+ name = options[:name]
448
+
449
+ unless name
450
+ puts @pastel.red("Error: accessory name required (--name)")
451
+ exit 1
452
+ end
453
+
454
+ puts @pastel.cyan("Odysseus Accessory Upgrade")
455
+ puts @pastel.blue("Accessory: #{name}")
456
+ puts ""
457
+
458
+ executor = Odysseus::Deployer::Executor.new(config_file)
459
+ executor.upgrade_accessory(name: name)
460
+
461
+ puts @pastel.green("Accessory #{name} upgraded!")
462
+ rescue Odysseus::Error => e
463
+ puts @pastel.red("Error: #{e.message}")
464
+ exit 1
465
+ end
466
+
467
+ # Accessory status command
468
+ # Usage: odysseus accessory status [--config FILE]
469
+ def accessory_status(options = {})
470
+ config_file = options[:config] || 'deploy.yml'
471
+
472
+ puts @pastel.cyan("Odysseus Accessory Status")
473
+ puts ""
474
+
475
+ executor = Odysseus::Deployer::Executor.new(config_file)
476
+ statuses = executor.accessory_status
477
+
478
+ if statuses.empty?
479
+ puts "No accessories configured"
480
+ else
481
+ statuses.each do |status|
482
+ status_text = status[:running] ? @pastel.green('running') : @pastel.red('stopped')
483
+ puts " #{@pastel.yellow(status[:name].to_s)} @ #{status[:host]}: #{status_text}"
484
+ puts " Image: #{status[:image]}"
485
+ puts " Container: #{status[:container_id] ? status[:container_id][0..11] : '(none)'}"
486
+ puts " Has proxy: #{status[:has_proxy] ? 'yes' : 'no'}"
487
+ end
488
+ end
489
+ rescue Odysseus::Error => e
490
+ puts @pastel.red("Error: #{e.message}")
491
+ exit 1
492
+ end
493
+
494
+ # Logs command - tail logs for a service
495
+ # Usage: odysseus logs <server> [--config FILE] [--role ROLE] [--follow] [--lines N] [--since TIME]
496
+ def logs(server, options = {})
497
+ config_file = options[:config] || 'deploy.yml'
498
+ role = (options[:role] || 'web').to_sym
499
+ follow = options[:follow] || false
500
+ lines = options[:lines] || 100
501
+ since = options[:since]
502
+
503
+ config = load_config(config_file)
504
+ service_name = role == :web ? config[:service] : "#{config[:service]}-#{role}"
505
+
506
+ puts @pastel.cyan("Odysseus Logs: #{service_name}")
507
+ puts @pastel.blue("Server: #{server}")
508
+ puts ""
509
+
510
+ ssh = connect_to_server(server, config)
511
+
512
+ begin
513
+ docker = Odysseus::Docker::Client.new(ssh)
514
+ containers = docker.list(service: service_name)
515
+
516
+ if containers.empty?
517
+ puts @pastel.yellow("No running containers found for #{service_name}")
518
+ return
519
+ end
520
+
521
+ container = containers.first
522
+ container_id = container['ID']
523
+
524
+ if follow
525
+ puts @pastel.dim("Following logs (Ctrl+C to stop)...")
526
+ puts ""
527
+ docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
528
+ print line
529
+ end
530
+ else
531
+ output = docker.logs(container_id, tail: lines, since: since)
532
+ puts output
533
+ end
534
+ ensure
535
+ ssh.close
536
+ end
537
+ rescue Odysseus::Error => e
538
+ puts @pastel.red("Error: #{e.message}")
539
+ exit 1
540
+ end
541
+
542
+ # Accessory logs command
543
+ # Usage: odysseus accessory logs <server> --name NAME [--config FILE] [--follow] [--lines N] [--since TIME]
544
+ def accessory_logs(server, options = {})
545
+ config_file = options[:config] || 'deploy.yml'
546
+ name = options[:name]
547
+ follow = options[:follow] || false
548
+ lines = options[:lines] || 100
549
+ since = options[:since]
550
+
551
+ unless name
552
+ puts @pastel.red("Error: accessory name required (--name)")
553
+ exit 1
554
+ end
555
+
556
+ config = load_config(config_file)
557
+ service_name = "#{config[:service]}-#{name}"
558
+
559
+ puts @pastel.cyan("Odysseus Accessory Logs: #{service_name}")
560
+ puts @pastel.blue("Server: #{server}")
561
+ puts ""
562
+
563
+ ssh = connect_to_server(server, config)
564
+
565
+ begin
566
+ docker = Odysseus::Docker::Client.new(ssh)
567
+ containers = docker.list(service: service_name)
568
+
569
+ if containers.empty?
570
+ puts @pastel.yellow("No running containers found for #{service_name}")
571
+ return
572
+ end
573
+
574
+ container = containers.first
575
+ container_id = container['ID']
576
+
577
+ if follow
578
+ puts @pastel.dim("Following logs (Ctrl+C to stop)...")
579
+ puts ""
580
+ docker.logs(container_id, follow: true, tail: lines, since: since) do |line|
581
+ print line
582
+ end
583
+ else
584
+ output = docker.logs(container_id, tail: lines, since: since)
585
+ puts output
586
+ end
587
+ ensure
588
+ ssh.close
589
+ end
590
+ rescue Odysseus::Error => e
591
+ puts @pastel.red("Error: #{e.message}")
592
+ exit 1
593
+ end
594
+
595
+ # App exec command - run a command in a new container using the app image
596
+ # Usage: odysseus app exec <server> <command> [--config FILE]
597
+ def app_exec(server, options = {})
598
+ config_file = options[:config] || 'deploy.yml'
599
+ command = options[:command]
600
+
601
+ unless command
602
+ puts @pastel.red("Error: command required")
603
+ exit 1
604
+ end
605
+
606
+ config = load_config(config_file)
607
+ image = "#{config[:image]}:latest"
608
+
609
+ puts @pastel.cyan("Odysseus App Exec")
610
+ puts @pastel.blue("Server: #{server}")
611
+ puts @pastel.blue("Image: #{image}")
612
+ puts @pastel.blue("Command: #{command}")
613
+ puts ""
614
+
615
+ ssh = connect_to_server(server, config)
616
+
617
+ begin
618
+ docker = Odysseus::Docker::Client.new(ssh)
619
+
620
+ # Build environment from config
621
+ env = {}
622
+ config[:env][:clear]&.each { |k, v| env[k.to_s] = v.to_s }
623
+
624
+ output = docker.run_once(
625
+ image: image,
626
+ command: command,
627
+ options: {
628
+ env: env,
629
+ network: 'odysseus'
630
+ }
631
+ )
632
+
633
+ puts output
634
+ ensure
635
+ ssh.close
636
+ end
637
+ rescue Odysseus::Error => e
638
+ puts @pastel.red("Error: #{e.message}")
639
+ exit 1
640
+ end
641
+
642
+ # App shell command - open an interactive shell in a temporary container
643
+ # Usage: odysseus app shell <server> [--config FILE]
644
+ def app_shell(server, options = {})
645
+ config_file = options[:config] || 'deploy.yml'
646
+
647
+ config = load_config(config_file)
648
+ image = "#{config[:image]}:latest"
649
+
650
+ puts @pastel.cyan("Odysseus App Shell")
651
+ puts @pastel.blue("Server: #{server}")
652
+ puts @pastel.blue("Image: #{image}")
653
+ puts ""
654
+
655
+ ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
656
+ env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
657
+
658
+ system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} /bin/sh'")
659
+ rescue Odysseus::Error => e
660
+ puts @pastel.red("Error: #{e.message}")
661
+ exit 1
662
+ end
663
+
664
+ # App console command - run an interactive console in a new container
665
+ # Usage: odysseus app console <server> [--config FILE] [--cmd COMMAND]
666
+ def app_console(server, options = {})
667
+ config_file = options[:config] || 'deploy.yml'
668
+ console_cmd = options[:cmd] || '/bin/sh'
669
+
670
+ config = load_config(config_file)
671
+ image = "#{config[:image]}:latest"
672
+
673
+ puts @pastel.cyan("Odysseus App Console")
674
+ puts @pastel.blue("Server: #{server}")
675
+ puts @pastel.blue("Image: #{image}")
676
+ puts @pastel.blue("Console: #{console_cmd}")
677
+ puts ""
678
+ puts @pastel.dim("Note: This runs 'docker run -it' via SSH. For full interactivity, use:")
679
+ puts @pastel.dim(" ssh #{config[:ssh][:user]}@#{server} -t 'docker run -it --rm --network odysseus #{image} #{console_cmd}'")
680
+ puts ""
681
+
682
+ # For truly interactive sessions, we need to exec through SSH directly
683
+ # The CLI can't easily support full TTY passthrough
684
+ ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
685
+ env_flags = config[:env][:clear]&.map { |k, v| "-e #{k}=#{v}" }&.join(' ') || ''
686
+
687
+ system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker run -it --rm --network odysseus #{env_flags} #{image} #{console_cmd}'")
688
+ rescue Odysseus::Error => e
689
+ puts @pastel.red("Error: #{e.message}")
690
+ exit 1
691
+ end
692
+
693
+ # Accessory shell command - open an interactive shell in a running accessory container
694
+ # Usage: odysseus accessory shell <server> --name NAME [--config FILE]
695
+ def accessory_shell(server, options = {})
696
+ config_file = options[:config] || 'deploy.yml'
697
+ name = options[:name]
698
+
699
+ unless name
700
+ puts @pastel.red("Error: accessory name required (--name)")
701
+ exit 1
702
+ end
703
+
704
+ config = load_config(config_file)
705
+ service_name = "#{config[:service]}-#{name}"
706
+
707
+ puts @pastel.cyan("Odysseus Accessory Shell")
708
+ puts @pastel.blue("Server: #{server}")
709
+ puts @pastel.blue("Accessory: #{name}")
710
+ puts ""
711
+
712
+ # Get container ID first
713
+ ssh = connect_to_server(server, config)
714
+ begin
715
+ docker = Odysseus::Docker::Client.new(ssh)
716
+ containers = docker.list(service: service_name)
717
+
718
+ if containers.empty?
719
+ puts @pastel.red("No running containers found for #{service_name}")
720
+ exit 1
721
+ end
722
+
723
+ container_id = containers.first['ID']
724
+ ensure
725
+ ssh.close
726
+ end
727
+
728
+ # Now exec with SSH passthrough for TTY
729
+ ssh_keys = config[:ssh][:keys].map { |k| "-i #{File.expand_path(k)}" }.join(' ')
730
+ system("ssh #{ssh_keys} -t #{config[:ssh][:user]}@#{server} 'docker exec -it #{container_id} /bin/sh'")
731
+ rescue Odysseus::Error => e
732
+ puts @pastel.red("Error: #{e.message}")
733
+ exit 1
734
+ end
735
+
736
+ # Cleanup command - remove containers for this service
737
+ # Usage: odysseus cleanup <server> [--config FILE] [--prune-images]
738
+ def cleanup(server, options = {})
739
+ config_file = options[:config] || 'deploy.yml'
740
+ prune_images = options[:all] || false
741
+
742
+ config = load_config(config_file)
743
+ service_name = config[:service]
744
+
745
+ puts @pastel.cyan("Odysseus Cleanup: #{service_name}")
746
+ puts @pastel.blue("Server: #{server}")
747
+ puts ""
748
+
749
+ ssh = connect_to_server(server, config)
750
+
751
+ begin
752
+ docker = Odysseus::Docker::Client.new(ssh)
753
+ caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: docker)
754
+
755
+ # Show current disk usage
756
+ puts @pastel.cyan("Current disk usage:")
757
+ puts docker.disk_usage
758
+ puts ""
759
+
760
+ # Collect all service names for this deploy.yml
761
+ service_names = [service_name]
762
+ config[:servers].keys.reject { |r| r == :web }.each do |role|
763
+ service_names << "#{service_name}-#{role}"
764
+ end
765
+ config[:accessories]&.each_key do |name|
766
+ service_names << "#{service_name}-#{name}"
767
+ end
768
+
769
+ puts @pastel.yellow("Removing containers for #{service_name}...")
770
+
771
+ total_removed = 0
772
+
773
+ # Remove all containers for each service (not just old ones)
774
+ service_names.each do |svc|
775
+ containers = docker.list(service: svc, all: true)
776
+ containers.each do |c|
777
+ docker.stop(c['ID'], timeout: 10) if c['State'] == 'running'
778
+ docker.remove(c['ID'], force: true)
779
+ total_removed += 1
780
+ end
781
+ end
782
+
783
+ puts " Removed #{total_removed} container(s)"
784
+
785
+ # Remove routes from Caddy for this service
786
+ puts ""
787
+ puts @pastel.yellow("Removing Caddy routes for #{service_name}...")
788
+
789
+ if caddy.running?
790
+ # Remove web service route
791
+ caddy.remove_upstream(service: service_name, upstream: nil) rescue nil
792
+
793
+ # Remove accessory routes
794
+ config[:accessories]&.each do |name, acc_config|
795
+ if acc_config[:proxy]
796
+ acc_service = "#{service_name}-#{name}"
797
+ caddy.remove_upstream(service: acc_service, upstream: nil) rescue nil
798
+ end
799
+ end
800
+
801
+ # Check if Caddy still has other services
802
+ remaining_services = caddy.list_services
803
+ remaining_services.reject! { |s| s[:service] == 'unknown' || s[:service].empty? }
804
+
805
+ if remaining_services.empty?
806
+ puts @pastel.yellow("No other services using Caddy, stopping Caddy...")
807
+ docker.stop('odysseus-caddy', timeout: 10) rescue nil
808
+ docker.remove('odysseus-caddy', force: true) rescue nil
809
+ puts " Caddy removed"
810
+ else
811
+ puts @pastel.dim(" Keeping Caddy (#{remaining_services.size} other service(s) configured)")
812
+ end
813
+ end
814
+
815
+ # Optionally prune dangling images
816
+ if prune_images
817
+ puts ""
818
+ puts @pastel.yellow("Pruning dangling images...")
819
+ results = docker.prune(containers: false, images: true, volumes: false, networks: false)
820
+ puts results[:images]
821
+ end
822
+
823
+ puts ""
824
+ puts @pastel.cyan("Disk usage after cleanup:")
825
+ puts docker.disk_usage
826
+ ensure
827
+ ssh.close
828
+ end
829
+
830
+ puts ""
831
+ puts @pastel.green("Cleanup complete!")
832
+ rescue Odysseus::Error => e
833
+ puts @pastel.red("Error: #{e.message}")
834
+ exit 1
835
+ end
836
+
837
+ # Accessory exec command - run a command in a running accessory container
838
+ # Usage: odysseus accessory exec <server> --name NAME <command> [--config FILE]
839
+ def accessory_exec(server, options = {})
840
+ config_file = options[:config] || 'deploy.yml'
841
+ name = options[:name]
842
+ command = options[:command]
843
+
844
+ unless name
845
+ puts @pastel.red("Error: accessory name required (--name)")
846
+ exit 1
847
+ end
848
+
849
+ unless command
850
+ puts @pastel.red("Error: command required")
851
+ exit 1
852
+ end
853
+
854
+ config = load_config(config_file)
855
+ service_name = "#{config[:service]}-#{name}"
856
+
857
+ puts @pastel.cyan("Odysseus Accessory Exec")
858
+ puts @pastel.blue("Server: #{server}")
859
+ puts @pastel.blue("Accessory: #{name}")
860
+ puts @pastel.blue("Command: #{command}")
861
+ puts ""
862
+
863
+ ssh = connect_to_server(server, config)
864
+
865
+ begin
866
+ docker = Odysseus::Docker::Client.new(ssh)
867
+ containers = docker.list(service: service_name)
868
+
869
+ if containers.empty?
870
+ puts @pastel.red("No running containers found for #{service_name}")
871
+ exit 1
872
+ end
873
+
874
+ container_id = containers.first['ID']
875
+ output = docker.exec(container_id, command)
876
+ puts output
877
+ ensure
878
+ ssh.close
879
+ end
880
+ rescue Odysseus::Error => e
881
+ puts @pastel.red("Error: #{e.message}")
882
+ exit 1
883
+ end
884
+
885
+ # Generate a new master key for encrypting secrets
886
+ # Usage: odysseus secrets generate-key
887
+ def secrets_generate_key(_options = {})
888
+ puts @pastel.cyan("Odysseus Secrets: Generate Key")
889
+ puts ""
890
+
891
+ key = Odysseus::Secrets::EncryptedFile.generate_key
892
+
893
+ puts @pastel.green("Generated master key:")
894
+ puts ""
895
+ puts " #{key}"
896
+ puts ""
897
+ puts @pastel.yellow("Save this key securely!")
898
+ puts @pastel.dim("Set it as ODYSSEUS_MASTER_KEY environment variable for encrypt/decrypt operations.")
899
+ end
900
+
901
+ # Encrypt a secrets file
902
+ # Usage: odysseus secrets encrypt --input secrets.yml --file secrets.yml.enc
903
+ def secrets_encrypt(options = {})
904
+ input_file = options[:input]
905
+ output_file = options[:file] || 'secrets.yml.enc'
906
+
907
+ unless input_file
908
+ puts @pastel.red("Error: input file required (--input)")
909
+ exit 1
910
+ end
911
+
912
+ unless File.exist?(input_file)
913
+ puts @pastel.red("Error: input file not found: #{input_file}")
914
+ exit 1
915
+ end
916
+
917
+ puts @pastel.cyan("Odysseus Secrets: Encrypt")
918
+ puts @pastel.blue("Input: #{input_file}")
919
+ puts @pastel.blue("Output: #{output_file}")
920
+ puts ""
921
+
922
+ secrets = YAML.load_file(input_file)
923
+ encrypted_file = Odysseus::Secrets::EncryptedFile.new(output_file)
924
+ encrypted_file.write(secrets)
925
+
926
+ puts @pastel.green("Secrets encrypted to #{output_file}")
927
+ puts ""
928
+ puts @pastel.dim("You can now delete the plaintext file: rm #{input_file}")
929
+ puts @pastel.dim("Add to deploy.yml: secrets_file: #{output_file}")
930
+ rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
931
+ puts @pastel.red("Error: #{e.message}")
932
+ puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
933
+ exit 1
934
+ rescue Odysseus::Error => e
935
+ puts @pastel.red("Error: #{e.message}")
936
+ exit 1
937
+ end
938
+
939
+ # Decrypt and display secrets
940
+ # Usage: odysseus secrets decrypt --file secrets.yml.enc
941
+ def secrets_decrypt(options = {})
942
+ secrets_file = options[:file] || 'secrets.yml.enc'
943
+
944
+ unless File.exist?(secrets_file)
945
+ puts @pastel.red("Error: secrets file not found: #{secrets_file}")
946
+ exit 1
947
+ end
948
+
949
+ puts @pastel.cyan("Odysseus Secrets: Decrypt")
950
+ puts @pastel.blue("File: #{secrets_file}")
951
+ puts ""
952
+
953
+ encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
954
+ secrets = encrypted_file.read
955
+
956
+ puts @pastel.yellow("Decrypted secrets:")
957
+ puts ""
958
+ secrets.each do |key, value|
959
+ # Mask values for display
960
+ masked_value = value.to_s.length > 4 ? "#{value[0..3]}#{'*' * (value.length - 4)}" : '****'
961
+ puts " #{key}: #{masked_value}"
962
+ end
963
+ puts ""
964
+ puts @pastel.dim("(Values are masked for security)")
965
+ rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
966
+ puts @pastel.red("Error: #{e.message}")
967
+ exit 1
968
+ rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
969
+ puts @pastel.red("Error: #{e.message}")
970
+ exit 1
971
+ rescue Odysseus::Error => e
972
+ puts @pastel.red("Error: #{e.message}")
973
+ exit 1
974
+ end
975
+
976
+ # Edit encrypted secrets using $EDITOR
977
+ # Usage: odysseus secrets edit --file secrets.yml.enc
978
+ def secrets_edit(options = {})
979
+ secrets_file = options[:file] || 'secrets.yml.enc'
980
+ editor = ENV['EDITOR'] || 'vi'
981
+
982
+ puts @pastel.cyan("Odysseus Secrets: Edit")
983
+ puts @pastel.blue("File: #{secrets_file}")
984
+ puts @pastel.blue("Editor: #{editor}")
985
+ puts ""
986
+
987
+ encrypted_file = Odysseus::Secrets::EncryptedFile.new(secrets_file)
988
+
989
+ # Load existing secrets or start with empty hash
990
+ secrets = if encrypted_file.exists?
991
+ encrypted_file.read
992
+ else
993
+ {}
994
+ end
995
+
996
+ # Write to temp file for editing
997
+ temp_file = Tempfile.new(['secrets', '.yml'])
998
+ begin
999
+ temp_file.write(YAML.dump(secrets))
1000
+ temp_file.close
1001
+
1002
+ # Open in editor
1003
+ system("#{editor} #{temp_file.path}")
1004
+
1005
+ # Read back and encrypt
1006
+ edited_secrets = YAML.load_file(temp_file.path)
1007
+ encrypted_file.write(edited_secrets)
1008
+
1009
+ puts @pastel.green("Secrets updated and encrypted to #{secrets_file}")
1010
+ ensure
1011
+ temp_file.unlink
1012
+ end
1013
+ rescue Odysseus::Secrets::EncryptedFile::MissingKeyError => e
1014
+ puts @pastel.red("Error: #{e.message}")
1015
+ puts @pastel.dim("Generate a key with: odysseus secrets generate-key")
1016
+ exit 1
1017
+ rescue Odysseus::Secrets::EncryptedFile::DecryptionError => e
1018
+ puts @pastel.red("Error: #{e.message}")
1019
+ exit 1
1020
+ rescue Odysseus::Error => e
1021
+ puts @pastel.red("Error: #{e.message}")
1022
+ exit 1
1023
+ end
1024
+
1025
+ private
1026
+
1027
+ def load_config(config_file)
1028
+ parser = Odysseus::Config::Parser.new(config_file)
1029
+ parser.parse
1030
+ end
1031
+
1032
+ def connect_to_server(server, config)
1033
+ Odysseus::Deployer::SSH.new(
1034
+ host: server,
1035
+ user: config[:ssh][:user],
1036
+ keys: config[:ssh][:keys],
1037
+ use_tailscale: true
1038
+ )
1039
+ end
1040
+ end
1041
+ end
1042
+ end