Declarative control of Aws resource constructs, via declastruct.
Declare the structures you want. Plan to see the changes required. Apply to make it so 🪄
npm install -s declastruct-awsdeclare 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 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 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.jsonimport { 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];
};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@localhostimport { 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' }.
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
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
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.