Skip to main content

Composing Modules

A Terraform module's contract with its caller is a list of variables, and almost all of them are strings. Pass a subnet ID where a VPC ID was expected and nothing objects; both are text, and the mistake surfaces when the provider rejects it, partway through an apply. So the safe way to use somebody else's module is to read its source, which is not much better than not using it.

Octo modules are wired by handing over nodes. The shop's storage module is not given a region ID, it is given the region, and its schema states what it will accept. This page is what that looks like in practice: connecting modules, running one twice, replacing one, and what happens when a connection is wrong.

Handing over a node​

  - moduleId: region-module
moduleClass: AwsSingleAzRegionModule
moduleInputs:
account: "${{account-module.model.account}}"
name: eu-west-1
regionId: aws-eu-west-1a
vpcCidrBlock: 10.0.0.0/16

- moduleId: receipts-module
moduleClass: AwsS3StorageServiceModule
moduleInputs:
region: "${{region-module.model.region}}"
bucketName: "${env.RECEIPTS_BUCKET_NAME}"

${{region-module.model.region}} resolves to the region node itself, and that is what the storage module needs. From the region it walks up to the account and the app to learn which account it is deploying into, and asks the region itself which AWS region that is. Handed the string eu-west-1 it could do none of that.

So the rule when writing a definition file is to pass the node. ${{region-module.model.region}} gives a module something it can ask questions of; ${{region-module.model.region.regionId}} gives it a string it can only trust.

The same module, twice​

A moduleId is per instance, so loading a class again gives an independent one. This is how the shop gets a public and a private subnet out of one subnet module:

  - moduleId: public-subnet-module
moduleClass: AwsSimpleSubnetModule
moduleInputs:
region: "${{region-module.model.region}}"
subnetAvailabilityZone: eu-west-1a
subnetCidrBlock: 10.0.0.0/24
subnetName: public-subnet
subnetOptions:
createNatGateway: true
disableSubnetIntraNetwork: false
subnetType: public

- moduleId: private-subnet-module
moduleClass: AwsSimpleSubnetModule
moduleInputs:
region: "${{region-module.model.region}}"
subnetAvailabilityZone: eu-west-1a
subnetCidrBlock: 10.0.2.0/24
subnetName: private-subnet
subnetOptions:
createNatGateway: false
disableSubnetIntraNetwork: true
subnetType: private
subnetSiblings:
- attachToNatGateway: true
subnet: "${{public-subnet-module.model.subnet}}"

Two folders, public-subnet-module/ and private-subnet-module/, and the second is handed the first. attachToNatGateway: true is the private subnet asking to route through the public one's NAT gateway, and the module works out what that costs in route tables.

Order​

Modules run in the order they are listed, and a module can only see what an earlier one registered. Move the receipts module above the region module and the run stops before anything is written:

Error: Input "receipts-module.input.region" refers to "${{region-module.model.region}}", which does not exist!
at InputService.refuseMissingReference (.../octo/dist/services/input/input.service.js:340:15)
at InputService.resolve (.../octo/dist/services/input/input.service.js:242:18)
at .../octo/dist/modules/module.container.js:54:62
inputKey: receipts-module.input.region

The first line is the schema check passing, so the file itself is fine. The failure is one step later, while the modules are being run. It names the input that failed and what it pointed at, which is either listed too late or misspelled.

Modules of one class run together, at the place where the first of them is listed. With the public subnet listed first, a filesystem, and then a private subnet that mounts the filesystem, both subnets run before the filesystem exists, and the private subnet fails as above. List the filesystem above the first subnet, not only above the subnet that needs it.

This ordering has nothing to do with the order things are created in AWS. That comes from the dependency edges between nodes, which is why the shop's checkout folder applies before the receipts folder even though the receipts module is listed first.

Replacing a module​

The shop uses AwsSingleAzRegionModule. Our AWS library also has AwsMultiAzRegionModule, which takes a list of availability zones instead of one:

imports:
- className: AwsMultiAzRegionModule
importPath: "@quadnix/octo-aws-cdk/modules/region/aws-multi-az-region"

modules:
- moduleId: region-module
moduleClass: AwsMultiAzRegionModule
moduleInputs:
account: "${{account-module.model.account}}"
name: eu-west-1
regionIds:
- aws-eu-west-1a
- aws-eu-west-1b
vpcCidrBlock: 10.0.0.0/16

Nothing downstream changes. The receipts module is still handed ${{region-module.model.region}}, and the checkout server module still resolves the same way, because neither was ever given an ID. They were handed a region, and both region modules publish the same AwsRegionAnchor.

What actually differs after the swap is worth being precise about. The generated region-module/ folder is the same VPC and internet gateway, because both modules define a region as one VPC. The change is in what the region advertises: its anchor now lists two availability zones instead of one, and that is what a subnet module reads when it decides where it can place itself.

The honest limit is that on live infrastructure this is still a migration. Octo will tell you exactly what gets created, replaced, and destroyed, and it does not sequence that transition for you.

Mixing libraries​

Nothing in the composition above depends on the modules coming from the same package. A module declares what it needs as an anchor schema, and any module publishing that shape satisfies it. So a region module you wrote in-house works with our storage module, provided it publishes an AwsRegionAnchor.

That is also the constraint. Composition works when the two sides agree on an anchor schema, and there is no automatic bridge when they do not. If your region publishes AcmeRegionAnchor and a module wants AwsRegionAnchor, you either publish both anchors from your region, or register a schema translation between them.

When a connection is wrong​

Point the storage module at an account instead of a region, and:

Error: Property "region" in schema could not be validated!
at getSchemaInstance (.../octo/dist/functions/schema/schema.js:22:19)
at ModuleContainer.apply (.../octo/dist/modules/module.container.js:59:46)
at async Octo.compose (.../octo/dist/main.js:120:16)
schemaClassName: AwsS3StorageServiceModuleSchema

The two failures mean different things. "Does not exist" is a reference to something that is not there, listed too late or misspelled. "Could not be validated" is a real node of the wrong shape.

Both happen while the graph is being assembled, before a line of Terraform is written and long before anything is created. That is the trade the whole design is making: more errors, all of them early.