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