diff --git a/Makefile b/Makefile index 442fe92..67d89fa 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,10 @@ ENV_DIR := infra/environments/aws/prod CHECK_SCRIPT := infra/check.sh MANAGEMENT_PORT := 8081 INSTANCE_TAG := chaeso-zip-vm +ASSET_DOMAIN := assets.chaeso-zip.com .DEFAULT_GOAL := help -.PHONY: help bootstrap up down status redeploy health check ps logs ssh _profile +.PHONY: help bootstrap up down status redeploy health check ps logs ssh upload-asset upload-assets _profile # 접속 헬퍼 ssm_target = $$(aws ec2 describe-instances \ @@ -39,6 +40,9 @@ help: @echo " make check 컨테이너/DB/볼륨 종합 점검" @echo " make ps 컨테이너 상태" @echo " make logs 실시간 로그" + @echo " make upload-asset FILE=./logo.png [KEY=channels/xxx-logo.png] 공개 자산 1개(로고, 프로필 등)" + @echo " make upload-asset FILE=\"./a.png ./b.png ./c.png\" [PREFIX=channels/] 공개 자산 여러 개" + @echo " make upload-assets DIR=./logos [PREFIX=channels/] 공개 자산 폴더 전체 업로드" @echo "" @echo "운영 (Terraform):" @echo " make bootstrap state 버킷 생성(최초 1회)" @@ -110,3 +114,22 @@ logs: _profile ssh: _profile $(ssh_to) + +upload-asset: _profile + @test -n "$(FILE)" || { echo "사용법: make upload-asset FILE=\"./a.png ./b.png\" [PREFIX=channels/] [KEY=1개일 때만]"; exit 1; } + @if [ $(words $(FILE)) -gt 1 ] && [ -n "$(KEY)" ]; then \ + echo "파일이 여러 개면 KEY 대신 PREFIX를 쓰세요"; exit 1; \ + fi + @bucket=$$(cd $(ENV_DIR) && terraform output -raw public_bucket_name) && \ + for f in $(FILE); do \ + key=$${KEY:-$(PREFIX)$$(basename "$$f")}; \ + aws s3 cp "$$f" "s3://$$bucket/$$key" --cache-control "public, max-age=300, must-revalidate" && \ + echo "✅ 업로드 완료 → https://$(ASSET_DOMAIN)/$$key (raw: https://$$bucket.s3.$(REGION).amazonaws.com/$$key)"; \ + done + +upload-assets: _profile + @test -n "$(DIR)" || { echo "사용법: make upload-assets DIR=./logos [PREFIX=channels/]"; exit 1; } + @bucket=$$(cd $(ENV_DIR) && terraform output -raw public_bucket_name) && \ + prefix="$(PREFIX)" && \ + aws s3 sync "$(DIR)" "s3://$$bucket/$$prefix" --cache-control "public, max-age=300, must-revalidate" && \ + echo "✅ 전체 업로드 완료 → https://$(ASSET_DOMAIN)/$$prefix (raw: https://$$bucket.s3.$(REGION).amazonaws.com/$$prefix)" diff --git a/docs/infra.md b/docs/infra.md index 22d84ab..163f1c5 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -18,7 +18,8 @@ infra/ ├── network/ # VPC/subnet/IGW/SG └── app/ # EC2/EIP/EBS/instance profile + startup-script ├── schedule.tf # EventBridge Scheduler 야간 자동 정지/기동 - └── s3-ad-history.tf # 온보딩 광고 이력 업로드용 S3 (§5) + ├── s3-ad-history.tf # 온보딩 광고 이력 업로드용 S3 (private, presigned) + └── s3-public.tf # 공개 정적 자산용 S3 (§5) ``` --- @@ -85,6 +86,8 @@ make ps # 컨테이너 상태 | `make check` | 컨테이너/DB/볼륨 종합 점검 | | `make ps` | 컨테이너 상태 | | `make logs` | 실시간 로그 | +| `make upload-asset FILE=./logo.png [KEY=channels/xxx.png]` | 공개 자산 업로드(1개/여러 개, §5) | +| `make upload-assets DIR=./logos [PREFIX=channels/]` | 공개 자산 폴더 전체 업로드 (§5) | --- @@ -100,4 +103,15 @@ make ps # 컨테이너 상태 | `chaeso-zip-stop-night` | `0 0 * * ? *` (매일 00:00) | `StopInstances` (Force=false, graceful) | | `chaeso-zip-start-morning` | `0 9 * * ? *` (매일 09:00) | `StartInstances` | +--- + +## 5. 공개 static S3 버킷 + +- 채널 로고, 프로필 이미지 등 공개 이미지 저장용 버킷입니다. +- 버킷명 입력 없이 — `make upload-asset`/`upload-assets`로 파일을 업로드 가능합니다. +- 버킷은 하나만 두고 `channels/.png`, `users/.png`처럼 키 prefix로 용도를 구분합니다. +- 서빙 도메인은 `https://assets.chaeso-zip.com`입니다. + +상세 코드는 `../infra/modules/aws/app/s3-public.tf`를 참고해주세요. + --- \ No newline at end of file diff --git a/infra/environments/aws/prod/main.tf b/infra/environments/aws/prod/main.tf index 6e816e2..ff8d8e1 100644 --- a/infra/environments/aws/prod/main.tf +++ b/infra/environments/aws/prod/main.tf @@ -58,3 +58,7 @@ output "public_ip" { output "ad_history_bucket_name" { value = module.app.ad_history_bucket_name } + +output "public_bucket_name" { + value = module.app.public_bucket_name +} diff --git a/infra/environments/aws/prod/team-access.tf b/infra/environments/aws/prod/team-access.tf index 0048e05..9c546a5 100644 --- a/infra/environments/aws/prod/team-access.tf +++ b/infra/environments/aws/prod/team-access.tf @@ -41,4 +41,25 @@ resource "aws_iam_user_policy_attachment" "teammate_ssm" { count = var.teammate_username != "" ? 1 : 0 user = aws_iam_user.teammate[0].name policy_arn = aws_iam_policy.ssm_shell.arn +} + +resource "aws_iam_user_policy" "teammate_public_s3" { + count = var.teammate_username != "" ? 1 : 0 + name = "chaeso-zip-teammate-public-s3" + user = aws_iam_user.teammate[0].name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "s3:ListBucket" + Resource = "arn:aws:s3:::${module.app.public_bucket_name}" + }, + { + Effect = "Allow" + Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"] + Resource = "arn:aws:s3:::${module.app.public_bucket_name}/*" + }, + ] + }) } \ No newline at end of file diff --git a/infra/modules/aws/app/main.tf b/infra/modules/aws/app/main.tf index 61c34b9..2571de1 100644 --- a/infra/modules/aws/app/main.tf +++ b/infra/modules/aws/app/main.tf @@ -122,7 +122,9 @@ resource "aws_instance" "this" { tags = { Name = "${var.name_prefix}-vm" } root_block_device { - encrypted = true + encrypted = true + volume_size = 30 + volume_type = "gp3" } lifecycle { diff --git a/infra/modules/aws/app/outputs.tf b/infra/modules/aws/app/outputs.tf index a9aff1b..61255ba 100644 --- a/infra/modules/aws/app/outputs.tf +++ b/infra/modules/aws/app/outputs.tf @@ -5,3 +5,7 @@ output "public_ip" { output "ad_history_bucket_name" { value = aws_s3_bucket.ad_history.bucket } + +output "public_bucket_name" { + value = aws_s3_bucket.public.bucket +} diff --git a/infra/modules/aws/app/s3-ad-history.tf b/infra/modules/aws/app/s3-ad-history.tf index 353b8e8..cdca7a4 100644 --- a/infra/modules/aws/app/s3-ad-history.tf +++ b/infra/modules/aws/app/s3-ad-history.tf @@ -50,7 +50,7 @@ resource "aws_s3_bucket_policy" "s3_access_logs" { Action = "s3:PutObject" Resource = "${aws_s3_bucket.s3_access_logs.arn}/*" Condition = { - ArnLike = { "aws:SourceArn" = aws_s3_bucket.ad_history.arn } + ArnLike = { "aws:SourceArn" = [aws_s3_bucket.ad_history.arn, aws_s3_bucket.public.arn] } StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id } } }, diff --git a/infra/modules/aws/app/s3-public.tf b/infra/modules/aws/app/s3-public.tf new file mode 100644 index 0000000..9b81971 --- /dev/null +++ b/infra/modules/aws/app/s3-public.tf @@ -0,0 +1,50 @@ +resource "aws_s3_bucket" "public" { + bucket = "${var.name_prefix}-public-${data.aws_caller_identity.current.account_id}" +} + +resource "aws_s3_bucket_logging" "public" { + bucket = aws_s3_bucket.public.id + target_bucket = aws_s3_bucket.s3_access_logs.id + target_prefix = "public/" +} + +resource "aws_s3_bucket_public_access_block" "public" { # NOSONAR + bucket = aws_s3_bucket.public.id + + # public read-only bucket for static assets (logos, profile images); write access is IAM-gated + block_public_acls = true + block_public_policy = false + ignore_public_acls = true + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "public" { + bucket = aws_s3_bucket.public.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "PublicReadGetObject" + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = "${aws_s3_bucket.public.arn}/*" + }, + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.public.arn, + "${aws_s3_bucket.public.arn}/*", + ] + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + }, + ] + }) + + depends_on = [aws_s3_bucket_public_access_block.public] +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 3eb29c5..dc1e67f 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -64,5 +64,4 @@ app: onboarding: s3: bucket: test-onboarding-bucket - region: ap-northeast-2 presign-ttl: PT5M