Document AWS CLI teardown and remove Terragrunt destroy option

This commit is contained in:
cloudneutral 2025-12-10 15:55:29 +08:00
parent bc54f640d8
commit 7aa12dca34
16 changed files with 376 additions and 212 deletions

View File

@ -17,22 +17,19 @@ on:
inputs:
deploy_action:
type: choice
options: [plan, apply, destroy]
options: [plan, apply]
default: plan
env:
TF_WORKDIR: iac-template/terraform-hcl-standard/aws-cloud
TG_ROOT: iac-template/terraform-hcl-standard/aws-cloud/bootstrap
DEPLOY_ACTION: ${{ github.event.inputs.deploy_action || 'plan' }}
TG_VERSION: 0.67.14
jobs:
bootstrap:
name: "Bootstrap Modules"
runs-on: ubuntu-latest
strategy:
matrix:
target: [bootstrap/state/, bootstrap/lock, bootstrap/identity]
steps:
- uses: actions/checkout@v4
@ -41,22 +38,20 @@ jobs:
cat <<'SUMMARY' >> "$GITHUB_STEP_SUMMARY"
## Bootstrap scope
- IAM: create Terraform deploy role and automation user for DevOps
- S3: create remote state bucket (versioned + SSE)
- DynamoDB: create state lock table for Terraform CRUD workflows
- S3: create remote state bucket (versioned + SSE + public access block)
- DynamoDB: create state lock table with encryption + PITR
Resource names and regions follow iac-template/terraform-hcl-standard/aws-cloud/config/accounts/bootstrap.yaml.
Terragrunt orchestrates state → lock → identity. Resource names and regions follow iac-template/terraform-hcl-standard/aws-cloud/config/accounts/bootstrap.yaml.
SUMMARY
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.5
- name: Restore Terraform state
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: tfstate-${{ matrix.target }}
path: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
- name: Install Terragrunt
run: |
curl -L "https://github.com/gruntwork-io/terragrunt/releases/download/v${TG_VERSION}/terragrunt_linux_amd64" -o terragrunt
sudo install terragrunt /usr/local/bin/terragrunt
- name: AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
@ -65,97 +60,28 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_BOOTSTRAP_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Init
working-directory: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
run: make init
- name: Plan
- name: Terragrunt Plan
if: env.DEPLOY_ACTION == 'plan'
working-directory: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
run: make plan
working-directory: ${{ env.TG_ROOT }}
run: terragrunt run-all plan --terragrunt-non-interactive
- name: Apply
- name: Terragrunt Apply
if: env.DEPLOY_ACTION == 'apply'
working-directory: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
run: make apply
- name: Load bootstrap config for destroy
if: env.DEPLOY_ACTION == 'destroy'
run: |
python -m pip install --quiet pyyaml
python - <<'PY'
import yaml
import os
from pathlib import Path
cfg_path = Path("iac-template/terraform-hcl-standard/aws-cloud/config/accounts/bootstrap.yaml")
cfg = yaml.safe_load(cfg_path.read_text())
env_path = Path(os.environ["GITHUB_ENV"])
current_env = env_path.read_text() if env_path.exists() else ""
env_path.write_text(
current_env
+ f"BOOTSTRAP_BUCKET={cfg['state']['bucket_name']}\n"
+ f"BOOTSTRAP_REGION={cfg['region']}\n"
+ f"BOOTSTRAP_DYNAMODB_TABLE={cfg['state']['dynamodb_table_name']}\n"
+ f"BOOTSTRAP_ROLE_NAME={cfg['iam']['role_name']}\n"
+ f"BOOTSTRAP_TERRAFORM_USER={cfg['iam']['terraform_user_name']}\n"
)
PY
- name: Destroy
if: env.DEPLOY_ACTION == 'destroy'
working-directory: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
env:
AWS_REGION: ${{ env.BOOTSTRAP_REGION }}
run: |
if [ "${{ matrix.target }}" = "bootstrap-s3" ]; then
make destroy bucket_name=${BOOTSTRAP_BUCKET} region=${BOOTSTRAP_REGION}
elif [ "${{ matrix.target }}" = "bootstrap-dynamodb" ]; then
make destroy table_name=${BOOTSTRAP_DYNAMODB_TABLE} region=${BOOTSTRAP_REGION}
else
make destroy role_name=${BOOTSTRAP_ROLE_NAME} terraform_user_name=${BOOTSTRAP_TERRAFORM_USER}
fi
working-directory: ${{ env.TG_ROOT }}
run: terragrunt run-all apply --terragrunt-non-interactive
- name: Save Outputs
if: env.DEPLOY_ACTION == 'apply'
working-directory: ${{ env.TF_WORKDIR }}/${{ matrix.target }}
run: terraform output -json > ../../outputs_${{ matrix.target }}.json
working-directory: ${{ env.TG_ROOT }}
run: |
mkdir -p outputs
for dir in state lock identity; do
terragrunt output -json --terragrunt-working-dir $dir > outputs/${dir}.json
done
- uses: actions/upload-artifact@v4
if: env.DEPLOY_ACTION == 'apply'
with:
name: outputs-${{ matrix.target }}
path: iac-template/terraform-hcl-standard/aws-cloud/outputs_${{ matrix.target }}.json
name: bootstrap-outputs
path: ${{ env.TG_ROOT }}/outputs
retention-days: 30
aggregate:
name: "Aggregate Bootstrap Outputs"
runs-on: ubuntu-latest
needs: bootstrap
if: ${{ github.event.inputs.deploy_action == 'apply' }}
steps:
- uses: actions/download-artifact@v4
with:
path: ./outputs
- name: Merge Outputs
run: |
shopt -s globstar nullglob
echo "{" > final_bootstrap_outputs.json
f=true
for x in outputs/**/outputs_*.json; do
k=$(basename $x .json | sed 's/outputs_//')
[ "$f" = true ] && f=false || echo "," >> final_bootstrap_outputs.json
echo "\"$k\": $(cat $x)" >> final_bootstrap_outputs.json
done
echo "}" >> final_bootstrap_outputs.json
- run: cat final_bootstrap_outputs.json
- uses: actions/upload-artifact@v4
with:
name: bootstrap-final-output
path: final_bootstrap_outputs.json

View File

@ -0,0 +1,72 @@
# AWS Bootstrap (Terraform + Terragrunt)
This bootstrap stack provisions the shared primitives required for Terraform automation on AWS using the **terraform-hcl-standard** baseline. It delivers an auditable, deterministic foundation that can be reused across environments.
## Architecture
- **state**: Versioned, SSE-encrypted S3 bucket with public access blocked for Terraform state storage.
- **lock**: DynamoDB table with point-in-time recovery (PITR) and server-side encryption for state locking and auditability.
- **identity**: Terraform deploy role plus automation user, wired with least-privilege inline policies stored as external JSON documents.
- **Orchestration**: Terragrunt dependencies guarantee the apply order (state → lock → identity) and propagate outputs (bucket name, region, lock table) automatically.
## Execution Order
1. `state`: Creates the S3 backend bucket and exports `bucket_name`, `bucket_arn`, and `region`.
2. `lock`: Creates the DynamoDB lock table in the same region and exports `dynamodb_table_name` and `region`.
3. `identity`: Uses dependency outputs to bind IAM policies to the created state and lock resources.
Terragrunt `run-all` handles the ordering; no manual sequencing is required.
## Security Model
- **Data plane**: S3 bucket enforces AES256 SSE, public access block, and versioning. DynamoDB enables server-side encryption and PITR for forensic recovery.
- **Control plane**: IAM policies are externalized in `identity/policies/*.json` and rendered via `aws_iam_policy_document` to keep Terraform code lean and auditable.
- **Config source of truth**: `config/accounts/bootstrap.yaml` defines canonical names, regions, and tags. Terragrunt passes outputs between modules to avoid drift.
## How to Run with Terragrunt
```bash
cd iac-template/terraform-hcl-standard/aws-cloud/bootstrap
# Plan everything in dependency order
terragrunt run-all plan
# Apply everything (state -> lock -> identity)
terragrunt run-all apply
```
### Targeting a Single Module
```bash
terragrunt plan --terragrunt-working-dir state
terragrunt apply --terragrunt-working-dir identity
```
Terragrunt injects dependency outputs automatically; you do not need to pass bucket or table names manually.
### Decommissioning Bootstrap Resources
Bootstrap is intentionally outside day-to-day state management. Avoid `terragrunt destroy` and use the AWS CLI for teardown to keep lifecycle control explicit and auditable.
```bash
# Remove automation user and deploy role (customize to your account IDs)
aws iam delete-access-key --user-name terraform-automation --access-key-id <key-id>
aws iam delete-user-policy --user-name terraform-automation --policy-name terraform-automation-inline
aws iam delete-user --user-name terraform-automation
aws iam detach-role-policy --role-name terraform-deploy --policy-arn arn:aws:iam::<account-id>:policy/terraform-deploy-inline
aws iam delete-role --role-name terraform-deploy
# Remove lock + state once no stacks depend on them
aws dynamodb delete-table --table-name <bootstrap-lock-table>
aws s3 rb s3://<bootstrap-state-bucket> --force
```
Document the teardown in your change log for auditability.
## CloudNeutral Bootstrap Principles
- **Separation of concerns**: State, locking, and identity are isolated modules with explicit interfaces.
- **Least privilege by default**: IAM policies grant the minimal scope required for bootstrap lifecycle operations.
- **Idempotent automation**: All configurations are declarative, version-controlled, and runnable via Terragrunt without manual steps.
- **Auditability**: Policies live in external JSON files; DynamoDB PITR and S3 versioning preserve history for compliance.
- **Portability**: Inputs are read from YAML configuration and Terragrunt dependencies, making the stack reusable across accounts and regions.

View File

@ -1,15 +1,17 @@
locals {
bootstrap = yamldecode(file("${path.module}/../../config/accounts/bootstrap.yaml"))
config_account_name = coalesce(var.account_name, local.bootstrap.account_name)
config_region = coalesce(var.region, local.bootstrap.region)
config_role_name = coalesce(var.role_name, local.bootstrap.iam.role_name)
config_terraform_user = coalesce(var.terraform_user_name, local.bootstrap.iam.terraform_user_name)
environment = coalesce(try(local.bootstrap.environment, null), try(local.bootstrap.iam.environment, null), "bootstrap")
extra_tags = try(local.bootstrap.tags, {})
config_account_name = coalesce(var.account_name, local.bootstrap.account_name)
config_region = coalesce(var.region, local.bootstrap.region)
config_role_name = coalesce(var.role_name, local.bootstrap.iam.role_name)
config_terraform_user = coalesce(var.terraform_user_name, local.bootstrap.iam.terraform_user_name)
environment = coalesce(try(local.bootstrap.environment, null), try(local.bootstrap.iam.environment, null), "bootstrap")
extra_tags = try(local.bootstrap.tags, {})
role_name = coalesce(var.existing_role_name, local.config_role_name)
role_name = coalesce(var.existing_role_name, local.config_role_name)
terraform_user_name = coalesce(var.existing_user_name, local.config_terraform_user)
state_bucket_name = coalesce(var.state_bucket_name, try(local.bootstrap.state.bucket_name, null))
lock_table_name = coalesce(var.state_lock_table_name, try(local.bootstrap.state.dynamodb_table_name, null))
}
locals {

View File

@ -1,21 +1,21 @@
#
# IAM Role: Terraform Deploy Role
# ----------------------------------------
data "aws_iam_policy_document" "terraform_deploy_assume_role" {
source_json = templatefile(
"${path.module}/policies/terraform-deploy-assume-role.json",
{
account_id = local.account.account_id
terraform_user_name = local.config_terraform_user
}
)
}
resource "aws_iam_role" "terraform_deploy_role" {
count = var.create_role ? 1 : 0
name = local.role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${local.account.account_id}:user/${local.config_terraform_user}"
}
Action = "sts:AssumeRole"
}]
})
name = local.role_name
assume_role_policy = data.aws_iam_policy_document.terraform_deploy_assume_role.json
tags = merge(
{
@ -23,91 +23,29 @@ resource "aws_iam_role" "terraform_deploy_role" {
Environment = coalesce(try(local.account.environment, null), local.environment)
},
try(local.account.tags, {}),
local.extra_tags
local.extra_tags,
)
}
data "aws_iam_policy_document" "terraform_deploy_inline" {
source_json = templatefile(
"${path.module}/policies/terraform-deploy-inline-policy.json",
{
account_id = local.account.account_id
bucket_name = local.state_bucket_name
region = local.config_region
role_name = local.role_name
table_name = local.lock_table_name
}
)
}
resource "aws_iam_role_policy" "terraform_deploy_role_policy" {
count = var.create_role ? 1 : 0
name = "${local.role_name}-bootstrap-minimal"
role = aws_iam_role.terraform_deploy_role[0].id
policy = jsonencode({
Version = "2012-10-17",
Statement = [
# Bootstrap S3 backend (state bucket)
{
Effect = "Allow",
Action = [
"s3:CreateBucket",
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:PutBucketVersioning",
"s3:PutBucketPolicy",
"s3:PutBucketTagging",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock",
],
Resource = "arn:aws:s3:::${local.bootstrap.state.bucket_name}"
},
{
Effect = "Allow",
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:PutObjectTagging"
],
Resource = "arn:aws:s3:::${local.bootstrap.state.bucket_name}/*"
},
# DynamoDB state lock table
{
Effect = "Allow",
Action = [
"dynamodb:CreateTable",
"dynamodb:DescribeTable",
"dynamodb:UpdateTable",
"dynamodb:TagResource",
"dynamodb:UntagResource"
],
Resource = "arn:aws:dynamodb:${local.config_region}:${local.account.account_id}:table/${local.bootstrap.state.dynamodb_table_name}"
},
# IAM roles needed for bootstrap lifecycle
{
Effect = "Allow",
Action = [
"iam:GetRole",
"iam:CreateRole",
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:TagRole",
"iam:UntagRole"
],
Resource = [
"arn:aws:iam::${local.account.account_id}:role/${local.role_name}",
"arn:aws:iam::${local.account.account_id}:role/bootstrap-*",
"arn:aws:iam::${local.account.account_id}:role/terraform-*"
]
},
{
Effect = "Allow",
Action = [
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy"
],
Resource = [
"arn:aws:iam::${local.account.account_id}:role/${local.role_name}",
"arn:aws:iam::${local.account.account_id}:role/bootstrap-*",
"arn:aws:iam::${local.account.account_id}:role/terraform-*"
]
}
]
})
name = "${local.role_name}-bootstrap-minimal"
role = aws_iam_role.terraform_deploy_role[0].id
policy = data.aws_iam_policy_document.terraform_deploy_inline.json
}
#
@ -122,23 +60,20 @@ resource "aws_iam_user" "terraform_user" {
#
# IAM User Policy:
# ----------------------------------------
data "aws_iam_policy_document" "terraform_user" {
source_json = templatefile(
"${path.module}/policies/terraform-user-assume-role.json",
{
account_id = local.account.account_id
role_name = local.role_name
}
)
}
resource "aws_iam_user_policy" "terraform_user_policy" {
count = var.create_user ? 1 : 0
name = "${local.terraform_user_name}-iac-policy"
user = aws_iam_user.terraform_user[0].name
policy = jsonencode({
Version = "2012-10-17",
Statement = [
# Assume TerraformDeployRole
{
Effect = "Allow",
Action = [
"sts:AssumeRole"
],
Resource = var.create_role ? aws_iam_role.terraform_deploy_role[0].arn : var.existing_role_arn
}
]
})
name = "${local.terraform_user_name}-iac-policy"
user = aws_iam_user.terraform_user[0].name
policy = data.aws_iam_policy_document.terraform_user.json
}

View File

@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::${account_id}:user/${terraform_user_name}"
},
"Action": "sts:AssumeRole"
}
]
}

View File

@ -0,0 +1,75 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BootstrapStateBucketManagement",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:PutBucketVersioning",
"s3:PutBucketPolicy",
"s3:PutBucketTagging",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock"
],
"Resource": "arn:aws:s3:::${bucket_name}"
},
{
"Sid": "BootstrapStateObjectAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:PutObjectTagging"
],
"Resource": "arn:aws:s3:::${bucket_name}/*"
},
{
"Sid": "TerraformLockTable",
"Effect": "Allow",
"Action": [
"dynamodb:CreateTable",
"dynamodb:DescribeTable",
"dynamodb:UpdateTable",
"dynamodb:TagResource",
"dynamodb:UntagResource"
],
"Resource": "arn:aws:dynamodb:${region}:${account_id}:table/${table_name}"
},
{
"Sid": "BootstrapIamRoleLifecycle",
"Effect": "Allow",
"Action": [
"iam:GetRole",
"iam:CreateRole",
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:TagRole",
"iam:UntagRole"
],
"Resource": [
"arn:aws:iam::${account_id}:role/${role_name}",
"arn:aws:iam::${account_id}:role/bootstrap-*",
"arn:aws:iam::${account_id}:role/terraform-*"
]
},
{
"Sid": "BootstrapIamRolePolicies",
"Effect": "Allow",
"Action": [
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy"
],
"Resource": [
"arn:aws:iam::${account_id}:role/${role_name}",
"arn:aws:iam::${account_id}:role/bootstrap-*",
"arn:aws:iam::${account_id}:role/terraform-*"
]
}
]
}

View File

@ -0,0 +1,12 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"sts:AssumeRole"
],
"Resource": "arn:aws:iam::${account_id}:role/${role_name}"
}
]
}

View File

@ -0,0 +1,39 @@
include "root" {
path = find_in_parent_folders()
}
locals {
bootstrap_config = include.root.locals.bootstrap_config
}
dependency "state" {
config_path = "../state"
mock_outputs = {
bucket_name = local.bootstrap_config.state.bucket_name
region = local.bootstrap_config.region
}
mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}
dependency "lock" {
config_path = "../lock"
mock_outputs = {
dynamodb_table_name = local.bootstrap_config.state.dynamodb_table_name
region = local.bootstrap_config.region
}
mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}
terraform {
source = "./"
}
inputs = {
region = dependency.state.outputs.region
state_bucket_name = dependency.state.outputs.bucket_name
state_lock_table_name = dependency.lock.outputs.dynamodb_table_name
}

View File

@ -61,3 +61,15 @@ variable "create_user" {
error_message = "existing_user_name must be provided when create_user is false."
}
}
variable "state_bucket_name" {
description = "Name of the Terraform state bucket (overrides bootstrap config when provided)"
type = string
default = null
}
variable "state_lock_table_name" {
description = "Name of the DynamoDB state lock table (overrides bootstrap config when provided)"
type = string
default = null
}

View File

@ -9,6 +9,14 @@ resource "aws_dynamodb_table" "terraform_locks" {
type = "S"
}
point_in_time_recovery {
enabled = true
}
server_side_encryption {
enabled = true
}
tags = merge(
{
Name = local.dynamodb_table_name

View File

@ -3,3 +3,7 @@ output "dynamodb_table_name" {
value = aws_dynamodb_table.terraform_locks.name
}
output "region" {
description = "AWS region hosting the DynamoDB lock table"
value = local.region
}

View File

@ -0,0 +1,26 @@
include "root" {
path = find_in_parent_folders()
}
locals {
bootstrap_config = include.root.locals.bootstrap_config
}
dependency "state" {
config_path = "../state"
mock_outputs = {
bucket_name = local.bootstrap_config.state.bucket_name
region = local.bootstrap_config.region
}
mock_outputs_allowed_terraform_commands = ["plan", "validate"]
}
terraform {
source = "./"
}
inputs = {
region = dependency.state.outputs.region
}

View File

@ -27,3 +27,12 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "sse" {
}
}
}
resource "aws_s3_bucket_public_access_block" "block" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

View File

@ -1,3 +1,13 @@
output "bucket_name" {
value = aws_s3_bucket.state.bucket
}
output "bucket_arn" {
value = aws_s3_bucket.state.arn
description = "ARN of the Terraform state bucket"
}
output "region" {
value = local.region
description = "AWS region hosting the state bucket"
}

View File

@ -0,0 +1,16 @@
include "root" {
path = find_in_parent_folders()
}
locals {
bootstrap_config = include.root.locals.bootstrap_config
}
terraform {
source = "./"
}
inputs = {
bucket_name = local.bootstrap_config.state.bucket_name
region = local.bootstrap_config.region
}

View File

@ -0,0 +1,6 @@
terraform_version_constraint = ">= 1.2.0"
terragrunt_version_constraint = ">= 0.67.14"
locals {
bootstrap_config = yamldecode(file("${get_terragrunt_dir()}/../config/accounts/bootstrap.yaml"))
}