跳转至

CI/CD 和网站发布

一句话

这个网站 push 就是发布,所以检查必须放在发布之前:本机先检查、build 里再检查,任何一步失败都不上传。 凭证能不存在就不存在(OCI 上用服务自己的身份),不得不存在的放进 Vault、权限压到最小。Terraform 只管"放网站的地方",网站内容用脚本同步。

本页整理自 Phase 7 和 Phase 10 的实验。AWS 侧(S3 / CloudFront)的设计见 AWS 对照,权限设计的细节见 安全。

1. 先认识这些组件

本仓库用的 AWS / GitHub 的近似物 一句话
MkDocs(Material 主题) — 把 Markdown 生成静态 HTML 网站的工具。本网站就是它生成的
OCI DevOps Code Repository CodeCommit / GitHub 的私有仓库 托管的 Git 仓库。本仓库整个 push 到这里
Build Pipeline + Managed Build CodeBuild / GitHub Actions 按 build_spec.yaml 的步骤执行 build。runner(执行 build 的临时机器)由 OCI 准备,按 build 期间用量计费
Trigger Webhook / Actions 的 on: push "main 分支有 push → 启动 Build Pipeline"
build_spec.yaml buildspec.yml / workflow 的 YAML build 的步骤定义,放在仓库根目录
Resource Principal CodeBuild 的 IAM Role 服务自己的身份。build 拿着它调 OCI 的 API,不需要 API Key
Dynamic Group (IAM Role 的信任关系) "哪些资源算一组"的规则。Policy 再给这个组授权
Object Storage bucket S3 bucket 放网站文件的地方。本仓库的 OCI 版直接把 bucket 公开当网站用
Vault Secret Secrets Manager 存密码 / Key 的地方。本仓库用来存 AWS 的 Access Key
Auth Token Personal Access Token git push 时代替密码用的 token(HTTPS)

2. 原理:push 之后发生了什么

flowchart TB
  pc["① 本机<br/>push 前检查 → 人确认 → git push"] -->|"HTTPS + Auth Token"| repo["② Code Repository"]
  repo -->|"Trigger:push 到 main"| s1
  subgraph build["③ Build Pipeline(Managed Build runner)"]
    direction TB
    s1["检查<br/>Terraform / 日文副本 / 工具测试"] --> s2["构建<br/>mkdocs build --strict<br/>搜索索引检查"]
    s2 --> s5["上传 OCI"] --> s6["上传 AWS"]
  end
  s5 -->|"Resource Principal"| oci["Object Storage<br/>OCI 版"]
  s6 -->|"Vault 里的 Access Key"| aws["S3 + CloudFront<br/>AWS 版"]

三个原则:

  • 质量门在上传之前:Terraform 检查、日文副本、strict build、搜索索引,任何一个失败,后面的上传都不执行 → 坏掉的网站不会被发布
  • AWS 上传排在最后:AWS 那一步失败,OCI 版已经更新完了,不受影响
  • 内容不进 Terraform:网站文件每次 build 都会变,放进 Terraform 的话 plan 永远有差异。Terraform 只建 bucket、Pipeline、权限;文件用脚本同步

3. 网站本身的结构(MkDocs + 多语言)

做法 为什么
xxx.md = 中文,xxx.ja.md = 日文(mkdocs-static-i18n 插件,日文没翻译的页面显示中文) 一个仓库出两种语言的网站
页面里的代码用 --8<-- "07-site/main.tf" 直接嵌入仓库里的文件 代码只有一份,网站和真实代码不会不一致
日文页嵌入 i18n/ja/ 下的副本:只翻译注释,代码完全一致,tools/check_ja_code.py 检查 日文读者看日文注释;副本和原文件的代码一旦不一致,build 失败
use_directory_urls: false(生成 topics/oke.html,不生成 topics/oke/index.html) Object Storage 访问目录时不会自动返回 index.html
搜索插件显式指定 lang: [en, zh, ja],i18n 插件 reconfigure_search: false i18n 插件重新配置搜索时会去掉部分页面的主记录,日文页的章节搜索结果变成 undefined 链接(见第 11 节)

4. OCI DevOps 的权限:Resource Principal

# 07-site/devops.tf(摘录):tf-lab 里的 Build Pipeline 都算这个组
resource "oci_identity_dynamic_group" "site_build" {
  matching_rule = "ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '${var.compartment_ocid}'}"
}
# 给这个组的权限(Policy 挂在 tf-lab compartment 上)
Allow dynamic-group tflab-site-build to manage objects in compartment tf-lab where target.bucket.name = 'tflab-site'
Allow dynamic-group tflab-site-build to read devops-repository in compartment tf-lab
Allow dynamic-group tflab-site-build to use ons-topics in compartment tf-lab
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<那一个 Secret>'
  • 没有要保管的密钥:build 本身就有身份。如果用 GitHub Actions,就得把 OCI 的 API Key 放进 GitHub 的 Secrets
  • 能做的只有:网站 bucket 里对象的读写删、读代码仓库、发 build 通知、读 AWS Key 那一个 Secret。存 state 的 bucket、bucket 本身都碰不到
  • 脚本里切换认证方式:本机用 ~/.oci/config,build 里加 --auth resource_principal

repos 不是 DevOps 的代码仓库

一开始写 read repos,build 报 Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound。 repos 是 Container Registry(OCIR)的资源类型,DevOps 的代码仓库是 devops-repository。 排查方法:临时给 manage devops-family in compartment tf-lab → 成功 → 说明 Dynamic Group 和 Policy 的写法没问题,问题在资源类型。确认后改回最小权限。

5. build_spec.yaml 的步骤设计

步骤 做什么 失败时
Setup Python 找 3.11 以上的 Python(没有就装)→ venv → 装固定版本的依赖 不发布
Terraform checks fmt -check → 每个 root module init -backend=false + validate → tflint 不发布
Check site tools and ja code copies 工具的单元测试、日文代码副本一致性 不发布
Build site mkdocs build --strict(有警告就失败)→ 搜索索引检查 不发布
Upload to Object Storage 同步到 OCI 的 bucket —
Upload to AWS 同步到 S3 + 清 CloudFront 缓存 OCI 版已更新,不受影响

几个设计点(build_spec.yaml 摘录):

env:
  variables:
    # runner 镜像的 pip 全局配置里有 Oracle 内部代理(runner 里解析不了)
    # → pip 不读任何配置文件,直接连 PyPI
    PIP_CONFIG_FILE: "/dev/null"
steps:
  - type: Command
    name: Terraform checks
    command: |
      set -euo pipefail
      TF_VERSION=1.16.6          # runner 里没有 terraform → 下载和本机一致的固定版本
      # ...
      terraform -chdir="${d}" init -backend=false -input=false -no-color >/dev/null   # 不连 state、不需要认证
      terraform -chdir="${d}" validate -no-color
  • 每个 step 是独立的 shell:venv 用绝对路径(/tmp/venv/bin/python)调用,不靠 activate
  • set -euo pipefail:任何命令失败立刻停;参数没传进来(set -u)也直接报错,不会悄悄跳过
  • 版本全部固定(Terraform、tflint、Python 包):和本机一致,今天能过的检查明天也能过
  • 网站页面会嵌入 build_spec.yaml,所以 AWS 的 bucket 名、distribution ID、Secret 的 OCID 不写在里面,而是作为 Build Pipeline 的参数(Terraform 里定义,build 时变成环境变量)

6. Object Storage 当静态网站

限制 对策
访问目录不返回 index.html use_directory_urls: false
Content-Type 由上传时指定(不指定的话 HTML 会被当成下载) 上传脚本按扩展名写死(Windows 的 mimetypes 读注册表,结果因机器而异)
不存在的页面返回 Object Storage 自己的 JSON 没办法(AWS 版可以显示 MkDocs 的 404 页面)
URL 里一定带 namespace 公开网站就藏不住;存 state 的 bucket 是 NoPublicAccess,知道 namespace 也读不到
  • bucket 的 access_type = "ObjectReadWithoutList":匿名能读文件,不能列出文件清单(ObjectRead 连文件名一览都公开)
  • 上传脚本 tools/upload_site.py(和 AWS 版的 upload_site_s3.py 同样的规则):
    • 按 MD5 只传变了的文件 → 省 API 请求(Always Free 每月 5 万次)。内容没变时 uploaded 0 / unchanged 72
    • bucket 里有、site/ 里没有的文件删掉(页面改名后旧页面不残留)
    • Cache-Control:HTML no-cache(发布后马上生效),其他缓存 1 小时
  • 没用 OCI CLI / aws s3 sync:本机装不了 OCI CLI(Windows 路径长度上限);aws s3 sync 的 Content-Type 靠猜,"变没变"看大小和时间(每次 build 时间都变 → 全部重传)

7. 跨云发布:OCI 的 build 往 AWS 上传

OCI 的 build runner 不是 AWS 的资源,用不了 AWS 的 IAM Role,只能发 IAM 用户的 Access Key(长期有效)。所以用别的办法补偿:

措施 做法
权限最小 只能列 / 写 / 删网站 bucket 的对象、清这个 distribution 的缓存。连读对象(GetObject)都没有
Key 不经过 Terraform aws_iam_access_key 的 secret 会以明文留在 state 里 → 脚本建 Key 后直接写进 OCI Vault 的 Secret
Key 不进环境变量 上传脚本用 Resource Principal 读 Secret,Key 只在进程的内存里,不出现在日志里
定期轮换 tools/rotate_aws_deploy_key.py:建新 Key → 写进 Secret → 成功后才删旧 Key

Secret 在 Terraform 里只建"框":

# 07-site/aws.tf(摘录)
resource "oci_vault_secret" "aws_deployer" {
  secret_content {
    content_type = "BASE64"
    content      = base64encode(jsonencode({ placeholder = true }))   # 占位符
  }
  lifecycle {
    ignore_changes = [secret_content]   # 真值由脚本写入;不加这行,下次 apply 会改回占位符
  }
}

IAM 用户的权限设计和 Key 轮换见 安全,S3 / CloudFront 侧见 AWS 对照。

8. push 前后的流程

时机 做什么 工具
push 前 日文副本一致性、terraform fmt、strict build、搜索索引、敏感信息扫描(公开网站 + 要 push 的文件)、表格里代码中的竖线、缺日文页面 tools/prepublish_check.py → NG 0 才能往下走
push 前 人确认:要 push 的 commit、检查结果、会更新哪些页面 —
push git push oci main —
push 后 等 build 结束;成功后把改动过的页面换算成 URL,逐个确认 200 tools/wait_build.py --since <push 前的 commit>
  • 敏感信息扫描要查的值(namespace、OCID、用户名等)运行时从本机的 OCI 配置和 SDK 读出来,不写进仓库
  • build 里也跑一部分同样的检查(日文副本、strict build、搜索索引):本机漏了,build 还能挡住
  • 这些检查都是"以前漏过的东西"变出来的:改了 build_spec.yaml 忘了同步日文副本、表格里出现反斜杠、页面里差点写进真实 IP 和邮箱

9. 为什么是这套组合

决策 选了 没选 理由
网站放哪 OCI Object Storage(+ AWS 版) GitHub Pages GitHub Pages 的免费方案要求仓库公开 → 不想公开整个仓库和 Git 历史
CI/CD OCI DevOps GitHub Actions 所在网络在浏览器上屏蔽了 GitHub;命令行能连,但不绕过(屏蔽说明网络的管理方可能不允许)
build 的凭证 Resource Principal API Key 放进 CI 的 Secrets 没有要保管的密钥
git push 的认证 HTTPS + Auth Token(Console 手动生成) SSH;用 Terraform 生成 Auth Token 所在网络挡了 22 端口;Terraform 生成的话 token 原文进 state
网站内容 脚本同步(MD5 比较) Terraform 管理每个文件 内容每次都变,plan 永远有差异
跨云凭证 Access Key 放 OCI Vault,脚本轮换 Key 写进 build 的环境变量 / 用 Terraform 建 Key 不出现在日志 / state / 本机磁盘
检查的位置 本机(push 前)+ build 里 只在 build 里 push 就是公开;build 里失败时敏感信息已经进了仓库

10. 验证

要证明的事 怎么做 结果
网站能匿名访问 curl 匿名访问页面 200 text/html; charset=utf-8
文件清单不公开 curl 访问 bucket 的列表 URL 404(ObjectReadWithoutList 生效)
前面的 step 失败时不会发布 Phase 7 搭建时实际碰到的失败(读不到 build_spec、pip 连不上) build FAILED,上传步骤没有执行(检查类 step 本身挡住发布的情况还没实际碰到过)
只有 main 会触发发布 在测试分支上手动跑 build(SDK 的 create_build_run)确认 Pipeline 参数 测试分支的 push 不触发,网站没被更新
AWS 的 Key 只能做该做的 用 deployer 的 Key 实际调用 写网站 bucket、清缓存成功;读对象、碰 state bucket、改 policy 全部 AccessDenied
发布后页面正常 wait_build.py 逐个确认改动过的页面 200
内容没变时不重传 连续跑两次上传脚本 第 2 次 uploaded 0

11. 坑

现象 原因 对策
Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound Policy 写成了 repos(OCIR 的资源类型) read devops-repository
runner 里 pip → ProxyError ... Name or service not known runner 镜像的 pip 全局配置里有 Oracle 内部代理 PIP_CONFIG_FILE=/dev/null;先加诊断输出(pip config list 等)再改,不靠猜
第一次 push 被拒(non-fast-forward) 建仓库时自动生成了空的 "Initial Commit" git push --force(只覆盖那个空 commit)
default_branch 每次 plan 都有差异 API 存成 refs/heads/main 代码里写 refs/heads/main
git push 卡住:auto-detection of host provider took too long Git Credential Manager 判断服务类型时超时 git config credential.<URL>.provider generic
Console 里看不到 build 日志 没建 Log(service = devops) Terraform 里建 Log Group + Log;build 结束约 1 分钟后才查得到
runner 里没有 terraform / tflint / unzip 镜像里没装 从官方下载固定版本,用 Python 的 zipfile 解压,再 chmod +x
改了 build_spec.yaml,CI 的日文副本检查失败 i18n/ja/build_spec.yaml 没同步 两个文件一起改;push 前跑 prepublish_check.py
日文页的章节搜索结果是 undefined 链接 i18n 插件重新配置搜索时去掉了部分页面的主记录 reconfigure_search: false + check_search_index.py 检查
检查工具在 Windows 上 UnicodeEncodeError 终端是 cp932,打印中文失败 工具开头 sys.stdout.reconfigure(encoding="utf-8");改完在 Windows 上实际跑一次

12. 自测问题

1. 为什么检查要在 push 之前做,build 里的检查不够吗?

这个仓库 push 就是发布。而且敏感信息一旦 push,就已经进了仓库的 Git 历史;build 里再发现也晚了。build 里的检查是第二道防线。

2. 用 GitHub Actions 发布到 OCI 和用 OCI DevOps,凭证方面有什么不同?

GitHub Actions 不是 OCI 的资源,要把 OCI 的 API Key 存进 GitHub 的 Secrets;OCI DevOps 的 Build Pipeline 本身就有身份(Resource Principal),通过 Dynamic Group + Policy 授权,没有要保管的密钥。

3. 为什么网站文件不交给 Terraform 管理?

网站文件每次 build 都会变,放进 Terraform 的话 plan 永远有差异,state 也会变大。Terraform 只管 bucket、Pipeline、权限这些基础设施,内容用脚本同步。

4. 往 AWS 上传为什么只能用 Access Key?怎么降低风险?

OCI 的 build runner 不是 AWS 的资源,用不了 IAM Role。补偿:权限压到最小(不能读对象、碰不到别的 bucket)、Key 不经过 Terraform(不进 state)、放在 OCI Vault 里只让 build 读、不进环境变量、定期轮换。

5. AWS 的 bucket 名为什么不写在 build_spec.yaml 里?

网站页面会嵌入 build_spec.yaml,写进去就公开了。改成 Build Pipeline 的参数,build 时变成环境变量。

6. Policy 写 read repos,build 读不到 build_spec.yaml,怎么判断是 Policy 写法错还是资源类型错?

临时给更大的权限(manage devops-family in compartment tf-lab):成功的话说明 Dynamic Group 和 Policy 的结构没问题,问题在资源类型(repos 是 OCIR 的)。确认后改回最小权限。

7. Secret 的内容为什么在 Terraform 里写占位符,还要 ignore_changes?

真值不能经过 Terraform(会进 state)。真值由脚本写入后,如果没有 ignore_changes,下次 apply 会把它改回占位符。

8. 上传脚本为什么要按 MD5 比较,而不是每次全部上传?

Always Free 的 Object Storage 每月只有 5 万次 API 请求。按 MD5 只传变了的文件,内容没变时一次上传都没有。

完整代码

build_spec.yaml
# OCI DevOps Build Pipeline(07-site/devops.tf)执行的内容
#   main 有 push → 这里的步骤在 Managed Build runner(OL8)上依次执行
#   每个 step 是独立的 shell → venv 用绝对路径调用,不靠 activate
version: 0.1
component: build
timeoutInSeconds: 900
shell: bash

env:
  variables:
    NO_MKDOCS_2_WARNING: "1" # 关掉 Material 停止维护的提示
    PIP_DISABLE_PIP_VERSION_CHECK: "1"
    # runner 镜像的 pip 全局配置里有 Oracle 内部代理(www-proxy-hqdc.us.oracle.com,解析不了)
    # → pip 不读任何配置文件,直接连 PyPI(pip 的规定:PIP_CONFIG_FILE=os.devnull 时不加载配置)
    PIP_CONFIG_FILE: "/dev/null"

steps:
  - type: Command
    name: Setup Python
    timeoutInSeconds: 600
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      # mkdocs-material 9.8 要 Python 3.11+;OL8 默认版本不满足时安装
      PY=""
      for p in python3.12 python3.11 python3; do
        if command -v "$p" >/dev/null && "$p" -c 'import sys; sys.exit(sys.version_info < (3, 11))'; then
          PY="$p"; break
        fi
      done
      if [ -z "$PY" ]; then
        dnf install -y -q python3.11
        PY=python3.11
      fi
      "$PY" --version
      "$PY" -m venv /tmp/venv
      /tmp/venv/bin/pip install -r requirements.txt oci==2.189.0 boto3==1.35.49

  # CI:Terraform 的检查(不通过就不发布网站)
  #   runner 里没有 terraform / tflint → 从官方下载固定版本(和本机一致)
  #   validate 不需要认证:init -backend=false(不连 state bucket),provider 只下载不配置
  - type: Command
    name: Terraform checks
    timeoutInSeconds: 600
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      TF_VERSION=1.16.6
      TFLINT_VERSION=0.64.0
      mkdir -p /tmp/bin
      curl -sSfL -o /tmp/terraform.zip "https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip"
      curl -sSfL -o /tmp/tflint.zip "https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}/tflint_linux_amd64.zip"
      # unzip 不一定有 → 用 Python 的 zipfile 解压(解压后执行权限会丢,要 chmod)
      /tmp/venv/bin/python -m zipfile -e /tmp/terraform.zip /tmp/bin
      /tmp/venv/bin/python -m zipfile -e /tmp/tflint.zip /tmp/bin
      chmod +x /tmp/bin/terraform /tmp/bin/tflint
      export PATH="/tmp/bin:${PATH}"
      terraform version
      tflint --version

      terraform fmt -check -recursive -diff .

      # root module(05-resource-manager/stack 除外:module 是打 zip 时才放进去的)
      export TF_PLUGIN_CACHE_DIR=/tmp/tf-plugin-cache # oci provider 只下载一次
      mkdir -p "${TF_PLUGIN_CACHE_DIR}"
      for d in 01-network 02-compute 03-modules/bootstrap 03-modules/env 04-3tier/env 06-oke 07-site 08-guard 09-secops 10-aws-site/bootstrap 10-aws-site/site; do
        echo "== validate ${d}"
        terraform -chdir="${d}" init -backend=false -input=false -no-color >/dev/null
        terraform -chdir="${d}" validate -no-color
      done

      # tflint:root module + module(i18n/ja 的副本不是完整的 module → 不检查)
      for d in 01-network 02-compute 03-modules/bootstrap 03-modules/env 03-modules/modules/compute 03-modules/modules/network \
               04-3tier/env 04-3tier/modules/compute 04-3tier/modules/lb 04-3tier/modules/network 06-oke 07-site 08-guard 09-secops 10-aws-site/bootstrap 10-aws-site/site; do
        echo "== tflint ${d}"
        tflint --chdir="${d}" --config="${PWD}/04-3tier/.tflint.hcl"
      done

  - type: Command
    name: Check site tools and ja code copies
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python -m unittest discover -s tests
      /tmp/venv/bin/python tools/check_ja_code.py

  - type: Command
    name: Build site
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/mkdocs build --strict
      /tmp/venv/bin/python tools/check_search_index.py

  - type: Command
    name: Upload to Object Storage
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python tools/upload_site.py --auth resource_principal

  # Phase 10-4:AWS 版(S3 + CloudFront)也上传
  #   AWS_* 是 build pipeline 的参数(07-site/devops.tf)→ 不写进这个文件(网站页面会嵌入它)
  #   Access Key 在 OCI Vault 的 Secret 里,脚本用 Resource Principal 读(不进环境变量、不出现在日志里)
  #   set -u:参数没传进来时在这里报错(不会悄悄跳过)
  - type: Command
    name: Upload to AWS
    command: |
      set -euo pipefail
      cd "${OCI_PRIMARY_SOURCE_DIR}"
      /tmp/venv/bin/python tools/upload_site_s3.py \
        --bucket "${AWS_SITE_BUCKET}" \
        --distribution-id "${AWS_DISTRIBUTION_ID}" \
        --creds-secret-id "${AWS_CREDS_SECRET_ID}"
07-site/devops.tf(DevOps 一套 + Dynamic Group / Policy)
# ------------------------------------------------------------
# Phase 7-2:OCI DevOps 自动发布(push → build → 上传)
#   本机 git push(HTTPS + Auth Token)→ Code Repository
#   → Trigger(main)→ Build Pipeline(build_spec.yaml:check → mkdocs build → upload)
#   认证:Resource Principal(Build Pipeline 自身的身份)→ 不发 API Key
#   不用 GitHub:所在网络在浏览器上屏蔽了 GitHub
# ------------------------------------------------------------

# tf-lab 的父 = tenancy(Dynamic Group 只能建在 tenancy 下)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# DevOps Project 必须指定一个通知 Topic(build 成功 / 失败等事件发到这里;不订阅也可以)
resource "oci_ons_notification_topic" "devops" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site-devops"
  description    = "DevOps project events for ${var.project_name}-site"

  freeform_tags = local.common_tags
}

resource "oci_devops_project" "site" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site"
  description    = "Build and publish the MkDocs study site"

  notification_config {
    topic_id = oci_ons_notification_topic.devops.id
  }

  freeform_tags = local.common_tags
}

# build 日志:不建 Log 的话 Console 里看不到 build 的输出
resource "oci_logging_log_group" "devops" {
  compartment_id = var.compartment_ocid
  display_name   = "${var.project_name}-site-devops"

  freeform_tags = local.common_tags
}

resource "oci_logging_log" "devops" {
  log_group_id       = oci_logging_log_group.devops.id
  display_name       = "${var.project_name}-site-devops"
  log_type           = "SERVICE"
  is_enabled         = true
  retention_duration = 30 # 天(最短 30)

  configuration {
    source {
      category    = "all"
      resource    = oci_devops_project.site.id
      service     = "devops"
      source_type = "OCISERVICE"
    }
  }

  freeform_tags = local.common_tags
}

# 私有 Git 仓库(本仓库整个 push 到这里)
resource "oci_devops_repository" "site" {
  project_id      = oci_devops_project.site.id
  name            = "oci-terraform"
  description     = "OCI x Terraform study repo (code + MkDocs site)"
  repository_type = "HOSTED"
  default_branch  = "refs/heads/main" # 写 "main" 的话 API 会存成 refs/heads/main → 每次 plan 都有差异

  freeform_tags = local.common_tags
}

resource "oci_devops_build_pipeline" "site" {
  project_id   = oci_devops_project.site.id
  display_name = "${var.project_name}-site-build"
  description  = "check -> mkdocs build -> upload to ${oci_objectstorage_bucket.site.name} and AWS S3"

  # Phase 10-4:build_spec.yaml 的 "Upload to AWS" 用(build 时作为环境变量传进去)
  build_pipeline_parameters {
    items {
      name          = "AWS_SITE_BUCKET"
      default_value = var.aws_site_bucket
    }
    items {
      name          = "AWS_DISTRIBUTION_ID"
      default_value = var.aws_distribution_id
    }
    items {
      name          = "AWS_CREDS_SECRET_ID"
      default_value = oci_vault_secret.aws_deployer.id
    }
  }

  freeform_tags = local.common_tags
}

# Managed Build:OCI 准备的 runner(按 build 期间的 OCPU / 内存计费)执行仓库根目录的 build_spec.yaml
resource "oci_devops_build_pipeline_stage" "build" {
  build_pipeline_id         = oci_devops_build_pipeline.site.id
  build_pipeline_stage_type = "BUILD"
  display_name              = "build-and-upload"
  # 明确写 description:不写的话 API 里是空字符串,更新 stage 时 → 400 Invalid description(2026-11-06)
  description          = "Build the MkDocs site and upload it to OCI Object Storage and AWS S3"
  image                = "OL8_X86_64_STANDARD_10"
  build_spec_file      = "build_spec.yaml"
  primary_build_source = "src"

  stage_execution_timeout_in_seconds = 900 # 卡住时 15 分钟强制结束(防止一直计费)

  # runner 规格:明确指定 CUSTOM 2 OCPU / 8GB(= DEFAULT 时实际分到的 VM.Standard.E5.Flex 2 / 8,费用不变)
  #   2026-11-06 DEFAULT 一直 GET_BUILD_RUNNER 失败(Unable to get Build Runner)→ 换成 CUSTOM 试是否能分到 runner
  #   CUSTOM 时 ocpus / memory_in_gbs 必填(SDK CustomBuildRunnerShapeConfig)
  build_runner_shape_config {
    build_runner_type = "CUSTOM"
    ocpus             = 2
    memory_in_gbs     = 8
  }

  build_pipeline_stage_predecessor_collection {
    items {
      id = oci_devops_build_pipeline.site.id # 第一个 stage 的前驱 = pipeline 本身
    }
  }

  build_source_collection {
    items {
      name            = "src"
      connection_type = "DEVOPS_CODE_REPOSITORY"
      repository_id   = oci_devops_repository.site.id
      repository_url  = oci_devops_repository.site.http_url
      branch          = "main"
    }
  }

  freeform_tags = local.common_tags
}

# main 有 push → 启动 build(其他分支 push 不触发)
resource "oci_devops_trigger" "push_main" {
  project_id     = oci_devops_project.site.id
  display_name   = "push-main"
  trigger_source = "DEVOPS_CODE_REPOSITORY"
  repository_id  = oci_devops_repository.site.id

  actions {
    type              = "TRIGGER_BUILD_PIPELINE"
    build_pipeline_id = oci_devops_build_pipeline.site.id

    filter {
      trigger_source = "DEVOPS_CODE_REPOSITORY"
      events         = ["PUSH"]

      include {
        head_ref = "main"
      }
    }
  }

  freeform_tags = local.common_tags
}

# ---------- 权限:Resource Principal ----------
# tf-lab 里的 Build Pipeline 都算这个 Dynamic Group 的成员
resource "oci_identity_dynamic_group" "site_build" {
  compartment_id = data.oci_identity_compartment.lab.compartment_id
  name           = "${var.project_name}-site-build"
  description    = "DevOps build pipelines in ${data.oci_identity_compartment.lab.name}"
  matching_rule  = "ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '${var.compartment_ocid}'}"

  freeform_tags = local.common_tags
}

# 挂在 tf-lab 上:只能授予 tf-lab(及其下层)里的权限
#   manage objects + where bucket 名 → 只能动 tflab-site 里的对象(tflab-tfstate 碰不到,bucket 本身也删不了)
#   read devops-repository → build 时读 build_spec.yaml、clone 代码
#     坑:repos 是 Container Registry(OCIR)的资源类型,不是 DevOps 的代码仓库
#         read / use / manage repos 都是 RelatedResourceNotAuthorizedOrNotFound(Unable to fetch build_spec file)
#   use ons-topics → 往 Project 的 Topic 发 build 事件
#   read secret-bundles + where secret.id → 只能读 AWS Access Key 那一个 Secret 的内容(Phase 10-4)
resource "oci_identity_policy" "site_build" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-site-build"
  description    = "Permissions for ${oci_identity_dynamic_group.site_build.name}"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to manage objects in compartment ${data.oci_identity_compartment.lab.name} where target.bucket.name = '${oci_objectstorage_bucket.site.name}'",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to read devops-repository in compartment ${data.oci_identity_compartment.lab.name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to use ons-topics in compartment ${data.oci_identity_compartment.lab.name}",
    "Allow dynamic-group ${oci_identity_dynamic_group.site_build.name} to read secret-bundles in compartment ${data.oci_identity_compartment.lab.name} where target.secret.id = '${oci_vault_secret.aws_deployer.id}'",
  ]

  freeform_tags = local.common_tags
}
07-site/main.tf(网站 bucket)
# ------------------------------------------------------------
# Phase 7-1:用 Object Storage 公开学习总结网站(MkDocs 的 site/)
#   Terraform 只管 bucket(基础设施)
#   网站文件(内容)每次 build 都会变 → 用 tools/upload_site.py 上传,不进 state(7-2 交给 OCI DevOps)
# ------------------------------------------------------------

locals {
  common_tags = {
    project    = "tf-lab"
    managed_by = "terraform"
  }
}

# Object Storage 的 namespace(URL 里要用)
data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_objectstorage_bucket" "site" {
  #checkov:skip=CKV_OCI_10:Public static website; objects are readable but listing is disabled
  #checkov:skip=CKV_OCI_8:Content is rebuilt from Git on every deploy; old versions are not needed
  #checkov:skip=CKV_OCI_9:Public content; Oracle-managed key is sufficient (CMK needs Vault)
  #checkov:skip=CKV_OCI_7:No event consumers; enable when an event rule is added
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = "${var.project_name}-site"

  # ObjectRead            :能读文件,也能列出 bucket 里全部文件名
  # ObjectReadWithoutList :能读文件,不能列清单 ← 网站只需要读
  # NoPublicAccess        :停止公开(var.site_public = false;08-guard 的 Function 也会改成这个)
  access_type  = var.site_public ? "ObjectReadWithoutList" : "NoPublicAccess"
  storage_tier = "Standard"
  versioning   = "Disabled" # 内容随时能从 Git 重新 build,不需要旧版本

  freeform_tags = local.common_tags
}
07-site/aws.tf(存 AWS Key 的 Vault / Secret)
# ------------------------------------------------------------
# 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
}