diff --git a/.gitignore b/.gitignore index 908bee1..fda3f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -215,6 +215,16 @@ fabric.properties hs_err_pid* replay_pid* +### Terraform ### +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.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 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 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. diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md new file mode 100644 index 0000000..ae4788c --- /dev/null +++ b/docs/infrastructure/terraform-import-runbook.md @@ -0,0 +1,128 @@ +# 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. + +## Completion Record + +The production import was completed on 2026-09-24 using the `moru-prod` AWS +profile in account `488230509502`. The reviewed plan contained 30 imports and +no additions, changes, or deletions. All 30 managed resources appeared in the +remote state afterward, and a fresh plan reported `No changes`. + +The backend is the private `moru-prod-terraform-state-488230509502` bucket, +with state at `prod/terraform.tfstate`. Do not reapply the saved import plan or +repeat this procedure against the populated state. For future changes, follow +the approval workflow in [infra/README.md](../../infra/README.md). + +## Historical Preconditions + +1. The Terraform state bucket has been created from `infra/bootstrap` after a + separate approval. +2. The `moru_terraform` IAM user has production read access and the dedicated + state-access policy. This allowed the import-only apply to update state + without changing the imported AWS resources. +3. Future infrastructure changes require separately approved, scoped AWS + write permissions; the state-access policy alone is not sufficient. +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. + +## Current Validation + +The following commands validate the configuration and check for drift using +the `moru-prod` profile after login: + +```bash +cd infra/prod +AWS_PROFILE=moru-prod terraform init -reconfigure -backend-config=backend.hcl +AWS_PROFILE=moru-prod terraform validate +AWS_PROFILE=moru-prod terraform plan -lock-timeout=5m +``` + +The expected result now is `No changes`. Any proposed create, update, +replacement, or deletion needs investigation and approval before an apply. + +## Historical Import Procedure (Completed) + +The steps below record the one-time migration. Do not run them again for this +production state. + +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. Keep the local `prod-import.tfplan` file out of Git and remove it when no + longer needed; saved plans can contain sensitive values. + +## 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 + +Keep an access-controlled backup of the local +`infra/bootstrap/terraform.tfstate`, which manages the state bucket itself. +The application deployment workflow does not run Terraform. + +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 new file mode 100644 index 0000000..ecd573c --- /dev/null +++ b/infra/README.md @@ -0,0 +1,53 @@ +# Terraform Infrastructure + +`bootstrap` created the dedicated S3 backend and retains local state. `prod` +manages the 30 existing Moru production AWS resources imported on 2026-09-24. +The post-import plan reported 0 to add, 0 to change, and 0 to destroy. See the +[import runbook](../docs/infrastructure/terraform-import-runbook.md) for the +original scope and verification record. + +## State and Access + +The production state is stored in the private +`moru-prod-terraform-state-488230509502` bucket at `prod/terraform.tfstate`. +The bucket uses AES256 encryption, versioning, public-access blocking, and S3 +lockfiles. Keep state, plan files, and local backend configuration files out of +Git; state and plans may contain sensitive values. The local +`infra/bootstrap/terraform.tfstate` is also sensitive and must be backed up in +an access-controlled location outside the repository. + +The `moru-prod` AWS CLI profile uses the `moru_terraform` IAM user. It has +production read access and the dedicated state-bucket policy, but not general +write access to EC2, RDS, or application S3 buckets. Grant only the specific +AWS write permissions required for an approved infrastructure change. Never +use the root account for Terraform operations. + +On a new workstation, copy `infra/prod/backend.tf.example` to +`infra/prod/backend.tf` and `infra/prod/backend.hcl.example` to +`infra/prod/backend.hcl`. Set the approved bucket name in `backend.hcl`. Both +files are ignored by Git. Then initialize from `infra/prod` with +`AWS_PROFILE=moru-prod terraform init -reconfigure -backend-config=backend.hcl`. +Do not create another state bucket or repeat the production import. + +## Production Changes + +1. Change the Terraform configuration in a reviewed PR. Keep application + deployment and host configuration outside this change unless separately + approved. +2. From `infra/prod`, run `terraform fmt -check`, `terraform validate`, and + `AWS_PROFILE=moru-prod terraform plan -out=prod.tfplan -lock-timeout=5m`. + Review the exact plan, especially any replacement or deletion. Do not use + `-lock=false` for production changes. +3. After explicit approval and with scoped AWS write permissions, apply the + reviewed plan with `AWS_PROFILE=moru-prod terraform apply prod.tfplan`. +4. Run `AWS_PROFILE=moru-prod terraform plan -lock-timeout=5m` again and check + for `No changes`. Do not commit the saved plan. + +The existing GitHub Actions workflow builds and deploys the application; it +does not run Terraform. Terraform applies remain manual and approval-gated. + +## Outside Terraform + +The shared VPC, subnets, and DB subnet group are referenced as data sources. +Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted +files remain outside Terraform management in this phase. 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..fac967b --- /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", + "env:/", + "env:/*", + ] + } + } + + 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" + } + } +} + +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..8a2c621 --- /dev/null +++ b/infra/bootstrap/variables.tf @@ -0,0 +1,35 @@ +variable "aws_region" { + type = string + description = "AWS region for the Terraform state bucket." + 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." + 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..e5e6961 --- /dev/null +++ b/infra/bootstrap/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = "~> 1.15.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region + allowed_account_ids = [var.aws_account_id] +} 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/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/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..f8bc470 --- /dev/null +++ b/infra/prod/data.tf @@ -0,0 +1,20 @@ +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 +} + +data "aws_subnet" "production_ec2" { + id = var.ec2_subnet_id +} + +data "aws_db_subnet_group" "existing" { + name = var.rds_subnet_group_name +} 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..58c5f46 --- /dev/null +++ b/infra/prod/iam.tf @@ -0,0 +1,111 @@ +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 { + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::moru-dev-assets-488230509502"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["tts/*", "profiles/*"] + } + } + + statement { + 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/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/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/rds.tf b/infra/prod/rds.tf new file mode 100644 index 0000000..64dbb45 --- /dev/null +++ b/infra/prod/rds.tf @@ -0,0 +1,105 @@ +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 + skip_final_snapshot = true + + 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/storage.tf b/infra/prod/storage.tf new file mode 100644 index 0000000..cba6460 --- /dev/null +++ b/infra/prod/storage.tf @@ -0,0 +1,95 @@ +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" + } + } +} + +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" + } + } +} + +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 new file mode 100644 index 0000000..28e0dea --- /dev/null +++ b/infra/prod/variables.tf @@ -0,0 +1,83 @@ +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" +} + +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" +} + +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" +} diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf new file mode 100644 index 0000000..9d1b4fd --- /dev/null +++ b/infra/prod/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = "~> 1.15.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region + allowed_account_ids = ["488230509502"] +}