Skip to content
56 changes: 56 additions & 0 deletions src/v2/components/acm-certificate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import { commonTags } from '../../../constants';

export namespace AcmCertificate {
export type Args = {
domain: pulumi.Input<string>;
hostedZoneId: pulumi.Input<string>;
};
}

export class AcmCertificate extends pulumi.ComponentResource {
certificate: aws.acm.Certificate;

constructor(
name: string,
args: AcmCertificate.Args,
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:acm:Certificate', name, {}, opts);

this.certificate = new aws.acm.Certificate(
`${args.domain}-certificate`,
{ domainName: args.domain, validationMethod: 'DNS', tags: commonTags },
{ parent: this },
);

const certificateValidationDomain = new aws.route53.Record(
`${args.domain}-cert-validation-domain`,
{
name: this.certificate.domainValidationOptions[0].resourceRecordName,
type: this.certificate.domainValidationOptions[0].resourceRecordType,
zoneId: args.hostedZoneId,
records: [
this.certificate.domainValidationOptions[0].resourceRecordValue,
],
ttl: 600,
},
{
parent: this,
deleteBeforeReplace: true,
},
);

const certificateValidation = new aws.acm.CertificateValidation(
`${args.domain}-cert-validation`,
{
certificateArn: this.certificate.arn,
validationRecordFqdns: [certificateValidationDomain.fqdn],
},
{ parent: this },
);

this.registerOutputs();
}
}
1 change: 1 addition & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { WebServerLoadBalancer } from './components/web-server/load-balancer';
export { ElastiCacheRedis } from './components/redis/elasticache-redis';
export { UpstashRedis } from './components/redis/upstash-redis';
export { Vpc } from './components/vpc';
export { AcmCertificate } from './components/acm-certificate';

import { OtelCollectorBuilder } from './otel/builder';
import { OtelCollector } from './otel';
Expand Down
122 changes: 122 additions & 0 deletions tests/acm-certificate/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as assert from 'node:assert';
import * as automation from '../automation';
import { InlineProgramArgs } from '@pulumi/pulumi/automation';
import { ACMClient } from '@aws-sdk/client-acm';
import { Route53Client } from '@aws-sdk/client-route-53';
import { backOff } from 'exponential-backoff';
import {
DescribeCertificateCommand,
CertificateType,
} from '@aws-sdk/client-acm';
import { ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53';
import { AcmCertificateTestContext } from './test-context';
import { describe, it, before, after } from 'node:test';

const programArgs: InlineProgramArgs = {
stackName: 'dev',
projectName: 'icb-test-acm-certificate',
program: () => import('./infrastructure'),
};

describe('ACM Certificate component deployment', () => {
const region = process.env.AWS_REGION;
const domainName = process.env.ICB_DOMAIN_NAME;
const hostedZoneId = process.env.ICB_HOSTED_ZONE_ID;
if (!region || !domainName || !hostedZoneId) {
throw new Error(
'AWS_REGION, ICB_DOMAIN_NAME and ICB_HOSTED_ZONE_ID environment variables are required',
);
}

const ctx: AcmCertificateTestContext = {
outputs: {},
config: {
exponentialBackOffConfig: {
delayFirstAttempt: true,
numOfAttempts: 5,
startingDelay: 2000,
timeMultiple: 1.5,
jitter: 'full',
},
},
clients: {
acm: new ACMClient({ region }),
route53: new Route53Client({ region }),
},
};

before(async () => {
ctx.outputs = await automation.deploy(programArgs);
});

after(() => automation.destroy(programArgs));

it('should create certificate with correct domain name', async () => {
const certificate = ctx.outputs.certificate.value;
assert.ok(certificate.certificate, 'Should have certificate property');
assert.ok(certificate.certificate.arn, 'Certificate should have ARN');

return backOff(async () => {
const certResult = await ctx.clients.acm.send(
new DescribeCertificateCommand({
CertificateArn: certificate.certificate.arn,
}),
);

const cert = certResult.Certificate;
assert.ok(cert, 'Certificate should exist');
assert.strictEqual(
cert.DomainName,
domainName,
'Certificate domain should match',
);
assert.strictEqual(
cert.Type,
CertificateType.AMAZON_ISSUED,
'Should be Amazon issued certificate',
);
}, ctx.config.exponentialBackOffConfig);
});

it('should have validation record with correct resource record value', async () => {
const certificate = ctx.outputs.certificate.value;
const hostedZone = ctx.outputs.hostedZone.value;

const certResult = await ctx.clients.acm.send(
new DescribeCertificateCommand({
CertificateArn: certificate.certificate.arn,
}),
);

const domainValidation =
certResult.Certificate?.DomainValidationOptions?.[0];
assert.ok(domainValidation, 'Should have domain validation options');
assert.ok(
domainValidation.ResourceRecord,
'Validation resource record should exists',
);

const recordsResult = await ctx.clients.route53.send(
new ListResourceRecordSetsCommand({
HostedZoneId: hostedZone.zoneId,
}),
);

const records = recordsResult.ResourceRecordSets || [];
const validationRecord = records.find(
record => record.Name === domainValidation.ResourceRecord?.Name,
);

assert.ok(validationRecord, 'Validation record should exist');
assert.strictEqual(
validationRecord.TTL,
600,
'Validation record should have 600 TTL',
);
assert.strictEqual(
validationRecord.ResourceRecords?.[0]?.Value,
domainValidation.ResourceRecord?.Value,
'Validation record should have correct value',
);
});
});
16 changes: 16 additions & 0 deletions tests/acm-certificate/infrastructure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { next as studion } from '@studion/infra-code-blocks';
import * as aws from '@pulumi/aws';

const appName = 'acm-certificate-test';

const hostedZone = aws.route53.getZoneOutput({
zoneId: process.env.ICB_HOSTED_ZONE_ID,
privateZone: false,
});

const certificate = new studion.AcmCertificate(`${appName}-certificate`, {
domain: process.env.ICB_DOMAIN_NAME!,
hostedZoneId: hostedZone.zoneId,
});

export { certificate, hostedZone };
33 changes: 33 additions & 0 deletions tests/acm-certificate/test-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { OutputMap } from '@pulumi/pulumi/automation';
import { ACMClient } from '@aws-sdk/client-acm';
import { Route53Client } from '@aws-sdk/client-route-53';

interface AcmCertificateTestConfig {
exponentialBackOffConfig: {
delayFirstAttempt: boolean;
numOfAttempts: number;
startingDelay: number;
timeMultiple: number;
jitter: 'full' | 'none';
};
}

interface ConfigContext {
config: AcmCertificateTestConfig;
}

interface PulumiProgramContext {
outputs: OutputMap;
}

interface AwsContext {
clients: {
acm: ACMClient;
route53: Route53Client;
};
}

export interface AcmCertificateTestContext
extends ConfigContext,
PulumiProgramContext,
AwsContext {}