Proteus
Proteus is a wrapper application around Terraform that facilitates management of resources.
The incentive for a Terraform wrapper is that Terraform in its current state cannot iteratively declare module includes in a loop. Writing all the module includes manually would inevitably lead to large manifest files. These files would be difficult if not impossible to maintain without human error.
Furthermore, the abstraction from Terraform that the configuration format of the wrapper provides, enables people who are not familiar with Terraform's configuration format to configure resources easily.
Setup
Requirements
- Ruby >= 2.5.5
- Terraform >= 0.11.14
- AWS IAM profile credentials
Prerequisites
Install Terraform
brew install terraform
If you would still like to use Terraform < 0.12
:
# Install Terraform 0.11.14 (for Homebrew users)
mkdir -p /usr/local/Cellar/terraform/0.11.14/bin \
&& curl -o /tmp/terraform.zip "https://releases.hashicorp.com/terraform/0.11.14/terraform_0.11.14_darwin_amd64.zip" \
&& unzip -o /tmp/terraform.zip -d "/usr/local/Cellar/terraform/0.11.14/bin" \
&& rm -f /tmp/terraform.zip \
&& brew switch terraform 0.11.14 \
&& brew pin terraform
Install proteus
gem install dh-proteus
Set up proteus root path
cd /path/to/your/repository
# initialize a project scaffolding
proteus init
The above directory will be a valid proteus
root. Should you want to be able to call proteus
from anywhere on your system,
set it as an environment variable like so:
export PROTEUS_ROOT=/path/to/your/repository
AWS profile configuration
Create profiles and credentials for your environments:
$HOME/.aws/config
:
[profile staging]
region = eu-west-1
[profile production]
region = eu-west-1
$HOME/.aws/credentials
:
[staging]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
[production]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
State
Terraform state is managed remotely in an S3 bucket. Make sure to create that bucket and enable versioning.
Customize configuration
Having set up all of the above, it is time to modify the configuration to your needs.
vim $PROTEUS_ROOT/config/config.yaml
Should the term "environments" be unclear, please keep on reading. Otherwise:
TL;DR usage
# Run "plan"
./proteus [environment] plan
# Validate output
# Run "apply"
./proteus [environment] apply
Check the example
context and its demo module in contexts/example
.
Contexts and Environments
Environments and contexts define a scope for Terraform configuration. While contexts are defined by creating a directory in the contexts directory,
environments are defined using a Terraform variables file in the environments
directory of a context.
State
Any tuple of the form (context, environment) has its own state. No state will be shared between tuples. That means: (default, production) will not have any shared resources with (default, staging). Neither will (foo, production) share any state with (bar, production.)
Conventions
- Valid environment names are are snake case and lowercase.
- files in
environments
need to comply with the following format:terraform.environment_name.tfvars
Once an environment gets defined using the above conventions, proteus
will pick it up as a scope for its subcommands.
Note: You do not need to touch any code for the environment to be available in the command line interface.
Modules
There are two types of modules: Standard Terraform modules and modules that are managed by proteus
.
Each module without proteus
configuration behaves as a standard module.
Conventions
- Module names are snake case and lowercase.
foo_bar
is a correct module name whileFooBar
andFoo_Bar
are both invalid names. - Input and output variables go inside of a file called
io.tf
within the root of the module - Group resources in separate files in the root of the module: Route53 related resources should be described in a file called
route53.tf
; IAM specific resources go in a file callediam.tf
. This way resource declarations are easy to find. - Be verbose: We're not using MS-DOS FAT here. There is no need to shorten resource names.
Note: Use the generators provided by proteus
for creating a scaffolding for contexts, modules and environments.
Standard modules
Standard modules can be implemented exactly as described in the Terraform documentation.
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.
Structure
modules/route53
├── io.tf # Definition of input and output variables
└── route53.tf # route53 resources
Managed modules
Managed modules extend the functionality of standard modules with a YAML configuration format, validators and templates.
Conventions
- Standard module conventions apply
- Singular (that means non-repeated resources) go into Terraform manifests in
config/global_resources
. Manifests in this directory can be either standard Terraform manifests or ERB templates - YAML Configuration files have to named exactly as an existing environment (with
.yaml
suffix) - Template names are snake case (lowercase)
- The validator is located in the module's config root and named
validator.rb
Note: If your module does not contain a configuration file for your environment, it will be ignored.
Structure
modules/rds
├── config # proteus confguration directory
│ ├── README.md
│ ├── global_resources # resources which only get applied once
│ │ ├── parameter_groups.tf
│ │ ├── rds.tf
│ │ └── vpc.tf
│ ├── production_ap.yaml # data for environment production_ap
│ ├── production_eu.yaml # data for environment production_eu
│ ├── production_us.yaml # ...
│ ├── qa.yaml # ...
│ ├── staging.yaml # ...
│ ├── templates
│ │ ├── _parameter_group.tf.erb # partial template for parameter groups
│ │ ├── _route53.tf.erb # partial template for route53 configuration
│ │ ├── defaults
│ │ │ └── parameter_group.yaml # default data for paramater group partial
│ │ └── rds.tf.erb # main template of the module
│ └── validator.rb # Class ensuring correct format of data
├── io.tf # Definition of input and output variables
└── rds.tf # rds resources of the module
Configuration format and templates
Configuration is implemented in YAML files in the root of the config
directory modules.
The configuration format has only one required key: template_data
.
Example:
global_resources:
# refers to module_name/config/global_resources/your_global_resource_template.tf.erb
your_global_resource_template:
key0: value0
key1: value1
template_data:
# refers to module_name/config/templates/your_template.tf.erb
your_template:
foo: bar
hue:
- hue
- hue
- hue
Each key in template_data
refers to a template name. Each key in a template section of the template_data
Hash is available as an instance variable in the corresponding ERB template.
For the above example:
The template file is your_template.tf.erb
. In the template @foo
is available as a String value and
@hue
is available as an Array.
The same gets applied to the global resource template your_global_resource_template.tf.erb
: @key0
and @key1
are available
as instance variables within the corresponding template.
Partial templates
In addition to standard templates which are used to render collections as a whole, modules support templates which can be used on single records within collections. This comes in handy if data related to a single record has to be rendered as it keeps templates short and YAML configuration logically structured.
An example use case for this is RDS hosts and Route 53 records where a single database can have multiple Route 53 records.
Consider the following configuration data for an RDS host within the RDS module:
template_data:
rds:
instances:
- instance_identifier: "dashboard"
instance_class: "db.m4.xlarge"
engine_version: "9.6.5"
engine: "postgres"
allocated_storage: 500
partials:
route53:
- app: "dashboard"
countries:
- "au"
- "bd"
- "bn"
- "hk"
- "kr"
- "my"
- "ph"
- "pk"
- "th"
- "tw"
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
be available as instance variables. That means, data within in instances
will be present in the template as @instances
.
When iterating over the instances in the main template, one can trigger rendering of a partial in the context of the current instance
if data for an existing partial template is present.
In the above example, the configuration for the instance with the identifier "dashboard" refers to a partial template called route53
and defines some
data within the scope of the key route53
.
The partial template can be rendered in the main template as follows:
<% @instances.each do |instance| %>
module "rds-<%= instance['instance_identifier'] %>" {
source = "./modules/rds"
...
instance_identifier = "<%= instance['instance_identifier'] %>"
}
# Render partial "_route53.tf.erb" if data is present
# within partials => route53
<%= render_partial(name: :route53, data: instance, force_rendering: false) %>
<% end %>
Note: The parameter force_rendering
defines whether or not the partial will be rendered regardless of data being present or absent. The parameter
defaults to true
.
Partial default values
If the data for a partial template is a Hash, defaults can be loaded from a YAML file in the templates/defaults
directory.
All of the default values will be injected into the partial template, respecting the values set in the main YAML configuration.
Thus, the values in the main configuration act as overrides to the defaults.
For further information about how partial defaults work, please refer to the example
module. You can render the module and check its
output by running the following command:
./proteus context example demo_env render && cat contexts/example/demo_module.tf
Default values
Each of the modules in this repository contains a file called io.tf
which defines the input and output variables of the module.
For input variables, default values can be defined. These defaults can be overridden using the YAML configuration.
The following example is based on the elasticache
module:
The module defines a variable named node_type
in its io.tf
manifest:
variable "node_type" {
default = "cache.t2.micro"
}
The configuration for the staging
environment sets an override as follows:
...
elasticache:
instances:
- replication_group_id: sidekiq
node_type: cache.m3.medium # override for node_type
engine: "redis"
engine_version: "3.2.4"
availability_zones:
- eu-west-1a
number_cache_clusters: 1
...
Inside of the template elasticache.tf.erb
, the method render_defaults
gets called:
...
engine = "<%= instance['engine'] %>"
engine_version = "<%= instance['engine_version'] %>"
<%= render_defaults(instance) %>
environment = "${var.environment}"
vpc_id = "${module.vpc.id}"
...
render_defaults
internally checks if the given context (in this case the data for an ElastiCache instance) defines
overrides for defaults defined in io.tf
and, if overrides are present, renders them into the template.
Validators
The proteus
library provides a simple DSL for validating module configuration. The DSL is available for validator classes.
Validator classes
The following conventions apply for validator classes:
- contained in
validator.rb
in config root of the respective module - Class name: module name in upper camel case
- Validators inherit from
Proteus::Validators::BaseValidator
- Validators override (and implement) exactly one method:
validate
Validation DSL
The DSL provides the following keywords:
Keyword | Description |
---|---|
within(key) { block } |
Ensures presence of key and data below key
|
ensure_unique_values |
Ensures unique values in collections |
ensure_data_type(type) |
Checks if the current context is of type type
|
ensure_uniqueness_across(key) |
Ensures uniqueness across a hierarchy |
each_key { block } |
Iterates over keys in the current context |
ensure_keys(*keys) |
Checks for presence of all provided keys in the current context |
each { block } |
Iterates over elements of a collection |
ensure_presence(key) |
Checks if key is present in the current context |
ensure_value(key, options) |
Ensures a value is in a set of predefined values or range or matches a regular expression |
in_case(key, has_value: [...]) { block } |
Optionally executes block if the value of key is in has_value
|
Here's an example for data and the corresponding validator:
Data:
template_data:
my_template:
countries:
country_a:
apps:
- foo
- bar
- baz
country_b:
apps:
- foo
- bar
- baz
Validator method:
def validate
within :template_data do # fails if template_data is absent
ensure_data_type Hash # fails is template_data is not a Hash
within :my_template do # fails if my_template is absent
ensure_data_type Hash # fails if my_template is not a Hash
within: countries do # fails if countries is absent
each_key do # iterates over countries
within :apps do # fails if any country is missing the apps key
ensure_data_type Array # fails if apps is not an Array
ensure_unique_values # fails if apps has duplicate values
end
end
end
end
end
end
Generators
TBD
Development
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.
To be done
- Tests
- Code documentation (YARD)
- Enforcing validators?
- Rubocop
License
Copyright (c) 2019 Delivery Hero SE. Proteus is free software, and may be redistributed under the terms specificed in the license.