跳转至

Phase 10:同一个网站放到 AWS(自己的域名)

目标

  • 用自己的域名 + HTTPS 发布这个学习网站(OCI 版的 URL 又长又难记,还带着 namespace)
  • 顺便走一遍 AWS 的流程:账号的初始设置 → Route 53 → ACM → CloudFront → S3(OAC)→ IAM
  • OCI 版保留 → 同一个网站、两朵云,push 一次两边都更新

结构

flowchart LR
  push["git push"] --> build["OCI DevOps build"]
  build -->|"Resource Principal"| oci["OCI Object Storage<br/>(OCI 版)"]
  build -->|"读 Access Key"| vault["OCI Vault<br/>Secret"]
  build -->|"Access Key(最小权限)"| s3["S3(私有)"]
  user["浏览器"] -->|"https://自己的域名"| r53["Route 53"]
  r53 --> cf["CloudFront<br/>ACM 证书"]
  cf -->|"OAC(SigV4)"| s3
步骤 内容 对照 OCI
10-1 账号的安全设置(root MFA、Budget、Identity Center 的管理员)、本机 CLI、state bucket Phase 0 / 3
10-2 Route 53 注册域名(Console 手动) —
10-3 S3 + CloudFront(OAC)+ ACM + Route 53 记录 Phase 7(公开 bucket)
10-4 OCI DevOps 的 build 也上传到 S3(AWS 的 Key 放 OCI Vault) Phase 7 / 9
10-aws-site/
├── bootstrap/     state 用的 S3 bucket(这个目录自己用 local state)
└── site/          state:S3 backend + use_lockfile
    ├── dns.tf         hosted zone(data source)/ ACM 证书 / 验证 CNAME / A・AAAA alias
    ├── s3.tf          私有 bucket + bucket policy(只允许这个 distribution)
    ├── cloudfront.tf  OAC / www 跳转 Function / distribution
    └── deploy.tf      OCI DevOps 用的 IAM 用户(最小权限)
07-site/aws.tf     OCI 侧:Vault + 密钥 + Secret(AWS 的 Access Key)
tools/upload_site_s3.py        site/ → S3 同步 + CloudFront 清缓存
tools/rotate_aws_deploy_key.py 发新 Key → 写进 OCI Vault → 删旧 Key

费用(这个项目第一次花钱)

项目 费用
域名(.com) $16 / 年(Route 53 的注册价格。下单即扣款、不能退)
Route 53 hosted zone $0.50 / 月
ACM 公开证书 免费(DNS 验证 → 自动续期)
Route 53 的 alias 查询(指向 CloudFront) 免费
CloudFront 永久免费额度:每月 1TB 流量、1000 万次请求、200 万次 Function 调用
S3 网站几 MB + state 几 KB → 每月不到 $0.01
OCI Vault / Secret 免费(软件密钥、Secret 150 个以内)

→ 一年大约 $22。AWS 账号设了 Budget(月 30 USD,邮件通知)。

OCI 版和 AWS 版的比较

OCI 版(Phase 7) AWS 版
URL objectstorage.<region>.oraclecloud.com/n/<namespace>/b/.../o/index.html https://<自己的域名>/
bucket 公开(ObjectReadWithoutList) 私有,只有 CloudFront 能读(OAC)
HTTPS 证书 oraclecloud.com 的(不能换) ACM(自己的域名,自动续期)
CDN / 缓存 没有 CloudFront(全球边缘节点)
不存在的页面 Object Storage 的 JSON MkDocs 的 404 页面(状态码 404)
安全响应头 加不了 HSTS、nosniff、X-Frame-Options 等(托管的 response headers policy)
目录 URL(/xxx/) 不返回 index.html 根目录可以(default_root_object),子目录同样不行 → 两边都用 use_directory_urls: false
费用 0(Always Free) 域名 + hosted zone,约 $22/年
紧急停止 site_public = false(bucket 变私有) site_enabled = false(distribution 停用,几分钟生效)

账号和权限

10-1:账号的初始设置(Console 手动)

  • root:开 MFA、确认没有 Access Key → 以后不用 root
  • Budget:月 30 USD(实际 85% / 100%、预测 100% 时发邮件)
  • AWS Organizations(单账号)→ IAM Identity Center → 用户 + 许可集 AdministratorAccess(session 4 小时)
  • 日常从 access portal(https://d-xxxxxxxxxx.awsapps.com/start)登录;本机 CLI 用 aws configure sso → 临时凭证,磁盘上没有长期有效的 Key
身份 能不能用一般的 Console 登录页
root(邮箱 + 密码) 能
IAM 用户(账号 ID + 用户名) 能
Identity Center 的用户 不能 → access portal

AdministratorAccess ≒ root,但不完全一样

只有 root 能做的:改 root 的邮箱 / 密码、关闭账号、改 Support plan、部分账单设置、解除"把所有人都锁在外面"的 bucket policy 等。 另外 Organizations 的管理账号不受 SCP 限制 → 保护靠 MFA + 不存长期 Key。

10-3:S3 只让 CloudFront 读

Allow  cloudfront.amazonaws.com  s3:GetObject  <网站 bucket>/*
       条件:AWS:SourceArn = <这个 distribution 的 ARN>
Deny   *  s3:*  (aws:SecureTransport = false)
  • 没给 s3:ListBucket → 不存在的文件 S3 返回 403 → CloudFront 把 403 / 404 都换成 /404.html(状态码 404)

10-4:OCI 的 build 往 AWS 上传

OCI 的 build runner 不是 AWS 的资源 → 用不了 IAM Role → 只能发 IAM 用户的 Access Key(长期有效)。补偿措施:

措施 内容
最小权限 只有 s3:ListBucket(网站 bucket)、s3:PutObject / s3:DeleteObject(网站 bucket 的对象)、cloudfront:CreateInvalidation / GetInvalidation(这个 distribution)。连 GetObject 都没有
Key 不经过 Terraform aws_iam_access_key 的 secret 会以明文留在 state 里 → 脚本建 Key 后直接写进 OCI Vault 的 Secret(不显示、不落盘)
OCI 侧只能读这一个 Secret read secret-bundles ... where target.secret.id = '<这个 Secret>'
Key 不进环境变量 不用 build_spec 的 vaultVariables;上传脚本用 Resource Principal 读 Secret,Key 只在进程的内存里
定期轮换 tools/rotate_aws_deploy_key.py:建新 Key → 写进 Secret → 成功后才删旧 Key(写入失败时删新 Key)
# OCI(07-site/devops.tf 的 Policy,加了一条)
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<Secret>'
  • Secret 在 Terraform 里只建"框":内容是占位符 + ignore_changes = [secret_content](不然下次 apply 会把真值改回占位符)
  • bucket 名 / distribution ID / Secret 的 OCID:build pipeline 的参数 → 不写进 build_spec.yaml(网站页面会嵌入它)

流程和观察

10-1:本机的 AWS CLI 和 state bucket

确认项 结果
aws sts get-caller-identity --profile tflab-aws assumed-role/AWSReservedSSO_AdministratorAccess_.../<用户名>
state bucket(7 个资源) 版本管理 / SSE-S3 / Block Public Access 全开 / ACL 禁用 / 旧版本 90 天删除
匿名 HTTPS 访问 state bucket 403
带凭证、但用 HTTP AccessDenied ... explicit deny in a resource-based policy(只允许 HTTPS)
  • S3 的 bucket 名全世界唯一(OCI 只要 namespace 内唯一)→ bucket_prefix + 随机后缀
  • AWS provider v4 以后,bucket 的版本管理 / 加密 / 生命周期等都是单独的资源(aws_s3_bucket 里的写法是 deprecated)
  • 锁:S3 backend 的 use_lockfile = true(Terraform 1.10+)→ bucket 里的 .tflock 对象。以前要另建 DynamoDB 表

10-2:域名

  • Route 53 → ドメインの登録:1 年、自动续费关、隐私保护开(WHOIS 里看不到个人信息)
  • 注册要几个小时;完成后 hosted zone 自动建好,注册商的 NS 和 zone 的 NS 一致
  • 收到验证邮件后 15 天内要点链接(不点的话域名会被停用)
  • Terraform 里 hosted zone 用 data source 读,不 import(import 的话 destroy 会把 zone 一起删掉 → 和 Phase 8 的 Budget 同一个道理)

10-3:apply(16 个资源)

资源 用时
ACM 证书(us-east-1) 9 秒
验证用 CNAME ×2 33 秒
等证书 ISSUED 1 秒(DNS 在 Route 53 里 → 马上验证完)
CloudFront distribution 10 分 47 秒(同步到全球的边缘节点)
A / AAAA alias ×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 是全球服务,只认这个 Region 的证书)→ provider 加 alias = "us_east_1"
  • 上传:tools/upload_site_s3.py(和 OCI 版同样的规则:自己指定 Content-Type、只传 MD5 变了的文件、删掉多余的对象)+ 有变化时 invalidation /*(每月 1000 条路径免费)
    • 没用 aws s3 sync:Content-Type 靠猜(Windows 上读注册表)、"变没变"看大小和时间(每次 build 时间都变 → 全部重传)

10-4:权限测试(deployer 的 Key)

操作 结果
列网站 bucket / PUT / DELETE / CreateInvalidation 成功
网站 bucket 的 GetObject AccessDenied
state bucket 的 List / PUT AccessDenied
删除网站 bucket 的 policy AccessDenied
ListBuckets / CloudFront 的设置 / IAM AccessDenied
  • Key 写进 Secret 后再 plan → No changes(ignore_changes 生效)

坑

新域名被所在网络的过滤器拦截

HTTP → 拦截页(Category: high-risk),HTTPS → TLS 握手被 reset。刚注册的域名常被判为高风险。 → 不绕过。本机用 https://<xxxx>.cloudfront.net + Host 头确认(curl -H "Host: www.<域名>" 也能测 www 跳转),自己的域名用手机(移动网络)确认。

winget install Amazon.AWSCLI → exit 1602

MSI 要管理员权限(UAC),本机账号不是管理员 → 被取消。 → MSI 的"管理员安装"只解压、不需要管理员权限:

msiexec /a "<AWSCLIV2.msi>" /qn TARGETDIR="$env:LOCALAPPDATA\Programs\AWSCLIV2"
...\AWSCLIV2\Amazon\AWSCLIV2\aws.exe 加到用户 PATH → VS Code 整个重启(终端只在 VS Code 启动时读 PATH)。

access portal 的书签第二天打不开

"セッションがタイムアウトしたか、停止したようです" → 收藏的是登录途中的 URL(...signin.aws/platform/login?workflowStateHandle=...,一次性)。 → 收藏 https://d-xxxxxxxxxx.awsapps.com/start(~/.aws/config 的 sso_start_url)。

access portal 用密码登录不了

"サインイン認証情報を確認できませんでした":要填的是用户名(不是邮箱);用户名不存在也会先进入密码页再报错。 → Console 里对这个用户"パスワードをリセット"→ 一次性密码 → 无痕窗口登录。

CLI 能用,Terraform 却 failed to refresh cached SSO token

CLI 缓存的 role 凭证还没过期,但 Terraform 要用 SSO token 重新取 → token 过期了。 → 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 是日文版(多语言插件后 build 的语言覆盖了)→ 中文页面的 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 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 完全私有,只允许"这一个 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
#   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 / 1000 万请求)和 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 里"程序用自己的身份"的做法)
#   → 只能发 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
}