From 31b920b4f8b99e51a2a41e79842a778666b65674 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 18 Oct 2026 17:02:52 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=EA=B2=80=EC=A6=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EC=A0=84=20=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=EA=B3=BC=20lint=20=EA=B2=BD=EA=B3=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitattributes 를 전체 LF 기준으로 정비 (확장자 없는 스크립트, systemd unit 포함) - terraform fmt 적용 (공백만 변경, plan 변경 없음) - 의도된 패턴의 shellcheck 경고에 사유와 함께 disable 주석 추가 (동작 변경 없음) Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 11 +++++++---- bootstrap/provider.tf | 4 ++-- environment/global/main.tf | 16 ++++++++-------- environment/global/variables.tf | 22 +++++++++++----------- modules/shared_resources/cloudfront.tf | 22 +++++++++++----------- modules/shared_resources/lambda.tf | 10 +++++----- modules/shared_resources/variables.tf | 22 +++++++++++----------- scripts/mysql_backup/install.sh | 2 ++ scripts/mysql_backup/lib/backup-common.sh | 3 +++ scripts/mysql_backup/validate-remote.sh | 2 ++ 10 files changed, 62 insertions(+), 52 deletions(-) diff --git a/.gitattributes b/.gitattributes index 07ade0c..0a6604a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,7 @@ -*.sh text eol=lf -*.tftpl text eol=lf -*.yml text eol=lf -*.yaml text eol=lf +# 모든 텍스트 파일은 OS와 무관하게 LF로 checkout 한다. +# 확장자 없는 스크립트(scripts/mysql_backup/bin/*)와 systemd unit 도 서버에서 그대로 실행되므로 CRLF 가 섞이면 안 된다. +* text=auto eol=lf + +*.png binary +*.jpg binary +*.zip binary diff --git a/bootstrap/provider.tf b/bootstrap/provider.tf index eec93f5..abc3ed3 100644 --- a/bootstrap/provider.tf +++ b/bootstrap/provider.tf @@ -22,8 +22,8 @@ provider "aws" { default_tags { tags = { - Env = "bootstrap" - Project = "solid-connection" + Env = "bootstrap" + Project = "solid-connection" ManagedBy = "terraform" } } diff --git a/environment/global/main.tf b/environment/global/main.tf index 5701eb6..57124a1 100644 --- a/environment/global/main.tf +++ b/environment/global/main.tf @@ -2,16 +2,16 @@ module "shared_resources" { source = "../../modules/shared_resources" providers = { - aws = aws + aws = aws } - s3_upload_bucket_name = var.s3_upload_bucket_name + s3_upload_bucket_name = var.s3_upload_bucket_name - resizing_img_func_name = var.resizing_img_func_name - resizing_img_func_role = var.resizing_img_func_role - resizing_img_func_handler = var.resizing_img_func_handler - resizing_img_func_runtime = var.resizing_img_func_runtime - resizing_img_func_layers = var.resizing_img_func_layers + resizing_img_func_name = var.resizing_img_func_name + resizing_img_func_role = var.resizing_img_func_role + resizing_img_func_handler = var.resizing_img_func_handler + resizing_img_func_runtime = var.resizing_img_func_runtime + resizing_img_func_layers = var.resizing_img_func_layers thumbnail_generating_func_name = var.thumbnail_generating_func_name thumbnail_generating_func_role = var.thumbnail_generating_func_role @@ -19,5 +19,5 @@ module "shared_resources" { thumbnail_generating_func_runtime = var.thumbnail_generating_func_runtime thumbnail_generating_func_layers = var.thumbnail_generating_func_layers - upload_cdn_web_acl_id = var.upload_cdn_web_acl_id + upload_cdn_web_acl_id = var.upload_cdn_web_acl_id } diff --git a/environment/global/variables.tf b/environment/global/variables.tf index b994d24..1c2161a 100644 --- a/environment/global/variables.tf +++ b/environment/global/variables.tf @@ -7,55 +7,55 @@ variable "s3_upload_bucket_name" { # [Lambda 관련 변수] variable "resizing_img_func_name" { description = "Image Resizing function name for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_role" { description = "Image Resizing function role for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_handler" { description = "Image Resizing function handler for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_runtime" { description = "Image Resizing function runtime for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_name" { description = "Thumbnail generating function name for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_role" { description = "Thumbnail generating function role for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_handler" { description = "Thumbnail generating function handler for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_runtime" { description = "Thumbnail generating function runtime for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_layers" { description = "Layers For Image Resizing func" - type = list(string) + type = list(string) } variable "thumbnail_generating_func_layers" { description = "Layers For Image Resizing func" - type = list(string) + type = list(string) } variable "upload_cdn_web_acl_id" { description = "WAF Web ACL Id for Upload Cloudfront CDN" - type = string + type = string } diff --git a/modules/shared_resources/cloudfront.tf b/modules/shared_resources/cloudfront.tf index 0fb1d95..691b39f 100644 --- a/modules/shared_resources/cloudfront.tf +++ b/modules/shared_resources/cloudfront.tf @@ -14,13 +14,13 @@ resource "aws_cloudfront_origin_access_control" "upload_oac" { # 2. CDN for Upload Bucket resource "aws_cloudfront_distribution" "upload_cdn" { - enabled = true - is_ipv6_enabled = true - comment = "solid-connection s3 upload cloudfront" - price_class = "PriceClass_All" - http_version = "http2" + enabled = true + is_ipv6_enabled = true + comment = "solid-connection s3 upload cloudfront" + price_class = "PriceClass_All" + http_version = "http2" - web_acl_id = var.upload_cdn_web_acl_id + web_acl_id = var.upload_cdn_web_acl_id tags = { "Name" = "solid-connection s3 upload cloudfront" @@ -33,8 +33,8 @@ resource "aws_cloudfront_distribution" "upload_cdn" { origin_id = "S3-${var.s3_upload_bucket_name}" origin_access_control_id = aws_cloudfront_origin_access_control.upload_oac.id - connection_attempts = 3 - connection_timeout = 10 + connection_attempts = 3 + connection_timeout = 10 } default_cache_behavior { @@ -42,10 +42,10 @@ resource "aws_cloudfront_distribution" "upload_cdn" { viewer_protocol_policy = "redirect-to-https" compress = true - allowed_methods = ["GET", "HEAD"] - cached_methods = ["GET", "HEAD"] + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] - cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" smooth_streaming = false } diff --git a/modules/shared_resources/lambda.tf b/modules/shared_resources/lambda.tf index 94420a8..ec8a126 100644 --- a/modules/shared_resources/lambda.tf +++ b/modules/shared_resources/lambda.tf @@ -18,11 +18,11 @@ resource "aws_lambda_function" "resizing_img_func" { handler = var.resizing_img_func_handler runtime = var.resizing_img_func_runtime - filename = data.archive_file.resizing_zip.output_path + filename = data.archive_file.resizing_zip.output_path source_code_hash = data.archive_file.resizing_zip.output_base64sha256 - layers = var.resizing_img_func_layers - timeout = 15 + layers = var.resizing_img_func_layers + timeout = 15 } resource "aws_lambda_function" "thumbnail_generating_func" { @@ -34,8 +34,8 @@ resource "aws_lambda_function" "thumbnail_generating_func" { filename = data.archive_file.thumbnail_zip.output_path source_code_hash = data.archive_file.thumbnail_zip.output_base64sha256 - layers = var.thumbnail_generating_func_layers - timeout = 15 + layers = var.thumbnail_generating_func_layers + timeout = 15 } # 3. Lambda Privileges Definition diff --git a/modules/shared_resources/variables.tf b/modules/shared_resources/variables.tf index 8cb35ab..fcd00fc 100644 --- a/modules/shared_resources/variables.tf +++ b/modules/shared_resources/variables.tf @@ -7,55 +7,55 @@ variable "s3_upload_bucket_name" { # [Lambda 관련 변수] variable "resizing_img_func_name" { description = "Image Resizing function name for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_role" { description = "Image Resizing function role for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_handler" { description = "Image Resizing function handler for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_runtime" { description = "Image Resizing function runtime for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_name" { description = "Thumbnail generating function name for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_role" { description = "Thumbnail generating function role for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_handler" { description = "Thumbnail generating function handler for uploaded s3 file" - type = string + type = string } variable "thumbnail_generating_func_runtime" { description = "Thumbnail generating function runtime for uploaded s3 file" - type = string + type = string } variable "resizing_img_func_layers" { description = "Layers For Image Resizing func" - type = list(string) + type = list(string) } variable "thumbnail_generating_func_layers" { description = "Layers For Thumbnail Generating func" - type = list(string) + type = list(string) } variable "upload_cdn_web_acl_id" { description = "WAF Web ACL Id for Upload Cloudfront CDN" - type = string + type = string } diff --git a/scripts/mysql_backup/install.sh b/scripts/mysql_backup/install.sh index f82acff..21135e7 100755 --- a/scripts/mysql_backup/install.sh +++ b/scripts/mysql_backup/install.sh @@ -81,6 +81,8 @@ load_candidate_config() { case "$key" in MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION|ALARM_API_HOST|ALARM_API_PORTS|ALARM_API_HEALTH_PORTS|ALARM_API_TOKEN) printf -v "$key" '%s' "$value" + # key 는 위 case 에서 허용한 변수 이름이며, 그 이름의 변수를 export 하는 것이 의도다. + # shellcheck disable=SC2163 export "$key" ;; *) diff --git a/scripts/mysql_backup/lib/backup-common.sh b/scripts/mysql_backup/lib/backup-common.sh index 43436bc..54516b8 100644 --- a/scripts/mysql_backup/lib/backup-common.sh +++ b/scripts/mysql_backup/lib/backup-common.sh @@ -2,7 +2,10 @@ set -Eeuo pipefail readonly BACKUP_ROOT="${MYSQL_BACKUP_ROOT:-/mnt/mysql-data/mysql-backup}" +# STAGING_DIR / STATE_DIR 은 이 파일을 source 하는 bin/* 스크립트에서 사용한다. +# shellcheck disable=SC2034 readonly STAGING_DIR="$BACKUP_ROOT/staging" +# shellcheck disable=SC2034 readonly STATE_DIR="$BACKUP_ROOT/state" readonly MYSQL_DATA_DIR="${MYSQL_DATA_DIR:-/mnt/mysql-data/mysql}" readonly MYSQL_CONTAINER="${MYSQL_CONTAINER:-mysql-server}" diff --git a/scripts/mysql_backup/validate-remote.sh b/scripts/mysql_backup/validate-remote.sh index a1c7344..25ef27d 100755 --- a/scripts/mysql_backup/validate-remote.sh +++ b/scripts/mysql_backup/validate-remote.sh @@ -18,6 +18,8 @@ if [[ -x "$VALIDATE_BIN" && -f "$CONFIG_FILE" ]]; then case "$key" in MYSQL_BACKUP_BUCKET|MYSQL_DATABASE|AWS_REGION|ALARM_API_HOST|ALARM_API_PORTS|ALARM_API_HEALTH_PORTS|ALARM_API_TOKEN) printf -v "$key" '%s' "$value" + # key 는 위 case 에서 허용한 변수 이름이며, 그 이름의 변수를 export 하는 것이 의도다. + # shellcheck disable=SC2163 export "$key" ;; *) From 803bbfde6dce5fd7df391a75740b4e51142edc16 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 18 Oct 2026 17:02:52 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20GitHubActionsTerraformRole=20trust?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20AWS=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EC=9D=BC=EC=B9=98=EC=8B=9C?= =?UTF-8?q?=ED=82=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS 에는 mysql-backup-deploy.yml (environment: prod-db) 용 sub 조건이 수동으로 추가돼 있으나 코드에 없어, bootstrap 이 apply 되면 해당 조건이 삭제되어 MySQL Backup Deploy 가 실패하는 drift 를 해결한다. 변경 후 bootstrap plan 은 변경 없음. Co-Authored-By: Claude Opus 5.5 --- bootstrap/iam.tf | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bootstrap/iam.tf b/bootstrap/iam.tf index 5b8901f..200b945 100644 --- a/bootstrap/iam.tf +++ b/bootstrap/iam.tf @@ -55,7 +55,11 @@ resource "aws_iam_role" "github_actions" { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { - "token.actions.githubusercontent.com:sub" = "repo:solid-connection/solid-connection-infra:ref:refs/heads/main" + # environment:prod-db 는 mysql-backup-deploy.yml (environment: prod-db) 가 같은 Role 을 사용하기 위한 조건이다. + "token.actions.githubusercontent.com:sub" = [ + "repo:solid-connection/solid-connection-infra:ref:refs/heads/main", + "repo:solid-connection/solid-connection-infra:environment:prod-db", + ] } } }] From b9e04dee7bde53c1678affc1e28a2f6ca6bb175b Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 18 Oct 2026 17:02:52 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20AI=20=EA=B0=9C=EB=B0=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=AC=BC=20=EA=B2=80=EC=A6=9D=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8(L1~L4)=EA=B3=BC=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=20=EB=82=B4=EC=9E=A5=20verify=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=EC=B6=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/verify: Docker 기반 검증 진입점 (이미지 자동 확인/빌드, 레포 읽기 전용 마운트) - L1 정적 검사: fmt, validate, tflint, trivy, shellcheck, actionlint, 스킬 사본 동기화 - L2 plan 검사: 리소스 단위 변경 목록, 정책 규칙 구조, 의도(expect) 대조 - L3 동작 테스트: 템플릿 inventory/렌더링 후 shellcheck, 스크립트 단위 테스트 - CI: verify.yml (L1+L3), terraform-plan.yml 에 변경 목록/정책/PR 본문 의도 대조 및 load_test plan 추가 - .claude/.agents verify 스킬, AGENTS.md, CLAUDE.md L4 런타임 검증 지침, PR 템플릿 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/verify/SKILL.md | 76 +++++ .claude/skills/verify/SKILL.md | 76 +++++ .coderabbit.yaml | 3 +- .github/pull_request_template.md | 23 ++ .github/workflows/terraform-plan.yml | 260 +++++++++++++++++- .github/workflows/verify.yml | 40 +++ .gitignore | 3 + .trivyignore.yaml | 62 +++++ AGENTS.md | 18 ++ CLAUDE.md | 60 +++- scripts/verify/README.md | 81 ++++++ .../verify/baseline/rendered-shellcheck.txt | 9 + scripts/verify/docker/Dockerfile | 106 +++++++ scripts/verify/docker/tflint.hcl | 38 +++ scripts/verify/l1.sh | 61 ++++ scripts/verify/l2.sh | 158 +++++++++++ scripts/verify/l3.sh | 93 +++++++ scripts/verify/lib/common.sh | 98 +++++++ scripts/verify/plan_check.py | 199 ++++++++++++++ scripts/verify/policies/README.md | 31 +++ scripts/verify/render/templates.tf | 67 +++++ scripts/verify/tests/test_plan_check.py | 159 +++++++++++ scripts/verify/verify.sh | 233 ++++++++++++++++ 23 files changed, 1937 insertions(+), 17 deletions(-) create mode 100644 .agents/skills/verify/SKILL.md create mode 100644 .claude/skills/verify/SKILL.md create mode 100644 .github/workflows/verify.yml create mode 100644 .trivyignore.yaml create mode 100644 AGENTS.md create mode 100644 scripts/verify/README.md create mode 100644 scripts/verify/baseline/rendered-shellcheck.txt create mode 100644 scripts/verify/docker/Dockerfile create mode 100644 scripts/verify/docker/tflint.hcl create mode 100755 scripts/verify/l1.sh create mode 100755 scripts/verify/l2.sh create mode 100755 scripts/verify/l3.sh create mode 100644 scripts/verify/lib/common.sh create mode 100755 scripts/verify/plan_check.py create mode 100644 scripts/verify/policies/README.md create mode 100644 scripts/verify/render/templates.tf create mode 100644 scripts/verify/tests/test_plan_check.py create mode 100755 scripts/verify/verify.sh diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md new file mode 100644 index 0000000..b3ad5f1 --- /dev/null +++ b/.agents/skills/verify/SKILL.md @@ -0,0 +1,76 @@ +--- +name: verify +description: solid-connection-infra 의 인프라 변경(Terraform, user_data 템플릿, 스크립트, 워크플로우)을 L1 정적 검사 / L2 plan 검사 / L3 동작 테스트로 검증하고, 실패하면 고쳐서 다시 검증한다. 코드를 수정한 뒤 커밋/PR 전에, 또는 사용자가 검증을 요청할 때 사용한다. +--- + +# 인프라 변경 검증 (L1 ~ L3) + +이 레포의 변경은 사람이 한 줄씩 읽는 대신 **기계가 판단할 수 있는 것은 전부 이 스킬로 검증**하고, +사람은 압축된 근거(검증 결과 + plan 변경 목록 + L4 체크리스트)만 보고 의도를 판단한다. + +실제 검증 로직은 `scripts/verify/` 에 있고 CI 도 같은 스크립트를 쓴다. 이 스킬은 실행 순서와 대응 방법만 정한다. +상세 설명: `scripts/verify/README.md` + +> 이 파일의 원본은 `.claude/skills/verify/SKILL.md` 이고, `.agents/skills/verify/SKILL.md` 는 다른 Code Agent 용 동일 사본이다. +> 수정할 때는 원본을 고친 뒤 사본에 그대로 복사한다. (L1 `skill copies in sync` 검사가 두 파일이 같은지 확인한다) + +## 절차 + +### 1. 작업 전: 의도 선언 + +코드를 고치기 전에 plan 에 나와야 할 리소스 변경을 `.verify/expect.txt` 에 적는다. (gitignore 대상) +같은 내용을 PR 본문의 `verify-expect` 코드 블록에도 그대로 옮긴다. **CI 는 PR 본문의 블록으로 의도를 대조하고, 블록이 없으면 plan job 이 실패한다.** + +```text +# +prod update module.prod_stack.aws_instance.api_server +``` + +- 교체(replace)가 예상되면 반드시 `replace` 로 적는다. `update` 로 적으면 실제 교체를 놓친다. +- 리소스 변경이 없어야 하는 작업(스크립트 수정, 리팩터링 등)이라면 빈 파일(빈 블록)을 둔다. 그러면 "변경 없음"이 검증된다. +- 환경 줄이 없는 환경은 "변경 없음"을 의도한 것으로 본다. +- load_test 는 평소 내려가 있어 plan 이 전체 create 로 나온다. load_test 를 건드리는 작업이면 `load_test create *` 처럼 선언한다. + +### 2. 구현 후: L1 + L3 (항상) + +```bash +scripts/verify/verify.sh +``` + +`FAIL` 이 있으면 출력된 로그를 보고 고친 뒤 다시 실행한다. 모두 `PASS` 가 될 때까지 반복한다. + +- `terraform fmt` 실패: 호스트의 terraform 버전이 다를 수 있으므로, 검증 이미지로 포맷한다. + `docker run --rm -v "$PWD:/workspace" -w /workspace "$(scripts/verify/verify.sh --print-image)" terraform fmt -recursive bootstrap environment modules scripts` +- 새 `.tftpl` 을 추가했거나 템플릿 변수를 바꿨다면 `scripts/verify/render/templates.tf` 도 갱신한다. (빠뜨리면 L3 `template inventory` 가 실패한다) + +### 3. Terraform 변경이 있으면: L2 + +```bash +scripts/verify/verify.sh l2 --expect .verify/expect.txt +``` + +- 대상 환경은 origin/main 대비 변경 파일로 자동 감지된다. (`--env` 로 직접 지정할 수도 있다.) +- `intent` 가 `FAIL` 이면: + - **의도에 없는 변경**: 코드가 의도와 다르게 동작하는 것이다. 코드를 고친다. 의도 파일을 실제 plan 에 맞춰 고치지 않는다. + - **의도했으나 plan 에 없음**: 구현이 빠졌거나, 의도 자체가 잘못된 것이다. 의도가 틀렸다면 사용자에게 설명하고 고친다. +- plan 전문이 필요하면 `.verify/l2/.plan.txt` 를 읽는다. +- `delete` 또는 `replace` 가 있으면, 의도한 것이더라도 사용자에게 명시적으로 알린다. + +### 4. 결과 보고 + +PR 본문(또는 사용자에게 보내는 보고)에 아래 내용을 남긴다. + +- 의도 블록 (```` ```verify-expect ```` — PR 템플릿에 자리가 있다) +- 실행한 계층과 요약 (`PASS n / FAIL n / SKIPPED n`) +- **SKIPPED 항목과 사유.** 검증하지 못한 것을 검증한 것처럼 쓰지 않는다. +- L2 리소스 변경 목록 (delete/replace 는 강조) +- L4 런타임 확인 체크리스트 (CLAUDE.md 의 "L4 런타임 검증" 참고) + +## 하지 말 것 + +- **검사를 통과시키려고 검증 정책을 수정하지 않는다.** 아래 파일은 사람이 별도 PR 로 관리한다. + `scripts/verify/docker/tflint.hcl`, `.trivyignore.yaml`, `scripts/verify/baseline/*`, `scripts/verify/policies/*` + 정책 때문에 막혔고 그 정책이 틀렸다고 판단되면, 수정하지 말고 사용자에게 사유와 함께 보고한다. +- `SKIPPED` 를 `PASS` 로 보고하지 않는다. +- user_data 템플릿(`*.tftpl`)을 lint 경고만 보고 고치지 않는다. 템플릿이 바뀌면 plan 에 인스턴스 변경이 생긴다. 고칠 필요가 있다면 L2 로 영향을 확인하고 사용자에게 알린다. +- `terraform apply` 는 실행하지 않는다. (apply 는 GitHub Actions 전용) diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..b3ad5f1 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,76 @@ +--- +name: verify +description: solid-connection-infra 의 인프라 변경(Terraform, user_data 템플릿, 스크립트, 워크플로우)을 L1 정적 검사 / L2 plan 검사 / L3 동작 테스트로 검증하고, 실패하면 고쳐서 다시 검증한다. 코드를 수정한 뒤 커밋/PR 전에, 또는 사용자가 검증을 요청할 때 사용한다. +--- + +# 인프라 변경 검증 (L1 ~ L3) + +이 레포의 변경은 사람이 한 줄씩 읽는 대신 **기계가 판단할 수 있는 것은 전부 이 스킬로 검증**하고, +사람은 압축된 근거(검증 결과 + plan 변경 목록 + L4 체크리스트)만 보고 의도를 판단한다. + +실제 검증 로직은 `scripts/verify/` 에 있고 CI 도 같은 스크립트를 쓴다. 이 스킬은 실행 순서와 대응 방법만 정한다. +상세 설명: `scripts/verify/README.md` + +> 이 파일의 원본은 `.claude/skills/verify/SKILL.md` 이고, `.agents/skills/verify/SKILL.md` 는 다른 Code Agent 용 동일 사본이다. +> 수정할 때는 원본을 고친 뒤 사본에 그대로 복사한다. (L1 `skill copies in sync` 검사가 두 파일이 같은지 확인한다) + +## 절차 + +### 1. 작업 전: 의도 선언 + +코드를 고치기 전에 plan 에 나와야 할 리소스 변경을 `.verify/expect.txt` 에 적는다. (gitignore 대상) +같은 내용을 PR 본문의 `verify-expect` 코드 블록에도 그대로 옮긴다. **CI 는 PR 본문의 블록으로 의도를 대조하고, 블록이 없으면 plan job 이 실패한다.** + +```text +# +prod update module.prod_stack.aws_instance.api_server +``` + +- 교체(replace)가 예상되면 반드시 `replace` 로 적는다. `update` 로 적으면 실제 교체를 놓친다. +- 리소스 변경이 없어야 하는 작업(스크립트 수정, 리팩터링 등)이라면 빈 파일(빈 블록)을 둔다. 그러면 "변경 없음"이 검증된다. +- 환경 줄이 없는 환경은 "변경 없음"을 의도한 것으로 본다. +- load_test 는 평소 내려가 있어 plan 이 전체 create 로 나온다. load_test 를 건드리는 작업이면 `load_test create *` 처럼 선언한다. + +### 2. 구현 후: L1 + L3 (항상) + +```bash +scripts/verify/verify.sh +``` + +`FAIL` 이 있으면 출력된 로그를 보고 고친 뒤 다시 실행한다. 모두 `PASS` 가 될 때까지 반복한다. + +- `terraform fmt` 실패: 호스트의 terraform 버전이 다를 수 있으므로, 검증 이미지로 포맷한다. + `docker run --rm -v "$PWD:/workspace" -w /workspace "$(scripts/verify/verify.sh --print-image)" terraform fmt -recursive bootstrap environment modules scripts` +- 새 `.tftpl` 을 추가했거나 템플릿 변수를 바꿨다면 `scripts/verify/render/templates.tf` 도 갱신한다. (빠뜨리면 L3 `template inventory` 가 실패한다) + +### 3. Terraform 변경이 있으면: L2 + +```bash +scripts/verify/verify.sh l2 --expect .verify/expect.txt +``` + +- 대상 환경은 origin/main 대비 변경 파일로 자동 감지된다. (`--env` 로 직접 지정할 수도 있다.) +- `intent` 가 `FAIL` 이면: + - **의도에 없는 변경**: 코드가 의도와 다르게 동작하는 것이다. 코드를 고친다. 의도 파일을 실제 plan 에 맞춰 고치지 않는다. + - **의도했으나 plan 에 없음**: 구현이 빠졌거나, 의도 자체가 잘못된 것이다. 의도가 틀렸다면 사용자에게 설명하고 고친다. +- plan 전문이 필요하면 `.verify/l2/.plan.txt` 를 읽는다. +- `delete` 또는 `replace` 가 있으면, 의도한 것이더라도 사용자에게 명시적으로 알린다. + +### 4. 결과 보고 + +PR 본문(또는 사용자에게 보내는 보고)에 아래 내용을 남긴다. + +- 의도 블록 (```` ```verify-expect ```` — PR 템플릿에 자리가 있다) +- 실행한 계층과 요약 (`PASS n / FAIL n / SKIPPED n`) +- **SKIPPED 항목과 사유.** 검증하지 못한 것을 검증한 것처럼 쓰지 않는다. +- L2 리소스 변경 목록 (delete/replace 는 강조) +- L4 런타임 확인 체크리스트 (CLAUDE.md 의 "L4 런타임 검증" 참고) + +## 하지 말 것 + +- **검사를 통과시키려고 검증 정책을 수정하지 않는다.** 아래 파일은 사람이 별도 PR 로 관리한다. + `scripts/verify/docker/tflint.hcl`, `.trivyignore.yaml`, `scripts/verify/baseline/*`, `scripts/verify/policies/*` + 정책 때문에 막혔고 그 정책이 틀렸다고 판단되면, 수정하지 말고 사용자에게 사유와 함께 보고한다. +- `SKIPPED` 를 `PASS` 로 보고하지 않는다. +- user_data 템플릿(`*.tftpl`)을 lint 경고만 보고 고치지 않는다. 템플릿이 바뀌면 plan 에 인스턴스 변경이 생긴다. 고칠 필요가 있다면 L2 로 영향을 확인하고 사용자에게 알린다. +- `terraform apply` 는 실행하지 않는다. (apply 는 GitHub Actions 전용) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index daf4dea..69c292a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -12,7 +12,8 @@ reviews: - path: "**/*.tf" instructions: | 이 PR의 Terraform 코드 변경을 리뷰합니다. - PR 댓글에 올라온 각 환경의 "Terraform Plan" 결과를 반드시 확인하고, 코드 변경과 plan 결과가 일치하는지 검토하세요. + PR 댓글에 올라온 각 환경의 "Terraform Plan" 결과(리소스 단위 변경 목록과 정책 검사 결과)를 반드시 확인하고, + 코드 변경과 plan 결과가 일치하는지 검토하세요. PR 본문의 "검증 결과"에 SKIPPED 항목이 있다면 검증되지 않은 범위로 취급하세요. 다음 항목을 중점적으로 검토하세요: - plan에 예상치 못한 resource destroy 또는 replace가 포함된 경우 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f039ae7..5175d15 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,6 +8,29 @@ +## 의도 (L2) + + + + +```verify-expect +``` + +## 검증 결과 (L1 ~ L3) + + + +- L1 + L3: PASS n / FAIL 0 / SKIPPED n +- L2 (Terraform 변경 시): 대상 환경 / 의도 대조 결과 +- SKIPPED 항목과 사유: + +## 런타임 확인 체크리스트 (L4) + + + + +- [ ] 대상: / 확인: / 기대 결과: + ## 특이 사항 diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index a200494..53163f6 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -3,6 +3,13 @@ name: Terraform Plan on: pull_request: branches: [main] + # edited: PR 본문의 verify-expect(의도) 블록을 고치면 의도 대조를 다시 실행한다. + types: [opened, synchronize, reopened, edited] + +# PR 본문 수정(edited)이 연달아 일어나도 같은 PR 의 plan 이 겹쳐 state lock 이 충돌하지 않게 한다. +concurrency: + group: terraform-plan-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: id-token: write @@ -22,6 +29,7 @@ jobs: prod: ${{ steps.filter.outputs.prod }} stage: ${{ steps.filter.outputs.stage }} monitoring: ${{ steps.filter.outputs.monitoring }} + load_test: ${{ steps.filter.outputs.load_test }} steps: - uses: actions/checkout@v4 with: @@ -41,17 +49,23 @@ jobs: - 'environment/prod/**' - 'modules/app_stack/**' - 'modules/common/**' + - 'config/side-infra/**' - 'config/secrets' stage: - 'environment/stage/**' - 'modules/app_stack/**' - 'modules/common/**' + - 'config/side-infra/**' - 'config/secrets' monitoring: - 'environment/monitoring/**' - 'modules/monitoring_stack/**' - 'modules/common/**' - 'config/secrets' + load_test: + - 'environment/load_test/**' + - 'modules/app_stack/templates/**' + - 'config/secrets' plan-bootstrap: needs: detect-changes @@ -77,8 +91,27 @@ jobs: id: plan working-directory: bootstrap run: | - terraform plan -no-color 2>&1 | tee plan_output.txt + terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: bootstrap + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env bootstrap --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" - name: Upload Plan Artifact if: always() uses: actions/upload-artifact@v4 @@ -93,10 +126,18 @@ jobs: const fs = require('fs'); const marker = ''; const planFile = 'bootstrap/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('bootstrap/plan_changes.md'); + const policy = readIfExists('bootstrap/plan_policy.txt'); + const intent = readIfExists('bootstrap/plan_intent.txt'); + const intentResult = fs.existsSync('bootstrap/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\n## Terraform Plan: \`bootstrap\`\n\n${summary}\n\n> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + const body = `${marker}\n## Terraform Plan: \`bootstrap\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -138,10 +179,29 @@ jobs: id: plan working-directory: environment/global run: | - terraform plan -no-color \ + terraform plan -no-color -out=tfplan \ -var-file="../../config/secrets/shared_resources.tfvars" \ 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: environment/global + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env global --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" - name: Upload Plan Artifact if: always() uses: actions/upload-artifact@v4 @@ -156,10 +216,18 @@ jobs: const fs = require('fs'); const marker = ''; const planFile = 'environment/global/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('environment/global/plan_changes.md'); + const policy = readIfExists('environment/global/plan_policy.txt'); + const intent = readIfExists('environment/global/plan_intent.txt'); + const intentResult = fs.existsSync('environment/global/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\n## Terraform Plan: \`global\`\n\n${summary}\n\n> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + const body = `${marker}\n## Terraform Plan: \`global\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -265,7 +333,7 @@ jobs: id: plan working-directory: environment/prod run: | - terraform plan -no-color \ + terraform plan -no-color -out=tfplan \ -var-file="../../config/secrets/prod.tfvars" \ -var-file="../../config/secrets/prod_db.tfvars" \ -var-file="../../config/secrets/app_stack.tfvars" \ @@ -274,6 +342,25 @@ jobs: - name: Stop SSM Tunnel if: always() run: kill $SSM_PID 2>/dev/null || true + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: environment/prod + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env prod --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" - name: Upload Plan Artifact if: always() uses: actions/upload-artifact@v4 @@ -288,10 +375,18 @@ jobs: const fs = require('fs'); const marker = ''; const planFile = 'environment/prod/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('environment/prod/plan_changes.md'); + const policy = readIfExists('environment/prod/plan_policy.txt'); + const intent = readIfExists('environment/prod/plan_intent.txt'); + const intentResult = fs.existsSync('environment/prod/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\n## Terraform Plan: \`prod\`\n\n${summary}\n\n> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + const body = `${marker}\n## Terraform Plan: \`prod\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -333,11 +428,30 @@ jobs: id: plan working-directory: environment/stage run: | - terraform plan -no-color \ + terraform plan -no-color -out=tfplan \ -var-file="../../config/secrets/stage.tfvars" \ -var-file="../../config/secrets/app_stack.tfvars" \ 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: environment/stage + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env stage --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" - name: Upload Plan Artifact if: always() uses: actions/upload-artifact@v4 @@ -352,10 +466,18 @@ jobs: const fs = require('fs'); const marker = ''; const planFile = 'environment/stage/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('environment/stage/plan_changes.md'); + const policy = readIfExists('environment/stage/plan_policy.txt'); + const intent = readIfExists('environment/stage/plan_intent.txt'); + const intentResult = fs.existsSync('environment/stage/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\n## Terraform Plan: \`stage\`\n\n${summary}\n\n> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + const body = `${marker}\n## Terraform Plan: \`stage\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -397,11 +519,30 @@ jobs: id: plan working-directory: environment/monitoring run: | - terraform plan -no-color \ + terraform plan -no-color -out=tfplan \ -var-file="../../config/secrets/monitoring.tfvars" \ -var-file="../../config/secrets/monitoring_stack.tfvars" \ 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: environment/monitoring + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env monitoring --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" - name: Upload Plan Artifact if: always() uses: actions/upload-artifact@v4 @@ -416,10 +557,109 @@ jobs: const fs = require('fs'); const marker = ''; const planFile = 'environment/monitoring/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('environment/monitoring/plan_changes.md'); + const policy = readIfExists('environment/monitoring/plan_policy.txt'); + const intent = readIfExists('environment/monitoring/plan_intent.txt'); + const intentResult = fs.existsSync('environment/monitoring/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; + const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; + const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = `${marker}\n## Terraform Plan: \`monitoring\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ comment_id: existing.id, owner: context.repo.owner, repo: context.repo.repo, body }); + } else { + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body }); + } + - name: Plan Status Check + if: steps.plan.outputs.exitcode == '1' + run: exit 1 + + # load_test 는 평소 내려가 있어 plan 이 전체 create 로 나온다. apply 는 load-test-start/stop 워크플로우가 담당한다. + plan-load_test: + needs: detect-changes + if: needs.detect-changes.outputs.load_test == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.GH_PAT }} + persist-credentials: false + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }} + aws-region: ap-northeast-2 + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: false + - name: Terraform Init + working-directory: environment/load_test + run: terraform init + - name: Terraform Plan + id: plan + working-directory: environment/load_test + run: | + terraform plan -no-color -out=tfplan \ + -var-file="../../config/secrets/load_test.tfvars" \ + 2>&1 | tee plan_output.txt + echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT + - name: Check Plan (L2) + if: steps.plan.outputs.exitcode == '0' + working-directory: environment/load_test + env: + # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + rc=0 + terraform show -json tfplan > plan.json + python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" policy plan.json > plan_policy.txt || rc=1 + if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then + python3 "$check" expect plan.json --env load_test --expect expect.txt --redact > plan_intent.txt || rc=1 + else + rc=1 + fi + cat plan_policy.txt plan_intent.txt + exit "$rc" + - name: Upload Plan Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: terraform-plan-load_test + path: environment/load_test/plan_output.txt + - name: Post Plan Comment + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const planFile = 'environment/load_test/plan_output.txt'; + const readIfExists = (f) => fs.existsSync(f) ? fs.readFileSync(f, 'utf8').trim() : ''; + const changes = readIfExists('environment/load_test/plan_changes.md'); + const policy = readIfExists('environment/load_test/plan_policy.txt'); + const intent = readIfExists('environment/load_test/plan_intent.txt'); + const intentResult = fs.existsSync('environment/load_test/plan_intent.txt') ? (intent ? `❌ 실패\n\n\`\`\`\n${intent}\n\`\`\`` : '✅ 일치') : ''; + const intentSection = intentResult ? `**의도 대조** (PR 본문 \`verify-expect\`): ${intentResult}\n\n` : ''; + const changesSection = changes ? `${changes}\n\n` : ''; + const policySection = policy ? `**정책 검사**\n\n\`\`\`\n${policy}\n\`\`\`\n\n` : ''; const output = fs.existsSync(planFile) ? fs.readFileSync(planFile, 'utf8') : ''; const summary = output.split('\n').find(l => /^Plan:/.test(l)) || output.split('\n').find(l => /No changes/.test(l)) || (output ? '(결과 파싱 불가)' : '⚠️ plan 실행 전 단계에서 실패했습니다. 워크플로우 로그를 확인하세요.'); const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const body = `${marker}\n## Terraform Plan: \`monitoring\`\n\n${summary}\n\n> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; + const body = `${marker}\n## Terraform Plan: \`load_test\`\n\n${summary}\n\n${changesSection}${policySection}${intentSection}> 전체 plan 결과는 보안을 위해 댓글에 포함되지 않습니다. [워크플로우 실행 아티팩트](${runUrl})를 확인하세요.`; const { data: comments } = await github.rest.issues.listComments({ issue_number: context.issue.number, diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..2c66a99 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,40 @@ +name: Verify + +# L1 정적 검사 + L3 동작 테스트 (scripts/verify) +# 로컬과 같은 검증 이미지/스크립트를 사용한다. L2 plan 검사는 terraform-plan.yml 에서 수행한다. + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Resolve Verify Image + id: image + run: echo "name=$(scripts/verify/verify.sh --print-image)" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + # verify.sh 는 이미지가 없으면 직접 빌드하지만, CI 에서는 레이어 캐시를 쓰기 위해 미리 빌드한다. + - name: Build Verify Image + uses: docker/build-push-action@v6 + with: + context: scripts/verify/docker + load: true + tags: ${{ steps.image.outputs.name }} + labels: solid-connection.verify=true + cache-from: type=gha,scope=verify-image + cache-to: type=gha,mode=max,scope=verify-image + + - name: L1 + L3 + run: scripts/verify/verify.sh l1 l3 --strict diff --git a/.gitignore b/.gitignore index c1f7668..961641f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,8 @@ __pycache__/ .idea/ .DS_Store +# --- 검증 결과 (scripts/verify) --- +.verify/ + # --- AGENTS --- AGENTS.local.md diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..282d75f --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,62 @@ +# trivy config (L1 IaC 보안 스캔) 예외 목록 +# +# 예외는 반드시 ID + 경로(paths)로 범위를 좁히고 statement 에 사유를 남긴다. +# 이 파일은 검증 정책이므로 인프라 변경 PR 에서 함께 수정하지 않는다. (별도 PR 로 리뷰) + +misconfigurations: + # --- 정책상 허용 --------------------------------------------------------- + - id: AWS-0104 + paths: + - "modules/app_stack/security_groups.tf" + - "modules/monitoring_stack/security_groups.tf" + - "environment/load_test/main.tf" + statement: >- + 아웃바운드 전체 허용(0.0.0.0/0 egress)은 패키지 설치, ECR/SSM/S3 접근 등에 필요해 정책상 허용한다. + 인바운드 규칙은 예외 대상이 아니다. + + - id: AWS-0132 + paths: + - "bootstrap/s3.tf" + - "environment/prod/mysql_backup.tf" + - "modules/shared_resources/s3.tf" + statement: >- + S3 암호화는 SSE-S3(AES256)를 사용한다. CMK(KMS) 는 비용 대비 요구사항이 없어 사용하지 않는다. + + - id: DS-0002 + paths: + - "scripts/verify/docker/Dockerfile" + statement: >- + 검증 전용 일회성 컨테이너다. 레포는 읽기 전용으로 마운트하고 root 캐시 볼륨에 쓰기 위해 root 로 실행한다. + + # --- 기존 부채 (#89 도입 시점, 후속 조치 필요) -------------------------- + # 아래 항목은 수정 시 인스턴스 교체 등 운영 영향이 있어 검증 파이프라인 도입과 분리한다. + - id: AWS-0028 + paths: + - "modules/app_stack/ec2.tf" + - "modules/monitoring_stack/ec2.tf" + statement: >- + 기존 부채: IMDSv2(http_tokens = required) 미강제. 적용 시 인스턴스 메타데이터 옵션 변경이 필요해 별도 이슈로 진행한다. + + - id: AWS-0131 + paths: + - "modules/app_stack/ec2.tf" + - "modules/monitoring_stack/ec2.tf" + statement: >- + 기존 부채: root 볼륨 미암호화. 적용 시 인스턴스 교체가 발생해 별도 이슈로 진행한다. + + - id: AWS-0086 + paths: + - "modules/shared_resources/s3.tf" + statement: 기존 부채, upload 버킷 public access block 미설정. 공개 범위 확인 후 별도 이슈로 진행한다. + - id: AWS-0087 + paths: + - "modules/shared_resources/s3.tf" + statement: 기존 부채, upload 버킷 public access block 미설정. 공개 범위 확인 후 별도 이슈로 진행한다. + - id: AWS-0091 + paths: + - "modules/shared_resources/s3.tf" + statement: 기존 부채, upload 버킷 public access block 미설정. 공개 범위 확인 후 별도 이슈로 진행한다. + - id: AWS-0093 + paths: + - "modules/shared_resources/s3.tf" + statement: 기존 부채, upload 버킷 public access block 미설정. 공개 범위 확인 후 별도 이슈로 진행한다. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3ac0612 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md + +이 레포의 작업 규칙과 구조는 [CLAUDE.md](CLAUDE.md)를 따릅니다. Claude Code 이외의 Code Agent도 같은 규칙을 적용합니다. + +## 변경 검증 + +코드를 수정한 뒤 커밋/PR 전에 아래 검증을 통과시킵니다. 절차와 금지 사항은 `verify` 스킬([.agents/skills/verify/SKILL.md](.agents/skills/verify/SKILL.md), 원본은 `.claude/skills/verify/SKILL.md`)에 있습니다. + +```bash +scripts/verify/verify.sh # L1 정적 검사 + L3 동작 테스트 +scripts/verify/verify.sh l2 --expect .verify/expect.txt # L2 plan 검사 (Terraform 변경 시) +``` + +- 의도(plan 에 나와야 할 리소스 변경)는 PR 본문의 `verify-expect` 블록에 적습니다. CI 가 실제 plan 과 대조합니다. +- `SKIPPED`는 통과가 아닙니다. PR에 사유를 명시합니다. +- L4 런타임 검증 지침은 [CLAUDE.md](CLAUDE.md)의 "L4 런타임 검증"을 따릅니다. +- 검증 정책 파일(`scripts/verify/docker/tflint.hcl`, `.trivyignore.yaml`, `scripts/verify/baseline/*`, `scripts/verify/policies/*`)은 수정하지 않습니다. +- `terraform apply`는 실행하지 않습니다. diff --git a/CLAUDE.md b/CLAUDE.md index 403a815..eaa2276 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ solid-connection 서비스의 AWS 인프라를 Terraform으로 관리하는 IaC ```text solid-connection-infra/ +├── .claude/skills/verify/ # 변경 검증 스킬 (L1~L3 절차) ├── config/ │ ├── secrets/ # git submodule (solid-connection-infra-secret) │ └── side-infra/ # Alloy 모니터링 설정 (config.alloy.tftpl) @@ -14,6 +15,10 @@ solid-connection-infra/ │ ├── monitoring_stack/ # 모니터링 전용 모듈 (EC2, Security Groups) │ ├── shared_resources/ # Global 공유 자원 (S3, CloudFront, Lambda, ACM) │ └── common/ # 공용 스크립트 (docker_setup.sh) +├── scripts/ +│ ├── verify/ # 변경 검증 파이프라인 (L1~L3, Docker 기반) +│ ├── mysql_backup/ # DB EC2 MySQL 백업 스크립트 +│ └── load_test/ # 부하 테스트 스크립트 └── environment/ ├── global/ # 공유 자원 환경 (shared_resources 모듈 사용) ├── prod/ # 프로덕션 환경 @@ -36,12 +41,12 @@ S3 Remote Backend를 사용하며, Terraform 1.10+의 S3 네이티브 락 기능 환경별 state 파일 경로: -| 환경 | key | -|------|-----| -| global | `env/global/terraform.tfstate` | -| prod | `env/prod/terraform.tfstate` | -| stage | `env/stage/terraform.tfstate` | -| load_test | `env/load_test/terraform.tfstate` | +| 환경 | key | +| ---------- | ---------------------------------- | +| global | `env/global/terraform.tfstate` | +| prod | `env/prod/terraform.tfstate` | +| stage | `env/stage/terraform.tfstate` | +| load_test | `env/load_test/terraform.tfstate` | | monitoring | `env/monitoring/terraform.tfstate` | ## 협업 워크플로우 @@ -65,12 +70,14 @@ feature/* 브랜치 Push 로컬에서는 **plan만 가능**합니다. apply 권한은 GitHub Actions OIDC 전용입니다. 개발자는 읽기 전용 IAM Policy가 부여된 자격증명을 발급받아 사용합니다: + - AWS read-only 권한 (`Describe*`, `List*`, `Get*`) - S3 `GetObject` on `solid-connection-tfstate` (state 읽기) - S3 `PutObject` / `DeleteObject` on `*.tflock` 허용 (plan 시 네이티브 락 획득/해제) - S3 `PutObject` / `DeleteObject` on `*.tfstate` 미부여 (state 쓰기 차단 → apply 불가) `~/.aws/credentials` 또는 환경 변수로 설정: + ```bash export AWS_PROFILE=solid-connection-readonly ``` @@ -78,6 +85,7 @@ export AWS_PROFILE=solid-connection-readonly ### tfvars 파일 secrets submodule이 초기화되어 있어야 합니다: + ```bash git submodule update --init --recursive ``` @@ -92,6 +100,46 @@ terraform init terraform plan -var-file="../../config/secrets/<환경명>.tfvars" ``` +검증 파이프라인으로 실행할 때는 `scripts/verify/verify.sh l2`를 씁니다. CI와 같은 tfvars 조합과 prod SSM 터널을 자동으로 처리합니다. + +## 변경 검증 절차 (L1 ~ L4) + +인프라 변경은 아래 계층으로 검증합니다. 상세 내용은 `scripts/verify/README.md`를 참고합니다. + +| 계층 | 수행 | 방법 | +| -------------- | ----------------- | --------------------------------------------------------- | +| L1 정적 검사 | 로컬 + CI | `scripts/verify/verify.sh` | +| L2 plan 검사 | 로컬 + CI | `scripts/verify/verify.sh l2 --expect .verify/expect.txt` | +| L3 동작 테스트 | 로컬 + CI | `scripts/verify/verify.sh` | +| L4 런타임 검증 | 작업자 (apply 후) | 아래 "L4 런타임 검증" 참고 | + +- Code Agent는 코드를 수정한 뒤 커밋/PR 전에 **`verify` 스킬**(`.claude/skills/verify`)을 따라 L1~L3를 통과시킵니다. +- 실행하지 못한 검사(`SKIPPED`)는 통과가 아닙니다. PR에 사유와 함께 명시합니다. +- 검증 정책 파일(`scripts/verify/docker/tflint.hcl`, `.trivyignore.yaml`, `scripts/verify/baseline/*`, `scripts/verify/policies/*`)은 인프라 변경 PR에서 함께 수정하지 않습니다. + +### L4 런타임 검증 + +L4는 apply 후 실제 리소스가 의도대로 동작하는지 확인하는 단계입니다. `main`에 머지되면 prod까지 바로 apply되므로, **L4는 prod에 반영된 뒤에 수행된다**는 점을 전제로 합니다. + +1. **PR 작성 시 (Agent)**: 변경 내용을 가장 잘 아는 시점에, 이 변경을 apply한 뒤 확인할 항목을 PR 본문의 "런타임 확인 체크리스트 (L4)"에 작성합니다. + - 각 항목에는 **대상 / 확인 명령 / 기대 결과**를 적습니다. + - 확인하는 방법이 없는 변경이라면 그 사실을 적습니다. +2. **apply 후 (작업자)**: GitHub Actions apply가 끝나면 체크리스트를 수행하고, 결과를 PR 댓글로 남깁니다. +3. **실패 시**: 즉시 원인을 확인하고, revert PR 또는 수정 PR을 올립니다. + +변경 유형별 체크리스트 예시: + +| 변경 유형 | 확인 예시 | +| ----------------------------------- | ---------------------------------------------------------------------------------------------- | +| nginx 설정 (`nginx_*.sh.tftpl`) | `sudo nginx -t`, `systemctl is-active nginx`, 외부/내부 경로별 `curl -I` 응답 코드 | +| side-infra (redis, alloy) | `docker ps`로 컨테이너 상태 확인, Grafana/Loki에 로그 유입 확인 | +| MySQL 백업 (`scripts/mysql_backup`) | `systemctl list-timers 'mysql-backup-*'`, `journalctl -u mysql-backup-dump`, S3 최신 객체 시각 | +| DB EC2 (`mysql_setup.sh.tftpl`) | `docker ps`로 mysql 상태 확인, 마운트(`findmnt /mnt/mysql-data`), 애플리케이션 DB 연결 | +| Security Group | 허용해야 할 경로는 연결되고, 차단해야 할 경로는 연결되지 않는지 확인 | +| S3 / CloudFront / Lambda | 실제 업로드 → CloudFront URL 응답, 썸네일/리사이즈 결과 | + +> 서버 내부 확인은 SSM 세션(`aws ssm start-session`)으로 합니다. 개발자 read-only 자격증명에 SSM 세션 권한이 있는지는 팀 정책에 따라 다르므로, 권한이 없다면 권한이 있는 팀원에게 L4를 요청합니다. + ## 주의사항 - **`terraform apply`는 로컬에서 실행하지 않습니다.** apply는 GitHub Actions에서만 실행됩니다 diff --git a/scripts/verify/README.md b/scripts/verify/README.md new file mode 100644 index 0000000..9593947 --- /dev/null +++ b/scripts/verify/README.md @@ -0,0 +1,81 @@ +# scripts/verify + +AI(Code Agent)와 사람이 같은 기준으로 인프라 변경을 검증하기 위한 파이프라인입니다. (#89) + +| 계층 | 성격 | 실행 위치 | +| -------------- | ----------------------------------------------------------------------- | ------------------------------- | +| L1 정적 검사 | 코드만 보고 판단 (fmt, validate, tflint, trivy, shellcheck, actionlint) | 로컬 + CI(`verify.yml`) | +| L2 plan 검사 | plan 을 JSON 으로 다뤄 변경 목록 / 정책 / 의도 대조 | 로컬 + CI(`terraform-plan.yml`) | +| L3 동작 테스트 | `.tftpl` 렌더링 후 shellcheck, 스크립트 단위 테스트 | 로컬 + CI(`verify.yml`) | +| L4 런타임 검증 | apply 후 실제 리소스 상태 확인 | 작업자 (CLAUDE.md, PR 템플릿) | + +## 사용법 + +필요한 것은 **Docker** 와 **bash** 뿐입니다. (Windows 는 Git Bash 또는 WSL) + +```bash +scripts/verify/verify.sh # L1 + L3 (기본, 자격증명 불필요) +scripts/verify/verify.sh l2 # 변경된 환경만 plan (origin/main 대비 자동 감지) +scripts/verify/verify.sh l2 --env prod # 환경 직접 지정 +scripts/verify/verify.sh all --expect .verify/expect.txt +``` + +- 검증 이미지는 처음 실행할 때, 그리고 `docker/` 디렉터리가 바뀌었을 때 자동으로 빌드됩니다. 이미지 태그는 `docker/` 내용의 해시입니다. +- 레포는 컨테이너에 **읽기 전용**으로 마운트되므로, 검증 과정에서 작업 트리가 바뀌지 않습니다. +- provider 와 trivy 캐시는 Docker 볼륨 `solid-connection-verify-cache` 에 저장됩니다. + +## 결과 해석 + +| 상태 | 의미 | +| --------- | --------------------------------------------------------------------------------- | +| `PASS` | 검사를 실행했고 통과 | +| `FAIL` | 검사를 실행했고 실패. 출력된 로그를 보고 수정 | +| `SKIPPED` | **실행하지 못함 (통과가 아님)**. 사유를 확인하고, 검증하지 못한 사실을 PR 에 명시 | + +`--strict` 를 주면 `SKIPPED` 도 실패로 처리합니다. (CI 에서 사용) + +## L2 준비 조건 + +| 조건 | 없을 때 | +| ---------------------------------------------------------------- | ------------------------------------------------------------ | +| AWS read-only 자격증명 (`AWS_PROFILE=solid-connection-readonly`) | `SKIPPED` (state 버킷에 접근할 수 없으면 다른 계정으로 판단) | +| `git submodule update --init --recursive` (tfvars) | `SKIPPED` | +| prod: DB EC2 로의 SSM 포트 포워딩 권한 | `SKIPPED` | + +plan 전문은 `.verify/l2/.plan.txt` 에 저장됩니다. (gitignore 대상) + +### 의도 파일 (`--expect`) + +작업 전에 "이 변경으로 plan 에 나와야 하는 리소스 변경"을 적어 두면, 실제 plan 과 기계적으로 대조합니다. +로컬은 `--expect` 파일로, CI(`terraform-plan.yml`)는 **PR 본문의 `verify-expect` 코드 블록**으로 대조합니다. PR 본문에 블록이 없으면 plan job 이 실패합니다. +적지 않은 환경은 "변경 없음"을 의도한 것으로 봅니다. load_test 는 평소 내려가 있어 전체 create 로 나오므로 `load_test create *` 처럼 선언합니다. +의도에 없는 변경과, 의도했지만 plan 에 없는 변경이 모두 `FAIL` 입니다. + +```text +# +prod update module.prod_stack.aws_instance.api_server +prod create module.prod_stack.aws_security_group_rule.* +stage update module.stage_stack.aws_instance.api_server +``` + +주소의 `*` 와 `?` 만 와일드카드이고, 대괄호는 글자 그대로입니다. (`mysql_user.users[*]`, `aws_instance.web[0]`) + +`replace` 는 `update` 와 구분됩니다. 교체가 의도라면 반드시 `replace` 로 적어야 합니다. + +## 검증 정책 파일 (사람이 소유) + +아래 파일은 "무엇을 통과로 볼지"를 정하는 정책입니다. **인프라 변경 PR 에서 함께 수정하지 않고**, 별도 PR 로 리뷰합니다. +검사를 통과시키기 위해 정책을 완화하지 않습니다. + +| 파일 | 내용 | +| ---------------------------------- | ------------------------------------------------ | +| `docker/tflint.hcl` | tflint 규칙 (기존 코드 baseline 포함) | +| `../../.trivyignore.yaml` | trivy 예외 (ID + 경로 + 사유) | +| `baseline/rendered-shellcheck.txt` | 렌더링된 템플릿의 기존 shellcheck 경고 예외 | +| `policies/*.py` | L2 plan 정책 규칙 ([README](policies/README.md)) | + +## 새 템플릿/테스트 추가 시 + +- 새 `.tftpl` 을 추가했다면 `render/templates.tf` 의 `rendered` 에 호출부와 같은 변수로 항목을 추가합니다. +- `scripts//tests/test_*.py` 는 L3 가 자동으로 실행합니다. +- 도구 버전은 `docker/Dockerfile` 의 `ARG` 에서 올립니다. (CI 의 `TF_VERSION` 과 terraform 버전을 맞출 것) diff --git a/scripts/verify/baseline/rendered-shellcheck.txt b/scripts/verify/baseline/rendered-shellcheck.txt new file mode 100644 index 0000000..4106fb6 --- /dev/null +++ b/scripts/verify/baseline/rendered-shellcheck.txt @@ -0,0 +1,9 @@ +# L3 렌더링 템플릿 shellcheck baseline (#89 도입 시점) +# +# 형식: <렌더링 파일명> # 사유 +# 템플릿을 고치면 user_data 가 바뀌어 plan 에 변경이 생기므로, 기존 경고는 여기서 예외 처리하고 +# 해당 템플릿을 수정할 일이 생길 때 함께 정리한 뒤 이 줄을 지운다. +# 이 파일은 검증 정책이므로 인프라 변경 PR 에서 새 항목을 추가하지 않는다. + +app_stack-mysql_setup.sh SC1087 # "$swap_file[[:space:]]" 는 배열 인덱싱이 아니라 문자열 연결로 의도대로 동작 +app_stack-mysql_setup.sh SC2034 # 대기 루프 카운터 i 미사용 (seq 반복 횟수만 사용) diff --git a/scripts/verify/docker/Dockerfile b/scripts/verify/docker/Dockerfile new file mode 100644 index 0000000..66821f5 --- /dev/null +++ b/scripts/verify/docker/Dockerfile @@ -0,0 +1,106 @@ +# syntax=docker/dockerfile:1 +# +# solid-connection-infra 검증 이미지 (L1 정적 검사 / L2 plan 검사 / L3 동작 테스트) +# +# 작업자의 OS(macOS/Windows/Linux)와 무관하게 같은 도구, 같은 버전으로 검증하기 위한 이미지다. +# 이 디렉터리의 파일이 바뀌면 scripts/verify/verify.sh 가 이미지 태그(파일 해시)가 바뀐 것을 감지해 자동으로 다시 빌드한다. +# 도구 버전을 올릴 때는 아래 ARG 만 수정한다. (tflint AWS ruleset 버전은 tflint.hcl 에서 관리) + +FROM python:3.12-slim-bookworm@sha256:34386ef0cb081344d7ec1c103ba398e6e9f64e9ab3a1509accc92a4e24a07258 + +# CI(.github/workflows/terraform-plan.yml)의 TF_VERSION 과 맞춘다. +ARG TERRAFORM_VERSION=1.10.5 +ARG TFLINT_VERSION=0.64.0 +ARG TRIVY_VERSION=0.75.0 +ARG ACTIONLINT_VERSION=1.7.12 +ARG SHELLCHECK_VERSION=0.11.0 +ARG TARGETARCH + +SHELL ["/bin/bash", "-Eeuo", "pipefail", "-c"] + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates curl unzip xz-utils jq netcat-openbsd procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/install + +# 체크섬 파일에서 해당 아티팩트 줄을 찾아 검증한다. +# $1: 체크섬 파일, $2: 아티팩트 파일명 +RUN cat >/usr/local/bin/verify-checksum <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +grep -E "[[:space:]]\*?$2\$" "$1" | sha256sum -c - +EOF +RUN chmod +x /usr/local/bin/verify-checksum + +RUN case "$TARGETARCH" in amd64|arm64) ;; *) echo "지원하지 않는 아키텍처: $TARGETARCH" >&2; exit 1 ;; esac + +# Terraform +RUN base="https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}" \ + && file="terraform_${TERRAFORM_VERSION}_linux_${TARGETARCH}.zip" \ + && curl -fsSLO "$base/$file" \ + && curl -fsSLO "$base/terraform_${TERRAFORM_VERSION}_SHA256SUMS" \ + && verify-checksum "terraform_${TERRAFORM_VERSION}_SHA256SUMS" "$file" \ + && unzip -q "$file" -d /usr/local/bin \ + && rm -rf /tmp/install/* + +# TFLint +RUN base="https://github.com/terraform-linters/tflint/releases/download/v${TFLINT_VERSION}" \ + && file="tflint_linux_${TARGETARCH}.zip" \ + && curl -fsSLO "$base/$file" \ + && curl -fsSLO "$base/checksums.txt" \ + && verify-checksum checksums.txt "$file" \ + && unzip -q "$file" -d /usr/local/bin \ + && rm -rf /tmp/install/* + +# Trivy (IaC 보안 스캔) +RUN base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}" \ + && arch="$([[ "$TARGETARCH" == amd64 ]] && echo 64bit || echo ARM64)" \ + && file="trivy_${TRIVY_VERSION}_Linux-${arch}.tar.gz" \ + && curl -fsSLO "$base/$file" \ + && curl -fsSLO "$base/trivy_${TRIVY_VERSION}_checksums.txt" \ + && verify-checksum "trivy_${TRIVY_VERSION}_checksums.txt" "$file" \ + && tar -xzf "$file" -C /usr/local/bin trivy \ + && rm -rf /tmp/install/* + +# actionlint (GitHub Actions 워크플로우 lint) +RUN base="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" \ + && file="actionlint_${ACTIONLINT_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && curl -fsSLO "$base/$file" \ + && curl -fsSLO "$base/actionlint_${ACTIONLINT_VERSION}_checksums.txt" \ + && verify-checksum "actionlint_${ACTIONLINT_VERSION}_checksums.txt" "$file" \ + && tar -xzf "$file" -C /usr/local/bin actionlint \ + && rm -rf /tmp/install/* + +# ShellCheck (공식 릴리스에 체크섬 파일이 없어 버전 고정으로만 관리) +RUN arch="$([[ "$TARGETARCH" == amd64 ]] && echo x86_64 || echo aarch64)" \ + && curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.${arch}.tar.xz" \ + | tar -xJf - --strip-components=1 -C /usr/local/bin "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" + +# AWS CLI v2 + Session Manager Plugin (L2 plan 의 자격증명 확인 및 prod DB SSM 터널용) +RUN arch="$([[ "$TARGETARCH" == amd64 ]] && echo x86_64 || echo aarch64)" \ + && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${arch}.zip" -o awscliv2.zip \ + && unzip -q awscliv2.zip \ + && ./aws/install \ + && ssm_arch="$([[ "$TARGETARCH" == amd64 ]] && echo ubuntu_64bit || echo ubuntu_arm64)" \ + && curl -fsSL "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/${ssm_arch}/session-manager-plugin.deb" -o ssm.deb \ + && dpkg -i ssm.deb \ + && rm -rf /tmp/install/* + +# TFLint 플러그인은 이미지에 미리 받아 둔다. (실행 시 GitHub API 호출/rate limit 회피) +ENV TFLINT_PLUGIN_DIR=/opt/tflint/plugins +COPY tflint.hcl /opt/tflint/tflint.hcl +RUN tflint --init --config /opt/tflint/tflint.hcl + +RUN terraform version && tflint --version && trivy --version && actionlint --version \ + && shellcheck --version && aws --version && session-manager-plugin --version && python3 --version + +# 레포가 .terraform.lock.hcl 을 커밋하지 않으므로, lock 파일 없이도 provider 캐시를 쓰도록 허용한다. +ENV TF_IN_AUTOMATION=1 \ + TF_INPUT=0 \ + TF_PLUGIN_CACHE_DIR=/cache/terraform-plugins \ + TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=true \ + TRIVY_CACHE_DIR=/cache/trivy \ + VERIFY_IN_CONTAINER=1 + +WORKDIR /work diff --git a/scripts/verify/docker/tflint.hcl b/scripts/verify/docker/tflint.hcl new file mode 100644 index 0000000..91371cf --- /dev/null +++ b/scripts/verify/docker/tflint.hcl @@ -0,0 +1,38 @@ +config { + # environment/* 가 호출하는 로컬 모듈(modules/*)까지 함께 검사한다. + call_module_type = "local" +} + +plugin "terraform" { + enabled = true + preset = "recommended" +} + +plugin "aws" { + enabled = true + version = "0.49.0" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} + +# --------------------------------------------------------------------------- +# 기존 코드 baseline (#89 도입 시점) +# +# 아래 규칙은 기존 코드에 위반이 남아 있어 일시적으로 끈다. +# 정리하려면 provider/버전 제약 추가, 변수 type 지정, 미사용 변수 제거(secrets tfvars 와 함께 정리)가 필요하며 +# plan 결과에 영향을 줄 수 있어 검증 파이프라인 도입과 분리해 진행한다. 정리 후 이 블록을 제거한다. +# --------------------------------------------------------------------------- +rule "terraform_required_providers" { + enabled = false +} + +rule "terraform_required_version" { + enabled = false +} + +rule "terraform_typed_variables" { + enabled = false +} + +rule "terraform_unused_declarations" { + enabled = false +} diff --git a/scripts/verify/l1.sh b/scripts/verify/l1.sh new file mode 100755 index 0000000..607b9ec --- /dev/null +++ b/scripts/verify/l1.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# L1 정적 검사: 코드를 실행하지 않고 판단할 수 있는, 빠르고 결과가 확정적인 검사. +# +set -Eeuo pipefail +# shellcheck source=lib/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +readonly LAYER=L1 + +section "L1 정적 검사" + +# 1. 포맷 (작업 복사본에는 secrets 가 없지만, 대상 디렉터리를 명시해 tfvars 가 diff 로 출력될 여지를 없앤다) +run_check "$LAYER" "terraform fmt" \ + terraform fmt -check -recursive -diff bootstrap environment modules scripts + +# 2. 문법/참조 검증 (backend 없이 init 하므로 자격증명이 필요 없다) +validate_dir() { + terraform -chdir="$1" init -backend=false -input=false >/dev/null + terraform -chdir="$1" validate -no-color +} +for dir in "${TF_ROOT_DIRS[@]}" "${TF_MODULE_DIRS[@]}"; do + run_check "$LAYER" "terraform validate ($dir)" validate_dir "$dir" +done + +# 3. Terraform lint (AWS ruleset 포함, environment/* 에서 호출하는 로컬 모듈까지 검사) +run_check "$LAYER" "tflint" \ + tflint --recursive --config /opt/tflint/tflint.hcl --format compact + +# 4. IaC 보안 스캔 (HIGH 이상만 실패 처리, 예외는 .trivyignore.yaml 에 사유와 함께 기록) +run_check "$LAYER" "trivy config" \ + trivy config --quiet --exit-code 1 --severity HIGH,CRITICAL \ + --ignorefile .trivyignore.yaml \ + --skip-dirs config/secrets --skip-dirs '**/.terraform' \ + . + +# 5. 셸 스크립트 lint (warning 이상만 실패 처리) +mapfile -t shell_scripts < <(list_shell_scripts) +run_check "$LAYER" "shellcheck (${#shell_scripts[@]} files)" \ + shellcheck --severity=warning --external-sources --source-path=SCRIPTDIR "${shell_scripts[@]}" + +# 6. GitHub Actions 워크플로우 lint (run 블록은 shellcheck 로 함께 검사됨) +# .git 없이 복사한 작업 디렉터리라 워크플로우 파일을 직접 넘긴다. +# run 블록의 shellcheck 도 위 셸 스크립트 lint 와 같은 기준(warning 이상)을 적용한다. +run_check "$LAYER" "actionlint" \ + env SHELLCHECK_OPTS=--severity=warning bash -c 'actionlint .github/workflows/*.yml' + +# 7. 에이전트별 스킬 사본 동기화 (원본: .claude/skills, 사본: .agents/skills) +# Windows 에서 symlink 가 깨지지 않도록 복사본을 두고, 내용이 갈라지지 않았는지만 검사한다. +skill_copies_in_sync() { + local src dst rc=0 + for src in .claude/skills/*/; do + dst=".agents/skills/$(basename "$src")" + if ! diff -r "$src" "$dst"; then + echo "→ 원본($src)을 사본($dst)에 그대로 복사하세요: cp -R ${src}. $dst/" + rc=1 + fi + done + return "$rc" +} +run_check "$LAYER" "skill copies in sync" skill_copies_in_sync diff --git a/scripts/verify/l2.sh b/scripts/verify/l2.sh new file mode 100755 index 0000000..1181f1a --- /dev/null +++ b/scripts/verify/l2.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# L2 plan 검사: 실제 AWS 상태와 비교한 plan 을 데이터(JSON)로 다뤄 변경 목록/정책/의도를 검사한다. +# +# 필요 조건 (없으면 FAIL 이 아니라 SKIPPED 로 사유를 남긴다) +# - AWS read-only 자격증명 (~/.aws 또는 환경 변수) +# - config/secrets submodule (tfvars) +# - prod: DB EC2 로의 SSM 포트 포워딩 권한 (mysql provider) +# +set -Eeuo pipefail +# shellcheck source=lib/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +readonly LAYER=L2 +readonly PLAN_DIR=/tmp/verify-plan +readonly OUT_DIR=/out/l2 +# 작업 복사본(/work)에는 secrets 가 없으므로 읽기 전용 원본을 참조한다. (verify.sh run_in_container 참고) +readonly SECRETS_DIR=/workspace/config/secrets +readonly SSM_TUNNEL_TIMEOUT=60 +readonly STATE_BUCKET=solid-connection-tfstate +SSM_PID="" +TUNNEL_ERROR="" + +section "L2 plan 검사" + +# CI(.github/workflows/terraform-plan.yml)와 같은 tfvars 조합 +var_files_of() { + case "$1" in + bootstrap) ;; + global) echo shared_resources.tfvars ;; + prod) echo prod.tfvars prod_db.tfvars app_stack.tfvars ;; + stage) echo stage.tfvars app_stack.tfvars ;; + monitoring) echo monitoring.tfvars monitoring_stack.tfvars ;; + load_test) echo load_test.tfvars ;; + esac +} + +dir_of() { + [[ "$1" == bootstrap ]] && echo bootstrap || echo "environment/$1" +} + +# prod 의 mysql provider 는 127.0.0.1:3306 으로 DB EC2 에 접속하므로 CI 와 같은 방식으로 SSM 터널을 연다. +# 터널 프로세스를 이 셸에서 관리해야 하므로 $(...) 로 호출하지 않고, 실패 사유는 TUNNEL_ERROR 에 남긴다. +start_prod_tunnel() { + local ec2_id db_host + ec2_id="$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=solid-connection-server-prod" "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].InstanceId' --output text 2>/dev/null || true)" + db_host="$(aws ec2 describe-instances \ + --filters "Name=tag:Name,Values=solid-connection-db-mysql-prod" "Name=instance-state-name,Values=running" \ + --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text 2>/dev/null || true)" + if [[ -z "$ec2_id" || "$ec2_id" == None || -z "$db_host" || "$db_host" == None ]]; then + TUNNEL_ERROR="prod EC2/DB EC2 조회 실패 (ec2:DescribeInstances 권한 확인)" + return 1 + fi + + aws ssm start-session --target "$ec2_id" \ + --document-name AWS-StartPortForwardingSessionToRemoteHost \ + --parameters "{\"host\":[\"$db_host\"],\"portNumber\":[\"3306\"],\"localPortNumber\":[\"3306\"]}" \ + >/tmp/ssm-tunnel.log 2>&1 & + SSM_PID=$! + + local _ + for _ in $(seq 1 "$SSM_TUNNEL_TIMEOUT"); do + if ! kill -0 "$SSM_PID" 2>/dev/null; then + TUNNEL_ERROR="SSM 세션 종료됨 (ssm:StartSession 권한 확인): $(tail -n1 /tmp/ssm-tunnel.log)" + return 1 + fi + nc -z 127.0.0.1 3306 2>/dev/null && return 0 + sleep 1 + done + TUNNEL_ERROR="${SSM_TUNNEL_TIMEOUT}초 내에 터널이 준비되지 않음" + return 1 +} + +stop_prod_tunnel() { + [[ -n "$SSM_PID" ]] && kill "$SSM_PID" 2>/dev/null || true + SSM_PID="" +} +trap stop_prod_tunnel EXIT + +plan_env() { + local env="$1" dir plan_file + dir="$(dir_of "$env")" + plan_file="$PLAN_DIR/$env.tfplan" + + local -a var_args=() + local f + for f in $(var_files_of "$env"); do + var_args+=("-var-file=$SECRETS_DIR/$f") + done + + terraform -chdir="$dir" init -input=false -reconfigure -no-color >/dev/null + terraform -chdir="$dir" plan -input=false -no-color -lock-timeout=60s -out="$plan_file" "${var_args[@]}" \ + >"$OUT_DIR/$env.plan.txt" 2>&1 || { cat "$OUT_DIR/$env.plan.txt"; return 1; } + terraform -chdir="$dir" show -json "$plan_file" >"$PLAN_DIR/$env.json" +} + +read -r -a envs <<<"${VERIFY_L2_ENVS:-}" +if [[ ${#envs[@]} -eq 0 ]]; then + echo " 변경된 Terraform 환경이 없어 L2 대상이 없습니다. (--env 로 직접 지정 가능)" + exit 0 +fi + +mkdir -p "$PLAN_DIR" "$OUT_DIR" + +# 자격증명이 있어도 다른 계정(개인/타 조직 profile)일 수 있으므로 state 버킷 접근으로 확인한다. +credential_error="" +if ! aws_identity="$(aws sts get-caller-identity --query Arn --output text 2>/dev/null)"; then + credential_error="AWS 자격증명 없음 또는 만료 (AWS_PROFILE 확인)" +elif ! aws s3api head-bucket --bucket "$STATE_BUCKET" >/dev/null 2>&1; then + credential_error="현재 자격증명($aws_identity)으로 $STATE_BUCKET 에 접근 불가 (AWS_PROFILE=solid-connection-readonly 확인)" +else + echo " AWS 자격증명: $aws_identity" +fi + +for env in "${envs[@]}"; do + if [[ -n "$credential_error" ]]; then + record SKIPPED "$LAYER" "plan ($env)" "$credential_error" + continue + fi + + missing="" + for f in $(var_files_of "$env"); do + [[ -f "$SECRETS_DIR/$f" ]] || missing="$missing $f" + done + if [[ -n "$missing" ]]; then + record SKIPPED "$LAYER" "plan ($env)" "tfvars 없음:$missing (git submodule update --init --recursive)" + continue + fi + + if [[ "$env" == prod ]]; then + if ! start_prod_tunnel; then + stop_prod_tunnel + record SKIPPED "$LAYER" "plan ($env)" "DB SSM 터널 실패: $TUNNEL_ERROR" + continue + fi + fi + + run_check "$LAYER" "plan ($env)" plan_env "$env" + [[ "$env" == prod ]] && stop_prod_tunnel + + if [[ ! -f "$PLAN_DIR/$env.json" ]]; then + continue + fi + python3 scripts/verify/plan_check.py summary "$PLAN_DIR/$env.json" + run_check "$LAYER" "policy ($env)" python3 scripts/verify/plan_check.py policy "$PLAN_DIR/$env.json" + if [[ -n "${VERIFY_EXPECT:-}" ]]; then + run_check "$LAYER" "intent ($env)" \ + python3 scripts/verify/plan_check.py expect "$PLAN_DIR/$env.json" --env "$env" --expect "$VERIFY_EXPECT" + else + record SKIPPED "$LAYER" "intent ($env)" "의도 파일 미지정 (--expect)" + fi +done + +# 호스트 사용자 소유로 돌려준다. (Linux 에서 root 소유 파일이 남지 않게) +[[ -n "${HOST_UID:-}" ]] && chown -R "$HOST_UID:${HOST_GID:-$HOST_UID}" /out 2>/dev/null || true +echo " plan 전문: .verify/l2/.plan.txt" diff --git a/scripts/verify/l3.sh b/scripts/verify/l3.sh new file mode 100755 index 0000000..65eae64 --- /dev/null +++ b/scripts/verify/l3.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# L3 동작 테스트: 서버에서 실제로 실행되는 스크립트/템플릿이 의도대로 동작하는지 검사한다. +# +set -Eeuo pipefail +# shellcheck source=lib/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +readonly LAYER=L3 +readonly RENDER_DIR=scripts/verify/render +readonly RENDERED_OUT=/tmp/verify-rendered + +section "L3 동작 테스트" + +# 0. 템플릿 inventory: 레포의 모든 .tftpl 이 렌더링 fixture 에 등록돼 있어야 한다. +# 등록을 빠뜨리면 아래 렌더링/shellcheck 가 그 템플릿을 검사하지 않고 PASS 하기 때문이다. +template_inventory() { + python3 - "$RENDER_DIR/templates.tf" <<'PY' +import pathlib, re, sys +fixture = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8") +registered = set(re.findall(r'\$\{local\.repo\}/([^"]+\.tftpl)"', fixture)) +actual = { + str(p) for p in pathlib.Path(".").rglob("*.tftpl") + if ".terraform" not in p.parts +} +missing = sorted(actual - registered) +stale = sorted(registered - actual) +for path in missing: + print(f"fixture 미등록: {path} → scripts/verify/render/templates.tf 에 호출부와 같은 변수로 추가하세요") +for path in stale: + print(f"존재하지 않는 템플릿 참조: {path}") +sys.exit(1 if missing or stale else 0) +PY +} +run_check "$LAYER" "template inventory" template_inventory + +# 1. .tftpl 렌더링 (fixture: scripts/verify/render/templates.tf) +# base64 로 감싸 terraform console 의 문자열 이스케이프($${ 등)를 피한다. +render_templates() { + terraform -chdir="$RENDER_DIR" init -backend=false -input=false >/dev/null + rm -rf "$RENDERED_OUT" && mkdir -p "$RENDERED_OUT" + echo 'base64encode(jsonencode(local.rendered))' \ + | terraform -chdir="$RENDER_DIR" console \ + | python3 -c ' +import base64, json, pathlib, sys +encoded = json.loads(sys.stdin.read()) +for name, content in json.loads(base64.b64decode(encoded)).items(): + (pathlib.Path(sys.argv[1]) / name).write_text(content) + print(name) +' "$RENDERED_OUT" +} +run_check "$LAYER" "template render" render_templates + +# 2. 렌더링된 user_data / SSM 스크립트 lint +# 템플릿 수정은 plan 변경을 만들기 때문에 기존 경고는 baseline 파일(파일명 + SC 코드)로만 예외 처리한다. +shellcheck_rendered() { + local baseline=scripts/verify/baseline/rendered-shellcheck.txt + local findings + findings="$(mktemp)" + shellcheck --severity=warning --format=gcc "$RENDERED_OUT"/*.sh >"$findings" || true + python3 - "$baseline" "$findings" <<'PY' +import re, sys +allowed = set() +for line in open(sys.argv[1], encoding="utf-8"): + line = line.split("#", 1)[0].split() + if len(line) == 2: + allowed.add(tuple(line)) +failed = False +for finding in open(sys.argv[2], encoding="utf-8").read().splitlines(): + m = re.match(r"^.*/([^/:]+):\d+:\d+: \w+: .*\[(SC\d+)\]$", finding) + if m and (m.group(1), m.group(2)) in allowed: + continue + print(finding) + failed = True +sys.exit(1 if failed else 0) +PY +} +if compgen -G "$RENDERED_OUT/*.sh" >/dev/null; then + run_check "$LAYER" "shellcheck (rendered templates)" shellcheck_rendered +else + record SKIPPED "$LAYER" "shellcheck (rendered templates)" "렌더링 실패로 검사 대상 없음" +fi + +# 3. 스크립트 단위 테스트 +run_check "$LAYER" "mysql_backup tests" bash scripts/mysql_backup/tests/run.sh +# scripts/*/tests/test_*.py 전체 (load_test, verify 등) +# tests 디렉터리에 __init__.py 가 없어 discover 대신 모듈 경로로 실행한다. (load-test-run.yml 과 동일한 방식) +python_tests() { + local -a modules + mapfile -t modules < <(find scripts -path '*/tests/test_*.py' | sed 's|/|.|g; s|\.py$||' | sort) + python3 -m unittest -q "${modules[@]}" +} +run_check "$LAYER" "python tests" python_tests diff --git a/scripts/verify/lib/common.sh b/scripts/verify/lib/common.sh new file mode 100644 index 0000000..943f596 --- /dev/null +++ b/scripts/verify/lib/common.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# 검증 계층 스크립트 공용 함수. 컨테이너 안에서만 사용한다. +# +# 결과는 $VERIFY_RESULTS 파일에 "상태계층항목사유" 형식으로 누적되고, +# verify.sh 가 마지막에 print_summary 로 한 번에 출력한다. +# +# 상태 +# PASS 검사를 실행했고 통과 +# FAIL 검사를 실행했고 실패 (전체 exit 1) +# SKIPPED 검사를 실행하지 못함 (자격증명 없음 등). 통과로 간주하지 않으며 사유를 반드시 남긴다. +# --strict 에서는 실패로 처리한다. + +readonly WORK_DIR=/work +cd "$WORK_DIR" || exit 1 + +# Terraform 루트/모듈 디렉터리 (validate 대상, 이 파일을 source 하는 계층 스크립트에서 사용) +# shellcheck disable=SC2034 +readonly TF_ROOT_DIRS=(bootstrap environment/global environment/prod environment/stage environment/monitoring environment/load_test) +# shellcheck disable=SC2034 +readonly TF_MODULE_DIRS=(modules/app_stack modules/monitoring_stack modules/shared_resources) + +record() { + local status="$1" layer="$2" name="$3" reason="${4:-}" + printf '%s\t%s\t%s\t%s\n' "$status" "$layer" "$name" "$reason" >>"$VERIFY_RESULTS" + case "$status" in + PASS) printf ' \033[32mPASS\033[0m %s\n' "$name" ;; + FAIL) printf ' \033[31mFAIL\033[0m %s\n' "$name" ;; + SKIPPED) printf ' \033[33mSKIPPED\033[0m %s — %s\n' "$name" "$reason" ;; + esac +} + +section() { + printf '\n\033[1m== %s ==\033[0m\n' "$1" +} + +# 명령을 실행해 PASS/FAIL 을 기록한다. 실패 시에만 전체 출력을 보여준다. +# usage: run_check +run_check() { + local layer="$1" name="$2" + shift 2 + + local log + log="$(mktemp)" + if (cd "$WORK_DIR" && "$@") >"$log" 2>&1; then + record PASS "$layer" "$name" + else + record FAIL "$layer" "$name" + sed 's/^/ | /' "$log" + fi + rm -f "$log" +} + +print_summary() { + local pass fail skipped + pass="$(grep -c '^PASS' "$VERIFY_RESULTS" || true)" + fail="$(grep -c '^FAIL' "$VERIFY_RESULTS" || true)" + skipped="$(grep -c '^SKIPPED' "$VERIFY_RESULTS" || true)" + + section "요약" + printf ' PASS %s / FAIL %s / SKIPPED %s\n' "$pass" "$fail" "$skipped" + + if [[ "$fail" -gt 0 ]]; then + echo + echo " 실패 항목:" + awk -F'\t' '$1=="FAIL" {printf " - [%s] %s\n", $2, $3}' "$VERIFY_RESULTS" + fi + if [[ "$skipped" -gt 0 ]]; then + echo + echo " 미검증 항목 (통과가 아님):" + awk -F'\t' '$1=="SKIPPED" {printf " - [%s] %s — %s\n", $2, $3, $4}' "$VERIFY_RESULTS" + fi + + if [[ "$fail" -gt 0 ]]; then + return 1 + fi + if [[ "$skipped" -gt 0 && "${VERIFY_STRICT:-false}" == true ]]; then + echo + echo " --strict: 미검증 항목이 있어 실패로 처리합니다." + return 1 + fi + return 0 +} + +# 셸 스크립트 목록: *.sh 와 shebang 이 있는 확장자 없는 파일 (.tftpl 은 L3 에서 렌더링 후 검사) +list_shell_scripts() { + local f + find "$WORK_DIR" \( -name .terraform -o -name .git -o -name node_modules \) -prune -o -type f -print \ + | LC_ALL=C sort \ + | while read -r f; do + case "$f" in + *.sh) echo "$f" ;; + */*.*) ;; + *) head -n1 "$f" 2>/dev/null | grep -qE '^#!.*(ba)?sh' && echo "$f" ;; + esac + done + return 0 +} diff --git a/scripts/verify/plan_check.py b/scripts/verify/plan_check.py new file mode 100755 index 0000000..e6447bf --- /dev/null +++ b/scripts/verify/plan_check.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""L2 plan 검사: `terraform show -json` 결과를 데이터로 다룬다. + +subcommands + summary 리소스 단위 변경 목록 (주소 + action). 값은 출력하지 않는다. + policy scripts/verify/policies/*.py 규칙 실행 (SEVERITY="block" 위반 시 exit 1) + expect 작업 의도 파일과 실제 변경 대조 (불일치 시 exit 1) + extract-expect PR 본문의 ```verify-expect 블록을 의도 파일로 추출 (CI 용, 블록이 없으면 exit 1) + +로컬(scripts/verify/l2.sh)과 CI(.github/workflows/terraform-plan.yml)가 같은 스크립트를 사용한다. +""" + +import argparse +import fnmatch +import importlib.util +import json +import os +import re +import sys +from pathlib import Path + +POLICY_DIR = Path(__file__).resolve().parent / "policies" + +# PR 본문에서 의도 블록을 찾는다. (GitHub 웹 편집기는 CRLF 를 쓴다) +EXPECT_BLOCK = re.compile(r"^```verify-expect[ \t]*\r?\n(.*?)^```", re.M | re.S) + +# 출력 순서: 위험한 변경이 먼저 보이게 한다. +ACTION_ORDER = ["delete", "replace", "create", "update"] +ACTION_MARK = {"delete": "🔴", "replace": "🟠", "create": "🟢", "update": "🟡"} + +# for_each 키는 tfvars 값에서 올 수 있다. (예: mysql_user.users[""]) +# 레포가 public 이므로 PR 댓글(--markdown)에서는 키를 가리고 숫자 인덱스(count)만 남긴다. +INSTANCE_KEY = re.compile(r'\["(?:[^"\\]|\\.)*"\]') + + +def redact_keys(address): + return INSTANCE_KEY.sub('["…"]', address) + + +def action_of(change): + """resource_changes[].change.actions 를 단일 action 으로 정규화한다.""" + actions = change["change"]["actions"] + if actions in (["delete", "create"], ["create", "delete"]): + return "replace" + if actions in (["no-op"], ["read"]): + return None + return actions[0] + + +def load_changes(plan_path): + with open(plan_path, encoding="utf-8") as f: + plan = json.load(f) + changes = [] + for rc in plan.get("resource_changes", []): + action = action_of(rc) + if action: + changes.append((action, rc["address"], rc)) + changes.sort(key=lambda c: (ACTION_ORDER.index(c[0]), c[1])) + return changes + + +def cmd_summary(args): + changes = load_changes(args.plan) + counts = {a: sum(1 for c in changes if c[0] == a) for a in ACTION_ORDER} + header = ", ".join(f"{a} {counts[a]}" for a in ACTION_ORDER) + + if args.markdown: + if not changes: + print("변경 없음") + return 0 + print(f"**리소스 변경**: {header}\n") + print("| | action | address |") + print("|---|---|---|") + for action, address, _ in changes: + print(f"| {ACTION_MARK[action]} | `{action}` | `{redact_keys(address)}` |") + if counts["delete"] or counts["replace"]: + print("\n> ⚠️ delete/replace 가 포함되어 있습니다. 의도한 변경인지 반드시 확인하세요.") + return 0 + + if not changes: + print(" 변경 없음") + return 0 + print(f" {header}") + for action, address, _ in changes: + print(f" {ACTION_MARK[action]} {action:<8} {address}") + return 0 + + +def load_policies(): + policies = [] + for path in sorted(POLICY_DIR.glob("*.py")): + if path.name.startswith("_"): + continue + spec = importlib.util.spec_from_file_location(f"policy_{path.stem}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + policies.append(module) + return policies + + +def cmd_policy(args): + changes = load_changes(args.plan) + policies = load_policies() + if not policies: + # stdout 은 CI 댓글에 실리므로 안내 문구는 stderr 로 보낸다. + print(" 등록된 정책 규칙 없음 (scripts/verify/policies/README.md 참고)", file=sys.stderr) + return 0 + + blocked = False + for policy in policies: + for action, address, rc in changes: + message = policy.check(action, rc) + if not message: + continue + severity = getattr(policy, "SEVERITY", "block") + blocked |= severity == "block" + # policy 출력은 CI 댓글에 실리므로 주소의 for_each 키를 가린다. + print(f" [{severity}] {policy.RULE_ID} {redact_keys(address)}: {message}") + return 1 if blocked else 0 + + +def parse_expect(path, env): + """의도 파일 형식: `
`, # 이후는 주석.""" + expected = [] + for lineno, raw in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1): + line = raw.split("#", 1)[0].strip() + if not line: + continue + parts = line.split() + if len(parts) != 3 or parts[1] not in ACTION_ORDER: + raise SystemExit(f"{path}:{lineno}: 형식 오류 (기대: <{'|'.join(ACTION_ORDER)}>
): {raw}") + if parts[0] == env: + expected.append((parts[1], parts[2])) + return expected + + +def cmd_expect(args): + changes = load_changes(args.plan) + expected = parse_expect(args.expect, args.env) + + def matched(action, address, rule): + # 주소의 [0], ["key"] 를 그대로 쓸 수 있도록 대괄호는 글자 그대로 취급하고 * 와 ? 만 와일드카드로 쓴다. + pattern = rule[1].replace("[", "\0").replace("]", "[]]").replace("\0", "[[]") + return action == rule[0] and fnmatch.fnmatchcase(address, pattern) + + unexpected = [(a, addr) for a, addr, _ in changes if not any(matched(a, addr, r) for r in expected)] + missing = [r for r in expected if not any(matched(a, addr, r) for a, addr, _ in changes)] + + for action, address in unexpected: + # CI 댓글(--redact)에서는 tfvars 에서 온 for_each 키를 가린다. 의도 패턴은 PR 본문에 이미 공개된 값이다. + print(f" 의도에 없는 변경: {action} {redact_keys(address) if args.redact else address}") + for action, pattern in missing: + print(f" 의도했으나 plan 에 없음: {action} {pattern}") + return 1 if unexpected or missing else 0 + + +def cmd_extract_expect(args): + """PR 본문은 신뢰할 수 없는 입력이므로 셸을 거치지 않고 환경 변수로만 받는다.""" + body = os.environ.get(args.body_env, "") + match = EXPECT_BLOCK.search(body) + if not match: + # CI 댓글의 코드 블록 안에 실리므로 백틱을 쓰지 않는다. + print(" PR 본문에 verify-expect 코드 블록이 없습니다. (리소스 변경이 없어야 하는 작업이면 빈 블록을 둡니다)") + return 1 + Path(args.out).write_text(match.group(1).replace("\r\n", "\n"), encoding="utf-8") + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("summary") + p.add_argument("plan") + p.add_argument("--markdown", action="store_true", help="PR 댓글용 markdown 출력") + p.set_defaults(func=cmd_summary) + + p = sub.add_parser("policy") + p.add_argument("plan") + p.set_defaults(func=cmd_policy) + + p = sub.add_parser("expect") + p.add_argument("plan") + p.add_argument("--env", required=True) + p.add_argument("--expect", required=True) + p.add_argument("--redact", action="store_true", help="CI 댓글용: 주소의 for_each 키를 가림") + p.set_defaults(func=cmd_expect) + + p = sub.add_parser("extract-expect") + p.add_argument("--body-env", default="PR_BODY", help="PR 본문이 담긴 환경 변수 이름") + p.add_argument("--out", required=True) + p.set_defaults(func=cmd_extract_expect) + + args = parser.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify/policies/README.md b/scripts/verify/policies/README.md new file mode 100644 index 0000000..1d410d4 --- /dev/null +++ b/scripts/verify/policies/README.md @@ -0,0 +1,31 @@ +# L2 plan 정책 규칙 + +`scripts/verify/plan_check.py policy` 가 이 디렉터리의 `*.py` 파일(`_` 로 시작하는 파일 제외)을 규칙으로 불러와 plan 의 각 리소스 변경에 적용합니다. + +> 구체적인 규칙은 별도 논의로 확정합니다. 현재 등록된 규칙은 없습니다. +> 규칙 파일은 검증 정책이므로 **인프라 변경 PR 에서 함께 수정하지 않고**, 별도 PR 로 리뷰합니다. + +## 규칙 파일 형식 + +```python +# scripts/verify/policies/no_stateful_destroy.py (예시) +RULE_ID = "STATEFUL-001" +SEVERITY = "block" # block: 실패 처리 / warn: 출력만 +DESCRIPTION = "상태를 가진 리소스의 삭제/교체 금지" + +STATEFUL_TYPES = {"aws_db_instance", "aws_ebs_volume", "aws_s3_bucket"} + + +def check(action, change): + """위반이면 사유 문자열, 아니면 None 을 반환한다. + + action: "create" | "update" | "delete" | "replace" + change: terraform show -json 의 resource_changes[] 항목 + (type, address, change.before, change.after 등 사용 가능) + """ + if change["type"] in STATEFUL_TYPES and action in ("delete", "replace"): + return f"{action} 은 허용되지 않습니다" + return None +``` + +출력에는 규칙이 반환한 메시지만 표시되므로, 메시지에 민감한 값(`change.after` 의 비밀번호 등)을 넣지 않습니다. diff --git a/scripts/verify/render/templates.tf b/scripts/verify/render/templates.tf new file mode 100644 index 0000000..2943465 --- /dev/null +++ b/scripts/verify/render/templates.tf @@ -0,0 +1,67 @@ +# L3 템플릿 렌더링 fixture +# +# user_data / SSM 으로 서버에서 실행되는 .tftpl 을 실제 호출부와 같은 변수 이름으로 렌더링해 +# scripts/verify/l3.sh 가 shellcheck 등으로 검사할 수 있게 한다. +# - 이 구성은 provider/backend 가 없으며 terraform console 로만 평가한다. (apply 하지 않음) +# - 템플릿에 변수가 추가되면 여기에도 추가해야 렌더링이 성공한다. +# - 새 .tftpl 을 추가하면 rendered 에 항목을 추가한다. + +locals { + repo = "${path.module}/../../.." + + mysql_tuning = file("${local.repo}/modules/app_stack/templates/mysql_tuning.cnf") + + alloy_config = templatefile("${local.repo}/config/side-infra/config.alloy.tftpl", { + loki_ip = "10.0.0.10" + }) + + rendered = { + # modules/app_stack/db_ec2.tf + "app_stack-mysql_setup.sh" = templatefile("${local.repo}/modules/app_stack/scripts/mysql_setup.sh.tftpl", { + db_root_username_b64 = base64encode("admin") + db_root_password_b64 = base64encode("verify-password") + db_data_volume_id = "vol-0123456789abcdef0" + mysql_config_content = local.mysql_tuning + }) + + # modules/app_stack/ec2.tf + "app_stack-nginx_install.sh" = templatefile("${local.repo}/modules/app_stack/scripts/nginx_install.sh.tftpl", { + domain_name = "api.example.com" + email = "verify@example.com" + conf_file_name = "api.conf" + }) + "app_stack-nginx_conf.sh" = templatefile("${local.repo}/modules/app_stack/scripts/nginx_conf.sh.tftpl", { + domain_name = "api.example.com" + conf_file_name = "api.conf" + }) + "app_stack-side_infra_setup.sh" = templatefile("${local.repo}/modules/app_stack/scripts/side_infra_setup.sh.tftpl", { + work_dir = "/home/ubuntu/side-infra" + alloy_env_name = "verify" + alloy_config_content = local.alloy_config + redis_version = "7.2" + redis_exporter_version = "v1.62.0" + alloy_version = "v1.4.0" + }) + + # modules/monitoring_stack/ec2.tf + "monitoring_stack-nginx_setup.sh" = templatefile("${local.repo}/modules/monitoring_stack/scripts/nginx_setup.sh.tftpl", { + domain_name = "monitoring.example.com" + email = "verify@example.com" + conf_file_name = "monitoring.conf" + }) + + # environment/load_test/main.tf + "load_test-mysql_setup.sh" = templatefile("${local.repo}/environment/load_test/templates/load_test_mysql_setup.sh.tftpl", { + aws_region = "ap-northeast-2" + data_volume_id = "vol-0123456789abcdef0" + db_name = "verify" + db_port = 3306 + ec2messages_endpoint_host = "vpce-0-ec2messages.ap-northeast-2.vpce.amazonaws.com" + load_test_parameter_prefix = "/solid-connection/load-test" + mysql_backup_bucket_name = "verify-mysql-backup" + mysql_config_content = local.mysql_tuning + ssm_endpoint_host = "vpce-0-ssm.ap-northeast-2.vpce.amazonaws.com" + ssmmessages_endpoint_host = "vpce-0-ssmmessages.ap-northeast-2.vpce.amazonaws.com" + }) + } +} diff --git a/scripts/verify/tests/test_plan_check.py b/scripts/verify/tests/test_plan_check.py new file mode 100644 index 0000000..8635069 --- /dev/null +++ b/scripts/verify/tests/test_plan_check.py @@ -0,0 +1,159 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLAN_CHECK = Path(__file__).resolve().parents[1] / "plan_check.py" + + +def resource_change(address, actions): + return {"address": address, "type": address.split(".")[-2], "change": {"actions": actions}} + + +PLAN = { + "resource_changes": [ + resource_change("module.prod_stack.aws_instance.api_server", ["update"]), + resource_change("aws_ebs_volume.db_data", ["delete", "create"]), + resource_change("aws_s3_bucket.unused", ["delete"]), + resource_change("aws_security_group.new", ["create"]), + resource_change("aws_iam_role.same", ["no-op"]), + resource_change("data.aws_ami.ubuntu", ["read"]), + resource_change('module.prod_stack.mysql_user.users["secret_user"]', ["create"]), + resource_change("aws_instance.indexed[0]", ["update"]), + ] +} + + +class PlanCheckTest(unittest.TestCase): + """Tests for plan JSON summary and intent comparison.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.plan = Path(self.tmp.name) / "plan.json" + self.plan.write_text(json.dumps(PLAN), encoding="utf-8") + + def run_check(self, *args): + return subprocess.run( + [sys.executable, str(PLAN_CHECK), *args], + capture_output=True, text=True, check=False, + ) + + def write_expect(self, content): + path = Path(self.tmp.name) / "expect.txt" + path.write_text(content, encoding="utf-8") + return str(path) + + def test_summary_orders_dangerous_actions_first_and_skips_noop(self): + result = self.run_check("summary", str(self.plan)) + + self.assertEqual(result.returncode, 0) + lines = [line.split()[1:] for line in result.stdout.splitlines()[1:]] + self.assertEqual(lines, [ + ["delete", "aws_s3_bucket.unused"], + ["replace", "aws_ebs_volume.db_data"], + ["create", "aws_security_group.new"], + ["create", 'module.prod_stack.mysql_user.users["secret_user"]'], + ["update", "aws_instance.indexed[0]"], + ["update", "module.prod_stack.aws_instance.api_server"], + ]) + + def test_summary_markdown_warns_on_delete_or_replace(self): + result = self.run_check("summary", str(self.plan), "--markdown") + + self.assertIn("delete/replace", result.stdout) + self.assertNotIn("aws_iam_role.same", result.stdout) + + def test_summary_markdown_redacts_for_each_keys_from_tfvars(self): + result = self.run_check("summary", str(self.plan), "--markdown") + + self.assertNotIn("secret_user", result.stdout) + self.assertIn('module.prod_stack.mysql_user.users["…"]', result.stdout) + self.assertIn("aws_instance.indexed[0]", result.stdout) + + def test_expect_passes_when_intent_matches_with_glob(self): + expect = self.write_expect( + "prod update module.prod_stack.aws_instance.*\n" + "prod replace aws_ebs_volume.db_data # 볼륨 교체\n" + "prod delete aws_s3_bucket.unused\n" + "prod create aws_security_group.new\n" + "prod create module.prod_stack.mysql_user.users[*]\n" + "prod update aws_instance.indexed[0]\n" + "stage delete aws_instance.other # 다른 환경 줄은 무시\n" + ) + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect) + + self.assertEqual(result.returncode, 0, result.stdout) + + def test_expect_fails_on_unexpected_and_missing_changes(self): + expect = self.write_expect( + "prod update module.prod_stack.aws_instance.api_server\n" + "prod create aws_security_group.new\n" + "prod create aws_lb.never_planned\n" + ) + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect) + + self.assertEqual(result.returncode, 1) + self.assertIn("의도에 없는 변경: replace aws_ebs_volume.db_data", result.stdout) + self.assertIn("의도에 없는 변경: delete aws_s3_bucket.unused", result.stdout) + self.assertIn("의도했으나 plan 에 없음: create aws_lb.never_planned", result.stdout) + + def test_expect_does_not_treat_replace_as_update(self): + expect = self.write_expect("prod update aws_ebs_volume.db_data\n") + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect) + + self.assertIn("의도에 없는 변경: replace aws_ebs_volume.db_data", result.stdout) + + def test_expect_redacts_unexpected_for_each_keys_for_ci(self): + expect = self.write_expect("") + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect, "--redact") + + self.assertNotIn("secret_user", result.stdout) + self.assertIn('mysql_user.users["…"]', result.stdout) + + def run_extract(self, body): + out = Path(self.tmp.name) / "extracted.txt" + result = subprocess.run( + [sys.executable, str(PLAN_CHECK), "extract-expect", "--out", str(out)], + capture_output=True, text=True, check=False, env={**os.environ, "PR_BODY": body}, + ) + return result, out + + def test_extract_expect_reads_block_from_pr_body_with_crlf(self): + body = "## 작업 내용\r\n\r\n```verify-expect\r\nprod update aws_instance.a\r\n# 주석\r\n```\r\n\r\n## 특이 사항\r\n" + + result, out = self.run_extract(body) + + self.assertEqual(result.returncode, 0) + self.assertEqual(out.read_text(encoding="utf-8"), "prod update aws_instance.a\n# 주석\n") + + def test_extract_expect_accepts_empty_block_as_no_change(self): + result, out = self.run_extract("```verify-expect\n```\n") + + self.assertEqual(result.returncode, 0) + self.assertEqual(out.read_text(encoding="utf-8"), "") + + def test_extract_expect_fails_without_block(self): + result, out = self.run_extract("```text\nprod update aws_instance.a\n```\n") + + self.assertEqual(result.returncode, 1) + self.assertFalse(out.exists()) + + def test_expect_rejects_malformed_line(self): + expect = self.write_expect("prod modify aws_ebs_volume.db_data\n") + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("형식 오류", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify/verify.sh b/scripts/verify/verify.sh new file mode 100755 index 0000000..e31b59e --- /dev/null +++ b/scripts/verify/verify.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# +# 인프라 레포 검증 진입점. +# +# 호스트에서 실행하면 검증 이미지를 확인하고(없거나 Dockerfile 이 바뀌었으면 빌드), +# 레포를 읽기 전용으로 마운트한 컨테이너 안에서 이 스크립트를 다시 실행한다. +# 컨테이너 안에서는 레포를 /work 로 복사한 뒤 요청한 계층(L1/L2/L3)을 순서대로 실행한다. +# +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly IMAGE_REPO="solid-connection-verify" +readonly DEFAULT_LAYERS=(l1 l3) +readonly ALL_ENVS=(bootstrap global prod stage monitoring load_test) + +usage() { + cat <<'EOF' +Usage: scripts/verify/verify.sh [LAYER...] [OPTIONS] + +LAYER: + l1 정적 검사 (fmt, validate, tflint, trivy, shellcheck, actionlint) + l2 plan 검사 (AWS read-only 자격증명, secrets submodule 필요) + l3 동작 테스트 (템플릿 렌더링 + shellcheck, 스크립트 테스트) + all l1 l2 l3 + (생략 시 l1 l3) + +OPTIONS: + --env ENV L2 대상 환경 (반복 가능, 생략 시 origin/main 대비 변경된 환경 자동 감지) + bootstrap | global | prod | stage | monitoring | load_test + --expect FILE L2 의도 대조 파일 (권장 위치: .verify/expect.txt, 형식은 scripts/verify/README.md 참고) + --base REF L2 변경 감지 기준 ref (기본: origin/main) + --strict SKIPPED 도 실패로 처리 (CI 용) + --rebuild 검증 이미지를 강제로 다시 빌드 + --print-image 사용할 검증 이미지 이름만 출력하고 종료 + -h, --help 도움말 +EOF +} + +# --------------------------------------------------------------------------- +# 컨테이너 안: 계층 실행 +# --------------------------------------------------------------------------- +run_in_container() { + # 원본(/workspace)은 읽기 전용이다. 검증 중 생기는 .terraform 등은 복사본(/work)에만 남는다. + # 복사본에는 secrets(tfvars)를 넣지 않는다. L1/L3 는 구조적으로 tfvars 를 읽을 수 없고(CI 와 동일), + # L2 만 읽기 전용 원본(/workspace/config/secrets)을 -var-file 로 참조한다. + tar -C /workspace --exclude=.git --exclude=.terraform --exclude=.verify \ + --exclude=./config/secrets --exclude='*.tfvars' -cf - . | tar -C /work -xf - + mkdir -p "$TF_PLUGIN_CACHE_DIR" "$TRIVY_CACHE_DIR" + + export VERIFY_RESULTS + VERIFY_RESULTS="$(mktemp)" + + local layer + for layer in "${LAYERS[@]}"; do + bash "/work/scripts/verify/${layer}.sh" + done + + # shellcheck source=lib/common.sh + source /work/scripts/verify/lib/common.sh + print_summary +} + +# --------------------------------------------------------------------------- +# 호스트: 이미지 준비 및 컨테이너 실행 +# --------------------------------------------------------------------------- +sha256_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | cut -d' ' -f1 + else + shasum -a 256 | cut -d' ' -f1 + fi +} + +# 이미지 태그는 docker/ 디렉터리 내용의 해시다. Dockerfile 이나 tflint.hcl 이 바뀌면 태그가 바뀌어 자동 재빌드된다. +image_name() { + local hash + hash="$(cd "$SCRIPT_DIR/docker" && find . -type f | LC_ALL=C sort | while read -r f; do printf '%s\n' "$f"; cat "$f"; done | sha256_stdin)" + printf '%s:%s\n' "$IMAGE_REPO" "${hash:0:12}" +} + +ensure_image() { + local image="$1" + + if ! docker info >/dev/null 2>&1; then + echo "ERROR: Docker 데몬에 연결할 수 없습니다. Docker Desktop(또는 docker 서비스)이 실행 중인지 확인하세요." >&2 + exit 2 + fi + + if [[ "$REBUILD" == false ]] && docker image inspect "$image" >/dev/null 2>&1; then + return + fi + + echo "==> 검증 이미지 빌드: $image (최초 1회 또는 docker/ 변경 시)" >&2 + docker build --label "solid-connection.verify=true" -t "$image" "$SCRIPT_DIR/docker" >&2 + + # 이전 해시의 이미지는 정리한다. (실패해도 검증에는 영향 없음) + docker image ls "$IMAGE_REPO" --format '{{.Repository}}:{{.Tag}}' \ + | { grep -vxF "$image" || true; } \ + | while read -r old; do docker image rm "$old" >/dev/null 2>&1 || true; done +} + +# origin/main 대비 변경 파일로 L2 대상 환경을 고른다. (CI terraform-plan.yml 의 paths-filter 와 같은 기준) +detect_changed_envs() { + local base="$1" files + if ! files="$( { git -C "$REPO_ROOT" diff --name-only "$base"...HEAD; git -C "$REPO_ROOT" diff --name-only HEAD; git -C "$REPO_ROOT" ls-files --others --exclude-standard; } 2>/dev/null)"; then + echo "WARN: '$base' 와 비교할 수 없어 변경 환경을 감지하지 못했습니다. --env 로 지정하세요." >&2 + return + fi + + matches() { grep -qE "$1" <<<"$files"; } + matches '^bootstrap/' && echo bootstrap + matches '^(environment/global/|modules/shared_resources/|config/secrets$)' && echo global + matches '^(environment/prod/|modules/app_stack/|modules/common/|config/side-infra/|config/secrets$)' && echo prod + matches '^(environment/stage/|modules/app_stack/|modules/common/|config/side-infra/|config/secrets$)' && echo stage + matches '^(environment/monitoring/|modules/monitoring_stack/|modules/common/|config/secrets$)' && echo monitoring + matches '^(environment/load_test/|modules/app_stack/templates/|config/secrets$)' && echo load_test + return 0 +} + +run_on_host() { + REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + local image + image="$(image_name)" + + if [[ "$PRINT_IMAGE" == true ]]; then + echo "$image" + return + fi + + ensure_image "$image" + + local -a docker_args=(run --rm -i) + [[ -t 0 && -t 1 ]] && docker_args+=(-t) + + # Git Bash(Windows)는 경로를 자동 변환하므로 막고, Docker 가 이해하는 Windows 경로를 넘긴다. + local mount_root="$REPO_ROOT" + if pwd -W >/dev/null 2>&1; then + export MSYS_NO_PATHCONV=1 + mount_root="$(cd "$REPO_ROOT" && pwd -W)" + fi + + docker_args+=( + -v "$mount_root:/workspace:ro" + -v "solid-connection-verify-cache:/cache" + -e "VERIFY_LAYERS=${LAYERS[*]}" + -e "VERIFY_STRICT=$STRICT" + ) + + # 의도 파일은 gitignore 대상(.verify/)에 둘 수 있으므로 레포 복사본이 아니라 별도로 마운트한다. + if [[ -n "$EXPECT" ]]; then + local expect_path="$EXPECT" + [[ "$expect_path" == /* ]] || expect_path="$PWD/$expect_path" + if [[ ! -f "$expect_path" ]]; then + echo "ERROR: 의도 파일이 없습니다: $EXPECT" >&2 + exit 2 + fi + local expect_dir + expect_dir="$(cd "$(dirname "$expect_path")" && { pwd -W 2>/dev/null || pwd; })" + docker_args+=(-v "$expect_dir/$(basename "$expect_path"):/tmp/verify-expect.txt:ro" -e "VERIFY_EXPECT=/tmp/verify-expect.txt") + fi + + if [[ " ${LAYERS[*]} " == *" l2 "* ]]; then + # macOS 기본 bash(3.2)는 set -u 에서 빈 배열 전개를 에러로 처리하므로 문자열로 다룬다. + local env_list="$ENVS" + if [[ -z "$env_list" ]]; then + env_list="$(detect_changed_envs "$BASE_REF" | tr '\n' ' ')" + echo "==> L2 대상 환경 (자동 감지, 기준 $BASE_REF): ${env_list:-(없음)}" >&2 + fi + docker_args+=(-e "VERIFY_L2_ENVS=$env_list") + + # plan 전문은 .verify/l2/ 에 남긴다. (gitignore 대상, 레포 마운트는 읽기 전용) + mkdir -p "$REPO_ROOT/.verify" + local out_dir="$REPO_ROOT/.verify" + pwd -W >/dev/null 2>&1 && out_dir="$(cd "$out_dir" && pwd -W)" + docker_args+=(-v "$out_dir:/out" -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)") + + # AWS 자격증명: ~/.aws 를 읽기 전용으로 넘기고, 환경 변수 방식도 그대로 전달한다. + if [[ -d "$HOME/.aws" ]]; then + local aws_dir="$HOME/.aws" + pwd -W >/dev/null 2>&1 && aws_dir="$(cd "$HOME/.aws" && pwd -W)" + docker_args+=(-v "$aws_dir:/root/.aws:ro") + fi + local var + for var in AWS_PROFILE AWS_REGION AWS_DEFAULT_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN; do + [[ -n "${!var:-}" ]] && docker_args+=(-e "$var") + done + fi + + docker "${docker_args[@]}" "$image" bash /workspace/scripts/verify/verify.sh +} + +# --------------------------------------------------------------------------- +# 인자 처리 +# --------------------------------------------------------------------------- +main() { + if [[ "${VERIFY_IN_CONTAINER:-}" == 1 ]]; then + read -r -a LAYERS <<<"${VERIFY_LAYERS:-${DEFAULT_LAYERS[*]}}" + run_in_container + return + fi + + LAYERS=() ENVS="" EXPECT="" BASE_REF="origin/main" STRICT=false REBUILD=false PRINT_IMAGE=false + while [[ $# -gt 0 ]]; do + case "$1" in + l1|l2|l3) LAYERS+=("$1"); shift ;; + all) LAYERS+=(l1 l2 l3); shift ;; + --env) + [[ " ${ALL_ENVS[*]} " == *" ${2:-} "* ]] || { echo "ERROR: 알 수 없는 환경: ${2:-}" >&2; exit 2; } + ENVS="$ENVS $2"; shift 2 ;; + --expect) EXPECT="$2"; shift 2 ;; + --base) BASE_REF="$2"; shift 2 ;; + --strict) STRICT=true; shift ;; + --rebuild) REBUILD=true; shift ;; + --print-image) PRINT_IMAGE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "ERROR: 알 수 없는 인자: $1" >&2; usage >&2; exit 2 ;; + esac + done + [[ ${#LAYERS[@]} -eq 0 ]] && LAYERS=("${DEFAULT_LAYERS[@]}") + + # 계층 순서는 항상 L1 → L2 → L3, 중복 제거 + local -a ordered=() + local l + for l in l1 l2 l3; do + [[ " ${LAYERS[*]} " == *" $l "* ]] && ordered+=("$l") + done + LAYERS=("${ordered[@]}") + + run_on_host +} + +main "$@" From dc0250f89361609c7f1c3ae0f17e66932f9a3c7f Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 18 Oct 2026 17:20:19 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20PR=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=EC=9D=98=20=EC=9D=98=EB=8F=84=20=EB=B8=94=EB=A1=9D=EC=9D=B4=20?= =?UTF-8?q?=EB=B9=88=20=EB=B0=95=EC=8A=A4=EB=A1=9C=EB=A7=8C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=EB=90=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설명을 HTML 주석 밖으로 꺼내고, verify-expect 블록에 형식 안내 주석을 넣는다. 주석(#)만 있는 블록은 "모든 환경 변경 없음"으로 대조된다. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5175d15..ad1363b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,10 +10,13 @@ ## 의도 (L2) - - +이 PR 로 plan 에 나와야 하는 리소스 변경입니다. CI 가 아래 블록과 실제 plan 을 대조합니다. (블록이 없으면 plan job 실패) +적지 않은 환경은 "변경 없음"으로 대조하며, `#` 이후는 주석입니다. ```verify-expect +# +# 예) prod update module.prod_stack.aws_instance.api_server +# 리소스 변경이 없는 PR 이면 이 주석만 남겨 둡니다. ``` ## 검증 결과 (L1 ~ L3) From 4e55cf6db7f4127b90a4b7147a3134f9016991d3 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sun, 18 Oct 2026 17:37:29 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EA=B2=80=EC=A6=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(=EC=9D=98=EB=8F=84=20=EB=8C=80=EC=A1=B0?= =?UTF-8?q?=20=EC=8B=A0=EB=A2=B0=EC=84=B1,=20plan=20=EC=B7=A8=EC=86=8C,=20?= =?UTF-8?q?apply=20=ED=95=84=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 의도 대조는 이벤트 payload 가 아닌 실행 시점의 PR 본문을 API 로 조회한다 - concurrency cancel-in-progress 를 끈다 (plan 강제 종료 시 S3 .tflock 잔존 방지) - extract-expect/expect 의 stderr 도 결과 파일에 기록해 형식 오류가 댓글에 "일치"로 보이지 않게 한다 - policy 규칙 로드/실행 오류를 stdout 에 실패로 기록한다 - terraform-apply.yml prod/stage 필터에 config/side-infra/** 추가 (plan 필터와 일치) - verify.sh: 기준 ref 와 비교할 수 없으면 L2 를 "대상 없음" 대신 SKIPPED 로 보고한다 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/terraform-apply.yml | 2 + .github/workflows/terraform-plan.yml | 90 ++++++++++++++++++------- scripts/verify/l2.sh | 5 ++ scripts/verify/plan_check.py | 13 +++- scripts/verify/tests/test_plan_check.py | 12 ++++ scripts/verify/verify.sh | 25 ++++--- 6 files changed, 111 insertions(+), 36 deletions(-) diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index de7e1fd..5afa41a 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -40,11 +40,13 @@ jobs: - 'environment/prod/**' - 'modules/app_stack/**' - 'modules/common/**' + - 'config/side-infra/**' - 'config/secrets' stage: - 'environment/stage/**' - 'modules/app_stack/**' - 'modules/common/**' + - 'config/side-infra/**' - 'config/secrets' monitoring: - 'environment/monitoring/**' diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 53163f6..da3c709 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -6,10 +6,12 @@ on: # edited: PR 본문의 verify-expect(의도) 블록을 고치면 의도 대조를 다시 실행한다. types: [opened, synchronize, reopened, edited] -# PR 본문 수정(edited)이 연달아 일어나도 같은 PR 의 plan 이 겹쳐 state lock 이 충돌하지 않게 한다. +# 같은 PR 의 plan 이 겹쳐 state lock 이 충돌하지 않게 한다. +# 실행 중인 plan 을 강제 종료하면 S3 .tflock 이 남을 수 있으므로 취소하지 않고, 대기 중인 실행만 최신 것으로 교체한다. +# (의도 대조는 실행 시점의 PR 본문을 조회하므로 어떤 실행이 남아도 최신 의도로 검사한다) concurrency: group: terraform-plan-${{ github.event.pull_request.number }} - cancel-in-progress: true + cancel-in-progress: false permissions: id-token: write @@ -97,16 +99,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: bootstrap env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env bootstrap --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env bootstrap --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi @@ -187,16 +195,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: environment/global env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env global --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env global --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi @@ -346,16 +360,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: environment/prod env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env prod --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env prod --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi @@ -437,16 +457,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: environment/stage env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env stage --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env stage --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi @@ -528,16 +554,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: environment/monitoring env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env monitoring --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env monitoring --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi @@ -619,16 +651,22 @@ jobs: if: steps.plan.outputs.exitcode == '0' working-directory: environment/load_test env: - # PR 본문은 신뢰할 수 없는 입력이므로 스크립트에 직접 넣지 않고 환경 변수로만 전달한다. - PR_BODY: ${{ github.event.pull_request.body }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json python3 "$check" summary plan.json --markdown > plan_changes.md python3 "$check" policy plan.json > plan_policy.txt || rc=1 - if python3 "$check" extract-expect --out expect.txt > plan_intent.txt; then - python3 "$check" expect plan.json --env load_test --expect expect.txt --redact > plan_intent.txt || rc=1 + # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + # stderr(형식 오류 등)도 결과 파일에 남겨, job 실패가 PR 댓글에 "일치"로 보이지 않게 한다. + if ! PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')"; then + echo " PR 본문 조회 실패" > plan_intent.txt + rc=1 + elif PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt > plan_intent.txt 2>&1; then + python3 "$check" expect plan.json --env load_test --expect expect.txt --redact > plan_intent.txt 2>&1 || rc=1 else rc=1 fi diff --git a/scripts/verify/l2.sh b/scripts/verify/l2.sh index 1181f1a..6256cb6 100755 --- a/scripts/verify/l2.sh +++ b/scripts/verify/l2.sh @@ -96,6 +96,11 @@ plan_env() { terraform -chdir="$dir" show -json "$plan_file" >"$PLAN_DIR/$env.json" } +if [[ -n "${VERIFY_L2_DETECT_ERROR:-}" ]]; then + record SKIPPED "$LAYER" "L2 대상 감지" "$VERIFY_L2_DETECT_ERROR" + exit 0 +fi + read -r -a envs <<<"${VERIFY_L2_ENVS:-}" if [[ ${#envs[@]} -eq 0 ]]; then echo " 변경된 Terraform 환경이 없어 L2 대상이 없습니다. (--env 로 직접 지정 가능)" diff --git a/scripts/verify/plan_check.py b/scripts/verify/plan_check.py index e6447bf..47dfdc1 100755 --- a/scripts/verify/plan_check.py +++ b/scripts/verify/plan_check.py @@ -100,7 +100,11 @@ def load_policies(): def cmd_policy(args): changes = load_changes(args.plan) - policies = load_policies() + try: + policies = load_policies() + except Exception as e: # noqa: BLE001 - 규칙 파일 오류도 CI 댓글에 실패로 보여야 한다 + print(f" 정책 규칙 로드 실패: {type(e).__name__}: {e}") + return 1 if not policies: # stdout 은 CI 댓글에 실리므로 안내 문구는 stderr 로 보낸다. print(" 등록된 정책 규칙 없음 (scripts/verify/policies/README.md 참고)", file=sys.stderr) @@ -109,7 +113,12 @@ def cmd_policy(args): blocked = False for policy in policies: for action, address, rc in changes: - message = policy.check(action, rc) + try: + message = policy.check(action, rc) + except Exception as e: # noqa: BLE001 - 규칙 오류를 통과로 오인하지 않도록 실패로 기록한다 + print(f" [error] {getattr(policy, 'RULE_ID', policy.__name__)} {redact_keys(address)}: 규칙 실행 오류 {type(e).__name__}") + blocked = True + continue if not message: continue severity = getattr(policy, "SEVERITY", "block") diff --git a/scripts/verify/tests/test_plan_check.py b/scripts/verify/tests/test_plan_check.py index 8635069..9851b0d 100644 --- a/scripts/verify/tests/test_plan_check.py +++ b/scripts/verify/tests/test_plan_check.py @@ -146,6 +146,18 @@ def test_extract_expect_fails_without_block(self): self.assertEqual(result.returncode, 1) self.assertFalse(out.exists()) + def test_policy_reports_rule_error_on_stdout_as_failure(self): + policy_dir = Path(__file__).resolve().parents[1] / "policies" + rule = policy_dir / "zz_test_broken_rule.py" + rule.write_text('RULE_ID = "TEST-BROKEN"\ndef check(action, change):\n raise KeyError("x")\n', encoding="utf-8") + self.addCleanup(rule.unlink) + + result = self.run_check("policy", str(self.plan)) + + self.assertEqual(result.returncode, 1) + self.assertIn("TEST-BROKEN", result.stdout) + self.assertIn("규칙 실행 오류", result.stdout) + def test_expect_rejects_malformed_line(self): expect = self.write_expect("prod modify aws_ebs_volume.db_data\n") diff --git a/scripts/verify/verify.sh b/scripts/verify/verify.sh index e31b59e..bc5ab15 100755 --- a/scripts/verify/verify.sh +++ b/scripts/verify/verify.sh @@ -101,12 +101,16 @@ ensure_image() { } # origin/main 대비 변경 파일로 L2 대상 환경을 고른다. (CI terraform-plan.yml 의 paths-filter 와 같은 기준) +# 기준 ref 와 비교할 수 없으면(미fetch, merge base 없음 등) 1 을 반환한다. 커밋된 변경이 감지에서 빠진 채 +# "대상 없음"으로 통과하지 않도록, 호출부가 이를 L2 SKIPPED 로 기록한다. detect_changed_envs() { - local base="$1" files - if ! files="$( { git -C "$REPO_ROOT" diff --name-only "$base"...HEAD; git -C "$REPO_ROOT" diff --name-only HEAD; git -C "$REPO_ROOT" ls-files --others --exclude-standard; } 2>/dev/null)"; then - echo "WARN: '$base' 와 비교할 수 없어 변경 환경을 감지하지 못했습니다. --env 로 지정하세요." >&2 - return + local base="$1" committed files + if ! committed="$(git -C "$REPO_ROOT" diff --name-only "$base"...HEAD 2>/dev/null)"; then + return 1 fi + files="$committed +$(git -C "$REPO_ROOT" diff --name-only HEAD 2>/dev/null) +$(git -C "$REPO_ROOT" ls-files --others --exclude-standard 2>/dev/null)" matches() { grep -qE "$1" <<<"$files"; } matches '^bootstrap/' && echo bootstrap @@ -162,12 +166,17 @@ run_on_host() { if [[ " ${LAYERS[*]} " == *" l2 "* ]]; then # macOS 기본 bash(3.2)는 set -u 에서 빈 배열 전개를 에러로 처리하므로 문자열로 다룬다. - local env_list="$ENVS" + local env_list="$ENVS" detected detect_error="" if [[ -z "$env_list" ]]; then - env_list="$(detect_changed_envs "$BASE_REF" | tr '\n' ' ')" - echo "==> L2 대상 환경 (자동 감지, 기준 $BASE_REF): ${env_list:-(없음)}" >&2 + if detected="$(detect_changed_envs "$BASE_REF")"; then + env_list="$(tr '\n' ' ' <<<"$detected")" + echo "==> L2 대상 환경 (자동 감지, 기준 $BASE_REF): ${env_list:-(없음)}" >&2 + else + detect_error="기준 ref '$BASE_REF' 와 비교할 수 없어 변경 환경을 감지하지 못함 (git fetch origin 후 재실행하거나 --base / --env 지정)" + echo "WARN: $detect_error" >&2 + fi fi - docker_args+=(-e "VERIFY_L2_ENVS=$env_list") + docker_args+=(-e "VERIFY_L2_ENVS=$env_list" -e "VERIFY_L2_DETECT_ERROR=$detect_error") # plan 전문은 .verify/l2/ 에 남긴다. (gitignore 대상, 레포 마운트는 읽기 전용) mkdir -p "$REPO_ROOT/.verify" From 56f8c0cd624fbe6954b016388fb8e07096f1c3f1 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Tue, 20 Oct 2026 14:11:54 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EA=B2=80=EC=A6=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=202=EC=B0=A8=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EC=84=A0=EC=96=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=8C=80=EC=A1=B0,=20forget=20action,=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B3=A0=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 의도에 선언한 환경 이름을 검증하고, 선언 환경이 모두 plan 대상인지 확인한다 (CI intent-envs job, 로컬 intent (declared envs)) — 오타/대상 아닌 환경 선언이 조용히 무시되지 않게 - forget action(removed 블록)을 별도로 표시하고, 미지원 action 은 traceback 대신 명시적 실패로 보고한다 - CI summary 실패가 의도 대조 전에 step 을 중단하지 않게 한다 - l2.sh: show -json 을 임시 파일로 쓰고 성공 시에만 반영, 단계별 실패를 명시적으로 반환 - 검증 이미지의 AWS CLI(2.38.2) / Session Manager Plugin(1.2.896.0) 버전 고정 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/verify/SKILL.md | 2 +- .claude/skills/verify/SKILL.md | 2 +- .github/pull_request_template.md | 2 +- .github/workflows/terraform-plan.yml | 47 ++++++++++++++++--- scripts/verify/README.md | 5 +- scripts/verify/docker/Dockerfile | 7 ++- scripts/verify/l2.sh | 30 ++++++++++-- scripts/verify/plan_check.py | 61 ++++++++++++++++++++----- scripts/verify/tests/test_plan_check.py | 47 +++++++++++++++++++ scripts/verify/verify.sh | 5 +- 10 files changed, 179 insertions(+), 29 deletions(-) diff --git a/.agents/skills/verify/SKILL.md b/.agents/skills/verify/SKILL.md index b3ad5f1..294c56d 100644 --- a/.agents/skills/verify/SKILL.md +++ b/.agents/skills/verify/SKILL.md @@ -22,7 +22,7 @@ description: solid-connection-infra 의 인프라 변경(Terraform, user_data 같은 내용을 PR 본문의 `verify-expect` 코드 블록에도 그대로 옮긴다. **CI 는 PR 본문의 블록으로 의도를 대조하고, 블록이 없으면 plan job 이 실패한다.** ```text -# +# prod update module.prod_stack.aws_instance.api_server ``` diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index b3ad5f1..294c56d 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -22,7 +22,7 @@ description: solid-connection-infra 의 인프라 변경(Terraform, user_data 같은 내용을 PR 본문의 `verify-expect` 코드 블록에도 그대로 옮긴다. **CI 는 PR 본문의 블록으로 의도를 대조하고, 블록이 없으면 plan job 이 실패한다.** ```text -# +# prod update module.prod_stack.aws_instance.api_server ``` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ad1363b..d480826 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,7 +14,7 @@ 적지 않은 환경은 "변경 없음"으로 대조하며, `#` 이후는 주석입니다. ```verify-expect -# +# # 예) prod update module.prod_stack.aws_instance.api_server # 리소스 변경이 없는 PR 이면 이 주석만 남겨 둡니다. ``` diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index da3c709..16fb888 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -69,6 +69,41 @@ jobs: - 'modules/app_stack/templates/**' - 'config/secrets' + # 환경별 plan job 은 자기 환경 줄만 대조하므로, plan 대상이 아닌 환경(또는 오타)에 선언한 의도는 아무도 검사하지 않는다. + # 그런 선언이 조용히 버려지지 않도록 선언 환경이 모두 plan 대상인지 확인한다. + intent-envs: + needs: detect-changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Check Declared Intent Environments (L2) + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + CHANGED: ${{ toJSON(needs.detect-changes.outputs) }} + run: | + check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" + mapfile -t planned < <(jq -r 'to_entries[] | select(.value == "true") | .key' <<<"$CHANGED") + echo "plan 대상 환경: ${planned[*]:-(없음)}" + + # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. + PR_BODY="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body // ""')" + if ! PR_BODY="$PR_BODY" python3 "$check" extract-expect --out expect.txt >/dev/null 2>&1; then + # 블록이 없을 때 plan 대상이 있으면 각 plan job 이 실패로 알린다. 대상이 없으면 확인할 의도도 없다. + echo "verify-expect 블록 없음: 선언 환경 확인을 건너뜁니다." + exit 0 + fi + + if ! out="$(python3 "$check" check-envs --expect expect.txt --planned "${planned[@]}" 2>&1)"; then + echo "$out" + echo "::error title=의도 대조::$(tr '\n' ' ' <<<"$out")" + { echo "### ❌ 의도 대조 (선언 환경)"; echo; echo '```'; echo "$out"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "선언한 환경이 모두 plan 대상입니다." + plan-bootstrap: needs: detect-changes if: needs.detect-changes.outputs.bootstrap == 'true' @@ -105,7 +140,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. @@ -201,7 +236,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. @@ -366,7 +401,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. @@ -463,7 +498,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. @@ -560,7 +595,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. @@ -657,7 +692,7 @@ jobs: check="$GITHUB_WORKSPACE/scripts/verify/plan_check.py" rc=0 terraform show -json tfplan > plan.json - python3 "$check" summary plan.json --markdown > plan_changes.md + python3 "$check" summary plan.json --markdown > plan_changes.md 2>&1 || rc=1 python3 "$check" policy plan.json > plan_policy.txt || rc=1 # 의도는 이벤트 payload 가 아니라 실행 시점의 PR 본문에서 읽는다. (push 직후 본문 수정 등 실행이 겹쳐도 최신 의도로 대조) # PR 본문은 신뢰할 수 없는 입력이므로 셸 코드에 넣지 않고 변수/환경 변수로만 다룬다. diff --git a/scripts/verify/README.md b/scripts/verify/README.md index 9593947..2fdcb44 100644 --- a/scripts/verify/README.md +++ b/scripts/verify/README.md @@ -52,7 +52,7 @@ plan 전문은 `.verify/l2/.plan.txt` 에 저장됩니다. (gitignore 대 의도에 없는 변경과, 의도했지만 plan 에 없는 변경이 모두 `FAIL` 입니다. ```text -# +# prod update module.prod_stack.aws_instance.api_server prod create module.prod_stack.aws_security_group_rule.* stage update module.stage_stack.aws_instance.api_server @@ -60,7 +60,8 @@ stage update module.stage_stack.aws_instance.api_server 주소의 `*` 와 `?` 만 와일드카드이고, 대괄호는 글자 그대로입니다. (`mysql_user.users[*]`, `aws_instance.web[0]`) -`replace` 는 `update` 와 구분됩니다. 교체가 의도라면 반드시 `replace` 로 적어야 합니다. +`replace` 는 `update` 와 구분됩니다. `forget` 은 `removed` 블록으로 state 에서만 제거하는 변경입니다. +환경 이름은 `bootstrap`, `global`, `prod`, `stage`, `monitoring`, `load_test` 만 허용되며, 선언한 환경이 plan 대상이 아니면 실패합니다. (오타나 잘못된 환경 선언이 조용히 무시되지 않도록) 교체가 의도라면 반드시 `replace` 로 적어야 합니다. ## 검증 정책 파일 (사람이 소유) diff --git a/scripts/verify/docker/Dockerfile b/scripts/verify/docker/Dockerfile index 66821f5..0103ad6 100644 --- a/scripts/verify/docker/Dockerfile +++ b/scripts/verify/docker/Dockerfile @@ -14,6 +14,8 @@ ARG TFLINT_VERSION=0.64.0 ARG TRIVY_VERSION=0.75.0 ARG ACTIONLINT_VERSION=1.7.12 ARG SHELLCHECK_VERSION=0.11.0 +ARG AWSCLI_VERSION=2.38.2 +ARG SSM_PLUGIN_VERSION=1.2.896.0 ARG TARGETARCH SHELL ["/bin/bash", "-Eeuo", "pipefail", "-c"] @@ -78,12 +80,13 @@ RUN arch="$([[ "$TARGETARCH" == amd64 ]] && echo x86_64 || echo aarch64)" \ | tar -xJf - --strip-components=1 -C /usr/local/bin "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" # AWS CLI v2 + Session Manager Plugin (L2 plan 의 자격증명 확인 및 prod DB SSM 터널용) +# 빌드 시점마다 버전이 달라지지 않도록 버전별 아카이브 경로를 사용한다. RUN arch="$([[ "$TARGETARCH" == amd64 ]] && echo x86_64 || echo aarch64)" \ - && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${arch}.zip" -o awscliv2.zip \ + && curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${arch}-${AWSCLI_VERSION}.zip" -o awscliv2.zip \ && unzip -q awscliv2.zip \ && ./aws/install \ && ssm_arch="$([[ "$TARGETARCH" == amd64 ]] && echo ubuntu_64bit || echo ubuntu_arm64)" \ - && curl -fsSL "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/${ssm_arch}/session-manager-plugin.deb" -o ssm.deb \ + && curl -fsSL "https://s3.amazonaws.com/session-manager-downloads/plugin/${SSM_PLUGIN_VERSION}/${ssm_arch}/session-manager-plugin.deb" -o ssm.deb \ && dpkg -i ssm.deb \ && rm -rf /tmp/install/* diff --git a/scripts/verify/l2.sh b/scripts/verify/l2.sh index 6256cb6..c2a46e9 100755 --- a/scripts/verify/l2.sh +++ b/scripts/verify/l2.sh @@ -90,10 +90,15 @@ plan_env() { var_args+=("-var-file=$SECRETS_DIR/$f") done - terraform -chdir="$dir" init -input=false -reconfigure -no-color >/dev/null + # run_check 는 if 문맥에서 실행하므로 set -e 가 적용되지 않는다. 단계마다 실패를 명시적으로 반환한다. + # 이전 실행의 결과가 남아 다음 검사에 쓰이지 않도록 먼저 지우고, JSON 은 성공했을 때만 최종 경로로 옮긴다. + rm -f "$plan_file" "$PLAN_DIR/$env.json" "$PLAN_DIR/$env.json.tmp" + terraform -chdir="$dir" init -input=false -reconfigure -no-color >"$OUT_DIR/$env.init.txt" 2>&1 \ + || { cat "$OUT_DIR/$env.init.txt"; return 1; } terraform -chdir="$dir" plan -input=false -no-color -lock-timeout=60s -out="$plan_file" "${var_args[@]}" \ >"$OUT_DIR/$env.plan.txt" 2>&1 || { cat "$OUT_DIR/$env.plan.txt"; return 1; } - terraform -chdir="$dir" show -json "$plan_file" >"$PLAN_DIR/$env.json" + terraform -chdir="$dir" show -json "$plan_file" >"$PLAN_DIR/$env.json.tmp" || return 1 + mv "$PLAN_DIR/$env.json.tmp" "$PLAN_DIR/$env.json" } if [[ -n "${VERIFY_L2_DETECT_ERROR:-}" ]]; then @@ -102,6 +107,24 @@ if [[ -n "${VERIFY_L2_DETECT_ERROR:-}" ]]; then fi read -r -a envs <<<"${VERIFY_L2_ENVS:-}" + +# 환경별 intent 는 자기 환경 줄만 대조하므로, plan 대상이 아닌 환경에 선언한 의도는 따로 확인한다. +# 자동 감지일 때 선언 환경이 대상이 아니면 그 환경 코드가 바뀌지 않은 것이므로 FAIL, +# --env 로 일부만 지정했다면 나머지는 검사하지 않은 것이므로 SKIPPED 로 남긴다. +if [[ -n "${VERIFY_EXPECT:-}" ]]; then + env_check_rc=0 + env_check_out="$(python3 scripts/verify/plan_check.py check-envs --expect "$VERIFY_EXPECT" --planned "${envs[@]}" 2>&1)" \ + || env_check_rc=$? + if [[ "$env_check_rc" -eq 0 ]]; then + record PASS "$LAYER" "intent (declared envs)" + elif [[ "$env_check_rc" -eq 3 && "${VERIFY_L2_AUTO:-false}" != true ]]; then + record SKIPPED "$LAYER" "intent (declared envs)" "$(tr '\n' ' ' <<<"$env_check_out")" + else + record FAIL "$LAYER" "intent (declared envs)" + sed 's/^/ | /' <<<"$env_check_out" + fi +fi + if [[ ${#envs[@]} -eq 0 ]]; then echo " 변경된 Terraform 환경이 없어 L2 대상이 없습니다. (--env 로 직접 지정 가능)" exit 0 @@ -148,7 +171,8 @@ for env in "${envs[@]}"; do if [[ ! -f "$PLAN_DIR/$env.json" ]]; then continue fi - python3 scripts/verify/plan_check.py summary "$PLAN_DIR/$env.json" + # summary 는 표시용이다. 실패(미지원 action 등)해도 아래 policy/intent 와 다음 환경 검사는 계속한다. + python3 scripts/verify/plan_check.py summary "$PLAN_DIR/$env.json" || true run_check "$LAYER" "policy ($env)" python3 scripts/verify/plan_check.py policy "$PLAN_DIR/$env.json" if [[ -n "${VERIFY_EXPECT:-}" ]]; then run_check "$LAYER" "intent ($env)" \ diff --git a/scripts/verify/plan_check.py b/scripts/verify/plan_check.py index 47dfdc1..5aee3e4 100755 --- a/scripts/verify/plan_check.py +++ b/scripts/verify/plan_check.py @@ -6,6 +6,7 @@ policy scripts/verify/policies/*.py 규칙 실행 (SEVERITY="block" 위반 시 exit 1) expect 작업 의도 파일과 실제 변경 대조 (불일치 시 exit 1) extract-expect PR 본문의 ```verify-expect 블록을 의도 파일로 추출 (CI 용, 블록이 없으면 exit 1) + check-envs 의도에 선언한 환경이 모두 plan 대상인지 확인 (형식 오류 exit 1, 대상 아님 exit 3) 로컬(scripts/verify/l2.sh)과 CI(.github/workflows/terraform-plan.yml)가 같은 스크립트를 사용한다. """ @@ -25,8 +26,16 @@ EXPECT_BLOCK = re.compile(r"^```verify-expect[ \t]*\r?\n(.*?)^```", re.M | re.S) # 출력 순서: 위험한 변경이 먼저 보이게 한다. -ACTION_ORDER = ["delete", "replace", "create", "update"] -ACTION_MARK = {"delete": "🔴", "replace": "🟠", "create": "🟢", "update": "🟡"} +# forget: Terraform 1.7+ removed 블록(destroy = false)으로 리소스를 state 에서만 제거한다. 실제 리소스는 남는다. +ACTION_ORDER = ["delete", "replace", "forget", "create", "update"] +ACTION_MARK = {"delete": "🔴", "replace": "🟠", "forget": "🟣", "create": "🟢", "update": "🟡"} + +# 의도 파일에 쓸 수 있는 환경 (verify.sh / terraform-plan.yml 의 환경과 동일) +ENVS = ["bootstrap", "global", "prod", "stage", "monitoring", "load_test"] + + +class UnsupportedPlan(Exception): + """처리 방법을 모르는 plan 내용. 조용히 무시하지 않고 실패로 보고한다.""" # for_each 키는 tfvars 값에서 올 수 있다. (예: mysql_user.users[""]) # 레포가 public 이므로 PR 댓글(--markdown)에서는 키를 가리고 숫자 인덱스(count)만 남긴다. @@ -44,7 +53,9 @@ def action_of(change): return "replace" if actions in (["no-op"], ["read"]): return None - return actions[0] + if len(actions) == 1 and actions[0] in ACTION_ORDER: + return actions[0] + raise UnsupportedPlan(f"지원하지 않는 plan action {actions}: {redact_keys(change['address'])} (scripts/verify/plan_check.py 갱신 필요)") def load_changes(plan_path): @@ -62,7 +73,8 @@ def load_changes(plan_path): def cmd_summary(args): changes = load_changes(args.plan) counts = {a: sum(1 for c in changes if c[0] == a) for a in ACTION_ORDER} - header = ", ".join(f"{a} {counts[a]}" for a in ACTION_ORDER) + # forget 은 드물기 때문에 있을 때만 표시한다. + header = ", ".join(f"{a} {counts[a]}" for a in ACTION_ORDER if a != "forget" or counts[a]) if args.markdown: if not changes: @@ -75,6 +87,8 @@ def cmd_summary(args): print(f"| {ACTION_MARK[action]} | `{action}` | `{redact_keys(address)}` |") if counts["delete"] or counts["replace"]: print("\n> ⚠️ delete/replace 가 포함되어 있습니다. 의도한 변경인지 반드시 확인하세요.") + if counts["forget"]: + print("\n> ⚠️ forget 은 리소스를 state 에서만 제거합니다. (실제 리소스는 남고 더 이상 Terraform 이 관리하지 않음)") return 0 if not changes: @@ -128,9 +142,9 @@ def cmd_policy(args): return 1 if blocked else 0 -def parse_expect(path, env): - """의도 파일 형식: `
`, # 이후는 주석.""" - expected = [] +def parse_expect_all(path): + """의도 파일 형식: `
`, # 이후는 주석. 모든 줄을 (env, action, pattern) 으로 반환한다.""" + entries = [] for lineno, raw in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1): line = raw.split("#", 1)[0].strip() if not line: @@ -138,9 +152,24 @@ def parse_expect(path, env): parts = line.split() if len(parts) != 3 or parts[1] not in ACTION_ORDER: raise SystemExit(f"{path}:{lineno}: 형식 오류 (기대: <{'|'.join(ACTION_ORDER)}>
): {raw}") - if parts[0] == env: - expected.append((parts[1], parts[2])) - return expected + if parts[0] not in ENVS: + raise SystemExit(f"{path}:{lineno}: 알 수 없는 환경 '{parts[0]}' (가능: {', '.join(ENVS)}): {raw}") + entries.append(tuple(parts)) + return entries + + +def parse_expect(path, env): + return [(action, pattern) for e, action, pattern in parse_expect_all(path) if e == env] + + +def cmd_check_envs(args): + """환경별 plan job 은 자기 환경 줄만 대조하므로, plan 대상이 아닌 환경에 선언한 의도는 아무도 검사하지 않는다. + 그런 선언을 조용히 버리지 않도록 별도로 확인한다.""" + declared = sorted({e for e, _, _ in parse_expect_all(args.expect)}) + unplanned = [e for e in declared if e not in args.planned] + for env in unplanned: + print(f" 의도에 선언했지만 plan 대상이 아닌 환경: {env} (이 변경으로 해당 환경 코드가 바뀌지 않았거나 대상에서 빠짐)") + return 3 if unplanned else 0 def cmd_expect(args): @@ -195,13 +224,23 @@ def main(): p.add_argument("--redact", action="store_true", help="CI 댓글용: 주소의 for_each 키를 가림") p.set_defaults(func=cmd_expect) + p = sub.add_parser("check-envs") + p.add_argument("--expect", required=True) + p.add_argument("--planned", nargs="*", default=[], help="plan 대상 환경 목록") + p.set_defaults(func=cmd_check_envs) + p = sub.add_parser("extract-expect") p.add_argument("--body-env", default="PR_BODY", help="PR 본문이 담긴 환경 변수 이름") p.add_argument("--out", required=True) p.set_defaults(func=cmd_extract_expect) args = parser.parse_args() - sys.exit(args.func(args)) + try: + sys.exit(args.func(args)) + except UnsupportedPlan as e: + # stdout 으로 내보내 CI 댓글과 로컬 출력 모두에 실패 사유가 보이게 한다. + print(f" {e}") + sys.exit(2) if __name__ == "__main__": diff --git a/scripts/verify/tests/test_plan_check.py b/scripts/verify/tests/test_plan_check.py index 9851b0d..247fbbe 100644 --- a/scripts/verify/tests/test_plan_check.py +++ b/scripts/verify/tests/test_plan_check.py @@ -158,6 +158,53 @@ def test_policy_reports_rule_error_on_stdout_as_failure(self): self.assertIn("TEST-BROKEN", result.stdout) self.assertIn("규칙 실행 오류", result.stdout) + def write_plan(self, changes): + path = Path(self.tmp.name) / "custom_plan.json" + path.write_text(json.dumps({"resource_changes": changes}), encoding="utf-8") + return str(path) + + def test_forget_is_reported_as_separate_action(self): + plan = self.write_plan([resource_change("aws_s3_bucket.legacy", ["forget"])]) + + summary = self.run_check("summary", plan, "--markdown") + expect = self.run_check("expect", plan, "--env", "prod", "--expect", self.write_expect("prod update aws_s3_bucket.legacy\n")) + + self.assertEqual(summary.returncode, 0) + self.assertIn("`forget`", summary.stdout) + self.assertIn("state 에서만 제거", summary.stdout) + self.assertIn("의도에 없는 변경: forget aws_s3_bucket.legacy", expect.stdout) + + def test_unknown_action_fails_explicitly_on_stdout(self): + plan = self.write_plan([resource_change('aws_instance.x["secret_key"]', ["teleport"])]) + + result = self.run_check("summary", plan, "--markdown") + + self.assertEqual(result.returncode, 2) + self.assertIn("지원하지 않는 plan action", result.stdout) + self.assertNotIn("secret_key", result.stdout) + + def test_expect_rejects_unknown_environment(self): + expect = self.write_expect("prd update aws_instance.a\n") + + result = self.run_check("expect", str(self.plan), "--env", "prod", "--expect", expect) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("알 수 없는 환경 'prd'", result.stderr) + + def test_check_envs_fails_when_declared_env_is_not_planned(self): + expect = self.write_expect("prod update aws_instance.a\nglobal create aws_s3_bucket.b\n") + + result = self.run_check("check-envs", "--expect", expect, "--planned", "global") + + self.assertEqual(result.returncode, 3) + self.assertIn("plan 대상이 아닌 환경: prod", result.stdout) + + def test_check_envs_passes_when_all_declared_envs_are_planned(self): + expect = self.write_expect("# 변경 없음\nprod update aws_instance.a\n") + + self.assertEqual(self.run_check("check-envs", "--expect", expect, "--planned", "prod", "stage").returncode, 0) + self.assertEqual(self.run_check("check-envs", "--expect", self.write_expect(""), "--planned").returncode, 0) + def test_expect_rejects_malformed_line(self): expect = self.write_expect("prod modify aws_ebs_volume.db_data\n") diff --git a/scripts/verify/verify.sh b/scripts/verify/verify.sh index bc5ab15..658a348 100755 --- a/scripts/verify/verify.sh +++ b/scripts/verify/verify.sh @@ -166,8 +166,9 @@ run_on_host() { if [[ " ${LAYERS[*]} " == *" l2 "* ]]; then # macOS 기본 bash(3.2)는 set -u 에서 빈 배열 전개를 에러로 처리하므로 문자열로 다룬다. - local env_list="$ENVS" detected detect_error="" + local env_list="$ENVS" detected detect_error="" auto=false if [[ -z "$env_list" ]]; then + auto=true if detected="$(detect_changed_envs "$BASE_REF")"; then env_list="$(tr '\n' ' ' <<<"$detected")" echo "==> L2 대상 환경 (자동 감지, 기준 $BASE_REF): ${env_list:-(없음)}" >&2 @@ -176,7 +177,7 @@ run_on_host() { echo "WARN: $detect_error" >&2 fi fi - docker_args+=(-e "VERIFY_L2_ENVS=$env_list" -e "VERIFY_L2_DETECT_ERROR=$detect_error") + docker_args+=(-e "VERIFY_L2_ENVS=$env_list" -e "VERIFY_L2_DETECT_ERROR=$detect_error" -e "VERIFY_L2_AUTO=$auto") # plan 전문은 .verify/l2/ 에 남긴다. (gitignore 대상, 레포 마운트는 읽기 전용) mkdir -p "$REPO_ROOT/.verify"