open-source

Introducing Boilerplate

An open source, cross-platform project generator / scaffolding tool
Introducing Boilerplate
YB
Yevgeniy Brikman
Co-Founder
Published March 6, 2024

An open source, cross-platform project generator / scaffolding tool

I’m happy to announce that we’ve we’ve open sourced a cross-platform project generator / scaffolding tool called Boilerplate, licensed under MPL 2.0: https://github.com/gruntwork-io/boilerplate.

We’ve used Boilerplate for years at Gruntwork to generate oft-repeated code, such as Terraform/OpenTofu projects, the Reference Architecture, and vending new AWS accounts and GitHub repos as part of DevOps Foundations. Boilerplate is similar to other project generator tools such as cookiecutter, yeoman, and copier, but with the advantages of being cross-platform (a single standalone binary), with support for typed input variables, validations, scripting, template composition, interactive and non-interactive modes, and more.

In this blog post, I’ll walk you through the following:

Example use cases

Boilerplate is useful whenever you find yourself repeatedly creating the same sort of file and folder structures over and over again, but need to be able to fill in some of the information dynamically, based on user input, such as:

  • Generating a new Terraform/OpenTofu module that has all of your company’s patterns and conventions baked in: e.g., folder structure, file naming, documentation, automated testing, etc.
  • Generating a new Java project with the standard directory layout.
  • Generate a repo and folder structure for each new team or microservice as part of supporting developer self-service.
  • Generating oft-repeated files, such as legal disclaimers or license at the top of each source file or in LICENSE.txt files.

Basic usage

The basic idea behind Boilerplate is that you create a template folder that contains:

  • A boilerplate.yml file that configures the template, such as the input variables to gather from the user.
  • Any number of other files and folders that generate the code you want, using Go templating syntax to fill in those input variables where necessary, do loops, do conditionals, and so on.

Let’s go through an example of creating a Boilerplate template to generate a basic OpenTofu module. This could be a good way to help standardize your company around common coding conventions for your modules, including:

  • File naming conventions: e.g., main.tf, outputs.tf, variables.tf.
  • Documentation: e.g., README.md.
  • License: e.g., LICENSE.txt.

Create a template folder called tofu-module and put a file called boilerplate.yml into it with the following contents:

variables:
- name: ModuleName
description: The name of the module
type: string

- name: CopyrightInfo
description: Copyright information to put in the README. Typically "Copyright <year> <company>."
type: string
default: ""

- name: TofuVersion
description: The version of OpenTofu to use
type: string
default: 1.6.2

This boilerplate.yml declares three variables to gather from the user: ModuleName, CopyrightInfo, TofuVersion. Note how each input variable has an description, type, and default value.

Next, create a main.tf with the following contents:

terraform {
required_version = "{{ .TofuVersion }}"
}

The preceding code is configuring the version of OpenTofu to use, but instead of hard-coding the value, the code above uses Go templating syntax ({{ ... }}) to fill in the value from the TofuVersion input variable declared earlier in boilerplate.yml.

Next, create a README.md with the following contents:

# {{ .ModuleName | title }} module

TODO: fill in documentation for the {{ .ModuleName | kebabcase }} module.

## License

{{ if .CopyrightInfo -}}
{{ .CopyrightInfo }}

{{ end -}}

Please see [LICENSE.txt](LICENSE.txt) for details on how the code in this
repo is licensed.

This file has more Go templating syntax to fill in data based on the input variables you declared earlier in boilerplate.yml, including ModuleName and CopyrightInfo. Note the use of the title and kebabcase functions: Boilerplate comes with all the sprig helpers built-in, so this code is making use of the title helper and kebabcase helper to format the module name in title case or kebab case, respectively.

The code above also contains a simple if-statement, ensuring that the data in .CopyrightInfo is only rendered if it’s non-empty. Go templating syntax lets you use conditionals, loops, functions, etc.

You may want to add variables.tf, outputs.tf, and LICENSE.txt files to your template as well. Have a look at the tofu-module example for the full code sample.

To generate a module from this template folder, run the following command:

boilerplate \
--template-url tofu-module \
--output-folder /tmp/tofu-module

The preceding command tells boilerplate to use the template folder tofu-module and to put the rendered output into the output folder /tmp/tofu-module. When you run this command, Boilerplate will to prompt you for each input variable:

ModuleName
The name of the module
? Please enter ModuleName: vpc

TofuVersion
The version of OpenTofu to use
? Please enter TofuVersion: 1.5.7

CopyrightInfo
Copyright information to put in the README. Typically
"Copyright <year> <company>."
? Please enter CopyrightInfo: Copyright 2024 Gruntwork, Inc.

Boilerplate shows you the variable name, description, and allows you to interactively type in the value for each variable. If you don’t want to do this interactively, you can run Boilerplate with the --non-interactive flag, and instead set the input variables via one or more --var flags. Alternatively, you can set all the values in a YAML file:

ModuleName: vpc
CopyrightInfo: Copyright 2024 Gruntwork, Inc.
TofuVersion: 1.6.2

And pass the path of that YAML file to Boilerplate via the --var-file flag:

boilerplate \
--template-url tofu-module \
--output-folder /tmp/tofu-module \
--var-file vars.yml \
--non-interactive

Boilerplate will go through all the files in your template folder and render them into the output folder:

$ cd /tmp/tofu-module
$ tree
.
├── LICENSE.txt
├── README.md
├── main.tf
├── outputs.tf
└── variables.tf

If you open up the files in the output folder, you’ll see that Go templating syntax is now fully rendered based on the input variables values you entered. For example, here’s the rendered main.tf:

terraform {
required_version = "1.6.2"
}

And here’s the rendered README.md:

# Vpc module

TODO: fill in documentation for the vpc module.

## License

Copyright 2024 Gruntwork, Inc.

Please see [LICENSE.txt](LICENSE.txt) for details on how the code in this
repo is licensed.

More advanced usage

Now that you’ve seen basic project generation in action, let’s look at a couple of the more advanced features available in Boilerplate:

  1. dependencies: create more complicated templates by composing together simpler templates.
  2. hooks: add scripting to your templates to perform actions that can’t be done with pure templating.

We’ll piggy back on top of the OpenTofu example in the previous section, but now, instead of generating just a single module, we’re going to generate our full recommended folder structure, which includes:

  • modules: The module itself goes into the modules folder. You’ll reuse the tofu-module template from the previous section to create the module.
  • examples: An example usage of your module goes into the examples folder. These examples are essentially executable documentation, both showing how to use your code, and giving you both a manual and automated test harness for testing that code.
  • test: An automated test for the example code in the examples folder.

Create a new template folder called tofu-example and put a new boilerplate.yml file in it with the following contents:

variables:
- name: ModuleName
description: The name of the module
type: string

- name: ModuleSource
description: The source URL (or file path) to use for the module
type: string

- name: TofuVersion
description: The version of OpenTofu to use
type: string
default: 1.6.2

hooks:
after:
# Format the code
- command: tofu
args:
- fmt
dir: "{{ outputFolder }}"

This is very similar to the boilerplate.yml from the previous section, declaring a few input variables at the top, but there’s something new on the bottom: a hooks section that defines scripts to run, either before code generation (in a before section) or after code generation (in an after section). The preceding code uses the after section to run tofu fmt to apply standard formatting to the generated code.

Add a main.tf file to the tofu-example folder with the following contents:

terraform {
required_version = "{{ .TofuVersion }}"
}

module "{{ .ModuleName | snakecase }}" {
source = "{{ .ModuleSource }}"

example_required_input = "Hello"
example_optional_input = "World"
}

This code will “instantiate” the module generated by the tofu-module template in the previous section. You should also add an outputs.tf file to tofu-example to proxy through the output variables from the module:

output "example_output" {
description = "example output"
value       = module.{{ .ModuleName | snakecase }}.example_output
}

Have a look at tofu-example for the full code sample.

OK, that does it for the tofu-example template. Next, create one more template folder called tofu-test, with the following boilerplate.yml:

variables:
- name: ModuleName
description: The name of the module
type: string

- name: ExamplePath
description: The folder path to to the example usage of the module to test
type: string

hooks:
after:
# Format the Go code
- command: goimports
args:
- "-w"
- "."
dir: "{{ outputFolder }}"

The code above defines a few input variables and makes use of hooks to format code using goimports. Next, create a file called {{ .ModuleName | snakecase }}_test.go (yes, you can use Go templating syntax in file and folder names!) and put the following contents in it:

package test

import (
"testing"

"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)

func Test{{ .ModuleName | camelcase }}(t *testing.T) {
t.Parallel()

opts := &terraform.Options{
TerraformDir:    "{{ .ExamplePath }}",
TerraformBinary: "tofu",
}

defer terraform.Destroy(t, opts)

terraform.InitAndApply(t, opts)

actualOutput := terraform.OutputRequired(t, opts, "example_output")
assert.Equal(t, "Hello World", actualOutput)
}

Have a look at the tofu-test example for the full code sample.

This is Go code that uses the Terratest library to create an automated test for your code that does the following:

  1. Run tofu init.
  2. Run tofu apply.
  3. Check that the output variable example_output is equal to the value “Hello, World”.
  4. Run tofu destroy at the end of the test.

OK, you now have three project templates. To put them together, create one final template folder called tofu-module-full and put the following boilerplate.yml in it:

variables:
- name: ModuleName
description: The name of the module
type: string

- name: CopyrightInfo
description: Copyright information to put in the README. Typically "Copyright <year> <company>."
type: string
default: ""

- name: TofuVersion
description: The version of OpenTofu to use
type: string
default: 1.5.7

dependencies:
- name: module
template-url: ../tofu-module
output-folder: "modules/{{ .ModuleName | kebabcase }}"

- name: example
template-url: ../tofu-example
output-folder: "examples/{{ .ModuleName | kebabcase }}"
variables:
- name: ModuleSource
description: The source URL (or file path) to use for the module
type: string
default: "../../modules/{{ .ModuleName | kebabcase }}"

- name: test
template-url: ../tofu-test
output-folder: "test"
variables:
- name: ExamplePath
description: The source URL (or file path) to use for the module
type: string
default: "../examples/{{ .ModuleName | kebabcase }}"

Have a look at the tofu-module-full example for the full code sample.

The code above gathers a few input variables, as you’ve seen before, but also introduces something new: dependencies. The dependencies section allows you to specify other Boilerplate templates that should be rendered as part of rendering the current one. This allows you to combine simpler templates together into more complicated ones.

The code above uses all three templates you created earlier, configuring a different output-folder for each of these dependencies: the tofu-module template will be rendered into the modules folder, the tofu-example template will be rendered into the examples folder, and the tofu-test template will be rendered into the test folder.

Note that, by default, any input variables in the top-level template are automatically passed down to the templates declared in dependencies, so, for example, the ModuleName, CopyrightInfo, and TofuVersion gathered in tofu-module-full automatically get passed down to tofu-module. However, you can set or override variables by adding a variables section to each dependency, which is how the code above configures the proper values for ModuleSource and ExamplePath for tofu-example and tofu-test, respectively.

Run Boilerplate to render your tofu-module-full template:

boilerplate \
--template-url tofu-module-full \
--output-folder /tmp/tofu-module-full \
--var-file vars.yml \
--non-interactive

Here are the files and folders that get generated:

$ cd /tmp/tofu-module-full
$ tree
.
├── examples
│   └── vpc
│       ├── README.md
│       ├── main.tf
│       └── outputs.tf
├── modules
│   └── vpc
│       ├── LICENSE.txt
│       ├── README.md
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
└── test
└── vpc_test.go

You now have a fully working and testable OpenTofu module! Jump into examples/vpc to manually test and see if it is working:

$ cd examples/vpc
$ tofu apply

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

example_output = "Hello World"

Looks good. Next, jump into the test/vpc folder to run an automated test to check it’s working. On the very first run, you need to initialize the Go module and download dependencies by running go mod init and then go mod tidy:

$ cd test/vpc
$ go mod init tofu-module-full
go: creating new go.mod: module tofu-module-full

$ go mod tidy
go: finding module for package github.com/stretchr/testify/assert
go: finding module for package github.com/gruntwork-io/terratest/modules/terraform
go: found github.com/gruntwork-io/terratest/modules/terraform in github.com/gruntwork-io/terratest v0.46.11
go: found github.com/stretchr/testify/assert in github.com/stretchr/testify v1.8.4

And now you can run the automated tests with go test:

$ go test -v
TestVpc 2024-03-05T11:31:40-05:00 retry.go:91: tofu [init -upgrade=false]
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Running command tofu with args [init -upgrade=false]
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing the backend...
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing modules...
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Initializing provider plugins...
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: OpenTofu has been successfully initialized!
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: You may now begin working with OpenTofu. Try running "tofu plan" to see
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: any changes that are required for your infrastructure. All OpenTofu commands
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: should now work.
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: If you ever set or change modules or backend configuration for OpenTofu,
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: rerun this command to reinitialize your working directory. If you forget, other
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: commands will detect it and remind you to do so if necessary.
TestVpc 2024-03-05T11:31:40-05:00 retry.go:91: tofu [apply -input=false -auto-approve -lock=false]
TestVpc 2024-03-05T11:31:40-05:00 logger.go:66: Running command tofu with args [apply -input=false -auto-approve -lock=false]
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: No changes. Your infrastructure matches the configuration.
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: OpenTofu has compared your real infrastructure against your configuration and
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: found no differences, so no changes are needed.
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Outputs:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: example_output = "Hello World"
TestVpc 2024-03-05T11:31:41-05:00 retry.go:91: tofu [output -no-color -json example_output]
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Running command tofu with args [output -no-color -json example_output]
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: "Hello World"
TestVpc 2024-03-05T11:31:41-05:00 retry.go:91: tofu [destroy -auto-approve -input=false -lock=false]
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Running command tofu with args [destroy -auto-approve -input=false -lock=false]
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Changes to Outputs:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:   - example_output = "Hello World" -> null
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: You can apply this plan to save these new output values to the OpenTofu
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: state, without changing any real infrastructure.
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66: Destroy complete! Resources: 0 destroyed.
TestVpc 2024-03-05T11:31:41-05:00 logger.go:66:
--- PASS: TestVpc (0.13s)
PASS
ok   tofu-module-full 0.526s

And there you go! A fully working OpenTofu module, example, and test, all automatically generated for you.

Give it a shot!

Of course, you can use Boilerplate for generating any type of code, and not just Terraform/OpenTofu. Now that Boilerplate is open source, give it a shot, and see what you can come up with:

https://github.com/gruntwork-io/boilerplate

Let us know how it works for you. Better yet, PRs are very welcome to make it even better!