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.
- 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: []
|