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