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,338 @@
1
+ # lib/odysseus/caddy/client.rb
2
+
3
+ require 'json'
4
+
5
+ module Odysseus
6
+ module Caddy
7
+ class Client
8
+ ADMIN_API_PORT = 2019
9
+ CONTAINER_NAME = 'odysseus-caddy'
10
+ CADDY_IMAGE = 'caddy:2-alpine'
11
+
12
+ # @param ssh [Odysseus::Deployer::SSH] SSH connection to server
13
+ # @param docker [Odysseus::Docker::Client] Docker client
14
+ def initialize(ssh:, docker:)
15
+ @ssh = ssh
16
+ @docker = docker
17
+ end
18
+
19
+ # Ensure Caddy is running
20
+ # @return [Boolean] true if caddy is running
21
+ def ensure_running
22
+ return true if running?
23
+
24
+ start_caddy
25
+ running?
26
+ end
27
+
28
+ # Check if Caddy container is running
29
+ # @return [Boolean]
30
+ def running?
31
+ @docker.running?(CONTAINER_NAME)
32
+ end
33
+
34
+ # Start Caddy container
35
+ def start_caddy
36
+ # Create network if not exists (with label to protect from prune)
37
+ @ssh.execute("docker network create --label odysseus.managed=true odysseus 2>/dev/null || true")
38
+
39
+ # Create data directory for certificates
40
+ @ssh.execute("mkdir -p /var/lib/odysseus/caddy")
41
+
42
+ # Run Caddy with admin API enabled and persistent storage for certs
43
+ @docker.run(
44
+ name: CONTAINER_NAME,
45
+ image: CADDY_IMAGE,
46
+ options: {
47
+ service: 'odysseus-proxy',
48
+ ports: ['80:80', '443:443', "#{ADMIN_API_PORT}:#{ADMIN_API_PORT}"],
49
+ network: 'odysseus',
50
+ restart: 'unless-stopped',
51
+ volumes: ['/var/lib/odysseus/caddy:/data'],
52
+ env: {
53
+ 'CADDY_ADMIN' => "0.0.0.0:#{ADMIN_API_PORT}"
54
+ },
55
+ labels: {
56
+ 'odysseus.managed' => 'true'
57
+ }
58
+ }
59
+ )
60
+
61
+ # Wait for Caddy to be ready
62
+ sleep 2
63
+ end
64
+
65
+ # Add an upstream server to a route
66
+ # @param service [String] service name (used as route identifier)
67
+ # @param hosts [Array<String>] domain hosts for this service
68
+ # @param upstream [String] upstream address (container:port)
69
+ # @param healthcheck [Hash] healthcheck config (optional)
70
+ # @param ssl [Boolean] enable automatic HTTPS (default: true)
71
+ # @param ssl_email [String] email for Let's Encrypt registration
72
+ def add_upstream(service:, hosts:, upstream:, healthcheck: nil, ssl: true, ssl_email: nil)
73
+ # Enable TLS for these hosts if ssl is enabled
74
+ enable_tls_for_hosts(hosts, email: ssl_email) if ssl
75
+
76
+ # Check if route already exists for this service
77
+ routes = api_request('GET', "/config/apps/http/servers/srv0/routes") || []
78
+ existing_idx = routes.find_index { |r| r['@id'] == "route-#{service}" }
79
+
80
+ if existing_idx
81
+ # Update existing route's upstreams
82
+ current_upstreams = routes[existing_idx].dig('handle', 0, 'upstreams') || []
83
+ unless current_upstreams.any? { |u| u['dial'] == upstream }
84
+ current_upstreams << { 'dial' => upstream }
85
+ api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{existing_idx}/handle/0/upstreams", current_upstreams)
86
+ end
87
+ else
88
+ # Create new route - prepend at index 0 so it matches before default routes
89
+ config = build_route_config(
90
+ service: service,
91
+ hosts: hosts,
92
+ upstreams: [upstream],
93
+ healthcheck: healthcheck
94
+ )
95
+ api_request('PUT', "/config/apps/http/servers/srv0/routes/0", config)
96
+ end
97
+ end
98
+
99
+ # Remove an upstream from a service (or entire route if upstream is nil)
100
+ # @param service [String] service name
101
+ # @param upstream [String, nil] upstream to remove, or nil to remove entire route
102
+ def remove_upstream(service:, upstream:)
103
+ # Get current config
104
+ routes = api_request('GET', "/config/apps/http/servers/srv0/routes")
105
+ return unless routes
106
+
107
+ # Find route for this service and remove the upstream
108
+ routes.each_with_index do |route, idx|
109
+ next unless route.dig('@id') == "route-#{service}"
110
+
111
+ # If no specific upstream, remove the entire route
112
+ if upstream.nil?
113
+ api_request('DELETE', "/config/apps/http/servers/srv0/routes/#{idx}")
114
+ break
115
+ end
116
+
117
+ upstreams = route.dig('handle', 0, 'upstreams') || []
118
+ upstreams.reject! { |u| u['dial'] == upstream }
119
+
120
+ if upstreams.empty?
121
+ # Remove entire route if no upstreams left
122
+ api_request('DELETE', "/config/apps/http/servers/srv0/routes/#{idx}")
123
+ else
124
+ # Update route with remaining upstreams
125
+ api_request('PATCH', "/config/apps/http/servers/srv0/routes/#{idx}/handle/0/upstreams", upstreams)
126
+ end
127
+
128
+ break
129
+ end
130
+ end
131
+
132
+ # Drain connections from an upstream (mark as down)
133
+ # @param service [String] service name
134
+ # @param upstream [String] upstream to drain
135
+ def drain_upstream(service:, upstream:)
136
+ # Caddy doesn't have built-in drain, so we set health to down
137
+ # This will stop new connections from being sent to this upstream
138
+ # The upstream will be removed after existing connections close
139
+
140
+ # For now, we just remove it - Caddy will gracefully close existing connections
141
+ remove_upstream(service: service, upstream: upstream)
142
+ end
143
+
144
+ # Remove stale upstreams that point to stopped/non-existent containers
145
+ # @param service [String] service name
146
+ # @return [Array<String>] list of removed upstreams
147
+ def cleanup_stale_upstreams(service:)
148
+ routes = api_request('GET', '/config/apps/http/servers/srv0/routes') || []
149
+ route_idx = routes.find_index { |r| r['@id'] == "route-#{service}" }
150
+ return [] unless route_idx
151
+
152
+ upstreams = routes[route_idx].dig('handle', 0, 'upstreams') || []
153
+ removed = []
154
+
155
+ upstreams.each do |upstream|
156
+ dial = upstream['dial']
157
+ # Extract container name from upstream (format: container_name:port)
158
+ container_name = dial.split(':').first
159
+ next if container_name.nil? || container_name.empty?
160
+
161
+ # Check if container is running
162
+ unless @docker.running?(container_name)
163
+ remove_upstream(service: service, upstream: dial)
164
+ removed << dial
165
+ end
166
+ end
167
+
168
+ removed
169
+ end
170
+
171
+ # Get current Caddy config
172
+ # @return [Hash] current config
173
+ def config
174
+ api_request('GET', '/config/')
175
+ end
176
+
177
+ # List all configured services/routes
178
+ # @return [Array<Hash>] list of services with their config
179
+ def list_services
180
+ routes = api_request('GET', '/config/apps/http/servers/srv0/routes') || []
181
+
182
+ routes.map do |route|
183
+ id = route['@id'] || 'unknown'
184
+ service_name = id.sub(/^route-/, '')
185
+ hosts = route.dig('match', 0, 'host') || []
186
+ upstreams = route.dig('handle', 0, 'upstreams') || []
187
+
188
+ {
189
+ service: service_name,
190
+ hosts: hosts,
191
+ upstreams: upstreams.map { |u| u['dial'] },
192
+ has_healthcheck: route.dig('handle', 0, 'health_checks').nil? ? false : true
193
+ }
194
+ end
195
+ end
196
+
197
+ # Get TLS/SSL status for domains
198
+ # @return [Hash] TLS configuration info
199
+ def tls_status
200
+ tls_config = api_request('GET', '/config/apps/tls') || {}
201
+ policies = tls_config.dig('automation', 'policies') || []
202
+
203
+ {
204
+ enabled: !policies.empty?,
205
+ policies: policies.map do |policy|
206
+ {
207
+ subjects: policy['subjects'] || [],
208
+ issuer: policy.dig('issuers', 0, 'module') || 'unknown',
209
+ email: policy.dig('issuers', 0, 'email')
210
+ }
211
+ end
212
+ }
213
+ end
214
+
215
+ # Get server listen addresses
216
+ # @return [Array<String>] listen addresses
217
+ def listen_addresses
218
+ servers = api_request('GET', '/config/apps/http/servers') || {}
219
+ servers.dig('srv0', 'listen') || []
220
+ end
221
+
222
+ # Print formatted status (for CLI use)
223
+ # @return [Hash] full status summary
224
+ def status
225
+ {
226
+ running: running?,
227
+ listen: listen_addresses,
228
+ services: list_services,
229
+ tls: tls_status
230
+ }
231
+ end
232
+
233
+ # Enable TLS/HTTPS for hosts
234
+ # @param hosts [Array<String>] domain hosts
235
+ # @param email [String] email for Let's Encrypt
236
+ def enable_tls_for_hosts(hosts, email: nil)
237
+ # Get existing TLS config to merge with
238
+ existing_tls = api_request('GET', '/config/apps/tls') || {}
239
+ existing_policies = existing_tls.dig('automation', 'policies') || []
240
+
241
+ # Collect all existing subjects
242
+ all_subjects = existing_policies.flat_map { |p| p['subjects'] || [] }
243
+
244
+ # Add new hosts (avoid duplicates)
245
+ hosts.each do |host|
246
+ all_subjects << host unless all_subjects.include?(host)
247
+ end
248
+
249
+ # Build issuer config
250
+ issuer = { 'module' => 'acme' }
251
+ issuer['email'] = email if email
252
+
253
+ # Configure TLS automation with Let's Encrypt (single policy for all domains)
254
+ tls_config = {
255
+ 'automation' => {
256
+ 'policies' => [
257
+ {
258
+ 'subjects' => all_subjects,
259
+ 'issuers' => [issuer]
260
+ }
261
+ ]
262
+ }
263
+ }
264
+
265
+ # Use PUT to create/replace TLS config
266
+ api_request('PUT', '/config/apps/tls', tls_config)
267
+
268
+ # Ensure HTTPS server exists and listens on 443
269
+ ensure_https_server
270
+ end
271
+
272
+ private
273
+
274
+ def ensure_https_server
275
+ # Check if we have an HTTPS server configured
276
+ servers = api_request('GET', '/config/apps/http/servers') || {}
277
+
278
+ unless servers['srv0']&.dig('listen')&.include?(':443')
279
+ # Add :443 to listen addresses
280
+ current_listen = servers.dig('srv0', 'listen') || [':80']
281
+ unless current_listen.include?(':443')
282
+ current_listen << ':443'
283
+ api_request('PATCH', '/config/apps/http/servers/srv0/listen', current_listen)
284
+ end
285
+ end
286
+ end
287
+
288
+ def build_route_config(service:, hosts:, upstreams:, healthcheck: nil)
289
+ route = {
290
+ '@id' => "route-#{service}",
291
+ 'match' => [{ 'host' => hosts }],
292
+ 'handle' => [
293
+ {
294
+ 'handler' => 'reverse_proxy',
295
+ 'upstreams' => upstreams.map { |u| { 'dial' => u } }
296
+ }
297
+ ]
298
+ }
299
+
300
+ # Add health checks if configured
301
+ if healthcheck
302
+ active_check = {
303
+ 'uri' => healthcheck[:path] || '/health',
304
+ 'interval' => "#{healthcheck[:interval] || 10}s",
305
+ 'timeout' => "#{healthcheck[:timeout] || 5}s"
306
+ }
307
+
308
+ # Add expected status code if specified (e.g., 200, 301, or 2 for 2xx)
309
+ if healthcheck[:expect_status]
310
+ status = healthcheck[:expect_status].to_s
311
+ # Caddy expects just the first digit for ranges like "2xx"
312
+ expect_value = status.end_with?('xx') ? status[0].to_i : status.to_i
313
+ active_check['expect_status'] = expect_value
314
+ end
315
+
316
+ route['handle'][0]['health_checks'] = { 'active' => active_check }
317
+ end
318
+
319
+ route
320
+ end
321
+
322
+ def api_request(method, path, body = nil)
323
+ # Build curl command to hit Caddy's admin API
324
+ cmd = "curl -s -X #{method} "
325
+ cmd += "-H 'Content-Type: application/json' "
326
+ cmd += "-d '#{body.to_json}' " if body
327
+ cmd += "http://localhost:#{ADMIN_API_PORT}#{path}"
328
+
329
+ output = @ssh.execute(cmd)
330
+ return nil if output.strip.empty?
331
+
332
+ JSON.parse(output)
333
+ rescue JSON::ParserError
334
+ output
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,225 @@
1
+ # lib/odysseus/config/parser.rb
2
+
3
+ require 'yaml'
4
+
5
+ module Odysseus
6
+ module Config
7
+ class Parser
8
+ # @param config_path [String] Path to deploy.yml
9
+ def initialize(config_path)
10
+ @config_path = config_path
11
+ end
12
+
13
+ # Parse and return config hash
14
+ # @return [Hash]
15
+ # @raise [Odysseus::ConfigError] if invalid
16
+ def parse
17
+ raw_config = load_yaml
18
+ validate!(raw_config)
19
+ normalize(raw_config)
20
+ rescue Psych::SyntaxError => e
21
+ raise Odysseus::ConfigParseError, "Failed to parse YAML: #{e.message}"
22
+ end
23
+
24
+ private
25
+
26
+ # Load YAML file
27
+ def load_yaml
28
+ YAML.load_file(@config_path)
29
+ rescue Errno::ENOENT
30
+ raise Odysseus::ConfigError, "Config file not found: #{@config_path}"
31
+ end
32
+
33
+ # Validate config structure
34
+ def validate!(config)
35
+ validator = Odysseus::Validators::Config.new(config)
36
+ validator.validate!
37
+ end
38
+
39
+ # Normalize config to standard format
40
+ def normalize(config)
41
+ {
42
+ service: config['service'],
43
+ image: config['image'],
44
+ servers: parse_servers(config['servers']),
45
+ proxy: parse_proxy(config['proxy']),
46
+ env: parse_env(config['env']),
47
+ secrets_file: config['secrets_file'],
48
+ ssh: parse_ssh(config['ssh']),
49
+ accessories: parse_accessories(config['accessories']),
50
+ builder: parse_builder(config['builder']),
51
+ registry: parse_registry(config['registry'])
52
+ }
53
+ end
54
+
55
+ # Parse servers config
56
+ # @param servers [Hash] servers block from config
57
+ # @return [Hash] normalized servers config
58
+ def parse_servers(servers)
59
+ return {} unless servers
60
+
61
+ servers.each_with_object({}) do |(role, config), acc|
62
+ acc[role.to_sym] = {
63
+ hosts: config['hosts'] || [],
64
+ aws: parse_aws_config(config['aws']),
65
+ options: symbolize_keys(config['options'] || {}),
66
+ cmd: config['cmd'],
67
+ volumes: config['volumes'],
68
+ healthcheck: parse_server_healthcheck(config['healthcheck'])
69
+ }
70
+ end
71
+ end
72
+
73
+ # Parse AWS host provider config
74
+ # @param aws [Hash] aws block from server config
75
+ # @return [Hash, nil] normalized aws config or nil
76
+ def parse_aws_config(aws)
77
+ return nil unless aws
78
+
79
+ {
80
+ asg: aws['asg'],
81
+ region: aws['region'],
82
+ use_private_ip: aws['use_private_ip'] || false,
83
+ state: aws['state'] || 'InService'
84
+ }
85
+ end
86
+
87
+ # Parse server-level healthcheck (for workers/jobs)
88
+ def parse_server_healthcheck(healthcheck)
89
+ return nil unless healthcheck
90
+
91
+ {
92
+ cmd: healthcheck['cmd'],
93
+ interval: healthcheck['interval'] || 30,
94
+ timeout: healthcheck['timeout'] || 10,
95
+ retries: healthcheck['retries'] || 3
96
+ }
97
+ end
98
+
99
+ # Parse proxy (Caddy) config
100
+ def parse_proxy(proxy)
101
+ return {} unless proxy
102
+
103
+ {
104
+ ssl: proxy.key?('ssl') ? proxy['ssl'] : true,
105
+ ssl_email: proxy['ssl_email'],
106
+ hosts: proxy['hosts'] || [],
107
+ app_port: proxy['app_port'],
108
+ healthcheck: parse_healthcheck(proxy['healthcheck']),
109
+ response_timeout: proxy['response_timeout'] || 60
110
+ }
111
+ end
112
+
113
+ # Parse healthcheck config
114
+ def parse_healthcheck(healthcheck)
115
+ return {} unless healthcheck
116
+
117
+ {
118
+ interval: healthcheck['interval'] || 5,
119
+ path: healthcheck['path'] || '/',
120
+ timeout: healthcheck['timeout'] || 5,
121
+ expect_status: healthcheck['expect_status'] # e.g., 200, 301, or "2xx"
122
+ }
123
+ end
124
+
125
+ # Parse environment variables
126
+ def parse_env(env)
127
+ return { clear: {}, secret: [] } unless env
128
+
129
+ {
130
+ clear: symbolize_keys(env['clear'] || {}),
131
+ secret: env['secret'] || []
132
+ }
133
+ end
134
+
135
+ # Parse SSH config
136
+ def parse_ssh(ssh)
137
+ return { user: 'root', keys: [] } unless ssh
138
+
139
+ {
140
+ user: ssh['user'] || 'root',
141
+ keys: ssh['keys'] || []
142
+ }
143
+ end
144
+
145
+ # Parse accessories config
146
+ def parse_accessories(accessories)
147
+ return {} unless accessories
148
+
149
+ accessories.each_with_object({}) do |(name, config), acc|
150
+ acc[name.to_sym] = {
151
+ image: config['image'],
152
+ hosts: config['hosts'],
153
+ cmd: config['cmd'],
154
+ ports: config['ports'],
155
+ volumes: config['volumes'],
156
+ env: parse_env(config['env']),
157
+ healthcheck: parse_accessory_healthcheck(config['healthcheck']),
158
+ proxy: parse_accessory_proxy(config['proxy'])
159
+ }
160
+ end
161
+ end
162
+
163
+ # Parse accessory healthcheck
164
+ def parse_accessory_healthcheck(healthcheck)
165
+ return nil unless healthcheck
166
+
167
+ {
168
+ cmd: healthcheck['cmd'],
169
+ interval: healthcheck['interval'] || 30,
170
+ timeout: healthcheck['timeout'] || 10,
171
+ retries: healthcheck['retries'] || 3
172
+ }
173
+ end
174
+
175
+ # Parse accessory proxy config
176
+ def parse_accessory_proxy(proxy)
177
+ return nil unless proxy
178
+
179
+ {
180
+ hosts: proxy['hosts'] || [],
181
+ app_port: proxy['app_port'],
182
+ ssl: proxy.key?('ssl') ? proxy['ssl'] : true,
183
+ ssl_email: proxy['ssl_email']
184
+ }
185
+ end
186
+
187
+ # Parse builder config
188
+ def parse_builder(builder)
189
+ return {} unless builder
190
+
191
+ {
192
+ strategy: (builder['strategy'] || 'local').to_sym,
193
+ host: builder['host'],
194
+ dockerfile: builder['dockerfile'] || 'Dockerfile',
195
+ context: builder['context'] || '.',
196
+ arch: builder['arch'],
197
+ platforms: builder['platforms'] || [],
198
+ build_args: symbolize_keys(builder['build_args'] || {}),
199
+ cache: builder.key?('cache') ? builder['cache'] : true,
200
+ push: builder['push'] || false,
201
+ multiarch: builder['multiarch'] || false
202
+ }
203
+ end
204
+
205
+ # Parse registry config
206
+ def parse_registry(registry)
207
+ return {} unless registry
208
+
209
+ {
210
+ server: registry['server'],
211
+ username: registry['username'],
212
+ password: registry['password']
213
+ }
214
+ end
215
+
216
+ # Convert string keys to symbols
217
+ def symbolize_keys(hash)
218
+ hash.each_with_object({}) do |(key, value), result|
219
+ sym_key = key.to_s.tr('-', '_').to_sym
220
+ result[sym_key] = value
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Odysseus
4
+ module Core
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/version"
4
+
5
+ module Odysseus
6
+ module Core
7
+ class Error < StandardError; end
8
+ # Your code goes here...
9
+ end
10
+ end