gjallarhorn 0.1.0.alpha → 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 +4 -4
- data/.yardopts +12 -0
- data/README.md +116 -9
- data/examples/zero-downtime-deployment.rb +104 -0
- data/lib/gjallarhorn/adapter/aws.rb +652 -0
- data/lib/gjallarhorn/adapter/base.rb +125 -0
- data/lib/gjallarhorn/cli.rb +90 -4
- data/lib/gjallarhorn/configuration.rb +64 -8
- data/lib/gjallarhorn/deployer.rb +179 -9
- data/lib/gjallarhorn/deployment/basic.rb +171 -0
- data/lib/gjallarhorn/deployment/legacy.rb +40 -0
- data/lib/gjallarhorn/deployment/strategy.rb +189 -0
- data/lib/gjallarhorn/deployment/zero_downtime.rb +276 -0
- data/lib/gjallarhorn/history.rb +164 -0
- data/lib/gjallarhorn/proxy/kamal_proxy_manager.rb +36 -0
- data/lib/gjallarhorn/proxy/manager.rb +186 -0
- data/lib/gjallarhorn/proxy/nginx_manager.rb +362 -0
- data/lib/gjallarhorn/proxy/traefik_manager.rb +36 -0
- data/lib/gjallarhorn/version.rb +1 -1
- data/lib/gjallarhorn.rb +16 -0
- metadata +101 -6
- data/lib/gjallarhorn/adapters/aws.rb +0 -96
- data/lib/gjallarhorn/adapters/base.rb +0 -56
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# AWS SSM deployment adapter for managing containerized applications
|
|
4
|
+
#
|
|
5
|
+
# The AWSAdapter uses AWS Systems Manager (SSM) to deploy and manage Docker containers
|
|
6
|
+
# on EC2 instances without requiring SSH access. It provides secure, API-first deployments
|
|
7
|
+
# by executing commands remotely through AWS SSM.
|
|
8
|
+
#
|
|
9
|
+
# @example Configuration
|
|
10
|
+
# production:
|
|
11
|
+
# provider: aws
|
|
12
|
+
# region: us-west-2
|
|
13
|
+
# services:
|
|
14
|
+
# - name: web
|
|
15
|
+
# ports: ["80:8080"]
|
|
16
|
+
# env:
|
|
17
|
+
# RAILS_ENV: production
|
|
18
|
+
#
|
|
19
|
+
# @since 0.1.0
|
|
20
|
+
module Gjallarhorn
|
|
21
|
+
module Adapter
|
|
22
|
+
# AWS Systems Manager adapter for container deployments
|
|
23
|
+
class AWSAdapter < Base
|
|
24
|
+
# Initialize AWS adapter with SSM and EC2 clients
|
|
25
|
+
#
|
|
26
|
+
# @param config [Hash] Configuration containing AWS region and other settings
|
|
27
|
+
def initialize(config)
|
|
28
|
+
super
|
|
29
|
+
require "aws-sdk-ssm"
|
|
30
|
+
require "aws-sdk-ec2"
|
|
31
|
+
|
|
32
|
+
# Handle both string and symbol keys for region
|
|
33
|
+
region = config["region"] || config[:region]
|
|
34
|
+
raise ArgumentError, "AWS region is required in configuration" unless region
|
|
35
|
+
|
|
36
|
+
@ssm = Aws::SSM::Client.new(region: region)
|
|
37
|
+
@ec2 = Aws::EC2::Client.new(region: region)
|
|
38
|
+
@current_environment = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Deploy container images to AWS EC2 instances via SSM
|
|
42
|
+
#
|
|
43
|
+
# @param image [String] Docker image to deploy
|
|
44
|
+
# @param environment [String] Target environment name
|
|
45
|
+
# @param services [Array<Hash>] Service configurations to deploy
|
|
46
|
+
# @return [void]
|
|
47
|
+
def deploy(image:, environment:, services: [])
|
|
48
|
+
instances = get_instances_by_tags(environment)
|
|
49
|
+
commands = build_deployment_commands(image, services)
|
|
50
|
+
|
|
51
|
+
logger.info "Deploying #{image} to #{instances.size} AWS instances"
|
|
52
|
+
|
|
53
|
+
response = execute_deployment_command(instances, commands, image)
|
|
54
|
+
wait_for_command_completion(response.command.command_id, instances)
|
|
55
|
+
verify_service_health(services)
|
|
56
|
+
|
|
57
|
+
logger.info "Deployment completed successfully"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rollback to a previous version (placeholder implementation)
|
|
61
|
+
#
|
|
62
|
+
# @param version [String] Version to rollback to
|
|
63
|
+
# @return [void]
|
|
64
|
+
# @todo Implement rollback functionality
|
|
65
|
+
def rollback(version:)
|
|
66
|
+
# Similar implementation for rollback
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get status of all instances in the environment
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<Hash>] Instance status information
|
|
72
|
+
def status
|
|
73
|
+
environment = config["environment"] || config[:environment] || "production"
|
|
74
|
+
instances = get_instances_by_tags(environment)
|
|
75
|
+
instances.map do |instance_id|
|
|
76
|
+
{
|
|
77
|
+
instance: instance_id,
|
|
78
|
+
status: get_instance_status(instance_id)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check health of a service (simplified implementation)
|
|
84
|
+
#
|
|
85
|
+
# @param service [String] Service name to check
|
|
86
|
+
# @return [Boolean] Always returns true (simplified)
|
|
87
|
+
# @todo Implement actual health check via SSM
|
|
88
|
+
def health_check(*)
|
|
89
|
+
# Implement health check via SSM command
|
|
90
|
+
true # Simplified
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Set the current deployment environment
|
|
94
|
+
#
|
|
95
|
+
# @param environment [String] Environment name
|
|
96
|
+
# @return [void]
|
|
97
|
+
def set_environment(environment)
|
|
98
|
+
@current_environment = environment
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Start a new container with enhanced configuration
|
|
102
|
+
#
|
|
103
|
+
# @param container_config [Hash] Container configuration
|
|
104
|
+
# @return [Hash] Container information
|
|
105
|
+
def start_container(container_config)
|
|
106
|
+
config = extract_container_config(container_config)
|
|
107
|
+
docker_cmd = build_docker_run_command(config)
|
|
108
|
+
|
|
109
|
+
@logger.info "Starting container: #{config[:name]}"
|
|
110
|
+
@logger.debug "Docker command: #{docker_cmd}"
|
|
111
|
+
|
|
112
|
+
container_id = execute_container_start(docker_cmd)
|
|
113
|
+
finalize_container_setup(container_id, config)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get running containers for a service
|
|
117
|
+
#
|
|
118
|
+
# @param service_name [String] Service name
|
|
119
|
+
# @return [Array<Hash>] Array of container information
|
|
120
|
+
def get_running_containers(service_name)
|
|
121
|
+
# List containers with service label/name pattern
|
|
122
|
+
cmd = [
|
|
123
|
+
"docker ps",
|
|
124
|
+
"--filter label=gjallarhorn.service=#{service_name}",
|
|
125
|
+
"--format '{{.ID}}:{{.Names}}:{{.Status}}:{{.CreatedAt}}'"
|
|
126
|
+
].join(" ")
|
|
127
|
+
|
|
128
|
+
output = execute_ssm_command_with_response(cmd)
|
|
129
|
+
parse_container_list(output, service_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def get_instances_by_tags(environment)
|
|
135
|
+
@logger.debug "Querying EC2 instances with filters: Environment=#{environment}, Role=web|app, state=running"
|
|
136
|
+
|
|
137
|
+
resp = @ec2.describe_instances(
|
|
138
|
+
filters: [
|
|
139
|
+
{ name: "tag:Environment", values: [environment] },
|
|
140
|
+
{ name: "tag:Role", values: %w[web app] },
|
|
141
|
+
{ name: "instance-state-name", values: ["running"] }
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
instances = resp.reservations.flat_map(&:instances)
|
|
146
|
+
@logger.debug "Found #{instances.length} instances matching filters"
|
|
147
|
+
|
|
148
|
+
instances.each do |instance|
|
|
149
|
+
tags_info = begin
|
|
150
|
+
if instance.respond_to?(:tags) && instance.tags
|
|
151
|
+
instance.tags.map { |t| "#{t.key}=#{t.value}" }.join(', ')
|
|
152
|
+
else
|
|
153
|
+
"N/A"
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError
|
|
156
|
+
"N/A"
|
|
157
|
+
end
|
|
158
|
+
@logger.debug "Instance: #{instance.instance_id}, State: #{instance.state.name}, Tags: #{tags_info}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
instance_ids = instances.map(&:instance_id)
|
|
162
|
+
@logger.debug "Returning instance IDs: #{instance_ids.join(', ')}" if instance_ids.any?
|
|
163
|
+
|
|
164
|
+
instance_ids
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_deployment_commands(image, services)
|
|
168
|
+
[
|
|
169
|
+
"docker pull #{image}",
|
|
170
|
+
*services.map { |svc| "docker stop #{svc[:name]} || true" },
|
|
171
|
+
*services.map do |svc|
|
|
172
|
+
"docker run -d --name #{svc[:name]} " \
|
|
173
|
+
"#{svc[:ports].map { |p| "-p #{p}" }.join(" ")} " \
|
|
174
|
+
"#{svc[:env].map { |k, v| "-e #{k}=#{v}" }.join(" ")} " \
|
|
175
|
+
"#{image}"
|
|
176
|
+
end
|
|
177
|
+
]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def execute_deployment_command(instances, commands, image)
|
|
181
|
+
@ssm.send_command(
|
|
182
|
+
instance_ids: instances,
|
|
183
|
+
document_name: "AWS-RunShellScript",
|
|
184
|
+
parameters: {
|
|
185
|
+
"commands" => commands,
|
|
186
|
+
"executionTimeout" => ["3600"]
|
|
187
|
+
},
|
|
188
|
+
comment: "Deploy #{image} via Gjallarhorn"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def verify_service_health(services)
|
|
193
|
+
services.each { |service| wait_for_health(service) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wait_for_command_completion(command_id, instances)
|
|
197
|
+
# Use the first instance for command completion check
|
|
198
|
+
instance_id = instances.is_a?(Array) ? instances.first : instances
|
|
199
|
+
@logger.debug "wait_for_command_completion: Using instance #{instance_id} for command #{command_id}"
|
|
200
|
+
|
|
201
|
+
@ssm.wait_until(:command_executed, command_id: command_id, instance_id: instance_id) do |w|
|
|
202
|
+
w.max_attempts = 60
|
|
203
|
+
w.delay = 5
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def get_instance_status(instance_id)
|
|
208
|
+
resp = @ec2.describe_instances(instance_ids: [instance_id])
|
|
209
|
+
instance = resp.reservations.first&.instances&.first
|
|
210
|
+
instance&.state&.name || "unknown"
|
|
211
|
+
rescue StandardError
|
|
212
|
+
"unknown"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Get all containers for a service (including stopped)
|
|
216
|
+
#
|
|
217
|
+
# @param service_name [String] Service name
|
|
218
|
+
# @return [Array<Hash>] Array of all container information
|
|
219
|
+
def get_all_containers(service_name)
|
|
220
|
+
cmd = [
|
|
221
|
+
"docker ps -a",
|
|
222
|
+
"--filter label=gjallarhorn.service=#{service_name}",
|
|
223
|
+
"--format '{{.ID}}:{{.Names}}:{{.Status}}:{{.CreatedAt}}'"
|
|
224
|
+
].join(" ")
|
|
225
|
+
|
|
226
|
+
output = execute_ssm_command_with_response(cmd)
|
|
227
|
+
parse_container_list(output, service_name)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Stop a container
|
|
231
|
+
#
|
|
232
|
+
# @param container_id [String] Container ID
|
|
233
|
+
# @param graceful [Boolean] Whether to stop gracefully
|
|
234
|
+
# @param timeout [Integer] Timeout for graceful stop
|
|
235
|
+
# @return [void]
|
|
236
|
+
def stop_container(container_id, graceful: true, timeout: 30)
|
|
237
|
+
if graceful
|
|
238
|
+
@logger.info "Gracefully stopping container: #{container_id}"
|
|
239
|
+
execute_ssm_command("docker stop --time #{timeout} #{container_id}")
|
|
240
|
+
else
|
|
241
|
+
@logger.info "Force stopping container: #{container_id}"
|
|
242
|
+
execute_ssm_command("docker kill #{container_id}")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Remove a container
|
|
247
|
+
#
|
|
248
|
+
# @param container_id [String] Container ID
|
|
249
|
+
# @return [void]
|
|
250
|
+
def remove_container(container_id)
|
|
251
|
+
@logger.info "Removing container: #{container_id}"
|
|
252
|
+
execute_ssm_command("docker rm #{container_id}")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Execute command in a running container
|
|
256
|
+
#
|
|
257
|
+
# @param container_id [String] Container ID
|
|
258
|
+
# @param command [String] Command to execute
|
|
259
|
+
# @return [String] Command output
|
|
260
|
+
def execute_in_container(container_id, command)
|
|
261
|
+
docker_cmd = "docker exec #{container_id} #{command}"
|
|
262
|
+
execute_ssm_command_with_response(docker_cmd)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Get container health status
|
|
266
|
+
#
|
|
267
|
+
# @param container_id [String] Container ID
|
|
268
|
+
# @return [Boolean] True if container is healthy
|
|
269
|
+
def get_container_health(container_id)
|
|
270
|
+
health_output = execute_ssm_command_with_response(
|
|
271
|
+
"docker inspect #{container_id} --format '{{.State.Health.Status}}'"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
health_status = health_output.strip.downcase
|
|
275
|
+
health_status == "healthy"
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
@logger.debug "Health check failed for #{container_id}: #{e.message}"
|
|
278
|
+
# If no health check is configured, check if container is running
|
|
279
|
+
get_container_status(container_id) == "running"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Get container status
|
|
283
|
+
#
|
|
284
|
+
# @param container_id [String] Container ID
|
|
285
|
+
# @return [String] Container status
|
|
286
|
+
def get_container_status(container_id)
|
|
287
|
+
status_output = execute_ssm_command_with_response(
|
|
288
|
+
"docker inspect #{container_id} --format '{{.State.Status}}'"
|
|
289
|
+
)
|
|
290
|
+
status_output.strip.downcase
|
|
291
|
+
rescue StandardError
|
|
292
|
+
"unknown"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Get detailed container information
|
|
296
|
+
#
|
|
297
|
+
# @param container_id [String] Container ID
|
|
298
|
+
# @return [Hash] Container information
|
|
299
|
+
def get_container_info(container_id)
|
|
300
|
+
# Get container IP address
|
|
301
|
+
ip_cmd = "docker inspect #{container_id} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"
|
|
302
|
+
ip_address = execute_ssm_command_with_response(ip_cmd).strip
|
|
303
|
+
|
|
304
|
+
# Get container port mappings
|
|
305
|
+
ports_cmd = "docker port #{container_id}"
|
|
306
|
+
ports_output = execute_ssm_command_with_response(ports_cmd)
|
|
307
|
+
|
|
308
|
+
{
|
|
309
|
+
id: container_id,
|
|
310
|
+
ip: ip_address.empty? ? nil : ip_address,
|
|
311
|
+
ports: parse_container_ports(ports_output),
|
|
312
|
+
host: target_instances.first # Simplified - use first instance
|
|
313
|
+
}
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
@logger.warn "Failed to get container info for #{container_id}: #{e.message}"
|
|
316
|
+
{
|
|
317
|
+
id: container_id,
|
|
318
|
+
ip: nil,
|
|
319
|
+
ports: [],
|
|
320
|
+
host: target_instances.first
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Build Docker run command from configuration
|
|
325
|
+
#
|
|
326
|
+
# @param config [Hash] Container configuration
|
|
327
|
+
# @return [String] Docker run command
|
|
328
|
+
def build_docker_run_command(config)
|
|
329
|
+
cmd_parts = ["docker run -d"]
|
|
330
|
+
|
|
331
|
+
# Container name
|
|
332
|
+
cmd_parts << "--name #{config[:name]}"
|
|
333
|
+
|
|
334
|
+
# Port mappings
|
|
335
|
+
config[:ports].each do |port|
|
|
336
|
+
cmd_parts << "-p #{port}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Environment variables
|
|
340
|
+
config[:env].each do |key, value|
|
|
341
|
+
cmd_parts << "-e #{key}=#{shell_escape(value)}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Volume mounts
|
|
345
|
+
config[:volumes].each do |volume|
|
|
346
|
+
cmd_parts << "-v #{volume}"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Labels
|
|
350
|
+
config[:labels].each do |key, value|
|
|
351
|
+
cmd_parts << "--label #{key}=#{shell_escape(value)}"
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Restart policy
|
|
355
|
+
cmd_parts << "--restart #{config[:restart_policy]}"
|
|
356
|
+
|
|
357
|
+
# Image
|
|
358
|
+
cmd_parts << config[:image]
|
|
359
|
+
|
|
360
|
+
# Command (if specified)
|
|
361
|
+
cmd_parts << config[:command] if config[:command]
|
|
362
|
+
|
|
363
|
+
cmd_parts.join(" ")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Execute SSM command and return response
|
|
367
|
+
#
|
|
368
|
+
# @param command [String] Command to execute
|
|
369
|
+
# @return [String] Command output
|
|
370
|
+
def execute_ssm_command_with_response(command)
|
|
371
|
+
instances = target_instances
|
|
372
|
+
@logger.debug "execute_ssm_command_with_response: Using instances: #{instances.inspect}"
|
|
373
|
+
@logger.debug "execute_ssm_command_with_response: Command: #{command}"
|
|
374
|
+
|
|
375
|
+
response = @ssm.send_command(
|
|
376
|
+
instance_ids: instances,
|
|
377
|
+
document_name: "AWS-RunShellScript",
|
|
378
|
+
parameters: {
|
|
379
|
+
"commands" => [command],
|
|
380
|
+
"executionTimeout" => ["300"]
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
command_id = response.command.command_id
|
|
385
|
+
wait_for_command_completion(command_id, target_instances)
|
|
386
|
+
|
|
387
|
+
# Get command output
|
|
388
|
+
get_command_output(command_id)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Get command output from SSM
|
|
392
|
+
#
|
|
393
|
+
# @param command_id [String] SSM command ID
|
|
394
|
+
# @return [String] Command output
|
|
395
|
+
def get_command_output(command_id)
|
|
396
|
+
instances = target_instances
|
|
397
|
+
@logger.debug "get_command_output: Using instances: #{instances.inspect}"
|
|
398
|
+
@logger.debug "get_command_output: First instance: #{instances.first.inspect}"
|
|
399
|
+
|
|
400
|
+
# Get output from first instance (simplified)
|
|
401
|
+
result = @ssm.get_command_invocation(
|
|
402
|
+
command_id: command_id,
|
|
403
|
+
instance_id: instances.first
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if result.status_details == "Success"
|
|
407
|
+
result.standard_output_content || ""
|
|
408
|
+
else
|
|
409
|
+
error_msg = result.standard_error_content || "Command failed"
|
|
410
|
+
raise DeploymentError, "SSM command failed: #{error_msg}"
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Parse container list output
|
|
415
|
+
#
|
|
416
|
+
# @param output [String] Docker ps output
|
|
417
|
+
# @param service_name [String] Service name for filtering
|
|
418
|
+
# @return [Array<Hash>] Parsed container information
|
|
419
|
+
def parse_container_list(output, service_name)
|
|
420
|
+
containers = []
|
|
421
|
+
|
|
422
|
+
output.split("\n").each do |line|
|
|
423
|
+
next if line.strip.empty?
|
|
424
|
+
|
|
425
|
+
parts = line.split(":")
|
|
426
|
+
next unless parts.length >= 3
|
|
427
|
+
|
|
428
|
+
containers << {
|
|
429
|
+
id: parts[0],
|
|
430
|
+
name: parts[1],
|
|
431
|
+
status: parts[2],
|
|
432
|
+
created_at: parse_container_timestamp(parts[3]),
|
|
433
|
+
service: service_name
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
containers
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Parse container port mappings
|
|
441
|
+
#
|
|
442
|
+
# @param ports_output [String] Docker port command output
|
|
443
|
+
# @return [Array<String>] Port mappings
|
|
444
|
+
def parse_container_ports(ports_output)
|
|
445
|
+
ports = []
|
|
446
|
+
|
|
447
|
+
ports_output.split("\n").each do |line|
|
|
448
|
+
next if line.strip.empty?
|
|
449
|
+
|
|
450
|
+
# Format: "3000/tcp -> 0.0.0.0:3000"
|
|
451
|
+
next unless line.match(%r{(\d+)/tcp -> [\d.]+:(\d+)})
|
|
452
|
+
|
|
453
|
+
container_port = ::Regexp.last_match(1)
|
|
454
|
+
host_port = ::Regexp.last_match(2)
|
|
455
|
+
ports << "#{host_port}:#{container_port}"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
ports
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Parse container creation timestamp
|
|
462
|
+
#
|
|
463
|
+
# @param timestamp_str [String] Timestamp string from Docker
|
|
464
|
+
# @return [Time] Parsed timestamp
|
|
465
|
+
def parse_container_timestamp(timestamp_str)
|
|
466
|
+
return Time.now.utc unless timestamp_str
|
|
467
|
+
|
|
468
|
+
# Docker timestamp format: "2024-01-15 10:30:45 +0000 UTC"
|
|
469
|
+
Time.parse(timestamp_str).utc
|
|
470
|
+
rescue StandardError
|
|
471
|
+
Time.now.utc
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Wait for container to be running
|
|
475
|
+
#
|
|
476
|
+
# @param container_id [String] Container ID
|
|
477
|
+
# @param timeout [Integer] Timeout in seconds
|
|
478
|
+
# @return [void]
|
|
479
|
+
def wait_for_container_running(container_id, timeout = 60)
|
|
480
|
+
start_time = Time.now
|
|
481
|
+
|
|
482
|
+
loop do
|
|
483
|
+
status = get_container_status(container_id)
|
|
484
|
+
|
|
485
|
+
if status == "running"
|
|
486
|
+
@logger.info "Container #{container_id} is running"
|
|
487
|
+
return
|
|
488
|
+
elsif status == "exited"
|
|
489
|
+
raise DeploymentError, "Container #{container_id} exited unexpectedly"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
elapsed = Time.now - start_time
|
|
493
|
+
if elapsed >= timeout
|
|
494
|
+
raise DeploymentError, "Container #{container_id} failed to start within #{timeout}s (status: #{status})"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
@logger.debug "Container #{container_id} status: #{status}, waiting..."
|
|
498
|
+
sleep 2
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Escape shell arguments
|
|
503
|
+
#
|
|
504
|
+
# @param value [String] Value to escape
|
|
505
|
+
# @return [String] Shell-escaped value
|
|
506
|
+
def shell_escape(value)
|
|
507
|
+
"'#{value.to_s.gsub("'", "'\\''")}'"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Execute SSM command without returning response (fire and forget)
|
|
511
|
+
#
|
|
512
|
+
# @param command [String] Command to execute
|
|
513
|
+
# @return [void]
|
|
514
|
+
def execute_ssm_command(command)
|
|
515
|
+
instances = target_instances
|
|
516
|
+
@logger.debug "execute_ssm_command: Using instances: #{instances.inspect}"
|
|
517
|
+
@logger.debug "execute_ssm_command: Command: #{command}"
|
|
518
|
+
|
|
519
|
+
@ssm.send_command(
|
|
520
|
+
instance_ids: instances,
|
|
521
|
+
document_name: "AWS-RunShellScript",
|
|
522
|
+
parameters: {
|
|
523
|
+
"commands" => [command],
|
|
524
|
+
"executionTimeout" => ["300"]
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Get target instances for the current environment
|
|
530
|
+
#
|
|
531
|
+
# @param environment [String] Environment name (overrides config)
|
|
532
|
+
# @return [Array<String>] Array of instance IDs
|
|
533
|
+
def target_instances(environment = nil)
|
|
534
|
+
# Check if instance IDs are explicitly configured
|
|
535
|
+
instance_ids = @config["instance_ids"] || @config[:instance_ids] ||
|
|
536
|
+
@config["instance-ids"] || @config[:"instance-ids"]
|
|
537
|
+
|
|
538
|
+
if instance_ids && !instance_ids.empty?
|
|
539
|
+
# Use explicitly configured instance IDs
|
|
540
|
+
instance_ids = [instance_ids] unless instance_ids.is_a?(Array)
|
|
541
|
+
@logger.debug "Using configured instance IDs: #{instance_ids.join(', ')}"
|
|
542
|
+
return instance_ids
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Fall back to tag-based discovery
|
|
546
|
+
# Use provided environment parameter, current environment, config, or default to production
|
|
547
|
+
env_name = environment || @current_environment || @config["environment"] || @config[:environment] || "production"
|
|
548
|
+
@logger.debug "No instance IDs configured, discovering instances by tags for environment: #{env_name}"
|
|
549
|
+
discovered_instances = get_instances_by_tags(env_name)
|
|
550
|
+
|
|
551
|
+
if discovered_instances.empty?
|
|
552
|
+
raise ArgumentError, "No EC2 instances found for environment '#{env_name}'. " \
|
|
553
|
+
"Either configure 'instance_ids' in your deploy.yml or ensure your EC2 " \
|
|
554
|
+
"instances are tagged with Environment=#{env_name} and Role=web|app"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
discovered_instances
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# Extract ECR registries from Docker command
|
|
561
|
+
#
|
|
562
|
+
# @param docker_cmd [String] Docker command
|
|
563
|
+
# @return [Array<Hash>] Array of ECR registry information
|
|
564
|
+
def extract_ecr_registries(docker_cmd)
|
|
565
|
+
registries = []
|
|
566
|
+
|
|
567
|
+
# Match ECR registry URLs: account.dkr.ecr.region.amazonaws.com
|
|
568
|
+
ecr_pattern = /(\d+)\.dkr\.ecr\.([^.]+)\.amazonaws\.com/
|
|
569
|
+
|
|
570
|
+
docker_cmd.scan(ecr_pattern) do |account_id, region|
|
|
571
|
+
registry_url = "#{account_id}.dkr.ecr.#{region}.amazonaws.com"
|
|
572
|
+
registries << {
|
|
573
|
+
account_id: account_id,
|
|
574
|
+
region: region,
|
|
575
|
+
registry_url: registry_url
|
|
576
|
+
}
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
registries.uniq
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Authenticate with ECR registries
|
|
583
|
+
#
|
|
584
|
+
# @param registries [Array<Hash>] ECR registry information
|
|
585
|
+
# @return [void]
|
|
586
|
+
def authenticate_ecr_registries(registries)
|
|
587
|
+
registries.each do |registry|
|
|
588
|
+
@logger.info "Authenticating with ECR registry: #{registry[:registry_url]}"
|
|
589
|
+
|
|
590
|
+
login_cmd = "aws ecr get-login-password --region #{registry[:region]} | " \
|
|
591
|
+
"docker login --username AWS --password-stdin #{registry[:registry_url]}"
|
|
592
|
+
|
|
593
|
+
begin
|
|
594
|
+
execute_ssm_command_with_response(login_cmd)
|
|
595
|
+
@logger.info "Successfully authenticated with ECR registry: #{registry[:registry_url]}"
|
|
596
|
+
rescue StandardError => e
|
|
597
|
+
@logger.error "Failed to authenticate with ECR registry #{registry[:registry_url]}: #{e.message}"
|
|
598
|
+
raise StandardError, "ECR authentication failed for #{registry[:registry_url]}: #{e.message}"
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Extract and normalize container configuration
|
|
604
|
+
#
|
|
605
|
+
# @param container_config [Hash] Raw container configuration
|
|
606
|
+
# @return [Hash] Normalized configuration
|
|
607
|
+
def extract_container_config(container_config)
|
|
608
|
+
{
|
|
609
|
+
name: container_config[:name],
|
|
610
|
+
image: container_config[:image],
|
|
611
|
+
ports: container_config[:ports] || [],
|
|
612
|
+
env: container_config[:env] || {},
|
|
613
|
+
volumes: container_config[:volumes] || [],
|
|
614
|
+
command: container_config[:command],
|
|
615
|
+
labels: container_config[:labels] || {},
|
|
616
|
+
restart_policy: container_config[:restart_policy] || "unless-stopped"
|
|
617
|
+
}
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Execute container start command and return container ID
|
|
621
|
+
#
|
|
622
|
+
# @param docker_cmd [String] Docker run command
|
|
623
|
+
# @return [String] Container ID
|
|
624
|
+
def execute_container_start(docker_cmd)
|
|
625
|
+
# Check if we need ECR authentication
|
|
626
|
+
if docker_cmd.include?('.dkr.ecr.')
|
|
627
|
+
@logger.debug "ECR registry detected, ensuring authentication"
|
|
628
|
+
ecr_registries = extract_ecr_registries(docker_cmd)
|
|
629
|
+
authenticate_ecr_registries(ecr_registries)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
execute_ssm_command_with_response(docker_cmd).strip
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Finalize container setup after creation
|
|
636
|
+
#
|
|
637
|
+
# @param container_id [String] Container ID
|
|
638
|
+
# @param config [Hash] Container configuration
|
|
639
|
+
# @return [Hash] Container information
|
|
640
|
+
def finalize_container_setup(container_id, config)
|
|
641
|
+
wait_for_container_running(container_id)
|
|
642
|
+
|
|
643
|
+
container_info = get_container_info(container_id)
|
|
644
|
+
container_info.merge(
|
|
645
|
+
name: config[:name],
|
|
646
|
+
image: config[:image],
|
|
647
|
+
created_at: Time.now.utc
|
|
648
|
+
)
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|