aws_ec2_environment 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,165 @@
1
+ require "aws-sdk-ec2"
2
+ require "socket"
3
+ require "yaml"
4
+
5
+ class AwsEc2Environment
6
+ class Error < StandardError; end
7
+ class BastionNotExpectedError < Error; end
8
+ class BastionNotFoundError < Error; end
9
+ class EnvironmentConfigNotFound < Error; end
10
+
11
+ require_relative "aws_ec2_environment/ssm_port_forwarding_session"
12
+ require_relative "aws_ec2_environment/ci_service"
13
+ require_relative "aws_ec2_environment/config"
14
+ require_relative "aws_ec2_environment/version"
15
+
16
+ attr_reader :config
17
+
18
+ def self.from_yaml_file(path, env_name)
19
+ config = YAML.safe_load_file(path).fetch(env_name.to_s, nil)
20
+
21
+ raise EnvironmentConfigNotFound, "#{path} does not have an environment named \"#{env_name}\"" if config.nil?
22
+
23
+ new(AwsEc2Environment::Config.new(env_name, config))
24
+ end
25
+
26
+ def initialize(config, logger: Logger.new($stdout))
27
+ @config = config
28
+ @logger = logger
29
+ @ssm_port_forwarding_sessions = []
30
+ end
31
+
32
+ # Lists the IDs of the EC2 instances matched by the environment instance filters
33
+ #
34
+ # If an instance does not have a public ip, its private ip will be used instead.
35
+ def ips
36
+ ips = ec2_instances.map { |instance| instance.public_ip_address || instance.private_ip_address }
37
+
38
+ log "found the following instances: #{ips.join(", ")}"
39
+
40
+ ips
41
+ end
42
+
43
+ # Lists the IDs of the EC2 instances matched by the environment instance filters
44
+ def ids
45
+ ids = ec2_instances.map(&:instance_id)
46
+
47
+ log "found the following instances: #{ids.join(", ")}"
48
+
49
+ ids
50
+ end
51
+
52
+ # Lists the hosts to use for sshing into the EC2 instances matched by the environment instance filters.
53
+ #
54
+ # If SSM should be used to connect to the instances, then porting sessions will created.
55
+ def hosts_for_sshing
56
+ return ips unless @config.use_ssm
57
+
58
+ log "using SSM to connect to instances"
59
+
60
+ reason = ssm_session_reason
61
+
62
+ ids.map { |id| "#{@config.ssm_host.gsub("\#{id}", id)}:#{start_ssh_port_forwarding_session(id, reason)}" }
63
+ end
64
+
65
+ def use_bastion_server?
66
+ !@config.bastion_filters.nil?
67
+ end
68
+
69
+ # Finds the public ip of the bastion instance for this environment.
70
+ #
71
+ # An error will be thrown if any of the following are true:
72
+ # - no bastion filters have been provided (indicating a bastion should not be used)
73
+ # - no instances are matched
74
+ # - multiple instance are matched
75
+ # - the matched instance does not have a public ip
76
+ def bastion_public_ip
77
+ if @config.bastion_filters.nil?
78
+ raise BastionNotExpectedError, "The #{@config.env_name} environment is not configured with a bastion"
79
+ end
80
+
81
+ instances = ec2_describe_instances(@config.bastion_filters)
82
+
83
+ if instances.length != 1
84
+ raise(
85
+ BastionNotFoundError,
86
+ "#{instances.length} potential bastion instances were found - " \
87
+ "please ensure your filters are specific enough to only return a single instance"
88
+ )
89
+ end
90
+
91
+ ip_address = instances[0].public_ip_address
92
+
93
+ if ip_address.nil?
94
+ raise BastionNotFoundError, "a potential bastion instance was found, but it does not have a public ip"
95
+ end
96
+
97
+ log "using bastion with ip #{ip_address}"
98
+
99
+ ip_address
100
+ end
101
+
102
+ # Builds a +ProxyCommand+ that can be used with +ssh+ to connect through the bastion instance,
103
+ # which can also be used with tools like +Capistrano+.
104
+ #
105
+ # Calling this command implies that a bastion server is expected to exist,
106
+ # so an error is thrown if one cannot be found.
107
+ #
108
+ # Usage with +Capistrano+:
109
+ #
110
+ # <code>
111
+ # set :ssh_options, proxy: Net::SSH::Proxy::Command.new(instances.build_ssh_bastion_proxy_command)
112
+ # </code>
113
+ def build_ssh_bastion_proxy_command
114
+ "ssh -o StrictHostKeyChecking=no #{@config.bastion_ssh_user}@#{bastion_public_ip} -W %h:%p"
115
+ end
116
+
117
+ def stop_ssh_port_forwarding_sessions
118
+ @ssm_port_forwarding_sessions.each(&:close)
119
+ end
120
+
121
+ private
122
+
123
+ def start_ssh_port_forwarding_session(instance_id, reason)
124
+ session = AwsEc2Environment::SsmPortForwardingSession.new(instance_id, 22, logger: @logger, reason: reason)
125
+
126
+ @ssm_port_forwarding_sessions << session
127
+
128
+ session.wait_for_local_port
129
+ end
130
+
131
+ def ssm_session_reason
132
+ service = AwsEc2Environment::CiService.detect
133
+
134
+ # if we're in a CI service, the build id should make it possible to find
135
+ # the details & logs of this specific run, which will have all the info
136
+ return "#{service[:name]}, build #{service[:build_id]}" unless service.nil?
137
+
138
+ # use the hostname if we're not on a CI service, as it's usually a good
139
+ # way to identify what physical machine the SSM session was created on
140
+ hostname = Socket.gethostname
141
+
142
+ # knowing the user can generally be a better starting point than the hostname,
143
+ # so include that too if possible (note, "USERNAME" is for Windows)
144
+ username = ENV.fetch("USER", ENV.fetch("USERNAME", "<unknown>"))
145
+
146
+ "#{username}@#{hostname}"
147
+ end
148
+
149
+ # @return [Aws::EC2::Client]
150
+ def ec2
151
+ @ec2 ||= Aws::EC2::Client.new(region: @config.aws_region)
152
+ end
153
+
154
+ def ec2_describe_instances(filters)
155
+ ec2.describe_instances(filters: filters).reservations.flat_map(&:instances)
156
+ end
157
+
158
+ def ec2_instances
159
+ ec2_describe_instances(@config.instance_filters)
160
+ end
161
+
162
+ def log(msg)
163
+ @logger.info "[#{@config.env_name} #{@config.aws_region}] : #{msg}"
164
+ end
165
+ end
@@ -0,0 +1,7 @@
1
+ class AwsEc2Environment
2
+ class CiService
3
+ CI_SERVICES: Array[{ name: String, detect: String | ^() -> bool, build_id_var: String }]
4
+
5
+ def self.detect: () -> { name: String, build_id: String }?
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # Holds the details about an application environment composed primarily of EC2 instances, including
2
+ # - what region they're in
3
+ # - the user to use for sshing
4
+ # - how to identify those instances
5
+ # - how to identify the bastion instance to use to connect (if any)
6
+ # - if SSM should be used to connect to the instances
7
+ class AwsEc2Environment
8
+ class Config
9
+ attr_reader env_name: Symbol
10
+ attr_reader aws_region: String
11
+ attr_reader ssh_user: String
12
+ attr_reader instance_filters: Array[Aws::EC2::Types::filter]
13
+ attr_reader bastion_ssh_user: String
14
+ attr_reader bastion_filters: Array[Aws::EC2::Types::filter]?
15
+ attr_reader use_ssm: bool
16
+ attr_reader ssm_host: String
17
+
18
+ def initialize: (Symbol env_name, Hash[String, untyped] attrs) -> void
19
+
20
+ private
21
+
22
+ def fetch_bastion_details: (Hash[String, untyped] attrs) -> [Array[Aws::EC2::Types::filter]?, String]
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ class AwsEc2Environment
2
+ class SsmPortForwardingSession
3
+ class SessionIdNotFoundError < Error
4
+ end
5
+
6
+ class SessionTimedOutError < Error
7
+ end
8
+
9
+ class SessionProcessError < Error
10
+ end
11
+
12
+ attr_reader instance_id: String
13
+ attr_reader remote_port: Integer
14
+ attr_reader pid: String
15
+
16
+ def initialize: (
17
+ String instance_id,
18
+ Integer remote_port,
19
+ ?local_port: Integer | nil,
20
+ ?logger: Logger,
21
+ ?timeout: Numeric,
22
+ ?reason: String | nil
23
+ ) -> void
24
+
25
+ def close: () -> void
26
+
27
+ def wait_for_local_port: () -> Integer
28
+
29
+ private
30
+
31
+ @logger: Logger
32
+ @instance_id: String
33
+ @session_id: String
34
+ @remote_port: Integer
35
+ @local_port: Integer?
36
+ @timeout: Numeric
37
+ @reader: IO
38
+ @writer: IO
39
+ @cmd_output: String
40
+
41
+ def ssm_port_forward_cmd: (Integer | nil local_port, String | nil reason) -> String
42
+
43
+ # Checks the cmd process output until either the given +pattern+ matches or the +timeout+ is over.
44
+ #
45
+ # It returns an array with the result of the match or otherwise +nil+.
46
+ #
47
+ # This is effectively a re-implementation of the +File#expect+ method except it captures
48
+ # the cmd process output over time so we can include it in the case of errors
49
+ def expect_cmd_output: (Regexp pattern, Numeric timeout) -> (Array[String] | nil)
50
+
51
+ # Updates the tracked output of the cmd process with any new data that is available in the buffer,
52
+ # until the next read will block at which point this method returns.
53
+ def update_cmd_output: () -> void
54
+
55
+ def wait_for_session_id: () -> String
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ class AwsEc2Environment
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class BastionNotExpectedError < Error
8
+ end
9
+
10
+ class BastionNotFoundError < Error
11
+ end
12
+
13
+ class EnvironmentConfigNotFound < Error
14
+ end
15
+
16
+ attr_reader config: Config
17
+
18
+ def self.from_yaml_file: (String path, Symbol env_name) -> AwsEc2Environment
19
+
20
+ def initialize: (Config config, logger: Logger) -> void
21
+
22
+ def ips: () -> Array[String]
23
+
24
+ def ids: () -> Array[String]
25
+
26
+ def hosts_for_sshing: () -> Array[String]
27
+
28
+ def use_bastion_server?: () -> bool
29
+
30
+ def bastion_public_ip: () -> String
31
+
32
+ def build_ssh_bastion_proxy_command: () -> String
33
+
34
+ def stop_ssh_port_forwarding_sessions: () -> void
35
+
36
+ private
37
+
38
+ @config: Config
39
+ @logger: Logger
40
+ @ssm_port_forwarding_sessions: Array[SsmPortForwardingSession]
41
+ @ec2: Aws::EC2::Client | nil
42
+
43
+ def start_ssh_port_forwarding_session: (String instance_id) -> Integer
44
+
45
+ def ssm_session_reason: () -> String
46
+
47
+ def ec2: () -> Aws::EC2::Client
48
+
49
+ def ec2_describe_instances: (Array[Aws::EC2::Types::filter] filters) -> Array[Aws::EC2::Instance]
50
+
51
+ def ec2_instances: () -> Array[Aws::EC2::Instance]
52
+
53
+ def log: (String msg) -> bool
54
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aws_ec2_environment
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Gareth Jones
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-08-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk-ec2
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: aws-sdk-ssm
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ description:
42
+ email:
43
+ - open-source@ackama.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".editorconfig"
49
+ - ".prettierignore"
50
+ - ".prettierrc.json"
51
+ - ".rspec"
52
+ - ".rubocop.yml"
53
+ - CHANGELOG.md
54
+ - CODE_OF_CONDUCT.md
55
+ - CONTRIBUTING.md
56
+ - Gemfile
57
+ - Gemfile.lock
58
+ - LICENSE.txt
59
+ - README.md
60
+ - Rakefile
61
+ - lib/aws_ec2_environment.rb
62
+ - lib/aws_ec2_environment/ci_service.rb
63
+ - lib/aws_ec2_environment/config.rb
64
+ - lib/aws_ec2_environment/ssm_port_forwarding_session.rb
65
+ - lib/aws_ec2_environment/version.rb
66
+ - sig/aws_ec2_environment.rbs
67
+ - sig/aws_ec2_environment/ci_service.rbs
68
+ - sig/aws_ec2_environment/config.rbs
69
+ - sig/aws_ec2_environment/ssm_port_forwarding_session.rbs
70
+ homepage: https://github.com/ackama/aws_ec2_environment
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/ackama/aws_ec2_environment
75
+ source_code_uri: https://github.com/ackama/aws_ec2_environment
76
+ changelog_uri: https://github.com/ackama/aws_ec2_environment
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.0.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.3.26
94
+ signing_key:
95
+ specification_version: 4
96
+ summary: Interact with AWS EC2-based Ruby apps easily
97
+ test_files: []