diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8d3d9fa..be5f9f9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: pull_request: branches: - master - - v2 + - alpha push: branches: - master - - v2 + - alpha jobs: tests: diff --git a/doc/custom-domain.md b/doc/custom-domain.md index bdb629c0..f36bec4f 100644 --- a/doc/custom-domain.md +++ b/doc/custom-domain.md @@ -10,52 +10,44 @@ The configuration for custom domain can be found under the `appSync.domain` attr appSync: name: my-api domain: - name: api.example.rehab - certificateArn: arn:aws:acm:us-east-1:123456789:certificate/7e14a3b2-f7a5-4da5-8150-4a03ede7158c + name: api.example.com + hostedZoneId: Z111111QQQQQQQ ``` ## Configuration - `name`: Required. The fully qualified domain name to assiciate this API to. -- `certificateArn`: Required. A valid certificate ARN for the domain name. +- `certificateArn`: Optional. A valid certificate ARN for the domain name. See [Certificate](#certificate). - `useCloudFormation`: Boolean. Optional. Wheter to use CloudFormation or CLI commands to manage the domain. See [Using CloudFormation or CLI commands](#using-cloudformation-vs-the-cli-commands). Defaults to `true`. -- `retain`: Boolean. Optional. Whether to retain the domain and domain association when they are removed from CloudFormation. Defaults to `false`. See [Ejecting from CloudFormation](#ejecting-from-cloudformation) -- `route53`: See [Route53 configuration](#route53-configuration). Defaults to `true` +- `retain`: Boolean, optional. Whether to retain the domain and domain association when they are removed from CloudFormation. Defaults to `false`. See [Ejecting from CloudFormation](#ejecting-from-cloudformation) +- `hostedZoneId`: Boolean, conditional. The Route53 hosted zone id where to create the certificate validation and/or AppSync Alias records. Required if `useCloudFormation` is `true` and `certificateArn` is not provided. +- `hostedZoneName`: The hosted zone name where to create the route53 Alias record. If `certificateArn` is provided, it takes precedence over `hostedZoneName`. +- `route53`: Boolean. Wether to create the Route53 Alias record for this domain. Set to `false` if you don't use Route53. Defaults to `true`. ## Certificate -This plugin does not provide any way to generate or manage your domain certificate. This is usually a set-and-forget kind of operation. You still need to provide its ARN and it must be a valid certificate for the provided domain name. +If `useCloudFormation` is `true` and a valid `certificateArn` is not provided, a certificate will be created for the provided domain `name` using CloudFormation. You must provide the `hostedZoneId` +where the DNS validation records for the certificate will be created. -## Route53 configuration +⚠️ Any change that requires a change of certificate attached to the domain requires a replacement of the AppSync domain resource. CloudFormation will usually fail with the following error when that happens: -When `true`, this plugin will try to create a Route53 CNAME entry in the Hosted Zone corresponding to the domain. This plugin will do its best to find the best Hosted Zone that matches the domain name. - -When `false`, no CNAME record will be created. - -You can also specify which hosted zone you want to create the record into: - -- `hostedZoneName`: The specific hosted zone name where to create the CNAME record. -- `hostedZoneId`: The specific hosted zone id where to create the CNAME record. - -example: - -```yaml -appSync: - domain: api.example.com - route53: - hostedZoneId: ABCDEFGHIJ +```bash +CloudFormation cannot update a stack when a custom-named resource requires replacing. Rename api.example.com and update the stack again. ``` +If `useCloudFormation` is `false`, when creating the domain with the `domain create` command, this plugin will try to find an existing certificate that +matches the given domain. If no valid certificate is found, an error will be thrown. No certificate will be auto-generated. + ## Using CloudFormation vs the CLI commands There are two ways to manage your custom domain: -- using CloudFormation +- using CloudFormation (default) - using the CLI [commands](commands.md#domain) -If `useCloudFormation` is set to `true`, the domain and domain association will be automatically created and managed by CloudFormation. However, in some cases you might not want that. +If `useCloudFormation` is set to `true`, the domain, domain association, and optionally, the domain certificate will be automatically created and managed by CloudFormation. However, in some cases you might not want that. -For example, if you wanted to use blue/green deployments, you might need to associate APIs from different stacks to the same domain. In that case, the only way to do it is to use the CLI. +For example, if you want to use blue/green deployments, you might need to associate APIs from different stacks to the same domain. In that case, the only way to do it is to use the CLI. For more information about managing domains with the CLI, see the [Commands](commands.md#domain) section. @@ -83,15 +75,15 @@ You can use different domains by stage easily thanks to [Serverless Framework St params: prod: domain: api.example.com - domainCert: arn:aws:acm:us-east-1:123456789:certificate/7e14a3b2-f7a5-4da5-8150-4a03ede7158c + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/7e14a3b2-f7a5-4da5-8150-4a03ede7158c staging: domain: qa.example.com - domainCert: arn:aws:acm:us-east-1:123456789:certificate/61d7d798-d656-4630-9ff9-d77a7d616dbe + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/61d7d798-d656-4630-9ff9-d77a7d616dbe default: domain: ${sls:stage}.example.com - domainCert: arn:aws:acm:us-east-1:379730309663:certificate/44211071-e102-4bf4-b7b0-06d0b78cd667 + domainCert: arn:aws:acm:us-east-1:123456789012:certificate/44211071-e102-4bf4-b7b0-06d0b78cd667 appSync: name: my-api diff --git a/src/__tests__/__snapshots__/api.test.ts.snap b/src/__tests__/__snapshots__/api.test.ts.snap index b452a93b..37d43140 100644 --- a/src/__tests__/__snapshots__/api.test.ts.snap +++ b/src/__tests__/__snapshots__/api.test.ts.snap @@ -97,10 +97,26 @@ Object { }, "Type": "AWS::AppSync::DomainNameApiAssociation", }, + "GraphQlDomainCertificate": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "DomainName": "api.example.com", + "DomainValidationOptions": Array [ + Object { + "DomainName": "api.example.com", + "HostedZoneId": "Z111111QQQQQQQ", + }, + ], + "ValidationMethod": "DNS", + }, + "Type": "AWS::CertificateManager::Certificate", + }, "GraphQlDomainName": Object { "DeletionPolicy": "Delete", "Properties": Object { - "CertificateArn": "arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52", + "CertificateArn": Object { + "Ref": "GraphQlDomainCertificate", + }, "DomainName": "api.example.com", }, "Type": "AWS::AppSync::DomainName", @@ -108,18 +124,77 @@ Object { "GraphQlDomainRoute53Record": Object { "DeletionPolicy": "Delete", "Properties": Object { - "HostedZoneName": "example.com.", + "AliasTarget": Object { + "DNSName": Object { + "Fn::GetAtt": Array [ + "GraphQlDomainName", + "AppSyncDomainName", + ], + }, + "EvaluateTargetHealth": false, + "HostedZoneId": Object { + "Fn::GetAtt": Array [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneId": "Z111111QQQQQQQ", "Name": "api.example.com", - "ResourceRecords": Array [ - Object { + "Type": "A", + }, + "Type": "AWS::Route53::RecordSet", + }, +} +`; + +exports[`Domains should generate domain resources with custom certificate ARN 1`] = ` +Object { + "GraphQlDomainAssociation": Object { + "DeletionPolicy": "Delete", + "DependsOn": Array [ + "GraphQlDomainName", + ], + "Properties": Object { + "ApiId": Object { + "Fn::GetAtt": Array [ + "GraphQlApi", + "ApiId", + ], + }, + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainNameApiAssociation", + }, + "GraphQlDomainName": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "CertificateArn": "arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52", + "DomainName": "api.example.com", + }, + "Type": "AWS::AppSync::DomainName", + }, + "GraphQlDomainRoute53Record": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "AliasTarget": Object { + "DNSName": Object { "Fn::GetAtt": Array [ "GraphQlDomainName", "AppSyncDomainName", ], }, - ], - "TTL": 300, - "Type": "CNAME", + "EvaluateTargetHealth": false, + "HostedZoneId": Object { + "Fn::GetAtt": Array [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneName": "example.com.", + "Name": "api.example.com", + "Type": "A", }, "Type": "AWS::Route53::RecordSet", }, @@ -155,18 +230,24 @@ Object { "GraphQlDomainRoute53Record": Object { "DeletionPolicy": "Delete", "Properties": Object { - "HostedZoneId": "ABCDEFGHI", - "Name": "api.example.com", - "ResourceRecords": Array [ - Object { + "AliasTarget": Object { + "DNSName": Object { "Fn::GetAtt": Array [ "GraphQlDomainName", "AppSyncDomainName", ], }, - ], - "TTL": 300, - "Type": "CNAME", + "EvaluateTargetHealth": false, + "HostedZoneId": Object { + "Fn::GetAtt": Array [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneId": "Z111111QQQQQQQ", + "Name": "api.example.com", + "Type": "A", }, "Type": "AWS::Route53::RecordSet", }, @@ -202,18 +283,24 @@ Object { "GraphQlDomainRoute53Record": Object { "DeletionPolicy": "Delete", "Properties": Object { - "HostedZoneName": "example.com.", - "Name": "foo.api.example.com", - "ResourceRecords": Array [ - Object { + "AliasTarget": Object { + "DNSName": Object { "Fn::GetAtt": Array [ "GraphQlDomainName", "AppSyncDomainName", ], }, - ], - "TTL": 300, - "Type": "CNAME", + "EvaluateTargetHealth": false, + "HostedZoneId": Object { + "Fn::GetAtt": Array [ + "GraphQlDomainName", + "HostedZoneId", + ], + }, + }, + "HostedZoneName": "example.com.", + "Name": "foo.api.example.com", + "Type": "A", }, "Type": "AWS::Route53::RecordSet", }, diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 69b64337..5cf47893 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -777,6 +777,19 @@ describe('Domains', () => { }); it('should generate domain resources', () => { + const api = new Api( + given.appSyncConfig({ + domain: { + name: 'api.example.com', + hostedZoneId: `Z111111QQQQQQQ`, + }, + }), + plugin, + ); + expect(api.compileCustomDomain()).toMatchSnapshot(); + }); + + it('should generate domain resources with custom certificate ARN', () => { const api = new Api( given.appSyncConfig({ domain: { @@ -814,9 +827,8 @@ describe('Domains', () => { name: 'api.example.com', certificateArn: 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', - route53: { - hostedZoneId: 'ABCDEFGHI', - }, + hostedZoneId: 'Z111111QQQQQQQ', + route53: true, }, }), plugin, @@ -831,9 +843,8 @@ describe('Domains', () => { name: 'foo.api.example.com', certificateArn: 'arn:aws:acm:us-east-1:1234567890:certificate/e4b6e9be-1aa7-458d-880e-069622e5be52', - route53: { - hostedZoneName: 'example.com.', - }, + hostedZoneName: 'example.com.', + route53: true, }, }), plugin, diff --git a/src/__tests__/commands.test.ts b/src/__tests__/commands.test.ts index 834f8dee..4de3ebc0 100644 --- a/src/__tests__/commands.test.ts +++ b/src/__tests__/commands.test.ts @@ -48,21 +48,36 @@ afterEach(() => { describe('create domain', () => { const createDomainName = jest.fn(); + const listCertificates = jest.fn(); afterEach(() => { createDomainName.mockClear(); + listCertificates.mockClear(); }); - it('should create a domain', async () => { + it('should create a domain with specified certificate ARN', async () => { await runServerless({ fixture: 'appsync', awsRequestStubMap: { AppSync: { createDomainName, }, + ACM: { + listCertificates, + }, }, command: 'appsync domain create', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + certificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493', + }, + }, + }, }); expect(createDomainName).toHaveBeenCalledTimes(1); + expect(listCertificates).not.toHaveBeenCalled(); expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493", @@ -70,6 +85,165 @@ describe('create domain', () => { } `); }); + + it('should create a domain and find a matching certificate, exact match', async () => { + listCertificates.mockResolvedValueOnce({ + CertificateSummaryList: [ + { + DomainName: '*.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507', + }, + { + DomainName: 'foo.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd', + }, + { + DomainName: 'api.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493', + }, + ], + }); + + await runServerless({ + fixture: 'appsync', + awsRequestStubMap: { + AppSync: { + createDomainName, + }, + ACM: { + listCertificates, + }, + }, + command: 'appsync domain create', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, + }); + + expect(listCertificates).toHaveBeenCalledTimes(1); + expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "CertificateStatuses": Array [ + "ISSUED", + ], + } + `); + expect(createDomainName).toHaveBeenCalledTimes(1); + expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493", + "domainName": "api.example.com", + } + `); + }); + + it('should fail creating a domain if ARN cannot be resolved', async () => { + listCertificates.mockResolvedValueOnce({ + CertificateSummaryList: [ + { + DomainName: 'foo.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd', + }, + ], + }); + + await expect( + runServerless({ + fixture: 'appsync', + awsRequestStubMap: { + AppSync: { + createDomainName, + }, + + ACM: { + listCertificates, + }, + }, + + command: 'appsync domain create', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"No certificate found for domain api.example.com."`, + ); + + expect(listCertificates).toHaveBeenCalledTimes(1); + expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "CertificateStatuses": Array [ + "ISSUED", + ], + } + `); + expect(createDomainName).not.toHaveBeenCalled(); + }); + + it('should create a domain and find a matching certificate, wildcard match', async () => { + listCertificates.mockResolvedValueOnce({ + CertificateSummaryList: [ + { + DomainName: 'foo.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd', + }, + { + DomainName: '*.example.com', + CertificateArn: + 'arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507', + }, + ], + }); + + await runServerless({ + fixture: 'appsync', + awsRequestStubMap: { + AppSync: { + createDomainName, + }, + ACM: { + listCertificates, + }, + }, + command: 'appsync domain create', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, + }); + + expect(listCertificates).toHaveBeenCalledTimes(1); + expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "CertificateStatuses": Array [ + "ISSUED", + ], + } + `); + expect(createDomainName).toHaveBeenCalledTimes(1); + expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507", + "domainName": "api.example.com", + } + `); + }); }); describe('delete domain', () => { @@ -88,6 +262,13 @@ describe('delete domain', () => { }, }, command: 'appsync domain delete', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(deleteDomainName).toHaveBeenCalledTimes(1); @@ -110,6 +291,13 @@ describe('delete domain', () => { }, }, command: 'appsync domain delete', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { yes: true, }, @@ -135,6 +323,13 @@ describe('delete domain', () => { }, }, command: 'appsync domain delete', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(confirmSpy).toHaveBeenCalled(); @@ -171,6 +366,13 @@ describe('assoc domain', () => { AppSync: { associateApi, getApiAssociation }, }, command: 'appsync domain assoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(describeStackResources).toHaveBeenCalledTimes(1); @@ -212,6 +414,13 @@ describe('assoc domain', () => { AppSync: { associateApi, getApiAssociation }, }, command: 'appsync domain assoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(describeStackResources).toHaveBeenCalledTimes(1); @@ -251,6 +460,13 @@ describe('assoc domain', () => { AppSync: { associateApi, getApiAssociation }, }, command: 'appsync domain assoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(confirmSpy).toHaveBeenCalled(); @@ -301,6 +517,13 @@ describe('assoc domain', () => { AppSync: { associateApi, getApiAssociation }, }, command: 'appsync domain assoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { yes: true, }, @@ -363,6 +586,13 @@ describe('domain disassoc', () => { AppSync: { disassociateApi, getApiAssociation }, }, command: 'appsync domain disassoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(confirmSpy).toHaveBeenCalled(); @@ -411,6 +641,13 @@ describe('domain disassoc', () => { AppSync: { disassociateApi, getApiAssociation }, }, command: 'appsync domain disassoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { yes: true, }, @@ -456,6 +693,13 @@ describe('domain disassoc', () => { AppSync: { disassociateApi, getApiAssociation }, }, command: 'appsync domain disassoc', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(describeStackResources).toHaveBeenCalledTimes(1); @@ -475,6 +719,7 @@ describe('domain create-record', () => { getDomainName.mockResolvedValue({ domainNameConfig: { appsyncDomainName: 'qbcdefghij.cloudfront.net', + hostedZoneId: 'Z111111QQQQQQQ', }, }); listHostedZonesByName.mockResolvedValue({ @@ -519,6 +764,13 @@ describe('domain create-record', () => { }, }, command: 'appsync domain create-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(getDomainName).toHaveBeenCalledTimes(1); @@ -533,29 +785,28 @@ describe('domain create-record', () => { ] `); expect(changeResourceRecordSets.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "ChangeBatch": Object { - "Changes": Array [ - Object { - "Action": "CREATE", - "ResourceRecordSet": Object { - "Name": "api.example.com", - "ResourceRecords": Array [ - Object { - "Value": "qbcdefghij.cloudfront.net", - }, - ], - "TTL": 300, - "Type": "CNAME", + Array [ + Object { + "ChangeBatch": Object { + "Changes": Array [ + Object { + "Action": "CREATE", + "ResourceRecordSet": Object { + "AliasTarget": Object { + "DNSName": "qbcdefghij.cloudfront.net", + "EvaluateTargetHealth": false, + "HostedZoneId": "Z111111QQQQQQQ", }, + "Name": "api.example.com", + "Type": "A", }, - ], - }, - "HostedZoneId": "KLMNOP", + }, + ], }, - ] - `); + "HostedZoneId": "KLMNOP", + }, + ] + `); expect(getChange.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -568,7 +819,7 @@ describe('domain create-record', () => { it('should handle changeResourceRecordSets errors', async () => { changeResourceRecordSets.mockRejectedValue( new ServerlessError( - "[Tried to create resource record set [name='api.example.com.', type='CNAME'] but it already exists]", + "[Tried to create resource record set [name='api.example.com.', type='A'] but it already exists]", ), ); @@ -586,9 +837,16 @@ describe('domain create-record', () => { }, command: 'appsync domain create-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[Tried to create resource record set [name='api.example.com.', type='CNAME'] but it already exists]"`, + `"[Tried to create resource record set [name='api.example.com.', type='A'] but it already exists]"`, ); expect(getDomainName).toHaveBeenCalledTimes(1); @@ -600,7 +858,7 @@ describe('domain create-record', () => { it('should handle changeResourceRecordSets errors silently', async () => { changeResourceRecordSets.mockRejectedValue( new ServerlessError( - "[Tried to create resource record set [name='api.example.com.', type='CNAME'] but it already exists]", + "[Tried to create resource record set [name='api.example.com.', type='A'] but it already exists]", ), ); @@ -616,6 +874,13 @@ describe('domain create-record', () => { }, }, command: 'appsync domain create-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { quiet: true }, }); @@ -642,6 +907,13 @@ describe('domain create-record', () => { }, command: 'appsync domain create-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { quiet: true }, }), ).rejects.toThrowErrorMatchingInlineSnapshot(` @@ -660,6 +932,7 @@ describe('domain delete-record', () => { const getDomainName = jest.fn().mockResolvedValue({ domainNameConfig: { appsyncDomainName: 'qbcdefghij.cloudfront.net', + hostedZoneId: 'Z111111QQQQQQQ', }, }); const listHostedZonesByName = jest.fn().mockResolvedValue({ @@ -706,6 +979,13 @@ describe('domain delete-record', () => { }, }, command: 'appsync domain delete-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(confirmSpy).toHaveBeenCalled(); @@ -721,29 +1001,28 @@ describe('domain delete-record', () => { ] `); expect(changeResourceRecordSets.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "ChangeBatch": Object { - "Changes": Array [ - Object { - "Action": "DELETE", - "ResourceRecordSet": Object { - "Name": "api.example.com", - "ResourceRecords": Array [ - Object { - "Value": "qbcdefghij.cloudfront.net", - }, - ], - "TTL": 300, - "Type": "CNAME", + Array [ + Object { + "ChangeBatch": Object { + "Changes": Array [ + Object { + "Action": "DELETE", + "ResourceRecordSet": Object { + "AliasTarget": Object { + "DNSName": "qbcdefghij.cloudfront.net", + "EvaluateTargetHealth": false, + "HostedZoneId": "Z111111QQQQQQQ", }, + "Name": "api.example.com", + "Type": "A", }, - ], - }, - "HostedZoneId": "KLMNOP", + }, + ], }, - ] - `); + "HostedZoneId": "KLMNOP", + }, + ] + `); expect(getChange.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -774,6 +1053,13 @@ describe('domain delete-record', () => { }, }, command: 'appsync domain delete-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }); expect(confirmSpy).toHaveBeenCalled(); @@ -811,6 +1097,13 @@ describe('domain delete-record', () => { }, }, command: 'appsync domain delete-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { yes: true }, }); @@ -827,29 +1120,28 @@ describe('domain delete-record', () => { ] `); expect(changeResourceRecordSets.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "ChangeBatch": Object { - "Changes": Array [ - Object { - "Action": "DELETE", - "ResourceRecordSet": Object { - "Name": "api.example.com", - "ResourceRecords": Array [ - Object { - "Value": "qbcdefghij.cloudfront.net", - }, - ], - "TTL": 300, - "Type": "CNAME", + Array [ + Object { + "ChangeBatch": Object { + "Changes": Array [ + Object { + "Action": "DELETE", + "ResourceRecordSet": Object { + "AliasTarget": Object { + "DNSName": "qbcdefghij.cloudfront.net", + "EvaluateTargetHealth": false, + "HostedZoneId": "Z111111QQQQQQQ", }, + "Name": "api.example.com", + "Type": "A", }, - ], - }, - "HostedZoneId": "KLMNOP", + }, + ], }, - ] - `); + "HostedZoneId": "KLMNOP", + }, + ] + `); expect(getChange.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -863,7 +1155,7 @@ describe('domain delete-record', () => { confirmSpy.mockResolvedValue(true); changeResourceRecordSets.mockRejectedValue( new ServerlessError( - "[Tried to delete resource record set [name='api.example.com.', type='CNAME'] but it was not found]", + "[Tried to delete resource record set [name='api.example.com.', type='A'] but it was not found]", ), ); @@ -881,9 +1173,16 @@ describe('domain delete-record', () => { }, command: 'appsync domain delete-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, }), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[Tried to delete resource record set [name='api.example.com.', type='CNAME'] but it was not found]"`, + `"[Tried to delete resource record set [name='api.example.com.', type='A'] but it was not found]"`, ); expect(confirmSpy).toHaveBeenCalled(); @@ -897,7 +1196,7 @@ describe('domain delete-record', () => { confirmSpy.mockResolvedValue(true); changeResourceRecordSets.mockRejectedValue( new ServerlessError( - "[Tried to delete resource record set [name='api.example.com.', type='CNAME'] but it was not found]", + "[Tried to delete resource record set [name='api.example.com.', type='A'] but it was not found]", ), ); @@ -913,6 +1212,13 @@ describe('domain delete-record', () => { }, }, command: 'appsync domain delete-record', + configExt: { + appSync: { + domain: { + useCloudFormation: false, + }, + }, + }, options: { quiet: true, }, diff --git a/src/__tests__/fixtures/appsync/serverless.yml b/src/__tests__/fixtures/appsync/serverless.yml index f80cb33e..92c06678 100644 --- a/src/__tests__/fixtures/appsync/serverless.yml +++ b/src/__tests__/fixtures/appsync/serverless.yml @@ -11,7 +11,6 @@ appSync: domain: useCloudFormation: false name: api.example.com - certificateArn: arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493 resolvers: Query.user: diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 704db84c..a7b530a0 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,4 +1,9 @@ -import { parseDateTimeOrDuration, parseDuration } from '../utils'; +import { + getHostedZoneName, + getWildCardDomainName, + parseDateTimeOrDuration, + parseDuration, +} from '../utils'; beforeAll(() => { jest.useFakeTimers('modern'); @@ -43,3 +48,24 @@ describe('parseDateTimeOrDuration', () => { }).toThrowErrorMatchingInlineSnapshot(`"Invalid date or duration"`); }); }); + +describe('domain', () => { + describe('getHostedZoneName', () => { + it('should extract a correct hostedZoneName', () => { + expect(getHostedZoneName('example.com')).toMatch('example.com.'); + expect(getHostedZoneName('api.example.com')).toMatch('example.com.'); + expect(getHostedZoneName('api.prod.example.com')).toMatch( + 'prod.example.com.', + ); + }); + }); + + describe('getWildCardDomainName', () => { + it('should extract a correct getWildCardDomainName', () => { + expect(getWildCardDomainName('api.example.com')).toMatch('*.example.com'); + expect(getWildCardDomainName('api.prod.example.com')).toMatch( + '*.prod.example.com', + ); + }); + }); +}); diff --git a/src/__tests__/validation/__snapshots__/base.test.ts.snap b/src/__tests__/validation/__snapshots__/base.test.ts.snap index a8fe2a39..2d1bc27e 100644 --- a/src/__tests__/validation/__snapshots__/base.test.ts.snap +++ b/src/__tests__/validation/__snapshots__/base.test.ts.snap @@ -4,15 +4,18 @@ exports[`Valdiation Domain Invalid should validate a Invalid 1`] = ` "/domain/enabled: must be boolean /domain/name: must be a valid domain name /domain/certificateArn: must be a string or a CloudFormation intrinsic function -/domain/route53: must be a boolean or a route53 configuration object" +/domain/route53: must be boolean" `; exports[`Valdiation Domain Invalid should validate a Invalid Route 53 1`] = ` "/domain/name: must be a valid domain name -/domain/route53/hostedZoneId: must be a string or a CloudFormation intrinsic function -/domain/route53/hostedZoneName: must be a valid zone name. Note: you must include a trailing dot (eg: \`example.com.\`)" +/domain/route53: must be boolean" `; +exports[`Valdiation Domain Invalid should validate a useCloudFormation: not present, certificateArn or hostedZoneId is required 1`] = `"/domain: when using CloudFormation, you must provide either certificateArn or hostedZoneId."`; + +exports[`Valdiation Domain Invalid should validate a useCloudFormation: true, certificateArn or hostedZoneId is required 1`] = `"/domain: when using CloudFormation, you must provide either certificateArn or hostedZoneId."`; + exports[`Valdiation Log Invalid should validate a Invalid 1`] = ` "/logging/level: must be one of 'ALL', 'ERROR' or 'NONE' /logging/retentionInDays: must be integer diff --git a/src/__tests__/validation/base.test.ts b/src/__tests__/validation/base.test.ts index 46df1d54..7c5ee456 100644 --- a/src/__tests__/validation/base.test.ts +++ b/src/__tests__/validation/base.test.ts @@ -208,6 +208,7 @@ describe('Valdiation', () => { ...basicConfig, domain: { name: 'api.example.com', + certificateArn: 'arn:aws:', }, } as AppSyncConfigInput, }, @@ -219,22 +220,19 @@ describe('Valdiation', () => { enabled: true, certificateArn: 'arn:aws:', name: 'api.example.com', + hostedZoneId: 'Z111111QQQQQQQ', + hostedZoneName: 'example.com.', route53: true, }, } as AppSyncConfigInput, }, { - name: 'Rotue53 object', + name: 'useCloudFormation: false, missing certificateArn', config: { ...basicConfig, domain: { - enabled: true, - certificateArn: 'arn:aws:', name: 'api.example.com', - route53: { - hostedZoneId: '12345', - hostedZoneName: 'example.com.', - }, + useCloudFormation: false, }, } as AppSyncConfigInput, }, @@ -261,12 +259,32 @@ describe('Valdiation', () => { }, }, }, + { + name: 'useCloudFormation: true, certificateArn or hostedZoneId is required', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + useCloudFormation: true, + }, + }, + }, + { + name: 'useCloudFormation: not present, certificateArn or hostedZoneId is required', + config: { + ...basicConfig, + domain: { + name: 'api.example.com', + }, + }, + }, { name: 'Invalid Route 53', config: { ...basicConfig, domain: { name: 'bar', + certificateArn: 'arn:aws:', route53: { hostedZoneId: 456, hostedZoneName: 789, diff --git a/src/index.ts b/src/index.ts index 7df4aa0a..396bbfe0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ import { AppSyncValidationError, validateConfig } from './validation'; import { confirmAction, getHostedZoneName, + getWildCardDomainName, parseDateTimeOrDuration, wait, } from './utils'; @@ -59,6 +60,11 @@ import { ListHostedZonesByNameRequest, ListHostedZonesByNameResponse, } from 'aws-sdk/clients/route53'; +import { + ListCertificatesRequest, + ListCertificatesResponse, +} from 'aws-sdk/clients/acm'; +import terminalLink from 'terminal-link'; const CONSOLE_BASE_URL = 'https://console.aws.amazon.com'; @@ -188,6 +194,12 @@ class ServerlessAppsyncPlugin { required: false, type: 'boolean', }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, }, }, delete: { @@ -209,7 +221,7 @@ class ServerlessAppsyncPlugin { }, }, 'create-record': { - usage: 'Create the CNAME record for this domain in Route53', + usage: 'Create the Alias record for this domain in Route53', lifecycleEvents: ['run'], options: { quiet: { @@ -218,10 +230,16 @@ class ServerlessAppsyncPlugin { required: false, type: 'boolean', }, + yes: { + usage: 'Automatic yes to prompts', + shortcut: 'y', + required: false, + type: 'boolean', + }, }, }, 'delete-record': { - usage: 'Deletes the CNAME record for this domain from Route53', + usage: 'Deletes the Alias record for this domain from Route53', lifecycleEvents: ['run'], options: { quiet: { @@ -292,17 +310,20 @@ class ServerlessAppsyncPlugin { 'appsync:console:run': () => this.openConsole(), 'appsync:cloudwatch:run': () => this.openCloudWatch(), 'appsync:logs:run': async () => this.initShowLogs(), - 'before:appsync:domain:create:run': async () => this.loadConfig(), + 'before:appsync:domain:create:run': async () => this.initDomainCommand(), 'appsync:domain:create:run': async () => this.createDomain(), - 'before:appsync:domain:delete:run': async () => this.loadConfig(), + 'before:appsync:domain:delete:run': async () => this.initDomainCommand(), 'appsync:domain:delete:run': async () => this.deleteDomain(), - 'before:appsync:domain:assoc:run': async () => this.loadConfig(), + 'before:appsync:domain:assoc:run': async () => this.initDomainCommand(), 'appsync:domain:assoc:run': async () => this.assocDomain(), - 'before:appsync:domain:disassoc:run': async () => this.loadConfig(), + 'before:appsync:domain:disassoc:run': async () => + this.initDomainCommand(), 'appsync:domain:disassoc:run': async () => this.disassocDomain(), - 'before:appsync:domain:create-record:run': async () => this.loadConfig(), + 'before:appsync:domain:create-record:run': async () => + this.initDomainCommand(), 'appsync:domain:create-record:run': async () => this.createRecord(), - 'before:appsync:domain:delete-record:run': async () => this.loadConfig(), + 'before:appsync:domain:delete-record:run': async () => + this.initDomainCommand(), 'appsync:domain:delete-record:run': async () => this.deleteRecord(), }; @@ -473,6 +494,29 @@ class ServerlessAppsyncPlugin { } } + async initDomainCommand() { + this.loadConfig(); + const domain = this.getDomain(); + + if (domain.useCloudFormation !== false) { + log.warning( + 'You are using the CloudFormation integration for domain configuration.\n' + + 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + + 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + + 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + + terminalLink( + 'eject from CloudFormation', + 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', + ) + + ' first.', + ); + + if (!this.options.yes && !(await confirmAction())) { + process.exit(0); + } + } + } + getDomain() { if (!this.api) { throw new this.serverless.classes.Error( @@ -488,15 +532,55 @@ class ServerlessAppsyncPlugin { return domain; } + async getDomainCertificateArn() { + const { CertificateSummaryList } = await this.provider.request< + ListCertificatesRequest, + ListCertificatesResponse + >( + 'ACM', + 'listCertificates', + // only fully issued certificates + { CertificateStatuses: ['ISSUED'] }, + // certificates must always be in us-east-1 + { region: 'us-east-1' }, + ); + + const domain = this.getDomain(); + + // try to find an exact match certificate + // fallback on wildcard + const matches = [domain.name, getWildCardDomainName(domain.name)]; + for (const match of matches) { + const cert = CertificateSummaryList?.find( + ({ DomainName }) => DomainName === match, + ); + if (cert) { + log.info( + `Found matching certificate for ${match}: ${cert.CertificateArn}`, + ); + return cert.CertificateArn; + } + } + } + async createDomain() { try { const domain = this.getDomain(); + const certificateArn = + domain.certificateArn || (await this.getDomainCertificateArn()); + + if (!certificateArn) { + throw new this.serverless.classes.Error( + `No certificate found for domain ${domain.name}.`, + ); + } + await this.provider.request< CreateDomainNameRequest, CreateDomainNameRequest >('AppSync', 'createDomainName', { domainName: domain.name, - certificateArn: domain.certificateArn, + certificateArn, }); log.success(`Domain '${domain.name}' created successfully`); } catch (error) { @@ -660,17 +744,15 @@ class ServerlessAppsyncPlugin { async getHostedZoneId() { const domain = this.getDomain(); - if (typeof domain.route53 === 'object' && domain.route53.hostedZoneId) { - return domain.route53.hostedZoneId; + if (domain.hostedZoneId) { + return domain.hostedZoneId; } else { const { HostedZones } = await this.provider.request< ListHostedZonesByNameRequest, ListHostedZonesByNameResponse >('Route53', 'listHostedZonesByName', {}); const hostedZoneName = - typeof domain.route53 === 'object' && domain.route53.hostedZoneName - ? domain.route53.hostedZoneName - : getHostedZoneName(domain.name); + domain.hostedZoneName || getHostedZoneName(domain.name); const foundHostedZone = HostedZones.find( (zone) => zone.Name === hostedZoneName, )?.Id; @@ -691,14 +773,15 @@ class ServerlessAppsyncPlugin { >('AppSync', 'getDomainName', { domainName: domain.name, }); - const { appsyncDomainName } = domainNameConfig || {}; - if (!appsyncDomainName) { + + const { hostedZoneId, appsyncDomainName: dnsName } = domainNameConfig || {}; + if (!hostedZoneId || !dnsName) { throw new this.serverless.classes.Error( `Domain ${domain.name} not found\nDid you forget to run 'sls appsync domain create'?`, ); } - return appsyncDomainName; + return { hostedZoneId, dnsName }; } async createRecord() { @@ -718,7 +801,7 @@ class ServerlessAppsyncPlugin { await this.checkRoute53RecordStatus(changeId); progressInstance.remove(); log.info( - `CNAME record '${domain.name}' with value '${appsyncDomainName}' was created in Hosted Zone '${hostedZoneId}'`, + `Alias record for '${domain.name}' was created in Hosted Zone '${hostedZoneId}'`, ); log.success('Route53 record created successfuly'); } @@ -730,7 +813,7 @@ class ServerlessAppsyncPlugin { const hostedZoneId = await this.getHostedZoneId(); log.warning( - `CNAME record '${domain.name}' with value '${appsyncDomainName}' will be deleted from Hosted Zone '${hostedZoneId}'`, + `Alias record for '${domain.name}' will be deleted from Hosted Zone '${hostedZoneId}'`, ); if (!this.options.yes && !(await confirmAction())) { return; @@ -749,7 +832,7 @@ class ServerlessAppsyncPlugin { await this.checkRoute53RecordStatus(changeId); progressInstance.remove(); log.info( - `CNAME record '${domain.name}' with value '${appsyncDomainName}' was deleted from Hosted Zone '${hostedZoneId}'`, + `Alias record for '${domain.name}' was deleted from Hosted Zone '${hostedZoneId}'`, ); log.success('Route53 record deleted successfuly'); } @@ -772,7 +855,10 @@ class ServerlessAppsyncPlugin { async changeRoute53Record( action: 'CREATE' | 'DELETE', hostedZoneId: string, - cname: string, + domainNamConfig: { + hostedZoneId: string; + dnsName: string; + }, ) { const domain = this.getDomain(); @@ -788,9 +874,12 @@ class ServerlessAppsyncPlugin { Action: action, ResourceRecordSet: { Name: domain.name, - Type: 'CNAME', - ResourceRecords: [{ Value: cname }], - TTL: 300, + Type: 'A', + AliasTarget: { + HostedZoneId: domainNamConfig.hostedZoneId, + DNSName: domainNamConfig.dnsName, + EvaluateTargetHealth: false, + }, }, }, ], @@ -819,7 +908,16 @@ class ServerlessAppsyncPlugin { return; } - this.serverless.addServiceOutputSection('appsync endpoints', endpoints); + const { name } = this.api?.config?.domain || {}; + if (name) { + endpoints.push(`graphql: https://${name}/graphql`); + endpoints.push(`realtime: wss://${name}/graphql/realtime`); + } + + this.serverless.addServiceOutputSection( + 'appsync endpoints', + endpoints.sort(), + ); } displayApiKeys() { diff --git a/src/resources/Api.ts b/src/resources/Api.ts index 29cfd66f..22d6ba96 100644 --- a/src/resources/Api.ts +++ b/src/resources/Api.ts @@ -199,13 +199,17 @@ export class Api { const domainNameLogicalId = this.naming.getDomainNameLogicalId(); const domainAssocLogicalId = this.naming.getDomainAssociationLogicalId(); + const domainCertificateLogicalId = + this.naming.getDomainCertificateLogicalId(); const resources = { [domainNameLogicalId]: { Type: 'AWS::AppSync::DomainName', DeletionPolicy: domain.retain ? 'Retain' : 'Delete', Properties: { - CertificateArn: domain.certificateArn, + CertificateArn: domain.certificateArn || { + Ref: domainCertificateLogicalId, + }, DomainName: domain.name, }, }, @@ -220,15 +224,29 @@ export class Api { }, }; + if (!domain.certificateArn) { + merge(resources, { + [domainCertificateLogicalId]: { + Type: 'AWS::CertificateManager::Certificate', + DeletionPolicy: domain.retain ? 'Retain' : 'Delete', + Properties: { + DomainName: domain.name, + ValidationMethod: 'DNS', + DomainValidationOptions: [ + { + DomainName: domain.name, + HostedZoneId: domain.hostedZoneId, + }, + ], + }, + }, + }); + } + if (domain.route53 !== false) { const hostedZoneName = - typeof domain.route53 === 'object' && domain.route53.hostedZoneName - ? domain.route53.hostedZoneName - : getHostedZoneName(domain.name); - const hostedZoneId = - typeof domain.route53 === 'object' && domain.route53.hostedZoneId - ? domain.route53.hostedZoneId - : undefined; + domain.hostedZoneName || getHostedZoneName(domain.name); + const domainRoute53Record = this.naming.getDomainReoute53RecordLogicalId(); @@ -237,15 +255,20 @@ export class Api { Type: 'AWS::Route53::RecordSet', DeletionPolicy: domain.retain ? 'Retain' : 'Delete', Properties: { - ...(hostedZoneId - ? { HostedZoneId: hostedZoneId } + ...(domain.hostedZoneId + ? { HostedZoneId: domain.hostedZoneId } : { HostedZoneName: hostedZoneName }), Name: domain.name, - Type: 'CNAME', - ResourceRecords: [ - { 'Fn::GetAtt': [domainNameLogicalId, 'AppSyncDomainName'] }, - ], - TTL: 300, + Type: 'A', + AliasTarget: { + HostedZoneId: { + 'Fn::GetAtt': [domainNameLogicalId, 'HostedZoneId'], + }, + DNSName: { + 'Fn::GetAtt': [domainNameLogicalId, 'AppSyncDomainName'], + }, + EvaluateTargetHealth: false, + }, }, }, }); diff --git a/src/resources/Naming.ts b/src/resources/Naming.ts index 442877b0..d3b3cacb 100644 --- a/src/resources/Naming.ts +++ b/src/resources/Naming.ts @@ -27,6 +27,10 @@ export class Naming { return this.getLogicalId(`GraphQlDomainName`); } + getDomainCertificateLogicalId() { + return this.getLogicalId(`GraphQlDomainCertificate`); + } + getDomainAssociationLogicalId() { return this.getLogicalId(`GraphQlDomainAssociation`); } diff --git a/src/types/plugin.ts b/src/types/plugin.ts index d330579b..bb4132c8 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -141,13 +141,10 @@ export type DomainConfig = { useCloudFormation?: boolean; retain?: boolean; name: string; - certificateArn: string; - route53?: - | boolean - | { - hostedZoneId?: string; - hostedZoneName?: string; - }; + certificateArn?: string; + hostedZoneId?: string; + hostedZoneName?: string; + route53?: boolean; }; export type SyncConfig = { diff --git a/src/types/serverless.d.ts b/src/types/serverless.d.ts index 9a9f1576..1038c06b 100644 --- a/src/types/serverless.d.ts +++ b/src/types/serverless.d.ts @@ -94,7 +94,7 @@ declare module 'serverless/lib/Serverless' { declare module 'serverless/lib/plugins/aws/provider.js' { import Serverless from 'serverless/lib/Serverless'; - + import { ServiceConfigurationOptions } from 'aws-sdk/lib/service'; declare class Provider { constructor(serverless: Serverless); naming: { @@ -105,6 +105,7 @@ declare module 'serverless/lib/plugins/aws/provider.js' { service: string, method: string, params: Input, + options?: ServiceConfigurationOptions, ) => Promise; } diff --git a/src/utils.ts b/src/utils.ts index 547fc522..5c6e110f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -83,7 +83,15 @@ export const parseDuration = (input: string | number) => { }; export const getHostedZoneName = (domain: string) => { - return `${domain.split('.').slice(1).join('.')}.`; + const parts = domain.split('.'); + if (parts.length > 2) { + parts.shift(); + } + return `${parts.join('.')}.`; +}; + +export const getWildCardDomainName = (domain: string) => { + return `*.${domain.split('.').slice(1).join('.')}`; }; export const question = async (question: string): Promise => { diff --git a/src/validation.ts b/src/validation.ts index 65d1ec76..256cec08 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -620,29 +620,35 @@ export const appSyncSchema = { retain: { type: 'boolean' }, name: { type: 'string', - pattern: '^([a-z][a-z0-9+-]*\\.){2,}[a-z][a-z0-9]*$', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*$', errorMessage: 'must be a valid domain name', }, certificateArn: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - route53: { - if: { type: 'object' }, - then: { - type: 'object', - properties: { - hostedZoneId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, - hostedZoneName: { - type: 'string', - pattern: '^([a-z][a-z0-9+-]*\\.){2,}$', - errorMessage: - 'must be a valid zone name. Note: you must include a trailing dot (eg: `example.com.`)', - }, - }, - }, - else: { - type: 'boolean', - errorMessage: 'must be a boolean or a route53 configuration object', - }, + hostedZoneId: { $ref: '#/definitions/stringOrIntrinsicFunction' }, + hostedZoneName: { + type: 'string', + pattern: '^([a-z][a-z0-9+-]*\\.)+[a-z][a-z0-9]*\\.$', + errorMessage: + 'must be a valid zone name. Note: you must include a trailing dot (eg: `example.com.`)', }, + route53: { type: 'boolean' }, + }, + required: ['name'], + if: { + anyOf: [ + { + not: { properties: { useCloudFormation: { const: false } } }, + }, + { not: { required: ['useCloudFormation'] } }, + ], + }, + then: { + anyOf: [ + { required: ['certificateArn'] }, + { required: ['hostedZoneId'] }, + ], + errorMessage: + 'when using CloudFormation, you must provide either certificateArn or hostedZoneId.', }, }, xrayEnabled: { type: 'boolean' },