コンテンツにスキップ

Phase 10:同じサイトを AWS にも置く(独自ドメイン)

目標

  • この学習サイトを独自ドメイン + HTTPS で公開します(OCI 版の URL は長くて覚えにくく、ネームスペースも含まれます)
  • あわせて AWS の流れを一通り体験します:アカウントの初期設定 → Route 53 → ACM → CloudFront → S3(OAC)→ IAM
  • OCI 版は残します → 同じサイトを 2 つのクラウドで。1 回の push で両方が更新されます

構成

flowchart LR
  push["git push"] --> build["OCI DevOps のビルド"]
  build -->|"リソース・プリンシパル"| oci["OCI Object Storage<br/>(OCI 版)"]
  build -->|"Access Key を読む"| vault["OCI ボールト<br/>シークレット"]
  build -->|"Access Key(最小権限)"| s3["S3(非公開)"]
  user["ブラウザ"] -->|"https://独自ドメイン"| r53["Route 53"]
  r53 --> cf["CloudFront<br/>ACM 証明書"]
  cf -->|"OAC(SigV4)"| s3
ステップ 内容 OCI との対応
10-1 アカウントのセキュリティ設定(ルートの MFA、Budget、Identity Center の管理者)、ローカル PC の CLI、state 用バケット Phase 0 / 3
10-2 Route 53 でドメインを登録(コンソールで手動) —
10-3 S3 + CloudFront(OAC)+ ACM + Route 53 のレコード Phase 7(公開バケット)
10-4 OCI DevOps のビルドから S3 にもアップロード(AWS のキーは OCI のボールト(Vault)に保管) Phase 7 / 9
10-aws-site/
├── bootstrap/     state 用の S3 バケット(このディレクトリ自身は local state)
└── site/          state:S3 backend + use_lockfile
    ├── dns.tf         ホストゾーン(data source)/ ACM 証明書 / 検証用 CNAME / A・AAAA エイリアス
    ├── s3.tf          非公開バケット + バケット・ポリシー(このディストリビューションだけ許可)
    ├── cloudfront.tf  OAC / www リダイレクトの Function / ディストリビューション
    └── deploy.tf      OCI DevOps 用の IAM ユーザー(最小権限)
07-site/aws.tf     OCI 側:ボールト + キー + シークレット(AWS の Access Key)
tools/upload_site_s3.py        site/ → S3 同期 + CloudFront のキャッシュ削除
tools/rotate_aws_deploy_key.py 新しいキーを発行 → OCI のボールトに書き込む → 古いキーを削除

費用(このプロジェクトで初めての出費)

項目 費用
ドメイン(.com) 年 $16(Route 53 の登録料金。申し込み時に課金され、返金不可)
Route 53 のホストゾーン 月 $0.50
ACM のパブリック証明書 無料(DNS 検証 → 自動更新)
Route 53 のエイリアス・クエリ(CloudFront 宛て) 無料
CloudFront 無期限の無料枠:月 1TB の転送、1,000 万リクエスト、200 万回の Function 実行
S3 サイト数 MB + state 数 KB → 月 $0.01 未満
OCI のボールト / シークレット 無料(ソフトウェア・キー、シークレット 150 個まで)

→ 年間でおよそ $22 です。AWS アカウントには Budget(月 30 USD、メール通知)を設定しています。

OCI 版と AWS 版の比較

OCI 版(Phase 7) AWS 版
URL objectstorage.<region>.oraclecloud.com/n/<namespace>/b/.../o/index.html https://<独自ドメイン>/
バケット 公開(ObjectReadWithoutList) 非公開、CloudFront だけが読める(OAC)
HTTPS 証明書 oraclecloud.com のもの(変更不可) ACM(独自ドメイン、自動更新)
CDN / キャッシュ なし CloudFront(世界中のエッジ)
存在しないページ Object Storage の JSON MkDocs の 404 ページ(ステータス 404)
セキュリティ・ヘッダー 付けられない HSTS、nosniff、X-Frame-Options など(マネージドのレスポンス・ヘッダー・ポリシー)
ディレクトリの URL(/xxx/) index.html を返さない ルートは可(default_root_object)、サブディレクトリは同じく不可 → どちらも use_directory_urls: false
費用 0(Always Free) ドメイン + ホストゾーンで年 $22 程度
緊急停止 site_public = false(バケットを非公開に) site_enabled = false(ディストリビューションを無効化、反映に数分)

アカウントと権限

10-1:アカウントの初期設定(コンソールで手動)

  • ルート・ユーザー:MFA を有効化し、Access Key がないことを確認 → 以後ルートは使いません
  • Budget:月 30 USD(実績 85% / 100%、予測 100% でメール)
  • AWS Organizations(アカウント 1 つ)→ IAM Identity Center → ユーザー + 許可セット AdministratorAccess(セッション 4 時間)
  • 普段はアクセス・ポータル(https://d-xxxxxxxxxx.awsapps.com/start)からサインインします。ローカル PC の CLI は aws configure sso → 一時的な認証情報なので、ディスク上に長期のキーはありません
ID の種類 通常のコンソールのサインイン画面を使えるか
ルート(メール + パスワード) 使える
IAM ユーザー(アカウント ID + ユーザー名) 使える
Identity Center のユーザー 使えない → アクセス・ポータル

AdministratorAccess ≒ ルート、ただし同じではない

ルートにしかできないこと:ルートのメール / パスワードの変更、アカウントの解約、サポート・プランの変更、一部の請求設定、「全員を締め出した」バケット・ポリシーの解除など。 また、Organizations の管理アカウントは SCP の制限を受けません → MFA と長期キーを持たないことで守ります。

10-3:S3 は CloudFront にだけ読ませる

Allow  cloudfront.amazonaws.com  s3:GetObject  <サイトのバケット>/*
       条件:AWS:SourceArn = <このディストリビューションの ARN>
Deny   *  s3:*  (aws:SecureTransport = false)
  • s3:ListBucket を与えていない → 存在しないファイルに S3 は 403 を返す → CloudFront で 403 / 404 をどちらも /404.html(ステータス 404)に置き換えます

10-4:OCI のビルドから AWS にアップロード

OCI のビルド・ランナーは AWS のリソースではない → IAM ロールが使えない → IAM ユーザーの Access Key(無期限)を発行するしかありません。その代わりに次の対策をとりました。

対策 内容
最小権限 s3:ListBucket(サイトのバケット)、s3:PutObject / s3:DeleteObject(サイトのバケットのオブジェクト)、cloudfront:CreateInvalidation / GetInvalidation(このディストリビューション)だけ。GetObject すらありません
キーを Terraform に通さない aws_iam_access_key の secret は state に平文で残る → スクリプトでキーを作成し、そのまま OCI ボールトのシークレットに書き込みます(表示しない、ファイルに残さない)
OCI 側はこのシークレットしか読めない read secret-bundles ... where target.secret.id = '<このシークレット>'
キーを環境変数に入れない build_spec の vaultVariables は使わず、アップロード・スクリプトがリソース・プリンシパルでシークレットを読みます。キーはプロセスのメモリ上にだけあります
定期的なローテーション tools/rotate_aws_deploy_key.py:新しいキーを作成 → シークレットに書き込み → 成功してから古いキーを削除(書き込みに失敗したら新しいキーを削除)
# OCI(07-site/devops.tf のポリシーに 1 行追加)
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<シークレット>'
  • Terraform ではシークレットの「枠」だけを作ります:中身はプレースホルダー + ignore_changes = [secret_content](そうしないと次の apply で本物の値がプレースホルダーに戻されます)
  • バケット名 / ディストリビューション ID / シークレットの OCID はビルド・パイプラインのパラメータ → build_spec.yaml には書きません(サイトのページに埋め込まれるため)

手順と観察

10-1:ローカル PC の AWS CLI と state 用バケット

確認項目 結果
aws sts get-caller-identity --profile tflab-aws assumed-role/AWSReservedSSO_AdministratorAccess_.../<ユーザー名>
state 用バケット(リソース 7 個) バージョニング / SSE-S3 / ブロック・パブリック・アクセスすべて有効 / ACL 無効 / 古いバージョンは 90 日で削除
state 用バケットに匿名で HTTPS アクセス 403
認証情報ありで HTTP AccessDenied ... explicit deny in a resource-based policy(HTTPS のみ許可)
  • S3 のバケット名は全世界で一意(OCI はネームスペース内で一意なら可)→ bucket_prefix + ランダムな接尾辞
  • AWS provider v4 以降、バケットのバージョニング / 暗号化 / ライフサイクルなどは別リソースです(aws_s3_bucket の中に書く方法は deprecated)
  • ロック:S3 backend の use_lockfile = true(Terraform 1.10 以降)→ バケット内の .tflock オブジェクト。以前は DynamoDB のテーブルが別途必要でした

10-2:ドメイン

  • Route 53 → ドメインの登録:1 年、自動更新はオフ、プライバシー保護はオン(WHOIS に個人情報が出ない)
  • 登録には数時間かかります。完了するとホストゾーンが自動で作られ、レジストラの NS とゾーンの NS が一致します
  • 届いた確認メールのリンクを 15 日以内にクリックする必要があります(しないとドメインが停止されます)
  • Terraform ではホストゾーンを data source で読み、import はしません(import すると destroy でゾーンごと削除されます → Phase 8 の予算と同じ考え方)

10-3:apply(リソース 16 個)

リソース 所要時間
ACM 証明書(us-east-1) 9 秒
検証用 CNAME ×2 33 秒
証明書の ISSUED 待ち 1 秒(DNS が Route 53 にある → すぐに検証完了)
CloudFront ディストリビューション 10 分 47 秒(世界中のエッジに展開)
A / AAAA エイリアス ×4 32 秒
確認項目 結果
トップ / 日本語ページ / Phase のページ 200、Content-Type も正しい
存在しないページ 404 + MkDocs の 404 ページ
http:// 301 → https://
www.<ドメイン>/xxx 301 → https://<ドメイン>/xxx(CloudFront Function)
レスポンス・ヘッダー HSTS、X-Content-Type-Options、X-Frame-Options、Referrer-Policy、Alt-Svc: h3
2 回目のリクエスト X-Cache: Hit from cloudfront
S3 の URL に直接アクセス 403
POST 拒否(GET / HEAD のみ許可)
  • ACM 証明書は us-east-1 に必要です(CloudFront はグローバル・サービスで、このリージョンの証明書しか使えない)→ provider に alias = "us_east_1" を追加
  • アップロード:tools/upload_site_s3.py(OCI 版と同じルール:Content-Type を自分で指定、MD5 が変わったファイルだけ送る、余分なオブジェクトは削除)+ 変更があれば invalidation /*(月 1,000 パスまで無料)
    • aws s3 sync を使わない理由:Content-Type を推測に頼る(Windows ではレジストリを読む)、「変わったか」をサイズと時刻で判断する(ビルドのたびに時刻が変わる → 毎回全件送信)

10-4:権限のテスト(deployer のキー)

操作 結果
サイトのバケットの一覧 / PUT / DELETE / CreateInvalidation 成功
サイトのバケットの GetObject AccessDenied
state 用バケットの List / PUT AccessDenied
サイトのバケットのポリシーを削除 AccessDenied
ListBuckets / CloudFront の設定 / IAM AccessDenied
  • キーをシークレットに書き込んだあとに再度 plan → No changes(ignore_changes が効いている)

ハマりどころ

新しいドメインが利用中のネットワークのフィルターでブロックされる

HTTP → ブロック・ページ(Category: high-risk)、HTTPS → TLS ハンドシェイクが reset されます。登録したばかりのドメインは高リスクと判定されがちです。 → 回避はしません。ローカル PC では https://<xxxx>.cloudfront.net + Host ヘッダーで確認し(curl -H "Host: www.<ドメイン>" で www のリダイレクトも確認可)、独自ドメインはスマートフォン(モバイル回線)で確認しました。

winget install Amazon.AWSCLI → exit 1602

MSI には管理者権限(UAC)が必要で、ローカル PC のアカウントは管理者ではない → キャンセル扱いになりました。 → MSI の「管理者インストール」は展開だけで、管理者権限は不要です:

msiexec /a "<AWSCLIV2.msi>" /qn TARGETDIR="$env:LOCALAPPDATA\Programs\AWSCLIV2"
...\AWSCLIV2\Amazon\AWSCLIV2\aws.exe をユーザーの PATH に追加 → VS Code ごと再起動します(ターミナルは VS Code の起動時にしか PATH を読まない)。

アクセス・ポータルのブックマークが翌日開けない

「セッションがタイムアウトしたか、停止したようです」→ ブックマークしたのはサインイン途中の URL(...signin.aws/platform/login?workflowStateHandle=...、使い捨て)でした。 → https://d-xxxxxxxxxx.awsapps.com/start(~/.aws/config の sso_start_url)をブックマークします。

アクセス・ポータルにパスワードでサインインできない

「サインイン認証情報を確認できませんでした」:入力するのはユーザー名(メールではない)です。ユーザー名が存在しなくても、いったんパスワード画面に進んでからエラーになります。 → コンソールでこのユーザーの「パスワードをリセット」→ ワンタイム・パスワード → シークレット・ウィンドウでサインイン。

CLI は使えるのに Terraform は failed to refresh cached SSO token

CLI がキャッシュしたロールの認証情報はまだ有効でも、Terraform は SSO トークンで取り直そうとする → トークンが期限切れでした。 → aws sso login --profile tflab-aws。

.gitignore の site/ が 10-aws-site/site/ まで除外していた

site/(MkDocs の出力)はどの階層の同名ディレクトリにも一致します → /site/(ルートだけ除外)に変更。git status --ignored で確認します。

その他

  • CloudFront のマネージド・ポリシー(Managed-CachingOptimized など)は data source で名前から ID を引き、UUID を直書きしない
  • Route 53 Domains の API は us-east-1 だけ(--region us-east-1)
  • ルートの 404.html が日本語版になっている(多言語プラグインで後からビルドされた言語が上書き)→ 中国語ページの 404 も日本語で表示される(未対応)

コード全体

10-aws-site/bootstrap/main.tf
# ------------------------------------------------------------
# Phase 10-1:Terraform state を置く S3 bucket(03-modules/bootstrap に相当)
#   鶏と卵の問題は OCI 版と同じ:このディレクトリ自身の state はローカルに残し、bucket の作成だけを担当する
#   → 10-aws-site のほかのディレクトリは backend "s3" + use_lockfile で state をこの bucket に置く
# ------------------------------------------------------------

resource "aws_s3_bucket" "tfstate" {
  # S3 の bucket 名は全世界で一意(OCI は namespace 内で一意であればよい)
  # → prefix を使い、AWS に後ろへランダムなサフィックスを付けさせる。アカウント ID はサフィックスに使わない:bucket 名は backend.hcl やログに出る
  bucket_prefix = "tflab-tfstate-"

  # bucket の誤削除防止(destroy 時にエラーになる)。本当に削除するときは先にこの行を false にする
  lifecycle {
    prevent_destroy = true
  }
}

# state が壊れたときに旧バージョンへ戻せる
resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration {
    status = "Enabled"
  }
}

# 旧バージョンが無限にたまるとストレージ料金がかかり続ける → 90 日後に削除(apply のたびに新しいバージョンができる)
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id

  rule {
    id     = "expire-old-state-versions"
    status = "Enabled"
    filter {}

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
    abort_incomplete_multipart_upload {
      days_after_initiation = 1
    }
  }

  # ルールはバージョニングを有効にしてからでないと意味がない
  depends_on = [aws_s3_bucket_versioning.tfstate]
}

# state にはリソースの詳細や機密情報まで入る。絶対に公開しない(OCI の NoPublicAccess に相当)
# 2023 年以降に作成した bucket はデフォルトでこうなるが、ここでは明示しておく。誰かが Console で無効にしても気づかれない事態を防ぐ(drift になる)
resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# ACL を無効化:アクセス制御は IAM / bucket policy だけで行う(AWS が現在推奨するやり方で、新しい bucket のデフォルト値でもある)
resource "aws_s3_bucket_ownership_controls" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# 保存時の暗号化:SSE-S3(AWS 管理のキー、無料)
#   SSE-KMS の CMK は 1 本あたり 1 USD/月。ここでは「キーを自分で管理する」必要がない → 使わない(Phase 9 の OCI Vault と比較)
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

# HTTPS を使わないリクエストを拒否(転送中の暗号化)
data "aws_iam_policy_document" "tfstate" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  policy = data.aws_iam_policy_document.tfstate.json

  # 先に Block Public Access を効かせ、policy が公開と判定されて失敗するのを避ける
  depends_on = [aws_s3_bucket_public_access_block.tfstate]
}
10-aws-site/site/dns.tf
# ------------------------------------------------------------
# Phase 10-3:Route 53(DNS)+ ACM(証明書)
# ------------------------------------------------------------

# Hosted zone はドメイン登録時に AWS が自動で作成したもの → data source で読むだけ、import しない
#   import すると destroy 時に zone ごと削除される(08-guard の Budget と同じ理屈)
data "aws_route53_zone" "site" {
  name         = var.domain_name
  private_zone = false
}

# 証明書:apex + www。DNS 検証 → zone に CNAME を追加し、ACM がドメインの所有を確認する
#   DNS 検証の証明書は CNAME が残っている限り自動更新される(メール検証のように毎年リンクをクリックする必要はない)
#   パブリック証明書は無料
resource "aws_acm_certificate" "site" {
  provider = aws.us_east_1

  domain_name               = var.domain_name
  subject_alternative_names = ["www.${var.domain_name}"]
  validation_method         = "DNS"

  # 証明書の差し替え時(例:ドメイン追加)は新しいものを先に作成し、CloudFront を切り替えてから古いものを削除 → サイトが止まらない
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for o in aws_acm_certificate.site.domain_validation_options : o.domain_name => {
      name   = o.resource_record_name
      type   = o.resource_record_type
      record = o.resource_record_value
    }
  }

  zone_id         = data.aws_route53_zone.site.zone_id
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  ttl             = 300
  allow_overwrite = true # 証明書の再作成時に CNAME の値が変わらないことがある → 同名レコードの上書きを許可
}

# ACM の検証完了(証明書が ISSUED になる)を待つ。CloudFront は ISSUED の証明書しか使えない
resource "aws_acm_certificate_validation" "site" {
  provider = aws.us_east_1

  certificate_arn         = aws_acm_certificate.site.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

# ドメイン → CloudFront。alias レコード:Route 53 自身が CloudFront の IP を解決する(apex には CNAME を使えない)
#   AWS リソースを指す alias のクエリは無料
#   A(IPv4)と AAAA(IPv6)の両方を作成する。distribution は IPv6 を有効にしている
locals {
  site_hosts = toset([var.domain_name, "www.${var.domain_name}"])
  site_records = {
    for pair in setproduct(local.site_hosts, ["A", "AAAA"]) : "${pair[0]}_${pair[1]}" => {
      name = pair[0]
      type = pair[1]
    }
  }
}

resource "aws_route53_record" "site" {
  for_each = local.site_records

  zone_id = data.aws_route53_zone.site.zone_id
  name    = each.value.name
  type    = each.value.type

  alias {
    name                   = aws_cloudfront_distribution.site.domain_name
    zone_id                = aws_cloudfront_distribution.site.hosted_zone_id
    evaluate_target_health = false
  }
}
10-aws-site/site/s3.tf
# ------------------------------------------------------------
# Phase 10-3:サイトのファイルを置く S3 bucket(プライベート)
#   07-site との比較:OCI は bucket 自体を公開(ObjectReadWithoutList)
#   AWS のここでは bucket は完全にプライベートで、「この 1 つの CloudFront distribution」だけに読み取りを許可(OAC)
#   → CloudFront を経由せず S3 の URL に直接アクセスすると 403。HTTPS / 独自ドメイン / キャッシュはすべて CloudFront が担当
# ------------------------------------------------------------

resource "aws_s3_bucket" "site" {
  bucket_prefix = "tflab-site-"

  # 中身はいつでも Git から build し直せる → バージョニングは有効にしない。destroy 時は中のファイルごと削除
  force_destroy = true
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket                  = aws_s3_bucket.site.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_ownership_controls" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
  bucket = aws_s3_bucket.site.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256" # SSE-KMS だと CloudFront(OAC)に kms:Decrypt 権限も必要 → 公開サイトには不要
    }
  }
}

data "aws_iam_policy_document" "site" {
  # この distribution だけにオブジェクトの読み取りを許可(AWS:SourceArn で distribution の ARN に限定)
  #   s3:ListBucket がない → 存在しないファイルに S3 は 403 を返す(404 ではない)→ cloudfront.tf で 403 を 404 ページに置き換える
  statement {
    sid       = "AllowCloudFrontRead"
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }

  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [aws_s3_bucket.site.arn, "${aws_s3_bucket.site.arn}/*"]

    principals {
      type        = "*"
      identifiers = ["*"]
    }
    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.site.json

  depends_on = [aws_s3_bucket_public_access_block.site]
}
10-aws-site/site/cloudfront.tf
# ------------------------------------------------------------
# Phase 10-3:CloudFront(CDN + HTTPS + 独自ドメイン)
#   ブラウザ → https://<ドメイン> → CloudFront のエッジロケーション(キャッシュ)→ キャッシュがなければ OAC の署名付きで S3 から取得
# ------------------------------------------------------------

# OAC(Origin Access Control):CloudFront が SigV4 署名で S3 にアクセス → S3 の bucket policy がこの署名を認める
#   旧方式の OAI(Origin Access Identity)は非推奨
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = aws_s3_bucket.site.id
  description                       = "tf-lab site bucket"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# AWS 定義済みのポリシー:名前で ID を引き、UUID をコードにハードコードしない
data "aws_cloudfront_cache_policy" "optimized" {
  name = "Managed-CachingOptimized" # cookie / query string をキャッシュキーに含めない → 静的サイトでヒット率が最も高い
}

data "aws_cloudfront_response_headers_policy" "security" {
  name = "Managed-SecurityHeadersPolicy" # HSTS、X-Content-Type-Options、X-Frame-Options など
}

# www.<ドメイン> → https://<ドメイン>(301)。正式な URL は 1 つだけにする
#   CloudFront Functions:毎月 200 万回まで無料(Always Free)
resource "aws_cloudfront_function" "www_redirect" {
  name    = "tflab-www-redirect"
  runtime = "cloudfront-js-2.0"
  comment = "Redirect www to apex"
  publish = true
  code    = <<-JS
    function handler(event) {
      var request = event.request;
      if (request.headers.host.value === 'www.${var.domain_name}') {
        return {
          statusCode: 301,
          statusDescription: 'Moved Permanently',
          headers: { location: { value: 'https://${var.domain_name}' + request.uri } }
        };
      }
      return request;
    }
  JS
}

resource "aws_cloudfront_distribution" "site" {
  enabled             = var.site_enabled
  comment             = "tf-lab study site"
  aliases             = [var.domain_name, "www.${var.domain_name}"]
  default_root_object = "index.html" # https://<ドメイン>/ → index.html(サブディレクトリには効かないが、mkdocs は use_directory_urls: false)
  is_ipv6_enabled     = true
  http_version        = "http2and3"

  # エッジロケーションの範囲:200 = 南米 / オセアニア以外(日本を含む)。100 は北米 + 欧州のみ → 日本からのアクセスが遠回りになる
  #   無料枠(毎月 1TB / 1,000 万リクエスト)は price class に関係ない
  price_class = "PriceClass_200"

  origin {
    origin_id                = "s3-site"
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  default_cache_behavior {
    target_origin_id           = "s3-site"
    viewer_protocol_policy     = "redirect-to-https"
    allowed_methods            = ["GET", "HEAD"]
    cached_methods             = ["GET", "HEAD"]
    compress                   = true
    cache_policy_id            = data.aws_cloudfront_cache_policy.optimized.id
    response_headers_policy_id = data.aws_cloudfront_response_headers_policy.security.id

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.www_redirect.arn
    }
  }

  # 存在しないページ:S3 は 403 を返す(OAC に ListBucket を与えていない)→ MkDocs の 404.html を表示し、ステータスコードは 404
  #   OCI 版ではできない(Object Storage は独自の JSON を返す)
  dynamic "custom_error_response" {
    for_each = [403, 404]
    content {
      error_code            = custom_error_response.value
      response_code         = 404
      response_page_path    = "/404.html"
      error_caching_min_ttl = 60
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate_validation.site.certificate_arn
    ssl_support_method       = "sni-only"     # dedicated IP は毎月 $600
    minimum_protocol_version = "TLSv1.2_2021" # TLS 1.0 / 1.1 は受け付けない
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
10-aws-site/site/deploy.tf
# ------------------------------------------------------------
# Phase 10-4:OCI DevOps の build がこのサイトへアップロードするための IAM ユーザー
#   OCI の build runner は AWS のリソースではない → IAM Role(AWS で「プログラムが自分の ID を使う」やり方)は使えない
#   → Access Key(長期有効)を発行するしかない。そのため、できることを最小限に絞る:
#     サイト bucket のオブジェクトの一覧 / 書き込み / 削除と、この distribution のキャッシュ削除だけ(bucket 自体やほかのリソースには触れない)
#   Access Key は Terraform では作成しない:aws_iam_access_key の secret が平文で state に残る
#     → tools/rotate_aws_deploy_key.py が Key を作成し、そのまま OCI Vault の Secret に書き込む(ローカルに保存せず、表示もしない)
# ------------------------------------------------------------

resource "aws_iam_user" "deployer" {
  name = "tflab-site-deployer"
  path = "/tflab/"
}

data "aws_iam_policy_document" "deployer" {
  # 同期時に bucket 内の既存オブジェクトの ETag と比較する(list_objects_v2)
  statement {
    sid       = "ListSiteBucket"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.site.arn]
  }

  statement {
    sid       = "WriteSiteObjects"
    actions   = ["s3:PutObject", "s3:DeleteObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]
  }

  statement {
    sid       = "InvalidateSiteCache"
    actions   = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
    resources = [aws_cloudfront_distribution.site.arn]
  }
}

# ユーザーのインライン policy(このユーザー専用。独立した管理 policy は不要)
resource "aws_iam_user_policy" "deployer" {
  name   = "site-deploy"
  user   = aws_iam_user.deployer.name
  policy = data.aws_iam_policy_document.deployer.json
}
07-site/aws.tf
# ------------------------------------------------------------
# Phase 10-4:build 後に AWS にもアップロード(10-aws-site/site の S3 + CloudFront)
#   AWS の Access Key(IAM ユーザー tflab-site-deployer)はここの Vault Secret に入れる
#   build runner は Resource Principal でこの Secret を読む → コード、build_spec、build ログのどこにも Key はない
#
#   Vault(DEFAULT、無料)
#    └─ マスター暗号化キー(SOFTWARE、無料)
#         └─ Secret:{"access_key_id": ..., "secret_access_key": ...}(JSON)
#
#   Secret の中身は Terraform を通さない:
#     Terraform は Secret(中身はプレースホルダー)を作成するだけ + ignore_changes
#     本当の値は tools/rotate_aws_deploy_key.py が新しいバージョンとして書き込む(そうしないと次の apply で本当の値がプレースホルダーに戻される)
#
# ⚠ 削除:destroy 後は「削除保留中」になり(7〜30 日)、その間は同名の Secret を再作成できない
# ------------------------------------------------------------

resource "oci_kms_vault" "site" {
  compartment_id = var.compartment_ocid
  display_name   = "${var.project_name}-site-vault"
  vault_type     = "DEFAULT"

  freeform_tags = local.common_tags
}

resource "oci_kms_key" "site" {
  compartment_id      = var.compartment_ocid
  display_name        = "${var.project_name}-site-key"
  management_endpoint = oci_kms_vault.site.management_endpoint
  protection_mode     = "SOFTWARE"

  key_shape {
    algorithm = "AES"
    length    = 32
  }

  freeform_tags = local.common_tags
}

resource "oci_vault_secret" "aws_deployer" {
  compartment_id = var.compartment_ocid
  vault_id       = oci_kms_vault.site.id
  key_id         = oci_kms_key.site.id
  secret_name    = "${var.project_name}-aws-site-deployer"
  description    = "AWS access key of IAM user tflab-site-deployer (written by tools/rotate_aws_deploy_key.py)"

  secret_content {
    content_type = "BASE64"
    content      = base64encode(jsonencode({ placeholder = true }))
  }

  lifecycle {
    ignore_changes = [secret_content]
  }

  freeform_tags = local.common_tags
}