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,333 @@
|
|
|
1
|
+
# lib/odysseus/docker/client.rb
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Odysseus
|
|
6
|
+
module Docker
|
|
7
|
+
class Client
|
|
8
|
+
HEALTHCHECK_POLL_INTERVAL = 2 # seconds
|
|
9
|
+
HEALTHCHECK_MAX_ATTEMPTS = 30 # ~60 seconds max wait
|
|
10
|
+
|
|
11
|
+
# @param ssh [Odysseus::Deployer::SSH] SSH connection to server
|
|
12
|
+
def initialize(ssh)
|
|
13
|
+
@ssh = ssh
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Run a new container
|
|
17
|
+
# @param name [String] container name
|
|
18
|
+
# @param image [String] image:tag
|
|
19
|
+
# @param options [Hash] container options
|
|
20
|
+
# @return [String] container ID
|
|
21
|
+
def run(name:, image:, options: {})
|
|
22
|
+
cmd = build_run_command(name: name, image: image, options: options)
|
|
23
|
+
output = @ssh.execute(cmd)
|
|
24
|
+
# Container ID is the last line (64-char hex), ignore any warnings
|
|
25
|
+
lines = output.strip.split("\n")
|
|
26
|
+
container_id = lines.last&.strip
|
|
27
|
+
|
|
28
|
+
# Validate it looks like a container ID
|
|
29
|
+
unless container_id&.match?(/\A[a-f0-9]{64}\z/)
|
|
30
|
+
raise Odysseus::DeployError, "Failed to start container: #{output}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
container_id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Stop a container
|
|
37
|
+
# @param container_id [String] container ID or name
|
|
38
|
+
# @param timeout [Integer] seconds to wait before killing
|
|
39
|
+
def stop(container_id, timeout: 10)
|
|
40
|
+
@ssh.execute("docker stop --time #{timeout} #{container_id}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove a container
|
|
44
|
+
# @param container_id [String] container ID or name
|
|
45
|
+
# @param force [Boolean] force remove running container
|
|
46
|
+
def remove(container_id, force: false)
|
|
47
|
+
force_flag = force ? '-f' : ''
|
|
48
|
+
@ssh.execute("docker rm #{force_flag} #{container_id}".strip)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# List containers for a service
|
|
52
|
+
# @param service [String] service name (label filter)
|
|
53
|
+
# @param all [Boolean] include stopped containers
|
|
54
|
+
# @return [Array<Hash>] container info
|
|
55
|
+
def list(service:, all: false)
|
|
56
|
+
all_flag = all ? '-a' : ''
|
|
57
|
+
format = '{{json .}}'
|
|
58
|
+
output = @ssh.execute(
|
|
59
|
+
"docker ps #{all_flag} --filter label=odysseus.service=#{service} --format '#{format}'"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
output.lines.map { |line| JSON.parse(line.strip) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get container health status
|
|
66
|
+
# @param container_id [String] container ID or name
|
|
67
|
+
# @return [String] health status (healthy, unhealthy, starting, none)
|
|
68
|
+
def health_status(container_id)
|
|
69
|
+
output = @ssh.execute(
|
|
70
|
+
"docker inspect --format '{{.State.Health.Status}}' #{container_id} 2>/dev/null || echo 'none'"
|
|
71
|
+
)
|
|
72
|
+
output.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wait for container to become healthy
|
|
76
|
+
# @param container_id [String] container ID or name
|
|
77
|
+
# @param timeout [Integer] max seconds to wait
|
|
78
|
+
# @return [Boolean] true if healthy, false if timeout
|
|
79
|
+
def wait_healthy(container_id, timeout: 60)
|
|
80
|
+
attempts = [timeout / HEALTHCHECK_POLL_INTERVAL, HEALTHCHECK_MAX_ATTEMPTS].min
|
|
81
|
+
|
|
82
|
+
attempts.times do
|
|
83
|
+
status = health_status(container_id)
|
|
84
|
+
return true if status == 'healthy'
|
|
85
|
+
return false if status == 'unhealthy'
|
|
86
|
+
|
|
87
|
+
sleep HEALTHCHECK_POLL_INTERVAL
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if container is running
|
|
94
|
+
# @param container_id [String] container ID or name
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def running?(container_id)
|
|
97
|
+
output = @ssh.execute(
|
|
98
|
+
"docker inspect --format '{{.State.Running}}' #{container_id} 2>/dev/null || echo 'false'"
|
|
99
|
+
)
|
|
100
|
+
output.strip == 'true'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get container IP address
|
|
104
|
+
# @param container_id [String] container ID or name
|
|
105
|
+
# @param network [String] network name (default: bridge)
|
|
106
|
+
# @return [String, nil] IP address or nil
|
|
107
|
+
def container_ip(container_id, network: 'bridge')
|
|
108
|
+
output = @ssh.execute(
|
|
109
|
+
"docker inspect --format '{{.NetworkSettings.Networks.#{network}.IPAddress}}' #{container_id} 2>/dev/null || echo ''"
|
|
110
|
+
)
|
|
111
|
+
ip = output.strip
|
|
112
|
+
ip.empty? ? nil : ip
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Pull an image
|
|
116
|
+
# @param image [String] image:tag
|
|
117
|
+
def pull(image)
|
|
118
|
+
@ssh.execute("docker pull #{image}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check if image exists locally
|
|
122
|
+
# @param image [String] image:tag
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def image_exists?(image)
|
|
125
|
+
output = @ssh.execute("docker images -q #{image} 2>/dev/null || echo ''")
|
|
126
|
+
!output.strip.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get logs from a container
|
|
130
|
+
# @param container_id [String] container ID or name
|
|
131
|
+
# @param follow [Boolean] follow log output (streaming)
|
|
132
|
+
# @param tail [Integer, String] number of lines to show from end, or 'all'
|
|
133
|
+
# @param since [String] show logs since timestamp (e.g., '10m', '2h', '2024-01-01')
|
|
134
|
+
# @param timestamps [Boolean] show timestamps
|
|
135
|
+
# @return [String] log output (or yields lines if block given)
|
|
136
|
+
def logs(container_id, follow: false, tail: 100, since: nil, timestamps: false, &block)
|
|
137
|
+
parts = ['docker logs']
|
|
138
|
+
parts << '--follow' if follow
|
|
139
|
+
parts << "--tail #{tail}" if tail
|
|
140
|
+
parts << "--since #{since}" if since
|
|
141
|
+
parts << '--timestamps' if timestamps
|
|
142
|
+
parts << container_id
|
|
143
|
+
|
|
144
|
+
cmd = parts.join(' ')
|
|
145
|
+
|
|
146
|
+
if block_given?
|
|
147
|
+
@ssh.stream(cmd, &block)
|
|
148
|
+
else
|
|
149
|
+
@ssh.execute(cmd)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Execute a command in a running container
|
|
154
|
+
# @param container_id [String] container ID or name
|
|
155
|
+
# @param command [String] command to execute
|
|
156
|
+
# @param interactive [Boolean] keep STDIN open
|
|
157
|
+
# @param tty [Boolean] allocate a TTY
|
|
158
|
+
# @return [String] command output
|
|
159
|
+
def exec(container_id, command, interactive: false, tty: false)
|
|
160
|
+
parts = ['docker exec']
|
|
161
|
+
parts << '-i' if interactive
|
|
162
|
+
parts << '-t' if tty
|
|
163
|
+
parts << container_id
|
|
164
|
+
parts << command
|
|
165
|
+
|
|
166
|
+
@ssh.execute(parts.join(' '))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Run a one-off command in a new container (doesn't persist)
|
|
170
|
+
# @param image [String] image to use
|
|
171
|
+
# @param command [String] command to execute
|
|
172
|
+
# @param options [Hash] container options (env, volumes, network, etc.)
|
|
173
|
+
# @return [String] command output
|
|
174
|
+
def run_once(image:, command:, options: {})
|
|
175
|
+
parts = ['docker run --rm']
|
|
176
|
+
|
|
177
|
+
# Environment variables
|
|
178
|
+
options[:env]&.each do |key, value|
|
|
179
|
+
parts << "-e #{key}=#{value}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Volume mounts
|
|
183
|
+
options[:volumes]&.each { |v| parts << "-v #{v}" }
|
|
184
|
+
|
|
185
|
+
# Network
|
|
186
|
+
parts << "--network #{options[:network]}" if options[:network]
|
|
187
|
+
|
|
188
|
+
# Interactive/TTY
|
|
189
|
+
parts << '-i' if options[:interactive]
|
|
190
|
+
parts << '-t' if options[:tty]
|
|
191
|
+
|
|
192
|
+
parts << image
|
|
193
|
+
parts << command
|
|
194
|
+
|
|
195
|
+
@ssh.execute(parts.join(' '))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Cleanup old stopped containers, keeping only the last N
|
|
199
|
+
# @param service [String] service name
|
|
200
|
+
# @param keep [Integer] number of stopped containers to keep
|
|
201
|
+
# @return [Array<String>] IDs of removed containers
|
|
202
|
+
def cleanup_old_containers(service:, keep: 2)
|
|
203
|
+
stopped = list(service: service, all: true).select do |c|
|
|
204
|
+
c['State'] == 'exited'
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Sort by created time (newest first) and remove old ones
|
|
208
|
+
sorted = stopped.sort_by { |c| c['CreatedAt'] }.reverse
|
|
209
|
+
to_remove = sorted.drop(keep)
|
|
210
|
+
|
|
211
|
+
to_remove.each do |container|
|
|
212
|
+
remove(container['ID'])
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
to_remove.map { |c| c['ID'] }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Prune unused Docker resources (excludes odysseus-managed resources)
|
|
219
|
+
# @param containers [Boolean] remove stopped containers (excludes odysseus-caddy)
|
|
220
|
+
# @param images [Boolean] remove dangling images
|
|
221
|
+
# @param volumes [Boolean] remove unused volumes (DANGEROUS - data loss!)
|
|
222
|
+
# @param networks [Boolean] remove unused networks (excludes odysseus network)
|
|
223
|
+
# @return [Hash] prune results
|
|
224
|
+
def prune(containers: true, images: true, volumes: false, networks: false)
|
|
225
|
+
results = {}
|
|
226
|
+
|
|
227
|
+
if containers
|
|
228
|
+
# Prune containers but exclude odysseus-caddy
|
|
229
|
+
# Use filter to exclude containers with odysseus label
|
|
230
|
+
output = @ssh.execute(
|
|
231
|
+
'docker container prune -f --filter "label!=odysseus.managed=true" 2>&1'
|
|
232
|
+
)
|
|
233
|
+
results[:containers] = output
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if images
|
|
237
|
+
output = @ssh.execute('docker image prune -f 2>&1')
|
|
238
|
+
results[:images] = output
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if volumes
|
|
242
|
+
output = @ssh.execute('docker volume prune -f 2>&1')
|
|
243
|
+
results[:volumes] = output
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
if networks
|
|
247
|
+
# Prune networks but exclude odysseus network
|
|
248
|
+
output = @ssh.execute(
|
|
249
|
+
'docker network prune -f --filter "label!=odysseus.managed=true" 2>&1'
|
|
250
|
+
)
|
|
251
|
+
results[:networks] = output
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
results
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Get disk usage info
|
|
258
|
+
# @return [String] docker system df output
|
|
259
|
+
def disk_usage
|
|
260
|
+
@ssh.execute('docker system df')
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def build_run_command(name:, image:, options:)
|
|
266
|
+
parts = ['docker run -d']
|
|
267
|
+
|
|
268
|
+
# Container name
|
|
269
|
+
parts << "--name #{name}"
|
|
270
|
+
|
|
271
|
+
# Labels for tracking
|
|
272
|
+
parts << "--label odysseus.service=#{options[:service] || name}"
|
|
273
|
+
parts << "--label odysseus.version=#{options[:version]}" if options[:version]
|
|
274
|
+
|
|
275
|
+
# Additional custom labels
|
|
276
|
+
if options[:labels]
|
|
277
|
+
options[:labels].each do |key, value|
|
|
278
|
+
parts << "--label #{key}=#{value}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Port mappings
|
|
283
|
+
if options[:ports]
|
|
284
|
+
options[:ports].each { |p| parts << "-p #{p}" }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Environment variables
|
|
288
|
+
if options[:env]
|
|
289
|
+
options[:env].each do |key, value|
|
|
290
|
+
# Don't log secret values
|
|
291
|
+
parts << "-e #{key}=#{value}"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Memory limits
|
|
296
|
+
parts << "--memory #{options[:memory]}" if options[:memory]
|
|
297
|
+
parts << "--memory-reservation #{options[:memory_reservation]}" if options[:memory_reservation]
|
|
298
|
+
|
|
299
|
+
# CPU limits
|
|
300
|
+
parts << "--cpus #{options[:cpus]}" if options[:cpus]
|
|
301
|
+
parts << "--cpu-shares #{options[:cpu_shares]}" if options[:cpu_shares]
|
|
302
|
+
|
|
303
|
+
# Health check (use image's HEALTHCHECK by default)
|
|
304
|
+
if options[:healthcheck]
|
|
305
|
+
hc = options[:healthcheck]
|
|
306
|
+
parts << "--health-cmd '#{hc[:cmd]}'" if hc[:cmd]
|
|
307
|
+
parts << "--health-interval #{hc[:interval]}s" if hc[:interval]
|
|
308
|
+
parts << "--health-timeout #{hc[:timeout]}s" if hc[:timeout]
|
|
309
|
+
parts << "--health-retries #{hc[:retries]}" if hc[:retries]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Network
|
|
313
|
+
parts << "--network #{options[:network]}" if options[:network]
|
|
314
|
+
|
|
315
|
+
# Volume mounts
|
|
316
|
+
if options[:volumes]
|
|
317
|
+
options[:volumes].each { |v| parts << "-v #{v}" }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Restart policy
|
|
321
|
+
parts << "--restart #{options[:restart] || 'unless-stopped'}"
|
|
322
|
+
|
|
323
|
+
# Image
|
|
324
|
+
parts << image
|
|
325
|
+
|
|
326
|
+
# Command (if provided)
|
|
327
|
+
parts << options[:cmd] if options[:cmd]
|
|
328
|
+
|
|
329
|
+
parts.join(' ')
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/odysseus/errors.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigError < Error; end
|
|
7
|
+
class ConfigParseError < ConfigError; end
|
|
8
|
+
class ConfigValidationError < ConfigError; end
|
|
9
|
+
class ConfigMissingKeyError < ConfigError; end
|
|
10
|
+
|
|
11
|
+
class DeployError < Error; end
|
|
12
|
+
class SSHError < DeployError; end
|
|
13
|
+
class SSHConnectionError < SSHError; end
|
|
14
|
+
class SSHCommandError < SSHError; end
|
|
15
|
+
|
|
16
|
+
class RegistryError < Error; end
|
|
17
|
+
class RegistryPushError < RegistryError; end
|
|
18
|
+
class RegistryAuthError < RegistryError; end
|
|
19
|
+
|
|
20
|
+
class BuildError < Error; end
|
|
21
|
+
class BuildFailedError < BuildError; end
|
|
22
|
+
class BuildContextError < BuildError; end
|
|
23
|
+
|
|
24
|
+
class GeneratorError < Error; end
|
|
25
|
+
class DockerComposeGenerationError < GeneratorError; end
|
|
26
|
+
class CaddyGenerationError < GeneratorError; end
|
|
27
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# lib/odysseus/host_providers/aws_asg.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module HostProviders
|
|
5
|
+
# AWS Auto Scaling Group host provider
|
|
6
|
+
# Resolves hosts from EC2 instances in an ASG
|
|
7
|
+
#
|
|
8
|
+
# Config options:
|
|
9
|
+
# asg: ASG name (required)
|
|
10
|
+
# region: AWS region (required)
|
|
11
|
+
# use_private_ip: Use private IP instead of public (default: false)
|
|
12
|
+
# state: Only include instances in this lifecycle state (default: InService)
|
|
13
|
+
#
|
|
14
|
+
# AWS credentials are loaded from standard AWS credential chain:
|
|
15
|
+
# - Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
16
|
+
# - Shared credentials file (~/.aws/credentials)
|
|
17
|
+
# - IAM instance profile (when running on EC2)
|
|
18
|
+
class AwsAsg < Base
|
|
19
|
+
def initialize(config)
|
|
20
|
+
super
|
|
21
|
+
@asg_name = config[:asg]
|
|
22
|
+
@region = config[:region]
|
|
23
|
+
@use_private_ip = config[:use_private_ip] || false
|
|
24
|
+
@lifecycle_state = config[:state] || 'InService'
|
|
25
|
+
|
|
26
|
+
validate_config!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] list of instance IPs/hostnames
|
|
30
|
+
def resolve
|
|
31
|
+
require_aws_sdk!
|
|
32
|
+
|
|
33
|
+
instances = fetch_asg_instances
|
|
34
|
+
instances.map { |i| extract_address(i) }.compact
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def name
|
|
38
|
+
"aws_asg(#{@asg_name})"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_config!
|
|
44
|
+
raise Odysseus::ConfigError, "AWS ASG provider requires 'asg' name" unless @asg_name
|
|
45
|
+
raise Odysseus::ConfigError, "AWS ASG provider requires 'region'" unless @region
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def require_aws_sdk!
|
|
49
|
+
require 'aws-sdk-autoscaling'
|
|
50
|
+
require 'aws-sdk-ec2'
|
|
51
|
+
rescue LoadError
|
|
52
|
+
raise Odysseus::ConfigError,
|
|
53
|
+
"AWS SDK not installed. Add 'aws-sdk-autoscaling' and 'aws-sdk-ec2' to your Gemfile."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fetch_asg_instances
|
|
57
|
+
asg_client = Aws::AutoScaling::Client.new(region: @region)
|
|
58
|
+
ec2_client = Aws::EC2::Client.new(region: @region)
|
|
59
|
+
|
|
60
|
+
# Get instance IDs from ASG
|
|
61
|
+
asg_response = asg_client.describe_auto_scaling_groups(
|
|
62
|
+
auto_scaling_group_names: [@asg_name]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
asg = asg_response.auto_scaling_groups.first
|
|
66
|
+
raise Odysseus::ConfigError, "ASG '#{@asg_name}' not found" unless asg
|
|
67
|
+
|
|
68
|
+
# Filter by lifecycle state
|
|
69
|
+
instance_ids = asg.instances
|
|
70
|
+
.select { |i| i.lifecycle_state == @lifecycle_state }
|
|
71
|
+
.map(&:instance_id)
|
|
72
|
+
|
|
73
|
+
return [] if instance_ids.empty?
|
|
74
|
+
|
|
75
|
+
# Get instance details from EC2
|
|
76
|
+
ec2_response = ec2_client.describe_instances(instance_ids: instance_ids)
|
|
77
|
+
|
|
78
|
+
ec2_response.reservations.flat_map(&:instances)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_address(instance)
|
|
82
|
+
if @use_private_ip
|
|
83
|
+
instance.private_ip_address
|
|
84
|
+
else
|
|
85
|
+
# Prefer public IP, fall back to private
|
|
86
|
+
instance.public_ip_address || instance.private_ip_address
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# lib/odysseus/host_providers/base.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module HostProviders
|
|
5
|
+
# Base class for host providers
|
|
6
|
+
# Host providers resolve hostnames/IPs from different sources
|
|
7
|
+
# (static lists, AWS ASG, etc.)
|
|
8
|
+
class Base
|
|
9
|
+
# @param config [Hash] provider-specific configuration
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Resolve and return list of hosts
|
|
15
|
+
# @return [Array<String>] list of hostnames or IPs
|
|
16
|
+
def resolve
|
|
17
|
+
raise NotImplementedError, "Subclasses must implement #resolve"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Provider name for display/logging
|
|
21
|
+
# @return [String]
|
|
22
|
+
def name
|
|
23
|
+
self.class.name.split('::').last
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# lib/odysseus/host_providers/static.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module HostProviders
|
|
5
|
+
# Static host provider - returns a fixed list of hosts
|
|
6
|
+
# This is the default provider when hosts are specified directly in config
|
|
7
|
+
class Static < Base
|
|
8
|
+
# @param config [Hash] configuration with :hosts key
|
|
9
|
+
def initialize(config)
|
|
10
|
+
super
|
|
11
|
+
@hosts = config[:hosts] || []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Array<String>] the static list of hosts
|
|
15
|
+
def resolve
|
|
16
|
+
@hosts
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
"static"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# lib/odysseus/host_providers.rb
|
|
2
|
+
|
|
3
|
+
module Odysseus
|
|
4
|
+
module HostProviders
|
|
5
|
+
class << self
|
|
6
|
+
# Registry of available host providers
|
|
7
|
+
def providers
|
|
8
|
+
@providers ||= {
|
|
9
|
+
static: Static,
|
|
10
|
+
aws_asg: AwsAsg
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Build a host provider from role configuration
|
|
15
|
+
# @param role_config [Hash] role configuration from deploy.yml
|
|
16
|
+
# @return [Base] host provider instance
|
|
17
|
+
def build(role_config)
|
|
18
|
+
if role_config[:aws]
|
|
19
|
+
# AWS ASG provider
|
|
20
|
+
AwsAsg.new(role_config[:aws])
|
|
21
|
+
elsif role_config[:hosts]
|
|
22
|
+
# Static hosts (default)
|
|
23
|
+
Static.new(hosts: role_config[:hosts])
|
|
24
|
+
else
|
|
25
|
+
# No hosts configured
|
|
26
|
+
Static.new(hosts: [])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Resolve hosts from role configuration
|
|
31
|
+
# @param role_config [Hash] role configuration from deploy.yml
|
|
32
|
+
# @return [Array<String>] list of resolved hosts
|
|
33
|
+
def resolve(role_config)
|
|
34
|
+
provider = build(role_config)
|
|
35
|
+
provider.resolve
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a custom host provider
|
|
39
|
+
# @param name [Symbol] provider name
|
|
40
|
+
# @param klass [Class] provider class (must inherit from Base)
|
|
41
|
+
def register(name, klass)
|
|
42
|
+
unless klass < Base
|
|
43
|
+
raise ArgumentError, "Provider must inherit from Odysseus::HostProviders::Base"
|
|
44
|
+
end
|
|
45
|
+
providers[name] = klass
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|