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 小时
- 按 MD5 只传变了的文件 → 省 API 请求(Always Free 每月 5 万次)。内容没变时
- 没用 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
}