diff --git a/src/v2/components/cloudfront/index.ts b/src/v2/components/cloudfront/index.ts index d797d73..e44dc5e 100644 --- a/src/v2/components/cloudfront/index.ts +++ b/src/v2/components/cloudfront/index.ts @@ -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( @@ -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', diff --git a/src/v2/components/web-server/builder.ts b/src/v2/components/web-server/builder.ts index dbd9277..9ecb248 100644 --- a/src/v2/components/web-server/builder.ts +++ b/src/v2/components/web-server/builder.ts @@ -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; @@ -26,7 +26,7 @@ export class WebServerBuilder { private _ecsConfig?: WebServerBuilder.EcsConfig; private _domain?: pulumi.Input; private _hostedZoneId?: pulumi.Input; - private _certificate?: pulumi.Input; + private _certificate?: pulumi.Input; private _healthCheckPath?: pulumi.Input; private _loadBalancingAlgorithmType?: pulumi.Input; private _otelCollector?: pulumi.Input; diff --git a/src/v2/components/web-server/index.ts b/src/v2/components/web-server/index.ts index 29c70d3..d352164 100644 --- a/src/v2/components/web-server/index.ts +++ b/src/v2/components/web-server/index.ts @@ -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; - 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 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; + certificate?: pulumi.Input; + /** + * ID of hosted zone is needed when the `domain` or the `certificate` + * arguments are provided. + */ + hostedZoneId?: pulumi.Input; /** * Path for the load balancer target group health check request. * @@ -76,7 +92,7 @@ export class WebServer extends pulumi.ComponentResource { initContainers?: pulumi.Output; sidecarContainers?: pulumi.Output; volumes?: pulumi.Output; - certificate?: pulumi.Output; + acmCertificate?: AcmCertificate; dnsRecords?: pulumi.Output; constructor( @@ -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; @@ -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); @@ -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, ); } @@ -329,49 +346,22 @@ export class WebServer extends pulumi.ComponentResource { } private createDnsRecords( - certificate: pulumi.Output, + certificate: pulumi.Input, hostedZoneId: pulumi.Input, domain?: pulumi.Input, ): pulumi.Output { - 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: [ { @@ -383,9 +373,7 @@ export class WebServer extends pulumi.ComponentResource { }, { parent: this }, ), - ); - }); - - return records; + ), + ); } } diff --git a/tests/web-server/domain.test.ts b/tests/web-server/domain.test.ts index 5d0721a..e50c4e5 100644 --- a/tests/web-server/domain.test.ts +++ b/tests/web-server/domain.test.ts @@ -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 () => { @@ -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 () => { @@ -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 () => { @@ -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({ @@ -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', ); } diff --git a/tests/web-server/infrastructure/index.ts b/tests/web-server/infrastructure/index.ts index 2d02dfa..62329b7 100644 --- a/tests/web-server/infrastructure/index.ts +++ b/tests/web-server/infrastructure/index.ts @@ -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`, @@ -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, };