diff --git a/AGENTS.md b/AGENTS.md index dab19d09e5c..786139e5636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,14 +48,21 @@ Verify your changes by following these guidelines: work prefixed with a "Prompt: " after a single line consisting of '---'. Make sure there are no empty lines before or after this line. Word wrap all paragraphs at 72 columns including the prompt. For the author of the commit, use the configured username in git with ' (AI)' appended and the user email. For example, `git commit --author="John Doe (AI) " -m "docs: update configuration guide"`. - To avoid issues with multi-line commit messages, write the message to `.commit-message.ai-generated.txt` and use `-F`: + To avoid issues with multi-line commit messages, write the message to `.commit-message.ai-generated.txt` **at the repository root** and use `-F` with the path relative to your cwd: ```bash - NODE_OPTIONS="--max-old-space-size=8192" git commit --author="John Doe (AI) " -F .commit-message.ai-generated.txt + # From packages/amplify-cli/: + NODE_OPTIONS="--max-old-space-size=8192" git commit --author="John Doe (AI) " -F ../../.commit-message.ai-generated.txt ``` Always set `NODE_OPTIONS="--max-old-space-size=8192"` when committing to prevent OOM failures in the lint-staged hook. - After a successful commit, delete the scratch file: `rm -f .commit-message.ai-generated.txt`. + After a successful commit, delete the scratch file: `rm -f ../../.commit-message.ai-generated.txt` (adjust the relative path to point to the repo root). + +- **CRITICAL: Always write `.commit-message.ai-generated.txt` to the repository root**, not inside a package directory. The `-F` path + in `git commit -F` is resolved relative to the cwd, so adjust the relative path accordingly (e.g., `../../.commit-message.ai-generated.txt` + when committing from `packages/amplify-cli/`). This prevents stale files from accumulating in package directories. +- The commit message subject line must be lowercase (commitlint enforces `subject-case`). Write `feat(scope): add feature` not + `feat(scope): Add feature`. - Since this repo has a commit hook that takes quite a long time to run, don't immediately commit every change you were asked to do. Apply your judgment, if the diff is still fairly small just keep going. @@ -68,7 +75,12 @@ Verify your changes by following these guidelines: ### 5. PR Stage -This stage prepares the PR description — the user is responsible for creating the actual PR. +Creating the PR means making sure everything meets our high bar and is +ready for peer review and merge. The user is responsible for actually +creating the PR, you are just preparing it. + +Ask the user which branch the PR will be targetting and inspect the entire diff. +Then, run the following phases, taking into account all the changes that were made: #### 5.1 Update Docs @@ -77,9 +89,16 @@ Documentation is updated at PR time — not per-commit — because code changes - Update the .md files in `docs/` that correspond to the code files you touched. - Update the appropriate skill files when a change impacts the contents of the skill. -#### 5.2 Create Body File +#### 5.2 Code Review + +Before creating the PR body, do a final pass over every file you touched: + +- Verify all code follows [CODING_GUIDELINES](./CODING_GUIDELINES.md) — read the guidelines file and check every rule against the code you touched. +- Update JSDoc on every public member that was added or changed. Be concise. + +#### 5.3 Create Body File -When asked to create a PR, generate a body into `.pr-body.ai-generated.md` and follow these guidelines: +When asked to create a PR, generate a body into `.pr-body.ai-generated.md` **at the repository root** (not inside a package directory) and follow these guidelines: - Use the PR template in `.github/PULL_REQUEST_TEMPLATE.md` as the structure. - Focus on **why** the change is being made and **what** it accomplishes, not the implementation details that are obvious from the diff. diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md index 8bd13545158..208c52895f8 100644 --- a/CODING_GUIDELINES.md +++ b/CODING_GUIDELINES.md @@ -106,6 +106,71 @@ Scattered client usage also risks inconsistent instantiation. For example, if AP --- +### Keep parallel structures symmetric + +When a class, interface, or module has two or more fields, methods, or data paths that serve the same role for different concerns, they should use the same types, the same access patterns, and the same naming conventions. Asymmetry — one field is a `Map` while its sibling is an `Array`, one uses a setter while its sibling uses a constructor parameter, one is optional while its sibling is required — signals that the design has drifted or that one path was added as an afterthought without aligning it with the existing one. + +Asymmetry is a code smell even when the code is functionally correct. A reader scanning the class sees two fields that should be peers but are shaped differently, and has to figure out _why_ — is there a semantic reason, or is it accidental? That investigation costs time and often reveals that the difference is accidental. + +This applies to naming too. When two sibling interfaces represent the same kind of thing for different domains, their property names should follow the same pattern. If one wraps its identity in a typed object (`resource: DiscoveredResource`), the other should too (`feature: DiscoveredFeature`) — not flatten it into loose fields (`feature: string; path: string`). And within those identity objects, analogous fields should use the same naming convention — if one has `resourceName`, the other should have `name`, not repeat the type (`feature.feature` stutters, `feature.name` doesn't). + +This extends to method signatures. Sibling methods that do the same thing for different domains should accept the same number of arguments in the same shape. If `recordFeature` takes a single `FeatureAssessment` object, `recordResource` should take a single `ResourceAssessment` object — not three positional arguments. And the parameter names should follow the same convention: if one is `feature: FeatureAssessment`, the other should be `resource: ResourceAssessment`, not `assessment: ResourceAssessment`. + +```typescript +// Bad — two collections serving the same role, different types +class Assessment { + private readonly _resources = new Map(); + private readonly _features: FeatureAssessment[] = []; +} + +// Good — both are arrays, same access pattern +class Assessment { + private readonly _resources: ResourceAssessment[] = []; + private readonly _features: FeatureAssessment[] = []; +} + +// Bad — sibling interfaces with asymmetric identity shapes +interface ResourceAssessment { + readonly resource: DiscoveredResource; // wrapped in typed object + readonly generate: SupportLevel; + readonly refactor: SupportLevel; +} +interface FeatureAssessment { + readonly feature: string; // flat string — asymmetric + readonly path: string; // flat string — asymmetric + readonly generate: SupportLevel; + readonly refactor: SupportLevel; +} + +// Good — both wrap identity in a typed object, same pattern +interface ResourceAssessment { + readonly resource: DiscoveredResource; + readonly generate: SupportLevel; + readonly refactor: SupportLevel; +} +interface FeatureAssessment { + readonly feature: DiscoveredFeature; + readonly generate: SupportLevel; + readonly refactor: SupportLevel; +} + +// Bad — sibling methods with asymmetric signatures +class Assessment { + recordResource(resource: DiscoveredResource, generate: SupportLevel, refactor: SupportLevel): void { ... } + recordFeature(feature: FeatureAssessment): void { ... } +} + +// Good — both take a single typed object, parameter named after the type +class Assessment { + recordResource(resource: ResourceAssessment): void { ... } + recordFeature(feature: FeatureAssessment): void { ... } +} +``` + +The test: look at sibling fields, sibling methods, sibling interfaces, or sibling parameters. If they serve analogous roles but differ in type, shape, naming pattern, or access pattern, ask whether the difference is justified by a real semantic distinction. If not, align them. + +--- + ## Mutability & State Management ### Minimize mutability diff --git a/docs/packages/amplify-cli/src/commands/gen2-migration.md b/docs/packages/amplify-cli/src/commands/gen2-migration.md index ec7da395473..c238c5a63b4 100644 --- a/docs/packages/amplify-cli/src/commands/gen2-migration.md +++ b/docs/packages/amplify-cli/src/commands/gen2-migration.md @@ -26,22 +26,17 @@ const rollingBack = (context.input.options ?? {})['rollback'] ?? false; ### Common Gen1 Configuration Extraction -Extracts shared Gen1 configuration (`appId`, `appName`, `envName`, `stackName`, `region`) once from state manager and Amplify service, -then passes these values to step constructors. This establishes a single source of truth; subcommands should use the injected values -rather than re-extracting them independently. +Creates a `Gen1App` facade that encapsulates all Gen1 app state — AWS clients, environment config, and the cloud backend snapshot. `Gen1App.create(context)` reads `team-provider-info.json`, fetches the app from the Amplify service, downloads the cloud backend from S3, and reads `amplify-meta.json`. The resulting instance is passed to all step constructors. ```ts -const appId = (Object.values(stateManager.getTeamProviderInfo())[0] as any).awscloudformation.AmplifyAppId; -const envName = localEnvName ?? migratingEnvName; -// ... extract other config values -const implementation: AmplifyMigrationStep = new step.class(logger, envName, appName, appId, stackName, region, context); +const gen1App = await Gen1App.create(context); +const implementation: AmplifyMigrationStep = new step.class(logger, gen1App, context); ``` ### Subcommand Dispatching Maps the subcommand name to its implementation class via the `STEPS` registry, then instantiates the step with extracted configuration. -The `assess` subcommand is intercepted before the `STEPS` lookup — it creates an `AmplifyMigrationAssessor` instead of a step, -calls `assess()` on the generate and refactor steps, and renders the result. +The `assess` subcommand is intercepted before the `STEPS` lookup — it creates an `AmplifyMigrationAssessor` with the `Gen1App` instance, calls `assess()` to collect resource and feature support levels, and prints the report. ### Plan-Based Execution @@ -116,7 +111,7 @@ flowchart LR [`src/commands/gen2-migration/_step.ts`](../../../packages/amplify-cli/src/commands/gen2-migration/_step.ts) -Abstract base class that defines the lifecycle contract for all migration steps. Each step returns a `Plan` from `forward()` and `rollback()`. +Abstract base class that defines the lifecycle contract for all migration steps. Constructor takes `(logger, gen1App, context)` — the `Gen1App` facade provides all app state. Each step returns a `Plan` from `forward()` and `rollback()`. ### `AmplifyMigrationOperation` @@ -173,6 +168,10 @@ amplify gen2-migration [options] - `SpinningLogger` is the only logger class — the deprecated `Logger` subclass has been removed. Import directly from `_spinning-logger.ts`. - Automatic rollback is enabled by default but can be disabled with `--no-rollback`. - The `--rollback` flag explicitly executes rollback operations for a step. +- `Gen1App` is the single facade for all Gen1 app state. It is created once in the dispatcher via `Gen1App.create(context)` and passed to all steps. Steps access `gen1App.appId`, `gen1App.region`, `gen1App.envName`, etc. instead of individual constructor params. +- `AwsClients` has a private constructor — use `AwsClients.create(context)` in production. Tests bypass this with `new (AwsClients as any)(...)`. +- Assessment uses a `Support` type with `level` and `note` fields. Each assessor provides its own note for unsupported entries. Use the `supported()`, `unsupported(note)`, `notApplicable()` helpers. +- `KNOWN_RESOURCE_KEYS` (in `gen1-app.ts`) defines all supported category:service pairs. Unknown resources get the `'UNKNOWN'` key. **Common pitfalls:** diff --git a/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md b/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md index a0bd50eecc7..f041fcb3d55 100644 --- a/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md +++ b/docs/packages/amplify-cli/src/commands/gen2-migration/assess.md @@ -1,100 +1,91 @@ # assess -The assess subcommand evaluates migration readiness for a Gen1 application. It reads the user's `amplify-meta.json`, queries the generate and refactor steps for each discovered resource, and renders a flat table showing support status per resource. +The assess subcommand evaluates migration readiness for a Gen1 application. It discovers all resources from `amplify-meta.json`, delegates to per-category assessors, and renders a table showing support status per resource and feature. -Unlike other gen2-migration subcommands, assess does not follow the `AmplifyMigrationStep` lifecycle (`validate → execute → rollback`). It is read-only and has no side effects. +Unlike other gen2-migration subcommands, assess does not follow the `AmplifyMigrationStep` lifecycle. It is read-only and has no side effects. ## Key Responsibilities - Discovers all resources from `amplify-meta.json` via `Gen1App.discover()` -- Creates an `Assessment` collector and passes it to the generate and refactor steps' `assess()` methods -- Each step iterates the discovered resources and records support via `assessment.record()` -- Renders a single flat table with Category, Resource, Service, Generate, and Refactor columns -- Displays a verdict: `✔ Migration can proceed.` or `✘ Migration blocked.` +- Delegates to per-category `Assessor` implementations that record resource-level and feature-level support +- Each assessor calls `assessment.recordResource()` and optionally `assessment.recordFeature()` for detected sub-features (overrides, custom policies) +- Renders a table with Category, Service, Resource, Generate, and Refactor columns +- The generate and refactor steps also use `Assessment.validFor(step)` during their validation phase ## Architecture -The assess command is handled as a special case in the gen2-migration dispatcher, intercepted after the shared config extraction but before the step lifecycle: +The assess command is handled as a special case in the gen2-migration dispatcher: ```mermaid flowchart TD - CLI["amplify gen2-migration assess"] --> DISPATCH["Dispatcher extracts appId, envName, etc."] - DISPATCH --> ASSESSOR["AmplifyMigrationAssessor"] - ASSESSOR --> ASSESSMENT["Assessment collector"] - ASSESSOR --> GEN["GenerateStep.assess(assessment)"] - ASSESSOR --> REF["RefactorStep.assess(assessment)"] - GEN -->|"record per resource"| ASSESSMENT - REF -->|"record per resource"| ASSESSMENT - ASSESSMENT --> DISPLAY["assessment.display()"] - DISPLAY --> TABLE["Flat table + verdict"] + CLI["amplify gen2-migration assess"] --> GEN1APP["Gen1App.create(context)"] + GEN1APP --> ASSESSOR["AmplifyMigrationAssessor(gen1App)"] + ASSESSOR --> DISCOVER["gen1App.discover()"] + DISCOVER --> SWITCH["Switch on resource.key"] + SWITCH --> AUTH["AuthCognitoAssessor"] + SWITCH --> S3["S3Assessor"] + SWITCH --> FUNC["FunctionAssessor"] + SWITCH --> OTHER["...other assessors"] + SWITCH --> UNKNOWN["UNKNOWN → unsupported"] + AUTH --> ASSESSMENT["Assessment collector"] + S3 --> ASSESSMENT + FUNC --> ASSESSMENT + OTHER --> ASSESSMENT + UNKNOWN --> ASSESSMENT + ASSESSMENT --> RENDER["assessment.render()"] ``` ### `AmplifyMigrationAssessor` [`src/commands/gen2-migration/assess.ts`](../../../../packages/amplify-cli/src/commands/gen2-migration/assess.ts) -Standalone class (not a step) that orchestrates the assessment. Creates generate and refactor step instances, calls `assess()` on each, then renders the result. +Standalone class (not a step). `assess()` returns an `Assessment` instance. `run()` calls `assess()` and prints the report. ### `Assessment` -[`src/commands/gen2-migration/_assessment.ts`](../../../../packages/amplify-cli/src/commands/gen2-migration/_assessment.ts) +[`src/commands/gen2-migration/assess/assessment.ts`](../../../../packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts) -Collector that steps contribute to during `assess()`. Each step calls `record('generate' | 'refactor', resource, response)` for every discovered resource. The `display()` method produces the terminal output. +Collector that assessors contribute to. Exposes `validFor('generate' | 'refactor')` for step validation, and `render()` for terminal output. Each entry uses the `Support` type with `level` and optional `note`. -### `SupportResponse` +### `Support` ```typescript -interface SupportResponse { - readonly supported: boolean; - readonly notes: readonly string[]; +interface Support { + readonly level: SupportLevel; // 'supported' | 'unsupported' | 'not-applicable' + readonly note?: string; // displayed in the table for unsupported entries } ``` -- `supported: true`, empty notes → `✔` -- `supported: true`, non-empty notes → `⚠` with notes -- `supported: false` → `✘` with status label +Helper functions: `supported()`, `unsupported(note)`, `notApplicable()`. -### `DiscoveredResource` +### `Assessor` -```typescript -interface DiscoveredResource { - readonly category: string; - readonly resourceName: string; - readonly service: string; - readonly key: ResourceKey; -} -``` +[`src/commands/gen2-migration/assess/assessor.ts`](../../../../packages/amplify-cli/src/commands/gen2-migration/assess/assessor.ts) -Produced by `Gen1App.discover()`, which iterates all categories in `amplify-meta.json` and extracts `(category, resourceName, service, key)` tuples. The `key` is a typed `category:service` pair from `SUPPORTED_RESOURCE_KEYS`, or `'unsupported'` for pairs the tool has no migration logic for. Skips internal categories (`providers`, `hosting`). Throws `AmplifyError` if a resource in a non-skipped category is missing the `service` field. +Interface with a single `record(assessment)` method. Each category has its own implementation. -## Blocker Condition +### `DiscoveredResource` -Migration is blocked if any resource has `refactor.supported === false`. Missing generate support is not a blocker — the user can write Gen2 code manually. +Produced by `Gen1App.discover()`. The `key` field is a typed `category:service` pair from `KNOWN_RESOURCE_KEYS`, or `'UNKNOWN'` for unrecognized pairs. ## Supported Resources -The same switch cases in each step's `assess()` and `execute()` methods define what's supported: - -| Category | Service | Generate | Refactor | -| --------- | ----------------------- | -------- | --------- | -| auth | Cognito | ✔ | ✔ | -| auth | Cognito-UserPool-Groups | ✔ | ✘ | -| storage | S3 | ✔ | ✔ | -| storage | DynamoDB | ✔ | ✔ | -| api | AppSync | ✔ | ✔ (no-op) | -| api | API Gateway | ✔ | ✔ (no-op) | -| analytics | Kinesis | ✔ | ✔ | -| function | Lambda | ✔ | ✔ (no-op) | - -Any other `(category, service)` pair gets `ResourceKey = 'unsupported'` and is marked unsupported for both steps. - -## ResourceKey and Exhaustive Switches - -`SUPPORTED_RESOURCE_KEYS` is a const array of all `category:service` pairs the tool supports. `ResourceKey` is the union of those pairs plus `'unsupported'`. Every switch on `resource.key` in the generate and refactor steps must handle all members — the ESLint `switch-exhaustiveness-check` rule enforces this at compile time. Adding a new pair to `SUPPORTED_RESOURCE_KEYS` forces every consumer to handle it. +| Category | Service | Generate | Refactor | +| --------- | ----------------------- | -------- | ----------- | +| auth | Cognito | ✔ | ✔ | +| auth | Cognito-UserPool-Groups | ✔ | ✔ | +| storage | S3 | ✔ | ✔ | +| storage | DynamoDB | ✔ | ✔ | +| api | AppSync | ✔ | n/a | +| api | API Gateway | ✔ | n/a | +| analytics | Kinesis | ✔ | ✔ | +| function | Lambda | ✔ | ✔ | +| geo | Map | ✔ | ✔ | +| geo | PlaceIndex | ✔ | ✔ | +| geo | GeofenceCollection | ✔ | unsupported | ## AI Development Notes -- The assess command reuses the same config extraction as other steps (appId, envName, stackName, region, logger) — no duplication. -- Adding support for a new resource type requires adding the pair to `SUPPORTED_RESOURCE_KEYS` in `gen1-app.ts`, then handling the new case in both `assess()` and `execute()` in the relevant step. The compiler enforces exhaustiveness. -- The `Assessment` class owns rendering — it produces a flat table with dynamic column widths and status text baked into the Generate/Refactor cells. -- `Gen1App.discover()` skips internal categories (`providers`, `hosting`) and throws on resources missing a `service` field. +- Adding a new resource type: add the pair to `KNOWN_RESOURCE_KEYS` in `gen1-app.ts`, create an assessor, handle the case in `assess.ts`, and in the generate/refactor steps. The compiler enforces exhaustiveness. +- The `Assessment` is also used by generate and refactor steps for validation — `validFor(step)` returns false if any resource or feature is unsupported for that step. +- Feature detection (overrides, custom policies) is assessor-specific. Each assessor checks for files in the cloud backend directory via `gen1App.fileExists()`. diff --git a/packages/amplify-cli/package.json b/packages/amplify-cli/package.json index 5d311cf5d93..01c9e0cce9e 100644 --- a/packages/amplify-cli/package.json +++ b/packages/amplify-cli/package.json @@ -132,6 +132,7 @@ "@aws-sdk/client-lambda": "^3.919.0", "@aws-sdk/client-s3": "^3.624.0", "@jest/globals": "^29.7.0", + "@smithy/node-http-handler": "^4.4.3", "@types/archiver": "^5.3.1", "@types/columnify": "^1.5.1", "@types/folder-hash": "^4.0.1", diff --git a/packages/amplify-cli/src/__tests__/commands/drift-detection/detect-stack-drift.test.ts b/packages/amplify-cli/src/__tests__/commands/drift-detection/detect-stack-drift.test.ts index 9043e5b217a..3970841b8d7 100644 --- a/packages/amplify-cli/src/__tests__/commands/drift-detection/detect-stack-drift.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/drift-detection/detect-stack-drift.test.ts @@ -1,6 +1,6 @@ import { type StackResourceDrift, type PropertyDifference } from '@aws-sdk/client-cloudformation'; import { isAmplifyRestApiDescriptionDrift, isAmplifyTriggerPolicyDrift } from '../../../commands/drift-detection/detect-stack-drift'; -import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger'; +import { SpinningLogger } from '../../../commands/gen2-migration/_infra/spinning-logger'; const mockPrinter = new SpinningLogger('test', { debug: true }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/__snapshots__/_assessment.test.ts.snap b/packages/amplify-cli/src/__tests__/commands/gen2-migration/__snapshots__/_assessment.test.ts.snap new file mode 100644 index 00000000000..0d417e1d681 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/__snapshots__/_assessment.test.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Assessment render() renders a fully supported app 1`] = ` +" +Assessment for "myapp" (env: dev) + +Resources + +┌──────────┬─────────┬──────────┬──────────┬──────────┐ +│ Category │ Service │ Resource │ Generate │ Refactor │ +├──────────┼─────────┼──────────┼──────────┼──────────┤ +│ auth │ Cognito │ pool │ ✔ │ ✔ │ +├──────────┼─────────┼──────────┼──────────┼──────────┤ +│ storage │ S3 │ bucket │ ✔ │ ✔ │ +└──────────┴─────────┴──────────┴──────────┴──────────┘" +`; + +exports[`Assessment render() renders an app blocked by unsupported refactor 1`] = ` +" +Assessment for "myapp" (env: dev) + +Resources + +┌──────────┬──────────┬──────────┬──────────┬──────────┐ +│ Category │ Service │ Resource │ Generate │ Refactor │ +├──────────┼──────────┼──────────┼──────────┼──────────┤ +│ auth │ Cognito │ pool │ ✔ │ ✔ │ +├──────────┼──────────┼──────────┼──────────┼──────────┤ +│ geo │ Location │ map │ ✘ Any │ ✘ Any │ +└──────────┴──────────┴──────────┴──────────┴──────────┘" +`; diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_assessment.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_assessment.test.ts index 1508993d31e..91bcd5aaef6 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_assessment.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_assessment.test.ts @@ -1,184 +1,101 @@ -import { Assessment } from '../../../commands/gen2-migration/_assessment'; -import { DiscoveredResource } from '../../../commands/gen2-migration/generate/_infra/gen1-app'; +import { Assessment } from '../../../commands/gen2-migration/assess/assessment'; describe('Assessment', () => { - describe('record()', () => { - it('creates an entry on first record for a resource', () => { + describe('validFor()', () => { + it('returns true when all resources are supported', () => { const assessment = new Assessment('app', 'dev'); - const resource: DiscoveredResource = { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }; - - assessment.record('generate', resource, { supported: true }); - - const entry = assessment.entries.get('auth:myPool'); - expect(entry).toBeDefined(); - expect(entry!.generate.supported).toBe(true); - // Refactor defaults to unsupported until recorded - expect(entry!.refactor.supported).toBe(false); + assessment.recordResource({ + resource: { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, + generate: { level: 'supported' }, + refactor: { level: 'supported' }, + }); + + expect(assessment.validFor('generate')).toBe(true); + expect(assessment.validFor('refactor')).toBe(true); }); - it('updates an existing entry without overwriting the other step', () => { + it('returns false when a resource is unsupported for generate', () => { const assessment = new Assessment('app', 'dev'); - const resource: DiscoveredResource = { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }; - - assessment.record('generate', resource, { supported: true }); - assessment.record('refactor', resource, { supported: true }); - - const entry = assessment.entries.get('storage:myBucket'); - expect(entry!.generate.supported).toBe(true); - expect(entry!.refactor.supported).toBe(true); + assessment.recordResource({ + resource: { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, + generate: { level: 'supported' }, + refactor: { level: 'supported' }, + }); + assessment.recordResource({ + resource: { category: 'geo', resourceName: 'map', service: 'Location', key: 'UNKNOWN' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'unsupported', note: expect.any(String) }, + }); + + expect(assessment.validFor('generate')).toBe(false); + expect(assessment.validFor('refactor')).toBe(false); }); - it('handles multiple resources across categories', () => { + it('returns false when a feature is unsupported', () => { const assessment = new Assessment('app', 'dev'); - - assessment.record( - 'generate', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record('generate', { category: 'storage', resourceName: 'bucket', service: 'S3', key: 'storage:S3' }, { supported: true }); - assessment.record( - 'generate', - { category: 'geo', resourceName: 'map', service: 'Location', key: 'unsupported' }, - { supported: false }, - ); - - expect(assessment.entries.size).toBe(3); - expect(assessment.entries.get('auth:pool')!.generate.supported).toBe(true); - expect(assessment.entries.get('storage:bucket')!.generate.supported).toBe(true); - expect(assessment.entries.get('geo:map')!.generate.supported).toBe(false); + assessment.recordResource({ + resource: { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, + generate: { level: 'supported' }, + refactor: { level: 'not-applicable' }, + }); + assessment.recordFeature({ + feature: { name: 'custom-policies', path: 'function/myFunc/custom-policies.json' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + + expect(assessment.validFor('generate')).toBe(false); + expect(assessment.validFor('refactor')).toBe(true); }); - }); - describe('display()', () => { - let output: string[]; - - beforeEach(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports -- capturing printer output for snapshot tests - const { printer } = require('@aws-amplify/amplify-prompts'); - output = []; - jest.spyOn(printer, 'info').mockImplementation((...args: unknown[]) => output.push(String(args[0]))); - jest.spyOn(printer, 'blankLine').mockImplementation(() => output.push('')); - }); + it('treats not-applicable as valid', () => { + const assessment = new Assessment('app', 'dev'); + assessment.recordResource({ + resource: { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, + generate: { level: 'supported' }, + refactor: { level: 'not-applicable' }, + }); - afterEach(() => { - jest.restoreAllMocks(); + expect(assessment.validFor('refactor')).toBe(true); }); + }); + describe('render()', () => { function stripAnsi(str: string): string { // eslint-disable-next-line no-control-regex -- stripping ANSI escape codes for snapshot comparison return str.replace(/\u001b\[[0-9;]*m/g, ''); } - function displayed(assessment: Assessment): string { - assessment.display(); - return output.map(stripAnsi).join('\n'); - } - it('renders a fully supported app', () => { const assessment = new Assessment('myapp', 'dev'); - assessment.record( - 'generate', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record( - 'refactor', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record('generate', { category: 'storage', resourceName: 'bucket', service: 'S3', key: 'storage:S3' }, { supported: true }); - assessment.record('refactor', { category: 'storage', resourceName: 'bucket', service: 'S3', key: 'storage:S3' }, { supported: true }); - - expect(displayed(assessment)).toMatchInlineSnapshot(` - " - Assessment for "myapp" (env: dev) - - ┌──────────┬──────────┬─────────┬──────────┬──────────┐ - │ Category │ Resource │ Service │ Generate │ Refactor │ - ├──────────┼──────────┼─────────┼──────────┼──────────┤ - │ auth │ pool │ Cognito │ ✔ │ ✔ │ - │ storage │ bucket │ S3 │ ✔ │ ✔ │ - └──────────┴──────────┴─────────┴──────────┴──────────┘ - - ✔ Migration can proceed." - `); + assessment.recordResource({ + resource: { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, + generate: { level: 'supported' }, + refactor: { level: 'supported' }, + }); + assessment.recordResource({ + resource: { category: 'storage', resourceName: 'bucket', service: 'S3', key: 'storage:S3' }, + generate: { level: 'supported' }, + refactor: { level: 'supported' }, + }); + + expect(stripAnsi(assessment.render())).toMatchSnapshot(); }); it('renders an app blocked by unsupported refactor', () => { const assessment = new Assessment('myapp', 'dev'); - assessment.record( - 'generate', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record( - 'refactor', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record( - 'generate', - { category: 'geo', resourceName: 'map', service: 'Location', key: 'unsupported' }, - { supported: false }, - ); - assessment.record( - 'refactor', - { category: 'geo', resourceName: 'map', service: 'Location', key: 'unsupported' }, - { supported: false }, - ); - - expect(displayed(assessment)).toMatchInlineSnapshot(` - " - Assessment for "myapp" (env: dev) - - ┌──────────┬──────────┬──────────┬──────────────────────┬────────────────────┐ - │ Category │ Resource │ Service │ Generate │ Refactor │ - ├──────────┼──────────┼──────────┼──────────────────────┼────────────────────┤ - │ auth │ pool │ Cognito │ ✔ │ ✔ │ - │ geo │ map │ Location │ ✘ manual code needed │ ✘ blocks migration │ - └──────────┴──────────┴──────────┴──────────────────────┴────────────────────┘ - - ✘ Migration blocked." - `); - }); - - it('renders an app with unsupported generate but supported refactor', () => { - const assessment = new Assessment('myapp', 'dev'); - assessment.record( - 'generate', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record( - 'refactor', - { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, - { supported: true }, - ); - assessment.record( - 'generate', - { category: 'custom', resourceName: 'alarms', service: 'CloudFormation', key: 'unsupported' }, - { supported: false }, - ); - assessment.record( - 'refactor', - { category: 'custom', resourceName: 'alarms', service: 'CloudFormation', key: 'unsupported' }, - { supported: true }, - ); - - expect(displayed(assessment)).toMatchInlineSnapshot(` - " - Assessment for "myapp" (env: dev) - - ┌──────────┬──────────┬────────────────┬──────────────────────┬──────────┐ - │ Category │ Resource │ Service │ Generate │ Refactor │ - ├──────────┼──────────┼────────────────┼──────────────────────┼──────────┤ - │ auth │ pool │ Cognito │ ✔ │ ✔ │ - │ custom │ alarms │ CloudFormation │ ✘ manual code needed │ ✔ │ - └──────────┴──────────┴────────────────┴──────────────────────┴──────────┘ - - ✔ Migration can proceed." - `); + assessment.recordResource({ + resource: { category: 'auth', resourceName: 'pool', service: 'Cognito', key: 'auth:Cognito' }, + generate: { level: 'supported' }, + refactor: { level: 'supported' }, + }); + assessment.recordResource({ + resource: { category: 'geo', resourceName: 'map', service: 'Location', key: 'UNKNOWN' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'unsupported', note: expect.any(String) }, + }); + + expect(stripAnsi(assessment.render())).toMatchSnapshot(); }); }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/app.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/app.ts index e71bb177d2a..d20f1ae77bf 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/app.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/app.ts @@ -3,8 +3,10 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import { MockClients } from './clients'; import { copySync } from './directories'; -import { SpinningLogger } from '../../../../commands/gen2-migration/_spinning-logger'; +import { SpinningLogger } from '../../../../commands/gen2-migration/_infra/spinning-logger'; import { Gen1App } from '../../../../commands/gen2-migration/generate/_infra/gen1-app'; +import { AwsFetcher } from '../../../../commands/gen2-migration/generate/_infra/aws-fetcher'; +import { AwsClients } from '../../../../commands/gen2-migration/_infra/aws-clients'; import { JSONUtilities } from '@aws-amplify/amplify-cli-core'; import { Snapshot } from './snapshot'; @@ -285,6 +287,43 @@ export class MigrationApp { (Gen1App as any).downloadCloudBackend = async () => this.ccbPath; } + /** + * Creates a Gen1App instance wired to this MigrationApp's mock data. + * Bypasses Gen1App.create() which requires real AWS credentials. + */ + public createGen1App(): Gen1App { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bypassing private constructor for tests + const clients = new (AwsClients as any)({ region: this.region }); + return { + appId: this.id, + appName: this.name, + region: this.region, + envName: this.environmentName, + rootStackName: this.rootStackName, + clients, + aws: new AwsFetcher(clients), + ccbDir: this.ccbPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- binding to private _meta field + meta: Gen1App.prototype.meta.bind({ _meta: this.meta } as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- binding to private _meta field + discover: Gen1App.prototype.discover.bind({ _meta: this.meta } as any), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- binding to private _meta field + metaOutput: Gen1App.prototype.metaOutput.bind({ _meta: this.meta } as any), + json: Gen1App.prototype.json.bind({ ccbDir: this.ccbPath }), + file: Gen1App.prototype.file.bind({ ccbDir: this.ccbPath }), + fileExists: Gen1App.prototype.fileExists.bind({ ccbDir: this.ccbPath }), + cliInputs: (category: string, resourceName: string) => + Gen1App.prototype.json.call({ ccbDir: this.ccbPath }, path.join(category, resourceName, 'cli-inputs.json')), + singleResourceName: (category: string, service: string) => { + const block = this.meta[category]; + if (!block) throw new Error(`Category '${category}' not found`); + const names = Object.keys(block).filter((n: string) => (block[n] as Record).service === service); + if (names.length !== 1) throw new Error(`Expected 1 '${service}' in '${category}', found ${names.length}`); + return names[0]; + }, + } as unknown as Gen1App; + } + /** * Walks the root CloudFormation template and all nested stack templates, * registering every physical resource ID in the CloudFormation mock. diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/logger.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/logger.ts index fe5c12d3ced..4bb01285589 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/logger.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/logger.ts @@ -1,4 +1,4 @@ -import { SpinningLogger } from '../../../../commands/gen2-migration/_spinning-logger'; +import { SpinningLogger } from '../../../../commands/gen2-migration/_infra/spinning-logger'; /** Creates a no-op SpinningLogger suitable for unit tests. */ export function noOpLogger(): SpinningLogger { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts index 61e3597a9a8..20e0194688e 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts @@ -1,9 +1,9 @@ -import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations'; +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_infra/validations'; import { $TSContext, stateManager } from '@aws-amplify/amplify-cli-core'; -import { CloudFormationClient, DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; -import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger'; +import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; +import { SpinningLogger } from '../../../commands/gen2-migration/_infra/spinning-logger'; +import { Gen1App } from '../../../commands/gen2-migration/generate/_infra/gen1-app'; -jest.mock('@aws-sdk/client-cloudformation'); jest.mock('bottleneck', () => { return jest.fn().mockImplementation(() => ({ schedule: jest.fn((fn) => fn()), @@ -20,19 +20,22 @@ describe('AmplifyGen2MigrationValidations', () => { let mockContext: $TSContext; let validations: AmplifyGen2MigrationValidations; + let mockCfnSend: jest.Mock; + beforeEach(() => { mockContext = {} as $TSContext; - validations = new AmplifyGen2MigrationValidations(new SpinningLogger('mock', { debug: true }), 'mock', 'mock', mockContext); + mockCfnSend = jest.fn(); + const mockGen1App = { + rootStackName: 'mock', + envName: 'mock', + clients: { cloudFormation: { send: mockCfnSend } }, + } as unknown as Gen1App; + validations = new AmplifyGen2MigrationValidations(new SpinningLogger('mock', { debug: true }), mockGen1App, mockContext); }); describe('validateStatefulResources', () => { - let mockSend: jest.Mock; - beforeEach(() => { - mockSend = jest.fn(); - (CloudFormationClient as jest.Mock).mockImplementation(() => ({ - send: mockSend, - })); + // mockCfnSend is already wired via gen1App.clients.cloudFormation }); afterEach(() => { @@ -344,7 +347,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should throw when nested stack contains stateful resources', async () => { - mockSend.mockResolvedValueOnce({ + mockCfnSend.mockResolvedValueOnce({ StackResourceSummaries: [ { ResourceType: 'AWS::DynamoDB::Table', @@ -376,7 +379,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should pass when nested stack contains only stateless resources', async () => { - mockSend.mockResolvedValueOnce({ + mockCfnSend.mockResolvedValueOnce({ StackResourceSummaries: [ { ResourceType: 'AWS::Lambda::Function', @@ -405,7 +408,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should handle multiple levels of nested stacks', async () => { - mockSend.mockResolvedValueOnce({ + mockCfnSend.mockResolvedValueOnce({ StackResourceSummaries: [ { ResourceType: 'AWS::CloudFormation::Stack', @@ -416,7 +419,7 @@ describe('AmplifyGen2MigrationValidations', () => { NextToken: undefined, }); - mockSend.mockResolvedValueOnce({ + mockCfnSend.mockResolvedValueOnce({ StackResourceSummaries: [ { ResourceType: 'AWS::S3::Bucket', @@ -465,7 +468,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should handle mixed direct and nested stateful resources', async () => { - mockSend.mockResolvedValueOnce({ + mockCfnSend.mockResolvedValueOnce({ StackResourceSummaries: [ { ResourceType: 'AWS::Cognito::UserPool', @@ -560,21 +563,12 @@ describe('AmplifyGen2MigrationValidations', () => { }); describe('validateDeploymentStatus', () => { - let mockSend: jest.Mock; - - beforeEach(() => { - mockSend = jest.fn(); - (CloudFormationClient as jest.Mock).mockImplementation(() => ({ - send: mockSend, - })); - }); - afterEach(() => { jest.clearAllMocks(); }); it('should throw StackNotFoundError when stack not found in CloudFormation', async () => { - mockSend.mockResolvedValue({ Stacks: [] }); + mockCfnSend.mockResolvedValue({ Stacks: [] }); await expect(validations.validateDeploymentStatus()).rejects.toMatchObject({ name: 'StackNotFoundError', @@ -592,7 +586,7 @@ describe('AmplifyGen2MigrationValidations', () => { }, }); - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ Stacks: [{ StackStatus: 'UPDATE_COMPLETE' }], }); @@ -608,7 +602,7 @@ describe('AmplifyGen2MigrationValidations', () => { }, }); - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ Stacks: [{ StackStatus: 'CREATE_COMPLETE' }], }); @@ -624,7 +618,7 @@ describe('AmplifyGen2MigrationValidations', () => { }, }); - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ Stacks: [{ StackStatus: 'UPDATE_IN_PROGRESS' }], }); @@ -644,7 +638,7 @@ describe('AmplifyGen2MigrationValidations', () => { }, }); - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ Stacks: [{ StackStatus: 'ROLLBACK_COMPLETE' }], }); @@ -657,15 +651,6 @@ describe('AmplifyGen2MigrationValidations', () => { }); describe('validateLockStatus', () => { - let mockSend: jest.Mock; - - beforeEach(() => { - mockSend = jest.fn(); - (CloudFormationClient as jest.Mock).mockImplementation(() => ({ - send: mockSend, - })); - }); - afterEach(() => { jest.clearAllMocks(); }); @@ -679,7 +664,7 @@ describe('AmplifyGen2MigrationValidations', () => { }, }); - mockSend.mockResolvedValue({ StackPolicyBody: undefined }); + mockCfnSend.mockResolvedValue({ StackPolicyBody: undefined }); await expect(validations.validateLockStatus()).rejects.toMatchObject({ name: 'MigrationError', @@ -708,7 +693,7 @@ describe('AmplifyGen2MigrationValidations', () => { ], }; - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ StackPolicyBody: JSON.stringify(expectedPolicy), }); @@ -735,7 +720,7 @@ describe('AmplifyGen2MigrationValidations', () => { ], }; - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ StackPolicyBody: JSON.stringify(wrongPolicy), }); @@ -766,7 +751,7 @@ describe('AmplifyGen2MigrationValidations', () => { ], }; - mockSend.mockResolvedValue({ + mockCfnSend.mockResolvedValue({ StackPolicyBody: JSON.stringify(wrongPolicy), }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts index d27d8ae5ac2..981ef6954e1 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess.test.ts @@ -1,111 +1,95 @@ import { AmplifyMigrationAssessor } from '../../../commands/gen2-migration/assess'; import { Gen1App, DiscoveredResource } from '../../../commands/gen2-migration/generate/_infra/gen1-app'; -import { Assessment } from '../../../commands/gen2-migration/_assessment'; -import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger'; -import { $TSContext } from '@aws-amplify/amplify-cli-core'; -jest.mock('../../../commands/gen2-migration/generate/_infra/gen1-app', () => { - const actual = jest.requireActual('../../../commands/gen2-migration/generate/_infra/gen1-app'); +function mockGen1App(resources: DiscoveredResource[], existingFiles: string[] = [], jsonFiles: Record = {}): Gen1App { + const fileSet = new Set(existingFiles); return { - ...actual, - Gen1App: { - ...actual.Gen1App, - create: jest.fn(), - }, - }; -}); - -jest.mock('../../../commands/gen2-migration/aws-clients', () => ({ - AwsClients: jest.fn(), -})); - -function mockDiscover(resources: DiscoveredResource[]): void { - (Gen1App.create as jest.Mock).mockResolvedValue({ + appName: 'test-app', + envName: 'dev', discover: () => resources, meta: () => undefined, - }); -} - -function createAssessor(): AmplifyMigrationAssessor { - const logger = new SpinningLogger('assess', { debug: true }); - return new AmplifyMigrationAssessor(logger, 'dev', 'test-app', 'app-123', 'root-stack', 'us-east-1', {} as $TSContext); + fileExists: (path: string) => fileSet.has(path), + json: (path: string) => jsonFiles[path], + } as unknown as Gen1App; } describe('AmplifyMigrationAssessor', () => { - describe('run()', () => { - it('records all supported resources as supported for both generate and refactor', async () => { - mockDiscover([ - { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, - { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }, - { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, - ]); + describe('assess()', () => { + it('returns empty assessment when no resources discovered', () => { + const gen1App = mockGen1App([]); + const assessor = new AmplifyMigrationAssessor(gen1App); + const assessment = assessor.assess(); + + expect(assessment.resources).toHaveLength(0); + expect(assessment.features).toHaveLength(0); + expect(assessment.validFor('generate')).toBe(true); + expect(assessment.validFor('refactor')).toBe(true); + }); - const displaySpy = jest.spyOn(Assessment.prototype, 'display').mockImplementation(() => {}); - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - - await createAssessor().run(); - - // Generate records all three as supported - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: 'myPool' }), { - supported: true, - }); - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: 'myBucket' }), { - supported: true, - }); - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: 'myFunc' }), { - supported: true, - }); - - // Refactor records all three as supported - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'myPool' }), { - supported: true, - }); - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'myBucket' }), { - supported: true, - }); - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'myFunc' }), { - supported: true, - }); - - expect(displaySpy).toHaveBeenCalled(); - - displaySpy.mockRestore(); - recordSpy.mockRestore(); + it('detects custom-policies.json for function resources', () => { + const gen1App = mockGen1App( + [{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }], + ['function/myFunc/custom-policies.json'], + { 'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::my-bucket/*'] }] }, + ); + const assessor = new AmplifyMigrationAssessor(gen1App); + const assessment = assessor.assess(); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0].feature.name).toBe('custom-policies'); }); - it('records unsupported resources as not supported', async () => { - mockDiscover([ - { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, - { category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }, - ]); + it('detects override.ts for auth resources', () => { + const gen1App = mockGen1App( + [{ category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }], + ['auth/myPool/override.ts'], + ); + const assessor = new AmplifyMigrationAssessor(gen1App); + const assessment = assessor.assess(); - const displaySpy = jest.spyOn(Assessment.prototype, 'display').mockImplementation(() => {}); - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0].feature.name).toBe('overrides'); + }); - await createAssessor().run(); + it('ignores empty custom-policies.json', () => { + const gen1App = mockGen1App( + [{ category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }], + ['function/myFunc/custom-policies.json'], + { 'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }] }, + ); + const assessor = new AmplifyMigrationAssessor(gen1App); + const assessment = assessor.assess(); + + expect(assessment.features).toHaveLength(0); + }); - // Notifications is unsupported for both - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: 'push' }), { - supported: false, - }); - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'push' }), { - supported: false, - }); + it('marks UNKNOWN resources as unsupported', () => { + const gen1App = mockGen1App([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'UNKNOWN' }]); + const assessor = new AmplifyMigrationAssessor(gen1App); + const assessment = assessor.assess(); - displaySpy.mockRestore(); - recordSpy.mockRestore(); + expect(assessment.resources).toHaveLength(1); + expect(assessment.resources[0].generate.level).toBe('unsupported'); + expect(assessment.resources[0].refactor.level).toBe('unsupported'); }); + }); - it('calls display after recording all resources', async () => { - mockDiscover([{ category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }]); + describe('run()', () => { + it('discovers all resources and prints the report', () => { + const gen1App = mockGen1App([ + { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, + { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }, + ]); - const displaySpy = jest.spyOn(Assessment.prototype, 'display').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- capturing printer output + const { printer } = require('@aws-amplify/amplify-prompts'); + const infoSpy = jest.spyOn(printer, 'info').mockImplementation(() => {}); - await createAssessor().run(); + new AmplifyMigrationAssessor(gen1App).run(); - expect(displaySpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalled(); - displaySpy.mockRestore(); + infoSpy.mockRestore(); }); }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/analytics/kinesis.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/analytics/kinesis.assessor.test.ts new file mode 100644 index 00000000000..0d2d70fd37d --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/analytics/kinesis.assessor.test.ts @@ -0,0 +1,23 @@ +import { AnalyticsKinesisAssessor } from '../../../../../commands/gen2-migration/assess/analytics/kinesis.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +const RESOURCE: DiscoveredResource = { category: 'analytics', resourceName: 'myStream', service: 'Kinesis', key: 'analytics:Kinesis' }; + +describe('AnalyticsKinesisAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new AnalyticsKinesisAssessor({} as Gen1App, RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('records no features', () => { + const assessment = new Assessment('app', 'dev'); + new AnalyticsKinesisAssessor({} as Gen1App, RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/data.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/data.assessor.test.ts new file mode 100644 index 00000000000..5f1691563b8 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/data.assessor.test.ts @@ -0,0 +1,40 @@ +import { DataAssessor } from '../../../../../commands/gen2-migration/assess/api/data.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +function mockGen1App(existingFiles: string[] = []): Gen1App { + const fileSet = new Set(existingFiles); + return { fileExists: (path: string) => fileSet.has(path) } as unknown as Gen1App; +} + +const RESOURCE: DiscoveredResource = { category: 'api', resourceName: 'myApi', service: 'AppSync', key: 'api:AppSync' }; + +describe('DataAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new DataAssessor(mockGen1App(), RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('not-applicable'); + }); + + it('detects override.ts', () => { + const assessment = new Assessment('app', 'dev'); + new DataAssessor(mockGen1App(['api/myApi/override.ts']), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0]).toEqual({ + feature: { name: 'overrides', path: 'api/myApi/override.ts' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + }); + + it('records no features when override.ts is absent', () => { + const assessment = new Assessment('app', 'dev'); + new DataAssessor(mockGen1App(), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/rest-api.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/rest-api.assessor.test.ts new file mode 100644 index 00000000000..baafadcf9b1 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/api/rest-api.assessor.test.ts @@ -0,0 +1,23 @@ +import { RestApiAssessor } from '../../../../../commands/gen2-migration/assess/api/rest-api.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +const RESOURCE: DiscoveredResource = { category: 'api', resourceName: 'myApi', service: 'API Gateway', key: 'api:API Gateway' }; + +describe('RestApiAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new RestApiAssessor({ fileExists: () => false } as unknown as Gen1App, RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('not-applicable'); + }); + + it('records no features', () => { + const assessment = new Assessment('app', 'dev'); + new RestApiAssessor({ fileExists: () => false } as unknown as Gen1App, RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-cognito.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-cognito.assessor.test.ts new file mode 100644 index 00000000000..fab71f6a3fd --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-cognito.assessor.test.ts @@ -0,0 +1,40 @@ +import { AuthCognitoAssessor } from '../../../../../commands/gen2-migration/assess/auth/auth-cognito.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +function mockGen1App(existingFiles: string[] = []): Gen1App { + const fileSet = new Set(existingFiles); + return { fileExists: (path: string) => fileSet.has(path) } as unknown as Gen1App; +} + +const RESOURCE: DiscoveredResource = { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }; + +describe('AuthCognitoAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new AuthCognitoAssessor(mockGen1App(), RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('detects override.ts', () => { + const assessment = new Assessment('app', 'dev'); + new AuthCognitoAssessor(mockGen1App(['auth/myPool/override.ts']), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0]).toEqual({ + feature: { name: 'overrides', path: 'auth/myPool/override.ts' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + }); + + it('records no features when override.ts is absent', () => { + const assessment = new Assessment('app', 'dev'); + new AuthCognitoAssessor(mockGen1App(), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.test.ts new file mode 100644 index 00000000000..33228877cee --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.test.ts @@ -0,0 +1,45 @@ +import { AuthUserPoolGroupsAssessor } from '../../../../../commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +function mockGen1App(existingFiles: string[] = []): Gen1App { + const fileSet = new Set(existingFiles); + return { fileExists: (path: string) => fileSet.has(path) } as unknown as Gen1App; +} + +const RESOURCE: DiscoveredResource = { + category: 'auth', + resourceName: 'userPoolGroups', + service: 'Cognito-UserPool-Groups', + key: 'auth:Cognito-UserPool-Groups', +}; + +describe('AuthUserPoolGroupsAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new AuthUserPoolGroupsAssessor(mockGen1App(), RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('detects override.ts', () => { + const assessment = new Assessment('app', 'dev'); + new AuthUserPoolGroupsAssessor(mockGen1App(['auth/userPoolGroups/override.ts']), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0]).toEqual({ + feature: { name: 'overrides', path: 'auth/userPoolGroups/override.ts' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + }); + + it('records no features when override.ts is absent', () => { + const assessment = new Assessment('app', 'dev'); + new AuthUserPoolGroupsAssessor(mockGen1App(), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts new file mode 100644 index 00000000000..d05a090a931 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/function/function.assessor.test.ts @@ -0,0 +1,56 @@ +import { FunctionAssessor } from '../../../../../commands/gen2-migration/assess/function/function.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +function mockGen1App(existingFiles: string[] = [], jsonFiles: Record = {}): Gen1App { + const fileSet = new Set(existingFiles); + return { + fileExists: (path: string) => fileSet.has(path), + json: (path: string) => jsonFiles[path], + } as unknown as Gen1App; +} + +const RESOURCE: DiscoveredResource = { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }; + +describe('FunctionAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new FunctionAssessor(mockGen1App(), RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('detects non-empty custom-policies.json', () => { + const gen1App = mockGen1App(['function/myFunc/custom-policies.json'], { + 'function/myFunc/custom-policies.json': [{ Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::bucket/*'] }], + }); + const assessment = new Assessment('app', 'dev'); + new FunctionAssessor(gen1App, RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0]).toEqual({ + feature: { name: 'custom-policies', path: 'function/myFunc/custom-policies.json' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + }); + + it('ignores empty custom-policies.json', () => { + const gen1App = mockGen1App(['function/myFunc/custom-policies.json'], { + 'function/myFunc/custom-policies.json': [{ Action: [], Resource: [] }], + }); + const assessment = new Assessment('app', 'dev'); + new FunctionAssessor(gen1App, RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); + + it('records no features when custom-policies.json is absent', () => { + const assessment = new Assessment('app', 'dev'); + new FunctionAssessor(mockGen1App(), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/dynamodb.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/dynamodb.assessor.test.ts new file mode 100644 index 00000000000..7f9f94f9aed --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/dynamodb.assessor.test.ts @@ -0,0 +1,23 @@ +import { DynamoDBAssessor } from '../../../../../commands/gen2-migration/assess/storage/dynamodb.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +const RESOURCE: DiscoveredResource = { category: 'storage', resourceName: 'myTable', service: 'DynamoDB', key: 'storage:DynamoDB' }; + +describe('DynamoDBAssessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new DynamoDBAssessor({ fileExists: () => false } as unknown as Gen1App, RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('records no features', () => { + const assessment = new Assessment('app', 'dev'); + new DynamoDBAssessor({ fileExists: () => false } as unknown as Gen1App, RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/s3.assessor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/s3.assessor.test.ts new file mode 100644 index 00000000000..b44cc92288a --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/assess/storage/s3.assessor.test.ts @@ -0,0 +1,40 @@ +import { S3Assessor } from '../../../../../commands/gen2-migration/assess/storage/s3.assessor'; +import { Assessment } from '../../../../../commands/gen2-migration/assess/assessment'; +import { Gen1App, DiscoveredResource } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; + +function mockGen1App(existingFiles: string[] = []): Gen1App { + const fileSet = new Set(existingFiles); + return { fileExists: (path: string) => fileSet.has(path) } as unknown as Gen1App; +} + +const RESOURCE: DiscoveredResource = { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }; + +describe('S3Assessor', () => { + it('records resource as supported', () => { + const assessment = new Assessment('app', 'dev'); + new S3Assessor(mockGen1App(), RESOURCE).record(assessment); + + const entry = assessment.resources[0]; + expect(entry!.generate.level).toBe('supported'); + expect(entry!.refactor.level).toBe('supported'); + }); + + it('detects override.ts', () => { + const assessment = new Assessment('app', 'dev'); + new S3Assessor(mockGen1App(['storage/myBucket/override.ts']), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(1); + expect(assessment.features[0]).toEqual({ + feature: { name: 'overrides', path: 'storage/myBucket/override.ts' }, + generate: { level: 'unsupported', note: expect.any(String) }, + refactor: { level: 'not-applicable' }, + }); + }); + + it('records no features when override.ts is absent', () => { + const assessment = new Assessment('app', 'dev'); + new S3Assessor(mockGen1App(), RESOURCE).record(assessment); + + expect(assessment.features).toHaveLength(0); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts index 41c5aa3ffb8..639620b911b 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate.test.ts @@ -2,6 +2,7 @@ import 'aws-sdk-client-mock-jest'; import { AmplifyMigrationGenerateStep, DependenciesInstaller } from '../../../commands/gen2-migration/generate'; import { MigrationAppOptions, MigrationApp } from './_framework/app'; import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_infra/validations'; // high to allow for debugging in the IDE const TIMEOUT_MINUTES = 60; @@ -77,15 +78,8 @@ async function testSnapshot(appName: string, appOptions?: MigrationAppOptions, c if (customize) { await customize(app); } - const step = new AmplifyMigrationGenerateStep( - app.logger, - app.environmentName, - app.name, - app.id, - app.rootStackName, - app.region, - {} as $TSContext, - ); + const gen1App = app.createGen1App(); + const step = new AmplifyMigrationGenerateStep(app.logger, gen1App, {} as $TSContext, {} as AmplifyGen2MigrationValidations); const plan = await step.forward(); await plan.execute(); @@ -105,72 +99,57 @@ async function testSnapshot(appName: string, appOptions?: MigrationAppOptions, c } import { Gen1App, DiscoveredResource } from '../../../commands/gen2-migration/generate/_infra/gen1-app'; -import { Assessment } from '../../../commands/gen2-migration/_assessment'; -import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger'; - -function mockDiscover(resources: DiscoveredResource[]): jest.SpyInstance { - return jest.spyOn(Gen1App, 'create').mockResolvedValue({ - discover: () => resources, +import { SpinningLogger } from '../../../commands/gen2-migration/_infra/spinning-logger'; + +/** Creates a minimal mock Gen1App for unit tests. */ +function mockGen1App(overrides: Partial = {}): Gen1App { + return { + appId: 'app-123', + appName: 'test-app', + region: 'us-east-1', + envName: 'dev', + rootStackName: 'root-stack', + discover: () => [], meta: () => undefined, - } as unknown as Gen1App); + fileExists: () => false, + ...overrides, + } as unknown as Gen1App; } -function createStep(): AmplifyMigrationGenerateStep { - const logger = new SpinningLogger('generate', { debug: true }); - return new AmplifyMigrationGenerateStep(logger, 'dev', 'test-app', 'app-123', 'root-stack', 'us-east-1', {} as $TSContext); +function mockDiscover(resources: DiscoveredResource[]): Gen1App { + return mockGen1App({ discover: () => resources }); } describe('AmplifyMigrationGenerateStep', () => { - let createSpy: jest.SpyInstance; - - afterEach(() => { - createSpy?.mockRestore(); - }); - - describe('assess()', () => { - it('records supported resources as supported', async () => { - createSpy = mockDiscover([ - { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, - { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }, - { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, - ]); - - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - const step = createStep(); - await step.assess(new Assessment('test-app', 'dev')); - - for (const name of ['myPool', 'myBucket', 'myFunc']) { - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: name }), { - supported: true, - }); - } + describe('forward()', () => { + it('fails validation when assessment contains unsupported resources', async () => { + const gen1 = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'UNKNOWN' }]); + const logger = new SpinningLogger('generate', { debug: true }); + const step = new AmplifyMigrationGenerateStep(logger, gen1, {} as $TSContext, {} as AmplifyGen2MigrationValidations); - recordSpy.mockRestore(); - }); - - it('records unsupported key as not supported', async () => { - createSpy = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }]); - - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - const step = createStep(); - await step.assess(new Assessment('test-app', 'dev')); - - expect(recordSpy).toHaveBeenCalledWith('generate', expect.objectContaining({ resourceName: 'push' }), { - supported: false, - }); - - recordSpy.mockRestore(); + const plan = await step.forward(); + const passed = await plan.validate(); + expect(passed).toBe(false); }); - }); - describe('execute()', () => { - it('warns and skips unsupported resources instead of throwing', async () => { - createSpy = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }]); + it('passes validation when all resources are supported', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mocking private methods for unit tests + const lockSpy = jest.spyOn(AmplifyMigrationGenerateStep.prototype as any, 'validateLockStatus').mockResolvedValue({ valid: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mocking private methods for unit tests + const wdSpy = jest + .spyOn(AmplifyMigrationGenerateStep.prototype as any, 'validateWorkingDirectory') + .mockResolvedValue({ valid: true }); + const gen1 = mockDiscover([ + { category: 'auth', resourceName: 'userPoolGroups', service: 'Cognito-UserPool-Groups', key: 'auth:Cognito-UserPool-Groups' }, + ]); + const logger = new SpinningLogger('generate', { debug: true }); + const step = new AmplifyMigrationGenerateStep(logger, gen1, {} as $TSContext, {} as AmplifyGen2MigrationValidations); - const step = createStep(); - // Should not throw — generate warns on unsupported, unlike refactor const plan = await step.forward(); - await plan.describe(); + const passed = await plan.validate(); + expect(passed).toBe(true); + lockSpy.mockRestore(); + wdSpy.mockRestore(); }); }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts index b30b70232fe..298c8233381 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts @@ -1,21 +1,13 @@ import { AmplifyMigrationLockStep } from '../../../commands/gen2-migration/lock'; import { $TSContext } from '@aws-amplify/amplify-cli-core'; -import { CloudFormationClient, SetStackPolicyCommand } from '@aws-sdk/client-cloudformation'; -import { AmplifyClient, UpdateAppCommand } from '@aws-sdk/client-amplify'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { SpinningLogger } from '../../../commands/gen2-migration/_spinning-logger'; - -jest.mock('@aws-sdk/client-cloudformation', () => ({ - ...jest.requireActual('@aws-sdk/client-cloudformation'), - CloudFormationClient: jest.fn(), -})); -jest.mock('@aws-sdk/client-amplify', () => ({ - ...jest.requireActual('@aws-sdk/client-amplify'), - AmplifyClient: jest.fn(), -})); +import { SetStackPolicyCommand } from '@aws-sdk/client-cloudformation'; +import { UpdateAppCommand } from '@aws-sdk/client-amplify'; +import { SpinningLogger } from '../../../commands/gen2-migration/_infra/spinning-logger'; +import { Gen1App } from '../../../commands/gen2-migration/generate/_infra/gen1-app'; +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_infra/validations'; + jest.mock('@aws-sdk/client-appsync', () => ({ ...jest.requireActual('@aws-sdk/client-appsync'), - AppSyncClient: jest.fn().mockImplementation(() => ({ send: jest.fn() })), paginateListGraphqlApis: jest.fn().mockImplementation(() => ({ [Symbol.asyncIterator]: async function* () { yield { graphqlApis: [{ name: 'testApp-testEnv', apiId: 'test-api-id' }] }; @@ -24,7 +16,6 @@ jest.mock('@aws-sdk/client-appsync', () => ({ })); jest.mock('@aws-sdk/client-dynamodb', () => ({ ...jest.requireActual('@aws-sdk/client-dynamodb'), - DynamoDBClient: jest.fn().mockImplementation(() => ({ send: jest.fn() })), paginateListTables: jest.fn().mockImplementation(() => ({ [Symbol.asyncIterator]: async function* () { yield { TableNames: ['Table1-test-api-id-testEnv', 'Table2-test-api-id-testEnv'] }; @@ -40,12 +31,6 @@ jest.mock('@aws-amplify/amplify-prompts', () => ({ })), isDebug: false, })); -jest.mock('../../../commands/gen2-migration/_validations', () => ({ - AmplifyGen2MigrationValidations: jest.fn().mockImplementation(() => ({ - validateDeploymentStatus: jest.fn().mockResolvedValue(undefined), - validateDrift: jest.fn().mockResolvedValue(undefined), - })), -})); describe('AmplifyMigrationLockStep', () => { let lockStep: AmplifyMigrationLockStep; @@ -57,10 +42,6 @@ describe('AmplifyMigrationLockStep', () => { mockCfnSend = jest.fn(); mockAmplifySend = jest.fn(); - (CloudFormationClient as jest.Mock).mockImplementation(() => ({ send: mockCfnSend })); - (AmplifyClient as jest.Mock).mockImplementation(() => ({ send: mockAmplifySend })); - (DynamoDBClient as jest.Mock).mockImplementation(() => ({ send: jest.fn() })); - mockLogger = new SpinningLogger('mock'); jest.spyOn(mockLogger, 'info').mockImplementation(() => {}); jest.spyOn(mockLogger, 'start').mockImplementation(() => {}); @@ -70,12 +51,24 @@ describe('AmplifyMigrationLockStep', () => { lockStep = new AmplifyMigrationLockStep( mockLogger, - 'testEnv', - 'testApp', - 'test-app-id', - 'test-root-stack', - 'us-east-1', + { + appId: 'test-app-id', + appName: 'testApp', + rootStackName: 'test-root-stack', + region: 'us-east-1', + envName: 'testEnv', + clients: { + cloudFormation: { send: mockCfnSend }, + amplify: { send: mockAmplifySend }, + appSync: { send: jest.fn() }, + dynamoDB: { send: jest.fn() }, + }, + } as unknown as Gen1App, {} as $TSContext, + { + validateDeploymentStatus: jest.fn().mockResolvedValue(undefined), + validateDrift: jest.fn().mockResolvedValue(undefined), + } as unknown as AmplifyGen2MigrationValidations, ); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/analytics/analytics-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/analytics/analytics-rollback.test.ts index 1d99d3e21a7..7ddf628f205 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/analytics/analytics-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/analytics/analytics-rollback.test.ts @@ -1,4 +1,5 @@ import { AnalyticsKinesisRollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/analytics/analytics-rollback'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -10,8 +11,7 @@ describe('AnalyticsKinesisRollbackRefactorer.targetLogicalId', () => { })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'analytics', resourceName: 'test', service: 'Kinesis', key: 'analytics:Kinesis' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts index 0b93f82949a..f24ede97061 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts @@ -1,6 +1,7 @@ import { AuthCognitoForwardRefactorer } from '../../../../../commands/gen2-migration/refactor/auth/auth-cognito-forward'; -import { CFNResource, CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; -import { AwsClients } from '../../../../../commands/gen2-migration/aws-clients'; +import { CFNResource, CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; +import { AwsClients } from '../../../../../commands/gen2-migration/_infra/aws-clients'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { StackFacade } from '../../../../../commands/gen2-migration/refactor/stack-facade'; import { noOpLogger } from '../../_framework/logger'; import { mockClient } from 'aws-sdk-client-mock'; @@ -105,19 +106,16 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { it('main auth: produces updateSource → updateTarget → beforeMove → move', async () => { setupMocks(cfnMock); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const gen1Env = new StackFacade(clients, 'gen1-root'); const gen2Branch = new StackFacade(clients, 'gen2-root'); const refactorer = new AuthCognitoForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + { region: 'us-east-1', clients, appId: 'appId', envName: 'main' } as unknown as Gen1App, '123456789', noOpLogger(), - 'appId', - 'main', { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, new Cfn(new CloudFormationClient({}), noOpLogger()), ); @@ -200,7 +198,7 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { IdentityProvider: { ProviderDetails: { client_id: 'google-id', client_secret: 'google-secret' } }, }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); (clients as any).cognitoIdentityProvider = new CognitoIdentityProviderClient({}); const gen1Env = new StackFacade(clients, 'gen1-root'); @@ -208,12 +206,9 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { const refactorer = new AuthCognitoForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + { region: 'us-east-1', clients, appId: 'appId', envName: 'main' } as unknown as Gen1App, '123456789', noOpLogger(), - 'appId', - 'main', { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, new Cfn(new CloudFormationClient({}), noOpLogger()), ); @@ -258,19 +253,16 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { Stacks: [{ StackName: 'gen1-auth-stack', StackStatus: rs, CreationTime: ts, Description: gen1AuthTemplate.Description }], }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const gen1Env = new StackFacade(clients, 'gen1-root'); const gen2Branch = new StackFacade(clients, 'gen2-root'); const refactorer = new AuthCognitoForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + { region: 'us-east-1', clients, appId: 'appId', envName: 'main' } as unknown as Gen1App, '123456789', noOpLogger(), - 'appId', - 'main', { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, new Cfn(new CloudFormationClient({}), noOpLogger()), ); @@ -285,7 +277,7 @@ function toIdMap(mappings: ResourceMapping[]): Map { describe('AuthCognitoForwardRefactorer.buildResourceMappings — UserPoolClient disambiguation', () => { function createRefactorer() { - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); const gen1Env = new StackFacade(clients, 'gen1'); const gen2Branch = new StackFacade(clients, 'gen2'); return new (class extends AuthCognitoForwardRefactorer { @@ -293,12 +285,9 @@ describe('AuthCognitoForwardRefactorer.buildResourceMappings — UserPoolClient super( gen1Env, gen2Branch, - clients, - 'us-east-1', + { region: 'us-east-1', clients, appId: 'appId', envName: 'main' } as unknown as Gen1App, '123456789', noOpLogger(), - 'appId', - 'main', { category: 'auth', resourceName: 'test', diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts index a6eeb680c86..e2d6000e90a 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts @@ -1,6 +1,7 @@ import { AuthCognitoRollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/auth/auth-cognito-rollback'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; -import { AwsClients } from '../../../../../commands/gen2-migration/aws-clients'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; +import { AwsClients } from '../../../../../commands/gen2-migration/_infra/aws-clients'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { StackFacade } from '../../../../../commands/gen2-migration/refactor/stack-facade'; import { noOpLogger } from '../../_framework/logger'; import { mockClient } from 'aws-sdk-client-mock'; @@ -97,14 +98,13 @@ describe('AuthCognitoRollbackRefactorer.plan()', () => { it('main auth: produces updateSource → updateTarget → move → afterMove', async () => { setupBasicMocks(); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new AuthCognitoRollbackRefactorer( new StackFacade(clients, 'gen1-root'), new StackFacade(clients, 'gen2-root'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123', noOpLogger(), { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, @@ -130,8 +130,7 @@ describe('AuthCognitoRollbackRefactorer.targetLogicalId', () => { })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.test.ts index 5ef59cecdb4..f4e6223fbc6 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.test.ts @@ -1,5 +1,6 @@ import { AuthUserPoolGroupsForwardRefactorer } from '../../../../../commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward'; -import { CFNResource } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNResource } from '../../../../../commands/gen2-migration/_infra/cfn-template'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { ResourceMapping } from '@aws-sdk/client-cloudformation'; import { noOpLogger } from '../../_framework/logger'; @@ -18,8 +19,7 @@ describe('AuthUserPoolGroupsForwardRefactorer.buildResourceMappings — GroupNam })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.test.ts index 3961d049d65..186600a9f4c 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.test.ts @@ -1,4 +1,5 @@ import { AuthUserPoolGroupsRollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -10,8 +11,7 @@ describe('AuthUserPoolGroupsRollbackRefactorer.targetLogicalId', () => { })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'auth', resourceName: 'userPoolGroups', service: 'Cognito-UserPool-Groups', key: 'auth:Cognito-UserPool-Groups' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/refactor.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/refactor.test.ts index 19fb1885fc5..c395ded1aa7 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/refactor.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/refactor.test.ts @@ -2,9 +2,9 @@ import { AmplifyMigrationRefactorStep } from '../../../../commands/gen2-migratio import { OUTPUT_DIRECTORY } from '../../../../commands/gen2-migration/refactor/cfn'; import { MigrationApp, MigrationAppOptions } from '../_framework/app'; import { Gen1App, DiscoveredResource } from '../../../../commands/gen2-migration/generate/_infra/gen1-app'; -import { Assessment } from '../../../../commands/gen2-migration/_assessment'; -import { SpinningLogger } from '../../../../commands/gen2-migration/_spinning-logger'; +import { SpinningLogger } from '../../../../commands/gen2-migration/_infra/spinning-logger'; import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { AmplifyGen2MigrationValidations } from '../../../../commands/gen2-migration/_infra/validations'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -85,15 +85,8 @@ async function testSnapshot(appName: string, appOptions?: MigrationAppOptions, c } const context: any = { parameters: { options: { to: findGen2RootStackName(app) } } }; - const refactorStep = new AmplifyMigrationRefactorStep( - app.logger, - app.environmentName, - app.name, - app.id, - app.rootStackName, - app.region, - context, - ); + const gen1App = app.createGen1App(); + const refactorStep = new AmplifyMigrationRefactorStep(app.logger, gen1App, context, {} as AmplifyGen2MigrationValidations); const plan = await refactorStep.forward(); await plan.execute(); @@ -128,11 +121,23 @@ function findGen2RootStackName(app: MigrationApp) { throw new Error(`Unable to find Gen2 root stack name for app: ${app.name}`); } -function mockDiscover(resources: DiscoveredResource[]): jest.SpyInstance { - return jest.spyOn(Gen1App, 'create').mockResolvedValue({ - discover: () => resources, +/** Creates a minimal mock Gen1App for unit tests. */ +function mockGen1App(overrides: Partial = {}): Gen1App { + return { + appId: 'app-123', + appName: 'test-app', + region: 'us-east-1', + envName: 'dev', + rootStackName: 'root-stack', + discover: () => [], meta: () => undefined, - } as unknown as Gen1App); + fileExists: () => false, + ...overrides, + } as unknown as Gen1App; +} + +function mockDiscover(resources: DiscoveredResource[]): Gen1App { + return mockGen1App({ discover: () => resources }); } function mockCreateInfrastructure(): jest.SpyInstance { @@ -142,117 +147,72 @@ function mockCreateInfrastructure(): jest.SpyInstance { accountId: '123456789012', gen1Env: {}, gen2Branch: {}, + cfn: {}, }); } -function createStep(toStack = 'gen2-stack'): AmplifyMigrationRefactorStep { - const logger = new SpinningLogger('refactor', { debug: true }); - const context = { parameters: { options: { to: toStack } } } as unknown as $TSContext; - return new AmplifyMigrationRefactorStep(logger, 'dev', 'test-app', 'app-123', 'root-stack', 'us-east-1', context); -} - describe('AmplifyMigrationRefactorStep', () => { - let createSpy: jest.SpyInstance; let infraSpy: jest.SpyInstance; afterEach(() => { - createSpy?.mockRestore(); infraSpy?.mockRestore(); }); - describe('assess()', () => { - it('records supported resources as supported', async () => { - createSpy = mockDiscover([ - { category: 'auth', resourceName: 'myPool', service: 'Cognito', key: 'auth:Cognito' }, - { category: 'storage', resourceName: 'myBucket', service: 'S3', key: 'storage:S3' }, - { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, - ]); - - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - const step = createStep(); - await step.assess(new Assessment('test-app', 'dev')); - - for (const name of ['myPool', 'myBucket', 'myFunc']) { - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: name }), { - supported: true, - }); - } - - recordSpy.mockRestore(); - }); - - it('records unsupported key as not supported', async () => { - createSpy = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }]); - - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - const step = createStep(); - await step.assess(new Assessment('test-app', 'dev')); - - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'push' }), { - supported: false, - }); - - recordSpy.mockRestore(); - }); - - it('records Cognito-UserPool-Groups as supported', async () => { - createSpy = mockDiscover([ - { category: 'auth', resourceName: 'userPoolGroups', service: 'Cognito-UserPool-Groups', key: 'auth:Cognito-UserPool-Groups' }, - ]); - - const recordSpy = jest.spyOn(Assessment.prototype, 'record'); - const step = createStep(); - await step.assess(new Assessment('test-app', 'dev')); - - expect(recordSpy).toHaveBeenCalledWith('refactor', expect.objectContaining({ resourceName: 'userPoolGroups' }), { - supported: true, - }); - - recordSpy.mockRestore(); - }); - }); - - describe('execute()', () => { - it('throws on unsupported resource key', async () => { + describe('forward()', () => { + it('fails validation when assessment contains unsupported resources', async () => { infraSpy = mockCreateInfrastructure(); - createSpy = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }]); + const gen1 = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'UNKNOWN' }]); + const logger = new SpinningLogger('refactor', { debug: true }); + const context = { parameters: { options: { to: 'gen2-stack' } } } as unknown as $TSContext; + const step = new AmplifyMigrationRefactorStep(logger, gen1, context, {} as AmplifyGen2MigrationValidations); - const step = createStep(); - await expect(step.forward()).rejects.toThrow(/Unsupported resource 'push'/); + const plan = await step.forward(); + const passed = await plan.validate(); + expect(passed).toBe(false); }); - it('does not throw for stateless-only resources', async () => { + it('passes validation for supported resources', async () => { infraSpy = mockCreateInfrastructure(); - createSpy = mockDiscover([ - { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, - { category: 'api', resourceName: 'myApi', service: 'AppSync', key: 'api:AppSync' }, - ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mocking private method for unit tests + const lockSpy = jest.spyOn(AmplifyMigrationRefactorStep.prototype as any, 'validateLockStatus').mockResolvedValue({ valid: true }); + const gen1 = mockDiscover([{ category: 'geo', resourceName: 'myMap', service: 'Map', key: 'geo:Map' }]); + const logger = new SpinningLogger('refactor', { debug: true }); + const context = { parameters: { options: { to: 'gen2-stack' } } } as unknown as $TSContext; + const step = new AmplifyMigrationRefactorStep(logger, gen1, context, {} as AmplifyGen2MigrationValidations); - const step = createStep(); const plan = await step.forward(); - await plan.describe(); + const passed = await plan.validate(); + expect(passed).toBe(true); + lockSpy.mockRestore(); }); }); describe('rollback()', () => { - it('throws on unsupported resource key', async () => { + it('fails validation when assessment contains unsupported resources', async () => { infraSpy = mockCreateInfrastructure(); - createSpy = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'unsupported' }]); + const gen1 = mockDiscover([{ category: 'notifications', resourceName: 'push', service: 'Pinpoint', key: 'UNKNOWN' }]); + const logger = new SpinningLogger('refactor', { debug: true }); + const context = { parameters: { options: { to: 'gen2-stack' } } } as unknown as $TSContext; + const step = new AmplifyMigrationRefactorStep(logger, gen1, context, {} as AmplifyGen2MigrationValidations); - const step = createStep(); - await expect(step.rollback()).rejects.toThrow(/Unsupported resource 'push'/); + const plan = await step.rollback(); + const passed = await plan.validate(); + expect(passed).toBe(false); }); - it('does not throw for stateless-only resources', async () => { + it('passes validation for supported resources', async () => { infraSpy = mockCreateInfrastructure(); - createSpy = mockDiscover([ - { category: 'function', resourceName: 'myFunc', service: 'Lambda', key: 'function:Lambda' }, - { category: 'api', resourceName: 'myGateway', service: 'API Gateway', key: 'api:API Gateway' }, - ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mocking private method for unit tests + const lockSpy = jest.spyOn(AmplifyMigrationRefactorStep.prototype as any, 'validateLockStatus').mockResolvedValue({ valid: true }); + const gen1 = mockDiscover([{ category: 'geo', resourceName: 'myMap', service: 'Map', key: 'geo:Map' }]); + const logger = new SpinningLogger('refactor', { debug: true }); + const context = { parameters: { options: { to: 'gen2-stack' } } } as unknown as $TSContext; + const step = new AmplifyMigrationRefactorStep(logger, gen1, context, {} as AmplifyGen2MigrationValidations); - const step = createStep(); const plan = await step.rollback(); - await plan.describe(); + const passed = await plan.validate(); + expect(passed).toBe(true); + lockSpy.mockRestore(); }); }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.test.ts index 57979966a0c..49ba5193bfd 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.test.ts @@ -1,5 +1,5 @@ import { resolveConditions } from '../../../../../commands/gen2-migration/refactor/resolvers/cfn-condition-resolver'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; /** * Builds a template with one condition gating one resource. diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.test.ts index 1ef045e91fd..740a3fec73c 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.test.ts @@ -1,5 +1,5 @@ import { resolveDependencies } from '../../../../../commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; const makeTemplate = (resources: Record): CFNTemplate => ({ AWSTemplateFormatVersion: '2010-09-09', diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts index c0f45f1ae05..33e11e2f2b5 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts @@ -1,5 +1,5 @@ import { resolveOutputs } from '../../../../../commands/gen2-migration/refactor/resolvers/cfn-output-resolver'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; const baseTemplate: CFNTemplate = { AWSTemplateFormatVersion: '2010-09-09', diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.test.ts index 90d458ff267..e1f14412861 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.test.ts @@ -1,5 +1,5 @@ import { resolveParameters } from '../../../../../commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; const baseTemplate: CFNTemplate = { AWSTemplateFormatVersion: '2010-09-09', diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/stack-facade.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/stack-facade.test.ts index f3993221cf4..518e43c8ce8 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/stack-facade.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/stack-facade.test.ts @@ -1,5 +1,5 @@ import { StackFacade } from '../../../../commands/gen2-migration/refactor/stack-facade'; -import { AwsClients } from '../../../../commands/gen2-migration/aws-clients'; +import { AwsClients } from '../../../../commands/gen2-migration/_infra/aws-clients'; import { mockClient } from 'aws-sdk-client-mock'; import { CloudFormationClient, GetTemplateCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; @@ -9,7 +9,7 @@ describe('StackFacade', () => { beforeEach(() => { cfnMock = mockClient(CloudFormationClient); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); facade = new StackFacade(clients, 'root-stack'); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.test.ts index 8365136e9f5..43f5c6cd2f8 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.test.ts @@ -1,4 +1,5 @@ import { StorageDynamoRollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/storage/storage-dynamo-rollback'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -10,8 +11,7 @@ describe('StorageDynamoRollbackRefactorer.targetLogicalId', () => { })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'DynamoDB', key: 'storage:DynamoDB' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-rollback.test.ts index 1c96458847f..b0b3aef9a68 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/storage/storage-rollback.test.ts @@ -1,4 +1,5 @@ import { StorageS3RollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/storage/storage-rollback'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -10,8 +11,7 @@ describe('StorageS3RollbackRefactorer.targetLogicalId', () => { })( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/category-refactorer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/category-refactorer.test.ts index abdb1aa087f..b46e5097882 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/category-refactorer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/category-refactorer.test.ts @@ -2,8 +2,9 @@ import { StorageS3ForwardRefactorer } from '../../../../../commands/gen2-migrati import { StorageS3RollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/storage/storage-rollback'; import { AnalyticsKinesisForwardRefactorer } from '../../../../../commands/gen2-migration/refactor/analytics/analytics-forward'; import { AnalyticsKinesisRollbackRefactorer } from '../../../../../commands/gen2-migration/refactor/analytics/analytics-rollback'; -import { CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; -import { AwsClients } from '../../../../../commands/gen2-migration/aws-clients'; +import { CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; +import { AwsClients } from '../../../../../commands/gen2-migration/_infra/aws-clients'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { StackFacade } from '../../../../../commands/gen2-migration/refactor/stack-facade'; import { noOpLogger } from '../../_framework/logger'; import { mockClient } from 'aws-sdk-client-mock'; @@ -63,12 +64,13 @@ function setupStorageMocks(cfnMock: ReturnType) { } function makeInstances() { - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const gen1Env = new StackFacade(clients, 'gen1-root'); const gen2Branch = new StackFacade(clients, 'gen2-root'); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); - return { clients, gen1Env, gen2Branch, cfn }; + const gen1App = { region: 'us-east-1', clients } as unknown as Gen1App; + return { clients, gen1Env, gen2Branch, cfn, gen1App }; } describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefactorer', () => { @@ -86,13 +88,12 @@ describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefact cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen1-root' }).resolves({ StackResources: [] }); cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-root' }).resolves({ StackResources: [] }); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); await expect( new StorageS3ForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -112,13 +113,12 @@ describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefact }); cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-root' }).resolves({ StackResources: [] }); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); await expect( new StorageS3ForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -139,13 +139,12 @@ describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefact }); cfnMock.on(GetTemplateCommand, { StackName: 'gen2-storage-stack' }).resolves({ TemplateBody: JSON.stringify(gen2StorageTemplate) }); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); await expect( new StorageS3ForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -169,12 +168,11 @@ describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefact setupStorageMocks(cfnMock); cfnMock.on(GetTemplateCommand, { StackName: 'gen1-storage-stack' }).resolves({ TemplateBody: JSON.stringify(noStorageTemplate) }); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); const ops = await new StorageS3ForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -191,12 +189,11 @@ describe('CategoryRefactorer.plan() orchestration — via StorageS3ForwardRefact it('produces updateSource → updateTarget → beforeMove → move for forward plan', async () => { setupStorageMocks(cfnMock); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); const ops = await new StorageS3ForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -255,12 +252,11 @@ describe('StorageS3RollbackRefactorer.plan() — rollback without holding stack' }); cfnMock.on(GetTemplateCommand, { StackName: 'gen1-storage-stack' }).resolves({ TemplateBody: JSON.stringify(gen1StorageTemplate) }); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); const ops = await new StorageS3RollbackRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -322,12 +318,11 @@ describe('Analytics wiring tests', () => { it('forward: discovers analytics stacks and maps Kinesis stream', async () => { setupAnalyticsMocks(cfnMock); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); const ops = await new AnalyticsKinesisForwardRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { @@ -347,12 +342,11 @@ describe('Analytics wiring tests', () => { it('rollback: discovers analytics stacks and maps to Gen1 KinesisStream ID', async () => { cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [] }); // no holding stack setupAnalyticsMocks(cfnMock); - const { clients, gen1Env, gen2Branch, cfn } = makeInstances(); + const { gen1Env, gen2Branch, cfn, gen1App } = makeInstances(); const ops = await new AnalyticsKinesisRollbackRefactorer( gen1Env, gen2Branch, - clients, - 'us-east-1', + gen1App, '123', noOpLogger(), { diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts index 05cacb08db4..a23a60a0413 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts @@ -1,5 +1,6 @@ import { ForwardCategoryRefactorer } from '../../../../../commands/gen2-migration/refactor/workflow/forward-category-refactorer'; -import { AwsClients } from '../../../../../commands/gen2-migration/aws-clients'; +import { AwsClients } from '../../../../../commands/gen2-migration/_infra/aws-clients'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { StackFacade } from '../../../../../commands/gen2-migration/refactor/stack-facade'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -54,14 +55,13 @@ describe('ForwardCategoryRefactorer.beforeMove', () => { cfnMock.on(GetTemplateCommand).resolves({ TemplateBody: GEN2_TEMPLATE_NO_BUCKET }); cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [] }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new TestForwardRefactorer( new StackFacade(clients, 'g1'), new StackFacade(clients, 'g2'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, @@ -82,14 +82,13 @@ describe('ForwardCategoryRefactorer.beforeMove', () => { cfnMock.on(DescribeStackResourcesCommand).resolves({ StackResources: [] }); cfnMock.on(GetTemplateCommand).resolves({ TemplateBody: GEN2_TEMPLATE_WITH_BUCKET }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new TestForwardRefactorer( new StackFacade(clients, 'g1'), new StackFacade(clients, 'g2'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, @@ -122,14 +121,13 @@ describe('ForwardCategoryRefactorer.beforeMove', () => { return { TemplateBody: GEN2_TEMPLATE_WITH_BUCKET }; }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new TestForwardRefactorer( new StackFacade(clients, 'g1'), new StackFacade(clients, 'g2'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, @@ -144,7 +142,7 @@ describe('ForwardCategoryRefactorer.beforeMove', () => { }); }); -import { CFNResource } from '../../../../../commands/gen2-migration/cfn-template'; +import { CFNResource } from '../../../../../commands/gen2-migration/_infra/cfn-template'; class TestForwardMappingRefactorer extends ForwardCategoryRefactorer { protected async fetchSourceStackId() { @@ -171,8 +169,7 @@ describe('ForwardCategoryRefactorer.buildResourceMappings (default type-matching const refactorer = new TestForwardMappingRefactorer( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts index 4196f143806..e0d9a53389f 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts @@ -1,6 +1,7 @@ import { RollbackCategoryRefactorer } from '../../../../../commands/gen2-migration/refactor/workflow/rollback-category-refactorer'; -import { CFNResource, CFNTemplate } from '../../../../../commands/gen2-migration/cfn-template'; -import { AwsClients } from '../../../../../commands/gen2-migration/aws-clients'; +import { CFNResource, CFNTemplate } from '../../../../../commands/gen2-migration/_infra/cfn-template'; +import { AwsClients } from '../../../../../commands/gen2-migration/_infra/aws-clients'; +import { Gen1App } from '../../../../../commands/gen2-migration/generate/_infra/gen1-app'; import { StackFacade } from '../../../../../commands/gen2-migration/refactor/stack-facade'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; import { noOpLogger } from '../../_framework/logger'; @@ -81,14 +82,13 @@ describe('RollbackCategoryRefactorer.afterMove', () => { cfnMock.on(ExecuteStackRefactorCommand).resolves({}); cfnMock.on(DescribeStackResourcesCommand).resolves({ StackResources: [] }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new TestRollbackRefactorer( new StackFacade(clients, 'gen1-root'), new StackFacade(clients, 'gen2-root'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123456789', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' }, @@ -97,7 +97,7 @@ describe('RollbackCategoryRefactorer.afterMove', () => { const operations = await (refactorer as any).afterMove('gen2-auth-stack-id'); - // 1 operation: refactor back to Gen2 (placeholder handled by Cfn.refactor) + // 1 operation: move resources from holding stack back to Gen2 expect(operations).toHaveLength(1); expect(await operations[0].describe()).toEqual([expect.stringContaining('Move')]); }); @@ -105,14 +105,13 @@ describe('RollbackCategoryRefactorer.afterMove', () => { it('returns empty operations when no holding stack exists', async () => { cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [] }); - const clients = new AwsClients({ region: 'us-east-1' }); + const clients = new (AwsClients as any)({ region: 'us-east-1' }); (clients as any).cloudFormation = new CloudFormationClient({}); const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); const refactorer = new TestRollbackRefactorer( new StackFacade(clients, 'gen1-root'), new StackFacade(clients, 'gen2-root'), - clients, - 'us-east-1', + { region: 'us-east-1', clients } as unknown as Gen1App, '123456789', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' }, @@ -131,8 +130,7 @@ class TestRollbackMappingRefactorer extends RollbackCategoryRefactorer { super( null as any, null as any, - null as any, - 'us-east-1', + { region: 'us-east-1' } as unknown as Gen1App, '123', noOpLogger(), { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, diff --git a/packages/amplify-cli/src/commands/drift-detection/detect-stack-drift.ts b/packages/amplify-cli/src/commands/drift-detection/detect-stack-drift.ts index a6d8cb1513f..72b1a2b7889 100644 --- a/packages/amplify-cli/src/commands/drift-detection/detect-stack-drift.ts +++ b/packages/amplify-cli/src/commands/drift-detection/detect-stack-drift.ts @@ -14,8 +14,8 @@ import { type DescribeStackDriftDetectionStatusCommandOutput, } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { extractCategory } from '../gen2-migration/categories'; -import type { SpinningLogger } from '../gen2-migration/_spinning-logger'; +import { extractCategory } from '../gen2-migration/_infra/categories'; +import type { SpinningLogger } from '../gen2-migration/_infra/spinning-logger'; import { extractStackNameFromId } from '../gen2-migration/refactor/utils'; /** diff --git a/packages/amplify-cli/src/commands/drift-detection/detect-template-drift.ts b/packages/amplify-cli/src/commands/drift-detection/detect-template-drift.ts index 84f206babec..bdfd07d9945 100644 --- a/packages/amplify-cli/src/commands/drift-detection/detect-template-drift.ts +++ b/packages/amplify-cli/src/commands/drift-detection/detect-template-drift.ts @@ -12,7 +12,7 @@ import { import { paginateListChangeSets } from '@aws-sdk/client-cloudformation'; import fs from 'fs-extra'; import * as path from 'path'; -import type { SpinningLogger } from '../gen2-migration/_spinning-logger'; +import type { SpinningLogger } from '../gen2-migration/_infra/spinning-logger'; export interface ResourceChangeWithNested extends ResourceChange { nestedChanges?: ResourceChangeWithNested[]; diff --git a/packages/amplify-cli/src/commands/drift-detection/services/cloudformation-service.ts b/packages/amplify-cli/src/commands/drift-detection/services/cloudformation-service.ts index 8259a974b28..4a948764c0f 100644 --- a/packages/amplify-cli/src/commands/drift-detection/services/cloudformation-service.ts +++ b/packages/amplify-cli/src/commands/drift-detection/services/cloudformation-service.ts @@ -8,7 +8,7 @@ import type { $TSContext } from '@aws-amplify/amplify-cli-core'; import { AmplifyError, pathManager, stateManager } from '@aws-amplify/amplify-cli-core'; import * as fs from 'fs-extra'; import * as path from 'path'; -import type { SpinningLogger } from '../../gen2-migration/_spinning-logger'; +import type { SpinningLogger } from '../../gen2-migration/_infra/spinning-logger'; import CloudFormation from '@aws-amplify/amplify-provider-awscloudformation/lib/aws-utils/aws-cfn'; import { downloadZip, extractZip } from '@aws-amplify/amplify-provider-awscloudformation/lib/zip-util'; diff --git a/packages/amplify-cli/src/commands/drift-detection/services/drift-formatter.ts b/packages/amplify-cli/src/commands/drift-detection/services/drift-formatter.ts index 1562b31a91a..14d1774f8b5 100644 --- a/packages/amplify-cli/src/commands/drift-detection/services/drift-formatter.ts +++ b/packages/amplify-cli/src/commands/drift-detection/services/drift-formatter.ts @@ -11,7 +11,7 @@ import { parseArn } from '@aws-amplify/amplify-cli-core'; import type { LocalDriftResults } from '../detect-local-drift'; import type { TemplateDriftResults, ResourceChangeWithNested } from '../detect-template-drift'; import { type StackDriftNode, type CloudFormationDriftResults } from '../detect-stack-drift'; -import { extractCategory } from '../../gen2-migration/categories'; +import { extractCategory } from '../../gen2-migration/_infra/categories'; interface DriftBlock { categoryName: string; diff --git a/packages/amplify-cli/src/commands/drift.ts b/packages/amplify-cli/src/commands/drift.ts index a909508709b..210cbc7aa33 100644 --- a/packages/amplify-cli/src/commands/drift.ts +++ b/packages/amplify-cli/src/commands/drift.ts @@ -9,7 +9,7 @@ import { detectStackDriftRecursive, type CloudFormationDriftResults } from './dr import { detectLocalDrift, type LocalDriftResults } from './drift-detection/detect-local-drift'; import { detectTemplateDrift, type TemplateDriftResults } from './drift-detection/detect-template-drift'; import { CloudFormationService, AmplifyConfigService, createUnifiedCategoryView } from './drift-detection/services'; -import { SpinningLogger } from './gen2-migration/_spinning-logger'; +import { SpinningLogger } from './gen2-migration/_infra/spinning-logger'; /** * Result of drift detection. diff --git a/packages/amplify-cli/src/commands/gen2-migration.ts b/packages/amplify-cli/src/commands/gen2-migration.ts index 5652fdf6bef..23ead4513fa 100644 --- a/packages/amplify-cli/src/commands/gen2-migration.ts +++ b/packages/amplify-cli/src/commands/gen2-migration.ts @@ -1,44 +1,34 @@ -import { AmplifyMigrationCloneStep } from './gen2-migration/clone'; import { $TSContext, AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { AmplifyMigrationStep } from './gen2-migration/_step'; +import { AmplifyMigrationStep } from './gen2-migration/_infra/step'; import { printer, prompter, isDebug } from '@aws-amplify/amplify-prompts'; import { AmplifyMigrationDecommissionStep } from './gen2-migration/decommission'; import { AmplifyMigrationGenerateStep } from './gen2-migration/generate'; import { AmplifyMigrationLockStep } from './gen2-migration/lock'; import { AmplifyMigrationRefactorStep } from './gen2-migration/refactor'; -import { AmplifyMigrationShiftStep } from './gen2-migration/shift'; -import { SpinningLogger } from './gen2-migration/_spinning-logger'; -import { stateManager } from '@aws-amplify/amplify-cli-core'; -import { AmplifyClient, GetAppCommand } from '@aws-sdk/client-amplify'; +import { SpinningLogger } from './gen2-migration/_infra/spinning-logger'; import chalk from 'chalk'; import { AmplifyMigrationAssessor } from './gen2-migration/assess'; -import { Plan } from './gen2-migration/_plan'; +import { Gen1App } from './gen2-migration/generate/_infra/gen1-app'; +import { Plan } from './gen2-migration/_infra/plan'; +import { AmplifyGen2MigrationValidations } from './gen2-migration/_infra/validations'; const STEPS = { - clone: { - class: AmplifyMigrationCloneStep, - description: 'Not Implemented', - }, - decommission: { - class: AmplifyMigrationDecommissionStep, - description: 'Decommission the Gen1 environment post migration', + lock: { + class: AmplifyMigrationLockStep, + description: 'Locks your Gen1 environment to prevent updates during migration', }, generate: { class: AmplifyMigrationGenerateStep, description: 'Generate Gen2 application code from your existing Gen1 environment', }, - lock: { - class: AmplifyMigrationLockStep, - description: 'Locks your Gen1 environment to prevent updates during migration', - }, refactor: { class: AmplifyMigrationRefactorStep, // eslint-disable-next-line spellcheck/spell-checker description: 'Move stateful resources from your Gen1 environment to your newly deployed Gen2 branch', }, - shift: { - class: AmplifyMigrationShiftStep, - description: 'Not Implemented', + decommission: { + class: AmplifyMigrationDecommissionStep, + description: 'Decommission the Gen1 environment post migration', }, }; @@ -75,45 +65,19 @@ export const run = async (context: $TSContext) => { }); } - // assuming all environment are deployed within the same app - can it be different? - const appId = (Object.values(stateManager.getTeamProviderInfo())[0] as any).awscloudformation.AmplifyAppId; - - const amplifyClient = new AmplifyClient(); - const app = await amplifyClient.send(new GetAppCommand({ appId })); - const appName = app.app.name; - - const migratingEnvName = (app.app.environmentVariables ?? {})['GEN2_MIGRATION_ENVIRONMENT_NAME']; - const localEnvName = stateManager.getCurrentEnvName(); - - if (!localEnvName && !migratingEnvName) { - throw new AmplifyError('EnvironmentNotInitializedError', { - message: `No environment configured for app '${appName}'`, - resolution: 'Run "amplify pull" to configure an environment.', - }); - } - - if (migratingEnvName && localEnvName && migratingEnvName !== localEnvName) { - throw new AmplifyError('MigrationError', { - message: `Environment mismatch: Your local env (${localEnvName}) does - not match the environment you marked for migration (${migratingEnvName})`, - }); - } - - const envName = localEnvName ?? migratingEnvName; - - const stackName = stateManager.getTeamProviderInfo()[envName].awscloudformation.StackName; - const region = stateManager.getTeamProviderInfo()[envName].awscloudformation.Region; + const gen1App = await Gen1App.create(context); - const logger = new SpinningLogger(`${stepName}] [${appName}/${envName}`, { debug: isDebug }); + const logger = new SpinningLogger(`${stepName}] [${gen1App.appName}/${gen1App.envName}`, { debug: isDebug }); // Assess is not a migration step — handle it separately. if (stepName === 'assess') { - const assessor = new AmplifyMigrationAssessor(logger, envName, appName, appId, stackName, region, context); - await assessor.run(); + const assessor = new AmplifyMigrationAssessor(gen1App); + assessor.run(); return; } - const implementation: AmplifyMigrationStep = new step.class(logger, envName, appName, appId, stackName, region, context); + const validations = new AmplifyGen2MigrationValidations(logger, gen1App, context); + const implementation: AmplifyMigrationStep = new step.class(logger, gen1App, context, validations); // Plan printer.blankLine(); @@ -145,7 +109,9 @@ export const run = async (context: $TSContext) => { printer.blankLine(); printer.info( - chalk.yellow(`You are about to ${rollingBack ? 'rollback' : 'execute'} '${stepName}' on environment '${appId}/${envName}'.`), + chalk.yellow( + `You are about to ${rollingBack ? 'rollback' : 'execute'} '${stepName}' on environment '${gen1App.appId}/${gen1App.envName}'.`, + ), ); printer.blankLine(); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_assessment.ts b/packages/amplify-cli/src/commands/gen2-migration/_assessment.ts deleted file mode 100644 index 0cc66519a3b..00000000000 --- a/packages/amplify-cli/src/commands/gen2-migration/_assessment.ts +++ /dev/null @@ -1,113 +0,0 @@ -import chalk from 'chalk'; -import { printer } from '@aws-amplify/amplify-prompts'; -import { DiscoveredResource, SupportResponse } from './generate/_infra/gen1-app'; - -/** - * Per-resource assessment combining generate and refactor support. - */ -export interface ResourceAssessment { - readonly resource: DiscoveredResource; - generate: SupportResponse; - refactor: SupportResponse; -} - -/** - * Collector that steps contribute to during assess(). - * Each step calls record() for every discovered resource, - * reporting whether it supports that resource. - */ -export class Assessment { - private readonly _entries = new Map(); - - constructor(private readonly appName: string, private readonly envName: string) {} - - /** - * Records a step's support for a discovered resource. - */ - public record(step: 'generate' | 'refactor', resource: DiscoveredResource, response: SupportResponse): void { - const key = `${resource.category}:${resource.resourceName}`; - if (!this._entries.has(key)) { - this._entries.set(key, { - resource, - generate: { supported: false }, - refactor: { supported: false }, - }); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- entry just created above if missing - this._entries.get(key)![step] = response; - } - - /** - * Returns all recorded assessments. - */ - public get entries(): ReadonlyMap { - return this._entries; - } - - /** - * Displays the assessment as a single flat table with a compact summary. - */ - public display(): void { - const assessments = [...this._entries.values()]; - - printer.blankLine(); - printer.info(chalk.bold(`Assessment for "${this.appName}" (env: ${this.envName})`)); - printer.blankLine(); - - Assessment.renderTable(assessments); - printer.blankLine(); - Assessment.renderSummary(assessments); - } - - private static renderTable(assessments: readonly ResourceAssessment[]): void { - const rows = assessments.map((a) => ({ - category: a.resource.category, - resource: a.resource.resourceName, - service: a.resource.service, - generate: Assessment.statusText(a.generate, 'manual code needed'), - refactor: Assessment.statusText(a.refactor, 'blocks migration'), - })); - - const colWidths = { - category: Math.max(8, ...rows.map((r) => r.category.length)) + 2, - resource: Math.max(8, ...rows.map((r) => r.resource.length)) + 2, - service: Math.max(7, ...rows.map((r) => r.service.length)) + 2, - generate: Math.max(8, ...rows.map((r) => r.generate.length)) + 2, - refactor: Math.max(8, ...rows.map((r) => r.refactor.length)) + 2, - }; - - const hr = (char: string, left: string, mid: string, right: string) => - `${left}${''.padEnd(colWidths.category, char)}${mid}${''.padEnd(colWidths.resource, char)}${mid}${''.padEnd( - colWidths.service, - char, - )}${mid}${''.padEnd(colWidths.generate, char)}${mid}${''.padEnd(colWidths.refactor, char)}${right}`; - - const row = (cat: string, res: string, svc: string, gen: string, ref: string) => - `│ ${cat.padEnd(colWidths.category - 2)} │ ${res.padEnd(colWidths.resource - 2)} │ ${svc.padEnd( - colWidths.service - 2, - )} │ ${gen.padEnd(colWidths.generate - 2)} │ ${ref.padEnd(colWidths.refactor - 2)} │`; - - printer.info(hr('─', '┌', '┬', '┐')); - printer.info(row('Category', 'Resource', 'Service', 'Generate', 'Refactor')); - printer.info(hr('─', '├', '┼', '┤')); - for (const r of rows) { - printer.info(row(r.category, r.resource, r.service, r.generate, r.refactor)); - } - printer.info(hr('─', '└', '┴', '┘')); - } - - private static renderSummary(assessments: readonly ResourceAssessment[]): void { - const refactorUnsupported = assessments.filter((a) => !a.refactor.supported).length; - - if (refactorUnsupported > 0) { - printer.info(chalk.red('✘ Migration blocked.')); - } else { - printer.info(chalk.green('✔ Migration can proceed.')); - } - } - - private static statusText(response: SupportResponse, unsupportedLabel: string): string { - if (!response.supported) return `✘ ${unsupportedLabel}`; - return '✔'; - } -} diff --git a/packages/amplify-cli/src/commands/gen2-migration/_infra/aws-clients.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/aws-clients.ts new file mode 100644 index 00000000000..e1025682353 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/aws-clients.ts @@ -0,0 +1,78 @@ +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { AppSyncClient } from '@aws-sdk/client-appsync'; +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'; +import { S3Client } from '@aws-sdk/client-s3'; +import { LambdaClient } from '@aws-sdk/client-lambda'; +import { CloudWatchEventsClient } from '@aws-sdk/client-cloudwatch-events'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { APIGatewayClient } from '@aws-sdk/client-api-gateway'; +import { SSMClient } from '@aws-sdk/client-ssm'; +import { STSClient } from '@aws-sdk/client-sts'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import type { AmplifyClientConfig } from '@aws-sdk/client-amplify'; + +// all clients share the same config so we just use one of them +// to encapsulate all properties we need. +type ClientConfig = AmplifyClientConfig; + +/** + * Single instantiation point for all AWS SDK clients used during Gen2 migration. + * Shared by both the generate and refactor steps. + */ +export class AwsClients { + public readonly amplify: AmplifyClient; + public readonly appSync: AppSyncClient; + public readonly cloudFormation: CloudFormationClient; + public readonly cognitoIdentityProvider: CognitoIdentityProviderClient; + public readonly cognitoIdentity: CognitoIdentityClient; + public readonly s3: S3Client; + public readonly lambda: LambdaClient; + public readonly cloudWatchEvents: CloudWatchEventsClient; + public readonly dynamoDB: DynamoDBClient; + public readonly apiGateway: APIGatewayClient; + public readonly ssm: SSMClient; + public readonly sts: STSClient; + + private constructor(config: ClientConfig) { + this.amplify = new AmplifyClient(config); + this.appSync = new AppSyncClient(config); + this.cloudFormation = new CloudFormationClient(config); + this.cognitoIdentityProvider = new CognitoIdentityProviderClient(config); + this.cognitoIdentity = new CognitoIdentityClient(config); + this.s3 = new S3Client(config); + this.lambda = new LambdaClient(config); + this.cloudWatchEvents = new CloudWatchEventsClient(config); + this.dynamoDB = new DynamoDBClient(config); + this.apiGateway = new APIGatewayClient(config); + this.ssm = new SSMClient(config); + this.sts = new STSClient(config); + } + + public static async create(context: $TSContext): Promise { + const providerPlugins = context.amplify.getProviderPlugins(context); + const provider = require(providerPlugins['awscloudformation']); + + let cred = {}; + try { + context.amplify.constructExeInfo(context); + cred = await provider.loadConfiguration(context); + } catch (error) { + // ignore missing config, the user may have default credentials configured, + // which is enough for us. it will fail later on if not. + } + + const config: ClientConfig = { + ...cred, + customUserAgent: provider.formUserAgentParam(context, 'gen2-migration'), + requestHandler: new NodeHttpHandler({ + httpAgent: provider.proxyAgent(), + httpsAgent: provider.proxyAgent(), + }), + }; + + return new AwsClients(config); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/categories.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/categories.ts similarity index 100% rename from packages/amplify-cli/src/commands/gen2-migration/categories.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/categories.ts diff --git a/packages/amplify-cli/src/commands/gen2-migration/cfn-template.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts similarity index 100% rename from packages/amplify-cli/src/commands/gen2-migration/cfn-template.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts diff --git a/packages/amplify-cli/src/commands/gen2-migration/_operation.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/operation.ts similarity index 95% rename from packages/amplify-cli/src/commands/gen2-migration/_operation.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/operation.ts index 9f911f075b6..3c564f34f6f 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_operation.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/operation.ts @@ -1,4 +1,4 @@ -import { DiscoveredResource } from './generate/_infra/gen1-app'; +import { DiscoveredResource } from '../generate/_infra/gen1-app'; /** * Result of a validation check. diff --git a/packages/amplify-cli/src/commands/gen2-migration/_plan.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts similarity index 97% rename from packages/amplify-cli/src/commands/gen2-migration/_plan.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts index 0d2ee87604f..1a5bbb0740e 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_plan.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/plan.ts @@ -1,5 +1,5 @@ -import { AmplifyMigrationOperation } from './_operation'; -import { SpinningLogger } from './_spinning-logger'; +import { AmplifyMigrationOperation } from './operation'; +import { SpinningLogger } from './spinning-logger'; import { printer } from '@aws-amplify/amplify-prompts'; import chalk from 'chalk'; import CLITable from 'cli-table3'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/planner.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/planner.ts similarity index 78% rename from packages/amplify-cli/src/commands/gen2-migration/planner.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/planner.ts index 32f8737a005..15f83450c02 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/planner.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/planner.ts @@ -1,4 +1,4 @@ -import { AmplifyMigrationOperation } from './_operation'; +import { AmplifyMigrationOperation } from './operation'; /** * Shared interface for units of work in the migration pipeline. diff --git a/packages/amplify-cli/src/commands/gen2-migration/_spinning-logger.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts similarity index 100% rename from packages/amplify-cli/src/commands/gen2-migration/_spinning-logger.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/spinning-logger.ts diff --git a/packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/stateful-resources.ts similarity index 100% rename from packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/stateful-resources.ts diff --git a/packages/amplify-cli/src/commands/gen2-migration/_step.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/step.ts similarity index 61% rename from packages/amplify-cli/src/commands/gen2-migration/_step.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/step.ts index 1c274573722..7a2b09b4509 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_step.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/step.ts @@ -1,6 +1,8 @@ import { $TSContext } from '@aws-amplify/amplify-cli-core'; -import { SpinningLogger } from './_spinning-logger'; -import { Plan } from './_plan'; +import { SpinningLogger } from './spinning-logger'; +import { Plan } from './plan'; +import { Gen1App } from '../generate/_infra/gen1-app'; +import { AmplifyGen2MigrationValidations } from './validations'; /** * Abstract base class that defines the lifecycle contract for all migration steps. @@ -8,12 +10,9 @@ import { Plan } from './_plan'; export abstract class AmplifyMigrationStep { constructor( protected readonly logger: SpinningLogger, - protected readonly currentEnvName: string, - protected readonly appName: string, - protected readonly appId: string, - protected readonly rootStackName: string, - protected readonly region: string, + protected readonly gen1App: Gen1App, protected readonly context: $TSContext, + protected readonly validations: AmplifyGen2MigrationValidations, ) {} /** diff --git a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/validations.ts similarity index 86% rename from packages/amplify-cli/src/commands/gen2-migration/_validations.ts rename to packages/amplify-cli/src/commands/gen2-migration/_infra/validations.ts index ad9db7afd16..ae608043ee6 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/validations.ts @@ -1,8 +1,7 @@ -import { AmplifyDriftDetector } from '../drift'; +import { AmplifyDriftDetector } from '../../drift'; import { $TSContext, AmplifyError, stateManager } from '@aws-amplify/amplify-cli-core'; import { DescribeChangeSetOutput, - CloudFormationClient, DescribeStacksCommand, ListStackResourcesCommand, GetStackPolicyCommand, @@ -11,10 +10,11 @@ import { STATEFUL_RESOURCES } from './stateful-resources'; import CLITable from 'cli-table3'; import Bottleneck from 'bottleneck'; import execa from 'execa'; -import { SpinningLogger } from './_spinning-logger'; +import { SpinningLogger } from './spinning-logger'; import chalk from 'chalk'; import { printer } from '@aws-amplify/amplify-prompts'; import { extractCategory } from './categories'; +import { Gen1App } from '../generate/_infra/gen1-app'; export class AmplifyGen2MigrationValidations { private readonly limiter = new Bottleneck({ @@ -22,12 +22,7 @@ export class AmplifyGen2MigrationValidations { minTime: 50, }); - constructor( - private readonly logger: SpinningLogger, - private readonly rootStackName: string, - private readonly envName, - private readonly context: $TSContext, - ) {} + public constructor(private readonly logger: SpinningLogger, private readonly gen1App: Gen1App, private readonly context: $TSContext) {} public async validateDrift(): Promise { const result = await new AmplifyDriftDetector(this.context, this.logger).detect(); @@ -52,13 +47,12 @@ export class AmplifyGen2MigrationValidations { } public async validateDeploymentStatus(): Promise { - this.logger.debug(`Inspecting root stack '${this.rootStackName}' status`); - const cfnClient = new CloudFormationClient({}); - const response = await cfnClient.send(new DescribeStacksCommand({ StackName: this.rootStackName })); + this.logger.debug(`Inspecting root stack '${this.gen1App.rootStackName}' status`); + const response = await this.gen1App.clients.cloudFormation.send(new DescribeStacksCommand({ StackName: this.gen1App.rootStackName })); if (!response.Stacks || response.Stacks.length === 0) { throw new AmplifyError('StackNotFoundError', { - message: `Stack ${this.rootStackName} not found in CloudFormation`, + message: `Stack ${this.gen1App.rootStackName} not found in CloudFormation`, resolution: 'Ensure the project is deployed.', }); } @@ -89,7 +83,7 @@ export class AmplifyGen2MigrationValidations { if (!changeSet.Changes) return; const deploymentBucketName = excludeDeploymentBucket - ? stateManager.getTeamProviderInfo()[this.envName].awscloudformation.DeploymentBucketName + ? stateManager.getTeamProviderInfo()[this.gen1App.envName].awscloudformation.DeploymentBucketName : undefined; this.logger.info('Scanning for stateful resources...'); @@ -153,9 +147,10 @@ export class AmplifyGen2MigrationValidations { } public async validateLockStatus(): Promise { - const cfnClient = new CloudFormationClient({}); - this.logger.debug(`Inspecting stack policy for ${this.rootStackName}`); - const { StackPolicyBody } = await cfnClient.send(new GetStackPolicyCommand({ StackName: this.rootStackName })); + this.logger.debug(`Inspecting stack policy for ${this.gen1App.rootStackName}`); + const { StackPolicyBody } = await this.gen1App.clients.cloudFormation.send( + new GetStackPolicyCommand({ StackName: this.gen1App.rootStackName }), + ); if (!StackPolicyBody) { throw new AmplifyError('MigrationError', { @@ -183,7 +178,7 @@ export class AmplifyGen2MigrationValidations { }); } - this.logger.debug(chalk.green(`Stack ${this.rootStackName} is locked ✔`)); + this.logger.debug(chalk.green(`Stack ${this.gen1App.rootStackName} is locked ✔`)); } private async getStatefulResources( @@ -191,17 +186,15 @@ export class AmplifyGen2MigrationValidations { parentLogicalId?: string, ): Promise> { const statefulResources: Array<{ category: string; resourceType: string; physicalId: string }> = []; - const cfn = new CloudFormationClient({ - maxAttempts: 5, - retryMode: 'adaptive', - }); const parentCategory = parentLogicalId ? extractCategory(parentLogicalId) : undefined; let nextToken: string | undefined; const nestedStackTasks: Array<{ physicalId: string; logicalId: string | undefined }> = []; do { - const response = await cfn.send(new ListStackResourcesCommand({ StackName: stackName, NextToken: nextToken })); + const response = await this.gen1App.clients.cloudFormation.send( + new ListStackResourcesCommand({ StackName: stackName, NextToken: nextToken }), + ); nextToken = response.NextToken; for (const resource of response.StackResourceSummaries ?? []) { diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess.ts b/packages/amplify-cli/src/commands/gen2-migration/assess.ts index a9b94c047c1..5488920bc0a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/assess.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/assess.ts @@ -1,52 +1,89 @@ -import { $TSContext } from '@aws-amplify/amplify-cli-core'; -import { Assessment } from './_assessment'; -import { AmplifyMigrationGenerateStep } from './generate'; -import { AmplifyMigrationRefactorStep } from './refactor'; -import { SpinningLogger } from '../gen2-migration/_spinning-logger'; +import { Assessment, unsupported } from './assess/assessment'; +import { Gen1App } from './generate/_infra/gen1-app'; +import { printer } from '@aws-amplify/amplify-prompts'; +import { Assessor } from './assess/assessor'; +import { AuthCognitoAssessor } from './assess/auth/auth-cognito.assessor'; +import { AuthUserPoolGroupsAssessor } from './assess/auth/auth-user-pool-groups.assessor'; +import { S3Assessor } from './assess/storage/s3.assessor'; +import { DynamoDBAssessor } from './assess/storage/dynamodb.assessor'; +import { DataAssessor } from './assess/api/data.assessor'; +import { RestApiAssessor } from './assess/api/rest-api.assessor'; +import { AnalyticsKinesisAssessor } from './assess/analytics/kinesis.assessor'; +import { FunctionAssessor } from './assess/function/function.assessor'; +import { GeoFenceCollectionAssessor } from './assess/geo/geo-geofence-collection.assessor'; +import { GeoMapAssessor } from './assess/geo/geo-map.assessor'; +import { GeoPlaceIndexAssessor } from './assess/geo/geo-place-index.assessor'; /** - * Evaluates migration readiness by calling assess() on the generate - * and refactor steps, then renders the result. + * Evaluates migration readiness by discovering resources and + * delegating to per-category assessors for resource-level and + * feature-level support detection. */ export class AmplifyMigrationAssessor { - constructor( - private readonly logger: SpinningLogger, - private readonly currentEnvName: string, - private readonly appName: string, - private readonly appId: string, - private readonly rootStackName: string, - private readonly region: string, - private readonly context: $TSContext, - ) {} + public constructor(private readonly gen1App: Gen1App) {} - /** - * Runs assessment and renders the result to the terminal. - */ - public async run(): Promise { - const assessment = new Assessment(this.appName, this.currentEnvName); + public assess(): Assessment { + const discovered = this.gen1App.discover(); + const combined = new Assessment(this.gen1App.appName, this.gen1App.envName); + + for (const resource of discovered) { + const assessors: Assessor[] = []; - const generateStep = new AmplifyMigrationGenerateStep( - this.logger, - this.currentEnvName, - this.appName, - this.appId, - this.rootStackName, - this.region, - this.context, - ); - await generateStep.assess(assessment); + switch (resource.key) { + case 'auth:Cognito': + assessors.push(new AuthCognitoAssessor(this.gen1App, resource)); + break; + case 'auth:Cognito-UserPool-Groups': + assessors.push(new AuthUserPoolGroupsAssessor(this.gen1App, resource)); + break; + case 'storage:S3': + assessors.push(new S3Assessor(this.gen1App, resource)); + break; + case 'storage:DynamoDB': + assessors.push(new DynamoDBAssessor(this.gen1App, resource)); + break; + case 'api:AppSync': + assessors.push(new DataAssessor(this.gen1App, resource)); + break; + case 'api:API Gateway': + assessors.push(new RestApiAssessor(this.gen1App, resource)); + break; + case 'analytics:Kinesis': + assessors.push(new AnalyticsKinesisAssessor(this.gen1App, resource)); + break; + case 'function:Lambda': + assessors.push(new FunctionAssessor(this.gen1App, resource)); + break; + case 'geo:GeofenceCollection': + assessors.push(new GeoFenceCollectionAssessor(this.gen1App, resource)); + break; + case 'geo:Map': + assessors.push(new GeoMapAssessor(this.gen1App, resource)); + break; + case 'geo:PlaceIndex': + assessors.push(new GeoPlaceIndexAssessor(this.gen1App, resource)); + break; + case 'UNKNOWN': + combined.recordResource({ + resource, + generate: unsupported('unknown resource type'), + refactor: unsupported('unknown resource type'), + }); + break; + } - const refactorStep = new AmplifyMigrationRefactorStep( - this.logger, - this.currentEnvName, - this.appName, - this.appId, - this.rootStackName, - this.region, - this.context, - ); - await refactorStep.assess(assessment); + for (const assessor of assessors) { + assessor.record(combined); + } + } + return combined; + } - assessment.display(); + /** + * Assesses all discovered resources and prints the full report. + */ + public run(): void { + const assessment = this.assess(); + printer.info(assessment.render()); } } diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/analytics/kinesis.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/analytics/kinesis.assessor.ts new file mode 100644 index 00000000000..e6dc5833e2e --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/analytics/kinesis.assessor.ts @@ -0,0 +1,17 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported } from '../assessment'; +import { Gen1App, DiscoveredResource } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a Kinesis analytics resource. + */ +export class AnalyticsKinesisAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this Kinesis resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/api/data.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/api/data.assessor.ts new file mode 100644 index 00000000000..8580515ebd1 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/api/data.assessor.ts @@ -0,0 +1,28 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for an AppSync GraphQL API resource. + * Detects overrides.ts usage. + */ +export class DataAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level and feature-level support for this API resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: notApplicable() }); + + const overridesPath = `api/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/api/rest-api.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/api/rest-api.assessor.ts new file mode 100644 index 00000000000..f550503e9bc --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/api/rest-api.assessor.ts @@ -0,0 +1,27 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for an API Gateway REST API resource. + */ +export class RestApiAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this REST API resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: notApplicable() }); + + const overridesPath = `api/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts new file mode 100644 index 00000000000..d805d088abe --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/assessment.ts @@ -0,0 +1,189 @@ +import chalk from 'chalk'; +import CLITable from 'cli-table3'; +import { DiscoveredResource } from '../generate/_infra/gen1-app'; + +/** + * Support level for a resource or feature dimension. + */ +export type SupportLevel = 'supported' | 'unsupported' | 'not-applicable'; + +/** + * Support entry combining a level with an optional note. + * The note is displayed in the assessment table when the level is unsupported. + */ +export interface Support { + readonly level: SupportLevel; + readonly note?: string; +} + +/** + * Shorthand for a supported entry. + */ +export const supported = (): Support => ({ level: 'supported' }); + +/** + * Shorthand for an unsupported entry with a note. + */ +export const unsupported = (note: string): Support => ({ level: 'unsupported', note }); + +/** + * Shorthand for a not-applicable entry. + */ +export const notApplicable = (): Support => ({ level: 'not-applicable' }); + +interface _Assessment { + readonly generate: Support; + readonly refactor: Support; +} + +/** + * Per-resource assessment combining generate and refactor support. + */ +export interface ResourceAssessment extends _Assessment { + readonly resource: DiscoveredResource; +} + +/** + * A detected sub-feature within a resource that the migration tool + * may or may not handle. + */ +export interface FeatureAssessment extends _Assessment { + readonly feature: DiscoveredFeature; +} + +/** + * A detected sub-feature within a resource. + */ +export interface DiscoveredFeature { + readonly name: string; + readonly path: string; +} + +/** + * Collector that assessors contribute to during assess(). + * Accumulates both resource-level and feature-level entries, + * and renders both tables as a string report. + */ +export class Assessment { + private readonly _resources: ResourceAssessment[] = []; + private readonly _features: FeatureAssessment[] = []; + + public constructor(private readonly appName: string, private readonly envName: string) {} + + /** + * Records support for a discovered resource. + */ + public recordResource(resource: ResourceAssessment): void { + this._resources.push(resource); + } + + /** + * Records a detected feature that the migration tool does not fully support. + */ + public recordFeature(feature: FeatureAssessment): void { + this._features.push(feature); + } + + /** + * All recorded resource assessments. + */ + public get resources(): readonly ResourceAssessment[] { + return this._resources; + } + + /** + * All recorded feature assessments. + */ + public get features(): readonly FeatureAssessment[] { + return this._features; + } + + /** + * Returns true if all resources and features are supported for the given step. + */ + // eslint-disable-next-line consistent-return -- exhaustive switch; compiler enforces all cases + public validFor(step: 'generate' | 'refactor'): boolean { + switch (step) { + case 'generate': + return ( + this._resources.every((ar) => ar.generate.level !== 'unsupported') && + this._features.every((fr) => fr.generate.level !== 'unsupported') + ); + case 'refactor': + return ( + this._resources.every((ar) => ar.refactor.level !== 'unsupported') && + this._features.every((fr) => fr.refactor.level !== 'unsupported') + ); + } + } + + /** + * Renders the assessment as a string containing resource and feature tables. + */ + public render(): string { + const lines: string[] = []; + + lines.push(''); + lines.push(chalk.bold(chalk.cyan(`Assessment for "${this.appName}" (env: ${this.envName})`))); + + if (this._resources.length > 0) { + lines.push(''); + lines.push(chalk.bold('Resources')); + lines.push(''); + lines.push(this.renderResourceTable()); + } + + if (this._features.length > 0) { + lines.push(''); + lines.push(chalk.bold('Features')); + lines.push(''); + lines.push(this.renderFeatureTable()); + } + + return lines.join('\n'); + } + + private renderResourceTable(): string { + const table = new CLITable({ + head: ['Category', 'Service', 'Resource', 'Generate', 'Refactor'], + style: { head: [] }, + }); + + for (const ra of this._resources) { + table.push([ + ra.resource.category, + ra.resource.service, + ra.resource.resourceName, + Assessment.supportText(ra.generate), + Assessment.supportText(ra.refactor), + ]); + } + + return table.toString(); + } + + private renderFeatureTable(): string { + const table = new CLITable({ + head: ['Name', 'Path', 'Generate', 'Refactor'], + style: { head: [] }, + }); + + for (const fr of this._features) { + table.push([fr.feature.name, fr.feature.path, Assessment.supportText(fr.generate), Assessment.supportText(fr.refactor)]); + } + + return table.toString(); + } + + // eslint-disable-next-line consistent-return -- exhaustive switch; compiler enforces all cases + private static supportText(support: Support): string { + switch (support.level) { + case 'supported': + return '✔'; + case 'unsupported': + return support.note ? `✘ ${support.note}` : '✘'; + case 'not-applicable': + return '—'; + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/assessor.ts new file mode 100644 index 00000000000..4485f9fb114 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/assessor.ts @@ -0,0 +1,9 @@ +import { Assessment } from './assessment'; + +/** + * Evaluates migration readiness for a single discovered resource. + * Each assessor records resource-level and feature-level support. + */ +export interface Assessor { + record(assessment: Assessment): void; +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-cognito.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-cognito.assessor.ts new file mode 100644 index 00000000000..36c86880d59 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-cognito.assessor.ts @@ -0,0 +1,28 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a Cognito auth resource. + * Detects overrides.ts usage. + */ +export class AuthCognitoAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level and feature-level support for this auth resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + + const overridesPath = `auth/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.ts new file mode 100644 index 00000000000..c63823f5bac --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/auth/auth-user-pool-groups.assessor.ts @@ -0,0 +1,28 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a Cognito User Pool Groups resource. + * Detects overrides.ts usage. + */ +export class AuthUserPoolGroupsAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level and feature-level support for this user pool groups resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + + const overridesPath = `auth/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts new file mode 100644 index 00000000000..71287b1a6f9 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/function/function.assessor.ts @@ -0,0 +1,44 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a single Lambda function resource. + * Detects custom-policies.json usage. + */ +export class FunctionAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level and feature-level support for this function. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ + resource: this.resource, + generate: supported(), + refactor: supported(), + }); + + const customPoliciesPath = `function/${this.resource.resourceName}/custom-policies.json`; + + if (this.hasCustomPolicies(customPoliciesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.CUSTOM_FUNCTION_POLICIES, path: customPoliciesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } + + /** + * Returns true if the function has non-empty custom policies. + * The file always exists but defaults to `[{"Action":[],"Resource":[]}]`. + */ + private hasCustomPolicies(filePath: string): boolean { + if (!this.gen1App.fileExists(filePath)) return false; + + const policies = this.gen1App.json(filePath); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped custom-policies.json + return policies.some((p: any) => p.Action.length > 0 || p.Resource.length > 0); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-geofence-collection.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-geofence-collection.assessor.ts new file mode 100644 index 00000000000..50725ba32ca --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-geofence-collection.assessor.ts @@ -0,0 +1,21 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported } from '../assessment'; +import { Gen1App, DiscoveredResource } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a geo GeofenceCollection resource. + */ +export class GeoFenceCollectionAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this GeofenceCollection resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ + resource: this.resource, + generate: supported(), + refactor: unsupported('requires manual data replication'), + }); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-map.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-map.assessor.ts new file mode 100644 index 00000000000..f13d31a041f --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-map.assessor.ts @@ -0,0 +1,17 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported } from '../assessment'; +import { Gen1App, DiscoveredResource } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a geo Map resource. + */ +export class GeoMapAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this Map resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-place-index.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-place-index.assessor.ts new file mode 100644 index 00000000000..886003c3c54 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/geo/geo-place-index.assessor.ts @@ -0,0 +1,17 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported } from '../assessment'; +import { Gen1App, DiscoveredResource } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a geo PlaceIndex resource. + */ +export class GeoPlaceIndexAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this PlaceIndex resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/storage/dynamodb.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/storage/dynamodb.assessor.ts new file mode 100644 index 00000000000..6812f0c502d --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/storage/dynamodb.assessor.ts @@ -0,0 +1,27 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for a DynamoDB storage resource. + */ +export class DynamoDBAssessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level support for this DynamoDB resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + + const overridesPath = `storage/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/assess/storage/s3.assessor.ts b/packages/amplify-cli/src/commands/gen2-migration/assess/storage/s3.assessor.ts new file mode 100644 index 00000000000..90027a8c3ec --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/assess/storage/s3.assessor.ts @@ -0,0 +1,28 @@ +import { Assessor } from '../assessor'; +import { Assessment, supported, unsupported, notApplicable } from '../assessment'; +import { Gen1App, DiscoveredResource, KNOWN_FEATURES } from '../../generate/_infra/gen1-app'; + +/** + * Assesses migration readiness for an S3 storage resource. + * Detects overrides.ts usage. + */ +export class S3Assessor implements Assessor { + public constructor(private readonly gen1App: Gen1App, private readonly resource: DiscoveredResource) {} + + /** + * Records resource-level and feature-level support for this S3 resource. + */ + public record(assessment: Assessment): void { + assessment.recordResource({ resource: this.resource, generate: supported(), refactor: supported() }); + + const overridesPath = `storage/${this.resource.resourceName}/override.ts`; + + if (this.gen1App.fileExists(overridesPath)) { + assessment.recordFeature({ + feature: { name: KNOWN_FEATURES.OVERRIDES, path: overridesPath }, + generate: unsupported('requires manual code changes'), + refactor: notApplicable(), + }); + } + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/aws-clients.ts b/packages/amplify-cli/src/commands/gen2-migration/aws-clients.ts deleted file mode 100644 index 2ba4e3da2e1..00000000000 --- a/packages/amplify-cli/src/commands/gen2-migration/aws-clients.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { AmplifyClient } from '@aws-sdk/client-amplify'; -import { AppSyncClient } from '@aws-sdk/client-appsync'; -import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; -import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; -import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'; -import { S3Client } from '@aws-sdk/client-s3'; -import { LambdaClient } from '@aws-sdk/client-lambda'; -import { CloudWatchEventsClient } from '@aws-sdk/client-cloudwatch-events'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { APIGatewayClient } from '@aws-sdk/client-api-gateway'; -import { SSMClient } from '@aws-sdk/client-ssm'; - -/** - * Single instantiation point for all AWS SDK clients used during Gen2 migration. - * Shared by both the generate and refactor steps. - */ -export class AwsClients { - public readonly amplify: AmplifyClient; - public readonly appSync: AppSyncClient; - public readonly cloudFormation: CloudFormationClient; - public readonly cognitoIdentityProvider: CognitoIdentityProviderClient; - public readonly cognitoIdentity: CognitoIdentityClient; - public readonly s3: S3Client; - public readonly lambda: LambdaClient; - public readonly cloudWatchEvents: CloudWatchEventsClient; - public readonly dynamoDB: DynamoDBClient; - public readonly apiGateway: APIGatewayClient; - public readonly ssm: SSMClient; - - constructor(params: { readonly region: string }) { - this.amplify = new AmplifyClient({ region: params.region }); - this.appSync = new AppSyncClient({ region: params.region }); - this.cloudFormation = new CloudFormationClient({ region: params.region }); - this.cognitoIdentityProvider = new CognitoIdentityProviderClient({ region: params.region }); - this.cognitoIdentity = new CognitoIdentityClient({ region: params.region }); - this.s3 = new S3Client({ region: params.region }); - this.lambda = new LambdaClient({ region: params.region }); - this.cloudWatchEvents = new CloudWatchEventsClient({ region: params.region }); - this.dynamoDB = new DynamoDBClient({ region: params.region }); - this.apiGateway = new APIGatewayClient({ region: params.region }); - this.ssm = new SSMClient({ region: params.region }); - } -} diff --git a/packages/amplify-cli/src/commands/gen2-migration/clone.ts b/packages/amplify-cli/src/commands/gen2-migration/clone.ts deleted file mode 100644 index 7b6b908e3ee..00000000000 --- a/packages/amplify-cli/src/commands/gen2-migration/clone.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AmplifyMigrationStep } from './_step'; -import { Plan } from './_plan'; - -export class AmplifyMigrationCloneStep extends AmplifyMigrationStep { - public async forward(): Promise { - throw new Error('Method not implemented.'); - } - - public async rollback(): Promise { - throw new Error('Not Implemented'); - } -} diff --git a/packages/amplify-cli/src/commands/gen2-migration/decommission.ts b/packages/amplify-cli/src/commands/gen2-migration/decommission.ts index 0680f402b33..c904889564d 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/decommission.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/decommission.ts @@ -1,9 +1,7 @@ -import { AmplifyMigrationStep } from './_step'; -import { AmplifyMigrationOperation, ValidationResult } from './_operation'; -import { Plan } from './_plan'; -import { AmplifyGen2MigrationValidations } from './_validations'; +import { AmplifyMigrationStep } from './_infra/step'; +import { AmplifyMigrationOperation, ValidationResult } from './_infra/operation'; +import { Plan } from './_infra/plan'; import { - CloudFormationClient, CreateChangeSetCommand, DescribeChangeSetCommand, DeleteChangeSetCommand, @@ -18,9 +16,8 @@ import { Cfn, HOLDING_STACK_NAME_SUFFIX } from './refactor/cfn'; export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { public async forward(): Promise { - const cfnClient = new CloudFormationClient({ region: this.region }); - const cfn = new Cfn(cfnClient, this.logger); - const holdingStacks = await this.findHoldingStacks(cfnClient); + const cfn = new Cfn(this.gen1App.clients.cloudFormation, this.logger); + const holdingStacks = await this.findHoldingStacks(); const operations: AmplifyMigrationOperation[] = []; @@ -47,14 +44,14 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { validate: () => undefined, describe: async () => ['Delete the Gen1 environment'], execute: async () => { - this.logger.info(`Starting decommission of environment: ${this.currentEnvName}`); + this.logger.info(`Starting decommission of environment: ${this.gen1App.envName}`); this.logger.info('Preparing to delete Gen1 resources...'); this.logger.info('Deleting Gen1 resources from the cloud. This will take a few minutes.'); - await removeEnvFromCloud(this.context, this.currentEnvName, true); + await removeEnvFromCloud(this.context, this.gen1App.envName, true); this.logger.info('Cleaning up SSM parameters...'); - await invokeDeleteEnvParamsFromService(this.context, this.currentEnvName); + await invokeDeleteEnvParamsFromService(this.context, this.gen1App.envName); this.logger.info('Successfully decommissioned Gen1 environment from the cloud'); - this.logger.info(`Environment '${this.currentEnvName}' has been completely removed from AWS`); + this.logger.info(`Environment '${this.gen1App.envName}' has been completely removed from AWS`); }, }); @@ -76,19 +73,18 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { private async validateStatefulResources(): Promise { try { const changeSet = await this.createChangeSet(); - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); // eslint-disable-next-line spellcheck/spell-checker - await validations.validateStatefulResources(changeSet, true); + await this.validations.validateStatefulResources(changeSet, true); return { valid: true }; } catch (e) { return { valid: false, report: e.message }; } } - private async findHoldingStacks(cfnClient: CloudFormationClient): Promise { + private async findHoldingStacks(): Promise { const holdingStacks: string[] = []; const paginator = paginateListStacks( - { client: cfnClient }, + { client: this.gen1App.clients.cloudFormation }, { StackStatusFilter: [ StackStatus.CREATE_COMPLETE, @@ -100,7 +96,7 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { ); for await (const page of paginator) { for (const stack of page.StackSummaries ?? []) { - if (stack.StackName?.endsWith(HOLDING_STACK_NAME_SUFFIX) && stack.StackName.includes(this.appId)) { + if (stack.StackName?.endsWith(HOLDING_STACK_NAME_SUFFIX) && stack.StackName.includes(this.gen1App.appId)) { holdingStacks.push(stack.StackName); } } @@ -109,12 +105,12 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { } private async createChangeSet(): Promise { - const cfn = new CloudFormationClient({}); + const cfn = this.gen1App.clients.cloudFormation; const changeSetName = `decommission-${Date.now()}`; await cfn.send( new CreateChangeSetCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, ChangeSetName: changeSetName, TemplateBody: JSON.stringify({ Resources: { @@ -129,7 +125,7 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { this.logger.info('Analyzing environment resources...'); await waitUntilChangeSetCreateComplete( { client: cfn, maxWaitTime: 120 }, - { StackName: this.rootStackName, ChangeSetName: changeSetName }, + { StackName: this.gen1App.rootStackName, ChangeSetName: changeSetName }, ); const allChanges = []; @@ -138,7 +134,7 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { do { changeSet = await cfn.send( new DescribeChangeSetCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, ChangeSetName: changeSetName, NextToken: nextToken, }), @@ -151,7 +147,7 @@ export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { await cfn.send( new DeleteChangeSetCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, ChangeSetName: changeSetName, }), ); diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate.ts b/packages/amplify-cli/src/commands/gen2-migration/generate.ts index f523c98bb2b..7229c0879b6 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate.ts @@ -2,14 +2,12 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs/promises'; import execa from 'execa'; -import { AmplifyMigrationStep } from './_step'; -import { AmplifyMigrationOperation, ValidationResult } from './_operation'; -import { Plan } from './_plan'; -import { AmplifyGen2MigrationValidations } from './_validations'; -import { AwsClients } from './aws-clients'; +import { AmplifyMigrationStep } from './_infra/step'; +import { AmplifyMigrationOperation, ValidationResult } from './_infra/operation'; +import { Plan } from './_infra/plan'; import { Gen1App } from './generate/_infra/gen1-app'; -import { Assessment } from './_assessment'; -import { Planner } from './planner'; +import { Planner } from './_infra/planner'; +import { AmplifyMigrationAssessor } from './assess'; import { BackendGenerator } from './generate/amplify/backend.generator'; import { RootPackageJsonGenerator } from './generate/package.json.generator'; import { BackendPackageJsonGenerator } from './generate/amplify/package.json.generator'; @@ -30,46 +28,41 @@ import { fileOrDirectoryExists } from './generate/_infra/files'; const AMPLIFY_DIR = 'amplify'; export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { - /** - * Records generate support for each discovered resource into the assessment. - */ - public async assess(assessment: Assessment): Promise { - const clients = new AwsClients({ region: this.region }); - const gen1App = await Gen1App.create({ appId: this.appId, region: this.region, envName: this.currentEnvName, clients }); - const discovered = gen1App.discover(); - - for (const resource of discovered) { - switch (resource.key) { - case 'auth:Cognito': - case 'auth:Cognito-UserPool-Groups': - case 'storage:S3': - case 'storage:DynamoDB': - case 'api:AppSync': - case 'api:API Gateway': - case 'analytics:Kinesis': - case 'function:Lambda': - case 'geo:Map': - case 'geo:PlaceIndex': - case 'geo:GeofenceCollection': - assessment.record('generate', resource, { supported: true }); - break; - case 'unsupported': - assessment.record('generate', resource, { supported: false }); - break; - } - } - } - public async forward(): Promise { - const clients = new AwsClients({ region: this.region }); - const gen1App = await Gen1App.create({ appId: this.appId, region: this.region, envName: this.currentEnvName, clients }); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'amplify-gen2-')); const backendGenerator = new BackendGenerator(outputDir); const packageJsonGenerator = new RootPackageJsonGenerator(outputDir); const generators: Planner[] = []; - const discovered = gen1App.discover(); + const assessor = new AmplifyMigrationAssessor(this.gen1App); + const assessment = assessor.assess(); + + const operations: AmplifyMigrationOperation[] = [ + { + describe: async () => [], + validate: () => ({ description: 'Lock status', run: () => this.validateLockStatus() }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }, + { + describe: async () => [], + validate: () => ({ description: 'Working directory', run: () => this.validateWorkingDirectory() }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }, + { + describe: async () => [], + validate: () => ({ + description: 'Assessment', + run: async () => { + const valid = assessment.validFor('generate'); + return { valid, report: valid ? undefined : assessment.render() }; + }, + }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }, + ]; // Cross-category state captured during the loop. let authGenerator: AuthGenerator | undefined; @@ -77,20 +70,22 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { let geoGenerator: GeoGenerator | undefined; const functionGenerators: FunctionGenerator[] = []; + const discovered = this.gen1App.discover(); + for (const resource of discovered) { switch (resource.key) { case 'auth:Cognito': { const isReferenceAuth = discovered .filter((r) => r.category === 'auth') .some((r) => { - const meta = (gen1App.meta('auth') ?? {})[r.resourceName] as Record | undefined; + const meta = (this.gen1App.meta('auth') ?? {})[r.resourceName] as Record | undefined; return meta?.serviceType === 'imported'; }); if (isReferenceAuth) { - generators.push(new ReferenceAuthGenerator(gen1App, backendGenerator, outputDir, resource)); + generators.push(new ReferenceAuthGenerator(this.gen1App, backendGenerator, outputDir, resource)); } else { - authGenerator = new AuthGenerator(gen1App, backendGenerator, outputDir, resource); + authGenerator = new AuthGenerator(this.gen1App, backendGenerator, outputDir, resource); generators.push(authGenerator); } break; @@ -99,35 +94,35 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { // Handled by the AuthGenerator created for the main Cognito resource. break; case 'storage:S3': - s3Generator = new S3Generator(gen1App, backendGenerator, outputDir, resource); + s3Generator = new S3Generator(this.gen1App, backendGenerator, outputDir, resource); generators.push(s3Generator); break; case 'storage:DynamoDB': { - generators.push(new DynamoDBGenerator(gen1App, backendGenerator, resource)); + generators.push(new DynamoDBGenerator(this.gen1App, backendGenerator, resource)); break; } case 'api:AppSync': - generators.push(new DataGenerator(gen1App, backendGenerator, outputDir, resource)); + generators.push(new DataGenerator(this.gen1App, backendGenerator, outputDir, resource)); break; case 'api:API Gateway': - generators.push(new RestApiGenerator(gen1App, backendGenerator, resource)); + generators.push(new RestApiGenerator(this.gen1App, backendGenerator, resource)); break; case 'analytics:Kinesis': - generators.push(new AnalyticsKinesisGenerator(gen1App, backendGenerator, outputDir, resource)); + generators.push(new AnalyticsKinesisGenerator(this.gen1App, backendGenerator, outputDir, resource)); break; case 'geo:Map': case 'geo:PlaceIndex': case 'geo:GeofenceCollection': // All geo services share a single GeoGenerator instance. if (!geoGenerator) { - geoGenerator = new GeoGenerator(gen1App, backendGenerator, outputDir, resource); + geoGenerator = new GeoGenerator(this.gen1App, backendGenerator, outputDir, resource); generators.push(geoGenerator); } break; case 'function:Lambda': { - const functionCategoryMap = computeFunctionCategories(gen1App); + const functionCategoryMap = computeFunctionCategories(this.gen1App); const funcGen = new FunctionGenerator({ - gen1App, + gen1App: this.gen1App, backendGenerator, packageJsonGenerator, outputDir, @@ -138,10 +133,11 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { functionGenerators.push(funcGen); break; } - case 'unsupported': - this.logger.warn( - `Skipping unsupported resource '${resource.resourceName}' (${resource.category}:${resource.service}). You will need to write Gen2 code for this resource manually.`, - ); + + // unsupported/unknown resources - skip them. + // the assessment validation will surface these to the user + // and require confirmation of missing capabilities. + case 'UNKNOWN': break; } } @@ -158,29 +154,15 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { generators.push(packageJsonGenerator); generators.push(new BackendPackageJsonGenerator(outputDir)); generators.push(new TsConfigGenerator(outputDir)); - generators.push(new AmplifyYmlGenerator(gen1App)); + generators.push(new AmplifyYmlGenerator(this.gen1App)); generators.push(new GitIgnoreGenerator()); - const operations: AmplifyMigrationOperation[] = [ - { - describe: async () => [], - validate: () => ({ description: 'Lock status', run: () => this.validateLockStatus() }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - execute: async () => {}, - }, - { - describe: async () => [], - validate: () => ({ description: 'Working directory', run: () => this.validateWorkingDirectory() }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - execute: async () => {}, - }, - { - validate: () => undefined, - describe: async () => [`Delete directory: ${path.join(process.cwd(), 'amplify')}`], - // eslint-disable-next-line @typescript-eslint/no-empty-function - execute: async () => {}, - }, - ]; + operations.push({ + validate: () => undefined, + describe: async () => [`Delete directory: ${path.join(process.cwd(), 'amplify')}`], + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }); // Collect all operations from generators in order. for (const generator of generators) { @@ -239,8 +221,7 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { private async validateLockStatus(): Promise { try { - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); - await validations.validateLockStatus(); + await this.validations.validateLockStatus(); return { valid: true }; } catch (e) { return { valid: false, report: e.message }; @@ -249,8 +230,7 @@ export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { private async validateWorkingDirectory(): Promise { try { - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); - await validations.validateWorkingDirectory(); + await this.validations.validateWorkingDirectory(); return { valid: true }; } catch (e) { return { valid: false, report: e.message }; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/aws-fetcher.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/aws-fetcher.ts index 19b78767479..e2be5c8fa02 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/aws-fetcher.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/aws-fetcher.ts @@ -24,7 +24,7 @@ import { GetGraphqlApiCommand, GraphqlApi } from '@aws-sdk/client-appsync'; import { DescribeTableCommand, TableDescription } from '@aws-sdk/client-dynamodb'; import { GetAppCommand } from '@aws-sdk/client-amplify'; import { GetResourcesCommand } from '@aws-sdk/client-api-gateway'; -import { AwsClients } from '../../aws-clients'; +import { AwsClients } from '../../_infra/aws-clients'; /** * Encapsulates all AWS SDK calls needed during Gen1 app introspection. diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/gen1-app.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/gen1-app.ts index 2b9e7481ebc..cde1194a095 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/gen1-app.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/_infra/gen1-app.ts @@ -5,21 +5,17 @@ import { readFileSync } from 'node:fs'; import { Stream } from 'node:stream'; import unzipper from 'unzipper'; import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { $TSMeta, $TSTeamProviderInfo, AmplifyError, JSONUtilities } from '@aws-amplify/amplify-cli-core'; -import { AwsClients } from '../../aws-clients'; +import { $TSAny, $TSContext, $TSMeta, AmplifyError, JSONUtilities } from '@aws-amplify/amplify-cli-core'; +import { AwsClients } from '../../_infra/aws-clients'; import { AwsFetcher } from './aws-fetcher'; +import { stateManager, pathManager } from '@aws-amplify/amplify-cli-core'; +import { App, GetAppCommand } from '@aws-sdk/client-amplify'; -export interface Gen1CreateOptions { - readonly appId: string; - readonly region: string; - readonly envName: string; - readonly clients: AwsClients; -} - -interface Gen1AppProps extends Gen1CreateOptions { +interface Gen1AppProps { readonly ccbDir: string; - readonly rootStackName: string; - readonly deploymentBucketName: string; + readonly clients: AwsClients; + readonly app: App; + readonly envName: string; } /** @@ -27,7 +23,7 @@ interface Gen1AppProps extends Gen1CreateOptions { * Adding a new pair here forces every exhaustive switch on ResourceKey * to handle it — the compiler will error on any switch that misses a case. */ -export const SUPPORTED_RESOURCE_KEYS = [ +export const KNOWN_RESOURCE_KEYS = [ 'auth:Cognito', 'auth:Cognito-UserPool-Groups', 'storage:S3', @@ -41,11 +37,16 @@ export const SUPPORTED_RESOURCE_KEYS = [ 'geo:GeofenceCollection', ] as const; +export enum KNOWN_FEATURES { + OVERRIDES = 'overrides', + CUSTOM_FUNCTION_POLICIES = 'custom-policies', +} + /** * Union of all known category:service pairs, plus 'unsupported' for * resources the tool has no migration logic for. */ -export type ResourceKey = (typeof SUPPORTED_RESOURCE_KEYS)[number] | 'unsupported'; +export type ResourceKey = (typeof KNOWN_RESOURCE_KEYS)[number] | 'UNKNOWN'; /** * A resource discovered from amplify-meta.json. @@ -57,13 +58,6 @@ export interface DiscoveredResource { readonly key: ResourceKey; } -/** - * Response from a migration step's assess method. - */ -export interface SupportResponse { - readonly supported: boolean; -} - /** * Facade for all Gen1 app state — both local files and AWS resources. * @@ -78,43 +72,68 @@ export interface SupportResponse { */ export class Gen1App { public readonly appId: string; + public readonly appName: string; public readonly region: string; public readonly envName: string; public readonly clients: AwsClients; public readonly aws: AwsFetcher; public readonly ccbDir: string; public readonly rootStackName: string; - public readonly deploymentBucketName: string; // eslint-disable-next-line @typescript-eslint/naming-convention -- private backing field for meta() private readonly _meta: $TSMeta; private constructor(props: Gen1AppProps) { - this.appId = props.appId; - this.region = props.region; + this.appId = props.app.appId; + this.appName = props.app.name; this.envName = props.envName; this.clients = props.clients; - this.aws = new AwsFetcher(props.clients); this.ccbDir = props.ccbDir; - this.rootStackName = props.rootStackName; - this.deploymentBucketName = props.deploymentBucketName; + this.aws = new AwsFetcher(this.clients); this._meta = JSONUtilities.readJson<$TSMeta>(path.join(props.ccbDir, 'amplify-meta.json'), { throwIfNotExist: true }) as $TSMeta; + this.rootStackName = this._meta.providers.awscloudformation.StackName; + this.region = this._meta.providers.awscloudformation.Region; } - public static async create(props: Gen1CreateOptions): Promise { - const tpiPath = path.join('amplify', 'team-provider-info.json'); - const tpi = JSONUtilities.readJson<$TSTeamProviderInfo>(tpiPath, { throwIfNotExist: true }) as $TSTeamProviderInfo; - const envConfig = tpi[props.envName]?.awscloudformation; - if (!envConfig?.StackName || !envConfig?.DeploymentBucketName) { + public static async create(context: $TSContext): Promise { + const clients = await AwsClients.create(context); + + const tpiRelPath = `./${path.relative(process.cwd(), pathManager.getTeamProviderInfoFilePath())}`; + if (!stateManager.teamProviderInfoExists()) { + throw new AmplifyError('MigrationError', { + message: `Unable to find '${tpiRelPath}' - Are you sure you're on the right branch?`, + resolution: 'Checkout to the Gen1 branch and rerun the command', + }); + } + const tpi = stateManager.getTeamProviderInfo(); + + // assuming all environment are deployed within the same app - can it be different? + const appId = (Object.values(tpi)[0] as $TSAny).awscloudformation.AmplifyAppId; + const app = await clients.amplify.send(new GetAppCommand({ appId })); + + const envName = await Gen1App.currentEnvName(app.app); + const envInfo = tpi[envName]; + if (!envInfo) { + throw new AmplifyError('MigrationError', { + message: `Environment ${envName} does not exist in ${tpiRelPath}`, + resolution: `Checkout to the branch corresponding to environment ${envName} and rerun the command`, + }); + } + + const cfnProvider = envInfo.awscloudformation; + if (!cfnProvider?.StackName || !cfnProvider?.DeploymentBucketName) { throw new AmplifyError('MigrationError', { - message: `Missing StackName or DeploymentBucketName for environment '${props.envName}' in team-provider-info.json`, + message: `Missing StackName or DeploymentBucketName for environment '${envName}' in '${tpiRelPath}'`, }); } - const ccbDir = await Gen1App.downloadCloudBackend(props.clients.s3, envConfig.DeploymentBucketName); - return new Gen1App({ ...props, ccbDir, rootStackName: envConfig.StackName, deploymentBucketName: envConfig.DeploymentBucketName }); + + const ccbDir = await Gen1App.downloadCloudBackend(clients.s3, cfnProvider.DeploymentBucketName); + return new Gen1App({ ccbDir, clients, envName, app: app.app }); } - /** Returns the category block from amplify-meta.json, or undefined if empty/absent. */ + /** + * Returns the category block from amplify-meta.json, or undefined if empty/absent. + */ public meta(category: string): Record | undefined { const block = (this._meta as Record)[category]; if (block && typeof block === 'object' && Object.keys(block as object).length > 0) { @@ -145,7 +164,7 @@ export class Gen1App { }); } const rawKey = `${category}:${service}`; - const key: ResourceKey = (SUPPORTED_RESOURCE_KEYS as readonly string[]).includes(rawKey) ? (rawKey as ResourceKey) : 'unsupported'; + const key: ResourceKey = (KNOWN_RESOURCE_KEYS as readonly string[]).includes(rawKey) ? (rawKey as ResourceKey) : 'UNKNOWN'; resources.push({ category, resourceName, service, key }); } } @@ -153,7 +172,9 @@ export class Gen1App { return resources; } - /** Returns a resource output value from amplify-meta.json. */ + /** + * Returns a resource output value from amplify-meta.json. + */ public metaOutput(category: string, resourceName: string, outputKey: string): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped amplify-meta.json const value = (this._meta as any)[category]?.[resourceName]?.output?.[outputKey]; @@ -165,7 +186,9 @@ export class Gen1App { return value; } - /** Returns the name of the single resource in a category matching a service type. */ + /** + * Returns the name of the single resource in a category matching a service type. + */ public singleResourceName(category: string, service: string): string { const categoryBlock = this.meta(category); if (!categoryBlock) { @@ -189,11 +212,45 @@ export class Gen1App { return readFileSync(path.join(this.ccbDir, relativePath), 'utf8'); } + /** + * Returns true if a file exists in the cloud backend directory. + */ + public fileExists(relativePath: string): boolean { + try { + readFileSync(path.join(this.ccbDir, relativePath)); + return true; + } catch { + // File does not exist — expected for optional feature files. + return false; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped Gen1 cli-inputs.json public cliInputs(category: string, resourceName: string): any { return this.json(path.join(category, resourceName, 'cli-inputs.json')); } + private static async currentEnvName(app: App): Promise { + const migratingEnvName = (app.environmentVariables ?? {})['GEN2_MIGRATION_ENVIRONMENT_NAME']; + const localEnvName = stateManager.getCurrentEnvName(); + + if (!localEnvName && !migratingEnvName) { + throw new AmplifyError('EnvironmentNotInitializedError', { + message: `No environment configured for app '${app.name}'`, + resolution: 'Run "amplify pull" to configure an environment.', + }); + } + + if (migratingEnvName && localEnvName && migratingEnvName !== localEnvName) { + throw new AmplifyError('MigrationError', { + message: `Environment mismatch: Your local env (${localEnvName}) does + not match the environment you marked for migration (${migratingEnvName})`, + }); + } + + return localEnvName ?? migratingEnvName; + } + private static async downloadCloudBackend(s3Client: S3Client, bucket: string): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'amplify-ccb-')); const zipKey = '#current-cloud-backend.zip'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify.yml.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify.yml.generator.ts index d305e4a198a..0e165f2ec97 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify.yml.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify.yml.generator.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import * as yaml from 'yaml'; -import { Planner } from '../planner'; -import { AmplifyMigrationOperation } from '../_operation'; +import { Planner } from '../_infra/planner'; +import { AmplifyMigrationOperation } from '../_infra/operation'; import { Gen1App } from './_infra/gen1-app'; import { fileOrDirectoryExists } from './_infra/files'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/analytics/kinesis.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/analytics/kinesis.generator.ts index 339ea75cbf9..9115622313d 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/analytics/kinesis.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/analytics/kinesis.generator.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.generator.ts index ce43178abed..99c7e75bc34 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.generator.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; import { UserPoolClientType } from '@aws-sdk/client-cognito-identity-provider'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/reference-auth.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/reference-auth.generator.ts index 8b122e3768c..08e2d2d1e0a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/reference-auth.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/reference-auth.generator.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/backend.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/backend.generator.ts index 02010a9a56c..d126ff0d0ba 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/backend.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/backend.generator.ts @@ -1,8 +1,8 @@ import ts from 'typescript'; import path from 'node:path'; import fs from 'node:fs/promises'; -import { Planner } from '../../planner'; -import { AmplifyMigrationOperation } from '../../_operation'; +import { Planner } from '../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; import { TS } from '../_infra/ts'; const factory = ts.factory; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/custom-resources/custom.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/custom-resources/custom.generator.ts index 5d64b81b01a..4b60c0b3c06 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/custom-resources/custom.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/custom-resources/custom.generator.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; import { JSONUtilities } from '@aws-amplify/amplify-cli-core'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { RootPackageJsonGenerator } from '../../package.json.generator'; import { Gen1App } from '../../_infra/gen1-app'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/data/data.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/data/data.generator.ts index ca3053791f1..5e18cbc7d43 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/data/data.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/data/data.generator.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; import { GraphqlApi } from '@aws-sdk/client-appsync'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts index 75b86542cbd..3bff5471815 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts @@ -1,9 +1,9 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { JSONUtilities } from '@aws-amplify/amplify-cli-core'; -import { Planner } from '../../../planner'; +import { Planner } from '../../../_infra/planner'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/geo/geo.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/geo/geo.generator.ts index ee0e65cb048..5529a0b2be1 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/geo/geo.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/geo/geo.generator.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { DiscoveredResource, Gen1App } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/package.json.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/package.json.generator.ts index ca49db3cc69..60872e30074 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/package.json.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/package.json.generator.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { Planner } from '../../planner'; -import { AmplifyMigrationOperation } from '../../_operation'; +import { Planner } from '../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; /** * Writes amplify/package.json with ES module configuration. diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts index ba77d8607ca..ff5fc16ed87 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/rest-api/rest-api.generator.ts @@ -1,6 +1,6 @@ import ts from 'typescript'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { CorsConfiguration, RestApiDefinition, RestApiPath, RestApiRenderer } from './rest-api.renderer'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/dynamodb.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/dynamodb.generator.ts index 7656e001b28..1c0e8f0f241 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/dynamodb.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/dynamodb.generator.ts @@ -1,5 +1,5 @@ -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { DynamoDBRenderer, DynamoDBGSI, DynamoDBTableDefinition } from './dynamodb.renderer'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/s3.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/s3.generator.ts index e164dac5c4d..6998a376648 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/s3.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/storage/s3.generator.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import ts from 'typescript'; import type { BucketAccelerateStatus, BucketVersioningStatus, ServerSideEncryptionConfiguration } from '@aws-sdk/client-s3'; -import { Planner } from '../../../planner'; -import { AmplifyMigrationOperation } from '../../../_operation'; +import { Planner } from '../../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../../_infra/operation'; import { BackendGenerator } from '../backend.generator'; import { Gen1App, DiscoveredResource } from '../../_infra/gen1-app'; import { TS } from '../../_infra/ts'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/tsconfig.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/tsconfig.generator.ts index 371fb274c37..b469fe801a3 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/tsconfig.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/tsconfig.generator.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { Planner } from '../../planner'; -import { AmplifyMigrationOperation } from '../../_operation'; +import { Planner } from '../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; /** * Writes amplify/tsconfig.json with Gen2 TypeScript configuration. diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/gitignore.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/gitignore.generator.ts index 59e6a402b26..e2ee6fa5d63 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/gitignore.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/gitignore.generator.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { Planner } from '../planner'; -import { AmplifyMigrationOperation } from '../_operation'; +import { Planner } from '../_infra/planner'; +import { AmplifyMigrationOperation } from '../_infra/operation'; const GEN2_GITIGNORE_ENTRIES = ['.amplify', 'amplify_outputs*', 'amplifyconfiguration*', 'aws-exports*', 'node_modules', 'build', 'dist']; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/package.json.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/package.json.generator.ts index 045b91afa69..7c857890d6d 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/package.json.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/package.json.generator.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; -import { Planner } from '../planner'; -import { AmplifyMigrationOperation } from '../_operation'; +import { Planner } from '../_infra/planner'; +import { AmplifyMigrationOperation } from '../_infra/operation'; import { JSONUtilities } from '@aws-amplify/amplify-cli-core'; type PackageJson = { diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts index 3b5cc43cc1b..3de5b6141a7 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/lock.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -1,12 +1,11 @@ -import { AmplifyMigrationStep } from './_step'; -import { AmplifyMigrationOperation, ValidationResult } from './_operation'; -import { Plan } from './_plan'; +import { AmplifyMigrationStep } from './_infra/step'; +import { AmplifyMigrationOperation, ValidationResult } from './_infra/operation'; +import { Plan } from './_infra/plan'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CloudFormationClient, SetStackPolicyCommand, GetStackPolicyCommand } from '@aws-sdk/client-cloudformation'; -import { AmplifyClient, UpdateAppCommand, GetAppCommand } from '@aws-sdk/client-amplify'; -import { DynamoDBClient, UpdateTableCommand, paginateListTables } from '@aws-sdk/client-dynamodb'; -import { AppSyncClient, paginateListGraphqlApis } from '@aws-sdk/client-appsync'; -import { AmplifyGen2MigrationValidations } from './_validations'; +import { SetStackPolicyCommand, GetStackPolicyCommand } from '@aws-sdk/client-cloudformation'; +import { UpdateAppCommand, GetAppCommand } from '@aws-sdk/client-amplify'; +import { UpdateTableCommand, paginateListTables } from '@aws-sdk/client-dynamodb'; +import { paginateListGraphqlApis } from '@aws-sdk/client-appsync'; const GEN2_MIGRATION_ENVIRONMENT_NAME = 'GEN2_MIGRATION_ENVIRONMENT_NAME'; @@ -37,10 +36,6 @@ const ALLOW_ALL_POLICY = { export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private _dynamoTableNames: string[]; - private _ddbClient: DynamoDBClient; - private _amplifyClient: AmplifyClient; - private _cfnClient: CloudFormationClient; - public async forward(): Promise { const operations: AmplifyMigrationOperation[] = []; @@ -63,7 +58,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { validate: () => undefined, describe: async () => [`Enable deletion protection for table '${tableName}'`], execute: async () => { - await this.ddbClient().send( + await this.gen1App.clients.dynamoDB.send( new UpdateTableCommand({ TableName: tableName, DeletionProtectionEnabled: true, @@ -76,36 +71,36 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { operations.push({ validate: () => undefined, - describe: async () => [`Add environment variable '${GEN2_MIGRATION_ENVIRONMENT_NAME}' (value: ${this.currentEnvName})`], + describe: async () => [`Add environment variable '${GEN2_MIGRATION_ENVIRONMENT_NAME}' (value: ${this.gen1App.envName})`], execute: async () => { - const app = await this.amplifyClient().send(new GetAppCommand({ appId: this.appId })); - const environmentVariables = { ...(app.app.environmentVariables ?? {}), [GEN2_MIGRATION_ENVIRONMENT_NAME]: this.currentEnvName }; - await this.amplifyClient().send(new UpdateAppCommand({ appId: this.appId, environmentVariables })); - this.logger.info(`Added '${GEN2_MIGRATION_ENVIRONMENT_NAME}' environment variable (value: ${this.currentEnvName})`); + const app = await this.gen1App.clients.amplify.send(new GetAppCommand({ appId: this.gen1App.appId })); + const environmentVariables = { ...(app.app.environmentVariables ?? {}), [GEN2_MIGRATION_ENVIRONMENT_NAME]: this.gen1App.envName }; + await this.gen1App.clients.amplify.send(new UpdateAppCommand({ appId: this.gen1App.appId, environmentVariables })); + this.logger.info(`Added '${GEN2_MIGRATION_ENVIRONMENT_NAME}' environment variable (value: ${this.gen1App.envName})`); }, }); operations.push({ validate: () => undefined, describe: async () => { - return [`Add lock statement to stack policy on '${this.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`]; + return [`Add lock statement to stack policy on '${this.gen1App.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`]; }, execute: async () => { const existingPolicy = await this.getExistingStackPolicy(); const alreadyLocked = existingPolicy.Statement.some(isLockStatement); if (alreadyLocked) { - this.logger.info(`Lock statement already exists in stack policy on '${this.rootStackName}', skipping`); + this.logger.info(`Lock statement already exists in stack policy on '${this.gen1App.rootStackName}', skipping`); return; } existingPolicy.Statement.push(LOCK_STATEMENT); const mergedPolicy = JSON.stringify(existingPolicy); - await this.cfnClient().send( + await this.gen1App.clients.cloudFormation.send( new SetStackPolicyCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, StackPolicyBody: mergedPolicy, }), ); - this.logger.info(`Successfully added lock statement to stack policy on '${this.rootStackName}'`); + this.logger.info(`Successfully added lock statement to stack policy on '${this.gen1App.rootStackName}'`); }, }); @@ -114,8 +109,8 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { logger: this.logger, title: 'Execute', implications: [ - `You will not be able to run 'amplify push' on environment '${this.currentEnvName}'`, - `You will not be able to migrate another environment until migration of '${this.currentEnvName}' is complete or rolled back`, + `You will not be able to run 'amplify push' on environment '${this.gen1App.envName}'`, + `You will not be able to migrate another environment until migration of '${this.gen1App.envName}' is complete or rolled back`, ], }); } @@ -136,10 +131,10 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { validate: () => undefined, describe: async () => [`Remove environment variable '${GEN2_MIGRATION_ENVIRONMENT_NAME}'`], execute: async () => { - const app = await this.amplifyClient().send(new GetAppCommand({ appId: this.appId })); + const app = await this.gen1App.clients.amplify.send(new GetAppCommand({ appId: this.gen1App.appId })); const environmentVariables = app.app.environmentVariables ?? {}; delete environmentVariables[GEN2_MIGRATION_ENVIRONMENT_NAME]; - await this.amplifyClient().send(new UpdateAppCommand({ appId: this.appId, environmentVariables })); + await this.gen1App.clients.amplify.send(new UpdateAppCommand({ appId: this.gen1App.appId, environmentVariables })); this.logger.info(`Removed ${GEN2_MIGRATION_ENVIRONMENT_NAME} environment variable`); }, }); @@ -147,25 +142,25 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { operations.push({ validate: () => undefined, describe: async () => { - return [`Remove lock statement from stack policy on '${this.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`]; + return [`Remove lock statement from stack policy on '${this.gen1App.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`]; }, execute: async () => { const existingPolicy = await this.getExistingStackPolicy(); const index = existingPolicy.Statement.findIndex(isLockStatement); if (index === -1) { - this.logger.info(`Lock statement not found in stack policy on '${this.rootStackName}'`); + this.logger.info(`Lock statement not found in stack policy on '${this.gen1App.rootStackName}'`); return; } existingPolicy.Statement.splice(index, 1); const restoredPolicy = existingPolicy.Statement.length > 0 ? JSON.stringify(existingPolicy) : JSON.stringify(ALLOW_ALL_POLICY); - await this.cfnClient().send( + await this.gen1App.clients.cloudFormation.send( new SetStackPolicyCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, StackPolicyBody: restoredPolicy, }), ); this.logger.info( - `Successfully removed lock statement from stack policy on '${this.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`, + `Successfully removed lock statement from stack policy on '${this.gen1App.rootStackName}': ${JSON.stringify(LOCK_STATEMENT)}`, ); }, }); @@ -175,7 +170,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { logger: this.logger, title: 'Rollback', implications: [ - `You will be able to run 'amplify push' on environment '${this.currentEnvName}'`, + `You will be able to run 'amplify push' on environment '${this.gen1App.envName}'`, `You will be able to start migration of another environment`, ], }); @@ -183,8 +178,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private async validateDeploymentStatus(): Promise { try { - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); - await validations.validateDeploymentStatus(); + await this.validations.validateDeploymentStatus(); return { valid: true }; } catch (e) { return { valid: false, report: e.message }; @@ -193,8 +187,7 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private async validateDrift(): Promise { try { - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); - await validations.validateDrift(); + await this.validations.validateDrift(); return { valid: true }; } catch (e) { return { valid: false, report: e.message }; @@ -203,10 +196,9 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private async fetchGraphQLApiId(): Promise { const apis = []; - const appSyncClient = new AppSyncClient(); - for await (const page of paginateListGraphqlApis({ client: appSyncClient }, {})) { + for await (const page of paginateListGraphqlApis({ client: this.gen1App.clients.appSync }, {})) { for (const api of page.graphqlApis ?? []) { - if (api.name === `${this.appName}-${this.currentEnvName}`) { + if (api.name === `${this.gen1App.appName}-${this.gen1App.envName}`) { apis.push(api.apiId); } } @@ -219,10 +211,9 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private async fetchGraphQLModelTables(graphQLApiId: string): Promise { const tables = []; - const dynamoClient = new DynamoDBClient(); - for await (const page of paginateListTables({ client: dynamoClient }, {})) { + for await (const page of paginateListTables({ client: this.gen1App.clients.dynamoDB }, {})) { for (const tableName of page.TableNames ?? []) { - if (tableName.includes(`-${graphQLApiId}-${this.currentEnvName}`)) { + if (tableName.includes(`-${graphQLApiId}-${this.gen1App.envName}`)) { tables.push(tableName); } } @@ -238,31 +229,10 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { return this._dynamoTableNames; } - private ddbClient() { - if (!this._ddbClient) { - this._ddbClient = new DynamoDBClient(); - } - return this._ddbClient; - } - - private amplifyClient() { - if (!this._amplifyClient) { - this._amplifyClient = new AmplifyClient(); - } - return this._amplifyClient; - } - - private cfnClient() { - if (!this._cfnClient) { - this._cfnClient = new CloudFormationClient({}); - } - return this._cfnClient; - } - private async getExistingStackPolicy(): Promise<{ Statement: Record[] }> { - const response = await this.cfnClient().send( + const response = await this.gen1App.clients.cloudFormation.send( new GetStackPolicyCommand({ - StackName: this.rootStackName, + StackName: this.gen1App.rootStackName, }), ); if (response.StackPolicyBody) { diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor.ts new file mode 100644 index 00000000000..5657641f48c --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor.ts @@ -0,0 +1,212 @@ +/* eslint-disable spellcheck/spell-checker */ +import { AmplifyMigrationStep } from './_infra/step'; +import { AmplifyMigrationOperation, ValidationResult } from './_infra/operation'; +import { Plan } from './_infra/plan'; +import { AmplifyError } from '@aws-amplify/amplify-cli-core'; +import { GetCallerIdentityCommand } from '@aws-sdk/client-sts'; +import { StackFacade } from './refactor/stack-facade'; +import { Planner } from './_infra/planner'; +import { AuthCognitoForwardRefactorer } from './refactor/auth/auth-cognito-forward'; +import { AuthCognitoRollbackRefactorer } from './refactor/auth/auth-cognito-rollback'; +import { StorageS3ForwardRefactorer } from './refactor/storage/storage-forward'; +import { StorageS3RollbackRefactorer } from './refactor/storage/storage-rollback'; +import { StorageDynamoForwardRefactorer } from './refactor/storage/storage-dynamo-forward'; +import { StorageDynamoRollbackRefactorer } from './refactor/storage/storage-dynamo-rollback'; +import { AnalyticsKinesisForwardRefactorer } from './refactor/analytics/analytics-forward'; +import { AnalyticsKinesisRollbackRefactorer } from './refactor/analytics/analytics-rollback'; +import { Assessment } from './assess/assessment'; +import { AuthUserPoolGroupsForwardRefactorer } from './refactor/auth/auth-user-pool-groups-forward'; +import { AuthUserPoolGroupsRollbackRefactorer } from './refactor/auth/auth-user-pool-groups-rollback'; +import { Cfn } from './refactor/cfn'; +import { AmplifyMigrationAssessor } from './assess'; + +export class AmplifyMigrationRefactorStep extends AmplifyMigrationStep { + public async forward(): Promise { + const toStack = this.extractParameters(); + const { accountId, gen1Env, gen2Branch, cfn } = await this.createInfrastructure(toStack); + + const refactorers: Planner[] = []; + const assessor = new AmplifyMigrationAssessor(this.gen1App); + const assessment = assessor.assess(); + + const discovered = this.gen1App.discover(); + + for (const resource of discovered) { + switch (resource.key) { + case 'auth:Cognito': + refactorers.push(new AuthCognitoForwardRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'auth:Cognito-UserPool-Groups': + refactorers.push( + new AuthUserPoolGroupsForwardRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn), + ); + break; + case 'storage:S3': + refactorers.push(new StorageS3ForwardRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'storage:DynamoDB': + refactorers.push(new StorageDynamoForwardRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'analytics:Kinesis': + refactorers.push(new AnalyticsKinesisForwardRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + + // stateless resources — nothing to refactor + case 'function:Lambda': + case 'api:AppSync': + case 'api:API Gateway': + case 'geo:Map': + case 'geo:PlaceIndex': + break; + + // unsupported/unknown resources - skip them. + // the assessment validation will surface these to the user + // and require confirmation of missing capabilities. + case 'geo:GeofenceCollection': + case 'UNKNOWN': + break; + } + } + + return this.buildPlan( + refactorers, + assessment, + [ + 'Stateful resources (Cognito, S3, DynamoDB, etc...) will be moved from Gen1 to Gen2 CloudFormation stacks', + 'Your Gen1 app will no longer manage these resources', + ], + 'Execute', + ); + } + + public async rollback(): Promise { + const toStack = this.extractParameters(); + const { accountId, gen1Env, gen2Branch, cfn } = await this.createInfrastructure(toStack); + + const refactorers: Planner[] = []; + const assessor = new AmplifyMigrationAssessor(this.gen1App); + const assessment = assessor.assess(); + + const discovered = this.gen1App.discover(); + + for (const resource of discovered) { + switch (resource.key) { + case 'auth:Cognito': + refactorers.push(new AuthCognitoRollbackRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'auth:Cognito-UserPool-Groups': + refactorers.push( + new AuthUserPoolGroupsRollbackRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn), + ); + break; + case 'storage:S3': + refactorers.push(new StorageS3RollbackRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'storage:DynamoDB': + refactorers.push(new StorageDynamoRollbackRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn)); + break; + case 'analytics:Kinesis': + refactorers.push( + new AnalyticsKinesisRollbackRefactorer(gen1Env, gen2Branch, this.gen1App, accountId, this.logger, resource, cfn), + ); + break; + + // stateless resources — nothing to refactor + case 'function:Lambda': + case 'api:AppSync': + case 'api:API Gateway': + case 'geo:Map': + case 'geo:PlaceIndex': + break; + + // unsupported/unknown resources - skip them. + // the assessment validation will surface these to the user + // and require confirmation of missing capabilities. + case 'geo:GeofenceCollection': + case 'UNKNOWN': + break; + } + } + + return this.buildPlan( + refactorers, + assessment, + ['Stateful resources will be moved back to Gen1 CloudFormation stacks', 'Your Gen2 app will no longer manage these resources'], + 'Rollback', + ); + } + + /** + * Creates shared AWS clients, stack facades, and the Cfn instance. + */ + private async createInfrastructure(toStack: string): Promise<{ + accountId: string; + gen1Env: StackFacade; + gen2Branch: StackFacade; + cfn: Cfn; + }> { + const { Account: accountId } = await this.gen1App.clients.sts.send(new GetCallerIdentityCommand({})); + if (!accountId) { + throw new AmplifyError('ConfigurationError', { message: 'Unable to determine AWS account ID' }); + } + + const clients = this.gen1App.clients; + const gen1Env = new StackFacade(clients, this.gen1App.rootStackName); + const gen2Branch = new StackFacade(clients, toStack); + const cfn = new Cfn(clients.cloudFormation, this.logger); + + return { accountId, gen1Env, gen2Branch, cfn }; + } + + /** + * Collects operations from all refactorers. + */ + private async buildPlan(refactorers: Planner[], assessment: Assessment, implications: string[], title: string): Promise { + const operations: AmplifyMigrationOperation[] = []; + + operations.push({ + describe: async () => [], + validate: () => ({ description: 'Lock status', run: () => this.validateLockStatus() }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }); + + operations.push({ + describe: async () => [], + validate: () => ({ + description: 'Assessment', + run: async () => { + const valid = assessment.validFor('refactor'); + return { valid, report: valid ? undefined : assessment.render() }; + }, + }), + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }); + + for (const refactorer of refactorers) { + operations.push(...(await refactorer.plan())); + } + + return new Plan({ operations, logger: this.logger, implications, title }); + } + + private async validateLockStatus(): Promise { + try { + await this.validations.validateLockStatus(); + return { valid: true }; + } catch (e) { + return { valid: false, report: e.message }; + } + } + + private extractParameters(): string { + const toStack = this.context.parameters?.options?.to; + + if (!toStack) { + throw new AmplifyError('InputValidationError', { message: '--to is required' }); + } + + return toStack; + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/analytics/analytics-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/analytics/analytics-rollback.ts index a39d4c5d55a..62292e90754 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/analytics/analytics-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/analytics/analytics-rollback.ts @@ -1,4 +1,4 @@ -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { ANALYTICS_RESOURCE_TYPES, KINESIS_STREAM_TYPE } from './analytics-forward'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts index 22f07f73beb..93cc90f5192 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts @@ -1,13 +1,8 @@ import { Output, Parameter } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { AwsClients } from '../../aws-clients'; -import { StackFacade } from '../stack-facade'; import { retrieveOAuthValues } from '../oauth-values-retriever'; import { ForwardCategoryRefactorer } from '../workflow/forward-category-refactorer'; -import { Cfn } from '../cfn'; -import { SpinningLogger } from '../../_spinning-logger'; -import { DiscoveredResource } from '../../generate/_infra/gen1-app'; -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; const HOSTED_PROVIDER_META_PARAMETER_NAME = 'hostedUIProviderMeta'; const HOSTED_PROVIDER_CREDENTIALS_PARAMETER_NAME = 'hostedUIProviderCreds'; @@ -39,21 +34,6 @@ export const RESOURCE_TYPES = [ * Moves main auth resources from Gen1 to Gen2. */ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { - constructor( - gen1Env: StackFacade, - gen2Branch: StackFacade, - clients: AwsClients, - region: string, - accountId: string, - logger: SpinningLogger, - private readonly appId: string, - private readonly environmentName: string, - protected readonly resource: DiscoveredResource, - cfn: Cfn, - ) { - super(gen1Env, gen2Branch, clients, region, accountId, logger, resource, cfn); - } - protected resourceTypes(): string[] { return RESOURCE_TYPES; } @@ -73,12 +53,12 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { } const oAuthValues = await retrieveOAuthValues({ - ssmClient: this.clients.ssm, - cognitoIdpClient: this.clients.cognitoIdentityProvider, + ssmClient: this.gen1App.clients.ssm, + cognitoIdpClient: this.gen1App.clients.cognitoIdentityProvider, oAuthParameter: oAuthParam, userPoolId, - appId: this.appId, - environmentName: this.environmentName, + appId: this.gen1App.appId, + environmentName: this.gen1App.envName, }); const credsParam = parameters.find((p) => p.ParameterKey === HOSTED_PROVIDER_CREDENTIALS_PARAMETER_NAME); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts index d6aedaf2f44..b5eee9d811a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts @@ -1,5 +1,5 @@ import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { RESOURCE_TYPES, diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.ts index f4203b97da1..af230632af0 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-forward.ts @@ -1,4 +1,4 @@ -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { ForwardCategoryRefactorer } from '../workflow/forward-category-refactorer'; export const USER_POOL_GROUP_TYPE = 'AWS::Cognito::UserPoolGroup'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.ts index ad8a08bfe36..816ba0dd895 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-user-pool-groups-rollback.ts @@ -1,4 +1,4 @@ -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { RESOURCE_TYPES, USER_POOL_GROUP_TYPE } from './auth-user-pool-groups-forward'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts index 044357fb501..0b90d79753c 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts @@ -24,9 +24,9 @@ import { waitUntilStackUpdateComplete, } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource, CFNTemplate } from '../cfn-template'; +import { CFNResource, CFNTemplate } from '../_infra/cfn-template'; import { extractStackNameFromId } from './utils'; -import { SpinningLogger } from '../_spinning-logger'; +import { SpinningLogger } from '../_infra/spinning-logger'; import chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/index.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/index.ts index 95c4de14e48..2ee3900a26a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/index.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/index.ts @@ -1 +1 @@ -export { AmplifyMigrationRefactorStep } from './refactor'; +export { AmplifyMigrationRefactorStep } from '../refactor'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/refactor.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/refactor.ts deleted file mode 100644 index 66d63a291b8..00000000000 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/refactor.ts +++ /dev/null @@ -1,260 +0,0 @@ -/* eslint-disable spellcheck/spell-checker */ -import { AmplifyMigrationStep } from '../_step'; -import { AmplifyMigrationOperation, ValidationResult } from '../_operation'; -import { Plan } from '../_plan'; -import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; -import { AmplifyGen2MigrationValidations } from '../_validations'; -import { AwsClients } from '../aws-clients'; -import { StackFacade } from './stack-facade'; -import { Planner } from '../planner'; -import { AuthCognitoForwardRefactorer } from './auth/auth-cognito-forward'; -import { AuthCognitoRollbackRefactorer } from './auth/auth-cognito-rollback'; -import { StorageS3ForwardRefactorer } from './storage/storage-forward'; -import { StorageS3RollbackRefactorer } from './storage/storage-rollback'; -import { StorageDynamoForwardRefactorer } from './storage/storage-dynamo-forward'; -import { StorageDynamoRollbackRefactorer } from './storage/storage-dynamo-rollback'; -import { AnalyticsKinesisForwardRefactorer } from './analytics/analytics-forward'; -import { AnalyticsKinesisRollbackRefactorer } from './analytics/analytics-rollback'; -import { Gen1App } from '../generate/_infra/gen1-app'; -import { Assessment } from '../_assessment'; -import { AuthUserPoolGroupsForwardRefactorer } from './auth/auth-user-pool-groups-forward'; -import { AuthUserPoolGroupsRollbackRefactorer } from './auth/auth-user-pool-groups-rollback'; -import { Cfn } from './cfn'; - -export class AmplifyMigrationRefactorStep extends AmplifyMigrationStep { - /** - * Records refactor support for each discovered resource into the assessment. - */ - public async assess(assessment: Assessment): Promise { - const clients = new AwsClients({ region: this.region }); - const gen1App = await Gen1App.create({ appId: this.appId, region: this.region, envName: this.currentEnvName, clients }); - const discovered = gen1App.discover(); - - for (const resource of discovered) { - switch (resource.key) { - case 'auth:Cognito': - case 'auth:Cognito-UserPool-Groups': - case 'storage:S3': - case 'storage:DynamoDB': - case 'analytics:Kinesis': - // falls through — stateless categories, nothing to refactor - case 'function:Lambda': - case 'api:AppSync': - case 'api:API Gateway': - case 'geo:Map': - case 'geo:PlaceIndex': - assessment.record('refactor', resource, { supported: true }); - break; - case 'geo:GeofenceCollection': - assessment.record('refactor', resource, { supported: false }); - break; - case 'unsupported': - assessment.record('refactor', resource, { supported: false }); - break; - } - } - } - - public async forward(): Promise { - const toStack = this.extractParameters(); - const { clients, accountId, gen1Env, gen2Branch, cfn } = await this.createInfrastructure(toStack); - - const gen1App = await Gen1App.create({ appId: this.appId, region: this.region, envName: this.currentEnvName, clients }); - const discovered = gen1App.discover(); - - const refactorers: Planner[] = []; - - for (const resource of discovered) { - switch (resource.key) { - case 'auth:Cognito': - refactorers.push( - new AuthCognitoForwardRefactorer( - gen1Env, - gen2Branch, - clients, - this.region, - accountId, - this.logger, - this.appId, - this.currentEnvName, - resource, - cfn, - ), - ); - break; - case 'auth:Cognito-UserPool-Groups': - refactorers.push( - new AuthUserPoolGroupsForwardRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'storage:S3': - refactorers.push( - new StorageS3ForwardRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'storage:DynamoDB': - refactorers.push( - new StorageDynamoForwardRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'analytics:Kinesis': - refactorers.push( - new AnalyticsKinesisForwardRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - // Stateless categories — nothing to refactor - // falls through - case 'function:Lambda': - case 'api:AppSync': - case 'api:API Gateway': - case 'geo:Map': - case 'geo:PlaceIndex': - break; - case 'geo:GeofenceCollection': - throw new AmplifyError('MigrationError', { - message: `Unsupported resource '${resource.resourceName}' (${resource.category}:${resource.service}). GeofenceCollection refactor is not supported.`, - }); - case 'unsupported': - throw new AmplifyError('MigrationError', { - message: `Unsupported resource '${resource.resourceName}' (${resource.category}:${resource.service}). Run 'amplify gen2-migration assess' to check migration readiness.`, - }); - } - } - - return this.buildPlan( - refactorers, - [ - 'Stateful resources (Cognito, S3, DynamoDB, etc...) will be moved from Gen1 to Gen2 CloudFormation stacks', - 'Your Gen1 app will no longer manage these resources', - ], - 'Execute', - ); - } - - public async rollback(): Promise { - const toStack = this.extractParameters(); - const { clients, accountId, gen1Env, gen2Branch, cfn } = await this.createInfrastructure(toStack); - - const gen1App = await Gen1App.create({ appId: this.appId, region: this.region, envName: this.currentEnvName, clients }); - const discovered = gen1App.discover(); - - const refactorers: Planner[] = []; - - for (const resource of discovered) { - switch (resource.key) { - case 'auth:Cognito': - refactorers.push( - new AuthCognitoRollbackRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'auth:Cognito-UserPool-Groups': - refactorers.push( - new AuthUserPoolGroupsRollbackRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'storage:S3': - refactorers.push( - new StorageS3RollbackRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'storage:DynamoDB': - refactorers.push( - new StorageDynamoRollbackRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - case 'analytics:Kinesis': - refactorers.push( - new AnalyticsKinesisRollbackRefactorer(gen1Env, gen2Branch, clients, this.region, accountId, this.logger, resource, cfn), - ); - break; - // Stateless categories — nothing to rollback - // falls through - case 'function:Lambda': - case 'api:AppSync': - case 'api:API Gateway': - case 'geo:Map': - case 'geo:PlaceIndex': - break; - case 'geo:GeofenceCollection': - throw new AmplifyError('MigrationError', { - message: `Unsupported resource '${resource.resourceName}' (${resource.category}:${resource.service}). GeofenceCollection refactor is not supported. Cannot rollback.`, - }); - case 'unsupported': - throw new AmplifyError('MigrationError', { - message: `Unsupported resource '${resource.resourceName}' (${resource.category}:${resource.service}). Cannot rollback.`, - }); - } - } - - return this.buildPlan( - refactorers, - ['Stateful resources will be moved back to Gen1 CloudFormation stacks', 'Your Gen2 app will no longer manage these resources'], - 'Rollback', - ); - } - - /** - * Creates shared AWS clients, stack facades, and the Cfn instance. - */ - private async createInfrastructure(toStack: string): Promise<{ - clients: AwsClients; - accountId: string; - gen1Env: StackFacade; - gen2Branch: StackFacade; - cfn: Cfn; - }> { - const stsClient = new STSClient({}); - const { Account: accountId } = await stsClient.send(new GetCallerIdentityCommand({})); - if (!accountId) { - throw new AmplifyError('ConfigurationError', { message: 'Unable to determine AWS account ID' }); - } - - const clients = new AwsClients({ region: this.region }); - const gen1Env = new StackFacade(clients, this.rootStackName); - const gen2Branch = new StackFacade(clients, toStack); - const cfn = new Cfn(clients.cloudFormation, this.logger); - - return { clients, accountId, gen1Env, gen2Branch, cfn }; - } - - /** - * Collects operations from all refactorers. - */ - private async buildPlan(refactorers: Planner[], implications: string[], title: string): Promise { - const operations: AmplifyMigrationOperation[] = []; - - operations.push({ - describe: async () => [], - validate: () => ({ description: 'Lock status', run: () => this.validateLockStatus() }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - execute: async () => {}, - }); - - for (const refactorer of refactorers) { - operations.push(...(await refactorer.plan())); - } - - return new Plan({ operations, logger: this.logger, implications, title }); - } - - private async validateLockStatus(): Promise { - try { - const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); - await validations.validateLockStatus(); - return { valid: true }; - } catch (e) { - return { valid: false, report: e.message }; - } - } - - private extractParameters(): string { - const toStack = this.context.parameters?.options?.to; - - if (!toStack) { - throw new AmplifyError('InputValidationError', { message: '--to is required' }); - } - - return toStack; - } -} diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.ts index 7d5ce771363..72b4b595393 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-condition-resolver.ts @@ -1,6 +1,6 @@ import { Parameter } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNConditionFunction, CFNConditionFunctionStatement, CFNFunction, CFNTemplate } from '../../cfn-template'; +import { CFNConditionFunction, CFNConditionFunctionStatement, CFNFunction, CFNTemplate } from '../../_infra/cfn-template'; /** * Resolves conditions in a CloudFormation template. diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.ts index fd11854fd16..7a09470a0e7 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-dependency-resolver.ts @@ -1,4 +1,4 @@ -import { CFNTemplate } from '../../cfn-template'; +import { CFNTemplate } from '../../_infra/cfn-template'; /** * Strips all DependsOn references from a CloudFormation template. diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts index ed16ccdce88..bf9bf365045 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts @@ -1,6 +1,6 @@ import { Output, StackResource } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource, CFNTemplate } from '../../cfn-template'; +import { CFNResource, CFNTemplate } from '../../_infra/cfn-template'; import { walkCfnTree } from './cfn-tree-walker'; /** diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.ts index 5d7fcf437fa..813e5db2ae6 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-parameter-resolver.ts @@ -1,6 +1,6 @@ import { Parameter } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNTemplate, CFN_PSEUDO_PARAMETERS_REF } from '../../cfn-template'; +import { CFNTemplate, CFN_PSEUDO_PARAMETERS_REF } from '../../_infra/cfn-template'; import { walkCfnTree } from './cfn-tree-walker'; /** diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/stack-facade.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/stack-facade.ts index dea2476eb3a..233ad89950c 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/stack-facade.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/stack-facade.ts @@ -6,8 +6,8 @@ import { StackResource, } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { AwsClients } from '../aws-clients'; -import { CFNTemplate } from '../cfn-template'; +import { AwsClients } from '../_infra/aws-clients'; +import { CFNTemplate } from '../_infra/cfn-template'; /** * Read-only facade over a CloudFormation stack hierarchy. diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.ts index a05d07e3ad4..e7ebee54e7a 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-dynamo-rollback.ts @@ -1,4 +1,4 @@ -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { DYNAMO_TABLE_TYPE } from './storage-dynamo-forward'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-rollback.ts index 87c054abd0f..34d4358890c 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/storage/storage-rollback.ts @@ -1,4 +1,4 @@ -import { CFNResource } from '../../cfn-template'; +import { CFNResource } from '../../_infra/cfn-template'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { findS3NestedStack, S3_BUCKET_TYPE } from './storage-forward'; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts index eede9f995b4..1276cb06c35 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/category-refactorer.ts @@ -1,14 +1,13 @@ import { Parameter, ResourceMapping } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource, CFNTemplate } from '../../cfn-template'; -import { Planner } from '../../planner'; -import { AmplifyMigrationOperation } from '../../_operation'; -import { AwsClients } from '../../aws-clients'; +import { CFNResource, CFNTemplate } from '../../_infra/cfn-template'; +import { Planner } from '../../_infra/planner'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; import { StackFacade } from '../stack-facade'; import { Cfn, HOLDING_STACK_NAME_SUFFIX } from '../cfn'; -import { SpinningLogger } from '../../_spinning-logger'; +import { SpinningLogger } from '../../_infra/spinning-logger'; import { extractStackNameFromId } from '../utils'; -import { DiscoveredResource } from '../../generate/_infra/gen1-app'; +import { DiscoveredResource, Gen1App } from '../../generate/_infra/gen1-app'; import CLITable from 'cli-table3'; const MAX_STACK_NAME_LENGTH = 128; @@ -48,8 +47,7 @@ export abstract class CategoryRefactorer implements Planner { constructor( protected readonly gen1Env: StackFacade, protected readonly gen2Branch: StackFacade, - protected readonly clients: AwsClients, - protected readonly region: string, + protected readonly gen1App: Gen1App, protected readonly accountId: string, protected readonly logger: SpinningLogger, protected readonly resource: DiscoveredResource, diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts index 5f42e2ab2e6..e5b628531e4 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts @@ -1,7 +1,7 @@ import { Output, Parameter, ResourceMapping } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource } from '../../cfn-template'; -import { AmplifyMigrationOperation } from '../../_operation'; +import { CFNResource } from '../../_infra/cfn-template'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; import { resolveParameters } from '../resolvers/cfn-parameter-resolver'; import { resolveOutputs } from '../resolvers/cfn-output-resolver'; import { resolveDependencies } from '../resolvers/cfn-dependency-resolver'; @@ -103,7 +103,7 @@ export abstract class ForwardCategoryRefactorer extends CategoryRefactorer { template: withParams, stackOutputs: outputs, stackResources, - region: this.region, + region: this.gen1App.region, accountId: this.accountId, }); const withDeps = resolveDependencies(withOutputs); @@ -131,7 +131,7 @@ export abstract class ForwardCategoryRefactorer extends CategoryRefactorer { template: withDeps, stackOutputs: outputs, stackResources, - region: this.region, + region: this.gen1App.region, accountId: this.accountId, }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts index 87ed982c972..dff3aed7878 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts @@ -1,7 +1,7 @@ import { ResourceMapping } from '@aws-sdk/client-cloudformation'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { CFNResource } from '../../cfn-template'; -import { AmplifyMigrationOperation } from '../../_operation'; +import { CFNResource } from '../../_infra/cfn-template'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; import { resolveParameters } from '../resolvers/cfn-parameter-resolver'; import { resolveOutputs } from '../resolvers/cfn-output-resolver'; import { resolveDependencies } from '../resolvers/cfn-dependency-resolver'; @@ -69,7 +69,7 @@ export abstract class RollbackCategoryRefactorer extends CategoryRefactorer { template: withParams, stackOutputs: outputs, stackResources, - region: this.region, + region: this.gen1App.region, accountId: this.accountId, }); const resolved = resolveDependencies(withOutputs); diff --git a/packages/amplify-cli/src/commands/gen2-migration/shift.ts b/packages/amplify-cli/src/commands/gen2-migration/shift.ts deleted file mode 100644 index 678f81b9960..00000000000 --- a/packages/amplify-cli/src/commands/gen2-migration/shift.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AmplifyMigrationStep } from './_step'; -import { Plan } from './_plan'; - -export class AmplifyMigrationShiftStep extends AmplifyMigrationStep { - public async forward(): Promise { - throw new Error('Method not implemented.'); - } - - public async rollback(): Promise { - throw new Error('Not Implemented'); - } -} diff --git a/packages/amplify-gen2-migration-e2e-system/package.json b/packages/amplify-gen2-migration-e2e-system/package.json index 61332a96ea9..b61d3e9c7e7 100644 --- a/packages/amplify-gen2-migration-e2e-system/package.json +++ b/packages/amplify-gen2-migration-e2e-system/package.json @@ -1,5 +1,5 @@ { - "name": "amplify-gen2-migration-e2e-system", + "name": "@aws-amplify/amplify-gen2-migration-e2e-system", "private": true, "version": "1.0.0", "description": "Migration automation system for AWS Amplify Gen1 to Gen2", diff --git a/packages/amplify-provider-awscloudformation/src/index.ts b/packages/amplify-provider-awscloudformation/src/index.ts index 43ed1290651..81439285363 100644 --- a/packages/amplify-provider-awscloudformation/src/index.ts +++ b/packages/amplify-provider-awscloudformation/src/index.ts @@ -216,4 +216,5 @@ module.exports = { formUserAgentParam, loadConfiguration, resolveRegion, + proxyAgent, }; diff --git a/yarn.lock b/yarn.lock index cf14c3b8d9a..107e77ca8f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -946,6 +946,42 @@ __metadata: languageName: node linkType: hard +"@aws-amplify/amplify-gen2-migration-e2e-system@workspace:packages/amplify-gen2-migration-e2e-system": + version: 0.0.0-use.local + resolution: "@aws-amplify/amplify-gen2-migration-e2e-system@workspace:packages/amplify-gen2-migration-e2e-system" + dependencies: + "@aws-amplify/amplify-e2e-core": "workspace:^" + "@cdklabs/cdk-atmosphere-client": latest + "@paralleldrive/cuid2": ^3.0.6 + "@types/fs-extra": ^11.0.4 + "@types/inquirer": ^8.2.6 + "@types/jest": ^29.0.0 + "@types/node": ^20.9.0 + "@types/uuid": ^9.0.7 + chalk: ^4.1.2 + dotenv: ^16.3.1 + eslint: ^8.57.1 + execa: ^5.1.1 + fast-check: ^3.15.0 + fs-extra: ^11.1.1 + glob: ^13.0.0 + inquirer: ^8.2.6 + jest: 29 + node-fetch: ^2.7.0 + ora: ^5.4.1 + ts-jest: ^29.0.0 + ts-node: ^10.4.0 + typescript: ^4.9.5 + uuid: ^9.0.1 + yargs: ^17.7.2 + dependenciesMeta: + "@cdklabs/cdk-atmosphere-client": + optional: true + bin: + amplify-migrate: dist/cli.js + languageName: unknown + linkType: soft + "@aws-amplify/amplify-go-function-template-provider@1.4.9, @aws-amplify/amplify-go-function-template-provider@workspace:packages/amplify-go-function-template-provider": version: 0.0.0-use.local resolution: "@aws-amplify/amplify-go-function-template-provider@workspace:packages/amplify-go-function-template-provider" @@ -1762,6 +1798,7 @@ __metadata: "@aws-sdk/client-ssm": ^3.919.0 "@aws-sdk/client-sts": ^3.919.0 "@jest/globals": ^29.7.0 + "@smithy/node-http-handler": ^4.4.3 "@types/archiver": ^5.3.1 "@types/columnify": ^1.5.1 "@types/folder-hash": ^4.0.1 @@ -23446,42 +23483,6 @@ __metadata: languageName: unknown linkType: soft -"amplify-gen2-migration-e2e-system@workspace:packages/amplify-gen2-migration-e2e-system": - version: 0.0.0-use.local - resolution: "amplify-gen2-migration-e2e-system@workspace:packages/amplify-gen2-migration-e2e-system" - dependencies: - "@aws-amplify/amplify-e2e-core": "workspace:^" - "@cdklabs/cdk-atmosphere-client": latest - "@paralleldrive/cuid2": ^3.0.6 - "@types/fs-extra": ^11.0.4 - "@types/inquirer": ^8.2.6 - "@types/jest": ^29.0.0 - "@types/node": ^20.9.0 - "@types/uuid": ^9.0.7 - chalk: ^4.1.2 - dotenv: ^16.3.1 - eslint: ^8.57.1 - execa: ^5.1.1 - fast-check: ^3.15.0 - fs-extra: ^11.1.1 - glob: ^13.0.0 - inquirer: ^8.2.6 - jest: 29 - node-fetch: ^2.7.0 - ora: ^5.4.1 - ts-jest: ^29.0.0 - ts-node: ^10.4.0 - typescript: ^4.9.5 - uuid: ^9.0.1 - yargs: ^17.7.2 - dependenciesMeta: - "@cdklabs/cdk-atmosphere-client": - optional: true - bin: - amplify-migrate: dist/cli.js - languageName: unknown - linkType: soft - "amplify-go-function-runtime-provider@2.3.54, amplify-go-function-runtime-provider@workspace:packages/amplify-go-function-runtime-provider": version: 0.0.0-use.local resolution: "amplify-go-function-runtime-provider@workspace:packages/amplify-go-function-runtime-provider"