odysseus-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +8 -0
- data/README.md +43 -0
- data/Rakefile +12 -0
- data/lib/odysseus/builder/client.rb +292 -0
- data/lib/odysseus/caddy/client.rb +338 -0
- data/lib/odysseus/config/parser.rb +225 -0
- data/lib/odysseus/core/version.rb +7 -0
- data/lib/odysseus/core.rb +10 -0
- data/lib/odysseus/deployer/executor.rb +389 -0
- data/lib/odysseus/deployer/ssh.rb +143 -0
- data/lib/odysseus/docker/client.rb +333 -0
- data/lib/odysseus/errors.rb +27 -0
- data/lib/odysseus/host_providers/aws_asg.rb +91 -0
- data/lib/odysseus/host_providers/base.rb +27 -0
- data/lib/odysseus/host_providers/static.rb +24 -0
- data/lib/odysseus/host_providers.rb +49 -0
- data/lib/odysseus/orchestrator/accessory_deploy.rb +309 -0
- data/lib/odysseus/orchestrator/job_deploy.rb +176 -0
- data/lib/odysseus/orchestrator/web_deploy.rb +253 -0
- data/lib/odysseus/secrets/encrypted_file.rb +125 -0
- data/lib/odysseus/secrets/loader.rb +56 -0
- data/lib/odysseus/validators/config.rb +85 -0
- data/lib/odysseus/version.rb +5 -0
- data/lib/odysseus.rb +26 -0
- data/sig/odysseus/core.rbs +6 -0
- metadata +127 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# lib/odysseus/deployer/executor.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module Deployer
|
|
5
|
+
class Executor
|
|
6
|
+
WEB_ROLE = :web
|
|
7
|
+
|
|
8
|
+
# @param config_path [String] path to deploy.yml
|
|
9
|
+
# @param verbose [Boolean] show commands being executed
|
|
10
|
+
def initialize(config_path, verbose: false)
|
|
11
|
+
@config_path = config_path
|
|
12
|
+
@config_dir = File.dirname(config_path)
|
|
13
|
+
parser = Odysseus::Config::Parser.new(config_path)
|
|
14
|
+
@config = parser.parse
|
|
15
|
+
@verbose = verbose
|
|
16
|
+
@secrets_loader = Odysseus::Secrets::Loader.new(@config, config_dir: @config_dir)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Build Docker image
|
|
20
|
+
# @param image_tag [String] docker image tag (e.g., "v1.0.0")
|
|
21
|
+
# @param push [Boolean] push to registry after build
|
|
22
|
+
# @param context_path [String] path to build context (defaults to config directory)
|
|
23
|
+
# @return [Hash] build result
|
|
24
|
+
def build(image_tag:, push: false, context_path: nil)
|
|
25
|
+
context = context_path || resolve_build_context
|
|
26
|
+
full_image = "#{@config[:image]}:#{image_tag}"
|
|
27
|
+
|
|
28
|
+
builder = build_builder
|
|
29
|
+
|
|
30
|
+
if push
|
|
31
|
+
builder.build_and_push(
|
|
32
|
+
context_path: context,
|
|
33
|
+
image: full_image,
|
|
34
|
+
registry: @config[:registry]
|
|
35
|
+
)
|
|
36
|
+
else
|
|
37
|
+
builder.build(context_path: context, image: full_image)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Build and deploy in one step
|
|
42
|
+
# @param image_tag [String] docker image tag
|
|
43
|
+
# @param context_path [String] path to build context
|
|
44
|
+
# @param dry_run [Boolean] if true, don't actually deploy
|
|
45
|
+
# @return [Hash] results
|
|
46
|
+
def build_and_deploy(image_tag:, context_path: nil, dry_run: false)
|
|
47
|
+
# First, build the image
|
|
48
|
+
build_result = build(image_tag: image_tag, push: true, context_path: context_path)
|
|
49
|
+
|
|
50
|
+
unless build_result[:success]
|
|
51
|
+
return { build: build_result, deploy: nil, success: false }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Then deploy
|
|
55
|
+
deploy_results = deploy_all(image_tag: image_tag, dry_run: dry_run)
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
build: build_result,
|
|
59
|
+
deploy: deploy_results,
|
|
60
|
+
success: deploy_results.values.all? { |r| r[:success] }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Push image to all configured hosts via SSH (using docker pussh)
|
|
65
|
+
# @param image_tag [String] docker image tag
|
|
66
|
+
# @return [Hash] pussh results
|
|
67
|
+
def pussh(image_tag:)
|
|
68
|
+
full_image = "#{@config[:image]}:#{image_tag}"
|
|
69
|
+
hosts = collect_all_hosts
|
|
70
|
+
|
|
71
|
+
if hosts.empty?
|
|
72
|
+
return { success: false, error: "No hosts configured" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
builder = build_builder
|
|
76
|
+
builder.pussh_to_hosts(
|
|
77
|
+
image: full_image,
|
|
78
|
+
hosts: hosts,
|
|
79
|
+
user: @config[:ssh][:user]
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Build and pussh to all hosts (no registry needed)
|
|
84
|
+
# @param image_tag [String] docker image tag
|
|
85
|
+
# @param context_path [String] path to build context
|
|
86
|
+
# @return [Hash] build and pussh results
|
|
87
|
+
def build_and_pussh(image_tag:, context_path: nil)
|
|
88
|
+
# First, build the image locally
|
|
89
|
+
build_result = build(image_tag: image_tag, push: false, context_path: context_path)
|
|
90
|
+
|
|
91
|
+
unless build_result[:success]
|
|
92
|
+
return { build: build_result, pussh: nil, success: false }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Then pussh to all hosts
|
|
96
|
+
pussh_result = pussh(image_tag: image_tag)
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
build: build_result,
|
|
100
|
+
pussh: pussh_result,
|
|
101
|
+
success: pussh_result[:success]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Build and distribute image based on config
|
|
106
|
+
# Uses registry if configured, otherwise pussh to hosts
|
|
107
|
+
# @param image_tag [String] docker image tag
|
|
108
|
+
# @param context_path [String] path to build context
|
|
109
|
+
# @return [Hash] build and distribution results
|
|
110
|
+
def build_and_distribute(image_tag:, context_path: nil)
|
|
111
|
+
if uses_registry?
|
|
112
|
+
build_and_push_to_registry(image_tag: image_tag, context_path: context_path)
|
|
113
|
+
else
|
|
114
|
+
build_and_pussh(image_tag: image_tag, context_path: context_path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if config uses a registry for image distribution
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def uses_registry?
|
|
121
|
+
@config[:registry] && @config[:registry][:server]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Build and push to registry
|
|
125
|
+
# @param image_tag [String] docker image tag
|
|
126
|
+
# @param context_path [String] path to build context
|
|
127
|
+
# @return [Hash] build and push results
|
|
128
|
+
def build_and_push_to_registry(image_tag:, context_path: nil)
|
|
129
|
+
build_result = build(image_tag: image_tag, push: true, context_path: context_path)
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
build: build_result,
|
|
133
|
+
push: build_result[:pushed] ? { success: true } : { success: false },
|
|
134
|
+
success: build_result[:success] && build_result[:pushed]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Deploy all roles to their configured hosts
|
|
139
|
+
# @param image_tag [String] docker image tag
|
|
140
|
+
# @param dry_run [Boolean] if true, don't actually deploy
|
|
141
|
+
def deploy_all(image_tag:, dry_run: false)
|
|
142
|
+
results = {}
|
|
143
|
+
|
|
144
|
+
@config[:servers].each do |role, role_config|
|
|
145
|
+
hosts = resolve_hosts(role_config)
|
|
146
|
+
hosts.each do |host|
|
|
147
|
+
puts "\n=== Deploying #{role} to #{host} ==="
|
|
148
|
+
results["#{role}@#{host}"] = deploy_role(host: host, image_tag: image_tag, dry_run: dry_run, role: role)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
results
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Deploy a single role to a specific host
|
|
156
|
+
# @param host [String] target host (from config)
|
|
157
|
+
# @param image_tag [String] docker image tag (e.g., "v1.2.3")
|
|
158
|
+
# @param dry_run [Boolean] if true, don't actually deploy
|
|
159
|
+
# @param role [Symbol] server role
|
|
160
|
+
def deploy_role(host:, image_tag:, dry_run: false, role:)
|
|
161
|
+
if dry_run
|
|
162
|
+
puts "Dry run - would deploy #{@config[:image]}:#{image_tag} to #{host}"
|
|
163
|
+
puts "Service: #{@config[:service]}"
|
|
164
|
+
puts "Role: #{role}"
|
|
165
|
+
if role == WEB_ROLE
|
|
166
|
+
puts "Proxy hosts: #{@config[:proxy][:hosts].join(', ')}"
|
|
167
|
+
end
|
|
168
|
+
return { success: true, dry_run: true }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
ssh = connect_to_server(host)
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
orchestrator = build_orchestrator(ssh, role)
|
|
175
|
+
orchestrator.deploy(image_tag: image_tag, role: role)
|
|
176
|
+
ensure
|
|
177
|
+
ssh.close
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Deploy an accessory to all its configured hosts
|
|
182
|
+
# @param name [Symbol] accessory name
|
|
183
|
+
def deploy_accessory(name:)
|
|
184
|
+
acc_config = get_accessory_config(name)
|
|
185
|
+
hosts = acc_config[:hosts] || []
|
|
186
|
+
|
|
187
|
+
raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
|
|
188
|
+
|
|
189
|
+
results = {}
|
|
190
|
+
hosts.each do |host|
|
|
191
|
+
puts "Deploying accessory #{name} to #{host}..."
|
|
192
|
+
ssh = connect_to_server(host)
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
196
|
+
results[host] = orchestrator.deploy(name: name.to_sym)
|
|
197
|
+
ensure
|
|
198
|
+
ssh.close
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
results
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Remove an accessory from all its configured hosts
|
|
205
|
+
# @param name [Symbol] accessory name
|
|
206
|
+
def remove_accessory(name:)
|
|
207
|
+
acc_config = get_accessory_config(name)
|
|
208
|
+
hosts = acc_config[:hosts] || []
|
|
209
|
+
|
|
210
|
+
raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
|
|
211
|
+
|
|
212
|
+
results = {}
|
|
213
|
+
hosts.each do |host|
|
|
214
|
+
puts "Removing accessory #{name} from #{host}..."
|
|
215
|
+
ssh = connect_to_server(host)
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
219
|
+
results[host] = orchestrator.remove(name: name.to_sym)
|
|
220
|
+
ensure
|
|
221
|
+
ssh.close
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
results
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Restart an accessory on all its configured hosts
|
|
228
|
+
# @param name [Symbol] accessory name
|
|
229
|
+
def restart_accessory(name:)
|
|
230
|
+
acc_config = get_accessory_config(name)
|
|
231
|
+
hosts = acc_config[:hosts] || []
|
|
232
|
+
|
|
233
|
+
raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
|
|
234
|
+
|
|
235
|
+
results = {}
|
|
236
|
+
hosts.each do |host|
|
|
237
|
+
puts "Restarting accessory #{name} on #{host}..."
|
|
238
|
+
ssh = connect_to_server(host)
|
|
239
|
+
|
|
240
|
+
begin
|
|
241
|
+
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
242
|
+
results[host] = orchestrator.restart(name: name.to_sym)
|
|
243
|
+
ensure
|
|
244
|
+
ssh.close
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
results
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Upgrade an accessory to a new image version on all its configured hosts
|
|
251
|
+
# @param name [Symbol] accessory name
|
|
252
|
+
def upgrade_accessory(name:)
|
|
253
|
+
acc_config = get_accessory_config(name)
|
|
254
|
+
hosts = acc_config[:hosts] || []
|
|
255
|
+
|
|
256
|
+
raise Odysseus::ConfigError, "No hosts configured for accessory #{name}" if hosts.empty?
|
|
257
|
+
|
|
258
|
+
results = {}
|
|
259
|
+
hosts.each do |host|
|
|
260
|
+
puts "Upgrading accessory #{name} on #{host}..."
|
|
261
|
+
ssh = connect_to_server(host)
|
|
262
|
+
|
|
263
|
+
begin
|
|
264
|
+
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
265
|
+
results[host] = orchestrator.upgrade(name: name.to_sym)
|
|
266
|
+
ensure
|
|
267
|
+
ssh.close
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
results
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# List accessory status on all configured hosts
|
|
274
|
+
def accessory_status
|
|
275
|
+
return [] unless @config[:accessories]&.any?
|
|
276
|
+
|
|
277
|
+
all_statuses = []
|
|
278
|
+
@config[:accessories].each do |name, acc_config|
|
|
279
|
+
hosts = acc_config[:hosts] || []
|
|
280
|
+
hosts.each do |host|
|
|
281
|
+
ssh = connect_to_server(host)
|
|
282
|
+
begin
|
|
283
|
+
orchestrator = Odysseus::Orchestrator::AccessoryDeploy.new(ssh: ssh, config: @config, secrets_loader: @secrets_loader)
|
|
284
|
+
status = orchestrator.get_status(name: name.to_sym)
|
|
285
|
+
status[:host] = host
|
|
286
|
+
all_statuses << status
|
|
287
|
+
ensure
|
|
288
|
+
ssh.close
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
all_statuses
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Boot all accessories to their configured hosts
|
|
296
|
+
def boot_accessories
|
|
297
|
+
return {} unless @config[:accessories]&.any?
|
|
298
|
+
|
|
299
|
+
results = {}
|
|
300
|
+
@config[:accessories].each_key do |name|
|
|
301
|
+
puts "\n=== Booting accessory: #{name} ==="
|
|
302
|
+
results[name] = deploy_accessory(name: name)
|
|
303
|
+
end
|
|
304
|
+
results
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def get_accessory_config(name)
|
|
310
|
+
name_sym = name.to_sym
|
|
311
|
+
acc_config = @config[:accessories]&.[](name_sym)
|
|
312
|
+
raise Odysseus::ConfigError, "Accessory '#{name}' not found in config" unless acc_config
|
|
313
|
+
acc_config
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def build_orchestrator(ssh, role)
|
|
317
|
+
logger = build_logger
|
|
318
|
+
if role == WEB_ROLE
|
|
319
|
+
Odysseus::Orchestrator::WebDeploy.new(
|
|
320
|
+
ssh: ssh, config: @config, logger: logger, secrets_loader: @secrets_loader
|
|
321
|
+
)
|
|
322
|
+
else
|
|
323
|
+
Odysseus::Orchestrator::JobDeploy.new(
|
|
324
|
+
ssh: ssh, config: @config, logger: logger, secrets_loader: @secrets_loader
|
|
325
|
+
)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def build_logger
|
|
330
|
+
verbose = @verbose
|
|
331
|
+
Object.new.tap do |l|
|
|
332
|
+
l.define_singleton_method(:info) { |msg| puts msg }
|
|
333
|
+
l.define_singleton_method(:warn) { |msg| puts "[WARN] #{msg}" }
|
|
334
|
+
l.define_singleton_method(:error) { |msg| puts "[ERROR] #{msg}" }
|
|
335
|
+
l.define_singleton_method(:debug) { |msg| puts " > #{msg}" if verbose }
|
|
336
|
+
l.define_singleton_method(:verbose?) { verbose }
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def connect_to_server(server)
|
|
341
|
+
Odysseus::Deployer::SSH.new(
|
|
342
|
+
host: server,
|
|
343
|
+
user: @config[:ssh][:user],
|
|
344
|
+
keys: @config[:ssh][:keys],
|
|
345
|
+
use_tailscale: true,
|
|
346
|
+
verbose: @verbose
|
|
347
|
+
)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def build_builder
|
|
351
|
+
Odysseus::Builder::Client.new(
|
|
352
|
+
config: @config[:builder],
|
|
353
|
+
ssh_config: @config[:ssh],
|
|
354
|
+
logger: build_logger,
|
|
355
|
+
verbose: @verbose
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def resolve_build_context
|
|
360
|
+
builder_config = @config[:builder]
|
|
361
|
+
context = builder_config[:context] || '.'
|
|
362
|
+
|
|
363
|
+
if context.start_with?('/')
|
|
364
|
+
context
|
|
365
|
+
else
|
|
366
|
+
File.join(@config_dir, context)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def collect_all_hosts
|
|
371
|
+
hosts = []
|
|
372
|
+
|
|
373
|
+
@config[:servers].each do |_role, role_config|
|
|
374
|
+
role_hosts = resolve_hosts(role_config)
|
|
375
|
+
hosts.concat(role_hosts)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
hosts.uniq
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Resolve hosts for a role using the appropriate provider
|
|
382
|
+
# @param role_config [Hash] role configuration
|
|
383
|
+
# @return [Array<String>] list of hosts
|
|
384
|
+
def resolve_hosts(role_config)
|
|
385
|
+
Odysseus::HostProviders.resolve(role_config)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# lib/odysseus/deployer/ssh.rb
|
|
2
|
+
|
|
3
|
+
require 'net/ssh'
|
|
4
|
+
require 'net/scp'
|
|
5
|
+
|
|
6
|
+
module Odysseus
|
|
7
|
+
module Deployer
|
|
8
|
+
class SSH
|
|
9
|
+
# @param host [String] hostname or IP
|
|
10
|
+
# @param user [String] SSH username (default: root)
|
|
11
|
+
# @param port [Integer] SSH port (default: 22)
|
|
12
|
+
# @param keys [Array<String>] SSH key paths
|
|
13
|
+
# @param use_tailscale [Boolean] if true, assume Tailscale hostname
|
|
14
|
+
# @param verbose [Boolean] log commands being executed
|
|
15
|
+
def initialize(host:, user: 'root', port: 22, keys: [], use_tailscale: true, verbose: false)
|
|
16
|
+
@host = host
|
|
17
|
+
@user = user
|
|
18
|
+
@port = port
|
|
19
|
+
@keys = keys.map { |k| File.expand_path(k) }
|
|
20
|
+
@use_tailscale = use_tailscale
|
|
21
|
+
@verbose = verbose
|
|
22
|
+
@session = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute remote command
|
|
26
|
+
# @param command [String] command to execute
|
|
27
|
+
# @return [String] command output
|
|
28
|
+
# @raise [Odysseus::SSHError] if command fails
|
|
29
|
+
def execute(command)
|
|
30
|
+
puts " > #{command}" if @verbose
|
|
31
|
+
with_connection do |session|
|
|
32
|
+
output = ""
|
|
33
|
+
session.open_channel do |channel|
|
|
34
|
+
channel.exec(command) do |ch, success|
|
|
35
|
+
raise Odysseus::SSHCommandError, "Failed to execute: #{command}" unless success
|
|
36
|
+
|
|
37
|
+
channel.on_data { |_, data| output += data }
|
|
38
|
+
channel.on_extended_data { |_, _, data| output += data }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
session.loop
|
|
42
|
+
output
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Upload file to remote server
|
|
47
|
+
# @param local_path [String] local file path
|
|
48
|
+
# @param remote_path [String] remote file path
|
|
49
|
+
def upload(local_path, remote_path)
|
|
50
|
+
with_connection do |session|
|
|
51
|
+
session.scp.upload!(local_path, remote_path, recursive: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Download file from remote server
|
|
56
|
+
# @param remote_path [String] remote file path
|
|
57
|
+
# @param local_path [String] local file path
|
|
58
|
+
def download(remote_path, local_path)
|
|
59
|
+
with_connection do |session|
|
|
60
|
+
session.scp.download!(remote_path, local_path, recursive: true)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Upload string content to remote file
|
|
65
|
+
# @param content [String] content to write
|
|
66
|
+
# @param remote_path [String] remote file path
|
|
67
|
+
def upload_string(content, remote_path)
|
|
68
|
+
with_connection do |session|
|
|
69
|
+
session.scp.upload!(StringIO.new(content), remote_path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Stream command output (for long-running commands like logs --follow)
|
|
74
|
+
# @param command [String] command to execute
|
|
75
|
+
# @yield [String] yields each line of output
|
|
76
|
+
def stream(command, &block)
|
|
77
|
+
with_connection do |session|
|
|
78
|
+
session.open_channel do |channel|
|
|
79
|
+
channel.exec(command) do |ch, success|
|
|
80
|
+
raise Odysseus::SSHCommandError, "Failed to execute: #{command}" unless success
|
|
81
|
+
|
|
82
|
+
channel.on_data do |_, data|
|
|
83
|
+
data.each_line { |line| block.call(line) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
channel.on_extended_data do |_, _, data|
|
|
87
|
+
data.each_line { |line| block.call(line) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
session.loop
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if connected
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
def connected?
|
|
98
|
+
@session&.closed? == false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Close connection
|
|
102
|
+
def close
|
|
103
|
+
@session&.close
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def with_connection
|
|
109
|
+
connect unless connected?
|
|
110
|
+
yield(@session)
|
|
111
|
+
rescue Errno::ECONNREFUSED => e
|
|
112
|
+
raise Odysseus::SSHConnectionError, "Connection refused to #{@host}. Is the server running and accepting SSH connections?"
|
|
113
|
+
rescue SocketError => e
|
|
114
|
+
raise Odysseus::SSHConnectionError, "Could not resolve hostname '#{@host}'. Check your DNS or /etc/hosts."
|
|
115
|
+
rescue Net::SSH::AuthenticationFailed => e
|
|
116
|
+
raise Odysseus::SSHConnectionError, "SSH authentication failed for #{@user}@#{@host}. Check your SSH keys."
|
|
117
|
+
rescue Errno::ETIMEDOUT, Net::SSH::ConnectionTimeout, Errno::EHOSTUNREACH => e
|
|
118
|
+
error_msg = "Connection to #{@host} timed out."
|
|
119
|
+
if @use_tailscale
|
|
120
|
+
error_msg += "\n\nThis looks like a Tailscale hostname. Please check:\n"
|
|
121
|
+
error_msg += " 1. Tailscale is running: tailscale status\n"
|
|
122
|
+
error_msg += " 2. You're authenticated: tailscale login\n"
|
|
123
|
+
error_msg += " 3. The host is online: tailscale ping #{@host}"
|
|
124
|
+
end
|
|
125
|
+
raise Odysseus::SSHConnectionError, error_msg
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def connect
|
|
129
|
+
puts "Connecting to #{@user}@#{@host}:#{@port}..." if @verbose
|
|
130
|
+
options = {
|
|
131
|
+
port: @port,
|
|
132
|
+
non_interactive: true,
|
|
133
|
+
verify_host_key: :never,
|
|
134
|
+
timeout: 10 # Connection timeout in seconds
|
|
135
|
+
}
|
|
136
|
+
options[:keys] = @keys if @keys.any?
|
|
137
|
+
|
|
138
|
+
@session = Net::SSH.start(@host, @user, options)
|
|
139
|
+
puts "Connected to #{@host}" if @verbose
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|