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.
- checksums.yaml +7 -0
- data/README.md +482 -0
- data/bin/odysseus +285 -0
- data/lib/odysseus/cli/cli.rb +1042 -0
- 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
|