dh-proteus 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +7 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +80 -0
  7. data/README.md +414 -0
  8. data/Rakefile +6 -0
  9. data/bin/console +14 -0
  10. data/bin/proteus +5 -0
  11. data/bin/proteus_testing +8 -0
  12. data/bin/setup +8 -0
  13. data/build.sh +28 -0
  14. data/dh-proteus.gemspec +50 -0
  15. data/lib/core_ext/hash.rb +14 -0
  16. data/lib/proteus.rb +9 -0
  17. data/lib/proteus/app.rb +86 -0
  18. data/lib/proteus/backend/backend.rb +41 -0
  19. data/lib/proteus/commands/apply.rb +53 -0
  20. data/lib/proteus/commands/clean.rb +22 -0
  21. data/lib/proteus/commands/destroy.rb +51 -0
  22. data/lib/proteus/commands/graph.rb +22 -0
  23. data/lib/proteus/commands/import.rb +75 -0
  24. data/lib/proteus/commands/move.rb +28 -0
  25. data/lib/proteus/commands/output.rb +29 -0
  26. data/lib/proteus/commands/plan.rb +55 -0
  27. data/lib/proteus/commands/remove.rb +71 -0
  28. data/lib/proteus/commands/render.rb +36 -0
  29. data/lib/proteus/commands/taint.rb +35 -0
  30. data/lib/proteus/common.rb +72 -0
  31. data/lib/proteus/config/config.rb +47 -0
  32. data/lib/proteus/context_management/context.rb +31 -0
  33. data/lib/proteus/context_management/helpers.rb +14 -0
  34. data/lib/proteus/generate.rb +18 -0
  35. data/lib/proteus/generators/context.rb +57 -0
  36. data/lib/proteus/generators/environment.rb +42 -0
  37. data/lib/proteus/generators/init.rb +40 -0
  38. data/lib/proteus/generators/module.rb +69 -0
  39. data/lib/proteus/generators/templates/config/config.yaml.erb +22 -0
  40. data/lib/proteus/generators/templates/context/main.tf.erb +7 -0
  41. data/lib/proteus/generators/templates/context/variables.tf.erb +3 -0
  42. data/lib/proteus/generators/templates/environment/terraform.tfvars.erb +6 -0
  43. data/lib/proteus/generators/templates/module/io.tf.erb +1 -0
  44. data/lib/proteus/generators/templates/module/module.tf.erb +1 -0
  45. data/lib/proteus/generators/templates/module/validator.rb.erb +9 -0
  46. data/lib/proteus/global_commands/validate.rb +45 -0
  47. data/lib/proteus/helpers.rb +91 -0
  48. data/lib/proteus/helpers/path_helpers.rb +90 -0
  49. data/lib/proteus/helpers/string_helpers.rb +13 -0
  50. data/lib/proteus/init.rb +16 -0
  51. data/lib/proteus/modules/manager.rb +53 -0
  52. data/lib/proteus/modules/terraform_module.rb +184 -0
  53. data/lib/proteus/templates/partial.rb +123 -0
  54. data/lib/proteus/templates/template_binding.rb +62 -0
  55. data/lib/proteus/validators/base_validator.rb +24 -0
  56. data/lib/proteus/validators/validation_dsl.rb +172 -0
  57. data/lib/proteus/validators/validation_error.rb +9 -0
  58. data/lib/proteus/validators/validation_helpers.rb +9 -0
  59. data/lib/proteus/version.rb +7 -0
  60. metadata +260 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b0d31f4cca8dbbd403180b503384d70fc284b2b95df51a095e71f629b4942210
4
+ data.tar.gz: 0b57912e38c3c4b3f80d57b4c865bd89113f2f0934fb0996582f4d3a4ed1efaf
5
+ SHA512:
6
+ metadata.gz: 2a3c50683b3358058ed6651459152bc166f6452533bd8ca5c6d5a4a1c03c1ec821a39ebc40d210c5dfe5d32f6055156e8af5617cea9579964f8de49d75279bc7
7
+ data.tar.gz: 1e9ea31e800c1ead1ce9ec95ef460365848dae61a2f34fb6584821a31a1745d0a6f78f255e0b328a062ce79f652c8036cd7005e3c1924ddfaac50128f6183be0
data/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ terraform.tfvars
14
+ *.tfplan
15
+ terraform.tfstate*
16
+ graph.png
17
+ .terraform
18
+ .DS_Store
19
+ .idea
20
+
21
+ release
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.5.5
7
+ before_install: gem install bundler -v 2.0.1
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in dh-proteus.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,80 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ proteus (0.1.3)
5
+ activesupport (~> 5.1.1)
6
+ aws-sdk-elasticsearchservice (~> 1.4.0)
7
+ aws-sdk-rds (~> 1.11.0)
8
+ aws-sdk-route53 (~> 1.7.0)
9
+ erubis (~> 2.7.0)
10
+ hcl-checker (~> 1.0.5)
11
+ thor (~> 0.20.0)
12
+
13
+ GEM
14
+ remote: https://rubygems.org/
15
+ specs:
16
+ activesupport (5.1.1)
17
+ concurrent-ruby (~> 1.0, >= 1.0.2)
18
+ i18n (~> 0.7)
19
+ minitest (~> 5.1)
20
+ tzinfo (~> 1.1)
21
+ aws-partitions (1.60.0)
22
+ aws-sdk-core (3.15.0)
23
+ aws-partitions (~> 1.0)
24
+ aws-sigv4 (~> 1.0)
25
+ jmespath (~> 1.0)
26
+ aws-sdk-elasticsearchservice (1.4.0)
27
+ aws-sdk-core (~> 3)
28
+ aws-sigv4 (~> 1.0)
29
+ aws-sdk-rds (1.11.0)
30
+ aws-sdk-core (~> 3)
31
+ aws-sigv4 (~> 1.0)
32
+ aws-sdk-route53 (1.7.0)
33
+ aws-sdk-core (~> 3)
34
+ aws-sigv4 (~> 1.0)
35
+ aws-sigv4 (1.0.2)
36
+ coderay (1.1.1)
37
+ concurrent-ruby (1.0.5)
38
+ diff-lcs (1.3)
39
+ erubis (2.7.0)
40
+ hcl-checker (1.0.5)
41
+ i18n (0.8.1)
42
+ jmespath (1.3.1)
43
+ method_source (0.8.2)
44
+ minitest (5.10.3)
45
+ pry (0.10.4)
46
+ coderay (~> 1.1.0)
47
+ method_source (~> 0.8.1)
48
+ slop (~> 3.4)
49
+ rake (10.5.0)
50
+ rspec (3.8.0)
51
+ rspec-core (~> 3.8.0)
52
+ rspec-expectations (~> 3.8.0)
53
+ rspec-mocks (~> 3.8.0)
54
+ rspec-core (3.8.0)
55
+ rspec-support (~> 3.8.0)
56
+ rspec-expectations (3.8.3)
57
+ diff-lcs (>= 1.2.0, < 2.0)
58
+ rspec-support (~> 3.8.0)
59
+ rspec-mocks (3.8.0)
60
+ diff-lcs (>= 1.2.0, < 2.0)
61
+ rspec-support (~> 3.8.0)
62
+ rspec-support (3.8.0)
63
+ slop (3.6.0)
64
+ thor (0.20.0)
65
+ thread_safe (0.3.6)
66
+ tzinfo (1.2.3)
67
+ thread_safe (~> 0.1)
68
+
69
+ PLATFORMS
70
+ ruby
71
+
72
+ DEPENDENCIES
73
+ bundler (~> 2.0)
74
+ proteus!
75
+ pry
76
+ rake (~> 10.0)
77
+ rspec (~> 3.0)
78
+
79
+ BUNDLED WITH
80
+ 2.0.1
data/README.md ADDED
@@ -0,0 +1,414 @@
1
+ # Terraform tool
2
+
3
+ This repository contains a wrapper application (proteus) around Terraform that facilitates management of resources.
4
+
5
+ The incentive for a Terraform wrapper is that Terraform in its current state cannot iteratively
6
+ declare module includes in a loop. Writing all the module includes manually would inevitably lead to
7
+ large manifest files.
8
+ These files would be difficult if not impossible to maintain without human error.
9
+
10
+ Furthermore, the abstraction from Terraform that the configuration format of the wrapper provides, enables
11
+ people who are not familiar with Terraform's configuration format to configure resources easily.
12
+
13
+ ## Setup
14
+
15
+ ### Requirements
16
+ * Ruby >= 2.5.5
17
+ * Terraform >= 0.11.14
18
+ * AWS IAM profile credentials
19
+
20
+ ### Prerequisites
21
+
22
+ #### Install Terraform
23
+ ```
24
+ brew install terraform
25
+ ```
26
+
27
+ If you would still like to use Terraform `< 0.12`:
28
+ ```
29
+ # Install Terraform 0.11.14 (for Homebrew users)
30
+ mkdir -p /usr/local/Cellar/terraform/0.11.14/bin \
31
+ && curl -o /tmp/terraform.zip "https://releases.hashicorp.com/terraform/0.11.14/terraform_0.11.14_darwin_amd64.zip" \
32
+ && unzip -o /tmp/terraform.zip -d "/usr/local/Cellar/terraform/0.11.14/bin" \
33
+ && rm -f /tmp/terraform.zip \
34
+ && brew switch terraform 0.11.14
35
+ && brew pin terraform
36
+ ```
37
+
38
+ #### Install proteus
39
+ ```
40
+ gem source --add https://YOUR_USER:YOUR_PASSWORD@nexus.usehurrier.com/repository/infra-gems
41
+ gem install proteus
42
+ ```
43
+
44
+ #### Set up proteus root path
45
+ ```
46
+ cd /path/to/your/repository
47
+
48
+ # initialize a project scaffolding
49
+ proteus init
50
+ ```
51
+
52
+ The above directory will be a valid `proteus` root. Should you want to be able to call `proteus` from anywhere on your system,
53
+ set it as an environment variable like so:
54
+ ```
55
+ export PROTEUS_ROOT=/path/to/your/repository
56
+ ```
57
+
58
+ #### AWS profile configuration
59
+ Create profiles and credentials for your environments:
60
+
61
+ `$HOME/.aws/config`:
62
+
63
+ ```
64
+ [profile staging]
65
+ region = eu-west-1
66
+ [profile production]
67
+ region = eu-west-1
68
+ ```
69
+
70
+ `$HOME/.aws/credentials`:
71
+ ```
72
+ [staging]
73
+ aws_access_key_id = YOUR_ACCESS_KEY_ID
74
+ aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
75
+
76
+ [production]
77
+ aws_access_key_id = YOUR_ACCESS_KEY_ID
78
+ aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
79
+ ```
80
+
81
+ #### State
82
+ Terraform state is managed remotely in an S3 bucket. Make sure to create that bucket and enable versioning.
83
+
84
+ ### Customize configuration
85
+ Having set up all of the above, it is time to modify the configuration to your needs.
86
+
87
+ ```
88
+ vim $PROTEUS_ROOT/config/config.yaml
89
+ ```
90
+
91
+ Should the term "environments" be unclear, please keep on reading. Otherwise:
92
+
93
+ ## TL;DR usage
94
+ ```
95
+ # Run "plan"
96
+ ./proteus [environment] plan
97
+
98
+ # Validate output
99
+
100
+ # Run "apply"
101
+ ./proteus [environment] apply
102
+ ```
103
+
104
+ Check the `example` context and its demo module in `contexts/example`.
105
+
106
+ ## Contexts and Environments
107
+ Environments and contexts define a scope for Terraform configuration. While contexts are defined by creating a directory in the contexts directory,
108
+ environments are defined using a Terraform variables file in the `environments` directory of a context.
109
+
110
+ ### State
111
+ Any tuple of the form (context, environment) has its own state. No state will be shared between tuples.
112
+ That means: (default, production) will not have any shared resources with (default, staging). Neither will (foo, production) share any state with
113
+ (bar, production.)
114
+
115
+ ### Conventions
116
+ * Valid environment names are are [snake case](https://en.wikipedia.org/wiki/Snake_case) and lowercase.
117
+ * files in `environments` need to comply with the following format: `terraform.environment_name.tfvars`
118
+
119
+ Once an environment gets defined using the above conventions, `proteus` will pick it up as a scope for its subcommands.
120
+
121
+ **Note:** You do not need to touch any code for the environment to be available in the command line interface.
122
+
123
+ ## Modules
124
+ There are two types of modules: Standard Terraform modules and modules that are managed by `proteus`.
125
+ Each module without `proteus` configuration behaves as a standard module.
126
+
127
+ ### Conventions
128
+ * Module names are [snake case](https://en.wikipedia.org/wiki/Snake_case) and lowercase. `foo_bar` is a correct module name while `FooBar` and `Foo_Bar` are both invalid names.
129
+ * Input and output variables go inside of a file called `io.tf` within the root of the module
130
+ * Group resources in separate files in the root of the module: Route53 related resources should be described in a file called `route53.tf`; IAM
131
+ specific resources go in a file called `iam.tf`. This way resource declarations are easy to find.
132
+ * Be verbose: We're not using MS-DOS FAT here. There is no need to shorten resource names.
133
+
134
+ **Note:** Use the generators provided by `proteus` for creating a scaffolding for contexts, modules and environments.
135
+
136
+ ### Standard modules
137
+ Standard modules can be implemented exactly as described in the [Terraform documentation](https://www.terraform.io/docs/modules/index.html).
138
+ They need to be included in a Terraform manifest in the root of a context. `proteus` will not use these modules for generating any code.
139
+
140
+ #### Structure
141
+ ```
142
+ modules/route53
143
+ ├── io.tf # Definition of input and output variables
144
+ └── route53.tf # route53 resources
145
+ ```
146
+
147
+ ### Managed modules
148
+ Managed modules extend the functionality of standard modules with a YAML configuration format, validators and templates.
149
+
150
+ #### Conventions
151
+ * Standard module conventions apply
152
+ * Singular (that means non-repeated resources) go into Terraform manifests in `config/global_resources`. Manifests in this directory
153
+ can be either standard Terraform manifests or ERB templates
154
+ * YAML Configuration files have to named exactly as an existing environment (with `.yaml` suffix)
155
+ * Template names are snake case (lowercase)
156
+ * The validator is located in the module's config root and named `validator.rb`
157
+
158
+ **Note: If your module does not contain a configuration file for your environment, it will be ignored.**
159
+
160
+ #### Structure
161
+ ```
162
+ modules/rds
163
+ ├── config # proteus confguration directory
164
+ │   ├── README.md
165
+ │   ├── global_resources # resources which only get applied once
166
+ │   │   ├── parameter_groups.tf
167
+ │   │   ├── rds.tf
168
+ │   │   └── vpc.tf
169
+ │   ├── production_ap.yaml # data for environment production_ap
170
+ │   ├── production_eu.yaml # data for environment production_eu
171
+ │   ├── production_us.yaml # ...
172
+ │   ├── qa.yaml # ...
173
+ │   ├── staging.yaml # ...
174
+ │   ├── templates
175
+ │   │   ├── _parameter_group.tf.erb # partial template for parameter groups
176
+ │   │   ├── _route53.tf.erb # partial template for route53 configuration
177
+ │   │   ├── defaults
178
+ │   │   │   └── parameter_group.yaml # default data for paramater group partial
179
+ │   │   └── rds.tf.erb # main template of the module
180
+ │   └── validator.rb # Class ensuring correct format of data
181
+ ├── io.tf # Definition of input and output variables
182
+ └── rds.tf # rds resources of the module
183
+ ```
184
+
185
+ #### Configuration format and templates
186
+ Configuration is implemented in YAML files in the root of the `config` directory modules.
187
+ The configuration format has only one required key: `template_data`.
188
+
189
+ *Example:*
190
+ ```
191
+ global_resources:
192
+ # refers to module_name/config/global_resources/your_global_resource_template.tf.erb
193
+ your_global_resource_template:
194
+ key0: value0
195
+ key1: value1
196
+ template_data:
197
+ # refers to module_name/config/templates/your_template.tf.erb
198
+ your_template:
199
+ foo: bar
200
+ hue:
201
+ - hue
202
+ - hue
203
+ - hue
204
+ ```
205
+ Each key in `template_data` refers to a template name. Each key in a template section of the `template_data`
206
+ Hash is available as an instance variable in the corresponding ERB template.
207
+
208
+ For the above example:
209
+ The template file is `your_template.tf.erb`. In the template `@foo` is available as a String value and
210
+ `@hue` is available as an Array.
211
+ The same gets applied to the global resource template `your_global_resource_template.tf.erb`: `@key0` and `@key1` are available
212
+ as instance variables within the corresponding template.
213
+
214
+ #### Partial templates
215
+ In addition to standard templates which are used to render collections as a whole, modules support templates which can be used
216
+ on single records within collections. This comes in handy if data related to a single record has to be rendered as it keeps
217
+ templates short and YAML configuration logically structured.
218
+
219
+ An example use case for this is RDS hosts and Route 53 records where a single database can have multiple Route 53 records.
220
+
221
+ Consider the following configuration data for an RDS host within the RDS module:
222
+ ```
223
+ template_data:
224
+ rds:
225
+ instances:
226
+ - instance_identifier: "dashboard"
227
+ instance_class: "db.m4.xlarge"
228
+ engine_version: "9.6.5"
229
+ engine: "postgres"
230
+ allocated_storage: 500
231
+
232
+ partials:
233
+ route53:
234
+ - app: "dashboard"
235
+ countries:
236
+ - "au"
237
+ - "bd"
238
+ - "bn"
239
+ - "hk"
240
+ - "kr"
241
+ - "my"
242
+ - "ph"
243
+ - "pk"
244
+ - "th"
245
+ - "tw"
246
+ ```
247
+ For this configuration, a template called `rds.tf.erb` will be loaded. Within the template all data within the scope of the key `rds` will
248
+ be available as instance variables. That means, data within in `instances` will be present in the template as `@instances`.
249
+
250
+ When iterating over the instances in the main template, one can trigger rendering of a partial in the context of the current instance
251
+ if data for an existing partial template is present.
252
+ In the above example, the configuration for the instance with the identifier "dashboard" refers to a partial template called `route53` and defines some
253
+ data within the scope of the key `route53`.
254
+
255
+ The partial template can be rendered in the main template as follows:
256
+ ```
257
+ <% @instances.each do |instance| %>
258
+ module "rds-<%= instance['instance_identifier'] %>" {
259
+ source = "./modules/rds"
260
+ ...
261
+ instance_identifier = "<%= instance['instance_identifier'] %>"
262
+ }
263
+
264
+ # Render partial "_route53.tf.erb" if data is present
265
+ # within partials => route53
266
+ <%= render_partial(name: :route53, data: instance, force_rendering: false) %>
267
+ <% end %>
268
+ ```
269
+ **Note:** The parameter `force_rendering` defines whether or not the partial will be rendered regardless of data being present or absent. The parameter
270
+ defaults to `true`.
271
+
272
+ **Partial default values**
273
+
274
+ If the data for a partial template is a Hash, defaults can be loaded from a YAML file in the `templates/defaults` directory.
275
+ All of the default values will be injected into the partial template, respecting the values set in the main YAML configuration.
276
+ Thus, the values in the main configuration act as overrides to the defaults.
277
+
278
+ For further information about how partial defaults work, please refer to the `example` module. You can render the module and check its
279
+ output by running the following command:
280
+
281
+ `./proteus context example demo_env render && cat contexts/example/demo_module.tf`
282
+
283
+
284
+ ### Default values
285
+ Each of the modules in this repository contains a file called `io.tf` which defines the input and output variables of the module.
286
+ For input variables, default values can be defined. These defaults can be overridden using the YAML configuration.
287
+ The following example is based on the `elasticache` module:
288
+
289
+ The module defines a variable named `node_type` in its `io.tf` manifest:
290
+ ```
291
+ variable "node_type" {
292
+ default = "cache.t2.micro"
293
+ }
294
+
295
+ ```
296
+ The configuration for the `staging` environment sets an override as follows:
297
+ ```
298
+ ...
299
+ elasticache:
300
+ instances:
301
+ - replication_group_id: sidekiq
302
+ node_type: cache.m3.medium # override for node_type
303
+ engine: "redis"
304
+ engine_version: "3.2.4"
305
+ availability_zones:
306
+ - eu-west-1a
307
+ number_cache_clusters: 1
308
+ ...
309
+ ```
310
+
311
+ Inside of the template `elasticache.tf.erb`, the method `render_defaults` gets called:
312
+ ```
313
+ ...
314
+ engine = "<%= instance['engine'] %>"
315
+ engine_version = "<%= instance['engine_version'] %>"
316
+
317
+ <%= render_defaults(instance) %>
318
+ environment = "${var.environment}"
319
+
320
+ vpc_id = "${module.vpc.id}"
321
+ ...
322
+
323
+ ```
324
+ `render_defaults` internally checks if the given context (in this case the data for an ElastiCache instance) defines
325
+ overrides for defaults defined in `io.tf` and, if overrides are present, renders them into the template.
326
+
327
+
328
+ #### Validators
329
+ The `proteus` library provides a simple DSL for validating module configuration. The DSL is available for validator classes.
330
+
331
+ #### Validator classes
332
+ The following conventions apply for validator classes:
333
+ * contained in `validator.rb` in config root of the respective module
334
+ * Class name: module name in [upper camel case](https://en.wikipedia.org/wiki/Camel_case)
335
+ * Validators inherit from `Proteus::Validators::BaseValidator`
336
+ * Validators override (and implement) exactly one method: `validate`
337
+
338
+ #### Validation DSL
339
+ The DSL provides the following keywords:
340
+
341
+ | Keyword | Description |
342
+ | ------------- |-------------|
343
+ | `within(key) { block }` | Ensures presence of `key` and data below `key` |
344
+ | `ensure_unique_values` | Ensures unique values in collections |
345
+ | `ensure_data_type(type)` | Checks if the current context is of type `type` |
346
+ | `ensure_uniqueness_across(key)` | Ensures uniqueness across a hierarchy |
347
+ | `each_key { block }` | Iterates over keys in the current context |
348
+ | `ensure_keys(*keys)` | Checks for presence of all provided keys in the current context |
349
+ | `each { block }` | Iterates over elements of a collection |
350
+ | `ensure_presence(key)` | Checks if `key` is present in the current context |
351
+ | `ensure_value(key, options)` | Ensures a value is in a set of predefined values or range or matches a regular expression |
352
+ | `in_case(key, has_value: [...]) { block }` | Optionally executes `block` if the value of `key` is in `has_value` |
353
+
354
+ Here's an example for data and the corresponding validator:
355
+
356
+ **Data:**
357
+ ```yaml
358
+ template_data:
359
+ my_template:
360
+ countries:
361
+ country_a:
362
+ apps:
363
+ - foo
364
+ - bar
365
+ - baz
366
+ country_b:
367
+ apps:
368
+ - foo
369
+ - bar
370
+ - baz
371
+ ```
372
+
373
+ **Validator method:**
374
+ ```ruby
375
+
376
+ def validate
377
+ within :template_data do # fails if template_data is absent
378
+
379
+ ensure_data_type Hash # fails is template_data is not a Hash
380
+
381
+ within :my_template do # fails if my_template is absent
382
+
383
+ ensure_data_type Hash # fails if my_template is not a Hash
384
+
385
+ within: countries do # fails if countries is absent
386
+ each_key do # iterates over countries
387
+ within :apps do # fails if any country is missing the apps key
388
+ ensure_data_type Array # fails if apps is not an Array
389
+ ensure_unique_values # fails if apps has duplicate values
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
396
+
397
+ ```
398
+
399
+ ## Generators
400
+ TBD
401
+
402
+ ## Development
403
+
404
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
405
+
406
+ ## To be done
407
+ * Error message if running apply without plan being present
408
+ * Tests
409
+ * Code documentation (YARD)
410
+ * Slack deployment?
411
+ * Disable Slack notifications for dry runs
412
+ * Enforcing validators?
413
+ * Prettier output
414
+ * Rubocop