diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts new file mode 100644 index 00000000..76ed8c05 --- /dev/null +++ b/src/v2/components/acm-certificate/index.ts @@ -0,0 +1,75 @@ +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; + /** + * Additional domains/subdomains to be included in this certificate. + */ + subjectAlternativeNames?: pulumi.Input[]; + hostedZoneId: pulumi.Input; + }; +} + +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, + subjectAlternativeNames: args.subjectAlternativeNames, + validationMethod: 'DNS', + tags: commonTags, + }, + { parent: this }, + ); + + this.createCertValidationRecords(args.domain, args.hostedZoneId); + + this.registerOutputs(); + } + + private createCertValidationRecords( + domainName: AcmCertificate.Args['domain'], + hostedZoneId: AcmCertificate.Args['hostedZoneId'], + ) { + this.certificate.domainValidationOptions.apply(domains => { + const validationRecords = domains.map( + domain => + new aws.route53.Record( + `${domain.domainName}-cert-validation-domain`, + { + name: domain.resourceRecordName, + type: domain.resourceRecordType, + zoneId: hostedZoneId, + records: [domain.resourceRecordValue], + ttl: 600, + }, + { + parent: this, + deleteBeforeReplace: true, + }, + ), + ); + + const certificateValidation = new aws.acm.CertificateValidation( + `${domainName}-cert-validation`, + { + certificateArn: this.certificate.arn, + validationRecordFqdns: validationRecords.map(record => record.fqdn), + }, + { parent: this }, + ); + }); + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts index aaaabc7a..6377d609 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -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'; diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts new file mode 100644 index 00000000..adeb8ca1 --- /dev/null +++ b/tests/acm-certificate/index.test.ts @@ -0,0 +1,143 @@ +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'; +import { requireEnv } from '../util'; + +const programArgs: InlineProgramArgs = { + stackName: 'dev', + projectName: 'icb-test-acm-certificate', + program: () => import('./infrastructure'), +}; + +const region = requireEnv('AWS_REGION'); +const domainName = requireEnv('ICB_DOMAIN_NAME'); +const hostedZoneId = requireEnv('ICB_HOSTED_ZONE_ID'); + +const ctx: AcmCertificateTestContext = { + outputs: {}, + config: { + subDomainName: `app.${domainName}`, + exponentialBackOffConfig: { + delayFirstAttempt: true, + numOfAttempts: 5, + startingDelay: 2000, + timeMultiple: 1.5, + jitter: 'full', + }, + }, + clients: { + acm: new ACMClient({ region }), + route53: new Route53Client({ region }), + }, +}; + +describe('ACM Certificate component deployment', () => { + 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', + ); + }); + + it('should create certificate with subject alternative names', async () => { + const sanCertificate = ctx.outputs.sanCertificate.value; + const certResult = await ctx.clients.acm.send( + new DescribeCertificateCommand({ + CertificateArn: sanCertificate.certificate.arn, + }), + ); + const cert = certResult.Certificate; + const sans = cert?.SubjectAlternativeNames || []; + + const expectedDomains = [ + ctx.config.subDomainName, + `api.${ctx.config.subDomainName}`, + `test.${ctx.config.subDomainName}`, + ]; + + expectedDomains.forEach(expectedDomain => { + assert.ok( + sans.includes(expectedDomain), + `Certificate should include: ${expectedDomain}`, + ); + }); + }); +}); diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts new file mode 100644 index 00000000..c501aa6c --- /dev/null +++ b/tests/acm-certificate/infrastructure/index.ts @@ -0,0 +1,27 @@ +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 domainName = process.env.ICB_DOMAIN_NAME!; +const certificate = new studion.AcmCertificate(`${appName}-certificate`, { + domain: domainName, + hostedZoneId: hostedZone.zoneId, +}); + +const subDomainName = `app.${domainName}`; +const sanCertificate = new studion.AcmCertificate( + `${appName}-certificate-san`, + { + domain: subDomainName, + subjectAlternativeNames: [`api.${subDomainName}`, `test.${subDomainName}`], + hostedZoneId: hostedZone.zoneId, + }, +); + +export { certificate, sanCertificate, hostedZone }; diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts new file mode 100644 index 00000000..7d4441a3 --- /dev/null +++ b/tests/acm-certificate/test-context.ts @@ -0,0 +1,34 @@ +import { OutputMap } from '@pulumi/pulumi/automation'; +import { ACMClient } from '@aws-sdk/client-acm'; +import { Route53Client } from '@aws-sdk/client-route-53'; + +interface AcmCertificateTestConfig { + subDomainName: string; + 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 {}