Phase 7:网站发布 + CI/CD(OCI DevOps)¶
目标¶
- 把这个学习笔记网站(MkDocs)公开到 Object Storage
- push 之后自动检查 → build → 发布(CI/CD)
- 不发 API Key:build 用 Resource Principal(服务自身的身份)
方针¶
| 决定 | 理由 |
|---|---|
| 不用 GitHub Pages | 免费方案要求仓库 public → 不想公开整个仓库和 Git 历史。网站上出现嵌入的代码可以接受 |
| 发布到 Object Storage | Always Free 范围内,而且能继续用 Terraform 练习 |
| CI/CD 用 OCI DevOps | 原计划是私有 GitHub 仓库 + GitHub Actions,但所在网络在浏览器上屏蔽了 GitHub。命令行能连,但不绕过(屏蔽说明网络的管理方可能不允许) |
| Terraform 只管 bucket | 网站文件每次 build 都会变,放进 Terraform 的话 plan 永远有差异 → 内容用脚本上传,不进 state |
结构¶
flowchart LR
pc["本机<br/>git push"] -->|HTTPS + Auth Token| repo["Code Repository<br/>(私有)"]
repo -->|"Trigger<br/>PUSH main"| bp["Build Pipeline<br/>(Managed Build)"]
bp -->|"Resource Principal"| bucket["Bucket tflab-site<br/>(公开)"]
bucket --> user["浏览器"]
07-site/
├── main.tf Bucket(ObjectReadWithoutList)
├── devops.tf DevOps 一套 + Dynamic Group / Policy
└── outputs.tf 网站 URL、仓库 URL
build_spec.yaml build 的步骤(仓库根目录)
tools/upload_site.py site/ → bucket 同步(Python SDK)
build 的步骤(build_spec.yaml):
| 步骤 | 内容 | 失败时 |
|---|---|---|
| Setup Python | venv + pip install -r requirements.txt oci |
|
| Terraform checks | fmt -check -recursive → 每个 root module validate → tflint |
不发布 |
| Check ja code copies | check_ja_code.py(日文版的代码副本和原文件一致吗) |
不发布 |
| Build site | mkdocs build --strict(有警告就失败) |
不发布 |
| Upload | upload_site.py --auth resource_principal |
Object Storage 当静态网站¶
| 限制 | 对策 |
|---|---|
访问目录时不会返回 index.html |
mkdocs.yml 里 use_directory_urls: false → phases/06-oke/ 变成 phases/06-oke.html |
| Content-Type 由上传时指定(不指定的话 HTML 会被当成下载) | 上传脚本按扩展名写死(Windows 的 mimetypes 读注册表,结果因机器而异) |
404 是 Object Storage 的 JSON,不显示 MkDocs 的 404.html |
没办法 |
| URL 里一定有 namespace | 公开网站就藏不住。存 state 的 bucket 是 NoPublicAccess,知道 namespace 也读不到 |
access_type = "ObjectReadWithoutList":匿名能读文件,不能列清单(ObjectRead连文件名一览都公开)- 上传脚本
tools/upload_site.py:- 按 MD5 只传变了的文件(Object Storage 返回的 md5 是 base64)→ 省 API 请求(Always Free 每月 5 万次)
site/里没有的对象会删掉(页面改名后旧页面不会残留)- Cache-Control:HTML
no-cache(发布后马上生效),其他缓存 1 小时
- 用 curl 匿名访问确认:页面
200 text/html; charset=utf-8,列清单404
权限:Resource Principal¶
Dynamic Group:ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '<tf-lab>'}
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
- "tf-lab 里的 Build Pipeline"本身就有权限 → 没有要保管的密钥(GitHub Actions 的话要把 API Key 放进 Secrets)
- 能做的只有:
tflab-site里对象的读写删、读代码仓库、发 build 通知。state 的 bucket、bucket 本身、其他资源都碰不到 - 脚本里:
oci.auth.signers.get_resource_principals_signer()(本机用~/.oci/config,用--auth切换)
git push 的认证¶
所在网络挡了 22 端口(SSH)→ 只能 HTTPS。
| 项目 | 内容 |
|---|---|
| 用户名 | <tenancy 名>/<用户名>(Default domain 不用写 domain 名) |
| 密码 | Auth Token(不是 Console 的密码) |
| 保存 | 第一次 push 后 Git Credential Manager 存进 Windows 凭据管理器 |
Auth Token 不用 Terraform 生成
oci_identity_auth_token 也能建,但 token 原文会进 state。和 API Key 一样,密码类的东西不经过 Terraform:在 Console 手动生成(My profile → Tokens and keys)。
每个用户最多 2 个;只在生成时显示一次。
流程和观察¶
| # | 触发 | 结果 | 内容 |
|---|---|---|---|
| 1 | push | FAILED(20 秒) | 读不到 build_spec.yaml(policy 写的是 read repos) |
| 2 | 手动 | FAILED | use repos → 一样 |
| 3 | 手动 | FAILED(runner 2 分钟) | 临时 manage devops-family → build_spec 读到了;pip 连不上 PyPI |
| 4 | push | FAILED | 加诊断输出 → 原因是 runner 镜像的 pip 配置 |
| 5 | push | SUCCEEDED(约 2.5 分钟) | PIP_CONFIG_FILE=/dev/null → 上传成功 |
| 6 | 手动 | FAILED | manage repos → 一样 → 资源类型写错了 |
| 7 | 手动 | SUCCEEDED | read devops-repository |
| 8 | push | SUCCEEDED | 最终 policy 下 push → 自动 build |
| 9 | push | SUCCEEDED(约 4 分钟) | 加了 Terraform 检查(fmt / 7 个 root module 的 validate / 12 个目录的 tflint) |
- 失败在 runner 启动之前(1、2、6)→ 不计费
- 第 2 次以后的 MD5 同步:内容没变时
uploaded 0 / unchanged 72 - build 日志要有 Log(service = devops)才能在 Console / Logging Search 里看到;build 结束后约 1 分钟才查得到
- runner 里有 Python 3.11、OCI CLI 3.58;没有 terraform / tflint(CI 里从官方下载固定版本)
坑¶
repos 不是 DevOps 的代码仓库
read repos / use repos / manage repos 都是 Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound。
repos 是 Container Registry(OCIR)的资源类型。DevOps 的代码仓库是 devops-repository。
- 排查方法:临时给
manage devops-family in compartment tf-lab→ 成功 → Dynamic Group / Policy 本身没问题 → 问题在资源类型 - 错误信息里的示例是
manage devops-family in tenancy,范围太大,没照抄
runner 里 pip 连不上 PyPI:ProxyError ... Name or service not known
没有 proxy 环境变量,但 runner 镜像的 pip 全局配置里写着 Oracle 内部代理(www-proxy-hqdc.us.oracle.com),runner 里解析不了。
→ PIP_CONFIG_FILE=/dev/null(pip 不加载任何配置文件)。
- 先加诊断输出(
pip config list、getent hosts pypi.org等)再改,不靠猜
第一次 push 被拒(non-fast-forward)
OCI 建仓库时自动生成一个空的 "Initial Commit"(没有文件)→ 本地历史和它没有共同祖先。
→ git push -u --force oci main(只覆盖那个空 commit)。
default_branch = 'main' 每次 plan 都有差异
API 存成 refs/heads/main。代码里也写 refs/heads/main。
Git Credential Manager 卡住:auto-detection of host provider took too long
GCM 判断"这是 GitHub 还是 Azure"时超时。
→ git config credential.https://devops.scmservice.ap-osaka-1.oci.oraclecloud.com.provider generic
本机还是装不了 OCI CLI(官方安装脚本也不行)
和 Phase 5b 一样是 Windows 的路径长度上限。上传改用 Python SDK 的脚本。 教训:开始前先翻自己的笔记。
完整代码¶
07-site/main.tf
# ------------------------------------------------------------
# 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/devops.tf
# ------------------------------------------------------------
# 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}"
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"
image = "OL8_X86_64_STANDARD_10"
build_spec_file = "build_spec.yaml"
primary_build_source = "src"
stage_execution_timeout_in_seconds = 900 # 卡住时 15 分钟强制结束(防止一直计费)
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 事件
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}",
]
freeform_tags = local.common_tags
}
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}"
# check_ja_code.py 要 3.9+;OL8 默认的 python3 可能是 3.6 → 找不到就装
PY=""
for p in python3.12 python3.11 python3.9 python3; do
if command -v "$p" >/dev/null && "$p" -c 'import sys; sys.exit(sys.version_info < (3, 9))'; 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
# 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; 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; do
echo "== tflint ${d}"
tflint --chdir="${d}" --config="${PWD}/04-3tier/.tflint.hcl"
done
- type: Command
name: Check ja code copies
command: |
set -euo pipefail
cd "${OCI_PRIMARY_SOURCE_DIR}"
/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
- 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