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