aws_ec2_environment 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []