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
data/README.md
ADDED
@@ -0,0 +1,296 @@
|
|
1
|
+
# AwsEc2Environment
|
2
|
+
|
3
|
+
A gem that makes it easier to interact with and deploy Ruby projects that are
|
4
|
+
hosted on EC2 instances in AWS.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Install the gem and add to the application's Gemfile by executing:
|
9
|
+
|
10
|
+
$ bundle add aws_ec2_environment
|
11
|
+
|
12
|
+
If bundler is not being used to manage dependencies, install the gem by
|
13
|
+
executing:
|
14
|
+
|
15
|
+
$ gem install aws_ec2_environment
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
Use `AwsEc2Environment.from_yaml_file` to create a new representation of your
|
20
|
+
EC2 environment from a config file:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
ec2_env = AwsEc2Environment.from_yaml_file("./aws.yml", :production)
|
24
|
+
|
25
|
+
# this will ensure that any post-connection cleanup is handled, such as terminating
|
26
|
+
# any SSM port forwarding sessions that are active
|
27
|
+
at_exit { ec2_env.stop_ssh_port_forwarding_sessions } if ec2_env.config.use_ssm
|
28
|
+
|
29
|
+
# this will return a list of hosts for sshing, handling any pre-connection setup
|
30
|
+
# such as starting port forwarding sessions for each instance if SSM is enabled.
|
31
|
+
ec2_env.hosts_for_sshing
|
32
|
+
```
|
33
|
+
|
34
|
+
### Configuration
|
35
|
+
|
36
|
+
This is the most basic configuration you can have:
|
37
|
+
|
38
|
+
```yaml
|
39
|
+
production:
|
40
|
+
aws_region: ap-southeast-2
|
41
|
+
ssh_user: deploy
|
42
|
+
filters:
|
43
|
+
- name: 'instance-state-name'
|
44
|
+
values: ['running']
|
45
|
+
- name: 'tag:Name'
|
46
|
+
values: ['MyWebsiteProductionAppServerAsg']
|
47
|
+
```
|
48
|
+
|
49
|
+
All the top level properties are required, and the `filters` key holds an array
|
50
|
+
of filters that are used with the
|
51
|
+
[`DescribeInstances` API endpoint](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html).
|
52
|
+
|
53
|
+
### With bastion hosts
|
54
|
+
|
55
|
+
You can specify filters for a bastion instance too:
|
56
|
+
|
57
|
+
```yaml
|
58
|
+
production:
|
59
|
+
aws_region: ap-southeast-2
|
60
|
+
ssh_user: deploy
|
61
|
+
filters:
|
62
|
+
- name: 'instance-state-name'
|
63
|
+
values: ['running']
|
64
|
+
- name: 'tag:Name'
|
65
|
+
values: ['MyWebsiteProductionAppServerAsg']
|
66
|
+
bastion_instance:
|
67
|
+
ssh_user: bastion
|
68
|
+
filters:
|
69
|
+
- name: 'instance-state-name'
|
70
|
+
values: ['running']
|
71
|
+
- name: 'tag:Name'
|
72
|
+
values: ['MyWebsiteProductionBastionAsg']
|
73
|
+
```
|
74
|
+
|
75
|
+
Note that the filters should result in _one_ instance being returned, otherwise
|
76
|
+
an error will be thrown.
|
77
|
+
|
78
|
+
If you use the same user as your application servers, you can pass an array of
|
79
|
+
filters as the value of the top-level property:
|
80
|
+
|
81
|
+
```yaml
|
82
|
+
production:
|
83
|
+
aws_region: ap-southeast-2
|
84
|
+
ssh_user: deploy
|
85
|
+
filters:
|
86
|
+
- name: 'instance-state-name'
|
87
|
+
values: ['running']
|
88
|
+
- name: 'tag:Name'
|
89
|
+
values: ['MyWebsiteProductionAppServerAsg']
|
90
|
+
bastion_instance:
|
91
|
+
- name: 'instance-state-name'
|
92
|
+
values: ['running']
|
93
|
+
- name: 'tag:Name'
|
94
|
+
values: ['MyWebsiteProductionBastionAsg']
|
95
|
+
```
|
96
|
+
|
97
|
+
#### With SSM
|
98
|
+
|
99
|
+
If your instances have the
|
100
|
+
[SSM Agent](https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-agent.html)
|
101
|
+
(preinstalled on some
|
102
|
+
[AMIs](https://docs.aws.amazon.com/systems-manager/latest/userguide/ami-preinstalled-agent.html)),
|
103
|
+
you can use SSM to connect directly to instances even if they're in a private
|
104
|
+
subnet, via port forwarding:
|
105
|
+
|
106
|
+
```yaml
|
107
|
+
production:
|
108
|
+
aws_region: ap-southeast-2
|
109
|
+
ssh_user: deploy
|
110
|
+
ssm_host: 'ec2.#{id}.local.ackama.app'
|
111
|
+
use_ssm: true
|
112
|
+
filters:
|
113
|
+
- name: 'instance-state-name'
|
114
|
+
values: ['running']
|
115
|
+
- name: 'tag:Name'
|
116
|
+
values: ['MyWebsiteProductionAppServerAsg']
|
117
|
+
```
|
118
|
+
|
119
|
+
> This requires the
|
120
|
+
> [`aws`](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html)
|
121
|
+
> CLI and
|
122
|
+
> [`session-manager-plugin`](https://github.com/aws/session-manager-plugin) to
|
123
|
+
> be installed locally. These both come preinstalled on GitHub Actions runners,
|
124
|
+
> and are otherwise easy to install manually.
|
125
|
+
>
|
126
|
+
> - [Installing `aws` cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)
|
127
|
+
> - [Installing `session-manager-plugin`](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)
|
128
|
+
|
129
|
+
You can also specify an alternative hostname to use instead of `127.0.0.1` with
|
130
|
+
the `ssm_host` property - this is useful when working with tools like Capistrano
|
131
|
+
that only log the host _name_, so this property can let you ensure each instance
|
132
|
+
can be identified in the logs.
|
133
|
+
|
134
|
+
This property should be a host that resolves to `127.0.0.1`, and you can inject
|
135
|
+
the instance id with `#{id}`.
|
136
|
+
|
137
|
+
> Ackama provides `ec2.*.local.ackama.app` for this
|
138
|
+
|
139
|
+
### With Capistrano
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
# ./Capfile
|
143
|
+
# ...
|
144
|
+
require "aws_ec2_environment"
|
145
|
+
|
146
|
+
# ./config/deploy/production.rb
|
147
|
+
set :rails_env, "production"
|
148
|
+
set :branch, "production"
|
149
|
+
|
150
|
+
ec2_env = AwsEc2Environment.from_yaml_file("./aws.yml", :production)
|
151
|
+
|
152
|
+
at_exit { ec2_env.stop_ssh_port_forwarding_sessions } if ec2_env.config.use_ssm
|
153
|
+
|
154
|
+
ssh_options = {}
|
155
|
+
|
156
|
+
if ec2_env.use_bastion_server?
|
157
|
+
ssh_options[:proxy] = Net::SSH::Proxy::Command.new(ec2_env.build_ssh_bastion_proxy_command)
|
158
|
+
end
|
159
|
+
|
160
|
+
set :ssh_options, ssh_options
|
161
|
+
|
162
|
+
role(:app, ec2_env.hosts_for_sshing, user: ec2_env.config.ssh_user)
|
163
|
+
```
|
164
|
+
|
165
|
+
### With custom port forwarding
|
166
|
+
|
167
|
+
You can also use the `SsmPortForwardingSession` class directly to do port
|
168
|
+
forwarding, which can be useful for things like custom rake tasks:
|
169
|
+
|
170
|
+
```ruby
|
171
|
+
require "aws_ec2_environment"
|
172
|
+
|
173
|
+
task :forward_port, %i[instance_id remote_port local_port] => :environment do |_, args|
|
174
|
+
# trap ctl+c to make things a bit nicer (otherwise we'll get an ugly stacktrace)
|
175
|
+
# since we expect this to be used to terminate the command
|
176
|
+
trap("SIGINT") { exit }
|
177
|
+
|
178
|
+
logger = Logger.new($stdout)
|
179
|
+
|
180
|
+
instance_id = args.fetch(:instance_id)
|
181
|
+
remote_port = args.fetch(:remote_port)
|
182
|
+
local_port = args.fetch(:local_port, nil)
|
183
|
+
|
184
|
+
session = AwsEc2Environment::SsmPortForwardingSession.new(
|
185
|
+
instance_id,
|
186
|
+
remote_port,
|
187
|
+
local_port:,
|
188
|
+
logger:
|
189
|
+
)
|
190
|
+
|
191
|
+
at_exit { session.close }
|
192
|
+
|
193
|
+
local_port = session.wait_for_local_port
|
194
|
+
|
195
|
+
local_alias = "ec2.#{instance_id}.local.ackama.app:#{local_port}"
|
196
|
+
logger.info "Use #{local_alias} to communicate with port #{remote_port} on #{instance_id}"
|
197
|
+
|
198
|
+
loop { sleep 1 }
|
199
|
+
end
|
200
|
+
```
|
201
|
+
|
202
|
+
### AWS Authentication and Permissions
|
203
|
+
|
204
|
+
Since this gem interacts with AWS, it must be configured with credentials - see
|
205
|
+
[here](https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/setup-config.html)
|
206
|
+
for how to do that.
|
207
|
+
|
208
|
+
> We recommend using
|
209
|
+
> [OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)
|
210
|
+
> to authenticate with AWS when running in GitHub Actions.
|
211
|
+
|
212
|
+
The credentials must be for an identity that is allowed to perform the
|
213
|
+
`ec2:DescribeInstances` action. If you're using SSM you must also allow the
|
214
|
+
`ssm:StartSession` and `ssm:TerminateSession` actions.
|
215
|
+
|
216
|
+
Here is a sample IAM policy document that grants these actions conditionally in
|
217
|
+
accordance with the principle of least privilege:
|
218
|
+
|
219
|
+
```json
|
220
|
+
{
|
221
|
+
"Version": "2012-10-17",
|
222
|
+
"Statement": [
|
223
|
+
{
|
224
|
+
"Sid": "AllowDescribingInstances",
|
225
|
+
"Effect": "Allow",
|
226
|
+
"Action": "ec2:DescribeInstances",
|
227
|
+
"Resource": "*"
|
228
|
+
},
|
229
|
+
{
|
230
|
+
"Sid": "AllowStartingPortForwardingSessions",
|
231
|
+
"Effect": "Allow",
|
232
|
+
"Action": "ssm:StartSession",
|
233
|
+
"Resource": "arn:aws:ssm:*::document/AWS-StartPortForwardingSession"
|
234
|
+
},
|
235
|
+
{
|
236
|
+
"Sid": "AllowStartingNewSessionsOnTaggedEC2Instances",
|
237
|
+
"Effect": "Allow",
|
238
|
+
"Action": "ssm:StartSession",
|
239
|
+
"Resource": "arn:aws:ec2:*:account-id:instance/*",
|
240
|
+
"Condition": {
|
241
|
+
"StringEquals": {
|
242
|
+
"ssm:resourceTag/Environment": "Production",
|
243
|
+
"ssm:resourceTag/Name": "MyWebsiteProductionAppServerAsg"
|
244
|
+
}
|
245
|
+
}
|
246
|
+
},
|
247
|
+
{
|
248
|
+
"Sid": "AllowTerminatingOwnSessions",
|
249
|
+
"Effect": "Allow",
|
250
|
+
"Action": "ssm:TerminateSession",
|
251
|
+
"Resource": "arn:aws:ssm:*:account-id:session/*",
|
252
|
+
"Condition": {
|
253
|
+
"StringLike": {
|
254
|
+
"ssm:resourceTag/aws:ssmmessages:session-id": "${aws:username}"
|
255
|
+
}
|
256
|
+
}
|
257
|
+
}
|
258
|
+
]
|
259
|
+
}
|
260
|
+
```
|
261
|
+
|
262
|
+
> Remember to replace "account-id" in the above document with the ID of your AWS
|
263
|
+
> account!
|
264
|
+
|
265
|
+
> If you are using a federated identity (such as GitHub's OpenID Connect
|
266
|
+
> provider), then you will need to replace `${aws:username}` with
|
267
|
+
> `${aws:userid}` - see
|
268
|
+
> [here](https://aws.amazon.com/premiumsupport/knowledge-center/iam-policy-variables-federated/)
|
269
|
+
> for more.
|
270
|
+
|
271
|
+
## Development
|
272
|
+
|
273
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
274
|
+
`rake spec` to run the tests. You can also run `bin/console` for an interactive
|
275
|
+
prompt that will allow you to experiment.
|
276
|
+
|
277
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
278
|
+
release a new version, update the version number in `version.rb`, and then run
|
279
|
+
`bundle exec rake release`, which will create a git tag for the version, push
|
280
|
+
git commits and the created tag, and push the `.gem` file to
|
281
|
+
[rubygems.org](https://rubygems.org).
|
282
|
+
|
283
|
+
## Contributing
|
284
|
+
|
285
|
+
Contributions are welcome. Please see the
|
286
|
+
[contribution guidelines](CONTRIBUTING.md) for detailed instructions.
|
287
|
+
|
288
|
+
## License
|
289
|
+
|
290
|
+
The gem is available as open source under the terms of the
|
291
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
292
|
+
|
293
|
+
## Code of Conduct
|
294
|
+
|
295
|
+
Everyone interacting in this project's codebases, issue trackers, chat rooms and
|
296
|
+
mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
class AwsEc2Environment
|
2
|
+
class CiService # rubocop:disable Metrics/ClassLength
|
3
|
+
CI_SERVICES = [
|
4
|
+
{
|
5
|
+
name: "AppVeyor",
|
6
|
+
detect: "APPVEYOR",
|
7
|
+
build_id_var: "APPVEYOR_BUILD_NUMBER"
|
8
|
+
},
|
9
|
+
{
|
10
|
+
name: "Azure Pipelines",
|
11
|
+
detect: "BUILD_BUILDURI",
|
12
|
+
build_id_var: "BUILD_BUILDNUMBER"
|
13
|
+
},
|
14
|
+
{
|
15
|
+
name: "Bamboo",
|
16
|
+
detect: "bamboo_agentId",
|
17
|
+
build_id_var: "bamboo_buildNumber"
|
18
|
+
},
|
19
|
+
{
|
20
|
+
name: "BitBucket Pipelines",
|
21
|
+
detect: "BITBUCKET_BUILD_NUMBER",
|
22
|
+
build_id_var: "BITBUCKET_BUILD_NUMBER"
|
23
|
+
},
|
24
|
+
{
|
25
|
+
name: "Buddy",
|
26
|
+
detect: "BUDDY_WORKSPACE_ID",
|
27
|
+
build_id_var: "BUDDY_EXECUTION_ID"
|
28
|
+
},
|
29
|
+
{
|
30
|
+
name: "Buildkite",
|
31
|
+
detect: "BUILDKITE",
|
32
|
+
build_id_var: "BUILDKITE_BUILD_NUMBER"
|
33
|
+
},
|
34
|
+
{
|
35
|
+
name: "CircleCI",
|
36
|
+
detect: "CIRCLECI",
|
37
|
+
build_id_var: "CIRCLE_BUILD_NUM"
|
38
|
+
},
|
39
|
+
{
|
40
|
+
name: "Cirrus",
|
41
|
+
detect: "CIRRUS_CI",
|
42
|
+
build_id_var: "CIRRUS_BUILD_ID"
|
43
|
+
},
|
44
|
+
{
|
45
|
+
name: "CodeBuild",
|
46
|
+
detect: "CODEBUILD_BUILD_ID",
|
47
|
+
build_id_var: "CODEBUILD_BUILD_ID"
|
48
|
+
},
|
49
|
+
{
|
50
|
+
name: "Codefresh",
|
51
|
+
detect: "CF_BUILD_ID",
|
52
|
+
build_id_var: "CF_BUILD_ID"
|
53
|
+
},
|
54
|
+
{
|
55
|
+
name: "CodeShip",
|
56
|
+
detect: -> { ENV.fetch("CI_NAME", "") == "codeship" },
|
57
|
+
build_id_var: "CI_BUILD_NUMBER"
|
58
|
+
},
|
59
|
+
{
|
60
|
+
name: "Drone",
|
61
|
+
detect: "DRONE",
|
62
|
+
build_id_var: "DRONE_BUILD_NUMBER"
|
63
|
+
},
|
64
|
+
{
|
65
|
+
name: "GitHub Actions",
|
66
|
+
detect: "GITHUB_ACTIONS",
|
67
|
+
build_id_var: "GITHUB_RUN_ID"
|
68
|
+
},
|
69
|
+
{
|
70
|
+
name: "GitLab",
|
71
|
+
detect: "GITLAB_CI",
|
72
|
+
build_id_var: "CI_PIPELINE_ID"
|
73
|
+
},
|
74
|
+
{
|
75
|
+
name: "Jenkins",
|
76
|
+
detect: "JENKINS_URL",
|
77
|
+
build_id_var: "BUILD_NUMBER"
|
78
|
+
},
|
79
|
+
{
|
80
|
+
name: "JetBrains Spaces",
|
81
|
+
detect: "JB_SPACE_EXECUTION_NUMBER",
|
82
|
+
build_id_var: "JB_SPACE_EXECUTION_NUMBER"
|
83
|
+
},
|
84
|
+
{
|
85
|
+
name: "Puppet",
|
86
|
+
detect: "DISTELLI_APPNAME",
|
87
|
+
build_id_var: "DISTELLI_BUILDNUM"
|
88
|
+
},
|
89
|
+
{
|
90
|
+
name: "Scrutinizer",
|
91
|
+
detect: "SCRUTINIZER",
|
92
|
+
build_id_var: "SCRUTINIZER_INSPECTION_UUID"
|
93
|
+
},
|
94
|
+
{
|
95
|
+
name: "Semaphore",
|
96
|
+
detect: "SEMAPHORE",
|
97
|
+
build_id_var: "SEMAPHORE_JOB_ID"
|
98
|
+
},
|
99
|
+
{
|
100
|
+
name: "Shippable",
|
101
|
+
detect: "SHIPPABLE",
|
102
|
+
build_id_var: "BUILD_NUMBER"
|
103
|
+
},
|
104
|
+
{
|
105
|
+
name: "TeamCity",
|
106
|
+
detect: "TEAMCITY_VERSION",
|
107
|
+
build_id_var: "BUILD_NUMBER"
|
108
|
+
},
|
109
|
+
{
|
110
|
+
name: "Travis",
|
111
|
+
detect: "TRAVIS",
|
112
|
+
build_id_var: "TRAVIS_BUILD_NUMBER"
|
113
|
+
},
|
114
|
+
{
|
115
|
+
name: "Vela",
|
116
|
+
detect: "VELA",
|
117
|
+
build_id_var: "VELA_BUILD_NUMBER"
|
118
|
+
},
|
119
|
+
{
|
120
|
+
name: "Wercker",
|
121
|
+
detect: "WERCKER_MAIN_PIPELINE_STARTED",
|
122
|
+
build_id_var: "WERCKER_MAIN_PIPELINE_STARTED"
|
123
|
+
},
|
124
|
+
{
|
125
|
+
name: "Woodpecker",
|
126
|
+
detect: -> { ENV.fetch("CI", "") == "woodpecker" },
|
127
|
+
build_id_var: "CI_BUILD_NUMBER"
|
128
|
+
}
|
129
|
+
].freeze
|
130
|
+
|
131
|
+
# Attempts to determine if the current process is running on a CI service,
|
132
|
+
# and if so returns the name of the service and the id of the current build
|
133
|
+
# which generally can be used to find the details and logs for this build.
|
134
|
+
def self.detect
|
135
|
+
service = CI_SERVICES.find do |details|
|
136
|
+
if details[:detect].is_a? String
|
137
|
+
ENV.key? details[:detect]
|
138
|
+
else
|
139
|
+
details[:detect].call
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
return nil if service.nil?
|
144
|
+
|
145
|
+
{
|
146
|
+
name: service[:name],
|
147
|
+
build_id: ENV.fetch(service[:build_id_var])
|
148
|
+
}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,47 @@
|
|
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,
|
10
|
+
:aws_region,
|
11
|
+
:ssh_user,
|
12
|
+
:instance_filters,
|
13
|
+
:bastion_ssh_user,
|
14
|
+
:bastion_filters,
|
15
|
+
:use_ssm,
|
16
|
+
:ssm_host
|
17
|
+
|
18
|
+
# @param [Symbol] env_name
|
19
|
+
# @param [Hash] attrs
|
20
|
+
def initialize(env_name, attrs)
|
21
|
+
@env_name = env_name
|
22
|
+
@aws_region = attrs.fetch("aws_region")
|
23
|
+
@use_ssm = attrs.fetch("use_ssm", false)
|
24
|
+
@ssm_host = attrs.fetch("ssm_host", "127.0.0.1")
|
25
|
+
@ssh_user = attrs.fetch("ssh_user")
|
26
|
+
@instance_filters = attrs.fetch("filters")
|
27
|
+
|
28
|
+
@bastion_filters, @bastion_ssh_user = fetch_bastion_details(attrs)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def fetch_bastion_details(attrs)
|
34
|
+
bastion_instance = attrs.fetch("bastion_instance", nil)
|
35
|
+
bastion_ssh_user = ssh_user
|
36
|
+
|
37
|
+
# if the bastion_instance is a hash, then we need to fetch specific keys
|
38
|
+
unless bastion_instance.nil? || bastion_instance.is_a?(Array)
|
39
|
+
bastion_ssh_user = bastion_instance.fetch("ssh_user", ssh_user)
|
40
|
+
# this is required when using the longhand
|
41
|
+
bastion_instance = bastion_instance.fetch("filters")
|
42
|
+
end
|
43
|
+
|
44
|
+
[bastion_instance, bastion_ssh_user]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require "aws-sdk-ssm"
|
2
|
+
require "pty"
|
3
|
+
require "timeout"
|
4
|
+
require "shellwords"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
class AwsEc2Environment
|
8
|
+
class SsmPortForwardingSession
|
9
|
+
class SessionIdNotFoundError < Error; end
|
10
|
+
class SessionTimedOutError < Error; end
|
11
|
+
class SessionProcessError < Error; end
|
12
|
+
|
13
|
+
# @return [String]
|
14
|
+
attr_reader :instance_id
|
15
|
+
|
16
|
+
# @return [Number]
|
17
|
+
attr_reader :remote_port
|
18
|
+
|
19
|
+
# @return [String]
|
20
|
+
attr_reader :pid
|
21
|
+
|
22
|
+
# rubocop:disable Metrics/ParameterLists
|
23
|
+
def initialize(
|
24
|
+
instance_id, remote_port,
|
25
|
+
local_port: nil, logger: Logger.new($stdout),
|
26
|
+
timeout: 15, reason: nil
|
27
|
+
)
|
28
|
+
# rubocop:enable Metrics/ParameterLists
|
29
|
+
@logger = logger
|
30
|
+
@instance_id = instance_id
|
31
|
+
@remote_port = remote_port
|
32
|
+
@local_port = nil
|
33
|
+
@timeout = timeout
|
34
|
+
|
35
|
+
@reader, @writer, @pid = PTY.spawn(ssm_port_forward_cmd(local_port, reason))
|
36
|
+
|
37
|
+
@cmd_output = ""
|
38
|
+
@session_id = wait_for_session_id
|
39
|
+
|
40
|
+
@logger.info("SSM session #{@session_id} opening, forwarding port #{remote_port} on #{instance_id}")
|
41
|
+
end
|
42
|
+
|
43
|
+
def close
|
44
|
+
@logger.info "Terminating SSM session #{@session_id}..."
|
45
|
+
resp = Aws::SSM::Client.new.terminate_session({ session_id: @session_id })
|
46
|
+
@logger.info "Terminated SSM session #{resp.session_id} successfully"
|
47
|
+
|
48
|
+
@reader.close
|
49
|
+
@writer.close
|
50
|
+
end
|
51
|
+
|
52
|
+
def wait_for_local_port
|
53
|
+
_, local_port = expect_cmd_output(/Port (\d+) opened for sessionId #{@session_id}.\r?\n/, @timeout) || []
|
54
|
+
|
55
|
+
if local_port.nil?
|
56
|
+
raise(
|
57
|
+
SessionTimedOutError,
|
58
|
+
"SSM session #{@session_id} did not become ready within #{@timeout} seconds (maybe increase the timeout?)"
|
59
|
+
)
|
60
|
+
end
|
61
|
+
|
62
|
+
local_port.to_i
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def ssm_port_forward_cmd(local_port, reason)
|
68
|
+
document_name = "AWS-StartPortForwardingSession"
|
69
|
+
parameters = { "portNumber" => [remote_port.to_s] }
|
70
|
+
parameters["localPortNumber"] = [local_port.to_s] unless local_port.nil?
|
71
|
+
flags = [
|
72
|
+
["--target", instance_id],
|
73
|
+
["--document-name", document_name],
|
74
|
+
["--parameters", parameters.to_json]
|
75
|
+
]
|
76
|
+
|
77
|
+
flags << ["--reason", reason] unless reason.nil?
|
78
|
+
flags = flags.map { |(flag, value)| "#{flag} #{Shellwords.escape(value)}" }.join(" ")
|
79
|
+
|
80
|
+
"aws ssm start-session #{flags}"
|
81
|
+
end
|
82
|
+
|
83
|
+
# Checks the cmd process output until either the given +pattern+ matches or the +timeout+ is over.
|
84
|
+
#
|
85
|
+
# It returns an array with the result of the match or otherwise +nil+.
|
86
|
+
#
|
87
|
+
# This is effectively a re-implementation of the +File#expect+ method except it captures
|
88
|
+
# the cmd process output over time so we can include it in the case of errors
|
89
|
+
def expect_cmd_output(pattern, timeout)
|
90
|
+
Timeout.timeout(timeout) do
|
91
|
+
loop do
|
92
|
+
update_cmd_output
|
93
|
+
|
94
|
+
match = @cmd_output.match(pattern)
|
95
|
+
|
96
|
+
return match.to_a unless match.nil?
|
97
|
+
end
|
98
|
+
end
|
99
|
+
rescue Timeout::Error
|
100
|
+
nil
|
101
|
+
end
|
102
|
+
|
103
|
+
# Updates the tracked output of the cmd process with any new data that is available in the buffer,
|
104
|
+
# until the next read will block at which point this method returns.
|
105
|
+
def update_cmd_output
|
106
|
+
loop do
|
107
|
+
@cmd_output << @reader.read_nonblock(1)
|
108
|
+
|
109
|
+
# next unless @cmd_output[-1] == "\n"
|
110
|
+
#
|
111
|
+
# last_newline = @cmd_output.rindex("\n", -2) || 0
|
112
|
+
# puts @cmd_output.slice(last_newline + 1, @cmd_output.length) if @cmd_output[-1] == "\n"
|
113
|
+
end
|
114
|
+
rescue IO::EAGAINWaitReadable
|
115
|
+
# do nothing as we don't want to block
|
116
|
+
rescue Errno::EIO
|
117
|
+
output = @cmd_output.strip
|
118
|
+
output = "<nothing was outputted by process>" if output.empty?
|
119
|
+
|
120
|
+
raise SessionProcessError, output
|
121
|
+
end
|
122
|
+
|
123
|
+
def wait_for_session_id
|
124
|
+
_, session_id = expect_cmd_output(/Starting session with SessionId: ([=,.@\w-]+)\r?\n/, @timeout) || []
|
125
|
+
|
126
|
+
if session_id.nil?
|
127
|
+
raise(
|
128
|
+
SessionIdNotFoundError,
|
129
|
+
"could not find session id within #{@timeout} seconds - SSM plugin output: #{@cmd_output}"
|
130
|
+
)
|
131
|
+
end
|
132
|
+
|
133
|
+
session_id
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|