Create a Module
A module is what somebody types into a definition file, so it is the only piece of your library most people will ever see. Everything else, the model, the resource, the anchor, is an implementation detail you can change later. The inputs a module accepts are not.
This page builds ShopQueueServiceModule, which gives the shop a queue for order events. It takes a
region, a queue name, and how long a message survives, and it produces one SQS queue.
octo create-module -n shop-queue-service -t service --package @shop
-t service is the model type this module builds. A queue is
infrastructure the shop uses that is not compute, so it is a service. The command writes the module
and its input schema under src/modules/service/shop-queue-service/, and adds the export to
src/index.ts.
The model comes from octo create-model, which Create a
Model runs.
The schema
Start here, because this is the contract.
import { type Region, RegionSchema, Schema, Validate } from '@quadnix/octo';
import { AwsRegionAnchorSchema } from '@quadnix/octo-aws-cdk/anchors/aws-region/schema';
export class ShopQueueServiceModuleSchema {
@Validate({ options: { minLength: 1 } })
queueName = Schema<string>();
@Validate({ options: { minLength: 1 } })
messageRetentionSeconds? = Schema<number>(345600);
@Validate([
{
options: {
isModel: { anchors: [{ schema: AwsRegionAnchorSchema }], NODE_NAME: 'region' },
},
},
{
destruct: (value: ShopQueueServiceModuleSchema['region']): [RegionSchema] => [value.synth()],
options: { isSchema: { schema: RegionSchema } },
},
])
region = Schema<Region>();
}
Three inputs, and three different jobs being done.
queueName is a plain required string. Schema<string>() with no argument makes it required, and
the minLength rule rejects an empty one.
messageRetentionSeconds is optional, and the argument to Schema<number>(345600) is its default.
Octo fills defaults in before your module runs, so inside onInit this is always a number.
region is the interesting one. The first rule says: whatever arrives here must be a node named
region, and it must publish an anchor satisfying AwsRegionAnchorSchema. The second says its
serialized form must satisfy RegionSchema. Together they mean a caller cannot hand this module an
account, a string, or a region from a library that does not publish AWS details, and the failure
happens while the graph is being assembled.
Note what is imported: AwsRegionAnchorSchema, and nothing else from @quadnix/octo-aws-cdk. The
module depends on a shape, not on a class. Any region module publishing that anchor satisfies it.
Validation has the full set of rules.
The module
@Module<ShopQueueServiceModule>('@shop', ShopQueueServiceModuleSchema)
export class ShopQueueServiceModule extends AModule<ShopQueueServiceModuleSchema, ShopQueueService> {
async onInit (inputs: ShopQueueServiceModuleSchema): Promise<ShopQueueService> {
const { app, awsAccountId, awsRegionId } = await this.registerMetadata(inputs);
const queueService = new ShopQueueService(inputs.queueName, [app]);
queueService.addAnchor(
new ShopQueueAnchor(
'ShopQueueAnchor',
{ awsAccountId, awsRegionId, queueName: inputs.queueName },
queueService,
),
);
return queueService;
}
override async registerMetadata (
inputs: ShopQueueServiceModuleSchema,
): Promise<{ app: App; awsAccountId: string; awsRegionId: string }> {
const region = inputs.region;
const account = region.getParents()['account'][0].to as Account;
const app = account.getParents()['app'][0].to as App;
const [matchingAnchor] = await region.getAnchorsMatchingSchema(AwsRegionAnchorSchema, [], {
searchBoundaryMembers: false,
});
return {
app,
awsAccountId: account.accountId,
awsRegionId: matchingAnchor.getSchemaInstance().properties.awsRegionId,
};
}
}
The decorator takes a package name and the schema class. The package name namespaces everything your
library registers, so @shop's ShopQueue and somebody else's ShopQueue can coexist.
onInit creates the model under the app, which attaches it to the graph, publishes an
anchor, and returns. Return every node the module
created, including overlays, because those are what later modules resolve against. Nothing here
creates infrastructure; that is the action's job.
Nothing here reads the cloud either. A module composes on every command, including generate, which
needs no credentials at all, and once more each time Terraform calls back into octo run-action
during an apply. A call made here is paid on all of them, and it does not answer the question it
looks like it answers: credentials that resolve are not permission to do the work, and they are not
necessarily the credentials Terraform applies with. Leave the cloud to the actions, which fail on
their first call, before anything has changed.
registerMetadata derives facts that both onInit and the module's actions need. Whatever it
returns arrives in every action of this module as actionInputs.metadata, which is how the action
below gets an AWS region ID without repeating the anchor lookup. It runs before onInit, and its
inputs are already validated.
The walk from the region up to the account and the app is worth noticing. The module was given one node, and the graph gave it the rest.
Failing early
The module is the right place to reject a request it cannot satisfy:
if (matchingAnchors.length !== 1) {
throw new ModuleError('Cannot find remote directory in S3Storage service!', this.constructor.name);
}
That is from our own S3 server module, and it fires when somebody asks a server for access to a directory the bucket was never told to create. Throwing here costs nothing: no Terraform has been written, and the message names what was wrong. The alternative is a failed apply with half a stack standing.
Using it
Build, then name it in the definition file:
npm run build
imports:
- className: ShopQueueServiceModule
importPath: "./dist/index.js"
modules:
- moduleId: orders-queue-module
moduleClass: ShopQueueServiceModule
moduleInputs:
region: "${{region-module.model.region}}"
queueName: shop-orders
messageRetentionSeconds: 86400
npm run octo:generate
INFO: Wrote 2 terragrunt folder(s) to ".octo-tf". {"emptied":[],"outputDir":".octo-tf","written":["orders-queue-module","region-module"]}
resource "aws_sqs_queue" "queue-shop-orders" {
provider = aws._123456789012-eu-west-1
message_retention_seconds = 86400
name = "shop-orders"
}
The module now behaves exactly like one from a library. It appears in reports under its own module
ID, it gets its own Terragrunt folder, and anything that needs the queue can be handed
${{orders-queue-module.model.service}}.
WHAT WILL CHANGE
+ added service=shop-orders-queue [service=shop-orders-queue,app=shop | orders-queue-module | +1]
+ queue-shop-orders
+ aws_sqs_queue.queue-shop-orders
Loading it twice
Nothing about a module instance is global, so a second queue is a second entry:
- moduleId: refunds-queue-module
moduleClass: ShopQueueServiceModule
moduleInputs:
region: "${{region-module.model.region}}"
queueName: shop-refunds
Two folders, two queues, one class. This works as long as everything the module names is derived
from its inputs. A resource ID hardcoded to queue-orders rather than built from queueName would
give both instances one ID, and octo generate refuses the second. That is the most common way a
module fails when somebody else loads it twice.
The ID an action passes only has to be unique among resources whose models sit in the same place in the graph. Octo adds a short hash of that place to make the full resource ID, so leave out what the graph already says, such as the region a model sits under.