TerraformコードからAWSパラメーターシートを自動生成 — Plan JSON実装

目次

AWSパラメーターシートをTerraformコードから自動生成する — Plan JSON ハイブリッド実装

AWS パラメーターシート自動化シリーズ

前提知識(必読):

  • Terraform基礎 (ID:1120) — init/plan/apply・変数
  • Terraform実践 (ID:1208) — module/state・複数環境
  • Python 3.11+(pip install 可能な環境)
  • AWS CLI v2 + Terraform 1.9系 の動作環境

関連シリーズ:

  • AWS×Terraform 複数人開発シリーズ 全3弾(state管理 → GitHub Actions → CodePipeline)

1. この記事について

1-1. 本記事で達成できること

この記事を最後まで読んで手を動かすと、次のものが手元に揃う。

  • Terraform plan JSON を解析する Python スクリプトtf_plan_parser.py
  • openpyxl で生成する AWS パラメーターシート Excel(マルチ環境・2段ヘッダ付き)
  • dev/stg/prod の 3 環境ぶんのパラメーターを1 枚の Excel に集約する仕組み

「Terraform コードから Excel のパラメーターシートを自動で作りたい」——その要望を、terraform plan -out tfplan && terraform show -json tfplan の 2 コマンドと Python スクリプト一式で実現する。

アーキテクチャ図: TF → plan.json → Python → Excel の全体フロー

上の図が本記事の全体像だ。Terraform コードを起点に plan JSON を生成し、Python の「ハイブリッドパーサ」がコード記載値と計算値を区別しながら抽出、openpyxl がエンタープライズ標準の Excel 書式で出力する。

1-2. 本シリーズの全体構成と本記事の位置づけ

本シリーズは 2 弾構成で、AWS 案件で繰り返し発生する「パラメーターシート管理」の煩雑さを段階的に解消する。

テーマ主な技術スタック概要
第1弾(本記事)Terraform → Excel 自動生成Terraform 1.9 / Python 3.11 / openpyxlplan JSON を解析し Excel パラメーターシートを生成
第2弾AWS Config との突合テスト自動化AWS Config / pytest / boto3実環境の Config 記録と TF コードの差分を単体テストで検出

第1弾は「コードから仕様書を作る」、第2弾は「仕様書と実環境のズレを検知する」。2 弾合わせて「設計 → 検証」の循環が完成する。

1-3. 関連シリーズ(AWS×Terraform 複数人開発)との役割分担

本シリーズは、同じ Terraform を扱う複数人開発シリーズ(cmd_040)と直交補完の関係にある。

シリーズキャッチフレーズ
継続デプロイ軸AWS×Terraform 複数人開発シリーズ(全3弾)“TF コードを継続的に作って回す”
設計整合検証軸本シリーズ(全2弾)“作ったものを仕様書と突合して検証する”

CI/CD パイプラインを育てながら(複数人開発シリーズ)、設計整合性を担保する(本シリーズ)。この 2 軸を並行して運用することで、エンタープライズ AWS 案件の品質管理が大幅に向上する。

CI 組み込みに発展させたい場合は、複数人開発シリーズ第2弾(GitHub Actions+OIDC で PR駆動CI/CD)を参照してほしい。

1-4. 対象読者とペルソナ

本記事は次のような読者を想定している。

主要ペルソナ: エンタープライズ DevOps / インフラエンジニア

  • 金融・製造・公共系の AWS 案件で Terraform を使っているが、設計書管理は今も Excel 手作業
  • プロジェクト完了後のパラメーターシート提出に毎回数日〜1 週間を費やしている
  • 「TF コードが正義なのは分かるが、顧客への提出ドキュメントは Excel でないと困る」という板挟みを経験している
  • Python は書けるが openpyxl や plan JSON の扱いは未経験

副次ペルソナ: テックリード / アーキテクト

  • チーム全体の設計整合性管理を担う立場
  • 手動突合レビューのボトルネック解消を探している
  • 第2弾の AWS Config 突合テストまで視野に入れている

前提知識チェックリスト

以下をすべて満たせば、本記事をスムーズに進められる。

[ ] Terraform init / plan / apply の基本操作
[ ] Python 3.11+ の基礎(型ヒント・辞書操作・ファイル I/O)
[ ] AWS CLI v2 の設定(aws configure 済み)
[ ] pip install が可能なローカル環境

Terraform の基礎が不安な方は Terraform基礎(ID:1120) から始めてほしい。

1-5. AWS Config 未経験者へ

本記事(第1弾)では AWS Config を一切使わない。Terraform plan JSON と Python だけで Excel が完成する。

AWS Config が登場するのは第2弾だ。第2弾は「Config の設定記録と Terraform コードのズレを pytest で検出する」内容になる予定で、Config 未経験者でも第2弾から始められるよう丁寧に解説する。

本記事を動かすのに AWS Config の知識は不要。
「Config って聞いたことあるけど使ったことない」という方も安心して進めてほしい。

1-6. 本記事の構成

本記事は 10 セクションで構成されている。セクション間の依存関係を理解してから読み進めると効率がよい。

セクションタイトル要所
1(本節)この記事について全体像・位置づけ・ペルソナ
2業務背景: なぜパラメーターシート自動化が必要かBefore/After・工数比較
3設計方針(除外ルール含む)Plan vs State の選択・除外設計
4Terraform plan JSON を理解する3 領域の読み分け方
5Plan JSON ハイブリッドパーサの実装(Python)本記事の核
6マルチ環境対応(environments/ 分離と集約ループ)dev/stg/prod 統合
7Excel 出力(openpyxl・統合書式・色・マージ)2段ヘッダ・色分け
8対象サービス別の属性カバー(IaaS5 + サーバーレス5)10 サービス詳解
9ハンズオン実行と成果物確認E2E 実行・エラー対処
10まとめと第2弾への導線Config 突合テストへの接続

読み物として通読してもよいが、セクション 3〜4 を読んでから 5 に進むと実装の意図が理解しやすい。

1-7. 完成物のプレビュー

最終的に生成される Excel のイメージを先出しする。「こんなものが作れるのか」を先に把握しておくと、各セクションの実装意図が掴みやすい。

【生成される Excel: param_sheet_20260418.xlsx】

┌─ シート: EC2 ─────────────────────────────────────────────────────┐
│ 環境│ リソース名 │ インスタンスタイプ │ AMI │ VPC│
│──────────────────────────────────────────────────────────────────│
│ dev │ aws_instance.web │ t3.micro │ ami-xxxxxxxx │ vpc-xxxxxxxx │
│ stg │ aws_instance.web │ t3.small │ ami-xxxxxxxx │ vpc-yyyyyyyy │
│ prod│ aws_instance.web │ t3.medium│ ami-xxxxxxxx │ vpc-zzzzzzzz │
└──────────────────────────────────────────────────────────────────┘

┌─ シート: RDS ─────────────────────────────────────────────────────┐
│ 環境│ リソース名 │ エンジン  │ インスタンスクラス │ マルチAZ │ 暗号化 │
│──────────────────────────────────────────────────────────────────│
│ dev │ aws_db_instance.main│ postgres 15  │ db.t3.micro │ false │ true│
│ stg │ aws_db_instance.main│ postgres 15  │ db.t3.small │ false │ true│
│ prod│ aws_db_instance.main│ postgres 15  │ db.r6g.large│ true  │ true│
└──────────────────────────────────────────────────────────────────┘

┌─ シート: meta ────────────────────────────────────────────────────┐
│ 生成日時│ 2026-04-18T16:36:00+09:00 │
│ commit SHA │ a1b2c3d4e5f6...  │
│ 環境 │ dev / stg / prod │
│ 生成者  │ generate_param_sheet.py v1.0 │
└──────────────────────────────────────────────────────────────────┘

3 環境 × 10 サービスの属性が1ファイルに集約される。セルはサービス種別ごとに色分けされ、ヘッダは「環境」「カテゴリ」の 2 段構成になっている。

1-8. 環境セットアップ(事前準備)

ハンズオンを進める前に、以下のパッケージをインストールしておく。

# Python パッケージ
pip install openpyxl==3.1.2

# Terraform バージョン確認(1.9.x 推奨)
terraform version
# Terraform v1.9.0 以上であること

# AWS CLI バージョン確認
aws --version
# aws-cli/2.x.x 以上であること

# AWS 認証確認
aws sts get-caller-identity
# 出力例:
# {
#  "UserId": "AIDAXXXXXXXXXXXXXXXXX",
#  "Account": "123456789012",
#  "Arn": "arn:aws:iam::123456789012:user/your-user"
# }

Python バージョンは 3.11 以上が必須だ。型ヒントの str | None 記法を使用するため、3.10 以下では動作しない。

python --version
# Python 3.11.x 以上であること

1-9. サンプルコードの表記統一

本記事を通じて、以下の値を統一して使用する。環境変数や tfvars で差し替える想定だが、コード例では固定値で記述する。

変数用途
AWSアカウントID123456789012ARN・リソース参照
AWSリージョンap-northeast-1Provider設定
S3バケット(state)myorg-terraform-statebackend設定
DynamoDBテーブルterraform-state-lockロック設定

これらは実行時に自分の環境の値に読み替えてほしい。ARN はすべて arn:aws:*:ap-northeast-1:123456789012:* 形式で記述する。

1-10. リポジトリ構成の先出し

本記事を通じて構築するディレクトリ構成を先出しする。実装を追いながら「今どこを作っているか」の地図として使ってほしい。

terraform-param-sheet/ # プロジェクトルート
├── environments/# 環境別 Terraform 設定
│├── dev/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││├── backend.tf
││└── plan.json  # make plan-all で生成
│├── stg/
││└── ...(同構成)
│└── prod/
│ └── ...(同構成)
├── modules/  # 共通 Terraform モジュール
│├── ec2/
│├── rds/
│└── network/
├── scripts/  # Python スクリプト群
│├── tf_plan_parser.py # Section 5: ハイブリッドパーサ(核)
│├── excel_writer.py# Section 7: openpyxl 書式制御
│├── multi_env_collector.py  # Section 6: 環境集約ループ
│└── generate_param_sheet.py # Section 9: エントリーポイント
├── output/# 生成物(git-ignored)
│└── param_sheet_YYYYMMDD.xlsx
├── Makefile  # Section 6: make plan-all 等
└── requirements.txt# openpyxl==3.1.2 等

Section 3 以降では、このディレクトリを上から下へ順に実装していく。


2. 業務背景: なぜパラメーターシート自動化が必要か

2-1. エンタープライズ AWS 案件の現実

エンタープライズの AWS 案件には、避けて通れないドキュメント要求がある。「パラメーターシート」だ。

プロジェクト開始時に設計パラメーターを記録し、構築後に実環境と突合し、レビューを経て顧客に提出する。この一連の作業が、規模の大小にかかわらず発生する。

「Terraform でコード管理しているなら、設計書も自動で出るんじゃないですか?」——顧客から聞かれたことがある方は多いだろう。答えは「仕組みを作れば出ますが、デフォルトでは出ません」だ。この記事はその仕組みを作る。

典型的な案件では以下のようなシートが求められる。

【典型的なパラメーターシート構成例(Excel)】

シート1: EC2インスタンス一覧
  - インスタンスID / AMI / インスタンスタイプ / VPC / サブネット / SG / タグ

シート2: VPC・ネットワーク一覧
  - VPC ID / CIDR / サブネット名 / AZ / ルートテーブル

シート3: RDS一覧
  - DB識別子 / エンジン / インスタンスクラス / ストレージ / マルチAZ / 暗号化

(以下、ALB・Lambda・API GW・DynamoDB・SNS・SQS ...)

このシートを 「Terraform コードから手動で転記」 している現場がいまだに多い。

2-2. 手動突合の工数と痛み

手動突合のフローを図で確認する。

Before: 手動突合フロー — 設計・構築・突合が分断されたサイロ状態

上の図(Before)では、「設計 → Terraform コード → AWS 環境構築 → 手動でパラメーターシート記入 → レビュー → 提出」の工程がすべて人手でつながっている。各工程の間に「コードを読んで Excel に転記する」という非生産的な作業が挟まる。

典型的な工数積み上げ

中規模案件(EC2 × 10、VPC × 2、RDS × 3、ALB × 2、Lambda × 20)を想定した場合の試算だ。

作業工数(人日)問題点
Terraform コードから属性値を読み出す1.0コードを1リソースずつ追う
AWS コンソールで実環境を確認し突合1.5コンソール画面の仕様変更リスク
Excel に転記・フォーマット整形1.0タイポ・行ズレのヒューマンエラー
レビュー対応・修正0.5指摘のたびに手動修正
合計4.0 人日約 3〜5 営業日相当

これが毎フェーズ(設計 → 構築 → 検収)発生し、さらにリソース追加・変更のたびに差分更新が必要になる。

実際のヒューマンエラー事例

手動転記で発生しやすいミスをいくつか挙げる。

  • SG ルールの抜け漏れ: ingress/egress のルールが多い場合、末尾のルールが転記されずにレビューを通過
  • マルチ環境の取り違え: dev の値を stg シートに転記するミス
  • AMI ID の古い版記載: plan 実行後に AMI を更新したが、シートは更新前の ID のまま提出
  • Lambda 関数名のタイポ: 関数名の末尾ハイフンを取り違え、顧客確認で発覚し差し戻し

これらはいずれも「人が目視で転記する」以上は避けられない構造的な問題だ。

2-3. 自動化後の世界(After)

本記事で実装する仕組みを導入すると、フローはこうなる。

# 1. 全環境の plan JSON を一括生成(Makefile)
make plan-all
# → environments/dev/plan.json
# → environments/stg/plan.json
# → environments/prod/plan.json

# 2. Python でパラメーターシート生成(1コマンド)
python generate_param_sheet.py
# → output/param_sheet_20260418.xlsx

この 2 コマンドで、3 環境ぶんのパラメーターが揃った Excel が自動生成される。

After: 自動化フロー — TF plan → Python → Excel が1コマンドでつながる

Before / After 比較

観点Before(手動)After(本記事の自動化)
所要時間3〜5 営業日(4.0 人日)約 15 分(初回セットアップ後)
転記ミス発生しやすい(人的ミス)ゼロ(コードから直接生成)
環境追加新環境ぶんの転記が増える自動対応(environments/ に追加するだけ)
更新対応TF 変更のたびに手動差分確認make plan-all && python generate_param_sheet.py を再実行
監査対応「誰がいつ書いたか」が不明生成日時・コミット SHA を meta シートに自動記録

数日かかっていた作業が 15 分に短縮される。さらに「転記ミス」が構造的にゼロになることで、レビュー工数も大幅に削減できる。

2-4. なぜ「terraform state」ではなく「terraform plan」を使うのか

terraform state pull で現在の state を読めばいいのでは」という疑問は自然だ。しかし state には致命的な問題がある。

State の問題点

// terraform.tfstate から EC2 の instance_type を読む場合
{
  "resources": [
 {
"type": "aws_instance",
"instances": [
  {
 "attributes": {
"instance_type": "t3.medium"
 }
  }
]
 }
  ]
}

State が持つ instance_type の値は 「AWS 環境に現在適用されている値」 だ。これはコードに記載された意図ではなく、apply 後の実態だ。

問題は「コンソールから手動変更された値」「別ツールが書き換えた値」も state に混入する点だ。パラメーターシートに求められるのは「Terraform コードに記載された設計意図の値」であり、実環境の現状値ではない。

Plan JSON の優位性

terraform plan -out tfplan && terraform show -json tfplan で生成される plan JSON には、コード記載値と計算値(aws_caller_identity 等の data source)の両方が含まれる。本記事では「ハイブリッドパーサ」と呼ぶアプローチで、コード記載値を優先しながら計算値を補完する手法を実装する(詳細はセクション 3〜5)。

2-5. AWS Config との関係(第2弾への接続)

本記事では AWS Config を使わないが、「なぜ第2弾で Config が登場するのか」を 1 段落で予告しておく。

AWS Config は AWS リソースの設定変更履歴を継続記録するサービスだ。「いつ、誰が、どのリソースの、何を変えたか」が記録に残る。第2弾では、Config の記録と Terraform コードが生成したパラメーターシートを pytest で突合し、「コードが想定している設定と実環境の設定がズレていないか」を単体テストとして自動検出する。

本記事でやること: Terraform コード → plan JSON → Excel パラメーターシート生成
第2弾でやること: パラメーターシート + AWS Config 記録 → 差分検出テスト

第1弾は Config がなくても完結する。Config を有効化していない AWS アカウントでも、本記事のハンズオンは動作する。

2-6. ハンズオンで使うサンプル構成

本記事のハンズオンでは、以下のシンプルな AWS 構成を例として使う。本番規模でなくても、仕組みを理解するには十分だ。

# サンプル構成の概要(terraform/main.tf)
# - VPC × 1
# - パブリックサブネット × 2(マルチAZ)
# - EC2 インスタンス × 1(t3.micro)
# - セキュリティグループ × 1(SSH・HTTP 許可)
# - RDS(db.t3.micro・PostgreSQL 15)× 1
# - S3 バケット(state 格納用)× 1

これだけのリソースでも、手動転記すると 30〜40 行の Excel 入力が必要になる。自動生成であれば 0 秒だ。

2-7. 手動突合フローの詳細解剖

Before の工程をさらに細かく分解する。自動化の価値を正確に把握するためだ。

典型的な手動フロー(フェーズごと)

【フェーズ1: 設計時】
┌─────────────────────────────────────────────────┐
│ 1. 設計者が Terraform コードを書く  │
│ 2. 設計者が Excel にパラメーター値を手入力│
│ (コードと Excel が別々に管理開始) │
│ 3. レビュアーが両方を見比べて確認  │
└─────────────────────────────────────────────────┘
 ↓ 構築フェーズへ進む
【フェーズ2: 構築時】
┌─────────────────────────────────────────────────┐
│ 4. terraform apply 実施 │
│ 5. apply 後の差分(計算値・リソースIDなど)を│
│ コンソールで確認し Excel に追記  │
│ 6. 環境ごとに同作業を繰り返す(dev→stg→prod)  │
└─────────────────────────────────────────────────┘
 ↓ 検収フェーズへ進む
【フェーズ3: 検収時】
┌─────────────────────────────────────────────────┐
│ 7. 顧客レビューで「この値が違う」と指摘  │
│ 8. コード・コンソール・Excel を再度突合  │
│ 9. 修正→再提出(フェーズ2〜3 が繰り返される)  │
└─────────────────────────────────────────────────┘

フェーズ2で「環境ごとに繰り返す」という点が特に痛い。dev・stg・prod の 3 環境があれば、工数は単純に 3 倍になる。

2-8. コードと Excel の二重管理問題

手動フローで最も深刻なのは「コードと Excel が二重管理になる」点だ。

Terraform コードを変更したとき、Excel も更新しなければならない。しかし、コードの変更通知が Excel 担当者に届くとは限らない。特にチーム開発では、誰かが variables.tf の値を変えても、Excel が古いまま放置されることが頻繁に起きる。

【よくある二重管理の破綻シナリオ】

Day 1:  Aさんが EC2 インスタンスタイプを t3.small に変更
  → コードを PR でマージ
  → Bさんの Excel 更新漏れ

Day 3:  Bさんが Excel を顧客に提出
  → 実環境は t3.small、Excel は t3.micro

Day 5:  顧客からの指摘で発覚
  → 差し戻し・再確認・再提出

本記事の自動化では、Excel の生成元が常に Terraform コードのみになる。コードを変更して make plan-all && python generate_param_sheet.py を再実行すれば、Excel が自動更新される。二重管理という概念がなくなる。

2-9. 自動化のコスト(初期投資)

「自動化の構築にもコストがかかるのでは」という疑問は正当だ。正直に答える。

初期構築コスト

作業想定時間
本記事の通読2〜3 時間
ハンズオン実行(Section 9)1〜2 時間
自分のプロジェクトへの適用4〜8 時間
合計7〜13 時間

初回は 1〜2 日の投資が必要だ。しかし 2 回目以降は「make plan-all && python generate_param_sheet.py を実行する 15 分」だけになる。

損益分岐点の試算

初期投資 10 時間(= 1.25 人日)を、手動突合の節約(4 人日/フェーズ)で回収する場合:

損益分岐 = 10時間 ÷ 4人日 = 0.3フェーズ

→ 最初のフェーズで回収完了
→ 以降は毎フェーズ 4人日 × 人日単価が削減効果

実際の案件では複数フェーズにまたがることが多く、長期プロジェクトほど投資対効果が高くなる。

2-10. 本シリーズが解決する 3 つの課題

まとめると、本シリーズは以下の 3 課題を解決する。

課題第1弾での解決第2弾での解決
Q1: パラメーターシート作成の工数plan JSON → Excel 自動生成で数日→15分
Q2: 転記ミス・ヒューマンエラーコードから直接生成するため構造的にゼロ
Q3: 実環境との整合性確認plan JSON でコード意図値を取得Config 突合テストで継続監視

本記事(第1弾)は Q1・Q2 の解決に集中する。Q3 の「実環境との継続的な整合性監視」は第2弾で AWS Config を使って実装する。

AWS Config 未経験の方へ: Q3 の対応は第2弾の担当です。本記事では Config の設定も Config の知識も一切不要です。

では、次のセクション(Section 3)から設計方針の詳細に入っていく。「なぜこのアーキテクチャになったのか」の背景を理解してからコードを読むと、実装の意図がよりクリアになる。


3. 設計方針(除外ルール含む)

本章では「なぜ Plan JSON ハイブリッド方式を選ぶのか」を3方式の比較から解説し、
除外ルール設計の多層防御とアーキテクチャ全体像を整理する。


3-1. Terraform パース3方式の比較

Terraform コードからパラメーターシート用の「コード記載値」を取り出す手段は、大きく3方式(+1)がある。
それぞれの特性を比較し、本記事が採用する方式を決定する。

方式取得内容ARN / ID 混入AWSデフォルト混入変数解決実装難易度
A. terraform show -json(state)実態値(apply 後)混入する混入する
B. terraform plan -out + show -json(plan 全体)計画値+構成情報一部(known after apply はマスク)プロバイダ補完値が混入
C. HCL 直接解析(python-hcl2 等)コード記載値のみ未解決(変数参照のまま)中〜高
D. Plan JSON ハイブリッド(本記事採用)code 記載キーだけ plan 値で抽出

「なぜ state ではダメか」— 具体例

terraform show -json が返す state JSON には、AWS が払い出した 実リソースの ARN・ID・IP アドレス がすべて含まれる。

{
  "values": {
 "root_module": {
"resources": [
  {
 "address": "aws_instance.app",
 "values": {
"id": "i-0a1b2c3d4e5f67890",
"arn": "arn:aws:ec2:ap-northeast-1:123456789012:instance/i-0a1b2c3d4e5f67890",
"public_ip": "54.199.xxx.xxx",
"private_ip": "10.0.1.50",
"instance_type": "t3.micro",
"ami": "ami-0123456789abcdef0",
"subnet_id": "subnet-0123456789abcdef0"
 }
  }
]
 }
  }
}

パラメーターシートに必要なのは「instance_type: t3.micro」「ami: ami-0123456789abcdef0」といった 設計者がコードに書いた値 だけだ。
しかし state JSON には idarnpublic_ipsubnet_id も混入する。
これらは AWS が動的に払い出す値であり、設計書に記載すべき「仕様」ではない。
state をそのままパラメーターシートに流し込むと 「AWS 払出し値 vs 設計値」の突合が崩壊 する。

「なぜ HCL 直解析ではダメか」

python-hcl2 を使った HCL 直接解析は ARN 混入を避けられるが、変数参照が未解決のまま残るという致命的な問題がある。

resource "aws_instance" "app" {
  instance_type = var.instance_type
  ami  = local.base_ami
  subnet_id  = module.vpc.private_subnet_id
}

このコードを HCL パーサで読むと instance_type の値は var.instance_type という文字列のままになる。
実際の値(t3.micro)はどこにも現れない。
パラメーターシートに var.instance_type と書かれても、突合に使える情報にはならない。

Plan JSON ハイブリッドの選定根拠

Plan JSON ハイブリッドは上記2つの欠点を同時に解消する。

  • configuration.root_module.resources[].expressions(コード記載キー集合の抽出元):
    コードに書いたキーだけが現れる。ARN や AWS デフォルト値は expressions登場しない

  • planned_values.root_module.resources[].values(解決済み値の抽出元):
    var.instance_typelocal.base_ami が plan 時に解決された実値に変換されている。

expressions に存在するキー ∩ planned_values の解決済み値」だけを Excel に出力することで、
コードに書いた値だけを、変数解決された状態で 取得できる。

# code_keys = configuration の expressions に現れるキー集合(設計者が書いたもの)
# values = planned_values の values(変数解決済み)
code_keys = set(expressions.keys())
exported = {k: v for k, v in values.items()
if k in code_keys and v is not None}

3-2. 除外ルール設計(多層防御)

Plan JSON ハイブリッドで expressions フィルタを通しても、
一部の ARN 派生フィールドや AWS 固有 ID が planned_values に残ることがある。
そのため Blacklist + code_keys フィルタの2層防御 を設計している。

実装コード(設計書 §5-3 準拠)

# 終端マッチ: 末尾がこれらで終わるキーは除外
EXCLUDE_SUFFIXES = ('_id', '_arn', 'tags_all')

# 完全一致: これらのキー名は無条件除外
EXCLUDE_EXACT = {'arn', 'id', 'owner_id', 'self_link'}

# サービス別除外: リソースタイプに応じて追加除外
EXCLUDE_BY_SERVICE = {
 'aws_instance': {'primary_network_interface_id', 'public_ip'},
 'aws_db_instance': {'address', 'endpoint', 'resource_id'},
}


def should_exclude(service: str, key: str) -> bool:
 """パラメーターシートへの出力対象から除外すべきキーか判定する。"""
 if key in EXCLUDE_EXACT:
  return True
 if any(key.endswith(s) for s in EXCLUDE_SUFFIXES):
  return True
 if key in EXCLUDE_BY_SERVICE.get(service, set()):
  return True
 return False

なぜ Whitelist(許可リスト)ではなく Blacklist(除外リスト)か

Whitelist 方式は「出力を許可するキーを明示的に列挙する」設計だ。
安全性は高いが、新しい AWS サービスやリソースタイプを追加するたびにリストを更新しなければならない
10 サービスから 20 サービスに増やすだけで保守工数が倍増する。

一方 Blacklist 方式は「除外したいパターンだけを管理する」。
AWS 払出し値の多くは末尾が _id_arn で終わるという一貫したパターンを持つため、
EXCLUDE_SUFFIXES で大部分をまとめて除外できる。
残った個別ケースはサービス別の EXCLUDE_BY_SERVICE で補う。

この「広域 Blacklist + code_keys フィルタ + サービス別 Blacklist」の多層防御 が、
シンプルさと正確性を両立する理由だ。

多層防御の全体図

plan.json
 │
 ├─ configuration.expressions  ─→  code_keys(Layer 1: コード記載キーのみ)
 │
 └─ planned_values.values─→  全キー(AWSデフォルト含む)
 │
∩ code_keys フィルタ(Layer 1 通過)
 │
EXCLUDE_SUFFIXES チェック(Layer 2-a)
 │
EXCLUDE_EXACT チェック(Layer 2-b)
 │
EXCLUDE_BY_SERVICE チェック(Layer 2-c)
 │
パラメーターシート出力値(最終結果)

3-3. 全体アーキテクチャの整理

3論点回答マトリクス

本記事の設計判断は「論点B(TF パース方式)・論点C(AWS Config 設定範囲)・論点E(既存記事との接続)」の3論点から成る。
第1弾(本記事)が対象とする論点 B・E をマトリクスで整理する。

┌─────────────────────────────────────────────────────────────────────┐
│  3論点回答マトリクス(第1弾スコープ)│
├──────────┬────────────────────────┬──────────────────────────────────┤
│  論点 │  選択肢  │  本記事の回答  │
├──────────┼────────────────────────┼──────────────────────────────────┤
│  論点B│ A. state│ │
│ (TFパース)│ B. plan 全体  │  D. Plan JSON ハイブリッド ✓ │
│ │ C. HCL 直解析 │  (code_keys × planned_values)  │
│ │ D. Plan JSON ハイブリッド││
├──────────┼────────────────────────┼──────────────────────────────────┤
│  論点C│ Config なし│  第2弾スコープ │
│ (Config) │ Config 最小構成  │  (本記事は TF → Excel のみ)│
│ │ Config + Aggregator │ │
├──────────┼────────────────────────┼──────────────────────────────────┤
│  論点E│ 前提知識なし  │ │
│ (既存記事)│ TF 基礎のみ参照  │  TF 基礎(ID:1120) +│
│ │ TF 基礎 + 実践参照  │  TF 実践(ID:1208) 参照 ✓ │
└──────────┴────────────────────────┴──────────────────────────────────┘

本記事の技術スタック

領域採用技術採用理由
TF パースPlan JSON ハイブリッド(configuration.expressionsplanned_valuesコード記載値のみ要件を厳密充足、変数解決済み
HCL 補助python-hcl2(オプション)Python 主言語のエコシステム統一、将来の HCL フォールバック用
Excel 生成openpyxlスタイル / 色 / 列幅 / 数式の書き戻しに対応
Excel 比較演算自作 Python(pandas なし)依存軽量化、突合対象行数は最大数千行で pandas 不要
マルチ環境environments/{dev,stg,prod}/ ディレクトリ分離cmd_040 第3弾(CodePipeline)と同手法で学習連続性確保
ビルド自動化GNU Make / シェルスクリプト全環境の plan.json 一括生成に make plan-all を利用
Terraform バージョン1.9 系plan JSON スキーマ(schema_version 1.2)が安定
AWS プロバイダhashicorp/aws ~> 5.0各サービスの最新属性に対応
対象リージョンap-northeast-1(東京)本記事サンプルの統一値

4. Terraform plan JSON を理解する

Plan JSON ハイブリッドの実装には plan.json の構造を理解する必要がある。
本章では plan の生成コマンドから、3層構造の読み分け方、実際の JSON 抜粋による解説まで順に進める。


4-1. plan コマンドの実行

# ステップ1: バイナリプラン(tfplan)を生成
terraform plan -out tfplan -input=false

# ステップ2: バイナリプランを JSON 形式に変換
terraform show -json tfplan > plan.json

ポイント:

  • -out tfplan はバイナリ形式でプラン結果を保存する。このファイルを直接読むことはできない。
  • terraform show -json tfplan によって人間・プログラムが読める JSON に変換する。
  • -input=false は CI / スクリプト実行時に変数入力プロンプトをスキップするために指定する。
  • plan.json のスキーマは Terraform 1.0 以降 schema_version: 1 で安定している。
    本記事は Terraform 1.9 系の schema_version 1.2 を前提とする。

注意: terraform show -json(引数なし)は state を JSON 化するコマンドだ。
plan.json を生成するには必ず -out tfplanterraform show -json tfplan の2ステップが必要。


4-2. plan.json の3層構造

plan.json の最上位レベルには多くのキーがあるが、本記事で利用するのは以下の 3領域 だ。

plan.json
├── configuration← 【Layer 1】コード記載情報(変数参照・式)
│└── root_module
│ └── resources[]
│  └── expressions  ← ★ code_keys の抽出元
│
├── planned_values  ← 【Layer 2】変数解決後の計画値
│└── root_module
│ └── resources[]
│  └── values ← ★ 実値の抽出元
│
└── resource_changes[] ← 【Layer 3】差分情報(add / change / delete)
 └── change.actions[]  (本記事では参照しない)

Layer 1: configuration

コードに書いた式・参照がそのまま記録される領域

  • キー expressions に、リソースブロック内の各属性の記述方法(定数値 or 変数参照)が格納される。
  • ここに 現れるキーだけが「設計者がコードに書いた属性」。AWS が自動補完したデフォルト値はここに出てこない。

Layer 2: planned_values

plan 時点で変数・locals・モジュール引数が解決された後の値が格納される領域

  • var.instance_type"t3.micro" のように、すべての変数参照が実値に展開されている。
  • known after apply(apply 後に初めて確定する値)は null になる。ARN・ID の多くはこれに該当し、自動的に除外できる。

Layer 3: resource_changes(本記事では参照しない)

actions フィールド(["create"] / ["update"] / ["delete"] / ["no-op"])で差分の種類がわかる。
Drift 検知など差分管理目的には有用だが、パラメーターシート生成では利用しない


4-3. configuration.expressions の読み方

expressions の各エントリは「定数値(constant_value)」または「変数参照(references)」のいずれかの形式を取る。

最小 JSON 例(aws_instance)

{
  "configuration": {
 "root_module": {
"resources": [
  {
 "address": "aws_instance.app",
 "type": "aws_instance",
 "name": "app",
 "provider_config_key": "aws",
 "expressions": {
"ami": {
  "references": ["local.base_ami"]
},
"instance_type": {
  "constant_value": "t3.micro"
},
"key_name": {
  "references": ["var.key_name"]
},
"subnet_id": {
  "references": ["module.vpc.private_subnet_id"]
},
"tags": {
  "constant_value": {
 "Name": "app-server",
 "Env": "dev"
  }
}
 }
  }
]
 }
  }
}

このように expressions には:

  • constant_value: HCL に直接書いたリテラル値(文字列・数値・マップ)
  • references: 変数(var.*)/ ローカル値(local.*)/ モジュール出力(module.*.*)への参照

のいずれかが格納される。

constant_value または references があれば code_keys に含める」のロジックは以下のとおりだ:

def extract_code_keys(expressions: dict) -> set[str]:
 """expressions に登場するキーを code_keys として返す。"""
 code_keys = set()
 for key, expr in expressions.items():
  # constant_value または references が存在すれば設計者が書いたキー
  if "constant_value" in expr or "references" in expr:
code_keys.add(key)
 return code_keys

この code_keysplanned_values.values を組み合わせることで、
変数参照が実値に解決された状態で、コード記載キーだけを取り出せる。

planned_values と組み合わせた実例

対応する planned_valuesvalues は次のようになる:

{
  "planned_values": {
 "root_module": {
"resources": [
  {
 "address": "aws_instance.app",
 "type": "aws_instance",
 "name": "app",
 "values": {
"ami": "ami-0123456789abcdef0",
"instance_type": "t3.micro",
"key_name": "my-keypair",
"subnet_id": "subnet-0123456789abcdef0",
"tags": {
  "Name": "app-server",
  "Env": "dev"
},
"id": null,
"arn": null,
"public_ip": null,
"private_ip": null,
"primary_network_interface_id": null,
"tags_all": {
  "Name": "app-server",
  "Env": "dev"
}
 }
  }
]
 }
  }
}

planned_values.values には idarnpublic_ip 等の AWS 払出し値も含まれるが:

  1. code_keys フィルタ: expressionsidarnpublic_ip は現れない → 一次除外
  2. EXCLUDE_SUFFIXES: tags_all_all 終端 → 除外対象)を補足
  3. EXCLUDE_EXACT: もし idarn が code_keys を漏れても完全一致で除外

この3層によって最終的に出力されるのは:

{
 "ami": "ami-0123456789abcdef0",
 "instance_type": "t3.micro",
 "key_name": "my-keypair",
 "tags": {"Name": "app-server", "Env": "dev"},
}

設計者がコードに書いた値だけが残る。

変数・locals・モジュールがどこで解決されるか

記述パターンexpressionsplanned_values
"t3.micro"(リテラル)constant_value: "t3.micro""t3.micro"
var.instance_typereferences: ["var.instance_type"]"t3.micro"(変数解決済み)
local.base_amireferences: ["local.base_ami"]"ami-0123456789abcdef0"(locals 展開済み)
module.vpc.private_subnet_idreferences: ["module.vpc.private_subnet_id"]"subnet-0123456789abcdef0"(モジュール出力展開済み)
AWS 払出し(id, arn 等)現れないnull(known after apply)

変数 / locals / モジュール引数の解決は planned_values で完了している
expressions は「何を書いたか」を判断するためだけに使い、実際の値は常に planned_values から取得する。


4-4. ハンズオン: 最小 HCL で plan.json を生成する

最小構成の aws_instance リソースと S3 バックエンド設定で、実際に plan.json を出力するまでの手順を示す。

ディレクトリ構成

handson/
├── environments/
│└── dev/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── (以降の章でモジュールを追加)

backend.tf

terraform {
  backend "s3" {
 bucket= "myorg-terraform-state"
 key= "dev/terraform.tfstate"
 region= "ap-northeast-1"
 dynamodb_table = "terraform-state-lock"
 encrypt  = true
  }
}

読み替え: myorg-terraform-state は自分の S3 バケット名に、
terraform-state-lock は DynamoDB テーブル名に置き換えること。

main.tf(最小 aws_instance 定義)

terraform {
  required_version = "~> 1.9"
  required_providers {
 aws = {
source  = "hashicorp/aws"
version = "~> 5.0"
 }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_instance" "app" {
  ami  = var.ami_id
  instance_type = var.instance_type

  tags = {
 Name = "handson-app"
 Env  = "dev"
  }
}

variables.tf

variable "ami_id" {
  type  = string
  description = "EC2 AMI ID(Amazon Linux 2023 等)"
}

variable "instance_type" {
  type  = string
  default  = "t3.micro"
  description = "EC2 インスタンスタイプ"
}

terraform.tfvars

ami_id  = "ami-0123456789abcdef0"
instance_type = "t3.micro"

注意: 実際の AMI ID は aws ec2 describe-images 等で最新の Amazon Linux 2023 AMI を確認して指定すること。

plan.json の生成手順

# environments/dev ディレクトリへ移動
cd environments/dev

# プロバイダのダウンロードと初期化
terraform init

# バイナリプランを出力(AWSへの変更は行われない)
terraform plan -out tfplan -input=false

# バイナリプランを JSON に変換
terraform show -json tfplan > plan.json

# 生成確認
wc -l plan.json # ファイルサイズ確認(数百〜数千行)
cat plan.json | python3 -m json.tool | head -30# 整形して先頭確認

生成された plan.json の構造確認

# configuration.expressions の確認
python3 -c "
import json, sys
plan = json.load(open('plan.json'))
for r in plan['configuration']['root_module']['resources']:
 print(r['address'], ':', list(r['expressions'].keys()))
"

期待される出力:

aws_instance.app : ['ami', 'instance_type', 'tags']

subnet_id を HCL に書いていないため、expressions には現れない。
このように 設計者が書いたキーだけが expressions に登録される ことが確認できる。

# planned_values の確認(変数解決後の値)
python3 -c "
import json
plan = json.load(open('plan.json'))
for r in plan['planned_values']['root_module']['resources']:
 print(r['address'])
 for k, v in r['values'].items():
  print(f'  {k}: {v}')
"

期待される出力(抜粋):

aws_instance.app
  ami: ami-0123456789abcdef0
  instance_type: t3.micro
  tags: {'Name': 'handson-app', 'Env': 'dev'}
  id: None
  arn: None
  public_ip: None
  ...(AWS 払出し値は null)

var.ami_idami-0123456789abcdef0 に、var.instance_typet3.micro に解決されていることがわかる。
idarnpublic_ipnull(known after apply)のため、v is not None フィルタで自動除外される。

ローカル実行(AWS 接続不要)の代替

backend として S3 を使わずローカルで試したい場合は、backend.tf を削除して local バックエンドを使う:

# backend.tf(ローカルテスト用)
terraform {
  backend "local" {
 path = "terraform.tfstate"
  }
}

ただし terraform plan 時に AWS への認証(EC2 AMI の存在確認等)は発生するため、
AWS_PROFILE または AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY の設定は必要だ。

# AWS CLI プロファイルを指定して実行
AWS_PROFILE=handson-dev terraform plan -out tfplan -input=false
terraform show -json tfplan > plan.json

これで plan.json が生成される。次章(Section 5)では、この plan.json を受け取って
パラメーターシート用のデータ構造に変換する Plan JSON ハイブリッドパーサ を実装する。


Section 5. Plan JSON ハイブリッドパーサの実装(Python)

Section 4 で plan.json の構造を理解したところで、いよいよ本記事の核となる Plan JSON ハイブリッドパーサ を実装します。このパーサが「Terraform コードに明示的に記載した値だけを、変数解決済みの状態で抽出する」ことで、後段の Excel 出力・AWS Config 突合が成り立ちます。


5-1. 実装方針の再確認

Section 3 で設計した「2段フィルタ方式」をコード化します。

2段フィルタの原理

plan.json
  │
  ├─ configuration.root_module.resources[].expressions
  │↓
  │【Layer 1: code_keys フィルタ】
  │Terraform コードに書かれた属性名のみを抽出 → code_keys 集合
  │
  ├─ planned_values.root_module.resources[].values
  │↓
  │【Layer 1 適用】 code_keys に含まれる key のみ通過
  │↓
  │【Layer 2: suffix マッチ除外】
  │key.endswith(('_id', '_arn', 'tags_all')) → 除外
  │↓
  │【Layer 3: exact / service 別除外】
  │key ∈ exclude_exact or exclude_by_service → 除外
  │↓
  │出力: {service_type: {resource_address: {attr: value}}}

なぜ 2 段構えが必要か?

planned_values には Terraform が AWS から補完したデフォルト値も含まれます。たとえば aws_instancetenancy(シングルテナンシー)や ebs_optimized は、HCL に書かなくても plan.json には常に現れます。Layer 1 の code_keys フィルタだけでこれらを除外できますが、Layer 2/3 でさらに _id, _arn 系のシステム払出し値を多重に弾くことで、false negative(意図せず値が漏れる)を防ぎます

処理フローチャート(概念図)

┌──────────────────────────────────────────────────┐
│  parse_plan(plan_json_path, config)  │
│  │
│  1. JSON 読み込み・format_version 検証  │
│  2. extract_code_keys(configuration) │
│  └── resource_address → code_keys の辞書を構築│
│  3. planned_values を走査│
│  ├── モジュール再帰展開(module.*)│
│  └── count / for_each インスタンス展開│
│  4. 各リソースに _extract_values() を適用  │
│  ├── Layer 1: code_keys 交差│
│  ├── Layer 2: suffix 除外│
│  └── Layer 3: exact / service 別除外 │
│  5. 戻り値: {service: {address: {attr: value}}}  │
└──────────────────────────────────────────────────┘

注意: draw.io フローチャート PNG(articles/diagrams/param-sheet-s5-flow.png)は別途掲載。本節ではテキスト版で構成を把握してください。


5-2. モジュール構成

aws-handson/
└── scripts/
 └── tf_plan_parser/
  ├── __init__.py # 公開 API をエクスポート
  ├── parser.py# コア実装(parse_plan 等)
  ├── cli.py# `python -m tf_plan_parser` エントリポイント
  └── tests/
├── __init__.py
├── fixtures/  # テスト用 plan.json スニペット
│├── minimal_vpc.json
│├── variable_resolved.json
│└── sensitive_env.json
├── test_normal.py# T01-T05 正常系
├── test_exclude.py  # T10-T13 除外系
└── test_edge.py  # T20-T24 エッジケース

各ファイルの役割を以下に示します。

ファイル役割
parser.pyparse_plan, extract_code_keys, should_exclude, ParserConfig を実装
cli.pyargparse + parse_plan を組み合わせた CLI ラッパ
__init__.pyfrom tf_plan_parser import parse_plan, ParserConfig の公開
tests/pytest テストスイート(T01-T24)

5-3. parser.py 完全実装

5-3-1. ParserConfig — パーサ設定の型定義

# scripts/tf_plan_parser/parser.py
from __future__ import annotations

import json
import logging
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

SUPPORTED_FORMAT_VERSIONS = {"1.0", "1.1", "1.2"}

# ---------------------------------------------------------------
# 設定クラス
# ---------------------------------------------------------------

@dataclass(frozen=True)
class ParserConfig:
 """ハイブリッドパーサの除外ルール設定。

 イミュータブル(frozen=True)なので、複数スレッドから共有しても安全。
 """

 exclude_suffixes: tuple[str, ...] = ("_id", "_arn", "tags_all")
 """Layer 2: キー末尾マッチで除外するサフィックス群"""

 exclude_exact: frozenset[str] = frozenset({"arn", "id", "owner_id", "self_link"})
 """Layer 3: 完全一致で除外するキー名"""

 exclude_by_service: dict[str, frozenset[str]] = field(
  default_factory=lambda: {
"aws_instance": frozenset({"primary_network_interface_id", "public_ip", "private_ip"}),
"aws_db_instance": frozenset({"address", "endpoint", "resource_id", "hosted_zone_id"}),
"aws_lb": frozenset({"dns_name", "zone_id"}),
  }
 )
 """Layer 3: サービス別の追加除外キー"""

 include_sensitive: bool = False
 """True にすると sensitive=true の属性値を保持する(デフォルト: REDACTED 置換)"""

ParserConfigdataclass(frozen=True) にすることで、設定を誤って書き換えるバグを防ぎます。呼び出し側は通常デフォルトをそのまま使い、exclude_by_service を追加したい場合のみ上書きします。

5-3-2. extract_code_keys — Layer 1 の辞書構築

def extract_code_keys(configuration: dict) -> dict[str, set[str]]:
 """configuration.root_module から resource_address → code_keys の辞書を構築する。

 Args:
  configuration: plan.json の configuration キー配下の dict

 Returns:
  {resource_address: {attr_key, ...}}
  例: {"aws_instance.web": {"instance_type", "ami", "tags"}}
 """
 result: dict[str, set[str]] = {}

 root = configuration.get("root_module", {})
 _collect_resources_keys(root, prefix="", result=result)

 return result


def _collect_resources_keys(
 module_block: dict,
 prefix: str,
 result: dict[str, set[str]],
) -> None:
 """モジュール再帰で configuration.resources の expressions を収集する。"""

 # 直接配置のリソース
 for res in module_block.get("resources", []):
  address = res.get("address", "")
  if prefix:
address = f"{prefix}.{address}"
  expressions: dict = res.get("expressions", {})
  code_keys = _flatten_expression_keys(expressions)
  if code_keys:
result[address] = code_keys

 # module_calls を再帰的に辿る
 for mod_name, mod_block in module_block.get("module_calls", {}).items():
  child_module = mod_block.get("module", {})
  child_prefix = f"module.{mod_name}" if not prefix else f"{prefix}.module.{mod_name}"
  _collect_resources_keys(child_module, prefix=child_prefix, result=result)


def _flatten_expression_keys(expressions: dict) -> set[str]:
 """expressions 辞書のトップレベルキーを返す。

 ネストされたブロック(例: network_interface {})はキー名だけ収録し、
 その内部属性は現状スコープ外とする。
 """
 return {k for k in expressions if not k.startswith("//") and k != "depends_on"}

_flatten_expression_keysexpressions のトップレベルキーだけを拾います。ingress / egress のようにブロック内がリストの場合、キー名 ingress は収録され、ブロック内部の from_port 等は Section 8 の SG 専用処理で扱います。

5-3-3. should_exclude — Layer 2/3 の除外判定

def should_exclude(service: str, key: str, config: ParserConfig) -> bool:
 """3 層除外ルールを統合判定する。

 Args:
  service: リソースタイプ(例: "aws_instance")
  key: 属性キー名(例: "instance_id", "ami")
  config: ParserConfig

 Returns:
  True = 除外すべき(パラメーターシートに出力しない)
 """
 # Layer 2: suffix マッチ
 if key.endswith(config.exclude_suffixes):
  return True

 # Layer 3a: exact マッチ
 if key in config.exclude_exact:
  return True

 # Layer 3b: サービス別除外
 service_excludes = config.exclude_by_service.get(service, frozenset())
 if key in service_excludes:
  return True

 return False

5-3-4. _extract_values — 値の抽出と null 処理

def _extract_values(
 planned: dict,
 code_keys: set[str],
 service: str,
 config: ParserConfig,
) -> dict[str, Any]:
 """planned_values の values dict から、code_keys ∩ !excluded の属性を抽出する。

 Args:
  planned: `planned_values.root_module.resources[i].values` に相当する dict
  code_keys: extract_code_keys で得た、このリソースの code 記載キー集合
  service: リソースタイプ(除外判定に使用)
  config: ParserConfig

 Returns:
  {attr: value, ...} — 除外済み・null 処理済みの dict
 """
 extracted: dict[str, Any] = {}

 for key, raw_value in planned.items():
  # Layer 1: code_keys フィルタ
  if key not in code_keys:
continue

  # Layer 2/3: 除外ルール
  if should_exclude(service, key, config):
continue

  # sensitive 値の処理
  if isinstance(raw_value, dict) and raw_value.get("sensitive") is True:
if not config.include_sensitive:
 extracted[key] = "***REDACTED***"
else:
 extracted[key] = raw_value.get("value")
continue

  # known_after_apply(None) はそのまま保持
  # → パラメーターシート側で「(apply後確定)」と表示
  extracted[key] = raw_value

 return extracted

known_after_apply の値は null(Python では None)として plan.json に現れます。None をそのまま辞書に入れておき、Excel 出力時に「(apply後確定)」と表示する設計です。

5-3-5. parse_plan — メインエントリポイント

def parse_plan(
 plan_json_path: Path,
 config: ParserConfig | None = None,
) -> dict[str, dict[str, dict[str, Any]]]:
 """plan.json を読み込み、code 記載値のみを抽出して返す。

 Args:
  plan_json_path: `terraform show -json tfplan` の出力ファイルパス
  config: パーサ設定(None なら ParserConfig() デフォルト値)

 Returns:
  {service_type: {resource_address: {attr: value, ...}}}

  例:
  {
 "aws_instance": {
"aws_instance.web": {"instance_type": "t3.medium", "ami": "ami-0abc1234"}
 },
 "aws_vpc": {
"aws_vpc.main": {"cidr_block": "10.0.0.0/16", "enable_dns_hostnames": True}
 }
  }

 Raises:
  FileNotFoundError: plan_json_path が存在しない
  ValueError: plan.json のパースに失敗、または format_version 非対応
 """
 if config is None:
  config = ParserConfig()

 if not plan_json_path.exists():
  raise FileNotFoundError(f"plan.json が見つかりません: {plan_json_path}")

 with plan_json_path.open(encoding="utf-8") as fp:
  try:
plan = json.load(fp)
  except json.JSONDecodeError as exc:
raise ValueError(f"plan.json の JSON パースに失敗: {exc}") from exc

 # format_version チェック
 fmt_ver = plan.get("format_version")
 if fmt_ver is None:
  raise ValueError("plan.json に format_version がありません。壊れたファイルの可能性があります。")
 if fmt_ver not in SUPPORTED_FORMAT_VERSIONS:
  warnings.warn(
f"未検証の format_version: {fmt_ver}。可能な範囲でパースを続行します。",
UserWarning,
stacklevel=2,
  )

 # Step 1: code_keys 辞書の構築
 configuration = plan.get("configuration", {})
 code_keys_map = extract_code_keys(configuration)

 if not code_keys_map:
  logger.warning("configuration.root_module にリソースが見つかりませんでした。空 dict を返します。")
  return {}

 # Step 2: planned_values を走査して値を抽出
 planned_values = plan.get("planned_values", {})
 if not planned_values:
  return {}

 result: dict[str, dict[str, dict[str, Any]]] = {}
 root_module = planned_values.get("root_module", {})
 _collect_planned_resources(root_module, prefix="", code_keys_map=code_keys_map, config=config, result=result)

 return result


def _collect_planned_resources(
 module_block: dict,
 prefix: str,
 code_keys_map: dict[str, set[str]],
 config: ParserConfig,
 result: dict[str, dict[str, dict[str, Any]]],
) -> None:
 """planned_values モジュールを再帰的に走査してリソース値を収集する。"""

 for resource in module_block.get("resources", []):
  address: str = resource.get("address", "")
  if prefix:
address = f"{prefix}.{address}"

  resource_type: str = resource.get("type", "")
  values: dict = resource.get("values", {})

  # code_keys が未登録のリソース(動的に生成されたもの等)はスキップ
  code_keys = code_keys_map.get(address, set())
  if not code_keys:
# モジュール展開(count/for_each)では address に "[0]" / `["key"]` がつく
# ベースアドレスで fallback 検索
base = _strip_index_suffix(address)
code_keys = code_keys_map.get(base, set())

  extracted = _extract_values(values, code_keys, resource_type, config)

  if extracted:
result.setdefault(resource_type, {})[address] = extracted

 # 子モジュールの再帰処理
 for child_module in module_block.get("child_modules", []):
  child_address = child_module.get("address", "")
  _collect_planned_resources(
child_module,
prefix=child_address,
code_keys_map=code_keys_map,
config=config,
result=result,
  )


def _strip_index_suffix(address: str) -> str:
 """count/for_each の index suffix を取り除く。

 例:
  "aws_instance.web[0]" → "aws_instance.web"
  "aws_instance.web[\"a\"]" → "aws_instance.web"
 """
 import re
 return re.sub(r'\[.*?\]$', '', address)

5-3-6. __init__.py — 公開 API

# scripts/tf_plan_parser/__init__.py
from .parser import ParserConfig, extract_code_keys, parse_plan, should_exclude

__all__ = ["parse_plan", "extract_code_keys", "should_exclude", "ParserConfig"]

5-4. cli.py — コマンドラインエントリポイント

# scripts/tf_plan_parser/cli.py
"""
使用例:
  python -m tf_plan_parser environments/dev/plan.json
  python -m tf_plan_parser environments/dev/plan.json --format table --service aws_instance
  python -m tf_plan_parser environments/dev/plan.json --include-sensitive
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from .parser import ParserConfig, parse_plan


def _build_parser() -> argparse.ArgumentParser:
 p = argparse.ArgumentParser(
  prog="tf_plan_parser",
  description="terraform show -json の plan.json からコード記載値のみを抽出します。",
 )
 p.add_argument("plan_json", type=Path, help="plan.json のパス")
 p.add_argument(
  "--format",
  choices=["json", "table"],
  default="json",
  help="出力フォーマット(デフォルト: json)",
 )
 p.add_argument(
  "--service",
  default=None,
  help="絞り込むサービス種別(例: aws_instance)。省略時は全サービス",
 )
 p.add_argument(
  "--include-sensitive",
  action="store_true",
  default=False,
  help="sensitive=true の属性値を REDACTED せずに出力する",
 )
 return p


def _print_table(data: dict) -> None:
 """簡易テーブル形式で出力する。"""
 for service, resources in data.items():
  print(f"\n=== {service} ===")
  for address, attrs in resources.items():
print(f"  [{address}]")
for key, value in attrs.items():
 print(f" {key}: {value!r}")


def main(argv: list[str] | None = None) -> int:
 args = _build_parser().parse_args(argv)

 config = ParserConfig(include_sensitive=args.include_sensitive)

 try:
  result = parse_plan(args.plan_json, config=config)
 except (FileNotFoundError, ValueError) as exc:
  print(f"ERROR: {exc}", file=sys.stderr)
  return 1

 # サービス絞り込み
 if args.service:
  result = {k: v for k, v in result.items() if k == args.service}

 if args.format == "json":
  print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
 else:
  _print_table(result)

 return 0


if __name__ == "__main__":
 sys.exit(main())

__main__.py を追加することで python -m tf_plan_parser が動作します。

# scripts/tf_plan_parser/__main__.py
from .cli import main
import sys

sys.exit(main())

5-5. 動作確認

最小 HCL でのエンドツーエンド確認

以下の HCL で plan.json を生成して動作を確認します。

# environments/dev/main.tf
terraform {
  required_providers {
 aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" { region = "ap-northeast-1" }

variable "instance_type" { default = "t3.micro" }
variable "ami_id"  { default = "ami-0abc123456789012" }

resource "aws_instance" "web" {
  ami  = var.ami_id
  instance_type = var.instance_type

  tags = { Name = "dev-web", Env = "dev" }
}
# plan.json の生成
cd environments/dev
terraform init
terraform plan -out tfplan
terraform show -json tfplan > plan.json

# パーサの実行
cd /path/to/aws-handson
python -m tf_plan_parser environments/dev/plan.json | python3 -m json.tool

期待出力:

{
  "aws_instance": {
 "aws_instance.web": {
"ami": "ami-0abc123456789012",
"instance_type": "t3.micro",
"tags": {
  "Env": "dev",
  "Name": "dev-web"
}
 }
  }
}

instance_id, public_ip, private_ip など HCL に書いていない属性は一切出力されていません。

テーブル形式での確認

python -m tf_plan_parser environments/dev/plan.json --format table --service aws_instance
=== aws_instance ===
  [aws_instance.web]
 ami: 'ami-0abc123456789012'
 instance_type: 't3.micro'
 tags: {'Env': 'dev', 'Name': 'dev-web'}

複数環境の並列確認

for env in dev stg prod; do
  echo "=== $env ==="
  python -m tf_plan_parser environments/$env/plan.json --format table
done

5-6. テストコード(tests/test_normal.py · tests/test_exclude.py

スキル設計書 T01–T24 のうち 5 ケースを pytest でコード化します。テスト用フィクスチャは辞書で定義し、ファイル I/O の代わりに tmp_path を使います。

# scripts/tf_plan_parser/tests/test_normal.py
"""正常系テスト: T01, T02, T03"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from tf_plan_parser import ParserConfig, parse_plan

# ---------------------------------------------------------------
# フィクスチャ生成ヘルパ
# ---------------------------------------------------------------

def _write_plan(tmp_path: Path, plan_dict: dict) -> Path:
 p = tmp_path / "plan.json"
 p.write_text(json.dumps(plan_dict), encoding="utf-8")
 return p


def _minimal_plan(resources_conf: list, resources_planned: list) -> dict:
 """最小構成の plan.json テンプレートを生成する。"""
 return {
  "format_version": "1.0",
  "configuration": {
"root_module": {
 "resources": resources_conf
}
  },
  "planned_values": {
"root_module": {
 "resources": resources_planned
}
  },
 }


# ---------------------------------------------------------------
# T01: 最小 HCL(aws_vpc 1 個)の plan.json
# ---------------------------------------------------------------

def test_T01_minimal_vpc(tmp_path: Path) -> None:
 """cidr_block のみ抽出され、id / arn は出力されないこと。"""
 plan = _minimal_plan(
  resources_conf=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"expressions": {
 "cidr_block": {"constant_value": "10.0.0.0/16"}
},
  }],
  resources_planned=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"values": {
 "cidr_block": "10.0.0.0/16",
 "id": "vpc-0abc1234",# AWS払出し → 除外期待
 "arn": "arn:aws:ec2:ap-northeast-1:123456789012:vpc/vpc-0abc1234",  # 除外期待
 "enable_dns_hostnames": True,  # code 未記載 → 除外期待
},
  }],
 )
 path = _write_plan(tmp_path, plan)
 result = parse_plan(path)

 assert "aws_vpc" in result
 attrs = result["aws_vpc"]["aws_vpc.main"]
 assert attrs == {"cidr_block": "10.0.0.0/16"}
 assert "id" not in attrs
 assert "arn" not in attrs
 assert "enable_dns_hostnames" not in attrs


# ---------------------------------------------------------------
# T02: 変数参照(var.instance_type)の plan.json
# ---------------------------------------------------------------

def test_T02_variable_resolved(tmp_path: Path) -> None:
 """planned_values に変数解決済み値が入り、パーサがそれを抽出できること。"""
 plan = _minimal_plan(
  resources_conf=[{
"address": "aws_instance.web",
"type": "aws_instance",
"expressions": {
 "ami":  {"references": ["var.ami_id"]},
 "instance_type": {"references": ["var.instance_type"]},
},
  }],
  resources_planned=[{
"address": "aws_instance.web",
"type": "aws_instance",
"values": {
 "ami":  "ami-0abc123456789012",
 "instance_type": "t3.medium",
 "id":None,# known_after_apply
 "public_ip":  None,# aws_instance service-別除外
},
  }],
 )
 path = _write_plan(tmp_path, plan)
 result = parse_plan(path)

 attrs = result["aws_instance"]["aws_instance.web"]
 assert attrs["ami"] == "ami-0abc123456789012"
 assert attrs["instance_type"] == "t3.medium"
 assert "id" not in attrs
 assert "public_ip" not in attrs


# ---------------------------------------------------------------
# T03: 複数リソース・複数サービス混在
# ---------------------------------------------------------------

def test_T03_multiple_services(tmp_path: Path) -> None:
 """サービス種別ごとに辞書が分かれて格納されること。"""
 plan = _minimal_plan(
  resources_conf=[
{
 "address": "aws_vpc.main",
 "type": "aws_vpc",
 "expressions": {"cidr_block": {"constant_value": "10.0.0.0/16"}},
},
{
 "address": "aws_instance.web",
 "type": "aws_instance",
 "expressions": {"instance_type": {"constant_value": "t3.micro"}, "ami": {"constant_value": "ami-x"}},
},
  ],
  resources_planned=[
{
 "address": "aws_vpc.main",
 "type": "aws_vpc",
 "values": {"cidr_block": "10.0.0.0/16", "id": "vpc-0000"},
},
{
 "address": "aws_instance.web",
 "type": "aws_instance",
 "values": {"instance_type": "t3.micro", "ami": "ami-x", "id": None},
},
  ],
 )
 path = _write_plan(tmp_path, plan)
 result = parse_plan(path)

 assert set(result.keys()) == {"aws_vpc", "aws_instance"}
 assert "aws_vpc.main" in result["aws_vpc"]
 assert "aws_instance.web" in result["aws_instance"]
# scripts/tf_plan_parser/tests/test_exclude.py
"""除外系テスト: T10, T21"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from tf_plan_parser import ParserConfig, parse_plan

from .test_normal import _minimal_plan, _write_plan


# ---------------------------------------------------------------
# T10: planned_values に arn / id が含まれる → 出力されないこと
# ---------------------------------------------------------------

def test_T10_arn_id_excluded(tmp_path: Path) -> None:
 plan = _minimal_plan(
  resources_conf=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"expressions": {
 "cidr_block": {"constant_value": "172.16.0.0/12"},
 "arn":  {"constant_value": "dummy"},# code に書いてある場合でも除外
 "id":{"constant_value": "dummy"},
},
  }],
  resources_planned=[{
"address": "aws_vpc.main",
"type": "aws_vpc",
"values": {
 "cidr_block": "172.16.0.0/12",
 "arn": "arn:aws:ec2:ap-northeast-1:123456789012:vpc/vpc-1111",
 "id":  "vpc-1111",
},
  }],
 )
 path = _write_plan(tmp_path, plan)
 result = parse_plan(path)

 attrs = result["aws_vpc"]["aws_vpc.main"]
 assert "arn" not in attrs
 assert "id" not in attrs
 assert attrs["cidr_block"] == "172.16.0.0/12"


# ---------------------------------------------------------------
# T21: sensitive=true の属性 → REDACTED / include_sensitive で保持
# ---------------------------------------------------------------

def test_T21_sensitive_redacted_by_default(tmp_path: Path) -> None:
 """include_sensitive=False(デフォルト)なら ***REDACTED*** に置換されること。"""
 plan = _minimal_plan(
  resources_conf=[{
"address": "aws_lambda_function.handler",
"type": "aws_lambda_function",
"expressions": {
 "function_name": {"constant_value": "my-func"},
 "environment":{"references": ["var.env_vars"]},
},
  }],
  resources_planned=[{
"address": "aws_lambda_function.handler",
"type": "aws_lambda_function",
"values": {
 "function_name": "my-func",
 "environment": {"sensitive": True, "value": {"DB_PASSWORD": "s3cr3t"}},
},
  }],
 )
 path = _write_plan(tmp_path, plan)

 # デフォルト(include_sensitive=False)
 result = parse_plan(path)
 attrs = result["aws_lambda_function"]["aws_lambda_function.handler"]
 assert attrs["environment"] == "***REDACTED***"

 # include_sensitive=True なら生値が入る
 config = ParserConfig(include_sensitive=True)
 result2 = parse_plan(path, config=config)
 attrs2 = result2["aws_lambda_function"]["aws_lambda_function.handler"]
 assert attrs2["environment"] == {"DB_PASSWORD": "s3cr3t"}

テストの実行

cd aws-handson
pip install pytest
python -m pytest scripts/tf_plan_parser/tests/ -v
tests/test_normal.py::test_T01_minimal_vpcPASSED
tests/test_normal.py::test_T02_variable_resolvedPASSED
tests/test_normal.py::test_T03_multiple_servicesPASSED
tests/test_exclude.py::test_T10_arn_id_excluded PASSED
tests/test_exclude.py::test_T21_sensitive_redacted_by_default PASSED

5 passed in 0.12s

5 ケース全件 PASS で、パーサの基本動作・除外ルール・sensitive 処理を網羅しています。


Section 5 まとめ

本セクションで実装したコアパーサの特徴を整理します。

機能実装ポイント
2段フィルタextract_code_keys で code_keys を先行構築 → _extract_values で交差
モジュール再帰_collect_resources_keys / _collect_planned_resources で再帰展開
count/for_each_strip_index_suffix でベースアドレスへ fallback
sensitive 処理ParserConfig.include_sensitive フラグで切替
CLI 二重利用cli.py + __main__.pypython -m tf_plan_parser として直接呼出し可
テスト容易性_minimal_plan ヘルパで fixtures を dict として宣言 → 外部ファイル不要

Section 6 では、このパーサを3環境(dev/stg/prod)に対して並列実行し、結果を集約するマルチ環境ループを実装します。

6. マルチ環境対応(environments/ 分離と集約ループ)

単一の main.tfdev/stg/prod を切り替える構成もありますが、エンタープライズ案件では
環境ごとに独立したディレクトリを持つ方式が主流です。
理由は明快で、各環境のバックエンド設定・変数値・バージョンピン留めを完全に独立管理できるためです。

本シリーズでも、AWS×Terraform 複数人開発シリーズ 第3弾
で採用した environments/ ディレクトリ分離パターンを踏襲します。
本セクションでは、その構成を前提にしながら、全環境の plan.json を一括生成し
3つの結果を1つの Python 集約ループで統合するところまでを実装します。


6-1. ディレクトリ構成

プロジェクト全体のディレクトリ構成を確認しましょう。
environments/ 配下に dev/stg/prod が並び、それぞれが独立した Terraform ルートモジュールになっています。

param-sheet-project/
├── environments/
│├── dev/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│├── stg/
││├── main.tf
││├── variables.tf
││├── terraform.tfvars
││└── backend.tf
│└── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── modules/
│├── ec2/
││├── main.tf
││├── variables.tf
││└── outputs.tf
│├── vpc/
│├── rds/
│└── lambda/
├── scripts/
│├── tf_plan_parser/
││├── __init__.py
││└── parser.py
│├── tf_to_excel.py
│└── aggregate_envs.py
├── output/
└── Makefile

ポイント:

  • environments/ 各ディレクトリが独立した backend.tf を持つため、state ファイルが環境ごとに隔離されます。
  • modules/ には環境横断で再利用するリソース定義を置き、各 environmentsplan.json
    -rw-r--r--  1 user  staff84K Apr 18 16:45 environments/dev/plan.json
    -rw-r--r--  1 user  staff87K Apr 18 16:45 environments/stg/plan.json
    -rw-r--r--  1 user  staff91K Apr 18 16:46 environments/prod/plan.json
    

    ステップ 4: 集約ループで動作確認する

    python scripts/aggregate_envs.py .
    
    === dev: 12 resources ===
      aws_instance.web
      aws_lb.main
      aws_security_group.web
      ... (9 more)
    === stg: 12 resources ===
      aws_instance.web
      aws_lb.main
      aws_security_group.web
      ... (9 more)
    === prod: 14 resources ===
      aws_instance.web
      aws_lb.main
      aws_rds_cluster.main
      ... (11 more)
    

    prod のみ RDS クラスターが追加されているような環境差も、集約ループが正しく拾えていることがわかります。

    よくあるエラーと対処

    エラー原因対処
    Error: No valid credential sources foundAWS 認証情報が未設定aws configure または環境変数 AWS_PROFILE を設定
    Error: Failed to get existing workspacesS3 バケット or DynamoDB テーブルが存在しないバケット/テーブルを先に作成する(bootstrap 手順は 第3弾 参照)
    FileNotFoundError: plan.json not foundmake plan-all が未実行make plan-all を先に実行する
    Error: Unsupported Terraform Core versionTerraform バージョンが required_version と不一致tfenv などでバージョンを合わせる(本記事は 1.9.x 想定)

    本セクションのまとめ

    本セクションでは以下を実装しました:

    • environments/{dev,stg,prod}/ ディレクトリ分離と、S3 バックエンドを使った state 管理設計
    • Makefileplan-all ターゲットによる全環境一括 plan.json 生成
    • aggregate_envs.py による3環境の解析結果統合({env: {resource: {attr: value}}} 形式)
    • make plan-all からの実際の動作確認手順

    次の Section 7 では、aggregate() が返す辞書を受け取り、openpyxl で2段ヘッダ・セル色分け・列幅自動調整を施したパラメーターシート Excel を生成する実装を見ていきます。

    Section 7. Excel 出力(openpyxl、統合書式、色・マージ)

    本セクションでは、Section 5 の parse_plan() が返す {address: {key: value}} 形式のデータを
    受け取り、複数環境を統合した Excel パラメーターシートを生成する実装を解説する。


    7-1. 出力 Excel の構成

    生成する xlsx ファイルは 3 シート構成とする。

    シート名内容目的
    統合ビュー2段ヘッダ × 全環境の期待値設計値の一覧と環境横断の比較
    差分一覧NG 行のみ抽出(第2弾で活用)差分レビューの効率化
    meta実行時刻・リージョン・AWSアカウント・tf version成果物のトレーサビリティ確保
    # シート名定数
    SHEET_INTEGRATED = "統合ビュー"
    SHEET_DIFF = "差分一覧"
    SHEET_META = "meta"
    

    Sheet1「統合ビュー」の列構成:

    [A] サービス  [B] リソース名  [C] 属性
    [D] 期待値(dev)  [E] 期待値(stg)  [F] 期待値(prod)
    ...(第2弾では現状値・判定列が右側に追加される)
    

    第1弾では「期待値」のみ出力し、第2弾(AWS Config 突合)で現状値・判定列を右側に追加する設計にしておく。
    これにより、第2弾移行時に ENV_SUBCOLS リストを変更するだけで列グループが拡張できる。

    Note: Section 6 で生成した {env: {address: {key: val}}} の入力形式を前提とする。
    Section 5 のパーサ出力スキーマ(parse_plan() の戻り値)と合わせておくことで、
    Section 6 の集約ループがそのまま本モジュールへ接続できる。


    7-2. 2段ヘッダの実装(設計書 §5-1)

    設計書の指定通り、Row1 に環境グループを、Row2 に「期待値」列名を配置する。
    openpyxlmerge_cells で Row1 の環境グループヘッダを横方向にマージする。

    7-2-1. 列インデックスの設計

    from __future__ import annotations
    from openpyxl import Workbook
    from openpyxl.utils import get_column_letter
    from openpyxl.styles import (
     PatternFill, Font, Alignment, Border, Side,
    )
    from openpyxl.worksheet.worksheet import Worksheet
    from typing import Any
    
    # 固定列: サービス・リソース名・属性
    FIXED_COLS = ["サービス", "リソース名", "属性"]
    N_FIXED = len(FIXED_COLS)  # = 3
    
    # 環境ごとの列グループ(第1弾: 期待値のみ)
    # 第2弾では ("期待値", "現状値", "判定") に拡張
    ENV_SUBCOLS= ["期待値"]
    N_ENV_SUBCOL  = len(ENV_SUBCOLS)  # = 1(第1弾)
    

    7-2-2. build_header() 関数

    def build_header(ws: Worksheet, envs: list[str]) -> int:
     """
     Row1: サービス | リソース名 | 属性 | === dev === | === stg === | ...
     Row2:|  |  | 期待値 | 期待値 | ...
    
     Returns:
      int: データ書き込み開始行番号(= 3)
     """
     # ---- Row1: 固定列ヘッダ(Row2 と縦マージ)----
     for col_idx, label in enumerate(FIXED_COLS, start=1):
      cell = ws.cell(row=1, column=col_idx, value=label)
      _style_header_fixed(cell)
      ws.merge_cells(
    start_row=1, start_column=col_idx,
    end_row=2,end_column=col_idx,
      )
    
     # ---- Row1: 環境グループヘッダ ----
     for env_idx, env in enumerate(envs):
      group_start_col = N_FIXED + env_idx * N_ENV_SUBCOL + 1
      group_end_col= group_start_col + N_ENV_SUBCOL - 1
    
      cell = ws.cell(row=1, column=group_start_col, value=f"=== {env} ===")
      _style_header_env(cell, env_idx)
    
      # N_ENV_SUBCOL > 1 のとき(第2弾以降)は横マージ
      if N_ENV_SUBCOL > 1:
    ws.merge_cells(
     start_row=1, start_column=group_start_col,
     end_row=1,end_column=group_end_col,
    )
    
      # ---- Row2: 環境サブ列ヘッダ ----
      for sub_idx, sub_label in enumerate(ENV_SUBCOLS):
    sub_col = group_start_col + sub_idx
    cell = ws.cell(row=2, column=sub_col, value=sub_label)
    _style_header_sub(cell, env_idx)
    
     return 3  # データ開始行
    

    7-2-3. ヘッダスタイル補助関数

    HEADER_FONT_FIXED = Font(name="Meiryo UI", bold=True, size=10)
    HEADER_FONT_ENV= Font(name="Meiryo UI", bold=True, size=10, color="FFFFFF")
    
    # 環境ごとの背景色(最大5環境)
    ENV_HEADER_COLORS = ["2F75B6", "375623", "843C0C", "5C3317", "1F3864"]
    
    THIN_BORDER = Border(
     left=Side(style="thin"), right=Side(style="thin"),
     top=Side(style="thin"),  bottom=Side(style="thin"),
    )
    
    def _style_header_fixed(cell) -> None:
     cell.font= HEADER_FONT_FIXED
     cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
     cell.fill= PatternFill("solid", fgColor="D9D9D9")
     cell.border = THIN_BORDER
    
    def _style_header_env(cell, env_idx: int) -> None:
     color = ENV_HEADER_COLORS[env_idx % len(ENV_HEADER_COLORS)]
     cell.font= HEADER_FONT_ENV
     cell.fill= PatternFill("solid", fgColor=color)
     cell.alignment = Alignment(horizontal="center", vertical="center")
     cell.border = THIN_BORDER
    
    def _style_header_sub(cell, env_idx: int) -> None:
     color = ENV_HEADER_COLORS[env_idx % len(ENV_HEADER_COLORS)]
     cell.fill= PatternFill("solid", fgColor=color)
     cell.font= Font(name="Meiryo UI", bold=False, size=9, color="FFFFFF")
     cell.alignment = Alignment(horizontal="center", vertical="center")
     cell.border = THIN_BORDER
    

    7-3. セル色分け定数と Verdict

    Section 6-2(設計書)の差分判定アルゴリズムと連携する色コードを定数として一元管理する。
    第1弾では Verdict を定義しておき、第2弾の突合実装でそのまま使用できるようにする。

    from enum import Enum
    
    class Verdict(str, Enum):
     OK= "OK"
     NG_VALUE_DIFFERS = "NG_VALUE_DIFFERS"
     NG_ONLY_IN_EXPECTED = "NG_ONLY_IN_EXPECTED"
     NG_ONLY_IN_ACTUAL= "NG_ONLY_IN_ACTUAL"
     SKIP = "SKIP"
    
    # セル背景色(None = 無色)
    VERDICT_COLORS: dict[Verdict, str | None] = {
     Verdict.OK:"C6EFCE",  # 緑
     Verdict.NG_VALUE_DIFFERS: "FFC7CE",  # 赤
     Verdict.NG_ONLY_IN_EXPECTED: "FFEB9C",  # 橙
     Verdict.NG_ONLY_IN_ACTUAL:"CC99FF",  # 紫
     Verdict.SKIP: None, # 無色
    }
    
    # Excel 判定列表示テキスト
    VERDICT_LABELS: dict[Verdict, str] = {
     Verdict.OK:"✓",
     Verdict.NG_VALUE_DIFFERS: "× 値差異",
     Verdict.NG_ONLY_IN_EXPECTED: "! 実環境未検出",
     Verdict.NG_ONLY_IN_ACTUAL:"! 設計外の存在",
     Verdict.SKIP: "-",
    }
    
    def make_fill(verdict: Verdict) -> PatternFill | None:
     """Verdict から PatternFill を返す。SKIP は None(無色)。"""
     color = VERDICT_COLORS[verdict]
     if color is None:
      return None
     return PatternFill("solid", fgColor=color)
    

    色の選定根拠:

    Verdictカラーコード意味選定理由
    OKC6EFCE全一致Excel「条件付き書式>緑」デフォルトと同一。視認しやすい
    NG_VALUE_DIFFERSFFC7CE値が異なるExcel「条件付き書式>赤」デフォルト。最重要フラグ
    NG_ONLY_IN_EXPECTEDFFEB9C実環境で確認できず橙 = 警告。値はTFにあるが実環境取得不可
    NG_ONLY_IN_ACTUALCC99FF設計にない値が実環境に存在紫 = 想定外。ドリフト検出の主なシグナル
    SKIPなし両方未設定ノイズ排除。空白行は目立たせない

    7-4. Excel 書込み関数

    7-4-1. 関数シグネチャとデータ構造

    from pathlib import Path
    
    def write_excel(
     data: dict[str, dict[str, dict[str, Any]]],
     output_path: Path,
     metadata: dict,
    ) -> None:
     """
     Args:
      data:  {env: {address: {key: val}}}
      output_path: 書き出し先 .xlsx パス
      metadata: {"run_at": str, "region": str,
     "account_id": str, "tf_version": str}
     """
     wb = Workbook()
     wb.remove(wb.active)  # デフォルトの空シートを削除
    
     ws_main = wb.create_sheet(SHEET_INTEGRATED)
     ws_diff = wb.create_sheet(SHEET_DIFF)
     ws_meta = wb.create_sheet(SHEET_META)
    
     envs = list(data.keys())
    
     # ① ヘッダ生成
     data_start_row = build_header(ws_main, envs)
     build_header(ws_diff, envs)
    
     # ② データ行書込み(差分行をリストに蓄積)
     diff_rows: list[tuple] = []
     _write_data_rows(ws_main, data, envs, data_start_row, diff_rows)
    
     # ③ 差分行を Sheet2 へ転記
     _write_diff_sheet(ws_diff, diff_rows, data_start_row)
    
     # ④ Sheet3 meta 書込み
     _write_meta_sheet(ws_meta, metadata)
    
     # ⑤ 書式: オートフィルタ・ウィンドウ枠固定・列幅調整
     _apply_sheet_format(ws_main, envs, data_start_row)
     _apply_sheet_format(ws_diff, envs, data_start_row)
    
     wb.save(output_path)
     print(f"[write_excel] Saved → {output_path}")
    

    7-4-2. データ行書込み

    def _write_data_rows(
     ws: Worksheet,
     data: dict[str, dict[str, dict[str, Any]]],
     envs: list[str],
     start_row: int,
     diff_rows: list[tuple],
    ) -> None:
     """全 address × key を行方向に展開して書込む。"""
     all_rows: list[tuple[str, str, str]] = _collect_rows(data, envs)
    
     for row_idx, (service, address, attr_key) in enumerate(all_rows, start=start_row):
      # 固定列(サービス・リソース名・属性)
      ws.cell(row=row_idx, column=1, value=service).border  = THIN_BORDER
      ws.cell(row=row_idx, column=2, value=address).border  = THIN_BORDER
      ws.cell(row=row_idx, column=3, value=attr_key).border = THIN_BORDER
    
      # 環境列
      for env_idx, env in enumerate(envs):
    col= N_FIXED + env_idx * N_ENV_SUBCOL + 1
    value = data.get(env, {}).get(address, {}).get(attr_key)
    cell  = ws.cell(row=row_idx, column=col,
     value=_format_cell_value(value))
    cell.border = THIN_BORDER
    cell.font= Font(name="Meiryo UI", size=9)
    
    # 第1弾: 値ありセルを薄い水色で識別(第2弾で Verdict 色付けに置換)
    if value is not None:
     cell.fill = PatternFill("solid", fgColor="DEEAF1")
    

    7-4-3. アドレス収集(全環境のキーを統合)

    def _collect_rows(
     data: dict[str, dict[str, dict[str, Any]]],
     envs: list[str],
    ) -> list[tuple[str, str, str]]:
     """
     全環境のアドレス・属性キーを収集し、
     (service, address, attr_key) のリストとして返す。
     サービス・アドレス・属性キーの順でソートして返す。
     """
     seen: dict[tuple[str, str], set[str]] = {}
    
     for env in envs:
      for address, attrs in data.get(env, {}).items():
    service = _address_to_service(address)
    key = (service, address)
    if key not in seen:
     seen[key] = set()
    seen[key].update(attrs.keys())
    
     rows: list[tuple[str, str, str]] = []
     for (service, address), attr_keys in sorted(seen.items()):
      for attr_key in sorted(attr_keys):
    rows.append((service, address, attr_key))
     return rows
    
    
    def _address_to_service(address: str) -> str:
     """
     'aws_instance.web' → 'aws_instance'
     'module.ec2.aws_instance.web'  → 'aws_instance'
     """
     for part in address.split("."):
      if part.startswith("aws_"):
    return part
     return address.split(".")[0]  # フォールバック
    
    
    def _format_cell_value(value: Any) -> str:
     """リスト・dict は JSON 文字列に変換して表示する。"""
     if value is None:
      return ""
     if isinstance(value, (list, dict)):
      import json
      return json.dumps(value, ensure_ascii=False)
     return str(value)
    

    7-4-4. 差分シート書込み

    def _write_diff_sheet(
     ws: Worksheet,
     diff_rows: list[tuple],
     start_row: int,
    ) -> None:
     """
     Sheet2「差分一覧」には第2弾(AWS Config 突合)で NG 判定した行を転記する。
     第1弾ではシートを空のままにし、ヘッダのみ出力する。
     """
     # 第1弾: プレースホルダ行を挿入して、第2弾時の書式確認に使う
     placeholder = ws.cell(row=start_row, column=1,
    value="(第2弾: AWS Config 突合後に差分行が自動転記されます)")
     placeholder.font = Font(name="Meiryo UI", size=9, italic=True, color="808080")
    

    7-4-5. Sheet3 meta 書込み

    def _write_meta_sheet(ws: Worksheet, metadata: dict) -> None:
     """実行情報をシート形式で記録する。"""
     ws.column_dimensions["A"].width = 25
     ws.column_dimensions["B"].width = 50
    
     rows = [
      ("実行日時(JST)", metadata.get("run_at",  "N/A")),
      ("AWSリージョン",metadata.get("region",  "ap-northeast-1")),
      ("AWSアカウントID", metadata.get("account_id", "123456789012")),
      ("Terraform バージョン",  metadata.get("tf_version", "N/A")),
      ("生成スクリプト",  "scripts/tf_to_excel.py"),
      ("備考",metadata.get("note", "")),
     ]
    
     for row_idx, (label, value) in enumerate(rows, start=1):
      label_cell = ws.cell(row=row_idx, column=1, value=label)
      value_cell = ws.cell(row=row_idx, column=2, value=value)
      label_cell.font= Font(bold=True, name="Meiryo UI", size=10)
      value_cell.font= Font(name="Meiryo UI", size=10)
      label_cell.fill= PatternFill("solid", fgColor="D9D9D9")
      label_cell.border = THIN_BORDER
      value_cell.border = THIN_BORDER
    

    7-4-6. オートフィルタ・ウィンドウ枠固定・列幅自動調整

    def _apply_sheet_format(
     ws: Worksheet,
     envs: list[str],
     data_start_row: int,
    ) -> None:
     """オートフィルタ・ウィンドウ枠固定・列幅自動調整を一括適用する。"""
     total_cols  = N_FIXED + len(envs) * N_ENV_SUBCOL
     last_col_letter = get_column_letter(total_cols)
    
     # オートフィルタ(Row2 ヘッダ行に付与)
     ws.auto_filter.ref = f"A2:{last_col_letter}2"
    
     # ウィンドウ枠固定(固定列3列 + ヘッダ2行を常時表示)
     ws.freeze_panes = ws.cell(row=data_start_row, column=N_FIXED + 1)
    
     # 列幅自動調整
     _auto_fit_columns(ws)
    
     # 行の高さを統一
     for row in ws.iter_rows():
      ws.row_dimensions[row[0].row].height = 18
    
    
    def _auto_fit_columns(ws: Worksheet, padding: int = 4) -> None:
     """全列の幅をセル内容の最大文字数に合わせて調整する(上限60文字)。"""
     col_widths: dict[int, int] = {}
    
     for row in ws.iter_rows():
      for cell in row:
    if cell.value is None:
     continue
    col= cell.column
    width = len(str(cell.value)) + padding
    if col not in col_widths or col_widths[col] < width:
     col_widths[col] = width
    
     for col_idx, width in col_widths.items():
      ws.column_dimensions[get_column_letter(col_idx)].width = min(width, 60)
    

    7-5. ハンズオン: Excel 生成と確認

    7-5-1. 必要パッケージのインストール

    pip install openpyxl
    # requirements.txt に追記する場合
    echo "openpyxl>=3.1.2" >> requirements.txt
    

    7-5-2. エントリポイント(scripts/tf_to_excel.py)

    #!/usr/bin/env python3
    """tf_to_excel.py — Terraform plan.json から Excel パラメーターシートを生成する"""
    from __future__ import annotations
    import argparse
    import json
    import subprocess
    import sys
    from datetime import datetime, timezone, timedelta
    from pathlib import Path
    
    from tf_plan_parser import parse_plan# Section 5 実装
    from excel_writerimport write_excel  # 本 Section の実装
    
    JST = timezone(timedelta(hours=9))
    
    
    def _get_tf_version() -> str:
     try:
      result = subprocess.run(
    ["terraform", "version", "-json"],
    capture_output=True, text=True, timeout=10,
      )
      return json.loads(result.stdout).get("terraform_version", "unknown")
     except Exception:
      return "unknown"
    
    
    def main() -> None:
     parser = argparse.ArgumentParser(description="Terraform plan.json → Excel")
     parser.add_argument(
      "--envs", nargs="+", required=True,
      metavar="ENV=plan.json",
      help="例: dev=environments/dev/plan.json stg=environments/stg/plan.json",
     )
     parser.add_argument("--out", required=True, help="出力先 xlsx パス")
     parser.add_argument("--account-id", default="123456789012")
     parser.add_argument("--region",  default="ap-northeast-1")
     args = parser.parse_args()
    
     # "env=path" 形式をパース
     env_plans: dict[str, Path] = {}
     for env_arg in args.envs:
      if "=" not in env_arg:
    print(f"[ERROR] --envs は 'env=path' 形式で指定: {env_arg}")
    sys.exit(2)
      env_name, plan_path_str = env_arg.split("=", 1)
      env_plans[env_name] = Path(plan_path_str)
    
     # 各環境の plan.json をパース
     data: dict[str, dict[str, dict]] = {}
     for env_name, plan_path in env_plans.items():
      if not plan_path.exists():
    print(f"[ERROR] plan.json が見つかりません: {plan_path}")
    sys.exit(2)
      print(f"[parse] {env_name}: {plan_path}")
      data[env_name] = parse_plan(plan_path)
    
     metadata = {
      "run_at":  datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S JST"),
      "region":  args.region,
      "account_id": args.account_id,
      "tf_version": _get_tf_version(),
     }
    
     output_path = Path(args.out)
     output_path.parent.mkdir(parents=True, exist_ok=True)
     write_excel(data, output_path, metadata)
    
    
    if __name__ == "__main__":
     main()
    

    7-5-3. 実行コマンド

    # 単一環境(dev のみ)
    python3 scripts/tf_to_excel.py \
      --envs dev=environments/dev/plan.json \
      --out  output/param-sheet-$(date +%Y%m%d-%H%M).xlsx \
      --region ap-northeast-1
    
    # 複数環境(dev / stg / prod)
    python3 scripts/tf_to_excel.py \
      --envs dev=environments/dev/plan.json \
    stg=environments/stg/plan.json \
    prod=environments/prod/plan.json \
      --out  output/param-sheet-$(date +%Y%m%d-%H%M).xlsx \
      --region ap-northeast-1
    

    make excel で上記を実行できるよう、Makefile(Section 6 実装)に以下を追記する:

    excel:
     python3 scripts/tf_to_excel.py \
    --envs dev=environments/dev/plan.json \
     stg=environments/stg/plan.json \
     prod=environments/prod/plan.json \
    --out output/param-sheet-$(shell date +%Y%m%d-%H%M).xlsx \
    --region ap-northeast-1
    

    7-5-4. 生成後の確認手順

    実行後、output/param-sheet-20260418-1600.xlsx が生成される。

    ls -lh output/param-sheet-*.xlsx
    # -rw-r--r-- 1 user staff 48K Apr 18 16:00 output/param-sheet-20260418-1600.xlsx
    

    Excel または LibreOffice で開き、以下を確認する:

    1. 「統合ビュー」シート
    2. Row1 にカラー背景の環境グループヘッダ(=== dev === など)が表示されること
    3. Row2 に「期待値」列名が表示されること
    4. Row1 の固定列(サービス/リソース名/属性)が Row1〜Row2 縦マージされていること
    5. データ行に値が正しく展開されていること
    6. 「差分一覧」シート: プレースホルダメッセージが表示されていること
    7. 「meta」シート: 実行時刻・リージョン・アカウントIDが正しく記録されていること
    8. ウィンドウ枠固定: 右スクロール時に A〜C 列が固定されていること
    9. オートフィルタ: Row2 のヘッダセルにドロップダウン矢印が表示されていること

    7-5-5. よくあるエラーと対処

    エラー原因対処
    ModuleNotFoundError: openpyxlインストール未了pip install openpyxl
    FileNotFoundError: plan.jsonplan.json 未生成make plan-all を先に実行
    KeyError: 'root_module'plan.json フォーマット不一致(TF バージョン差異)terraform version で 1.9.x を確認
    列幅が極端に広がる長いリスト値がセルに入っている_auto_fit_columns の上限値(60)を調整
    日本語が文字化けするWSL 環境 + LibreOffice のフォント非対応LibreOffice で「游ゴシック」等に変更して確認
    IllegalCharacterError制御文字がセル値に混入_format_cell_valuere.sub(r'[\x00-\x1f]', '', ...) を適用

    7-6. Excel 完成イメージ(図)

    以下に「統合ビュー」シートのレイアウト図を示す。

    ╔══════════════════════════════════════════════════════════════════════════════════════╗
    ║  統合ビュー シート — 2段ヘッダ構成(第1弾: 期待値のみ)║
    ╠══════════╤════════════════════════════╤════════════════╦═══════════╦═══════════╦═══╣
    ║  Row1 │ A: サービス │ B:リソース名 │ C: 属性  ║ === dev ===║ === stg ===║...║
    ║ │(グレー・縦マージ)  │(グレー・縦マージ)║(青背景) ║(緑背景) ║║
    ╠══════════╪═════════════╪═════════════╪════════════════╬═══════════╬═══════════╬═══╣
    ║  Row2 │ │ │ ║  期待値║  期待値║...║
    ║(サブ列)│(縦マージ) │(縦マージ) │(縦マージ) ║(青背景) ║(緑背景) ║║
    ╠══════════╪═════════════╪═════════════╪════════════════╬═══════════╬═══════════╬═══╣
    ║  Row3 │aws_instance │mod.ec2.inst │ instance_type  ║ t3.micro  ║ t3.small  ║...║
    ║ │aws_instance │mod.ec2.inst │ ami║ami-0bba69 ║ami-0bba69 ║...║
    ║ │aws_vpc│mod.vpc.main │ cidr_block  ║10.0.0.0/16║10.1.0.0/16║...║
    ║ │aws_vpc│mod.vpc.main │ enable_dns_..  ║true ║true ║...║
    ║ │aws_subnet│mod.subnet.az│ availability_..║ap-ne-1a║ap-ne-1a║...║
    ╚══════════╧═════════════╧═════════════╧════════════════╩═══════════╩═══════════╩═══╝
    
    凡例(第1弾の色付け):
      ████ 薄い水色(DEEAF1): 値が存在する期待値セル
     無色  : 値未設定(None)のセル
    
      ▼ ウィンドウ枠固定ライン: 列 D・行 3(A〜C列とRow1〜Row2が常時表示)
      ▼ オートフィルタ: Row2 のすべての列ヘッダにドロップダウン矢印
    
    第2弾(AWS Config 突合)で追加される列:
      [現状値(dev)][現状値(stg)][現状値(prod)]
      [判定(dev)  ][判定(stg)  ][判定(prod)  ]
     ✓ = 緑(C6EFCE)
     × 値差異 = 赤(FFC7CE)
     ! 実環境未検出 = 橙(FFEB9C)
     ! 設計外の存在 = 紫(CC99FF)
    

    ポイント解説:

    要素設定内容効果
    ウィンドウ枠固定D3 を起点に設定A〜C 列と Row1〜Row2 が常時表示
    オートフィルタRow2 全列に付与サービス/属性での絞込みが即座に可能
    列幅自動調整最大文字数 + padding(上限60)内容に合わせた幅で読みやすいレイアウト
    環境別ヘッダ色環境ごとに異なる色(青/緑/橙…)視覚的に環境を識別しやすい
    サービス別ソート_collect_rows でアドレス順ソート同一サービスが連続して表示され比較しやすい

    7-まとめ

    本セクションで実装した Excel 出力モジュールのポイントを整理する。

    項目実装内容
    2段ヘッダmerge_cells による環境グループヘッダ(Row1)+サブ列名(Row2)
    色分け定数Verdict Enum + VERDICT_COLORS で判定結果ごとのセル色を一元管理
    meta シート実行時刻・リージョン・アカウントID・TFバージョンを記録しトレーサビリティを確保
    オートフィルタRow2 に設定し、サービス/属性での絞込みを即可能に
    ウィンドウ枠固定固定列(サービス/リソース名/属性)+ヘッダ行を常時表示
    列幅自動調整セル内容の最大文字数に合わせて自動設定(上限60文字)
    第2弾への拡張路ENV_SUBCOLS["期待値", "現状値", "判定"] に変更するだけで列グループが拡張できる設計

    第2弾(AWS Config 突合編)では、本モジュールの write_excel()compare() 関数の
    Verdict 結果を渡すことで、判定列の色付けと差分シート(Sheet2)の自動生成が完成する。

    第1弾: parse_plan() ─────────────────────────→ write_excel() → Excel(期待値のみ)
      ↑
    第2弾: parse_plan() + config_fetch() → compare() ──┘ → Excel(期待値+現状値+判定)
    

    次のセクション(Section 8)では、EC2/VPC/RDS/Lambda など 10 サービスの Terraform 定義と
    属性マッピングを詳細に解説する。

    Section 8. 対象サービス別の属性カバー

    本セクションでは、パラメーターシート自動生成の対象となる 10 サービスそれぞれについて、
    Terraform リソース定義・plan.json からの期待抽出値・Python 属性マッピング辞書を網羅的に解説する。


    8-0. サービス別属性カバー全体マトリクス

    設計書 §5-2 の属性カバー表を冒頭に再掲する。「◎」= Excel 出力対象、「✕」= 除外(AWS 払出し値 or ID 系)。

    サービスTerraform リソース主な対象属性除外属性
    EC2aws_instanceinstance_type, ami, key_name, iam_instance_profilesubnet_id, primary_network_interface_id, public_ip
    VPCaws_vpccidr_block, enable_dns_hostnames, enable_dns_supportid, owner_id, arn
    Subnetaws_subnetcidr_block, availability_zone, map_public_ip_on_launchsubnet_id, vpc_id
    SGaws_security_groupname, description, ingress(正規化済), egress(正規化済)id, owner_id, arn
    RDSaws_db_instanceengine, engine_version, instance_class, allocated_storage, multi_azaddress, endpoint, resource_id
    ALBaws_lbload_balancer_type, internal, security_groupsarn, dns_name
    Lambdaaws_lambda_functionruntime, handler, memory_size, timeout, environment.variablesarn, function_name(重複時)
    API GW v2aws_apigatewayv2_apiprotocol_type, route_key, integration_typeid, api_endpoint
    DynamoDBaws_dynamodb_tablebilling_mode, hash_key, range_key, attributearn, id, stream_arn
    SNS/SQSaws_sns_topic / aws_sqs_queuedisplay_name / visibility_timeout_seconds, message_retention_secondsarn, id

    除外ルール共通実装(設計書 §5-3 準拠):

    EXCLUDE_SUFFIXES = ('_id', '_arn', 'tags_all')
    EXCLUDE_EXACT = {'arn', 'id', 'owner_id', 'self_link'}
    EXCLUDE_BY_SERVICE: dict[str, set[str]] = {
     'aws_instance': {'primary_network_interface_id', 'public_ip'},
     'aws_db_instance': {'address', 'endpoint', 'resource_id'},
    }
    
    def should_exclude(service: str, key: str) -> bool:
     if key in EXCLUDE_EXACT:
      return True
     if any(key.endswith(s) for s in EXCLUDE_SUFFIXES):
      return True
     if key in EXCLUDE_BY_SERVICE.get(service, set()):
      return True
     return False
    

    8-1. EC2 (aws_instance)

    対象属性

    属性区分備考
    instance_type◎ 出力t3.micro 等
    ami◎ 出力コード記載値のみ
    key_name◎ 出力SSH キーペア名
    iam_instance_profile◎ 出力省略時は null
    subnet_id✕ 除外_id suffix → EXCLUDE_SUFFIXES でカット
    primary_network_interface_id✕ 除外EXCLUDE_BY_SERVICE[“aws_instance”]
    public_ip✕ 除外EXCLUDE_BY_SERVICE[“aws_instance”]

    最小 HCL 例

    resource "aws_instance" "web" {
      ami= "ami-xxxxxxxxxxxxxxxxx"
      instance_type  = "t3.micro"
      key_name = "my-keypair"
      iam_instance_profile = aws_iam_instance_profile.web.name
    
      tags = {
     Name = "web-server"
     Env  = var.env
      }
    }
    

    plan.json からの抽出期待値

    {
      "configuration": {
     "root_module": {
    "resources": [
      {
     "address": "aws_instance.web",
     "type": "aws_instance",
     "expressions": {
    "ami": { "constant_value": "ami-xxxxxxxxxxxxxxxxx" },
    "instance_type": { "constant_value": "t3.micro" },
    "key_name": { "constant_value": "my-keypair" },
    "iam_instance_profile": { "references": ["aws_iam_instance_profile.web"] }
     }
      }
    ]
     }
      },
      "planned_values": {
     "root_module": {
    "resources": [
      {
     "address": "aws_instance.web",
     "type": "aws_instance",
     "values": {
    "ami": "ami-xxxxxxxxxxxxxxxxx",
    "instance_type": "t3.micro",
    "key_name": "my-keypair",
    "iam_instance_profile": "web-instance-profile",
    "subnet_id": "subnet-0123456789abcdef0",
    "public_ip": null,
    "primary_network_interface_id": null
     }
      }
    ]
     }
      }
    }
    

    expressions に現れるキー: ami, instance_type, key_name, iam_instance_profile
    subnet_idexpressions に存在しないため code_keys フィルタで自動除外。

    Python 属性マッピング辞書

    EC2_ATTR_MAP: dict[str, dict] = {
     "aws_instance": {
      "include": ["instance_type", "ami", "key_name", "iam_instance_profile"],
      "exclude_service": ["primary_network_interface_id", "public_ip"],
     }
    }
    

    特記事項

    • ami はダミー値 ami-xxxxxxxxxxxxxxxxx を HCL に使用する(実AMI ID の漏洩防止)。
    • iam_instance_profile はモジュール参照の場合、plan.json の planned_values で解決済みの文字列名が得られる。

    8-2. VPC (aws_vpc)

    対象属性

    属性区分備考
    cidr_block◎ 出力10.0.0.0/16 等
    enable_dns_hostnames◎ 出力true/false
    enable_dns_support◎ 出力true/false
    id✕ 除外EXCLUDE_EXACT
    owner_id✕ 除外EXCLUDE_EXACT

    最小 HCL 例

    resource "aws_vpc" "main" {
      cidr_block  = "10.0.0.0/16"
      enable_dns_hostnames = true
      enable_dns_support= true
    
      tags = {
     Name = "main-vpc"
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_vpc.main",
      "type": "aws_vpc",
      "expressions": {
     "cidr_block": { "constant_value": "10.0.0.0/16" },
     "enable_dns_hostnames": { "constant_value": true },
     "enable_dns_support": { "constant_value": true }
      },
      "values": {
     "cidr_block": "10.0.0.0/16",
     "enable_dns_hostnames": true,
     "enable_dns_support": true,
     "id": "vpc-0123456789abcdef0",
     "owner_id": "123456789012"
      }
    }
    

    Python 属性マッピング辞書

    VPC_ATTR_MAP: dict[str, dict] = {
     "aws_vpc": {
      "include": ["cidr_block", "enable_dns_hostnames", "enable_dns_support"],
     }
    }
    

    8-3. Subnet (aws_subnet)

    対象属性

    属性区分備考
    cidr_block◎ 出力
    availability_zone◎ 出力ap-northeast-1a 等
    map_public_ip_on_launch◎ 出力パブリックサブネット判定
    subnet_id✕ 除外_id suffix
    vpc_id✕ 除外_id suffix

    最小 HCL 例

    resource "aws_subnet" "public_1a" {
      vpc_id= aws_vpc.main.id
      cidr_block  = "10.0.1.0/24"
      availability_zone = "ap-northeast-1a"
      map_public_ip_on_launch = true
    
      tags = {
     Name = "public-1a"
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_subnet.public_1a",
      "expressions": {
     "cidr_block": { "constant_value": "10.0.1.0/24" },
     "availability_zone": { "constant_value": "ap-northeast-1a" },
     "map_public_ip_on_launch": { "constant_value": true }
      },
      "values": {
     "cidr_block": "10.0.1.0/24",
     "availability_zone": "ap-northeast-1a",
     "map_public_ip_on_launch": true,
     "vpc_id": "vpc-0123456789abcdef0"
      }
    }
    

    vpc_idexpressionsreferences として存在するが _id suffix により除外される。

    Python 属性マッピング辞書

    SUBNET_ATTR_MAP: dict[str, dict] = {
     "aws_subnet": {
      "include": ["cidr_block", "availability_zone", "map_public_ip_on_launch"],
     }
    }
    

    8-4. Security Group (aws_security_group + aws_security_group_rule)

    対象属性

    属性区分備考
    name◎ 出力
    description◎ 出力
    ingress◎ 出力(正規化後)ソート必須
    egress◎ 出力(正規化後)ソート必須
    id✕ 除外EXCLUDE_EXACT
    owner_id✕ 除外EXCLUDE_EXACT

    最小 HCL 例

    resource "aws_security_group" "web" {
      name  = "web-sg"
      description = "Security group for web servers"
      vpc_id= aws_vpc.main.id
    
      ingress {
     from_port= 443
     to_port  = 443
     protocol = "tcp"
     cidr_blocks = ["0.0.0.0/0"]
      }
    
      ingress {
     from_port= 80
     to_port  = 80
     protocol = "tcp"
     cidr_blocks = ["0.0.0.0/0"]
      }
    
      egress {
     from_port= 0
     to_port  = 0
     protocol = "-1"
     cidr_blocks = ["0.0.0.0/0"]
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_security_group.web",
      "expressions": {
     "name": { "constant_value": "web-sg" },
     "description": { "constant_value": "Security group for web servers" },
     "ingress": [
    {
      "from_port": { "constant_value": 443 },
      "to_port": { "constant_value": 443 },
      "protocol": { "constant_value": "tcp" },
      "cidr_blocks": { "constant_value": ["0.0.0.0/0"] }
    },
    {
      "from_port": { "constant_value": 80 },
      "to_port": { "constant_value": 80 },
      "protocol": { "constant_value": "tcp" },
      "cidr_blocks": { "constant_value": ["0.0.0.0/0"] }
    }
     ]
      }
    }
    

    ingress/egress 正規化(特殊処理)

    SG ルールはリスト要素でかつ順序に意味がない。plan.json 上の並び順は Terraform 内部実装依存のため、
    比較前に正規化キーでソートする必要がある。

    def normalize_sg_rules(rules: list[dict]) -> list[dict]:
     """ingress/egress ルールを正規化キーでソートして順序依存を排除する。"""
     def sort_key(r: dict) -> tuple:
      return (
    r.get("protocol", ""),
    r.get("from_port", 0),
    r.get("to_port", 0),
    ",".join(sorted(r.get("cidr_blocks") or [])),
    ",".join(sorted(r.get("ipv6_cidr_blocks") or [])),
      )
     return sorted(rules, key=sort_key)
    

    使用例:

    from typing import Any
    
    def extract_sg_attributes(values: dict[str, Any]) -> dict[str, Any]:
     result = {
      "name": values.get("name"),
      "description": values.get("description"),
      "ingress": normalize_sg_rules(values.get("ingress") or []),
      "egress": normalize_sg_rules(values.get("egress") or []),
     }
     return result
    

    Python 属性マッピング辞書

    SG_ATTR_MAP: dict[str, dict] = {
     "aws_security_group": {
      "include": ["name", "description", "ingress", "egress"],
      "special_handler": "normalize_sg_rules",
     }
    }
    

    8-5. RDS (aws_db_instance)

    対象属性

    属性区分備考
    engine◎ 出力mysql, postgres 等
    engine_version◎ 出力
    instance_class◎ 出力db.t3.micro 等
    allocated_storage◎ 出力GB単位の整数
    multi_az◎ 出力本番環境の確認項目
    address✕ 除外EXCLUDE_BY_SERVICE[“aws_db_instance”]
    endpoint✕ 除外EXCLUDE_BY_SERVICE[“aws_db_instance”]
    resource_id✕ 除外EXCLUDE_BY_SERVICE[“aws_db_instance”]
    db_subnet_group_name◎ 出力サブネットグループ名(文字列)は出力対象

    最小 HCL 例

    resource "aws_db_instance" "main" {
      engine= "mysql"
      engine_version = "8.0"
      instance_class = "db.t3.micro"
      allocated_storage = 20
      multi_az = false
      db_name  = "appdb"
      username = "admin"
      password = var.db_password  # シークレットは変数経由
      db_subnet_group_name = aws_db_subnet_group.main.name
      skip_final_snapshot  = true
    
      tags = {
     Name = "main-rds"
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_db_instance.main",
      "expressions": {
     "engine": { "constant_value": "mysql" },
     "engine_version": { "constant_value": "8.0" },
     "instance_class": { "constant_value": "db.t3.micro" },
     "allocated_storage": { "constant_value": 20 },
     "multi_az": { "constant_value": false },
     "db_subnet_group_name": { "references": ["aws_db_subnet_group.main"] }
      },
      "values": {
     "engine": "mysql",
     "engine_version": "8.0",
     "instance_class": "db.t3.micro",
     "allocated_storage": 20,
     "multi_az": false,
     "db_subnet_group_name": "main-subnet-group",
     "address": "main-rds.xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com",
     "endpoint": "main-rds.xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306",
     "resource_id": "db-XXXXXXXXXXXXXXXXXXXXXXX"
      }
    }
    

    Python 属性マッピング辞書

    RDS_ATTR_MAP: dict[str, dict] = {
     "aws_db_instance": {
      "include": [
    "engine", "engine_version", "instance_class",
    "allocated_storage", "multi_az", "db_subnet_group_name",
      ],
      "exclude_service": ["address", "endpoint", "resource_id"],
     }
    }
    

    8-6. ALB (aws_lb + aws_lb_target_group + aws_lb_listener)

    ALB は複数の Terraform リソースで構成される複合サービスである。
    パラメーターシートでは ALB 本体・ターゲットグループ・リスナーを 別行 として記録し、
    リソース名(resource_name 列)で紐づける。

    対象属性

    リソース属性区分
    aws_lbload_balancer_type, internal, security_groups◎ 出力
    aws_lbarn, dns_name✕ 除外
    aws_lb_target_groupport, protocol, target_type◎ 出力
    aws_lb_target_grouparn✕ 除外
    aws_lb_listenerport, protocol, default_action◎ 出力
    aws_lb_listenerarn, load_balancer_arn✕ 除外

    最小 HCL 例

    resource "aws_lb" "main" {
      name= "main-alb"
      load_balancer_type = "application"
      internal  = false
      security_groups = [aws_security_group.alb.id]
      subnets= [aws_subnet.public_1a.id, aws_subnet.public_1c.id]
    }
    
    resource "aws_lb_target_group" "web" {
      name  = "web-tg"
      port  = 80
      protocol = "HTTP"
      target_type = "ip"
      vpc_id= aws_vpc.main.id
    }
    
    resource "aws_lb_listener" "http" {
      load_balancer_arn = aws_lb.main.arn
      port  = "80"
      protocol = "HTTP"
    
      default_action {
     type = "forward"
     target_group_arn = aws_lb_target_group.web.arn
      }
    }
    

    plan.json からの抽出期待値(aws_lb 抜粋)

    {
      "address": "aws_lb.main",
      "expressions": {
     "name": { "constant_value": "main-alb" },
     "load_balancer_type": { "constant_value": "application" },
     "internal": { "constant_value": false },
     "security_groups": { "references": ["aws_security_group.alb"] }
      },
      "values": {
     "name": "main-alb",
     "load_balancer_type": "application",
     "internal": false,
     "security_groups": ["sg-0123456789abcdef0"],
     "arn": "arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:loadbalancer/app/main-alb/0123456789abcdef",
     "dns_name": "main-alb-123456789.ap-northeast-1.elb.amazonaws.com"
      }
    }
    

    Python 属性マッピング辞書

    ALB_ATTR_MAP: dict[str, dict] = {
     "aws_lb": {
      "include": ["name", "load_balancer_type", "internal", "security_groups"],
     },
     "aws_lb_target_group": {
      "include": ["name", "port", "protocol", "target_type"],
     },
     "aws_lb_listener": {
      "include": ["port", "protocol", "default_action"],
     },
    }
    

    8-7. Lambda (aws_lambda_function)

    対象属性

    属性区分備考
    runtime◎ 出力python3.12 等
    handler◎ 出力index.handler 等
    memory_size◎ 出力MB単位
    timeout◎ 出力秒単位
    environment.variables◎ 出力KMS マスク注記あり
    function_name◎ 出力コード記載の命名
    arn✕ 除外_arn suffix
    invoke_arn✕ 除外_arn suffix

    最小 HCL 例

    resource "aws_lambda_function" "processor" {
      function_name = "data-processor"
      runtime = "python3.12"
      handler = "index.handler"
      memory_size= 256
      timeout = 30
      role = aws_iam_role.lambda.arn
      filename= "lambda.zip"
    
      environment {
     variables = {
    ENV  = var.env
    TABLE_NAME = aws_dynamodb_table.main.name
    # シークレット値は直接記載禁止 — SSM Parameter Store / Secrets Manager 参照を使用
     }
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_lambda_function.processor",
      "expressions": {
     "function_name": { "constant_value": "data-processor" },
     "runtime": { "constant_value": "python3.12" },
     "handler": { "constant_value": "index.handler" },
     "memory_size": { "constant_value": 256 },
     "timeout": { "constant_value": 30 },
     "environment": [
    {
      "variables": {
     "ENV": { "references": ["var.env"] },
     "TABLE_NAME": { "references": ["aws_dynamodb_table.main"] }
      }
    }
     ]
      },
      "values": {
     "function_name": "data-processor",
     "runtime": "python3.12",
     "handler": "index.handler",
     "memory_size": 256,
     "timeout": 30,
     "environment": [{ "variables": { "ENV": "dev", "TABLE_NAME": "main-table" } }],
     "arn": "arn:aws:lambda:ap-northeast-1:123456789012:function:data-processor"
      }
    }
    

    ⚠️ セキュリティ注記: environment.variables の KMS マスク

    environment.variables に機密値(API キー・パスワード等)が直接入っている場合、
    plan.json にも平文で出力される。パラメーターシートへのエクスポート前に KMS 暗号化設定を確認すること。

    KMS_SENTINEL = "***MASKED***"
    
    def mask_sensitive_env_vars(
     variables: dict[str, str],
     kms_key_arn: str | None,
    ) -> dict[str, str]:
     """KMS キーが設定されている場合は環境変数値を Excel 上でマスクする。"""
     if kms_key_arn:
      return {k: KMS_SENTINEL for k in variables}
     return variables
    

    Python 属性マッピング辞書

    LAMBDA_ATTR_MAP: dict[str, dict] = {
     "aws_lambda_function": {
      "include": [
    "function_name", "runtime", "handler",
    "memory_size", "timeout", "environment",
      ],
      "special_handler": "mask_sensitive_env_vars",
     }
    }
    

    8-8. API Gateway v2 (aws_apigatewayv2_api + aws_apigatewayv2_route)

    対象属性

    リソース属性区分
    aws_apigatewayv2_apiname, protocol_type◎ 出力
    aws_apigatewayv2_apiid, api_endpoint✕ 除外
    aws_apigatewayv2_routeroute_key◎ 出力
    aws_apigatewayv2_integrationintegration_type, integration_uri◎ 出力

    最小 HCL 例

    resource "aws_apigatewayv2_api" "main" {
      name = "main-http-api"
      protocol_type = "HTTP"
    }
    
    resource "aws_apigatewayv2_route" "processor" {
      api_id = aws_apigatewayv2_api.main.id
      route_key = "POST /process"
      target = "integrations/${aws_apigatewayv2_integration.processor.id}"
    }
    
    resource "aws_apigatewayv2_integration" "processor" {
      api_id = aws_apigatewayv2_api.main.id
      integration_type= "AWS_PROXY"
      integration_uri = aws_lambda_function.processor.invoke_arn
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_apigatewayv2_api.main",
      "expressions": {
     "name": { "constant_value": "main-http-api" },
     "protocol_type": { "constant_value": "HTTP" }
      },
      "values": {
     "name": "main-http-api",
     "protocol_type": "HTTP",
     "id": "abc1234567",
     "api_endpoint": "https://abc1234567.execute-api.ap-northeast-1.amazonaws.com"
      }
    }
    

    Python 属性マッピング辞書

    APIGW_ATTR_MAP: dict[str, dict] = {
     "aws_apigatewayv2_api": {
      "include": ["name", "protocol_type"],
     },
     "aws_apigatewayv2_route": {
      "include": ["route_key"],
     },
     "aws_apigatewayv2_integration": {
      "include": ["integration_type", "integration_uri"],
     },
    }
    

    8-9. DynamoDB (aws_dynamodb_table)

    対象属性

    属性区分備考
    name◎ 出力テーブル名
    billing_mode◎ 出力PAY_PER_REQUEST / PROVISIONED
    hash_key◎ 出力パーティションキー名
    range_key◎ 出力ソートキー名(省略可)
    attribute◎ 出力属性スキーマ一覧
    read_capacity◎ 出力PROVISIONED 時のみ
    write_capacity◎ 出力PROVISIONED 時のみ
    arn✕ 除外_arn suffix
    id✕ 除外EXCLUDE_EXACT
    stream_arn✕ 除外_arn suffix

    最小 HCL 例

    resource "aws_dynamodb_table" "main" {
      name= "main-table"
      billing_mode = "PAY_PER_REQUEST"
      hash_key  = "pk"
      range_key = "sk"
    
      attribute {
     name = "pk"
     type = "S"
      }
    
      attribute {
     name = "sk"
     type = "S"
      }
    
      attribute {
     name = "gsi_pk"
     type = "S"
      }
    
      global_secondary_index {
     name= "gsi-index"
     hash_key  = "gsi_pk"
     projection_type = "ALL"
      }
    
      tags = {
     Name = "main-table"
      }
    }
    

    plan.json からの抽出期待値

    {
      "address": "aws_dynamodb_table.main",
      "expressions": {
     "name": { "constant_value": "main-table" },
     "billing_mode": { "constant_value": "PAY_PER_REQUEST" },
     "hash_key": { "constant_value": "pk" },
     "range_key": { "constant_value": "sk" },
     "attribute": [
    { "name": { "constant_value": "pk" }, "type": { "constant_value": "S" } },
    { "name": { "constant_value": "sk" }, "type": { "constant_value": "S" } },
    { "name": { "constant_value": "gsi_pk" }, "type": { "constant_value": "S" } }
     ]
      },
      "values": {
     "name": "main-table",
     "billing_mode": "PAY_PER_REQUEST",
     "hash_key": "pk",
     "range_key": "sk",
     "attribute": [
    { "name": "pk", "type": "S" },
    { "name": "sk", "type": "S" },
    { "name": "gsi_pk", "type": "S" }
     ],
     "arn": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/main-table",
     "id": "main-table",
     "stream_arn": null
      }
    }
    

    Python 属性マッピング辞書

    DYNAMODB_ATTR_MAP: dict[str, dict] = {
     "aws_dynamodb_table": {
      "include": [
    "name", "billing_mode", "hash_key", "range_key",
    "attribute", "read_capacity", "write_capacity",
      ],
     }
    }
    

    8-10. SNS/SQS (aws_sns_topic + aws_sqs_queue)

    SNS と SQS は独立したリソースだが、パラメーターシート上ではメッセージング系サービスとして
    同一セクションにまとめて記録する。

    対象属性

    リソース属性区分備考
    aws_sns_topicname◎ 出力
    aws_sns_topicdisplay_name◎ 出力省略可
    aws_sns_topickms_master_key_id◎ 出力暗号化設定の確認項目
    aws_sns_topicarn✕ 除外_arn suffix
    aws_sns_topicid✕ 除外EXCLUDE_EXACT
    aws_sqs_queuename◎ 出力
    aws_sqs_queuevisibility_timeout_seconds◎ 出力デフォルト 30 秒
    aws_sqs_queuemessage_retention_seconds◎ 出力デフォルト 4 日 = 345600
    aws_sqs_queuedelay_seconds◎ 出力デフォルト 0
    aws_sqs_queuereceive_wait_time_seconds◎ 出力Long Polling 設定
    aws_sqs_queuearn✕ 除外_arn suffix
    aws_sqs_queueid✕ 除外EXCLUDE_EXACT
    aws_sqs_queueurl✕ 除外EXCLUDE_EXACT 対象外だが AWS 払出し値のため code_keys で除外

    最小 HCL 例

    resource "aws_sns_topic" "alerts" {
      name  = "system-alerts"
      display_name= "System Alerts"
      kms_master_key_id = aws_kms_key.sns.id
    }
    
    resource "aws_sqs_queue" "processor" {
      name  = "processor-queue"
      visibility_timeout_seconds = 60
      message_retention_seconds  = 86400
      delay_seconds  = 0
      receive_wait_time_seconds  = 20
    }
    
    resource "aws_sqs_queue" "processor_dlq" {
      name = "processor-dlq"
      message_retention_seconds = 1209600  # 14 日
    }
    

    plan.json からの抽出期待値(SNS 抜粋)

    {
      "address": "aws_sns_topic.alerts",
      "expressions": {
     "name": { "constant_value": "system-alerts" },
     "display_name": { "constant_value": "System Alerts" },
     "kms_master_key_id": { "references": ["aws_kms_key.sns"] }
      },
      "values": {
     "name": "system-alerts",
     "display_name": "System Alerts",
     "kms_master_key_id": "arn:aws:kms:ap-northeast-1:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
     "arn": "arn:aws:sns:ap-northeast-1:123456789012:system-alerts",
     "id": "arn:aws:sns:ap-northeast-1:123456789012:system-alerts"
      }
    }
    

    plan.json からの抽出期待値(SQS 抜粋)

    {
      "address": "aws_sqs_queue.processor",
      "expressions": {
     "name": { "constant_value": "processor-queue" },
     "visibility_timeout_seconds": { "constant_value": 60 },
     "message_retention_seconds": { "constant_value": 86400 },
     "delay_seconds": { "constant_value": 0 },
     "receive_wait_time_seconds": { "constant_value": 20 }
      },
      "values": {
     "name": "processor-queue",
     "visibility_timeout_seconds": 60,
     "message_retention_seconds": 86400,
     "delay_seconds": 0,
     "receive_wait_time_seconds": 20,
     "arn": "arn:aws:sqs:ap-northeast-1:123456789012:processor-queue",
     "url": "https://sqs.ap-northeast-1.amazonaws.com/123456789012/processor-queue",
     "id": "https://sqs.ap-northeast-1.amazonaws.com/123456789012/processor-queue"
      }
    }
    

    Python 属性マッピング辞書

    SNS_SQS_ATTR_MAP: dict[str, dict] = {
     "aws_sns_topic": {
      "include": ["name", "display_name", "kms_master_key_id"],
     },
     "aws_sqs_queue": {
      "include": [
    "name", "visibility_timeout_seconds", "message_retention_seconds",
    "delay_seconds", "receive_wait_time_seconds",
      ],
     },
    }
    

    8-11. サービス別マッピング辞書まとめ

    各サービスの属性マッピング辞書を1つの SERVICE_ATTR_MAP に集約する。
    パーサーはこの辞書を参照して、各リソースタイプの出力対象属性を決定する。

    from typing import TypedDict
    
    class ServiceAttrConfig(TypedDict, total=False):
     include: list[str]
     exclude_service: list[str]
     special_handler: str
    
    SERVICE_ATTR_MAP: dict[str, ServiceAttrConfig] = {
     # --- IaaS 系 ---
     "aws_instance": {
      "include": ["instance_type", "ami", "key_name", "iam_instance_profile"],
      "exclude_service": ["primary_network_interface_id", "public_ip"],
     },
     "aws_vpc": {
      "include": ["cidr_block", "enable_dns_hostnames", "enable_dns_support"],
     },
     "aws_subnet": {
      "include": ["cidr_block", "availability_zone", "map_public_ip_on_launch"],
     },
     "aws_security_group": {
      "include": ["name", "description", "ingress", "egress"],
      "special_handler": "normalize_sg_rules",
     },
     "aws_db_instance": {
      "include": [
    "engine", "engine_version", "instance_class",
    "allocated_storage", "multi_az", "db_subnet_group_name",
      ],
      "exclude_service": ["address", "endpoint", "resource_id"],
     },
     # --- ロードバランサー ---
     "aws_lb": {
      "include": ["name", "load_balancer_type", "internal", "security_groups"],
     },
     "aws_lb_target_group": {
      "include": ["name", "port", "protocol", "target_type"],
     },
     "aws_lb_listener": {
      "include": ["port", "protocol", "default_action"],
     },
     # --- サーバーレス系 ---
     "aws_lambda_function": {
      "include": [
    "function_name", "runtime", "handler",
    "memory_size", "timeout", "environment",
      ],
      "special_handler": "mask_sensitive_env_vars",
     },
     "aws_apigatewayv2_api": {
      "include": ["name", "protocol_type"],
     },
     "aws_apigatewayv2_route": {
      "include": ["route_key"],
     },
     "aws_apigatewayv2_integration": {
      "include": ["integration_type", "integration_uri"],
     },
     "aws_dynamodb_table": {
      "include": [
    "name", "billing_mode", "hash_key", "range_key",
    "attribute", "read_capacity", "write_capacity",
      ],
     },
     # --- メッセージング系 ---
     "aws_sns_topic": {
      "include": ["name", "display_name", "kms_master_key_id"],
     },
     "aws_sqs_queue": {
      "include": [
    "name", "visibility_timeout_seconds", "message_retention_seconds",
    "delay_seconds", "receive_wait_time_seconds",
      ],
     },
    }
    

    この辞書を should_exclude() 関数と組み合わせることで、多層防御が実現される:

    def extract_attributes(
     resource_type: str,
     code_keys: set[str],
     values: dict[str, object],
    ) -> dict[str, object]:
     """
     plan.json の code_keys と SERVICE_ATTR_MAP の include リストの積集合から
     属性を抽出し、除外ルールを適用して返す。
     """
     config = SERVICE_ATTR_MAP.get(resource_type, {})
     include = set(config.get("include", code_keys))  # 辞書未定義なら code_keys をそのまま使用
    
     result: dict[str, object] = {}
     for key in include & code_keys:
      if should_exclude(resource_type, key):
    continue
      val = values.get(key)
      if val is None:
    continue
      result[key] = val
    
     return result
    

    Section 8 執筆完了。10 サービス(EC2/VPC/Subnet/SG/RDS/ALB+TG+Listener/Lambda/API GW v2/DynamoDB/SNS/SQS)の属性カバー網羅。

    Section 9. ハンズオン実行と成果物確認

    ここまでのセクションで、Plan JSON ハイブリッドパーサ・マルチ環境集約・Excel 出力・サービス別属性マッピングの実装が揃いました。本セクションでは、これらのコンポーネントを組み合わせ、実際に手を動かして パラメーターシート Excel を生成するエンドツーエンドのハンズオン を実施します。

    空の AWS アカウントでも動作確認できるよう、最小構成の Terraform サンプル(EC2 + VPC)を用意します。作業完了後には Sheet1(統合ビュー)・Sheet3(meta)が埋まった Excel ファイルが手元に残ります。


    9-1. 前提確認

    ツールバージョン確認

    ハンズオンを開始する前に、以下のコマンドで必要なツールが揃っているかを確認してください。

    # Python バージョン(3.11 以上必須)
    python3 --version
    # 期待出力例: Python 3.11.9
    
    # Terraform バージョン(1.9 系必須)
    terraform version
    # 期待出力例: Terraform v1.9.8
    
    # AWS CLI バージョン(v2 必須)
    aws --version
    # 期待出力例: aws-cli/2.15.30 Python/3.11.8 ...
    
    # AWS 認証確認
    aws sts get-caller-identity
    # 期待出力例(アカウント ID はダミー):
    # {
    #  "UserId": "AIDAEXAMPLEID",
    #  "Account": "123456789012",
    #  "Arn": "arn:aws:iam::123456789012:user/your-iam-user"
    # }
    

    Terraform が 1.9.x 未満の場合、plan JSON のスキーマバージョンが異なりパーサが動作しない可能性があります。tfenv や公式バイナリで 1.9 系にアップデートしてください。

    Python 依存ライブラリのインストール

    pip install openpyxl python-hcl2
    # 確認
    python3 -c "import openpyxl; print('openpyxl', openpyxl.__version__)"
    python3 -c "import hcl2; print('python-hcl2 OK')"
    

    openpyxl は Excel ファイルの生成に、python-hcl2 は HCL フォールバック解析(オプション)に使用します。

    ディレクトリ構成の確認

    ハンズオン用のプロジェクトディレクトリが以下の構成になっていることを確認します。

    param-sheet-project/
    ├── environments/
    │├── dev/
    ││├── main.tf
    ││├── variables.tf
    ││├── terraform.tfvars
    ││└── backend.tf
    │├── stg/
    ││├── main.tf
    ││├── variables.tf
    ││├── terraform.tfvars
    ││└── backend.tf
    │└── prod/
    │ ├── main.tf
    │ ├── variables.tf
    │ ├── terraform.tfvars
    │ └── backend.tf
    ├── modules/
    │├── ec2/
    │└── vpc/
    ├── scripts/
    │└── tf_to_excel.py
    ├── output/  # 生成 xlsx の出力先(git-ignore 推奨)
    └── Makefile
    

    9-2. エンドツーエンド実行手順

    Step 1: サンプル Terraform コードの準備

    Section 4 で解説した最小構成(EC2 + VPC)を environments/dev/main.tf に用意します。以下は動作確認用の最小 HCL です。実際の本番コードと置き換えて使用してください。

    # environments/dev/main.tf
    terraform {
      required_version = "~> 1.9"
      required_providers {
     aws = {
    source  = "hashicorp/aws"
    version = "~> 5.0"
     }
      }
    }
    
    provider "aws" {
      region = var.aws_region
    }
    
    variable "aws_region" { default = "ap-northeast-1" }
    variable "env"  { default = "dev" }
    variable "instance_type" { default = "t3.micro" }
    
    # VPC
    resource "aws_vpc" "main" {
      cidr_block  = "10.0.0.0/16"
      enable_dns_hostnames = true
      enable_dns_support= true
      tags = { Name = "param-sheet-vpc-${var.env}", Env = var.env }
    }
    
    # Subnet
    resource "aws_subnet" "public" {
      vpc_id= aws_vpc.main.id
      cidr_block  = "10.0.1.0/24"
      availability_zone = "${var.aws_region}a"
      map_public_ip_on_launch = true
      tags = { Name = "public-subnet-${var.env}", Env = var.env }
    }
    
    # Security Group
    resource "aws_security_group" "web" {
      name  = "web-sg-${var.env}"
      description = "Web tier security group"
      vpc_id= aws_vpc.main.id
    
      ingress {
     from_port= 443
     to_port  = 443
     protocol = "tcp"
     cidr_blocks = ["0.0.0.0/0"]
     description = "HTTPS from internet"
      }
    
      egress {
     from_port= 0
     to_port  = 0
     protocol = "-1"
     cidr_blocks = ["0.0.0.0/0"]
     description = "All outbound"
      }
    
      tags = { Name = "web-sg-${var.env}", Env = var.env }
    }
    
    # EC2 Instance
    resource "aws_instance" "web" {
      ami= "ami-0d52744d6551d851e"  # ap-northeast-1 Amazon Linux 2023
      instance_type  = var.instance_type
      subnet_id= aws_subnet.public.id
      vpc_security_group_ids = [aws_security_group.web.id]
    
      tags = { Name = "web-server-${var.env}", Env = var.env }
    }
    

    stg/prod/ も同様の構成で作成し、terraform.tfvarsenvinstance_type の値を環境ごとに変えます。

    # environments/stg/terraform.tfvars
    env  = "stg"
    instance_type = "t3.small"
    
    # environments/prod/terraform.tfvars
    env  = "prod"
    instance_type = "t3.medium"
    

    Step 2: make plan-all で全環境の plan.json を生成

    # プロジェクトルートで実行
    make plan-all
    

    Makefile の plan-all ターゲットは Section 6 で作成済みです。各環境ディレクトリで terraform initterraform plan -out tfplanterraform show -json tfplan > plan.json を順次実行します。

    初回実行時は terraform init に時間がかかります。プロバイダプラグインのダウンロードが完了するまで待機してください(目安: 60〜120 秒)。

    # 期待出力イメージ
    === dev ===
    Initializing the backend...
    ...
    Plan: 4 to add, 0 to change, 0 to destroy.
    === stg ===
    ...
    Plan: 4 to add, 0 to change, 0 to destroy.
    === prod ===
    ...
    Plan: 4 to add, 0 to change, 0 to destroy.
    

    成功すると各環境ディレクトリに plan.json が生成されます。

    # ファイル生成確認
    ls -lh environments/*/plan.json
    # 期待出力例
    # -rw-r--r-- 1 user group 48K environments/dev/plan.json
    # -rw-r--r-- 1 user group 49K environments/stg/plan.json
    # -rw-r--r-- 1 user group 49K environments/prod/plan.json
    

    plan.json のファイルサイズ目安: EC2 + VPC 最小構成で 40〜60 KB 程度です。モジュールや追加リソースが増えると比例して増加します。

    Step 3: tf_to_excel.py でパラメーターシートを生成

    python3 scripts/tf_to_excel.py \
      --envs environments/dev/plan.json \
    environments/stg/plan.json \
    environments/prod/plan.json \
      --out output/param-sheet-$(date +%Y%m%d-%H%M).xlsx
    

    スクリプトの実行時間目安: EC2 + VPC 構成(3環境 × 4リソース)で 3〜8 秒 程度。大規模構成(100リソース超)でも 30〜60 秒以内が目安です。

    # 期待出力例
    [INFO] Parsing environments/dev/plan.json ...
    [INFO]resources: 4 (aws_vpc:1, aws_subnet:1, aws_security_group:1, aws_instance:1)
    [INFO] Parsing environments/stg/plan.json ...
    [INFO]resources: 4
    [INFO] Parsing environments/prod/plan.json ...
    [INFO]resources: 4
    [INFO] Writing Excel: output/param-sheet-20260418-1000.xlsx
    [INFO] Sheet1 (統合ビュー): 22 rows written
    [INFO] Sheet3 (meta): written
    [OK] output/param-sheet-20260418-1000.xlsx (38 KB)
    

    9-3. 成果物の確認方法

    生成された output/param-sheet-20260418-1000.xlsx を Excel または LibreOffice Calc で開きます。

    Sheet1「統合ビュー」の構造

    Row 1 (ヘッダ第1段):
      サービス | リソース名 | 属性 | ===== 期待値 ===== | ===== 現状値 ===== | ===== 判定 =====
     ↑ 3列マージセル  ↑ 3列マージセル ↑ 3列マージセル
    
    Row 2 (ヘッダ第2段):
      -  | -| - | dev | stg | prod  | dev  | stg  | prod  | dev | stg | prod
    
    Row 3 以降 (データ行):
      EC2| web-server-dev | instance_type | t3.micro | t3.small | t3.medium | - | - | - | - | - | -
      EC2| web-server-dev | ami  | ami-0d52... | ami-0d52... | ami-0d52... | ...
      VPC| param-sheet-vpc-dev | cidr_block | 10.0.0.0/16 | ...
      ...
    

    行数目安: EC2 + VPC + Subnet + SG の4リソース、3環境で 20〜25 行 程度(除外フィルタ適用後)。10サービス・複数モジュールを追加すると 200〜300 行になります。

    列幅: スクリプトが column_dimensions で自動調整済みのため、開いた時点で読みやすい状態です。必要に応じて手動で追加調整してください。

    セル色の見方:

    意味
    緑(#C6EFCEOK(期待値と現状値が一致)
    赤(#FFC7CENG(値の不一致)
    橙(#FFEB9C実環境で未検出
    紫(#CC99FF設計外の存在
    白(無色)SKIP(両方未設定)

    第1弾では AWS Config による実環境取得がまだないため、現状値列はすべて空白(-)になります。この段階での用途は「コードから期待値を自動転記した Excel を関係者に配布する」ことです。第2弾(Config 突合)で現状値・判定列が埋まります。

    Sheet3「meta」の確認

    Sheet3 の内容例:
      実行日時: 2026-04-18 10:00:00
      リージョン: ap-northeast-1
      AWSアカウント: 123456789012
      Terraform バージョン: 1.9.8
      スクリプトバージョン: 1.0.0
      環境: dev, stg, prod
      リソース総数: 12
    

    meta シートは突合の再現性(いつ・誰が・どのバージョンで生成したか)を記録するための監査証跡です。社内レビュー時にこのシートを見せることで、パラメーターシートの鮮度を説明できます。


    9-4. よくあるエラーと対処

    エラー1: Terraform バージョン不一致(plan.json スキーマ差異)

    # エラー例
    KeyError: 'configuration'
      File "scripts/tf_to_excel.py", line 42, in parse_plan
     code_keys = extract_code_keys(plan["configuration"]["root_module"]["resources"])
    

    原因: Terraform 1.8 以前の plan.json は configuration キーの構造が異なります。

    対処:

    # Terraform バージョン確認
    terraform version
    
    # tfenv でバージョン切り替え(tfenv 使用時)
    tfenv install 1.9.8
    tfenv use 1.9.8
    
    # plan.json を再生成
    make plan-all
    

    本スクリプトは Terraform 1.9.x の plan JSON スキーマ(schema_version: 1.2)を前提としています。

    エラー2: AWS 認証エラー(plan 失敗)

    # エラー例
    ╷
    │ Error: No valid credential sources found
    │
    │with provider["registry.terraform.io/hashicorp/aws"],
    │ ...
    ╵
    

    対処:

    # AWS 認証情報の確認
    aws sts get-caller-identity
    
    # 認証が切れている場合(SSO 使用時)
    aws sso login --profile your-profile
    
    # 環境変数で指定する場合
    export AWS_PROFILE=your-profile
    # または
    export AWS_ACCESS_KEY_ID=...
    export AWS_SECRET_ACCESS_KEY=...
    export AWS_REGION=ap-northeast-1
    

    注意: AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を直接コードや tfvars に書かないでください。環境変数または AWS プロファイルを使用します。

    エラー3: openpyxl ImportError

    # エラー例
    ModuleNotFoundError: No module named 'openpyxl'
    

    対処:

    pip install openpyxl
    
    # 仮想環境を使用している場合
    source .venv/bin/activate
    pip install openpyxl
    

    Python の実行環境(仮想環境・グローバル環境)がスクリプト実行時と pip install 時で一致しているかを確認してください。

    エラー4: plan.json が空またはリソースゼロ

    # スクリプト出力例
    [WARN] environments/dev/plan.json: 0 resources found
    [WARN] Sheet1 has no data rows. Check plan.json content.
    

    原因 A: main.tf に resource ブロックがない、または全リソースが count = 0 になっている。

    原因 B: terraform plan が失敗して plan.json が空ファイルになっている。

    対処:

    # plan.json の内容確認
    python3 -c "
    import json, sys
    with open('environments/dev/plan.json') as f:
     d = json.load(f)
    print('format_version:', d.get('format_version'))
    res = d.get('planned_values', {}).get('root_module', {}).get('resources', [])
    print('resources count:', len(res))
    "
    
    # plan を再実行
    cd environments/dev
    terraform plan -out tfplan -input=false
    terraform show -json tfplan > plan.json
    

    9-5. AWS Config の概念:第2弾への準備

    この記事(第1弾)は AWS Config なしで動作します。
    本サブセクションは、次回の第2弾「AWS Config と Terraform パラメーターシートを突合する」に向けた概念整理です。Config 未経験の方は読み進めることで第2弾の準備ができます。Config 経験者はスキップしても構いません。

    AWS Config とは

    AWS Config は、AWS リソースの構成変更を継続的に記録・追跡するマネージドサービスです。「今、EC2 インスタンスの instance_type は何か?」「1週間前と比べて Security Group のルールは変わったか?」といった問いに、API 1本で回答できます。

    本シリーズとの関係はシンプルです。

    第1弾(本記事):  Terraform コード → plan.json → Excel(期待値列を埋める)
    第2弾:  AWS Config → Advanced Query → Excel(現状値列を埋める)
      ↓
      期待値 vs 現状値 を突合 → 差分検知
    

    第2弾で Config を使う理由は、aws ec2 describe-instances のような個別 API を何十本も叩くのではなく、SQL ライクなクエリで複数リソースタイプをまとめて取得できるからです。

    -- Config Advanced Query の例(第2弾で詳解)
    SELECT
      resourceId,
      resourceName,
      configuration.instanceType,
      configuration.subnetId,
      tags
    WHERE
      resourceType = 'AWS::EC2::Instance'
      AND tags.Env = 'prod'
    

    第2弾に向けた前提確認コマンド

    今すぐ Config を有効化する必要はありませんが、以下のコマンドで 現在の有効化状態 を確認しておくと第2弾の作業がスムーズになります。

    # Configuration Recorder の有効化状態を確認
    aws configservice describe-configuration-recorder-status \
      --region ap-northeast-1 \
      --query 'ConfigurationRecordersStatus[0].recording'
    # 出力例: true(有効)/ false(無効)
    
    # Config が有効でない場合はこの段階では OK(第2弾で設定します)
    # 出力がエラーになる場合も問題なし
    

    コスト感(正直に)

    AWS Config の料金は主に2つです。

    料金項目単価目安
    構成変更の記録$0.003 / 項目EC2 1台を1日運用: 数円以下
    ルール評価$0.001 / 評価ハンズオン用途: 月 $1〜5

    ハンズオン終了後は Recorder を停止(aws configservice stop-configuration-recorder)することでコストを抑制できます。第2弾の記事内で詳しく解説します。

    「Config 未経験でも第2弾から始められます。」第2弾では Recorder の有効化から丁寧にステップを踏むので、今回のハンズオンが完了した時点で第2弾の準備は十分です。


    Section 10. まとめと第2弾への導線

    10-1. 本記事で得たもの

    本記事(第1弾)では、エンタープライズ AWS プロジェクトに欠かせない パラメーターシート の自動生成 を実現しました。得られた成果を振り返ります。

    Plan JSON ハイブリッド方式の価値:
    terraform state の問題(ARN・ID 混入)と HCL 直解析 の問題(変数未解決)を同時に回避
    configuration.expressions × planned_values.values の組合せで、コード記載値のみを変数解決済みで抽出 するという設計上の核心を実装した
    – Terraform のメジャーバージョン間でも安定して動作する plan JSON スキーマ(format_version: 1.2)に依存するため、将来の互換性リスクを最小化

    Excel 統合シートの実用性:
    – dev / stg / prod の3環境を1シートに横断統合し、環境差を一目で比較できるフォーマット
    – 2段ヘッダ・セル色分け(OK/NG/未検出/設計外)・オートフィルタを組み合わせ、社内レビューにそのまま提出できるレベルのドキュメントを自動生成
    – 生成時間は 100 リソース規模でも 30 秒以内。会議前の最終確認にも使える

    第1弾単独の実用価値:
    – AWS Config は第2弾のテーマです。本記事の成果物(Excel)は Config なしで単独利用できます。
    – Terraform コードから期待値を自動転記した Excel を関係者に配布し、「この構成で合っていますか?」という設計レビューを実施するユースケースは、今日からすぐに実践できます
    – 手作業で Excel を埋める工数(典型案件で数日〜数週間)が、スクリプト実行1コマンドに短縮されます


    10-2. cmd_040 シリーズ読者への横展開

    本シリーズ(cmd_042)と cmd_040(AWS×Terraform 複数人開発)は 直交補完 の関係にあります。

    cmd_040 シリーズ  ─── 継続的デプロイ軸("作って回す")
      第1弾: state 管理・lock・drift 対策
      第2弾: GitHub Actions + OIDC で PR駆動 CI/CD
      第3弾: CodePipeline × CodeBuild でエンタープライズ CI/CD
    
    cmd_042 シリーズ  ─── 設計整合検証軸("作ったものを検証する")
      第1弾(本記事): Terraform → Excel パラメーターシート自動生成
      第2弾: AWS Config と突合する単体テスト自動化
    

    cmd_040 でデプロイパイプラインを構築済みの読者は、本シリーズを重ねることで「CI/CD で自動デプロイ → Config で設計整合を自動検証」という完全な DevOps ループが完成します。

    具体的な連携イメージ:
    – cmd_040 第2弾(GitHub Actions)のパイプライン末尾に make excel を追加 → デプロイのたびにパラメーターシートを自動更新
    – 第2弾(Config 突合)の exit code 1 を CI の fail 条件に組み込む → 設計外リソースが存在したらパイプラインを止める

    CI 組込みに発展させたい方は cmd_040 第2弾(GitHub Actions + OIDC で PR駆動 CI/CD) を参照してください。本記事のスクリプトはそのまま利用できます。


    10-3. 第2弾への導線

    第2弾では、本記事で生成した Excel の 現状値列と判定列を AWS Config で自動入力 します。

    第2弾で追加実装する主な内容:
    – AWS Config Recorder の最小構成セットアップ(IAM ロール・Delivery Channel 含む)
    – Config Advanced Query(SQL ライクな API)でリソース属性を一括取得
    – 取得値を正規化・比較し、OK / NG / SKIP の判定を Excel に書き戻す
    – CLI サマリと終了コード(0=全一致 / 1=差分あり)の実装
    – 「意図的に差分を起こして検出する」ハンズオン

    第2弾は Config 未経験の方を想定した丁寧なセットアップ手順 から始まります。月額コストは $1〜5 程度(ハンズオン後に Recorder を停止すればほぼゼロ)。Config のコストを懸念して踏み出せずにいた方も、ぜひ挑戦してみてください。

    AWS パラメーターシート自動化シリーズ

    第2弾を読む(AWS Config 突合編)


    10-4. Config 未経験者へのメッセージ

    本記事を読んで「AWS Config という言葉は知っているが、まだ使ったことがない」という方に向けて、第2弾に向けた安心感をお伝えします。

    Config の有効化は難しくありません。 第2弾では Terraform で Configuration Recorder を数十行の HCL で立ち上げるところから始めます。既存の Terraform 知識がそのまま活かせます。

    コストは月額数ドルです。 EC2・RDS・Lambda などのリソースが数十台程度の環境であれば、月 $1〜5 程度が目安です。ハンズオン完了後に Recorder を止めればほぼゼロになります。

    第2弾から始めても大丈夫です。 第1弾(本記事)の Excel 生成部分は、第2弾でそのまま使い回します。第1弾をハンズオンまで完了させておくと、第2弾の作業がスムーズです。ただし、第2弾の冒頭で第1弾の成果物の使い方も改めて説明するので、第1弾を読んだだけでも第2弾を追うことはできます。

    エンタープライズ AWS プロジェクトでは、「設計書(パラメーターシート)と実環境がいつの間にかズレている」 という問題は珍しくありません。本シリーズを活用して、そのズレを自動検知できる仕組みを自チームに持ち込んでください。