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,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
|