diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/README.md b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/README.md index 879e1fbd..9cf6b4b4 100644 --- a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/README.md +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/README.md @@ -40,6 +40,23 @@ designed-for behavior: `python3 check_expected_drift.py ` (exits non-zero on any failed assertion, including secret-hygiene checks for config-var values). +**Scenario C — Estimate baseline from live prices (no billing data):** + +1. Create a scratch directory with `.migration/0715-1820/` containing the three + artifacts from `seed-estimate/` (`heroku-resource-inventory.json`, + `preferences.json`, `aws-design.json`) plus `seed-estimate/.phase-status.json` + (discover/clarify/design completed, estimate in progress). +2. Invoke the heroku-to-aws skill and resume the run — the Estimate phase starts. +3. The inventory has NO `billing_profile`, but its live-discovered add-ons carry + `config.monthly_price_usd`. Expect a `current_costs.source: + "live_prices_plus_cache"` baseline of exactly **$352/month** (add-ons $220 + exact + $0 scheduler from cache + dynos $132 from cache), the mandatory + derived-baseline caveat, and a full `cost_comparison` + + `migration_cost_considerations` — the comparison must NOT be gated on billing + data. +4. Check the produced `estimation-infra.json` against `expected-estimate.json` — + machine-checkable via `python3 check_expected_estimate.py `. + **What a run must never produce** (from either scenario): - Config var values anywhere (fixture keys like `STRIPE_SECRET_KEY` are key diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/check_expected_estimate.py b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/check_expected_estimate.py new file mode 100644 index 00000000..3bbd373c --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/check_expected_estimate.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Assert an Estimate run's output against expected-estimate.json (scenario C). + +Usage: + python3 check_expected_estimate.py + +Where contains the estimation-infra.json produced by a +replay seeded from seed-estimate/ (live-discovered inventory, NO billing data). +Verifies the live_prices_plus_cache baseline: exact add-on prices + cache dyno +rates = 352.0/month, honest derived-baseline caveat, comparison and migration +considerations unlocked for the derived source. Exits 0 on PASS, 1 on FAIL with +one line per failed assertion. Stdlib only. +""" + +import json +import math +import sys +from pathlib import Path + +FAILS: list[str] = [] +TOL = 0.01 # exact math expected; tolerance only for float representation + + +def check(cond: bool, msg: str) -> None: + if not cond: + FAILS.append(msg) + + +def close(a, b) -> bool: + return isinstance(a, (int, float)) and not isinstance(a, bool) and math.isclose(a, b, abs_tol=TOL) + + +def find_number(node, target): + """True if `target` appears as a numeric value anywhere under node.""" + if isinstance(node, dict): + return any(find_number(v, target) for v in node.values()) + if isinstance(node, list): + return any(find_number(v, target) for v in node) + return close(node, target) + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + run_dir = Path(sys.argv[1]) + fixture_dir = Path(__file__).resolve().parent + + est = json.loads((run_dir / "estimation-infra.json").read_text()) + exp = json.loads((fixture_dir / "expected-estimate.json").read_text()) + doc = json.dumps(est) + + # --- Baseline (Part 1, rung 2: live prices + dyno cache) --- + b = exp["baseline"] + cur = est.get("current_costs", {}) + check(cur.get("source") == b["source"], f"current_costs.source={cur.get('source')} want {b['source']}") + check( + find_number(cur, b["monthly_total"]), + f"current_costs has no numeric total == {b['monthly_total']} (exact add-on + cache dyno math)", + ) + note = str(cur.get("baseline_note") or "") + check(bool(note.strip()), "baseline_note missing/empty — the derived-baseline caveat is mandatory") + for word in b["baseline_note_must_mention"]: + check(word.lower() in note.lower(), f"baseline_note does not mention '{word}'") + if b.get("must_not_fabricate_billing"): + check(cur.get("source") != "billing_data", "source claims billing_data with no billing_profile in inventory") + bp = est.get("billing_profile") + check(not bp, "estimation-infra.json fabricated a billing_profile") + + # --- Cost comparison (Part 3 — must run for the derived source) --- + c = exp["cost_comparison"] + comp = est.get("cost_comparison") + check(isinstance(comp, dict) and bool(comp), "cost_comparison absent — comparison must run for ANY baseline source") + if isinstance(comp, dict): + check( + close(comp.get("heroku_monthly_baseline"), c["heroku_monthly_baseline"]), + f"cost_comparison.heroku_monthly_baseline={comp.get('heroku_monthly_baseline')} want {c['heroku_monthly_baseline']}", + ) + check( + comp.get("baseline_source") == c["baseline_source"], + f"cost_comparison.baseline_source={comp.get('baseline_source')}", + ) + for opt in c["required_options"]: + o = comp.get(opt) + if not isinstance(o, dict): + check(False, f"cost_comparison.{opt} missing") + continue + for f in c["required_option_fields"]: + check(f in o, f"cost_comparison.{opt}.{f} missing") + aws = o.get("aws_monthly") + diff = o.get("monthly_difference") + if isinstance(aws, (int, float)) and isinstance(diff, (int, float)): + check( + close(diff, aws - c["heroku_monthly_baseline"]), + f"cost_comparison.{opt} monthly_difference {diff} != {aws} - {c['heroku_monthly_baseline']}", + ) + ann = o.get("annual_difference") + if isinstance(diff, (int, float)) and isinstance(ann, (int, float)): + check(close(ann, diff * 12), f"cost_comparison.{opt} annual_difference {ann} != 12 x {diff}") + + # --- Migration cost considerations (Part 4 — keyed off baseline presence) --- + m = exp["migration_cost_considerations"] + mig = est.get("migration_cost_considerations", {}) + check( + mig.get("baseline_available") is m["baseline_available"], + f"migration_cost_considerations.baseline_available={mig.get('baseline_available')}", + ) + check( + mig.get("baseline_source") == m["baseline_source"], + f"migration_cost_considerations.baseline_source={mig.get('baseline_source')}", + ) + check( + len(mig.get("categories", [])) >= m["categories_min"], + "migration_cost_considerations.categories empty — dual-run cost must be priced from the derived baseline", + ) + + # --- Projected costs (Property-16 invariant + design coverage) --- + proj = est.get("projected_costs", {}) + balanced = proj.get("aws_monthly_balanced") + check( + isinstance(balanced, (int, float)) and balanced > 0, + f"projected_costs.aws_monthly_balanced={balanced} not a positive number", + ) + breakdown = proj.get("breakdown", {}) + if exp["projected_costs"]["balanced_equals_breakdown_sum"] and isinstance(breakdown, dict) and breakdown: + + def entry_cost(v): + if isinstance(v, (int, float)) and not isinstance(v, bool): + return v + if isinstance(v, dict): + for k in ("balanced", "mid", "monthly", "monthly_cost", "cost"): + if isinstance(v.get(k), (int, float)) and not isinstance(v.get(k), bool): + return v[k] + return None + + costs = [entry_cost(v) for v in breakdown.values()] + if all(c is not None for c in costs) and isinstance(balanced, (int, float)): + total = sum(costs) + check( + math.isclose(total, balanced, abs_tol=max(0.02 * balanced, 1.0)), + f"balanced total {balanced} != breakdown sum {round(total, 2)} (Property-16)", + ) + else: + check(False, "breakdown entries lack a recognizable balanced/monthly cost field") + + if exp["projected_costs"]["every_design_service_priced_or_warned"]: + design = json.loads((run_dir / "aws-design.json").read_text()) + warnings_txt = json.dumps(est.get("warnings", [])) + json.dumps(est.get("pricing_source", {})) + for svc in design.get("services", []): + sid = svc.get("service_id", "") + name = svc.get("aws_service", "") + covered = sid in doc or name in doc or sid in warnings_txt + check(covered, f"design service {sid} neither in the cost breakdown nor warned as unpriced") + + # --- Warnings hygiene --- + warn_doc = json.dumps(est.get("warnings", [])) + for bad in exp["warnings_must_not_contain"]: + check(bad not in warn_doc, f"warnings contain '{bad}' — everything in this scenario is priceable") + + # --- Must-not-exist / secret hygiene --- + check('"billing_period"' not in doc, "billing_period present — no invoice data exists in this scenario") + for bad in ("sk_live", "postgres://", "rediss://", "AKIA", "Bearer "): + check(bad not in doc, f"possible secret value: {bad}") + + if FAILS: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + return 1 + print("PASS — expected-estimate.json assertions hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/expected-estimate.json b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/expected-estimate.json new file mode 100644 index 00000000..7705ad6b --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/expected-estimate.json @@ -0,0 +1,31 @@ +{ + "_comment": "Expected Estimate-phase outcomes for scenario C (seed-estimate/ inputs: live-discovered inventory WITHOUT billing_profile). Asserts the live_prices_plus_cache baseline rung: add-ons priced exactly from config.monthly_price_usd, dynos from heroku-pricing-cache.md, mandatory derived-baseline caveat, comparison + migration considerations present for the derived source. Checked by check_expected_estimate.py.", + "baseline": { + "source": "live_prices_plus_cache", + "monthly_total": 352.0, + "_math": "add-ons exact from live prices: 200 (pg standard-2) + 15 (redis premium-0) + 0 (papertrail choklad) + 5 (pg essential-0) = 220; scheduler:standard has no live price (terraform-only) and falls through to the cache = 0; dynos from cache: 2x standard-2x (50) + 1x standard-1x (25) + 1x basic (7) = 132; release formation quantity 0 = 0. Total 352.", + "baseline_note_must_mention": ["invoice", "usage"], + "must_not_fabricate_billing": true + }, + "cost_comparison": { + "heroku_monthly_baseline": 352.0, + "baseline_source": "live_prices_plus_cache", + "required_options": ["option_a_premium", "option_b_balanced", "option_c_optimized"], + "required_option_fields": ["aws_monthly", "monthly_difference", "annual_difference", "percent_change"] + }, + "migration_cost_considerations": { + "baseline_available": true, + "baseline_source": "live_prices_plus_cache", + "categories_min": 1 + }, + "projected_costs": { + "balanced_positive": true, + "balanced_equals_breakdown_sum": true, + "every_design_service_priced_or_warned": true + }, + "warnings_must_not_contain": ["unpriced_heroku"], + "must_not_exist_anywhere": [ + "billing_period (no invoice data exists in this scenario)", + "any config var VALUE (STRIPE_SECRET_KEY etc. are key names only)" + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/.phase-status.json new file mode 100644 index 00000000..8ebeb3d0 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/.phase-status.json @@ -0,0 +1,13 @@ +{ + "migration_id": "0715-1820", + "last_updated": "2026-07-15T18:36:00Z", + "current_phase": "estimate", + "phases": { + "discover": "completed", + "clarify": "completed", + "design": "completed", + "estimate": "in_progress", + "generate": "pending", + "feedback": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/aws-design.json b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/aws-design.json new file mode 100644 index 00000000..734631ce --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/aws-design.json @@ -0,0 +1,164 @@ +{ + "_comment": "Seed design output for the Estimate-phase replay (scenario C). Hand-assembled by following design-mapping.md rules against seed-estimate/heroku-resource-inventory.json with the seed preferences (EB default target, multi-az). Every entry is priceable from aws-infra-pricing.json so the replay needs zero MCP calls.", + "phase": "design", + "design_source": "terraform+live", + "timestamp": "2026-07-15T18:35:00Z", + "metadata": { + "total_services": 9, + "total_apps_migrated": 2, + "fir_workloads_detected": [], + "fir_generation_note": "" + }, + "services": [ + { + "service_id": "eb:acme-web:web", + "source_resource_id": "formation:acme-web:web", + "heroku_app": "acme-web", + "aws_service": "Elastic Beanstalk", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "platform": "Docker running on 64bit Amazon Linux 2023", + "instance_type": "t3.medium", + "environment_type": "LoadBalanced", + "tier": "WebServer", + "min_instances": 1, + "max_instances": 2, + "process_type": "web", + "deployment_policy": "Rolling" + } + }, + { + "service_id": "eb:acme-web:worker", + "source_resource_id": "formation:acme-web:worker", + "heroku_app": "acme-web", + "aws_service": "Elastic Beanstalk", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "platform": "Docker running on 64bit Amazon Linux 2023", + "instance_type": "t3.small", + "environment_type": "SingleInstance", + "tier": "WebServer", + "min_instances": 1, + "max_instances": 1, + "process_type": "worker", + "deployment_policy": "Rolling" + } + }, + { + "service_id": "eb:acme-staging:web", + "source_resource_id": "formation:acme-staging:web", + "heroku_app": "acme-staging", + "aws_service": "Elastic Beanstalk", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "platform": "Docker running on 64bit Amazon Linux 2023", + "instance_type": "t3.micro", + "environment_type": "LoadBalanced", + "tier": "WebServer", + "min_instances": 1, + "max_instances": 2, + "process_type": "web", + "deployment_policy": "Rolling" + } + }, + { + "service_id": "rds:acme-web:postgres", + "source_resource_id": "addon:acme-web:heroku-postgresql:standard-2", + "heroku_app": "acme-web", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.m6g.large", + "multi_az": true, + "storage_gb": 256, + "engine_version": "15", + "rds_proxy": true + } + }, + { + "service_id": "rds:acme-staging:postgres", + "source_resource_id": "addon:acme-staging:heroku-postgresql:essential-0", + "heroku_app": "acme-staging", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.t4g.micro", + "multi_az": true, + "storage_gb": 1, + "engine_version": "15", + "rds_proxy": false + } + }, + { + "service_id": "elasticache:acme-web:redis", + "source_resource_id": "addon:acme-web:heroku-redis:premium-0", + "heroku_app": "acme-web", + "aws_service": "ElastiCache Redis", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "node_type": "cache.t4g.micro", + "multi_az": true, + "automatic_failover": true, + "transit_encryption": true, + "engine_version": "7.0" + } + }, + { + "service_id": "cloudwatch_logs:acme-web:papertrail", + "source_resource_id": "addon:acme-web:papertrail:choklad", + "heroku_app": "acme-web", + "aws_service": "CloudWatch Logs", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "log_group": "/heroku/acme-web", + "retention_days": 30 + } + }, + { + "service_id": "eventbridge_scheduler:acme-web:scheduler", + "source_resource_id": "addon:acme-web:scheduler:standard", + "heroku_app": "acme-web", + "aws_service": "EventBridge Scheduler", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "schedule_count_estimate": 3 + } + }, + { + "service_id": "secrets_manager:acme-web:config", + "source_resource_id": "config:acme-web", + "heroku_app": "acme-web", + "aws_service": "Secrets Manager", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "secret_count": 9 + } + } + ], + "deferred": [ + { + "source_resource_id": "pipeline:acme", + "reason": "Pipeline detected (staging -> production). Detect-only in v1; recreate as separate environments/CI-CD post-migration." + } + ], + "warnings": [ + "Heroku release process `acme-web:release` is a run-once deployment hook and was not mapped to persistent AWS compute. Implement it as a deployment hook or manual migration step.", + "Custom domain www.acme-demo.com uses manual DNS cutover per preferences; no Route 53 hosted zone designed." + ], + "vpc_design": { + "create_new_vpc": true, + "cidr": "10.0.0.0/16", + "az_count": 2, + "nat_gateways": 0, + "note": "EB-managed environments in public subnets with security groups; databases in private subnets reached via EB instance SGs. No NAT gateway required for this design." + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/heroku-resource-inventory.json b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/heroku-resource-inventory.json new file mode 100644 index 00000000..13107c04 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/heroku-resource-inventory.json @@ -0,0 +1,271 @@ +{ + "_comment": "Seed for the Estimate-phase replay (scenario C in README.md). This is the assembled scenario-B inventory (live + stale Terraform, drift merged) — the shape a real Discover run of these fixtures produces. NO billing_profile on purpose: the estimate must build its baseline from the live-captured monthly_price_usd values plus the dyno pricing cache.", + "metadata": { + "discovery_timestamp": "2026-07-15T18:25:00Z", + "total_apps_discovered": 3, + "discovery_sources": ["terraform", "live"], + "confidence": "reduced", + "confidence_note": "acme-data-team captures failed (403 team app); its resources are not in this inventory" + }, + "apps": [ + { + "app_name": "acme-web", + "app_id": "a1b2c3d4-0001-4a5b-8c6d-0123456789ab", + "heroku_generation": "cedar", + "generation_action": "detect_only", + "generation_diagnostics": [], + "space": null, + "discovery_status": "success", + "failure_reason": null, + "procfile_parse_warning": null, + "app_json_parse_warning": null + }, + { + "app_name": "acme-staging", + "app_id": "a1b2c3d4-0002-4a5b-8c6d-0123456789ab", + "heroku_generation": "cedar", + "generation_action": "detect_only", + "generation_diagnostics": [], + "space": null, + "discovery_status": "success", + "failure_reason": null, + "procfile_parse_warning": null, + "app_json_parse_warning": null + }, + { + "app_name": "acme-data-team", + "app_id": "a1b2c3d4-0003-4a5b-8c6d-0123456789ab", + "heroku_generation": "unknown", + "generation_action": "detect_only", + "generation_diagnostics": [], + "space": null, + "discovery_status": "discovery_failed", + "failure_reason": "per-app captures failed: 403 (team app not readable by the captured account)", + "procfile_parse_warning": null, + "app_json_parse_warning": null + } + ], + "resources": [ + { + "resource_id": "formation:acme-web:web", + "resource_type": "formation", + "heroku_app": "acme-web", + "source": "live+terraform", + "config": { + "process_type": "web", + "command": "npm start", + "dyno_type": "standard-2x", + "quantity": 2 + } + }, + { + "resource_id": "formation:acme-web:worker", + "resource_type": "formation", + "heroku_app": "acme-web", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "process_type": "worker", + "command": "node worker.js", + "dyno_type": "standard-1x", + "quantity": 1 + } + }, + { + "resource_id": "formation:acme-web:release", + "resource_type": "formation", + "heroku_app": "acme-web", + "source": "terraform", + "config": { + "process_type": "release", + "command": null, + "dyno_type": "standard-1x", + "quantity": 0 + } + }, + { + "resource_id": "addon:acme-web:heroku-postgresql:standard-2", + "resource_type": "addon", + "heroku_app": "acme-web", + "source": "live+terraform", + "config": { + "addon_service": "heroku-postgresql", + "plan": "standard-2", + "provider": "heroku", + "monthly_price_usd": 200.0, + "pg_version": "16.4", + "data_size_gb": 42.3, + "table_count": 58, + "connection_pooling": true + } + }, + { + "resource_id": "addon:acme-web:scheduler:standard", + "resource_type": "addon", + "heroku_app": "acme-web", + "source": "terraform", + "not_found_live": true, + "config": { + "addon_service": "scheduler", + "plan": "standard", + "provider": "scheduler" + } + }, + { + "resource_id": "addon:acme-web:heroku-redis:premium-0", + "resource_type": "addon", + "heroku_app": "acme-web", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "addon_service": "heroku-redis", + "plan": "premium-0", + "provider": "heroku", + "monthly_price_usd": 15.0, + "redis_version": "7.2.5", + "maxmemory_policy": "noeviction", + "ha_enabled": true, + "encryption_in_transit": true + } + }, + { + "resource_id": "addon:acme-web:papertrail:choklad", + "resource_type": "addon", + "heroku_app": "acme-web", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "addon_service": "papertrail", + "plan": "choklad", + "provider": "papertrail", + "monthly_price_usd": 0.0 + } + }, + { + "resource_id": "addon:acme-staging:heroku-postgresql:essential-0", + "resource_type": "addon", + "heroku_app": "acme-staging", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "addon_service": "heroku-postgresql", + "plan": "essential-0", + "provider": "heroku", + "monthly_price_usd": 5.0, + "pg_version": "16.4", + "data_size_gb": 0.4, + "table_count": 12, + "connection_pooling": false + } + }, + { + "resource_id": "formation:acme-staging:web", + "resource_type": "formation", + "heroku_app": "acme-staging", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "process_type": "web", + "command": "npm start", + "dyno_type": "basic", + "quantity": 1 + } + }, + { + "resource_id": "config:acme-web", + "resource_type": "config", + "heroku_app": "acme-web", + "source": "live", + "config": { + "config_var_keys": [ + "DATABASE_URL", + "LANG", + "NODE_ENV", + "OPENAI_API_KEY", + "REDIS_TLS_URL", + "REDIS_URL", + "SENTRY_DSN", + "SESSION_SECRET", + "STRIPE_SECRET_KEY" + ] + } + }, + { + "resource_id": "config:acme-staging", + "resource_type": "config", + "heroku_app": "acme-staging", + "source": "live", + "config": { + "config_var_keys": ["DATABASE_URL", "LANG", "NODE_ENV", "SESSION_SECRET"] + } + }, + { + "resource_id": "domain:acme-web:www.acme-demo.com", + "resource_type": "domain", + "heroku_app": "acme-web", + "source": "live", + "unmanaged_by_terraform": true, + "config": { + "hostname": "www.acme-demo.com", + "sni_endpoint": "tokyo-1234" + } + }, + { + "resource_id": "pipeline:acme", + "resource_type": "pipeline", + "heroku_app": "unassociated", + "source": "live", + "config": { + "pipeline_name": "acme", + "stages": [ + { "stage": "staging", "app": "acme-staging" }, + { "stage": "production", "app": "acme-web" } + ], + "review_apps_enabled": false, + "detection_status": "detect-only" + } + } + ], + "terraform_metadata": { + "found": true, + "tf_files_scanned": 1, + "resource_types_extracted": ["heroku_app", "heroku_formation", "heroku_addon"], + "parse_warnings": [] + }, + "live_metadata": { + "found": true, + "captured_at": "2026-07-15T18:20:00Z", + "apps_captured": 2, + "apps_failed": 1, + "capture_warnings": [ + "acme-data-team: all per-app captures failed (403)", + "kafka capture skipped: heroku-kafka plugin not installed" + ], + "limitations": ["formations scaled to zero are not visible to live discovery"], + "default_heroku_domains_skipped": 1, + "drift": { + "resources_live_only": 7, + "resources_terraform_only": 1, + "config_conflicts": [ + { + "resource_id": "formation:acme-web:web", + "field": "quantity", + "terraform_value": 1, + "live_value": 2 + }, + { + "resource_id": "formation:acme-web:web", + "field": "dyno_type", + "terraform_value": "standard-1x", + "live_value": "standard-2x" + }, + { + "resource_id": "addon:acme-web:heroku-postgresql:standard-2", + "field": "plan", + "terraform_value": "standard-0", + "live_value": "standard-2" + } + ] + } + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/preferences.json b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/preferences.json new file mode 100644 index 00000000..70371b3c --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-live-capture/seed-estimate/preferences.json @@ -0,0 +1,58 @@ +{ + "_comment": "Seed clarify output for the Estimate-phase replay (scenario C). Values chosen to keep the design deterministic: default EB compute target, multi-az availability, plan-derived DB size from the live capture.", + "migration_id": "0715-1820", + "skill": "heroku-to-aws", + "metadata": { + "timestamp": "2026-07-15T18:30:00Z", + "clarify_mode": "full", + "questions_asked": ["Q1", "Q3", "Q6", "Q12c", "Q15"], + "questions_defaulted": ["Q2", "Q4", "Q5", "Q6b", "Q6c", "Q7", "Q10", "Q12", "Q12b", "Q13", "Q14"], + "questions_skipped_not_applicable": ["Q8", "Q9", "Q11"] + }, + "global": { + "target_region": "us-east-1", + "compliance": "none", + "availability": "multi-az", + "maintenance_window": "sun:04:00-sun:06:00", + "environment_naming": "production", + "migration_approach": "all_at_once", + "interim_cutover": false, + "target_exit_date": null, + "ktlo_warning": null, + "fir_intent": null + }, + "data": { + "database_ha": "multi-az", + "migration_method": "pg_dump_restore", + "estimated_db_size_gb": 42.3, + "db_size_source": "plan_derived", + "redis_ha": "multi-az", + "kafka_retention_days": null, + "dns_strategy": "manual_cutover" + }, + "network": { + "existing_vpc_id": null, + "subnet_ids": [], + "private_space_detected": false + }, + "operational": { + "container_registry": "ecr", + "containerization_status": "dockerfile_present", + "log_retention_days": 30, + "alerting": "basic", + "cost_optimization": "balanced" + }, + "design_constraints": { + "compute_target": { + "default": "elastic_beanstalk", + "overrides": [], + "chosen_by": "default", + "recommendation": { + "value": "elastic_beanstalk", + "confidence": "high", + "reasons": ["Dockerfile present; small formation counts fit EB managed platform"] + } + }, + "eb_deploy_method": { "value": "container", "chosen_by": "default" } + } +} diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-assemble.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-assemble.md index 5b52e8b3..b75ddd37 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-assemble.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-assemble.md @@ -45,6 +45,12 @@ any single cost-engine Part; the schema leaves its shape open): } ``` +Sign convention: savings = Heroku minus AWS (positive = you save by migrating). +This is deliberately the OPPOSITE sign of +`roi_analysis.recurring_savings.monthly_difference_*` (difference = AWS minus +Heroku) — same fact, savings-vs-difference framing. When presenting either, +always label the direction in words; never print a bare signed value. + Write to `$MIGRATION_DIR/estimation-infra.json`. --- @@ -69,7 +75,7 @@ of the `_validate_json` postcondition. After writing `estimation-infra.json`, present a concise summary to the user: 1. **Pricing source and accuracy** — State cache age and accuracy range -2. **Heroku baseline vs AWS projected** (balanced tier) — one-line comparison (if billing available) +2. **Heroku baseline vs AWS projected** (balanced tier) — one-line comparison (if a baseline was determined, labeled with its source; include the derived-baseline caveat when the source is not billing data) 3. **Three-tier table**: Premium, Balanced, Optimized with monthly totals - Premium: _Highest resilience / highest monthly estimate_ - Balanced: _Default scenario; compare Heroku to this first_ @@ -77,7 +83,7 @@ After writing `estimation-infra.json`, present a concise summary to the user: - One-line note: Three figures are pricing scenarios for the same architecture (not three Terraform stacks). Generated Terraform aligns with Balanced. 4. **Per-service cost breakdown** (balanced tier, 1 line per service) 5. **Migration complexity**: tier + timeline range -6. **Monthly and annual savings** (or increase) vs Heroku per tier (if comparison available) +6. **Monthly and annual savings** (or increase) vs Heroku per tier (if a baseline was determined) 7. **Top 2-3 optimization opportunities** with savings potential 8. **Recommendation**: `path_label` with one-line justification diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-cost-engine.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-cost-engine.md index 8e41955b..ba6f5493 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-cost-engine.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate-cost-engine.md @@ -49,12 +49,20 @@ Attempt to reach awspricing MCP with **up to 2 retries** (3 total attempts, 10-s ### Pricing Hierarchy (per-service lookup order) -| Priority | Source | Condition | `pricing_source` value | -| -------- | ---------------------------------------------------- | -------------------------------------------- | ---------------------- | -| 1 | `references/vendored/pricing/aws-infra-pricing.json` | Service found in the pricing file | `"cached"` | -| 2 | MCP API (`get_pricing`) | Service NOT in the file, MCP available | `"live"` | -| 3 | Pricing file after MCP failure | MCP attempted but failed, service IS in file | `"cached_fallback"` | -| 4 | Unavailable | NOT in file AND MCP failed | `"unavailable"` | +| Priority | Source | Condition | `pricing_source` value | +| -------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- | +| 1 | `references/vendored/pricing/aws-infra-pricing.json` | Service found in the pricing file | `"cached"` | +| 2 | MCP API (`get_pricing`) | Service NOT in the file, MCP available | `"live"` | +| 3 | Pricing file after MCP failure | MCP attempted but failed, service IS in file | `"cached_fallback"` | +| 4 | Formula constants / well-known published rate | NOT in file, MCP failed, but this file's own formulas carry the rate (state it verbatim) | `"estimated"` | +| 5 | Unavailable | NOT in file, MCP failed, no formula constant either | `"unavailable"` | + +Row 4 is the documented home of the `services_by_source.estimated` bucket the +schema and assembler already carry: a service priced from a rate this file +itself states (never a guessed or remembered number) is `"estimated"`, always +accompanied by a warning naming the rate and its source. Only a service with +no cache entry, no MCP, AND no stated formula rate is `"unavailable"` and +excluded from totals. For typical Heroku migrations (Elastic Beanstalk, Fargate, RDS, Aurora, ElastiCache, ALB, NAT Gateway, S3, CloudWatch, Secrets Manager, EventBridge, SES, OpenSearch, MQ), ALL prices are in `aws-infra-pricing.json`. Zero MCP calls needed. @@ -82,7 +90,30 @@ Use the best available source for Heroku monthly baseline (first match wins): - Extract `billing_profile.line_items[]` for per-app breakdown - Set `current_costs.source: "billing_data"` -2. **Heroku pricing cache** — If no billing data, Load `references/shared/heroku-pricing-cache.md` and derive costs from discovered resources: +2. **Live-captured prices + dyno cache** — If no billing data AND at least one + add-on resource carries `config.monthly_price_usd` (the account's actual + billed plan rates from the Platform API capture). The gate is the presence of + live prices, not merely that live discovery ran: a live run that captured + ZERO priced add-ons (e.g. a dyno-only app) has nothing live-priced in it — + fall to rung 3, whose `pricing_cache` label and ±5% accuracy describe that + baseline honestly. + - **Add-ons:** sum `config.monthly_price_usd` across add-on resources — these + are exact, and they price plans the cache has never heard of (no + `"unpriced_heroku"` holes for priced add-ons) + - **Dynos:** the API does not price formations; look up each formation's + `dyno_type` in `references/shared/heroku-pricing-cache.md` (±5% published + flat rates) × `quantity` + - Set `current_costs.source: "live_prices_plus_cache"` + - Set `current_costs.accuracy: "exact for add-ons, ±5% for dynos"` + - An add-on WITHOUT `monthly_price_usd` (e.g. a Terraform-only entry) falls + through to the rung-3 cache lookup for that resource only; if neither + prices it, mark `"unpriced_heroku"` and add to warnings + - `baseline_note` (mandatory): "Derived from your account's actual add-on + prices plus published dyno rates — not an invoice. Excludes usage-based + charges (bandwidth, build minutes), team seats, credits, and discounts; + your invoice may differ." + +3. **Heroku pricing cache** — If no billing data and no live-captured prices, Load `references/shared/heroku-pricing-cache.md` and derive costs from discovered resources: - For each resource in inventory, look up its plan in the cache tables (case-insensitive exact match) - Multiply dyno costs by `formation.quantity` - Sum all matched resources to get `heroku_monthly_estimated` @@ -90,18 +121,18 @@ Use the best available source for Heroku monthly baseline (first match wins): - Set `current_costs.accuracy: "±5%"` - If any resource plan is not found in cache, mark as `"unpriced_heroku"` and exclude from total; add to warnings -3. **User-provided** — If pricing cache produces zero matched resources (unlikely with Terraform discovery), ask: "I need your current Heroku monthly spend to produce a meaningful cost comparison. What is your approximate Heroku monthly cost?" Use the answer. +4. **User-provided** — If neither live prices nor the pricing cache match any resource (unlikely with Terraform or live discovery), ask: "I need your current Heroku monthly spend to produce a meaningful cost comparison. What is your approximate Heroku monthly cost?" Use the answer. - Set `current_costs.source: "user_provided"` -4. **Unavailable** — If user declines: present AWS costs without Heroku comparison. +5. **Unavailable** — If user declines: present AWS costs without Heroku comparison. - Set `current_costs.source: "unavailable"` - Note: "Heroku baseline unavailable — AWS costs shown without comparison." -When billing data or pricing cache is available, present the Heroku baseline as: +Whenever a baseline was determined (any source except `"unavailable"`), present it as: -- Total monthly cost +- Total monthly cost, with its source and accuracy stated plainly (invoice data vs actual plan prices vs rate card vs user estimate) - Per-app breakdown (dyno, add-on, platform charges) -- Billing period +- Billing period (billing-data source only) or `baseline_note` (derived sources) --- @@ -162,7 +193,7 @@ When `aws-design.json` contains Elastic Beanstalk services (`aws_service: "Elast 1. **EC2 instances**: Look up the instance type's hourly rate in `ec2.instances[instance_type]` × 730 hours × the running instance estimate. For the Balanced tier, use steady-state `min_instances` so EB and Fargate comparisons use comparable running-capacity assumptions. Show `max_instances` as scaling headroom, not as 730 hours of guaranteed spend. 2. **ALB** (LoadBalanced environments only): use the same ALB formula as standalone ALB entries: `alb.monthly_fixed` plus an LCU estimate. SingleInstance non-web environments do NOT incur ALB cost. -3. **EBS storage**: EC2 On-Demand pricing does not include EBS root volumes. Either add a small per-instance gp3 root-volume estimate when a rate is available, or explicitly list EBS root volume cost as a known minor omission requiring verification. Do not claim a 30GB EC2 allowance. +3. **EBS storage**: EC2 On-Demand pricing does not include EBS root volumes. Add per instance: `ebs.gp3_per_gb_month` × (`aws_config.root_volume_gb` when the design specifies one, else `ebs.eb_root_volume_gb_default`) × the running instance estimate. Do not claim a 30GB EC2 allowance. 4. **NAT Gateway**: If the VPC design places EB instances in private subnets that require outbound internet access, include the same NAT Gateway line used by the other compute paths. **Total EB monthly cost** = (EC2_hourly × 730 × running_instance_estimate) + ALB_costs (web only) + applicable networking/storage supporting costs. EB itself charges $0 — all costs are the underlying resources. @@ -255,15 +286,22 @@ This entry REPLACES any CloudWatch entries in a "Supporting" row — never doubl ## Part 3: Cost Comparison (Heroku vs AWS) -### When Billing Data Available +### When a Heroku Baseline Was Determined (any Part 1 source except `"unavailable"`) + +The comparison is the point of this phase — it runs whenever Part 1 produced a +baseline, from ANY source. Do not reserve it for billing data: a +`live_prices_plus_cache` or `pricing_cache` baseline yields the same side-by-side +with its accuracy labeled honestly. Present a side-by-side comparison: -- **Heroku current monthly total** (from `billing_profile.total_monthly_cost`) +- **Heroku current monthly total** (from Part 1's baseline, labeled with `current_costs.source` and its accuracy; when derived rather than invoiced, repeat the `baseline_note` caveat next to the number) - **AWS Premium / Balanced / Optimized monthly totals** - **Difference** (savings or increase) per tier vs Heroku — monthly and annual - **Per-app breakdown** for the Balanced tier: for each Heroku app, show: - - Current Heroku spend (from `billing_profile.line_items` filtered by app) + - Current Heroku spend — from `billing_profile.line_items` filtered by app + (billing source), or from that app's summed live prices + cache dyno rates + (derived sources) - Projected AWS spend (sum of services mapped from that app) - Difference @@ -271,7 +309,8 @@ Include in `estimation-infra.json`: ```json "cost_comparison": { - "heroku_monthly_baseline": "", + "heroku_monthly_baseline": "", + "baseline_source": "", "option_a_premium": { "aws_monthly": "", "monthly_difference": "", @@ -293,9 +332,9 @@ Include in `estimation-infra.json`: } ``` -### When Billing Data NOT Available +### When NO Baseline Was Determined (`current_costs.source == "unavailable"`) -Omit `cost_comparison` section or set `heroku_monthly_baseline` to null. Present AWS costs without comparison. State: "Heroku billing data not available — showing projected AWS costs only. Provide Heroku invoices and re-run discovery to see side-by-side comparison." +Omit the `cost_comparison` section or set `heroku_monthly_baseline` to null. Present AWS costs without comparison. State: "Heroku baseline unavailable — showing projected AWS costs only. Run live discovery (or provide Heroku invoices) and re-run to see the side-by-side comparison." --- @@ -303,25 +342,31 @@ Omit `cost_comparison` section or set `heroku_monthly_baseline` to null. Present Heroku does not charge egress fees for data transfer during migration (unlike GCP). However, there may be time-based costs during parallel operation. -### IF billing data IS available: +Key this section off baseline presence (any Part 1 source except `"unavailable"`), not billing data specifically — a derived baseline prices the dual-run window just as well, with the same accuracy caveat as the baseline itself. + +### IF a Heroku baseline WAS determined: ```json "migration_cost_considerations": { - "billing_data_available": true, + "baseline_available": true, + "baseline_source": "", "categories": [ - "Heroku platform fees during parallel operation (both Heroku and AWS running simultaneously during cutover window)" + "Heroku platform fees during parallel operation (both Heroku and AWS running simultaneously during cutover window): ~/month for the duration of the cutover" ], "note": "Heroku charges are subscription-based. During migration, both Heroku and AWS costs apply until Heroku apps are decommissioned. No data transfer egress fees from Heroku." } ``` -### IF billing data is NOT available: +When the baseline is derived (`live_prices_plus_cache` or `pricing_cache`), append to the note: "Dual-run figure is derived from plan prices, not invoices — actual parallel-operation cost may differ by usage-based charges." + +### IF NO baseline was determined (`current_costs.source == "unavailable"`): ```json "migration_cost_considerations": { - "billing_data_available": false, + "baseline_available": false, + "baseline_source": "unavailable", "categories": [], - "note": "Parallel operation costs depend on Heroku billing. Provide Heroku invoices for dual-run cost projections." + "note": "Parallel operation costs depend on Heroku spend. Run live discovery (or provide Heroku invoices) for dual-run cost projections." } ``` @@ -353,11 +398,11 @@ Present monthly and annual cost difference between Heroku baseline and each AWS ```json "roi_analysis": { "recurring_savings": { - "monthly_difference_balanced": "", - "monthly_difference_optimized": "", + "monthly_difference_balanced": "", + "monthly_difference_optimized": "", "annual_difference_balanced": "<× 12>", "annual_difference_optimized": "<× 12>", - "note": "Negative = AWS cheaper. Positive = Heroku cheaper on pure cost basis." + "note": "Sign convention: difference = AWS minus Heroku, so negative = AWS cheaper. This is the OPPOSITE sign of financial_summary.monthly_savings_* (savings = Heroku minus AWS) — same fact, difference-vs-savings framing. Any presentation of either number MUST label it (e.g. 'AWS is $X/mo cheaper'), never print a bare signed value." }, "operational_efficiency_factors": [...], "non_cost_benefits": [...], diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/estimate/estimation-infra.schema.json b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/estimate/estimation-infra.schema.json index 8c967f46..9b48c5e9 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/estimate/estimation-infra.schema.json +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/estimate/estimation-infra.schema.json @@ -13,7 +13,7 @@ "type": "object", "required": ["status"], "properties": { - "status": { "type": "string", "enum": ["cached", "live", "cached_fallback", "unavailable"] }, + "status": { "type": "string", "enum": ["cached", "cached_stale", "live", "cached_fallback", "unavailable"] }, "message": { "type": "string" }, "fallback_staleness": { "type": "object" }, "services_by_source": { diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/pricing/aws-infra-pricing.json b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/pricing/aws-infra-pricing.json index 18d6314e..956a5cd9 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/pricing/aws-infra-pricing.json +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/vendored/pricing/aws-infra-pricing.json @@ -115,6 +115,12 @@ "monthly_fixed": 32.85, "per_gb_processed": 0.045 }, + "ebs": { + "_comment": "EBS volume pricing (us-east-1). Used to price EC2-backed compute root volumes (Elastic Beanstalk instances, EKS nodes) so EB estimates need no known-omission caveat.", + "gp3_per_gb_month": 0.08, + "eb_root_volume_gb_default": 8, + "_default_note": "eb_root_volume_gb_default is the AL2023 EB default root size; use aws_config.root_volume_gb when the design specifies one." + }, "rds_proxy": { "per_vcpu_hour": 0.015 }, diff --git a/migrate/plugins/migration-to-aws/skills/shared/estimate/estimation-infra.schema.json b/migrate/plugins/migration-to-aws/skills/shared/estimate/estimation-infra.schema.json index 8c967f46..9b48c5e9 100644 --- a/migrate/plugins/migration-to-aws/skills/shared/estimate/estimation-infra.schema.json +++ b/migrate/plugins/migration-to-aws/skills/shared/estimate/estimation-infra.schema.json @@ -13,7 +13,7 @@ "type": "object", "required": ["status"], "properties": { - "status": { "type": "string", "enum": ["cached", "live", "cached_fallback", "unavailable"] }, + "status": { "type": "string", "enum": ["cached", "cached_stale", "live", "cached_fallback", "unavailable"] }, "message": { "type": "string" }, "fallback_staleness": { "type": "object" }, "services_by_source": { diff --git a/migrate/plugins/migration-to-aws/skills/shared/pricing/aws-infra-pricing.json b/migrate/plugins/migration-to-aws/skills/shared/pricing/aws-infra-pricing.json index 18d6314e..956a5cd9 100644 --- a/migrate/plugins/migration-to-aws/skills/shared/pricing/aws-infra-pricing.json +++ b/migrate/plugins/migration-to-aws/skills/shared/pricing/aws-infra-pricing.json @@ -115,6 +115,12 @@ "monthly_fixed": 32.85, "per_gb_processed": 0.045 }, + "ebs": { + "_comment": "EBS volume pricing (us-east-1). Used to price EC2-backed compute root volumes (Elastic Beanstalk instances, EKS nodes) so EB estimates need no known-omission caveat.", + "gp3_per_gb_month": 0.08, + "eb_root_volume_gb_default": 8, + "_default_note": "eb_root_volume_gb_default is the AL2023 EB default root size; use aws_config.root_volume_gb when the design specifies one." + }, "rds_proxy": { "per_vcpu_hour": 0.015 },