diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts new file mode 100644 index 0000000..91f2005 --- /dev/null +++ b/src/v2/components/acm-certificate/index.ts @@ -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; + 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, 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(); + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts index aaaabc7..6377d60 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 0000000..adbe963 --- /dev/null +++ b/tests/acm-certificate/index.test.ts @@ -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', + ); + }); +}); diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts new file mode 100644 index 0000000..74802d9 --- /dev/null +++ b/tests/acm-certificate/infrastructure/index.ts @@ -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 }; diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts new file mode 100644 index 0000000..69d0a55 --- /dev/null +++ b/tests/acm-certificate/test-context.ts @@ -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 {}