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
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
|