Skip to main content

Create a Terraform Resource

Modules, models, and anchors are all descriptions of intent, and intent is forgiving: a name that reads oddly still works. A resource is not forgiving. It has to match what a provider actually does, including which arguments cannot be changed in place, which values only exist after an apply, and what another resource can safely reference before this one has ever been created.

This page writes ShopQueue, the SQS queue behind the shop's order queue service. It is a Terraform resource, meaning Terraform owns its lifecycle and Octo only writes the configuration and reads the outcome. That is what almost every resource should be; the external kind is an escape hatch.

octo create-terraform-resource -n shop-queue --package @shop

The schema​

src/resources/shop-queue/index.schema.ts
export class ShopQueueSchema extends BaseResourceSchema {
@Validate({
destruct: (value: ShopQueueSchema['properties']): string[] => [
value.awsAccountId,
value.awsRegionId,
value.queueName,
],
options: { minLength: 1 },
})
override properties = Schema<{
awsAccountId: string;
awsRegionId: string;
fifoQueue: boolean;
messageRetentionSeconds: number;
queueName: string;
}>();

@Validate({
destruct: (value: ShopQueueSchema['response']): string[] => {
const subjects: string[] = [];
if (value.Arn) {
subjects.push(value.Arn);
}
if (value.Url) {
subjects.push(value.Url);
}
return subjects;
},
options: { minLength: 1 },
})
override response = Schema<{
Arn?: string;
Url?: string;
}>({
Arn: 'arn:aws:sqs:eu-west-1:000000000000:mock-queue',
Url: 'https://sqs.eu-west-1.amazonaws.com/000000000000/mock-queue',
});
}

properties is the desired configuration. It carries the account and region because that is how a resource binds to a provider, and that pair is on nearly every resource for the same reason.

response is what exists only after an apply, and every key here has to be emitted as a Terraform output by toHCL() below. A key declared and not emitted makes octo commit throw missing terraform outputs after a real apply, which is the most expensive place to find out.

The response defaults are not decoration

The values passed to Schema<...>() become the Terragrunt mock_outputs a consumer plans against before this resource exists. Somebody else's terraform plan runs the provider's own parsing over them, so make each one a syntactically valid example of the real thing: a plausible ARN, a plausible URL. Without a default the mock falls back to the literal mock-<outputName>, and any consumer whose argument must parse as an ARN fails to plan.

The resource​

src/resources/shop-queue/shop-queue.resource.ts

@Resource<ShopQueue>('@shop', 'shop-queue', ShopQueueSchema)
export class ShopQueue extends ATerraformResource<ShopQueueSchema, ShopQueue> {
declare properties: ShopQueueSchema['properties'];
declare response: ShopQueueSchema['response'];

constructor (localResourceId: string, properties: ShopQueueSchema['properties']) {
super(localResourceId, properties, []);
}

override async toHCL (terraform: TerraformModuleScope): Promise<void> {
const octoResource = terraform.addOctoTerraformResource(this as ShopQueue, {
provider: { accountId: this.properties.awsAccountId, regionId: this.properties.awsRegionId },
});

const queue = octoResource.addTerraformResource('aws_sqs_queue', this.resourceId, {
fifo_queue: this.properties.fifoQueue,
message_retention_seconds: this.properties.messageRetentionSeconds,
name: this.properties.queueName,
});

octoResource.output({
Arn: terraform.raw(`${queue.address}.arn`),
Url: terraform.raw(`${queue.address}.url`),
});

if (Object.keys(this.tags).length > 0) {
queue.attribute('tags', this.tags);
}
}
}

Which produces exactly this:

.octo-tf/orders-queue-module/main.tf
resource "aws_sqs_queue" "queue-shop-orders" {
provider = aws._123456789012-eu-west-1
fifo_queue = false
message_retention_seconds = 86400
name = "shop-orders"
}
.octo-tf/orders-queue-module/outputs.tf
output "queue-shop-orders-Arn" {
value = aws_sqs_queue.queue-shop-orders.arn
}

output "queue-shop-orders-Url" {
value = aws_sqs_queue.queue-shop-orders.url
}

The as ShopQueue cast on the first line is doing real work. Without it this stays polymorphic, the scope's response type resolves against this rather than against the schema, and output() stops typechecking.

addOctoTerraformResource can be called once, and addTerraformResource as many times as the resource needs; several calls chain with depends_on in call order, which is how one Octo resource becomes a role plus two policy attachments. Name every block after the resource ID, as this.resourceId or `${this.resourceId}_policy`. When a resource's ID changes but the name it reaches AWS under stays, Octo moves each of its blocks to the block of the same suffix, and it recognizes a block only by that prefix. A block emitted once per item, like a file upload, passes { chain: false } as the fourth argument to stay out of the chain, so the items are created in parallel. The last two lines are how a resource opts in to tagging, and every Terraform resource in @quadnix/octo-aws-cdk has them.

Saying what a change costs​

The default assumption is that a property change is an in-place update, and that assumption is wrong for anything a provider marks force-new. Terraform would recreate the queue while Octo reported an update, and octo validate would show the recreate with nothing to explain it.

override async diffProperties(previous: ShopQueue): Promise<Diff[]> {
if (previous.properties.fifoQueue !== this.properties.fifoQueue) {
return [
new Diff(
this,
DiffAction.REPLACE,
'resourceId',
this.getContext(),
'fifo_queue is force-new on aws_sqs_queue; a change recreates the queue and loses its messages',
),
];
}

if (!DiffUtility.isObjectDeepEquals(previous.properties, this.properties)) {
return [new Diff(this, DiffAction.UPDATE, 'properties', this.properties)];
}

return [];
}

Two comparisons, because two of these properties behave differently. Turning the queue into a FIFO one destroys it; changing the retention does not. Saying which is which is the whole job of this method, and the reason string is what a reviewer reads:

      ± vpc-eu-west-1
why: cidr_block is force-new on aws_vpc; a change recreates the VPC

Go through the provider documentation for your resource type and list its force-new arguments. This is tedious and it is the single most valuable thing in the class.

One of them will not belong here. name is force-new on aws_sqs_queue too, and comparing it in this method would be dead code: the resource ID is derived from the queue name, so a rename produces a different resource ID, and Octo reads that as one resource destroyed and another created without ever calling diffProperties. A force-new argument that the resource ID is derived from is already handled, and Resources covers what that destroy costs. Compare the force-new arguments the ID does not carry.

Unique names​

SQS refuses a second queue named shop-orders in the same account and region. Octo keeps two resources apart by their IDs, and two queue modules loaded into two Octo regions that both sit in eu-west-1 produce two IDs and one name. Nothing about the name is wrong on its own, so the clash would surface part way through octo apply, after the first queue was created.

Say which name the provider keeps unique, and within what, in the uniqueProperties option of @Resource:

src/resources/shop-queue/shop-queue.resource.ts
@Resource<ShopQueue>('@shop', 'shop-queue', ShopQueueSchema, {
uniqueProperties: [{ path: 'queueName', within: ['awsAccountId', 'awsRegionId'] }],
})
export class ShopQueue extends ATerraformResource<ShopQueueSchema, ShopQueue> {
// ... the class body is unchanged
}

path is the property holding the name, and within lists the properties that together form the space it must be unique in. octo generate then refuses two queues that share all three values, and still accepts shop-orders in eu-west-1 and in us-east-1, which SQS allows. IAM role names are unique per account, so a role lists only awsAccountId; S3 bucket names are unique across all of AWS, so a bucket lists nothing.

A name nested in an array is written with [], as cachePolicies[].name, and two entries inside one resource are compared with each other as well. A path or a property the schema does not have is a compile error. A resource with several such names lists one entry per name.

The check compares resources of one class with each other, and nothing else:

  • A resource that lists no names is not checked, and Octo does not warn.
  • Two classes that create the same kind of thing in AWS are not compared with each other. Two bucket resources of two libraries can still pick one name.
  • A name already taken outside the app, such as a bucket name somebody else owns, still fails at octo apply.

Data a destroy loses​

Deleting a queue deletes the messages waiting in it, and nothing in octo.yaml can put them back. Say so with the destroyLosesData option of @Resource:

src/resources/shop-queue/shop-queue.resource.ts
@Resource<ShopQueue>('@shop', 'shop-queue', ShopQueueSchema, {
destroyLosesData: true,
uniqueProperties: [{ path: 'queueName', within: ['awsAccountId', 'awsRegionId'] }],
})
export class ShopQueue extends ATerraformResource<ShopQueueSchema, ShopQueue> {
// ... the class body is unchanged
}

Every destroy Terraform plans of a queue is then marked as losing data, in the validate report and in the list octo apply prints before it refuses, whether a module was removed or a property forced a replacement. It adds a message and nothing else: octo apply already refuses every destroy of a Terraform resource until --auto-approve is passed.

Set it where the resource holds what Octo cannot rebuild from its inputs: rows, files, objects, messages, images. A bucket Octo fills from a folder on every apply loses nothing it cannot restore, and leaves it unset.

Choosing parents​

A resource's parents are the resources the cloud provider needs to exist before this one can be created. They are not the list of everything the resource mentions. Two questions decide it, and the provider's API reference answers both:

  • Does the create call carry a value the provider assigns only when the other resource is created? A subnet needs its VPC's ID, and nobody knows vpc-0d8d555c656b950ad until the VPC exists.
  • Does the provider refuse the create call while the other resource is missing? S3 rejects a bucket policy that names an IAM role which does not exist yet.

A yes to either makes the other resource a parent. It goes into the constructor's parents, and its value is read with getRef, below. Where the provider needs it but this resource reads nothing from it, as an internet-facing load balancer needs its VPC's internet gateway, pass it to addOctoTerraformResource as explicitParents instead.

Two noes mean it is not a parent, even when this resource names it. Checkout's IAM role carries a policy naming the receipts bucket, and IAM accepts that policy before the bucket exists. The ARN in it is built from the bucket's name, which somebody chose and which is known before anything is created, so there is nothing to wait for. The action that creates the role finds the bucket in the graph and passes the bucket resource in, and the role reads the name off it, so nobody types the ARN.

A method that takes a resource it does not make a parent can also refuse ones its author did not mean to allow. The role refuses a bucket in another account, so a grant cannot quietly reach somebody else's storage. Which limits to set is your call; say each one in the method's JSDoc and cover it with a test, because the next author reads the limit there and nowhere else.

A parent adds one thing to a reference: this resource waits for the other one. You may also ask for that wait where the provider would accept the call but the result would be wrong. A CloudFront invalidation reads nothing from the bucket, and takes it as a parent anyway so that it runs after the files are uploaded, not before. The one thing a parent must never do is close a cycle. The role names the bucket and the bucket's policy names the role, so if every mention were a parent, each would wait for the other, and Octo would refuse the graph at octo generate with a dependency cycle.

Referencing another resource​

Never write a Terraform address by hand. Ask for a reference:

const roleArn = terraform.getRef(this.parents[0], 'Arn');

getRef resolves late, when Octo knows which folder each resource landed in. Same folder, and it renders as a native expression. Different folder, and it renders as var.iam_role_..._Arn, and Octo writes the variable block, the output block on the other side, the Terragrunt dependency, and the inputs wiring. That is the mechanism behind the two folders in Shop Stack, and it is why you cannot write the address yourself: at the point you would write it, the answer is not known.

When the value needs text of this resource's own around the reference, such as a path after a domain name the provider assigned, join the two with interpolate. The reference inside it is wired the same way:

const ordersUrl = terraform.interpolate('https://', terraform.getRef(this.parents[0], 'DomainName'), '/api/orders');

Ordering against another resource​

A reference orders the create, because Terraform can see it. What it cannot see is an ordering that runs the other way. Deleting checkout's IAM role requires first removing the statement in the receipts bucket policy that names it, and nothing in the bucket's configuration points at the role in a way that says so.

Say it on the dependency instead, in the constructor of the resource that knows the rule. The S3 storage resource declares both directions when a principal is granted access:

const { childToParentDependency, parentToChildDependency } = principal.addChild('resourceId', this, 'resourceId');

// Before this bucket's permissions are updated, the principal must have been added.
childToParentDependency.addBehavior('update-permissions', DiffAction.UPDATE, 'resourceId', DiffAction.ADD);

// Before the principal is deleted, this bucket's permissions must have been updated.
parentToChildDependency.addBehavior('resourceId', DiffAction.DELETE, 'update-permissions', DiffAction.UPDATE);

Each behavior reads as: when this action happens to that field of one node, this other action on that field of the other node must have happened first. Octo orders the whole run by them, and renders the result as depends_on inside a folder and as a Terragrunt dependency across folders. The full signature is on Dependency.

Which behaviors to declare​

Every parent link already carries three orderings, so a resource never declares these:

When the...it waits for the...
child is addedparent's add, update
child gains a parentparent's add
parent is deletedchild's delete

They cover a child that is created or deleted along with its parent. The bucket above is neither: it already exists when checkout is granted access, and it is still there when checkout's role is deleted. That is the case every behavior a resource declares is for, a child that stays while its parent comes or goes. Find them in three steps.

  1. List the diffs each side can produce, and the field each one is on. Read them from the two resources' diff methods. The bucket reports a permission change as an update on update-permissions; an IAM role is added, replaced, or deleted on resourceId, and updated on the type of policy that changed. A behavior that names a diff neither side produces orders nothing, and Octo does not warn about it.
  2. Where the child can be updated or replaced to point at a parent that is being added or replaced, the child waits for the parent. The bucket's first line above: its permissions change only after checkout's role exists.
  3. Where the parent can be deleted while the child is updated or replaced to stop pointing at it, the parent waits for the child. The bucket's second line: the role is deleted only after the bucket's policy stops naming it.

Declare every pair those steps find, even where both resources sit in one folder and Terraform would order them by the reference. Never declare the reverse of a wait that already exists: two nodes waiting for each other is a cycle, and Octo refuses the graph at octo generate.

The rest of the scope​

terraform.raw(expr)An HCL expression to emit unquoted, such as ${address}.arn.
terraform.interpolate(...parts)One quoted string from text and getRef results, such as a URL.
terraform.jsonencode(obj)Wraps a value in jsonencode(...). Policy documents.
terraform.mapAttr(obj)A map attribute, key = { }, as opposed to a block.
terraform.localPath(filePath)A file on disk, named so the folder reads the same everywhere.
terraform.getProviderAliasRef(accountId, regionId)The provider expression, when a resource has to target a different one.
terraform.addTerraformData(type, name, spec)A data block, for looking something up.
resource.block(type)A nested block inside a resource.
resource.attribute(key, value)One argument.

The distinction that catches people is mapAttr against a plain object. A plain JS object in a spec becomes a block, with no =; mapAttr produces a map attribute, with one. Which of the two a provider wants is per argument, and the provider documentation is the only authority. Getting it wrong produces Blocks of type "x" are not expected here at plan time.

localPath is for a resource that uploads something, and it returns a quoted HCL string rather than an expression:

const source = terraform.localPath(filePath);
scope.addTerraformResource('aws_s3_object', name, {
etag: terraform.raw(`filemd5(${source})`),
source: terraform.raw(source),
});

What it renders is "${var.OCTO_PROJECT_ROOT}/website/index.html", a path relative to the project root joined to a variable Octo declares in that folder's variables.tf and fills in when it runs Terragrunt. Writing the path yourself puts the generating machine's directory layout into a file your team commits, and Terragrunt runs Terraform from a copy of the folder, so a plain relative path would not resolve either.

Checking it​

The generated folders are ordinary Terraform, so the fastest loop while writing a resource does not involve Octo at all:

npm run build && npm run octo:generate
cd .octo-tf/orders-queue-module
terraform init -backend=false
terraform validate

That catches an argument name typo, a block that should have been a map, and a missing required argument, in seconds and without credentials. Testing a Module covers pinning the output down so it stays right.