From 684d2180ef502f928fa7a7415845816c4533edbf Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:07:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=ED=98=84=ED=99=A9=EA=B3=BC=20Terraform=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/infrastructure/prod-inventory.md | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/infrastructure/prod-inventory.md diff --git a/docs/infrastructure/prod-inventory.md b/docs/infrastructure/prod-inventory.md new file mode 100644 index 0000000..2550bd3 --- /dev/null +++ b/docs/infrastructure/prod-inventory.md @@ -0,0 +1,88 @@ +# Production Infrastructure Inventory + +## Purpose + +This document records the existing production infrastructure before it is +adopted by Terraform. It is an inventory, not an authorization to change AWS +resources. + +## Scope + +| Area | Existing resource | Terraform approach | +| --- | --- | --- | +| EC2 | `i-0b4fe94824ca7d3c7` (`moru-server`) | Import and manage | +| Elastic IP | `eipalloc-0423074c40c068102` | Import and manage | +| EC2 security group | `sg-0ff632924dad2adb6` (`moru-ec2-sg`) | Import and manage | +| RDS MySQL | `moru-db` | Import and manage | +| RDS security group | `sg-0056408c9dc50cef4` (`moru-rds-sg`) | Import and manage | +| S3 assets | `moru-prod-assets-488230509502` | Import and manage | +| S3 TTS previews | `moru-prod-preview-assets-488230509502` | Import and manage | +| EC2 IAM role/profile | `moru-server-s3-role` | Import and manage | +| CloudWatch Logs | `/moru/prod/app` | Import and manage | +| VPC | `vpc-087c2d76939191694` | Reference with data source | +| EC2 subnet | `subnet-0a94acc68b254e59f` | Reference with data source | +| RDS subnet group | `default-vpc-087c2d76939191694` | Reference with data source | + +The VPC and subnets are the account's default network and may be shared by +other workloads. They must not be imported or changed in this migration. + +## Observed Production Configuration + +### Compute and network + +- EC2: `t3.micro`, Amazon Linux AMI `ami-0e4ab31f1847c850c`, in + `ap-northeast-2c`. +- The instance uses the `moru-key` key pair, the `moru-server-s3-role` instance + profile, and an IMDSv2-required metadata configuration. +- Elastic IP `43.202.84.114` is associated with the instance. +- The application container publishes only to `127.0.0.1:8080`; Nginx remains + host-managed and proxies the public HTTP(S) traffic. + +### Database + +- RDS MySQL `moru-db`: MySQL `8.4.9`, `db.t4g.micro`, 20 GiB `gp2`, encrypted + storage, one-day backup retention, and no Multi-AZ deployment. +- The database name is `moru`. Credentials are intentionally excluded from this + repository and Terraform configuration. + +### Storage and logging + +- `moru-prod-assets-488230509502` stores private application assets. +- `moru-prod-preview-assets-488230509502` allows public reads only below + `tts/previews/*` and `tts/common/*` for preview and common TTS audio. +- Both production buckets use AES256 default encryption. Versioning is not + currently enabled. +- Docker uses the `awslogs` driver and writes to `/moru/prod/app`, which retains + logs for 14 days. No CloudWatch metric alarms are currently configured. +- The EC2 role has inline policies for the production and development asset + buckets. The production policy allows only the `tts/*` and `profiles/*` + prefixes and CloudWatch log writes. + +## Out of Scope for This Migration + +- Docker Compose services, the Redis container, and the GitHub Actions deploy + workflow remain unchanged. +- Nginx, Certbot, DuckDNS, and files on the EC2 host are documented only. They + are not changed or managed by Terraform in this phase. +- Database credentials, application secrets, and GCP credentials remain in the + existing secret-management flow. + +## Security Review Items + +These items were observed during the read-only audit. They are intentionally +not changed by the Terraform adoption work and need separate approval. + +1. The RDS instance is publicly accessible, and `moru-rds-sg` currently allows + all traffic from IPv4 and IPv6 addresses. +2. `moru-ec2-sg` allows SSH from every IPv4 address. +3. The production S3 buckets do not currently use versioning. This is distinct + from the planned Terraform state bucket, which must use versioning. + +## Adoption Safety Rules + +- Never run `terraform apply`, `terraform import`, or `terraform destroy` + without explicit approval. +- Do not accept a plan that replaces, creates, modifies, or destroys an + existing production resource during import. +- Keep Terraform state, plan files, credentials, and application secrets out of + Git. From 00ddb6b216c92d8bc6fc09d77d065dc0e715f0c1 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:16:15 +0900 Subject: [PATCH 2/9] =?UTF-8?q?chore:=20Terraform=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EC=99=80=20prod=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B5=AC=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 ++ infra/README.md | 71 +++++++++++++++ infra/bootstrap/.terraform.lock.hcl | 26 ++++++ infra/bootstrap/main.tf | 131 ++++++++++++++++++++++++++++ infra/bootstrap/outputs.tf | 9 ++ infra/bootstrap/variables.tf | 29 ++++++ infra/bootstrap/versions.tf | 14 +++ infra/prod/.terraform.lock.hcl | 26 ++++++ infra/prod/backend.hcl.example | 5 ++ infra/prod/versions.tf | 16 ++++ 10 files changed, 336 insertions(+) create mode 100644 infra/README.md create mode 100644 infra/bootstrap/.terraform.lock.hcl create mode 100644 infra/bootstrap/main.tf create mode 100644 infra/bootstrap/outputs.tf create mode 100644 infra/bootstrap/variables.tf create mode 100644 infra/bootstrap/versions.tf create mode 100644 infra/prod/.terraform.lock.hcl create mode 100644 infra/prod/backend.hcl.example create mode 100644 infra/prod/versions.tf diff --git a/.gitignore b/.gitignore index 908bee1..c2451b0 100644 --- a/.gitignore +++ b/.gitignore @@ -215,6 +215,15 @@ fabric.properties hs_err_pid* replay_pid* +### Terraform ### +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +!*.tfvars.example +infra/prod/backend.hcl + ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..7990fa3 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,71 @@ +# Terraform Infrastructure + +`bootstrap` creates the dedicated S3 backend resources. It intentionally uses +local state for this one-time setup. `prod` manages the existing Moru +production infrastructure after its backend is configured. + +## Safety Boundary + +The current `moru-prod` AWS profile is read-only. It can run discovery, +`terraform fmt`, `terraform validate`, and a read-only import plan. It cannot +create the state bucket or import resources into remote state. + +Do not run `terraform apply`, `terraform import`, or `terraform destroy` until +the plan is reviewed and explicit approval is given. + +## State Backend + +The backend is deliberately separate from the existing application buckets. +The bootstrap configuration creates a private bucket with: + +- AES256 server-side encryption +- S3 bucket versioning for state recovery +- S3 lockfiles through `use_lockfile = true` +- public-access blocking and bucket-owner-enforced ownership +- a transport-security bucket policy +- an unattached least-privilege policy for the future Terraform operator + +HashiCorp recommends bucket versioning for S3 state recovery and S3 lockfiles +for state locking. DynamoDB locking is not used because it is deprecated by the +S3 backend. + +### Bootstrap after approval + +1. Sign in with a separately approved AWS identity that can create the backend + bucket and IAM policy. Do not use the root account. +2. Choose a globally unique state bucket name. The recommended candidate is + `moru-prod-terraform-state-488230509502`, subject to availability. +3. Run the following from `infra/bootstrap`: + + ```bash + terraform init + terraform plan -var='state_bucket_name=CHOSEN_BUCKET_NAME' + ``` + +4. Review that the plan only creates the state bucket resources and the + unattached operator policy. After explicit approval, run `terraform apply` + with the same variable. +5. Attach the created state-access policy only to the approved Terraform + operator identity. Keep the `moru_terraform` discovery user read-only. + +### Configure the production backend + +1. Copy `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. +2. Replace the bucket name with the approved state bucket name. Do not commit + this file. +3. Run the following from `infra/prod`: + + ```bash + terraform init -backend-config=backend.hcl + terraform plan + ``` + +The first production plan should show imports only. Any planned create, update, +replacement, or destroy for an existing production resource is a stop signal. + +## Non-Terraform Host Configuration + +Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted +files remain outside the Terraform scope in this phase. Their existing setup is +documented in the repository and on the production host; it must not be changed +as part of adoption. diff --git a/infra/bootstrap/.terraform.lock.hcl b/infra/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..d591841 --- /dev/null +++ b/infra/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.65.0" + constraints = "~> 6.0" + hashes = [ + "h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=", + "zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be", + "zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3", + "zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917", + "zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8", + "zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09", + "zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8", + "zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7", + "zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5", + "zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294", + "zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f", + "zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab", + "zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af", + "zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87", + "zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59", + ] +} diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf new file mode 100644 index 0000000..27664f2 --- /dev/null +++ b/infra/bootstrap/main.tf @@ -0,0 +1,131 @@ +locals { + common_tags = { + Environment = var.environment + ManagedBy = "Terraform" + Project = var.project + Purpose = "terraform-state" + } +} + +data "aws_iam_policy_document" "state_bucket" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + + resources = [ + "arn:aws:s3:::${var.state_bucket_name}", + "arn:aws:s3:::${var.state_bucket_name}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +data "aws_iam_policy_document" "state_operator" { + statement { + sid = "ListStatePrefix" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::${var.state_bucket_name}"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + var.state_key, + "${var.state_key}.tflock", + ] + } + } + + statement { + sid = "ReadAndWriteState" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + resources = ["arn:aws:s3:::${var.state_bucket_name}/${var.state_key}"] + } + + statement { + sid = "ManageStateLock" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = ["arn:aws:s3:::${var.state_bucket_name}/${var.state_key}.tflock"] + } +} + +resource "aws_s3_bucket" "terraform_state" { + bucket = var.state_bucket_name + force_destroy = false + tags = local.common_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_policy" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + policy = data.aws_iam_policy_document.state_bucket.json +} + +resource "aws_iam_policy" "terraform_state_operator" { + name = "${var.project}-${var.environment}-terraform-state-access" + description = "Least-privilege access to the Moru production Terraform state and lockfile." + policy = data.aws_iam_policy_document.state_operator.json + tags = local.common_tags +} diff --git a/infra/bootstrap/outputs.tf b/infra/bootstrap/outputs.tf new file mode 100644 index 0000000..c7f55b2 --- /dev/null +++ b/infra/bootstrap/outputs.tf @@ -0,0 +1,9 @@ +output "state_bucket_name" { + description = "Name of the created Terraform state bucket." + value = aws_s3_bucket.terraform_state.id +} + +output "terraform_state_operator_policy_arn" { + description = "Attach this policy only to the approved Terraform operator identity." + value = aws_iam_policy.terraform_state_operator.arn +} diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf new file mode 100644 index 0000000..7e5a7b5 --- /dev/null +++ b/infra/bootstrap/variables.tf @@ -0,0 +1,29 @@ +variable "aws_region" { + type = string + description = "AWS region for the Terraform state bucket." + default = "ap-northeast-2" +} + +variable "project" { + type = string + description = "Project tag for newly created backend resources." + default = "moru" +} + +variable "environment" { + type = string + description = "Environment tag for newly created backend resources." + default = "prod" +} + +variable "state_bucket_name" { + type = string + description = "Globally unique name for the dedicated Terraform state bucket." + nullable = false +} + +variable "state_key" { + type = string + description = "Object key used by the production Terraform backend." + default = "prod/terraform.tfstate" +} diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf new file mode 100644 index 0000000..758b18e --- /dev/null +++ b/infra/bootstrap/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = "~> 1.15.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/infra/prod/.terraform.lock.hcl b/infra/prod/.terraform.lock.hcl new file mode 100644 index 0000000..d591841 --- /dev/null +++ b/infra/prod/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.65.0" + constraints = "~> 6.0" + hashes = [ + "h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=", + "zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be", + "zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3", + "zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917", + "zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8", + "zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09", + "zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8", + "zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7", + "zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5", + "zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294", + "zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f", + "zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab", + "zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af", + "zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87", + "zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59", + ] +} diff --git a/infra/prod/backend.hcl.example b/infra/prod/backend.hcl.example new file mode 100644 index 0000000..4eb3cf2 --- /dev/null +++ b/infra/prod/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "REPLACE_WITH_APPROVED_STATE_BUCKET" +key = "prod/terraform.tfstate" +region = "ap-northeast-2" +encrypt = true +use_lockfile = true diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf new file mode 100644 index 0000000..d16a787 --- /dev/null +++ b/infra/prod/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = "~> 1.15.0" + + backend "s3" {} + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" +} From 7c0e8278706330c637c9073574521706290c6cdf Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:18:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20EC2=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=EC=99=80=20IAM=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/compute.tf | 48 +++++++++++++ infra/prod/data.tf | 16 +++++ infra/prod/ec2_security_group.tf | 63 +++++++++++++++++ infra/prod/iam.tf | 113 +++++++++++++++++++++++++++++++ infra/prod/variables.tf | 47 +++++++++++++ infra/prod/versions.tf | 2 +- 6 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 infra/prod/compute.tf create mode 100644 infra/prod/data.tf create mode 100644 infra/prod/ec2_security_group.tf create mode 100644 infra/prod/iam.tf create mode 100644 infra/prod/variables.tf diff --git a/infra/prod/compute.tf b/infra/prod/compute.tf new file mode 100644 index 0000000..88de452 --- /dev/null +++ b/infra/prod/compute.tf @@ -0,0 +1,48 @@ +resource "aws_instance" "moru_server" { + ami = "ami-0e4ab31f1847c850c" + instance_type = "t3.micro" + key_name = "moru-key" + subnet_id = data.aws_subnet.production_ec2.id + private_ip = "172.31.37.85" + vpc_security_group_ids = [aws_security_group.moru_ec2.id] + iam_instance_profile = aws_iam_instance_profile.moru_server.name + + ebs_optimized = true + monitoring = false + source_dest_check = true + + metadata_options { + http_endpoint = "enabled" + http_protocol_ipv6 = "disabled" + http_put_response_hop_limit = 2 + http_tokens = "required" + instance_metadata_tags = "disabled" + } + + root_block_device { + delete_on_termination = true + encrypted = true + iops = 3000 + kms_key_id = "arn:aws:kms:ap-northeast-2:488230509502:key/83e16ab6-e2c5-4a75-be69-f0af42d60e8b" + throughput = 125 + volume_size = 20 + volume_type = "gp3" + } + + tags = { + Name = "moru-server" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_eip" "moru_server" { + domain = "vpc" + instance = aws_instance.moru_server.id + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/data.tf b/infra/prod/data.tf new file mode 100644 index 0000000..cee9d2f --- /dev/null +++ b/infra/prod/data.tf @@ -0,0 +1,16 @@ +data "aws_caller_identity" "current" {} + +data "aws_vpc" "existing" { + id = var.vpc_id +} + +data "aws_subnet" "production_ec2" { + id = var.ec2_subnet_id +} + +check "expected_aws_account" { + assert { + condition = data.aws_caller_identity.current.account_id == var.aws_account_id + error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." + } +} diff --git a/infra/prod/ec2_security_group.tf b/infra/prod/ec2_security_group.tf new file mode 100644 index 0000000..91aaf5e --- /dev/null +++ b/infra/prod/ec2_security_group.tf @@ -0,0 +1,63 @@ +resource "aws_security_group" "moru_ec2" { + name = "moru-ec2-sg" + description = "launch-wizard-2 created 2026-07-12T14:22:52.206Z" + vpc_id = data.aws_vpc.existing.id + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_ssh_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 22 + ip_protocol = "tcp" + to_port = 22 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_http_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 80 + ip_protocol = "tcp" + to_port = 80 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_http_ipv6" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv6 = "::/0" + from_port = 80 + ip_protocol = "tcp" + to_port = 80 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_https_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + ip_protocol = "tcp" + to_port = 443 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_https_ipv6" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv6 = "::/0" + from_port = 443 + ip_protocol = "tcp" + to_port = 443 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_application_test" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "58.29.146.38/32" + from_port = 8080 + ip_protocol = "tcp" + to_port = 8080 +} + +resource "aws_vpc_security_group_egress_rule" "moru_ec2_all_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} diff --git a/infra/prod/iam.tf b/infra/prod/iam.tf new file mode 100644 index 0000000..1eb7589 --- /dev/null +++ b/infra/prod/iam.tf @@ -0,0 +1,113 @@ +data "aws_iam_policy_document" "moru_server_assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +data "aws_iam_policy_document" "moru_dev_assets" { + statement { + sid = "AccessMoruAssetBucket" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::moru-dev-assets-488230509502"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["tts/*", "profiles/*"] + } + } + + statement { + sid = "ManageMoruAssetObjects" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = [ + "arn:aws:s3:::moru-dev-assets-488230509502/tts/*", + "arn:aws:s3:::moru-dev-assets-488230509502/profiles/*", + ] + } +} + +data "aws_iam_policy_document" "moru_prod_assets" { + statement { + sid = "AccessMoruAssetBucket" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::moru-prod-assets-488230509502"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["tts/*", "profiles/*"] + } + } + + statement { + sid = "ManageMoruAssetObjects" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = [ + "arn:aws:s3:::moru-prod-assets-488230509502/tts/*", + "arn:aws:s3:::moru-prod-assets-488230509502/profiles/*", + ] + } + + statement { + sid = "WriteMoruApplicationLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + ] + resources = ["arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/moru/prod/app:*"] + } +} + +resource "aws_iam_role" "moru_server" { + name = var.ec2_iam_role_name + description = "Allows EC2 instances to call AWS services on your behalf." + assume_role_policy = data.aws_iam_policy_document.moru_server_assume_role.json + max_session_duration = 3600 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "moru_dev_assets" { + name = "moru-dev-assets-policy" + role = aws_iam_role.moru_server.id + policy = data.aws_iam_policy_document.moru_dev_assets.json +} + +resource "aws_iam_role_policy" "moru_prod_assets" { + name = "moru-prod-assets-policy" + role = aws_iam_role.moru_server.id + policy = data.aws_iam_policy_document.moru_prod_assets.json +} + +resource "aws_iam_instance_profile" "moru_server" { + name = "moru-server-s3-role" + role = aws_iam_role.moru_server.name + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf new file mode 100644 index 0000000..e38e159 --- /dev/null +++ b/infra/prod/variables.tf @@ -0,0 +1,47 @@ +variable "aws_region" { + type = string + description = "Region that contains the existing production resources." + default = "ap-northeast-2" +} + +variable "aws_account_id" { + type = string + description = "AWS account that owns the existing production resources." + default = "488230509502" +} + +variable "vpc_id" { + type = string + description = "Existing default VPC. Terraform must only reference it." + default = "vpc-087c2d76939191694" +} + +variable "ec2_subnet_id" { + type = string + description = "Existing default public subnet used by the production EC2 instance." + default = "subnet-0a94acc68b254e59f" +} + +variable "ec2_instance_id" { + type = string + description = "Existing Moru production EC2 instance ID." + default = "i-0b4fe94824ca7d3c7" +} + +variable "ec2_eip_allocation_id" { + type = string + description = "Existing Elastic IP allocation associated with the production EC2 instance." + default = "eipalloc-0423074c40c068102" +} + +variable "ec2_security_group_id" { + type = string + description = "Existing security group attached to the production EC2 instance." + default = "sg-0ff632924dad2adb6" +} + +variable "ec2_iam_role_name" { + type = string + description = "Existing EC2 role that grants S3 and CloudWatch access." + default = "moru-server-s3-role" +} diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index d16a787..5bb76dd 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -12,5 +12,5 @@ terraform { } provider "aws" { - region = "ap-northeast-2" + region = var.aws_region } From e2c08deaebff943399f4e085b46531b22e10e456 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:20:11 +0900 Subject: [PATCH 4/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20RDS=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=EC=99=80=20DB=20=EB=B3=B4=EC=95=88?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/data.tf | 4 ++ infra/prod/rds.tf | 104 +++++++++++++++++++++++++++++++ infra/prod/rds_security_group.tf | 27 ++++++++ infra/prod/variables.tf | 18 ++++++ 4 files changed, 153 insertions(+) create mode 100644 infra/prod/rds.tf create mode 100644 infra/prod/rds_security_group.tf diff --git a/infra/prod/data.tf b/infra/prod/data.tf index cee9d2f..9afd242 100644 --- a/infra/prod/data.tf +++ b/infra/prod/data.tf @@ -8,6 +8,10 @@ data "aws_subnet" "production_ec2" { id = var.ec2_subnet_id } +data "aws_db_subnet_group" "existing" { + name = var.rds_subnet_group_name +} + check "expected_aws_account" { assert { condition = data.aws_caller_identity.current.account_id == var.aws_account_id diff --git a/infra/prod/rds.tf b/infra/prod/rds.tf new file mode 100644 index 0000000..6a73d4d --- /dev/null +++ b/infra/prod/rds.tf @@ -0,0 +1,104 @@ +resource "aws_db_parameter_group" "moru" { + name = "korean" + family = "mysql8.4" + description = "korean setting" + + parameter { + name = "character_set_client" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_connection" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_database" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_filesystem" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_results" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_server" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "collation_connection" + value = "utf8mb4_general_ci" + apply_method = "immediate" + } + + parameter { + name = "collation_server" + value = "utf8mb4_general_ci" + apply_method = "immediate" + } + + parameter { + name = "max_connections" + value = "150" + apply_method = "immediate" + } + + parameter { + name = "time_zone" + value = "Asia/Seoul" + apply_method = "immediate" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_db_instance" "moru" { + identifier = var.rds_instance_identifier + engine = "mysql" + engine_version = "8.4.9" + instance_class = "db.t4g.micro" + db_name = "moru" + port = 3306 + + allocated_storage = 20 + storage_type = "gp2" + storage_encrypted = true + + availability_zone = "ap-northeast-2a" + db_subnet_group_name = data.aws_db_subnet_group.existing.name + vpc_security_group_ids = [aws_security_group.moru_rds.id] + parameter_group_name = aws_db_parameter_group.moru.name + option_group_name = "default:mysql-8-4" + network_type = "IPV4" + publicly_accessible = true + multi_az = false + deletion_protection = false + copy_tags_to_snapshot = true + iam_database_authentication_enabled = false + + auto_minor_version_upgrade = true + backup_retention_period = 1 + backup_window = "16:19-16:49" + maintenance_window = "sun:14:52-sun:15:22" + apply_immediately = false + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/rds_security_group.tf b/infra/prod/rds_security_group.tf new file mode 100644 index 0000000..5096a97 --- /dev/null +++ b/infra/prod/rds_security_group.tf @@ -0,0 +1,27 @@ +resource "aws_security_group" "moru_rds" { + name = "moru-rds-sg" + description = "Created by RDS management console" + vpc_id = data.aws_vpc.existing.id + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "moru_rds_all_ipv4" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_ingress_rule" "moru_rds_all_ipv6" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv6 = "::/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_egress_rule" "moru_rds_all_ipv4" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf index e38e159..74739f2 100644 --- a/infra/prod/variables.tf +++ b/infra/prod/variables.tf @@ -45,3 +45,21 @@ variable "ec2_iam_role_name" { description = "Existing EC2 role that grants S3 and CloudWatch access." default = "moru-server-s3-role" } + +variable "rds_instance_identifier" { + type = string + description = "Existing production RDS instance identifier." + default = "moru-db" +} + +variable "rds_security_group_id" { + type = string + description = "Existing security group attached to the production RDS instance." + default = "sg-0056408c9dc50cef4" +} + +variable "rds_subnet_group_name" { + type = string + description = "Existing default DB subnet group. Terraform must only reference it." + default = "default-vpc-087c2d76939191694" +} From 85206ff860238b4ad761b6e6691c04c9b021e429 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:21:14 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20S3=EC=99=80?= =?UTF-8?q?=20CloudWatch=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/observability.tf | 8 +++ infra/prod/storage.tf | 99 +++++++++++++++++++++++++++++++++++++ infra/prod/variables.tf | 18 +++++++ 3 files changed, 125 insertions(+) create mode 100644 infra/prod/observability.tf create mode 100644 infra/prod/storage.tf diff --git a/infra/prod/observability.tf b/infra/prod/observability.tf new file mode 100644 index 0000000..853b8c8 --- /dev/null +++ b/infra/prod/observability.tf @@ -0,0 +1,8 @@ +resource "aws_cloudwatch_log_group" "application" { + name = var.application_log_group_name + retention_in_days = 14 + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/storage.tf b/infra/prod/storage.tf new file mode 100644 index 0000000..ea0b063 --- /dev/null +++ b/infra/prod/storage.tf @@ -0,0 +1,99 @@ +resource "aws_s3_bucket" "production_assets" { + bucket = var.production_assets_bucket_name + force_destroy = false + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +data "aws_iam_policy_document" "production_preview_public_read" { + statement { + sid = "AllowPublicReadTtsAudio" + effect = "Allow" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:GetObject"] + + resources = [ + "arn:aws:s3:::${var.production_preview_bucket_name}/tts/previews/*", + "arn:aws:s3:::${var.production_preview_bucket_name}/tts/common/*", + ] + } +} + +resource "aws_s3_bucket" "production_preview" { + bucket = var.production_preview_bucket_name + force_destroy = false + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + block_public_acls = true + block_public_policy = false + ignore_public_acls = true + restrict_public_buckets = false +} + +resource "aws_s3_bucket_ownership_controls" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_policy" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + policy = data.aws_iam_policy_document.production_preview_public_read.json +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf index 74739f2..28e0dea 100644 --- a/infra/prod/variables.tf +++ b/infra/prod/variables.tf @@ -63,3 +63,21 @@ variable "rds_subnet_group_name" { description = "Existing default DB subnet group. Terraform must only reference it." default = "default-vpc-087c2d76939191694" } + +variable "production_assets_bucket_name" { + type = string + description = "Existing private production assets bucket." + default = "moru-prod-assets-488230509502" +} + +variable "production_preview_bucket_name" { + type = string + description = "Existing production TTS preview bucket." + default = "moru-prod-preview-assets-488230509502" +} + +variable "application_log_group_name" { + type = string + description = "Existing CloudWatch log group used by Docker awslogs." + default = "/moru/prod/app" +} From 4fa92abeb36065125a1541bf5bab12fca7afedb8 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:26:20 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20import=20=EC=A0=88=EC=B0=A8=EC=99=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../terraform-import-runbook.md | 109 +++++++++++++ infra/README.md | 9 +- infra/prod/backend.tf.example | 3 + infra/prod/iam.tf | 2 - infra/prod/imports.tf | 149 ++++++++++++++++++ infra/prod/rds.tf | 1 + infra/prod/versions.tf | 2 - 8 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 docs/infrastructure/terraform-import-runbook.md create mode 100644 infra/prod/backend.tf.example create mode 100644 infra/prod/imports.tf diff --git a/.gitignore b/.gitignore index c2451b0..fda3f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -223,6 +223,7 @@ replay_pid* *.tfvars !*.tfvars.example infra/prod/backend.hcl +infra/prod/backend.tf ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md new file mode 100644 index 0000000..466d286 --- /dev/null +++ b/docs/infrastructure/terraform-import-runbook.md @@ -0,0 +1,109 @@ +# Production Terraform Import Runbook + +## Purpose + +This runbook adopts existing Moru production AWS resources into Terraform +state. It must not create, update, replace, or delete an application resource. +The configuration uses declarative `import` blocks, so the import is reviewed +through the normal `plan` and `apply` workflow. + +## Preconditions + +1. The Terraform state bucket has been created from `infra/bootstrap` after a + separate approval. +2. The approved Terraform operator has the state-access policy created by the + bootstrap configuration, plus only the AWS permissions needed to manage the + imported resources. +3. The `moru_terraform` discovery user remains read-only. Do not use it for + import or apply. +4. No concurrent Terraform operation is running for prod. +5. The production service is healthy before beginning. Record the current + health result and do not restart Docker, Nginx, or EC2 as part of this work. + +## Import Order + +The import blocks in `infra/prod/imports.tf` cover the following existing +resources: + +1. EC2 security group and its seven rules +2. EC2 IAM role, two inline policies, and instance profile +3. EC2 instance and its Elastic IP +4. RDS security group and its three rules +5. RDS parameter group and MySQL instance +6. Private production assets S3 bucket and its security settings +7. Public TTS preview S3 bucket, its security settings, and its public-read + bucket policy +8. CloudWatch application log group + +The default VPC, default subnets, default DB subnet group, Docker Compose, +Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted files are not +imported in this phase. + +## Read-Only Validation + +The following commands do not write Terraform state and are safe to run with +the `moru_terraform` read-only profile after login: + +```bash +cd infra/prod +AWS_PROFILE=moru-prod terraform init -reconfigure +AWS_PROFILE=moru-prod terraform validate +AWS_PROFILE=moru-prod terraform plan -lock=false +``` + +The expected result is a plan containing imports only. Stop immediately when +the plan includes an action other than `import` for an existing resource. + +## Approved Import Procedure + +1. Use the approved Terraform operator profile and configure + `infra/prod/backend.tf` and `infra/prod/backend.hcl` from the committed + examples. +2. Initialize the remote state backend: + + ```bash + terraform init -reconfigure -backend-config=backend.hcl + ``` + +3. Produce a reviewable plan: + + ```bash + terraform plan -out=prod-import.tfplan -lock-timeout=5m + terraform show -no-color prod-import.tfplan + ``` + +4. Confirm every action is an import and no existing resource has a planned + update, replacement, or deletion. Obtain explicit approval for the exact + plan. +5. Apply the reviewed plan once: + + ```bash + terraform apply prod-import.tfplan + ``` + +6. Verify Terraform now reports no drift: + + ```bash + terraform plan -lock-timeout=5m + terraform state list + ``` + +7. Remove the local `prod-import.tfplan` file. It is ignored by Git and must + never be committed. + +## Stop Conditions + +Stop and investigate before an apply when any of the following occurs: + +- A plan proposes a create, change, replace, or destroy action for an existing + production resource. +- The AWS account check is not `488230509502`. +- The state bucket cannot be encrypted, versioned, or locked. +- A resource ID differs from `docs/infrastructure/prod-inventory.md`. +- The application health check fails before or during the activity. + +## Post-Import Follow-Up + +The open RDS and SSH network rules are documented security findings, not an +incidental Terraform migration change. Address them in a separate, reviewed +security issue with an explicit connectivity test and rollback procedure. diff --git a/infra/README.md b/infra/README.md index 7990fa3..d1005c0 100644 --- a/infra/README.md +++ b/infra/README.md @@ -50,9 +50,10 @@ S3 backend. ### Configure the production backend -1. Copy `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. +1. Copy `infra/prod/backend.tf.example` to `infra/prod/backend.tf` and copy + `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. 2. Replace the bucket name with the approved state bucket name. Do not commit - this file. + either file. 3. Run the following from `infra/prod`: ```bash @@ -63,6 +64,10 @@ S3 backend. The first production plan should show imports only. Any planned create, update, replacement, or destroy for an existing production resource is a stop signal. +Before the state bucket exists, leave `backend.tf` absent and use Terraform's +local backend only for read-only validation. Local state and plan files are +ignored by Git and must never be committed. + ## Non-Terraform Host Configuration Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted diff --git a/infra/prod/backend.tf.example b/infra/prod/backend.tf.example new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/infra/prod/backend.tf.example @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/infra/prod/iam.tf b/infra/prod/iam.tf index 1eb7589..58c5f46 100644 --- a/infra/prod/iam.tf +++ b/infra/prod/iam.tf @@ -13,7 +13,6 @@ data "aws_iam_policy_document" "moru_server_assume_role" { data "aws_iam_policy_document" "moru_dev_assets" { statement { - sid = "AccessMoruAssetBucket" effect = "Allow" actions = ["s3:ListBucket"] resources = ["arn:aws:s3:::moru-dev-assets-488230509502"] @@ -26,7 +25,6 @@ data "aws_iam_policy_document" "moru_dev_assets" { } statement { - sid = "ManageMoruAssetObjects" effect = "Allow" actions = [ "s3:DeleteObject", diff --git a/infra/prod/imports.tf b/infra/prod/imports.tf new file mode 100644 index 0000000..0711f30 --- /dev/null +++ b/infra/prod/imports.tf @@ -0,0 +1,149 @@ +import { + to = aws_security_group.moru_ec2 + id = var.ec2_security_group_id +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_ssh_ipv4 + id = "sgr-004e04b488a69a503" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_http_ipv4 + id = "sgr-01059589aaad7c6a1" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_http_ipv6 + id = "sgr-03bb52b4064251e44" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_https_ipv4 + id = "sgr-0a3f486457056be64" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_https_ipv6 + id = "sgr-0e861ece375b623f2" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_application_test + id = "sgr-0f864a686e6cd7576" +} + +import { + to = aws_vpc_security_group_egress_rule.moru_ec2_all_ipv4 + id = "sgr-0a32ac6e70b28569a" +} + +import { + to = aws_iam_role.moru_server + id = var.ec2_iam_role_name +} + +import { + to = aws_iam_role_policy.moru_dev_assets + id = "moru-server-s3-role:moru-dev-assets-policy" +} + +import { + to = aws_iam_role_policy.moru_prod_assets + id = "moru-server-s3-role:moru-prod-assets-policy" +} + +import { + to = aws_iam_instance_profile.moru_server + id = "moru-server-s3-role" +} + +import { + to = aws_instance.moru_server + id = var.ec2_instance_id +} + +import { + to = aws_eip.moru_server + id = var.ec2_eip_allocation_id +} + +import { + to = aws_security_group.moru_rds + id = var.rds_security_group_id +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_rds_all_ipv4 + id = "sgr-0c65f28523cfde22d" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_rds_all_ipv6 + id = "sgr-0d3eba8272521a11d" +} + +import { + to = aws_vpc_security_group_egress_rule.moru_rds_all_ipv4 + id = "sgr-010c3ce6d12b7f436" +} + +import { + to = aws_db_parameter_group.moru + id = "korean" +} + +import { + to = aws_db_instance.moru + id = var.rds_instance_identifier +} + +import { + to = aws_s3_bucket.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_public_access_block.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_ownership_controls.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_server_side_encryption_configuration.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_public_access_block.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_ownership_controls.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_server_side_encryption_configuration.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_policy.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_cloudwatch_log_group.application + id = var.application_log_group_name +} diff --git a/infra/prod/rds.tf b/infra/prod/rds.tf index 6a73d4d..64dbb45 100644 --- a/infra/prod/rds.tf +++ b/infra/prod/rds.tf @@ -97,6 +97,7 @@ resource "aws_db_instance" "moru" { backup_window = "16:19-16:49" maintenance_window = "sun:14:52-sun:15:22" apply_immediately = false + skip_final_snapshot = true lifecycle { prevent_destroy = true diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index 5bb76dd..758b18e 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -1,8 +1,6 @@ terraform { required_version = "~> 1.15.0" - backend "s3" {} - required_providers { aws = { source = "hashicorp/aws" From 8770d9ed08f486af8aa159096eebca81def2c5b7 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 16:49:42 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/infrastructure/terraform-import-runbook.md | 3 ++- infra/prod/data.tf | 16 ++++++++-------- infra/prod/versions.tf | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md index 466d286..32e15d2 100644 --- a/docs/infrastructure/terraform-import-runbook.md +++ b/docs/infrastructure/terraform-import-runbook.md @@ -42,7 +42,8 @@ imported in this phase. ## Read-Only Validation The following commands do not write Terraform state and are safe to run with -the `moru_terraform` read-only profile after login: +the `moru-prod` read-only profile after login (`moru_terraform` is the +read-only discovery user, not the profile name): ```bash cd infra/prod diff --git a/infra/prod/data.tf b/infra/prod/data.tf index 9afd242..f8bc470 100644 --- a/infra/prod/data.tf +++ b/infra/prod/data.tf @@ -1,4 +1,11 @@ -data "aws_caller_identity" "current" {} +data "aws_caller_identity" "current" { + lifecycle { + postcondition { + condition = self.account_id == var.aws_account_id + error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." + } + } +} data "aws_vpc" "existing" { id = var.vpc_id @@ -11,10 +18,3 @@ data "aws_subnet" "production_ec2" { data "aws_db_subnet_group" "existing" { name = var.rds_subnet_group_name } - -check "expected_aws_account" { - assert { - condition = data.aws_caller_identity.current.account_id == var.aws_account_id - error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." - } -} diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index 758b18e..9d1b4fd 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -10,5 +10,6 @@ terraform { } provider "aws" { - region = var.aws_region + region = var.aws_region + allowed_account_ids = ["488230509502"] } From 6052a1db0e33525e2b2bbffba2cd6a7760de5285 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 23 Sep 2026 17:42:20 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20Terraform=20state=20workspace=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B6=8C=ED=95=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/bootstrap/main.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf index 27664f2..5bc4b23 100644 --- a/infra/bootstrap/main.tf +++ b/infra/bootstrap/main.tf @@ -45,6 +45,8 @@ data "aws_iam_policy_document" "state_operator" { values = [ var.state_key, "${var.state_key}.tflock", + "env:/", + "env:/*", ] } } From 91bab9d98129146de52181ace5b1f49fa759b37f Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 23 Sep 2026 18:08:45 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20Terraform=20bootstrap=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=A0=9C=ED=95=9C=EA=B3=BC=20S3=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/bootstrap/main.tf | 2 -- infra/bootstrap/variables.tf | 6 ++++++ infra/bootstrap/versions.tf | 3 ++- infra/prod/storage.tf | 4 ---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf index 5bc4b23..fac967b 100644 --- a/infra/bootstrap/main.tf +++ b/infra/bootstrap/main.tf @@ -107,8 +107,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } } diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf index 7e5a7b5..8a2c621 100644 --- a/infra/bootstrap/variables.tf +++ b/infra/bootstrap/variables.tf @@ -4,6 +4,12 @@ variable "aws_region" { default = "ap-northeast-2" } +variable "aws_account_id" { + type = string + description = "AWS account that owns the production Terraform state bucket." + default = "488230509502" +} + variable "project" { type = string description = "Project tag for newly created backend resources." diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf index 758b18e..e5e6961 100644 --- a/infra/bootstrap/versions.tf +++ b/infra/bootstrap/versions.tf @@ -10,5 +10,6 @@ terraform { } provider "aws" { - region = var.aws_region + region = var.aws_region + allowed_account_ids = [var.aws_account_id] } diff --git a/infra/prod/storage.tf b/infra/prod/storage.tf index ea0b063..cba6460 100644 --- a/infra/prod/storage.tf +++ b/infra/prod/storage.tf @@ -31,8 +31,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "production_assets apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } } @@ -88,8 +86,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "production_previe apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } }