1.0.5 • Published 3 years ago

@joshwycuff/terrascript v1.0.5

Weekly downloads
407
License
MIT
Repository
github
Last release
3 years ago

terrascript

JavsScript/TypeScript wrapper for running Terraform commands in NodeJS

npm version Actions Status

What is Terrascript?

There are two parts to Terrascript. One is a JavaScript/TypeScript wrapper for running Terraform commands in NodeJS. The other is a CLI which allows you to structure, organize, and automate Terraform configurations and tasks.

Installation

Using npm:

npm install @joshwycuff/terrascript

Using yarn:

yarn add @joshwycuff/terrascript

I also recommend using @jahed/terraform along with Terrascript.

Usage

CLI

Terrascript command-line arguments take the form:

terrascript TARGET_PATH COMMAND [ARGUMENTS...]

You can run any command you want via terrascript but there is special support for Terraform commands.

Terrascript needs to be run in the same directory as a Terrascript yaml configuration file (terrascript.yml).

Configuration file

A configuration file with every supported key and no values looks like the following:

name:
subprojects:
config:
groups:
hooks:
modules:
scripts:
targets:
definitions:

Note that nothing is stopping you from storing additional information in other keys. This can even be useful when using template expressions.

Name

The name key contains a string value. It is not required and will default to the name of the directory the yaml file is in.

Targets

A Terrascript command will not run if the TARGET_PATH does not resolve to a defined target in the configuration file.

Here's a config file with a single target named dev:

targets:
  dev:

You can now run something like:

terrascript dev echo hello world
# hello world

You can also specify multiple targets:

targets:
  dev:
  prod:

Now you can run commands for either target, or you can use glob patterns to run multiple targets:

terrascript "*" pwd
# /Users/somebody/project
# /Users/somebody/project
Groups

You can also specify groups of targets:

groups:
  agroup:
    - dev
    - prod
targets:
  dev:
  prod:
terrascript agroup pwd
# /Users/somebody/project
# /Users/somebody/project

Yeah, I know. These commands are not terribly useful yet. Hold on...

Config

You can use the config key to set environment variables.

config:
  env:
    A_VAR: A_VAL
targets:
  dev:
terrascript dev echo '$A_VAR'
# A_VAL

You can also override the top-level config with target-level configs.

config:
  env:
    A_VAR: A_VAL
targets:
  dev:
    config:
      env:
        A_VAR: overridden
terrascript dev echo '$A_VAR'
# overridden
Aliasing

If you have something in your config file that needs to be in several places, you can use aliasing. Alias definitions are found under the definitions key and must begin with "$".

config:
  env:
    VAR1: $VAL
    VAR2: $VAL
targets:
  dev:
definitions:
  $VAL: stuffidontwanttorepeat
terrascript dev echo '$VAR1'
# stuffidontwanttorepeat

Aliasing is a simple way to make DRY config files. However, it is limited. Subprojects cannot "see" alias definitions of parent projects. You also can't combine or perform any logic with aliases. For anything that aliasing can't accomplish, you probably need to go with template expressions.

Scripts

If you have a set of commands that you run often, you can create a script for them.

targets:
  dev:
scripts:
  things:
    - echo thing 1
    - echo thing 2
terrascript dev things
# thing 1
# thing 2
Template Expressions

You can use template expressions to access various available variables within the current context or even to run arbitrary Javascript code.

The main available variable is called context which looks like this:

export interface IContext extends Hash<any> {
    tf?: Terraform;
    conf: IConfig;
    spec: ISpec;
    target?: ITarget;
}

tf is an instance of Terraform. This is more useful in module functions than templates.

conf is the current config under which the given command/hook/script is being run.

spec is the yaml configuration (or specification) under which the given command/hook/script is being run.

target is the current target.

tf and target may or may not be available depending on if you're accessing the context in a hook and which hook it is. They're always available in a normal command or script, though.

Also, for convenience, conf, spec, and target are made available in templates.

Template expressions are evaluated dynamically at runtime and can access inherited values.

name: project
target:
  dev:
scripts:
  things:
    - echo {{ spec.name }}
    - echo {{ target.name }}
    - echo {{ 1 + 1 }}
terrascript dev things
# project
# dev
# 2
Hooks

There are a number of special hooks that allow you to run commands before or after certain events. Here they are in the order in which they run:

  • beforeEachSubproject
  • beforeEachTarget
  • beforeEachScript
  • beforeEachCommand
  • beforeEachTerraform
  • beforeEachTerraformApply
  • beforeEachTerraformDestroy
  • afterEachTerraformDestroy
  • afterEachTerraformApply
  • afterEachTerraform
  • afterEachCommand
  • afterEachScript
  • afterEachTarget
  • afterEachSubproject

Here's an example:

targets:
  dev:
  prod:
hooks:
  beforeEachTarget:
    - echo do a thing beforeEachTarget
terrascript "*" echo '{{ target.name }}'
# do a thing beforeEachTarget
# dev
# do a thing beforeEachTarget
# prod
Modules

With modules, you can also run Javascript functions within scripts. The function should take a single input which is the context object. Modules that you want to import should look like this:

// scripts/mymodule.js
module.exports = {
    func: (context) => {
        console.log('running func')
        console.log(`I'm in ${context.target.name}`)
    }
}

You can import Javascript modules and run them in scripts like so:

targets:
  dev:
modules:
  mymodule: scripts/mymodule.js
scripts:
  doathing:
    - function: mymodule.func
terrascript dev doathing
# running func
# I'm in dev

You can also modify the context which then propagates to downstream targets.

// scripts/mymodule.js
module.exports = {
    func: (context) => {
        console.log('running func')
        console.log('adding an environment variable to the config')
        context.conf.env.A_VAR = 'A_VAL'
    }
}
targets:
  dev:
modules:
  mymodule: scripts/mymodule.js
scripts:
  doathing:
    - echo $A_VAR
    - function: mymodule.func
    - echo $A_VAR
terrascript dev doathing
#
# running func
# adding an environment variable to the config
# A_VAL
Special Terraform support

Okay, so I want to do Terraform things...

Any Terraform subcommand can be run by just specifying the subcommand and its arguments. You don't have to type terraform as part of the command.

terrascript dev init
terrascript dev plan
terrascript dev apply

This shorthand also extends to scripts.

targets:
  dev:
scripts:
  build:
    - init
    - plan
    - apply

You can specify Terraform input variables in the config section.

config:
  tfVars:
    environment: "{{ target.name }}"
targets:
  dev:
terrascript dev apply
# `terraform apply -var=environment=dev`

You can specify Terraform variable definitions (.tfvars) files in the config section as well.

config:
  tfVarsFiles:
    - "tfvars/{{ target.name }}.tfvars"
targets:
  dev:
terrascript dev apply
# `terraform apply -var-file=tfvars/dev.tfvars`

Tired of typing -auto-approve?

config:
  autoApprove: true
targets:
  dev:
terrascript dev apply
# `terraform apply -auto-approve`

Want to specify auto-approve for only apply or destroy commands?

config:
  autoApproveApply: true
  autoApproveDestroy: true
targets:
  dev:

You can dynamically configure your remote backend in the config block.

# main.tf
terraform {
    backend "s3" {}
}
config:
  autoApprove: true
  backendConfig:
    profile: my-profile
    region: us-east-1
    bucket: my-remote-state-bucket
    key: "project/{{ target.name }}/terraform.tfstate"
    dynamodb_table: my-remote-state-lock-table
targets:
  dev:
  prod:
hooks:
  # This hook is useful when running multiple targets which have different backend configurations.
  beforeEachTarget: init -reconfigure
terrascript "*" apply
# `terraform init -reconfigure \
#     -backend-config=profile=my-profile \
#     -backend-config=region=us-east-1 \
#     -backend-config=bucket=my-remote-state-bucket \
#     -backend-config=key=project/dev/terraform.tfstate \
#     -backend-config=dynamodb_table=my-remote-state-lock-table`
# `terraform apply -auto-approve`
# `terraform init -reconfigure \
#     -backend-config=profile=my-profile \
#     -backend-config=region=us-east-1 \
#     -backend-config=bucket=my-remote-state-bucket \
#     -backend-config=key=project/prod/terraform.tfstate \
#     -backend-config=dynamodb_table=my-remote-state-lock-table`
# `terraform apply -auto-approve`
Subprojects

What if I have a bunch of Terraform projects?

Let's say we have a project structure like this:

.
├── terrascript.yml
└── infrastructure/
    ├── subproject1/
    │   ├── terrascript.yml
    │   ├── main.tf
    │   └── ...
    └── subproject2/
        ├── terrascript.yml
        ├── main.tf
        └── ...

And the terrascript.yml files look like this:

# ./terrascript.yml
subprojects:
  subproject1: ./infrastructure/subproject1/
  subproject2: ./infrastructure/subproject2/
# ./infrastructure/subproject1/terrascript.yml
targets:
  dev:
  prod:
# ./infrastructure/subproject2/terrascript.yml
targets:
  dev:
  prod:

To run the dev target for just subproject1:

terrascript subproject1/dev apply

To run the dev target for all subprojects:

terrascript dev apply

To run all targets for all subprojects:

terrascript "*" apply

Note that since the top-level terrascript.yml does not contain any targets, these commands are not run there. It simply passes the commands down to its subprojects.

Also note that glob patterns apply to both subprojects and targets.

Inheritance

Subprojects inherit yaml configuration from parent projects. This applies to every supported key with the notable exceptions of subprojects and targets. This means that backendConfig (and pretty much anything else) set in the top-level terrascript.yml file is also found in lower-level subprojects at runtime. Values in subprojects override inherited values (just as target-level values will override everything else).

Let's make a more complicated project.

.
├── terrascript.yml
└── infrastructure/
    ├── subproject1/
    │   ├── terrascript.yml
    │   ├── subproject1a/
    │   │   ├── terrascript.yml
    │   │   ├── main.tf
    │   │   └── ...
    │   └── subproject1b/
    │       ├── terrascript.yml
    │       ├── main.tf
    │       └── ...
    └── subproject2/
        ├── terrascript.yml
        ├── subproject2a/
        │   ├── terrascript.yml
        │   ├── main.tf
        │   └── ...
        └── subproject2b/
            ├── terrascript.yml
            ├── main.tf
            └── ...

The terrascript files might look something like:

# ./terrascript.yml
# Note that the key below has no special significance to Terraform or Terrascript. It's simply a
# stored value that gets passed down via inheritance and is useful for templating (as you can see
# in the backend key below).
projectName: project
subprojects:
  subproject1: ./infrastructure/subproject1/
  subproject2: ./infrastructure/subproject2/
config:
  autoApprove: true
  backendConfig:
    profile: my-profile
    region: us-east-1
    bucket: my-remote-state-bucket
    # this key would come out to something like: project/subproject1/subproject1a/dev/terraform.tfstate
    key: "{{ spec.projectName }}/{{ spec.subprojectName }}/{{ spec.name }}/{{ target.name }}/terraform.tfstate"
    dynamodb_table: my-remote-state-lock-table
  tfVars:
    environment: "{{ target.name }}" # This input variable would apply to every subproject.
hooks:
  # This hook is useful when running multiple targets which have different backend configurations.
  beforeEachTarget: init -reconfigure
# ./subproject1/terrascript.yml
subprojectName: subproject1
subprojects:
  subproject1a: ./subproject1a/
  subproject1b: ./subproject1b/
config:
  tfVars:
    someName: someValue # this is an input variable that applies to all of subproject1 including its subprojects
# ./subproject1/subproject1a/terrascript.yml
config:
  tfVars:
    someNameFor1a: someValueFor1a # this is an input variable that applies only to subproject1a
targets:
  dev:
  prod:

You can see that this project has a more nested structure where different subprojects have different configuration needs but there are common elements that can be templated and passed down through inheritance.

Now, let's say you want to run a command for just subproject1a:

terrascript subproject1/subproject1a/dev apply # note that we don't need to init since we've got that hook

What if, for some reason, I wanted to run a command for only subprojects ending in "b"?

terrascript "*/*b/dev" apply  # note that the quotes are needed because of the asterisks

Can I run dev for all subprojects? Yep.

terrascript dev apply

Can I run every target for every subproject? Yep.

terrascript "*" apply
cd ./infrastructure/subproject1/subproject1a/
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject1/subproject1a/dev/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=dev \
    -var=someName=someValue \
    -var=someNameFor1a=someValueFor1a
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject1/subproject1a/prod/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=prod \
    -var=someName=someValue \
    -var=someNameFor1a=someValueFor1a
cd ../../infrastructure/subproject1/subproject1b/
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject1/subproject1b/dev/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=dev \
    -var=someName=someValue
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject1/subproject1b/prod/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=prod \
    -var=someName=someValue
cd ../../infrastructure/subproject2/subproject2a/
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject2/subproject2a/dev/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=dev
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject2/subproject2a/prod/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=prod
cd ../../infrastructure/subproject2/subproject2b/
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject2/subproject2b/dev/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=dev
terraform init -reconfigure \
    -backend-config=profile=my-profile \
    -backend-config=region=us-east-1 \
    -backend-config=bucket=my-remote-state-bucket \
    -backend-config=key=project/subproject2/subproject2b/prod/terraform.tfstate \
    -backend-config=dynamodb_table=my-remote-state-lock-table
terraform apply -auto-approve \
    -var=environment=prod

Javascript/Typescript wrapper

TODO