Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

74 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

declastruct-aws

test publish

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-aws

use 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.json

apply 🪄

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.json

example.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@localhost

example.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.json

upgrade 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 plan will show a change against it (the default is imdsv2-only: required / hop 1 / enabled). a launch template is immutable, so apply does 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.hash enables 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:LIVE for stable endpoints across deploys
  • safe rollbacks: retarget aliases to previous versions without redeploy
  • canary deploys: use routingConfig.additionalVersionWeights to 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.json

this pattern enables:

  • write-only secrets: plan reconciles a SecureString via metadata only — no GetParameter, no kms:Decrypt, and no secret-derived artifact stored anywhere to leak
  • least-privilege plan roles: the plan role can be denied kms:Decrypt outright, so a CI plan job can no longer read prod secrets (the whole risk terraform bakes in)
  • explicit rotation: supply a value to write/rotate; leave it undefined for a steady-state KEEP — the secret is never read back into a plan or state file
  • plaintext value-compare: non-secret String params 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[].groups is the per-service composition within each day
  • lookback windows: vary range to compare 1d / 1w / 1m spend and gauge the rate of change
  • forecast vs actual: DeclaredAwsCostReportSpendForecastDao projects 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. range is 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.

About

declarative control of Aws constructs via declastruct

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages