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.
@@ -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