跳转至

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