diff --git a/migrate/plugins/migration-to-aws/.mcp.json b/migrate/plugins/migration-to-aws/.mcp.json index 4a27eded..ff93672c 100644 --- a/migrate/plugins/migration-to-aws/.mcp.json +++ b/migrate/plugins/migration-to-aws/.mcp.json @@ -25,6 +25,25 @@ "temporal-docs": { "type": "http", "url": "https://temporal.mcp.kapa.ai" + }, + "aws-pricing-calculator": { + "command": "npx", + "args": [ + "-y", + "sample-aws-pricing-calculator-mcp@latest" + ], + "timeout": 120000, + "type": "stdio", + "autoApprove": [ + "get_server_info", + "search_services", + "get_service_fields", + "create_estimate", + "add_service", + "validate_estimate", + "build_estimate", + "export_estimate" + ] } } } diff --git a/migrate/plugins/migration-to-aws/fixtures/README.md b/migrate/plugins/migration-to-aws/fixtures/README.md index 7f56ab83..3c0d2711 100644 --- a/migrate/plugins/migration-to-aws/fixtures/README.md +++ b/migrate/plugins/migration-to-aws/fixtures/README.md @@ -2,6 +2,7 @@ Also see: +- `heroku-workshop/` — Heroku what-if workshop seed + arm64 reprice snapshot + `check_expected_workshop.py` - `vercel-workshop/` — Vercel what-if workshop seed + outcome-B/x86 reprice + `check_expected_workshop.py` ## Migration report reference fixture 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 index 8ebeb3d0..90f7b9a3 100644 --- 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 @@ -7,6 +7,7 @@ "clarify": "completed", "design": "completed", "estimate": "in_progress", + "workshop": "pending", "generate": "pending", "feedback": "pending" } diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/README.md b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/README.md new file mode 100644 index 00000000..ea577926 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/README.md @@ -0,0 +1,54 @@ +# Heroku what-if workshop fixtures + +Canned Estimate → workshop reprice artifacts for the `heroku-to-aws` what-if +workshop checkpoint (`references/phases/workshop/`, `_kind: checkpoint`). +Discovery inventory is frozen; Design + Estimate refresh under `scenarios/`. + +| Path | Role | +| ---------------------------- | -------------------------------------------------------------------------- | +| `seed/` | Post-Estimate baseline (x86 EB `t3.small`, single-AZ) before workshop | +| `after-arm64-reprice/` | After Apply & reprice with `workshop.cpu_architecture=arm64` (`t4g.small`) | +| `expected-workshop.json` | Assertions for the asserter | +| `check_expected_workshop.py` | Stdlib checker | + +## Assert a run + +```bash +python3 check_expected_workshop.py after-arm64-reprice +# or against a live .migration// that followed the arm64 sheet path: +python3 check_expected_workshop.py /path/to/.migration/MMDD-HHMM seed +``` + +## Fresh-agent replay bar (house standard) + +Before opening a PR that changes workshop behavior, a **fresh agent** must produce +an after-state from `seed/` by following the phase specs only (no fixture peek): + +1. Copy `seed/*` into a scratch `.migration//` (include `.phase-status.json` + with `workshop: pending`). +2. Agent plays the SA: enter workshop → set CPU architecture `arm64` → Apply & + reprice → Compare → (optional) Exit without Generate. +3. Run `python3 check_expected_workshop.py seed` — must **PASS**. + +The committed `after-arm64-reprice/` is the reference snapshot for that path. +Record in the PR that the replay was run (agent transcript or CI note). Manual +demo alone is not enough for the house bar. + +## SA demo script (manual) + +1. Copy `seed/*` into a scratch `.migration/0719-demo/` (include `.phase-status.json`). +2. Invoke heroku-to-aws Estimate handoff / say **Enter what-if workshop**. +3. Confirm baseline capture creates `scenarios/scenario-001*`. +4. On the sheet, set **CPU architecture → arm64**, leave other knobs; **Apply & reprice**. +5. Confirm Design emits EB `t4g.small` + `cpu_architecture: arm64`, Estimate + balanced total changes, `scenario-002` appears, inventory bytes unchanged. +6. **Compare scenarios** — baseline vs scenario-002 (all three cost tiers). +7. Optional second apply: set `data.database_ha` / `global.availability` to + `multi-az`, Apply & reprice → `scenario-003`. +8. **Exit to Generate** — `workshop-assemble` marks workshop completed and sets + `current_phase: generate`; working tree matches the active scenario. + +Partner one-liner: _SAs can run a what-if workshop after Estimate: change region, +HA, compute target, or Graviton preference and compare up to 5 priced scenarios +without re-discovery. (Region dollar deltas need awspricing MCP; otherwise rates +stay us-east-1-cache-based.)_ diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/.phase-status.json new file mode 100644 index 00000000..ee5688eb --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/.phase-status.json @@ -0,0 +1,14 @@ +{ + "migration_id": "0719-workshop", + "last_updated": "2026-07-19T19:05:00Z", + "current_phase": "estimate", + "phases": { + "discover": "completed", + "clarify": "completed", + "design": "completed", + "estimate": "completed", + "workshop": "in_progress", + "generate": "pending", + "feedback": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/aws-design.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/aws-design.json new file mode 100644 index 00000000..a6c9c5ed --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/aws-design.json @@ -0,0 +1,39 @@ +{ + "migration_id": "0719-workshop", + "phase": "design", + "services": [ + { + "service_id": "eb:acme-web:web", + "source_resource_id": "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": "t4g.small", + "cpu_architecture": "arm64", + "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": "acme-web:postgres", + "heroku_app": "acme-web", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.t4g.medium", + "multi_az": false, + "storage_gb": 64 + } + } + ], + "warnings": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/estimation-infra.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/estimation-infra.json new file mode 100644 index 00000000..a010437b --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/estimation-infra.json @@ -0,0 +1,57 @@ +{ + "phase": "estimate", + "design_source": "aws-design.json", + "timestamp": "2026-07-19T19:05:00Z", + "pricing_source": { + "status": "cached", + "message": "us-east-1 cache" + }, + "accuracy_confidence": "+/-5-10%", + "current_costs": { + "source": "unavailable", + "baseline_note": "no billing" + }, + "projected_costs": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 88.0, + "aws_monthly_optimized": 78.0, + "aws_annual_optimized": 936.0, + "breakdown": { + "total": { + "premium": 120.0, + "mid": 88.0, + "low": 78.0 + }, + "Elastic Beanstalk": { + "premium": 60.0, + "mid": 43.0, + "low": 40.0, + "pricing_source": "cached" + }, + "RDS PostgreSQL": { + "premium": 60.0, + "mid": 45.0, + "low": 38.0, + "pricing_source": "cached" + } + } + }, + "complexity_tier": "small", + "complexity_inputs": { + "service_count": 2 + }, + "recommendation": { + "path": "migrate_optimized", + "path_label": "Migrate with Optimized pricing scenario", + "migrate_if": [ + "want AWS control plane" + ], + "stay_if": [ + "need Heroku DX indefinitely" + ] + }, + "workshop": { + "scenario_id": "scenario-002", + "region_note": null + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/heroku-resource-inventory.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/heroku-resource-inventory.json new file mode 100644 index 00000000..54f6fc6a --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/heroku-resource-inventory.json @@ -0,0 +1,27 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "resources": [ + { + "resource_id": "acme-web:web", + "resource_type": "formation", + "heroku_app": "acme-web", + "config": { + "dyno_type": "standard-1x", + "quantity": 2, + "process_type": "web" + } + }, + { + "resource_id": "acme-web:postgres", + "resource_type": "addon", + "heroku_app": "acme-web", + "config": { + "addon_service": "heroku-postgresql", + "plan": "standard-0", + "connection_pooling": true + } + } + ], + "billing_profile": null +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/preferences.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/preferences.json new file mode 100644 index 00000000..a3f1fa03 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/preferences.json @@ -0,0 +1,72 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "metadata": { + "timestamp": "2026-07-19T19:00:00Z", + "clarify_mode": "fast_path", + "questions_asked": [ + "Q1", + "Q3", + "Q12c", + "Q15" + ], + "questions_defaulted": [], + "questions_skipped_not_applicable": [] + }, + "global": { + "target_region": "us-east-1", + "compliance": "none", + "availability": "single-az", + "maintenance_window": "sun:05:00-sun:06:00", + "environment_naming": "acme", + "migration_approach": "full_cutover", + "interim_cutover": false, + "target_exit_date": null, + "ktlo_warning": null, + "fir_intent": null + }, + "data": { + "database_ha": "single-az", + "migration_method": "pg_dump_restore", + "estimated_db_size_gb": 20, + "db_size_source": "plan_derived" + }, + "operational": { + "container_registry": "ecr", + "containerization_status": "containerized", + "log_retention_days": 30, + "alerting": "cloudwatch", + "cost_optimization": "balanced" + }, + "design_constraints": { + "compute_target": { + "default": "elastic_beanstalk", + "overrides": [], + "chosen_by": "system_recommended", + "recommendation": { + "value": "elastic_beanstalk", + "confidence": "high", + "reasons": [ + "web formation fits EB default" + ] + } + }, + "eb_deploy_method": { + "value": "github_actions", + "chosen_by": "default" + } + }, + "defaults_applied": [], + "sources": { + "Q1": "user", + "Q3": "user", + "Q12c": "default", + "Q15": "default" + }, + "workshop": { + "active": true, + "cpu_architecture": "arm64", + "last_sheet_at": "2026-07-19T19:00:00Z", + "active_scenario_id": "scenario-002" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/index.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/index.json new file mode 100644 index 00000000..07a25fe6 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/index.json @@ -0,0 +1,22 @@ +{ + "baseline_scenario_id": "scenario-001", + "active_scenario_id": "scenario-002", + "max_scenarios": 5, + "inventory_fingerprint": "bbd511b4df40262d61fb96e1f2e47b0c935f90d0ce398dfc8dc0ea6dd52f1bdf", + "scenarios": [ + { + "scenario_id": "scenario-001", + "label": "baseline", + "created_at": "2026-07-19T18:30:00Z", + "source": "baseline", + "manifest": "scenarios/scenario-001.json" + }, + { + "scenario_id": "scenario-002", + "label": "arm64", + "created_at": "2026-07-19T19:05:00Z", + "source": "workshop", + "manifest": "scenarios/scenario-002.json" + } + ] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.aws-design.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.aws-design.json new file mode 100644 index 00000000..1b092fd1 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.aws-design.json @@ -0,0 +1,39 @@ +{ + "migration_id": "0719-workshop", + "phase": "design", + "services": [ + { + "service_id": "eb:acme-web:web", + "source_resource_id": "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.small", + "cpu_architecture": "x86_64", + "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": "acme-web:postgres", + "heroku_app": "acme-web", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.t4g.medium", + "multi_az": false, + "storage_gb": 64 + } + } + ], + "warnings": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.estimation-infra.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.estimation-infra.json new file mode 100644 index 00000000..fd5b3ae7 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.estimation-infra.json @@ -0,0 +1,57 @@ +{ + "phase": "estimate", + "design_source": "aws-design.json", + "timestamp": "2026-07-19T18:30:00Z", + "pricing_source": { + "status": "cached", + "message": "us-east-1 cache" + }, + "accuracy_confidence": "+/-5-10%", + "current_costs": { + "source": "unavailable", + "baseline_note": "no billing" + }, + "projected_costs": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 95.0, + "aws_monthly_optimized": 78.0, + "aws_annual_optimized": 936.0, + "breakdown": { + "total": { + "premium": 120.0, + "mid": 95.0, + "low": 78.0 + }, + "Elastic Beanstalk": { + "premium": 60.0, + "mid": 50.0, + "low": 40.0, + "pricing_source": "cached" + }, + "RDS PostgreSQL": { + "premium": 60.0, + "mid": 45.0, + "low": 38.0, + "pricing_source": "cached" + } + } + }, + "complexity_tier": "small", + "complexity_inputs": { + "service_count": 2 + }, + "recommendation": { + "path": "migrate_optimized", + "path_label": "Migrate with Optimized pricing scenario", + "migrate_if": [ + "want AWS control plane" + ], + "stay_if": [ + "need Heroku DX indefinitely" + ] + }, + "workshop": { + "scenario_id": null, + "region_note": null + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.json new file mode 100644 index 00000000..57ca54dc --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.json @@ -0,0 +1,22 @@ +{ + "scenario_id": "scenario-001", + "label": "baseline", + "created_at": "2026-07-19T18:30:00Z", + "source": "baseline", + "preferences_subset": {}, + "preferences_fingerprint": "68b3a7da27ff04dce84bdd67675712e257c11eedb15247cfc01adc9a0d64ab77", + "aws_design_fingerprint": "77923e75cfb1871763121d9d0c168e2b01952509590aea9372f2051a21b5aafd", + "estimation_summary": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 95.0, + "aws_monthly_optimized": 78.0, + "complexity_tier": "small", + "pricing_source": "cached", + "region_note": null + }, + "paths": { + "preferences": "scenarios/scenario-001.preferences.json", + "aws_design": "scenarios/scenario-001.aws-design.json", + "estimation_infra": "scenarios/scenario-001.estimation-infra.json" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.preferences.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.preferences.json new file mode 100644 index 00000000..8c1a610c --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-001.preferences.json @@ -0,0 +1,66 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "metadata": { + "timestamp": "2026-07-19T18:00:00Z", + "clarify_mode": "fast_path", + "questions_asked": [ + "Q1", + "Q3", + "Q12c", + "Q15" + ], + "questions_defaulted": [], + "questions_skipped_not_applicable": [] + }, + "global": { + "target_region": "us-east-1", + "compliance": "none", + "availability": "single-az", + "maintenance_window": "sun:05:00-sun:06:00", + "environment_naming": "acme", + "migration_approach": "full_cutover", + "interim_cutover": false, + "target_exit_date": null, + "ktlo_warning": null, + "fir_intent": null + }, + "data": { + "database_ha": "single-az", + "migration_method": "pg_dump_restore", + "estimated_db_size_gb": 20, + "db_size_source": "plan_derived" + }, + "operational": { + "container_registry": "ecr", + "containerization_status": "containerized", + "log_retention_days": 30, + "alerting": "cloudwatch", + "cost_optimization": "balanced" + }, + "design_constraints": { + "compute_target": { + "default": "elastic_beanstalk", + "overrides": [], + "chosen_by": "system_recommended", + "recommendation": { + "value": "elastic_beanstalk", + "confidence": "high", + "reasons": [ + "web formation fits EB default" + ] + } + }, + "eb_deploy_method": { + "value": "github_actions", + "chosen_by": "default" + } + }, + "defaults_applied": [], + "sources": { + "Q1": "user", + "Q3": "user", + "Q12c": "default", + "Q15": "default" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.aws-design.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.aws-design.json new file mode 100644 index 00000000..a6c9c5ed --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.aws-design.json @@ -0,0 +1,39 @@ +{ + "migration_id": "0719-workshop", + "phase": "design", + "services": [ + { + "service_id": "eb:acme-web:web", + "source_resource_id": "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": "t4g.small", + "cpu_architecture": "arm64", + "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": "acme-web:postgres", + "heroku_app": "acme-web", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.t4g.medium", + "multi_az": false, + "storage_gb": 64 + } + } + ], + "warnings": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.estimation-infra.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.estimation-infra.json new file mode 100644 index 00000000..a010437b --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.estimation-infra.json @@ -0,0 +1,57 @@ +{ + "phase": "estimate", + "design_source": "aws-design.json", + "timestamp": "2026-07-19T19:05:00Z", + "pricing_source": { + "status": "cached", + "message": "us-east-1 cache" + }, + "accuracy_confidence": "+/-5-10%", + "current_costs": { + "source": "unavailable", + "baseline_note": "no billing" + }, + "projected_costs": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 88.0, + "aws_monthly_optimized": 78.0, + "aws_annual_optimized": 936.0, + "breakdown": { + "total": { + "premium": 120.0, + "mid": 88.0, + "low": 78.0 + }, + "Elastic Beanstalk": { + "premium": 60.0, + "mid": 43.0, + "low": 40.0, + "pricing_source": "cached" + }, + "RDS PostgreSQL": { + "premium": 60.0, + "mid": 45.0, + "low": 38.0, + "pricing_source": "cached" + } + } + }, + "complexity_tier": "small", + "complexity_inputs": { + "service_count": 2 + }, + "recommendation": { + "path": "migrate_optimized", + "path_label": "Migrate with Optimized pricing scenario", + "migrate_if": [ + "want AWS control plane" + ], + "stay_if": [ + "need Heroku DX indefinitely" + ] + }, + "workshop": { + "scenario_id": "scenario-002", + "region_note": null + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.json new file mode 100644 index 00000000..6325b192 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.json @@ -0,0 +1,24 @@ +{ + "scenario_id": "scenario-002", + "label": "arm64", + "created_at": "2026-07-19T19:05:00Z", + "source": "workshop", + "preferences_subset": { + "workshop.cpu_architecture": "arm64" + }, + "preferences_fingerprint": "a5812e3a5608173fe95182172c1b2f5a6c3c5b082a19741a91533a1f39780ccc", + "aws_design_fingerprint": "b65ba356b775e52e42bcda6a566c05aa2d76e6b430abcf4190c26896c0e26b35", + "estimation_summary": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 88.0, + "aws_monthly_optimized": 78.0, + "complexity_tier": "small", + "pricing_source": "cached", + "region_note": null + }, + "paths": { + "preferences": "scenarios/scenario-002.preferences.json", + "aws_design": "scenarios/scenario-002.aws-design.json", + "estimation_infra": "scenarios/scenario-002.estimation-infra.json" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.preferences.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.preferences.json new file mode 100644 index 00000000..a3f1fa03 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/after-arm64-reprice/scenarios/scenario-002.preferences.json @@ -0,0 +1,72 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "metadata": { + "timestamp": "2026-07-19T19:00:00Z", + "clarify_mode": "fast_path", + "questions_asked": [ + "Q1", + "Q3", + "Q12c", + "Q15" + ], + "questions_defaulted": [], + "questions_skipped_not_applicable": [] + }, + "global": { + "target_region": "us-east-1", + "compliance": "none", + "availability": "single-az", + "maintenance_window": "sun:05:00-sun:06:00", + "environment_naming": "acme", + "migration_approach": "full_cutover", + "interim_cutover": false, + "target_exit_date": null, + "ktlo_warning": null, + "fir_intent": null + }, + "data": { + "database_ha": "single-az", + "migration_method": "pg_dump_restore", + "estimated_db_size_gb": 20, + "db_size_source": "plan_derived" + }, + "operational": { + "container_registry": "ecr", + "containerization_status": "containerized", + "log_retention_days": 30, + "alerting": "cloudwatch", + "cost_optimization": "balanced" + }, + "design_constraints": { + "compute_target": { + "default": "elastic_beanstalk", + "overrides": [], + "chosen_by": "system_recommended", + "recommendation": { + "value": "elastic_beanstalk", + "confidence": "high", + "reasons": [ + "web formation fits EB default" + ] + } + }, + "eb_deploy_method": { + "value": "github_actions", + "chosen_by": "default" + } + }, + "defaults_applied": [], + "sources": { + "Q1": "user", + "Q3": "user", + "Q12c": "default", + "Q15": "default" + }, + "workshop": { + "active": true, + "cpu_architecture": "arm64", + "last_sheet_at": "2026-07-19T19:00:00Z", + "active_scenario_id": "scenario-002" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/check_expected_workshop.py b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/check_expected_workshop.py new file mode 100644 index 00000000..c460b364 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/check_expected_workshop.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Assert a Heroku what-if workshop run against expected-workshop.json. + +Usage: + python3 check_expected_workshop.py [] + +Defaults: compares to the fixture's seed/ sibling for +inventory byte-stability. Exits 0 on PASS, 1 on FAIL. Stdlib only. +""" + +from __future__ import annotations + +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) not in (2, 3): + print(__doc__) + return 2 + + run_dir = Path(sys.argv[1]) + fixture_dir = Path(__file__).resolve().parent + seed_dir = Path(sys.argv[2]) if len(sys.argv) == 3 else fixture_dir / "seed" + exp = json.loads((fixture_dir / "expected-workshop.json").read_text()) + + inv_run = run_dir / "heroku-resource-inventory.json" + inv_seed = seed_dir / "heroku-resource-inventory.json" + check(inv_run.exists(), "missing heroku-resource-inventory.json in run dir") + check(inv_seed.exists(), "missing seed inventory") + if inv_run.exists() and inv_seed.exists() and exp.get("inventory_must_match_seed_bytes"): + check( + inv_run.read_bytes() == inv_seed.read_bytes(), + "inventory bytes changed — workshop must freeze discovery", + ) + + index_path = run_dir / "scenarios" / "index.json" + check(index_path.exists(), "missing scenarios/index.json") + if not index_path.exists(): + _print_fails() + return 1 + + index = json.loads(index_path.read_text()) + scenarios = index.get("scenarios") or [] + check(len(scenarios) >= exp["min_scenarios"], f"scenario count {len(scenarios)} < {exp['min_scenarios']}") + check(len(scenarios) <= exp["max_scenarios"], f"scenario count {len(scenarios)} > max {exp['max_scenarios']}") + check(index.get("baseline_scenario_id") == exp["baseline_scenario_id"], "baseline_scenario_id mismatch") + check(index.get("active_scenario_id") == exp["active_scenario_id"], "active_scenario_id mismatch") + + prefs = json.loads((run_dir / "preferences.json").read_text()) + workshop = prefs.get("workshop") or {} + check(workshop.get("cpu_architecture") == exp["active_cpu_architecture"], "active cpu_architecture mismatch") + check(workshop.get("active_scenario_id") == exp["active_scenario_id"], "prefs workshop.active_scenario_id mismatch") + + design = json.loads((run_dir / "aws-design.json").read_text()) + eb = next( + (s for s in design.get("services", []) if s.get("aws_service") == "Elastic Beanstalk"), + None, + ) + check(eb is not None, "no Elastic Beanstalk service in active design") + if eb is not None: + check( + eb.get("aws_config", {}).get("instance_type") == exp["active_eb_instance_type"], + f"EB instance_type={eb.get('aws_config', {}).get('instance_type')} want {exp['active_eb_instance_type']}", + ) + check( + eb.get("aws_config", {}).get("cpu_architecture") == exp["active_cpu_architecture"], + "EB cpu_architecture mismatch", + ) + + base_design_path = run_dir / "scenarios" / f"{exp['baseline_scenario_id']}.aws-design.json" + if base_design_path.exists() and exp.get("design_must_differ_from_baseline"): + base_design = json.loads(base_design_path.read_text()) + base_eb = next( + (s for s in base_design.get("services", []) if s.get("aws_service") == "Elastic Beanstalk"), + None, + ) + check(base_eb is not None, "no Elastic Beanstalk in baseline design snapshot") + if base_eb is not None and eb is not None: + base_type = base_eb.get("aws_config", {}).get("instance_type") + act_type = eb.get("aws_config", {}).get("instance_type") + check(act_type != base_type, f"design instance_type unchanged ({act_type}) vs baseline ({base_type})") + if exp.get("baseline_eb_instance_type"): + check( + base_type == exp["baseline_eb_instance_type"], + f"baseline EB instance_type={base_type} want {exp['baseline_eb_instance_type']}", + ) + + est = json.loads((run_dir / "estimation-infra.json").read_text()) + base_manifest = run_dir / "scenarios" / f"{exp['baseline_scenario_id']}.json" + check(base_manifest.exists(), "missing baseline scenario manifest") + if base_manifest.exists() and exp.get("balanced_must_differ_from_baseline"): + base = json.loads(base_manifest.read_text()) + base_bal = base["estimation_summary"]["aws_monthly_balanced"] + act_bal = est["projected_costs"]["aws_monthly_balanced"] + check(act_bal != base_bal, f"balanced total unchanged ({act_bal}) vs baseline ({base_bal})") + + phase_path = run_dir / ".phase-status.json" + if phase_path.exists(): + phase = json.loads(phase_path.read_text()) + phases = phase.get("phases", {}) + if exp.get("generate_must_not_be_completed"): + check(phases.get("generate") != "completed", "generate must not auto-complete during workshop") + if exp.get("current_phase_must_be"): + check( + phase.get("current_phase") == exp["current_phase_must_be"], + f"current_phase={phase.get('current_phase')} want {exp['current_phase_must_be']}", + ) + if exp.get("workshop_phase_must_be"): + check( + phases.get("workshop") == exp["workshop_phase_must_be"], + f"phases.workshop={phases.get('workshop')} want {exp['workshop_phase_must_be']}", + ) + + # Secret hygiene: no token-looking material + blob = (run_dir / "preferences.json").read_text() + (run_dir / "aws-design.json").read_text() + for bad in ("HEROKU_API_KEY", "Bearer ", "sk_live"): + check(bad not in blob, f"possible secret material: {bad}") + + if FAILS: + _print_fails() + return 1 + print("PASS — expected-workshop.json assertions hold") + return 0 + + +def _print_fails() -> None: + print(f"FAIL ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/expected-workshop.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/expected-workshop.json new file mode 100644 index 00000000..a0850dea --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/expected-workshop.json @@ -0,0 +1,15 @@ +{ + "inventory_must_match_seed_bytes": true, + "min_scenarios": 2, + "baseline_scenario_id": "scenario-001", + "active_scenario_id": "scenario-002", + "active_cpu_architecture": "arm64", + "active_eb_instance_type": "t4g.small", + "baseline_eb_instance_type": "t3.small", + "design_must_differ_from_baseline": true, + "balanced_must_differ_from_baseline": true, + "generate_must_not_be_completed": true, + "current_phase_must_be": "estimate", + "workshop_phase_must_be": "in_progress", + "max_scenarios": 5 +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/.phase-status.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/.phase-status.json new file mode 100644 index 00000000..995ce2d6 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/.phase-status.json @@ -0,0 +1,14 @@ +{ + "migration_id": "0719-workshop", + "last_updated": "2026-07-19T18:30:00Z", + "current_phase": "estimate", + "phases": { + "discover": "completed", + "clarify": "completed", + "design": "completed", + "estimate": "completed", + "workshop": "pending", + "generate": "pending", + "feedback": "pending" + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/aws-design.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/aws-design.json new file mode 100644 index 00000000..1b092fd1 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/aws-design.json @@ -0,0 +1,39 @@ +{ + "migration_id": "0719-workshop", + "phase": "design", + "services": [ + { + "service_id": "eb:acme-web:web", + "source_resource_id": "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.small", + "cpu_architecture": "x86_64", + "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": "acme-web:postgres", + "heroku_app": "acme-web", + "aws_service": "RDS PostgreSQL", + "confidence": "deterministic", + "aws_config": { + "region": "us-east-1", + "instance_class": "db.t4g.medium", + "multi_az": false, + "storage_gb": 64 + } + } + ], + "warnings": [] +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/estimation-infra.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/estimation-infra.json new file mode 100644 index 00000000..fd5b3ae7 --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/estimation-infra.json @@ -0,0 +1,57 @@ +{ + "phase": "estimate", + "design_source": "aws-design.json", + "timestamp": "2026-07-19T18:30:00Z", + "pricing_source": { + "status": "cached", + "message": "us-east-1 cache" + }, + "accuracy_confidence": "+/-5-10%", + "current_costs": { + "source": "unavailable", + "baseline_note": "no billing" + }, + "projected_costs": { + "aws_monthly_premium": 120.0, + "aws_monthly_balanced": 95.0, + "aws_monthly_optimized": 78.0, + "aws_annual_optimized": 936.0, + "breakdown": { + "total": { + "premium": 120.0, + "mid": 95.0, + "low": 78.0 + }, + "Elastic Beanstalk": { + "premium": 60.0, + "mid": 50.0, + "low": 40.0, + "pricing_source": "cached" + }, + "RDS PostgreSQL": { + "premium": 60.0, + "mid": 45.0, + "low": 38.0, + "pricing_source": "cached" + } + } + }, + "complexity_tier": "small", + "complexity_inputs": { + "service_count": 2 + }, + "recommendation": { + "path": "migrate_optimized", + "path_label": "Migrate with Optimized pricing scenario", + "migrate_if": [ + "want AWS control plane" + ], + "stay_if": [ + "need Heroku DX indefinitely" + ] + }, + "workshop": { + "scenario_id": null, + "region_note": null + } +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/heroku-resource-inventory.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/heroku-resource-inventory.json new file mode 100644 index 00000000..54f6fc6a --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/heroku-resource-inventory.json @@ -0,0 +1,27 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "resources": [ + { + "resource_id": "acme-web:web", + "resource_type": "formation", + "heroku_app": "acme-web", + "config": { + "dyno_type": "standard-1x", + "quantity": 2, + "process_type": "web" + } + }, + { + "resource_id": "acme-web:postgres", + "resource_type": "addon", + "heroku_app": "acme-web", + "config": { + "addon_service": "heroku-postgresql", + "plan": "standard-0", + "connection_pooling": true + } + } + ], + "billing_profile": null +} diff --git a/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/preferences.json b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/preferences.json new file mode 100644 index 00000000..8c1a610c --- /dev/null +++ b/migrate/plugins/migration-to-aws/fixtures/heroku-workshop/seed/preferences.json @@ -0,0 +1,66 @@ +{ + "migration_id": "0719-workshop", + "skill": "heroku-to-aws", + "metadata": { + "timestamp": "2026-07-19T18:00:00Z", + "clarify_mode": "fast_path", + "questions_asked": [ + "Q1", + "Q3", + "Q12c", + "Q15" + ], + "questions_defaulted": [], + "questions_skipped_not_applicable": [] + }, + "global": { + "target_region": "us-east-1", + "compliance": "none", + "availability": "single-az", + "maintenance_window": "sun:05:00-sun:06:00", + "environment_naming": "acme", + "migration_approach": "full_cutover", + "interim_cutover": false, + "target_exit_date": null, + "ktlo_warning": null, + "fir_intent": null + }, + "data": { + "database_ha": "single-az", + "migration_method": "pg_dump_restore", + "estimated_db_size_gb": 20, + "db_size_source": "plan_derived" + }, + "operational": { + "container_registry": "ecr", + "containerization_status": "containerized", + "log_retention_days": 30, + "alerting": "cloudwatch", + "cost_optimization": "balanced" + }, + "design_constraints": { + "compute_target": { + "default": "elastic_beanstalk", + "overrides": [], + "chosen_by": "system_recommended", + "recommendation": { + "value": "elastic_beanstalk", + "confidence": "high", + "reasons": [ + "web formation fits EB default" + ] + } + }, + "eb_deploy_method": { + "value": "github_actions", + "chosen_by": "default" + } + }, + "defaults_applied": [], + "sources": { + "Q1": "user", + "Q3": "user", + "Q12c": "default", + "Q15": "default" + } +} diff --git a/migrate/plugins/migration-to-aws/scripts/validate-heroku-migration-report.py b/migrate/plugins/migration-to-aws/scripts/validate-heroku-migration-report.py new file mode 100755 index 00000000..85c7b671 --- /dev/null +++ b/migrate/plugins/migration-to-aws/scripts/validate-heroku-migration-report.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Validate heroku-to-aws migration-report.html (thin stakeholder report). + +Required sections: decision-summary, exec-costs, next-steps. +Conditional: what-if-scenarios when scenarios/index.json has ≥2 entries. +Footer must contain "draft for review". + +Exit 0 on PASS, 1 on FAIL. + +Usage: + python3 validate-heroku-migration-report.py /path/to/migration-report.html \\ + --migration-dir "$MIGRATION_DIR" +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REQUIRED_SECTION_IDS = [ + "decision-summary", + "exec-costs", + "next-steps", +] + +SECTION_OPEN = re.compile( + r']*\bid=["\']([^"\']+)["\'][^>]*>', + re.IGNORECASE, +) + + +def _section_counts(html: str) -> dict[str, int]: + counts: dict[str, int] = {} + for match in SECTION_OPEN.finditer(html): + sid = match.group(1) + counts[sid] = counts.get(sid, 0) + 1 + return counts + + +def validate(html: str, migration_dir: Path | None) -> list[str]: + errors: list[str] = [] + counts = _section_counts(html) + + for sid in REQUIRED_SECTION_IDS: + n = counts.get(sid, 0) + if n == 0: + errors.append(f'missing required
') + elif n > 1: + errors.append(f'duplicate
({n} occurrences)') + + if "draft for review" not in html.lower(): + errors.append('footer must contain "draft for review" disclaimer') + + if migration_dir is not None: + index_path = migration_dir / "scenarios" / "index.json" + if index_path.is_file(): + try: + index = json.loads(index_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + index = None + scenarios = (index or {}).get("scenarios") or [] + if len(scenarios) >= 2 and counts.get("what-if-scenarios", 0) < 1: + errors.append( + 'scenarios/index.json has ≥2 scenarios but no ' + '
' + ) + + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report_path", type=Path) + parser.add_argument("--migration-dir", type=Path, default=None) + args = parser.parse_args() + + if not args.report_path.is_file(): + print(f"REPORT_FAIL | file={args.report_path} | reason=not_found", file=sys.stderr) + return 1 + + html = args.report_path.read_text(encoding="utf-8") + errors = validate(html, args.migration_dir) + if errors: + print(f"REPORT_FAIL | file={args.report_path} | errors={len(errors)}", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + return 1 + + counts = _section_counts(html) + optional = [] + if counts.get("what-if-scenarios", 0) >= 1: + optional.append("what-if-scenarios") + print( + "REPORT_OK | structure=complete | sections=" + f"{len(REQUIRED_SECTION_IDS)}/{len(REQUIRED_SECTION_IDS)}" + + (f" | optional={','.join(optional)}" if optional else "") + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/SKILL.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/SKILL.md index e3c23061..db661dc6 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/SKILL.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/SKILL.md @@ -1,6 +1,6 @@ --- name: heroku-to-aws -description: "Migrate workloads from Heroku to AWS. Triggers on: migrate from Heroku, Heroku to AWS, move off Heroku, migrate Heroku app, migrate Heroku Postgres to RDS, migrate Heroku Redis to ElastiCache, migrate Heroku Kafka to MSK, migrate dynos to Elastic Beanstalk, migrate dynos to Fargate, Heroku migration, move from Heroku to AWS, migrate Heroku Private Space, Heroku to Elastic Beanstalk, Heroku to ECS, Heroku to Fargate, leave Heroku, migrate off Heroku platform. Runs a 6-phase process: discover Heroku resources live via the authenticated Heroku CLI (read-only, consent-gated) and/or from Terraform files, Procfile/app.json, and optional billing exports, clarify migration requirements, design AWS architecture, estimate costs, generate migration artifacts, and collect optional feedback. Clarify must finish before Design, Estimate, or Generate. Uses a flat resource model (no clustering or dependency graphs) with deterministic mapping tables for core services (Dynos → Elastic Beanstalk by default, Postgres → RDS/Aurora, Redis → ElastiCache, Kafka → MSK) and a fast-path table for 13+ common add-ons. Cedar/Fir generation detection is detect-only in v1. Pipeline/Review Apps are detect-only. Do not use for: GCP or Azure migrations to AWS, AWS-to-Heroku reverse migration, general AWS architecture advice without migration intent, Heroku-to-Heroku refactoring, or multi-cloud deployments that do not involve migrating off Heroku." +description: "Migrate workloads from Heroku to AWS. Triggers on: migrate from Heroku, Heroku to AWS, move off Heroku, migrate Heroku app, migrate Heroku Postgres to RDS, migrate Heroku Redis to ElastiCache, migrate Heroku Kafka to MSK, migrate dynos to Elastic Beanstalk, migrate dynos to Fargate, Heroku migration, move from Heroku to AWS, migrate Heroku Private Space, Heroku to Elastic Beanstalk, Heroku to ECS, Heroku to Fargate, leave Heroku, migrate off Heroku platform, what-if workshop, reprice Heroku migration, compare migration scenarios, workshop mode. Runs a 6-phase process: discover Heroku resources live via the authenticated Heroku CLI (read-only, consent-gated) and/or from Terraform files, Procfile/app.json, and optional billing exports, clarify migration requirements, design AWS architecture, estimate costs, generate migration artifacts, and collect optional feedback. After Estimate, an optional what-if workshop can reprice region/HA/compute/Graviton scenarios without re-discovery. Clarify must finish before Design, Estimate, or Generate. Uses a flat resource model (no clustering or dependency graphs) with deterministic mapping tables for core services (Dynos → Elastic Beanstalk by default, Postgres → RDS/Aurora, Redis → ElastiCache, Kafka → MSK) and a fast-path table for 13+ common add-ons. Cedar/Fir generation detection is detect-only in v1. Pipeline/Review Apps are detect-only. Do not use for: GCP or Azure migrations to AWS, AWS-to-Heroku reverse migration, general AWS architecture advice without migration intent, Heroku-to-Heroku refactoring, or multi-cloud deployments that do not involve migrating off Heroku." --- # Heroku-to-AWS Migration Skill @@ -17,6 +17,7 @@ description: "Migrate workloads from Heroku to AWS. Triggers on: migrate from He - **Flat resource model**: Heroku resources are organized per-app without dependency graphs or clustering. No topological sorting, typed edges, or cluster formation logic. Resources are processed as a flat list in input order. - **Deterministic mappings**: Core services use fixed lookup tables (Dyno Type Table, Postgres Plan Table, Redis Plan Table, Kafka Plan Table). Common add-ons use the Fast-Path Table. Unknown add-ons hit the specialist gate. - **DMS has Heroku constraints**: AWS DMS cannot perform continuous replication (CDC) with Heroku Postgres because Heroku does not grant the REPLICATION role. DMS is for one-time bulk migration with a cutover window only. The skill must surface this constraint when DMS is selected. +- **What-if after Estimate**: After costs are computed, SAs can enter an optional what-if workshop checkpoint (`references/phases/workshop/workshop.md`) to change region, HA, compute target, or CPU architecture (x86 vs Graviton), refresh Design + Estimate, and compare up to 5 priced scenarios — without re-running Discover. Region dollar deltas need awspricing MCP; without it, rates stay us-east-1-cache-based. Workshop arch defaults to **x86_64** here (EB tables historically x86-first); vercel-to-aws defaults workshop arch to **arm64**. --- @@ -72,8 +73,10 @@ are not restated here. skill's entry phase (the one carrying `_init: true`). The interpreter loads THIS phase directly; it does not scan every phase's frontmatter to discover the root. All subsequent phases are reached by following each phase's `_advances_to`. On a -warm start, `current_phase` in `.phase-status.json` is authoritative (see -`INTERPRETER.md` § The interpreter loop). +warm start, `current_phase` in `.phase-status.json` is authoritative **except** +when deferred-advance checkpoint resume applies (`INTERPRETER.md` § The +interpreter loop step 2 — Estimate completed + `workshop` pending/in_progress +must not re-run Estimate). **Clarify is mandatory (heroku policy).** Do not skip Clarify or jump straight to Design, Estimate, or Generate even if the user asks — there is no exception for @@ -124,17 +127,26 @@ heroku-to-aws/ │ │ │ └── design.md # Phase 3: Design orchestrator (flat single-pass mapping) │ │ ├── estimate/ │ │ │ └── estimate.md # Phase 4: Cost projection +│ │ ├── workshop/ +│ │ │ ├── workshop.md # Checkpoint: optional post-Estimate what-if +│ │ │ ├── workshop-sheet.md # Assumption sheet knobs +│ │ │ ├── workshop-refresh.md # Patch prefs → Design → Estimate → snapshot +│ │ │ ├── workshop-compare.md # Side-by-side scenarios +│ │ │ └── workshop-assemble.md # Resolve checkpoint → return to Generate │ │ ├── generate/ │ │ │ ├── generate.md # Phase 5: Generate orchestrator │ │ │ ├── generate-terraform.md # Terraform configurations -│ │ │ └── generate-docs.md # MIGRATION_GUIDE.md + README.md +│ │ │ ├── generate-docs.md # MIGRATION_GUIDE.md + README.md +│ │ │ ├── generate-report.md # migration-report.html (stakeholder + scenarios) +│ │ │ └── generate-eks.md # EKS manifests when design has EKS │ │ └── feedback/ │ │ └── feedback.md # Phase 6: Feedback collection (reuses shared) │ │ │ └── shared/ # heroku-to-aws's own shared references │ ├── README.md # what lives here + pointers to plugin-neutral shared data │ ├── heroku-pricing-cache.md # Heroku plan pricing (source-side baseline) -│ └── schema-discover-heroku.md # heroku-resource-inventory.json schema +│ ├── schema-discover-heroku.md # heroku-resource-inventory.json schema +│ └── schema-workshop-scenarios.md # scenarios/ + preferences.workshop contract │ ├── knowledge/design/ # design lookup DATA (pure data, referenced by │ │ # design.md _knowledge, gated per _when) @@ -168,8 +180,10 @@ heroku-to-aws/ The interpreter loop (`INTERPRETER.md` § The interpreter loop) drives phase sequencing, gates, and state. This section defines only the heroku-specific -checkpoint orchestration: WHERE the optional `feedback` checkpoint is offered (a -checkpoint's placement is orchestration prose, not part of the phase contract). +checkpoint orchestration: WHERE the optional `workshop` and `feedback` +checkpoints are offered (placement is orchestration prose, not part of the phase +contract). Both are `_kind: checkpoint` — off-backbone, trigger-entered, never +`current_phase`. > **Plan-share links are GATED OFF.** The share landing page > (`https://aws.amazon.com/startups/migrate/connect`) is not yet live (404). Do @@ -180,7 +194,12 @@ checkpoint's placement is orchestration prose, not part of the phase contract). - **After Discover**: No prompt. Proceed directly to Clarify. -- **After Estimate** (if `phases.feedback` is `"pending"`): Output to user: +- **After Estimate**: First offer the what-if workshop checkpoint per + `estimate-assemble.md` (Enter workshop / Proceed toward Generate). Outer + Estimate keeps `current_phase: estimate` until workshop is resolved (entered + then exited via `workshop-assemble.md`, or declined). If the user enters + workshop, follow `references/phases/workshop/workshop.md`. Then, if + `phases.feedback` is `"pending"`: ``` Would you like to share quick feedback? (5 optional questions + @@ -194,6 +213,20 @@ checkpoint's placement is orchestration prose, not part of the phase contract). - If user picks **A** → Load `references/phases/feedback/feedback.md`, execute it. Set `phases.feedback` to `"completed"`. Continue to Generate. - If user picks **B** → Set `phases.feedback` to `"completed"`. Continue to Generate. +- **Workshop resume (mandatory):** If `current_phase == "estimate"` AND + `phases.estimate == "completed"` AND `phases.workshop` is `"pending"` or + `"in_progress"`, **do not recompute Estimate**. If `"pending"`, re-present the + post-Estimate workshop offer from `estimate-assemble.md`. If `"in_progress"`, + load `references/phases/workshop/workshop.md`. Generate must wait until + `phases.workshop == "completed"` (entered+exited or declined). + +- **Warm start / explicit what-if**: If the user says "what if", "reprice", + "workshop mode", or "compare scenarios" and Estimate artifacts already exist, + load `references/phases/workshop/workshop.md` directly (respect Generate + `_re_entry_guard` when Terraform was already produced). Knobs on the pilot + sheet: region, HA, compute target, cost optimization, CPU architecture + (x86 vs Graviton). There is no traffic-multiplier knob in v1. + - **After Generate**: No prompt. If `phases.feedback` is still `"pending"`, set it to `"completed"` and mark the migration complete. **Critical constraint**: Follow each phase reference file's workflow exactly. If unable to complete a step, stop and report the specific issue. Do not fabricate or infer data. diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/dyno-eb-sizing.json b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/dyno-eb-sizing.json index 29633059..505774b4 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/dyno-eb-sizing.json +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/dyno-eb-sizing.json @@ -1,5 +1,5 @@ { - "_comment": "Dyno type -> Elastic Beanstalk EC2 instance sizing. DIRECT LOOKUP: read ec2_instance_type column; do NOT recompute it. heroku_memory_mb is PROVENANCE ONLY (why this row selects that instance type). All instance types are valid for EB Docker platform on AL2023.", + "_comment": "Dyno type -> Elastic Beanstalk EC2 instance sizing. DIRECT LOOKUP: read ec2_instance_type column; do NOT recompute it. heroku_memory_mb is PROVENANCE ONLY (why this row selects that instance type). All instance types are valid for EB Docker platform on AL2023. When preferences.workshop.cpu_architecture is arm64, Design uses ec2_instance_type_arm64 when present; otherwise falls back to ec2_instance_type with warning architecture_fallback_x86.", "_match": "exact, case-insensitive, on formation config.dyno_type", "_on_not_found": "reject the formation; produce NO EB entry; append warning 'Unsupported dyno type: {dyno_type}. Cannot map to Elastic Beanstalk. Please contact support or provide manual sizing.'; continue", "_environment_type": { @@ -14,88 +14,109 @@ "eco": { "heroku_memory_mb": 512, "ec2_instance_type": "t3.micro", - "_note": "Minimal dev workloads; t3.micro is marginal for Docker — consider t3.small for production" + "_note": "Minimal dev workloads; t3.micro is marginal for Docker \u2014 consider t3.small for production", + "ec2_instance_type_arm64": "t4g.micro" }, "basic": { "heroku_memory_mb": 512, "ec2_instance_type": "t3.micro", - "_note": "Minimal dev workloads; t3.micro is marginal for Docker — consider t3.small for production" + "_note": "Minimal dev workloads; t3.micro is marginal for Docker \u2014 consider t3.small for production", + "ec2_instance_type_arm64": "t4g.micro" }, "standard-1x": { "heroku_memory_mb": 512, - "ec2_instance_type": "t3.small" + "ec2_instance_type": "t3.small", + "ec2_instance_type_arm64": "t4g.small" }, "standard-2x": { "heroku_memory_mb": 1024, - "ec2_instance_type": "t3.medium" + "ec2_instance_type": "t3.medium", + "ec2_instance_type_arm64": "t4g.medium" }, "performance-m": { "heroku_memory_mb": 2560, - "ec2_instance_type": "m5.large" + "ec2_instance_type": "m5.large", + "ec2_instance_type_arm64": "m6g.large" }, "performance-l": { "heroku_memory_mb": 14336, - "ec2_instance_type": "m5.xlarge" + "ec2_instance_type": "m5.xlarge", + "ec2_instance_type_arm64": "m6g.xlarge" }, "performance-l-ram": { "heroku_memory_mb": 30720, - "ec2_instance_type": "r5.xlarge" + "ec2_instance_type": "r5.xlarge", + "ec2_instance_type_arm64": "r6g.xlarge" }, "performance-xl": { "heroku_memory_mb": 63488, - "ec2_instance_type": "m5.4xlarge" + "ec2_instance_type": "m5.4xlarge", + "ec2_instance_type_arm64": "m6g.4xlarge" }, "performance-2xl": { "heroku_memory_mb": 129024, - "ec2_instance_type": "m5.8xlarge" + "ec2_instance_type": "m5.8xlarge", + "ec2_instance_type_arm64": "m6g.8xlarge" }, "private-s": { "heroku_memory_mb": 1024, - "ec2_instance_type": "t3.medium" + "ec2_instance_type": "t3.medium", + "ec2_instance_type_arm64": "t4g.medium" }, "private-m": { "heroku_memory_mb": 2560, - "ec2_instance_type": "m5.large" + "ec2_instance_type": "m5.large", + "ec2_instance_type_arm64": "m6g.large" }, "private-l": { "heroku_memory_mb": 14336, - "ec2_instance_type": "m5.xlarge" + "ec2_instance_type": "m5.xlarge", + "ec2_instance_type_arm64": "m6g.xlarge" }, "private-l-ram": { "heroku_memory_mb": 30720, - "ec2_instance_type": "r5.xlarge" + "ec2_instance_type": "r5.xlarge", + "ec2_instance_type_arm64": "r6g.xlarge" }, "private-xl": { "heroku_memory_mb": 63488, - "ec2_instance_type": "m5.4xlarge" + "ec2_instance_type": "m5.4xlarge", + "ec2_instance_type_arm64": "m6g.4xlarge" }, "private-2xl": { "heroku_memory_mb": 129024, - "ec2_instance_type": "m5.8xlarge" + "ec2_instance_type": "m5.8xlarge", + "ec2_instance_type_arm64": "m6g.8xlarge" }, "shield-s": { "heroku_memory_mb": 1024, - "ec2_instance_type": "t3.medium" + "ec2_instance_type": "t3.medium", + "ec2_instance_type_arm64": "t4g.medium" }, "shield-m": { "heroku_memory_mb": 2560, - "ec2_instance_type": "m5.large" + "ec2_instance_type": "m5.large", + "ec2_instance_type_arm64": "m6g.large" }, "shield-l": { "heroku_memory_mb": 14336, - "ec2_instance_type": "m5.xlarge" + "ec2_instance_type": "m5.xlarge", + "ec2_instance_type_arm64": "m6g.xlarge" }, "shield-l-ram": { "heroku_memory_mb": 30720, - "ec2_instance_type": "r5.xlarge" + "ec2_instance_type": "r5.xlarge", + "ec2_instance_type_arm64": "r6g.xlarge" }, "shield-xl": { "heroku_memory_mb": 63488, - "ec2_instance_type": "m5.4xlarge" + "ec2_instance_type": "m5.4xlarge", + "ec2_instance_type_arm64": "m6g.4xlarge" }, "shield-2xl": { "heroku_memory_mb": 129024, - "ec2_instance_type": "m5.8xlarge" + "ec2_instance_type": "m5.8xlarge", + "ec2_instance_type_arm64": "m6g.8xlarge" } } } diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/eks-pod-sizing.json b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/eks-pod-sizing.json index 8195c33f..117e8f10 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/eks-pod-sizing.json +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/knowledge/design/eks-pod-sizing.json @@ -2,143 +2,160 @@ "_comment": "Dyno type -> EKS pod sizing + node selection. Used by the design EKS branch when design_constraints.compute_target.value is eks-managed or eks-or-ecs. DIRECT LOOKUP: read req_cpu/req_mem/lim_cpu/lim_mem + node_type per dyno. This file holds DATA only (pod rows, node-rank, cluster constants); the node-group SIZING ALGORITHM lives in design-eks.md prose.", "_match": "exact, case-insensitive, on formation config.dyno_type", "_on_not_found": "reject the formation; produce NO entry; warn 'Unsupported dyno type: {dyno_type}. Cannot map to EKS.'; continue (same as the Fargate path)", - "rows": { "standard-1x": { "req_cpu": "250m", "req_mem": "512Mi", "lim_cpu": "500m", "lim_mem": "512Mi", - "node_type": "m6i.large" + "node_type": "m6i.large", + "node_type_arm64": "m6g.large" }, "standard-2x": { "req_cpu": "500m", "req_mem": "1024Mi", "lim_cpu": "1000m", "lim_mem": "1024Mi", - "node_type": "m6i.large" + "node_type": "m6i.large", + "node_type_arm64": "m6g.large" }, "performance-m": { "req_cpu": "1000m", "req_mem": "2560Mi", "lim_cpu": "2000m", "lim_mem": "2560Mi", - "node_type": "m6i.xlarge" + "node_type": "m6i.xlarge", + "node_type_arm64": "m6g.xlarge" }, "performance-l": { "req_cpu": "4000m", "req_mem": "14336Mi", "lim_cpu": "8000m", "lim_mem": "14336Mi", - "node_type": "m6i.4xlarge" + "node_type": "m6i.4xlarge", + "node_type_arm64": "m6g.4xlarge" }, "performance-l-ram": { "req_cpu": "4000m", "req_mem": "30720Mi", "lim_cpu": "8000m", "lim_mem": "30720Mi", - "node_type": "r6i.4xlarge" + "node_type": "r6i.4xlarge", + "node_type_arm64": "r6g.4xlarge" }, "performance-xl": { "req_cpu": "8000m", "req_mem": "63488Mi", "lim_cpu": "16000m", "lim_mem": "63488Mi", - "node_type": "m6i.8xlarge" + "node_type": "m6i.8xlarge", + "node_type_arm64": "m6g.8xlarge" }, "performance-2xl": { "req_cpu": "16000m", "req_mem": "129024Mi", "lim_cpu": "32000m", "lim_mem": "129024Mi", - "node_type": "m6i.16xlarge" + "node_type": "m6i.16xlarge", + "node_type_arm64": "m6g.16xlarge" }, "private-s": { "req_cpu": "500m", "req_mem": "1024Mi", "lim_cpu": "1000m", "lim_mem": "1024Mi", - "node_type": "m6i.large" + "node_type": "m6i.large", + "node_type_arm64": "m6g.large" }, "private-m": { "req_cpu": "1000m", "req_mem": "2560Mi", "lim_cpu": "2000m", "lim_mem": "2560Mi", - "node_type": "m6i.xlarge" + "node_type": "m6i.xlarge", + "node_type_arm64": "m6g.xlarge" }, "private-l": { "req_cpu": "4000m", "req_mem": "14336Mi", "lim_cpu": "8000m", "lim_mem": "14336Mi", - "node_type": "m6i.4xlarge" + "node_type": "m6i.4xlarge", + "node_type_arm64": "m6g.4xlarge" }, "private-l-ram": { "req_cpu": "4000m", "req_mem": "30720Mi", "lim_cpu": "8000m", "lim_mem": "30720Mi", - "node_type": "r6i.4xlarge" + "node_type": "r6i.4xlarge", + "node_type_arm64": "r6g.4xlarge" }, "private-xl": { "req_cpu": "8000m", "req_mem": "63488Mi", "lim_cpu": "16000m", "lim_mem": "63488Mi", - "node_type": "m6i.8xlarge" + "node_type": "m6i.8xlarge", + "node_type_arm64": "m6g.8xlarge" }, "private-2xl": { "req_cpu": "16000m", "req_mem": "129024Mi", "lim_cpu": "32000m", "lim_mem": "129024Mi", - "node_type": "m6i.16xlarge" + "node_type": "m6i.16xlarge", + "node_type_arm64": "m6g.16xlarge" }, "shield-s": { "req_cpu": "500m", "req_mem": "1024Mi", "lim_cpu": "1000m", "lim_mem": "1024Mi", - "node_type": "m6i.large" + "node_type": "m6i.large", + "node_type_arm64": "m6g.large" }, "shield-m": { "req_cpu": "1000m", "req_mem": "2560Mi", "lim_cpu": "2000m", "lim_mem": "2560Mi", - "node_type": "m6i.xlarge" + "node_type": "m6i.xlarge", + "node_type_arm64": "m6g.xlarge" }, "shield-l": { "req_cpu": "4000m", "req_mem": "14336Mi", "lim_cpu": "8000m", "lim_mem": "14336Mi", - "node_type": "m6i.4xlarge" + "node_type": "m6i.4xlarge", + "node_type_arm64": "m6g.4xlarge" }, "shield-l-ram": { "req_cpu": "4000m", "req_mem": "30720Mi", "lim_cpu": "8000m", "lim_mem": "30720Mi", - "node_type": "r6i.4xlarge" + "node_type": "r6i.4xlarge", + "node_type_arm64": "r6g.4xlarge" }, "shield-xl": { "req_cpu": "8000m", "req_mem": "63488Mi", "lim_cpu": "16000m", "lim_mem": "63488Mi", - "node_type": "m6i.8xlarge" + "node_type": "m6i.8xlarge", + "node_type_arm64": "m6g.8xlarge" }, "shield-2xl": { "req_cpu": "16000m", "req_mem": "129024Mi", "lim_cpu": "32000m", "lim_mem": "129024Mi", - "node_type": "m6i.16xlarge" + "node_type": "m6i.16xlarge", + "node_type_arm64": "m6g.16xlarge" } }, - "node_size_rank": { "_comment": "Rank for the 'largest-pod-class-wins' node selection: pick the node_type of the largest dyno present across all formations. Higher rank = larger. Tie at rank 3 (m6i.4xlarge vs r6i.4xlarge): prefer m6i.4xlarge UNLESS a RAM-optimized dyno (*-l-ram) is the only dyno at that rank, in which case use r6i.4xlarge.", "m6i.large": 1, @@ -148,16 +165,32 @@ "m6i.8xlarge": 4, "m6i.16xlarge": 5 }, - - "system_overhead_per_node": { "cpu": "500m", "memory": "512Mi" }, - - "_node_capacity_note": "PROVENANCE / verification-only — NOT read at runtime. The node-group sizing algorithm (design-eks.md) selects instance type by node_size_rank and computes node count as ceil(total_pods / 4) with min_size=2; it does NOT bin-pack against these allocatable figures. This block records WHY each node_type was chosen and why '>=4 pods per node' holds, so the node_type recommendations, the system_overhead totals, and the /4 divisor can be re-derived if dyno classes or overhead change. Allocatable = total minus system_overhead_per_node. Migrated from the former design-refs/eks-mapping-table.md 'Node Capacity Validation' + 'System Overhead Per Node' tables.", + "system_overhead_per_node": { + "cpu": "500m", + "memory": "512Mi" + }, + "_node_capacity_note": "PROVENANCE / verification-only \u2014 NOT read at runtime. The node-group sizing algorithm (design-eks.md) selects instance type by node_size_rank and computes node count as ceil(total_pods / 4) with min_size=2; it does NOT bin-pack against these allocatable figures. This block records WHY each node_type was chosen and why '>=4 pods per node' holds, so the node_type recommendations, the system_overhead totals, and the /4 divisor can be re-derived if dyno classes or overhead change. Allocatable = total minus system_overhead_per_node. Migrated from the former design-refs/eks-mapping-table.md 'Node Capacity Validation' + 'System Overhead Per Node' tables.", "_system_overhead_breakdown": { - "kubelet": { "cpu": "~100m", "memory": "256Mi" }, - "kube-proxy": { "cpu": "~100m", "memory": "128Mi" }, - "aws-vpc-cni": { "cpu": "~10m per ENI", "memory": "128Mi" }, - "daemonsets_total": { "cpu": "~290m", "memory": "-" }, - "total": { "cpu": "500m", "memory": "512Mi" } + "kubelet": { + "cpu": "~100m", + "memory": "256Mi" + }, + "kube-proxy": { + "cpu": "~100m", + "memory": "128Mi" + }, + "aws-vpc-cni": { + "cpu": "~10m per ENI", + "memory": "128Mi" + }, + "daemonsets_total": { + "cpu": "~290m", + "memory": "-" + }, + "total": { + "cpu": "500m", + "memory": "512Mi" + } }, "_node_capacity": { "m6i.large": { @@ -165,51 +198,83 @@ "memory": "8192Mi", "allocatable_cpu": "1500m", "allocatable_memory": "7680Mi", - "fits_ge_4_pods_of": ["standard-1x", "standard-2x", "private-s", "shield-s"] + "fits_ge_4_pods_of": [ + "standard-1x", + "standard-2x", + "private-s", + "shield-s" + ] }, "m6i.xlarge": { "vcpu": 4, "memory": "16384Mi", "allocatable_cpu": "3500m", "allocatable_memory": "15872Mi", - "fits_ge_4_pods_of": ["performance-m", "private-m", "shield-m"] + "fits_ge_4_pods_of": [ + "performance-m", + "private-m", + "shield-m" + ] }, "m6i.4xlarge": { "vcpu": 16, "memory": "65536Mi", "allocatable_cpu": "15500m", "allocatable_memory": "65024Mi", - "fits_ge_4_pods_of": ["performance-l", "private-l", "shield-l"] + "fits_ge_4_pods_of": [ + "performance-l", + "private-l", + "shield-l" + ] }, "r6i.4xlarge": { "vcpu": 16, "memory": "131072Mi", "allocatable_cpu": "15500m", "allocatable_memory": "130560Mi", - "fits_ge_4_pods_of": ["performance-l-ram", "private-l-ram", "shield-l-ram"] + "fits_ge_4_pods_of": [ + "performance-l-ram", + "private-l-ram", + "shield-l-ram" + ] }, "m6i.8xlarge": { "vcpu": 32, "memory": "131072Mi", "allocatable_cpu": "31500m", "allocatable_memory": "130560Mi", - "fits_ge_4_pods_of": ["performance-xl", "private-xl", "shield-xl"] + "fits_ge_4_pods_of": [ + "performance-xl", + "private-xl", + "shield-xl" + ] }, "m6i.16xlarge": { "vcpu": 64, "memory": "262144Mi", "allocatable_cpu": "63500m", "allocatable_memory": "261632Mi", - "fits_ge_4_pods_of": ["performance-2xl", "private-2xl", "shield-2xl"] + "fits_ge_4_pods_of": [ + "performance-2xl", + "private-2xl", + "shield-2xl" + ] } }, - "cluster": { "cluster_name": "heroku-migration-cluster", "kubernetes_version_fallback": "1.31", "_kubernetes_version_note": "FALLBACK only. The design-eks procedure should query the latest EKS-supported stable version at generation time (aws eks describe-addon-versions) and use this value only when the query is unavailable. Do NOT treat 1.31 as pinned.", - "node_group_type_by_pref": { "eks-managed": "self-managed", "eks-or-ecs": "managed" }, + "node_group_type_by_pref": { + "eks-managed": "self-managed", + "eks-or-ecs": "managed" + }, "_node_group_note": "eks-managed -> self-managed node groups (full K8s control); eks-or-ecs -> managed node groups (less operational burden).", - "addons": ["vpc-cni", "coredns", "kube-proxy", "aws-load-balancer-controller"] + "addons": [ + "vpc-cni", + "coredns", + "kube-proxy", + "aws-load-balancer-controller" + ] } } diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/clarify/clarify-assemble.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/clarify/clarify-assemble.md index a41e18e9..6c5b7c71 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/clarify/clarify-assemble.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/clarify/clarify-assemble.md @@ -97,6 +97,11 @@ Write `$MIGRATION_DIR/preferences.json`: } ``` +Do **not** write a `workshop` object from Clarify. The what-if workshop +(`references/phases/workshop/`) creates/patches `preferences.workshop` later +(`cpu_architecture`, `active`, `active_scenario_id`, `last_sheet_at`). See +`references/shared/schema-workshop-scenarios.md`. + ### Schema Rules 1. The `sources` object records how each question was answered: `"user"` (explicitly answered), `"default"` (system default applied, including skipped questions and "use defaults for the rest"). @@ -108,6 +113,7 @@ Write `$MIGRATION_DIR/preferences.json`: 7. `data.database_ha`, `data.redis_ha`, `data.kafka_retention_days` are omitted entirely when those services are not present in the inventory. 8. `design_constraints.compute_target` uses the structured Q12c shape (`default`, `overrides`, `chosen_by`, `recommendation`). Existing reused preferences with legacy `compute_target.value` may be read by Design for backward compatibility, but newly assembled preferences MUST write the structured shape. 9. `design_constraints.eb_deploy_method` is required when the resolved compute plan includes at least one Elastic Beanstalk formation; omit it for all-Fargate or all-EKS targets. +10. Omit `workshop` on Clarify assemble — workshop mode owns that object. --- diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-assemble.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-assemble.md index 1d3e4463..d46e112f 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-assemble.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-assemble.md @@ -53,6 +53,11 @@ Step 7, then emit `GATE_FAIL` (STOP; do not patch artifacts) or ## Step 8: Update Phase Status and Hand Off +> **Inner workshop reprice:** When Design is invoked from +> `workshop-refresh.md`, stop after writing `aws-design.json` (and soft +> validation). Skip this Step 8 and do **not** emit `HANDOFF_OK` — see +> `workshop-refresh.md` § Inner runs. + Only after `HANDOFF_OK`, apply the phase-status update protocol (`INTERPRETER.md` § The interpreter loop) — mark `phases.design` completed and advance per `_advances_to` — in the **same turn** as the output message below. Output to user — build message from design contents: diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-eks.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-eks.md index 6031c35a..5bbc206b 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-eks.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-eks.md @@ -36,7 +36,7 @@ When the compute target preference indicates EKS: 3. **Node group sizing:** - Determine the largest dyno type present across all formations. - - Select instance type using the **largest-pod-class-wins** rule: use the recommended `node_type` for the largest dyno present, ranked by the JSON's `node_size_rank` (higher = larger). On a rank tie between `m6i.4xlarge` and `r6i.4xlarge`, prefer `m6i.4xlarge` unless a RAM-optimized dyno (`*-l-ram`) is the only dyno at that rank, in which case use `r6i.4xlarge`. All pods from smaller classes fit on those nodes with room to spare. + - Select instance type using the **largest-pod-class-wins** rule: use the recommended `node_type` (or `node_type_arm64` when `preferences.workshop.cpu_architecture` is `arm64` and that column exists — see `design-mapping.md` CPU architecture resolution) for the largest dyno present, ranked by the JSON's `node_size_rank` (higher = larger). On a rank tie between `m6i.4xlarge` and `r6i.4xlarge` (or their `m6g`/`r6g` arm counterparts), prefer the general-purpose family unless a RAM-optimized dyno (`*-l-ram`) is the only dyno at that rank. All pods from smaller classes fit on those nodes with room to spare. - Calculate node count: - `min_size` = 2 (HA) - `desired_size` = `max(min_size, ceil(total_pods / 4))` — clamp UP to `min_size`; AWS rejects `desired_size < min_size`, which would otherwise happen for small workloads (`total_pods <= 4`). diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-mapping.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-mapping.md index 41431230..a0868467 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-mapping.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/design/design-mapping.md @@ -56,6 +56,21 @@ Extract from `preferences.json`: - `data.database_ha` → overrides availability for database specifically (if present) - `data.redis_ha` → Redis HA configuration - `operational.log_retention_days` → CloudWatch log retention +- `operational.cost_optimization` → narrative bias only in v1 (conservative/balanced/aggressive); does not invent instance sizes outside the sizing tables +- `workshop.cpu_architecture` → `"x86_64"` (default when absent) or `"arm64"` (Graviton-capable classes where tables provide them) + +### CPU architecture resolution (workshop) + +Resolve once before mapping formations: + +1. `cpu_architecture` = `preferences.workshop.cpu_architecture` if set, else `"x86_64"`. +2. When `"arm64"`: + - **Elastic Beanstalk**: use `ec2_instance_type_arm64` from the matched `dyno-eb-sizing.json` row when present; else use `ec2_instance_type` and append warning `architecture_fallback_x86` naming the formation. + - **Fargate**: set `aws_config.cpu_architecture` to `"ARM64"` (task size from `dyno-fargate-sizing.json` unchanged — Fargate ARM is a runtime arch flag). Prefer Estimate rates `fargate.per_vcpu_hour_arm64` / `per_gb_mem_hour_arm64` when present. + - **EKS**: use `node_type_arm64` from `eks-pod-sizing.json` when present; else `node_type` + `architecture_fallback_x86` warning. + - **RDS / ElastiCache**: sizing tables already emit Graviton classes (`db.t4g.*`, `db.m6g.*`, `cache.t4g.*`, etc.) — leave as-is. Do not invent non-table classes. +3. When `"x86_64"`: use the default x86 columns (`ec2_instance_type`, `node_type`); for Fargate set `aws_config.cpu_architecture` to `"X86_64"` or omit (platform default). +4. Never invent instance types or rates. Honest fallback + warning only. --- @@ -119,7 +134,9 @@ Continue with the next resource after the Fargate entry is appended. This guard Do NOT produce an EB mapping for this formation. Continue to next resource. - - **If found**: Extract `ec2_instance_type` from the matched row. + - **If found**: Extract the instance type using the CPU architecture + resolution above (`ec2_instance_type_arm64` when `cpu_architecture` is + `arm64` and the column exists; otherwise `ec2_instance_type`). 4. **Environment type and tier**: @@ -140,7 +157,8 @@ Continue with the next resource after the Fargate entry is appended. This guard "aws_config": { "region": "{target_region}", "platform": "Docker running on 64bit Amazon Linux 2023", - "instance_type": "", + "instance_type": "", + "cpu_architecture": "", "environment_type": "", "tier": "WebServer", "min_instances": 1, @@ -194,6 +212,7 @@ Continue with the next resource after the Fargate entry is appended. This guard "region": "{target_region}", "task_cpu": , "task_memory": , + "cpu_architecture": "", "desired_count": , "container_image": "placeholder:{heroku_app}-{process_type}", "process_type": "{process_type}", 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 b75ddd37..d51fdd7e 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 @@ -51,6 +51,17 @@ This is deliberately the OPPOSITE sign of Heroku) — same fact, savings-vs-difference framing. When presenting either, always label the direction in words; never print a bare signed value. +Also attach optional workshop metadata when present (does not affect Property-16): + +```json +{ + "workshop": { + "scenario_id": "", + "region_note": "" + } +} +``` + Write to `$MIGRATION_DIR/estimation-infra.json`. --- @@ -62,12 +73,19 @@ and enforced per `INTERPRETER.md` § Gate protocol: **re-read `estimation-infra. from disk**, run the mechanical checks (`_check_file_exists` / `_validate_json`) and the `_assert` judgment checks (recommendation shape, the Property-16 total-invariant, every-service-priced, complexity tier), then emit `GATE_FAIL` (do NOT patch artifacts; -STOP) or `HANDOFF_OK | phase=estimate | artifacts=estimation-infra.json` and advance. +STOP) or `HANDOFF_OK | phase=estimate | artifacts=estimation-infra.json`. One check needs this fragment's context: `estimation-infra.json` must also pass `references/vendored/estimate/estimation-infra.schema.json` validation (the schema shape) — verify that as part of the `_validate_json` postcondition. +### Inner workshop reprice — skip this gate's state transition + +When invoked from `workshop-refresh.md` (inner reprice): write +`estimation-infra.json`, optionally soft-check Property-16, present a brief +summary, then **return to the workshop loop**. Do **not** emit `HANDOFF_OK`, do +**not** update `.phase-status.json`, do **not** offer the what-if workshop below. + --- ## Present Summary @@ -88,3 +106,43 @@ After writing `estimation-infra.json`, present a concise summary to the user: 8. **Recommendation**: `path_label` with one-line justification Keep under 25 lines. The user can ask for details or re-read `estimation-infra.json`. + +--- + +## Phase status after outer Estimate (deferred Generate advance) + +After outer-run `HANDOFF_OK` (not an inner workshop reprice): + +1. Mark `phases.estimate` → `"completed"`. +2. Ensure `phases.workshop` exists (seed `"pending"` if the key is missing). +3. **Do not** set `current_phase` to `"generate"` yet — leave `current_phase` at + `"estimate"` until the workshop checkpoint is resolved (entered then exited, or + declined). This matches checkpoint semantics: workshop never owns + `current_phase`, and mid-workshop fixtures correctly stay on `estimate`. +4. Offer the what-if workshop below. + +--- + +## Post-Estimate: What-If Workshop Offer + +After outer-run `HANDOFF_OK`, the summary above, and the deferred phase-status +update — offer: + +``` +Estimate complete. Before Generate, you can run a what-if workshop: +change region, HA, compute target, or CPU architecture (x86 vs Graviton) +and compare priced scenarios without re-discovering inventory. + +[A] Enter what-if workshop +[B] Proceed toward Generate +``` + +- **A** → Load `references/phases/workshop/workshop.md` (checkpoint) and follow it + (baseline capture if `scenarios/` missing, then the sheet). Keep + `current_phase: estimate`; set `phases.workshop` → `"in_progress"`. +- **B** → Mark `phases.workshop` → `"completed"` (resolved/declined — no + `scenarios/` required). Set `current_phase` → `"generate"`. Continue with the + Feedback/Generate checkpoints in `SKILL.md`. + +On first workshop entry after this Estimate, `workshop-refresh.md` baseline +capture snapshots the current artifacts as `scenario-001` before any edits. 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 ba6f5493..76e74c8b 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 @@ -27,6 +27,23 @@ Read `references/vendored/pricing/aws-infra-pricing.json` (shared AWS infrastruc - If ≤ 30 days old: **Cached prices are the primary source.** No MCP calls needed for services listed in the file. Set `pricing_source: "cached"`. - If > 30 days old: Infrastructure prices (Fargate, RDS, S3, etc.) remain reliable. Attempt MCP (Step 0b) for services not in the file; use the cached rates as fallback with `pricing_source: "cached_stale"`. +**Region honesty:** Read `preferences.json.global.target_region`. The pricing cache +`_meta.region` is `us-east-1`. When `target_region` differs: + +1. Prefer MCP `get_pricing` with that region for services you look up live. +2. When still using cache rates, set + `workshop.region_note` (and surface in the estimate narrative): + `"Rates from us-east-1 cache applied to {target_region} — verify via awspricing MCP for regional deltas."` +3. When regions match, set `workshop.region_note` to `null` / omit. + +**Scenario metadata:** If `preferences.workshop.active_scenario_id` is set, carry +it into the cost-engine contribution as `workshop.scenario_id` for the assembler. + +**Fargate arch rates:** When a Fargate service has `aws_config.cpu_architecture` +of `ARM64` (or preferences `workshop.cpu_architecture` is `arm64`), use +`fargate.per_vcpu_hour_arm64` / `fargate.per_gb_mem_hour_arm64` when present; +else x86 Fargate rates + warning. + Each service object carries its rates and (where relevant) a `multi_az_handling` key. Look up the rates from there — do not hardcode them. Apply the cost formula from the Per-Service Calculation Formulas table below. ### Step 0b: MCP Availability Check (only if cache stale or service not listed) diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate.md index 24448cc1..d4176b66 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/estimate/estimate.md @@ -75,8 +75,11 @@ Composed of a cost-engine fragment + one assembler (declared in the frontmatter `_fragments`/`_assemble`); the interpreter runs the fragment, then the assembler, and evaluates the `_knowledge` guards to load the pricing/defaults/tier data. The fragment selects the pricing mode and computes the financial picture; the assembler -writes the final artifact, runs the completion gate, and presents the summary — read -each unit file for its own contract. +writes the final artifact, runs the completion gate, presents the summary, and +**offers the optional what-if workshop checkpoint** +(`references/phases/workshop/workshop.md`, `_kind: checkpoint`) before Generate — +read each unit file for its own contract. Outer Estimate defers +`current_phase → generate` until workshop is resolved (see `estimate-assemble.md`). --- diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-assemble.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-assemble.md index 435c369b..d7de47f2 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-assemble.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-assemble.md @@ -4,6 +4,7 @@ _of_phase: generate _reads: - terraform (fragment contribution) - docs (fragment contribution) + - report (fragment contribution) - eks-generate (fragment contribution, when EKS in design) _produces: - generation-warnings.json @@ -12,19 +13,20 @@ _produces: # Generate — Validate and Assemble > **Assembler unit.** Runs after the generation fragments (`generate-terraform.md`, -> `generate-docs.md`, and `generate-eks.md` when EKS is in the design) have written -> their artifacts. It runs the cross-artifact validation (every-service-generated-or-warned, -> reference integrity, no `{{VAR}}` leak), enforces the completion handoff gate, -> and updates `.phase-status.json`. It owns the phase's final artifact-level contract. +> `generate-docs.md`, `generate-report.md`, and `generate-eks.md` when EKS is in +> the design) have written their artifacts. It runs the cross-artifact validation +> (every-service-generated-or-warned, reference integrity, no `{{VAR}}` leak), +> enforces the completion handoff gate, and updates `.phase-status.json`. It owns +> the phase's final artifact-level contract. --- ## Step 3: Validate Complete Artifact Set The full generated artifact set (core terraform files + MIGRATION_GUIDE.md + -README.md + generation-warnings.json) is gate-checked by this phase's -`_postconditions` (see the Completion Handoff Gate below). This assembler adds the -cross-artifact checks that span multiple fragment outputs: +README.md + migration-report.html + generation-warnings.json) is gate-checked by +this phase's `_postconditions` (see the Completion Handoff Gate below). This +assembler adds the cross-artifact checks that span multiple fragment outputs: **Cross-reference checks:** @@ -41,11 +43,18 @@ cross-artifact checks that span multiple fragment outputs: The completion checks are declared in this phase's `_postconditions` frontmatter and enforced per `INTERPRETER.md` § Gate protocol: re-read the generated artifacts from disk, run the mechanical checks (`_check_file_exists` for the core terraform files + -MIGRATION_GUIDE.md + README.md) and the `_assert` judgment checks (valid provider / -aws_region variable, a domain .tf beyond core, guide sections, conditional Postgres/ -Redis migration scripts, conditional EKS terraform + kubernetes manifests, every service -accounted for, no `{{VARIABLE}}` placeholders), then emit `GATE_FAIL` (STOP) or -`HANDOFF_OK | phase=generate | artifacts=terraform/,MIGRATION_GUIDE.md,README.md`. +MIGRATION_GUIDE.md + README.md + migration-report.html) and the `_assert` judgment +checks (valid provider / aws_region variable, a domain .tf beyond core, guide +sections, report sections, conditional Postgres/ Redis migration scripts, conditional +EKS terraform + kubernetes manifests, every service accounted for, no `{{VARIABLE}}` +placeholders), then emit `GATE_FAIL` (STOP) or +`HANDOFF_OK | phase=generate | artifacts=terraform/,MIGRATION_GUIDE.md,README.md,migration-report.html`. + +Optionally run +`python3 "$PLUGIN_ROOT/scripts/validate-heroku-migration-report.py" \ + "$MIGRATION_DIR/migration-report.html" --migration-dir "$MIGRATION_DIR"` +and treat exit `1` as `GATE_FAIL` for the report (repair HTML; do not delete +Terraform/docs). --- @@ -62,10 +71,12 @@ Artifacts produced: • terraform/ — [N] Terraform files for AWS infrastructure • MIGRATION_GUIDE.md — Step-by-step migration procedure • README.md — Artifact listing and quick start +• migration-report.html — Stakeholder summary (costs + what-if scenarios when present) • scripts/ — Database migration scripts [• generation-warnings.json — N service(s) require manual setup] (show this line only when warnings is non-empty; the file is always written) Migration planning is complete. All artifacts are in $MIGRATION_DIR/. +Share migration-report.html with stakeholders; use MIGRATION_GUIDE.md for cutover. ``` After this output, SKILL.md handles the post-Generate share prompt and feedback finalization. diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-docs.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-docs.md index e0437119..b20e6d8c 100644 --- a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-docs.md +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-docs.md @@ -812,6 +812,7 @@ This directory contains all artifacts needed to migrate your Heroku application( | `terraform/security-groups.tf` | Security group rules | | `MIGRATION_GUIDE.md` | Step-by-step migration procedure | | `README.md` | This file — artifact listing and quick start | +| `migration-report.html` | Stakeholder summary (costs + optional what-if scenarios); draft for review | {{IF has_postgres}} | `scripts/migrate-postgres.sh` | PostgreSQL data migration script | {{ENDIF}} @@ -826,6 +827,12 @@ This directory contains all artifacts needed to migrate your Heroku application( | `preferences.json` | Migration preferences (input) | | `aws-design.json` | Designed AWS architecture (input) | | `estimation-infra.json` | Cost estimates (input) | +| `scenarios/` | Optional what-if workshop snapshots (baseline + priced variants; see skill workshop docs) | + +> **SA tip:** After Estimate (before or instead of regenerating), you can re-enter +> what-if workshop mode to change region, HA, compute target, or Graviton +> preference and compare up to 5 scenarios without re-discovery. Generated +> `migration-report.html` includes the scenario comparison when variants exist. --- diff --git a/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-report.md b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-report.md new file mode 100644 index 00000000..cca04ea1 --- /dev/null +++ b/migrate/plugins/migration-to-aws/skills/heroku-to-aws/references/phases/generate/generate-report.md @@ -0,0 +1,158 @@ +--- +_fragment: report +_of_phase: generate +_contributes: + - migration-report.html +--- + +# Generate — Stakeholder HTML Report (Heroku) + +> Thin shareable HTML for SAs / founders. Not a full GCP/Vercel assessment +> clone — decision + costs + optional what-if scenarios, then point at +> `MIGRATION_GUIDE.md` for procedure. Runs **after** docs so the guide exists +> when the report links to it. + +**Execute ALL steps in order. Do not skip.** + +--- + +## Inputs + +| Artifact | Use | +| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `estimation-infra.json` | Recommendation path, three cost tiers, complexity | +| `aws-design.json` | Short service count / primary compute target | +| `preferences.json` | Region, HA, arch (active scenario = working tree) | +| `scenarios/index.json` + manifests **+ each scenario's `scenarios/scenario-NNN.preferences.json` / `.aws-design.json` copies** | What-if table when ≥2 scenarios — manifests carry the cost tiers/complexity; the per-scenario Region/HA/Compute/Arch columns come from the scenario's preferences/design copies | +| `MIGRATION_GUIDE.md` | Must already exist (docs fragment ran first) | + +--- + +## Step 1: Gather figures + +From `estimation-infra.json`: + +- `recommendation.path_label` (or `financial_summary.recommendation`) +- `projected_costs.aws_monthly_premium` / `_balanced` / `_optimized` +- `complexity_tier` +- `pricing_source.status` (for a one-line pricing confidence note) + +From design + preferences: region, primary compute (EB / Fargate / EKS), +service count. + +--- + +## Step 2: Write `migration-report.html` + +Write a **self-contained** HTML file to `$MIGRATION_DIR/migration-report.html` +(inline CSS only). Required section IDs: + +| Section ID | Content | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `decision-summary` | Verdict / path label, complexity, one-sentence next action | +| `exec-costs` | Heroku-vs-AWS side-by-side when a Heroku baseline exists (`current_costs.source != "unavailable"`); otherwise the AWS three-tier table with a one-line note that no Heroku baseline was available. Estimated monthly; Balanced primary | +| `next-steps` | Ordered list pointing to `MIGRATION_GUIDE.md` phases (not a procedure dump) | + +### Conditional — `what-if-scenarios` + +When `scenarios/index.json` exists and `scenarios[]` has **≥ 2** entries, +render `
` **after** `exec-costs` and **before** +`next-steps`: + +Render, in order: + +- Table columns (must stay in sync with `workshop-compare.md`'s table): + +| Scenario | Region | HA | Compute | Arch | Premium $/mo | Balanced $/mo | Optimized $/mo | Complexity | +| -------- | ------ | -- | ------- | ---- | ------------ | ------------- | -------------- | ---------- | + +- Mark the active row (`index.active_scenario_id`). +- For each scenario with a non-null `estimation_summary.calculator_url`, + render the scenario name as a link (or an adjacent "open in AWS Pricing + Calculator" link) — stakeholders can open and edit the estimate there. +- Under the table: active vs baseline knob deltas; any `region_note`; remind + inventory is frozen and Terraform matches the **active** scenario only. + +Omit the section when workshop was declined or never entered. + +### TOC + footer + +- `