Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
56 changes: 56 additions & 0 deletions src/v2/components/acm-certificate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import { commonTags } from '../../../constants';

export namespace AcmCertificate {
export type Args = {
domain: pulumi.Input<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, 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();
}
}
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
122 changes: 122 additions & 0 deletions tests/acm-certificate/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as assert from 'node:assert';
import * as automation from '../automation';
import { InlineProgramArgs } from '@pulumi/pulumi/automation';
import { ACMClient } from '@aws-sdk/client-acm';
import { Route53Client } from '@aws-sdk/client-route-53';
import { backOff } from 'exponential-backoff';
import {
DescribeCertificateCommand,
CertificateType,
} from '@aws-sdk/client-acm';
import { ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53';
import { AcmCertificateTestContext } from './test-context';
import { describe, it, before, after } from 'node:test';

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

describe('ACM Certificate component deployment', () => {
const region = process.env.AWS_REGION;
const domainName = process.env.DOMAIN_NAME;
if (!region || !domainName) {
throw new Error(
'AWS_REGION and DOMAIN_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,
'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',
);
});
});
37 changes: 37 additions & 0 deletions tests/acm-certificate/infrastructure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 certificate = new studion.AcmCertificate(`${appName}-certificate`, {
domain: domainName,
hostedZoneId: hostedZone.zoneId,
});

module.exports = {
certificate,
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 {
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 {}