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.
@@ -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