diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..306f6f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.egg-info/ # Python package metadata (auto-generated) + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Claude Code workspace (documentation and settings) +# These are collaboration/documentation files, not code +CLAUDE.md +.claude/ +memory/ +ARCHITECTURE.md +PROJECT_STRUCTURE.md + +# Environment variables (sensitive) +.env +.env.local +*.pem +*.key +credentials.json +**/iam_to_slack.json + +# AWS/Cloud credentials +~/.aws/ +~/.gcp/ + +# uv (UV package manager) +.venv/ +uv.lock +__pypackages__/ +.uv/ + +# Terraform 상태 & 캐시 +**/.terraform/ +**/.terraform.lock.hcl +**/terraform.tfstate +**/terraform.tfstate.backup +**/*.tfplan + +# 빌드 산출물 (null_resource가 생성) +**/.build/ +**/dist/ + +# 실제 변수값 (민감 정보 포함) +**/terraform.tfvars +**/*.auto.tfvars diff --git a/README.md b/README.md index ed14846..89e77b8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,139 @@ # cloud-usage -A repository to store source codes to monitor cloud cost and track issues related to cloud usages. +AWS 클라우드 비용 및 리소스 사용 현황을 모니터링하고 Slack으로 리포트를 전송하는 프로젝트. + +--- + +## 환경 설정 + +### 1. 의존성 설치 + +```bash +uv sync +``` + +### 2. 환경변수 + +프로젝트 루트에 `.env` 파일을 생성한다. + +#### 공통 + +| 키 | 설명 | +|----|------| +| `SLACK_BOT_TOKEN` | Slack Bot OAuth 토큰 | +| `SLACK_CHANNEL_ID` | 리포트를 전송할 채널 ID | +| `AWS_PROFILE` | AWS 프로파일명 | +| `AWS_DEFAULT_REGION` | 기본 AWS 리전 | +| `ACCOUNT_NAME` | Slack 리포트 헤더에 표시할 계정 별칭 | + +#### CUR (Athena) 전용 추가 변수 + +| 키 | 필수 | 설명 | +|----|------|------| +| `ATHENA_OUTPUT_LOCATION` | ✅ | Athena 쿼리 결과 저장 S3 URI (예: `s3://bucket/results/`) | +| `ATHENA_DATABASE` | ⬜ | Athena 데이터베이스명 (기본: `hyu_ddps_logs`) | +| `ATHENA_WORKGROUP` | ⬜ | Athena 워크그룹 (기본: `primary`) | + +> `ATHENA_OUTPUT_LOCATION`은 CUR 데이터 위치가 아닌 Athena **쿼리 결과**가 저장되는 S3 경로다. +> AWS 콘솔 → Athena → Settings → Query result location 값과 동일. + +#### IAM → Slack 사용자 매핑 (DM 발송용, 선택) + +`monitor_v2/iam_to_slack.json` 파일로 관리한다. + +```json +{ + "alice": "U012ABC3456", + "bob": "U098XYZ7890" +} +``` + +파일이 없을 경우 환경변수 `IAM_SLACK_USER_MAP` (JSON 문자열)을 폴백으로 사용한다. + +--- + +## monitor_v2 — 실행 방법 + +### 데이터 소스 비교 + +| 항목 | Cost Explorer (CE) | Athena CUR | +|------|-------------------|------------| +| 데이터 소스 | AWS Cost Explorer API | Athena `hyu_ddps_logs.cur_logs` | +| 추가 환경변수 | 없음 | `ATHENA_OUTPUT_LOCATION` 필요 | +| 쿼리 레퍼런스 | — | `monitor_v2/cost/queries.sql` | +| 메시지 구성 | 동일 | 동일 | +| forecast | CE forecast API | CE forecast API (공통) | + +--- + +### Cost Explorer (CE) 기반 + +#### 터미널 출력 (Slack 발송 없음) + +```bash +uv run python -m monitor_v2.test_cost +uv run python -m monitor_v2.test_ec2 +``` + +#### Slack 전송 + +```bash +# 비용 리포트 +uv run python -m monitor_v2.test_cost_to_slack + +# EC2 리포트 +uv run python -m monitor_v2.test_ec2_to_slack +``` + +--- + +### Athena CUR 기반 + +#### Slack 전송 + +```bash +# 비용 리포트 +uv run python -m monitor_v2.test_cost_cur_to_slack + +# EC2 리포트 +uv run python -m monitor_v2.test_ec2_cur_to_slack +``` + +--- + +### 리포트 전송 내용 (CE·CUR 공통) + +#### 비용 리포트 + +- **Main**: 일일 비용 (당일/전일) + 월 누계 + 이달 예상 + Top 5 서비스 +- **Thread 1**: 전체 서비스 비용 목록 +- **Thread 2**: IAM User별 비용 분석 (당일 / MTD) +- **Thread 3**: 서비스 + 리전별 비용 (당일 / MTD / 예상) + +#### EC2 리포트 + +- **Main**: EC2 비용 (당일/전일/MTD) + 활성 리전 + Top 5 인스턴스 타입 + Top 5 IAM User +- **Thread 1**: 리전별 인스턴스 상세 (On-Demand/Spot × running/stopped/terminated) +- **Thread 2**: 미사용 EBS 볼륨 + Snapshot 목록 +- **Thread 3**: IAM User별 EC2 비용 (당일 / MTD / 이달 예상) + +--- + +## print_test — 실행 방법 + +AWS API 응답 구조 확인용 탐색 스크립트. 모든 명령은 프로젝트 루트에서 실행한다. + +```bash +# EC2 +uv run python -m print_test.ec2.describe_instances +uv run python -m print_test.ec2.describe_volumes + +# Cost Explorer +uv run python -m print_test.cost_explorer.get_cost_and_usage + +# CloudTrail +uv run python -m print_test.cloudtrail.lookup_events + +# Lambda +uv run python -m print_test.lambda_fn.list_functions +``` diff --git a/idea/slack_message_design.md b/idea/slack_message_design.md new file mode 100644 index 0000000..eb506be --- /dev/null +++ b/idea/slack_message_design.md @@ -0,0 +1,591 @@ +# Slack 비용 모니터링 메시지 설계 + +> 목적: 연구원들이 단일 AWS 계정 내 다중 region에서 사용하는 리소스 비용을 매일 1회 Slack으로 공유. +> 기준: 실서비스(CloudZero, nOps, Finout, AWS Cost Explorer 등) 조사 인사이트 반영. +> +> **메시지 구조 개요**: 채널에 2개의 독립된 Main 메시지가 순차 발송됨. +> - **Main 1**: 전체 비용 요약 + Top 5 서비스 (비용 개요) +> - **Main 2**: EC2 전용 상세 리포트 (인스턴스 단위 분석) + +--- + +## 설계 원칙 + +| 원칙 | 이유 | +|------|------| +| **Main은 간결** | 채널이 지저분해지면 아무도 안 읽음 (CloudZero 사례) | +| **Thread는 상세** | 궁금한 사람만 펼쳐보는 구조 (nOps 사례) | +| **비교가 핵심** | 절대 금액보다 "오늘이 어제보다 얼마나 달라졌는가"가 핵심 행동 유도 | +| **Top 5 중심** | 80% 이상의 비용은 5개 서비스에서 발생 (파레토 원칙) | +| **이상만 강조** | 정상 범위면 안 읽어도 됨, 이상 시에만 눈에 띄어야 함 | +| **단일 계정 기준** | 다중 계정 비교 불필요, 구조 단순화로 가독성 향상 | +| **책임 소재 명시** | IAM User별 분류로 누가 무엇을 얼마나 쓰는지 투명하게 공유 | +| **낭비 리소스 개인 알림** | 채널 공지보다 당사자 DM이 행동 유도에 효과적 | + +--- + +## Main 1 메시지 설계 — 전체 비용 요약 + +> 채널에 노출되는 첫 번째 메시지. 30초 안에 오늘 전체 비용 상황을 파악할 수 있어야 함. + +### 구성 요소 + +- **헤더**: 보고 대상 날짜 (어제 날짜 기준) + 계정명 (Account alias) +- **일일 총비용**: 전일 대비 금액 차이 + 퍼센트 변화 + 방향 아이콘 (📈/📉/➡️) +- **월단위 누적**: 이번 달 누계 비용 + 전월 동기 대비 변화율 +- **Top 5 서비스**: 비용 내림차순, 각 서비스별 전일 대비 변화율 + 전월 동기 대비 변화율 +- **이상 마커**: 전일 대비 20% 이상 증가 서비스에 ⚠️ 표시 +- **Thread 안내**: "자세한 내용은 스레드에서 확인" 한 줄 + +### Main 1 메시지 예시 + +``` +📊 AWS Cost Report — 2026-03-25 +계정: hyu-ddps + +일일 비용: $123.45 (어제 $110.00 / +$13.45 / +12.2% 📈) +월 누계: $892.00 (전월 동기 $850.00 / +$42.00 / +4.9%) + +───────────────────────────────────────────── +🏆 Top 5 서비스 + +1. AmazonEC2 $78.00 +3.1% vs 어제 +2.5% vs 전월동기 +2. AmazonS3 $20.00 -1.5% vs 어제 -0.8% vs 전월동기 +3. AWSLambda $9.00 ±0.0% vs 어제 +5.0% vs 전월동기 +4. AmazonRDS $8.00 +50.0% vs 어제 ⚠️ +1.2% vs 전월동기 +5. AmazonCloudWatch $4.00 +2.0% vs 어제 ±0.0% vs 전월동기 +───────────────────────────────────────────── +💬 자세한 내용은 스레드에서 확인하세요. +``` + +--- + +## Main 1 — Thread 메시지 설계 + +> Main 1 아래에 순차 발송되는 스레드. 전체 비용 요약 및 서비스별 분석. + +--- + +### Thread 1: hyu-ddps 계정의 모든 서비스 비용 + +**포함 정보:** +- 오늘 총비용 +- 어제 총비용 + 차이 (금액 + %) + +``` +📋 hyu-ddps 계정 서비스 비용 + +오늘: $123.45 +어제: $110.00 (+$13.45 / +12.2% 📈) +``` + +--- + +### Thread 2: IAM User별 비용 분석 + +> 각 IAM User가 해당 날짜에 사용한 총비용을 내림차순으로 나열하고, 각 User별로 비용이 높은 리소스를 드릴다운. + +**구성 요소:** +- 👤 IAM User 식별자 (태그 기반) + 해당 User의 일일 총비용 (내림차순 정렬) +- 각 User 아래: 해당 User가 사용한 서비스 리소스를 비용 내림차순으로 리스트업 +- 리소스명(서비스명) + 비용 표시 +- 태그 미설정 리소스는 별도 항목으로 집계 + +``` +👥 IAM User별 비용 분석 + +👤 kim@ddps.cloud 합계: $55.00 + ├─ AmazonEC2 $48.00 + ├─ AmazonS3 $5.00 + └─ AmazonCloudWatch $2.00 + +👤 park@ddps.cloud 합계: $40.00 + ├─ AmazonEC2 $30.00 + ├─ AmazonRDS $8.00 + └─ AmazonS3 $2.00 + +👤 lee@ddps.cloud 합계: $20.00 + ├─ AmazonEC2 $18.00 + └─ AWSLambda $2.00 + +👤 (태그 없음 / 공용) 합계: $8.45 + ├─ AmazonCloudWatch $4.00 + ├─ AWSLambda $3.00 + └─ AmazonS3 $1.45 +``` + +--- + +### Thread 3: EC2 외 서비스 상세 + +> EC2를 제외한 모든 AWS 서비스의 상세 내역. 서비스별 총 비용을 내림차순으로 나열하고, 각 서비스 아래에 활성 region별 비용을 내림차순으로 표시. + +**계층 구조:** +``` +서비스명 + 총 비용 +└── 활성 region (비용 높은 순) + └── region별 비용 +``` + +``` +🔍 EC2 외 서비스 상세 + +📌 AmazonS3 $20.00 -1.5% vs 어제 + ├─ ap-northeast-2 $12.00 + ├─ us-east-1 $6.00 + └─ eu-west-1 $2.00 + +📌 AWSLambda $9.00 ±0.0% vs 어제 + ├─ ap-northeast-2 $7.00 + └─ us-east-1 $2.00 + +📌 AmazonRDS $8.00 +50.0% vs 어제 ⚠️ + └─ ap-northeast-2 $8.00 + +📌 AmazonCloudWatch $4.00 +2.0% vs 어제 + ├─ ap-northeast-2 $2.50 + └─ us-east-1 $1.50 + +📌 AmazonVPC $1.45 ±0.0% vs 어제 + └─ ap-northeast-2 $1.45 +``` + +--- + +## Main 2 메시지 설계 — EC2 전용 리포트 + +> Main 1과 별개로 채널에 독립 발송되는 두 번째 메시지. +> EC2는 정보량이 많아 Main 1 스레드에 넣으면 가독성이 떨어지므로 별도 Main으로 분리. +> **Main 2도 핵심만 담는다**: 총 비용 + 활성 region + 비용 Top 5 인스턴스만. + +### 구성 요소 + +- **헤더**: EC2 전용 리포트임을 명시 + 날짜 +- **EC2 총 비용**: 전일 대비 금액 차이 + 변화율 +- **활성 region 요약**: 비용이 발생한 region 목록 + region별 소계 (간결, 금액 내림차순) +- **Top 5 인스턴스 타입**: instance_type 기준 집계 비용 상위 5개 +- **Thread 안내**: "전체 인스턴스 상세는 스레드에서 확인" + +### Top 5 인스턴스 타입에 포함할 정보 + +| 항목 | 표시 이유 | +|------|----------| +| instance_type | 비용 단가 파악 (t3 vs p3 차이가 큼) | +| 인스턴스 수 | 해당 타입의 활성 인스턴스 개수 | +| 합산 비용 | 해당 타입 전체의 일 비용 합계 | + +### Main 2 메시지 예시 + +``` +🖥️ EC2 Instance Report — 2026-03-25 +계정: hyu-ddps + +EC2 총 비용: $78.00 (어제 $75.60 / +$2.40 / +3.1% 📈) + +───────────────────────────────────────────── +📍 활성 Region + + ap-northeast-2 (서울) $50.00 + us-east-1 (버지니아) $28.00 + +───────────────────────────────────────────── +🏆 Top 5 인스턴스 타입 (비용 기준) + +1. p3.2xlarge 1대 $28.00 +2. g5.2xlarge 1대 $28.00 +3. t3.large 1대 $15.00 +4. g4dn.xlarge 1대 $4.00 +5. t3.micro 1대 $2.00 +───────────────────────────────────────────── +💬 전체 인스턴스 상세는 스레드에서 확인하세요. +``` + +--- + +## Main 2 — Thread 메시지 설계 + +> Main 2 아래에 순차 발송되는 스레드. Main 2에서 보여준 Top 5 외 전체 인스턴스를 포함한 상세 정보. +> region → 구매 유형 → 상태 → 인스턴스 단위로 드릴다운. + +--- + +### Thread 1: 전체 인스턴스 상세 + +> Main 2에서 Top 5만 요약 표시했으므로, Thread에서는 모든 인스턴스를 계층 구조로 전부 표시. +> EC2 비용 규모가 가장 크고 변수가 많으므로 가장 상세한 계층 구조로 표시. + +**계층 구조:** +``` +EC2 총 비용 +└── 활성 region (비용 높은 순) + └── On-Demand / Spot 분류 + └── instance_state (running / stopped / terminated) + └── 인스턴스별 정보 + - 비용 + - instance_name + - instance_id + - instance_type + - 실행 시간 (h:m:s 포맷) + └── 잔존 EBS / Snapshot 존재 여부 리스트 +``` + +**`start` 상태 포함 여부:** +- `start`는 AWS 공식 instance_state값이 아니므로 **표시 대상에서 제외** +- 전환 중 과도 상태(`pending`, `stopping`, `shutting-down`)는 수명이 짧고 비용이 미미하므로 **별도 항목 없이 인접 상태에 귀속** +- **최종 표시 상태: `running` / `stopped` / `terminated` 3가지** + +**조건부 DM 알림 발송 기준:** +- `stopped` 상태가 **24시간 이상** 경과한 인스턴스 → 해당 IAM User에게 DM 발송 +- 잔존 EBS 또는 Snapshot이 존재 → 해당 IAM User에게 DM 발송 + +``` +📌 AmazonEC2 총 비용: $78.00 + +┌───────────────────────────────────────────────────── +│ 📍 ap-northeast-2 (서울) $50.00 +├───────────────────────────────────────────────────── +│ +│ [On-Demand] +│ +│ ▶ running +│ 🔸 $28.00 | my-gpu-server | i-0a1b2c3d4e | p3.2xlarge | 12:34:56 +│ 👤 park@ddps.cloud +│ 잔존 리소스: EBS 200GB (gp3) — $10.00 +│ ⚠️ DM 알림 대상 +│ +│ 🔸 $15.00 | research-worker-01 | i-0f1e2d3c4b | t3.large | 08:12:00 +│ 👤 kim@ddps.cloud +│ 잔존 리소스: 없음 +│ +│ ▶ stopped ⚠️ DM 알림 대상 +│ 🔸 $0.00 | dev-instance-kim | i-0b2c3d4e5f | t3.medium | 00:00:00 +│ 👤 kim@ddps.cloud +│ 잔존 리소스: EBS 100GB (gp2) — $5.00 +│ Snapshot 2개 — $0.80 +│ ⚠️ DM 알림 대상 +│ +│ ▶ terminated +│ 🔸 $2.00 | old-test-server | i-0c3d4e5f6a | t3.micro | 04:20:00 +│ 👤 lee@ddps.cloud +│ 잔존 리소스: Snapshot 1개 — $0.40 +│ ⚠️ DM 알림 대상 +│ +│ [Spot] +│ +│ ▶ running +│ 🔸 $4.00 | spot-train-01 | i-0d4e5f6a7b | g4dn.xlarge | 02:10:33 +│ 👤 lee@ddps.cloud +│ 잔존 리소스: 없음 +│ +└───────────────────────────────────────────────────── + +┌───────────────────────────────────────────────────── +│ 📍 us-east-1 (버지니아) $28.00 +├───────────────────────────────────────────────────── +│ +│ [On-Demand] +│ +│ ▶ running +│ 🔸 $28.00 | nlp-experiment | i-0e5f6a7b8c | g5.2xlarge | 06:44:10 +│ 👤 park@ddps.cloud +│ 잔존 리소스: 없음 +│ +└───────────────────────────────────────────────────── +``` + +#### IAM User 식별 방법 + +> `describe_instances()` 응답의 `OwnerId`는 AWS Account ID(12자리 숫자)이며 IAM User를 구별하지 않는다. +> 인스턴스를 실행한 IAM User는 **CloudTrail `lookup_events` + `RunInstances` 이벤트**를 통해 식별한다. + +**조회 흐름:** +``` +1. describe_instances() → 인스턴스 ID 목록 수집 +2. CloudTrail lookup_events(EventName=RunInstances, 최대 60일 이내) + → userIdentity.userName = IAM username (예: "kim") + → Resources[].ResourceName = 인스턴스 ID (예: "i-0b2c3d4e5f") +3. 인스턴스 ID 매칭 → IAM username 확보 +4. IAM username → IAM_SLACK_USER_MAP → Slack User ID → DM 발송 +``` + +**제약 조건:** +- 이 시스템은 CloudTrail을 **최대 60일 이전**까지만 조회 (비용 및 실용성 고려) +- 60일 초과 인스턴스 → IAM User 표시: `"Unknown (60일 초과)"` +- 60일 초과 인스턴스에 대해서는 DM 발송 불가, Thread에만 `Unknown`으로 기재 + +--- + +### Thread 2: 미사용 리소스 목록 + +> 인스턴스에 연결되지 않은 EBS 볼륨과, AMI에서 참조되지 않는 Snapshot 목록. +> 비용 낭비를 유발하는 리소스를 인지하는 목적이며, 실제 삭제는 수행하지 않음. + +--- + +#### 포함 대상 및 제외 기준 + +**미사용 EBS 볼륨** (`describe_volumes`, `status=available`) + +| 구분 | 조건 | 비고 | +|------|------|------| +| 포함 | `available` 상태 (인스턴스 미연결) | Thread 5에 기재 ⚠️ | +| 제외 | Tags에 `kubernetes.io` 포함 (Kubernetes PVC 볼륨) | 생성 14일 초과 시에는 포함 ☄️ | +| 제외 | Tags에 `aws:backup:source-resource-arn` 포함 (AWS Backup 관리) | 전체 제외 | +| 제외 | 생성 후 1일 미만 | 프로비저닝 중일 가능성 있음 | + +**미사용 EBS Snapshot** (`describe_snapshots`, `OwnerIds=[account_id]`) + +| 구분 | 조건 | 표시 | +|------|------|------| +| 포함 | AMI 미참조 + 태그 없음 + `completed` | ⚠️ 명확한 정리 대상 | +| 포함 | AMI 미참조 + 태그 있음 + `completed` | ☄️ 확인 권장 수준 | +| 제외 | `describe_images()` 수집 AMI의 `BlockDeviceMappings.SnapshotId`에 포함 | 제외 — 삭제 시 AMI 손상 | +| 제외 | Tags에 `aws:backup:source-resource-arn` 포함 | 제외 | +| 제외 | Description에 `"Created by AWS Backup"` 포함 | 제외 | +| 제외 | State가 `pending` | 제외 — 생성 중 | + +> 방치 기간이 **60일 이상**인 Snapshot은 🚨 으로 강조 표시 + +--- + +#### Thread 5 메시지 예시 + +``` +[미사용 리소스 목록] + +💾 미사용 EBS 볼륨 (3개) + ap-northeast-2 | vol-0a1b2c3d | gp3 | 100GB | 45일간 미사용 ⚠️ + ap-northeast-2 | vol-0b2c3d4e | gp2 | 50GB | 20일간 미사용 ⚠️ + us-east-1 | vol-0c3d4e5f | gp3 | 200GB | 3일간 미사용 ☄️ (kubernetes) + +📸 미사용 Snapshot (4개) + ap-northeast-2 | snap-0a1b2c3d | 100GB | 72일간 미사용 🚨 ← 60일 이상 방치 + ap-northeast-2 | snap-0b2c3d4e | 50GB | 30일간 미사용 ⚠️ + ap-northeast-2 | snap-0c3d4e5f | 80GB | 15일간 미사용 ☄️ ← 태그 있음, 확인 권장 + us-east-1 | snap-0d4e5f6a | 20GB | 5일간 미사용 ⚠️ +``` + +--- + +## 개인 DM 알림 설계 + +### IAM User 식별 및 Slack 매핑 전략 + +#### IAM User 식별 + +`describe_instances()` 응답만으로는 인스턴스를 실행한 IAM User를 알 수 없다. +**CloudTrail `lookup_events` + `RunInstances` 이벤트**로 IAM username을 역추적한다. + +``` +인스턴스 ID + → CloudTrail lookup_events(EventName=RunInstances, 최대 60일 이내) + → userIdentity.userName = IAM username (예: "kim") +``` + +- CloudTrail 조회 범위: 최대 **60일 이전**까지 +- 60일 초과 인스턴스: DM 발송 불가, Thread에 `"Unknown (60일 초과)"` 표시 + +#### IAM username → Slack User ID 매핑 + +IAM username과 Slack User ID는 **dict 형태의 매핑 테이블**로 관리한다. +Lambda 환경변수 `IAM_SLACK_USER_MAP`에 JSON으로 저장: + +```json +{ + "kim": "U0123ABC", + "park": "U0456DEF", + "lee": "U0789GHI" +} +``` + +> **운영 주의사항**: 신규 연구원이 합류하거나 퇴사 시 이 매핑 테이블을 **수동으로 추가/삭제**해야 한다. +> 자동 동기화 메커니즘은 현재 없으며, 매핑 누락 시 해당 User에 대한 DM은 skip되고 Thread에 "매핑 없음" 로그가 기재된다. + +--- + +### 발송 조건 + +| 조건 | DM 수신 대상 | +|------|-------------| +| EC2 인스턴스가 `stopped` 상태로 **24시간 이상** 경과한 경우 | CloudTrail 기반 IAM User | +| `stopped` 또는 `terminated` 인스턴스에 잔존 EBS가 있는 경우 | CloudTrail 기반 IAM User | +| `stopped` 또는 `terminated` 인스턴스에 잔존 Snapshot이 있는 경우 | CloudTrail 기반 IAM User | + +> 동일 IAM User에게 여러 조건이 해당하는 경우, 하나의 DM으로 통합 발송. + +--- + +### DM에 포함할 정보 + +- 대상 리소스 유형 (인스턴스 / EBS / Snapshot) +- 리소스 식별자 (instance_id, volume_id, snapshot_id 등) +- 리소스가 위치한 region +- 해당 리소스로 인해 발생 중인 일 비용 +- 월 기준 절감 예상액 (일 비용 × 30) +- 권고 행동 (간결하게 1줄) + +--- + +### DM 메시지 예시 + +**케이스 1 — stopped 인스턴스 + 잔존 EBS/Snapshot이 모두 있는 경우** + +``` +⚠️ [AWS 비용 알림] 낭비 리소스가 감지되었습니다 — 2026-03-25 + +안녕하세요, kim 님. +아래 리소스에서 불필요한 비용이 발생 중입니다. + +───────────────────────────────────────── +🛑 정지된 EC2 인스턴스 + 인스턴스: dev-instance-kim (i-0b2c3d4e5f) + 타입: t3.medium + Region: ap-northeast-2 (서울) + 상태: stopped (36시간 경과) + +💾 잔존 EBS 볼륨 + 볼륨 ID: vol-0a1b2c3d4e (100GB / gp2) + Region: ap-northeast-2 (서울) + 일 비용: $5.00 + +📸 잔존 Snapshot + Snapshot: snap-0f1e2d3c4b / snap-0e2f3a4b5c + Region: ap-northeast-2 (서울) + 일 비용: $0.80 + +───────────────────────────────────────── +💡 위 리소스를 삭제하면 월 약 $174.00 절감이 가능합니다. +───────────────────────────────────────── +``` + +**케이스 2 — 잔존 Snapshot만 있는 경우 (terminated 인스턴스)** + +``` +⚠️ [AWS 비용 알림] 낭비 리소스가 감지되었습니다 — 2026-03-25 + +안녕하세요, lee 님. +아래 리소스에서 불필요한 비용이 발생 중입니다. + +───────────────────────────────────────── +📸 잔존 Snapshot (terminated 인스턴스 연관) + Snapshot: snap-0c3d4e5f6a + 연관 인스턴스: old-test-server (i-0c3d4e5f6a) — terminated + Region: ap-northeast-2 (서울) + 일 비용: $0.40 + +───────────────────────────────────────── +💡 위 리소스를 삭제하면 월 약 $12.00 절감이 가능합니다. +───────────────────────────────────────── +``` + +--- + +## 리소스 삭제 조치 (미정) + +> AMI 삭제, EBS Snapshot 삭제, EBS Volume 삭제를 Slack 인터페이스 내에서 어떻게 구현할 것인지 아직 결정되지 않았다. +> +> **고려 중인 조치 대상:** +> - disabled / deprecated AMI deregister +> - 미사용 EBS Snapshot 삭제 (`aws_delete_snapshot.py` 기존 로직 참고) +> - 미사용 EBS Volume 삭제 +> +> **미결 질문:** +> - Slack Block Kit의 버튼(Button) 액션으로 삭제 요청을 트리거하는 방식이 적합한가? +> - 삭제 전 확인 단계(confirm dialog)를 어떻게 구성할 것인가? +> - 삭제 권한을 Lambda IAM Role에 부여하는 것이 보안 측면에서 적절한가? +> - 삭제 이력을 어디에 기록할 것인가? (CloudTrail만으로 충분한가?) + +--- + +## 전체 메시지 흐름 다이어그램 + +``` +[Slack 채널 — 매일 1회, 2개의 독립 메시지 순차 발송] +│ +├── 📊 Main 1 — 전체 비용 요약 +│ ├── 헤더: 날짜 + 계정명 + 일일 총비용 +│ ├── 전일 대비 변화 (금액 + % + 📈/📉/➡️) +│ ├── Top 5 서비스 (전일 대비 % / 전월동기 대비 %) +│ │ └── 이상 서비스에 ⚠️ 표시 +│ └── "자세한 내용은 스레드 참조" 안내 +│ +│ └── 💬 Main 1 Thread +│ ├── Thread 1: 계정 전체 요약 +│ │ ├── 오늘 / 어제 비용 + 차이 +│ │ ├── 7일 평균 +│ │ └── 이번달 누계 vs 전월 동기 +│ │ +│ ├── Thread 2: IAM User별 비용 분석 +│ │ ├── 👤 User-A 합계: $XX (내림차순) +│ │ │ └── 서비스명 + 비용 (내림차순) +│ │ ├── 👤 User-B 합계: $XX +│ │ │ └── 서비스명 + 비용 (내림차순) +│ │ └── 👤 (태그 없음 / 공용) +│ │ +│ ├── Thread 3: EC2 외 서비스 상세 +│ │ └── 📌 서비스명 + 총 비용 (내림차순) +│ │ └── region별 비용 (내림차순) +│ │ +│ └── Thread 4: 이상 감지 (조건 충족 시에만 발송) +│ └── 이상 서비스 + 변화율 + 30일 평균 대비 + 관련 User +│ +└── 🖥️ Main 2 — EC2 전용 리포트 (핵심만) + ├── 헤더: EC2 전용 날짜 + ├── EC2 총 비용 + 전일 대비 변화 + ├── 활성 region 목록 + region별 소계 (내림차순) + ├── Top 5 인스턴스 한 줄 요약 + │ └── instance_name | instance_type | AMI | IAM user | 비용 | 실행시간 | EBS 여부 + └── "전체 인스턴스 상세는 스레드 참조" 안내 + + └── 💬 Main 2 Thread + ├── Thread 1 (EC2): 전체 인스턴스 상세 + │ ├── IAM User 식별: CloudTrail RunInstances (최대 60일) + │ │ └── 60일 초과 인스턴스: "Unknown (60일 초과)" 표시 + │ ├── EC2 총 비용 + │ └── 📍 region (비용 높은 순) + │ ├── [On-Demand] + │ │ ├── ▶ running → 인스턴스 목록 + 잔존 리소스 + │ │ ├── ▶ stopped → 인스턴스 목록 + 잔존 리소스 + ⚠️ DM 대상 (24h 이상) + │ │ └── ▶ terminated → 인스턴스 목록 + 잔존 리소스 + │ └── [Spot] + │ ├── ▶ running + │ └── ▶ terminated + │ + └── Thread 5 (EC2): 미사용 리소스 목록 + ├── 💾 미사용 EBS 볼륨 목록 (kubernetes 14일↑, AWS Backup 제외) + └── 📸 미사용 Snapshot 목록 (AMI 참조, AWS Backup, pending 제외) + └── 60일 이상 방치 → 🚨 강조 + +[개인 DM — 조건 충족 시에만 발송] +│ +└── ⚠️ DM (CloudTrail 기반 IAM User → IAM_SLACK_USER_MAP → Slack User ID) + ├── 발송 조건 1: stopped 인스턴스 24시간 이상 경과 + ├── 발송 조건 2: 잔존 EBS 존재 + └── 발송 조건 3: 잔존 Snapshot 존재 + └── 리소스 식별자 + region + 일 비용 + 월 절감 예상액 + (매핑 없는 User: skip + Thread에 "매핑 없음" 로그 기재) +``` + +--- + +## 실서비스 인사이트 반영 테이블 + +| 서비스 | 참고한 아이디어 | 적용 위치 | +|--------|----------------|----------| +| **CloudZero** | Main은 요약만, Thread에 상세 분리 | 전체 구조 | +| **CloudZero** | "이상 감지" 시에만 강조 표시 | ⚠️ 마커, Thread 4 | +| **nOps** | 심각도 레벨 구분 (±5% / 20% 기준) | 변화율 아이콘 및 ⚠️ 기준 | +| **nOps** | IAM User @-mention으로 책임 소재 명시 | Main 1 Thread 2, Main 2 Thread 1의 👤 표시 | +| **nOps** | 당사자에게 직접 개인 알림 발송 | 개인 DM 알림 설계 전체 | +| **AWS Cost Explorer** | Usage Type으로 On-Demand / Spot 구분 | Main 2 Thread 1 EC2 상세 | +| **AWS Cost Explorer** | instance_state별 분류 | Main 2 Thread 1 running / stopped / terminated 계층 | +| **AWS Budgets** | 월 누계와 전월 동기 비교 | Thread 1 | +| **Kubecost** | 색상(아이콘) 기반 심각도 시각화 | 📈/📉/➡️/⚠️ 마커 | +| **Finout** | 모든 리소스를 동일 포맷으로 정규화 | Thread 4 서비스 통일 포맷 | +| **aws-billing-to-slack** | 지난달 vs 이번달 누계 비교 | Thread 1 | +| **내부 설계 신규** | IAM User별 총비용 집계 + 드릴다운 | Thread 2 전체 | +| **내부 설계 신규** | CloudTrail 기반 IAM User 역추적 | Main 2 Thread 1 IAM 식별, 개인 DM | +| **내부 설계 신규** | IAM username ↔ Slack User ID dict 매핑 (수동 관리) | 개인 DM IAM_SLACK_USER_MAP | +| **내부 설계 신규** | 잔존 EBS/Snapshot 발견 시 당사자 DM | 개인 DM 알림 설계 | +| **내부 설계 신규** | 미사용 EBS/Snapshot 별도 Thread 5로 분리 | Main 2 Thread 5 | +| **내부 설계 신규** | 단일 계정 기준 단순화 | Main 메시지 + Thread 1 구조 | diff --git a/monitor_v2/__init__.py b/monitor_v2/__init__.py new file mode 100644 index 0000000..4911d53 --- /dev/null +++ b/monitor_v2/__init__.py @@ -0,0 +1 @@ +# monitor_v2 패키지 diff --git a/monitor_v2/cost/__init__.py b/monitor_v2/cost/__init__.py new file mode 100644 index 0000000..24d7116 --- /dev/null +++ b/monitor_v2/cost/__init__.py @@ -0,0 +1 @@ +# cost 패키지 diff --git a/monitor_v2/cost/data.py b/monitor_v2/cost/data.py new file mode 100644 index 0000000..d540a41 --- /dev/null +++ b/monitor_v2/cost/data.py @@ -0,0 +1,282 @@ +""" +monitor_v2/cost/data.py + +Cost Explorer 데이터 수집 모듈. + +수집 대상: + 1. 일일 서비스별 비용 (D-1 / D-2) → Main 1 요약 + 2. 서비스 + aws:createdBy 태그별 비용 (D-1 + MTD) → Thread 2 (IAM User별) + 3. 서비스 + 리전별 비용 (D-1 + MTD) → Thread 3 (서비스 리전 상세) + 4. MTD 총비용 → Main 1 헤더 월 누계 + 5. 잔여 예측 비용 (전체 합계 + 서비스별) → Main 1 / Thread 3 예측 +""" +from pprint import pprint + +import boto3 +from datetime import date, timedelta +from calendar import monthrange +import logging + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# 날짜 계산 헬퍼 +# --------------------------------------------------------------------------- + +def _build_day_period(base_date: date, days_ago: int) -> dict: + """D-N 하루 TimePeriod. End는 exclusive.""" + target = base_date - timedelta(days=days_ago) + #print("start", target.strftime('%Y-%m-%d')) + #print("end", (target + timedelta(days=1)).strftime('%Y-%m-%d')) + + return { + 'Start': target.strftime('%Y-%m-%d'), + 'End': (target + timedelta(days=1)).strftime('%Y-%m-%d'), + } + + +def _build_mtd_period(base_date: date) -> dict: + """당월 1일 ~ base_date (exclusive).""" + start = base_date.replace(day=1) + return { + 'Start': start.strftime('%Y-%m-%d'), + 'End': base_date.strftime('%Y-%m-%d'), + } + + + +# --------------------------------------------------------------------------- +# API 호출 및 파싱 +# --------------------------------------------------------------------------- + +def _parse_first_groups(resp: dict) -> list: + """ResultsByTime[0].Groups 안전 추출.""" + return resp.get('ResultsByTime', [{}])[0].get('Groups', []) + + +def fetch_daily_by_service(ce, period: dict) -> dict: + """{service: float}""" + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost'], + GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}], + ) + return { + g['Keys'][0]: float(g['Metrics']['UnblendedCost']['Amount']) + for g in _parse_first_groups(resp) + } + + +def fetch_daily_by_service_and_creator(ce, period: dict) -> dict: + """ + SERVICE + aws:createdBy 태그별 비용. + + GroupBy 2개 제한 준수 (DIMENSION + TAG). + + Returns: + {service: {creator_label: float}} + 미태깅 리소스의 creator_label = '(태그 없음 / 공용)' + """ + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + {'Type': 'TAG', 'Key': 'aws:createdBy'}, + ], + ) + result = {} + for group in _parse_first_groups(resp): + service = group['Keys'][0] + raw_tag = group['Keys'][1] # "aws:createdBy$" + creator = raw_tag.split('$', 1)[1] if '$' in raw_tag else raw_tag + creator = creator or 'aws:createdBy 태그 없음' + amount = float(group['Metrics']['UnblendedCost']['Amount']) + result.setdefault(service, {}) + result[service][creator] = result[service].get(creator, 0.0) + amount + return result + + +def fetch_daily_by_service_and_region(ce, period: dict) -> dict: + """ + SERVICE + REGION별 비용. EC2 외 서비스 Thread 3용. + + Returns: + {service: {region: float}} + """ + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + {'Type': 'DIMENSION', 'Key': 'REGION'}, + ], + ) + result = {} + for group in _parse_first_groups(resp): + service = group['Keys'][0] + region = group['Keys'][1] or 'global' + amount = float(group['Metrics']['UnblendedCost']['Amount']) + result.setdefault(service, {}) + result[service][region] = result[service].get(region, 0.0) + amount + return result + + +def fetch_mtd_by_service_and_creator(ce, period: dict) -> dict: + """ + MTD: SERVICE + aws:createdBy → {service: {creator: float}}. + 기간이 비면(당월 1일 실행) {} 반환. + """ + if period['Start'] >= period['End']: + return {} + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + {'Type': 'TAG', 'Key': 'aws:createdBy'}, + ], + ) + result = {} + for group in _parse_first_groups(resp): + service = group['Keys'][0] + raw_tag = group['Keys'][1] + creator = raw_tag.split('$', 1)[1] if '$' in raw_tag else raw_tag + creator = creator or 'aws:createdBy 태그 없음' + amount = float(group['Metrics']['UnblendedCost']['Amount']) + result.setdefault(service, {}) + result[service][creator] = result[service].get(creator, 0.0) + amount + return result + + +def fetch_mtd_by_service_and_region(ce, period: dict) -> dict: + """ + MTD: SERVICE + REGION → {service: {region: float}}. + 기간이 비면(당월 1일 실행) {} 반환. + """ + if period['Start'] >= period['End']: + return {} + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + {'Type': 'DIMENSION', 'Key': 'REGION'}, + ], + ) + result = {} + for group in _parse_first_groups(resp): + service = group['Keys'][0] + region = group['Keys'][1] or 'global' + amount = float(group['Metrics']['UnblendedCost']['Amount']) + result.setdefault(service, {}) + result[service][region] = result[service].get(region, 0.0) + amount + return result + + + +def fetch_cost_forecast(ce, today_kst: date) -> float: + """ + 오늘부터 이달 말일까지 예상 비용 (UNBLENDED_COST, MONTHLY 예측). + + CE 예측 API가 실패하면(데이터 부족 등) 0.0 반환. + """ + last_day = monthrange(today_kst.year, today_kst.month)[1] + end_date = date(today_kst.year, today_kst.month, last_day) + timedelta(days=1) + + period = { + 'Start': today_kst.strftime('%Y-%m-%d'), + 'End': end_date.strftime('%Y-%m-%d'), + } + try: + resp = ce.get_cost_forecast( + TimePeriod=period, + Metric='UNBLENDED_COST', + Granularity='MONTHLY', + PredictionIntervalLevel=80, + ) + return float(resp.get('Total', {}).get('Amount', '0')) + except Exception as exc: + log.warning("get_cost_forecast 실패 (무시): %s", exc) + return 0.0 + + +def fetch_mtd_total(ce, period: dict) -> float: + """ + MTD 기간 총 비용 (GroupBy 없이 전체 합산). + + Start == End (당월 1일에 Lambda 실행) 이면 0 반환. + """ + if period['Start'] >= period['End']: + return 0.0 + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + ) + total = 0.0 + for item in resp.get('ResultsByTime', []): + total += float( + item.get('Total', {}).get('UnblendedCost', {}).get('Amount', '0') + ) + return total + + +# --------------------------------------------------------------------------- +# 일괄 수집 (lambda_handler에서 1회 호출) +# --------------------------------------------------------------------------- + +def collect_all(today_kst: date) -> dict: + """ + Main 1 + 스레드 전체에 필요한 Cost Explorer 데이터를 수집한다. + + CE 데이터 지연: + Cost Explorer는 약 24~48시간 지연이 있어 당일/전일 데이터가 미집계 상태일 수 있다. + 따라서 리포트 기준일(d1_date)은 today_kst - 2일로 고정하고, + forecast만 현재 날짜(today_kst) 기준으로 조회한다. + + Returns: + { + 'd1_date': date, # 리포트 대상일 (today - 2일) + 'daily_d1': dict, # {service: float} d1_date + 'daily_d2': dict, # {service: float} d1_date - 1일 + 'by_creator': dict, # {service: {creator: float}} d1_date + 'by_creator_mtd': dict, # {service: {creator: float}} MTD (d1_date 기준 월) + 'by_region': dict, # {service: {region: float}} d1_date + 'by_region_mtd': dict, # {service: {region: float}} MTD (d1_date 기준 월) + 'mtd_this': float, # d1_date 기준 월 MTD 총비용 + 'forecast': float, # today_kst~말일 잔여 예상 비용 (0.0이면 예측 불가) + } + ※ CE forecast API는 GroupBy 미지원 → 서비스별 예측은 MTD 비율로 비례 배분 (report.py) + """ + ce = boto3.client('ce', region_name='us-east-1') + + # CE 데이터 2일 지연 → 리포트 기준일을 today - 2일로 설정 + d1_date = today_kst - timedelta(days=2) + + period_d1 = _build_day_period(today_kst, days_ago=2) + period_d2 = _build_day_period(today_kst, days_ago=3) + + # MTD: d1_date 기준 월 1일 ~ d1_date 포함 (End exclusive = d1_date + 1) + period_mtd_this = { + 'Start': d1_date.replace(day=1).strftime('%Y-%m-%d'), + 'End': (d1_date + timedelta(days=1)).strftime('%Y-%m-%d'), + } + + return { + 'd1_date': d1_date, + 'daily_d1': fetch_daily_by_service(ce, period_d1), + 'daily_d2': fetch_daily_by_service(ce, period_d2), + 'by_creator': fetch_daily_by_service_and_creator(ce, period_d1), + 'by_creator_mtd': fetch_mtd_by_service_and_creator(ce, period_mtd_this), + 'by_region': fetch_daily_by_service_and_region(ce, period_d1), + 'by_region_mtd': fetch_mtd_by_service_and_region(ce, period_mtd_this), + 'mtd_this': fetch_mtd_total(ce, period_mtd_this), + 'forecast': fetch_cost_forecast(ce, today_kst), + } diff --git a/monitor_v2/cost/data_cur.py b/monitor_v2/cost/data_cur.py new file mode 100644 index 0000000..baddf33 --- /dev/null +++ b/monitor_v2/cost/data_cur.py @@ -0,0 +1,371 @@ +""" +monitor_v2/cost/data_cur.py + +Athena CUR 쿼리 기반 Cost 데이터 수집 모듈. + +data.py (Cost Explorer API 방식)의 동등 구현. +반환 구조는 data.py 의 collect_all() 과 동일하므로 +report.py 의 send_main1_report() 를 그대로 사용할 수 있다. + +대상 테이블: hyu_ddps_logs.cur_logs +파티션: year (STRING), month (STRING, 두 자리 zero-padded) + +쿼리 대응 (queries.sql): + Q1 fetch_daily_by_service (d1) → daily_d1 + Q2 fetch_daily_by_service (d2) → daily_d2 + Q3 fetch_daily_by_service_and_creator (d1) → by_creator + Q4* fetch_daily_by_service_and_region (d1) → by_region (* SQL 수정: region 컬럼 사용) + Q5 fetch_mtd_by_service_and_creator → by_creator_mtd + Q6 fetch_mtd_by_service_and_region → by_region_mtd + Q7 fetch_mtd_total → mtd_this + Q8 fetch_cost_forecast → CE API 그대로 (data.py 공유) + +환경변수: + ATHENA_DATABASE 쿼리 대상 DB (기본: hyu_ddps_logs) + ATHENA_OUTPUT_LOCATION S3 결과 저장 위치 예: s3://my-bucket/athena-results/ + ATHENA_WORKGROUP Athena 워크그룹 (기본: primary) +""" + +import os +import time +import boto3 +from datetime import date, timedelta +import logging + +from .data import fetch_cost_forecast # Q8: CE API 재사용 + +log = logging.getLogger(__name__) + +_ATHENA_DATABASE = os.environ.get('ATHENA_DATABASE') +_ATHENA_OUTPUT_LOCATION = os.environ.get('ATHENA_OUTPUT_LOCATION') +_ATHENA_WORKGROUP = os.environ.get('ATHENA_WORKGROUP', 'primary') + +_POLL_INTERVAL = 1.5 # seconds +_MAX_WAIT = 120 # seconds + + +# --------------------------------------------------------------------------- +# Athena 실행 헬퍼 +# --------------------------------------------------------------------------- + +def _run_query(athena, sql: str) -> list: + """ + Athena 쿼리를 실행하고 결과를 dict 리스트로 반환한다. + 헤더 행(첫 번째 row) 은 제외한다. + + Returns: + [{'col': 'val', ...}, ...] + """ + start_kwargs = { + 'QueryString': sql, + 'QueryExecutionContext': {'Database': _ATHENA_DATABASE}, + 'WorkGroup': _ATHENA_WORKGROUP, + } + if _ATHENA_OUTPUT_LOCATION: + start_kwargs['ResultConfiguration'] = { + 'OutputLocation': _ATHENA_OUTPUT_LOCATION, + } + + resp = athena.start_query_execution(**start_kwargs) + exec_id = resp['QueryExecutionId'] + + # 완료 대기 + elapsed = 0.0 + while elapsed < _MAX_WAIT: + status = athena.get_query_execution(QueryExecutionId=exec_id) + state = status['QueryExecution']['Status']['State'] + if state == 'SUCCEEDED': + break + if state in ('FAILED', 'CANCELLED'): + reason = status['QueryExecution']['Status'].get('StateChangeReason', '') + raise RuntimeError( + f"Athena 쿼리 실패 [{state}]: {reason}\nSQL 앞 200자: {sql[:200]}" + ) + time.sleep(_POLL_INTERVAL) + elapsed += _POLL_INTERVAL + else: + raise TimeoutError(f"Athena 쿼리 타임아웃 ({_MAX_WAIT}s): exec_id={exec_id}") + + # 결과 수집 (페이지네이션) + rows, headers, next_token = [], None, None + while True: + kwargs = {'QueryExecutionId': exec_id, 'MaxResults': 1000} + if next_token: + kwargs['NextToken'] = next_token + result = athena.get_query_results(**kwargs) + page = result['ResultSet']['Rows'] + if headers is None: + headers = [c.get('VarCharValue', '') for c in page[0]['Data']] + page = page[1:] + for row in page: + cells = [c.get('VarCharValue', '') for c in row['Data']] + rows.append(dict(zip(headers, cells))) + next_token = result.get('NextToken') + if not next_token: + break + return rows + + +def _partition(target: date) -> tuple: + """(year_str, month_str) 파티션 값 반환. month는 zero-padding 없음 (예: '4').""" + return str(target.year), str(target.month) + + +# --------------------------------------------------------------------------- +# 개별 쿼리 함수 (queries.sql 대응) +# --------------------------------------------------------------------------- + +def fetch_daily_by_service_cur(athena, target_date: date) -> dict: + """ + Q1 / Q2 해당. + 지정 날짜의 서비스별 일일 비용. + + Returns: + {service: float} + """ + year, month = _partition(target_date) + #print("target_date", target_date) + #print(year, month) + sql = f""" + SELECT + product_product_name AS service, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{target_date}') + GROUP BY product_product_name + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY cost DESC + """ + rows = _run_query(athena, sql) + return { + r['service']: float(r['cost']) + for r in rows + if r.get('service') and r.get('cost') + } + + +def fetch_daily_by_service_and_creator_cur(athena, d1_date: date) -> dict: + """ + Q3 해당. + D-1 서비스 + aws:createdBy 태그별 비용. + + Returns: + {service: {creator: float}} + """ + year, month = _partition(d1_date) + sql = f""" + SELECT + product_product_name AS service, + COALESCE( + NULLIF(resource_tags_aws_created_by, ''), + 'aws:createdBy 태그 없음' + ) AS creator, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + GROUP BY + product_product_name, + COALESCE(NULLIF(resource_tags_aws_created_by, ''), 'aws:createdBy 태그 없음') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY service, cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + svc = r.get('service', '') + creator = r.get('creator') or 'aws:createdBy 태그 없음' + cost = float(r.get('cost', 0) or 0) + result.setdefault(svc, {}) + result[svc][creator] = result[svc].get(creator, 0.0) + cost + return result + + +def fetch_daily_by_service_and_region_cur(athena, d1_date: date) -> dict: + """ + Q4 해당 (product_region_code 사용, queries.sql Q4 오기 수정). + D-1 서비스 + 리전별 비용. + + Returns: + {service: {region: float}} + """ + year, month = _partition(d1_date) + sql = f""" + SELECT + product_product_name AS service, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + GROUP BY + product_product_name, + COALESCE(NULLIF(product_region_code, ''), 'global') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + svc = r.get('service', '') + region = r.get('region') or 'global' + cost = float(r.get('cost', 0) or 0) + result.setdefault(svc, {}) + result[svc][region] = result[svc].get(region, 0.0) + cost + return result + + +def fetch_mtd_by_service_and_creator_cur(athena, d1_date: date) -> dict: + """ + Q5 해당. + MTD 서비스 + aws:createdBy 별 비용. + 당월 1일 실행 시(범위 없음) {} 반환. + + Returns: + {service: {creator: float}} + """ + mtd_start = d1_date.replace(day=1) + if mtd_start >= d1_date: + return {} + year, month = _partition(d1_date) + sql = f""" + SELECT + product_product_name AS service, + COALESCE( + NULLIF(resource_tags_aws_created_by, ''), + 'aws:createdBy 태그 없음' + ) AS creator, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) + BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + GROUP BY + product_product_name, + COALESCE(NULLIF(resource_tags_aws_created_by, ''), 'aws:createdBy 태그 없음') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY service, cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + svc = r.get('service', '') + creator = r.get('creator') or 'aws:createdBy 태그 없음' + cost = float(r.get('cost', 0) or 0) + result.setdefault(svc, {}) + result[svc][creator] = result[svc].get(creator, 0.0) + cost + return result + + +def fetch_mtd_by_service_and_region_cur(athena, d1_date: date) -> dict: + """ + Q6 해당. + MTD 서비스 + 리전별 비용. + 당월 1일 실행 시 {} 반환. + + Returns: + {service: {region: float}} + """ + mtd_start = d1_date.replace(day=1) + if mtd_start >= d1_date: + return {} + year, month = _partition(d1_date) + sql = f""" + SELECT + product_product_name AS service, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) + BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + GROUP BY + product_product_name, + COALESCE(NULLIF(product_region_code, ''), 'global') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + svc = r.get('service', '') + region = r.get('region') or 'global' + cost = float(r.get('cost', 0) or 0) + result.setdefault(svc, {}) + result[svc][region] = result[svc].get(region, 0.0) + cost + return result + + +def fetch_mtd_total_cur(athena, d1_date: date) -> float: + """ + Q7 해당. + MTD 총 비용 단일 합계. + 당월 1일 실행 시 0.0 반환. + """ + mtd_start = d1_date.replace(day=1) + if mtd_start >= d1_date: + return 0.0 + year, month = _partition(d1_date) + sql = f""" + SELECT SUM(line_item_unblended_cost) AS mtd_total + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) + BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + """ + rows = _run_query(athena, sql) + if rows and rows[0].get('mtd_total'): + return float(rows[0]['mtd_total']) + return 0.0 + + +# --------------------------------------------------------------------------- +# 일괄 수집 +# --------------------------------------------------------------------------- + +def collect_all(today_kst: date) -> dict: + """ + Athena CUR 기반 데이터 일괄 수집. + 반환 구조는 data.py collect_all() 과 동일 → report.py 공유 사용 가능. + + CE 데이터 지연 보정: + 리포트 기준일(d1_date) = today_kst - 2일 + forecast 만 today_kst 기준 CE API 사용 (Q8) + + Returns: + { + 'd1_date': date, # 리포트 대상일 (today - 2) + 'daily_d1': dict, # {service: float} + 'daily_d2': dict, # {service: float} + 'by_creator': dict, # {service: {creator: float}} + 'by_creator_mtd': dict, # {service: {creator: float}} + 'by_region': dict, # {service: {region: float}} + 'by_region_mtd': dict, # {service: {region: float}} + 'mtd_this': float, + 'forecast': float, # CE API (0.0 = 예측 불가) + } + """ + athena = boto3.client('athena', region_name='ap-northeast-2') + ce = boto3.client('ce', region_name='us-east-1') + + d1_date = today_kst - timedelta(days=1) + d2_date = d1_date - timedelta(days=1) + + return { + 'd1_date': d1_date, + 'daily_d1': fetch_daily_by_service_cur(athena, d1_date), + 'daily_d2': fetch_daily_by_service_cur(athena, d2_date), + 'by_creator': fetch_daily_by_service_and_creator_cur(athena, d1_date), + 'by_creator_mtd': fetch_mtd_by_service_and_creator_cur(athena, d1_date), + 'by_region': fetch_daily_by_service_and_region_cur(athena, d1_date), + 'by_region_mtd': fetch_mtd_by_service_and_region_cur(athena, d1_date), + 'mtd_this': fetch_mtd_total_cur(athena, d1_date), + 'forecast': fetch_cost_forecast(ce, today_kst), + } diff --git a/monitor_v2/cost/queries.sql b/monitor_v2/cost/queries.sql new file mode 100644 index 0000000..46236de --- /dev/null +++ b/monitor_v2/cost/queries.sql @@ -0,0 +1,208 @@ +-- ============================================================================= +-- monitor_v2/cost/queries.sql +-- +-- Cost Explorer API 호출 → Athena CUR 쿼리 매핑 +-- +-- 대상 테이블: hyu_ddps_logs.cur_logs +-- 파티션: year (STRING), month (STRING, zero-padded) +-- +-- CE API ↔ CUR 컬럼 대응 +-- SERVICE dimension → product_product_name +-- REGION dimension → product_region_code (빈 값 = global 서비스) +-- aws:createdBy TAG → resource_tags_aws_created_by +-- UnblendedCost metric → line_item_unblended_cost +-- 일별 날짜 필터 → DATE(line_item_usage_start_date) +-- +-- 날짜 파라미터 (실행 전 치환) +-- {d1_date} 리포트 기준일 예: '2026-04-04' (today - 2일, CE 지연 보정) +-- {d2_date} 전일 비교일 예: '2026-04-03' (today - 3일) +-- {mtd_start} MTD 시작일 예: '2026-04-01' (d1_date 월의 1일) +-- {year} 파티션 연도 예: '2026' +-- {month} 파티션 월(두자리) 예: '04' +-- +-- NOTE: line_item_line_item_type 미필터 → CE API 기본 동작(전체 유형 합산)과 동일. +-- 크레딧·세금 제외가 필요하면 WHERE 절에 아래를 추가: +-- AND line_item_line_item_type NOT IN ('Credit', 'Refund', 'EdpDiscount') +-- ============================================================================= + + +-- ----------------------------------------------------------------------------- +-- Q1. fetch_daily_by_service (D-1) +-- CE: get_cost_and_usage(GroupBy=[SERVICE], Granularity=DAILY, period=d1) +-- data.py: fetch_daily_by_service(ce, period_d1) → daily_d1 +-- 결과: {service: float} +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) = DATE('2026-04-05') +GROUP BY product_product_name +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY cost DESC; + +-- ----------------------------------------------------------------------------- +-- Q2. fetch_daily_by_service (D-2) +-- CE: get_cost_and_usage(GroupBy=[SERVICE], Granularity=DAILY, period=d2) +-- data.py: fetch_daily_by_service(ce, period_d2) → daily_d2 +-- 결과: {service: float} +-- ※ Q1과 동일 구조, 날짜만 {d2_date}로 교체 +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) = DATE('2026-04-04') +GROUP BY product_product_name +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY cost DESC; + + +-- ----------------------------------------------------------------------------- +-- Q3. fetch_daily_by_service_and_creator (D-1) +-- CE: get_cost_and_usage(GroupBy=[SERVICE, TAG(aws:createdBy)], Granularity=DAILY) +-- data.py: fetch_daily_by_service_and_creator(ce, period_d1) → by_creator +-- 결과: {service: {creator_label: float}} +-- +-- CE TAG 값 'aws:createdBy$IAMUser:arn:alice' → '$' 뒤가 실제 creator +-- CUR는 resource_tags_aws_created_by 에 그 값이 그대로 저장됨 +-- 미태깅 = NULL 또는 빈 문자열 → 'aws:createdBy 태그 없음' 처리 +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + COALESCE( + NULLIF(resource_tags_aws_created_by, ''), + 'aws:createdBy 태그 없음' + ) AS creator, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') +GROUP BY + product_product_name, + COALESCE(NULLIF(resource_tags_aws_created_by, ''), 'aws:createdBy 태그 없음') +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY service, cost DESC; + + +-- ----------------------------------------------------------------------------- +-- Q4. fetch_daily_by_service_and_region (D-1) +-- CE: get_cost_and_usage(GroupBy=[SERVICE, REGION], Granularity=DAILY) +-- data.py: fetch_daily_by_service_and_region(ce, period_d1) → by_region +-- 결과: {service: {region: float}} +-- +-- 빈 region_code = 글로벌 서비스(Route53, IAM 등) → 'global' +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + COALESCE( + NULLIF(resource_tags_aws_created_by, ''), + 'aws:createdBy 태그 없음' + ) AS creator, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) = DATE('2026-04-05') +GROUP BY + product_product_name, + COALESCE(NULLIF(resource_tags_aws_created_by, ''), 'aws:createdBy 태그 없음') +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY cost, service DESC; + + + +-- ----------------------------------------------------------------------------- +-- Q5. fetch_mtd_by_service_and_creator +-- CE: get_cost_and_usage(GroupBy=[SERVICE, TAG(aws:createdBy)], Granularity=MONTHLY) +-- data.py: fetch_mtd_by_service_and_creator(ce, period_mtd_this) → by_creator_mtd +-- 결과: {service: {creator_label: float}} +-- +-- MTD 범위: {mtd_start} ~ {d1_date} (inclusive) +-- 당월 1일에 실행된 경우 범위가 비므로 Python에서 {} 반환 (이 쿼리 미실행) +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + COALESCE( + NULLIF(resource_tags_aws_created_by, ''), + 'aws:createdBy 태그 없음' + ) AS creator, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) BETWEEN DATE('2026-04-01') AND DATE('2026-04-05') +GROUP BY + product_product_name, + COALESCE(NULLIF(resource_tags_aws_created_by, ''), 'aws:createdBy 태그 없음') +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY service, cost DESC; + + +-- ----------------------------------------------------------------------------- +-- Q6. fetch_mtd_by_service_and_region +-- CE: get_cost_and_usage(GroupBy=[SERVICE, REGION], Granularity=MONTHLY) +-- data.py: fetch_mtd_by_service_and_region(ce, period_mtd_this) → by_region_mtd +-- 결과: {service: {region: float}} +-- ----------------------------------------------------------------------------- +SELECT + product_product_name AS service, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) BETWEEN DATE('2026-04-01') AND DATE('2026-04-05') +GROUP BY + product_product_name, + COALESCE(NULLIF(product_region_code, ''), 'global') +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY cost DESC; + + +-- ----------------------------------------------------------------------------- +-- Q7. fetch_mtd_total +-- CE: get_cost_and_usage(GroupBy=없음, Granularity=MONTHLY) +-- data.py: fetch_mtd_total(ce, period_mtd_this) → mtd_this (float) +-- 결과: 단일 합계 float +-- ----------------------------------------------------------------------------- +SELECT + SUM(line_item_unblended_cost) AS mtd_total +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month = '4' + AND DATE(line_item_usage_start_date) BETWEEN DATE('2026-04-01') AND DATE('2026-04-05'); + +-- ----------------------------------------------------------------------------- +-- Q8. fetch_cost_forecast +-- CE: get_cost_forecast(Granularity=MONTHLY, Metric=UNBLENDED_COST) +-- → CUR는 과거 데이터만 저장하므로 SQL로 직접 예측 불가. +-- 아래는 참고용 선형 추세 추정 쿼리 (정확도 낮음, 검증 목적). +-- +-- 원리: 당월 경과일 비용 ÷ 경과일 수 × 잔여일 수 = 잔여 예상 비용 +-- projected = mtd_actual + daily_avg * days_remaining +-- ----------------------------------------------------------------------------- +SELECT + SUM(line_item_unblended_cost) AS mtd_actual, + DATE_DIFF('day', DATE('{mtd_start}'), DATE('{d1_date}')) + 1 AS days_elapsed, + -- 이달 말일까지 잔여일 (말일 = month의 마지막 날, Athena: LAST_DAY_OF_MONTH 미지원) + -- 직접 치환: {days_in_month} = 해당 월 총 일수 (예: 30) + {days_in_month} - (DATE_DIFF('day', DATE('{mtd_start}'), DATE('{d1_date}')) + 1) + AS days_remaining, + SUM(line_item_unblended_cost) + / (DATE_DIFF('day', DATE('{mtd_start}'), DATE('{d1_date}')) + 1) + AS daily_avg, + SUM(line_item_unblended_cost) + + SUM(line_item_unblended_cost) + / (DATE_DIFF('day', DATE('{mtd_start}'), DATE('{d1_date}')) + 1) + * ({days_in_month} - (DATE_DIFF('day', DATE('{mtd_start}'), DATE('{d1_date}')) + 1)) + AS projected_total +FROM hyu_ddps_logs.cur_logs +WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}'); diff --git a/monitor_v2/cost/report.py b/monitor_v2/cost/report.py new file mode 100644 index 0000000..e04cdcf --- /dev/null +++ b/monitor_v2/cost/report.py @@ -0,0 +1,303 @@ +""" +monitor_v2/cost/report.py + +Main 1 메시지 + 스레드 3개를 Block Kit으로 순차 발송한다. + +발송 순서: + 1. Main 1 — 전체 비용 요약 + Top 5 서비스 + 2. Thread 1 — 계정 전체 서비스 비용 목록 + 3. Thread 2 — IAM User별 비용 분석 (aws:createdBy) + 4. Thread 3 — 서비스별 리전 상세 (EC2 포함 전체 서비스) + +테이블 렌더링: + - {"type": "markdown"} 블록으로 Markdown 테이블 렌더링 + - 모든 행을 페이지 구분 없이 표시 (3000자 초과 시 자동 분할) + +환경변수: + ACCOUNT_NAME: AWS 계정 별칭 (표시용) +""" + +import os +from datetime import date, timedelta +from ..slack import client as slack +from ..utils.blocks import ( + header as _header, section as _section, divider as _divider, + context as _context, md_table_blocks as _md_table_blocks, + table_section as _table_section, fields_section as _fields_section, + calc_change, fmt_change, EC2_SERVICES, +) + +ACCOUNT_NAME = os.environ.get('ACCOUNT_NAME', 'hyu-ddps') +TOP_N = 5 + + +def _top_n(costs: dict, n: int = TOP_N) -> list: + return sorted( + [(s, c) for s, c in costs.items() if c > 0], + key=lambda x: x[1], reverse=True + )[:n] + + +# --------------------------------------------------------------------------- +# Main 1 +# --------------------------------------------------------------------------- + +def _arrow(delta: float) -> str: + return "▲" if delta >= 0 else "▼" + + +def _build_main1( + d1_date: date, daily_d1: dict, daily_d2: dict, + mtd_this: float, forecast: float = 0.0, +) -> list: + total_d1 = sum(daily_d1.values()) + total_d2 = sum(daily_d2.values()) + d2_date = d1_date - timedelta(days=1) + d_yday, p_yday = calc_change(total_d1, total_d2) + projected = mtd_this + forecast + + # 일일 비용 + daily_fields = [ + f"*{d1_date}*\n`${total_d1:,.2f}`", + f"*{d2_date}*\n`${total_d2:,.2f}` _{_arrow(d_yday)} {fmt_change(d_yday, p_yday)}_", + ] + + # 월 누계 + forecast_str = ( + f"`${projected:,.2f}` _(잔여 +${forecast:,.2f})_" if forecast > 0 + else "_(예측 데이터 없음)_" + ) + mtd_fields = [ + f"*이번달 누계*\n`${mtd_this:,.2f}`", + f"*이달 예상*\n{forecast_str}", + ] + + # Top 5 서비스 + top5_blocks = [] + for rank, (service, cost) in enumerate(_top_n(daily_d1), 1): + d1, p1 = calc_change(cost, daily_d2.get(service, 0.0)) + top5_blocks.append(_section( + f"*{rank}. {service}* — `${cost:,.2f}`\n어제대비 {_arrow(d1)} {fmt_change(d1, p1)}" + )) + + return [ + _header(f"AWS Cost Report | {d1_date} | {ACCOUNT_NAME}"), + _section("*[ 일일 비용 ]*"), + _fields_section(daily_fields), + _divider(), + _section("*[ 월 누계 ]*"), + _fields_section(mtd_fields), + _divider(), + _section(f"*[ Top {TOP_N} 서비스 {d1_date} ]*"), + *top5_blocks, + _divider(), + _context("자세한 내용은 스레드에서 확인하세요."), + ] + + +# --------------------------------------------------------------------------- +# Thread 1: 전체 서비스 비용 목록 +# --------------------------------------------------------------------------- + +def _build_thread1(d1_date: date, daily_d1: dict, daily_d2: dict) -> list: + total_d1 = sum(daily_d1.values()) + total_d2 = sum(daily_d2.values()) + d, p = calc_change(total_d1, total_d2) + + summary_rows = [ + [str(d1_date), f"${total_d1:,.2f}", ""], + [str(d1_date - timedelta(days=1)), f"${total_d2:,.2f}", fmt_change(d, p)], + ] + svc_rows = [ + [service, f"${cost:,.4f}"] + for service, cost in sorted(daily_d1.items(), key=lambda x: x[1], reverse=True) + if cost > 0 + ] + + return [ + _header(f"Thread 1 | 서비스별 전체 비용 | {ACCOUNT_NAME}"), + _divider(), + *_table_section("*[ 합계 ]*", ["날짜", "비용", "변화"], summary_rows), + _divider(), + *_table_section("*[ 서비스 목록 ]*", ["서비스", "비용"], svc_rows), + ] + + +# --------------------------------------------------------------------------- +# Thread 2: IAM User별 비용 분석 +# --------------------------------------------------------------------------- + +def _shorten_creator(creator: str) -> str: + """ + creator 문자열을 표시용으로 단축한다. + + - IAMUser:xxx:alice → alice (마지막 토큰) + - AssumedRole:xxx:SvcName → AssumedRole:SvcName (1번째 + 3번째 토큰) + - 그 외 → 원본 그대로 + """ + parts = creator.split(':') + if creator.startswith('IAMUser'): + return parts[-1] + if creator.startswith('AssumedRole') and len(parts) >= 3: + return f"{parts[0]}:{parts[2]}" + return creator + + +def _creator_rollup(data: dict) -> tuple: + totals, services = {}, {} + for service, creators in data.items(): + for creator, cost in creators.items(): + totals[creator] = totals.get(creator, 0.0) + cost + services.setdefault(creator, {}) + services[creator][service] = services[creator].get(service, 0.0) + cost + return totals, services + +def _creator_table_rows(totals: dict, services: dict) -> list: + rows = [] + for creator, total in sorted(totals.items(), key=lambda x: x[1], reverse=True): + if total <= 0: + continue + short = _shorten_creator(creator) + rows.append([f"**{short}**", "_(합계)_", f"**${total:,.2f}**"]) + for svc, cost in sorted(services[creator].items(), key=lambda x: x[1], reverse=True): + if cost > 0: + rows.append(["", svc, f"${cost:,.2f}"]) + return rows or [["(데이터 없음)", "", ""]] + + +def _build_thread2(by_creator: dict, by_creator_mtd: dict, forecast: float) -> list: + d1_totals, d1_svc = _creator_rollup(by_creator) + mtd_totals, mtd_svc = _creator_rollup(by_creator_mtd) + total_mtd = sum(mtd_totals.values()) + + d1_rows = _creator_table_rows(d1_totals, d1_svc) + + if not mtd_totals: + mtd_rows = [["(데이터 없음)", "", ""]] + else: + mtd_rows = [] + for creator, mtd_total in sorted(mtd_totals.items(), key=lambda x: x[1], reverse=True): + if mtd_total <= 0: + continue + short = _shorten_creator(creator) + mtd_rows.append([f"**{short}**", "_(MTD 합계)_", f"**${mtd_total:,.2f}**"]) + if forecast > 0 and total_mtd > 0: + creator_fc = forecast * (mtd_total / total_mtd) + projected = mtd_total + creator_fc + mtd_rows.append(["", "이달 예상", f"${projected:,.2f} _(+${creator_fc:,.2f})_"]) + for svc, cost in sorted(mtd_svc[creator].items(), key=lambda x: x[1], reverse=True): + if cost > 0: + mtd_rows.append(["", svc, f"${cost:,.2f}"]) + + headers = ["IAM User", "서비스", "비용"] + blocks = [ + _header("Thread 2 | IAM User별 비용 분석"), + _divider(), + *_table_section("*[ 당일 ]*", headers, d1_rows), + _divider(), + *_table_section("*[ 당월 누계 (MTD) ]*", headers, mtd_rows), + ] + if forecast <= 0: + blocks.append(_context("* 잔여 예측 없음 — 현재 날짜가 아니므로 CE forecast API 요청 실패")) + return blocks + + +# --------------------------------------------------------------------------- +# Thread 3: 서비스별 리전 상세 +# --------------------------------------------------------------------------- + +def _region_table_rows_d1(service_region_dict: dict, d2_ref: dict) -> list: + filtered = {s: r for s, r in service_region_dict.items() if sum(r.values()) > 0} + sorted_svcs = sorted(filtered.items(), key=lambda x: sum(x[1].values()), reverse=True) + rows = [] + for service, regions in sorted_svcs: + total = sum(regions.values()) + d, p = calc_change(total, d2_ref.get(service, 0.0)) + rows.append([f"**{service}**", "_(합계)_", f"**${total:,.2f}**", fmt_change(d, p)]) + for region, cost in sorted(regions.items(), key=lambda x: x[1], reverse=True): + if cost > 0: + rows.append(["", region, f"${cost:,.2f}", ""]) + return rows or [["(데이터 없음)", "", "", ""]] + +def _region_table_rows_mtd(service_region_dict: dict, forecast: float) -> list: + filtered = {s: r for s, r in service_region_dict.items() if sum(r.values()) > 0} + sorted_svcs = sorted(filtered.items(), key=lambda x: sum(x[1].values()), reverse=True) + total_mtd = sum(sum(r.values()) for r in filtered.values()) + rows = [] + for service, regions in sorted_svcs: + svc_mtd = sum(regions.values()) + if forecast > 0 and total_mtd > 0: + svc_fc = forecast * (svc_mtd / total_mtd) + projected = svc_mtd + svc_fc + fc_str = f"${projected:,.2f} _(+${svc_fc:,.2f})_" + else: + fc_str = "" + rows.append([f"**{service}**", "_(MTD)_", f"**${svc_mtd:,.2f}**", fc_str]) + for region, cost in sorted(regions.items(), key=lambda x: x[1], reverse=True): + if cost > 0: + rows.append(["", region, f"${cost:,.2f}", ""]) + return rows or [["(데이터 없음)", "", "", ""]] + + +def _build_thread3( + by_region: dict, by_region_mtd: dict, + daily_d2: dict, forecast: float, d1_date: date = None, +) -> list: + d1_rows = _region_table_rows_d1(by_region, daily_d2) + mtd_rows = _region_table_rows_mtd(by_region_mtd, forecast) + d1_label = str(d1_date) if d1_date else "당일" + headers = ["서비스", "리전", "비용", "비교"] + mtd_headers = ["서비스", "리전", "비용", "예상 비용"] + + blocks = [ + _header("Thread 3 | 서비스별 리전 상세"), + _divider(), + *_table_section(f"*[ {d1_label} ]*", headers, d1_rows), + _divider(), + *_table_section("*[ 당월 누계 (MTD) ]*", mtd_headers, mtd_rows), + ] + if forecast <= 0: + blocks.append(_context("* 잔여 예측 없음 — 현재 날짜가 아니므로 CE forecast API 요청 실패")) + return blocks + + +# --------------------------------------------------------------------------- +# 진입점 +# --------------------------------------------------------------------------- + +def send_main1_report(cost_data: dict) -> None: + """ + Main 1 + 스레드 3개를 Block Kit으로 순차 발송한다. + + Args: + cost_data: cost/data.py collect_all()의 반환값 + """ + d1_date = cost_data['d1_date'] + daily_d1 = cost_data['daily_d1'] + daily_d2 = cost_data['daily_d2'] + by_creator = cost_data['by_creator'] + by_region = cost_data['by_region'] + mtd_this = cost_data['mtd_this'] + by_creator_mtd = cost_data.get('by_creator_mtd', {}) + by_region_mtd = cost_data.get('by_region_mtd', {}) + forecast = cost_data.get('forecast', 0.0) + + main_ts = slack.post_blocks( + _build_main1(d1_date, daily_d1, daily_d2, mtd_this, forecast), + fallback_text=f"AWS Cost Report {d1_date} / {ACCOUNT_NAME}", + ) + slack.post_blocks( + _build_thread1(d1_date, daily_d1, daily_d2), + fallback_text="Thread 1: 서비스별 전체 비용", + thread_ts=main_ts, + ) + slack.post_blocks( + _build_thread2(by_creator, by_creator_mtd, forecast), + fallback_text="Thread 2: IAM User별 비용 분석", + thread_ts=main_ts, + ) + slack.post_blocks( + _build_thread3(by_region, by_region_mtd, daily_d2, forecast, d1_date), + fallback_text="Thread 3: 서비스별 리전 상세", + thread_ts=main_ts, + ) diff --git a/monitor_v2/cost/report_cur.py b/monitor_v2/cost/report_cur.py new file mode 100644 index 0000000..4ebb9c9 --- /dev/null +++ b/monitor_v2/cost/report_cur.py @@ -0,0 +1,27 @@ +""" +monitor_v2/cost/report_cur.py + +Athena CUR 기반 Cost 리포트 발송 진입점. + +data_cur.py 의 collect_all() 이 반환하는 dict 구조는 +data.py 의 collect_all() 과 동일하므로 report.py 의 블록 빌더를 그대로 사용한다. + +사용 예: + from monitor_v2.cost.data_cur import collect_all + from monitor_v2.cost.report_cur import send_cur_report + + cost_data = collect_all(today_kst) + send_cur_report(cost_data) +""" + +from .report import send_main1_report as _send + + +def send_cur_report(cost_data: dict) -> None: + """ + Athena CUR 데이터 기반 Cost 리포트를 Slack으로 발송한다. + + Args: + cost_data: data_cur.collect_all() 의 반환값 + """ + _send(cost_data) diff --git a/monitor_v2/ec2/__init__.py b/monitor_v2/ec2/__init__.py new file mode 100644 index 0000000..f3af6c3 --- /dev/null +++ b/monitor_v2/ec2/__init__.py @@ -0,0 +1 @@ +# ec2 패키지 diff --git a/monitor_v2/ec2/data.py b/monitor_v2/ec2/data.py new file mode 100644 index 0000000..42e3089 --- /dev/null +++ b/monitor_v2/ec2/data.py @@ -0,0 +1,381 @@ +""" +monitor_v2/ec2/data.py + +EC2 인스턴스 및 미사용 리소스 수집 모듈. + +수집 대상: + 1. running / stopped / terminated 인스턴스 (모든 리전) + 2. 미사용 EBS 볼륨 (available 상태) + 3. 미사용 Snapshot (AMI 미참조, non-backup) + 4. D-1 기간 EC2 비용 인스턴스 타입 + 리전별 + +참고: + - terminated 인스턴스는 종료 후 약 1시간만 describe_instances에서 조회됨. + D-1 비용 리포트와 실제 인스턴스 목록이 일치하지 않을 수 있음. + - Spot 인스턴스 식별: InstanceLifecycle == 'spot' +""" + +import re +from pprint import pprint + +import boto3 +from botocore.exceptions import ClientError +from datetime import datetime, timezone + +_STATE_REASON_RE = re.compile(r'\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} GMT)\)') + + +def _parse_state_transition_time(reason: str): + """StateTransitionReason 문자열에서 datetime 추출. 파싱 실패 시 None.""" + m = _STATE_REASON_RE.search(reason or '') + if not m: + return None + try: + return datetime.strptime(m.group(1), '%Y-%m-%d %H:%M:%S GMT').replace(tzinfo=timezone.utc) + except ValueError: + return None + +_QUERY_STATES = ['running', 'stopped', 'terminated'] +DISPLAY_STATES = {'running', 'stopped', 'terminated'} +EBS_MIN_AGE_HOURS = 24 +K8S_EBS_GRACE_DAYS = 14 +SNAPSHOT_ALERT_DAYS = 60 + + +# --------------------------------------------------------------------------- +# EC2 인스턴스 수집 +# --------------------------------------------------------------------------- + +def collect_instances(regions: list) -> dict: + """ + 모든 리전의 EC2 인스턴스를 수집한다. + + Args: + regions: 조회할 리전 리스트 (session.get_available_regions('ec2')) + + Returns: + { + 'ap-northeast-2': [ + { + 'instance_id': str, + 'instance_type': str, + 'state': str, # 'running' | 'stopped' | 'terminated' + 'name': str, # Tags의 Name 값, 없으면 '' + 'launch_time': datetime | None, + 'purchase_option': str, # 'On-Demand' | 'Spot' + 'tags': dict, # 모든 태그 {key: value} + 'iam_user': str, # aws:createdBy 태그 값, 없으면 '' + 'state_transition_time': datetime | None, # stop/terminate 시각 + }, + ... + ], + ... + } + 인스턴스가 없는 리전은 포함되지 않음. + """ + result = {} + + for region in regions: + try: + ec2 = boto3.client('ec2', region_name=region) + paginator = ec2.get_paginator('describe_instances') + instances = [] + + for page in paginator.paginate( + Filters=[{'Name': 'instance-state-name', 'Values': _QUERY_STATES}] + ): + for reservation in page.get('Reservations', []): + for inst in reservation.get('Instances', []): + state = inst['State']['Name'] + if state not in DISPLAY_STATES: + continue + + all_tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])} + name = all_tags.get('Name', '') + iam_user = all_tags.get('aws:createdBy', '') + + lifecycle = inst.get('InstanceLifecycle', 'normal') + purchase = 'Spot' if lifecycle == 'spot' else 'On-Demand' + + state_transition_time = _parse_state_transition_time( + inst.get('StateTransitionReason', '') + ) + + instances.append({ + 'instance_id': inst['InstanceId'], + 'instance_type': inst['InstanceType'], + 'state': state, + 'name': name, + 'launch_time': inst.get('LaunchTime'), + 'purchase_option': purchase, + 'tags': all_tags, + 'iam_user': iam_user, + 'state_transition_time': state_transition_time, + }) + + if instances: + result[region] = instances + + except ClientError: + continue + + return result + + +# --------------------------------------------------------------------------- +# 미사용 EBS 수집 +# --------------------------------------------------------------------------- + +def collect_unused_ebs(regions: list) -> list: + """ + 미사용(available 상태) EBS 볼륨을 수집한다. + + 제외 조건 (설계 문서 기준): + - 생성 1일 미만 + - kubernetes.io 태그 포함 + 생성 14일 미만 + - aws:backup:source-resource-arn 태그 포함 + + Returns: + [ + { + 'region': str, + 'volume_id': str, + 'volume_type': str, + 'size_gb': int, + 'age_days': int, + 'is_k8s': bool, + }, + ... + ] + """ + now = datetime.now(timezone.utc) + volumes = [] + + for region in regions: + try: + ec2 = boto3.client('ec2', region_name=region) + paginator = ec2.get_paginator('describe_volumes') + + for page in paginator.paginate( + Filters=[{'Name': 'status', 'Values': ['available']}] + ): + for vol in page.get('Volumes', []): + create_time = vol.get('CreateTime') + if not create_time: + continue + + age_hours = (now - create_time).total_seconds() / 3600 + tags = {t['Key']: t['Value'] for t in vol.get('Tags', [])} + + if 'aws:backup:source-resource-arn' in tags: + continue + # if age_hours < EBS_MIN_AGE_HOURS: + # continue + + is_k8s = any(k.startswith('kubernetes.io') for k in tags) + age_days = age_hours / 24 + + # if is_k8s and age_days < K8S_EBS_GRACE_DAYS: + # continue + + volumes.append({ + 'region': region, + 'volume_id': vol['VolumeId'], + 'volume_type': vol.get('VolumeType', ''), + 'size_gb': vol.get('Size', 0), + 'age_days': int(age_days), + 'is_k8s': is_k8s, + 'iam_user': tags.get('aws:createdBy', ''), + }) + + except ClientError: + continue + + return volumes + + +# --------------------------------------------------------------------------- +# 미사용 Snapshot 수집 +# --------------------------------------------------------------------------- + +def collect_unused_snapshots(regions: list, account_id: str) -> list: + """ + AMI에서 참조되지 않는 미사용 Snapshot을 수집한다. + + 제외 조건: + - describe_images(Owners=['self'])의 BlockDeviceMappings에 포함된 SnapshotId + - aws:backup:source-resource-arn 태그 포함 + - Description에 'Created by AWS Backup' 포함 + - State == 'pending' + + Returns: + [ + { + 'region': str, + 'snapshot_id': str, + 'size_gb': int, + 'age_days': int, + 'has_tags': bool, + }, + ... + ] + """ + now = datetime.now(timezone.utc) + snapshots = [] + + for region in regions: + try: + ec2 = boto3.client('ec2', region_name=region) + + ami_snap_ids = set() + ami_resp = ec2.describe_images(Owners=['self']) + + for ami in ami_resp.get('Images', []): + for bdm in ami.get('BlockDeviceMappings', []): + snap_id = bdm.get('Ebs', {}).get('SnapshotId') + if snap_id: + ami_snap_ids.add(snap_id) + + paginator = ec2.get_paginator('describe_snapshots') + for page in paginator.paginate(OwnerIds=[account_id]): + #print("Snapshots") + #pprint(page.get('Snapshots', [])) + + for snap in page.get('Snapshots', []): + if snap.get('State') == 'pending': + continue + if snap['SnapshotId'] in ami_snap_ids: + continue + + tags = {t['Key']: t['Value'] for t in snap.get('Tags', [])} + desc = snap.get('Description', '') + + if 'aws:backup:source-resource-arn' in tags: + continue + if 'Created by AWS Backup' in desc: + continue + + start_time = snap.get('StartTime') + age_days = int((now - start_time).total_seconds() / 86400) if start_time else 0 + + snapshots.append({ + 'region': region, + 'snapshot_id': snap['SnapshotId'], + 'size_gb': snap.get('VolumeSize', 0), + 'age_days': age_days, + 'has_tags': bool(tags), + 'iam_user': tags.get('aws:createdBy', ''), + 'name': tags.get('Name', ''), + }) + + except ClientError: + continue + + return snapshots + + +# --------------------------------------------------------------------------- +# EC2 비용 (인스턴스 타입별) +# --------------------------------------------------------------------------- + +def collect_ec2_cost_by_type(ce, period: dict) -> dict: + """ + D-1 기간 EC2 인스턴스 타입 + 리전별 비용. + + Filter: SERVICE = 'Amazon EC2' + GroupBy: INSTANCE_TYPE + REGION (2개 제한 준수) + + Returns: + {instance_type: {region: float}} + """ + resp = ce.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'INSTANCE_TYPE'}, + {'Type': 'DIMENSION', 'Key': 'REGION'}, + ], + ) + result = {} + for group in resp.get('ResultsByTime', [{}])[0].get('Groups', []): + itype = group['Keys'][0] + if itype == 'NoInstanceType': # EC2 외 서비스(EBS, 데이터 전송 등)는 제외 + continue + region = group['Keys'][1] + amount = float(group['Metrics']['UnblendedCost']['Amount']) + if amount <= 0: + continue + result.setdefault(itype, {}) + result[itype][region] = result[itype].get(region, 0.0) + amount + return result + + +def collect_ec2_cost_by_type_mtd(ce, period_mtd: dict) -> dict: + """ + MTD 기간 EC2 인스턴스 타입 + 리전별 비용. + + Returns: + {instance_type: {region: float}} + """ + if period_mtd['Start'] >= period_mtd['End']: + return {} + resp = ce.get_cost_and_usage( + TimePeriod=period_mtd, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'INSTANCE_TYPE'}, + {'Type': 'DIMENSION', 'Key': 'REGION'}, + ], + ) + result = {} + for group in resp.get('ResultsByTime', [{}])[0].get('Groups', []): + itype = group['Keys'][0] + if itype == 'NoInstanceType': + continue + region = group['Keys'][1] + amount = float(group['Metrics']['UnblendedCost']['Amount']) + if amount <= 0: + continue + result.setdefault(itype, {}) + result[itype][region] = result[itype].get(region, 0.0) + amount + return result + + +# --------------------------------------------------------------------------- +# 일괄 수집 +# --------------------------------------------------------------------------- + +def collect_all(regions: list, account_id: str, ce, period_d1: dict) -> dict: + """ + Main 2 + 스레드에 필요한 EC2 데이터를 수집한다. + + Args: + regions: session.get_available_regions('ec2') + account_id: STS get_caller_identity Account + ce: boto3 ce 클라이언트 + period_d1: D-1 TimePeriod dict + + Returns: + { + 'instances': dict, # {region: [inst, ...]} + 'unused_ebs': list, + 'unused_snapshots': list, + 'type_cost': dict, # {itype: {region: float}} D-1 + 'type_cost_mtd': dict, # {itype: {region: float}} MTD + } + """ + from datetime import date as _date + d1_date = _date.fromisoformat(period_d1['Start']) + period_mtd = { + 'Start': d1_date.replace(day=1).strftime('%Y-%m-%d'), + 'End': period_d1['End'], + } + return { + 'instances': collect_instances(regions), + 'unused_ebs': collect_unused_ebs(regions), + 'unused_snapshots': collect_unused_snapshots(regions, account_id), + 'type_cost': collect_ec2_cost_by_type(ce, period_d1), + 'type_cost_mtd': collect_ec2_cost_by_type_mtd(ce, period_mtd), + } diff --git a/monitor_v2/ec2/data_cur.py b/monitor_v2/ec2/data_cur.py new file mode 100644 index 0000000..2c546a4 --- /dev/null +++ b/monitor_v2/ec2/data_cur.py @@ -0,0 +1,177 @@ +""" +monitor_v2/ec2/data_cur.py + +EC2 데이터 수집 모듈 (Athena CUR 버전). + +data.py 대비 변경점: + - collect_ec2_cost_by_type : CE API → Athena CUR 쿼리로 교체 + - collect_instances / collect_unused_ebs / collect_unused_snapshots : 기존 data.py 재사용 + +collect_all() 반환 구조는 data.py 와 동일 → ec2/report.py 그대로 사용 가능. + +Athena 쿼리: + product_instance_type ↔ CE INSTANCE_TYPE dimension + product_region_code ↔ CE REGION dimension + product_instance_type != '' ↔ CE 의 NoInstanceType 제외 조건 +""" + +import boto3 +from datetime import date, timedelta +import logging + +from .data import ( + collect_instances, + collect_unused_ebs, + collect_unused_snapshots, +) +from ..cost.data_cur import _run_query, _partition + +log = logging.getLogger(__name__) + + +def collect_ec2_cost_by_type_cur(athena, d1_date: date) -> dict: + """ + D-1 EC2 인스턴스 타입 + 리전별 비용 (Athena CUR). + + CE API의 GroupBy=[INSTANCE_TYPE, REGION] + NoInstanceType 제외와 동일한 결과. + + Returns: + {instance_type: {region: float}} + """ + year, month = _partition(d1_date) + sql = f""" + SELECT + product_instance_type AS instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + AND product_instance_type != '' + GROUP BY + product_instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + itype = r.get('instance_type', '') + region = r.get('region') or 'global' + cost = float(r.get('cost', 0) or 0) + result.setdefault(itype, {}) + result[itype][region] = result[itype].get(region, 0.0) + cost + return result + + +def collect_ec2_cost_by_type_mtd_cur(athena, d1_date: date) -> dict: + """ + MTD EC2 인스턴스 타입 + 리전별 비용 (Athena CUR). + + Returns: + {instance_type: {region: float}} + """ + mtd_start = d1_date.replace(day=1) + if mtd_start >= d1_date: + return {} + year, month = _partition(d1_date) + sql = f""" + SELECT + product_instance_type AS instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SUM(line_item_unblended_cost) AS cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) + BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + AND product_instance_type != '' + GROUP BY + product_instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') + HAVING SUM(line_item_unblended_cost) > 0 + ORDER BY cost DESC + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + itype = r.get('instance_type', '') + region = r.get('region') or 'global' + cost = float(r.get('cost', 0) or 0) + result.setdefault(itype, {}) + result[itype][region] = result[itype].get(region, 0.0) + cost + return result + + +def collect_spot_cost_cur(athena, d1_date: date) -> tuple: + """ + D-1 / D-2 / MTD Spot EC2 비용 합산 (Athena CUR). + + Spot 인스턴스는 line_item_usage_type에 'SpotUsage'가 포함된 행으로 식별한다. + + Returns: + (spot_d1: float, spot_d2: float, spot_mtd: float) + """ + d2_date = d1_date - timedelta(days=1) + mtd_start = d1_date.replace(day=1) + + def _query_spot(start: date, end: date) -> float: + y, m = _partition(end) + sql = f""" + SELECT SUM(line_item_unblended_cost) AS spot_cost + FROM hyu_ddps_logs.cur_logs + WHERE year = '{y}' + AND month = '{m}' + AND DATE(line_item_usage_start_date) + BETWEEN DATE('{start}') AND DATE('{end}') + AND line_item_usage_type LIKE '%SpotUsage%' + """ + rows = _run_query(athena, sql) + return float(rows[0].get('spot_cost', 0) or 0) if rows else 0.0 + + spot_d1 = _query_spot(d1_date, d1_date) + spot_d2 = _query_spot(d2_date, d2_date) + spot_mtd = _query_spot(mtd_start, d1_date) if mtd_start < d1_date else 0.0 + + return spot_d1, spot_d2, spot_mtd + + +def collect_all(regions: list, account_id: str, d1_date: date) -> dict: + """ + Main 2 + 스레드에 필요한 EC2 데이터를 수집한다. + + data.py의 collect_all()과 반환 구조 동일. + EC2 비용 수집만 Athena CUR 로 교체하고 나머지는 EC2 API 그대로 사용. + + Args: + regions: describe_regions 로 조회한 리전 리스트 + account_id: STS get_caller_identity Account + d1_date: 리포트 기준일 (cost/data_cur.collect_all 의 d1_date 와 일치) + + Returns: + { + 'instances': dict, # {region: [inst, ...]} + 'unused_ebs': list, + 'unused_snapshots': list, + 'type_cost': dict, # {itype: {region: float}} D-1 + 'type_cost_mtd': dict, # {itype: {region: float}} MTD + 'spot_d1': float, # Spot 당일 비용 + 'spot_d2': float, # Spot 전날 비용 + 'spot_mtd': float, # Spot 당월 누계 비용 + } + """ + athena = boto3.client('athena', region_name='ap-northeast-2') + spot_d1, spot_d2, spot_mtd = collect_spot_cost_cur(athena, d1_date) + + return { + 'instances': collect_instances(regions), + 'unused_ebs': collect_unused_ebs(regions), + 'unused_snapshots': collect_unused_snapshots(regions, account_id), + 'type_cost': collect_ec2_cost_by_type_cur(athena, d1_date), + 'type_cost_mtd': collect_ec2_cost_by_type_mtd_cur(athena, d1_date), + 'spot_d1': spot_d1, + 'spot_d2': spot_d2, + 'spot_mtd': spot_mtd, + } diff --git a/monitor_v2/ec2/iam_resolver.py b/monitor_v2/ec2/iam_resolver.py new file mode 100644 index 0000000..ecebb57 --- /dev/null +++ b/monitor_v2/ec2/iam_resolver.py @@ -0,0 +1,136 @@ +""" +monitor_v2/ec2/iam_resolver.py + +CloudTrail lookup_events + RunInstances 이벤트를 통해 +인스턴스 ID → IAM username 매핑을 구성한다. + +제약: + - CloudTrail lookup API는 최대 90일까지 조회 가능하나, + 이 시스템은 비용·실용성 고려해 60일 이내만 조회 + - 60일 초과 인스턴스 → 'Unknown (60일 초과)' 반환, DM 발송 불가 + - lookup_events는 리전별로 호출해야 하므로 순차 조회 (인스턴스 수가 많을수록 느림) + +IAM → Slack 매핑: + monitor_v2/iam_to_slack.json 파일에서 우선 로드. + 파일이 없을 경우 환경변수 IAM_SLACK_USER_MAP (JSON 문자열) 폴백. + 둘 다 없으면 DM 발송 스킵, 채널 Thread에만 표시. +""" + +import json +import os +import boto3 +from botocore.exceptions import ClientError +from datetime import datetime, timedelta, timezone + + +_IAM_TO_SLACK_PATH = os.path.join(os.path.dirname(__file__), '..', 'iam_to_slack.json') +try: + with open(_IAM_TO_SLACK_PATH, encoding='utf-8') as _f: + IAM_SLACK_MAP: dict = json.load(_f) +except (FileNotFoundError, json.JSONDecodeError): + _RAW_MAP = os.environ.get('IAM_SLACK_USER_MAP', '{}') + IAM_SLACK_MAP = json.loads(_RAW_MAP) + +LOOKBACK_DAYS = 60 + + +def _extract_username(user_identity: dict) -> str: + """ + CloudTrail userIdentity dict에서 식별 가능한 username을 추출한다. + + identity type 별 처리: + IAMUser → userIdentity.userName + AssumedRole → ARN 마지막 슬래시 뒤 (세션명, 보통 이메일) + Root → 'root' + 기타 → 'Unknown' + """ + id_type = user_identity.get('type', '') + if id_type == 'IAMUser': + return user_identity.get('userName', 'Unknown') + if id_type == 'AssumedRole': + arn = user_identity.get('arn', '') + return arn.split('/')[-1] if '/' in arn else arn + if id_type == 'Root': + return 'root' + return 'Unknown' + + +def build_instance_creator_map(instance_ids: list, regions: list) -> dict: + """ + 여러 리전의 CloudTrail에서 RunInstances 이벤트를 조회해 + {instance_id: iam_username} 매핑을 반환한다. + + Args: + instance_ids: 조회할 인스턴스 ID 목록 + regions: 인스턴스가 존재하는 리전 목록 + + Returns: + { + 'i-0abc123': 'kim', + 'i-0def456': 'park@ddps.cloud', + 'i-0ghi789': 'Unknown (60일 초과)', + } + """ + if not instance_ids: + return {} + + id_set = set(instance_ids) + creator_map = {} + start_time = datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS) + + for region in regions: + if id_set <= set(creator_map.keys()): + break + + try: + ct = boto3.client('cloudtrail', region_name=region) + paginator = ct.get_paginator('lookup_events') + + for page in paginator.paginate( + LookupAttributes=[ + {'AttributeKey': 'EventName', 'AttributeValue': 'RunInstances'} + ], + StartTime=start_time, + ): + for event in page.get('Events', []): + for resource in event.get('Resources', []): + rid = resource.get('ResourceName', '') + if rid not in id_set or rid in creator_map: + continue + + try: + detail = json.loads(event.get('CloudTrailEvent', '{}')) + user_id = detail.get('userIdentity', {}) + creator_map[rid] = _extract_username(user_id) + except (json.JSONDecodeError, KeyError): + creator_map[rid] = 'Unknown' + + except ClientError: + continue + + for iid in id_set: + if iid not in creator_map: + creator_map[iid] = 'Unknown (60일 초과)' + + return creator_map + + +def get_slack_user_id(iam_username: str) -> str | None: + """ + IAM username → Slack User ID 변환. + + assumed-role ARN의 경우 마지막 슬래시 뒤 세션명으로 조회. + 매핑 없으면 None 반환 (DM 발송 스킵). + + Args: + iam_username: build_instance_creator_map 반환값 + + Returns: + 'U01234567' 형식의 Slack User ID 또는 None + """ + if not iam_username or iam_username.startswith('Unknown'): + return None + + print("iam_username", iam_username) + clean = iam_username.split('/')[-1] + return IAM_SLACK_MAP.get(clean) or IAM_SLACK_MAP.get(iam_username) diff --git a/monitor_v2/ec2/report.py b/monitor_v2/ec2/report.py new file mode 100644 index 0000000..b8815a1 --- /dev/null +++ b/monitor_v2/ec2/report.py @@ -0,0 +1,528 @@ +""" +monitor_v2/ec2/report.py + +Main 2 메시지 (EC2 전용) + 스레드 3개를 Block Kit으로 순차 발송하고 조건부 DM을 발송한다. + +발송 순서: + 1. Main 2 — EC2 총 비용 (당일/전일/MTD) + 비용 발생 리전 + Top 5 인스턴스 타입 + Top 5 IAM User + 2. Thread 1 — 전체 인스턴스 상세 (리전별 코드 블록, Slack 자동 '더 보기' 토글) + 3. Thread 2 — 미사용 리소스 목록 (EBS + Snapshot) + 4. Thread 3 — IAM User별 EC2 비용 분석 + 예상 비용 + +환경변수: + ACCOUNT_NAME: AWS 계정 별칭 (표시용) +""" + +import os +from datetime import datetime, timedelta, timezone +from pprint import pprint + +from ..utils.blocks import ( + header as _header, section as _section, divider as _divider, + context as _context, md_table_blocks as _md_table_blocks, + table_section as _table_section, fields_section as _fields_section, + split_by_aggregate as _split_by_aggregate, + calc_change, fmt_change, EC2_SERVICES, +) +from .iam_resolver import build_instance_creator_map, get_slack_user_id +from ..slack import client as slack + +ACCOUNT_NAME = os.environ.get('ACCOUNT_NAME', 'hyu-ddps') +STOPPED_DM_HOURS = 24 +SNAPSHOT_ALERT_DAYS = 60 +KST = timezone(timedelta(hours=9)) + + +def _region_label(region: str) -> str: + return f"{region}" + + +def _uptime_str(launch_time) -> str: + """업타임 hh:mm:ss 형식.""" + if not launch_time: + return '-' + now = datetime.now(timezone.utc) + delta = now - launch_time + h, rem = divmod(int(delta.total_seconds()), 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def _fmt_dt(dt) -> str: + """datetime → KST 날짜+시간 문자열. None이면 '-'.""" + if not dt: + return '-' + return dt.astimezone(KST).strftime('%Y-%m-%d %H:%M KST') + + +def _shorten_creator(creator: str) -> str: + """ + - IAMUser:xxx:alice → alice + - AssumedRole:xxx:SvcName → AssumedRole:SvcName + - arn:aws:... → 마지막 / 이후 토큰 + - 그 외 → 원본 + """ + if creator.startswith('arn:aws:'): + return creator.split('/')[-1] + parts = creator.split(':') + if creator.startswith('IAMUser'): + return parts[-1] + if creator.startswith('AssumedRole') and len(parts) >= 3: + return f"{parts[0]}:{parts[2]}" + return creator + + +def _ec2_by_user(by_creator: dict) -> dict: + """EC2 서비스만 필터링한 creator별 합산 비용.""" + totals = {} + for svc, creators in by_creator.items(): + if svc in EC2_SERVICES: + for creator, cost in creators.items(): + totals[creator] = totals.get(creator, 0.0) + cost + return totals + + +# --------------------------------------------------------------------------- +# Main 2 본문 +# --------------------------------------------------------------------------- + +def _arrow(delta: float) -> str: + return "▲" if delta >= 0 else "▼" + + +def _top5_type_blocks(type_cost: dict) -> list: + """인스턴스 타입 합계 Top 5 section 블록 반환.""" + totals = {itype: sum(r.values()) for itype, r in type_cost.items()} + sorted_types = sorted(totals.items(), key=lambda x: x[1], reverse=True)[:5] + return [ + _section(f"*{rank}. {t}* — `${c:,.2f}`") + for rank, (t, c) in enumerate(sorted_types, 1) + ] or [_section("_(데이터 없음)_")] + + +def _build_main2( + d1_date, + ec2_type_cost: dict, + ec2_type_cost_mtd: dict, + ec2_d1: float, + ec2_d2: float, + ec2_mtd: float, + ec2_user_mtd: dict, + spot_d1: float = 0.0, + spot_d2: float = 0.0, + spot_mtd: float = 0.0, +) -> list: + """ + 비용이 발생한 리전만 표시한다 (stopped 전용 리전 = $0, 미포함). + Top 5 인스턴스 타입: D-1 기준 + MTD 기준 각각 표시. + Top 5 IAM User는 MTD EC2 비용 기준으로 표시. + """ + d2_date = d1_date - timedelta(days=1) + d, p = calc_change(ec2_d1, ec2_d2) + sd, sp = calc_change(spot_d1, spot_d2) + + # 비용이 발생한 리전만 (D-1 type_cost 기준) + region_totals: dict = {} + for itype, regions in ec2_type_cost.items(): + for region, cost in regions.items(): + region_totals[region] = region_totals.get(region, 0.0) + cost + sorted_regions = sorted(region_totals.items(), key=lambda x: x[1], reverse=True) + + top5_users = sorted(ec2_user_mtd.items(), key=lambda x: x[1], reverse=True)[:5] + + # EC2 비용 fields + cost_fields = [ + f"*{d1_date}*\n`${ec2_d1:,.2f}`", + f"*{d2_date}*\n`${ec2_d2:,.2f}` _{_arrow(d)} {fmt_change(d, p)}_", + ] + + # Spot 비용 fields + spot_fields = [ + f"*{d1_date}*\n`${spot_d1:,.2f}`", + f"*{d2_date}*\n`${spot_d2:,.2f}` _{_arrow(sd)} {fmt_change(sd, sp)}_", + ] + + # 리전 fields (2열 그리드, 10개씩 분할) + region_field_items = [f"*{r}*\n`${c:,.2f}`" for r, c in sorted_regions] + region_blocks = [ + _fields_section(region_field_items[i:i + 10]) + for i in range(0, max(len(region_field_items), 1), 10) + ] + + # Top 5 User sections + user_blocks = [ + _section(f"*{rank}. {_shorten_creator(c)}* — `${cost:,.2f}`") + for rank, (c, cost) in enumerate(top5_users, 1) + if cost > 0 + ] or [_section("_(데이터 없음)_")] + + return [ + _header(f"EC2 Instance Report | {d1_date} | {ACCOUNT_NAME}"), + _section("*[ EC2 비용 ]*"), + _fields_section(cost_fields), + _section(f"*당월 누계* — `${ec2_mtd:,.2f}`"), + _divider(), + _section("*[ Spot Instance 비용 ]*"), + _fields_section(spot_fields), + _section(f"*당월 누계* — `${spot_mtd:,.2f}`"), + _divider(), + _section(f"*[ 비용이 발생한 활성 Region ({len(region_totals)}개) ]*"), + *region_blocks, + _divider(), + _section(f"*[ Top 5 인스턴스 타입 {d1_date} ]*"), + *_top5_type_blocks(ec2_type_cost), + _divider(), + _section("*[ Top 5 인스턴스 타입 MTD ]*"), + *_top5_type_blocks(ec2_type_cost_mtd), + _divider(), + _section("*[ Top 5 IAM User (EC2 MTD) ]*"), + *user_blocks, + _divider(), + _context("전체 인스턴스 상세는 스레드에서 확인하세요."), + ] + + +# --------------------------------------------------------------------------- +# Thread 1: 전체 인스턴스 상세 — 리전별 코드 블록 +# --------------------------------------------------------------------------- + +def _format_region_instances_blocks( + region: str, + instances: list, + creator_map: dict, + dm_targets: list, + now: datetime, +) -> list: + """ + 리전의 인스턴스를 region >> 구매유형 >> 상태 계층으로 Markdown 테이블 블록으로 포매팅. + 업타임: hh:mm:ss (running), 실행시점 + 종료시점 KST 표시. + """ + by_purchase: dict = {} + for inst in instances: + by_purchase.setdefault(inst['purchase_option'], {}).setdefault(inst['state'], []).append(inst) + + blocks = [] + + for purchase in ['On-Demand', 'Spot']: + if purchase not in by_purchase: + continue + + for state in ['running', 'stopped', 'terminated']: + if state not in by_purchase[purchase]: + continue + inst_list = by_purchase[purchase][state] + rows = [] + + for inst in inst_list: + iid = inst['instance_id'] + itype = inst['instance_type'] + name = inst['name'] or iid + creator = creator_map.get(iid, 'Unknown') + short = _shorten_creator(creator) + + launch_str = _fmt_dt(inst.get('launch_time')) + + if state == 'running': + uptime = _uptime_str(inst.get('launch_time')) + time_col = f"업타임: {uptime}" + else: + t = inst.get('state_transition_time') or inst.get('launch_time') + stop_str = _fmt_dt(t) + time_col = f"종료: {stop_str}" + + if state == 'stopped' and t: + stopped_hours = (now - t).total_seconds() / 3600 + if stopped_hours >= STOPPED_DM_HOURS: + time_col += f" (!{int(stopped_hours)}h 경과)" + dm_targets.append({ + 'creator': creator, + 'instance_id': iid, + 'name': name, + 'reason': f'stopped {int(stopped_hours)}시간 경과', + }) + + rows.append([name, iid, itype, launch_str, time_col, short]) + + blocks.extend(_table_section( + f"*[{purchase}] {state} ({len(inst_list)}개)*", + ["이름", "ID", "타입", "시작", "업타임/종료", "생성자"], + rows, + )) + + return blocks + + +# --------------------------------------------------------------------------- +# Thread 2: 미사용 리소스 목록 +# --------------------------------------------------------------------------- + +def _build_thread2_unused( + unused_ebs: list, + unused_snapshots: list, + stopped_instances: list, + creator_map: dict, +) -> list: + ebs_rows = [ + [ + vol['region'], + vol['volume_id'], + vol['volume_type'], + f"{vol['size_gb']}GB", + f"{vol['age_days']}일", + ('kubernetes ' if vol['is_k8s'] else '') + ( + _shorten_creator(vol['iam_user']) if vol.get('iam_user') else '' + ), + ] + for vol in sorted(unused_ebs, key=lambda x: x['age_days'], reverse=True) + ] or [["없음", "", "", "", "", ""]] + + snap_rows = [ + [ + snap['region'], + snap['snapshot_id'], + f"{snap['size_gb']}GB", + f"{snap['age_days']}일", + snap.get('name') or '태그 없음', + ] + for snap in sorted(unused_snapshots, key=lambda x: x['age_days'], reverse=True) + ] or [["없음", "", "", "", ""]] + + def _stopped_sort_key(inst): + t = inst.get('state_transition_time') or inst.get('launch_time') + return t if t else datetime.min.replace(tzinfo=timezone.utc) + + stopped_rows = [ + [ + inst['region'], + inst['name'] or inst['instance_id'], + inst['instance_id'], + inst['instance_type'], + inst['purchase_option'], + _fmt_dt(inst.get('state_transition_time') or inst.get('launch_time')), + _shorten_creator( + creator_map.get(inst['instance_id']) + or inst.get('iam_user') + or 'Unknown' + ), + ] + for inst in sorted(stopped_instances, key=_stopped_sort_key) + ] or [["없음", "", "", "", "", "", ""]] + + return [ + _header("Thread 2 | 미사용 리소스"), + _divider(), + *_table_section( + f"*[ Stopped 인스턴스 ({len(stopped_instances)}개) ]*", + ["리전", "이름", "ID", "타입", "구매", "중지 시각", "생성자"], + stopped_rows, + ), + _divider(), + *_table_section( + f"*[ 미사용 EBS 볼륨 ({len(unused_ebs)}개) ]*", + ["리전", "볼륨 ID", "타입", "크기", "경과", "비고 (생성자)"], + ebs_rows, + ), + _divider(), + *_table_section( + f"*[ 미사용 Snapshot ({len(unused_snapshots)}개) ]*", + ["리전", "스냅샷 ID", "크기", "경과", "비고 (Name 태그)"], + snap_rows, + ), + ] + + +# --------------------------------------------------------------------------- +# Thread 3: IAM User별 EC2 비용 분석 + 예상 비용 +# --------------------------------------------------------------------------- + +def _build_thread3_ec2_by_user( + by_creator: dict, + by_creator_mtd: dict, + ec2_mtd: float, + mtd_this: float, + forecast: float, + d1_date=None, +) -> list: + d1_totals = _ec2_by_user(by_creator) + mtd_totals = _ec2_by_user(by_creator_mtd) + + # EC2가 전체 MTD에서 차지하는 비율로 EC2 잔여 예측 산출 + ec2_forecast = (forecast * ec2_mtd / mtd_this) if (forecast > 0 and mtd_this > 0) else 0.0 + + all_creators = set(d1_totals) | set(mtd_totals) + sorted_rows = sorted( + [(c, d1_totals.get(c, 0.0), mtd_totals.get(c, 0.0)) for c in all_creators], + key=lambda x: x[2], + reverse=True, + ) + + table_rows = [] + for c, d1, mtd in sorted_rows: + if d1 <= 0 and mtd <= 0: + continue + if ec2_forecast > 0 and ec2_mtd > 0: + user_fc = ec2_forecast * (mtd / ec2_mtd) + projected = mtd + user_fc + fc_str = f"${projected:,.2f} _(+${user_fc:,.2f})_" + else: + fc_str = "-" + table_rows.append([_shorten_creator(c), f"${d1:,.4f}", f"${mtd:,.2f}", fc_str]) + + if not table_rows: + table_rows = [["(데이터 없음)", "", "", ""]] + + date_label = str(d1_date) if d1_date else "당일" + blocks = [ + _header("Thread 3 | IAM User별 EC2 비용"), + _divider(), + *_table_section( + f"*[ EC2 비용 ({date_label} / MTD / 이달 예상) ]*", + ["사용자", date_label, "MTD", "이달 예상"], + table_rows, + ), + ] + if ec2_forecast <= 0: + blocks.append(_context("* 잔여 예측 없음 — CE forecast API 요청 실패 또는 당월 1일")) + return blocks + + +# --------------------------------------------------------------------------- +# DM 발송 +# --------------------------------------------------------------------------- + +def _send_dms(dm_targets: list) -> None: + for target in dm_targets: + slack_uid = get_slack_user_id(target['creator']) + if not slack_uid: + print(f"[DM 스킵] IAM_SLACK_USER_MAP 미등록: {target['creator']}") + continue + + msg = ( + f"미사용 리소스 알림\n" + f"인스턴스: {target['name']} ({target['instance_id']})\n" + f"사유: {target['reason']}\n" + f"정리해주시길 부탁드립니다." + ) + slack.send_dm(slack_uid, msg) + + +# --------------------------------------------------------------------------- +# 진입점 +# --------------------------------------------------------------------------- + +def send_main2_report(cost_data: dict, ec2_data: dict) -> None: + """ + Main 2 + 스레드 3개를 Block Kit으로 순차 발송하고, 조건부 DM을 발송한다. + + Thread 1은 리전별로 분리 발송한다. 각 리전은 코드 블록(rich_text_preformatted)으로 + 표시되며, 내용이 길면 Slack이 자동으로 '간략히 보기 / 더 보기' 토글을 추가한다. + + Args: + cost_data: cost/data.py collect_all()의 반환값 + ec2_data: ec2/data.py collect_all()의 반환값 + """ + d1_date = cost_data['d1_date'] + #print("cost_data") + #pprint(cost_data) + + #print("ec2_data") + #pprint(ec2_data) + ec2_d1 = sum(v for k, v in cost_data['daily_d1'].items() if k in EC2_SERVICES) + ec2_d2 = sum(v for k, v in cost_data['daily_d2'].items() if k in EC2_SERVICES) + ec2_mtd = sum( + sum(regions.values()) + for svc, regions in cost_data.get('by_region_mtd', {}).items() + if svc in EC2_SERVICES + ) + ec2_user_mtd = _ec2_by_user(cost_data.get('by_creator_mtd', {})) + ec2_type_cost_mtd = ec2_data.get('type_cost_mtd', {}) + spot_d1 = ec2_data.get('spot_d1', 0.0) + spot_d2 = ec2_data.get('spot_d2', 0.0) + spot_mtd = ec2_data.get('spot_mtd', 0.0) + + # Main 2 + main2_ts = slack.post_blocks( + _build_main2( + d1_date, + ec2_data['type_cost'], + ec2_type_cost_mtd, + ec2_d1, ec2_d2, ec2_mtd, + ec2_user_mtd, + spot_d1, spot_d2, spot_mtd, + ), + fallback_text=f"EC2 Instance Report {d1_date} / {ACCOUNT_NAME}", + ) + + # creator_map 조회 (CloudTrail 기반) + all_instance_ids = [ + inst['instance_id'] + for instances in ec2_data['instances'].values() + for inst in instances + ] + regions = list(ec2_data['instances'].keys()) + creator_map = build_instance_creator_map(all_instance_ids, regions) + + # Thread 1: 헤더 메시지 + total_instances = sum(len(v) for v in ec2_data['instances'].values()) + slack.post_blocks( + [ + _header(f"Thread 1 | EC2 인스턴스 상세 | {ACCOUNT_NAME}"), + _divider(), + _context( + f"활성 리전: {len(ec2_data['instances'])}개 | " + f"총 인스턴스: {total_instances}개 | " + f"각 리전은 코드 블록으로 표시됩니다." + ), + ], + fallback_text="Thread 1: EC2 인스턴스 상세", + thread_ts=main2_ts, + ) + + # Thread 1: 리전별 Markdown 테이블 발송 + now = datetime.now(timezone.utc) + dm_targets = [] + + for region, instances in sorted(ec2_data['instances'].items()): + region_blocks = [_header(_region_label(region))] + region_blocks.extend(_format_region_instances_blocks(region, instances, creator_map, dm_targets, now)) + + for batch in _split_by_aggregate(region_blocks): + slack.post_blocks( + batch, + fallback_text=_region_label(region), + thread_ts=main2_ts, + ) + + # Thread 2: 미사용 리소스 + stopped_instances = [ + {'region': region, **inst} + for region, instances in ec2_data['instances'].items() + for inst in instances + if inst['state'] == 'stopped' + ] + slack.post_blocks( + _build_thread2_unused( + ec2_data['unused_ebs'], + ec2_data['unused_snapshots'], + stopped_instances, + creator_map, + ), + fallback_text="Thread 2: 미사용 리소스", + thread_ts=main2_ts, + ) + + # Thread 3: IAM User별 EC2 비용 + slack.post_blocks( + _build_thread3_ec2_by_user( + cost_data.get('by_creator', {}), + cost_data.get('by_creator_mtd', {}), + ec2_mtd, + cost_data.get('mtd_this', 0.0), + cost_data.get('forecast', 0.0), + d1_date=d1_date, + ), + fallback_text="Thread 3: IAM User별 EC2 비용", + thread_ts=main2_ts, + ) + + _send_dms(dm_targets) diff --git a/monitor_v2/ec2/report_cur.py b/monitor_v2/ec2/report_cur.py new file mode 100644 index 0000000..e34231f --- /dev/null +++ b/monitor_v2/ec2/report_cur.py @@ -0,0 +1,28 @@ +""" +monitor_v2/ec2/report_cur.py + +Athena CUR 기반 EC2 리포트 발송 진입점. + +ec2/data_cur.py 의 collect_all() 반환 구조는 +ec2/data.py 의 collect_all() 과 동일하므로 report.py 의 블록 빌더를 그대로 사용한다. + +사용 예: + from monitor_v2.ec2.data_cur import collect_all as collect_ec2_data + from monitor_v2.ec2.report_cur import send_ec2_cur_report + + ec2_data = collect_ec2_data(regions, account_id, d1_date) + send_ec2_cur_report(cost_data, ec2_data) +""" + +from .report import send_main2_report as _send + + +def send_ec2_cur_report(cost_data: dict, ec2_data: dict) -> None: + """ + Athena CUR 데이터 기반 EC2 리포트를 Slack으로 발송한다. + + Args: + cost_data: cost/data_cur.collect_all() 의 반환값 + ec2_data: ec2/data_cur.collect_all() 의 반환값 + """ + _send(cost_data, ec2_data) diff --git a/monitor_v2/infra/iam.tf b/monitor_v2/infra/iam.tf new file mode 100644 index 0000000..fcfb33d --- /dev/null +++ b/monitor_v2/infra/iam.tf @@ -0,0 +1,124 @@ +# ── Lambda 실행 역할 ───────────────────────────────────────────────── +resource "aws_iam_role" "lambda_exec" { + name = "${local.function_name}-role" + description = "monitor_v2 Lambda execution role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "LambdaAssumeRole" + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) + + tags = { + Project = "cloud-usage-monitor" + Version = "v2" + } +} + +# AWS 관리형 정책: CloudWatch Logs 기본 쓰기 +resource "aws_iam_role_policy_attachment" "lambda_basic_exec" { + role = aws_iam_role.lambda_exec.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +# 커스텀 인라인 정책: 비즈니스 로직 권한 +resource "aws_iam_policy" "monitor_v2" { + name = "${local.function_name}-policy" + description = "monitor_v2 required AWS service permissions" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + # ── Cost Explorer ─────────────────────────────────────────────── + # cost/data.py: fetch_daily_*, fetch_mtd_*, fetch_cost_forecast + { + Sid = "CostExplorer" + Effect = "Allow" + Action = [ + "ce:GetCostAndUsage", + "ce:GetCostForecast", + ] + Resource = "*" + }, + # ── EC2 조회 ──────────────────────────────────────────────────── + # ec2/data.py: collect_instances, collect_unused_ebs, + # collect_unused_snapshots, DescribeRegions + { + Sid = "EC2ReadOnly" + Effect = "Allow" + Action = [ + "ec2:DescribeInstances", + "ec2:DescribeVolumes", + "ec2:DescribeSnapshots", + "ec2:DescribeImages", + "ec2:DescribeRegions", + ] + Resource = "*" + }, + # ── CloudTrail ────────────────────────────────────────────────── + # ec2/iam_resolver.py: build_instance_creator_map (RunInstances 이벤트 조회) + { + Sid = "CloudTrailReadOnly" + Effect = "Allow" + Action = ["cloudtrail:LookupEvents"] + Resource = "*" + }, + # ── STS ───────────────────────────────────────────────────────── + # lambda_handler.py: sts.get_caller_identity() + { + Sid = "STSGetCallerIdentity" + Effect = "Allow" + Action = ["sts:GetCallerIdentity"] + Resource = "*" + }, + # ── Athena ──────────────────────────────────── + # cost/data_cur.py, ec2/data_cur.py: Athena 쿼리 실행 + { + Sid = "AthenaQuery" + Effect = "Allow" + Action = [ + "athena:StartQueryExecution", + "athena:GetQueryExecution", + "athena:GetQueryResults", + "athena:StopQueryExecution", + ] + Resource = "*" + }, + # ── Glue (Athena 메타스토어) ───────────────────────────────────── + # Athena가 hyu_ddps_logs 데이터베이스 테이블 정보 조회 시 필요 + { + Sid = "GlueReadOnly" + Effect = "Allow" + Action = [ + "glue:GetDatabase", + "glue:GetTable", + "glue:GetPartitions", + ] + Resource = "*" + }, + # ── S3 (────────────────────── + # Athena 쿼리 결과를 ATHENA_OUTPUT_LOCATION에 쓰고 읽는 권한 + # CUR 원본 파일이 저장된 버킷 읽기 권한 + { + Sid = "S3AthenaAccess" + Effect = "Allow" + Action = [ + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:PutObject", + ] + Resource = "*" + }, + ] + }) +} + +resource "aws_iam_role_policy_attachment" "monitor_v2" { + role = aws_iam_role.lambda_exec.name + policy_arn = aws_iam_policy.monitor_v2.arn +} diff --git a/monitor_v2/infra/main.tf b/monitor_v2/infra/main.tf new file mode 100644 index 0000000..1195bf6 --- /dev/null +++ b/monitor_v2/infra/main.tf @@ -0,0 +1,218 @@ +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.0" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +locals { + function_name = "monitor-v2-daily-report" + project_root = "${path.module}/../.." + build_dir = "${path.module}/.build" +} + +# ── 의존성 설치 & 소스 복사 ────────────────────────────────────────── +# +# pip install 결과와 monitor_v2 소스를 .build/ 디렉토리에 모아서 +# archive_file이 단일 zip으로 묶을 수 있게 준비한다. +# +# 트리거: +# - pyproject.toml 변경 → 의존성 재설치 +# - monitor_v2/**/*.py 변경 → 소스 재복사 +resource "null_resource" "build_package" { + triggers = { + requirements = filemd5("${local.project_root}/pyproject.toml") + source_hash = sha256(join(",", [ + for f in sort(fileset("${local.project_root}/monitor_v2", "**/*.py")) : + filesha256("${local.project_root}/monitor_v2/${f}") + if !startswith(f, "test_") + ])) + } + + provisioner "local-exec" { + command = <<-EOT + set -e + rm -rf '${local.build_dir}' + mkdir -p '${local.build_dir}' + + # Python 의존성 설치 (slack-sdk는 순수 Python이므로 플랫폼 플래그 불필요) + pip install slack-sdk \ + --target '${local.build_dir}' \ + --quiet + + # monitor_v2 패키지 복사 (infra/, 테스트 파일, 캐시 제외) + cp -r '${local.project_root}/monitor_v2' '${local.build_dir}/monitor_v2' + rm -rf '${local.build_dir}/monitor_v2/infra' + find '${local.build_dir}/monitor_v2' -name 'test_*.py' -delete + find '${local.build_dir}/monitor_v2' -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true + EOT + interpreter = ["bash", "-c"] + } +} + +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = local.build_dir + output_path = "${path.module}/dist/lambda.zip" + depends_on = [null_resource.build_package] +} + +# ── Lambda 함수 ────────────────────────────────────────────────────── +resource "aws_lambda_function" "monitor_v2" { + function_name = local.function_name + description = "monitor_v2: daily AWS cost + EC2 report to Slack" + + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + + handler = "monitor_v2.lambda_handler.lambda_handler" + runtime = "python3.12" + + role = aws_iam_role.lambda_exec.arn + timeout = var.lambda_timeout + memory_size = var.lambda_memory_size + + environment { + variables = { + SLACK_BOT_TOKEN = var.slack_bot_token + SLACK_CHANNEL_ID = var.slack_channel_id + ACCOUNT_NAME = var.account_name + ATHENA_OUTPUT_LOCATION = var.athena_output_location + ATHENA_DATABASE = var.athena_database + ATHENA_WORKGROUP = var.athena_workgroup + } + } + + tags = { + Project = "cloud-usage-monitor" + Version = "v2" + } + + depends_on = [aws_cloudwatch_log_group.lambda_logs] +} + +# ── EventBridge (CloudWatch Events) ────────────────────────────────── +# +# 스케줄 4개 (KST 기준, EventBridge는 UTC 사용) +# KST 08:00 = UTC 23:00 전날 → cost 전날 데이터 +# KST 08:10 = UTC 23:10 전날 → ec2 전날 데이터 +# KST 22:00 = UTC 13:00 → cost 당일 데이터 +# KST 22:10 = UTC 13:10 → ec2 당일 데이터 + +# ── KST 08:00 cost (전날) ───────────────────────────────────────────── +resource "aws_cloudwatch_event_rule" "morning_cost" { + name = "${local.function_name}-morning-cost" + description = "KST 08:00 cost report (yesterday)" + schedule_expression = "cron(0 23 * * ? *)" + state = "ENABLED" +} + +resource "aws_cloudwatch_event_target" "morning_cost" { + rule = aws_cloudwatch_event_rule.morning_cost.name + target_id = "morning-cost" + arn = aws_lambda_function.monitor_v2.arn + input = jsonencode({ report_type = "cost", date_mode = "yesterday" }) +} + +resource "aws_lambda_permission" "morning_cost" { + statement_id = "AllowEventBridgeMorningCost" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.monitor_v2.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.morning_cost.arn +} + +# ── KST 08:10 ec2 (전날) ────────────────────────────────────────────── +resource "aws_cloudwatch_event_rule" "morning_ec2" { + name = "${local.function_name}-morning-ec2" + description = "KST 08:10 ec2 report (yesterday)" + schedule_expression = "cron(10 23 * * ? *)" + state = "ENABLED" +} + +resource "aws_cloudwatch_event_target" "morning_ec2" { + rule = aws_cloudwatch_event_rule.morning_ec2.name + target_id = "morning-ec2" + arn = aws_lambda_function.monitor_v2.arn + input = jsonencode({ report_type = "ec2", date_mode = "yesterday" }) +} + +resource "aws_lambda_permission" "morning_ec2" { + statement_id = "AllowEventBridgeMorningEc2" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.monitor_v2.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.morning_ec2.arn +} + +# ── KST 22:00 cost (당일) ───────────────────────────────────────────── +resource "aws_cloudwatch_event_rule" "evening_cost" { + name = "${local.function_name}-evening-cost" + description = "KST 22:00 cost report (today)" + schedule_expression = "cron(0 13 * * ? *)" + state = "ENABLED" +} + +resource "aws_cloudwatch_event_target" "evening_cost" { + rule = aws_cloudwatch_event_rule.evening_cost.name + target_id = "evening-cost" + arn = aws_lambda_function.monitor_v2.arn + input = jsonencode({ report_type = "cost", date_mode = "today" }) +} + +resource "aws_lambda_permission" "evening_cost" { + statement_id = "AllowEventBridgeEveningCost" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.monitor_v2.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.evening_cost.arn +} + +# ── KST 22:10 ec2 (당일) ────────────────────────────────────────────── +resource "aws_cloudwatch_event_rule" "evening_ec2" { + name = "${local.function_name}-evening-ec2" + description = "KST 22:10 ec2 report (today)" + schedule_expression = "cron(10 13 * * ? *)" + state = "ENABLED" +} + +resource "aws_cloudwatch_event_target" "evening_ec2" { + rule = aws_cloudwatch_event_rule.evening_ec2.name + target_id = "evening-ec2" + arn = aws_lambda_function.monitor_v2.arn + input = jsonencode({ report_type = "ec2", date_mode = "today" }) +} + +resource "aws_lambda_permission" "evening_ec2" { + statement_id = "AllowEventBridgeEveningEc2" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.monitor_v2.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.evening_ec2.arn +} + +# ── CloudWatch Logs ─────────────────────────────────────────────────── +resource "aws_cloudwatch_log_group" "lambda_logs" { + name = "/aws/lambda/${local.function_name}" + retention_in_days = 30 + + tags = { + Project = "cloud-usage-monitor" + Version = "v2" + } +} diff --git a/monitor_v2/infra/outputs.tf b/monitor_v2/infra/outputs.tf new file mode 100644 index 0000000..c073f7d --- /dev/null +++ b/monitor_v2/infra/outputs.tf @@ -0,0 +1,34 @@ +output "lambda_function_name" { + description = "Lambda 함수 이름" + value = aws_lambda_function.monitor_v2.function_name +} + +output "lambda_function_arn" { + description = "Lambda 함수 ARN" + value = aws_lambda_function.monitor_v2.arn +} + +output "lambda_invoke_arn" { + description = "Lambda 호출 ARN (API Gateway 연동 등에 사용)" + value = aws_lambda_function.monitor_v2.invoke_arn +} + +output "lambda_role_arn" { + description = "Lambda 실행 역할 ARN" + value = aws_iam_role.lambda_exec.arn +} + +output "eventbridge_rule_names" { + description = "EventBridge 규칙 이름 목록 (4개 스케줄)" + value = { + morning_cost = aws_cloudwatch_event_rule.morning_cost.name + morning_ec2 = aws_cloudwatch_event_rule.morning_ec2.name + evening_cost = aws_cloudwatch_event_rule.evening_cost.name + evening_ec2 = aws_cloudwatch_event_rule.evening_ec2.name + } +} + +output "log_group_name" { + description = "CloudWatch 로그 그룹 이름" + value = aws_cloudwatch_log_group.lambda_logs.name +} diff --git a/monitor_v2/infra/terraform.tfvars.example b/monitor_v2/infra/terraform.tfvars.example new file mode 100644 index 0000000..3b8c41e --- /dev/null +++ b/monitor_v2/infra/terraform.tfvars.example @@ -0,0 +1,20 @@ +# ── 필수 값 ───────────────────────────────────────────────────────── +# 실제 값을 입력한 뒤 terraform.tfvars 로 복사해서 사용 + +aws_region = "" +account_name = "" + +slack_bot_token = "xoxb-..." # Slack Bot User OAuth Token +slack_channel_id = "C..." # 대상 채널 ID + +# ── CUR / Athena (선택) ────────────────────────────────────────────── +# athena_output_location = "s3://your-bucket/athena-results/" +# athena_database = "" +# athena_workgroup = "" + +# ── Lambda 실행 설정 (선택) ────────────────────────────────────────── +# lambda_timeout = 900 +# lambda_memory_size = 512 + +# ── 스케줄 (선택) ──────────────────────────────────────────────────── +# schedule_expression = "cron(5 13 * * ? *)" # KST 22:05 diff --git a/monitor_v2/infra/variables.tf b/monitor_v2/infra/variables.tf new file mode 100644 index 0000000..a4da180 --- /dev/null +++ b/monitor_v2/infra/variables.tf @@ -0,0 +1,56 @@ +variable "aws_region" { + description = "Lambda를 배포할 AWS 리전" + type = string + default = "ap-northeast-2" +} + +variable "slack_bot_token" { + description = "Slack Bot User OAuth Token (xoxb-...)" + type = string + sensitive = true +} + +variable "slack_channel_id" { + description = "메시지를 보낼 Slack 채널 ID (C로 시작)" + type = string +} + +variable "account_name" { + description = "리포트 헤더에 표시할 AWS 계정 별칭 (e.g., hyu-ddps)" + type = string +} + +# ── CUR / Athena ───────────────────────────────────────────── + +variable "athena_output_location" { + description = "Athena 쿼리 결과를 저장할 S3 URI (e.g., s3://bucket/prefix/). CUR 미사용 시 빈 문자열" + type = string + default = "" +} + +variable "athena_database" { + description = "Athena 데이터베이스 이름" + type = string + default = "hyu_ddps_logs" +} + +variable "athena_workgroup" { + description = "Athena 워크그룹 이름" + type = string + default = "primary" +} + +# ── Lambda 실행 설정 ──────────────────────────────────────────────── + +variable "lambda_timeout" { + description = "Lambda 타임아웃 (초). 전 리전 순회 고려하여 최대값 권장" + type = number + default = 900 # 15분 (Lambda 최대값) +} + +variable "lambda_memory_size" { + description = "Lambda 메모리 크기 (MB)" + type = number + default = 512 +} + diff --git a/monitor_v2/lambda_handler.py b/monitor_v2/lambda_handler.py new file mode 100644 index 0000000..b46bc8c --- /dev/null +++ b/monitor_v2/lambda_handler.py @@ -0,0 +1,71 @@ +import boto3 +from datetime import datetime, timedelta, timezone + +from .cost.data_cur import collect_all as collect_cost_data +from .ec2.data_cur import collect_all as collect_ec2_data +from .cost.report_cur import send_cur_report +from .ec2.report_cur import send_ec2_cur_report +from .slack import client as slack + +KST = timezone(timedelta(hours=9)) + + +def lambda_handler(event, context): + """ + Lambda 핸들러. + + Args: + event: { + 'report_type': 'cost' | 'ec2' | 'all' (기본값: 'all'), + 'date_mode': 'today' | 'yesterday' (기본값: 'today'), + } + context: Lambda context 객체 + + date_mode 동작: + 'today' → today_kst 그대로 → d1_date = today - 1 (KST 22:00, CUR 당일 반영 후) + 'yesterday' → today_kst - 1 → d1_date = today - 2 (KST 08:00, CUR 전날까지만 반영) + + Returns: + 200 (성공) / 500 (실패) + """ + event = event or {} + report_type = event.get('report_type', 'all') + date_mode = event.get('date_mode', 'today') + + try: + today_kst = datetime.now(KST).date() + if date_mode == 'yesterday': + today_kst = today_kst - timedelta(days=1) + + sts = boto3.client('sts') + account_id = sts.get_caller_identity()['Account'] + + ec2_client = boto3.client('ec2', region_name='us-east-1') + ec2_regions = [ + r['RegionName'] + for r in ec2_client.describe_regions( + Filters=[{ + 'Name': 'opt-in-status', + 'Values': ['opt-in-not-required', 'opted-in'], + }] + )['Regions'] + ] + + # ── 데이터 수집 (CUR / Athena 기반, forecast만 CE 사용) ────────── + cost_data = collect_cost_data(today_kst) + + # ── Slack 발송 ─────────────────────────────────────────────────── + if report_type in ('cost', 'all'): + send_cur_report(cost_data) + + if report_type in ('ec2', 'all'): + ec2_data = collect_ec2_data(ec2_regions, account_id, cost_data['d1_date']) + send_ec2_cur_report(cost_data, ec2_data) + + return 200 + + except Exception as e: + import traceback + slack.post_error(context="lambda_handler", error=e) + print(traceback.format_exc()) + return 500 diff --git a/monitor_v2/slack/__init__.py b/monitor_v2/slack/__init__.py new file mode 100644 index 0000000..1368be7 --- /dev/null +++ b/monitor_v2/slack/__init__.py @@ -0,0 +1 @@ +# slack 패키지 diff --git a/monitor_v2/slack/client.py b/monitor_v2/slack/client.py new file mode 100644 index 0000000..ce0d860 --- /dev/null +++ b/monitor_v2/slack/client.py @@ -0,0 +1,102 @@ +""" +monitor_v2/slack/client.py + +Bot Token 방식 Slack 클라이언트. + +Incoming Webhook과의 차이: + - Webhook: 단방향 POST, thread_ts 지원 불가 + - Bot Token: chat.postMessage API → thread_ts로 스레드 답글 가능 + conversations.open → DM 채널 ID 획득 후 DM 발송 가능 + +환경변수: + SLACK_BOT_TOKEN: xoxb-... 형식의 Bot User OAuth Token + SLACK_CHANNEL_ID: 메시지를 보낼 채널 ID (C로 시작) + +사전 조건 (Slack App 설정): + Bot Token Scopes: + chat:write — 채널 메시지 발송 + im:write — DM 채널 열기 + users:read — (선택) 유저 정보 조회 + 채널에 Bot을 초대해야 chat:write 권한이 작동함 +""" + +import os + +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +BOT_TOKEN = os.environ['SLACK_BOT_TOKEN'] +CHANNEL_ID = os.environ['SLACK_CHANNEL_ID'] + +_client = WebClient(token=BOT_TOKEN) + + +def post_message(text: str, thread_ts: str = None) -> str: + """ + 채널에 텍스트 메시지를 전송한다. + + Args: + text: 전송할 메시지 텍스트 + thread_ts: 스레드로 달 경우 부모 메시지의 ts. None이면 새 메인 메시지. + + Returns: + 전송된 메시지의 ts 문자열 (스레드 부모로 재사용 가능) + """ + kwargs = {'channel': CHANNEL_ID, 'text': text} + if thread_ts: + kwargs['thread_ts'] = thread_ts + + response = _client.chat_postMessage(**kwargs) + return response['ts'] + + +def post_blocks(blocks: list, fallback_text: str = '', thread_ts: str = None) -> str: + """ + Block Kit 블록 배열을 채널에 전송한다. + + Args: + blocks: slack_sdk Block 객체 또는 dict 리스트 + fallback_text: 알림 미리보기에 표시될 텍스트 (blocks 미지원 환경 대비) + thread_ts: 스레드로 달 경우 부모 메시지의 ts. None이면 새 메인 메시지. + + Returns: + 전송된 메시지의 ts 문자열 + """ + serialized = [b.to_dict() if hasattr(b, 'to_dict') else b for b in blocks] + kwargs = {'channel': CHANNEL_ID, 'blocks': serialized, 'text': fallback_text} + if thread_ts: + kwargs['thread_ts'] = thread_ts + + response = _client.chat_postMessage(**kwargs) + return response['ts'] + + +def send_dm(slack_user_id: str, text: str) -> None: + """ + 특정 Slack User에게 DM을 발송한다. + + conversations.open으로 IM 채널 ID를 획득한 뒤 chat.postMessage를 호출. + DM 실패 시 전체 실행을 중단하지 않고 로그만 남긴다. + + Args: + slack_user_id: Slack User ID (U로 시작, IAM_SLACK_USER_MAP에서 조회) + text: DM 내용 + """ + try: + dm_resp = _client.conversations_open(users=[slack_user_id]) + dm_channel = dm_resp['channel']['id'] + _client.chat_postMessage(channel=dm_channel, text=text) + except SlackApiError as e: + print(f"[DM 발송 실패] user={slack_user_id}, error={e.response['error']}") + + +def post_error(context: str, error: Exception) -> None: + """ + 에러 발생 시 채널에 알림을 전송한다. + 전송 자체가 실패해도 예외를 삼켜 Lambda 종료를 막지 않는다. + """ + msg = f"[monitor_v2] 오류 발생\n컨텍스트: {context}\n오류: {str(error)}" + try: + post_message(msg) + except Exception: + pass diff --git a/monitor_v2/test_cost.py b/monitor_v2/test_cost.py new file mode 100644 index 0000000..45ea031 --- /dev/null +++ b/monitor_v2/test_cost.py @@ -0,0 +1,262 @@ +""" +monitor_v2/test_cost.py + +cost/data.py 단독 테스트 — Slack 발송 없이 수집 결과를 print. + +lambda_handler.py 와 동일하게 collect_all()을 호출하고, +반환된 8개 키를 섹션별로 출력한다. + +사용법: + uv run python -m monitor_v2.test_cost + 또는 + python -m monitor_v2.test_cost +""" + +import sys +from pathlib import Path +from datetime import datetime, timedelta, timezone +from pprint import pprint + +# 프로젝트 루트(cloud-usage/)를 경로에 추가 → monitor_v2 패키지 import 가능 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from print_test.utils.environment import setup_environment +from monitor_v2.cost.data import collect_all as collect_cost_data + +SEP = "─" * 70 +SEP2 = "=" * 70 +KST = timezone(timedelta(hours=9)) + + +def _fmt_service_cost(costs: dict, top_n: int = None) -> None: + """서비스별 비용 dict를 내림차순으로 출력.""" + sorted_items = sorted(costs.items(), key=lambda x: x[1], reverse=True) + if top_n: + sorted_items = sorted_items[:top_n] + for service, cost in sorted_items: + if cost > 0: + print(f" {service:<45} ${cost:>10,.4f}") + + +def _fmt_by_creator(by_creator: dict) -> None: + """서비스 → 생성자 구조를 creator별 총합 내림차순으로 출력.""" + creator_totals = {} + creator_services = {} + for service, creators in by_creator.items(): + for creator, cost in creators.items(): + creator_totals[creator] = creator_totals.get(creator, 0.0) + cost + creator_services.setdefault(creator, {}) + creator_services[creator][service] = creator_services[creator].get(service, 0.0) + cost + + for creator, total in sorted(creator_totals.items(), key=lambda x: x[1], reverse=True): + if total <= 0: + continue + short = creator.split(':')[-1] if creator.startswith('IAMUser') else creator + print(f" 👤 {short:<45} 합계: ${total:,.4f}") + for svc, cost in sorted(creator_services[creator].items(), key=lambda x: x[1], reverse=True): + if cost > 0: + print(f" ├─ {svc:<40} ${cost:,.4f}") + print() + + +def _fmt_by_creator_mtd(by_creator_mtd: dict, forecast: float) -> None: + """MTD creator별 총합 내림차순 + 비례 예측 출력.""" + creator_totals = {} + creator_services = {} + for service, creators in by_creator_mtd.items(): + for creator, cost in creators.items(): + creator_totals[creator] = creator_totals.get(creator, 0.0) + cost + creator_services.setdefault(creator, {}) + creator_services[creator][service] = creator_services[creator].get(service, 0.0) + cost + + if not creator_totals: + print(" (데이터 없음)\n") + return + + total_mtd = sum(creator_totals.values()) + for creator, total in sorted(creator_totals.items(), key=lambda x: x[1], reverse=True): + if total <= 0: + continue + short = creator.split(':')[-1] if creator.startswith('IAMUser') else creator + if forecast > 0 and total_mtd > 0: + fc = forecast * (total / total_mtd) + prj = total + fc + fstr = f" → 예상 ${prj:,.4f} (+${fc:,.4f} 추정)" + else: + fstr = "" + print(f" 👤 {short:<45} MTD: ${total:,.4f}{fstr}") + for svc, cost in sorted(creator_services[creator].items(), key=lambda x: x[1], reverse=True): + if cost > 0: + print(f" ├─ {svc:<40} ${cost:,.4f}") + print() + if forecast <= 0: + print(" ※ 잔여 예측 없음 (CE 예측 API 미응답)\n") + + +def _fmt_by_region(by_region: dict) -> None: + """서비스 → 리전 구조를 서비스 총합 내림차순으로 출력.""" + if not by_region: + print(" (데이터 없음)\n") + return + for service, regions in sorted(by_region.items(), key=lambda x: sum(x[1].values()), reverse=True): + total = sum(regions.values()) + if total <= 0: + continue + print(f" 📌 {service:<45} ${total:,.4f}") + for region, cost in sorted(regions.items(), key=lambda x: x[1], reverse=True): + if cost > 0: + print(f" ├─ {region:<30} ${cost:,.4f}") + print() + + +def _fmt_by_region_mtd(by_region_mtd: dict, forecast: float) -> None: + """MTD 서비스별 리전 구조 + 서비스 MTD 비율 비례 예측 출력.""" + if not by_region_mtd: + print(" (데이터 없음)\n") + return + total_mtd = sum( + sum(r.values()) + for r in by_region_mtd.values() + if sum(r.values()) > 0 + ) + for service, regions in sorted(by_region_mtd.items(), key=lambda x: sum(x[1].values()), reverse=True): + total = sum(regions.values()) + if total <= 0: + continue + if forecast > 0 and total_mtd > 0: + svc_fc = forecast * (total / total_mtd) + projected = total + svc_fc + fstr = f" → 예상 ${projected:,.4f} (+${svc_fc:,.4f} 추정)" + else: + fstr = "" + print(f" 📌 {service:<45} MTD: ${total:,.4f}{fstr}") + for region, cost in sorted(regions.items(), key=lambda x: x[1], reverse=True): + if cost > 0: + print(f" ├─ {region:<30} ${cost:,.4f}") + print() + if forecast <= 0: + print(" ※ 잔여 예측 없음 (CE 예측 API 미응답)\n") + + +def main(): + print("\n" + SEP2) + print(" monitor_v2 / cost/data.py — 단독 테스트") + print(SEP2) + + setup_environment() + + # CE는 UTC 자정 기준으로 하루를 끊으므로 UTC date를 today로 사용. + # KST date를 쓰면 자정~09:00 KST 구간에서 D-1이 하루 어긋날 수 있음. + today_kst = datetime.now(timezone.utc).date() #- timedelta(days=1) + print(f"\n 기준 날짜 (UTC date, CE 컷오프 기준): {today_kst}") + print(f" 리포트 대상 (D-1): {today_kst - timedelta(days=1)}") + print(f" 현재 KST 시각: {datetime.now(KST).strftime('%Y-%m-%d %H:%M')}") + print() + + print("▶ collect_all() 호출 중 (API 10회)...") + cost_data = collect_cost_data(today_kst) + print(" 완료\n") + + d1_date = cost_data['d1_date'] + daily_d1 = cost_data['daily_d1'] + daily_d2 = cost_data['daily_d2'] + daily_lm = cost_data['daily_lm'] + + # ── 1. 날짜 정보 ───────────────────────────────────────────────────────── + print(SEP) + print(f"[1] 날짜 정보") + print(SEP) + print(f" d1_date (리포트 대상일): {d1_date}") + print(f" daily_d1 기간: {d1_date}") + print(f" daily_d2 기간: {today_kst - timedelta(days=2)}") + print() + + # ── 2. D-1 서비스별 비용 ───────────────────────────────────────────────── + total_d1 = sum(daily_d1.values()) + print(SEP) + print(f"[2] D-1 서비스별 비용 (총 ${total_d1:,.4f})") + print(SEP) + _fmt_service_cost(daily_d1) + print() + + # ── 3. D-2 서비스별 비용 ───────────────────────────────────────────────── + total_d2 = sum(daily_d2.values()) + print(SEP) + print(f"[3] D-2 서비스별 비용 (총 ${total_d2:,.4f})") + print(SEP) + _fmt_service_cost(daily_d2) + print() + + # ── 4. 전월 동일일 서비스별 비용 ───────────────────────────────────────── + total_lm = sum(daily_lm.values()) + print(SEP) + print(f"[4] 전월 동일일 서비스별 비용 (총 ${total_lm:,.4f})") + print(SEP) + _fmt_service_cost(daily_lm) + print() + + # ── 5. MTD ─────────────────────────────────────────────────────────────── + mtd_this = cost_data['mtd_this'] + mtd_prev = cost_data['mtd_prev'] + forecast = cost_data.get('forecast', 0.0) + print(SEP) + print(f"[5] MTD (Month-To-Date) 누계 + 예측") + print(SEP) + print(f" 이번달 MTD: ${mtd_this:>12,.4f}") + print(f" 전월 MTD: ${mtd_prev:>12,.4f}") + if mtd_prev: + diff = mtd_this - mtd_prev + pct = diff / mtd_prev * 100 + print(f" 전월 대비: {'+' if diff >= 0 else ''}${diff:,.4f} ({'+' if pct >= 0 else ''}{pct:.1f}%)") + if forecast > 0: + projected = mtd_this + forecast + print(f" 잔여 예측: +${forecast:>11,.4f}") + print(f" 이달 예상: ${projected:>12,.4f}") + else: + print(f" 잔여 예측: (데이터 없음 — CE 예측 API 미응답)") + print() + + # ── 6. IAM User별 (by_creator) D-1 + MTD ──────────────────────────────── + forecast = cost_data.get('forecast', 0.0) + by_creator_mtd = cost_data.get('by_creator_mtd', {}) + by_region_mtd = cost_data.get('by_region_mtd', {}) + + print(SEP) + print(f"[6] aws:createdBy 태그별 비용 (Thread 2 원본)") + print(SEP) + print(" ※ '(태그 없음 / 공용)' = aws:createdBy 미태깅 리소스\n") + print(f" ▸ D-1 당일") + _fmt_by_creator(cost_data['by_creator']) + print(f" ▸ 당월 누계 (MTD)") + _fmt_by_creator_mtd(by_creator_mtd, forecast) + + # ── 7. 서비스 + 리전별 (by_region) D-1 + MTD ──────────────────────────── + print(SEP) + print(f"[7] 서비스 + 리전별 비용 (Thread 3 원본, EC2 포함 전체)") + print(SEP) + print(f" ▸ D-1 당일") + _fmt_by_region(cost_data['by_region']) + print(f" ▸ 당월 누계 (MTD) + 잔여 예측 (서비스 MTD 비율 비례 추정)") + _fmt_by_region_mtd(by_region_mtd, forecast) + + # ── 8. 원본 dict 요약 ──────────────────────────────────────────────────── + print(SEP) + print(f"[8] collect_all() 반환 dict 키 목록") + print(SEP) + for key, val in cost_data.items(): + if isinstance(val, dict): + print(f" '{key}': dict ({len(val)}개 항목)") + elif isinstance(val, float): + extra = ' ← 예측 불가' if key == 'forecast' and val == 0.0 else '' + print(f" '{key}': float = ${val:,.4f}{extra}") + else: + print(f" '{key}': {type(val).__name__} = {val}") + print() + + print(SEP2) + print(" 완료 — Slack 발송 없음") + print(SEP2 + "\n") + + +if __name__ == "__main__": + main() diff --git a/monitor_v2/test_cost_cur_to_slack.py b/monitor_v2/test_cost_cur_to_slack.py new file mode 100644 index 0000000..4ee8eb7 --- /dev/null +++ b/monitor_v2/test_cost_cur_to_slack.py @@ -0,0 +1,41 @@ +""" +monitor_v2/test_cost_cur_to_slack.py + +Athena CUR 기반 Cost 리포트 Slack 발송 테스트. + +test_cost_to_slack.py 와 동일한 메시지 구성으로 발송하되 +데이터 소스를 Cost Explorer API → Athena CUR 쿼리로 교체한다. + +실행: + python -m monitor_v2.test_cost_cur_to_slack + 또는 + python monitor_v2/test_cost_cur_to_slack.py + +필요 환경변수 (.env): + SLACK_BOT_TOKEN + SLACK_CHANNEL_ID + ATHENA_OUTPUT_LOCATION 예: s3://my-bucket/athena-results/ + ATHENA_DATABASE + ATHENA_WORKGROUP + ACCOUNT_NAME +""" + +import sys +import os +from pathlib import Path +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from print_test.utils.environment import setup_environment +setup_environment() + +from monitor_v2.cost.data_cur import collect_all as collect_cost_data_cur +from monitor_v2.cost.report_cur import send_cur_report + +KST = timezone(timedelta(hours=9)) + +if __name__ == "__main__": + today_kst = datetime.now(KST).date() + cost_data = collect_cost_data_cur(today_kst) + send_cur_report(cost_data) diff --git a/monitor_v2/test_cost_to_slack.py b/monitor_v2/test_cost_to_slack.py new file mode 100644 index 0000000..bbf6ba9 --- /dev/null +++ b/monitor_v2/test_cost_to_slack.py @@ -0,0 +1,26 @@ +import sys +import os +from pathlib import Path +from datetime import datetime, timedelta, timezone +import boto3 + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# setup_environment()를 먼저 호출해 환경변수를 세팅한 뒤 +# slack/client.py 처럼 모듈 레벨에서 os.environ을 읽는 모듈을 임포트한다. +from print_test.utils.environment import setup_environment +setup_environment() + +from monitor_v2.cost.data import collect_all as collect_cost_data +from monitor_v2.cost.report import send_main1_report + +KST = timezone(timedelta(hours=9)) + +if __name__ == "__main__": + today_kst = datetime.now(KST).date() #- timedelta(days=1) + session = boto3.session.Session() + ce = boto3.client('ce', region_name='us-east-1') + sts = boto3.client('sts') + + cost_data = collect_cost_data(today_kst) + send_main1_report(cost_data) diff --git a/monitor_v2/test_ec2.py b/monitor_v2/test_ec2.py new file mode 100644 index 0000000..70bfac8 --- /dev/null +++ b/monitor_v2/test_ec2.py @@ -0,0 +1,259 @@ +""" +monitor_v2/test_ec2.py + +ec2/data.py 단독 테스트 — Slack 발송 없이 수집 결과를 print. + +lambda_handler.py 와 동일하게 cost_data에서 period_d1을 계산한 뒤 +ec2/data.py의 collect_all()을 호출하고, 반환된 4개 키를 섹션별로 출력한다. + +사용법: + uv run python -m monitor_v2.test_ec2 + 또는 + python -m monitor_v2.test_ec2 +""" +import os +import sys +import boto3 +from pathlib import Path +from datetime import datetime, timedelta, timezone +from pprint import pprint + +# 프로젝트 루트(cloud-usage/)를 경로에 추가 → monitor_v2 패키지 import 가능 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from print_test.utils.environment import setup_environment +from monitor_v2.cost.data import collect_all as collect_cost_data +from monitor_v2.ec2.data import collect_all as collect_ec2_data + +SEP = "─" * 70 +SEP2 = "=" * 70 +KST = timezone(timedelta(hours=9)) + + +def _region_label(region: str) -> str: + return f"{region}" + + +def _runtime_str(launch_time, end_time=None) -> str: + """launch_time ~ end_time(없으면 now) 기간을 Xd Yh Zm 형식으로 반환.""" + if not launch_time: + return 'N/A' + ref = end_time if end_time else datetime.now(timezone.utc) + total_secs = int((ref - launch_time).total_seconds()) + if total_secs < 0: + return 'N/A' + days, rem = divmod(total_secs, 86400) + h, rem = divmod(rem, 3600) + m, _ = divmod(rem, 60) + if days > 0: + return f"{days}d {h:02d}h {m:02d}m" + return f"{h:02d}h {m:02d}m" + + +def _fmt_kst(dt) -> str: + """UTC datetime을 'YYYY-MM-DD HH:MM KST' 문자열로 변환.""" + if not dt: + return 'N/A' + kst_dt = dt.astimezone(KST) + return kst_dt.strftime('%Y-%m-%d %H:%M KST') + + +def _fmt_user_tags(tags: dict) -> str: + """aws: 접두어 태그와 Name 태그를 제외한 사용자 태그를 'K=V, ...' 형식으로 반환.""" + items = [ + f"{k}={v}" + for k, v in sorted(tags.items()) + if not k.startswith('aws:') and k != 'Name' + ] + return ', '.join(items) if items else '' + + +def _fmt_instances(instances_by_region: dict) -> None: + """인스턴스 목록을 리전 → 상태 계층으로 출력.""" + total_count = sum(len(v) for v in instances_by_region.values()) + print(f" 총 {total_count}개 인스턴스 (인스턴스가 있는 리전만 표시)\n") + + for region, instances in sorted(instances_by_region.items()): + print(f" 📍 {_region_label(region)} ({len(instances)}개)") + + by_state = {} + for inst in instances: + by_state.setdefault(inst['state'], []).append(inst) + + for state in ['running', 'stopped', 'terminated']: + if state not in by_state: + continue + print(f" ▶ {state} ({len(by_state[state])}개)") + for inst in by_state[state]: + name = inst['name'] or inst['instance_id'] + itype = inst['instance_type'] + pur = inst['purchase_option'] + + # 실행 시간 계산 + end_time = inst['state_transition_time'] if state != 'running' else None + runtime = _runtime_str(inst['launch_time'], end_time) + + # IAM 사용자 (aws:createdBy 파싱) + raw_user = inst.get('iam_user', '') + iam_user = raw_user.split(':')[-1] if raw_user.startswith('IAMUser') else raw_user + + # 시작/종료 시각 + launch_str = _fmt_kst(inst['launch_time']) + end_str = _fmt_kst(inst['state_transition_time']) if state != 'running' else None + + # 사용자 태그 + user_tags = _fmt_user_tags(inst.get('tags', {})) + + print(f" {name:<30} {inst['instance_id']} {itype:<14} [{pur}]") + + if state == 'running': + print(f" 시작: {launch_str} │ 실행: {runtime}" + + (f" │ 👤 {iam_user}" if iam_user else '')) + else: + end_label = '종료' if state == 'terminated' else '중지' + print(f" 시작: {launch_str}" + + (f" {end_label}: {end_str}" if end_str else '') + + f" │ 실행: {runtime}" + + (f" │ 👤 {iam_user}" if iam_user else '')) + + if user_tags: + print(f" 태그: {user_tags}") + print() + + +def _fmt_type_cost(type_cost: dict) -> None: + """인스턴스 타입별 비용을 총합 내림차순으로 출력.""" + type_totals = {itype: sum(r.values()) for itype, r in type_cost.items()} + for itype, total in sorted(type_totals.items(), key=lambda x: x[1], reverse=True): + if total <= 0: + continue + print(f" {itype:<20} ${total:>10,.4f}") + for region, cost in sorted(type_cost[itype].items(), key=lambda x: x[1], reverse=True): + if cost > 0: + print(f" ├─ {_region_label(region):<35} ${cost:,.4f}") + print() + + +def _fmt_unused_ebs(unused_ebs: list) -> None: + if not unused_ebs: + print(" 없음\n") + return + for vol in sorted(unused_ebs, key=lambda x: x['age_days'], reverse=True): + marker = '☄️ (kubernetes)' if vol['is_k8s'] else '⚠️' + print( + f" {vol['region']:<18} {vol['volume_id']} " + f"{vol['volume_type']:<6} {vol['size_gb']:>5}GB " + f"{vol['age_days']:>4}일 {marker}" + ) + print() + + +def _fmt_unused_snapshots(unused_snapshots: list) -> None: + ALERT_DAYS = 60 + if not unused_snapshots: + print(" 없음\n") + return + for snap in sorted(unused_snapshots, key=lambda x: x['age_days'], reverse=True): + if snap['age_days'] >= ALERT_DAYS: + marker = '🚨 60일 초과' + elif snap['has_tags']: + marker = '☄️ 태그 있음' + else: + marker = '⚠️' + print( + f" {snap['region']:<18} {snap['snapshot_id']} " + f"{snap['size_gb']:>5}GB {snap['age_days']:>4}일 {marker}" + ) + print() + + +def main(): + print("\n" + SEP2) + print(" monitor_v2 / ec2/data.py — 단독 테스트") + print(SEP2) + + setup_environment() + + today_kst = datetime.now(KST).date() - timedelta(days=1) + print(f"\n 기준 날짜 (today_kst): {today_kst}") + print(f" 리포트 대상 (D-1): {today_kst - timedelta(days=1)}\n") + + d1_date = today_kst + period_d1 = { + 'Start': d1_date.strftime('%Y-%m-%d'), + 'End': (d1_date + timedelta(days=1)).strftime('%Y-%m-%d'), + } + print(f" D-1 period: {period_d1['Start']} ~ {period_d1['End']} (End exclusive)\n") + + # ── EC2 수집 준비 ───────────────────────────────────────────────────────── + profile = os.environ.get('AWS_PROFILE', 'default') + session = boto3.Session(profile_name=profile) + ce = boto3.client('ce', region_name='us-east-1') + sts = boto3.client('sts') + account_id = sts.get_caller_identity()['Account'] + ec2_client = session.client('ec2', region_name='ap-northeast-2') + response = ec2_client.describe_regions( + Filters=[{ + 'Name': 'opt-in-status', + 'Values': ['opt-in-not-required', 'opted-in'] + }] + ) + ec2_regions = [r['RegionName'] for r in response['Regions']] + + print(f" AWS Account ID: {account_id}") + print(f" 조회 리전 수: {len(ec2_regions)}개\n") + + print("▶ ec2/data.py collect_all() 호출 중 (전 리전 순차 조회, 시간 소요)...") + ec2_data = collect_ec2_data(ec2_regions, account_id, ce, period_d1) + print(" 완료\n") + + # ── 1. 인스턴스 목록 ───────────────────────────────────────────────────── + print(SEP) + print(f"[1] EC2 인스턴스 (running / stopped / terminated)") + print(SEP) + if ec2_data['instances']: + _fmt_instances(ec2_data['instances']) + else: + print(" 인스턴스 없음\n") + + # ── 2. 인스턴스 타입별 D-1 비용 ───────────────────────────────────────── + print(SEP) + print(f"[2] 인스턴스 타입별 D-1 비용 (period: {period_d1['Start']})") + print(SEP) + if ec2_data['type_cost']: + _fmt_type_cost(ec2_data['type_cost']) + else: + print(" EC2 비용 없음 (해당 일 미사용)\n") + + # ── 3. 미사용 EBS ──────────────────────────────────────────────────────── + print(SEP) + print(f"[3] 미사용 EBS 볼륨 ({len(ec2_data['unused_ebs'])}개)") + print(SEP) + _fmt_unused_ebs(ec2_data['unused_ebs']) + + # ── 4. 미사용 Snapshot ─────────────────────────────────────────────────── + print(SEP) + print(f"[4] 미사용 Snapshot ({len(ec2_data['unused_snapshots'])}개)") + print(SEP) + _fmt_unused_snapshots(ec2_data['unused_snapshots']) + + # ── 5. 반환 dict 요약 ──────────────────────────────────────────────────── + print(SEP) + print(f"[5] collect_all() 반환 dict 키 목록") + print(SEP) + instances = ec2_data['instances'] + print(f" 'instances': dict (활성 리전: {len(instances)}개)") + for region, insts in sorted(instances.items()): + print(f" {_region_label(region)}: {len(insts)}개") + print(f" 'unused_ebs': list ({len(ec2_data['unused_ebs'])}개)") + print(f" 'unused_snapshots': list ({len(ec2_data['unused_snapshots'])}개)") + print(f" 'type_cost': dict (인스턴스 타입: {len(ec2_data['type_cost'])}종)") + print() + + print(SEP2) + print(" 완료 — Slack 발송 없음") + print(SEP2 + "\n") + + +if __name__ == "__main__": + main() diff --git a/monitor_v2/test_ec2_cur_to_slack.py b/monitor_v2/test_ec2_cur_to_slack.py new file mode 100644 index 0000000..7777274 --- /dev/null +++ b/monitor_v2/test_ec2_cur_to_slack.py @@ -0,0 +1,57 @@ +""" +monitor_v2/test_ec2_cur_to_slack.py + +Athena CUR 기반 EC2 리포트 Slack 발송 테스트. + +test_ec2_to_slack.py 와 동일한 메시지 구성으로 발송하되 +비용 데이터 소스를 Cost Explorer API → Athena CUR 쿼리로 교체한다. + +실행: + python -m monitor_v2.test_ec2_cur_to_slack + 또는 + python monitor_v2/test_ec2_cur_to_slack.py + +필요 환경변수 (.env): + SLACK_BOT_TOKEN + SLACK_CHANNEL_ID + ATHENA_OUTPUT_LOCATION + ATHENA_DATABASE (선택, 기본: hyu_ddps_logs) + ATHENA_WORKGROUP (선택, 기본: primary) + ACCOUNT_NAME (선택, 기본: hyu-ddps) +""" + +import sys +import boto3 +from pathlib import Path +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from print_test.utils.environment import setup_environment +setup_environment() + +from monitor_v2.cost.data_cur import collect_all as collect_cost_data_cur +from monitor_v2.ec2.data_cur import collect_all as collect_ec2_data_cur +from monitor_v2.ec2.report_cur import send_ec2_cur_report + +KST = timezone(timedelta(hours=9)) + +if __name__ == "__main__": + today_kst = datetime.now(KST).date() + + sts = boto3.client('sts') + account_id = sts.get_caller_identity()['Account'] + + ec2_client = boto3.client('ec2', region_name='us-east-1') + ec2_regions = [ + r['RegionName'] + for r in ec2_client.describe_regions( + Filters=[{'Name': 'opt-in-status', 'Values': ['opt-in-not-required', 'opted-in']}] + )['Regions'] + ] + + cost_data = collect_cost_data_cur(today_kst) + d1_date = cost_data['d1_date'] + + ec2_data = collect_ec2_data_cur(ec2_regions, account_id, d1_date) + send_ec2_cur_report(cost_data, ec2_data) diff --git a/monitor_v2/test_ec2_to_slack.py b/monitor_v2/test_ec2_to_slack.py new file mode 100644 index 0000000..d3278e1 --- /dev/null +++ b/monitor_v2/test_ec2_to_slack.py @@ -0,0 +1,41 @@ +import sys +import os +import boto3 +from pathlib import Path +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from print_test.utils.environment import setup_environment +setup_environment() + +from monitor_v2.cost.data import collect_all as collect_cost_data +from monitor_v2.ec2.data import collect_all as collect_ec2_data +from monitor_v2.ec2.report import send_main2_report + +KST = timezone(timedelta(hours=9)) + +if __name__ == "__main__": + today_kst = datetime.now(KST).date() + session = boto3.session.Session() + ce = boto3.client('ce', region_name='us-east-1') + sts = boto3.client('sts') + + account_id = sts.get_caller_identity()['Account'] + + ec2_client = session.client('ec2', region_name='us-east-1') + response = ec2_client.describe_regions( + Filters=[{'Name': 'opt-in-status', 'Values': ['opt-in-not-required', 'opted-in']}] + ) + ec2_regions = [r['RegionName'] for r in response['Regions']] + + cost_data = collect_cost_data(today_kst) + + d1_date = cost_data['d1_date'] + period_d1 = { + 'Start': d1_date.strftime('%Y-%m-%d'), + 'End': (d1_date + timedelta(days=1)).strftime('%Y-%m-%d'), + } + + ec2_data = collect_ec2_data(ec2_regions, account_id, ce, period_d1) + send_main2_report(cost_data, ec2_data) diff --git a/monitor_v2/utils/__init__.py b/monitor_v2/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/monitor_v2/utils/blocks.py b/monitor_v2/utils/blocks.py new file mode 100644 index 0000000..11f92a8 --- /dev/null +++ b/monitor_v2/utils/blocks.py @@ -0,0 +1,144 @@ +""" +monitor_v2/utils/blocks.py + +cost/report.py 와 ec2/report.py 가 공통으로 사용하는 +Block Kit 헬퍼, 계산/포맷 헬퍼, 공유 상수를 모아둔 모듈. +""" + +from slack_sdk.models.blocks import ( + HeaderBlock, SectionBlock, DividerBlock, ContextBlock, +) +from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject + +# --------------------------------------------------------------------------- +# 공유 상수 +# --------------------------------------------------------------------------- + +SEP = "─" * 60 + +EC2_SERVICES = { + 'Amazon Elastic Compute Cloud - Compute', # CE API 서비스명 + 'Amazon Elastic Compute Cloud', # CUR product_product_name + 'Amazon EC2', + 'EC2 - Other', +} + +_MD_BLOCK_MAX = 2800 # Slack 개별 markdown 블록 3000자 제한에서 여유분 확보 +_AGGREGATE_MD_MAX = 9500 # Slack 메시지 내 markdown 블록 합산 10000자 제한에서 여유분 확보 + + +# --------------------------------------------------------------------------- +# Block Kit 헬퍼 +# --------------------------------------------------------------------------- + +def header(text: str) -> HeaderBlock: + """굵고 큰 헤더. plain_text 전용, 150자 제한.""" + return HeaderBlock(text=PlainTextObject(text=text[:150])) + + +def section(text: str) -> SectionBlock: + """mrkdwn 텍스트 섹션. 소제목 및 본문용.""" + return SectionBlock(text=MarkdownTextObject(text=text)) + + +def fields_section(fields: list) -> SectionBlock: + """2열 그리드 SectionBlock. fields는 mrkdwn 문자열 리스트 (최대 10개).""" + return SectionBlock(fields=[MarkdownTextObject(text=f) for f in fields[:10]]) + + +def divider() -> DividerBlock: + """섹션 간 구분선.""" + return DividerBlock() + + +def context(text: str) -> ContextBlock: + """보조 설명용 작은 mrkdwn 텍스트.""" + return ContextBlock(elements=[MarkdownTextObject(text=text)]) + + +def md_block(text: str) -> dict: + """Slack 신규 markdown 블록 — Markdown 테이블 렌더링 지원.""" + return {"type": "markdown", "text": text} + + +def md_table_blocks(headers: list, rows: list) -> list: + """ + Markdown 테이블을 _MD_BLOCK_MAX 한도에 맞게 분할해 md_block 리스트로 반환. + 분할 시 각 블록마다 헤더 행을 반복 포함한다. + + Args: + headers: 열 헤더 리스트 + rows: 2차원 리스트 (각 행 = 헤더 길이와 동일한 셀 목록) + """ + header_text = ( + "| " + " | ".join(str(h) for h in headers) + " |\n" + + "| " + " | ".join(["---"] * len(headers)) + " |\n" + ) + chunks, current, cur_len = [], [], len(header_text) + for row in rows: + line = "| " + " | ".join(str(c) for c in row) + " |" + cost = len(line) + 1 + if current and cur_len + cost > _MD_BLOCK_MAX: + chunks.append(current) + current, cur_len = [], len(header_text) + current.append(line) + cur_len += cost + if current: + chunks.append(current) + return [md_block(header_text + "\n".join(c)) for c in chunks] if chunks \ + else [md_block(header_text.rstrip())] + + +def table_section(title: str, headers: list, rows: list) -> list: + """소제목 section + 전체 행 Markdown 테이블 블록 리스트.""" + return [section(title), *md_table_blocks(headers, rows)] + + +def split_by_aggregate(blocks: list) -> list: + """ + 단일 메시지 내 markdown 블록 합산이 _AGGREGATE_MD_MAX를 초과하지 않도록 + blocks를 분할하여 리스트의 리스트로 반환한다. + + markdown 블록(dict, type='markdown')의 text 길이만 합산 대상으로 계산한다. + 분할 경계는 항상 markdown 블록 앞에서 발생하므로 section/divider 등은 이동하지 않는다. + """ + groups: list = [] + current: list = [] + agg_len: int = 0 + + for block in blocks: + if isinstance(block, dict) and block.get('type') == 'markdown': + text_len = len(block.get('text', '')) + if current and agg_len + text_len > _AGGREGATE_MD_MAX: + groups.append(current) + current, agg_len = [], 0 + agg_len += text_len + current.append(block) + + if current: + groups.append(current) + + return groups or [[]] + + +# --------------------------------------------------------------------------- +# 공통 계산 / 포맷 헬퍼 +# --------------------------------------------------------------------------- + +def calc_change(today: float, compare: float) -> tuple: + """(delta, pct|None). compare=0이면 pct=None.""" + delta = today - compare + pct = (delta / compare * 100.0) if compare else None + return delta, pct + + +def fmt_change(delta: float, pct) -> str: + """+$13.45 (+12.2%) 형식 문자열.""" + sign = '+' if delta >= 0 else '' + d_str = f"{sign}${abs(delta):,.2f}" if delta >= 0 else f"-${abs(delta):,.2f}" + if pct is None: + suffix = '(신규)' if delta > 0 else '(중단)' if delta < 0 else '' + else: + s = '+' if pct >= 0 else '' + suffix = f"({s}{pct:.1f}%)" + return f"{d_str} {suffix}".strip() diff --git a/print_test/cloudtrail/lookup_events.py b/print_test/cloudtrail/lookup_events.py new file mode 100644 index 0000000..d0ba5d9 --- /dev/null +++ b/print_test/cloudtrail/lookup_events.py @@ -0,0 +1,160 @@ +""" +API 5: CloudTrail - lookup_events + +목적: EC2 인스턴스 상태 변화 이벤트 조회 (전체 활성 region) + +사용법: + python -m print_test.cloudtrail.lookup_events + 또는 + uv run python -m print_test.cloudtrail.lookup_events +""" + +import json +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from pprint import pprint + +import boto3 + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from print_test.utils.printer import StructuredPrinter + + +printer = StructuredPrinter() + +#SEARCH_MODES = ["RunInstances", "StartInstances", "TerminateInstances", "StopInstances", "BidEvictedEvent"] +SEARCH_MODES = ["RunInstances"] + + +def get_active_regions(session): + """활성화된 AWS region 목록 반환""" + ec2 = session.client('ec2') + regions = [r['RegionName'] for r in ec2.describe_regions()['Regions']] + return regions + + +def lookup_cloudtrail_events(cloudtrail, event_name, start_time, end_time): + """ + CloudTrail lookup_events 호출 (NextToken 페이지네이션 포함) + + 반환: 전체 Events 리스트 (모든 페이지 합산) + """ + all_events = [] + next_token = None + + while True: + kwargs = { + 'LookupAttributes': [{'AttributeKey': 'EventName', 'AttributeValue': event_name}], + 'StartTime': start_time, + 'EndTime': end_time, + } + if next_token: + kwargs['NextToken'] = next_token + + response = cloudtrail.lookup_events(**kwargs) + all_events.extend(response.get('Events', [])) + + next_token = response.get('NextToken') + if not next_token: + break + + return all_events + + +def test_cloudtrail_lookup_events(region, event_name, events): + """ + API 5: CloudTrail 이벤트 로그 조회 결과 출력 + + 목적: EC2 인스턴스 상태 변화 이벤트 조회 + """ + printer.print_header(f"API 5: CloudTrail [{region}] {event_name}", "lookup_events") + + printer.print_section("파싱된 데이터") + events_info = [] + for event in events: + # CloudTrailEvent는 JSON 문자열이므로 파싱 필수 + cloud_trail_event = json.loads(event.get('CloudTrailEvent', '{}')) + print(f"raw trail event") + pprint(cloud_trail_event) + info = { + 'EventId': event.get('EventId'), + 'EventName': event.get('EventName'), + 'EventTime': str(event.get('EventTime')), + 'Username': event.get('Username'), + 'Resources': [r.get('ResourceName') for r in event.get('Resources', [])], + 'RequestParameters': cloud_trail_event.get('requestParameters', {}) + } + events_info.append(info) + + printer.print_response({'events': events_info}) + + printer.print_key_info({ + '총 이벤트 수': len(events), + '이벤트 타입': list(set([e.get('EventName') for e in events])), + '이벤트 시간': str(events[0].get('EventTime', 'N/A')) if events else 'N/A' + }) + + printer.print_parsing_tips([ + "CloudTrailEvent는 JSON 문자열 - json.loads() 필수", + "EventTime은 ISO 8601 문자열 형식", + "Resources 리스트가 비어있을 수 있음", + "NextToken으로 페이지네이션 처리", + "동일 이벤트가 중복될 수 있음 - 필터링 필요" + ]) + + +def main(): + """메인 실행 함수""" + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " " * 78 + "║") + print("║" + " API 5: CloudTrail - lookup_events (전체 region)".center(78) + "║") + print("║" + " " * 78 + "║") + print("╚" + "=" * 78 + "╝") + + profile = os.environ.get('AWS_PROFILE', 'default') + print(f"🔄 AWS 세션 생성 중... (profile={profile})\n") + + session = boto3.Session(profile_name=profile) + + # 활성 region 목록 조회 + regions = get_active_regions(session) + print(f"✓ 활성 region {len(regions)}개 탐색 완료: {regions}\n") + + # 시간 범위: 전일 KST 기준 (aws_daily_instance_usage_report.py 패턴 동일) + utc_now = datetime.now(timezone.utc) + kst = timezone(timedelta(hours=9)) + start_time = (utc_now + timedelta(days=-1)).astimezone(kst).replace(hour=0, minute=0, second=0, microsecond=0) + end_time = utc_now.astimezone(kst).replace(hour=17, minute=0, second=0, microsecond=0) + print(f"📅 조회 기간: {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')} KST\n") + + # region별 × mode별 조회 + for region in regions: + print(f"region: {region}") + for mode in SEARCH_MODES: + try: + cloudtrail = session.client('cloudtrail', region_name=region) + events = lookup_cloudtrail_events(cloudtrail, mode, start_time, end_time) + + if not events: + print(f" [{region}] {mode}: 이벤트 없음") + continue + + test_cloudtrail_lookup_events(region, mode, events) + + except Exception as e: + print(f"\n❌ [{region}] {mode} 오류: {e}") + import traceback + traceback.print_exc() + + # 완료 메시지 + print("\n" + "=" * 80) + print("✅ API 5 테스트 완료!") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/cost_explorer/aws_createdBy.py b/print_test/cost_explorer/aws_createdBy.py new file mode 100644 index 0000000..484fc1e --- /dev/null +++ b/print_test/cost_explorer/aws_createdBy.py @@ -0,0 +1,258 @@ +""" +API 7: Cost Explorer - aws:createdBy 태그 기반 IAM 유저별 비용 집계 + +목적: aws:createdBy 태그를 dimension으로 활용해 계정 내 IAM 유저별 서비스 비용 집계 가능 여부 확인 + +사전 조건: + - AWS Billing 콘솔 > Cost allocation tags 에서 'aws:createdBy' 활성화 필요 + - 활성화 후 최대 24시간 이후부터 Cost Explorer에서 조회 가능 + +확인 항목: + 1. TAG(aws:createdBy) 단일 GroupBy — IAM 유저별 월간 총 비용 (MTD) + 2. 특정 유저 필터링 — 단일 IAM 유저의 서비스별 월간 비용 (MTD) + +응답 구조 주의: + - Keys[0] 포맷: "aws:createdBy$" (달러($)로 키 prefix 구분) + - 미태깅 리소스: Keys[0] = "aws:createdBy$" (value가 빈 문자열) + - tag_value 추출: group['Keys'][0].split('$', 1)[1] + +사용법: + python -m print_test.cost_explorer.aws_createdBy + 또는 + uv run python -m print_test.cost_explorer.aws_createdBy +""" + +import os +import sys +from datetime import date +from pathlib import Path +import boto3 + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from print_test.utils.environment import setup_environment +from print_test.utils.printer import StructuredPrinter + +printer = StructuredPrinter() + + +# ───────────────────────────────────────── +# 날짜 계산 +# ───────────────────────────────────────── + +def build_month_to_date_period(base_date): + """당월 1일 ~ 오늘(exclusive) MTD 기간.""" + start = base_date.replace(day=1) + end = base_date + return {'Start': start.strftime('%Y-%m-%d'), 'End': end.strftime('%Y-%m-%d')} + + +# ───────────────────────────────────────── +# 태그 파싱 헬퍼 +# ───────────────────────────────────────── + +def parse_tag_key(raw_key): + """ + Cost Explorer TAG GroupBy 응답에서 실제 태그값 추출. + + AWS 응답 포맷: "aws:createdBy$" + 미태깅: "aws:createdBy$" → "(untagged)" 반환 + """ + if '$' in raw_key: + value = raw_key.split('$', 1)[1] + return value if value else '(untagged)' + return raw_key + + +def shorten_creator(creator): + """ + IAM 유저 ARN을 짧게 표시. + 예: "arn:aws:iam::123456789012:user/john" → "user/john" + "assumed-role/AWSReservedSSO_xxx/user@example.com" → 마지막 3분할 그대로 + """ + if creator.startswith('arn:aws:'): + # ARN 마지막 세그먼트 (resource 부분) + return creator.split(':')[-1] + return creator + + +# ───────────────────────────────────────── +# 탐색 함수 +# ───────────────────────────────────────── + +def explore_costs_by_creator(ce_client, period, label): + """ + TAG(aws:createdBy) 단일 GroupBy → IAM 유저별 총 비용. + + GroupBy: [TAG: aws:createdBy] + Metrics: UnblendedCost + """ + printer.print_header(f"[{label}] IAM 유저별 총 비용 (aws:createdBy 단일 GroupBy)", "get_cost_and_usage") + print(f" TimePeriod: {period['Start']} ~ {period['End']}") + print(f" GroupBy: TAG(aws:createdBy) 단일") + + response = ce_client.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'TAG', 'Key': 'aws:createdBy'}, + ], + ) + + printer.print_section("원본 응답 구조 (ResultsByTime[0])") + printer.print_response(response['ResultsByTime'][0]) + + # 파싱: {creator: float} + groups = response['ResultsByTime'][0].get('Groups', []) + costs = {} + for group in groups: + raw_key = group['Keys'][0] # "aws:createdBy$" + creator = parse_tag_key(raw_key) + amount = float(group['Metrics']['UnblendedCost']['Amount']) + costs[creator] = costs.get(creator, 0.0) + amount + + sorted_costs = sorted(costs.items(), key=lambda x: x[1], reverse=True) + + printer.print_section("파싱 결과 — IAM 유저별 비용 (내림차순)") + printer.print_response({'creators': [ + {'Creator': shorten_creator(c), 'FullARN': c, 'Cost': f'${v:,.4f}'} + for c, v in sorted_costs + ]}) + + total = sum(costs.values()) + printer.print_key_info({ + '기간': f"{period['Start']} ~ {period['End']}", + '총 비용': f'${total:,.4f}', + 'IAM 유저 수': len(costs), + 'Top 5': [f"{shorten_creator(c)}: ${v:,.4f}" for c, v in sorted_costs[:5]], + }) + + printer.print_parsing_tips([ + "Keys[0] = 'aws:createdBy$' 형태 — $ 앞은 태그 키 이름", + "미태깅 리소스: 'aws:createdBy$' (value 비어있음) → '(untagged)' 처리", + "tag_value는 IAM ARN: arn:aws:iam::123:user/john 또는 assumed-role ARN", + "aws:createdBy 태그가 Billing 콘솔에서 활성화되지 않으면 Groups=[] 반환", + ]) + + return costs + + +def explore_costs_by_service_for_creator(ce_client, period, creator_arn, label): + """ + 특정 IAM 유저 필터 + SERVICE GroupBy → 해당 유저의 서비스별 비용. + + Filter: TAG(aws:createdBy) = creator_arn + GroupBy: [DIMENSION: SERVICE] + Metrics: UnblendedCost + """ + printer.print_header( + f"[{label}] 특정 유저 필터 → 서비스별 비용", + "get_cost_and_usage" + ) + print(f" TimePeriod: {period['Start']} ~ {period['End']}") + print(f" Filter: aws:createdBy = {creator_arn!r}") + print(f" GroupBy: DIMENSION(SERVICE)") + + response = ce_client.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + Filter={ + 'Tags': { + 'Key': 'aws:createdBy', + 'Values': [creator_arn], + } + }, + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + ], + ) + + printer.print_section("원본 응답 구조 (ResultsByTime[0])") + printer.print_response(response['ResultsByTime'][0]) + + groups = response['ResultsByTime'][0].get('Groups', []) + costs = {} + for group in groups: + service = group['Keys'][0] + amount = float(group['Metrics']['UnblendedCost']['Amount']) + costs[service] = amount + + sorted_costs = sorted(costs.items(), key=lambda x: x[1], reverse=True) + total = sum(costs.values()) + + printer.print_section(f"파싱 결과 — {shorten_creator(creator_arn)} 서비스별 비용") + printer.print_response({'services': [ + {'Service': s, 'Cost': f'${v:,.4f}'} for s, v in sorted_costs + ]}) + + printer.print_key_info({ + '조회 유저': shorten_creator(creator_arn), + '총 비용': f'${total:,.4f}', + '서비스 수': len(costs), + }) + + printer.print_parsing_tips([ + "Filter.Tags.Values 에 태그 값 전체(ARN 포함)를 넣어야 함", + "필터 후 GroupBy는 1개만 사용 가능 → SERVICE or INSTANCE_TYPE 등", + "여러 유저 동시 필터: Values 리스트에 여러 ARN 추가 (OR 조건)", + ]) + + return costs + + +# ───────────────────────────────────────── +# 메인 +# ───────────────────────────────────────── + +def main(): + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " API 7: Cost Explorer - aws:createdBy 태그 기반 IAM 유저별 비용 집계".center(78) + "║") + print("╚" + "=" * 78 + "╝") + + setup_environment() + + profile = os.environ.get('AWS_PROFILE', 'default') + print(f"🔄 AWS Cost Explorer API 호출 중... (profile={profile})\n") + + print(" ⚠️ 사전 조건 체크:") + print(" AWS Billing 콘솔 > Cost allocation tags > 'aws:createdBy' 활성화 필요") + print(" 미활성화 시 Groups=[] 반환 (비용 $0으로 보임)\n") + + session = boto3.Session(profile_name=profile) + ce_client = session.client('ce', region_name='us-east-1') + + today = date.today() + period_mtd = build_month_to_date_period(today) + + print(f" MTD 기간: {period_mtd['Start']} ~ {period_mtd['End']}\n") + + try: + # 1. IAM 유저별 월간 총 비용 (TAG 단일 GroupBy) + costs_by_creator = explore_costs_by_creator(ce_client, period_mtd, "MTD") + + # 2. 상위 유저 1명에 대해 서비스별 상세 조회 (필터 방식) + if costs_by_creator: + top_creator = max(costs_by_creator, key=costs_by_creator.get) + if top_creator != '(untagged)': + explore_costs_by_service_for_creator( + ce_client, period_mtd, top_creator, "MTD Top유저" + ) + else: + print("\n⚠️ 상위 유저가 (untagged)이므로 필터 상세 조회 스킵") + + except Exception as e: + print(f"\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() + return + + print("\n" + "=" * 80) + print("✅ API 7 탐색 완료 — aws:createdBy 태그 기반 IAM 유저별 비용 집계") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/cost_explorer/get_cost_and_usage.py b/print_test/cost_explorer/get_cost_and_usage.py new file mode 100644 index 0000000..f390272 --- /dev/null +++ b/print_test/cost_explorer/get_cost_and_usage.py @@ -0,0 +1,369 @@ +""" +API 6: Cost Explorer - get_cost_and_usage + +목적: 실제 AWS API 호출로 응답 구조 및 비용 데이터 확인 + +확인 항목: + 1. SERVICE 기준 — 전체 서비스 비용 (오늘 / 전일 / 전월 동일일) + 2. EC2 필터 + INSTANCE_TYPE 기준 — EC2 instance_type별 비용 및 실행시간 + +사용법: + python -m print_test.cost_explorer.get_cost_and_usage + 또는 + uv run python -m print_test.cost_explorer.get_cost_and_usage +""" + +import os +import sys +from calendar import monthrange +from datetime import date, timedelta +from pathlib import Path +from pprint import pprint + +import boto3 + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from print_test.utils.environment import setup_environment +from print_test.utils.printer import StructuredPrinter + +printer = StructuredPrinter() + + +# ───────────────────────────────────────── +# 날짜 계산 +# ───────────────────────────────────────── + +def build_period(base_date, days_ago): + """Cost Explorer TimePeriod dict. End는 exclusive이므로 +1일.""" + start = base_date - timedelta(days=days_ago) + end = start + timedelta(days=1) + return {'Start': start.strftime('%Y-%m-%d'), 'End': end.strftime('%Y-%m-%d')} + + +def build_last_month_period(base_date, days_ago): + """전월 동일일 TimePeriod. 말일 클램핑 포함.""" + target = base_date - timedelta(days=days_ago) + month = target.month - 1 if target.month > 1 else 12 + year = target.year if target.month > 1 else target.year - 1 + day = min(target.day, monthrange(year, month)[1]) + start = date(year, month, day) + end = start + timedelta(days=1) + return {'Start': start.strftime('%Y-%m-%d'), 'End': end.strftime('%Y-%m-%d')} + + +def build_month_period(year, month): + """특정 월 전체 TimePeriod. End는 다음달 1일 (exclusive).""" + start = date(year, month, 1) + next_month = month + 1 if month < 12 else 1 + next_year = year if month < 12 else year + 1 + end = date(next_year, next_month, 1) + return {'Start': start.strftime('%Y-%m-%d'), 'End': end.strftime('%Y-%m-%d')} + + +# ───────────────────────────────────────── +# 응답 탐색 함수 +# ───────────────────────────────────────── + +def explore_service_costs(ce_client, period, label): + """ + SERVICE 기준 비용 조회 및 응답 구조 출력. + + GroupBy: [SERVICE] 단일 + Metrics: UnblendedCost + """ + printer.print_header(f"[{label}] 전체 서비스 비용", "get_cost_and_usage") + print(f" TimePeriod: {period['Start']} ~ {period['End']}") + + response = ce_client.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost'], + GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}], + ) + + printer.print_section("원본 응답 구조 (ResultsByTime[0])") + printer.print_response(response['ResultsByTime'][0]) + + # 파싱: {service: float} + groups = response['ResultsByTime'][0].get('Groups', []) + costs = {} + for group in groups: + service = group['Keys'][0] + amount = float(group['Metrics']['UnblendedCost']['Amount']) + costs[service] = amount + + # 비용 내림차순 정렬 + sorted_costs = sorted(costs.items(), key=lambda x: x[1], reverse=True) + + printer.print_section("파싱 결과 — 서비스별 비용 (내림차순)") + printer.print_response({'services': [ + {'Service': s, 'Cost': f'${c:,.4f}'} for s, c in sorted_costs + ]}) + + total = sum(costs.values()) + estimated = response['ResultsByTime'][0].get('Estimated', False) + printer.print_key_info({ + '기간': f"{period['Start']} ~ {period['End']}", + '총 비용': f'${total:,.4f}', + '서비스 수': len(costs), + 'Estimated': estimated, + 'Top 5': [f"{s}: ${c:,.4f}" for s, c in sorted_costs[:5]], + }) + + printer.print_parsing_tips([ + "Keys[0] = 서비스 이름 (GroupBy=SERVICE 단일이므로 Keys는 1개 원소)", + "Amount는 문자열 반환 → float() 변환 필수", + "Estimated=True: 해당 날짜 데이터가 아직 미확정", + "Groups가 빈 리스트 = 해당 기간 비용 $0", + ]) + + return costs + + +def explore_monthly_top_service(ce_client, period, label): + """ + 월 단위 누적 비용 기준 서비스별 + IAM 유저별 비용 조회. + + Granularity: MONTHLY (기간 전체를 1개 결과로 반환) + GroupBy: [SERVICE, TAG(aws:createdBy)] — 서비스별 생성자 비용 분리 + Metrics: UnblendedCost + + 주의: GroupBy 최대 2개 제한 → USAGE_TYPE과 동시 사용 불가 + """ + printer.print_header(f"[{label}] 월 누적 서비스별 + 생성자별 비용", "get_cost_and_usage") + print(f" TimePeriod: {period['Start']} ~ {period['End']} (End는 exclusive)") + print(f" GroupBy: SERVICE + TAG(aws:createdBy)") + + response = ce_client.get_cost_and_usage( + TimePeriod=period, + Granularity='MONTHLY', + Metrics=['UnblendedCost'], + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'SERVICE'}, + {'Type': 'TAG', 'Key': 'aws:createdBy'}, + ], + ) + + printer.print_section("원본 응답 구조 (ResultsByTime[0])") + printer.print_response(response['ResultsByTime'][0]) + + # 파싱: {service: float} 및 {service: {creator: float}} + groups = response['ResultsByTime'][0].get('Groups', []) + costs = {} + by_creator = {} # {service: {creator: cost}} + + for group in groups: + service = group['Keys'][0] + raw_tag = group['Keys'][1] # "aws:createdBy$" + creator = raw_tag.split('$', 1)[1] if '$' in raw_tag else raw_tag + creator = creator if creator else '(untagged)' + amount = float(group['Metrics']['UnblendedCost']['Amount']) + + costs[service] = costs.get(service, 0.0) + amount + by_creator.setdefault(service, {}) + by_creator[service][creator] = by_creator[service].get(creator, 0.0) + amount + + sorted_costs = sorted(costs.items(), key=lambda x: x[1], reverse=True) + + # 표시: 서비스별 합계 + 상위 생성자 인라인 + display_rows = [] + for s, c in sorted_costs: + display_rows.append({'Service': s, 'Cost': f'${c:,.4f}'}) + top_creators = sorted(by_creator.get(s, {}).items(), key=lambda x: x[1], reverse=True)[:3] + for creator, ccost in top_creators: + short = creator.split(':')[-1] if creator.startswith('IAMUser') else creator + display_rows.append({'Service': f' └─ {short}', 'Cost': f'${ccost:,.4f}'}) + + printer.print_section("파싱 결과 — 서비스별 월 누적 비용 (생성자 Top3 인라인)") + printer.print_response({'services': display_rows}) + + total = sum(costs.values()) + estimated = response['ResultsByTime'][0].get('Estimated', False) + top_service, top_cost = sorted_costs[0] if sorted_costs else ('없음', 0.0) + + printer.print_key_info({ + '기간': f"{period['Start']} ~ {period['End']}", + '총 비용': f'${total:,.4f}', + '서비스 수': len(costs), + 'Estimated': estimated, + '최다 비용 서비스': top_service, + '최다 비용': f'${top_cost:,.4f}', + '비율': f'{top_cost / total * 100:.1f}%' if total > 0 else 'N/A', + 'Top 5 서비스': [f"{s}: ${c:,.4f}" for s, c in sorted_costs[:5]], + }) + + printer.print_parsing_tips([ + "GroupBy=[SERVICE, TAG(aws:createdBy)] → Keys[0]=서비스, Keys[1]='aws:createdBy$'", + "creator 추출: Keys[1].split('$', 1)[1] ($ 앞은 태그 키 이름)", + "미태깅 리소스: Keys[1]='aws:createdBy$' → creator='' → '(untagged)' 처리", + "GroupBy 최대 2개 제한: USAGE_TYPE과 동시 사용 불가 → 별도 호출 필요", + "aws:createdBy 미활성화 시 Groups에 creator=(untagged) 단일 행만 반환", + ]) + + return costs, top_service, top_cost + + +def explore_ec2_by_instance_type(ce_client, period, label): + """ + EC2 필터 + INSTANCE_TYPE 기준 비용 및 실행시간 조회. + + GroupBy: [INSTANCE_TYPE, REGION] (최대 2개 제약) + Metrics: UnblendedCost + UsageQuantity (실행시간 단위: Hrs) + Filter: SERVICE = 'Amazon EC2' + """ + printer.print_header(f"[{label}] EC2 — instance_type별 비용 및 실행시간", "get_cost_and_usage") + print(f" TimePeriod: {period['Start']} ~ {period['End']}") + print(f" Filter: Amazon EC2 only") + print(f" GroupBy: INSTANCE_TYPE + REGION") + + response = ce_client.get_cost_and_usage( + TimePeriod=period, + Granularity='DAILY', + Metrics=['UnblendedCost', 'UsageQuantity'], + Filter={ + 'Dimensions': { + 'Key': 'SERVICE', + 'Values': ['Amazon EC2'], + } + }, + GroupBy=[ + {'Type': 'DIMENSION', 'Key': 'INSTANCE_TYPE'}, + {'Type': 'DIMENSION', 'Key': 'REGION'}, + ], + ) + + printer.print_section("원본 응답 구조 (ResultsByTime[0])") + printer.print_response(response['ResultsByTime'][0]) + + # 파싱: {instance_type: {cost, hours}} — REGION 무시하고 타입별 합산 + groups = response['ResultsByTime'][0].get('Groups', []) + by_type = {} + for group in groups: + instance_type = group['Keys'][0] + region = group['Keys'][1] + cost = float(group['Metrics']['UnblendedCost']['Amount']) + hours = float(group['Metrics']['UsageQuantity']['Amount']) + + if instance_type not in by_type: + by_type[instance_type] = {'cost': 0.0, 'hours': 0.0, 'regions': []} + by_type[instance_type]['cost'] += cost + by_type[instance_type]['hours'] += hours + if region not in by_type[instance_type]['regions']: + by_type[instance_type]['regions'].append(region) + + sorted_types = sorted(by_type.items(), key=lambda x: x[1]['cost'], reverse=True) + + printer.print_section("파싱 결과 — instance_type별 집계 (비용 내림차순)") + printer.print_response({'instance_types': [ + { + 'InstanceType': t, + 'Cost': f"${d['cost']:,.4f}", + 'Hours': f"{d['hours']:.1f} Hrs", + 'Regions': d['regions'], + } + for t, d in sorted_types + ]}) + + total_ec2 = sum(d['cost'] for d in by_type.values()) + printer.print_key_info({ + '기간': f"{period['Start']} ~ {period['End']}", + 'EC2 총 비용': f'${total_ec2:,.4f}', + 'instance_type 수': len(by_type), + 'Top 5 (비용 기준)': [ + f"{t}: ${d['cost']:,.4f} / {d['hours']:.1f}Hrs" + for t, d in sorted_types[:5] + ], + }) + + printer.print_parsing_tips([ + "GroupBy 2개: Keys[0]=INSTANCE_TYPE, Keys[1]=REGION", + "UsageQuantity 단위는 'Hrs' (BoxUsage 기반 청구 시간)", + "같은 instance_type이 여러 리전에 걸쳐 나오므로 타입별 합산 필요", + "GroupBy 최대 2개 제약: INSTANCE_TYPE + LINKED_ACCOUNT 동시 사용 불가", + ]) + + return by_type + + +# ───────────────────────────────────────── +# 메인 +# ───────────────────────────────────────── + +def main(): + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " API 6: Cost Explorer - get_cost_and_usage".center(78) + "║") + print("╚" + "=" * 78 + "╝") + + setup_environment() + + profile = os.environ.get('AWS_PROFILE', 'default') + print(f"🔄 AWS Cost Explorer API 호출 중... (profile={profile})\n") + + # Cost Explorer는 글로벌 단일 엔드포인트 (us-east-1 고정) + session = boto3.Session(profile_name=profile) + ce_client = session.client('ce', region_name='us-east-1') + + today = date.today() + days_ago = 1 # D-1 기준 (확정값) + + period_d1 = build_period(today, days_ago) + period_d2 = build_period(today, days_ago + 1) + period_lm = build_last_month_period(today, days_ago) + + print(f" D-1 (리포트 대상): {period_d1['Start']}") + print(f" D-2 (전일 비교): {period_d2['Start']}") + print(f" 전월 동일일: {period_lm['Start']}\n") + + # 26년 3월 전체 누적 기간 + period_mar2026 = build_month_period(2026, 3) + print(f" 26년 03월 누적: {period_mar2026['Start']} ~ {period_mar2026['End']}\n") + + try: + # 1. 전체 서비스 — 3개 기간 + costs_d1 = explore_service_costs(ce_client, period_d1, "D-1 (오늘)") + costs_d2 = explore_service_costs(ce_client, period_d2, "D-2 (전일)") + costs_lm = explore_service_costs(ce_client, period_lm, "전월 동일일") + + # 2. EC2 instance_type — D-1만 (구조 확인용) + explore_ec2_by_instance_type(ce_client, period_d1, "D-1 EC2") + + # 3. 26년 3월 월 누적 최다 비용 서비스 + explore_monthly_top_service(ce_client, period_mar2026, "26년 03월 누적") + + # 4. 전체 서비스 3기간 비교 요약 + printer.print_header("비교 요약 — D-1 vs D-2 vs 전월 동일일", "종합") + + all_services = set(costs_d1) | set(costs_d2) | set(costs_lm) + sorted_by_today = sorted(all_services, key=lambda s: costs_d1.get(s, 0.0), reverse=True) + + rows = [] + for service in sorted_by_today[:10]: + c1 = costs_d1.get(service, 0.0) + c2 = costs_d2.get(service, 0.0) + clm = costs_lm.get(service, 0.0) + d_yday = f"{'+' if c1-c2 >= 0 else ''}{c1-c2:+.2f}" if c2 else "N/A" + d_lm = f"{'+' if c1-clm >= 0 else ''}{c1-clm:+.2f}" if clm else "N/A" + rows.append({ + 'Service': service[:40], + 'D-1': f"${c1:,.2f}", + 'vs D-2': d_yday, + 'vs 전월': d_lm, + }) + + printer.print_response({'top10_comparison': rows}, max_items=10) + + except Exception as e: + print(f"\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() + return + + print("\n" + "=" * 80) + print("✅ API 6 탐색 완료!") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/ec2/describe_instances.py b/print_test/ec2/describe_instances.py new file mode 100644 index 0000000..9579552 --- /dev/null +++ b/print_test/ec2/describe_instances.py @@ -0,0 +1,128 @@ +""" +API 1: EC2 - describe_instances + +목적: 모든 리전의 running/stopped 인스턴스 조회 + +사용법: + python -m print_test.ec2.describe_instances + 또는 + uv run python -m print_test.ec2.describe_instances +""" + +import os +import sys +from pathlib import Path + +import boto3 + + +# 프로젝트 루트에서 import +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from print_test.utils.printer import StructuredPrinter +from print_test.utils.environment import setup_environment + + + +printer = StructuredPrinter() + + +def test_ec2_describe_instances(response): + """ + API 1: EC2 인스턴스 조회 + + 목적: 모든 리전의 running/stopped 인스턴스 조회 + """ + printer.print_header("API 1: EC2 인스턴스 조회", "describe_instances") + + printer.print_section("원본 응답 구조") + printer.print_response(response) + + printer.print_section("파싱된 데이터") + instances_info = [] + for reservation in response.get('Reservations', []): + for instance in reservation.get('Instances', []): + info = { + 'InstanceId': instance.get('InstanceId'), + 'InstanceType': instance.get('InstanceType'), + 'State': instance.get('State', {}).get('Name'), + 'LaunchTime': str(instance.get('LaunchTime')), + 'Tags': {tag['Key']: tag['Value'] for tag in instance.get('Tags', [])} + } + instances_info.append(info) + + printer.print_response({'instances': instances_info}) + + printer.print_key_info({ + '총 인스턴스 수': len(instances_info), + 'Running': len([i for i in instances_info if i['State'] == 'running']), + 'Stopped': len([i for i in instances_info if i['State'] == 'stopped']), + }) + + printer.print_parsing_tips([ + "Reservations 리스트가 빈 경우 처리 필요", + "Tags 필드는 선택사항 - .get() 사용", + "State.Name으로 상태 확인 (running, stopped, terminated)", + "LaunchTime은 datetime 객체 - 문자열로 변환 후 출력" + ]) + + +def main(): + """메인 실행 함수""" + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " " * 78 + "║") + print("║" + " API 1: EC2 - describe_instances".center(78) + "║") + print("║" + " " * 78 + "║") + print("╚" + "=" * 78 + "╝") + + # 환경변수 세팅 + setup_environment() + + # AWS 세션 및 클라이언트 생성 + profile = os.environ.get('AWS_PROFILE', 'default') + print(f"🔄 AWS API 호출 중... (profile={profile}, 전체 리전)\n") + + session = boto3.Session(profile_name=profile) + + # 활성화된 리전 목록 조회 + ec2_global = session.client('ec2', region_name='us-east-1') + regions_response = ec2_global.describe_regions( + Filters=[{'Name': 'opt-in-status', 'Values': ['opt-in-not-required', 'opted-in']}] + ) + regions = [r['RegionName'] for r in regions_response['Regions']] + print(f" 조회 대상 리전 수: {len(regions)}") + + # 전체 리전 집계 + all_reservations = [] + for region in regions: + try: + ec2 = session.client('ec2', region_name=region) + resp = ec2.describe_instances() + for reservation in resp.get('Reservations', []): + for instance in reservation.get('Instances', []): + instance['_Region'] = region + all_reservations.append(reservation) + except Exception as e: + print(f" [{region}] 조회 실패: {e}") + + response = {'Reservations': all_reservations} + print("✓ API 응답 수신 완료\n") + + # API 테스트 + try: + test_ec2_describe_instances(response) + except Exception as e: + print(f"\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() + return + + # 완료 메시지 + print("\n" + "=" * 80) + print("✅ API 1 테스트 완료!") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/ec2/describe_volumes.py b/print_test/ec2/describe_volumes.py new file mode 100644 index 0000000..612d2ec --- /dev/null +++ b/print_test/ec2/describe_volumes.py @@ -0,0 +1,124 @@ +""" +API 2: EC2 - describe_volumes + +목적: 미사용 EBS 볼륨 조회 + +사용법: + python -m print_test.ec2.describe_volumes + 또는 + uv run python -m print_test.ec2.describe_volumes +""" + +import os +import sys +from pathlib import Path + +import boto3 + +# 프로젝트 루트에서 import +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from print_test.utils.environment import setup_environment +from print_test.utils.printer import StructuredPrinter + + +printer = StructuredPrinter() + + +def test_ec2_describe_volumes(response): + """ + API 2: EC2 미사용 볼륨 조회 + + 목적: 미사용 EBS 볼륨 조회 + """ + printer.print_header("API 2: EC2 미사용 볼륨 조회", "describe_volumes") + + printer.print_section("원본 응답 구조") + printer.print_response(response) + + printer.print_section("파싱된 데이터") + volumes_info = [] + for volume in response.get('Volumes', []): + info = { + 'VolumeId': volume.get('VolumeId'), + 'Size': f"{volume.get('Size')}GB", + 'Type': volume.get('VolumeType'), + 'Status': volume.get('State'), + 'CreatedTime': str(volume.get('CreateTime')), + 'Tags': {tag['Key']: tag['Value'] for tag in volume.get('Tags', [])} + } + volumes_info.append(info) + + printer.print_response({'volumes': volumes_info}) + + printer.print_key_info({ + '총 볼륨 수': len(volumes_info), + '총 용량': f"{sum(float(v['Size'].replace('GB', '')) for v in volumes_info if 'GB' in v['Size'])}GB", + 'Volume 타입': list(set([v['Type'] for v in volumes_info])) + }) + + printer.print_parsing_tips([ + "Status='available'은 미사용 볼륨", + "CreateTime은 datetime 객체", + "Kubernetes 태그로 cluster 리소스 구분", + "Size는 정수형 - GB 단위" + ]) + + +def main(): + """메인 실행 함수""" + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " " * 78 + "║") + print("║" + " API 2: EC2 - describe_volumes".center(78) + "║") + print("║" + " " * 78 + "║") + print("╚" + "=" * 78 + "╝") + + # 환경변수 세팅 + setup_environment() + + # AWS 세션 및 클라이언트 생성 + profile = os.environ.get('AWS_PROFILE', 'default') + print(f"🔄 AWS API 호출 중... (profile={profile}, 전체 리전)\n") + + session = boto3.Session(profile_name=profile) + + # 활성화된 리전 목록 조회 + ec2_global = session.client('ec2', region_name='us-east-1') + regions_response = ec2_global.describe_regions( + Filters=[{'Name': 'opt-in-status', 'Values': ['opt-in-not-required', 'opted-in']}] + ) + regions = [r['RegionName'] for r in regions_response['Regions']] + print(f" 조회 대상 리전 수: {len(regions)}") + + # 전체 리전 집계 + all_volumes = [] + for region in regions: + try: + ec2 = session.client('ec2', region_name=region) + resp = ec2.describe_volumes() + for volume in resp.get('Volumes', []): + volume['_Region'] = region + all_volumes.append(volume) + except Exception as e: + print(f" [{region}] 조회 실패: {e}") + + response = {'Volumes': all_volumes} + print("✓ API 응답 수신 완료\n") + + # API 테스트 + try: + test_ec2_describe_volumes(response) + except Exception as e: + print(f"\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() + return + + # 완료 메시지 + print("\n" + "=" * 80) + print("✅ API 2 테스트 완료!") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/lambda_fn/list_functions.py b/print_test/lambda_fn/list_functions.py new file mode 100644 index 0000000..79ff2d2 --- /dev/null +++ b/print_test/lambda_fn/list_functions.py @@ -0,0 +1,109 @@ +""" +API 8: Lambda - list_functions + +목적: 계정의 모든 Lambda 함수 조회 + +사용법: + python -m print_test.lambda_fn.list_functions + 또는 + uv run python -m print_test.lambda_fn.list_functions +""" + +import sys +from pathlib import Path + +from print_test.utils.environment import setup_environment +from print_test.utils.printer import StructuredPrinter + +# 프로젝트 루트에서 import +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +printer = StructuredPrinter() + + +def test_lambda_list_functions(mock_responses): + """ + API 8: Lambda 함수 목록 조회 + + 목적: 계정의 모든 Lambda 함수 조회 + """ + printer.print_header("API 8: Lambda 함수 목록 조회", "list_functions") + + response = mock_responses.LAMBDA_LIST_FUNCTIONS_RESPONSE + + printer.print_section("원본 응답 구조") + printer.print_response(response) + + printer.print_section("파싱된 데이터") + functions_info = [] + for func in response.get('Functions', []): + info = { + 'FunctionName': func.get('FunctionName'), + 'Runtime': func.get('Runtime'), + 'Memory': f"{func.get('MemorySize')}MB", + 'Timeout': f"{func.get('Timeout')}s", + 'LastModified': str(func.get('LastModified')), + 'PackageType': func.get('PackageType'), + } + functions_info.append(info) + + printer.print_response({'functions': functions_info}) + + printer.print_key_info({ + '총 함수 수': len(response.get('Functions', [])), + '런타임 분포': list(set([f.get('Runtime') for f in response.get('Functions', [])])), + '페이지네이션': 'NextMarker' in response and response['NextMarker'] is not None + }) + + printer.print_parsing_tips([ + "PackageType: 'Zip' 또는 'Image'", + "LoggingConfig는 최신 함수에만 있음", + "LastModified는 ISO 8601 문자열", + "NextMarker로 페이지네이션 처리" + ]) + + +def main(): + """메인 실행 함수""" + print("\n") + print("╔" + "=" * 78 + "╗") + print("║" + " " * 78 + "║") + print("║" + " API 8: Lambda - list_functions".center(78) + "║") + print("║" + " " * 78 + "║") + print("╚" + "=" * 78 + "╝") + + # 환경변수 세팅 + setup_environment() + + # Mock 응답 로드 + print("🔄 Mock 응답 로드 중...\n") + + if not mock_responses: + print("❌ Mock 응답을 로드할 수 없습니다.") + print(" test/fixtures/aws_responses.py 파일이 필요합니다.") + return + + print("✓ Mock 응답 로드 완료\n") + + # API 테스트 + try: + test_lambda_list_functions(mock_responses) + except AttributeError as e: + print(f"\n❌ Mock 응답 필드 오류: {e}") + print(f" test/fixtures/aws_responses.py 파일 구조를 확인하세요.") + return + except Exception as e: + print(f"\n❌ 오류 발생: {e}") + import traceback + traceback.print_exc() + return + + # 완료 메시지 + print("\n" + "=" * 80) + print("✅ API 8 테스트 완료!") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + main() diff --git a/print_test/utils/environment.py b/print_test/utils/environment.py new file mode 100644 index 0000000..104d135 --- /dev/null +++ b/print_test/utils/environment.py @@ -0,0 +1,89 @@ +"""환경변수 설정 및 로드""" +import os +from pathlib import Path +from typing import Dict + + +# 프로젝트 루트: print_test/utils/environment.py → 상위 2단계 +_PROJECT_ROOT = Path(__file__).parent.parent.parent + + +def load_env_from_file(env_file_path: str = ".env") -> Dict[str, str]: + """ + .env 파일에서 환경변수 로드 + + 형식: + KEY = "value" + 또는 + KEY="value" + + 반환: + {KEY: value} 딕셔너리 + """ + env_vars = {} + if not os.path.exists(env_file_path): + print(f"⚠️ {env_file_path} 파일이 없습니다. 환경변수 생략") + return env_vars + + with open(env_file_path, 'r') as f: + for line in f: + line = line.strip() + # 주석, 빈 줄 제외 + if not line or line.startswith('#'): + continue + # KEY = "value" 형식 파싱 + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + env_vars[key] = value + + return env_vars + + +def setup_environment(): + """ + 환경변수 세팅 (구체적인 단계) + + 단계 1: .env 파일에서 로드 + 단계 2: OS 환경변수에 설정 + 단계 3: AWS_PROFILE 설정 (AWS CLI 기반 자격증명) + 단계 4: AWS 리전 설정 + """ + print("=" * 80) + print("🔧 환경변수 세팅") + print("=" * 80) + + # 단계 1: .env 파일 로드 (프로젝트 루트 기준 절대경로) + env_file_path = str(_PROJECT_ROOT / ".env") + env_vars = load_env_from_file(env_file_path) + print(f"\n✓ {env_file_path}에서 로드된 변수:") + for key, value in env_vars.items(): + masked_value = value[:20] + "..." if len(value) > 20 else value + print(f" - {key}: {masked_value}") + + # 단계 2: OS 환경변수에 설정 + for key, value in env_vars.items(): + os.environ[key] = value + print(f" ✓ os.environ['{key}'] 설정됨") + + # 단계 3: AWS_PROFILE 설정 (AWS 자격증명 기반) + if 'AWS_PROFILE' not in os.environ: + os.environ['AWS_PROFILE'] = 'default' + print(f"\n✓ AWS_PROFILE 기본값 설정:") + print(f" - AWS_PROFILE: default (기본 프로필)") + else: + print(f"\n✓ AWS_PROFILE 설정:") + print(f" - AWS_PROFILE: {os.environ['AWS_PROFILE']}") + + # 단계 4: AWS 리전 설정 + if 'AWS_REGION' not in os.environ: + os.environ['AWS_REGION'] = 'ap-northeast-2' + if 'AWS_DEFAULT_REGION' not in os.environ: + os.environ['AWS_DEFAULT_REGION'] = os.environ['AWS_REGION'] + + print(f"\n✓ AWS 리전 설정:") + print(f" - AWS_REGION: {os.environ['AWS_REGION']}") + print(f" - AWS_DEFAULT_REGION: {os.environ['AWS_DEFAULT_REGION']}") + + print("\n" + "=" * 80 + "\n") diff --git a/print_test/utils/printer.py b/print_test/utils/printer.py new file mode 100644 index 0000000..3cf9b1e --- /dev/null +++ b/print_test/utils/printer.py @@ -0,0 +1,60 @@ +"""구조화된 출력 관리""" +from pprint import PrettyPrinter +from typing import Dict, Any, List + + +class StructuredPrinter: + """구조화된 출력 관리""" + + def __init__(self): + self.pp = PrettyPrinter(indent=2, width=100, compact=False) + + def print_header(self, title: str, api_name: str = ""): + """API 섹션 헤더""" + print("\n" + "=" * 80) + print(f"📊 {title}") + if api_name: + print(f" API: {api_name}") + print("=" * 80) + + def print_section(self, section_title: str): + """섹션 제목""" + print(f"\n▶ {section_title}") + print("-" * 80) + + def print_response(self, response: Dict[str, Any], max_items: int = 100): + """응답 출력 (아이템 제한)""" + if isinstance(response, dict): + display_response = self._truncate_response(response, max_items) + self.pp.pprint(display_response) + else: + self.pp.pprint(response) + + def _truncate_response(self, obj: Any, max_items: int) -> Any: + """대용량 리스트 축약""" + if isinstance(obj, dict): + return {k: self._truncate_response(v, max_items) for k, v in obj.items()} + elif isinstance(obj, list): + truncated = obj[:max_items] + if len(obj) > max_items: + truncated.append(f"... ({len(obj) - max_items}개 더 있음)") + return truncated + else: + return obj + + def print_key_info(self, info_dict: Dict[str, Any]): + """핵심 정보 테이블 형식""" + print("\n📋 핵심 정보:") + for key, value in info_dict.items(): + if isinstance(value, (dict, list)): + print(f" {key}:") + for item in (value if isinstance(value, list) else [value]): + print(f" - {item}") + else: + print(f" {key}: {value}") + + def print_parsing_tips(self, tips: List[str]): + """파싱 팁""" + print("\n💡 파싱 팁:") + for tip in tips: + print(f" ✓ {tip}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..59df430 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cloud-usage-monitor" +version = "0.1.0" +description = "Multi-cloud cost monitoring and resource management system" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +keywords = ["aws", "gcp", "azure", "cost-monitoring", "lambda"] + +# Production dependencies +dependencies = [ + "boto3>=1.26.0", # AWS SDK + "botocore>=1.29.0", # AWS core (included with boto3) + "google-cloud-bigquery>=3.11.0", # GCP BigQuery + "google-auth>=2.20.0", # GCP authentication + "google-auth-oauthlib>=1.0.0", # GCP OAuth + "requests>=2.28.0", # HTTP library + "adal>=1.2.7", # Azure AD authentication + "slack_sdk>=3.19.0", # Slack Bot Token API (monitor_v2) +] + +[tool.setuptools.packages.find] +include = ["monitor*", "print_test*"]