Class: Gjallarhorn::Adapter::AWSAdapter
- Defined in:
- lib/gjallarhorn/adapter/aws.rb
Overview
AWS Systems Manager adapter for container deployments
Instance Attribute Summary
Attributes inherited from Base
Instance Method Summary collapse
-
#authenticate_ecr_registries(registries) ⇒ void
private
Authenticate with ECR registries.
- #build_deployment_commands(image, services) ⇒ Object private
-
#build_docker_run_command(config) ⇒ String
private
Build Docker run command from configuration.
-
#deploy(image:, environment:, services: []) ⇒ void
Deploy container images to AWS EC2 instances via SSM.
-
#execute_container_start(docker_cmd) ⇒ String
private
Execute container start command and return container ID.
- #execute_deployment_command(instances, commands, image) ⇒ Object private
-
#execute_in_container(container_id, command) ⇒ String
private
Execute command in a running container.
-
#execute_ssm_command(command) ⇒ void
private
Execute SSM command without returning response (fire and forget).
-
#execute_ssm_command_with_response(command) ⇒ String
private
Execute SSM command and return response.
-
#extract_container_config(container_config) ⇒ Hash
private
Extract and normalize container configuration.
-
#extract_ecr_registries(docker_cmd) ⇒ Array<Hash>
private
Extract ECR registries from Docker command.
-
#finalize_container_setup(container_id, config) ⇒ Hash
private
Finalize container setup after creation.
-
#get_all_containers(service_name) ⇒ Array<Hash>
private
Get all containers for a service (including stopped).
-
#get_command_output(command_id) ⇒ String
private
Get command output from SSM.
-
#get_container_health(container_id) ⇒ Boolean
private
Get container health status.
-
#get_container_info(container_id) ⇒ Hash
private
Get detailed container information.
-
#get_container_status(container_id) ⇒ String
private
Get container status.
- #get_instance_status(instance_id) ⇒ Object private
- #get_instances_by_tags(environment) ⇒ Object private
-
#get_running_containers(service_name) ⇒ Array<Hash>
Get running containers for a service.
-
#health_check ⇒ Boolean
Check health of a service (simplified implementation).
-
#initialize(config) ⇒ AWSAdapter
constructor
Initialize AWS adapter with SSM and EC2 clients.
-
#parse_container_list(output, service_name) ⇒ Array<Hash>
private
Parse container list output.
-
#parse_container_ports(ports_output) ⇒ Array<String>
private
Parse container port mappings.
-
#parse_container_timestamp(timestamp_str) ⇒ Time
private
Parse container creation timestamp.
-
#remove_container(container_id) ⇒ void
private
Remove a container.
-
#rollback(version:) ⇒ void
Rollback to a previous version (placeholder implementation).
-
#set_environment(environment) ⇒ void
Set the current deployment environment.
-
#shell_escape(value) ⇒ String
private
Escape shell arguments.
-
#start_container(container_config) ⇒ Hash
Start a new container with enhanced configuration.
-
#status ⇒ Array<Hash>
Get status of all instances in the environment.
-
#stop_container(container_id, graceful: true, timeout: 30) ⇒ void
private
Stop a container.
-
#target_instances(environment = nil) ⇒ Array<String>
private
Get target instances for the current environment.
- #verify_service_health(services) ⇒ Object private
- #wait_for_command_completion(command_id, instances) ⇒ Object private
-
#wait_for_container_running(container_id, timeout = 60) ⇒ void
private
Wait for container to be running.
Methods inherited from Base
Constructor Details
#initialize(config) ⇒ AWSAdapter
Initialize AWS adapter with SSM and EC2 clients
27 28 29 30 31 32 33 34 35 36 37 38 39 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 27 def initialize(config) super require "aws-sdk-ssm" require "aws-sdk-ec2" # Handle both string and symbol keys for region region = config["region"] || config[:region] raise ArgumentError, "AWS region is required in configuration" unless region @ssm = Aws::SSM::Client.new(region: region) @ec2 = Aws::EC2::Client.new(region: region) @current_environment = nil end |
Instance Method Details
#authenticate_ecr_registries(registries) ⇒ void (private)
This method returns an undefined value.
Authenticate with ECR registries
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 586 def authenticate_ecr_registries(registries) registries.each do |registry| @logger.info "Authenticating with ECR registry: #{registry[:registry_url]}" login_cmd = "aws ecr get-login-password --region #{registry[:region]} | " \ "docker login --username AWS --password-stdin #{registry[:registry_url]}" begin execute_ssm_command_with_response(login_cmd) @logger.info "Successfully authenticated with ECR registry: #{registry[:registry_url]}" rescue StandardError => e @logger.error "Failed to authenticate with ECR registry #{registry[:registry_url]}: #{e.}" raise StandardError, "ECR authentication failed for #{registry[:registry_url]}: #{e.}" end end end |
#build_deployment_commands(image, services) ⇒ Object (private)
167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 167 def build_deployment_commands(image, services) [ "docker pull #{image}", *services.map { |svc| "docker stop #{svc[:name]} || true" }, *services.map do |svc| "docker run -d --name #{svc[:name]} " \ "#{svc[:ports].map { |p| "-p #{p}" }.join(" ")} " \ "#{svc[:env].map { |k, v| "-e #{k}=#{v}" }.join(" ")} " \ "#{image}" end ] end |
#build_docker_run_command(config) ⇒ String (private)
Build Docker run command from configuration
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 328 def build_docker_run_command(config) cmd_parts = ["docker run -d"] # Container name cmd_parts << "--name #{config[:name]}" # Port mappings config[:ports].each do |port| cmd_parts << "-p #{port}" end # Environment variables config[:env].each do |key, value| cmd_parts << "-e #{key}=#{shell_escape(value)}" end # Volume mounts config[:volumes].each do |volume| cmd_parts << "-v #{volume}" end # Labels config[:labels].each do |key, value| cmd_parts << "--label #{key}=#{shell_escape(value)}" end # Restart policy cmd_parts << "--restart #{config[:restart_policy]}" # Image cmd_parts << config[:image] # Command (if specified) cmd_parts << config[:command] if config[:command] cmd_parts.join(" ") end |
#deploy(image:, environment:, services: []) ⇒ void
This method returns an undefined value.
Deploy container images to AWS EC2 instances via SSM
47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 47 def deploy(image:, environment:, services: []) instances = (environment) commands = build_deployment_commands(image, services) logger.info "Deploying #{image} to #{instances.size} AWS instances" response = execute_deployment_command(instances, commands, image) wait_for_command_completion(response.command.command_id, instances) verify_service_health(services) logger.info "Deployment completed successfully" end |
#execute_container_start(docker_cmd) ⇒ String (private)
Execute container start command and return container ID
624 625 626 627 628 629 630 631 632 633 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 624 def execute_container_start(docker_cmd) # Check if we need ECR authentication if docker_cmd.include?('.dkr.ecr.') @logger.debug "ECR registry detected, ensuring authentication" ecr_registries = extract_ecr_registries(docker_cmd) authenticate_ecr_registries(ecr_registries) end execute_ssm_command_with_response(docker_cmd).strip end |
#execute_deployment_command(instances, commands, image) ⇒ Object (private)
180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 180 def execute_deployment_command(instances, commands, image) @ssm.send_command( instance_ids: instances, document_name: "AWS-RunShellScript", parameters: { "commands" => commands, "executionTimeout" => ["3600"] }, comment: "Deploy #{image} via Gjallarhorn" ) end |
#execute_in_container(container_id, command) ⇒ String (private)
Execute command in a running container
260 261 262 263 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 260 def execute_in_container(container_id, command) docker_cmd = "docker exec #{container_id} #{command}" execute_ssm_command_with_response(docker_cmd) end |
#execute_ssm_command(command) ⇒ void (private)
This method returns an undefined value.
Execute SSM command without returning response (fire and forget)
514 515 516 517 518 519 520 521 522 523 524 525 526 527 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 514 def execute_ssm_command(command) instances = target_instances @logger.debug "execute_ssm_command: Using instances: #{instances.inspect}" @logger.debug "execute_ssm_command: Command: #{command}" @ssm.send_command( instance_ids: instances, document_name: "AWS-RunShellScript", parameters: { "commands" => [command], "executionTimeout" => ["300"] } ) end |
#execute_ssm_command_with_response(command) ⇒ String (private)
Execute SSM command and return response
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 370 def execute_ssm_command_with_response(command) instances = target_instances @logger.debug "execute_ssm_command_with_response: Using instances: #{instances.inspect}" @logger.debug "execute_ssm_command_with_response: Command: #{command}" response = @ssm.send_command( instance_ids: instances, document_name: "AWS-RunShellScript", parameters: { "commands" => [command], "executionTimeout" => ["300"] } ) command_id = response.command.command_id wait_for_command_completion(command_id, target_instances) # Get command output get_command_output(command_id) end |
#extract_container_config(container_config) ⇒ Hash (private)
Extract and normalize container configuration
607 608 609 610 611 612 613 614 615 616 617 618 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 607 def extract_container_config(container_config) { name: container_config[:name], image: container_config[:image], ports: container_config[:ports] || [], env: container_config[:env] || {}, volumes: container_config[:volumes] || [], command: container_config[:command], labels: container_config[:labels] || {}, restart_policy: container_config[:restart_policy] || "unless-stopped" } end |
#extract_ecr_registries(docker_cmd) ⇒ Array<Hash> (private)
Extract ECR registries from Docker command
564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 564 def extract_ecr_registries(docker_cmd) registries = [] # Match ECR registry URLs: account.dkr.ecr.region.amazonaws.com ecr_pattern = /(\d+)\.dkr\.ecr\.([^.]+)\.amazonaws\.com/ docker_cmd.scan(ecr_pattern) do |account_id, region| registry_url = "#{account_id}.dkr.ecr.#{region}.amazonaws.com" registries << { account_id: account_id, region: region, registry_url: registry_url } end registries.uniq end |
#finalize_container_setup(container_id, config) ⇒ Hash (private)
Finalize container setup after creation
640 641 642 643 644 645 646 647 648 649 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 640 def finalize_container_setup(container_id, config) wait_for_container_running(container_id) container_info = get_container_info(container_id) container_info.merge( name: config[:name], image: config[:image], created_at: Time.now.utc ) end |
#get_all_containers(service_name) ⇒ Array<Hash> (private)
Get all containers for a service (including stopped)
219 220 221 222 223 224 225 226 227 228 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 219 def get_all_containers(service_name) cmd = [ "docker ps -a", "--filter label=gjallarhorn.service=#{service_name}", "--format '{{.ID}}:{{.Names}}:{{.Status}}:{{.CreatedAt}}'" ].join(" ") output = execute_ssm_command_with_response(cmd) parse_container_list(output, service_name) end |
#get_command_output(command_id) ⇒ String (private)
Get command output from SSM
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 395 def get_command_output(command_id) instances = target_instances @logger.debug "get_command_output: Using instances: #{instances.inspect}" @logger.debug "get_command_output: First instance: #{instances.first.inspect}" # Get output from first instance (simplified) result = @ssm.get_command_invocation( command_id: command_id, instance_id: instances.first ) if result.status_details == "Success" result.standard_output_content || "" else error_msg = result.standard_error_content || "Command failed" raise DeploymentError, "SSM command failed: #{error_msg}" end end |
#get_container_health(container_id) ⇒ Boolean (private)
Get container health status
269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 269 def get_container_health(container_id) health_output = execute_ssm_command_with_response( "docker inspect #{container_id} --format '{{.State.Health.Status}}'" ) health_status = health_output.strip.downcase health_status == "healthy" rescue StandardError => e @logger.debug "Health check failed for #{container_id}: #{e.}" # If no health check is configured, check if container is running get_container_status(container_id) == "running" end |
#get_container_info(container_id) ⇒ Hash (private)
Get detailed container information
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 299 def get_container_info(container_id) # Get container IP address ip_cmd = "docker inspect #{container_id} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" ip_address = execute_ssm_command_with_response(ip_cmd).strip # Get container port mappings ports_cmd = "docker port #{container_id}" ports_output = execute_ssm_command_with_response(ports_cmd) { id: container_id, ip: ip_address.empty? ? nil : ip_address, ports: parse_container_ports(ports_output), host: target_instances.first # Simplified - use first instance } rescue StandardError => e @logger.warn "Failed to get container info for #{container_id}: #{e.}" { id: container_id, ip: nil, ports: [], host: target_instances.first } end |
#get_container_status(container_id) ⇒ String (private)
Get container status
286 287 288 289 290 291 292 293 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 286 def get_container_status(container_id) status_output = execute_ssm_command_with_response( "docker inspect #{container_id} --format '{{.State.Status}}'" ) status_output.strip.downcase rescue StandardError "unknown" end |
#get_instance_status(instance_id) ⇒ Object (private)
207 208 209 210 211 212 213 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 207 def get_instance_status(instance_id) resp = @ec2.describe_instances(instance_ids: [instance_id]) instance = resp.reservations.first&.instances&.first instance&.state&.name || "unknown" rescue StandardError "unknown" end |
#get_instances_by_tags(environment) ⇒ Object (private)
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 134 def (environment) @logger.debug "Querying EC2 instances with filters: Environment=#{environment}, Role=web|app, state=running" resp = @ec2.describe_instances( filters: [ { name: "tag:Environment", values: [environment] }, { name: "tag:Role", values: %w[web app] }, { name: "instance-state-name", values: ["running"] } ] ) instances = resp.reservations.flat_map(&:instances) @logger.debug "Found #{instances.length} instances matching filters" instances.each do |instance| = begin if instance.respond_to?(:tags) && instance. instance..map { |t| "#{t.key}=#{t.value}" }.join(', ') else "N/A" end rescue StandardError "N/A" end @logger.debug "Instance: #{instance.instance_id}, State: #{instance.state.name}, Tags: #{}" end instance_ids = instances.map(&:instance_id) @logger.debug "Returning instance IDs: #{instance_ids.join(', ')}" if instance_ids.any? instance_ids end |
#get_running_containers(service_name) ⇒ Array<Hash>
Get running containers for a service
120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 120 def get_running_containers(service_name) # List containers with service label/name pattern cmd = [ "docker ps", "--filter label=gjallarhorn.service=#{service_name}", "--format '{{.ID}}:{{.Names}}:{{.Status}}:{{.CreatedAt}}'" ].join(" ") output = execute_ssm_command_with_response(cmd) parse_container_list(output, service_name) end |
#health_check ⇒ Boolean
Implement actual health check via SSM
Check health of a service (simplified implementation)
88 89 90 91 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 88 def health_check(*) # Implement health check via SSM command true # Simplified end |
#parse_container_list(output, service_name) ⇒ Array<Hash> (private)
Parse container list output
419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 419 def parse_container_list(output, service_name) containers = [] output.split("\n").each do |line| next if line.strip.empty? parts = line.split(":") next unless parts.length >= 3 containers << { id: parts[0], name: parts[1], status: parts[2], created_at: (parts[3]), service: service_name } end containers end |
#parse_container_ports(ports_output) ⇒ Array<String> (private)
Parse container port mappings
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 444 def parse_container_ports(ports_output) ports = [] ports_output.split("\n").each do |line| next if line.strip.empty? # Format: "3000/tcp -> 0.0.0.0:3000" next unless line.match(%r{(\d+)/tcp -> [\d.]+:(\d+)}) container_port = ::Regexp.last_match(1) host_port = ::Regexp.last_match(2) ports << "#{host_port}:#{container_port}" end ports end |
#parse_container_timestamp(timestamp_str) ⇒ Time (private)
Parse container creation timestamp
465 466 467 468 469 470 471 472 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 465 def () return Time.now.utc unless # Docker timestamp format: "2024-01-15 10:30:45 +0000 UTC" Time.parse().utc rescue StandardError Time.now.utc end |
#remove_container(container_id) ⇒ void (private)
This method returns an undefined value.
Remove a container
250 251 252 253 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 250 def remove_container(container_id) @logger.info "Removing container: #{container_id}" execute_ssm_command("docker rm #{container_id}") end |
#rollback(version:) ⇒ void
Implement rollback functionality
This method returns an undefined value.
Rollback to a previous version (placeholder implementation)
65 66 67 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 65 def rollback(version:) # Similar implementation for rollback end |
#set_environment(environment) ⇒ void
This method returns an undefined value.
Set the current deployment environment
97 98 99 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 97 def set_environment(environment) @current_environment = environment end |
#shell_escape(value) ⇒ String (private)
Escape shell arguments
506 507 508 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 506 def shell_escape(value) "'#{value.to_s.gsub("'", "'\\''")}'" end |
#start_container(container_config) ⇒ Hash
Start a new container with enhanced configuration
105 106 107 108 109 110 111 112 113 114 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 105 def start_container(container_config) config = extract_container_config(container_config) docker_cmd = build_docker_run_command(config) @logger.info "Starting container: #{config[:name]}" @logger.debug "Docker command: #{docker_cmd}" container_id = execute_container_start(docker_cmd) finalize_container_setup(container_id, config) end |
#status ⇒ Array<Hash>
Get status of all instances in the environment
72 73 74 75 76 77 78 79 80 81 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 72 def status environment = config["environment"] || config[:environment] || "production" instances = (environment) instances.map do |instance_id| { instance: instance_id, status: get_instance_status(instance_id) } end end |
#stop_container(container_id, graceful: true, timeout: 30) ⇒ void (private)
This method returns an undefined value.
Stop a container
236 237 238 239 240 241 242 243 244 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 236 def stop_container(container_id, graceful: true, timeout: 30) if graceful @logger.info "Gracefully stopping container: #{container_id}" execute_ssm_command("docker stop --time #{timeout} #{container_id}") else @logger.info "Force stopping container: #{container_id}" execute_ssm_command("docker kill #{container_id}") end end |
#target_instances(environment = nil) ⇒ Array<String> (private)
Get target instances for the current environment
533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 533 def target_instances(environment = nil) # Check if instance IDs are explicitly configured instance_ids = @config["instance_ids"] || @config[:instance_ids] || @config["instance-ids"] || @config[:"instance-ids"] if instance_ids && !instance_ids.empty? # Use explicitly configured instance IDs instance_ids = [instance_ids] unless instance_ids.is_a?(Array) @logger.debug "Using configured instance IDs: #{instance_ids.join(', ')}" return instance_ids end # Fall back to tag-based discovery # Use provided environment parameter, current environment, config, or default to production env_name = environment || @current_environment || @config["environment"] || @config[:environment] || "production" @logger.debug "No instance IDs configured, discovering instances by tags for environment: #{env_name}" discovered_instances = (env_name) if discovered_instances.empty? raise ArgumentError, "No EC2 instances found for environment '#{env_name}'. " \ "Either configure 'instance_ids' in your deploy.yml or ensure your EC2 " \ "instances are tagged with Environment=#{env_name} and Role=web|app" end discovered_instances end |
#verify_service_health(services) ⇒ Object (private)
192 193 194 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 192 def verify_service_health(services) services.each { |service| wait_for_health(service) } end |
#wait_for_command_completion(command_id, instances) ⇒ Object (private)
196 197 198 199 200 201 202 203 204 205 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 196 def wait_for_command_completion(command_id, instances) # Use the first instance for command completion check instance_id = instances.is_a?(Array) ? instances.first : instances @logger.debug "wait_for_command_completion: Using instance #{instance_id} for command #{command_id}" @ssm.wait_until(:command_executed, command_id: command_id, instance_id: instance_id) do |w| w.max_attempts = 60 w.delay = 5 end end |
#wait_for_container_running(container_id, timeout = 60) ⇒ void (private)
This method returns an undefined value.
Wait for container to be running
479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 |
# File 'lib/gjallarhorn/adapter/aws.rb', line 479 def wait_for_container_running(container_id, timeout = 60) start_time = Time.now loop do status = get_container_status(container_id) if status == "running" @logger.info "Container #{container_id} is running" return elsif status == "exited" raise DeploymentError, "Container #{container_id} exited unexpectedly" end elapsed = Time.now - start_time if elapsed >= timeout raise DeploymentError, "Container #{container_id} failed to start within #{timeout}s (status: #{status})" end @logger.debug "Container #{container_id} status: #{status}, waiting..." sleep 2 end end |