Generative AI Use Cases JPで既存のACM証明書を指定できるようカスタマイズする

## はじめに
Generative AI Use Cases JP (以降、GenU) は生成 AI を活用した様々なビジネスユースケースに対応したデモアプリケーションの実装です。GitHub 上で OSS として公開されています。

https://github.com/aws-samples/generative-ai-use-cases-jp

本記事では GenU 自体の詳細や細かいデプロイ方法、利用方法などには触れません。これらについては [生成 AI 体験ワークショップ](https://catalog.workshops.aws/generative-ai-use-cases-jp/ja-JP) などを参照、体験いただくことをおすすめします。

### カスタムドメインの使用
GenU は AWS CDK を使用して実装されており、CDK の context で様々なデプロイオプションを指定できます。

https://github.com/aws-samples/generative-ai-use-cases-jp/blob/main/docs/DEPLOY_OPTION.md

オプションとして Web サイトの URL にカスタムドメインを使用することができます。同一 AWS アカウントの Route 53 に作成済みのパブリックホストゾーン ID を指定することで、ACM による証明書の作成と検証、CloudFront ディストリビューションの設定、Route 53 のエイリアスレコードの作成までを実行してくれます。

`cdk.json` に以下のように context を設定するだけです。便利ですね！！

```json:packages/cdk/cdk.json
{
  "context": {
    "hostName": "genai",
    "domainName": "example.com",
    "hostedZoneId": "XXXXXXXXXXXXXXXXXXXX"
  }
}
```

一方で Amazon Route 53 のパブリックホストゾーンは別アカウントで管理している環境もあるでしょう。本記事作成時点で GenU ではこのような構成には対応しておらず、ドキュメントにも以下の記載があります。

> 同一 AWS アカウントにパブリックホストゾーンを持っていない場合は、AWS ACM による SSL 証明書の検証時に手動で DNS レコードを追加する方法や、Eメール検証を行う方法もあります。これらの方法を利用する場合は、CDK のドキュメントを参照してカスタマイズしてください: [aws-cdk-lib.aws_certificatemanager module · AWS CDK](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_certificatemanager-readme.html)

前置きが長くなりましたが、本記事では別アカウントでパブリックホストゾーンを管理している場合のカスタマイズ例を紹介します。

## カスタマイズ方針
以下の方針でカスタマイズを行った例を紹介します。他にもカスタマイズの仕方はいろいろあるかと思いますが一例として参考になれば幸いです。

* 既存の機能は維持する
    * ホスト名、ドメイン名、ホストゾーン ID を指定した場合に証明書の作成やエイリアスレコードの登録をおこなう
* 既存の ACM 証明書の ARN を context で指定できるようにする
    * ホスト名、ドメイン名、証明書の ARN を指定した場合に、指定された証明書を使用して CloudFront ディストリビューションを設定するように変更する
    * ACM 証明書は事前に同一アカウント内で発行されている前提
    * 証明書検証や CloudFront ディストリビューションを指す DNS レコードは別アカウントのホストゾーンや外部の DNS サービスに手動で作成する


## コードの修正内容
以下の 4 ファイルを修正します。

* packages/cdk/bin/generative-ai-use-cases.ts
* packages/cdk/lib/cloud-front-waf-stack.ts
* packages/cdk/lib/construct/web.ts
* packages/cdk/cdk.json

修正したコードは以下に置いています。

https://github.com/hayao-k/generative-ai-use-cases-jp/tree/feature/support-existing-ACM

diff:

https://github.com/hayao-k/generative-ai-use-cases-jp/pull/1/files


何か考慮漏れがあったらごめんなさい。

### packages/cdk/bin/generative-ai-use-cases.ts
GenU に関連する複数のスタックを定義し、デプロイするためのエントリーポイントとなるコードです。以下の変更を加えます。

1. アプリケーションのコンテキストから `certificateArn` を取得し、その型と値をチェックする処理を追加
1. カスタムドメインの設定チェックロジックを変更
`certificateArn` が提供されている場合と提供されていない場合で、必要な設定項目が異なるため、それぞれの条件に応じたエラーチェックを実装
1. `CloudFrontWafStack` の作成時に渡すプロパティとして `certificateArn` を追加

```diff_typescript:packages/cdk/bin/generative-ai-use-cases.ts
+  // アプリケーションのコンテキストからcertificateArnを取得
+ const certificateArn = app.node.tryGetContext('certificateArn');
+ 
+ // certificateArnの型と値をチェック
+  if (
+   typeof certificateArn != 'undefined' &&
+   typeof certificateArn != 'string' &&
+   certificateArn != null
+  ) {
+    // certificateArnが文字列でない場合、エラーをスロー
+    throw new Error('certificateArn must be a string');
+  }

- // check hostName, domainName hostedZoneId are all set or none of them
- if (
-   !(
-     (hostName && domainName && hostedZoneId) ||
-     (!hostName && !domainName && !hostedZoneId)
-   )
- ) {
-   throw new Error(
-     'hostName, domainName and hostedZoneId must be set or none of them'
-   );

+ // カスタムドメインの設定をチェック
+ if (certificateArn) {
+   // certificateArnが提供されている場合
+   // hostNameとdomainNameが設定されている必要があり、hostedZoneIdは設定されていてはいけない
+   if (!hostName || !domainName) {
+     throw new Error('When certificateArn is provided, both hostName and domainName must be set');
+   }
+   if (hostedZoneId) {
+     throw new Error('When certificateArn is provided, hostedZoneId must not be set');
+   }
+ } else {
+   // certificateArnが提供されていない場合
+   // hostName、domainName、hostedZoneIdの全てが設定されているか、全てが設定されていない必要がある
+   if (
+     !(
+       (hostName && domainName && hostedZoneId) ||
+       (!hostName && !domainName && !hostedZoneId)
+     )
+   ) {
+     throw new Error('When certificateArn is not provided, either all of hostName, domainName, and hostedZoneId must be set, or none of them');
+   }
+ }

  let cloudFrontWafStack: CloudFrontWafStack | undefined;
  // IP アドレス範囲(v4もしくはv6のいずれか)か地理的制限が定義されている場合のみ、CloudFrontWafStack をデプロイする
  if (
    allowedIpV4AddressRanges ||
    allowedIpV6AddressRanges ||
    allowedCountryCodes ||
    hostName
  ) {
    // WAF v2 は us-east-1 でのみデプロイ可能なため、Stack を分けている
    cloudFrontWafStack = new CloudFrontWafStack(app, `CloudFrontWafStack${stackNameSuffix}`, {
      env: {
        account: process.env.CDK_DEFAULT_ACCOUNT,
        region: 'us-east-1',
      },
      allowedIpV4AddressRanges,
      allowedIpV6AddressRanges,
      allowedCountryCodes,
      hostName,
      domainName,
      hostedZoneId,
+     certificateArn, // 追加
      crossRegionReferences: true,
  });
}
```

### packages/cdk/lib/cloud-front-waf-stack.ts
CloudFront ディストリビューションに関連付けられる AWS WAF の Web ACL (Access Control List) と SSL/TLS 証明書を作成するコンストラクトです。以下の変更を加えます。

1. `CloudFrontWafStackProps` インターフェースに `certificateArn` プロパティを追加
2. 既存の証明書 ARN が提供された場合 (`props.certificateArn`)、その証明書を使用するように変更

```diff_typescript:packages/cdk/lib/cloud-front-waf-stack.ts
  interface CloudFrontWafStackProps extends StackProps {
    allowedIpV4AddressRanges: string[] | null;
    allowedIpV6AddressRanges: string[] | null;
    allowedCountryCodes: string[] | null;
    hostName?: string;
    domainName?: string;
    hostedZoneId?: string;
+   certificateArn?: string; // 追加
  }

  export class CloudFrontWafStack extends Stack {
    public readonly webAclArn: string;
    public readonly webAcl: CommonWebAcl;
    public readonly cert: ICertificate;
    constructor(scope: Construct, id: string, props: CloudFrontWafStackProps) {
      super(scope, id, props);
      if (
        props.allowedIpV4AddressRanges ||
        props.allowedIpV6AddressRanges ||
        props.allowedCountryCodes
      ) {
        const webAcl = new CommonWebAcl(this, `WebAcl${id}`, {
          scope: 'CLOUDFRONT',
          allowedIpV4AddressRanges: props.allowedIpV4AddressRanges,
          allowedIpV6AddressRanges: props.allowedIpV6AddressRanges,
          allowedCountryCodes: props.allowedCountryCodes,
        });
        new CfnOutput(this, 'WebAclId', {
          value: webAcl.webAclArn,
        });
        this.webAclArn = webAcl.webAclArn;
        this.webAcl = webAcl;
      }
      
-     if (props.hostName && props.domainName && props.hostedZoneId) {
+     if (props.certificateArn) {
+      // 既存の証明書ARNが提供されている場合、その証明書を使用
+       this.cert = Certificate.fromCertificateArn(
+         this, 
+         'ExistingCert',
+         props.certificateArn
+       );
+     } else if (props.hostName && props.domainName && props.hostedZoneId) {
+       // ホスト名、ドメイン名、ホストゾーンIDがすべて提供された場合のみ、新しい証明書を作成

+       // 既存のホストゾーンを参照
        const hostedZone = HostedZone.fromHostedZoneAttributes(
          this,
          'HostedZone',
          {
            hostedZoneId: props.hostedZoneId,
            zoneName: props.domainName,
          }
        );
+       // 新しい証明書を作成 
-       const cert = new Certificate(this, 'Cert', {
+       this.cert = new Certificate(this, 'Cert', {
          domainName: `${props.hostName}.${props.domainName}`,
          validation: CertificateValidation.fromDns(hostedZone),
        });
-       this.cert = cert;
      }
    }
  }
```

### packages/cdk/lib/construct/web.ts
Web アプリケーションのインフラストラクチャを定義するコンストラクトです。CloudFront と S3 を使用した静的サイトのホスティングや Web アプリケーションのビルド、オプションで WAF やカスタムドメインの設定をおこないます。以下の変更を加えます。

1. カスタムドメインの設定条件の変更
`props.hostedZoneId` の確認を削除し、`props.cert`、`props.hostName`、`props.domainName` が設定されている場合にカスタムドメインを設定するように変更します。
1. DNS レコード作成ロジックの変更
ホストゾーン ID が設定されている場合は修正前と同様に DNS レコードを作成します。ホストゾーン ID が提供されていない場合は、DNS レコードを作成せず、代わりに手動でレコード作成が必要な旨の警告メッセージを出力します。

```diff_typescript:packages/cdk/lib/construct/web.ts
      if (
        props.cert &&
        props.hostName &&
-       props.domainName &&
-       props.hostedZoneId
+       props.domainName
      ) {
        cloudFrontToS3Props.cloudFrontDistributionProps.certificate = props.cert;
        cloudFrontToS3Props.cloudFrontDistributionProps.domainNames = [
          `${props.hostName}.${props.domainName}`,
        ];
      }
      const { cloudFrontWebDistribution, s3BucketInterface } = new CloudFrontToS3(
        this,
        'Web',
        cloudFrontToS3Props
      );

-     if (
-       props.cert &&
-       props.hostName &&
-       props.domainName &&
-       props.hostedZoneId
-     ) {
-       // DNS record for custom domain
-       const hostedZone = HostedZone.fromHostedZoneAttributes(
-         this,
-         'HostedZone',
-         {
-           hostedZoneId: props.hostedZoneId,
-           zoneName: props.domainName,
-         }
-       );
-       new ARecord(this, 'ARecord', {
-         zone: hostedZone,
-         recordName: props.hostName,
-         target: RecordTarget.fromAlias(
-           new CloudFrontTarget(cloudFrontWebDistribution)
-         ),
-       });
-     }

+     if (props.hostName && props.domainName) {
+       if (props.hostedZoneId) {
+         // ホストゾーンIDが提供されている場合、既存のホストゾーンを参照
+         const hostedZone = HostedZone.fromHostedZoneAttributes(
+           this,
+           'HostedZone',
+           {
+             hostedZoneId: props.hostedZoneId,
+             zoneName: props.domainName,
+           }
+         );
+         // CloudFront ディストリビューションを指すエイリアスレコードを作成
+         new ARecord(this, 'ARecord', {
+           zone: hostedZone,
+           recordName: props.hostName,
+           target: RecordTarget.fromAlias(
+             new CloudFrontTarget(cloudFrontWebDistribution)
+           ),
+         });
+       } else {
+         // ホストゾーンIDが提供されていない場合、エイリアスレコードを作成せず、警告を出力
+         console.warn('hostedZoneId not provided. DNS record will not be created automatically.');
+       }
+     }
```

### packages/cdk/cdk.json
context に `certificateArn` を追加します。

```diff_json:packages/cdk/cdk.json
  {
    "context": {
      "hostName": null,
      "domainName": null,
      "hostedZoneId": null,
+     "certificateArn": null
    }
  }
```

## まとめ
全体として以下のような動作となるよう変更を加えました。

* `generative-ai-use-cases.ts` で `certificateArn` が設定された場合、`CloudFrontWafStack` に渡されます
* `CloudFrontWafStack` は受け取った `certificateArn` を使用して証明書を設定し、その証明書は `Web` コンストラクトに渡されます
* `Web` コンストラクトは、受け取った証明書とドメイン情報を使用して CloudFront ディストリビューションを設定します

既存の ACM 証明書を指定してデプロイする場合は、例えば以下のように cdk.json を編集し、デプロイします。

```packages/cdk/cdk.json
{
  "context": {
    "hostName": "genai",
    "domainName": "example.com",
    "certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  }
}
```

```shell-session
$ npm run cdk:deploy
```

以上です。
参考になれば幸いです。