git:20260624.50dd7db to git:20260630.2c45edf

470 added, 861 removed. Audit B to A.

---
name: data-platform-pipeline
description: |
Build a production-ready serverless data lake pipeline on AWS. Creates S3 storage
(3-bucket layout), Glue Catalog, ETL jobs, IAM roles, and Athena query layer.
Use for AWS Data Lab builds, new data platform setup, or adding data sources
to an existing lake. Triggers: "build data pipeline", "data lake setup",
"ingest data", "ETL pipeline", "Glue job", "data platform", "serverless analytics".
+ Korean triggers: "데이터 파이프라인", "데이터 레이크", "ETL 파이프라인",
+ "글루 잡", "데이터 플랫폼", "서버리스 분석", "데이터 수집".
---
# Data Platform Pipeline (AWS Serverless)
- This skill builds the **ingestion → storage → catalog → query** layers of a serverless data platform. The output is a working CDK TypeScript project plus the Glue scripts and Athena DDL needed to run it. Downstream consumption (Quick Sight, dashboards, chat agents) is out of scope here — this skill stops at "data is queryable in Athena."
+ This skill builds the **ingestion → storage → catalog → query** layers of a serverless data platform. The output is a working CDK TypeScript project plus the Glue scripts and Athena DDL to run it. Downstream consumption (Quick Sight, dashboards, chat agents) is out of scope — this skill stops at "data is queryable in Athena."
The skill is opinionated: best practices are baked in, not presented as options. If a choice has a clear winner for serverless analytics on AWS, the skill picks it.
- > **Language**: Always respond in the language the user uses. If the user writes in Korean, respond in Korean. If English, respond in English. Code and CDK output are always in English regardless of conversation language.
+ ---
- > **Execution Model**: This skill does NOT just generate code for the user to run manually.
- > You ARE the builder. You have terminal access. Generate the CDK project, then:
+ ## 🔴 CRITICAL RULES (never violate)
+
+ 1. **S3 Tables catalog needs `--extra-jars` + `--user-jars-first: 'true'`** — Glue hard-fails (`Cannot find constructor for interface org.apache.iceberg.catalog.Catalog`) without the `s3-tables-catalog-for-iceberg-runtime` JAR. `--datalake-formats iceberg` alone is NOT enough.
+ 2. **`spark.sql.extensions` is STATIC in Glue 5** — set ALL Spark/Iceberg config via the job's `--conf` in `defaultArguments`, NEVER via `spark.conf.set()` at runtime (fails with `Cannot modify the value of a static config`).
+ 3. **No views on the S3 Tables catalog** — `CREATE VIEW` is unsupported (incl. cross-catalog refs from `AwsDataCatalog`). Use `mart_*` CTAS tables instead of `v_*` views on the Iceberg path.
+ 4. **`DROP TABLE` (purge=false) is unsupported on S3 Tables** — use `DROP TABLE ... PURGE` or the S3 Tables API (`aws s3tables delete-table`).
+ 5. **NEVER switch architecture due to tool versions** — upgrade the CLI/CDK instead. Tool versions are fixable in 2 minutes; architecture is permanent. Fall back to Hive only when the user explicitly opts in, or S3 Tables is genuinely unavailable in-region.
+
+ > Full failure modes, the supported/unsupported Athena DDL list, encoding rules, CDK gotchas, and the known-issues table → **`reference/gotchas.md`**.
+
+ ---
+
+ > **Language**: Always respond in the language the user uses. Korean in → Korean out; English in → English out. Code and CDK output are always in English regardless of conversation language.
+
+ > **Execution Model**: This skill does NOT just generate code for the user to run manually. You ARE the builder — you have terminal access. Generate the CDK project, then:
> 1. Install dependencies (`npm install`)
> 2. Synthesize (`cdk synth`) — fix any errors before proceeding
> 3. Deploy (`cdk deploy --all --require-approval never`)
> 4. Run post-deploy verification (crawlers, queries, smoke tests)
> 5. If anything fails, diagnose, fix, and retry automatically
> 6. Only ask the user when a DECISION is needed (not for execution permission)
>
- > The user's role is to provide business context and approve architecture decisions.
- > YOUR role is to build, deploy, verify, and iterate until it works.
+ > The user provides business context and approves architecture decisions. YOUR role is to build, deploy, verify, and iterate until it works.
>
> | Agent does silently | Agent asks user |
> |---|---|
> | `npm install`, `cdk synth`, `cdk deploy` | "Deploy to production?" (if environment=production) |
- > | Run crawlers, check schemas | "Column names don't match any known pattern — here's what I see: […]. Which mapping is correct?" |
+ > | Run crawlers, check schemas | "Column names don't match any known pattern — which mapping is correct?" |
> | Fix column mismatches (if obvious mapping) | "I found 3 possible interpretations. Which one?" |
> | Run smoke tests | Report results: "✅ All tables have data, views working" |
> | Auto-retry on transient errors | "This error persists after 3 retries: [error]. Need your input." |
> | Update ARCHITECTURE.md | — |
---
+ ## Reference files (load on demand)
+
+ The core below is the default flow. Pull in a reference file when you reach its topic:
+
+ | File | When to read |
+ |------|-------------|
+ | `reference/iceberg-cdk.md` | Building the Iceberg path — full CDK (table bucket, IAM grants, Glue 5.x job + trigger, JAR upload, maintenance, teardown) |
+ | `reference/scripts.md` | Need any Glue job script, mart/view SQL, `run-views.py`, `smoke-test.py`, quality-check SQL, **dirty-data handling** (NFD filenames, mixed encoding, trailing-minus numbers, mixed date formats, join-key normalization, cross-source bridges, Excel normalization) |
+ | `reference/hive-pattern.md` | User opted into Hive — full path (3 buckets, crawlers, transform job, crawler bootstrap) |
+ | `reference/vpc-connectivity.md` | JDBC source is on-prem or in a private subnet |
+ | `reference/gotchas.md` | Hit an opaque failure, or before generating Athena DDL on S3 Tables |
+
+ ---
+
## 1. Prerequisites & Inputs
### Current state assessment (ask FIRST, before other questions)
- Before starting any work, determine what already exists. Present as interactive choice:
+ Determine what already exists before any work. Present as an interactive choice:
```
What is the current state of your data platform?
a) Starting from scratch — nothing exists yet
b) Some infrastructure exists — I have an ARCHITECTURE.md or similar doc describing it
c) Partial build — S3 buckets exist but no Glue/ETL yet
d) Glue Catalog is set up — raw data is already cataloged, need ETL + views
e) Pipeline exists — adding a new data source to existing platform
f) Let me describe the current state: ___
```
- **If user picks (b):** Ask for the path to the architecture doc. Read it and incorporate existing state — do NOT recreate what already exists.
-
- **If user picks (c)–(e):** Ask which specific components exist. Skip those steps in the workflow.
-
- **If user picks (f):** Let them describe, then confirm your understanding before proceeding.
+ - **(b):** Ask for the path to the architecture doc. Read it and incorporate existing state — do NOT recreate what exists.
+ - **(c)–(e):** Ask which components exist. Skip those steps.
+ - **(f):** Let them describe, then confirm your understanding before proceeding.
**Key principle:** Never deploy infrastructure that already exists. Always check first.
- ### Ask the user for these inputs at the start. Do not proceed until all are collected.
+ ### Primary inputs — collect ALL before proceeding
| Input | Example | Notes |
| --- | --- | --- |
- | `project_prefix` | `acme` | Lowercase, kebab-friendly. Used as the naming convention for every resource. |
- | `aws_region` | `ap-northeast-2`, `us-west-2` | Must match where the customer wants the data lake. |
+ | `project_prefix` | `acme` | Lowercase, kebab-friendly. Naming convention for every resource. |
+ | `aws_region` | `ap-northeast-2`, `us-west-2` | Where the data lake lives. |
| `source_type` | `jdbc` / `s3` / `cdc` | Drives the decision tree in §3. |
- | `source_details` | See below | DB endpoint + Secrets Manager ARN, OR existing S3 path. |
- | `business_questions` | "Monthly defect-rate trend, Top 5 defects by vendor" | Drives table selection and Athena view design. |
-
- **`source_details` shape by source type:**
+ | `source_details` | see below | DB endpoint + Secrets Manager ARN, OR existing S3 path. |
+ | `business_questions` | "월별 검사 불량률 추세, 거래처별 불량 TOP5" | Drives table selection and Athena view/mart design. |
+ **`source_details` by type:**
- **JDBC**: `{ engine: "sqlserver"|"mysql"|"postgresql"|"oracle", host, port, database, secret_arn, tables: [...] }`
- **S3**: `{ bucket, prefix, format: "csv"|"json"|"parquet" }`
- **CDC**: Out of scope — see §3.
- ### Follow-up questions (ask after receiving initial inputs)
+ ### Follow-up questions (ask after primary inputs, ONE AT A TIME, with a recommended default)
- After collecting the primary inputs, ask these follow-up questions to refine the pipeline design. **Always provide a recommended default** — the user can accept the recommendation or override.
+ | # | Question | Recommended default |
+ |---|----------|---------------------|
+ | 1 | Storage pattern? | **Iceberg (S3 Tables)** — auto-compaction, ACID, time travel, schema evolution, no crawler. See §4. |
+ | 2 | Data volume? (rows/day + total) | **<1M rows/day, <100GB total** (DPU 2, standard for Data Lab) |
+ | 3 | Run frequency? (daily/hourly/weekly) | **Daily at 02:00 KST (17:00 UTC)** (off-peak) |
+ | 4 | Table relationships? (join columns) | **Infer from column names** (`product_code`, `supplier_id`, …; ask if ambiguous) |
+ | 5 | Code-to-name mappings? (status codes etc.) | **Yes, generate from source data** (query DISTINCT, propose mappings) |
+ | 6 | Partitioning strategy? | **Partition by date (year/month)** — optimal for time-series |
+ | 7 | Sensitive columns to mask/exclude? | **None** (add masking later via Lake Formation governance) |
- | # | Question | Why it matters | Recommended default |
- |---|----------|----------------|---------------------|
- | 1 | "What's the approximate data volume? (rows per day and total historical size)" | Determines Glue DPU allocation and cost guardrails | **Recommended: <1M rows/day, <100GB total** (adjusts DPU to 2, standard for most Data Lab builds) |
- | 2 | "How often should the pipeline run? (daily / hourly / weekly)" | Sets the Glue trigger schedule | **Recommended: Daily at 02:00 UTC** (off-peak, sufficient for most batch analytics) |
- | 3 | "What are the relationships between tables? (which columns join them?)" | Drives Athena view JOIN design | **Recommended: Infer from column names** (skill will match `product_code`, `supplier_id`, etc. If ambiguous, ask for clarification) |
- | 4 | "Are there code-to-name mappings needed? (e.g., status codes like 1='active', 2='inactive')" | Drives CASE statement generation in Athena views | **Recommended: Yes, generate from source data** (skill will query DISTINCT values and propose mappings) |
- | 5 | "Preferred partitioning strategy for curated data?" | Affects query cost and performance in Athena | **Recommended: Partition by date (year/month)** — optimal for time-series queries which are the most common pattern |
- | 6 | "Any sensitive columns that need masking or exclusion?" | Drives which columns to drop or hash in the transform step | **Recommended: None** (include all columns; add masking later via Lake Formation when governance is enabled) |
+ For question #1, present the two patterns explicitly:
- If the user says "just use the defaults" or "go with your recommendations", accept ALL defaults and proceed without further questions.
+ ```
+ 어떤 스토리지 패턴을 사용할까요?
+ a) Iceberg / S3 Tables (추천 ✓) — 자동 maintenance, ACID, time travel, schema evolution
+ b) Hive (기존 패턴) — S3 + Glue Crawler + Parquet. 기존 Hive 인프라 / 호환성 필요 시
+ ```
- > **Interaction pattern:** Present each question ONE AT A TIME as a multiple-choice prompt with the recommended default highlighted. Do NOT dump all questions at once as a text block. Example format:
- >
- > ```
- > [1/6] What's the approximate data volume?
- > a) < 1M rows/day, < 100GB total (recommended ✓)
- > b) 1M-10M rows/day, 100GB-1TB
- > c) > 10M rows/day, > 1TB
- > d) Custom input: ___
- > ```
- >
- > Accept "use the recommendations" or "defaults" to skip all remaining questions.
+ The choice determines the build: **Iceberg (default)** uses §4 + `reference/iceberg-cdk.md`; **Hive (opt-in)** follows `reference/hive-pattern.md`. Both share the `{prefix}_db` interface so the consumption layer is unaffected.
- ### Account preconditions to verify before generating CDK
+ > **Interaction pattern:** Present each question as a one-at-a-time multiple-choice prompt with the default highlighted. Do NOT dump all questions at once. If the user says "추천대로" / "just use the defaults", accept ALL defaults (including **Iceberg** for #1) and proceed.
- Run these checks (or instruct the user to run them) before writing any code:
+ ### ⚠️ MANDATORY: run ALL precondition checks before building
+ Do NOT skip any precondition, and do NOT start the build until every one passes. If ANY fails: (1) report which and why, (2) give the fix command, (3) STOP and wait — or fix it yourself if safe (e.g. `cdk bootstrap`), (4) re-run to confirm, (5) only then build. A missing prerequisite surfaces later as an opaque deploy/runtime failure that is far more expensive to debug.
+
```bash
- # 1. Confirm active AWS identity matches the target account
+ # 1. Active identity matches the target account
aws sts get-caller-identity
-
- # 2. Confirm region is set
+ # 2. Region is set
aws configure get region
-
- # 3. Lake Formation Data Lake Settings (see §6 for full handling)
- aws lakeformation get-data-lake-settings --region {aws_region}
-
+ # 3. Lake Formation Data Lake Settings (interpret below)
+ aws lakeformation get-data-lake-settings --region {aws_region} \
+ --query 'DataLakeSettings.CreateDatabaseDefaultPermissions' --output json
# 4. CDK bootstrap status
aws cloudformation describe-stacks --stack-name CDKToolkit --region {aws_region} 2>/dev/null \
|| echo "CDK NOT BOOTSTRAPPED — run: cdk bootstrap aws://ACCOUNT_ID/{aws_region}"
+ # 5. CDK CLI ≥ aws-cdk-lib version pinned in package.json (mismatch → opaque synth failures)
+ cdk --version
+ ```
+ **Lake Formation result:** output contains `"IAM_ALLOWED_PRINCIPALS"` → good. Output is `[]` → **strict mode** — STOP and surface options to the user (see `reference/gotchas.md` → Lake Formation strict mode).
+
+ **IAM permissions** — simulate the key create actions; STOP if any show implicit/explicit deny:
+ ```bash
+ aws iam simulate-principal-policy \
+ --policy-source-arn $(aws sts get-caller-identity --query Arn --output text) \
+ --action-names iam:CreateRole s3:CreateBucket glue:CreateJob glue:CreateDatabase \
+ athena:CreateWorkGroup lakeformation:GrantPermissions --output table
```
+ (If `simulate-principal-policy` is itself denied, confirm the identity is admin/PowerUser with the user, or read the first AccessDenied from a no-op deploy.)
- If any precondition fails, stop and surface the specific error to the user with remediation steps.
+ ### Additional preconditions for the Iceberg path (default)
- ---
+ **Tooling versions:** AWS CLI ≥ 2.22, aws-cdk-lib ≥ 2.173. Detect, and **upgrade rather than fall back** (fatal rule 5) — full detect/upgrade commands in `reference/gotchas.md`.
- ## 2. Architecture Overview
+ ```bash
+ # S3 Tables reachable in-region?
+ aws s3tables list-table-buckets --region {aws_region} >/dev/null 2>&1 \
+ && echo "S3 Tables reachable" || echo "S3 Tables NOT available/no perm — confirm regional availability"
+ # Glue Data Catalog integration enabled? (REQUIRED for Athena to query S3 Tables)
+ aws glue get-catalog --catalog-id s3tablescatalog --region {aws_region} >/dev/null 2>&1 \
+ && echo "S3 Tables Glue integration enabled" || echo "NOT integrated — enable before Athena can query"
+ ```
+ - **Regional availability:** if `aws_region` doesn't support S3 Tables, offer to (a) switch region or (b) fall back to Hive.
+ - **Glue Data Catalog integration (REQUIRED):** one-time per account+region; registers the `s3tablescatalog` federated catalog. If missing, enable it before deploy — `aws glue create-catalog` command in `reference/iceberg-cdk.md`. Without it, the Glue write and any Athena query against `s3tablescatalog/...` fail.
+ - **IAM granularity:** S3 Tables IAM is **table-level** (`s3tables:*` on table-bucket/namespace/table ARNs). Grant ETL + Athena roles `s3tables:*` on the table bucket ARN plus `glue:*` on `s3tablescatalog` (see §5 / `reference/iceberg-cdk.md`).
+
+ ### Interactive data model design (MANDATORY before building)
+
+ Before generating CDK/scripts, propose the data model plan:
+
+ **Step 1: Propose tables + marts with recommendations**
+
+ Based on the user's source data and business questions, propose:
```
- ┌─────────────────────────────────────────────────┐
- │ AWS Account │
- │ │
- ┌──────────────┐ │ ┌─────────────┐ ┌──────────────────────┐ │
- │ Source │ │ │ Glue │ │ S3 │ │
- │ - SQL Server │──────┼─▶│ ETL Job │─────▶│ {prefix}-raw-zone/ │ │
- │ - MySQL │ JDBC │ │ (ingest) │ │ {source}/{table}/ │ │
- │ - Oracle │ │ └─────────────┘ └──────────┬───────────┘ │
- └──────────────┘ │ │ │ │
- │ │ Crawler │ │
- │ ▼ ▼ │
- │ ┌──────────────┐ ┌──────────────────┐ │
- │ │ Glue Catalog │◀─────│ Glue ETL │ │
- │ │ {prefix}_db │ │ (transform) │ │
- │ │ raw_* tables │ └────────┬─────────┘ │
- │ └──────┬───────┘ │ │
- │ │ ▼ │
- │ │ ┌────────────────────┐ │
- │ │ │ S3 curated-zone/ │ │
- │ │ │ Parquet + Snappy │ │
- │ │ └─────────┬──────────┘ │
- │ │ │ │
- │ ▼ ▼ │
- │ ┌─────────────────────────────────────────┐ │
- │ │ Athena Workgroup ({prefix}-workgroup) │ │
- │ │ - Tables: {table} │ │
- │ │ - Views: v_{table} (enriched) │ │
- │ │ - Results: s3://{prefix}-analytics-…/ │ │
- │ └─────────────────────────────────────────┘ │
- │ │
- └─────────────────────────────────────────────────┘
- │
- ▼
- [downstream consumption layer]
+ 데이터 모델 초안:
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ 📁 Base tables (raw → cleaned):
+ - base_{source}_{table} — [설명]
+ ...
+ 📁 Mart tables (business-ready):
+ - mart_{name} — grain: (col1, col2) — [설명, 어떤 질문에 답하는지]
+ ...
+
+ 🔗 Join relationships:
+ - base_X.col_a → base_Y.col_b
+ ...
+
+ "이 구조가 비즈니스 질문을 답하기에 충분한지 확인해주세요.
+ 추가로 필요한 분석이나 수정할 부분이 있으면 말씀해주세요."
```
+ **Step 2: Iterate**
+
+ User might say:
+ - "mart_quality에 거래처별 집계도 추가해줘"
+ - "이 테이블은 필요 없어"
+ - "raw_orders와 raw_products를 조인한 mart가 하나 더 필요해"
+
+ → Adjust and show updated plan.
+
+ **Step 3: Confirm + execute**
+
+ "최종 데이터 모델 확인: [plan]. 이대로 빌드 시작할까요?"
+ → Once approved, generate CDK + scripts + deploy autonomously.
+
---
+ ## 2. Architecture Overview
+
+ Two storage patterns (chosen via follow-up #1):
+
+ - **Iceberg / S3 Tables (default)** — a **Glue 5.x Spark Job (Iceberg connector)** reads the source (raw S3 files in `{prefix}-raw-zone`, or a JDBC DB directly) and writes **straight into Iceberg tables in an S3 Table Bucket**, scheduled by a Glue Trigger (cron). No curated bucket, no crawler at any stage: types are declared in job code, Iceberg auto-registers schema on write, S3 Tables compacts automatically. Athena is query-only. Adds ACID, MERGE upserts, time travel. Data-flow diagram in §4.
+ - **Hive (opt-in)** — classic three-bucket layout (raw → curated Parquet → Athena), cataloged by Glue Crawlers. Diagram + full path in `reference/hive-pattern.md`.
+
+ ---
+
## 3. Decision Trees
### Source type → ingestion approach
```
User's source type?
├── JDBC (SQL Server / MySQL / PostgreSQL / Oracle)
- │ ├── Create Glue Connection (in VPC if source is private)
- │ ├── Create Glue ETL Job using ingest-jdbc.py
- │ ├── Schedule via Glue trigger (daily 02:00 KST default)
- │ └── Output: Parquet in s3://{prefix}-raw-zone/{source}/{table}/
+ │ ├── Create Glue Connection (in VPC if source is private/on-prem — reference/vpc-connectivity.md)
+ │ ├── Iceberg (default): Glue 5.x JDBC job (ingest-jdbc-iceberg.py) → writes Iceberg directly (CAN SKIP raw S3)
+ │ │ Hive (opt-in): ingest-jdbc.py → raw Parquet, then transform.py → curated
+ │ ├── Schedule via Glue Trigger (cron, daily 02:00 KST default) — NOT EventBridge + Athena
+ │ └── Output: Iceberg table in s3tablescatalog/{prefix}-table-bucket (Hive: Parquet in raw-zone)
│
├── Existing S3 data (CSV / JSON / Parquet)
- │ ├── Create Glue Crawler on existing S3 path
- │ ├── If CSV/JSON: create transform job → Parquet
- │ ├── If already Parquet: skip transform, only catalog
- │ └── Output: Tables in Glue Catalog
- │
- │ ⚠ Multi-CSV under one prefix: if a single S3 prefix contains multiple CSVs
- │ with DIFFERENT schemas (common for ERP exports), the crawler will collapse
- │ them into ONE combined table. Use one of these patterns instead:
- │
- │ Preferred: one folder per logical table
- │ s3://{bucket}/erp/quality_inspections/quality_inspections.csv
- │ s3://{bucket}/erp/production_orders/production_orders.csv
- │ s3://{bucket}/erp/suppliers/suppliers.csv
- │
- │ Or: separate s3Targets entries per file (when a flat folder can't be reorganized)
- │ s3Targets: [
- │ { path: `s3://${rawBucket.bucketName}/erp/quality_inspections/` },
- │ { path: `s3://${rawBucket.bucketName}/erp/production_orders/` },
- │ { path: `s3://${rawBucket.bucketName}/erp/suppliers/` },
- │ ]
+ │ ├── Iceberg (default): Glue 5.x Spark job (ingest-iceberg.py) reads raw S3 → writes Iceberg. No crawler.
+ │ │ Hive (opt-in): Glue Crawler on S3 path; CSV/JSON → transform → Parquet; Parquet → catalog only
+ │ ├── Schedule via Glue Trigger (cron)
+ │ └── ⚠ Multi-CSV under one prefix collapses into one table on the Hive crawler path — see reference/hive-pattern.md
│
└── CDC (Change Data Capture)
- └── OUT OF SCOPE for this skill's main flow.
- Recommend: Use AWS DMS with S3 target (Parquet format).
- Point DMS at the source DB, configure full-load + ongoing CDC.
- Once data lands in S3, re-run this skill with source_type="s3".
-
+ └── OUT OF SCOPE. Recommend AWS DMS → S3 (Parquet), full-load + ongoing CDC,
+ then re-run this skill with source_type="s3".
```
+ > **Multi-prefix → multi-job (one job per logical table).** A Glue job ingests ONE logical table — its files share a schema and land under one prefix (`{prefix}-raw-zone/{source}/{table}/`). When the source spans multiple prefixes that each hold a DIFFERENT schema (e.g. `.../sales/`, `.../suppliers/`, `.../inspections/`), generate **one ingest job + one Iceberg table per prefix**, not a single job over the bucket root. Each job declares its own typed transform and writes its own table into `{prefix}_db`; schedule them under one trigger (or chain via CONDITIONAL triggers if order matters). Only files that genuinely share a schema (e.g. monthly `2024-01.csv`, `2024-02.csv` under one prefix) belong to the same job. Mirror each as its own entry in `platform.yaml` `tables:` and its own `written_by` job.
+
### Format → transform needed?
```
Source format?
├── Parquet → catalog only (no transform job)
- ├── CSV / JSON / TSV / fixed-width → transform → Parquet+Snappy
- ├── XLSX / Excel → reject. Tell user to export to CSV first.
+ ├── CSV / JSON / TSV / fixed-width → transform → Parquet+Snappy (Hive) / typed in Spark (Iceberg)
+ ├── XLSX / Excel → Glue Python Shell job (pandas + openpyxl → Parquet). Spark has no native .xlsx reader.
+ │ Max 1 DPU. Files > 1GB → ask user to convert to CSV first.
└── XML / proprietary → custom Glue script with appropriate library
-
```
- > **The pipeline never downloads data.** All processing happens server-side via Glue (Spark). CSVs stay in S3 and are read directly by the Crawler and ETL Jobs — including multi-GB files. There is no scenario where this skill should suggest downloading a CSV locally before cataloging it.
+ > **The pipeline never downloads data.** All processing is server-side via Glue (Spark). CSVs stay in S3 and are read directly — including multi-GB files. Never suggest downloading a CSV locally before cataloging.
- ---
+ ### Mixed source formats (csv + xlsx under one bucket)
- ## 4. Storage Layer
+ When the source contains BOTH CSV and Excel files, generate **two** Glue jobs chained by a CONDITIONAL trigger: `{prefix}-excel-preprocess` (Python Shell, 1 DPU) reads .xlsx → Parquet in the raw zone FIRST, then `{prefix}-iceberg-ingest` (Spark, 2 DPU) reads everything (original CSVs + converted Parquets) → writes Iceberg.
- ### Three buckets, always
+ ---
- | Bucket | Purpose | Format | Lifecycle |
- | --- | --- | --- | --- |
- | `{prefix}-raw-zone` | Landing zone, unmodified or near-original | Source format or Parquet | IA @ 90d, Glacier @ 365d |
- | `{prefix}-curated-zone` | Cleaned, transformed, joined | **Parquet + Snappy always** | None (active data) |
- | `{prefix}-analytics-zone` | Athena results, scratch, exports | Mixed | Delete incomplete MPU @ 7d |
+ ## 4. Storage Pattern: Iceberg (Default) — CORE FLOW
- ### `lib/storage-stack.ts` (CDK pattern)
+ This is the **first architecture decision** (follow-up #1). **Iceberg is the default** — present it first. Fall back to Hive only on explicit opt-in (existing Hive infra, compatibility, or S3 Tables unavailable in-region) — then follow `reference/hive-pattern.md`.
- ```typescript
- import * as cdk from 'aws-cdk-lib';
- import * as s3 from 'aws-cdk-lib/aws-s3';
- import { Construct } from 'constructs';
+ ### Iceberg vs Hive at a glance
- export interface StorageStackProps extends cdk.StackProps {
- prefix: string;
- environment: 'production' | 'development';
- dataClassification: 'internal' | 'confidential' | 'public';
- owner: string;
- }
+ | Component | Hive (opt-in) | Iceberg (default) |
+ |-----------|--------------|-------------------|
+ | Storage | S3 General Purpose (curated bucket) | S3 Table Bucket + Namespace |
+ | Cataloging | Glue Crawler (scheduled) | Iceberg auto-registers on write |
+ | ETL | Glue PySpark → raw, then transform → curated | **Glue 5.x Spark Job → writes Iceberg directly** |
+ | Incremental | Job Bookmark | MERGE INTO (native upsert) |
+ | Maintenance | Manual compaction | S3 Tables automatic compaction |
+ | Format | Parquet + Snappy | Parquet + ZSTD |
+ | DML / time travel / ACID | Overwrite partition / ❌ / ❌ | MERGE/DELETE/UPDATE / ✅ / ✅ |
- export class StorageStack extends cdk.Stack {
- readonly rawBucket: s3.IBucket;
- readonly curatedBucket: s3.IBucket;
- readonly analyticsBucket: s3.IBucket;
+ > **Which path uses what?**
+ > - **Iceberg (default):** §4 core flow here + **`reference/iceberg-cdk.md`** (all CDK) + `reference/scripts.md` (job scripts) + §5 (IAM, add `s3tables:*`) + §7 (Athena workgroup) + §8–§13. **Skip ALL crawlers and the curated bucket.**
+ > - **Hive (opt-in):** **`reference/hive-pattern.md`** end-to-end (3 buckets, crawlers, transform job, crawler bootstrap).
- constructor(scope: Construct, id: string, props: StorageStackProps) {
- super(scope, id, props);
+ ### Schema adaptability — adapt to ANY schema, never freak out
- this.rawBucket = this.createBucket('Raw', `${props.prefix}-raw-zone`, props, [
- {
- id: 'archive-old-raw',
- transitions: [
- { storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(90) },
- { storageClass: s3.StorageClass.GLACIER, transitionAfter: cdk.Duration.days(365) },
- ],
- },
- ]);
+ The worked examples use the ERP **quality-inspection** sample schema (`inspection_id`, `supplier_id`, `result`, `inspection_date`, …). That is **illustrative only** — a template, not a contract.
- this.curatedBucket = this.createBucket('Curated', `${props.prefix}-curated-zone`, props, []);
+ - **Read the actual source schema FIRST**, before any transform or SQL. S3: `spark.read.csv(path, header=True, inferSchema=True).printSchema()`. JDBC: driver metadata / `SHOW COLUMNS`. Cataloged: `aws glue get-table`.
+ - **Adapt ALL transforms, types, partition columns, JOIN keys, mart SQL** to the real columns. Don't blindly copy the example columns.
+ - **If the schema differs from the examples, DO NOT error or refuse** — adapt to whatever tabular schema is present.
+ - **Pick the partition column from the real schema** — the most-queried date/timestamp column; else a low-cardinality dimension, or unpartitioned for small reference data.
+ - **Record the discovered schema in `ARCHITECTURE.md`** (column names + types per table).
- this.analyticsBucket = this.createBucket('Analytics', `${props.prefix}-analytics-zone`, props, [
- { id: 'cleanup-mpu', abortIncompleteMultipartUploadAfter: cdk.Duration.days(7) },
- ]);
- }
+ Only STOP and ask when a column mapping is genuinely ambiguous (multiple plausible interpretations).
- private createBucket(
- id: string,
- name: string,
- props: StorageStackProps,
- rules: s3.LifecycleRule[],
- ): s3.Bucket {
- const bucket = new s3.Bucket(this, id, {
- bucketName: name,
- encryption: s3.BucketEncryption.S3_MANAGED, // SSE-S3 default; parameterize for KMS CMK
- blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
- enforceSSL: true,
- versioned: true,
- lifecycleRules: rules,
- removalPolicy: cdk.RemovalPolicy.RETAIN, // never auto-delete data
- });
- cdk.Tags.of(bucket).add('project', props.prefix);
- cdk.Tags.of(bucket).add('environment', props.environment);
- cdk.Tags.of(bucket).add('data-classification', props.dataClassification);
- cdk.Tags.of(bucket).add('owner', props.owner);
- return bucket;
- }
- }
+ ### Dirty real-world data — VALUE-level corruption (not just schema)
- ```
+ > 🔴 Schema adaptability above only handles **column-name / type** drift. Real Korean manufacturing ERP exports also carry **value-level** corruption that passes every structural check (row-count > 0, null-check, STRICT) yet silently produces WRONG numbers — e.g. a date format the parser misses dropped 16% of production rows, and trailing-minus costs cast to NULL zeroing every cost KPI. You MUST screen for these BEFORE trusting any aggregate. Full helpers + Spark snippets → **`reference/scripts.md` → "Dirty real-world data handling"**.
- > **Importing existing buckets:** If the customer already has S3 buckets they want to reuse (not created by this CDK app), replace the `createBucket` call with `s3.Bucket.fromBucketName(this, id, existingBucketName)` for those specific buckets. On a routine redeploy, CDK identifies buckets it owns by their logical ID — `RemovalPolicy.RETAIN` ensures the physical bucket is preserved across stack updates and destroys.
+ | # | Corruption | Symptom if unhandled | Fix (one-liner) |
+ |---|-----------|---------------------|-----------------|
+ | 1 | **Unicode NFD filenames** (Korean decomposed form on macOS) — `MES_생산실적_202601.csv` | `NoSuchKey` on literal match — breaks on EVERY Korean/CJK filename on macOS | `list_objects` prefix-match, then use the **actual byte key** returned |
+ | 2 | **Mixed encoding per source** (MES = EUC-KR, SAP = UTF-8 in one pipeline) | Mojibake / garbled Korean in dimensions | Per-source `.option("encoding", ...)` branch in the Spark job |
+ | 3 | **SAP trailing-minus negatives** — `150.000-` means -150 (often >50% of rows) | `cast('double')` → NULL → all cost/amount KPIs become 0 | `parse_num` helper: detect trailing `-`, move it to front before cast |
+ | 4 | **Mixed date formats** (`yyyyMMddHHmmss` + `yyyy-MM-dd HH:mm:ss` + `yyyy/M/d H:m:s` no zero-pad + literal `'NULL'`) | Unparsed rows silently dropped → metric too low (16% loss seen) | `coalesce(to_timestamp(c,fmt1), …fmt2, …fmt3)` chain + filter literal `'NULL'` |
+ | 5 | **Join-key leading-zero / whitespace** — MATNR as `10010015` vs `000…010010002` vs ` 000…009` | Joins return 0 rows → empty dimensions | `norm_key`: `regexp_replace(trim(c),'^0+','')` on BOTH sides before any join |
+ | 6 | **Cross-source bridge (no common key)** — SAP material groups (`FG100…`) vs Finance categories (`브라켓류…`) | Cannot join the two sources at all | Domain-knowledge bridge table: infer mapping from name-membership overlap |
- ### Encryption
+ > **Rule:** the Iceberg/Spark default path reads empty CSV fields as `null` automatically, but it does NOT auto-fix any of the six above. After ingest, run the row-count reconciliation in §8 (source vs base table) — a gap means one of these silently dropped or zeroed rows.
- - Default: **SSE-S3** (`BucketEncryption.S3_MANAGED`).
- - If customer requires CMK: parameterize a `kmsKeyArn` prop and switch to `BucketEncryption.KMS` with the imported key. Grant Glue/Athena roles `kms:Decrypt` and `kms:GenerateDataKey`.
+ ### Mart grain declaration + downstream SUM safety
- ---
+ Single-grain marts hide this, but real marts mix grains. Putting a **coarser-grain** measure into a **finer-grain** mart duplicates it on SUM — e.g. a material-level QMEL notification count placed in a `(material × defect_code)` grain mart was duplicated by the number of defect codes per material (426 → 3,527, an 8.3× overcount) and every validation still passed.
- ## 5. Catalog & Schema
+ **Every mart MUST declare its grain explicitly:**
+ - In the mart SQL: a header comment `-- GRAIN: (material_key, defect_code)`
+ - In `platform.yaml`: `grain: [material_key, defect_code]` (§10)
- ### Glue Database
+ **Rule — never put a measure from a COARSER grain into a FINER grain mart without pre-aggregating first.**
+ - ❌ WRONG: `material_qmel_count` in a `(material × defect_code)` grain mart → duplicates on SUM
+ - ✅ RIGHT: pre-aggregate to material grain FIRST, then join as a 1:1 lookup, OR keep it in a separate material-grain mart
- - One database per project: `{prefix}_db` (e.g., `acme_db`).
- - For multi-domain projects, use one DB per domain: `{prefix}_quality_db`, `{prefix}_sales_db`.
- - Domain separation matters: Lake Formation permissions are per-database, and downstream tools (Quick Sight, Redshift Spectrum) bind to a database.
+ **Rule — if downstream will SUM a column, it MUST be valid to SUM across the mart's grain.** List the SUM-safe measures in `platform.yaml` under `sum_safe_columns: [...]` (§10). Any measure NOT in that list is correct only with `MAX`/`AVG`/`COUNT(DISTINCT)` or after collapsing to its native grain — the consumption skill reads `sum_safe_columns` and pre-aggregates KPI datasets accordingly.
- ### Table naming convention
+ > The single safest pattern for KPI cards is a **dedicated single-row KPI mart** (one row, every measure pre-aggregated with the correct function incl. `COUNT(DISTINCT …)`). Build it here in the Glue job; the consumption skill points KPI visuals at it. SQL → `reference/scripts.md`.
- | Layer | Prefix | Example |
- | --- | --- | --- |
- | Raw (untransformed, from source) | `raw_{source}_` | `raw_sqlserver_quality_inspections` |
- | Curated (cleaned Parquet) | *(no prefix)* | `quality_inspections` |
- | Views (enriched, code→name joins) | `v_` | `v_quality_inspections` |
+ ### Iceberg data flow
- > ⚠ **Glue Crawler appends file extensions to table names** when crawling raw S3 files (e.g. `quality_inspections.csv` → `raw_quality_inspections_csv`, not `raw_quality_inspections`). Pick one:
- > - **Accept the suffix** — update `TABLE_CONFIG` references and Athena view DDL to use the `_csv` form. Simplest.
- > - **Override via crawler `Configuration`** — set `TableLevelConfiguration` and a `TableNameSeparator` policy in the crawler's `Configuration` JSON.
- > - **Pre-organize without extensions** — store raw files under directory paths only (`s3://.../quality_inspections/part-0001` with no `.csv` suffix on the object key). Crawler then names tables from the directory.
+ ```
+ ┌──────────────┐ ┌────────────────────────────┐
+ │ S3 file src │ ┌──────────────────────┐ │ S3 Table Bucket (Iceberg) │
+ │ CSV / JSON │─▶│ {prefix}-raw-zone/ │─┐ │ {prefix}-table-bucket │
+ └──────────────┘ │ {source}/{table}/ │ │ │ namespace: {prefix}_db │
+ └──────────────────────┘ │ │ tables: {table} (ICEBERG) │
+ ┌──────────────────▼────┐ │ │
+ ┌──────────────┐ │ Glue 5.x Spark Job │─┼────────────────────────────│
+ │ DB source │───────▶│ (Iceberg connector): │ │ writeTo / MERGE INTO │
+ │ JDBC │ JDBC │ read → transform → │ │ (direct, schema-on-write) │
+ └──────────────┘ (skip │ write Iceberg │ └──────────────┬─────────────┘
+ raw S3)└───────────────────────┘ │
+ ▲ ▼
+ Glue Trigger (cron, §6) ┌─────────────────────────────┐
+ │ Athena Workgroup (QUERY ONLY)│
+ │ catalog: s3tablescatalog/… │
+ │ auto-compaction by S3 Tables│
+ └─────────────────────────────┘
+ ```
- ### Crawlers
+ ### Storage (Iceberg)
- - One crawler per source domain (not per table — let it discover).
- - Schedule: on-demand for first run, then daily after ETL job completes (chained via EventBridge).
- - Skip if table already in catalog: handled via Crawler's built-in schema-change behavior (`UpdateBehavior: LOG`).
+ - **S3 Table Bucket** `{prefix}-table-bucket` + **Namespace** `{prefix}_db` (replaces the Glue Database, same name).
+ - Raw landing bucket `{prefix}-raw-zone` (CSV/JSON before ETL) + analytics bucket `{prefix}-analytics-zone` — created as in the Hive storage stack, just **omit the curated bucket**.
+ - **No curated bucket** — the Iceberg tables ARE the curated layer.
- ### Partitioning guidance
+ CDK for the table bucket + namespace → **`reference/iceberg-cdk.md`**.
- | Data shape | Partition by |
- | --- | --- |
- | Time-series, query by date | `year`, `month`, `day` (Hive-style: `year=2025/month=11/...`) |
- | Multi-tenant SaaS | `tenant_id`, then `year/month` |
- | Small reference data (<1 GB) | No partitions |
- | Multi-region data | `region`, then time |
+ ### ETL (Iceberg) — Glue 5.x Spark Job writes directly to Iceberg
- Rule of thumb: aim for 100 MB – 1 GB Parquet files per partition. Too small = many file overhead; too large = no parallelism benefit.
+ The default ETL is **one Glue 5.x Spark Job** that reads the source (raw S3 files, or a JDBC DB), types/cleans/filters in Spark, and writes **directly into the Iceberg table**. Athena is the **query engine only**. This matches AWS prescriptive guidance for batch ingestion into Iceberg/S3 Tables. Scheduled by a **Glue Trigger (cron)** (§6). Athena CTAS/INSERT is reserved as a **fallback for one-time exploratory loads**.
- ### `lib/catalog-stack.ts` (CDK pattern)
+ The Iceberg path uses **zero crawlers** — schema-on-write replaces inference, avoiding `_csv` suffixes, SerDe drift, type misdetection, and multi-CSV collapse. Types are declared in the `.py` script, version-controlled.
- ```typescript
- import * as glue from 'aws-cdk-lib/aws-glue';
- import * as iam from 'aws-cdk-lib/aws-iam';
- import * as cdk from 'aws-cdk-lib';
- import { Construct } from 'constructs';
+ The connector reaches S3 Tables through the `s3tablescatalog` Spark catalog. Per 🔴 rules 1–2, ALL Spark/Iceberg config (`--datalake-formats`, `--conf`, `--extra-jars`, `--user-jars-first`) is set in the job's `defaultArguments`, NOT in the Python. Then `df.writeTo(...)` / Spark SQL `MERGE INTO` against `s3tablescatalog.{namespace}.{table}`.
- export interface CatalogStackProps extends cdk.StackProps {
- prefix: string;
- rawBucketName: string;
- curatedBucketName: string;
- environment: string;
- owner: string;
- }
+ **Three cases** (full script bodies in `reference/scripts.md`):
+ - **Case A — S3 file source:** `ingest-iceberg.py` reads raw CSV/JSON → writes Iceberg.
+ - **Case B — DB source:** `ingest-jdbc-iceberg.py` reads JDBC → writes Iceberg directly (skip raw S3 unless you need an immutable landing copy for replay/audit).
+ - **Case C — incremental/upsert:** Spark `MERGE INTO` inside the same job (no Job Bookmark — Iceberg snapshots self-track).
- export class CatalogStack extends cdk.Stack {
- readonly database: glue.CfnDatabase;
- readonly crawlerRole: iam.Role;
+ **No crawlers at any stage**, no Job Bookmark. Schema evolution lives on the table (`ALTER TABLE ADD/DROP COLUMNS`) — update the Glue job transform and the table evolves on next write.
- constructor(scope: Construct, id: string, props: CatalogStackProps) {
- super(scope, id, props);
+ **Fallback** — Athena CTAS/INSERT for one-time exploratory loads only (declare raw CSV as an explicit external table, then CTAS — and wrap every `CAST` in `NULLIF(col,'')`, see `reference/gotchas.md`). Not the production path. SQL in `reference/scripts.md`.
- this.database = new glue.CfnDatabase(this, 'Database', {
- catalogId: cdk.Stack.of(this).account,
- databaseInput: {
- name: `${props.prefix}_db`,
- description: `${props.prefix} data lake catalog`,
- },
- });
+ ### No views — use materialized mart tables (CTAS)
- this.crawlerRole = new iam.Role(this, 'CrawlerRole', {
- roleName: `${props.prefix}-glue-crawler-role`,
- assumedBy: new iam.ServicePrincipal('glue.amazonaws.com'),
- managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSGlueServiceRole')],
- });
- this.crawlerRole.addToPolicy(new iam.PolicyStatement({
- actions: ['s3:GetObject', 's3:ListBucket'],
- resources: [
- `arn:aws:s3:::${props.rawBucketName}`,
- `arn:aws:s3:::${props.rawBucketName}/*`,
- `arn:aws:s3:::${props.curatedBucketName}`,
- `arn:aws:s3:::${props.curatedBucketName}/*`,
- ],
- }));
+ Per 🔴 rule 3, `CREATE VIEW` doesn't work across the S3 Tables catalog. Materialize enrichment/aggregation as a `mart_*` CTAS table in the same namespace (best: as an extra `writeTo` inside the Glue job, so it's typed in code and refreshed on schedule). The consumption layer reads `mart_*` exactly as it would have read `v_*`. SQL in `reference/scripts.md`. On the **Hive** path, `v_{table}` views work normally — no mart needed.
- new glue.CfnCrawler(this, 'RawCrawler', {
- name: `${props.prefix}-raw-crawler`,
- role: this.crawlerRole.roleArn,
- databaseName: `${props.prefix}_db`,
- tablePrefix: 'raw_',
- targets: { s3Targets: [{ path: `s3://${props.rawBucketName}/` }] },
- schemaChangePolicy: { updateBehavior: 'LOG', deleteBehavior: 'LOG' },
- tags: { project: props.prefix, environment: props.environment, owner: props.owner },
- });
+ ### Maintenance, time travel, Athena S3-Tables constraints
- new glue.CfnCrawler(this, 'CuratedCrawler', {
- name: `${props.prefix}-curated-crawler`,
- role: this.crawlerRole.roleArn,
- databaseName: `${props.prefix}_db`,
- targets: { s3Targets: [{ path: `s3://${props.curatedBucketName}/` }] },
- schemaChangePolicy: { updateBehavior: 'LOG', deleteBehavior: 'LOG' },
- tags: { project: props.prefix, environment: props.environment, owner: props.owner },
- });
+ - **Maintenance:** S3 Tables compacts automatically. Tune snapshot retention / file size via the maintenance API (`put-table-maintenance-configuration`) — NOT SQL. `OPTIMIZE`/`VACUUM` unsupported. Commands in `reference/iceberg-cdk.md`.
+ - **Time travel:** `FOR TIMESTAMP AS OF`, `$snapshots` metadata table. Examples in `reference/scripts.md`.
+ - **Athena DDL constraints (what you MUST NOT generate vs what IS supported):** see `reference/gotchas.md`.
- cdk.Tags.of(this).add('project', props.prefix);
- cdk.Tags.of(this).add('environment', props.environment);
- cdk.Tags.of(this).add('owner', props.owner);
- }
- }
+ ### Convention coupling & catalog name (BOTH patterns)
- ```
+ `{prefix}_db` is the interface between pipeline and consumption, regardless of pattern. The only downstream difference is the **Athena catalog name**:
- ---
+ | Pattern | Athena catalog | Consumption connects to |
+ |---------|----------------|------------------------|
+ | Iceberg (default) | `s3tablescatalog/{prefix}-table-bucket` | `mart_*` CTAS tables |
+ | Hive (opt-in) | `AwsDataCatalog` | `v_*` views |
- ## 6. IAM & Security (Lake Formation)
+ Record which pattern was used in `ARCHITECTURE.md` (§10) — the consumption skill reads it to pick the catalog. Cross-catalog reference format:
+ - S3 Tables: `"s3tablescatalog/{prefix}-table-bucket"."{prefix}_db"."{table}"`
+ - Raw (AwsDataCatalog, fallback only): `"AwsDataCatalog"."{prefix}_raw"."{table}"`
- > ⚠ **IAM `description` field allows only ASCII Latin-1 characters** (`[ -~¡-ÿ]`). Korean characters, em dashes (—), and arrows (→) will be rejected, causing the entire IAM stack to roll back. Use plain ASCII in role descriptions, then put Korean copy in CDK `Tags` or a separate `Glue table comment` instead.
+ ---
- ### Korean / non-ASCII encoding rules across services
+ ## 5. IAM & Security (Lake Formation)
- | Service | Korean / non-ASCII OK? | Notes |
- |---|:---:|---|
- | IAM role description | ❌ | ASCII Latin-1 only — rolls back the whole stack on violation |
- | Glue table comments | ✅ | UTF-8 |
- | Athena named query description | ✅ | UTF-8 |
- | Quick Sight dashboard / visual titles | ✅ | UTF-8 |
- | CDK feature flag values | ❌ | ASCII only |
+ > ⚠ **IAM `description` is ASCII Latin-1 only** — Korean/em-dash/arrow rolls back the whole stack. Encoding rules table → `reference/gotchas.md`.
### Per-function IAM roles (mandatory)
| Role | Used by | Permissions (summary) |
| --- | --- | --- |
- | `{prefix}-glue-crawler-role` | Glue Crawlers | S3 read on raw+curated, Catalog write |
- | `{prefix}-glue-etl-role` | Glue ETL Jobs | S3 read/write on all 3 buckets, Catalog read+write, Secrets Manager (JDBC), CloudWatch Logs |
- | `{prefix}-athena-query-role` | Athena human/service queries | Catalog read, S3 read on curated, S3 write on analytics |
- | `{prefix}-quicksight-role` | VPC connection / federated query scenarios only | Athena query, Catalog read, S3 read on curated + write on analytics results |
-
- > **About `{prefix}-quicksight-role`:** This role is only needed when Quick Sight has to assume a custom role — typically VPC connections (private Redshift, RDS) or federated query setups. **A vanilla Athena-on-S3 dashboard does NOT use this role** — it uses the AWS-managed `aws-quicksight-service-role-v0` instead, with the `AWSQuickSightS3Policy` customer-managed policy controlling S3 bucket access. The consumption skill configures Quick Sight's S3 access separately (see consumption skill §11). Keep this role definition only if you know you'll need a VPC connection or federated query; otherwise omit it.
-
- ### Lake Formation IAM-only mode (the part everyone gets wrong)
-
- Lake Formation has two coexisting permission models:
-
- 1. **IAM-based** (legacy default): IAM policies + the magic `IAMAllowedPrincipals` grant.
- 2. **LF-managed**: Explicit grants via `lakeformation:Grant*`.
-
- For batch analytics where IAM is sufficient, the skill explicitly configures **IAM-only mode** so behavior is deterministic across accounts.
-
- ### Precondition check (run before deploy)
-
- ```bash
- aws lakeformation get-data-lake-settings --region {aws_region} \
- --query 'DataLakeSettings.CreateDatabaseDefaultPermissions' --output json
- ```
-
- **Interpret the result:**
-
- - Output contains `"Principal": {"DataLakePrincipalIdentifier": "IAM_ALLOWED_PRINCIPALS"}` → **good**, default IAM grant is in place.
- - Output is `[]` (empty array) → **WARN the user**:
-
- > ⚠️ **Lake Formation strict mode detected.**
- > This account has `IAMAllowedPrincipals` revoked at the account level.
- > Glue tables created by this skill will NOT be accessible via IAM policies alone.
- >
- > **Options (pick one — do not let the skill auto-remediate):**
- >
- > 1. Ask your account/security admin to re-grant `IAMAllowedPrincipals` on `CreateDatabaseDefaultPermissions` and `CreateTableDefaultPermissions`. This is the simplest fix.
- > 2. Add explicit Lake Formation grants for each role this skill creates (Crawler, ETL, Athena, Quick Sight) on the database and tables. The skill will generate the LF grant CDK on request.
- > 3. Continue and accept that queries will fail with `Insufficient Lake Formation permissions` until grants are added.
- >
- > **Do not proceed with deploy until you confirm which option to take.**
-
- If the precondition is OK, the skill explicitly adds an `IAMAllowedPrincipals` grant on each new database it creates (defense-in-depth — don't rely on account-level default).
-
- ### CDK pattern: explicit IAM-only grant on the database
-
- ```typescript
- import * as lakeformation from 'aws-cdk-lib/aws-lakeformation';
+ | `{prefix}-glue-crawler-role` | Glue Crawlers (Hive only) | S3 read on raw+curated, Catalog write |
+ | `{prefix}-glue-etl-role` | Glue ETL Jobs | S3 read/write on buckets, Catalog read+write, Secrets Manager (JDBC), CloudWatch Logs |
+ | `{prefix}-athena-query-role` | Athena queries | Catalog read, S3 read on curated, S3 write on analytics |
+ | `{prefix}-quicksight-role` | VPC/federated-query scenarios only | Usually NOT needed — see `reference/gotchas.md` |
- new lakeformation.CfnPermissions(this, 'IAMAllowedDbPerm', {
- dataLakePrincipal: { dataLakePrincipalIdentifier: 'IAM_ALLOWED_PRINCIPALS' },
- resource: {
- databaseResource: {
- catalogId: cdk.Stack.of(this).account,
- name: `${props.prefix}_db`,
- },
- },
- permissions: ['ALL'],
- }).addDependency(this.database);
+ ### Lake Formation IAM-only mode
- ```
+ LF has two coexisting models: IAM-based (legacy, `IAMAllowedPrincipals`) and LF-managed (explicit grants). For batch analytics where IAM suffices, this skill configures **IAM-only mode** for deterministic behavior. The precondition check is in §1; strict-mode handling + the explicit `IAMAllowedPrincipals` grant CDK → `reference/gotchas.md`.
- ### `lib/iam-roles` excerpt — Glue ETL role
+ ### Glue ETL role (excerpt)
```typescript
const etlRole = new iam.Role(this, 'EtlRole', {
roleName: `${prefix}-glue-etl-role`,
assumedBy: new iam.ServicePrincipal('glue.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSGlueServiceRole')],
});
etlRole.addToPolicy(new iam.PolicyStatement({
actions: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
resources: [
`arn:aws:s3:::${prefix}-raw-zone`, `arn:aws:s3:::${prefix}-raw-zone/*`,
- `arn:aws:s3:::${prefix}-curated-zone`, `arn:aws:s3:::${prefix}-curated-zone/*`,
+ `arn:aws:s3:::${prefix}-curated-zone`, `arn:aws:s3:::${prefix}-curated-zone/*`, // Hive only
`arn:aws:s3:::${prefix}-analytics-zone`, `arn:aws:s3:::${prefix}-analytics-zone/*`,
],
}));
// Only if JDBC source:
etlRole.addToPolicy(new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [props.jdbcSecretArn],
}));
-
```
- ---
-
- ## 7. ETL Pipeline
-
- ### `glue-scripts/ingest-jdbc.py` — JDBC → S3 raw
-
- ```python
- """
- JDBC ingestion to S3 raw zone.
- Reads each table from the source DB and writes Parquet partitioned by ingestion_date.
- """
- import sys
- from datetime import datetime
- from awsglue.transforms import *
- from awsglue.utils import getResolvedOptions
- from pyspark.context import SparkContext
- from pyspark.sql.functions import lit
- from awsglue.context import GlueContext
- from awsglue.job import Job
-
- args = getResolvedOptions(sys.argv, [
- 'JOB_NAME', 'CONNECTION_NAME', 'DATABASE', 'TABLES', 'TARGET_BUCKET', 'SOURCE_NAME', 'ENGINE',
- ])
-
- sc = SparkContext()
- glueContext = GlueContext(sc)
- spark = glueContext.spark_session
- job = Job(glueContext)
- job.init(args['JOB_NAME'], args)
-
- ingestion_date = datetime.utcnow().strftime('%Y-%m-%d')
- tables = args['TABLES'].split(',')
- # Engine mapping: sqlserver→sqlserver, mysql→mysql, postgresql→postgresql, oracle→oracle
- connection_type = args.get('ENGINE', 'sqlserver')
-
- for table in tables:
- table = table.strip()
- print(f"Ingesting {args['DATABASE']}.{table}")
- df = glueContext.create_dynamic_frame.from_options(
- connection_type=connection_type,
- connection_options={
- 'useConnectionProperties': 'true',
- 'connectionName': args['CONNECTION_NAME'],
- 'dbtable': table,
- 'database': args['DATABASE'],
- },
- )
- df = df.toDF().withColumn('ingestion_date', lit(ingestion_date))
- output_path = f"s3://{args['TARGET_BUCKET']}/{args['SOURCE_NAME']}/{table}/"
- (df.write
- .mode('overwrite')
- .partitionBy('ingestion_date')
- .parquet(output_path))
- print(f" → {output_path}")
-
- job.commit()
-
- ```
-
- ### `glue-scripts/transform.py` — Raw → Curated
-
- ```python
- """
- Raw → Curated transformation.
- Cleans, casts, filters, and writes Parquet+Snappy to curated zone.
- Customize the transform_table() function per business domain.
- """
- import sys
- from awsglue.transforms import *
- from awsglue.utils import getResolvedOptions
- from pyspark.context import SparkContext
- from awsglue.context import GlueContext
- from awsglue.job import Job
- from pyspark.sql.functions import col, trim, to_date, when
-
- args = getResolvedOptions(sys.argv, [
- 'JOB_NAME', 'DATABASE', 'RAW_TABLE', 'CURATED_TABLE', 'TARGET_BUCKET',
- ])
-
- sc = SparkContext()
- glueContext = GlueContext(sc)
- spark = glueContext.spark_session
- job = Job(glueContext)
- job.init(args['JOB_NAME'], args)
-
- raw = glueContext.create_dynamic_frame.from_catalog(
- database=args['DATABASE'], table_name=args['RAW_TABLE']
- ).toDF()
-
- # Domain-specific cleanup — replace with actual transforms
- curated = (raw
- .filter(col('deleted_flag') != 'Y')
- .withColumn('inspected_at', to_date(col('inspected_at')))
- .withColumn('vendor_id', trim(col('vendor_id')))
- .dropDuplicates(['inspection_id'])
- )
-
- (curated.write
- .mode('overwrite')
- .option('compression', 'snappy')
- .partitionBy('inspected_at')
- .parquet(f"s3://{args['TARGET_BUCKET']}/{args['CURATED_TABLE']}/"))
-
- job.commit()
+ > **Iceberg path:** the bucket-level policies above cover only raw + analytics. The Iceberg tables need **table-level** `s3tables:*` grants on the table-bucket ARN plus `glue:*` on the `s3tablescatalog` integration catalog, on BOTH the ETL and Athena roles. Full grant block → `reference/iceberg-cdk.md`. Scope `s3tables:*` to the specific bucket ARN — never `*`.
- ```
+ ---
- ### `lib/pipeline-stack.ts` — Glue Job + Glue Trigger schedule
+ ## 6. ETL Pipeline (scheduling)
- ```typescript
- new glue.CfnJob(this, 'IngestJob', {
- name: `${props.prefix}-ingest-jdbc`,
- role: props.etlRoleArn,
- command: {
- name: 'glueetl',
- pythonVersion: '3',
- scriptLocation: `s3://${props.scriptsBucket}/glue-scripts/ingest-jdbc.py`,
- },
- glueVersion: '4.0',
- numberOfWorkers: 2,
- workerType: 'G.1X',
- timeout: 120, // minutes
- defaultArguments: {
- '--CONNECTION_NAME': `${props.prefix}-jdbc-connection`,
- '--DATABASE': props.sourceDatabase,
- '--TABLES': props.sourceTables.join(','),
- '--TARGET_BUCKET': props.rawBucketName,
- '--SOURCE_NAME': props.sourceName,
- '--ENGINE': props.sourceEngine, // sqlserver | mysql | postgresql | oracle
- '--enable-metrics': 'true',
- '--enable-continuous-cloudwatch-log': 'true',
- },
- tags: { project: props.prefix, environment: props.environment, owner: props.owner },
- });
+ JDBC connectivity (Glue Connection, on-prem/VPC prerequisites, `test-connection`) applies to **both** patterns when the source is JDBC → **`reference/vpc-connectivity.md`**. For a public/reachable endpoint, create the Glue Connection directly.
- // Native Glue scheduler — simpler than EventBridge for Glue-only triggers.
- new glue.CfnTrigger(this, 'IngestTrigger', {
- name: `${props.prefix}-daily-trigger`,
- type: 'SCHEDULED',
- schedule: 'cron(0 17 * * ? *)', // 02:00 KST = 17:00 UTC, daily
- startOnCreation: true,
- actions: [{ jobName: `${props.prefix}-ingest-jdbc` }],
- });
+ - **Iceberg (default):** ONE Glue 5.x Spark job per source that reads and **writes Iceberg directly** (`ingest-iceberg.py` / `ingest-jdbc-iceberg.py`). No separate transform job, no curated bucket. Full CDK (job + JAR upload + `--conf`/`--extra-jars`/`--user-jars-first` + cron trigger) → **`reference/iceberg-cdk.md`**.
+ - **Hive (opt-in):** two-job `ingest-jdbc.py` → `transform.py` flow with conditional trigger chaining → **`reference/hive-pattern.md`**.
- ```
+ Both schedule with a **Glue Trigger (cron)** — never EventBridge + Athena (Athena has no scheduler).
---
- ## 8. Query Layer (Athena)
+ ## 7. Query Layer (Athena)
### Workgroup config
```typescript
import * as athena from 'aws-cdk-lib/aws-athena';
new athena.CfnWorkGroup(this, 'Workgroup', {
name: `${props.prefix}-workgroup`,
state: 'ENABLED',
workGroupConfiguration: {
enforceWorkGroupConfiguration: true,
publishCloudWatchMetricsEnabled: true,
- bytesScannedCutoffPerQuery: 1_000_000_000, // 1 GB cap — see §10
+ bytesScannedCutoffPerQuery: 1_000_000_000, // 1 GB cap — see §9
resultConfiguration: {
outputLocation: `s3://${props.analyticsBucket}/athena-results/`,
- encryptionConfiguration: { encryptionOption: 'SSE_S3' },
+ encryptionConfiguration: { encryptionOption: 'SSE_S3' }, // NOT KMS — breaks DML on S3 Tables
},
},
tags: [
{ key: 'project', value: props.prefix },
{ key: 'environment', value: props.environment },
{ key: 'owner', value: props.owner },
],
});
-
```
- ### `athena-views/views.sql` — view DDL pattern
-
- ```sql
- -- Enrichment view: join codes to human-readable names, derive metrics
- CREATE OR REPLACE VIEW v_quality_inspections AS
- SELECT
- qi.inspection_id,
- qi.inspected_at,
- qi.vendor_id,
- v.vendor_name,
- qi.product_id,
- p.product_name,
- qi.inspection_item_code,
- CASE qi.inspection_item_code
- WHEN '01' THEN '외관'
- WHEN '02' THEN '치수'
- WHEN '03' THEN '성분'
- ELSE '기타'
- END AS inspection_item_name,
- qi.defect_count,
- qi.total_count,
- CAST(qi.defect_count AS DOUBLE) / NULLIF(qi.total_count, 0) * 100 AS defect_rate_pct
- FROM quality_inspections qi
- LEFT JOIN vendors v ON qi.vendor_id = v.vendor_id
- LEFT JOIN products p ON qi.product_id = p.product_id;
-
- ```
-
- Patterns to apply:
-
- - `CASE` statements for code→Korean name enrichment (matches business question phrasing)
- - `LEFT JOIN` for dimension lookups, never `INNER JOIN` (avoid silent row drops)
- - `NULLIF(denom, 0)` to avoid divide-by-zero
- - `DATE_TRUNC('month', date_col) AS month` for time-series rollups
-
- ### Named queries for the user's business questions
-
- For each business question collected as input, generate one named query in Athena:
-
- ```sql
- -- Q: Top 5 defects by vendor (FY 2025)
- SELECT vendor_name, SUM(defect_count) AS total_defects
- FROM v_quality_inspections
- WHERE inspected_at >= DATE '2025-01-01'
- GROUP BY vendor_name
- ORDER BY total_defects DESC
- LIMIT 5;
- ```
-
- > **CDK gotcha — `CfnNamedQuery.workGroup` does NOT auto-create a CloudFormation dependency.** Passing the workgroup name as a string literal lets CFN try to create the named query and the workgroup in parallel, which fails on fresh stacks. Either reference the workgroup token, or add the dependency explicitly:
- >
- > ```typescript
- > const workgroup = new athena.CfnWorkGroup(this, 'Workgroup', { /* … */ });
- >
- > const namedQuery = new athena.CfnNamedQuery(this, 'ValidationQuery', {
- > workGroup: workgroup.ref, // token — establishes a CFN dependency
- > database: `${props.prefix}_db`,
- > queryString: '...',
- > name: `${props.prefix}-vendor-top5`,
- > });
- > // OR, if you must use a string literal for the workgroup name:
- > // namedQuery.addDependency(workgroup);
- > ```
-
- ### Creating views from CDK — use a Python helper, not bash
-
- Athena views can't be declared as CDK constructs (no `CfnView`). The skill provides a Python script that the CDK app invokes via `BucketDeployment` + a `CustomResource`, or the operator runs manually after `cdk deploy`.
-
- ```python
- # scripts/run-views.py
- """
- Apply CREATE OR REPLACE VIEW statements from a .sql file via Athena.
- Use Python — bash awk-based SQL splitting is fragile with multi-line CASE statements.
- """
- import boto3, re, sys, time
-
-
- def run_views(workgroup: str, database: str, views_file: str, region: str):
- client = boto3.client('athena', region_name=region)
- with open(views_file) as f:
- sql = f.read()
-
- # Split by CREATE OR REPLACE VIEW boundaries
- statements = re.split(r'(?=CREATE\s+OR\s+REPLACE\s+VIEW)', sql, flags=re.IGNORECASE)
- statements = [s.strip().rstrip(';') for s in statements if s.strip()]
-
- for stmt in statements:
- view_name = re.search(r'VIEW\s+(\S+)', stmt, re.IGNORECASE).group(1)
- print(f"Creating view: {view_name}")
- response = client.start_query_execution(
- QueryString=stmt,
- WorkGroup=workgroup,
- QueryExecutionContext={'Database': database},
- )
- exec_id = response['QueryExecutionId']
- while True:
- status = client.get_query_execution(QueryExecutionId=exec_id)
- state = status['QueryExecution']['Status']['State']
- if state in ('SUCCEEDED', 'FAILED', 'CANCELLED'):
- break
- time.sleep(1)
- if state != 'SUCCEEDED':
- reason = status['QueryExecution']['Status'].get('StateChangeReason', 'unknown')
- print(f" FAILED: {reason}")
- sys.exit(1)
- print(f" OK")
-
-
- if __name__ == '__main__':
- run_views(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
- ```
-
- Invoke it after deploy:
- ```bash
- python3 scripts/run-views.py {prefix}-workgroup {prefix}_db athena-views/views.sql {region}
- ```
+ > ⚠️ Use **SSE-S3**, not KMS, for workgroups that write to S3 Tables (KMS result encryption breaks INSERT/MERGE/UPDATE/DELETE — `reference/gotchas.md`).
- ### Post-deploy bootstrap sequence — execute yourself, do NOT hand off to user
+ ### Views (Hive) / marts (Iceberg) + named queries
- After `cdk deploy --all --require-approval never` succeeds, run this sequence yourself. Do NOT tell the user to run these commands. If any step fails, diagnose the error, fix the code, redeploy, and retry.
+ - **Hive:** `v_{table}` enrichment views (`CASE` for code→name, `LEFT JOIN` for dimensions, `NULLIF` for divide-by-zero, `DATE_TRUNC` for rollups). DDL in `reference/scripts.md`.
+ - **Iceberg:** `mart_*` CTAS tables instead (rule 3).
+ - One named query per business question. ⚠️ `CfnNamedQuery.workGroup` does NOT auto-create a CFN dependency — use the token or `addDependency` (`reference/gotchas.md`).
+ - Views/marts are applied post-deploy via `scripts/run-views.py` (no `CfnView` construct exists). Script in `reference/scripts.md`.
- 1. **Start the raw crawler** and wait for completion:
+ ### Post-deploy bootstrap — execute yourself, do NOT hand off
- ```bash
- aws glue start-crawler --name {prefix}-raw-crawler --region {region}
- # Poll until State = "READY"
- while true; do
- state=$(aws glue get-crawler --name {prefix}-raw-crawler --region {region} --query 'Crawler.State' --output text)
- [ "$state" = "READY" ] && break
- sleep 10
- done
- ```
+ After `cdk deploy --all --require-approval never` succeeds, run the bootstrap yourself. If a step fails, diagnose, fix the code, redeploy, retry. Report a single summary at the end; don't narrate every step.
- 2. **Verify raw table schemas** match what the transform/views expect:
+ **Iceberg bootstrap (DEFAULT)** — no crawlers, no `v_*` views:
+ 1. **Upload raw source to S3** (S3 file sources only; JDBC sources skip — the job reads the DB directly):
```bash
- aws athena start-query-execution \
- --work-group {prefix}-workgroup \
- --query-string "SHOW COLUMNS IN {prefix}_db.raw_{table}" \
- --region {region}
- # Wait for SUCCEEDED, then get-query-results to read columns
+ aws s3 cp ./data/ s3://{prefix}-raw-zone/{source}/{table}/ --recursive
```
-
- 3. **Compare actual column names with expected names** in `views.sql` and `transform.py`. If they differ:
- - **Obvious mapping** (e.g., `inspectionId` ↔ `inspection_id`, common case differences, snake/camel): auto-update `views.sql` JOIN keys + SELECT columns and `transform.py` column references, then redeploy `PipelineStack` and retry.
- - **Ambiguous** (multiple plausible mappings, or columns the source doesn't have at all): STOP and ask the user — present the actual columns vs. what the views expect, and let the user pick the mapping.
-
- 4. **Run the transform job** and wait for completion:
-
+ 2. **Run the Glue 5.x Spark job** and poll until `SUCCEEDED` — it reads, types/cleans/filters, and writes the Iceberg table (auto-registered):
```bash
- aws glue start-job-run --job-name {prefix}-transform --region {region}
+ aws glue start-job-run --job-name {prefix}-ingest-iceberg --region {region}
# Poll get-job-run until JobRunState in (SUCCEEDED, FAILED, STOPPED)
```
-
- 5. **Start the curated crawler**:
-
- ```bash
- aws glue start-crawler --name {prefix}-curated-crawler --region {region}
- # Poll until READY (same pattern as step 1)
- ```
-
- 6. **Create views**:
-
- ```bash
- python3 scripts/run-views.py {prefix}-workgroup {prefix}_db athena-views/views.sql {region}
- ```
-
- 7. **Run smoke test**:
-
+ **Schema reconciliation:** verify actual source columns vs the types declared in the job transform. Differ → fix the `.py` transform (not crawler output/DDL) and re-run. Obvious mapping → auto-fix. Ambiguous → STOP and ask.
+ 3. **Build/verify the mart tables** (`mart_*` replace `v_*`). Preferred: the Glue job writes marts as an extra `writeTo`. If via Athena instead:
```bash
- python3 scripts/smoke-test.py --prefix {prefix} --region {region}
+ python3 scripts/run-views.py {prefix}-workgroup {prefix}_db athena-views/marts.sql {region}
```
-
- 8. **Update ARCHITECTURE.md** with the actual table list, partition counts, and any column rename decisions made in step 3.
-
- Report a single summary at the end:
- > ✅ Pipeline deployed. Tables: [list]. Smoke test: passed. Views: [list]. Renames applied: [list or "none"].
+ 4. **Run the smoke test** (§8) and **update ARCHITECTURE.md + platform.yaml** (§10).
- Do not narrate every step to the user — just the final summary plus any decision points where you actually had to stop.
+ **Hive bootstrap** (crawler-based, opt-in only) → full numbered sequence in `reference/hive-pattern.md`.
---
- ## 9. Data Quality Checks
-
- After every pipeline run, execute these checks. Bake them into the README and provide as a CloudWatch Synthetic Canary or scheduled Athena named query.
-
- ```sql
- -- A. Row count: target should be within 1% of source
- SELECT COUNT(*) AS target_count FROM {prefix}_db.{table};
- -- (compare manually with source DB count)
-
- -- B. Null rate on key columns (inspection_id, vendor_id, etc.)
- SELECT
- COUNT(*) AS total,
- SUM(CASE WHEN {key_column} IS NULL THEN 1 ELSE 0 END) AS nulls,
- ROUND(SUM(CASE WHEN {key_column} IS NULL THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS null_pct
- FROM {prefix}_db.{table};
-
- -- C. Date range — confirms the latest partition loaded
- SELECT MIN({date_col}) AS earliest, MAX({date_col}) AS latest
- FROM {prefix}_db.{table};
-
- -- D. Duplicate primary key check
- SELECT {pk_column}, COUNT(*) AS occurrences
- FROM {prefix}_db.{table}
- GROUP BY {pk_column}
- HAVING COUNT(*) > 1
- LIMIT 10;
-
- -- E. Referential integrity (if there are FK relationships)
- SELECT a.{fk_column}
- FROM {prefix}_db.{table} a
- LEFT JOIN {prefix}_db.{dim_table} b ON a.{fk_column} = b.{pk_column}
- WHERE b.{pk_column} IS NULL
- LIMIT 10;
+ ## 8. Data Quality Checks
- ```
+ After every pipeline run, run row-count, null-rate, date-range, duplicate-PK, and referential-integrity checks. Generate one set per curated table in `athena-views/quality-checks.sql`. SQL templates in `reference/scripts.md`.
- Generate one set of these queries per curated table and include them in `athena-views/quality-checks.sql`.
+ > 🔴 **Row-count > 0 and null-checks are NOT enough — reconcile COUNTS against the source.** The dirty-data failures above (mixed date formats, trailing-minus, NFD filenames, unnormalized join keys) all leave a structurally valid table with the WRONG number of rows or zeroed measures. For each base table, compare `COUNT(*)` (and key `SUM`s) against the raw source file/row count; a gap beyond ~1% means rows were silently dropped or zeroed — trace it to the specific corruption (date parse miss? cast-to-NULL? join miss?) before declaring the run good. This is the producer-side half of the consumption skill's mandatory **KPI Numerical Accuracy Verification** — the two together close the "validation passed ≠ correct answer" gap. Reconciliation SQL → `reference/scripts.md`.
### Post-deploy smoke test
- After `cdk deploy`, run the smoke test to verify data actually flows end-to-end:
-
```bash
python3 scripts/smoke-test.py --prefix {prefix} --region {region}
```
-
- The smoke test script should:
- 1. Run each named query in the workgroup and assert `rows > 0`.
- 2. Verify each curated Glue table exists with `partition_count > 0`.
- 3. Run a sample `SELECT * FROM {table} LIMIT 10` against each curated table to confirm read access from the Athena workgroup with the configured IAM role.
- 4. Exit non-zero on any failure so it can be wired into CI / a deploy hook.
-
- Generate this script as part of the project scaffolding alongside the CDK app.
+ Asserts each named query returns `rows > 0`, each table has `partition_count > 0`, and `SELECT … LIMIT 10` reads from the workgroup role; exits non-zero on failure. Generate it alongside the CDK app — spec + invocation in `reference/scripts.md`.
---
- ## 10. Cost Guardrails
+ ## 9. Cost Guardrails
| Lever | Default | Rationale |
| --- | --- | --- |
- | Glue Job `numberOfWorkers` | 2 (`G.1X`) | First runs are small; scale up only after measuring |
- | Glue Job `timeout` | 120 minutes | Prevents runaway jobs from billing for hours |
- | Athena `bytesScannedCutoffPerQuery` | 1 GB | Forces partitioning + column selection. Bumps to 10 GB only with cause. |
- | S3 raw lifecycle | IA @ 90d, Glacier @ 365d | Raw is rarely re-read after curation |
- | S3 analytics MPU cleanup | 7 days | Athena cancelled queries leave orphaned multipart uploads |
+ | Glue Job `numberOfWorkers` | 2 (`G.1X`) | First runs are small; scale up after measuring |
+ | Glue Job `timeout` | 120 min | Prevents runaway jobs billing for hours |
+ | Athena `bytesScannedCutoffPerQuery` | 1 GB | Forces partitioning + column selection; bump to 10 GB only with cause |
+ | S3 raw lifecycle | IA @ 90d, Glacier @ 365d | Raw rarely re-read after curation |
+ | S3 analytics MPU cleanup | 7 days | Cancelled Athena queries leave orphaned MPUs |
| Glue trigger schedule | Daily, off-peak | Don't ingest during business hours unless real-time |
### Monitoring
```typescript
- // CloudWatch alarm: Glue job failure
+ // Glue job failure
new cloudwatch.Alarm(this, 'IngestJobFailure', {
metric: new cloudwatch.Metric({
- namespace: 'AWS/Glue',
+ namespace: 'Glue',
metricName: 'glue.driver.aggregate.numFailedTasks',
- dimensionsMap: { JobName: `${prefix}-ingest-jdbc` },
- statistic: 'Sum',
- period: cdk.Duration.minutes(5),
+ dimensionsMap: { JobName: `${prefix}-ingest-iceberg` }, // or {prefix}-ingest-jdbc on Hive
+ statistic: 'Sum', period: cdk.Duration.minutes(5),
}),
- threshold: 1,
- evaluationPeriods: 1,
+ threshold: 1, evaluationPeriods: 1,
alarmDescription: 'Glue ingest job has failed tasks',
});
- // CloudWatch alarm: Athena scan exceeds budget
+ // Athena scan exceeds budget
new cloudwatch.Alarm(this, 'AthenaScanBudget', {
metric: new cloudwatch.Metric({
- namespace: 'AWS/Athena',
- metricName: 'ProcessedBytes',
+ namespace: 'AWS/Athena', metricName: 'ProcessedBytes',
dimensionsMap: { WorkGroup: `${prefix}-workgroup` },
- statistic: 'Sum',
- period: cdk.Duration.hours(1),
+ statistic: 'Sum', period: cdk.Duration.hours(1),
}),
- threshold: 100_000_000_000, // 100 GB/hour
- evaluationPeriods: 1,
+ threshold: 100_000_000_000, evaluationPeriods: 1, // 100 GB/hour
});
-
```
---
- ## 11. Output Contract
+ ## 10. Output Contract
- The skill MUST generate a `README.md` at the project root documenting exactly what was created. Downstream skills and tools rely on this convention.
+ The skill MUST generate a `README.md`, an `ARCHITECTURE.md`, and a `platform.yaml` at the project root. Downstream skills rely on these conventions. A consuming tool only needs `{prefix}` and `{region}` — everything else is derived.
+ ### `README.md` — naming convention
+
```markdown
# {prefix} Data Platform — Pipeline Layer
-
## Output Contract
-
+ ### Storage Pattern
+ - Pattern: `Iceberg (S3 Tables)` OR `Hive` <-- record which one was built
### Naming Convention
- - Project prefix: `{prefix}`
- - Region: `{region}`
- - Environment: `{environment}`
+ - Project prefix: `{prefix}` · Region: `{region}` · Environment: `{environment}`
+ ```
- ### Storage
- - Raw: `s3://{prefix}-raw-zone/{source}/{table}/`
- - Curated: `s3://{prefix}-curated-zone/{table}/`
- - Analytics: `s3://{prefix}-analytics-zone/athena-results/`
+ **Storage + catalog — Iceberg (default):**
+ ```markdown
+ ### Storage (Iceberg)
+ - Table Bucket: `{prefix}-table-bucket` · Namespace: `{prefix}_db`
+ - Raw landing: `s3://{prefix}-raw-zone/` (S3 file sources only) · Analytics: `s3://{prefix}-analytics-zone/`
+ ### Catalog
+ - S3 Tables catalog: `s3tablescatalog/{prefix}-table-bucket` · Database/Namespace: `{prefix}_db`
+ - Tables: `{table}` (Iceberg, PARQUET+ZSTD) — written by `{prefix}-ingest-iceberg`
+ - Marts: `mart_{table}` (CTAS) — replaces `v_{table}` views (unsupported across S3 Tables catalog)
+ - Raw external tables: none (only if Athena CTAS fallback used: `raw_{table}` in `{prefix}_raw`)
+ ```
+ **Storage + catalog — Hive (opt-in):**
+ ```markdown
+ ### Storage (Hive)
+ - Raw: `s3://{prefix}-raw-zone/{source}/{table}/` · Curated: `s3://{prefix}-curated-zone/{table}/`
+ - Analytics: `s3://{prefix}-analytics-zone/athena-results/`
### Glue Catalog
- - Database: `{prefix}_db`
- - Raw tables: `raw_{source}_{table}` (e.g., `raw_sqlserver_quality_inspections`)
- - Curated tables: `{table}` (e.g., `quality_inspections`)
- - Views: `v_{table}` (e.g., `v_quality_inspections`)
+ - Database: `{prefix}_db` · Raw: `raw_{source}_{table}` · Curated: `{table}` · Views: `v_{table}`
+ ```
+ **Shared for both:**
+ ```markdown
### Athena
- - Workgroup: `{prefix}-workgroup`
- - Results: `s3://{prefix}-analytics-zone/athena-results/`
- - Scan cap: 1 GB per query
-
+ - Workgroup: `{prefix}-workgroup` · Results: `s3://{prefix}-analytics-zone/athena-results/` · Scan cap: 1 GB
### IAM Roles
- - Crawler: `{prefix}-glue-crawler-role`
- - ETL: `{prefix}-glue-etl-role`
- - Athena: `{prefix}-athena-query-role`
- - Quick Sight: `{prefix}-quicksight-role` (created here, used by consumption layer)
-
- ### Validation Queries
+ - Crawler: `{prefix}-glue-crawler-role` (Hive) · ETL: `{prefix}-glue-etl-role`
+ - Athena: `{prefix}-athena-query-role` · Quick Sight: `{prefix}-quicksight-role` (only if VPC/federated)
+ ### Validation
See `athena-views/quality-checks.sql`. Run after each pipeline run.
-
### Schedule
- Daily ingest job at 02:00 KST (17:00 UTC). Glue trigger: `{prefix}-daily-trigger`.
-
+ Daily ingest at 02:00 KST (17:00 UTC). Glue trigger: `{prefix}-daily-trigger`.
```
- A consuming tool (BI, chat agent, downstream pipeline) only needs `{prefix}` and `{region}` — everything else is derived from this convention.
-
- ### Architecture Record (`ARCHITECTURE.md`)
-
- The skill MUST create and maintain an `ARCHITECTURE.md` file in the CDK project root. Update it every time infrastructure is added or modified.
-
- This file serves three purposes:
- 1. **Team reference** — engineers can read it to understand what's deployed
- 2. **AI agent context** — future AI agents (including this skill on re-run) read it to understand existing state
- 3. **Change log** — records what was built and when
+ ### `ARCHITECTURE.md` — human-readable architecture record
- Template:
+ Create on first run; **READ it first** on re-run; update EVERY time infrastructure changes. Single source of truth for "what exists." Capture: current state (buckets, tables w/ row counts, jobs, schedules, **discovered schemas**), the chosen pattern + catalog name, a Decisions table (why + "do NOT change to"), and a Known-Issues table (copy from `reference/gotchas.md`), plus a change log. Template:
```markdown
# {prefix} Data Platform — Architecture Record
-
- ## Current State
- Last updated: {YYYY-MM-DD}
-
- ### Storage Layer
- - Raw zone: `s3://{prefix}-raw-zone/` (SSE-S3, lifecycle: IA@90d, Glacier@365d)
- - Curated zone: `s3://{prefix}-curated-zone/` (SSE-S3, Parquet+Snappy)
- - Analytics zone: `s3://{prefix}-analytics-zone/` (Athena results)
-
- ### Catalog
- - Database: `{prefix}_db`
- - Tables: [list all tables with row counts and last crawl date]
- - Views: [list all views]
-
- ### ETL
- - Ingest jobs: [list with schedule]
- - Transform jobs: [list]
- - Trigger: `{prefix}-daily-trigger` (cron: 0 17 * * ? *)
-
- ### Query
- - Athena workgroup: `{prefix}-workgroup` (scan limit: 1GB)
- - Named queries: [list]
+ ## Current State (Last updated: {YYYY-MM-DD})
+ ### Architecture Pattern
+ - `Architecture Pattern: Iceberg (S3 Tables)` OR `Architecture Pattern: Hive`
+ (consumption layer reads this to pick the Athena catalog)
+ ### Storage Layer / Catalog / ETL / Query / IAM / Lake Formation
+ [fill per pattern — Iceberg: table bucket + raw + analytics; Hive: raw + curated + analytics]
+ - Catalog name in Athena: `s3tablescatalog/{prefix}-table-bucket` (Iceberg) OR `AwsDataCatalog` (Hive)
+ - Views/marts: Hive → `v_{table}`. Iceberg → `mart_{table}` CTAS.
+ - Trigger: `{prefix}-daily-trigger` (cron 0 17 * * ? *)
+ ## Decisions (why, not just what)
+ | Decision | Rationale | Do NOT change to |
+ |----------|-----------|-----------------|
+ | Iceberg / S3 Tables | ACID + time travel + auto-compaction; no crawler drift | Hive (unless pre-existing Hive infra) |
+ | Glue 5.x Spark (not Athena CTAS) | Typed transforms in code, schedulable, MERGE | Athena-only ETL (no scheduler) |
+ | ZSTD compression | Better ratio than Snappy for analytics | Snappy (Hive default only) |
+ | Single DB `{prefix}_db` | Clean LF grant boundary; convention discovery | `default` database |
+ | Glue Trigger (cron) | Native Glue scheduler | EventBridge → Athena (no scheduler) |
+ ## Known Issues & Gotchas
+ [copy the table from reference/gotchas.md]
+ ## Change Log
+ | Date | Change | By |
+ ```
- ### IAM
- - Crawler role: `{prefix}-glue-crawler-role`
- - ETL role: `{prefix}-glue-etl-role`
- - Query role: `{prefix}-athena-query-role`
+ ### `platform.yaml` — machine-readable state manifest
- ### Lake Formation
- - Mode: IAM-only (IAMAllowedPrincipals granted on {prefix}_db)
+ Downstream tools and the consumption skill read this to auto-discover catalog, tables, column types, and join keys without parsing prose. Generate on first deploy; **READ + merge** if it exists; update on EVERY change. The `tables` section must reflect the **actual discovered schema**, not the examples. The `consumption` section starts empty (the consumption skill fills it).
- ## Change Log
- | Date | Change | By |
- |------|--------|-----|
- | {date} | Initial deployment — pipeline for {source_type} | {user/agent} |
+ ```yaml
+ platform:
+ prefix: "{prefix}"
+ region: "{region}"
+ pattern: "iceberg" # iceberg | hive
+ catalog: "s3tablescatalog/{prefix}-table-bucket" # or AwsDataCatalog for Hive
+ created: "YYYY-MM-DD"
+ updated: "YYYY-MM-DD"
+ storage:
+ table_bucket: "{prefix}-table-bucket" # Iceberg only
+ raw_zone: "{prefix}-raw-zone"
+ analytics_zone: "{prefix}-analytics-zone"
+ tables:
+ quality_inspections: # example — adapt to actual tables
+ type: iceberg_mart # iceberg_mart | iceberg_base | hive_curated | hive_raw
+ columns: {inspection_id: bigint, supplier_id: string, inspection_date: date}
+ partition: "month(inspection_date)"
+ join_keys: {supplier_id: "suppliers.supplier_id"}
+ written_by: "{prefix}-ingest-iceberg"
+ mart_defect_root_cause: # multi-grain mart example — grain + SUM-safety are MANDATORY
+ type: iceberg_mart
+ grain: [material_key, defect_code] # explicit grain — consumption reads this to avoid SUM duplication
+ sum_safe_columns: [defect_count] # columns valid to SUM across this grain
+ # NOT sum-safe at this grain: material_qmel_count (coarser grain → duplicates on SUM)
+ single_row: false # true only for a dedicated 1-row KPI-summary mart
+ validation_sql: "SELECT SUM(defect_count) FROM mart_defect_root_cause" # ground-truth aggregate
+ written_by: "{prefix}-ingest-iceberg"
+ data_quality_issues: # record every value-level issue found + its impact (read by consumption)
+ - table: base_mes_production
+ issue: "start_time NULL for 511 rows (4.1%)"
+ impact: "Excluded from cycle-time calculations"
+ - table: base_sap_orders
+ issue: "planned_end_date missing for 133 orders (16%)"
+ impact: "Cannot calculate delay for these orders"
+ etl:
+ "{prefix}-ingest-iceberg":
+ glue_version: "5.0"
+ workers: 2
+ schedule: "cron(0 17 * * ? *)"
+ lineage:
+ - "raw_quality_inspections -> mart_quality_summary -> quality-dataset(SPICE) -> quality-dashboard"
+ consumption:
+ quicksight_region: "{region}"
+ spice_refresh: "DAILY 04:00 Asia/Seoul"
+ datasets: {}
+ dashboards: {}
+ topics: {}
```
- **Rules:**
- - Create `ARCHITECTURE.md` on first run
- - Update it EVERY time infrastructure is added/modified (new table, new job, config change)
- - If `ARCHITECTURE.md` already exists, READ it first to understand current state before making changes
- - The file is the single source of truth for "what exists"
+ > **Why both?** `ARCHITECTURE.md` is for humans (prose, rationale); `platform.yaml` is for machines (parseable, no ambiguity). Keep them in sync.
---
- ## 12. Teardown
+ ## 11. Teardown
- > **Iteration tip — separate stacks for fast iteration.** CloudFormation rolls back the entire stack on any resource failure. For pipeline iteration (where Glue jobs and Athena views change frequently while storage and IAM stay stable), split into separate stacks:
- > 1. **StorageStack** — S3 buckets, encryption, lifecycle (very stable)
- > 2. **CatalogStack** — Glue database, crawlers, IAM roles (stable)
- > 3. **PipelineStack** — Glue ETL jobs, triggers, named queries, views (changes every iteration)
- >
- > Dashboard / view iteration (~2 min/cycle) won't trigger rollback of upstream resources. Cross-stack references via `props` keep CDK happy.
+ > **Iteration tip — separate stacks.** CFN rolls back the whole stack on any resource failure. Split into **StorageStack** (very stable), **CatalogStack** (stable: DB, crawlers, IAM), **PipelineStack** (changes every iteration: jobs, triggers, named queries, views). View/mart iteration won't roll back upstream resources. Cross-stack refs via `props`.
- Tearing down a data platform is a destructive operation. **You execute these commands yourself when the user asks for teardown — but always confirm intent first ("This will delete S3 data + Glue catalog. Confirm?") before running anything.** Buckets use `RemovalPolicy.RETAIN` precisely so `cdk destroy` cannot accidentally delete data.
+ Teardown is destructive. **You run these yourself when asked, but always confirm intent first** ("This will delete S3 data + Glue catalog. Confirm?"). Buckets use `RemovalPolicy.RETAIN` so `cdk destroy` can't accidentally delete data.
```bash
- # 1. Empty S3 buckets first (CDK can't delete non-empty buckets, and these have RETAIN)
+ # 1. Empty S3 buckets (CDK can't delete non-empty RETAIN buckets)
aws s3 rm s3://{prefix}-raw-zone --recursive
- aws s3 rm s3://{prefix}-curated-zone --recursive
+ aws s3 rm s3://{prefix}-curated-zone --recursive # Hive only
aws s3 rm s3://{prefix}-analytics-zone --recursive
-
- # 2. Manually delete buckets if desired
- aws s3 rb s3://{prefix}-raw-zone
- aws s3 rb s3://{prefix}-curated-zone
- aws s3 rb s3://{prefix}-analytics-zone
-
- # 3. Delete Glue database (drops all tables and views — confirm first)
+ # 2. Delete buckets if desired
+ aws s3 rb s3://{prefix}-raw-zone ; aws s3 rb s3://{prefix}-analytics-zone
+ # 3. Delete Glue database (drops all tables/views — confirm first)
aws glue delete-database --name {prefix}_db
-
- # 4. Deregister Lake Formation resources (only if registered with LF)
+ # 4. Deregister Lake Formation resources (only if registered)
aws lakeformation deregister-resource --resource-arn arn:aws:s3:::{prefix}-raw-zone || true
- aws lakeformation deregister-resource --resource-arn arn:aws:s3:::{prefix}-curated-zone || true
-
- # 5. CDK destroy — handles roles, jobs, crawlers, workgroup, Glue triggers
+ # 5. CDK destroy — roles, jobs, crawlers, workgroup, triggers
cdk destroy --all
-
```
- For partial teardown (e.g., remove a single source from an existing lake), pass CDK context flags rather than running destroy:
-
- ```bash
- cdk deploy --context source:sqlserver=remove
+ > **Iceberg adds a step:** delete the S3 Table Bucket contents (tables → namespace → table bucket) before `cdk destroy`. Commands in `reference/iceberg-cdk.md`.
- ```
+ For partial teardown (remove one source), use CDK context flags instead of destroy: `cdk deploy --context source:sqlserver=remove`.
---
- ## 13. Batch-Only Scope
-
- > **Scope:** This skill covers **batch** data ingestion and transformation. For real-time/streaming requirements, consider: `Kinesis Data Streams → Firehose → S3 (Iceberg format) → Athena`. That is a separate architecture pattern and is not covered here. Do not attempt to bolt Kinesis onto this pipeline — Iceberg + streaming compaction has different table-format and IAM requirements that warrant their own skill.
+ ## 12. Batch-Only Scope
- For sub-daily ingestion of small data: schedule the existing Glue job more frequently (hourly is reasonable). Below 15 minutes, switch to streaming.
+ This skill covers **batch** ingestion/transformation. For real-time/streaming: `Kinesis Data Streams → Firehose → S3 (Iceberg) → Athena` — a separate architecture, not covered here. Don't bolt Kinesis onto this pipeline. For sub-daily batch of small data, schedule the Glue job more frequently (hourly is fine); below 15 minutes, switch to streaming.
---
- ## 14. When to Add Governance
+ ## 13. When to Add Governance
- Lake Formation strict mode, tag-based access control (LF-TBAC), and cross-account sharing add real complexity. Don't enable them by default. Trigger to add governance:
+ Don't enable LF strict mode / LF-TBAC / cross-account sharing by default. Triggers:
| Trigger | What to add |
| --- | --- |
- | A second team needs access to a subset of tables | LF column/row-level grants per role |
- | PII columns identified | Column masking via LF, plus tagging `data-classification=confidential` |
- | Cross-account data sharing | LF cross-account grant + RAM share (or AWS DataZone) |
- | "We need a data mesh" / "data products" | AWS DataZone (separate skill) |
- | Audit/compliance requirement | CloudTrail data events on S3, plus LF audit trail |
+ | A second team needs a subset of tables | LF column/row-level grants per role |
+ | PII columns identified | LF column masking + `data-classification=confidential` tag |
+ | Cross-account sharing | LF cross-account grant + RAM share (or AWS DataZone) |
+ | "Data mesh" / "data products" | AWS DataZone (separate skill) |
+ | Audit/compliance | CloudTrail data events on S3 + LF audit trail |
- The naming convention and tags this skill applies are pre-conditions for all of the above. Doing them now is cheap; retrofitting them later is expensive.
+ The naming convention and tags this skill applies are preconditions for all of the above — cheap now, expensive to retrofit.
---
- ## Future-Proofing Summary
+ ## Maintaining and extending the platform
- These choices were made now so that adding governance later is a CDK change, not a re-architecture:
+ `ARCHITECTURE.md` + `platform.yaml` are the single source of truth. When extending: (1) read them FIRST (§1 current-state question), (2) add incrementally without recreating (idempotent CDK, `RemovalPolicy.RETAIN`), (3) update both files. Schema changes: Iceberg → `ALTER TABLE ADD/DROP COLUMNS` + update the job transform; Hive → re-run the crawler. New source → one new Glue job + table (+ mart). Performance → check Athena scan cutoff (§9), partition design, S3 Tables compaction (`reference/iceberg-cdk.md`). Cost → Glue DPU/workers + trigger frequency (§9).
- - **Tags on every resource** → LF tag-based access control reads them directly.
- - **Per-function IAM roles** → LF grants attach per-principal; one-role-per-job means clean grant boundaries.
- - **One database per domain** (not `default`) → LF permissions are per-database; domain DBs map to teams.
- - **Naming convention in README** → any tool (consumption, governance, monitoring) discovers resources without manual configuration.
- - **Idempotent CDK** → same template handles greenfield and "add new source" without destroy/recreate.
+ ---
+ ## Tool-Specific Context File Generation
+
+ After a successful deploy, generate a context file so future agent sessions understand the project without re-reading all infrastructure. Replace `{prefix}`, `{pattern}` (iceberg|hive), `{region}` with actual values.
+
+ **Claude Code → `CLAUDE.md` in project root:**
+ ```markdown
+ # {prefix} Data Platform
+ Read `platform.yaml` before making any changes.
+ Pattern: {pattern}. Prefix: {prefix}. Region: {region}.
+ Update platform.yaml and ARCHITECTURE.md after every change.
+ ```
+
+ **Kiro → `.kiro/steering/platform-context.md`:**
+ ```markdown
---
+ inclusion: always
+ description: "Platform context for {prefix} data lake"
+ ---
+ Read platform.yaml before making any changes.
+ Pattern: {pattern}. Prefix: {prefix}. Region: {region}.
+ Update platform.yaml and ARCHITECTURE.md after every change.
+ ```
- ## Section Map (for skill consumers)
+ ---
- 1. Prerequisites & Inputs — what user provides, what must exist
- 2. Architecture Overview — ASCII diagram
- 3. Decision Trees — source type, format
- 4. Storage Layer — S3 buckets
- 5. Catalog & Schema — Glue DB, crawlers, naming, partitioning
- 6. IAM & Security — roles + Lake Formation IAM-only config
- 7. ETL Pipeline — Glue jobs, scripts, scheduling
- 8. Query Layer — Athena workgroup, views, named queries
- 9. Data Quality Checks — post-run validation
- 10. Cost Guardrails — DPU, scan caps, lifecycle, monitoring
- 11. Output Contract — what was created, naming, downstream discovery, ARCHITECTURE.md record
- 12. Teardown — destroy + manual cleanup
- 13. Batch-Only Scope — explicit non-goals
- 14. When to Add Governance — triggers for LF/DataZone/etc.
+ ## Section Map
+ 🔴 Critical Rules · Reference files (load-on-demand table) · §1 Prerequisites & Inputs · §2 Architecture Overview · §3 Decision Trees · §4 Storage Pattern: Iceberg (CORE FLOW) · §5 IAM & Security · §6 ETL Pipeline · §7 Query Layer · §8 Data Quality · §9 Cost Guardrails · §10 Output Contract · §11 Teardown · §12 Batch-Only Scope · §13 Governance · Reference files: `iceberg-cdk.md`, `scripts.md`, `hive-pattern.md`, `vpc-connectivity.md`, `gotchas.md`