aws_ec2_environment 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AwsEc2Environment
4
+ VERSION = "0.1.0"
5
+ end