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/31] 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 00000000..30e8eaf7 --- /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 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'; 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/31] 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 00000000..9cd8d3ed --- /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 00000000..afd45202 --- /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 00000000..2ddda335 --- /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/31] 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 e17ee52b..0e23665f 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 6249fa58887952bdc65cafa4fb5530b4c52a9d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 9 Dec 2025 09:41:31 +0100 Subject: [PATCH 04/31] Add subjectAlternativeNames option arg --- src/v2/components/acm-certificate/index.ts | 69 ++++++++++++++-------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index 30e8eaf7..00e98736 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -4,6 +4,10 @@ import { commonTags } from '../../../constants'; export type AcmCertificateArgs = { domain: pulumi.Input; + /** + * Additional domains/subdomains to be included in this certificate. + */ + subjectAlternativeNames?: pulumi.Input[]; hostedZoneId: pulumi.Input; }; @@ -19,36 +23,51 @@ export class AcmCertificate extends pulumi.ComponentResource { 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], + domainName: args.domain, + subjectAlternativeNames: args.subjectAlternativeNames, + validationMethod: 'DNS', + tags: commonTags, }, { parent: this }, ); + this.createCertificationValidationRecords(args.domain, args.hostedZoneId); + this.registerOutputs(); } + + private createCertificationValidationRecords( + domainName: AcmCertificateArgs['domain'], + hostedZoneId: AcmCertificateArgs['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 }, + ); + }); + } } From 710a1023547c2c0c995d38f292b2a9b82d899d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 9 Dec 2025 11:21:41 +0100 Subject: [PATCH 05/31] Add test for cert with SANs --- tests/acm-certificate/index.test.ts | 25 +++++++++++++++++++ tests/acm-certificate/infrastructure/index.ts | 11 ++++++++ tests/acm-certificate/test-context.ts | 1 + 3 files changed, 37 insertions(+) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 9cd8d3ed..6363f803 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -32,6 +32,7 @@ describe('ACM Certificate component deployment', () => { outputs: {}, config: { certificateName: 'acm-cert-test-cert', + subDomainName: `app.${process.env.DOMAIN_NAME!}`, exponentialBackOffConfig: { delayFirstAttempt: true, numOfAttempts: 5, @@ -123,4 +124,28 @@ describe('ACM Certificate component deployment', () => { '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 index afd45202..dcd84ded 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -13,7 +13,18 @@ const certificate = new studion.AcmCertificate(`${appName}-certificate`, { hostedZoneId: hostedZone.zoneId, }); +const subDomainName = `app.${process.env.DOMAIN_NAME!}`; +const sanCertificate = new studion.AcmCertificate( + `${appName}-certificate-san`, + { + domain: subDomainName, + subjectAlternativeNames: [`api.${subDomainName}`, `test.${subDomainName}`], + hostedZoneId: hostedZone.zoneId, + }, +); + module.exports = { certificate, + sanCertificate, hostedZone, }; diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts index 2ddda335..0fa9ead3 100644 --- a/tests/acm-certificate/test-context.ts +++ b/tests/acm-certificate/test-context.ts @@ -4,6 +4,7 @@ import { Route53Client } from '@aws-sdk/client-route-53'; interface AcmCertificateTestConfig { certificateName: string; + subDomainName: string; exponentialBackOffConfig: { delayFirstAttempt: boolean; numOfAttempts: number; 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 06/31] 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 30e8eaf7..91f20050 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 07/31] 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 0e23665f..e17ee52b 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 08/31] 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 9cd8d3ed..0968af38 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 09/31] 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 0968af38..25ae90ce 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 afd45202..347c26c6 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 10/31] 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 347c26c6..49ac71db 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 9a826288dac71603452a7c0ac07d49bb2eab9d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 10:40:36 +0100 Subject: [PATCH 11/31] Refactor 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 5666b047..a3a1b226 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}`, + 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 12/31] 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 25ae90ce..adbe9636 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 49ac71db..ede5d81f 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 2ddda335..69d0a55a 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 034c44737f5a569551a969a782fce8038453e37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 10 Dec 2025 21:36:50 +0100 Subject: [PATCH 13/31] Fix method name typo --- src/v2/components/acm-certificate/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index 8856ed43..76ed8c05 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -34,12 +34,12 @@ export class AcmCertificate extends pulumi.ComponentResource { { parent: this }, ); - this.createCertificationValidationRecords(args.domain, args.hostedZoneId); + this.createCertValidationRecords(args.domain, args.hostedZoneId); this.registerOutputs(); } - private createCertificationValidationRecords( + private createCertValidationRecords( domainName: AcmCertificate.Args['domain'], hostedZoneId: AcmCertificate.Args['hostedZoneId'], ) { 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 14/31] 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 ede5d81f..74802d90 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 }; From 1eee0254db381881da962b250f5653a566101fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 12:38:34 +0100 Subject: [PATCH 15/31] Update web server builder with cert method --- src/v2/components/web-server/builder.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/v2/components/web-server/builder.ts b/src/v2/components/web-server/builder.ts index 9fea21d3..a8af92d3 100644 --- a/src/v2/components/web-server/builder.ts +++ b/src/v2/components/web-server/builder.ts @@ -3,6 +3,7 @@ import * as awsx from '@pulumi/awsx'; import { EcsService } from '../ecs-service'; import { WebServer } from '.'; import { OtelCollector } from '../../otel'; +import { AcmCertificate } from '../acm-certificate'; export namespace WebServerBuilder { export type EcsConfig = Omit; @@ -26,6 +27,7 @@ export class WebServerBuilder { private _ecsConfig?: WebServerBuilder.EcsConfig; private _domain?: pulumi.Input; private _hostedZoneId?: pulumi.Input; + private _certificate?: pulumi.Input; private _healthCheckPath?: pulumi.Input; private _otelCollector?: pulumi.Input; private _initContainers: pulumi.Input[] = []; @@ -87,6 +89,18 @@ export class WebServerBuilder { return this; } + public withCertificate( + certificate: WebServerBuilder.Args['certificate'], + hostedZoneId: pulumi.Input, + domain?: pulumi.Input, + ): this { + this._certificate = certificate; + this._hostedZoneId = hostedZoneId; + this._domain = domain; + + return this; + } + public withInitContainer(container: WebServer.InitContainer): this { this._initContainers.push(container); @@ -140,6 +154,7 @@ export class WebServerBuilder { publicSubnetIds: this._vpc.publicSubnetIds, domain: this._domain, hostedZoneId: this._hostedZoneId, + certificate: this._certificate, healthCheckPath: this._healthCheckPath, otelCollector: this._otelCollector, initContainers: this._initContainers, From b8ef5e88b6eebad0aa693d7fb492f7927a21adc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 12:51:44 +0100 Subject: [PATCH 16/31] Enable san record creation from cert --- src/v2/components/web-server/index.ts | 132 +++++++++++++----- src/v2/components/web-server/load-balancer.ts | 10 +- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 107f34c4..1142777f 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -2,7 +2,7 @@ import * as pulumi from '@pulumi/pulumi'; import * as aws from '@pulumi/aws'; import * as awsx from '@pulumi/awsx'; import { commonTags } from '../../../constants'; -import { AcmCertificate } from '../../../components/acm-certificate'; +import { AcmCertificate } from '../acm-certificate'; import { EcsService } from '../ecs-service'; import { WebServerLoadBalancer } from './load-balancer'; import { OtelCollector } from '../../otel'; @@ -44,8 +44,10 @@ export namespace WebServer { * The domain which will be used to access the service. * The domain or subdomain must belong to the provided hostedZone. */ + // TODO: document this better domain?: pulumi.Input; hostedZoneId?: pulumi.Input; + certificate?: pulumi.Input; /** * Path for the load balancer target group health check request. * @@ -71,8 +73,9 @@ export class WebServer extends pulumi.ComponentResource { initContainers?: pulumi.Output; sidecarContainers?: pulumi.Output; volumes?: pulumi.Output; - certificate?: AcmCertificate; - dnsRecord?: aws.route53.Record; + certificate?: pulumi.Output; + dnsRecord?: pulumi.Output; + sanRecords?: pulumi.Output; constructor( name: string, @@ -80,17 +83,21 @@ export class WebServer extends pulumi.ComponentResource { opts: pulumi.ComponentResourceOptions = {}, ) { super('studion:WebServer', name, args, opts); + const { vpc, domain, hostedZoneId, certificate } = args; - const { vpc, domain, hostedZoneId } = args; - - if (domain && !hostedZoneId) { + if ((domain || certificate) && !hostedZoneId) { throw new Error( - 'WebServer:hostedZoneId must be provided when the domain is specified', + 'WebServer: hostedZoneId must be provided when domain or certificate are provided', ); } + const hasCustomDomain = !!domain && !!hostedZoneId; - if (hasCustomDomain) { - this.certificate = this.createTlsCertificate({ domain, hostedZoneId }); + if (certificate) { + this.certificate = pulumi.output(certificate); + } else if (hasCustomDomain) { + this.certificate = pulumi.output( + this.createTlsCertificate({ domain, hostedZoneId }), + ); } this.name = name; @@ -125,8 +132,14 @@ export class WebServer extends pulumi.ComponentResource { ); }); - if (hasCustomDomain) { - this.dnsRecord = this.createDnsRecord({ domain, hostedZoneId }); + if (this.certificate) { + const dnsResult = this.createDnsRecords( + this.certificate, + hostedZoneId!, + domain, + ); + this.dnsRecord = dnsResult.primary; + this.sanRecords = dnsResult.sans; } this.registerOutputs(); @@ -309,28 +322,79 @@ export class WebServer extends pulumi.ComponentResource { ); } - private createDnsRecord({ - domain, - hostedZoneId, - }: Pick< - Required, - 'domain' | 'hostedZoneId' - >): aws.route53.Record { - return new aws.route53.Record( - `${this.name}-route53-record`, - { - type: 'A', - name: domain, - zoneId: hostedZoneId, - aliases: [ - { - name: this.lb.lb.dnsName, - zoneId: this.lb.lb.zoneId, - evaluateTargetHealth: true, - }, - ], - }, - { parent: this }, - ); + private createDnsRecords( + certificate: pulumi.Output, + hostedZoneId: pulumi.Input, + domain?: pulumi.Input, + ): { + primary: pulumi.Output; + sans: pulumi.Output; + } { + if (domain) { + return { + primary: pulumi.output( + new aws.route53.Record( + `${this.name}-route53-record`, + { + type: 'A', + name: domain, + zoneId: hostedZoneId, + aliases: [ + { + name: this.lb.lb.dnsName, + zoneId: this.lb.lb.zoneId, + evaluateTargetHealth: true, + }, + ], + }, + { parent: this }, + ), + ), + + sans: pulumi.output([]), + }; + } + + const records = pulumi + .all([ + certificate.certificate.domainName, + certificate.certificate.subjectAlternativeNames, + ]) + .apply(([primaryDomain, sans]) => { + const allDomains = [ + primaryDomain, + ...(sans || []).filter(san => san !== primaryDomain), + ]; + + const allRecords = allDomains.map( + (domain, index) => + new aws.route53.Record( + `${this.name}-route53-record${index === 0 ? '' : `-${index}`}`, + { + type: 'A', + name: domain, + zoneId: hostedZoneId, + aliases: [ + { + name: this.lb.lb.dnsName, + zoneId: this.lb.lb.zoneId, + evaluateTargetHealth: true, + }, + ], + }, + { parent: this }, + ), + ); + + return { + primaryRecord: allRecords[0], + sanRecords: allRecords.slice(1), + }; + }); + + return { + primary: records.primaryRecord, + sans: records.sanRecords, + }; } } diff --git a/src/v2/components/web-server/load-balancer.ts b/src/v2/components/web-server/load-balancer.ts index 2552c3f9..6a422686 100644 --- a/src/v2/components/web-server/load-balancer.ts +++ b/src/v2/components/web-server/load-balancer.ts @@ -7,7 +7,7 @@ export namespace WebServerLoadBalancer { export type Args = { vpc: pulumi.Input; port: pulumi.Input; - certificate?: aws.acm.Certificate; + certificate?: pulumi.Input; healthCheckPath?: pulumi.Input; }; } @@ -88,7 +88,11 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { ); this.tlsListener = certificate && - this.createLbTlsListener(this.lb, this.targetGroup, certificate); + this.createLbTlsListener( + this.lb, + this.targetGroup, + pulumi.output(certificate), + ); this.registerOutputs(); } @@ -96,7 +100,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { private createLbTlsListener( lb: aws.lb.LoadBalancer, lbTargetGroup: aws.lb.TargetGroup, - certificate: aws.acm.Certificate, + certificate: pulumi.Output, ): aws.lb.Listener { return new aws.lb.Listener( `${this.name}-listener-443`, From 868ecb5f571e8adf7e2ad6b16f4415b137226f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 14:15:37 +0100 Subject: [PATCH 17/31] Implement tests for web server components with certificates --- tests/web-server/domain.test.ts | 160 ++++++++++++++++++++++ tests/web-server/index.test.ts | 11 +- tests/web-server/infrastructure/config.ts | 16 +++ tests/web-server/infrastructure/index.ts | 79 +++++++++-- 4 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 tests/web-server/domain.test.ts diff --git a/tests/web-server/domain.test.ts b/tests/web-server/domain.test.ts new file mode 100644 index 00000000..4ebc703a --- /dev/null +++ b/tests/web-server/domain.test.ts @@ -0,0 +1,160 @@ +import { it } from 'node:test'; +import * as assert from 'node:assert'; +import { WebServerTestContext } from './test-context'; +import { DescribeListenersCommand } from '@aws-sdk/client-elastic-load-balancing-v2'; +import { ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53'; +import { backOff } from 'exponential-backoff'; +import { request } from 'undici'; +import status from 'http-status'; + +export function testWebServerWithDomain(ctx: WebServerTestContext) { + it('should configure HTTPS listener with certificate for web server with custom domain', async () => { + const webServer = ctx.outputs.webServerWithDomain.value; + await assertHttpsListenerWithCertificate(ctx, webServer); + }); + + it('should create single DNS A record for web server with custom domain', async () => { + const webServer = ctx.outputs.webServerWithDomain.value; + const { primary } = ctx.config.webServerWithDomainConfig; + + assert.ok(webServer.dnsRecord, 'DNS record should be configured'); + await assertDnsARecord(ctx, primary, webServer.lb.lb.dnsName); + }); + + it('web server should be accessible via custom domain over HTTPS', async () => { + const { primary } = ctx.config.webServerWithDomainConfig; + await assertHealthCheckAccessible(ctx, primary); + }); + + it('should configure HTTPS listener with certificate for web server with SAN certificate', async () => { + const webServer = ctx.outputs.webServerWithSanCertificate.value; + await assertHttpsListenerWithCertificate(ctx, webServer); + }); + + it('should create DNS records for primary domain and all SANs', async () => { + const webServer = ctx.outputs.webServerWithSanCertificate.value; + const { primary, sans } = ctx.config.webServerWithSanCertificateConfig; + + assert.ok(webServer.dnsRecord, 'Primary DNS record should exist'); + assert.ok(webServer.sanRecords, 'SAN records should exist'); + + await assertDnsARecord(ctx, primary, webServer.lb.lb.dnsName); + + for (const san of sans) { + await assertDnsARecord(ctx, san, webServer.lb.lb.dnsName); + } + }); + + it('should be accessible via all SAN domains over HTTPS', async () => { + const { primary, sans } = ctx.config.webServerWithSanCertificateConfig; + const allDomains = [primary, ...sans]; + + for (const domain of allDomains) { + await assertHealthCheckAccessible(ctx, domain); + } + }); + + it('should configure HTTPS listener with certificate for web server', async () => { + const webServer = ctx.outputs.webServerWithCertificate.value; + await assertHttpsListenerWithCertificate(ctx, webServer); + }); + + it('should create DNS record only for specified domain in web server with certificate', async () => { + const webServer = ctx.outputs.webServerWithCertificate.value; + const { primary } = ctx.config.webServerWithCertificateConfig; + + assert.ok(webServer.dnsRecord, 'DNS record should exist'); + await assertDnsARecord(ctx, primary, webServer.lb.lb.dnsName); + }); + + it('should be accessible via specified domain over HTTPS', async () => { + const { primary } = ctx.config.webServerWithCertificateConfig; + await assertHealthCheckAccessible(ctx, primary); + }); +} + +async function assertHttpsListenerWithCertificate( + ctx: WebServerTestContext, + webServer: any, +) { + assert.ok(webServer.certificate, 'Certificate should be configured'); + assert.ok(webServer.lb.tlsListener, 'TLS listener should exist'); + + const command = new DescribeListenersCommand({ + ListenerArns: [webServer.lb.tlsListener.arn], + }); + + const response = await ctx.clients.elb.send(command); + const [listener] = response.Listeners ?? []; + + assert.ok(listener, 'HTTPS listener should exist in AWS'); + assert.strictEqual( + listener.Port, + 443, + 'HTTPS listener should be on port 443', + ); + assert.strictEqual( + listener.Protocol, + 'HTTPS', + 'Listener protocol should be HTTPS', + ); + + const certificateArn = listener.Certificates?.[0]?.CertificateArn; + assert.strictEqual( + certificateArn, + webServer.certificate.certificate.arn, + 'Certificate ARN should match the configured certificate', + ); +} + +async function assertDnsARecord( + ctx: WebServerTestContext, + domain: string, + loadBalancerDnsName: string, +) { + const hostedZoneId = process.env.ICB_HOSTED_ZONE_ID!; + + const command = new ListResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + StartRecordName: domain, + StartRecordType: 'A', + MaxItems: 1, + }); + + const response = await ctx.clients.route53.send(command); + const record = response.ResourceRecordSets?.find( + r => r.Name === `${domain}.` && r.Type === 'A', + ); + + assert.ok(record, `A record for ${domain} should exist in Route53`); + assert.ok(record.AliasTarget, 'Record should be an alias record'); + assert.ok( + record.AliasTarget?.DNSName?.includes(loadBalancerDnsName), + `Record for ${domain} should point to load balancer`, + ); +} + +async function assertHealthCheckAccessible( + ctx: WebServerTestContext, + domain: string, +) { + return backOff( + async () => { + const response = await request( + `https://${domain}${ctx.config.healthCheckPath}`, + ); + assert.strictEqual( + response.statusCode, + status.OK, + `Should receive 200 from ${domain}`, + ); + }, + { + delayFirstAttempt: true, + numOfAttempts: 10, + startingDelay: 2000, + timeMultiple: 2, + jitter: 'full', + }, + ); +} diff --git a/tests/web-server/index.test.ts b/tests/web-server/index.test.ts index 7450ea0c..82de42ae 100644 --- a/tests/web-server/index.test.ts +++ b/tests/web-server/index.test.ts @@ -21,6 +21,7 @@ import status from 'http-status'; import * as automation from '../automation'; import { WebServerTestContext } from './test-context'; import * as config from './infrastructure/config'; +import { testWebServerWithDomain } from './domain.test'; const programArgs: InlineProgramArgs = { stackName: 'dev', @@ -37,8 +38,12 @@ class NonRetryableError extends Error { describe('Web server component deployment', () => { const region = process.env.AWS_REGION; - if (!region) { - throw new Error('AWS_REGION environment variable is required'); + 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: WebServerTestContext = { @@ -330,4 +335,6 @@ describe('Web server component deployment', () => { }, ); }); + + describe('With domain', () => testWebServerWithDomain(ctx)); }); diff --git a/tests/web-server/infrastructure/config.ts b/tests/web-server/infrastructure/config.ts index 765de42f..c8276fcf 100644 --- a/tests/web-server/infrastructure/config.ts +++ b/tests/web-server/infrastructure/config.ts @@ -1,2 +1,18 @@ export const webServerName = 'web-server-test'; export const healthCheckPath = '/healthcheck'; + +const baseDomain = process.env.ICB_DOMAIN_NAME!; + +export const webServerWithDomainConfig = { + primary: `domain.${baseDomain}`, +}; + +export const webServerWithSanCertificateConfig = { + primary: baseDomain, + sans: [`api.${baseDomain}`, `app.${baseDomain}`], +}; + +export const webServerWithCertificateConfig = { + primary: `test.${baseDomain}`, + sans: [`test.api.${baseDomain}`, `test.app.${baseDomain}`], +}; diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index 21daf6bd..59ead4bc 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -1,7 +1,13 @@ import { Project, next as studion } from '@studion/infra-code-blocks'; import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; -import { webServerName, healthCheckPath } from './config'; +import { + webServerName, + healthCheckPath, + webServerWithDomainConfig, + webServerWithSanCertificateConfig, + webServerWithCertificateConfig, +} from './config'; const stackName = pulumi.getStack(); const project: Project = new Project(webServerName, { services: [] }); @@ -38,15 +44,16 @@ const cluster = new aws.ecs.Cluster(`${webServerName}-cluster`, { name: `${webServerName}-cluster-${stackName}`, tags, }); +const ecs = { + cluster, + desiredCount: 1, + size: 'small' as const, + autoscaling: { enabled: false }, +}; const webServer = new studion.WebServerBuilder(webServerName) .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) - .configureEcs({ - cluster, - desiredCount: 1, - size: 'small', - autoscaling: { enabled: false }, - }) + .configureEcs(ecs) .withInitContainer(init) .withSidecarContainer(sidecar) .withVpc(project.vpc) @@ -54,4 +61,60 @@ const webServer = new studion.WebServerBuilder(webServerName) .withCustomHealthCheckPath(healthCheckPath) .build({ parent: cluster }); -export { project, webServer, otelCollector }; +const hostedZone = aws.route53.getZoneOutput({ + zoneId: process.env.ICB_HOSTED_ZONE_ID, + privateZone: false, +}); + +// TODO: wildcard +const webServerWithDomain = new studion.WebServerBuilder(`web-server-domain`) + .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) + .configureEcs(ecs) + .withVpc(project.vpc) + .withCustomHealthCheckPath(healthCheckPath) + .withCustomDomain(webServerWithDomainConfig.primary, hostedZone.zoneId) + .build({ parent: cluster }); + +const sanWebServerCert = new studion.AcmCertificate( + `${webServerName}-san-cert`, + { + domain: webServerWithSanCertificateConfig.primary, + subjectAlternativeNames: webServerWithSanCertificateConfig.sans, + hostedZoneId: hostedZone.zoneId, + }, +); +const webServerWithSanCertificate = new studion.WebServerBuilder( + `web-server-san`, +) + .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) + .configureEcs(ecs) + .withVpc(project.vpc) + .withCustomHealthCheckPath(healthCheckPath) + .withCertificate(sanWebServerCert, hostedZone.zoneId) + .build({ parent: cluster }); + +const certWebServer = new studion.AcmCertificate(`${webServerName}-cert`, { + domain: webServerWithCertificateConfig.primary, + subjectAlternativeNames: webServerWithCertificateConfig.sans, + hostedZoneId: hostedZone.zoneId, +}); +const webServerWithCertificate = new studion.WebServerBuilder(`web-server-cert`) + .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) + .configureEcs(ecs) + .withVpc(project.vpc) + .withCustomHealthCheckPath(healthCheckPath) + .withCertificate( + certWebServer, + hostedZone.zoneId, + webServerWithCertificateConfig.primary, + ) + .build({ parent: cluster }); + +export { + project, + webServer, + otelCollector, + webServerWithSanCertificate, + webServerWithCertificate, + webServerWithDomain, +}; From 970460222162120103abbeba71cc3be243864866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 14:25:23 +0100 Subject: [PATCH 18/31] Add docs for certificate and domain args edge case --- src/v2/components/web-server/index.ts | 6 +++++- tests/web-server/infrastructure/index.ts | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 1142777f..654e474b 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -44,9 +44,13 @@ export namespace WebServer { * The domain which will be used to access the service. * The domain or subdomain must belong to the provided hostedZone. */ - // TODO: document this better domain?: pulumi.Input; hostedZoneId?: pulumi.Input; + /** + * If provided without `domain` argument, Route53 A records will be created for the certificate's + * primary domain and all subject alternative names (SANs). + * If `domain` argument is also provided, only a single A record for that domain will be created. + */ certificate?: pulumi.Input; /** * Path for the load balancer target group health check request. diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index 59ead4bc..a37ecf1c 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -66,7 +66,6 @@ const hostedZone = aws.route53.getZoneOutput({ privateZone: false, }); -// TODO: wildcard const webServerWithDomain = new studion.WebServerBuilder(`web-server-domain`) .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) .configureEcs(ecs) From 6fc989d8111ddb646677b9e54aa1c100fcade465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 14:48:33 +0100 Subject: [PATCH 19/31] Remove publicSubnetIds arg --- src/v2/components/web-server/builder.ts | 2 -- src/v2/components/web-server/index.ts | 2 -- tests/build/index.tst.ts | 1 - 3 files changed, 5 deletions(-) diff --git a/src/v2/components/web-server/builder.ts b/src/v2/components/web-server/builder.ts index a8af92d3..00d67ff0 100644 --- a/src/v2/components/web-server/builder.ts +++ b/src/v2/components/web-server/builder.ts @@ -11,7 +11,6 @@ export namespace WebServerBuilder { export type Args = Omit< WebServer.Args, | 'vpc' - | 'publicSubnetIds' | 'cluster' | 'volumes' | 'domain' @@ -151,7 +150,6 @@ export class WebServerBuilder { ...this._container, vpc: this._vpc, volumes: this._volumes, - publicSubnetIds: this._vpc.publicSubnetIds, domain: this._domain, hostedZoneId: this._hostedZoneId, certificate: this._certificate, diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 654e474b..972a8482 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -38,8 +38,6 @@ export namespace WebServer { export type Args = EcsConfig & Container & { - // TODO: Automatically use subnet IDs from passed `vpc` - publicSubnetIds: pulumi.Input[]>; /** * The domain which will be used to access the service. * The domain or subdomain must belong to the provided hostedZone. diff --git a/tests/build/index.tst.ts b/tests/build/index.tst.ts index 749e9af9..6406fec9 100644 --- a/tests/build/index.tst.ts +++ b/tests/build/index.tst.ts @@ -51,7 +51,6 @@ describe('Build output', () => { cluster: new aws.ecs.Cluster('clusterName'), image: 'sample/image', port: 8080, - publicSubnetIds: ['sub-1', 'sub-2', 'sub-3'], }); }); From ec7aba03cbde4b48e0a4c2e156c6fd6a4c442d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 14:58:25 +0100 Subject: [PATCH 20/31] Refactor ecs service creation method --- src/v2/components/web-server/index.ts | 87 ++++++++++++++------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 972a8482..4ad46583 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -121,18 +121,14 @@ export class WebServer extends pulumi.ComponentResource { this.ecsConfig = this.createEcsConfig(args); this.volumes = this.getVolumes(args); - // TODO: Move output mapping to createEcsService - this.service = pulumi - .all([this.initContainers, this.sidecarContainers]) - .apply(([initContainers, sidecarContainers]) => { - return this.createEcsService( - this.container, - this.lb, - this.ecsConfig, - this.volumes, - [...initContainers, ...sidecarContainers], - ); - }); + this.service = this.createEcsService( + this.container, + this.lb, + this.ecsConfig, + this.volumes, + this.initContainers, + this.sidecarContainers, + ); if (this.certificate) { const dnsResult = this.createDnsRecords( @@ -288,40 +284,49 @@ export class WebServer extends pulumi.ComponentResource { lb: WebServerLoadBalancer, ecsConfig: WebServer.EcsConfig, volumes?: pulumi.Output, - containers?: EcsService.Container[], - ): EcsService { - return new EcsService( - `${this.name}-ecs`, - { - ...ecsConfig, - volumes, - containers: [ + initContainers?: pulumi.Output, + sidecarContainers?: pulumi.Output, + ): pulumi.Output { + return pulumi + .all([ + initContainers || pulumi.output([]), + sidecarContainers || pulumi.output([]), + ]) + .apply(([inits, sidecars]) => { + return new EcsService( + `${this.name}-ecs`, { - ...webServerContainer, - name: this.name, - portMappings: [ - EcsService.createTcpPortMapping(webServerContainer.port), + ...ecsConfig, + volumes, + containers: [ + { + ...webServerContainer, + name: this.name, + portMappings: [ + EcsService.createTcpPortMapping(webServerContainer.port), + ], + essential: true, + }, + ...inits, + ...sidecars, ], - essential: true, + enableServiceAutoDiscovery: false, + loadBalancers: [ + { + containerName: this.name, + containerPort: webServerContainer.port, + targetGroupArn: lb.targetGroup.arn, + }, + ], + assignPublicIp: true, + securityGroup: this.serviceSecurityGroup, }, - ...(containers || []), - ], - enableServiceAutoDiscovery: false, - loadBalancers: [ { - containerName: this.name, - containerPort: webServerContainer.port, - targetGroupArn: lb.targetGroup.arn, + parent: this, + dependsOn: [lb, lb.targetGroup], }, - ], - assignPublicIp: true, - securityGroup: this.serviceSecurityGroup, - }, - { - parent: this, - dependsOn: [lb, lb.targetGroup], - }, - ); + ); + }); } private createDnsRecords( From ce4031bc6ad7aae4e595f78e33bebfa51890cf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 15:16:55 +0100 Subject: [PATCH 21/31] Add load balancing algorithm argument --- src/v2/components/web-server/builder.ts | 8 ++++++++ src/v2/components/web-server/index.ts | 2 ++ src/v2/components/web-server/load-balancer.ts | 7 ++++++- tests/web-server/index.test.ts | 16 +++++++++++++++- tests/web-server/infrastructure/index.ts | 1 + 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/v2/components/web-server/builder.ts b/src/v2/components/web-server/builder.ts index 00d67ff0..5a7bbf9b 100644 --- a/src/v2/components/web-server/builder.ts +++ b/src/v2/components/web-server/builder.ts @@ -28,6 +28,7 @@ export class WebServerBuilder { private _hostedZoneId?: pulumi.Input; private _certificate?: pulumi.Input; private _healthCheckPath?: pulumi.Input; + private _loadBalancingAlgorithmType?: pulumi.Input; private _otelCollector?: pulumi.Input; private _initContainers: pulumi.Input[] = []; private _sidecarContainers: pulumi.Input[] = []; @@ -126,6 +127,12 @@ export class WebServerBuilder { return this; } + public withLoadBalancingAlgorithm(algorithm: pulumi.Input) { + this._loadBalancingAlgorithmType = algorithm; + + return this; + } + public build(opts: pulumi.ComponentResourceOptions = {}): WebServer { if (!this._container) { throw new Error( @@ -154,6 +161,7 @@ export class WebServerBuilder { hostedZoneId: this._hostedZoneId, certificate: this._certificate, healthCheckPath: this._healthCheckPath, + loadBalancingAlgorithmType: this._loadBalancingAlgorithmType, otelCollector: this._otelCollector, initContainers: this._initContainers, sidecarContainers: this._sidecarContainers, diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 4ad46583..bb13d64e 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -57,6 +57,7 @@ export namespace WebServer { * "/healthcheck" */ healthCheckPath?: pulumi.Input; + loadBalancingAlgorithmType?: pulumi.Input; initContainers?: pulumi.Input[]>; sidecarContainers?: pulumi.Input< pulumi.Input[] @@ -110,6 +111,7 @@ export class WebServer extends pulumi.ComponentResource { port: args.port, certificate: this.certificate?.certificate, healthCheckPath: args.healthCheckPath, + loadBalancingAlgorithmType: args.loadBalancingAlgorithmType, }, { parent: this }, ); diff --git a/src/v2/components/web-server/load-balancer.ts b/src/v2/components/web-server/load-balancer.ts index 6a422686..2d2a10c5 100644 --- a/src/v2/components/web-server/load-balancer.ts +++ b/src/v2/components/web-server/load-balancer.ts @@ -9,6 +9,7 @@ export namespace WebServerLoadBalancer { port: pulumi.Input; certificate?: pulumi.Input; healthCheckPath?: pulumi.Input; + loadBalancingAlgorithmType?: pulumi.Input; }; } @@ -58,7 +59,8 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { this.name = name; const vpc = pulumi.output(args.vpc); - const { port, certificate, healthCheckPath } = args; + const { port, certificate, healthCheckPath, loadBalancingAlgorithmType } = + args; this.securityGroup = this.createLbSecurityGroup(vpc.vpcId); @@ -80,6 +82,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { port, vpc.vpcId, healthCheckPath, + loadBalancingAlgorithmType, ); this.httpListener = this.createLbHttpListener( this.lb, @@ -158,6 +161,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { port: pulumi.Input, vpcId: awsx.ec2.Vpc['vpcId'], healthCheckPath: pulumi.Input | undefined, + loadBalancingAlgorithmType?: pulumi.Input, ): aws.lb.TargetGroup { return new aws.lb.TargetGroup( `${this.name}-tg`, @@ -167,6 +171,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { protocol: 'HTTP', targetType: 'ip', vpcId, + loadBalancingAlgorithmType, healthCheck: { healthyThreshold: 3, unhealthyThreshold: 2, diff --git a/tests/web-server/index.test.ts b/tests/web-server/index.test.ts index 82de42ae..b65e78df 100644 --- a/tests/web-server/index.test.ts +++ b/tests/web-server/index.test.ts @@ -12,6 +12,7 @@ import { DescribeLoadBalancersCommand, DescribeTargetGroupsCommand, DescribeListenersCommand, + DescribeTargetGroupAttributesCommand, } from '@aws-sdk/client-elastic-load-balancing-v2'; import { ACMClient } from '@aws-sdk/client-acm'; import { Route53Client } from '@aws-sdk/client-route-53'; @@ -97,7 +98,7 @@ describe('Web server component deployment', () => { ); }); - it('should create target group with correct health check path', async () => { + it('should create target group with the correct configuration', async () => { const webServer = ctx.outputs.webServer.value; const command = new DescribeTargetGroupsCommand({ @@ -113,6 +114,19 @@ describe('Web server component deployment', () => { ctx.config.healthCheckPath, 'Target group should have correct health check path', ); + + const attributesCommand = new DescribeTargetGroupAttributesCommand({ + TargetGroupArn: webServer.lb.targetGroup.arn, + }); + const attributesResponse = await ctx.clients.elb.send(attributesCommand); + const algorithmAttribute = attributesResponse.Attributes?.find( + attr => attr.Key === 'load_balancing.algorithm.type', + ); + assert.strictEqual( + algorithmAttribute?.Value, + 'round_robin', + 'Target group should use least_outstanding_requests algorithm', + ); }); it('should create HTTP listener on port 80', async () => { diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index a37ecf1c..edc9ddf6 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -59,6 +59,7 @@ const webServer = new studion.WebServerBuilder(webServerName) .withVpc(project.vpc) .withOtelCollector(otelCollector) .withCustomHealthCheckPath(healthCheckPath) + .withLoadBalancingAlgorithm('least_outstanding_requests') .build({ parent: cluster }); const hostedZone = aws.route53.getZoneOutput({ From 9348fe8dcf832b140be4ef8eb460dda4b32becf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 15:21:27 +0100 Subject: [PATCH 22/31] Resolve lb algorithm assertion error --- tests/web-server/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/web-server/index.test.ts b/tests/web-server/index.test.ts index b65e78df..6e4c7e49 100644 --- a/tests/web-server/index.test.ts +++ b/tests/web-server/index.test.ts @@ -124,7 +124,7 @@ describe('Web server component deployment', () => { ); assert.strictEqual( algorithmAttribute?.Value, - 'round_robin', + 'least_outstanding_requests', 'Target group should use least_outstanding_requests algorithm', ); }); From 99e85c527656c48ace0e82216d4cddaef5bc2259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Thu, 1 Jan 2026 15:23:29 +0100 Subject: [PATCH 23/31] Rephrase assertion text --- tests/web-server/domain.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/web-server/domain.test.ts b/tests/web-server/domain.test.ts index 4ebc703a..78590110 100644 --- a/tests/web-server/domain.test.ts +++ b/tests/web-server/domain.test.ts @@ -21,7 +21,7 @@ export function testWebServerWithDomain(ctx: WebServerTestContext) { await assertDnsARecord(ctx, primary, webServer.lb.lb.dnsName); }); - it('web server should be accessible via custom domain over HTTPS', async () => { + it('should make web server accessible via custom domain over HTTPS', async () => { const { primary } = ctx.config.webServerWithDomainConfig; await assertHealthCheckAccessible(ctx, primary); }); From cb1a4084332f71373854c8a6f4d7126144eb5af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 6 Jan 2026 14:04:38 +0100 Subject: [PATCH 24/31] Bump pulumi aws and awsx dependencies --- package-lock.json | 50 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 52 insertions(+) diff --git a/package-lock.json b/package-lock.json index bb33cf33..e34a1f7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "@pulumi/aws": "^6.66.3", + "@pulumi/aws-v7": "npm:@pulumi/aws@^7.15.0", "@pulumi/awsx": "^2.21.0", + "@pulumi/awsx-v3": "npm:@pulumi/awsx@^3.1.0", "@pulumi/pulumi": "^3.146.0", "@pulumi/random": "^4.17.0", "@pulumiverse/grafana": "^0.16.3", @@ -4806,6 +4808,17 @@ "mime": "^2.0.0" } }, + "node_modules/@pulumi/aws-v7": { + "name": "@pulumi/aws", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@pulumi/aws/-/aws-7.15.0.tgz", + "integrity": "sha512-teHcWfllICarutAQq7ix2X5PFaNCXBe+vQmnR6Efnq/PkZcXbVq+6eLmSFkjuYboa3nIb2YUzG9ic34hUmf79Q==", + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0", + "mime": "^2.0.0" + } + }, "node_modules/@pulumi/awsx": { "version": "2.22.0", "resolved": "https://registry.npmjs.org/@pulumi/awsx/-/awsx-2.22.0.tgz", @@ -4822,6 +4835,43 @@ "mime": "^2.0.0" } }, + "node_modules/@pulumi/awsx-v3": { + "name": "@pulumi/awsx", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@pulumi/awsx/-/awsx-3.1.0.tgz", + "integrity": "sha512-2vT0T3MF0Nj9ZP+pGLDSSJ5zTPOT42fPMNddUkPmKsJJ8Zyhjw8+2FKtHJAPPs4oeUVI3z5Zp3qOECa0EtqX6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-ecs": "^3.405.0", + "@pulumi/aws": "^7.11.0", + "@pulumi/docker": "^4.6.0", + "@pulumi/docker-build": "^0.0.14", + "@pulumi/pulumi": "^3.142.0", + "@types/aws-lambda": "^8.10.23", + "docker-classic": "npm:@pulumi/docker@3.6.1", + "mime": "^2.0.0" + } + }, + "node_modules/@pulumi/awsx-v3/node_modules/@pulumi/aws": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@pulumi/aws/-/aws-7.15.0.tgz", + "integrity": "sha512-teHcWfllICarutAQq7ix2X5PFaNCXBe+vQmnR6Efnq/PkZcXbVq+6eLmSFkjuYboa3nIb2YUzG9ic34hUmf79Q==", + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0", + "mime": "^2.0.0" + } + }, + "node_modules/@pulumi/awsx-v3/node_modules/@pulumi/docker-build": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@pulumi/docker-build/-/docker-build-0.0.14.tgz", + "integrity": "sha512-dLCta3BOrYRxHyp22QnVh06qlSiSORtmIypcQ6yb4+GZ2+ThbML06pINyERc5ClgJXGQFSVTtuWhMSiurTWU2w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@pulumi/pulumi": "^3.142.0" + } + }, "node_modules/@pulumi/docker": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/@pulumi/docker/-/docker-4.6.2.tgz", diff --git a/package.json b/package.json index a0cd0574..bf08cb41 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,9 @@ "prettier": "@studion/prettier-config", "dependencies": { "@pulumi/aws": "^6.66.3", + "@pulumi/aws-v7": "npm:@pulumi/aws@^7.15.0", "@pulumi/awsx": "^2.21.0", + "@pulumi/awsx-v3": "npm:@pulumi/awsx@^3.1.0", "@pulumi/pulumi": "^3.146.0", "@pulumi/random": "^4.17.0", "@pulumiverse/grafana": "^0.16.3", From 7ab813a30414a99ed885876c510bc874377c0aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 6 Jan 2026 14:07:36 +0100 Subject: [PATCH 25/31] Replace all occurrences of project with vpc component --- src/v2/components/acm-certificate/index.ts | 2 +- src/v2/components/ecs-service/index.ts | 4 +-- src/v2/components/ecs-service/policies.ts | 2 +- src/v2/components/redis/elasticache-redis.ts | 4 +-- src/v2/components/vpc/index.ts | 4 +-- src/v2/components/web-server/builder.ts | 2 +- src/v2/components/web-server/index.ts | 4 +-- src/v2/components/web-server/load-balancer.ts | 4 +-- src/v2/otel/builder.ts | 2 +- src/v2/otel/index.ts | 2 +- tests/acm-certificate/infrastructure/index.ts | 2 +- tests/build/index.tst.ts | 4 +-- tests/ecs-service/index.test.ts | 8 ++--- tests/ecs-service/infrastructure/index.ts | 30 +++++++++---------- tests/ecs-service/persistent-storage.test.ts | 4 +-- tests/redis/elasticache-redis.test.ts | 8 ++--- tests/redis/infrastructure/index.ts | 16 +++++----- tests/web-server/infrastructure/index.ts | 15 +++++----- 18 files changed, 58 insertions(+), 59 deletions(-) diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index 76ed8c05..00f3945d 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import { commonTags } from '../../../constants'; export namespace AcmCertificate { diff --git a/src/v2/components/ecs-service/index.ts b/src/v2/components/ecs-service/index.ts index 33566fd8..4f792dd2 100644 --- a/src/v2/components/ecs-service/index.ts +++ b/src/v2/components/ecs-service/index.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; -import * as awsx from '@pulumi/awsx'; +import * as aws from '@pulumi/aws-v7'; +import * as awsx from '@pulumi/awsx-v3'; import { CustomSize, Size } from '../../../types/size'; import { PredefinedSize, commonTags } from '../../../constants'; import { assumeRolePolicy } from './policies'; diff --git a/src/v2/components/ecs-service/policies.ts b/src/v2/components/ecs-service/policies.ts index 294a9394..49c89142 100644 --- a/src/v2/components/ecs-service/policies.ts +++ b/src/v2/components/ecs-service/policies.ts @@ -1,4 +1,4 @@ -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; export const assumeRolePolicy: aws.iam.PolicyDocument = { Version: '2012-10-17', diff --git a/src/v2/components/redis/elasticache-redis.ts b/src/v2/components/redis/elasticache-redis.ts index 84b6035b..48049197 100644 --- a/src/v2/components/redis/elasticache-redis.ts +++ b/src/v2/components/redis/elasticache-redis.ts @@ -1,6 +1,6 @@ -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import * as pulumi from '@pulumi/pulumi'; -import * as awsx from '@pulumi/awsx'; +import * as awsx from '@pulumi/awsx-v3'; import { commonTags } from '../../../constants'; type RedisArgs = { diff --git a/src/v2/components/vpc/index.ts b/src/v2/components/vpc/index.ts index 51aa4e92..73c7ceac 100644 --- a/src/v2/components/vpc/index.ts +++ b/src/v2/components/vpc/index.ts @@ -1,7 +1,7 @@ import * as pulumi from '@pulumi/pulumi'; -import * as awsx from '@pulumi/awsx'; +import * as awsx from '@pulumi/awsx-v3'; import { commonTags } from '../../../constants'; -import { enums } from '@pulumi/awsx/types'; +import { enums } from '@pulumi/awsx-v3/types'; export type VpcArgs = { /** diff --git a/src/v2/components/web-server/builder.ts b/src/v2/components/web-server/builder.ts index 5a7bbf9b..dbd92773 100644 --- a/src/v2/components/web-server/builder.ts +++ b/src/v2/components/web-server/builder.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi'; -import * as awsx from '@pulumi/awsx'; +import * as awsx from '@pulumi/awsx-v3'; import { EcsService } from '../ecs-service'; import { WebServer } from '.'; import { OtelCollector } from '../../otel'; diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index bb13d64e..6b0f46eb 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; -import * as awsx from '@pulumi/awsx'; +import * as aws from '@pulumi/aws-v7'; +import * as awsx from '@pulumi/awsx-v3'; import { commonTags } from '../../../constants'; import { AcmCertificate } from '../acm-certificate'; import { EcsService } from '../ecs-service'; diff --git a/src/v2/components/web-server/load-balancer.ts b/src/v2/components/web-server/load-balancer.ts index 2d2a10c5..92857a26 100644 --- a/src/v2/components/web-server/load-balancer.ts +++ b/src/v2/components/web-server/load-balancer.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; -import * as awsx from '@pulumi/awsx'; +import * as aws from '@pulumi/aws-v7'; +import * as awsx from '@pulumi/awsx-v3'; import { commonTags } from '../../../constants'; export namespace WebServerLoadBalancer { diff --git a/src/v2/otel/builder.ts b/src/v2/otel/builder.ts index fc63b7ad..25283434 100644 --- a/src/v2/otel/builder.ts +++ b/src/v2/otel/builder.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import * as batchProcessor from './batch-processor'; import * as memoryLimiterProcessor from './memory-limiter-processor'; import { OtelCollector } from '.'; diff --git a/src/v2/otel/index.ts b/src/v2/otel/index.ts index 94bd878d..c748e820 100644 --- a/src/v2/otel/index.ts +++ b/src/v2/otel/index.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi'; -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import * as yaml from 'yaml'; import { EcsService } from '../components/ecs-service'; import { OTLPReceiver } from './otlp-receiver'; diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index c501aa6c..f3984fe8 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -1,5 +1,5 @@ import { next as studion } from '@studion/infra-code-blocks'; -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; const appName = 'acm-certificate-test'; diff --git a/tests/build/index.tst.ts b/tests/build/index.tst.ts index 6406fec9..c6f0a196 100644 --- a/tests/build/index.tst.ts +++ b/tests/build/index.tst.ts @@ -1,5 +1,5 @@ -import * as aws from '@pulumi/aws'; -import * as awsx from '@pulumi/awsx'; +import * as aws from '@pulumi/aws-v7'; +import * as awsx from '@pulumi/awsx-v3'; import { describe, expect, it } from 'tstyche'; import { next as studion } from '@studion/infra-code-blocks'; import { OtelCollector } from '../../dist/v2/otel'; diff --git a/tests/ecs-service/index.test.ts b/tests/ecs-service/index.test.ts index 3efede1b..d7102e0d 100644 --- a/tests/ecs-service/index.test.ts +++ b/tests/ecs-service/index.test.ts @@ -240,7 +240,7 @@ describe('EcsService component deployment', () => { it('should have security group with proper rules', async () => { const ecsService = ctx.outputs.minimalEcsService.value; - const project = ctx.outputs.project.value; + const vpc = ctx.outputs.vpc.value; assert.ok( ecsService.securityGroups.length > 0, 'Should have security groups', @@ -248,7 +248,7 @@ describe('EcsService component deployment', () => { const sg = ecsService.securityGroups[0]; assert.ok( - sg.ingress[0].cidrBlocks.includes(project.vpc.vpc.cidrBlock), + sg.ingress[0].cidrBlocks.includes(vpc.vpc.vpc.cidrBlock), 'Ingress rule should allow traffic from VPC CIDR', ); assert.strictEqual( @@ -260,14 +260,14 @@ describe('EcsService component deployment', () => { it('should create security group in the correct VPC', async () => { const ecsService = ctx.outputs.minimalEcsService.value; - const project = ctx.outputs.project.value; + const vpc = ctx.outputs.vpc.value; assert.ok( ecsService.securityGroups.length > 0, 'Should have security groups', ); const sg = ecsService.securityGroups[0]; - const expectedVpcId = project.vpc.vpcId; + const expectedVpcId = vpc.vpc.vpcId; assert.strictEqual( sg.vpcId, diff --git a/tests/ecs-service/infrastructure/index.ts b/tests/ecs-service/infrastructure/index.ts index 6c4209e4..3d25e1c0 100644 --- a/tests/ecs-service/infrastructure/index.ts +++ b/tests/ecs-service/infrastructure/index.ts @@ -1,6 +1,6 @@ -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import * as pulumi from '@pulumi/pulumi'; -import { Project, next as studion } from '@studion/infra-code-blocks'; +import { next as studion } from '@studion/infra-code-blocks'; const appName = 'ecs-test'; const stackName = pulumi.getStack(); @@ -15,7 +15,7 @@ const sampleServiceContainer = { portMappings: [studion.EcsService.createTcpPortMapping(appPort)], }; -const project = new Project(appName, { services: [] }); +const vpc = new studion.Vpc(`${appName}-vpc`, {}); const cluster = new aws.ecs.Cluster( `${appName}-cluster`, @@ -23,18 +23,18 @@ const cluster = new aws.ecs.Cluster( name: `${appName}-cluster-${stackName}`, tags, }, - { parent: project }, + { parent: vpc }, ); const minimalEcsService = new studion.EcsService(`${appName}-min`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], tags, }); const lbSecurityGroup = new aws.ec2.SecurityGroup(`${appName}-lb-sg`, { - vpcId: project.vpc.vpcId, + vpcId: vpc.vpc.vpcId, ingress: [ { protocol: 'tcp', @@ -58,7 +58,7 @@ const lb = new aws.lb.LoadBalancer(`${appName}-lb`, { internal: false, loadBalancerType: 'application', securityGroups: [lbSecurityGroup.id], - subnets: project.vpc.publicSubnetIds, + subnets: vpc.vpc.publicSubnetIds, tags, }); @@ -66,7 +66,7 @@ const targetGroup = new aws.lb.TargetGroup(`${appName}-tg`, { port: appPort, protocol: 'HTTP', targetType: 'ip', - vpcId: project.vpc.vpcId, + vpcId: vpc.vpc.vpcId, healthCheck: { path: '/', port: 'traffic-port', @@ -89,7 +89,7 @@ const listener = new aws.lb.Listener(`${appName}-listener`, { const ecsServiceWithLb = new studion.EcsService(`${appName}-lb`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], assignPublicIp: true, loadBalancers: [ @@ -106,7 +106,7 @@ const lbUrl = pulumi.interpolate`http://${lb.dnsName}`; const ecsWithDiscovery = new studion.EcsService(`${appName}-sd`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], enableServiceAutoDiscovery: true, tags, @@ -116,7 +116,7 @@ const ecsServiceWithAutoscaling = new studion.EcsService( `${appName}-autoscale`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], autoscaling: { enabled: true, @@ -129,7 +129,7 @@ const ecsServiceWithAutoscaling = new studion.EcsService( const ecsServiceWithStorage = new studion.EcsService(`${appName}-storage`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, volumes: [{ name: 'data-volume' }], containers: [ { @@ -166,7 +166,7 @@ const ecsServiceWithEmptyVolumes = new studion.EcsService( `${appName}-empty-vol`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], volumes: [], tags, @@ -177,7 +177,7 @@ const ecsServiceWithOutputEmptyVolumes = new studion.EcsService( `${appName}-empty-otp-vol`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [sampleServiceContainer], volumes: pulumi.output([]), tags, @@ -185,7 +185,7 @@ const ecsServiceWithOutputEmptyVolumes = new studion.EcsService( ); module.exports = { - project, + vpc, cluster, minimalEcsService, ecsServiceWithLb, diff --git a/tests/ecs-service/persistent-storage.test.ts b/tests/ecs-service/persistent-storage.test.ts index 03377343..ff0ed49f 100644 --- a/tests/ecs-service/persistent-storage.test.ts +++ b/tests/ecs-service/persistent-storage.test.ts @@ -51,7 +51,7 @@ export function testEcsServiceWithStorage(ctx: EcsTestContext) { it('should create security group for EFS with correct rules', async () => { const ecsServiceWithStorage = ctx.outputs.ecsServiceWithStorage.value; - const vpc = ctx.outputs.project.value.vpc; + const vpc = ctx.outputs.vpc.value.vpc; const describeMountTargetsCommand = new DescribeMountTargetsCommand({ FileSystemId: ecsServiceWithStorage.persistentStorage.fileSystem.id, @@ -119,7 +119,7 @@ export function testEcsServiceWithStorage(ctx: EcsTestContext) { it('should create mount targets in all private subnets', async () => { const ecsServiceWithStorage = ctx.outputs.ecsServiceWithStorage.value; - const vpc = ctx.outputs.project.value.vpc; + const vpc = ctx.outputs.vpc.value.vpc; const command = new DescribeMountTargetsCommand({ FileSystemId: ecsServiceWithStorage.persistentStorage.fileSystem.id, diff --git a/tests/redis/elasticache-redis.test.ts b/tests/redis/elasticache-redis.test.ts index b1171e4c..57823848 100644 --- a/tests/redis/elasticache-redis.test.ts +++ b/tests/redis/elasticache-redis.test.ts @@ -104,7 +104,7 @@ export function testElastiCacheRedis(ctx: RedisTestContext) { it('should create a subnet group in the correct VPC', async () => { const redis = ctx.outputs.elastiCacheRedis.value; - const project = ctx.outputs.project.value; + const vpc = ctx.outputs.vpc.value; const subnetGroupName = redis.subnetGroup.name; const command = new DescribeCacheSubnetGroupsCommand({ @@ -118,7 +118,7 @@ export function testElastiCacheRedis(ctx: RedisTestContext) { const [subnetGroup] = CacheSubnetGroups; assert.strictEqual( subnetGroup.VpcId, - project.vpc.vpcId, + vpc.vpc.vpcId, 'Subnet group should be in the correct VPC', ); assert.ok( @@ -129,7 +129,7 @@ export function testElastiCacheRedis(ctx: RedisTestContext) { it('should create a security group with correct ingress rules', async () => { const redis = ctx.outputs.elastiCacheRedis.value; - const project = ctx.outputs.project.value; + const vpc = ctx.outputs.vpc.value; const sgId = redis.securityGroup.id; const command = new DescribeSecurityGroupsCommand({ @@ -143,7 +143,7 @@ export function testElastiCacheRedis(ctx: RedisTestContext) { const [securityGroup] = SecurityGroups; assert.strictEqual( securityGroup.VpcId, - project.vpc.vpcId, + vpc.vpc.vpcId, 'Security group should be in the correct VPC', ); diff --git a/tests/redis/infrastructure/index.ts b/tests/redis/infrastructure/index.ts index 799dce8a..edf4808e 100644 --- a/tests/redis/infrastructure/index.ts +++ b/tests/redis/infrastructure/index.ts @@ -1,7 +1,7 @@ -import * as aws from '@pulumi/aws'; +import * as aws from '@pulumi/aws-v7'; import * as pulumi from '@pulumi/pulumi'; import * as upstash from '@upstash/pulumi'; -import { Project, next as studion } from '@studion/infra-code-blocks'; +import { next as studion } from '@studion/infra-code-blocks'; const appName = 'redis-test'; const stackName = pulumi.getStack(); @@ -10,17 +10,17 @@ const tags = { Environment: stackName, }; -const project = new Project(appName, { services: [] }); +const vpc = new studion.Vpc(`${appName}-vpc`, {}); const defaultElastiCacheRedis = new studion.ElastiCacheRedis( `${appName}-default-elasticache`, - { vpc: project.vpc }, + { vpc: vpc.vpc }, ); const elastiCacheRedis = new studion.ElastiCacheRedis( `${appName}-elasticache`, { - vpc: project.vpc, + vpc: vpc.vpc, engineVersion: '6.x', nodeType: 'cache.t4g.micro', parameterGroupName: 'default.redis6.x', @@ -34,7 +34,7 @@ const cluster = new aws.ecs.Cluster( name: `${appName}-cluster-${stackName}`, tags, }, - { parent: project }, + { parent: vpc }, ); const testClientContainer = { @@ -97,7 +97,7 @@ const testClientContainer = { const testClient = new studion.EcsService(`${appName}-ec-client`, { cluster, - vpc: project.vpc, + vpc: vpc.vpc, containers: [testClientContainer], assignPublicIp: false, }); @@ -121,7 +121,7 @@ if (upstashEmail && upstashApiKey) { } module.exports = { - project, + vpc, defaultElastiCacheRedis, elastiCacheRedis, cluster, diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index edc9ddf6..fb9851e6 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -1,5 +1,5 @@ -import { Project, next as studion } from '@studion/infra-code-blocks'; -import * as aws from '@pulumi/aws'; +import { next as studion } from '@studion/infra-code-blocks'; +import * as aws from '@pulumi/aws-v7'; import * as pulumi from '@pulumi/pulumi'; import { webServerName, @@ -10,7 +10,7 @@ import { } from './config'; const stackName = pulumi.getStack(); -const project: Project = new Project(webServerName, { services: [] }); +const vpc = new studion.Vpc(`${webServerName}-vpc`, {}); const tags = { Env: stackName, Project: webServerName }; const init = { name: 'init', @@ -56,7 +56,7 @@ const webServer = new studion.WebServerBuilder(webServerName) .configureEcs(ecs) .withInitContainer(init) .withSidecarContainer(sidecar) - .withVpc(project.vpc) + .withVpc(vpc.vpc) .withOtelCollector(otelCollector) .withCustomHealthCheckPath(healthCheckPath) .withLoadBalancingAlgorithm('least_outstanding_requests') @@ -70,7 +70,7 @@ const hostedZone = aws.route53.getZoneOutput({ const webServerWithDomain = new studion.WebServerBuilder(`web-server-domain`) .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) .configureEcs(ecs) - .withVpc(project.vpc) + .withVpc(vpc.vpc) .withCustomHealthCheckPath(healthCheckPath) .withCustomDomain(webServerWithDomainConfig.primary, hostedZone.zoneId) .build({ parent: cluster }); @@ -88,7 +88,7 @@ const webServerWithSanCertificate = new studion.WebServerBuilder( ) .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) .configureEcs(ecs) - .withVpc(project.vpc) + .withVpc(vpc.vpc) .withCustomHealthCheckPath(healthCheckPath) .withCertificate(sanWebServerCert, hostedZone.zoneId) .build({ parent: cluster }); @@ -101,7 +101,7 @@ const certWebServer = new studion.AcmCertificate(`${webServerName}-cert`, { const webServerWithCertificate = new studion.WebServerBuilder(`web-server-cert`) .configureWebServer('nginxdemos/nginx-hello:plain-text', 8080) .configureEcs(ecs) - .withVpc(project.vpc) + .withVpc(vpc.vpc) .withCustomHealthCheckPath(healthCheckPath) .withCertificate( certWebServer, @@ -111,7 +111,6 @@ const webServerWithCertificate = new studion.WebServerBuilder(`web-server-cert`) .build({ parent: cluster }); export { - project, webServer, otelCollector, webServerWithSanCertificate, From 58c134c542cd71426b146f59c8128964cf3ca81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Tue, 6 Jan 2026 14:51:46 +0100 Subject: [PATCH 26/31] Add region arg to certificate component --- src/v2/components/acm-certificate/index.ts | 2 ++ tests/acm-certificate/infrastructure/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index 00f3945d..fc6143c5 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -10,6 +10,7 @@ export namespace AcmCertificate { */ subjectAlternativeNames?: pulumi.Input[]; hostedZoneId: pulumi.Input; + region?: pulumi.Input; }; } @@ -29,6 +30,7 @@ export class AcmCertificate extends pulumi.ComponentResource { domainName: args.domain, subjectAlternativeNames: args.subjectAlternativeNames, validationMethod: 'DNS', + region: args.region, tags: commonTags, }, { parent: this }, diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index f3984fe8..f7da7fd0 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -12,6 +12,7 @@ const domainName = process.env.ICB_DOMAIN_NAME!; const certificate = new studion.AcmCertificate(`${appName}-certificate`, { domain: domainName, hostedZoneId: hostedZone.zoneId, + region: process.env.AWS_REGION, }); const subDomainName = `app.${domainName}`; From 4a786f51f334a0ab508846d618eba2be15fba396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 9 Jan 2026 13:18:55 +0100 Subject: [PATCH 27/31] Update load balancer listener ssl policy (#111) Update load balancer SSL policy to `ELBSecurityPolicy-TLS13-1-2-2021-06` (enforces TLS 1.2+/TLS 1.3, modern ciphers, improved security and performance). --- src/v2/components/web-server/load-balancer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v2/components/web-server/load-balancer.ts b/src/v2/components/web-server/load-balancer.ts index 2d2a10c5..a5369fef 100644 --- a/src/v2/components/web-server/load-balancer.ts +++ b/src/v2/components/web-server/load-balancer.ts @@ -111,7 +111,7 @@ export class WebServerLoadBalancer extends pulumi.ComponentResource { loadBalancerArn: lb.arn, port: 443, protocol: 'HTTPS', - sslPolicy: 'ELBSecurityPolicy-2016-08', + sslPolicy: 'ELBSecurityPolicy-TLS13-1-2-2021-06', certificateArn: certificate.arn, defaultActions: [ { From 18fa3b62128046776b9f6476d16efb52e6f645fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Fri, 9 Jan 2026 17:27:02 +0100 Subject: [PATCH 28/31] Move setup to the top-level to prevent false positives --- tests/acm-certificate/index.test.ts | 44 +++++++++++++---------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index adbe9636..364aba80 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -11,6 +11,7 @@ import { 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', @@ -18,33 +19,28 @@ const programArgs: InlineProgramArgs = { 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 region = requireEnv('AWS_REGION'); +const domainName = requireEnv('ICB_DOMAIN_NAME'); +const hostedZoneId = requireEnv('ICB_HOSTED_ZONE_ID'); - 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 }), +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 }), + }, +}; +describe('ACM Certificate component deployment', () => { before(async () => { ctx.outputs = await automation.deploy(programArgs); }); From f91a03c87605dae00549c76d0d41b5802fdf94a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 14 Jan 2026 15:38:53 +0100 Subject: [PATCH 29/31] test: add region certificate test --- src/v2/components/acm-certificate/index.ts | 8 ++++++- tests/acm-certificate/index.test.ts | 21 ++++++++++++++++++- .../acm-certificate/infrastructure/config.ts | 2 ++ tests/acm-certificate/infrastructure/index.ts | 9 +++++++- tests/acm-certificate/test-context.ts | 1 + 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 tests/acm-certificate/infrastructure/config.ts diff --git a/src/v2/components/acm-certificate/index.ts b/src/v2/components/acm-certificate/index.ts index fc6143c5..89906eec 100644 --- a/src/v2/components/acm-certificate/index.ts +++ b/src/v2/components/acm-certificate/index.ts @@ -36,7 +36,11 @@ export class AcmCertificate extends pulumi.ComponentResource { { parent: this }, ); - this.createCertValidationRecords(args.domain, args.hostedZoneId); + this.createCertValidationRecords( + args.domain, + args.hostedZoneId, + args.region, + ); this.registerOutputs(); } @@ -44,6 +48,7 @@ export class AcmCertificate extends pulumi.ComponentResource { private createCertValidationRecords( domainName: AcmCertificate.Args['domain'], hostedZoneId: AcmCertificate.Args['hostedZoneId'], + region: AcmCertificate.Args['region'], ) { this.certificate.domainValidationOptions.apply(domains => { const validationRecords = domains.map( @@ -69,6 +74,7 @@ export class AcmCertificate extends pulumi.ComponentResource { { certificateArn: this.certificate.arn, validationRecordFqdns: validationRecords.map(record => record.fqdn), + region, }, { parent: this }, ); diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 2b074948..96bb9160 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -1,3 +1,4 @@ +import { alternateRegion } from './infrastructure/config'; import * as assert from 'node:assert'; import * as automation from '../automation'; import { InlineProgramArgs } from '@pulumi/pulumi/automation'; @@ -37,6 +38,7 @@ const ctx: AcmCertificateTestContext = { }, clients: { acm: new ACMClient({ region }), + acmAlternateRegion: new ACMClient({ region: alternateRegion }), route53: new Route53Client({ region }), }, }; @@ -46,7 +48,7 @@ describe('ACM Certificate component deployment', () => { ctx.outputs = await automation.deploy(programArgs); }); - after(() => automation.destroy(programArgs)); + // after(() => automation.destroy(programArgs)); it('should create certificate with correct domain name', async () => { const certificate = ctx.outputs.certificate.value; @@ -139,4 +141,21 @@ describe('ACM Certificate component deployment', () => { 'Certificate should include all expected domains', ); }); + + it('should create certificate in correct region', async () => { + const certificate = ctx.outputs.regionCertificate.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.acmAlternateRegion.send( + new DescribeCertificateCommand({ + CertificateArn: certificate.certificate.arn, + }), + ); + + const cert = certResult.Certificate; + assert.ok(cert, 'Certificate should exist'); + }, ctx.config.exponentialBackOffConfig); + }); }); diff --git a/tests/acm-certificate/infrastructure/config.ts b/tests/acm-certificate/infrastructure/config.ts new file mode 100644 index 00000000..0ecee376 --- /dev/null +++ b/tests/acm-certificate/infrastructure/config.ts @@ -0,0 +1,2 @@ +export const alternateRegion = + process.env.AWS_REGION === 'eu-central-1' ? 'us-west-1' : 'eu-central-1'; diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index f7da7fd0..beebeedc 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -1,5 +1,6 @@ import { next as studion } from '@studion/infra-code-blocks'; import * as aws from '@pulumi/aws-v7'; +import { alternateRegion } from './config'; const appName = 'acm-certificate-test'; @@ -25,4 +26,10 @@ const sanCertificate = new studion.AcmCertificate( }, ); -export { certificate, sanCertificate, hostedZone }; +const regionCertificate = new studion.AcmCertificate(`${appName}-region-cert`, { + domain: `region.${domainName}`, + hostedZoneId: hostedZone.zoneId, + region: alternateRegion, +}); + +export { certificate, sanCertificate, hostedZone, regionCertificate }; diff --git a/tests/acm-certificate/test-context.ts b/tests/acm-certificate/test-context.ts index 7d4441a3..85e5a896 100644 --- a/tests/acm-certificate/test-context.ts +++ b/tests/acm-certificate/test-context.ts @@ -24,6 +24,7 @@ interface PulumiProgramContext { interface AwsContext { clients: { acm: ACMClient; + acmAlternateRegion: ACMClient; route53: Route53Client; }; } From 6d73cd51364267cc7a302cdcddfb4fdb8b45fc94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 14 Jan 2026 15:42:34 +0100 Subject: [PATCH 30/31] test: update test assertion --- tests/acm-certificate/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acm-certificate/index.test.ts b/tests/acm-certificate/index.test.ts index 96bb9160..2603a527 100644 --- a/tests/acm-certificate/index.test.ts +++ b/tests/acm-certificate/index.test.ts @@ -48,7 +48,7 @@ describe('ACM Certificate component deployment', () => { ctx.outputs = await automation.deploy(programArgs); }); - // after(() => automation.destroy(programArgs)); + after(() => automation.destroy(programArgs)); it('should create certificate with correct domain name', async () => { const certificate = ctx.outputs.certificate.value; @@ -142,7 +142,7 @@ describe('ACM Certificate component deployment', () => { ); }); - it('should create certificate in correct region', async () => { + it('should create certificate in alternate region', async () => { const certificate = ctx.outputs.regionCertificate.value; assert.ok(certificate.certificate, 'Should have certificate property'); assert.ok(certificate.certificate.arn, 'Certificate should have ARN'); From d040a217e0eace02bd4c336281fcdfc45744263e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Borna=20=C5=A0unji=C4=87?= Date: Wed, 14 Jan 2026 16:19:09 +0100 Subject: [PATCH 31/31] test: update test infrastructure args --- tests/acm-certificate/infrastructure/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/acm-certificate/infrastructure/index.ts b/tests/acm-certificate/infrastructure/index.ts index beebeedc..13b75c14 100644 --- a/tests/acm-certificate/infrastructure/index.ts +++ b/tests/acm-certificate/infrastructure/index.ts @@ -13,7 +13,6 @@ const domainName = process.env.ICB_DOMAIN_NAME!; const certificate = new studion.AcmCertificate(`${appName}-certificate`, { domain: domainName, hostedZoneId: hostedZone.zoneId, - region: process.env.AWS_REGION, }); const subDomainName = `app.${domainName}`; @@ -32,4 +31,4 @@ const regionCertificate = new studion.AcmCertificate(`${appName}-region-cert`, { region: alternateRegion, }); -export { certificate, sanCertificate, hostedZone, regionCertificate }; +export { certificate, sanCertificate, regionCertificate, hostedZone };