declastruct-aws
v1.13.0
Published
declarative control of Aws constructs via declastruct
Maintainers
Readme
declastruct-aws
Declarative control of Aws resource constructs, via declastruct.
Declare the structures you want. Plan to see the changes required. Apply to make it so 🪄
install
npm install -s declastruct-awsuse via cli
example.1
wish ✨
declare the resources you wish to have - and what state you wish them to be in
import { UnexpectedCodePathError } from 'helpful-errors';
export const getProviders = async (): Promise<DeclastructProvider[]> => [
getDeclastructAwsProvider(
{},
{
log: console,
},
),
];
export const getResources = async (): Promise<DomainEntity<any>[]> => {
// declare the resources you wish to construct
};plan 🔮
plan how to achieve the wish of resources you've declared
this will emit a plan that declares the changes required in order to fulfill the wish
npx declastruct plan --wish provision/github/resources.ts --output provision/github/.temp/plan.jsonapply 🪄
apply the plan to fulfill the wish
this will apply only the changes declared in the plan - and only if this plan is still applicable
npx declastruct apply --plan provision/github/.temp/plan.jsonexample.2 = open a vpc tunnel via an ec2 instance
import { RefByUnique } from 'domain-objects';
import { getDeclastructAwsProvider, DeclaredAwsRdsCluster, DeclaredAwsEc2Instance, DeclaredAwsSsmVpcTunnel } from 'declastruct-aws';
export const getProviders = async (): Promise<DeclastructProvider[]> => [
getDeclastructAwsProvider(
{},
{
log: console,
},
),
];
export const getResources = async (): Promise<DomainEntity<any>[]> => {
const cluster = RefByUnique.as<typeof DeclaredAwsRdsCluster>({
name: 'yourdb',
});
const bastion = RefByUnique.as<typeof DeclaredAwsEc2Instance>({
exid: 'vpc-main-bastion',
})
const tunnel = DeclaredAwsSsmVpcTunnel.as({
via: { mechanism: 'aws.ssm', bastion }
into: { cluster },
from: { host: 'localhost', port: 777_5432 },
status: "OPEN",
})
return [tunnel];
};example.3 = open an ssh tunnel to an ec2 instance
import * as fs from 'fs';
import { RefByUnique } from 'domain-objects';
import {
getDeclastructAwsProvider,
DeclaredAwsEc2Instance,
DeclaredAwsEc2SshKeyAuthorized,
DeclaredAwsSsmSshTunnel,
} from 'declastruct-aws';
export const getProviders = async (): Promise<DeclastructProvider[]> => [
getDeclastructAwsProvider(
{},
{
log: console,
},
),
];
export const getResources = async (): Promise<DomainEntity<any>[]> => {
const instance = RefByUnique.as<typeof DeclaredAwsEc2Instance>({
exid: 'my-dev-instance',
});
// authorize your SSH key on the instance
const sshKey = DeclaredAwsEc2SshKeyAuthorized.as({
instance,
publicKey: fs.readFileSync(`${process.env.HOME}/.ssh/id_ed25519.pub`, 'utf8'),
comment: 'my-laptop',
});
// open SSH tunnel via SSM
const sshTunnel = DeclaredAwsSsmSshTunnel.as({
instance,
from: { port: 2222 },
into: { port: 22 },
status: 'OPEN',
});
return [sshKey, sshTunnel];
};after npx declastruct apply, SSH in:
ssh -i ~/.ssh/id_ed25519 -p 2222 ec2-user@localhostexample.4 = provision an ec2 instance with hibernation
import { RefByUnique } from 'domain-objects';
import {
getDeclastructAwsProvider,
DeclaredAwsEc2LaunchTemplate,
DeclaredAwsEc2Instance,
DeclaredAwsEc2InstanceSession,
DeclaredAwsVpcSubnet,
DeclaredAwsVpcSecurityGroup,
} from 'declastruct-aws';
export const getProviders = async (): Promise<DeclastructProvider[]> => [
getDeclastructAwsProvider(
{},
{
log: console,
},
),
];
export const getResources = async (): Promise<DomainEntity<any>[]> => {
// declare launch template with hibernation enabled
const template = DeclaredAwsEc2LaunchTemplate.as({
exid: 'my-dev-template',
instanceType: 't3.micro',
imageId: 'ami-0c55b159cbfafe1f0', // Amazon Linux 2023
hibernation: true,
rootVolumeEncrypted: true, // required for hibernation
rootVolumeSize: 16, // must be >= instance RAM
iamInstanceProfile: null,
userData: null,
metadataOptions: null, // null = secure default (imdsv2-only: required / hop 1 / enabled)
// docker/container box that needs the extra hop? add the export to the import above:
// import { ec2InstanceMetadataOptionsSecure } from 'declastruct-aws';
// then override just the hop limit off it (stays imdsv2-only):
// metadataOptions: { ...ec2InstanceMetadataOptionsSecure, httpPutResponseHopLimit: 2 }
tags: { purpose: 'dev' },
});
// declare instance
const instance = DeclaredAwsEc2Instance.as({
exid: 'my-dev-instance',
template: { exid: template.exid },
subnet: RefByUnique.as<typeof DeclaredAwsVpcSubnet>({ exid: 'my-subnet' }),
securityGroups: [RefByUnique.as<typeof DeclaredAwsVpcSecurityGroup>({ exid: 'my-sg' })],
tags: { purpose: 'dev' },
});
// control lifecycle via session
const session = DeclaredAwsEc2InstanceSession.as({
instance: { exid: instance.exid },
status: 'active', // 'active' | 'stopped' | 'hibernated'
});
return [template, instance, session];
};to hibernate the instance, change status: 'hibernated' and re-apply:
npx declastruct plan --wish resources.ts --into plan.json
npx declastruct apply --plan plan.jsonupgrade note — the secure metadata default plans a change on prior boxes. an already-deployed launch template that predates this secure default reads back as imdsv1-allowed, so
declastruct planwill show a change against it (the default is imdsv2-only:required/ hop 1 /enabled). a launch template is immutable, soapplydoes NOT heal it in place — the set fails loud. to adopt the secure posture, prune the old template + its instances and re-apply to create them imdsv2-only. to keep the old posture, opt out on the declaration:metadataOptions: { ...ec2InstanceMetadataOptionsSecure, httpTokens: 'optional' }.
example.5 = deploy a lambda with version and alias
import { RefByUnique } from 'domain-objects';
import { ConstraintError } from 'helpful-errors';
import {
calcAwsLambdaConfigHash,
DeclaredAwsIamRole,
DeclaredAwsIamRolePolicyAttachedInline,
DeclaredAwsLambda,
DeclaredAwsLambdaAlias,
DeclaredAwsLambdaVersion,
genDeclaredAwsLambdaCode,
getDeclastructAwsProvider,
} from 'declastruct-aws';
export const getProviders = async (): Promise<DeclastructProvider[]> => [
getDeclastructAwsProvider(
{},
{
log: console,
},
),
];
export const getResources = async (): Promise<DomainEntity<any>[]> => {
// declare iam role for lambda execution
const lambdaRole = DeclaredAwsIamRole.as({
name: 'my-lambda-role',
path: '/',
description: 'Role for lambda execution',
policies: [
{
effect: 'Allow',
principal: { service: 'lambda.amazonaws.com' },
action: 'sts:AssumeRole',
},
],
tags: { managedBy: 'declastruct' },
});
// declare inline policy for CloudWatch Logs permissions
const lambdaRolePolicy = DeclaredAwsIamRolePolicyAttachedInline.as({
name: 'cloudwatch-logs',
role: RefByUnique.as<typeof DeclaredAwsIamRole>(lambdaRole),
document: {
statements: [
{
effect: 'Allow',
action: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
],
resource: '*',
},
],
},
});
// declare lambda function ($LATEST) with code from zip
const lambda = DeclaredAwsLambda.as({
name: 'svc-sea-turtle.prod.getSandbars',
runtime: 'nodejs18.x',
handler: 'index.handler',
timeout: 30,
memory: 128,
role: RefByUnique.as<typeof DeclaredAwsIamRole>(lambdaRole),
envars: { NODE_ENV: 'production' },
code: genDeclaredAwsLambdaCode({ zipUri: '.artifact/contents.zip' }),
tags: { managedBy: 'declastruct' },
});
// publish immutable version (fingerprinted by code + config hash)
const lambdaVersion = DeclaredAwsLambdaVersion.as({
lambda: RefByUnique.as<typeof DeclaredAwsLambda>(lambda),
hash: {
code: lambda.code?.hash ?? ConstraintError.throw('lambda.code.hash is required'),
config: calcAwsLambdaConfigHash({ of: lambda }),
},
});
// point LIVE alias to this version
const lambdaAlias = DeclaredAwsLambdaAlias.as({
name: 'LIVE',
lambda: RefByUnique.as<typeof DeclaredAwsLambda>(lambda),
version: RefByUnique.as<typeof DeclaredAwsLambdaVersion>(lambdaVersion),
description: 'live traffic alias',
});
return [lambdaRole, lambdaRolePolicy, lambda, lambdaVersion, lambdaAlias];
};this pattern enables:
- change detection:
code.hashenables declastruct to detect when code changed and deploy only when needed - immutable versions: each deploy publishes a new version fingerprinted by
hash: { code, config } - aliased endpoints: invoke via
function:LIVEfor stable endpoints across deploys - safe rollbacks: retarget aliases to previous versions without redeploy
- canary deploys: use
routingConfig.additionalVersionWeightsto split traffic between versions
example.6 = manage SSM parameters — plaintext config + write-only secrets
declare non-secret config and secrets side by side. the secret is write-only: plan
never reads its value (no GetParameter, no kms:Decrypt), so a least-privilege plan role
needs only ssm:DescribeParameters for it — exactly the posture terraform cannot offer.
import {
getDeclastructAwsProvider,
DeclaredAwsSsmParameterPlain,
DeclaredAwsSsmParameterSecure,
} from 'declastruct-aws';
export const getProviders = async () => [
await getDeclastructAwsProvider({}, { log: console }),
];
export const getResources = async () => {
// plaintext config — the value is NOT sensitive, so drift is detected by a
// normal value-compare (plan reads it via GetParameter; no decrypt needed)
const logLevel = DeclaredAwsSsmParameterPlain.as({
name: '/svc-notifications/prod/log-level',
value: 'info',
description: 'the log level',
tags: { managedBy: 'declastruct' },
});
// secret (SecureString) — WRITE-ONLY. plan never reads the value; supply a value to
// create/rotate, leave it undefined to KEEP the extant secret (no read, no decrypt).
// best practice: source the value from an env var set ONLY when you intend to write.
const authToken = DeclaredAwsSsmParameterSecure.as({
name: '/svc-notifications/prod/twilio/auth-token',
value: process.env.TWILIO_AUTH_TOKEN, // undefined = keep; present = create/rotate
keyId: null, // null = the account default aws/ssm key (a CMK is optional)
description: 'twilio auth token',
tags: { managedBy: 'declastruct' },
});
return [logLevel, authToken];
};# a least-privilege plan role needs NO GetParameter and NO kms:Decrypt for the secret
npx declastruct plan --wish resources.ts --into plan.json
npx declastruct apply --plan plan.jsonthis pattern enables:
- write-only secrets:
planreconciles aSecureStringvia metadata only — noGetParameter, nokms:Decrypt, and no secret-derived artifact stored anywhere to leak - least-privilege plan roles: the plan role can be denied
kms:Decryptoutright, so a CI plan job can no longer read prod secrets (the whole risk terraform bakes in) - explicit rotation: supply a
valueto write/rotate; leave it undefined for a steady-stateKEEP— the secret is never read back into a plan or state file - plaintext value-compare: non-secret
Stringparams still detect value drift normally, since their value is not sensitive
example.7 = read cost reports — daily spend trend (1d / 1w / 1m) vs forecast
cost reports are read-only — you don't apply them, you read them. each is a saved
Cost Explorer query as code: declare the range + how to slice, then read the resolved numbers
as a typed domain object. read via the report DAO's get.one.byUnique.
import { asIsoTimeStamp } from 'iso-time';
import {
getDeclastructAwsProvider,
DeclaredAwsCostReportSpendObservedDao,
DeclaredAwsCostReportSpendForecastDao,
} from 'declastruct-aws';
// source the aws context the reports read through
const provider = await getDeclastructAwsProvider({}, { log: console });
const { context } = provider;
// a lookback window that ends at the last full day (UTC), N days back
const asLookback = (input: { days: number }) => {
const untilMs = Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
); // 00:00 today = end of the last full day (exclusive)
const sinceMs = untilMs - input.days * 24 * 60 * 60 * 1000;
return {
since: asIsoTimeStamp(new Date(sinceMs).toISOString()),
until: asIsoTimeStamp(new Date(untilMs).toISOString()),
};
};
// daily spend trend, grouped by service, for 1d / 1w / 1m lookbacks
for (const [label, days] of [['1d', 1], ['1w', 7], ['1m', 30]] as const) {
const report = await DeclaredAwsCostReportSpendObservedDao.get.one.byUnique(
{
range: asLookback({ days }),
granularity: 'DAILY',
groupBy: { dimension: 'SERVICE' },
filter: null,
metric: 'UnblendedCost',
},
context,
);
// report.total = spend over the window; report.buckets = the daily trend
console.log(label, report?.total, report?.buckets?.length, 'day-buckets');
}
// forecast the month ahead, with an 80% confidence band
const forecast = await DeclaredAwsCostReportSpendForecastDao.get.one.byUnique(
{
range: {
since: asIsoTimeStamp(new Date().toISOString()),
until: asLookback({ days: -30 }).until, // ~30 days ahead
},
granularity: 'MONTHLY',
metric: 'UnblendedCost',
filter: null,
predictionInterval: 80,
},
context,
);
// forecast.total = mean projection; forecast.points[].lower/upper = the confidence band
console.log('forecast', forecast?.total, forecast?.points);this pattern enables:
- spend trend:
granularity: 'DAILY'returns one bucket per day (the trend);buckets[].groupsis the per-service composition within each day - lookback windows: vary
rangeto compare 1d / 1w / 1m spend and gauge the rate of change - forecast vs actual:
DeclaredAwsCostReportSpendForecastDaoprojects the window ahead with a mean + confidence band, to set beside the observed trend - net vs gross: switch
metric(UnblendedCost= gross list-price,NetUnblendedCost= net of credits) to match what "money that actually left" means for you - read-only + idempotent: a report has no desired state to converge — you read it; a re-read simply refreshes the derived numbers
note — Cost Explorer is not real-time (data settles over ~24–48h), so a bucket that includes today reads
estimated: true. each paged read is a $0.01 Cost Explorer request.rangeis part of a report's identity, so it must be ABSOLUTE dates — a hardcoded window is frozen to that window; recompute the range (as above) to keep a report current.
example.8 = receive mail into s3 — an SES receipt rule that writes to a lifecycle'd bucket
SES and S3 in one wish. mail addressed to the mailbox lands as an object in the bucket, and the bucket ages it down a glacier staircase. the bucket is blocked from public access at the type level, and its policy names the exact receipt rule allowed to write into it.
import { RefByUnique } from 'domain-objects';
import {
getDeclastructAwsProvider,
getCredentials,
asSesReceiptRuleArn,
DeclaredAwsS3Bucket,
DeclaredAwsS3BucketPolicy,
DeclaredAwsSesReceiptRuleSet,
DeclaredAwsSesReceiptRule,
} from 'declastruct-aws';
const BUCKET = 'ehmpathy-mail-inbound-demo';
const RULE_SET = 'ehmpathy-mail-inbound';
const RULE = 'to-s3';
const ECHO = '[email protected]';
export const getProviders = async () => [
await getDeclastructAwsProvider({}, { log: console }),
];
export const getResources = async () => {
const { account, region } = await getCredentials();
// the bucket policy's SourceArn must name the RECEIPT RULE arn (deterministic from names)
const receiptRuleArn = asSesReceiptRuleArn({
region,
account,
ruleSetName: RULE_SET,
ruleName: RULE,
});
// 1. the inbound store — glacier staircase lifecycle (Standard -> GLACIER_IR -> DEEP_ARCHIVE);
// a mail store is never public, and needs no versions
const store = DeclaredAwsS3Bucket.as({
name: BUCKET,
access: { public: 'blocked' },
lifecycle: {
objects: {
expire: null, // keep every current object; the transitions age it down
transitions: [
{ afterDays: 30, class: 'GLACIER_IR' },
{ afterDays: 180, class: 'DEEP_ARCHIVE' },
],
},
versions: false, // unversioned, so this bucket stays torn-down-able
multiparts: { expire: null },
},
tags: { managedBy: 'declastruct', purpose: 'mail' },
});
// 2. the bucket policy — allow SES (this account only) to PutObject into inbound/
const storePolicy = DeclaredAwsS3BucketPolicy.as({
bucket: RefByUnique.as<typeof DeclaredAwsS3Bucket>({ name: BUCKET }),
document: {
statements: [
{
sid: 'AllowSesPut',
effect: 'Allow',
principal: { service: 'ses.amazonaws.com' },
action: 's3:PutObject',
resource: `arn:aws:s3:::${BUCKET}/inbound/*`,
condition: {
StringEquals: { 'aws:SourceAccount': account },
ArnLike: { 'aws:SourceArn': receiptRuleArn },
},
},
],
},
});
// 3. the receipt rule set — the account's active set
const ruleSet = DeclaredAwsSesReceiptRuleSet.as({
name: RULE_SET,
active: true,
});
// 4. the receipt rule — route inbound mail for ECHO to the s3 inbound/ prefix
const rule = DeclaredAwsSesReceiptRule.as({
ruleSet: RefByUnique.as<typeof DeclaredAwsSesReceiptRuleSet>({ name: RULE_SET }),
name: RULE,
enabled: true,
recipients: [ECHO],
actions: [
{
s3: {
bucket: RefByUnique.as<typeof DeclaredAwsS3Bucket>({ name: BUCKET }),
objectKeyPrefix: 'inbound/',
topic: null,
kmsKeyArn: null,
},
sns: null,
lambda: null,
bounce: null,
stop: null,
addHeader: null,
workmail: null,
connect: null,
},
],
// AWS materializes TlsPolicy to 'Optional' by default (it is never absent), so declare
// the real default explicitly — a null desired would drift to a perpetual UPDATE against
// the 'Optional' AWS returns
tlsPolicy: 'Optional',
scanEnabled: true,
});
// apply order = declared array order (declastruct does no topological sort). SES test-puts
// the bucket when the rule is created, so the bucket + policy must exist first.
return [store, storePolicy, ruleSet, rule];
};this pattern enables:
- safe by default:
access: { public: 'blocked' }is one token; the public-capable posture costs four booleans typed out ({ acls: { block, ignore }, policies: { block, restrict } }), so the exposed state is never reached by inattention - one verb, three subjects:
lifecycledecomposes by what expires —objects,versions,multiparts— each with the sameexpire. which sub-object is populated names the mode, so a reader sees "staircase, unversioned, no multipart cleanup" from the shape alone - no silent bill:
multiparts.expireaborts stranded upload parts from a dead writer — partsaws s3 lscannot see, and that bill forever otherwise - cross-service refs: the receipt rule names the bucket by
RefByUnique, so the two converge together and a rename of either is a compile error rather than a runtime 404 - explicit AWS defaults:
tlsPolicy: 'Optional'is declared because AWS materializes it — anulldesired would readUPDATEon every plan, forever
note —
versions: falsecarries real weight for a bucket you may need to delete. once versions are enabled, a delete inserts a delete marker rather than a true erase, so the bucket cannot be emptied by object deletes alone — and'suspended'does not undo that. s3 bucket names are a global namespace, so a stuck bucket burns its name account-wide.
