Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ AI agent skills for migrating workloads to AWS, built for [Claude Code](https://

## What This Does

Point this plugin at your Terraform files, application code, or billing data. It runs a structured 6-phase assessment — discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.
Point this plugin at your Heroku account (via your authenticated Heroku CLI, read-only and consent-gated), your Terraform files, application code, or billing data. It runs a structured 6-phase assessment — discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.

**Supported migration sources:**

Expand Down Expand Up @@ -82,6 +82,7 @@ After installation, just describe what you want to migrate:
**Heroku migrations:**

- "Migrate my Heroku app to AWS"
- "Discover my Heroku apps and estimate AWS costs"
- "Move my Heroku Postgres to RDS"
- "Migrate from Heroku to Fargate"
- "Migrate from Heroku to Elastic Beanstalk"
Expand All @@ -90,6 +91,8 @@ After installation, just describe what you want to migrate:

The skill creates a `.migration/<session>/` directory in the current working directory with all artifacts.

**Live Heroku discovery — how it works:** No Terraform or exports needed. If `heroku login` works in your terminal, just ask — the agent requests your consent, then inventories your account using read-only list/info CLI commands. It captures app names, dyno types, add-on plans and prices, domains, pipelines, and config var **key names only**. It never reads config var values, credentials, or your API token, and never runs a command that creates, changes, or deletes anything. If you also have `heroku_*` Terraform, the agent cross-checks it against your live account and reports drift.

## What It Detects

### GCP → AWS
Expand Down Expand Up @@ -164,9 +167,9 @@ The skill creates a `.migration/<session>/` directory in the current working dir

- Claude Code >=2.1.29, Codex (latest), or [Cursor >= 2.5](https://cursor.com/changelog/2-5)
- AWS CLI configured with appropriate credentials
- At least one input source: Terraform files, application code, or billing data
- At least one input source: an authenticated Heroku CLI (Heroku migrations), Terraform files, application code, or billing data
- **For GCP AI/agentic migration:** Application source code is required (billing/IaC alone cannot detect agent architecture)
- **For Heroku migration:** Terraform files with `heroku_*` resources are required (Procfile/app.json supplements but cannot stand alone)
- **For Heroku migration:** an authenticated Heroku CLI (recommended — live, read-only discovery with your consent) or Terraform files with `heroku_*` resources (Procfile/app.json supplements but cannot stand alone). When both are available, live data is authoritative for current state and Terraform drift is surfaced.

## Structure

Expand Down
20 changes: 16 additions & 4 deletions migrate/plugins/migration-to-aws/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ AI agent skills for migrating workloads to AWS, built for [Claude Code](https://

## What This Does

Point this plugin at your Terraform files, application code, or billing data. It runs a structured 6-phase assessment — discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.
Point this plugin at your Heroku account (via your authenticated Heroku CLI, read-only and consent-gated), your Terraform files, application code, or billing data. It runs a structured 6-phase assessment — discovering what you have, asking the right questions, designing the AWS architecture, estimating costs with real pricing data, and generating runnable migration artifacts.

**Supported migration sources:**

Expand Down Expand Up @@ -103,7 +103,7 @@ ln -s "$(pwd)" ~/.cursor/plugins/local/migration-to-aws

### Workflow

1. **Discover** — Scan Terraform files, application code, and/or billing data. Detects infrastructure resources, AI models, agentic frameworks, tools, and orchestration patterns.
1. **Discover** — Scan Terraform files, application code, and/or billing data — or, for Heroku, inventory your account live via the authenticated Heroku CLI (read-only, consent-gated). Detects infrastructure resources, AI models, agentic frameworks, tools, and orchestration patterns.
2. **Clarify** — Ask targeted questions about migration preferences, AI priorities, agentic migration approach, database sizing, and timeline.
3. **Design** — Map source services to AWS equivalents. For AI workloads: select Bedrock models with honest pricing comparison. For agentic workloads: design AgentCore Harness config or Strands architecture.
4. **Estimate** — Calculate monthly AWS costs using real-time pricing data. Compare to current spend.
Expand Down Expand Up @@ -198,9 +198,21 @@ The `--json` verdict lists each violation with `file`, `line`, `rule`, and `fix_

- Claude Code >=2.1.29, Codex (latest), or [Cursor >= 2.5](https://cursor.com/changelog/2-5)
- AWS CLI configured with appropriate credentials
- At least one input source: Terraform files, application code, or billing data
- At least one input source: an authenticated Heroku CLI (Heroku migrations), Terraform files, application code, or billing data
- **For GCP AI/agentic migration:** Application source code is required (billing/IaC alone cannot detect agent architecture)
- **For Heroku migration:** Terraform files with `heroku_*` resources are required (Procfile/app.json supplements but cannot stand alone)
- **For Heroku migration:** an authenticated Heroku CLI (recommended) or Terraform files with `heroku_*` resources (Procfile/app.json supplements but cannot stand alone)

### Live Heroku discovery — how it works

No Terraform or exports needed. If `heroku login` works in your terminal, just ask
your agent to migrate ("Migrate my Heroku app to AWS" or "Discover my Heroku apps
and estimate AWS costs"). The agent asks for your consent, then inventories your
account using read-only list/info CLI commands — it captures app names, dyno types,
add-on plans and prices, domains, pipelines, and config var **key names only**. It
never reads config var values, credentials, or your API token, and never runs a
command that creates, changes, or deletes anything. If you also have `heroku_*`
Terraform, the agent cross-checks it against your live account and reports drift.

- **For AI execution (llm-to-bedrock skill):** Python 3.10+, `uv`, and Bedrock model access enabled
- **`uvx` required for cost estimation:** The `awspricing` MCP server runs via [`uvx`](https://docs.astral.sh/uv/guides/tools/) (part of the `uv` Python package manager). Install with `pip install uv` or `brew install uv`. Without it, the Estimate phase falls back to cached pricing — migration still works but live pricing lookups are unavailable.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Heroku live-discovery fixtures (replay mode)

Canned Heroku CLI outputs for testing the `heroku-to-aws` live-discovery path
(`discover-live-capture.md` → `discover-live.md` → `discover-assemble.md`) without
a Heroku account. The fake account is deliberately small but exercises every
designed-for behavior:

- **acme-web** — production app: 2× Standard-2X web + 1× Standard-1X worker,
Postgres `standard-2` (42.3 GB — large enough to matter for migration-tool
selection), Redis `premium-0` (HA + TLS), Papertrail, one custom domain, and a
config var list whose keys imply an AI workload (`OPENAI_API_KEY`).
- **acme-staging** — small staging app on `essential-0`.
- **acme-data-team** — a team app the captured account cannot read: every per-app
capture is `failed` with a 403 in `manifest.json`. Expected result:
`discovery_status: "discovery_failed"`, confidence `reduced`, run continues.
- **kafka** capture is `skipped` (plugin not installed) — expected to be a
warning, never a halt.
- `spaces.json` is empty — the common startup case.

## How to replay

**Scenario A — live-only (no Terraform):**

1. Create a scratch directory containing NO `.tf` files.
2. Create `.migration/0715-1820/` and copy `live-capture/` into it.
3. Invoke the heroku-to-aws skill ("migrate my Heroku app to AWS").
4. The Discover phase's source precondition passes via the manifest; the `live`
fragment parses the captures. Expect an inventory with `discovery_sources:
["live"]`, 2 successful apps + 1 failed, and no `drift` key.

**Scenario B — live + Terraform (drift exercise):**

1. As above, but also copy `workspace-terraform/heroku.tf` into the scratch
directory root.
2. `heroku.tf` is deliberately stale — each divergence is commented with the
merge rule it exercises (config conflicts, plan change, terraform-only,
live-only, scaled-to-zero gap-fill).
3. Check the assembled `heroku-resource-inventory.json` against
`expected-drift.json` — machine-checkable via
`python3 check_expected_drift.py <run-dir>` (exits non-zero on any failed
assertion, including secret-hygiene checks for config-var values).

**What a run must never produce** (from either scenario):

- Config var values anywhere (fixture keys like `STRIPE_SECRET_KEY` are key
NAMES; if a value shows up, the keys-only rule broke)
- Clustering fields (`cluster_id`, `edges`, `dependencies`, ...)
- A halt due to the 403 app or the skipped kafka capture

## Regenerating / extending

Captures follow the exact command whitelist in `discover-live-capture.md` Step 3
(same filenames, `.out` extension for text captures). If you add a capture type,
add its row to the whitelist first, then the fixture, then extend
`expected-drift.json`. All IDs, names, and emails are synthetic.
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Assert a Discover run's output against expected-drift.json (scenario B).

Usage:
python3 check_expected_drift.py <migration_run_dir>

Where <migration_run_dir> contains the heroku-resource-inventory.json produced
by a replay of this fixture's scenario B (live-capture/ + workspace-terraform/
heroku.tf). Exits 0 on PASS, 1 on FAIL with one line per failed assertion.
Stdlib only. (Same pattern as the gcp-live-capture asserter.)
"""

import json
import sys
from pathlib import Path

FAILS: list[str] = []


def check(cond: bool, msg: str) -> None:
if not cond:
FAILS.append(msg)


def main() -> int:
if len(sys.argv) != 2:
print(__doc__)
return 2
run_dir = Path(sys.argv[1])
fixture_dir = Path(__file__).resolve().parent

inv = json.loads((run_dir / "heroku-resource-inventory.json").read_text())
exp = json.loads((fixture_dir / "expected-drift.json").read_text())

# Metadata
meta = inv["metadata"]
check(meta["total_apps_discovered"] == exp["metadata"]["total_apps_discovered"], "total_apps_discovered")
for s in exp["metadata"]["discovery_sources_must_include"]:
check(s in meta.get("discovery_sources", []), f"discovery_sources missing {s}")
check(meta.get("confidence") == exp["metadata"]["confidence"], f"confidence={meta.get('confidence')}")

# Apps
apps = {a["app_name"]: a for a in inv["apps"]}
for name, e in exp["apps"].items():
a = apps.get(name)
if a is None:
check(False, f"missing app {name}")
continue
for k in ("discovery_status", "heroku_generation", "app_id"):
if k in e:
check(a.get(k) == e[k], f"app {name} {k}={a.get(k)} want {e[k]}")
if "failure_reason_contains" in e:
check(e["failure_reason_contains"] in (a.get("failure_reason") or ""), f"app {name} failure_reason")

# Resources
res = {r["resource_id"]: r for r in inv["resources"]}
for rid, e in exp["merged_resources"].items():
r = res.get(rid)
if r is None:
check(False, f"missing resource {rid}")
continue
if "source" in e:
check(r.get("source") == e["source"], f"{rid} source={r.get('source')} want {e['source']}")
if e.get("unmanaged_by_terraform"):
check(r.get("unmanaged_by_terraform") is True, f"{rid} unmanaged flag")
if e.get("not_found_live"):
check(r.get("not_found_live") is True, f"{rid} not_found_live flag")
for flag in e.get("must_not_have", []):
check(r.get(flag) is not True, f"{rid} must not have {flag}")
for k, v in e.get("config", {}).items():
if k == "config_var_keys_count":
check(len(r["config"].get("config_var_keys", [])) == v, f"{rid} key count")
elif k == "stages":
check(r["config"].get("stages") == v, f"{rid} stages")
else:
check(r["config"].get(k) == v, f"{rid} config.{k}={r['config'].get(k)} want {v}")
if "expected_config_conflicts" in e:
conflict_fields = [
c["field"] for c in inv.get("live_metadata", {}).get("drift", {}).get("config_conflicts", [])
if c.get("resource_id") == rid
]
for f in e["expected_config_conflicts"]:
check(f in conflict_fields, f"{rid} missing config conflict on {f}")

# live_metadata + drift
lm = inv["live_metadata"]
check(lm.get("apps_captured") == exp["live_metadata"]["apps_captured"], "apps_captured")
check(lm.get("apps_failed") == exp["live_metadata"]["apps_failed"], "apps_failed")
lim = json.dumps(lm.get("limitations", []))
check(exp["live_metadata"]["limitations_must_include"] in lim, "limitations missing scaled-to-zero note")
drift = lm.get("drift", {})
check(
drift.get("resources_live_only", 0) >= exp["live_metadata"]["drift"]["resources_live_only_min"],
f"resources_live_only={drift.get('resources_live_only')}",
)
check(
drift.get("resources_terraform_only") == exp["live_metadata"]["drift"]["resources_terraform_only"],
f"resources_terraform_only={drift.get('resources_terraform_only')}",
)
conflict_fields = sorted(c["field"] for c in drift.get("config_conflicts", []))
check(
conflict_fields == sorted(exp["live_metadata"]["drift"]["config_conflicts_expected_fields"]),
f"conflict fields {conflict_fields}",
)

# Must-not-exist / secret hygiene
doc = json.dumps(inv)
check("domain:acme-web:acme-web-1a2b3c4d5e6f" not in doc, "default herokuapp domain leaked as resource")
for bad in ("cluster_id", "creation_order_depth", "must_migrate_together", '"edges"', '"dependencies"'):
check(bad not in doc, f"forbidden clustering field {bad}")
for bad in ("sk_live", "postgres://", "rediss://", "AKIA", "Bearer "):
check(bad not in doc, f"possible secret value: {bad}")

def walk(node, path="$"):
if isinstance(node, dict):
if "name" in node and ("value" in node or "valueFrom" in node):
check(False, f"env-like object with a value payload at {path}")
for k, v in node.items():
if k == "config_vars":
check(False, f"raw config_vars at {path} — only config_var_keys (names) allowed")
walk(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
walk(v, f"{path}[{i}]")

walk(inv)
for key_name in ("STRIPE_SECRET_KEY", "DATABASE_URL", "SESSION_SECRET"):
check(f'"{key_name}": ' not in doc, f"config var name {key_name} appears as a KEY (value paired)")

if FAILS:
print(f"FAIL ({len(FAILS)}):")
for f in FAILS:
print(f" - {f}")
return 1
print("PASS — expected-drift.json assertions hold")
return 0


if __name__ == "__main__":
sys.exit(main())
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"_comment": "Expected merge/drift outcomes when BOTH workspace-terraform/ and live-capture/ are used (scenario B in README.md). Not a full inventory — these are the assertions an eval run checks against heroku-resource-inventory.json.",
"metadata": {
"total_apps_discovered": 3,
"discovery_sources_must_include": ["terraform", "live"],
"confidence": "reduced",
"confidence_reason": "acme-data-team captures failed (403)"
},
"apps": {
"acme-web": {
"discovery_status": "success",
"heroku_generation": "cedar",
"app_id": "a1b2c3d4-0001-4a5b-8c6d-0123456789ab"
},
"acme-staging": { "discovery_status": "success", "heroku_generation": "cedar", "unmanaged_by_terraform": true },
"acme-data-team": { "discovery_status": "discovery_failed", "failure_reason_contains": "403" }
},
"merged_resources": {
"formation:acme-web:web": {
"source": "live+terraform",
"config": { "quantity": 2, "dyno_type": "standard-2x", "command": "npm start" },
"expected_config_conflicts": ["quantity", "dyno_type"]
},
"formation:acme-web:release": {
"source": "terraform",
"config": { "quantity": 0 },
"note": "gap-fill rule 4 — scaled to zero, invisible live, NOT drift"
},
"addon:acme-web:heroku-postgresql:standard-2": {
"source": "live+terraform",
"expected_config_conflicts": ["plan"],
"note": "rule 1a treats the plan change (tf standard-0 vs live standard-2) as ONE merged resource with a plan conflict, so source is live+terraform per rule 1",
"config": { "data_size_gb": 42.3, "pg_version": "16.4", "connection_pooling": true, "monthly_price_usd": 200.0 }
},
"addon:acme-web:scheduler:standard": { "source": "terraform", "not_found_live": true },
"addon:acme-web:heroku-redis:premium-0": {
"source": "live",
"unmanaged_by_terraform": true,
"config": {
"ha_enabled": true,
"encryption_in_transit": true,
"redis_version": "7.2.5",
"maxmemory_policy": "noeviction",
"monthly_price_usd": 15.0
}
},
"addon:acme-web:papertrail:choklad": { "source": "live", "unmanaged_by_terraform": true },
"addon:acme-staging:heroku-postgresql:essential-0": {
"source": "live",
"unmanaged_by_terraform": true,
"config": { "data_size_gb": 0.4 }
},
"formation:acme-staging:web": {
"source": "live",
"unmanaged_by_terraform": true,
"config": { "dyno_type": "basic", "quantity": 1 }
},
"config:acme-web": { "config": { "config_var_keys_count": 9 }, "must_not_contain_values": true },
"domain:acme-web:www.acme-demo.com": { "source": "live" },
"pipeline:acme": {
"source": "live",
"config": {
"stages": [{ "stage": "staging", "app": "acme-staging" }, { "stage": "production", "app": "acme-web" }]
}
}
},
"must_not_exist": [
"domain:acme-web:acme-web-1a2b3c4d5e6f.herokuapp.com (default herokuapp.com domains are counted, not resources)",
"any resource with cluster_id / edges / dependencies fields",
"any config var VALUE anywhere in the document"
],
"live_metadata": {
"apps_captured": 2,
"apps_failed": 1,
"limitations_must_include": "formations scaled to zero are not visible to live discovery",
"drift": {
"resources_live_only_min": 5,
"resources_terraform_only": 1,
"config_conflicts_expected_fields": ["quantity", "dyno_type", "plan"]
}
}
}
Loading
Loading