コンテンツにスキップ

CI/CD とサイトの公開

ひとことで

このサイトは push がそのまま公開です。だからチェックは公開より前に置きます。ローカル PC で一度チェックし、ビルドの中でもう一度チェックし、どこかで失敗したらアップロードしません。 認証情報は持たずに済むなら持たない(OCI ではサービス自身の ID を使う)、どうしても必要なものはボールトに入れて権限を最小にする。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 の手順どおりにビルドを実行。runner(ビルドを実行する一時的なマシン)は OCI が用意し、ビルド中の使用量で課金されます
トリガー(Trigger) Webhook / Actions の on: push 「main ブランチに push があったらビルド・パイプラインを起動」
build_spec.yaml buildspec.yml / workflow の YAML ビルド手順の定義。リポジトリ直下に置きます
リソース・プリンシパル(Resource Principal) CodeBuild の IAM ロール サービス自身の ID。ビルドはこれで OCI の API を呼び出すので、API キーは不要です
動的グループ(Dynamic Group) (IAM ロールの信頼関係) 「どのリソースを 1 つのグループとみなすか」のルール。このグループにポリシーで権限を与えます
オブジェクト・ストレージ(Object Storage)のバケット S3 バケット サイトのファイルを置く場所。このリポジトリの OCI 版はバケットを公開して、そのままサイトとして使っています
ボールト(Vault)のシークレット(Secret) Secrets Manager パスワードやキーを保管する場所。このリポジトリでは AWS のアクセス・キーを入れています
認証トークン(Auth Token) Personal Access Token git push のときにパスワードの代わりに使うトークン(HTTPS)

2. 仕組み:push のあと何が起きるか

flowchart TB
  pc["① ローカル PC<br/>push 前チェック<br/>→ 人が確認 → git push"] -->|"HTTPS + 認証トークン"| repo["② コード・リポジトリ"]
  repo -->|"トリガー:main への push"| s1
  subgraph build["③ ビルド・パイプライン(マネージド・ビルドの runner)"]
    direction TB
    s1["チェック<br/>Terraform<br/>日本語版コピー<br/>ツールのテスト"] --> s2["ビルド<br/>mkdocs build --strict<br/>検索インデックス<br/>のチェック"]
    s2 --> s5["OCI にアップロード"] --> s6["AWS にアップロード"]
  end
  s5 -->|"リソース・プリンシパル"| oci["オブジェクト・ストレージ<br/>OCI 版"]
  s6 -->|"ボールト内の<br/>アクセス・キー"| aws["S3 + CloudFront<br/>AWS 版"]

3 つの原則:

  • 品質ゲートはアップロードの前:Terraform のチェック、日本語版のコピー、strict ビルド、検索インデックスのどれかが失敗すると、後ろのアップロードは実行されません → 壊れたサイトは公開されません
  • AWS へのアップロードは最後:AWS のステップが失敗しても、OCI 版はすでに更新済みで影響を受けません
  • コンテンツは Terraform に入れない:サイトのファイルはビルドのたびに変わるため、Terraform に入れると plan に毎回差分が出ます。Terraform が作るのはバケット、パイプライン、権限だけで、ファイルはスクリプトで同期します

3. サイト自体の構成(MkDocs + 多言語)

やり方 理由
xxx.md = 中国語、xxx.ja.md = 日本語(mkdocs-static-i18n プラグイン。日本語訳のないページは中国語で表示) 1 つのリポジトリから 2 言語のサイトを出す
ページ内のコードは --8<-- "07-site/main.tf" でリポジトリのファイルを直接埋め込む コードは 1 か所だけなので、サイトと実際のコードが食い違わない
日本語ページは i18n/ja/ のコピーを埋め込む:翻訳するのはコメントだけで、コードは完全に同じ。tools/check_ja_code.py でチェック 日本語の読者は日本語のコメントで読める。コピーと元ファイルのコードが食い違えばビルドが失敗する
use_directory_urls: false(topics/oke/index.html ではなく topics/oke.html を生成) オブジェクト・ストレージはディレクトリにアクセスされても index.html を返さない
検索プラグインに lang: [en, zh, ja] を明示し、i18n プラグインは reconfigure_search: false i18n プラグインが検索を再設定すると一部ページのメイン・レコードが消え、日本語ページの章の検索結果が undefined リンクになる(第 11 節)

4. OCI DevOps の権限:リソース・プリンシパル

# 07-site/devops.tf(抜粋):tf-lab 内のビルド・パイプラインはすべてこのグループ
resource "oci_identity_dynamic_group" "site_build" {
  matching_rule = "ALL {resource.type = 'devopsbuildpipeline', resource.compartment.id = '${var.compartment_ocid}'}"
}
# このグループに与える権限(ポリシーは 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
Allow dynamic-group tflab-site-build to read secret-bundles in compartment tf-lab where target.secret.id = '<その 1 つの Secret>'
  • 保管すべき鍵がない:ビルド自体が ID を持っています。GitHub Actions を使うなら、OCI の API キーを GitHub の Secrets に入れる必要があります
  • できることは、サイト用バケット内のオブジェクトの読み書き・削除、コード・リポジトリの読み取り、ビルド通知の送信、AWS キーが入ったシークレット 1 つの読み取りだけです。state のバケットやバケット自体には触れません
  • スクリプト側で認証方法を切り替えます:ローカル PC では ~/.oci/config、ビルドの中では --auth resource_principal

repos は DevOps のコード・リポジトリではない

最初は read repos と書いていて、ビルドが Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound で失敗しました。 repos はコンテナ・レジストリ(OCIR)のリソース・タイプで、DevOps のコード・リポジトリは devops-repository です。 切り分け方:一時的に manage devops-family in compartment tf-lab を与える → 成功 → 動的グループとポリシーの書き方は正しく、問題はリソース・タイプだとわかる。確認後は最小権限に戻しました。

5. build_spec.yaml のステップ設計

ステップ 内容 失敗したとき
Setup Python 3.11 以上の Python を探す(なければインストール)→ venv → バージョン固定の依存をインストール 公開しない
Terraform checks fmt -check → 各ルート・モジュールで init -backend=false + validate → tflint 公開しない
Check site tools and ja code copies ツールの単体テスト、日本語版コードのコピーの一致 公開しない
Build site mkdocs build --strict(警告があれば失敗)→ 検索インデックスのチェック 公開しない
Upload to Object Storage OCI のバケットに同期 —
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 がない → ローカル PC と同じ固定バージョンをダウンロード
      # ...
      terraform -chdir="${d}" init -backend=false -input=false -no-color >/dev/null   # state にはつながない。認証も不要
      terraform -chdir="${d}" validate -no-color
  • ステップごとに別の shell:venv は activate に頼らず、絶対パス(/tmp/venv/bin/python)で呼び出します
  • set -euo pipefail:どのコマンドが失敗してもすぐ止まります。変数が渡っていないとき(set -u)もそのままエラーになり、黙ってスキップされません
  • バージョンはすべて固定(Terraform、tflint、Python パッケージ):ローカル PC と同じにして、今日通ったチェックが明日も通るようにします
  • サイトのページに build_spec.yaml を埋め込むので、AWS のバケット名、ディストリビューション ID、シークレットの OCID は中に書かず、ビルド・パイプラインのパラメータにしています(Terraform で定義し、ビルド時に環境変数になる)

6. オブジェクト・ストレージを静的サイトとして使う

制約 対策
ディレクトリにアクセスしても index.html を返さない use_directory_urls: false
Content-Type はアップロード時に指定する(指定しないと HTML がダウンロード扱いになる) アップロード・スクリプトで拡張子ごとに固定(Windows の mimetypes はレジストリを読むので、マシンによって結果が変わる)
存在しないページはオブジェクト・ストレージ自身の JSON が返る 対策なし(AWS 版では MkDocs の 404 ページを表示できる)
URL に必ずネームスペース(namespace)が含まれる 公開サイトである以上は隠せない。state のバケットは NoPublicAccess なので、ネームスペースがわかっても読めない
  • バケットの access_type = "ObjectReadWithoutList":匿名でファイルは読めますが、ファイルの一覧は取れません(ObjectRead だとファイル名の一覧まで公開されます)
  • アップロード・スクリプト tools/upload_site.py(AWS 版の upload_site_s3.py も同じルール):
    • MD5 で変わったファイルだけを送る → API リクエストを節約(Always Free は月 5 万回)。内容が変わっていなければ uploaded 0 / unchanged 72
    • バケットにあって site/ にないファイルは削除する(ページ名を変えても古いページが残らない)
    • Cache-Control:HTML は no-cache(公開後すぐに反映)、それ以外は 1 時間キャッシュ
  • OCI CLI や aws s3 sync を使わない理由:ローカル PC に OCI CLI をインストールできない(Windows のパス長の上限)。aws s3 sync は Content-Type を推測で決め、「変わったかどうか」をサイズと時刻で判断する(ビルドのたびに時刻が変わる → 毎回全部送り直し)

7. クラウドをまたぐ公開:OCI のビルドから AWS にアップロード

OCI のビルド runner は AWS のリソースではないので、AWS の IAM ロールは使えず、IAM ユーザーのアクセス・キー(長期間有効)を発行するしかありません。そのぶん別の手段で補います:

対策 やり方
権限は最小 サイト用バケットのオブジェクトの一覧・書き込み・削除と、このディストリビューションのキャッシュ削除だけ。オブジェクトの読み取り(GetObject)すらない
キーは Terraform を通さない aws_iam_access_key の secret は state に平文で残る → スクリプトでキーを作り、そのまま OCI ボールトのシークレットに書き込む
キーを環境変数に入れない アップロード・スクリプトがリソース・プリンシパルでシークレットを読む。キーはプロセスのメモリにあるだけで、ログには出ない
定期的にローテーション tools/rotate_aws_deploy_key.py:新しいキーを作る → シークレットに書き込む → 成功してから古いキーを削除

シークレットは 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 ユーザーの権限設計とキーのローテーションは セキュリティ、S3 / CloudFront 側は AWS との対比 を参照してください。

8. push の前後の流れ

タイミング やること ツール
push 前 日本語版コピーの一致、terraform fmt、strict ビルド、検索インデックス、機密情報のスキャン(公開サイト + push するファイル)、表の中のコードの縦棒、日本語ページの不足 tools/prepublish_check.py → NG 0 のときだけ先へ進む
push 前 人が確認:push する commit、チェック結果、更新されるページ —
push git push oci main —
push 後 ビルドの終了を待つ。成功したら変更したページを URL に変換し、1 つずつ 200 を確認 tools/wait_build.py --since <push 前の commit>
  • 機密情報のスキャンで探す値(ネームスペース、OCID、ユーザー名など)は実行時にローカル PC の OCI 設定と SDK から読み出し、リポジトリには書きません
  • ビルドの中でも同じチェックの一部(日本語版コピー、strict ビルド、検索インデックス)を実行します:ローカル PC で見逃しても、ビルドで止められます
  • これらのチェックはどれも「以前見逃したもの」から生まれました:build_spec.yaml を変えて日本語版のコピーの同期を忘れた、表の中にバックスラッシュが入った、ページに本物の IP やメール・アドレスを書きかけた、など

9. なぜこの組み合わせなのか

決定 選んだもの 選ばなかったもの 理由
サイトの置き場所 OCI オブジェクト・ストレージ(+ AWS 版) GitHub Pages GitHub Pages の無料プランはリポジトリの公開が必要 → リポジトリ全体と Git の履歴は公開したくない
CI/CD OCI DevOps GitHub Actions 利用中のネットワークでブラウザから GitHub がブロックされている。コマンドラインからはつながるが、回避はしない(ブロックされているのは、ネットワークの管理者が許可していない可能性があるため)
ビルドの認証情報 リソース・プリンシパル API キーを CI の Secrets に入れる 保管すべき鍵がない
git push の認証 HTTPS + 認証トークン(コンソールで手動作成) SSH / Terraform で認証トークンを作成 利用中のネットワークで 22 番ポートがブロックされている。Terraform で作るとトークンの原文が state に入る
サイトのコンテンツ スクリプトで同期(MD5 比較) Terraform でファイルごとに管理 中身は毎回変わるので、plan に毎回差分が出る
クラウドをまたぐ認証情報 アクセス・キーを OCI ボールトに入れ、スクリプトでローテーション キーをビルドの環境変数に入れる / Terraform でキーを作成 ログ / state / ローカル PC のディスクに現れない
チェックの場所 ローカル PC(push 前)+ ビルドの中 ビルドの中だけ push = 公開。ビルドで失敗した時点で、機密情報はもうリポジトリに入っている

10. 検証

確かめたいこと やり方 結果
サイトに匿名でアクセスできる curl で匿名アクセス 200 text/html; charset=utf-8
ファイルの一覧は公開されない curl でバケットの一覧 URL にアクセス 404(ObjectReadWithoutList が効いている)
前のステップが失敗したら公開されない Phase 7 の構築中に実際に起きた失敗(build_spec を読めない、pip がつながらない) ビルド FAILED、アップロードのステップは実行されず(チェック系のステップ自体が公開を止めたケースはまだ実際には起きていない)
main だけが公開をトリガーする テスト・ブランチで手動ビルド(SDK の create_build_run)を実行してパイプラインのパラメータを確認 テスト・ブランチへの push ではトリガーされず、サイトは更新されない
AWS のキーは必要なことしかできない deployer のキーで実際に呼び出す サイト用バケットへの書き込み、キャッシュ削除は成功。オブジェクトの読み取り、state バケットへのアクセス、ポリシーの変更はすべて AccessDenied
公開後のページが正常 wait_build.py で変更したページを 1 つずつ確認 200
中身が変わっていなければ送り直さない アップロード・スクリプトを 2 回続けて実行 2 回目は uploaded 0

11. ハマりどころ

現象 原因 対策
Unable to fetch build_spec file ... RelatedResourceNotAuthorizedOrNotFound ポリシーに 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
コンソールでビルドのログが見えない ログ(service = devops)を作っていない Terraform でロググループ + ログを作成。ビルド終了から約 1 分後に検索できる
runner に terraform / tflint / unzip がない イメージに入っていない 公式から固定バージョンをダウンロードし、Python の zipfile で解凍して chmod +x
build_spec.yaml を変えたら、CI の日本語版コピーのチェックが失敗 i18n/ja/build_spec.yaml を同期していない 2 つのファイルを一緒に変更。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 の前にチェックするのですか。ビルドの中のチェックだけでは足りませんか?

このリポジトリは push がそのまま公開です。しかも機密情報は push した時点でリポジトリの Git 履歴に入ってしまうので、ビルドで見つけても手遅れです。ビルドの中のチェックは 2 番目の防御線です。

2. GitHub Actions から OCI に公開する場合と OCI DevOps の場合、認証情報の面で何が違いますか?

GitHub Actions は OCI のリソースではないので、OCI の API キーを GitHub の Secrets に保管する必要があります。OCI DevOps のビルド・パイプラインは自分自身の ID(リソース・プリンシパル)を持っていて、動的グループ + ポリシーで権限を与えるので、保管すべき鍵がありません。

3. なぜサイトのファイルを Terraform で管理しないのですか?

サイトのファイルはビルドのたびに変わるので、Terraform に入れると plan に毎回差分が出て、state も大きくなります。Terraform はバケット、パイプライン、権限といったインフラだけを管理し、中身はスクリプトで同期します。

4. AWS へのアップロードにはなぜアクセス・キーしか使えないのですか。リスクはどう下げますか?

OCI のビルド runner は AWS のリソースではないので、IAM ロールを使えません。補う手段は、権限を最小にする(オブジェクトを読めない、ほかのバケットに触れない)、キーを Terraform に通さない(state に入れない)、OCI ボールトに入れてビルドだけが読めるようにする、環境変数に入れない、定期的にローテーションする、です。

5. AWS のバケット名をなぜ build_spec.yaml に書かないのですか?

サイトのページに build_spec.yaml を埋め込むので、書くと公開されてしまいます。ビルド・パイプラインのパラメータにして、ビルド時に環境変数として渡します。

6. ポリシーに read repos と書いたらビルドが build_spec.yaml を読めません。ポリシーの書き方の誤りか、リソース・タイプの誤りか、どう見分けますか?

一時的に大きめの権限(manage devops-family in compartment tf-lab)を与えます。それで成功すれば、動的グループとポリシーの構造は正しく、問題はリソース・タイプ(repos は OCIR のもの)だとわかります。確認したら最小権限に戻します。

7. シークレットの中身を Terraform ではプレースホルダーにして、さらに ignore_changes を付けるのはなぜですか?

本当の値を Terraform に通すと state に入ってしまうからです。本当の値はスクリプトが書き込みますが、ignore_changes がないと、次の apply でプレースホルダーに戻されてしまいます。

8. アップロード・スクリプトはなぜ毎回全部アップロードせず、MD5 で比較するのですか?

Always Free のオブジェクト・ストレージは API リクエストが月 5 万回までです。MD5 で変わったファイルだけを送れば、中身が変わっていないときはアップロードが 1 回も発生しません。

コード全文

build_spec.yaml
# OCI DevOps のビルド・パイプライン(07-site/devops.tf)が実行する内容
#   main に push される → ここのステップがマネージド・ビルドのランナー(OL8)で順番に実行される
#   ステップごとに別々のシェル → 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"
    # ランナー・イメージの 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 のチェック(通らなければサイトは公開しない)
  #   ランナーには terraform / tflint がない → 公式サイトから固定バージョンをダウンロード(ローカル PC と同じ)
  #   validate に認証は不要:init -backend=false(state のバケットにはつながない)。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 .

      # ルート・モジュール(05-resource-manager/stack は除外:モジュールは zip 作成時に入れるため)
      export TF_PLUGIN_CACHE_DIR=/tmp/tf-plugin-cache # oci provider のダウンロードは 1 回だけ
      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:ルート・モジュール + モジュール(i18n/ja のコピーは完全なモジュールではない → チェックしない)
      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 一式 + 動的グループ / ポリシー)
# ------------------------------------------------------------
# Phase 7-2:OCI DevOps による自動公開(push → ビルド → アップロード)
#   ローカル PC で git push(HTTPS + 認証トークン)→ コード・リポジトリ(Code Repository)
#   → トリガー(main)→ ビルド・パイプライン(build_spec.yaml:チェック → mkdocs build → アップロード)
#   認証:リソース・プリンシパル(Resource Principal、ビルド・パイプライン自身の ID)→ API キーは発行しない
#   GitHub を使わない理由:利用中のネットワークでブラウザから GitHub がブロックされているため
# ------------------------------------------------------------

# tf-lab の親 = テナンシ(動的グループはテナンシ直下にしか作れない)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# DevOps プロジェクトには通知トピックの指定が必須(ビルドの成功 / 失敗などのイベントが届く。サブスクライブしなくてもよい)
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
}

# ビルドのログ:ログを作らないとコンソールでビルドの出力が見られない
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_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
}

# マネージド・ビルド:OCI が用意するランナー(ビルド中の 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 上は空文字列になり、ステージ更新時に 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 分で強制終了(課金が続くのを防ぐ)

  # ランナーのシェイプ:CUSTOM 2 OCPU / 8GB を明示(= DEFAULT で実際に割り当てられた VM.Standard.E5.Flex 2 / 8、費用は同じ)
  #   2026-11-06 DEFAULT で GET_BUILD_RUNNER が失敗し続けた(Unable to get Build Runner)→ CUSTOM でランナーを確保できるか試す
  #   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 # 最初のステージの前段 = パイプライン自体
    }
  }

  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 されたらビルドを開始(ほかのブランチへの 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
}

# ---------- 権限:リソース・プリンシパル ----------
# tf-lab 内のビルド・パイプラインはすべてこの動的グループのメンバーになる
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 でバケット名を指定 → tflab-site 内のオブジェクトしか操作できない(tflab-tfstate には触れず、バケット自体も削除できない)
#   read devops-repository → ビルド時に build_spec.yaml を読み、コードを clone する
#     ハマりどころ:repos はコンテナ・レジストリ(OCIR)のリソース・タイプで、DevOps のコード・リポジトリではない
#         read / use / manage repos はどれも RelatedResourceNotAuthorizedOrNotFound(Unable to fetch build_spec file)
#   use ons-topics → プロジェクトのトピックにビルドのイベントを送る
#   read secret-bundles + where secret.id → AWS Access Key のシークレット 1 つの中身しか読めない(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(サイト用バケット)
# ------------------------------------------------------------
# Phase 7-1:オブジェクト・ストレージ(Object Storage)で学習ノートのサイト(MkDocs の site/)を公開
#   Terraform が管理するのはバケットだけ(インフラ)
#   サイトのファイル(コンテンツ)はビルドのたびに変わる → tools/upload_site.py でアップロードし、state には入れない(7-2 で OCI DevOps に任せる)
# ------------------------------------------------------------

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

# オブジェクト・ストレージのネームスペース(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            :ファイルを読めるうえ、バケット内の全ファイル名も一覧できる
  # ObjectReadWithoutList :ファイルは読めるが、一覧はできない ← サイトには読み取りだけで十分
  # NoPublicAccess        :公開を停止(var.site_public = false。08-guard の Function もこれに変更する)
  access_type  = var.site_public ? "ObjectReadWithoutList" : "NoPublicAccess"
  storage_tier = "Standard"
  versioning   = "Disabled" # コンテンツはいつでも Git から再ビルドできるので、古いバージョンは不要

  freeform_tags = local.common_tags
}
07-site/aws.tf(AWS キーを入れるボールト / シークレット)
# ------------------------------------------------------------
# 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
}