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