aws_ec2_environment 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +11 -0
- data/.prettierignore +6 -0
- data/.prettierrc.json +15 -0
- data/.rspec +2 -0
- data/.rubocop.yml +242 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +78 -0
- data/CONTRIBUTING.md +78 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +105 -0
- data/LICENSE.txt +21 -0
- data/README.md +296 -0
- data/Rakefile +12 -0
- data/lib/aws_ec2_environment/ci_service.rb +151 -0
- data/lib/aws_ec2_environment/config.rb +47 -0
- data/lib/aws_ec2_environment/ssm_port_forwarding_session.rb +136 -0
- data/lib/aws_ec2_environment/version.rb +5 -0
- data/lib/aws_ec2_environment.rb +165 -0
- data/sig/aws_ec2_environment/ci_service.rbs +7 -0
- data/sig/aws_ec2_environment/config.rbs +24 -0
- data/sig/aws_ec2_environment/ssm_port_forwarding_session.rbs +57 -0
- data/sig/aws_ec2_environment.rbs +54 -0
- metadata +97 -0
@@ -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,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: []
|