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