humidifier 3.0.1 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4981fed9d0ee50e48fb96d62dc82574b5f66c4a3c8f96eb45a7a36babde88900
4
- data.tar.gz: 1106c9c7ed5b0ed6a6b3252bd6377d0b73bdaf848c23a9fa0115f03ceb79392e
3
+ metadata.gz: 70a3a43a433113df153ba4a97f549ac007299f91fb5c0a266dea0cbb70303712
4
+ data.tar.gz: 1e81ad0413783a00d1c0067334319634ceac86185de55e73fbe2c747f237b328
5
5
  SHA512:
6
- metadata.gz: 9b25d56506a612c4a1962def35bc044b09977258eb5ce64f4a998636b8877930d00c7e0d18151af072be8a2a1656e9883a7d6a809d235df9c36d637622fcba25
7
- data.tar.gz: 555aec2670276edba2614dd9776a4eb7e265a772bb2a58a98beb1afc3ac301b266d922235b4d61339a84aa7bc7e68db0ba36cb95fd5524750fafa59dcc88ea82
6
+ metadata.gz: 7626be05d2483a66ab1909fcf7bb515609fc5eb7fb404b648b10554af1501fb801c0625250258d2675ed056803acfc4a42d238f8ca1696447c1dc16165da795d
7
+ data.tar.gz: 4f9d3167302db844968d433b12e82c8128f538fad1f27da6d0ecdcd8cd7398c5d8fd2fc36da15bf03bd1a2334b065307a4709e7f7e10aeb22e1198345da8d56f
data/README.md CHANGED
@@ -5,7 +5,35 @@
5
5
 
6
6
  Humidifier allows you to build AWS CloudFormation (CFN) templates programmatically. CFN stacks and resources are represented as Ruby objects with accessors for all their supported properties. Stacks and resources have `to_cf` methods that allow you to quickly inspect what will be uploaded.
7
7
 
8
- For the full docs, go to [https://localytics.github.io/humidifier/](http://localytics.github.io/humidifier/). For local development instructions, see the [Development](https://localytics.github.io/humidifier/#label-Development) section.
8
+ - [Installation](#installation)
9
+ - [Getting started](#getting-started)
10
+ - [Example usage](#example-usage)
11
+ - [Interfacing with AWS](#interfacing-with-aws)
12
+ - [CloudFormation functions](#cloudformation-functions)
13
+ - [Change Sets](#change-sets)
14
+ - [Introspection](#introspection)
15
+ - [Large templates](#large-templates)
16
+ - [Forcing uploading](#forcing-uploading)
17
+ - [CLI](#cli)
18
+ - [Resource files](#resource-files)
19
+ - [Mappers](#mappers)
20
+ - [Using the CLI](#using-the-cli)
21
+ - [`change [?stack]`](#change-stack)
22
+ - [`deploy [?stack] [*parameters]`](#deploy-stack-parameters)
23
+ - [`display [stack] [?pattern]`](#display-stack-pattern)
24
+ - [`stacks`](#stacks)
25
+ - [`upload [?stack]`](#upload-stack)
26
+ - [`validate [?stack]`](#validate-stack)
27
+ - [Parameters](#parameters)
28
+ - [Shortcuts](#shortcuts)
29
+ - [Automatic id properties](#automatic-id-properties)
30
+ - [Anonymous mappers](#anonymous-mappers)
31
+ - [Cross-stack references](#cross-stack-references)
32
+ - [Development](#development)
33
+ - [Testing](#testing)
34
+ - [Specs](#specs)
35
+ - [Contributing](#contributing)
36
+ - [License](#license)
9
37
 
10
38
  ## Installation
11
39
 
@@ -72,7 +100,7 @@ There are additionally four functions on `Humidifier::Stack` that support waitin
72
100
 
73
101
  #### CloudFormation functions
74
102
 
75
- You can use CFN intrinsic functions and references using `Humidifier.fn.[name]` and `Humidifier.ref`. Those will build appropriate structures that know how to be dumped to CFN syntax appropriately.
103
+ You can use CFN intrinsic functions and references using `Humidifier.fn.[name]` and `Humidifier.ref`. They will build appropriate structures that know how to be dumped to CFN syntax.
76
104
 
77
105
  #### Change Sets
78
106
 
@@ -114,6 +142,241 @@ Humidifier.configure do |config|
114
142
  end
115
143
  ```
116
144
 
145
+ ## CLI
146
+
147
+ `Humidifier` can also be used as a CLI for managing resources through configuration files. To get started, build a ruby script (for example `bin/humidifier`) that executes the `Humidifier::CLI` class, like so:
148
+
149
+ ```ruby
150
+ #!/usr/bin/env ruby
151
+ require 'humidifier'
152
+
153
+ Humidifier.configure do |config|
154
+ # optional, defaults to the current working directory, so that all of the
155
+ # directories from the location that you run the CLI are assumed to contain
156
+ # resource specifications
157
+ config.stack_path = 'stacks'
158
+
159
+ # optional, a default prefix to use before deploying to AWS
160
+ config.stack_prefix = 'humidifier-'
161
+
162
+ # specifies that `users.yml` files contain specifications for `AWS::IAM::User`
163
+ # resources
164
+ config.map :users, to: 'IAM::User'
165
+ end
166
+
167
+ Humidifier::CLI.start(ARGV)
168
+ ```
169
+
170
+ ### Resource files
171
+
172
+ Inside of the `stacks` directory configured above, create a subdirectory for each CloudFormation stack that you want to deploy. With the above configuration, we can create YAML files in the form of `users.yml` for each stack, which will specify IAM users to create. The file format looks like the below:
173
+
174
+ ```yaml
175
+ EngUser:
176
+ path: /humidifier/
177
+ user_name: EngUser
178
+ groups:
179
+ - Engineering
180
+ - Testing
181
+ - Deployment
182
+
183
+ AdminUser:
184
+ path: /humidifier/
185
+ user_name: AdminUser
186
+ groups:
187
+ - Management
188
+ - Administration
189
+ ```
190
+
191
+ The top-level keys are the logical resource names that will be displayed in the CloudFormation screen. They point to a map of key/value pairs that will be passed on to `humidifier`. Any `humidifier` (and therefore any CloudFormation) attribute may be specified. For more information on CloudFormation templates and which attributes may be specified, see both the [`humidifier` docs](http://localytics.github.io/humidifier) and the [CloudFormation docs](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-guide.html).
192
+
193
+ ### Mappers
194
+
195
+ Oftentimes, specifying these attributes can become repetitive, e.g., each user should automatically receive the same "path" attribute. Other times, you may want custom logic to execute depending on which AWS environment you're running in. Finally, you may want to reference resources in the same or other stacks.
196
+
197
+ `Humidifier`'s solution for this is to allow customized "mapper" classes to take the user-provided attributes and transform them into the attributes that CloudFormation expects. Consider the following example for mapping a user:
198
+
199
+ ```ruby
200
+ class UserMapper < Humidifier::Config::Mapper
201
+ GROUPS = {
202
+ 'eng' => %w[Engineering Testing Deployment],
203
+ 'admin' => %w[Management Administration]
204
+ }
205
+
206
+ defaults do |logical_name|
207
+ { path: '/humidifier/', user_name: logical_name }
208
+ end
209
+
210
+ attribute :group do |group|
211
+ groups = GROUPS[group]
212
+ groups.any? ? { groups: GROUPS[group] } : {}
213
+ end
214
+ end
215
+
216
+ Humidifier.configure do |config|
217
+ config.map :users, to: 'IAM::User', using: UserMapper
218
+ end
219
+ ```
220
+
221
+ This means that by default, all entries in the `users.yml` files will get a `/humidifier/` path, the `user_name` attribute will be set based on the logical name that was provided for the resource, and you can additionally specify a `group` attribute, even though it is not native to CloudFormation. With this `group` attribute, it will actually map to the `groups` attribute that CloudFormation expects.
222
+
223
+ With this new mapper in place, we can simplify our YAML file to:
224
+
225
+ ```yaml
226
+ EngUser:
227
+ group: eng
228
+
229
+ AdminUser:
230
+ group: admin
231
+ ```
232
+
233
+ ### Using the CLI
234
+
235
+ Now that you've configured your CLI, your resources, and your mappers, you can use the CLI to display, validate, and deploy your infrastructure to CloudFormation. Run your script without any arguments to get the help message and explanations for each command.
236
+
237
+ Each command has an `--aws-profile` (or `-p`) option for specifying which profile to authenticate against when querying AWS. You should ensure that this profile has the correct permissions for creating whatever resources are going to part of your stack. You can also rely on the `AWS_*` environment variables, or the EC2 instance profile if you're deploying from an instance. For more information, see the [AWS docs](http://docs.aws.amazon.com/sdkforruby/api/) under the "Configuration" section.
238
+
239
+ Below are the list of commands and some of their options.
240
+
241
+ #### `change [?stack]`
242
+
243
+ Creates a change set for either the specified stack or all stacks in the repo. The change set represents the changes between what is currently deployed versus
244
+ the resources represented by the configuration.
245
+
246
+ #### `deploy [?stack] [*parameters]`
247
+
248
+ Creates or updates (depending on if the stack already exists) one or all stacks in the repo.
249
+
250
+ The `deploy` command also allows a `--prefix` command line argument that will override the default prefix (if one is configured) for the stack that is being deployed. This is especially useful when you're deploying multiple copies of the same stack (for instance, multiple autoscaling groups) that have different purposes or semantically mean newer versions of resources.
251
+
252
+ #### `display [stack] [?pattern]`
253
+
254
+ Displays the specified stack in JSON format on the command line. If you optionally pass a pattern argument, it will filter the resources down to just ones whose names match the given pattern.
255
+
256
+ #### `stacks`
257
+
258
+ Displays the names of all of the stacks that `humidifier` is managing.
259
+
260
+ #### `upload [?stack]`
261
+
262
+ Upload one or all stacks in the repo to S3 for reference later. Note that this must be combined with the `humidifier` `s3_bucket` configuration option.
263
+
264
+ #### `validate [?stack]`
265
+
266
+ Validate that one or all stacks in the repo are properly configured and using values that CloudFormation understands.
267
+
268
+ ### Parameters
269
+
270
+ CloudFormation template parameters can be specified by having a special `parameters.yml` file in your stack directory. This file should contain a YAML-encoded object whose keys are the names of the parameters and whose values are the parameter configuration (using the same underscore paradigm as `humidifier` resources for specifying configuration).
271
+
272
+ You can pass values to the CLI deploy command after the stack name on the command line as in:
273
+
274
+ ```sh
275
+ bin/humidifier deploy foobar Param1=Foo Param2=Bar
276
+ ```
277
+
278
+ Those parameters will get passed in as values when the stack is deployed.
279
+
280
+ ### Shortcuts
281
+
282
+ A couple of convenient shortcuts are built into `humidifier` so that writing templates and mappers both can be more concise.
283
+
284
+ #### Automatic id properties
285
+
286
+ There are a lot of properties in the AWS CloudFormation resource specification that are simply pointers to other entities within the AWS ecosystem. For example, an `AWS::EC2::VPCGatewayAttachment` entity has a `VpcId` property that represents the ID of the associated `AWS::EC2::VPC`.
287
+
288
+ Because this pattern is so common, `humidifier` detects all properties ending in `Id` and allows you to specify them without the suffix. If you choose to use this format, `humidifier` will automatically turn that value into a [CloudFormation resource reference](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html).
289
+
290
+ #### Anonymous mappers
291
+
292
+ A lot of the time, mappers that you create will not be overly complicated, especially if you're using automatic id properties. So, the `config.map` method optionally takes a block, and allows you to specify the mapper inline. This is recommended for mappers that aren't too complicated as to warrant their own class (for instance, for testing purposes). An example of this using the `UserMapper` from above is below:
293
+
294
+ ```ruby
295
+ Humidifier.configure do |config|
296
+ config.map :users, to: 'IAM::User' do
297
+ GROUPS = {
298
+ 'eng' => %w[Engineering Testing Deployment],
299
+ 'admin' => %w[Management Administration]
300
+ }
301
+
302
+ defaults do |logical_name|
303
+ { path: '/humidifier/', user_name: logical_name }
304
+ end
305
+
306
+ attribute :group do |group|
307
+ groups = GROUPS[group]
308
+ groups.any? ? { groups: GROUPS[group] } : {}
309
+ end
310
+ end
311
+ end
312
+ ```
313
+
314
+ #### Cross-stack references
315
+
316
+ AWS allows cross-stack references through the [intrinsic `Fn::ImportValue` function](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html). You can take advantage of this with `humidifier` by using the `export: true` option on resources in your stacks. For instance, if in one stack you have a subnet that you need to reference in another, you could (`stacks/vpc/subnets.yml`):
317
+
318
+ ```yaml
319
+ ProductionPrivateSubnet2a:
320
+ vpc: ProductionVPC
321
+ cidr_block: 10.0.0.0/19
322
+ availability_zone: us-west-2a
323
+ export: true
324
+
325
+ ProductionPrivateSubnet2b:
326
+ vpc: ProductionVPC
327
+ cidr_block: 10.0.64.0/19
328
+ availability_zone: us-west-2b
329
+ export: true
330
+
331
+ ProductionPrivateSubnet2c:
332
+ vpc: ProductionVPC
333
+ cidr_block: 10.0.128.0/19
334
+ availability_zone: us-west-2c
335
+ export: true
336
+ ```
337
+
338
+ And then in another stack, you could reference those values (`stacks/rds/db_subnets_groups.yml`):
339
+
340
+ ```yaml
341
+ ProductionDBSubnetGroup:
342
+ db_subnet_group_description: Production DB private subnet group
343
+ subnets:
344
+ - ProductionPrivateSubnet2a
345
+ - ProductionPrivateSubnet2b
346
+ - ProductionPrivateSubnet2c
347
+ ```
348
+
349
+ Within the configuration, you would specify to use the `Fn::ImportValue` function like so:
350
+
351
+ ```ruby
352
+ Humidifier.configure do |config|
353
+ config.stack_path = 'stacks'
354
+
355
+ config.map :subnets, to: 'EC2::Subnet'
356
+
357
+ config.map :db_subnet_groups, to: 'RDS::DBSubnetGroup' do
358
+ attribute :subnets do |subnet_names|
359
+ subnet_ids =
360
+ subnet_names.map do |subnet_name|
361
+ Humidifier.fn.import_value(subnet_name)
362
+ end
363
+
364
+ { subnet_ids: subnet_ids }
365
+ end
366
+ end
367
+ end
368
+ ```
369
+
370
+ If you specify `export: true` it will by default export a reference to the resource listed in the stack. You can also choose to export a different attribute by specifying the attribute as the value to export. For example, if we were creating instance profiles and wanted to export the `Arn` so that it could be referenced by an instance later, we could:
371
+
372
+ ```yaml
373
+ APIRoleInstanceProfile:
374
+ depends_on: APIRole
375
+ roles:
376
+ - APIRole
377
+ export: Arn
378
+ ```
379
+
117
380
  ## Development
118
381
 
119
382
  To get started, ensure you have ruby installed, version 2.4 or later. From there, install the `bundler` gem: `gem install bundler` and then `bundle install` in the root of the repository.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Humidifier
4
+ # A CLI for running commands to manipulate the stacks that Humidifier knows
5
+ # about.
6
+ class CLI < Thor
7
+ class_option :aws_profile, desc: 'The AWS profile to authenticate with',
8
+ aliases: ['-p']
9
+
10
+ class_option :debug, desc: 'Sets up debug mode', aliases: ['-d']
11
+ class_around :safe_execute
12
+
13
+ desc 'change [?stack]', 'Create changesets for one or all stacks'
14
+ def change(name = nil)
15
+ authorize
16
+
17
+ stack_names_from(name).each do |stack_name|
18
+ directory = Directory.new(stack_name)
19
+
20
+ puts "Creating a changeset for #{directory.stack_name}"
21
+ directory.create_change_set
22
+ end
23
+ end
24
+
25
+ desc 'deploy [?stack] [*parameters]', 'Update one or all stacks'
26
+ option :wait, desc: 'Wait for the stack to create/update',
27
+ type: :boolean, default: false
28
+ option :prefix, desc: 'The prefix to use for the stack'
29
+ def deploy(name = nil, *parameters)
30
+ authorize
31
+
32
+ stack_names_from(name).each do |stack_name|
33
+ directory = Directory.new(stack_name, prefix: options[:prefix])
34
+
35
+ puts "Deploying #{directory.stack_name}"
36
+ directory.deploy(options[:wait], parameters_from(parameters))
37
+ end
38
+ end
39
+
40
+ desc 'display [stack] [?pattern]',
41
+ 'Display the CloudFormation JSON for a given stack'
42
+ def display(name, pattern = nil)
43
+ puts Directory.new(name, pattern: pattern && /#{pattern}/i).to_cf
44
+ end
45
+
46
+ desc 'stacks', 'List the stacks known to Humidifier'
47
+ def stacks
48
+ puts Humidifier.config.stack_names.sort
49
+ end
50
+
51
+ desc 'upload [?stack]', 'Upload one or all stacks to S3'
52
+ def upload(name = nil)
53
+ authorize
54
+
55
+ stack_names_from(name).each do |stack_name|
56
+ directory = Directory.new(stack_name)
57
+
58
+ puts "Uploading #{directory.stack_name}"
59
+ directory.upload
60
+ end
61
+ end
62
+
63
+ desc 'validate [?stack]',
64
+ 'Validate that one or all stacks are valid with CloudFormation'
65
+ def validate(name = nil)
66
+ authorize
67
+
68
+ print 'Validating... '
69
+
70
+ valid =
71
+ stack_names_from(name).all? do |stack_name|
72
+ Directory.new(stack_name).valid?
73
+ end
74
+
75
+ puts valid ? 'Valid.' : 'Invalid.'
76
+ end
77
+
78
+ no_commands do
79
+ def authorize
80
+ return unless options[:aws_profile]
81
+
82
+ Aws.config[:credentials] =
83
+ Aws::SharedCredentials.new(profile_name: options[:aws_profile])
84
+ end
85
+
86
+ def parameters_from(opts)
87
+ opts.map do |opt|
88
+ key, value = opt.split('=')
89
+ { parameter_key: key, parameter_value: value }
90
+ end
91
+ end
92
+
93
+ def safe_execute
94
+ yield
95
+ rescue Error => error
96
+ raise error if options[:debug]
97
+
98
+ puts error.message
99
+ exit 1
100
+ end
101
+
102
+ def stack_names_from(name)
103
+ name ? [name] : Humidifier.config.stack_names
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Humidifier
4
+ class Config
5
+ # The parent class for mapper classes. These classes are used to transform
6
+ # arbitrary attributes coming from the user-provided YAML files into valid
7
+ # CloudFormation props that can then be used in the template. This class
8
+ # provides an easy-to-extend DSL that allows for default attributes
9
+ # specifying custom attributes.
10
+ class Mapper
11
+ # Raised when the attribute given in the file could not be matched to an
12
+ # attribute in the mapper.
13
+ class InvalidResourceAttributeError < Error
14
+ def initialize(clazz, key)
15
+ super("Invalid attribute name given for #{clazz.aws_name}: #{key}")
16
+ end
17
+ end
18
+
19
+ # The list of attributes that are common to all resources that need to be
20
+ # handled separately.
21
+ COMMON_ATTRIBUTES = Resource::COMMON_ATTRIBUTES.values
22
+
23
+ class << self
24
+ # Defines a custom attribute. The given block will receive the
25
+ # user-provided value for the attribute. The block should return a hash
26
+ # where the keys are valid humidifier properties and the values are
27
+ # valid values for those properties. In the below example, we specify
28
+ # the group attribute which maps to the groups attribute after some
29
+ # transformation.
30
+ #
31
+ # attribute :group do |group|
32
+ # groups = GROUPS[group]
33
+ # groups.any? ? { groups: GROUPS[group] } : {}
34
+ # end
35
+ def attribute(name, &block)
36
+ define_method(:"attribute_#{name}", &block)
37
+ attribute_methods << name
38
+ end
39
+
40
+ # The names of the custom attribute methods.
41
+ def attribute_methods
42
+ @attribute_methods ||= []
43
+ end
44
+
45
+ # Defines the default attributes that should be applied to all resources
46
+ # of this type. The given block will be passed the logical resource
47
+ # name that the user specified for the resource. The block should return
48
+ # a hash where the keys are valid humidifier properties and the values
49
+ # are valid values for those properties. In the example below, the
50
+ # user_name property is set based on the logical name.
51
+ #
52
+ # defaults do |name|
53
+ # { user_name: name }
54
+ # end
55
+ def defaults(&block)
56
+ define_method(:attribute_defaults, &block)
57
+ end
58
+ end
59
+
60
+ # Builds a humidifier resource using the given humidifier resource class,
61
+ # the logical name for the resource, and the user-specified attributes.
62
+ def resource_for(clazz, name, attributes)
63
+ mapped =
64
+ respond_to?(:attribute_defaults) ? attribute_defaults(name) : {}
65
+
66
+ attributes.each do |key, value|
67
+ mapped.merge!(mapped_from(clazz, key, value))
68
+ end
69
+
70
+ common_attributes = common_attributes_from(mapped)
71
+
72
+ resource = clazz.new(mapped)
73
+ resource.update_attributes(common_attributes)
74
+ resource
75
+ end
76
+
77
+ private
78
+
79
+ def common_attributes_from(mapped)
80
+ COMMON_ATTRIBUTES.each_with_object({}) do |common_attribute, extract|
81
+ extracted = mapped.delete(common_attribute)
82
+ extract[common_attribute] = extracted if extracted
83
+ end
84
+ end
85
+
86
+ def mapped_from(clazz, key, value) # rubocop:disable Metrics/MethodLength
87
+ if self.class.attribute_methods.include?(key.to_sym)
88
+ # The given attribute name has been defined using the `::attribute`
89
+ # DSL method, so send the given value to that method and return the
90
+ # resulting hash.
91
+ public_send(:"attribute_#{key}", value)
92
+ elsif clazz.prop?(key)
93
+ # The given attribute name is a valid property on the resource, so
94
+ # directly map the attribute to the given value.
95
+ { key.to_sym => value }
96
+ elsif clazz.prop?("#{key}_id")
97
+ # The given attribute name corresponds to a property on the resource
98
+ # that takes the ID of another resource (for example, specifying the
99
+ # vpc option in the file when the resource has a vpc_id property). In
100
+ # this case, automatically convert the given value into a
101
+ # CloudFormation reference.
102
+ { "#{key}_id": Humidifier.ref(value) }
103
+ elsif COMMON_ATTRIBUTES.include?(key.to_sym)
104
+ # The given attribute name is one of the attributes common to all
105
+ # resources (for example creation_policy), so map that directly to the
106
+ # given value.
107
+ { key.to_sym => value }
108
+ else
109
+ # The given attribute name did not match one of the valid options, so
110
+ # raise an error to alert the user.
111
+ raise InvalidResourceAttributeError.new(clazz, key)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Humidifier
4
+ class Config
5
+ class Mapping
6
+ attr_reader :clazz, :mapper
7
+
8
+ def initialize(opts = {}, &block)
9
+ @clazz = Humidifier[normalized(opts[:to])]
10
+ raise Error, "Invalid resource: #{opts[:to].inspect}" if @clazz.nil?
11
+
12
+ if opts[:using] && block_given?
13
+ raise Error, 'Cannot specify :using and provide an anonymous mapper'
14
+ end
15
+
16
+ @mapper = mapper_from(opts, &block)
17
+ end
18
+
19
+ def resource_for(name, attributes)
20
+ mapper.resource_for(clazz, name, attributes)
21
+ end
22
+
23
+ private
24
+
25
+ def mapper_from(opts, &block)
26
+ if opts[:using]
27
+ opts[:using].new
28
+ elsif block_given?
29
+ Class.new(Mapper, &block).new
30
+ else
31
+ Mapper.new
32
+ end
33
+ end
34
+
35
+ def normalized(name)
36
+ name.start_with?('AWS') ? name : "AWS::#{name}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -16,31 +16,110 @@ module Humidifier
16
16
  # An optional prefix for the JSON file names.
17
17
  attr_accessor :s3_prefix
18
18
 
19
- def initialize(opts = {})
20
- @force_upload = opts[:force_upload]
21
- @s3_bucket = opts[:s3_bucket]
22
- @s3_prefix = opts[:s3_prefix]
19
+ # The path to the various directories containing the YAML files representing
20
+ # stacks. If blank, it's assumed to be the current working direction from
21
+ # which the CLI is executing.
22
+ attr_reader :stack_path
23
+
24
+ # An optional prefix for the stack names before they get uploaded to AWS.
25
+ attr_accessor :stack_prefix
26
+
27
+ def initialize
28
+ @mappings = {}
29
+ @stack_path = '.'
30
+ end
31
+
32
+ def files_for(name)
33
+ Dir["#{stack_path}/#{name}/*.yml"]
34
+ end
35
+
36
+ # `#map` is a declaration of a link between a file name and a mapper
37
+ # configuration. It is used to declare the manner in which a set of
38
+ # attributes read from a resource file is converted into instantiations of
39
+ # that type.
40
+ #
41
+ # For more information about the mapping DSL and how attributes get
42
+ # converted into props, see the `Humidifier::Config::Mapper` class.
43
+ #
44
+ # == Basic mapping
45
+ #
46
+ # For the most basic of mappings, you can just map a file name to a
47
+ # resource, which effectively means that each attribute you provide must
48
+ # map directly to an AWS attribute for that resource, and that no additional
49
+ # attributes will be provided. For example, the following code indicates
50
+ # that files named `routes.yml` will contain `AWS::EC2::Route` resources,
51
+ # and that every attribute read will directly correspond to one from AWS:
52
+ #
53
+ # Humidifier.configure do |config|
54
+ # config.map :routes, to: 'EC2::Route'
55
+ # end
56
+ #
57
+ # == Using the DSL
58
+ #
59
+ # For mappings for which you want to use the `Humidifier::Config::Mapper`
60
+ # DSL, you can pass a block which will get used to create a new mapper
61
+ # class. This is useful for shorter mapper declarations. For example, in the
62
+ # following code we map files named `instance_profiles.yml` to
63
+ # `AWS::IAM::InstanceProfile` resources. In that mapping we default the
64
+ # `path` prop to "/" and we allow the `roles` prop to just pass a list of
65
+ # roles as an array, which we then convert into CloudFormation references.
66
+ #
67
+ # Humidifier.configure do |config|
68
+ # config.map :instance_profiles, to: 'IAM::InstanceProfile' do
69
+ # defaults do |_|
70
+ # { path: '/' }
71
+ # end
72
+ #
73
+ # attribute :roles do |names|
74
+ # { roles: names.map { |name| Humidifier.ref(name) } }
75
+ # end
76
+ # end
77
+ # end
78
+ #
79
+ # == Reusing mappers
80
+ #
81
+ # Finally, if you want to pull the mapper out for reuse, testing, or just
82
+ # separation of code, you can pass the `:using` key with a mapper as a
83
+ # value. This will cause the given file type to be mapped using whatever
84
+ # class you provided. For example, the following code creates a mapper that
85
+ # automatically tags the resource with the logical name from the stack. It
86
+ # then configures network ACLs to use that so that all network ACL resource
87
+ # declarations automatically have a tag on them with their name.
88
+ #
89
+ # class NameToTag < Humidifier::Config::Mapper
90
+ # defaults do |logical_name|
91
+ # { tags: [{ key: 'Name', value: logical_name }] }
92
+ # end
93
+ # end
94
+ #
95
+ # Humidifier.configure do |config|
96
+ # config.map :network_acls, to: 'EC2::NetworkAcl', using: NameToTag
97
+ # end
98
+ #
99
+ def map(type, opts = {}, &block)
100
+ mappings[type.to_sym] = Mapping.new(opts, &block)
23
101
  end
24
102
 
25
- # raise an error unless the s3_bucket field is set
26
- # rubocop:disable Metrics/MethodLength
27
- def ensure_upload_configured!(identifier)
28
- return if s3_bucket
103
+ def mapping_for(type)
104
+ mappings[type.to_sym]
105
+ end
29
106
 
30
- upload_message = <<~MSG
31
- The %<identifier>s stack's body is too large to be use the template_body
32
- option, and therefore must use the template_url option instead. You can
33
- configure Humidifier to do this automatically by setting up the s3
34
- config on the top-level Humidifier object like so:
107
+ def stack_path=(stack_path)
108
+ unless File.exist?(stack_path)
109
+ raise Error, "Invalid filepath: #{stack_path}"
110
+ end
35
111
 
36
- Humidifier.configure do |config|
37
- config.s3_bucket = 'my.s3.bucket'
38
- config.s3_prefix = 'my-prefix/' # optional
39
- end
40
- MSG
112
+ @stack_path = stack_path
113
+ end
41
114
 
42
- raise upload_message.gsub('%<identifier>s', identifier)
115
+ def stack_names
116
+ Dir["#{stack_path}/*"].each_with_object([]) do |name, names|
117
+ names << File.basename(name) if File.directory?(name)
118
+ end
43
119
  end
44
- # rubocop:enable Metrics/MethodLength
120
+
121
+ private
122
+
123
+ attr_reader :mappings
45
124
  end
46
125
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Humidifier
4
+ # Represents a directory on the filesystem containing YAML files that
5
+ # correspond to resources belonging to a stack. Contains all of the logic for
6
+ # interfacing with humidifier to deploy stacks, validate them, display them.
7
+ class Directory
8
+ # Represents an exported resource in a stack for use in cross-stack
9
+ # references.
10
+ Export =
11
+ Struct.new(:name, :attribute) do
12
+ def value
13
+ if attribute.is_a?(String)
14
+ Humidifier.fn.get_att([name, attribute])
15
+ else
16
+ Humidifier.ref(name)
17
+ end
18
+ end
19
+ end
20
+
21
+ attr_reader :name, :pattern, :prefix, :exports, :stack_name
22
+
23
+ def initialize(name, pattern: nil, prefix: nil)
24
+ @name = name
25
+ @pattern = pattern
26
+ @prefix = prefix
27
+ @exports = []
28
+ @stack_name = "#{prefix || Humidifier.config.stack_prefix}#{name}"
29
+ end
30
+
31
+ def create_change_set
32
+ return unless valid?
33
+
34
+ stack.create_change_set(
35
+ capabilities: %w[CAPABILITY_IAM CAPABILITY_NAMED_IAM]
36
+ )
37
+ end
38
+
39
+ def deploy(wait = false, parameter_values = {})
40
+ return unless valid?
41
+
42
+ stack.public_send(
43
+ wait ? :deploy_and_wait : :deploy,
44
+ capabilities: %w[CAPABILITY_IAM CAPABILITY_NAMED_IAM],
45
+ parameters: parameter_values
46
+ )
47
+ end
48
+
49
+ def to_cf
50
+ stack.to_cf
51
+ end
52
+
53
+ def upload
54
+ stack.upload if valid?
55
+ end
56
+
57
+ def valid?
58
+ stack.valid?
59
+ end
60
+
61
+ private
62
+
63
+ def stack
64
+ Stack.new(
65
+ name: stack_name,
66
+ description: "Resources for #{stack_name}",
67
+ resources: resources,
68
+ outputs: outputs,
69
+ parameters: parameters
70
+ )
71
+ end
72
+
73
+ def outputs
74
+ exports.each_with_object({}) do |export, exported|
75
+ exported[export.name] =
76
+ Output.new(value: export.value, export_name: export.name)
77
+ end
78
+ end
79
+
80
+ def parameters
81
+ @parameters ||=
82
+ begin
83
+ parameter_filepath =
84
+ Humidifier.config.files_for(name).detect do |filepath|
85
+ File.basename(filepath, '.yml') == 'parameters'
86
+ end
87
+
88
+ parameter_filepath ? parameters_from(parameter_filepath) : {}
89
+ end
90
+ end
91
+
92
+ def parameters_from(filepath)
93
+ loaded = YAML.load_file(filepath)
94
+ return {} unless loaded
95
+
96
+ loaded.each_with_object({}) do |(name, opts), params|
97
+ opts = opts.map { |key, value| [key.to_sym, value] }.to_h
98
+ params[name] = Parameter.new(opts)
99
+ end
100
+ end
101
+
102
+ def parse(filepath, type)
103
+ mapping = Humidifier.config.mapping_for(type)
104
+ return {} if mapping.nil?
105
+
106
+ loaded = YAML.load_file(filepath)
107
+ return {} unless loaded
108
+
109
+ loaded.each_with_object({}) do |(name, attributes), resources|
110
+ next if pattern && name !~ pattern
111
+
112
+ attribute = attributes.delete('export')
113
+ exports << Export.new(name, attribute) if attribute
114
+
115
+ resources[name] = mapping.resource_for(name, attributes)
116
+ end
117
+ end
118
+
119
+ def resources
120
+ filepaths = Humidifier.config.files_for(name)
121
+
122
+ filepaths.each_with_object({}) do |filepath, resources|
123
+ basename = File.basename(filepath, '.yml')
124
+
125
+ # Explicitly skip past parameters so we can pull them out later
126
+ next if basename == 'parameters'
127
+
128
+ resources.merge!(parse(filepath, basename))
129
+ end
130
+ end
131
+ end
132
+ end
@@ -3,7 +3,13 @@
3
3
  module Humidifier
4
4
  # Represents a CFN stack
5
5
  class Stack
6
- class TemplateTooLargeError < StandardError
6
+ class NoResourcesError < Error
7
+ def initialize(stack, action)
8
+ super("Refusing to #{action} stack #{stack.name} with no resources")
9
+ end
10
+ end
11
+
12
+ class TemplateTooLargeError < Error
7
13
  def initialize(bytesize)
8
14
  super(
9
15
  "Cannot use a template > #{MAX_TEMPLATE_URL_SIZE} bytes " \
@@ -14,6 +20,22 @@ module Humidifier
14
20
  end
15
21
  end
16
22
 
23
+ class UploadNotConfiguredError < Error
24
+ def initialize(identifier)
25
+ super(<<~MSG)
26
+ The #{identifier} stack's body is too large to be use the
27
+ template_body option, and therefore must use the template_url option
28
+ instead. You can configure Humidifier to do this automatically by
29
+ setting up the s3 config on the top-level Humidifier object like so:
30
+
31
+ Humidifier.configure do |config|
32
+ config.s3_bucket = 'my.s3.bucket'
33
+ config.s3_prefix = 'my-prefix/' # optional
34
+ end
35
+ MSG
36
+ end
37
+ end
38
+
17
39
  # The AWS region, can be set through the environment, defaults to us-east-1
18
40
  AWS_REGION = ENV['AWS_REGION'] || 'us-east-1'
19
41
 
@@ -109,6 +131,8 @@ module Humidifier
109
131
  end
110
132
 
111
133
  def create_change_set(opts = {})
134
+ raise NoResourcesError.new(self, :change) unless resources.any?
135
+
112
136
  params = {
113
137
  stack_name: identifier,
114
138
  change_set_name: "changeset-#{Time.now.strftime('%Y-%m-%d-%H-%M-%S')}"
@@ -128,6 +152,8 @@ module Humidifier
128
152
  end
129
153
 
130
154
  def deploy(opts = {})
155
+ raise NoResourcesError.new(self, :deploy) unless resources.any?
156
+
131
157
  exists? ? update(opts) : create(opts)
132
158
  end
133
159
 
@@ -144,8 +170,12 @@ module Humidifier
144
170
  end
145
171
 
146
172
  def update(opts = {})
147
- params =
148
- { stack_name: identifier }.merge!(template_for(opts)).merge!(opts)
173
+ params = {
174
+ capabilities: %w[CAPABILITY_IAM CAPABILITY_NAMED_IAM],
175
+ stack_name: identifier
176
+ }
177
+
178
+ params.merge!(template_for(opts)).merge!(opts)
149
179
 
150
180
  try_valid { client.update_stack(params) }
151
181
  end
@@ -154,15 +184,29 @@ module Humidifier
154
184
  perform_and_wait(:update, opts)
155
185
  end
156
186
 
157
- def upload
158
- Humidifier.config.ensure_upload_configured!(identifier)
159
- upload_object("#{Humidifier.config.s3_prefix}#{identifier}.json")
187
+ def upload # rubocop:disable Metrics/AbcSize
188
+ raise NoResourcesError.new(self, :upload) unless resources.any?
189
+
190
+ bucket = Humidifier.config.s3_bucket
191
+ raise UploadNotConfiguredError, identifier unless bucket
192
+
193
+ Aws.config.update(region: AWS_REGION)
194
+ key = "#{Humidifier.config.s3_prefix}#{identifier}.json"
195
+
196
+ Aws::S3::Client.new.put_object(body: to_cf, bucket: bucket, key: key)
197
+ Aws::S3::Object.new(bucket, key).presigned_url(:get)
160
198
  end
161
199
 
162
200
  def valid?(opts = {})
163
201
  params = template_for(opts).merge!(opts)
164
202
 
165
203
  try_valid { client.validate_template(params) }
204
+ rescue Aws::CloudFormation::Errors::AccessDenied
205
+ raise Error, <<~MSG
206
+ The authenticated AWS profile does not have the requisite permissions
207
+ to run this command. Ensure the profile has the
208
+ "cloudformation:ValidateTemplate" IAM permission.
209
+ MSG
166
210
  end
167
211
 
168
212
  def self.next_default_identifier
@@ -175,33 +219,12 @@ module Humidifier
175
219
 
176
220
  attr_reader :default_identifier
177
221
 
178
- def perform_and_wait(method, opts)
179
- public_send(method, opts).tap do
180
- signal = :"stack_#{method}_complete"
181
-
182
- client.wait_until(signal, stack_name: identifier) do |waiter|
183
- waiter.max_attempts = (opts.delete(:max_wait) || MAX_WAIT) / 5
184
- waiter.delay = 5
185
- end
222
+ def bytesize
223
+ to_cf.bytesize.tap do |size|
224
+ raise TemplateTooLargeError, size if size > MAX_TEMPLATE_URL_SIZE
186
225
  end
187
226
  end
188
227
 
189
- def try_valid
190
- yield || true
191
- rescue Aws::CloudFormation::Errors::ValidationError => error
192
- warn(error.message)
193
- warn(error.backtrace)
194
- false
195
- end
196
-
197
- def upload_object(key)
198
- Aws.config.update(region: AWS_REGION)
199
- bucket = Humidifier.config.s3_bucket
200
-
201
- Aws::S3::Client.new.put_object(body: to_cf, bucket: bucket, key: key)
202
- Aws::S3::Object.new(bucket, key).presigned_url(:get)
203
- end
204
-
205
228
  def enumerable_resources
206
229
  ENUMERABLE_RESOURCES.each_with_object({}) do |(name, prop), list|
207
230
  resources = public_send(prop)
@@ -214,6 +237,17 @@ module Humidifier
214
237
  end
215
238
  end
216
239
 
240
+ def perform_and_wait(method, opts)
241
+ public_send(method, opts).tap do
242
+ signal = :"stack_#{method}_complete"
243
+
244
+ client.wait_until(signal, stack_name: identifier) do |waiter|
245
+ waiter.max_attempts = (opts.delete(:max_wait) || MAX_WAIT) / 5
246
+ waiter.delay = 5
247
+ end
248
+ end
249
+ end
250
+
217
251
  def static_resources
218
252
  STATIC_RESOURCES.each_with_object({}) do |(name, prop), list|
219
253
  resource = public_send(prop)
@@ -221,12 +255,6 @@ module Humidifier
221
255
  end
222
256
  end
223
257
 
224
- def bytesize
225
- to_cf.bytesize.tap do |size|
226
- raise TemplateTooLargeError, size if size > MAX_TEMPLATE_URL_SIZE
227
- end
228
- end
229
-
230
258
  def template_for(opts)
231
259
  @template ||=
232
260
  if opts.delete(:force_upload) ||
@@ -238,5 +266,13 @@ module Humidifier
238
266
  { template_body: to_cf }
239
267
  end
240
268
  end
269
+
270
+ def try_valid
271
+ yield || true
272
+ rescue Aws::CloudFormation::Errors::ValidationError => error
273
+ warn(error.message)
274
+ warn(error.backtrace)
275
+ false
276
+ end
241
277
  end
242
278
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Humidifier
4
4
  # Gem version
5
- VERSION = '3.0.1'
5
+ VERSION = '3.1.0'
6
6
  end
data/lib/humidifier.rb CHANGED
@@ -8,6 +8,8 @@ require 'yaml'
8
8
  require 'aws-sdk-cloudformation'
9
9
  require 'aws-sdk-s3'
10
10
  require 'fast_underscore'
11
+ require 'thor'
12
+ require 'thor/hollaback'
11
13
 
12
14
  # Hook into the string extension and ensure it works for certain AWS acronyms
13
15
  String.prepend(
@@ -20,6 +22,9 @@ String.prepend(
20
22
 
21
23
  # container module for all gem classes
22
24
  module Humidifier
25
+ # A parent class for all Humidifier errors for easier rescuing.
26
+ class Error < StandardError; end
27
+
23
28
  class << self
24
29
  # the configuration instance
25
30
  def config
@@ -58,18 +63,24 @@ module Humidifier
58
63
  end
59
64
  end
60
65
 
61
- require 'humidifier/condition'
62
- require 'humidifier/config'
63
66
  require 'humidifier/fn'
67
+ require 'humidifier/ref'
68
+ require 'humidifier/props'
69
+
70
+ require 'humidifier/cli'
71
+ require 'humidifier/condition'
72
+ require 'humidifier/directory'
64
73
  require 'humidifier/loader'
65
74
  require 'humidifier/mapping'
66
75
  require 'humidifier/output'
67
76
  require 'humidifier/parameter'
68
- require 'humidifier/ref'
69
77
  require 'humidifier/resource'
70
78
  require 'humidifier/serializer'
71
79
  require 'humidifier/stack'
72
80
  require 'humidifier/version'
73
- require 'humidifier/props'
81
+
82
+ require 'humidifier/config'
83
+ require 'humidifier/config/mapper'
84
+ require 'humidifier/config/mapping'
74
85
 
75
86
  Humidifier::Loader.load
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: humidifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Localytics
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: thor
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.20'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.20'
69
+ - !ruby/object:Gem::Dependency
70
+ name: thor-hollaback
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.1'
55
83
  - !ruby/object:Gem::Dependency
56
84
  name: bundler
57
85
  requirement: !ruby/object:Gem::Requirement
@@ -176,8 +204,12 @@ files:
176
204
  - LICENSE
177
205
  - README.md
178
206
  - lib/humidifier.rb
207
+ - lib/humidifier/cli.rb
179
208
  - lib/humidifier/condition.rb
180
209
  - lib/humidifier/config.rb
210
+ - lib/humidifier/config/mapper.rb
211
+ - lib/humidifier/config/mapping.rb
212
+ - lib/humidifier/directory.rb
181
213
  - lib/humidifier/fn.rb
182
214
  - lib/humidifier/loader.rb
183
215
  - lib/humidifier/mapping.rb