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,309 @@
1
+ # lib/odysseus/orchestrator/accessory_deploy.rb
2
+
3
+ module Odysseus
4
+ module Orchestrator
5
+ class AccessoryDeploy
6
+ # @param ssh [Odysseus::Deployer::SSH] SSH connection
7
+ # @param config [Hash] parsed deploy config
8
+ # @param secrets_loader [Odysseus::Secrets::Loader] secrets loader (optional)
9
+ # @param logger [Object] logger (optional)
10
+ def initialize(ssh:, config:, secrets_loader: nil, logger: nil)
11
+ @ssh = ssh
12
+ @config = config
13
+ @secrets_loader = secrets_loader
14
+ @logger = logger || default_logger
15
+ @docker = Odysseus::Docker::Client.new(ssh)
16
+ @caddy = Odysseus::Caddy::Client.new(ssh: ssh, docker: @docker)
17
+ end
18
+
19
+ # Deploy/ensure an accessory is running
20
+ # @param name [Symbol] accessory name
21
+ # @return [Hash] deploy result
22
+ def deploy(name:)
23
+ accessory_config = @config[:accessories][name]
24
+ raise Odysseus::ConfigError, "Accessory '#{name}' not found in config" unless accessory_config
25
+
26
+ service_name = accessory_name(name)
27
+ image = accessory_config[:image]
28
+
29
+ log "Deploying accessory: #{service_name}"
30
+
31
+ # Check if accessory is already running
32
+ existing = @docker.list(service: service_name)
33
+ if existing.any? { |c| c['State'] == 'running' }
34
+ log "Accessory #{service_name} is already running"
35
+ return { success: true, already_running: true, service: service_name }
36
+ end
37
+
38
+ # Start the accessory
39
+ log "Starting #{service_name}..."
40
+ container_id = start_accessory(name: name, config: accessory_config)
41
+ log "Started container: #{container_id[0..11]}"
42
+
43
+ # Wait for healthy if healthcheck configured
44
+ if accessory_config[:healthcheck]
45
+ log "Waiting for container to be healthy..."
46
+ unless @docker.wait_healthy(container_id, timeout: 120)
47
+ @docker.stop(container_id)
48
+ @docker.remove(container_id, force: true)
49
+ raise Odysseus::DeployError, "Accessory failed health checks"
50
+ end
51
+ log "Container is healthy!"
52
+ else
53
+ sleep 3
54
+ unless @docker.running?(container_id)
55
+ raise Odysseus::DeployError, "Accessory failed to start"
56
+ end
57
+ end
58
+
59
+ # Add to Caddy if proxy config is present
60
+ if accessory_config[:proxy]
61
+ log "Configuring proxy..."
62
+ add_to_caddy(name: name, container_id: container_id, config: accessory_config)
63
+ end
64
+
65
+ log "Accessory #{service_name} deployed!"
66
+
67
+ {
68
+ success: true,
69
+ container_id: container_id,
70
+ service: service_name,
71
+ image: image
72
+ }
73
+ rescue StandardError => e
74
+ log "Accessory deploy failed: #{e.message}", :error
75
+ raise
76
+ end
77
+
78
+ # Stop and remove an accessory
79
+ # @param name [Symbol] accessory name
80
+ def remove(name:)
81
+ accessory_config = @config[:accessories][name]
82
+ raise Odysseus::ConfigError, "Accessory '#{name}' not found in config" unless accessory_config
83
+
84
+ service_name = accessory_name(name)
85
+ log "Removing accessory: #{service_name}"
86
+
87
+ # Remove from Caddy if proxy configured
88
+ if accessory_config[:proxy]
89
+ containers = @docker.list(service: service_name)
90
+ containers.each do |c|
91
+ container_name = c['Names'].delete_prefix('/')
92
+ port = accessory_config[:proxy][:app_port]
93
+ @caddy.drain_upstream(service: service_name, upstream: "#{container_name}:#{port}")
94
+ end
95
+ end
96
+
97
+ # Stop and remove containers
98
+ containers = @docker.list(service: service_name, all: true)
99
+ containers.each do |c|
100
+ @docker.stop(c['ID']) if c['State'] == 'running'
101
+ @docker.remove(c['ID'], force: true)
102
+ end
103
+
104
+ log "Accessory #{service_name} removed!"
105
+ { success: true, service: service_name }
106
+ end
107
+
108
+ # Restart an accessory (remove and redeploy)
109
+ # @param name [Symbol] accessory name
110
+ def restart(name:)
111
+ remove(name: name)
112
+ deploy(name: name)
113
+ end
114
+
115
+ # Upgrade an accessory to a new image version (preserves volumes)
116
+ # @param name [Symbol] accessory name
117
+ # @return [Hash] upgrade result
118
+ def upgrade(name:)
119
+ accessory_config = @config[:accessories][name]
120
+ raise Odysseus::ConfigError, "Accessory '#{name}' not found in config" unless accessory_config
121
+
122
+ service_name = accessory_name(name)
123
+ image = accessory_config[:image]
124
+
125
+ log "Upgrading accessory: #{service_name} to #{image}"
126
+
127
+ # Pull the new image first (before stopping anything)
128
+ log "Pulling new image: #{image}..."
129
+ @docker.pull(image)
130
+ log "Image pulled successfully"
131
+
132
+ # Check for existing container
133
+ existing = @docker.list(service: service_name, all: true)
134
+ old_container = existing.first
135
+
136
+ # Remove from Caddy if proxy configured (before stopping)
137
+ if accessory_config[:proxy] && old_container && old_container['State'] == 'running'
138
+ container_name = old_container['Names'].delete_prefix('/')
139
+ port = accessory_config[:proxy][:app_port]
140
+ log "Removing from proxy..."
141
+ @caddy.drain_upstream(service: service_name, upstream: "#{container_name}:#{port}")
142
+ end
143
+
144
+ # Stop and remove old container if exists
145
+ if old_container
146
+ log "Stopping old container: #{old_container['ID'][0..11]}..."
147
+ @docker.stop(old_container['ID'], timeout: 30) if old_container['State'] == 'running'
148
+ @docker.remove(old_container['ID'], force: true)
149
+ log "Old container removed"
150
+ end
151
+
152
+ # Start the accessory with the new image (volumes are preserved on host)
153
+ log "Starting new container with #{image}..."
154
+ container_id = start_accessory(name: name, config: accessory_config)
155
+ log "Started container: #{container_id[0..11]}"
156
+
157
+ # Wait for healthy if healthcheck configured
158
+ if accessory_config[:healthcheck]
159
+ log "Waiting for container to be healthy..."
160
+ unless @docker.wait_healthy(container_id, timeout: 120)
161
+ @docker.stop(container_id)
162
+ @docker.remove(container_id, force: true)
163
+ raise Odysseus::DeployError, "Accessory failed health checks after upgrade"
164
+ end
165
+ log "Container is healthy!"
166
+ else
167
+ sleep 3
168
+ unless @docker.running?(container_id)
169
+ raise Odysseus::DeployError, "Accessory failed to start after upgrade"
170
+ end
171
+ end
172
+
173
+ # Add to Caddy if proxy config is present
174
+ if accessory_config[:proxy]
175
+ log "Configuring proxy..."
176
+ add_to_caddy(name: name, container_id: container_id, config: accessory_config)
177
+ end
178
+
179
+ log "Accessory #{service_name} upgraded to #{image}!"
180
+
181
+ {
182
+ success: true,
183
+ container_id: container_id,
184
+ service: service_name,
185
+ image: image,
186
+ upgraded: true
187
+ }
188
+ rescue StandardError => e
189
+ log "Accessory upgrade failed: #{e.message}", :error
190
+ raise
191
+ end
192
+
193
+ # List status of all accessories
194
+ # @return [Array<Hash>] accessory statuses
195
+ def list_status
196
+ return [] unless @config[:accessories]
197
+
198
+ @config[:accessories].map do |name, config|
199
+ service_name = accessory_name(name)
200
+ containers = @docker.list(service: service_name, all: true)
201
+ running = containers.find { |c| c['State'] == 'running' }
202
+
203
+ {
204
+ name: name,
205
+ service: service_name,
206
+ image: config[:image],
207
+ running: !running.nil?,
208
+ container_id: running&.dig('ID'),
209
+ has_proxy: !config[:proxy].nil?
210
+ }
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def accessory_name(name)
217
+ "#{@config[:service]}-#{name}"
218
+ end
219
+
220
+ def start_accessory(name:, config:)
221
+ service_name = accessory_name(name)
222
+
223
+ @docker.run(
224
+ name: service_name,
225
+ image: config[:image],
226
+ options: {
227
+ service: service_name,
228
+ env: build_environment(config[:env]),
229
+ ports: config[:ports],
230
+ volumes: config[:volumes],
231
+ network: 'odysseus',
232
+ restart: 'unless-stopped',
233
+ healthcheck: build_healthcheck(config[:healthcheck]),
234
+ cmd: config[:cmd]
235
+ }
236
+ )
237
+ end
238
+
239
+ def build_environment(env_config)
240
+ return {} unless env_config
241
+
242
+ env = {}
243
+
244
+ # Clear env vars (hardcoded values)
245
+ env_config[:clear]&.each do |key, value|
246
+ env[key.to_s] = value.to_s
247
+ end
248
+
249
+ # Secret env vars - first try encrypted file, then server environment
250
+ env_config[:secret]&.each do |key|
251
+ # Try encrypted secrets file first
252
+ if @secrets_loader&.configured?
253
+ value = @secrets_loader.get(key)
254
+ if value
255
+ env[key.to_s] = value.to_s
256
+ next
257
+ end
258
+ end
259
+
260
+ # Fall back to server's environment
261
+ value = @ssh.execute("echo $#{key}").strip
262
+ env[key.to_s] = value unless value.empty?
263
+ end
264
+
265
+ env
266
+ end
267
+
268
+ def build_healthcheck(hc_config)
269
+ return nil unless hc_config
270
+
271
+ {
272
+ cmd: hc_config[:cmd],
273
+ interval: hc_config[:interval] || 30,
274
+ timeout: hc_config[:timeout] || 10,
275
+ retries: hc_config[:retries] || 3
276
+ }
277
+ end
278
+
279
+ def add_to_caddy(name:, container_id:, config:)
280
+ container_info = @ssh.execute("docker inspect --format '{{.Name}}' #{container_id}").strip
281
+ container_name = container_info.delete_prefix('/')
282
+
283
+ proxy_config = config[:proxy]
284
+ port = proxy_config[:app_port]
285
+ upstream = "#{container_name}:#{port}"
286
+
287
+ @caddy.add_upstream(
288
+ service: accessory_name(name),
289
+ hosts: proxy_config[:hosts],
290
+ upstream: upstream,
291
+ ssl: proxy_config[:ssl],
292
+ ssl_email: proxy_config[:ssl_email]
293
+ )
294
+ end
295
+
296
+ def default_logger
297
+ @default_logger ||= Object.new.tap do |l|
298
+ def l.info(msg); puts msg; end
299
+ def l.warn(msg); puts "[WARN] #{msg}"; end
300
+ def l.error(msg); puts "[ERROR] #{msg}"; end
301
+ end
302
+ end
303
+
304
+ def log(message, level = :info)
305
+ @logger.send(level, message)
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,176 @@
1
+ # lib/odysseus/orchestrator/job_deploy.rb
2
+
3
+ module Odysseus
4
+ module Orchestrator
5
+ class JobDeploy
6
+ # @param ssh [Odysseus::Deployer::SSH] SSH connection
7
+ # @param config [Hash] parsed deploy config
8
+ # @param logger [Object] logger (optional)
9
+ # @param secrets_loader [Odysseus::Secrets::Loader] secrets loader (optional)
10
+ def initialize(ssh:, config:, logger: nil, secrets_loader: nil)
11
+ @ssh = ssh
12
+ @config = config
13
+ @logger = logger || default_logger
14
+ @secrets_loader = secrets_loader
15
+ @docker = Odysseus::Docker::Client.new(ssh)
16
+ end
17
+
18
+ # Execute deploy for a job/worker service
19
+ # @param image_tag [String] image tag to deploy
20
+ # @param role [Symbol] server role (e.g., :jobs, :worker)
21
+ # @return [Hash] deploy result
22
+ def deploy(image_tag:, role:)
23
+ service = @config[:service]
24
+ role_name = "#{service}-#{role}"
25
+ image = "#{@config[:image]}:#{image_tag}"
26
+
27
+ log "Starting deploy of #{role_name} with #{image}"
28
+
29
+ # Step 1: Find existing containers for this role
30
+ log "Checking for existing containers..."
31
+ old_containers = @docker.list(service: role_name)
32
+ log "Found #{old_containers.size} existing container(s)"
33
+
34
+ # Step 2: Start new container
35
+ log "Starting new container..."
36
+ new_container_id = start_new_container(image: image, role: role)
37
+ log "Started container: #{new_container_id[0..11]}"
38
+
39
+ # Step 3: Wait for healthy (if healthcheck configured)
40
+ server_config = @config[:servers][role] || {}
41
+ if server_config[:healthcheck]
42
+ log "Waiting for container to be healthy..."
43
+ unless wait_for_healthy(new_container_id)
44
+ handle_failed_deploy(new_container_id)
45
+ raise Odysseus::DeployError, "Container failed health checks"
46
+ end
47
+ log "Container is healthy!"
48
+ else
49
+ # No healthcheck - just wait a few seconds for startup
50
+ log "No healthcheck configured, waiting for startup..."
51
+ sleep 5
52
+ unless @docker.running?(new_container_id)
53
+ handle_failed_deploy(new_container_id)
54
+ raise Odysseus::DeployError, "Container failed to start"
55
+ end
56
+ end
57
+
58
+ # Step 4: Stop old containers gracefully
59
+ old_containers.each do |old|
60
+ log "Stopping old container: #{old['ID'][0..11]}..."
61
+ graceful_stop(old['ID'])
62
+ end
63
+
64
+ # Step 5: Cleanup old stopped containers
65
+ log "Cleaning up old containers..."
66
+ @docker.cleanup_old_containers(service: role_name, keep: 2)
67
+
68
+ {
69
+ success: true,
70
+ container_id: new_container_id,
71
+ service: role_name,
72
+ image: image
73
+ }
74
+ rescue StandardError => e
75
+ log "Deploy failed: #{e.message}", :error
76
+ raise
77
+ end
78
+
79
+ private
80
+
81
+ def start_new_container(image:, role:)
82
+ service = @config[:service]
83
+ role_name = "#{service}-#{role}"
84
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
85
+ container_name = "#{role_name}-#{timestamp}"
86
+
87
+ server_config = @config[:servers][role] || {}
88
+ options = server_config[:options] || {}
89
+
90
+ @docker.run(
91
+ name: container_name,
92
+ image: image,
93
+ options: {
94
+ service: role_name,
95
+ version: timestamp,
96
+ env: build_environment,
97
+ memory: options[:memory],
98
+ memory_reservation: options[:memory_reservation],
99
+ cpus: options[:cpus],
100
+ cpu_shares: options[:cpu_shares],
101
+ network: 'odysseus',
102
+ healthcheck: build_healthcheck(server_config[:healthcheck]),
103
+ cmd: server_config[:cmd]
104
+ }
105
+ )
106
+ end
107
+
108
+ def build_environment
109
+ env = {}
110
+
111
+ # Clear env vars (hardcoded values)
112
+ @config[:env][:clear]&.each do |key, value|
113
+ env[key.to_s] = value.to_s
114
+ end
115
+
116
+ # Secret env vars (from encrypted file or server environment)
117
+ @config[:env][:secret]&.each do |key|
118
+ # Try encrypted secrets file first
119
+ if @secrets_loader&.configured?
120
+ value = @secrets_loader.get(key)
121
+ if value
122
+ env[key.to_s] = value.to_s
123
+ next
124
+ end
125
+ end
126
+
127
+ # Fall back to server's environment
128
+ value = @ssh.execute("echo $#{key}").strip
129
+ env[key.to_s] = value unless value.empty?
130
+ end
131
+
132
+ env
133
+ end
134
+
135
+ def build_healthcheck(hc_config)
136
+ return nil unless hc_config
137
+
138
+ {
139
+ cmd: hc_config[:cmd],
140
+ interval: hc_config[:interval] || 30,
141
+ timeout: hc_config[:timeout] || 10,
142
+ retries: hc_config[:retries] || 3
143
+ }
144
+ end
145
+
146
+ def wait_for_healthy(container_id, timeout: 120)
147
+ @docker.wait_healthy(container_id, timeout: timeout)
148
+ end
149
+
150
+ def graceful_stop(container_id)
151
+ # Give workers time to finish current job (30 second timeout)
152
+ @docker.stop(container_id, timeout: 30)
153
+ @docker.remove(container_id)
154
+ end
155
+
156
+ def handle_failed_deploy(new_container_id)
157
+ log "Rolling back failed deploy...", :warn
158
+ @docker.stop(new_container_id)
159
+ @docker.remove(new_container_id, force: true)
160
+ log "Rollback complete"
161
+ end
162
+
163
+ def default_logger
164
+ @default_logger ||= Object.new.tap do |l|
165
+ def l.info(msg); puts msg; end
166
+ def l.warn(msg); puts "[WARN] #{msg}"; end
167
+ def l.error(msg); puts "[ERROR] #{msg}"; end
168
+ end
169
+ end
170
+
171
+ def log(message, level = :info)
172
+ @logger.send(level, message)
173
+ end
174
+ end
175
+ end
176
+ end