Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 2 additions & 5 deletions src/v2/components/cloudfront/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class CloudFront extends pulumi.ComponentResource {
this.name = name;

const { behaviors, domain, certificate, hostedZoneId, tags } = args;
const hasCustomDomain = domain || certificate;
const hasCustomDomain = !!domain || !!certificate;

if (hasCustomDomain && !hostedZoneId) {
throw new Error(
Expand Down Expand Up @@ -225,10 +225,7 @@ export class CloudFront extends pulumi.ComponentResource {
certificate.domainName,
certificate.subjectAlternativeNames,
])
.apply(([domain, alternativeDomains]) => [
domain,
...alternativeDomains,
]),
.apply(([dn, sans = []]) => [...new Set([dn, ...sans])]),
viewerCertificate: {
acmCertificateArn: certificate.arn,
sslSupportMethod: 'sni-only',
Expand Down
4 changes: 2 additions & 2 deletions src/v2/components/web-server/builder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws-v7';
import * as awsx from '@pulumi/awsx-v3';
import { EcsService } from '../ecs-service';
import { WebServer } from '.';
import { OtelCollector } from '../../otel';
import { AcmCertificate } from '../acm-certificate';

export namespace WebServerBuilder {
export type EcsConfig = Omit<WebServer.EcsConfig, 'vpc' | 'volumes'>;
Expand All @@ -26,7 +26,7 @@ export class WebServerBuilder {
private _ecsConfig?: WebServerBuilder.EcsConfig;
private _domain?: pulumi.Input<string>;
private _hostedZoneId?: pulumi.Input<string>;
private _certificate?: pulumi.Input<AcmCertificate>;
private _certificate?: pulumi.Input<aws.acm.Certificate>;
private _healthCheckPath?: pulumi.Input<string>;
private _loadBalancingAlgorithmType?: pulumi.Input<string>;
private _otelCollector?: pulumi.Input<OtelCollector>;
Expand Down
110 changes: 49 additions & 61 deletions src/v2/components/web-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,33 @@ export namespace WebServer {
export type Args = EcsConfig &
Container & {
/**
* The domain which will be used to access the service.
* The domain or subdomain must belong to the provided hostedZone.
* Domain name for CloudFront distribution. Implies creation of certificate
* and alias record. Must belong to the provided hosted zone.
* Providing the `certificate` argument has following effects:
* - Certificate creation is skipped
* - Provided certificate must cover the domain name
* Responsibility to ensure mentioned requirements in on the consumer, and
* falling to do so will result in unexpected behavior.
*/
domain?: pulumi.Input<string>;
hostedZoneId?: pulumi.Input<string>;
/**
* 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 for CloudFront distribution. Domain and alternative domains
* are automatically pulled from the certificate and translated into alias
* records. Domains covered by the certificate, must belong to the provided
* hosted zone. The certificate must be in `us-east-1` region. In a case
* of wildcard certificate the `domain` argument is required.
* Providing the `domain` argument has following effects:
* - Alias records creation, from automatically pulled domains, is skipped
* - Certificate must cover the provided domain name
* Responsibility to ensure mentioned requirements in on the consumer, and
* falling to do so will result in unexpected behavior.
*/
certificate?: pulumi.Input<AcmCertificate>;
certificate?: pulumi.Input<aws.acm.Certificate>;
/**
* ID of hosted zone is needed when the `domain` or the `certificate`
* arguments are provided.
*/
hostedZoneId?: pulumi.Input<string>;
/**
* Path for the load balancer target group health check request.
*
Expand All @@ -76,7 +92,7 @@ export class WebServer extends pulumi.ComponentResource {
initContainers?: pulumi.Output<EcsService.Container[]>;
sidecarContainers?: pulumi.Output<EcsService.Container[]>;
volumes?: pulumi.Output<EcsService.PersistentStorageVolume[]>;
certificate?: pulumi.Output<AcmCertificate>;
acmCertificate?: AcmCertificate;
dnsRecords?: pulumi.Output<aws.route53.Record[]>;

constructor(
Expand All @@ -86,20 +102,16 @@ export class WebServer extends pulumi.ComponentResource {
) {
super('studion:WebServer', name, args, opts);
const { vpc, domain, hostedZoneId, certificate } = args;
const hasCustomDomain = !!domain || !!certificate;

if ((domain || certificate) && !hostedZoneId) {
if (hasCustomDomain && !hostedZoneId) {
throw new Error(
'HostedZoneId must be provided when domain or certificate are provided',
'Provide `hostedZoneId` alongside `domain` and/or `certificate`.',
);
}

const hasCustomDomain = !!domain && !!hostedZoneId;
if (certificate) {
this.certificate = pulumi.output(certificate);
} else if (hasCustomDomain) {
this.certificate = pulumi.output(
this.createTlsCertificate({ domain, hostedZoneId }),
);
if (domain && hostedZoneId && !certificate) {
this.acmCertificate = this.createTlsCertificate({ domain, hostedZoneId });
}

this.name = name;
Expand All @@ -108,11 +120,16 @@ export class WebServer extends pulumi.ComponentResource {
{
vpc,
port: args.port,
certificate: this.certificate?.certificate,
certificate: certificate ?? this.acmCertificate?.certificate,
healthCheckPath: args.healthCheckPath,
loadBalancingAlgorithmType: args.loadBalancingAlgorithmType,
},
{ parent: this },
{
parent: this,
...(this.acmCertificate
? { dependsOn: [this.acmCertificate.certificateValidation] }
: undefined),
},
);
this.serviceSecurityGroup = this.createSecurityGroup(vpc);

Expand All @@ -131,10 +148,10 @@ export class WebServer extends pulumi.ComponentResource {
this.sidecarContainers,
);

if (this.certificate) {
if (hasCustomDomain && hostedZoneId) {
this.dnsRecords = this.createDnsRecords(
this.certificate,
hostedZoneId!,
certificate ?? this.acmCertificate!.certificate,
hostedZoneId,
domain,
);
}
Expand Down Expand Up @@ -329,49 +346,22 @@ export class WebServer extends pulumi.ComponentResource {
}

private createDnsRecords(
certificate: pulumi.Output<AcmCertificate>,
certificate: pulumi.Input<aws.acm.Certificate>,
hostedZoneId: pulumi.Input<string>,
domain?: pulumi.Input<string>,
): pulumi.Output<aws.route53.Record[]> {
if (domain) {
const record = 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 },
);
const certOutput = pulumi.output(certificate);

return pulumi.output([record]);
}

const records = pulumi
.all([
certificate.certificate.domainName,
certificate.certificate.subjectAlternativeNames,
])
.apply(([primaryDomain, sans]) => {
const allDomains = [
primaryDomain,
...(sans || []).filter(san => san !== primaryDomain),
];

return allDomains.map(
(domain, index) =>
return pulumi
.all([domain, certOutput.domainName, certOutput.subjectAlternativeNames])
.apply(([domain, certDomain, certSans = []]) =>
(domain ? [domain] : [...new Set([certDomain, ...certSans])]).map(
(alias, index) =>
new aws.route53.Record(
`${this.name}-route53-record${index === 0 ? '' : `-${index}`}`,
{
type: 'A',
name: domain,
name: alias,
zoneId: hostedZoneId,
aliases: [
{
Expand All @@ -383,9 +373,7 @@ export class WebServer extends pulumi.ComponentResource {
},
{ parent: this },
),
);
});

return records;
),
);
}
}
32 changes: 26 additions & 6 deletions tests/web-server/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ 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);

assert.ok(webServer.acmCertificate, 'Certificate should be created');
await assertHttpsListenerWithCertificate(
ctx,
webServer,
webServer.acmCertificate.certificate.arn,
);
});

it('should create single DNS A record for web server with custom domain', async () => {
Expand All @@ -33,7 +39,14 @@ export function testWebServerWithDomain(ctx: WebServerTestContext) {

it('should configure HTTPS listener with certificate for web server with SAN certificate', async () => {
const webServer = ctx.outputs.webServerWithSanCertificate.value;
await assertHttpsListenerWithCertificate(ctx, webServer);
const certificate = ctx.outputs.sanWebServerCert.value;

assert.ok(!webServer.acmCertificate, 'Certificate should not be created');
await assertHttpsListenerWithCertificate(
ctx,
webServer,
certificate.certificate.arn,
);
});

it('should create DNS records for primary domain and all SANs', async () => {
Expand Down Expand Up @@ -65,7 +78,14 @@ export function testWebServerWithDomain(ctx: WebServerTestContext) {

it('should configure HTTPS listener with certificate for web server', async () => {
const webServer = ctx.outputs.webServerWithCertificate.value;
await assertHttpsListenerWithCertificate(ctx, webServer);
const certificate = ctx.outputs.certWebServer.value;

assert.ok(!webServer.acmCertificate, 'Certificate should not be created');
await assertHttpsListenerWithCertificate(
ctx,
webServer,
certificate.certificate.arn,
);
});

it('should create DNS record only for specified domain in web server with certificate', async () => {
Expand All @@ -90,8 +110,8 @@ export function testWebServerWithDomain(ctx: WebServerTestContext) {
async function assertHttpsListenerWithCertificate(
ctx: WebServerTestContext,
webServer: any,
certificateArn: string,
) {
assert.ok(webServer.certificate, 'Certificate should be configured');
assert.ok(webServer.lb.tlsListener, 'TLS listener should exist');

const command = new DescribeListenersCommand({
Expand All @@ -113,10 +133,10 @@ async function assertHttpsListenerWithCertificate(
'Listener protocol should be HTTPS',
);

const certificateArn = listener.Certificates?.[0]?.CertificateArn;
const [{ CertificateArn }] = listener.Certificates!;
assert.strictEqual(
CertificateArn,
certificateArn,
webServer.certificate.certificate.arn,
'Certificate ARN should match the configured certificate',
);
}
Expand Down
13 changes: 9 additions & 4 deletions tests/web-server/infrastructure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ const webServerWithSanCertificate = new studion.WebServerBuilder(
.configureEcs(ecs)
.withVpc(vpc.vpc)
.withCustomHealthCheckPath(healthCheckPath)
.withCertificate(sanWebServerCert, hostedZone.zoneId)
.build({ parent: cluster });
.withCertificate(sanWebServerCert.certificate, hostedZone.zoneId)
.build({
parent: cluster,
dependsOn: [sanWebServerCert.certificateValidation],
});

const certWebServer = new studion.AcmCertificate(
`${webServerName}-cert`,
Expand All @@ -119,16 +122,18 @@ const webServerWithCertificate = new studion.WebServerBuilder(`web-server-cert`)
.withVpc(vpc.vpc)
.withCustomHealthCheckPath(healthCheckPath)
.withCertificate(
certWebServer,
certWebServer.certificate,
hostedZone.zoneId,
webServerWithCertificateConfig.primary,
)
.build({ parent: cluster });
.build({ parent: cluster, dependsOn: [certWebServer.certificateValidation] });

export {
webServer,
otelCollector,
sanWebServerCert,
webServerWithSanCertificate,
certWebServer,
webServerWithCertificate,
webServerWithDomain,
};