From 12bd0dd953705d67b273a6e1ee096da7c2b5713d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 5 Dec 2025 12:20:45 +0100 Subject: [PATCH 01/10] Create acm certificate v2 component --- src/v2/components/acm-certificate/index.ts | 54 ++++++++++++++++++++++ src/v2/index.ts | 1 + 2 files changed, 55 insertions(+) create mode 100644 src/v2/components/acm-certificate/index.ts diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts new file mode 100644 index 0000000..30e8eaf --- /dev/null +++ b/src/v2/components/acm-certificate/index.ts @@ -0,0 +1,54 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as aws from '@pulumi/aws'; +import { commonTags } from '../../../constants'; + +export type AcmCertificateArgs = { + domain: pulumi.Input; + hostedZoneId: pulumi.Input; +}; + +export class AcmCertificate extends pulumi.ComponentResource { + certificate: aws.acm.Certificate; + + constructor( + name: string, + args: AcmCertificateArgs, + 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'; From b8b8d944ae4d6df90469c284ec2197eb0be8b607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 5 Dec 2025 12:21:52 +0100 Subject: [PATCH 02/10] Implement acm certificate tests --- tests/acm-certificate/index.test.ts | 126 ++++++++++++++++++ tests/acm-certificate/infrastructure/index.ts | 19 +++ tests/acm-certificate/test-context.ts | 34 +++++ 3 files changed, 179 insertions(+) create mode 100644 tests/acm-certificate/index.test.ts create mode 100644 tests/acm-certificate/infrastructure/index.ts create mode 100644 tests/acm-certificate/test-context.ts diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts new file mode 100644 index 0000000..9cd8d3e --- /dev/null +++ b/tests/acm-certificate/index.test.ts @@ -0,0 +1,126 @@ +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.DOMAIN_NAME; + const hostedZoneName = process.env.HOSTED_ZONE_NAME; + if (!region || !domainName || !hostedZoneName) { + throw new Error( + 'AWS_REGION, DOMAIN_NAME and HOSTED_ZONE_NAME environment variables are required', + ); + } + + const ctx: AcmCertificateTestContext = { + outputs: {}, + config: { + certificateName: 'acm-cert-test-cert', + 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, + 'Should have resource record for validation', + ); + + 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 with correct name', + ); + 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..afd4520 --- /dev/null +++ b/tests/acm-certificate/infrastructure/index.ts @@ -0,0 +1,19 @@ +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({ + name: process.env.HOSTED_ZONE_NAME, + privateZone: false, +}); + +const certificate = new studion.AcmCertificate(`${appName}-certificate`, { + domain: process.env.DOMAIN_NAME!, + hostedZoneId: hostedZone.zoneId, +}); + +module.exports = { + certificate, + hostedZone, +}; diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts new file mode 100644 index 0000000..2ddda33 --- /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 { + certificateName: 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 {} From 431039c9e3d39958381ea8e0e448accee5527c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 5 Dec 2025 12:27:59 +0100 Subject: [PATCH 03/10] Add legacy prefix to certificate v1 component --- src/components/acm-certificate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/acm-certificate.ts b/src/components/acm-certificate.ts index e17ee52..0e23665 100644 --- a/src/components/acm-certificate.ts +++ b/src/components/acm-certificate.ts @@ -15,7 +15,7 @@ export class AcmCertificate extends pulumi.ComponentResource { args: AcmCertificateArgs, opts: pulumi.ComponentResourceOptions = {}, ) { - super('studion:acm:Certificate', name, {}, opts); + super('studion:acm:LegacyCertificate', name, {}, opts); this.certificate = new aws.acm.Certificate( `${args.domain}-certificate`, From 1c98b9e1032a388db58f244eb7311c8b68031ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 09:36:27 +0100 Subject: [PATCH 04/10] Add acm certificate namespace for types --- src/v2/components/acm-certificate/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index 30e8eaf..91f2005 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -2,17 +2,19 @@ import * as pulumi from '@pulumi/pulumi'; import * as aws from '@pulumi/aws'; import { commonTags } from '../../../constants'; -export type AcmCertificateArgs = { - domain: pulumi.Input; - hostedZoneId: pulumi.Input; -}; +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: AcmCertificateArgs, + args: AcmCertificate.Args, opts: pulumi.ComponentResourceOptions = {}, ) { super('studion:acm:Certificate', name, {}, opts); From 06184a8f89163fcd1b151579725099854f150f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 09:38:11 +0100 Subject: [PATCH 05/10] Remove legacy prefix from v1 certificate component --- src/components/acm-certificate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/acm-certificate.ts b/src/components/acm-certificate.ts index 0e23665..e17ee52 100644 --- a/src/components/acm-certificate.ts +++ b/src/components/acm-certificate.ts @@ -15,7 +15,7 @@ export class AcmCertificate extends pulumi.ComponentResource { args: AcmCertificateArgs, opts: pulumi.ComponentResourceOptions = {}, ) { - super('studion:acm:LegacyCertificate', name, {}, opts); + super('studion:acm:Certificate', name, {}, opts); this.certificate = new aws.acm.Certificate( `${args.domain}-certificate`, From 50ff25237573b8f9305e68be47c767f366c2f2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 09:40:19 +0100 Subject: [PATCH 06/10] Rename test assertions --- tests/acm-certificate/index.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 9cd8d3e..0968af3 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -94,7 +94,7 @@ describe('ACM Certificate component deployment', () => { assert.ok(domainValidation, 'Should have domain validation options'); assert.ok( domainValidation.ResourceRecord, - 'Should have resource record for validation', + 'Validation resource record should exists', ); const recordsResult = await ctx.clients.route53.send( @@ -108,10 +108,7 @@ describe('ACM Certificate component deployment', () => { record => record.Name === domainValidation.ResourceRecord?.Name, ); - assert.ok( - validationRecord, - 'Validation record should exist with correct name', - ); + assert.ok(validationRecord, 'Validation record should exist'); assert.strictEqual( validationRecord.TTL, 600, From 5082c38884691f20bae3feb41fe4a5cbd252f5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 10:21:23 +0100 Subject: [PATCH 07/10] Fallback to hosted zone id arg if zone is not found by domain name --- tests/acm-certificate/index.test.ts | 5 ++-- tests/acm-certificate/infrastructure/index.ts | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 0968af3..25ae90c 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -21,10 +21,9 @@ const programArgs: InlineProgramArgs = { describe('ACM Certificate component deployment', () => { const region = process.env.AWS_REGION; const domainName = process.env.DOMAIN_NAME; - const hostedZoneName = process.env.HOSTED_ZONE_NAME; - if (!region || !domainName || !hostedZoneName) { + if (!region || !domainName) { throw new Error( - 'AWS_REGION, DOMAIN_NAME and HOSTED_ZONE_NAME environment variables are required', + 'AWS_REGION and DOMAIN_NAME environment variables are required', ); } diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index afd4520..347c26c 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -1,15 +1,33 @@ import { next as studion } from '@studion/infra-code-blocks'; import * as aws from '@pulumi/aws'; +import * as pulumi from '@pulumi/pulumi'; const appName = 'acm-certificate-test'; -const hostedZone = aws.route53.getZoneOutput({ - name: process.env.HOSTED_ZONE_NAME, - privateZone: false, -}); +const domainName = process.env.DOMAIN_NAME!; + +const hostedZone = pulumi.output( + aws.route53 + .getZone({ + name: `${domainName}aa`, + privateZone: false, + }) + .catch(() => { + const hostedZoneId = process.env.HOSTED_ZONE_ID; + if (!hostedZoneId) { + throw new Error( + 'HOSTED_ZONE_ID environment variable is required when hosted zone cannot be found by domain name', + ); + } + return aws.route53.getZone({ + zoneId: hostedZoneId, + privateZone: false, + }); + }), +); const certificate = new studion.AcmCertificate(`${appName}-certificate`, { - domain: process.env.DOMAIN_NAME!, + domain: domainName, hostedZoneId: hostedZone.zoneId, }); From 46ca7e83e81f392b3d4aef70ea1e28ee77ad8115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 10:23:38 +0100 Subject: [PATCH 08/10] Fix get zone method args --- tests/acm-certificate/infrastructure/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index 347c26c..49ac71d 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -9,7 +9,7 @@ const domainName = process.env.DOMAIN_NAME!; const hostedZone = pulumi.output( aws.route53 .getZone({ - name: `${domainName}aa`, + name: `${domainName}`, privateZone: false, }) .catch(() => { From 359a94b5a5de0ec269aec45abd1f56d404e4d95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 21:29:29 +0100 Subject: [PATCH 09/10] Add ICB prefix to env variables --- tests/acm-certificate/index.test.ts | 8 +++--- tests/acm-certificate/infrastructure/index.ts | 28 ++++--------------- tests/acm-certificate/test-context.ts | 1 - 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 25ae90c..adbe963 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -20,17 +20,17 @@ const programArgs: InlineProgramArgs = { describe('ACM Certificate component deployment', () => { const region = process.env.AWS_REGION; - const domainName = process.env.DOMAIN_NAME; - if (!region || !domainName) { + const domainName = process.env.ICB_DOMAIN_NAME; + const hostedZoneId = process.env.ICB_HOSTED_ZONE_ID; + if (!region || !domainName || !hostedZoneId) { throw new Error( - 'AWS_REGION and DOMAIN_NAME environment variables are required', + 'AWS_REGION, ICB_DOMAIN_NAME and ICB_HOSTED_ZONE_ID environment variables are required', ); } const ctx: AcmCertificateTestContext = { outputs: {}, config: { - certificateName: 'acm-cert-test-cert', exponentialBackOffConfig: { delayFirstAttempt: true, numOfAttempts: 5, diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index 49ac71d..ede5d81 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -1,33 +1,15 @@ import { next as studion } from '@studion/infra-code-blocks'; import * as aws from '@pulumi/aws'; -import * as pulumi from '@pulumi/pulumi'; const appName = 'acm-certificate-test'; -const domainName = process.env.DOMAIN_NAME!; - -const hostedZone = pulumi.output( - aws.route53 - .getZone({ - name: `${domainName}`, - privateZone: false, - }) - .catch(() => { - const hostedZoneId = process.env.HOSTED_ZONE_ID; - if (!hostedZoneId) { - throw new Error( - 'HOSTED_ZONE_ID environment variable is required when hosted zone cannot be found by domain name', - ); - } - return aws.route53.getZone({ - zoneId: hostedZoneId, - privateZone: false, - }); - }), -); +const hostedZone = aws.route53.getZoneOutput({ + zoneId: process.env.ICB_HOSTED_ZONE_ID, + privateZone: false, +}); const certificate = new studion.AcmCertificate(`${appName}-certificate`, { - domain: domainName, + domain: process.env.ICB_DOMAIN_NAME!, hostedZoneId: hostedZone.zoneId, }); diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts index 2ddda33..69d0a55 100644 --- a/tests/acm-certificate/test-context.ts +++ b/tests/acm-certificate/test-context.ts @@ -3,7 +3,6 @@ import { ACMClient } from '@aws-sdk/client-acm'; import { Route53Client } from '@aws-sdk/client-route-53'; interface AcmCertificateTestConfig { - certificateName: string; exponentialBackOffConfig: { delayFirstAttempt: boolean; numOfAttempts: number; From e56724ea745993ab77b4fa5f162f689b4f46905d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 16 Dec 2025 13:14:28 +0100 Subject: [PATCH 10/10] Export certificate using esmodule syntax --- tests/acm-certificate/infrastructure/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index ede5d81..74802d9 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -13,7 +13,4 @@ const certificate = new studion.AcmCertificate(`${appName}-certificate`, { hostedZoneId: hostedZone.zoneId, }); -module.exports = { - certificate, - hostedZone, -}; +export { certificate, hostedZone };