Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
12bd0dd
Create acm certificate v2 component
bornast Dec 5, 2025
b8b8d94
Implement acm certificate tests
bornast Dec 5, 2025
431039c
Add legacy prefix to certificate v1 component
bornast Dec 5, 2025
6249fa5
Add subjectAlternativeNames option arg
bornast Dec 9, 2025
710a102
Add test for cert with SANs
bornast Dec 9, 2025
1c98b9e
Add acm certificate namespace for types
bornast Dec 10, 2025
06184a8
Remove legacy prefix from v1 certificate component
bornast Dec 10, 2025
50ff252
Rename test assertions
bornast Dec 10, 2025
5082c38
Fallback to hosted zone id arg if zone is not found by domain name
bornast Dec 10, 2025
266a103
Merge branch 'task/certificate-v2-component' into task/certificate-v2…
bornast Dec 10, 2025
46ca7e8
Fix get zone method args
bornast Dec 10, 2025
80e5f92
Merge branch 'task/certificate-v2-component' into task/certificate-v2…
bornast Dec 10, 2025
9a82628
Refactor get zone method args
bornast Dec 10, 2025
359a94b
Add ICB prefix to env variables
bornast Dec 10, 2025
2497cc5
Merge branch 'task/certificate-v2-component' into task/certificate-v2…
bornast Dec 10, 2025
034c447
Fix method name typo
bornast Dec 10, 2025
e56724e
Export certificate using esmodule syntax
bornast Dec 16, 2025
5c53176
Merge branch task/certificate-v2-component into task/certificate-v2-s…
bornast Dec 16, 2025
fbf61a3
Merge branch 'master' into task/certificate-v2-component
bornast Jan 9, 2026
18fa3b6
Move setup to the top-level to prevent false positives
bornast Jan 9, 2026
ded476b
Merge branch 'task/certificate-v2-component' into task/certificate-v2…
bornast Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/v2/components/acm-certificate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import { commonTags } from '../../../constants';

export namespace AcmCertificate {
export type Args = {
domain: pulumi.Input<string>;
/**
* Additional domains/subdomains to be included in this certificate.
*/
subjectAlternativeNames?: pulumi.Input<string>[];
hostedZoneId: pulumi.Input<string>;
};
}

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

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

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

this.createCertValidationRecords(args.domain, args.hostedZoneId);

this.registerOutputs();
}

private createCertValidationRecords(
domainName: AcmCertificate.Args['domain'],
hostedZoneId: AcmCertificate.Args['hostedZoneId'],
) {
this.certificate.domainValidationOptions.apply(domains => {
const validationRecords = domains.map(
domain =>
new aws.route53.Record(
`${domain.domainName}-cert-validation-domain`,
{
name: domain.resourceRecordName,
type: domain.resourceRecordType,
zoneId: hostedZoneId,
records: [domain.resourceRecordValue],
ttl: 600,
},
{
parent: this,
deleteBeforeReplace: true,
},
),
);

const certificateValidation = new aws.acm.CertificateValidation(
`${domainName}-cert-validation`,
{
certificateArn: this.certificate.arn,
validationRecordFqdns: validationRecords.map(record => record.fqdn),
},
{ parent: this },
);
});
}
}
1 change: 1 addition & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { WebServerLoadBalancer } from './components/web-server/load-balancer';
export { ElastiCacheRedis } from './components/redis/elasticache-redis';
export { UpstashRedis } from './components/redis/upstash-redis';
export { Vpc } from './components/vpc';
export { AcmCertificate } from './components/acm-certificate';

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

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

const region = requireEnv('AWS_REGION');
const domainName = requireEnv('ICB_DOMAIN_NAME');
const hostedZoneId = requireEnv('ICB_HOSTED_ZONE_ID');

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

describe('ACM Certificate component deployment', () => {
before(async () => {
ctx.outputs = await automation.deploy(programArgs);
});

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

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

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

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

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

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

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

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

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

assert.ok(validationRecord, 'Validation record should exist');
assert.strictEqual(
validationRecord.TTL,
600,
'Validation record should have 600 TTL',
);
assert.strictEqual(
validationRecord.ResourceRecords?.[0]?.Value,
domainValidation.ResourceRecord?.Value,
'Validation record should have correct value',
);
});

it('should create certificate with subject alternative names', async () => {
const sanCertificate = ctx.outputs.sanCertificate.value;
const certResult = await ctx.clients.acm.send(
new DescribeCertificateCommand({
CertificateArn: sanCertificate.certificate.arn,
}),
);
const cert = certResult.Certificate;
const sans = cert?.SubjectAlternativeNames || [];

const expectedDomains = [
ctx.config.subDomainName,
`api.${ctx.config.subDomainName}`,
`test.${ctx.config.subDomainName}`,
];

expectedDomains.forEach(expectedDomain => {
assert.ok(
sans.includes(expectedDomain),
`Certificate should include: ${expectedDomain}`,
);
});
});
});
27 changes: 27 additions & 0 deletions tests/acm-certificate/infrastructure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { next as studion } from '@studion/infra-code-blocks';
import * as aws from '@pulumi/aws';

const appName = 'acm-certificate-test';

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

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

const subDomainName = `app.${domainName}`;
const sanCertificate = new studion.AcmCertificate(
`${appName}-certificate-san`,
{
domain: subDomainName,
subjectAlternativeNames: [`api.${subDomainName}`, `test.${subDomainName}`],
hostedZoneId: hostedZone.zoneId,
},
);

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

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

interface ConfigContext {
config: AcmCertificateTestConfig;
}

interface PulumiProgramContext {
outputs: OutputMap;
}

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

export interface AcmCertificateTestContext
extends ConfigContext,
PulumiProgramContext,
AwsContext {}