diff --git a/bun.lock b/bun.lock index b84bfbb62..daf00c170 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "agentcore", "dependencies": { + "@aws-cdk/cloudformation-diff": "2.187.4", "@aws-cdk/toolkit-lib": "1.38.2", "@aws-sdk/client-application-signals": "^3.1092.0", "@aws-sdk/client-bedrock-agent": "^3.1092.0", @@ -13,6 +14,7 @@ "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws-sdk/client-s3": "^3.1121.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws-sdk/client-xray": "^3.1092.0", "@aws-sdk/util-endpoints": "^3.996.44", @@ -864,7 +866,7 @@ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], - "fast-uri": ["fast-uri@4.1.5", "", {}, "sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "fastify": ["fastify@5.12.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^7.0.0", "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A=="], @@ -1012,7 +1014,7 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], @@ -1668,6 +1670,8 @@ "@ag-ui/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@alcalzone/ansi-tokenize/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "@aws-cdk/aws-service-spec/@aws-cdk/service-spec-types": ["@aws-cdk/service-spec-types@0.0.273", "", { "dependencies": { "@cdklabs/tskb": "^0.0.4" } }, "sha512-Pe1j265hAAl10aiUj+gk5pKbOf3A34uz2mSFgJDPpN6t3A/g6mBjMbrVwZKHIqGHvoksaJ5mo2kKczjDoKq7kw=="], "@aws-cdk/cdk-assets-lib/@aws-cdk/cloud-assembly-api": ["@aws-cdk/cloud-assembly-api@2.4.0", "", { "dependencies": { "json-source-map": "^0.6.1", "jsonschema": "^1.5.0", "semver": "^7.8.5" }, "peerDependencies": { "@aws-cdk/cloud-assembly-schema": ">=54.21.0" } }, "sha512-ooStESI1uXNcFAx1IgG6XcN7SsMuenoaxSb1FHqcLqwy7Lq5HUMWM52mtIzrS/uJmoFNvoxQ7fGbk8MfCGIjlw=="], @@ -1848,6 +1852,8 @@ "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@fastify/ajv-compiler/fast-uri": ["fast-uri@4.1.5", "", {}, "sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg=="], + "@fastify/websocket/fastify-plugin": ["fastify-plugin@6.0.0", "", {}, "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg=="], "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-logs": "0.221.0", "@opentelemetry/sdk-metrics": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg=="], @@ -1878,8 +1884,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "ajv/fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], @@ -1904,6 +1908,8 @@ "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "fast-json-stringify/fast-uri": ["fast-uri@4.1.5", "", {}, "sha512-vZeoMRB4epNr7QfdHxel7te/RcX16CxyXI07JCCTFWZA2s4v1azGNESRj+2EoaHSaWFL/Z3GmKT2jF6A202jLg=="], + "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "ink/wrap-ansi": ["wrap-ansi@10.0.1", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0" } }, "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q=="], @@ -1924,6 +1930,8 @@ "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "supports-hyperlinks/has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="], "supports-hyperlinks/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -1942,8 +1950,6 @@ "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@aws-cdk/cloudformation-diff/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@aws-cdk/cloudformation-diff/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@aws-cdk/cx-api/@aws-cdk/cloud-assembly-schema/jsonschema": ["jsonschema@1.5.0", "", { "bundled": true }, "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw=="], @@ -1990,8 +1996,6 @@ "@opentelemetry/otlp-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.221.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.221.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg=="], - "@textlint/linter-formatter/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], @@ -2002,14 +2006,8 @@ "table/slice-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "table/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "table/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "table/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@aws-cdk/cloudformation-diff/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index e67bb6869..426442a5b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "typescript": "^5" }, "dependencies": { + "@aws-cdk/cloudformation-diff": "2.187.4", "@aws-cdk/toolkit-lib": "1.38.2", "@aws-sdk/client-application-signals": "^3.1092.0", "@aws-sdk/client-bedrock-agent": "^3.1092.0", @@ -74,6 +75,7 @@ "@aws-sdk/client-cloudformation": "^3.1092.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@aws-sdk/client-s3": "^3.1121.0", "@aws-sdk/client-sts": "^3.1092.0", "@aws-sdk/client-xray": "^3.1092.0", "@aws-sdk/util-endpoints": "^3.996.44", diff --git a/scripts/build.ts b/scripts/build.ts index 1d506c90f..8d382a270 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -24,10 +24,15 @@ const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; // __dirname to the *build machine's* path, which resolves on the builder and // fails everywhere else. Binaries therefore have to hand the Toolkit an // explicit copy of anything it would otherwise read from its package. -const EXTERNAL = ["@aws-cdk/toolkit-lib", "@napi-rs/keyring"]; +// +// @aws-cdk/cloudformation-diff (migrate's replacement check) is external for the +// same reason: it loads its resource database from a file inside its package. +const EXTERNAL = ["@aws-cdk/toolkit-lib", "@napi-rs/keyring", "@aws-cdk/cloudformation-diff"]; const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"]; +const SERVICE_SPEC_DATABASE = "db.json.gz"; + // Shrink whitespace/syntax but keep identifiers: minified names make stack // traces unreadable and erase error names telemetry keys on. const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const; @@ -79,6 +84,50 @@ function assetLoaderPlugin(): Bun.BunPlugin { }; } +/** + * `agentcore migrate` checks for replacements with @aws-cdk/cloudformation-diff, + * which reads the AWS resource specification from `db.json.gz` beside its + * @aws-cdk/aws-service-spec dependency, at a path built from `__dirname`. In a + * standalone binary that path is the build machine's, so this embeds the database + * and points the loader at the embedded copy instead. + */ +function serviceSpecDatabase(): string { + const diffManifest = Bun.resolveSync("@aws-cdk/cloudformation-diff/package.json", REPO_ROOT); + const specManifest = Bun.resolveSync( + "@aws-cdk/aws-service-spec/package.json", + resolve(diffManifest, ".."), + ); + const database = join(resolve(specManifest, ".."), SERVICE_SPEC_DATABASE); + if (!existsSync(database)) { + throw new Error( + `@aws-cdk/aws-service-spec no longer ships ${SERVICE_SPEC_DATABASE}; looked in ${database}`, + ); + } + return database; +} + +function serviceSpecDatabasePlugin(database: string): Bun.BunPlugin { + const loader = /@aws-cdk[/\\]aws-service-spec[/\\]lib[/\\]index\.js$/; + const pathLine = "const DB_PATH = path.join(__dirname, '..', DB_COMPRESSED);"; + return { + name: "aws-service-spec-database", + setup(build) { + build.onLoad({ filter: loader }, async ({ path }) => { + const source = await Bun.file(path).text(); + if (!source.includes(pathLine)) { + throw new Error(`${path} no longer builds its database path as expected: ${pathLine}`); + } + return { + contents: + `import EMBEDDED_DB_PATH from ${JSON.stringify(database)} with { type: "file" };\n` + + source.replace(pathLine, "const DB_PATH = EMBEDDED_DB_PATH;"), + loader: "js", + }; + }); + }, + }; +} + function runtimeShellSdkPlugin(): Bun.BunPlugin { const runtimeEntry = Bun.resolveSync("bedrock-agentcore/runtime", REPO_ROOT); const runtimeDirectory = resolve(runtimeEntry, ".."); @@ -127,13 +176,13 @@ function bootstrapTemplate(): string { return template; } -async function assertTemplateIsEmbedded(outfile: string, template: string): Promise { +async function assertFileIsEmbedded(outfile: string, file: string, label: string): Promise { const [executable, contents] = await Promise.all([ Bun.file(outfile).bytes(), - Bun.file(template).bytes(), + Bun.file(file).bytes(), ]); if (!Buffer.from(executable).includes(contents)) { - throw new Error(`${outfile} does not contain ${BOOTSTRAP_TEMPLATE.join("/")}`); + throw new Error(`${outfile} does not contain ${label}`); } } @@ -196,6 +245,7 @@ async function compile(target: string): Promise { await $`mkdir -p ${join(DIST, "bin")}`; const template = bootstrapTemplate(); + const database = serviceSpecDatabase(); await Bun.build({ entrypoints: [ENTRYPOINT, ...assets, template], compile: { target: target as Bun.Build.CompileTarget, outfile }, @@ -203,11 +253,17 @@ async function compile(target: string): Promise { define: DEFINE, root: REPO_ROOT, naming: { asset: ASSET_NAMING }, - plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin(), nativeKeyringPlugin(target)], + plugins: [ + assetLoaderPlugin(), + runtimeShellSdkPlugin(), + nativeKeyringPlugin(target), + serviceSpecDatabasePlugin(database), + ], }); - await assertTemplateIsEmbedded(outfile, template); + await assertFileIsEmbedded(outfile, template, BOOTSTRAP_TEMPLATE.join("/")); + await assertFileIsEmbedded(outfile, database, `aws-service-spec ${SERVICE_SPEC_DATABASE}`); console.log( - `Compiled ${target} → ${outfile} (${assets.length} assets embedded, plus the bootstrap template)`, + `Compiled ${target} → ${outfile} (${assets.length} assets embedded, plus the bootstrap template and the AWS resource specification)`, ); } diff --git a/scripts/generate-v0-cdk-apps.ts b/scripts/generate-v0-cdk-apps.ts new file mode 100644 index 000000000..a90a0075c --- /dev/null +++ b/scripts/generate-v0-cdk-apps.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env bun +/** + * Regenerates src/core/project/migrate/v0CdkApps.ts: what every CLI release that + * creates v0 projects (v0.4.0 to v0.31.1, 1.0.0-preview.1 to 1.0.0-preview.33) + * vended in agentcore/cdk, read from its git tag's src/assets/cdk. Releases that + * vended identical apps share an entry. Run from a clone with the release tags: + * + * bun scripts/generate-v0-cdk-apps.ts + */ +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + dependencyNames, + manifestFingerprint, + sourceFingerprint, +} from "../src/core/project/migrate/v0CdkApp"; + +const REPO_ROOT = join(import.meta.dir, ".."); +const OUTPUT = join(REPO_ROOT, "src", "core", "project", "migrate", "v0CdkApps.ts"); +const ASSETS = "src/assets/cdk"; +/** `agentcore create` renames these templates as it writes them. */ +const RENAMED: Record = { + "gitignore.template": ".gitignore", + "npmignore.template": ".npmignore", +}; + +const git = (...args: string[]) => + execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + +const version = (tag: string) => tag.replace(/^v/, ""); +const supported = (tag: string) => + /^v0\.\d+\.\d+$/.test(tag) ? Number(tag.split(".")[1]) >= 4 : /^v1\.0\.0-preview\.\d+$/.test(tag); +const tags = git("tag", "-l", "v0.*", "v1.0.0-preview.*") + .split("\n") + .filter((tag) => tag && supported(tag)) + .sort((a, b) => { + const order = (tag: string) => + tag.startsWith("v1") + ? [1, Number(tag.split(".").at(-1))] + : [0, Number(tag.split(".")[1]), Number(tag.split(".")[2])]; + const [x, y] = [order(a), order(b)]; + for (let i = 0; i < Math.max(x.length, y.length); i++) { + if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0); + } + return 0; + }); + +type App = { files: Record; packageJson: string }; +const apps = new Map(); +const dependencies = new Set(); +for (const tag of tags) { + const files: Record = {}; + let packageJson = ""; + for (const path of git("ls-tree", "-r", "--name-only", tag, ASSETS).split("\n").filter(Boolean)) { + const relative = path.slice(ASSETS.length + 1); + const file = RENAMED[relative] ?? relative; + const source = git("show", `${tag}:${path}`); + if (file === "package.json") { + const manifest = JSON.parse(source); + packageJson = manifestFingerprint(manifest); + for (const name of dependencyNames(manifest)) dependencies.add(name); + } else { + files[file] = sourceFingerprint(source); + } + } + const app: App = { files: Object.fromEntries(Object.entries(files).sort()), packageJson }; + const key = JSON.stringify(app); + const entry = apps.get(key) ?? { app, releases: [] }; + entry.releases.push(version(tag)); + apps.set(key, entry); +} + +/** "0.4.0 to 0.15.0 or 1.0.0-preview.1": consecutive releases collapse into ranges. */ +function describe(releases: string[]): string { + const ranges: string[][] = []; + for (const release of releases) { + const index = tags.map(version).indexOf(release); + const last = ranges.at(-1); + const previous = last?.at(-1); + if ( + last && + previous && + tags.map(version).indexOf(previous) === index - 1 && + previous.startsWith("1.") === release.startsWith("1.") + ) { + last.push(release); + } else { + ranges.push([release]); + } + } + return ranges + .map((range) => (range.length === 1 ? range[0]! : `${range[0]} to ${range.at(-1)}`)) + .join(" or "); +} + +const entries = [...apps.values()].map(({ app, releases }) => ({ + releases: describe(releases), + ...app, +})); +writeFileSync( + OUTPUT, + `// Generated by scripts/generate-v0-cdk-apps.ts from the release tags. Do not edit. + +/** The agentcore/cdk app one or more releases vended: each file's fingerprint, and the manifest's. */ +export type V0CdkApp = { + releases: string; + /** {@link sourceFingerprint} of each file but package.json, by path relative to agentcore/cdk. */ + files: Record; + /** {@link manifestFingerprint} of package.json. */ + packageJson: string; +}; + +/** Every app a release that creates v0 projects vended, oldest first. */ +export const V0_CDK_APPS: readonly V0CdkApp[] = ${JSON.stringify(entries, null, 2)}; + +/** Every dependency any of those apps' package.json declared; a v0 deploy may restore any of them. */ +export const V0_CDK_DEPENDENCY_NAMES: readonly string[] = ${JSON.stringify([...dependencies].sort(), null, 2)}; +`, +); +console.log(`Wrote ${entries.length} apps from ${tags.length} releases to ${OUTPUT}`); diff --git a/src/core/index.tsx b/src/core/index.tsx index c5f84bf9b..2c171dce8 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -32,6 +32,8 @@ import type { Logger } from "../logging"; import type { ProjectManager } from "../handlers/project/types"; import { FsProjectManager } from "./project"; import { BedrockAgentImporter, type CoreBedrockAgentImporter } from "./project/bedrockAgentImport"; +import { FsProjectMigrator } from "./project/migrate"; +import type { ProjectMigrator } from "../handlers/project/migrate/types"; export type { AwsClients, @@ -59,6 +61,7 @@ type CoreClientConfig = { newSessionId?: () => string; now?: () => number; bedrockAgentImporter?: CoreBedrockAgentImporter; + projectMigrator?: ProjectMigrator; openRuntimeShell?: OpenRuntimeShell; openHarnessShell?: OpenHarnessShell; }; @@ -96,6 +99,7 @@ export class CoreClient implements AwsClients { readonly projectManager: ProjectManager; readonly bedrockAgentImporter: CoreBedrockAgentImporter; + readonly projectMigrator: ProjectMigrator; readonly fetch: CoreFetch; constructor(config: CoreClientConfig) { @@ -144,6 +148,14 @@ export class CoreClient implements AwsClients { ), }); this.bedrockAgentImporter = config.bedrockAgentImporter ?? new BedrockAgentImporter(); + // Shares the CloudFormation client factory, so stack reads go through the same + // record/replay seam as deploy. + this.projectMigrator = + config.projectMigrator ?? + new FsProjectMigrator({ + logger: this.logger.child({ module: "projectMigrator" }), + createCloudFormationClient: config.createCloudFormationClient, + }); } // control returns the control-plane client for `config`, creating and caching it diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 9b6843d41..3f26c2b5c 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -43,6 +43,7 @@ import { import { countDeployableResources, stackArtifactForTarget, + synthCommand, type StackArtifact, } from "./cdk/assembly"; import { @@ -226,25 +227,13 @@ export class CdkBackend implements ProjectBackend { // Chunks still go to the debug log whole; the splitter reassembles them // into lines for the live progress tail. const lines = createLineSplitter(emit); - return this.runner( - [ - "npm", - "run", - "cdk", - "--", - "synth", - "--quiet", - "--output", - this.assemblyDirectory(project), - ], - { - cwd: this.cdkDirectory(project), - onOutput: (chunk) => { - this.logger.debug(chunk); - lines.push(chunk); - }, + return this.runner(synthCommand(this.assemblyDirectory(project)), { + cwd: this.cdkDirectory(project), + onOutput: (chunk) => { + this.logger.debug(chunk); + lines.push(chunk); }, - ).finally(() => lines.flush()); + }).finally(() => lines.flush()); }); } catch (error) { const credentialName = explainUndeployedCredentials diff --git a/src/core/project/backends/cdk/assembly.ts b/src/core/project/backends/cdk/assembly.ts index 483702d2c..93a359af6 100644 --- a/src/core/project/backends/cdk/assembly.ts +++ b/src/core/project/backends/cdk/assembly.ts @@ -41,6 +41,15 @@ const StackTemplateSchema = z }) .passthrough(); +/** + * The command that synthesizes a project's vended CDK app (run in its + * `agentcore/cdk` directory) into a cloud assembly at `outputDirectory`. Builds + * the app first, through its `cdk` npm script. + */ +export function synthCommand(outputDirectory: string): string[] { + return ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", outputDirectory]; +} + /** The synthesized stack a deploy selected, and where its template lives. */ export type StackArtifact = { /** Artifact id the CDK Toolkit selects the stack by. */ diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts index 423f23fca..d5304ac03 100644 --- a/src/core/project/backends/cdk/credentials.test.ts +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -81,6 +81,12 @@ async function project(credentials: unknown[], envLocal?: string): Promise { + const base = await project(credentials, envLocal); + return { ...base, spec: { ...base.spec, migratedFrom: "v1" } }; +} + /** The same project loaded from a v0 `agentcore.json`. */ async function legacyProject(credentials: unknown[], envLocal?: string): Promise { const v0Spec = v0Fixture("baseline.json"); @@ -997,6 +1003,36 @@ describe("createCredentialRemover", () => { }); }); +describe("projects migrated from v0", () => { + test("keep the bare credential name v0 created the provider under", async () => { + const existing = { credentialProviderArn: "arn:apikey:openai-key" }; + const subject = account({ "openai-key": existing }); + + const { result } = await run( + subject.provision, + await migratedProject([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n"), + ); + + expect(Object.keys(subject.contents())).toEqual(["openai-key"]); + expect(subject.contents()["openai-key"]?.operation).toBe("updated"); + expect(result["openai-key"]?.credentialProviderArn).toBe("arn:apikey:openai-key"); + }); + + test("never delete API key or OAuth providers, which v0 projects may share", async () => { + const subject = account({ "openai-key": { credentialProviderArn: "arn:apikey:openai-key" } }); + + await collect( + createCredentialRemover(subject.client)(await migratedProject([]), { + ...INPUT, + providers: [API_KEY], + }), + ); + + expect(subject.deleted).toEqual([]); + expect(Object.keys(subject.contents())).toEqual(["openai-key"]); + }); +}); + describe("v0 projects", () => { // The v0 CLI named every provider by the bare credential name, and the agent code it // deployed looks providers up by that name, so a v0 project keeps it. diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts index 0c782f888..06681aeb1 100644 --- a/src/core/project/backends/cdk/credentials.ts +++ b/src/core/project/backends/cdk/credentials.ts @@ -36,16 +36,25 @@ export type DeployedCredential = { }; export type DeployedCredentials = Record; +/** + * Whether the project keeps v0's credential provider names: a v0 project, or one + * `agentcore migrate` converted from v0 (`migratedFrom: "v1"`), whose deployed agent + * code and providers still use them. + */ +function keepsV0CredentialNames(project: Project): boolean { + return project.legacy !== undefined || project.spec.migratedFrom === "v1"; +} + /** * The Identity name a credential's provider is created under. Scoped to the project * and target so two targets in one account and region never share a provider. * - * A v0 project keeps the bare credential name v0 created its providers under: the - * agent code it deployed looks them up by that name, and a scoped name would create a - * second set of providers beside the ones still in use. + * A v0 project, and one migrated from v0, keeps the bare credential name v0 created + * its providers under: the agent code it deployed looks them up by that name, and a + * scoped name would create a second set of providers beside the ones still in use. */ function providerName(project: Project, targetName: string, credentialName: string): string { - if (project.legacy) return credentialName; + if (keepsV0CredentialNames(project)) return credentialName; return `${project.name}_${targetName}_${credentialName}`; } @@ -151,7 +160,7 @@ export function createCredentialRemover(identity: ProviderDeletes): CredentialRe const options: CoreOptions = { region, credentials }; for (const { name, authorizerType } of providers) { const provider = providerName(project, targetName, name); - if (project.legacy && authorizerType !== "PaymentCredentialProvider") { + if (keepsV0CredentialNames(project) && authorizerType !== "PaymentCredentialProvider") { yield { type: "step", message: `Leaving credential provider '${provider}' in place: v0 projects can share it`, diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index cb42a3f61..d635ab500 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { writeMigrationState } from "./migrate/journal"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; import { @@ -1926,6 +1927,37 @@ describe("FsProjectManager.deploy", () => { }); }); + test("refuses a target a migration hasn't finished, for every caller (CLI, TUI, harness dev)", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, targets); + await writeMigrationState(root, { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: "installed", backup: "agentcore/.cli/migrate-backup", changes: [] }, + refactored: ["staging"], + targets: [ + { + name: "prod", + account: "444455556666", + region: "eu-west-1", + stackName: "AgentCore-example-prod", + status: "refactoring", + refactorId: "rf-1", + }, + ], + }); + + await expect(deploy(subject.manager, project, "prod")).rejects.toThrow( + "Target 'prod' (AWS account 444455556666, eu-west-1) hasn't been migrated yet", + ); + expect(subject.calls).toEqual([]); + expect(subject.accountCalls).toEqual([]); + // The migrated target deploys as usual. + await deploy(subject.manager, project, "staging"); + expect(subject.calls.map((call) => call.input.target.name)).toEqual(["staging"]); + }); + test("rejects an unknown target before invoking the backend", async () => { const root = await inTempDirectory(); const subject = deployManager(); @@ -2631,3 +2663,30 @@ describe("FsProjectManager removal", () => { expect(twice.project.spec.runtimes).toEqual([]); }); }); + +describe("migratedFrom", () => { + test("survives reading and writing agentcore.json", async () => { + await inTempDirectory(); + const setup = manager(); + const { project } = await runCreate(setup.manager, { + name: "example", + scaffoldRuntimeInput: AGENT_PYTHON, + skipInstall: true, + skipGit: true, + }); + const specPath = join(project.rootPath, "agentcore", "agentcore.json"); + const spec = JSON.parse(await readFile(specPath, "utf8")) as Record; + await writeFile(specPath, JSON.stringify({ ...spec, migratedFrom: "v1" }, null, 2)); + + const migrated = await setup.manager.resolve({ filePath: project.rootPath }); + if (!migrated) throw new Error("expected project"); + expect(migrated.spec.migratedFrom).toBe("v1"); + await runAdd(setup.manager, migrated, { + resourceType: "memory", + resourceConfig: getDefaultMemorySpec("extra"), + }); + + const written = JSON.parse(await readFile(specPath, "utf8")) as Record; + expect(written.migratedFrom).toBe("v1"); + }); +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 935c4cb40..f9c329a48 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -108,6 +108,7 @@ import { resolveRuntimeTemplateProfile } from "../../handlers/project/runtimeTem import { projectFormat, type ProjectFormat } from "./legacy/detect"; import { loadLegacyProject } from "./legacy/load"; import { assertV1Project } from "./legacy/writeGate"; +import { assertNoPendingMigration } from "./migrate/journal"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; @@ -1423,6 +1424,9 @@ export class FsProjectManager implements ProjectManager { input: DeployProjectInput, ): AsyncGenerator { assertV1Project(project, "deploy"); + // A target `agentcore migrate` hasn't finished with still has its v0 logical + // IDs; every deploy path (CLI, TUI, harness dev) comes through here. + await assertNoPendingMigration(project.rootPath, input.target); const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); const fileExists = existsSync(targetsPath); const targets = await this.listTargets(project); diff --git a/src/core/project/migrate/__fixtures__/physical-names-contract.agentcore.json b/src/core/project/migrate/__fixtures__/physical-names-contract.agentcore.json new file mode 100644 index 000000000..07661bcac --- /dev/null +++ b/src/core/project/migrate/__fixtures__/physical-names-contract.agentcore.json @@ -0,0 +1,836 @@ +{ + "name": "maxproj", + "version": 2, + "migratedFrom": "v1", + "managedBy": "CDK", + "tags": { + "agentcore:created-by": "agentcore-cli", + "team": "x" + }, + "runtimes": [ + { + "name": "agentA", + "description": "A", + "build": "CodeZip", + "entrypoint": "main.py:app", + "codeLocation": "app/agentA", + "runtimeVersion": "PYTHON_3_13", + "envVars": [ + { + "name": "FOO", + "value": "bar" + } + ], + "networkMode": "VPC", + "networkConfig": { + "subnets": [ + "subnet-0123456789abcdef0" + ], + "securityGroups": [ + "sg-0123456789abcdef0" + ], + "vpcId": "vpc-0123456789abcdef0" + }, + "instrumentation": { + "enableOtel": false + }, + "protocol": "HTTP", + "requestHeaderAllowlist": [ + "X-Amzn-Bedrock-AgentCore-Runtime-Custom-Foo", + "Authorization" + ], + "executionRoleArn": "arn:aws:iam::123456789012:role/r", + "additionalPolicies": [ + "policies/extra.json" + ], + "authorizerType": "CUSTOM_JWT", + "authorizerConfiguration": { + "customJwtAuthorizer": { + "discoveryUrl": "https://idp.example.com/.well-known/openid-configuration", + "allowedAudience": [ + "aud" + ], + "allowedClients": [ + "cli" + ], + "allowedScopes": [ + "read" + ], + "customClaims": [ + { + "inboundTokenClaimName": "groups", + "inboundTokenClaimValueType": "STRING_ARRAY", + "authorizingClaimMatchValue": { + "claimMatchOperator": "CONTAINS_ANY", + "claimMatchValue": { + "matchValueStringList": [ + "admin" + ] + } + } + } + ], + "privateEndpoint": { + "managedVpcResource": { + "vpcIdentifier": "vpc-0123456789abcdef0", + "subnetIds": [ + "subnet-0123456789abcdef0" + ], + "endpointIpAddressType": "IPV4", + "securityGroupIds": [ + "sg-0123456789abcdef0" + ], + "tags": { + "a": "b" + }, + "routingDomain": "idp.example.com" + } + }, + "privateEndpointOverrides": [ + { + "domain": "other.example.com", + "privateEndpoint": { + "managedVpcResource": { + "vpcIdentifier": "vpc-0123456789abcdef0", + "subnetIds": [ + "subnet-0123456789abcdef0" + ], + "endpointIpAddressType": "IPV6" + } + } + } + ] + } + }, + "tags": { + "r": "1" + }, + "lifecycleConfiguration": { + "idleRuntimeSessionTimeout": 900, + "maxLifetime": 28800 + }, + "filesystemConfigurations": [ + { + "sessionStorage": { + "mountPath": "/mnt/data" + } + }, + { + "efsAccessPoint": { + "accessPointArn": "arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0", + "mountPath": "/mnt/efs" + } + }, + { + "s3FilesAccessPoint": { + "accessPointArn": "arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef0/access-point/fsap-0123456789abcdef0", + "mountPath": "/mnt/s3" + } + } + ], + "endpoints": { + "prod": { + "version": 1, + "description": "p" + } + }, + "connections": [ + { + "id": "mem1", + "to": { + "type": "memory", + "arn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m-1", + "namespaces": [ + "/x" + ] + }, + "access": "read", + "description": "d" + }, + { + "to": { + "type": "gateway", + "arn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g-1", + "outboundAuth": { + "oauth": { + "providerArn": "arn:p", + "scopes": [ + "s" + ], + "grantType": "CLIENT_CREDENTIALS", + "customParameters": { + "k": "v" + } + } + } + } + }, + { + "to": { + "type": "gateway", + "arn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g-2", + "outboundAuth": { + "awsIam": {} + } + } + }, + { + "to": { + "type": "runtime", + "arn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/r-1", + "exec": true + }, + "access": "readwrite" + }, + { + "to": { + "type": "browser", + "arn": "arn:aws:bedrock-agentcore:us-east-1:aws:browser/aws.browser.v1" + } + }, + { + "to": { + "type": "codeInterpreter" + } + } + ] + }, + { + "name": "agentB", + "build": "Container", + "entrypoint": "main.py", + "codeLocation": "app/agentB", + "dockerfile": "docker/Dockerfile", + "buildContextPath": "app", + "customDockerBuildArgs": { + "PIP_INDEX": "x" + }, + "protocol": "MCP", + "authorizerType": "AWS_IAM" + }, + { + "name": "agentC", + "build": "CodeZip", + "entrypoint": "index.ts", + "codeLocation": "app/agentC", + "runtimeVersion": "NODE_22", + "networkMode": "PUBLIC", + "protocol": "A2A" + }, + { + "name": "agentD", + "build": "CodeZip", + "entrypoint": "main.py", + "codeLocation": "app/agentD", + "runtimeVersion": "PYTHON_3_14", + "protocol": "AGUI" + } + ], + "memories": [ + { + "name": "mem1", + "eventExpiryDuration": 30, + "strategies": [ + { + "type": "SEMANTIC", + "name": "sem", + "description": "d", + "namespaceTemplates": [ + "/users/{actorId}/facts" + ] + }, + { + "type": "SUMMARIZATION", + "namespaces": [ + "/summaries/{actorId}/{sessionId}" + ] + }, + { + "type": "USER_PREFERENCE" + }, + { + "type": "EPISODIC", + "namespaces": [ + "/episodes/{actorId}/{sessionId}" + ], + "reflectionNamespaces": [ + "/episodes/{actorId}" + ] + } + ], + "indexedKeys": [ + { + "key": "topic", + "type": "STRING" + } + ], + "tags": { + "m": "1" + }, + "encryptionKeyArn": "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", + "executionRoleArn": "arn:aws:iam::123456789012:role/m", + "streamDeliveryResources": { + "resources": [ + { + "kinesis": { + "dataStreamArn": "arn:aws:kinesis:us-east-1:1:stream/s", + "contentConfigurations": [ + { + "type": "MEMORY_RECORDS", + "level": "FULL_CONTENT" + } + ] + } + } + ] + } + }, + { + "name": "mem2", + "eventExpiryDuration": 7, + "strategies": [] + } + ], + "knowledgeBases": [ + { + "type": "AgentCoreKnowledgeBase", + "name": "kb-docs", + "description": "kb", + "dataSources": [ + { + "type": "S3", + "uri": "s3://my-bucket/docs" + }, + { + "type": "CONFLUENCE", + "connectorConfigFile": "kb/confluence.json" + } + ], + "gateway": "gw1" + } + ], + "credentials": [ + { + "authorizerType": "ApiKeyCredentialProvider", + "name": "openaiKey" + }, + { + "authorizerType": "OAuthCredentialProvider", + "name": "my-oauth", + "vendor": "CustomOauth2", + "discoveryUrl": "https://idp.example.com/.well-known/openid-configuration", + "scopes": [ + "read" + ] + }, + { + "authorizerType": "OAuthCredentialProvider", + "name": "gw1-oauth", + "vendor": "CustomOauth2", + "discoveryUrl": "https://idp.example.com/.well-known/openid-configuration", + "managed": true + }, + { + "authorizerType": "PaymentCredentialProvider", + "name": "coinbaseCred", + "provider": "CoinbaseCDP" + }, + { + "authorizerType": "PaymentCredentialProvider", + "name": "stripeCred", + "provider": "StripePrivy" + } + ], + "evaluators": [ + { + "name": "evalNum", + "level": "SESSION", + "description": "d", + "config": { + "llmAsAJudge": { + "modelProvider": "Bedrock", + "model": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "instructions": "Rate {context}", + "ratingScale": { + "numerical": [ + { + "value": 1, + "label": "bad", + "definition": "b" + }, + { + "value": 5, + "label": "good", + "definition": "g" + } + ] + } + } + }, + "kmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", + "tags": { + "e": "1" + } + }, + { + "name": "evalCat", + "level": "TRACE", + "config": { + "llmAsAJudge": { + "modelProvider": "OpenResponses", + "model": "gpt-4o", + "instructions": "Rate {context}", + "ratingScale": { + "categorical": [ + { + "label": "yes", + "definition": "y" + }, + { + "label": "no", + "definition": "n" + } + ] + } + } + } + }, + { + "name": "evalCode", + "level": "TOOL_CALL", + "config": { + "codeBased": { + "managed": { + "codeLocation": "evals/code", + "entrypoint": "lambda_function.handler", + "timeoutSeconds": 60 + } + } + } + }, + { + "name": "evalExt", + "level": "SESSION", + "config": { + "codeBased": { + "external": { + "lambdaArn": "arn:aws:lambda:us-east-1:123456789012:function:f" + } + } + } + } + ], + "onlineEvalConfigs": [ + { + "name": "oe1", + "agent": "agentA", + "endpoint": "prod", + "evaluators": [ + "evalNum", + "Builtin.Helpfulness", + "arn:aws:x" + ], + "samplingRate": 10, + "description": "d", + "enableOnCreate": true, + "tags": { + "o": "1" + } + } + ], + "agentCoreGateways": [ + { + "name": "gw1", + "resourceName": "gw1-res", + "protocolType": "MCP", + "description": "g", + "targets": [ + { + "name": "lambdaPy", + "targetType": "lambda", + "toolDefinitions": [ + { + "name": "t1", + "description": "d", + "inputSchema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + }, + "required": [ + "a" + ] + }, + "outputSchema": { + "type": "string" + } + } + ], + "compute": { + "host": "Lambda", + "implementation": { + "language": "Python", + "path": "tools/t1", + "handler": "handler.main" + }, + "pythonVersion": "PYTHON_3_12", + "timeout": 30, + "memorySize": 512, + "iamPolicy": { + "Version": "2012-10-17", + "Statement": [], + "Id": "x" + } + }, + "outboundAuth": { + "type": "OAUTH", + "credentialName": "my-oauth", + "scopes": [ + "read" + ] + } + }, + { + "name": "lambdaTs", + "targetType": "lambda", + "toolDefinitions": [ + { + "name": "t2", + "description": "d", + "inputSchema": { + "type": "object" + } + } + ], + "compute": { + "host": "Lambda", + "implementation": { + "language": "TypeScript", + "path": "tools/t2", + "handler": "index.handler" + }, + "nodeVersion": "NODE_20" + } + }, + { + "name": "mcpExt", + "targetType": "mcpServer", + "endpoint": "https://mcp.example.com/mcp", + "outboundAuth": { + "type": "NONE" + } + }, + { + "name": "openapi", + "targetType": "openApiSchema", + "outboundAuth": { + "type": "API_KEY", + "credentialName": "openaiKey" + }, + "schemaSource": { + "inline": { + "path": "schemas/api.json" + } + } + }, + { + "name": "smithy", + "targetType": "smithyModel", + "schemaSource": { + "s3": { + "uri": "s3://b/model.json", + "bucketOwnerAccountId": "123456789012" + } + } + }, + { + "name": "apigw", + "targetType": "apiGateway", + "outboundAuth": { + "type": "API_KEY", + "credentialName": "openaiKey" + }, + "apiGateway": { + "restApiId": "abc123", + "stage": "prod", + "apiGatewayToolConfiguration": { + "toolFilters": [ + { + "filterPath": "/pets", + "methods": [ + "GET" + ] + } + ], + "toolOverrides": [ + { + "name": "listPets", + "path": "/pets", + "method": "GET", + "description": "d" + } + ] + } + } + }, + { + "name": "lfa", + "targetType": "lambdaFunctionArn", + "lambdaFunctionArn": { + "lambdaArn": "arn:aws:lambda:us-east-1:123456789012:function:f", + "toolSchemaFile": "tools/schema.json" + } + }, + { + "name": "kbconn", + "targetType": "connector", + "connectorId": "bedrock-knowledge-bases", + "configurations": [ + { + "name": "Retrieve", + "description": "d", + "parameterValues": { + "knowledgeBaseId": "kb-docs" + }, + "parameterOverrides": [ + { + "path": "/x", + "description": "d", + "visible": false + } + ] + }, + { + "name": "AgenticRetrieveStream", + "parameterValues": { + "retrievers": [ + { + "configuration": { + "knowledgeBase": { + "knowledgeBaseId": "ABCDEFGHIJ" + } + } + } + ] + } + } + ] + }, + { + "name": "websearch", + "targetType": "connector", + "connectorId": "web-search" + } + ], + "authorizerType": "CUSTOM_JWT", + "authorizerConfiguration": { + "customJwtAuthorizer": { + "discoveryUrl": "https://idp.example.com/.well-known/openid-configuration", + "allowedClients": [ + "c" + ] + } + }, + "enableSemanticSearch": false, + "exceptionLevel": "DEBUG", + "policyEngineConfiguration": { + "policyEngineName": "pe1", + "mode": "ENFORCE" + }, + "executionRoleArn": "arn:aws:iam::123456789012:role/gw", + "tags": { + "g": "1" + } + }, + { + "name": "gw2", + "protocolType": "None", + "targets": [ + { + "name": "httprt", + "targetType": "httpRuntime", + "outboundAuth": { + "type": "OAUTH", + "credentialName": "my-oauth" + }, + "httpRuntime": { + "runtime": "agentA", + "runtimeEndpoint": "prod" + } + }, + { + "name": "pt-iam", + "targetType": "passthrough", + "outboundAuth": { + "type": "GATEWAY_IAM_ROLE", + "service": "bedrock", + "region": "us-east-1" + }, + "passthrough": { + "endpoint": "https://api.example.com:443/v1", + "protocolType": "INFERENCE", + "stickinessConfiguration": { + "identifier": "x-session", + "timeout": 3600 + } + } + }, + { + "name": "pt-jwt", + "targetType": "passthrough", + "outboundAuth": { + "type": "JWT_PASSTHROUGH" + }, + "passthrough": { + "endpoint": "https://api.example.com", + "protocolType": "CUSTOM" + } + } + ], + "authorizerType": "AWS_IAM", + "enableSemanticSearch": true, + "exceptionLevel": "NONE" + } + ], + "toolRuntimes": [ + { + "name": "rtTool", + "toolDefinition": { + "name": "rt-tool", + "description": "d", + "inputSchema": { + "type": "object" + } + }, + "compute": { + "host": "AgentCoreRuntime", + "implementation": { + "language": "Python", + "path": "tools/rt", + "handler": "server.py:app" + }, + "runtime": { + "build": "CodeZip", + "pythonVersion": "PYTHON_3_12", + "name": "rtToolRuntime", + "entrypoint": "server.py:app", + "codeLocation": "tools/rt", + "networkMode": "PUBLIC" + } + }, + "bindings": [ + { + "runtimeName": "agentA", + "envVarName": "RT_TOOL_URL" + } + ] + } + ], + "policyEngines": [ + { + "name": "pe1", + "description": "d", + "encryptionKeyArn": "arn:k", + "tags": { + "p": "1" + }, + "policies": [ + { + "name": "pol1", + "description": "d", + "statement": "permit(principal, action, resource);", + "sourceFile": "policies/p.cedar", + "validationMode": "IGNORE_ALL_FINDINGS", + "enforcementMode": "LOG_ONLY", + "authorizationPhase": "RETURN_OUTPUT" + }, + { + "name": "pol2", + "statement": "forbid(principal, action, resource);", + "validationMode": "FAIL_ON_ANY_FINDINGS", + "enforcementMode": "ACTIVE", + "authorizationPhase": "INITIATE" + } + ] + } + ], + "configBundles": [ + { + "name": "cb1", + "type": "ConfigurationBundle", + "description": "d", + "components": { + "{{runtime:agentA}}": { + "configuration": { + "systemPrompt": "hi" + } + } + }, + "branchName": "main", + "commitMessage": "init", + "kmsKeyArn": "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + } + ], + "harnesses": [ + { + "name": "h1", + "path": "app/h1" + } + ], + "payments": [ + { + "name": "pm1", + "authorizerType": "AWS_IAM", + "connectors": [ + { + "name": "cb_manual", + "provider": "CoinbaseCDP", + "provisionMode": "MANUAL", + "credentialName": "coinbaseCred" + }, + { + "name": "cb_quick", + "provider": "CoinbaseCDP", + "provisionMode": "QUICK_CREATE" + }, + { + "name": "stripe1", + "provider": "StripePrivy", + "credentialName": "stripeCred" + } + ], + "description": "Payment manager one", + "autoPayment": false, + "defaultSpendLimit": "25.00", + "paymentToolAllowlist": [ + "pay" + ], + "networkPreferences": [ + "base" + ] + }, + { + "name": "pm2", + "authorizerType": "CUSTOM_JWT", + "authorizerConfiguration": { + "customJWTAuthorizer": { + "discoveryUrl": "https://idp.example.com/.well-known/openid-configuration", + "allowedClients": [ + "c" + ], + "allowedAudience": [ + "a" + ], + "allowedScopes": [ + "s" + ] + } + }, + "connectors": [], + "autoPayment": true, + "defaultSpendLimit": "10.00" + } + ] +} diff --git a/src/core/project/migrate/__fixtures__/physical-names-contract.golden.json b/src/core/project/migrate/__fixtures__/physical-names-contract.golden.json new file mode 100644 index 000000000..798a3d7fd --- /dev/null +++ b/src/core/project/migrate/__fixtures__/physical-names-contract.golden.json @@ -0,0 +1,28 @@ +{ + "project": "maxproj", + "migratedFrom": "v1", + "names": { + "config-bundle cb1": "maxprojcb1", + "evaluator evalCat": "maxproj_evalCat", + "evaluator evalCode": "maxproj_evalCode", + "evaluator evalExt": "maxproj_evalExt", + "evaluator evalNum": "maxproj_evalNum", + "evaluator-function evalCode": "maxproj-eval-evalCode", + "gateway gw2": "maxproj-gw2", + "gateway-target-function gw1/lambdaPy": "maxproj-lambdaPy", + "gateway-target-function gw1/lambdaTs": "maxproj-lambdaTs", + "harness h1": "maxproj_h1", + "harness-repository h1": "maxproj/harness-h1", + "harness-role h1": "maxproj_h1", + "memory mem1": "maxproj_mem1", + "memory mem2": "maxproj_mem2", + "online-eval oe1": "maxproj_oe1", + "policy-engine pe1": "maxproj_pe1", + "runtime agentA": "maxproj_agentA", + "runtime agentB": "maxproj_agentB", + "runtime agentC": "maxproj_agentC", + "runtime agentD": "maxproj_agentD", + "runtime-repository agentB": "maxproj/agentb", + "tool-runtime rtTool": "maxproj_rtToolRuntime" + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/codezip.bridge.json b/src/core/project/migrate/__fixtures__/templates/codezip.bridge.json new file mode 100644 index 000000000..4e43cf9a0 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/codezip.bridge.json @@ -0,0 +1,1414 @@ +{ + "Description": "AgentCore stack for CompatRef deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryAgent1MemoryServiceRole069B32EF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Memory execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/ExecutionRole/Resource" + } + }, + "ApplicationMemoryAgent1MemoryMemory16C002F9": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryServiceRole069B32EF", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "agent1Memory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "agent1Memory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "agent1Memory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "agent1Memory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "CompatRef_agent1Memory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/Resource" + } + }, + "ApplicationEvaluatorRefjudgeB84BC6F1": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate the session. Context: {context}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "Fails to meet expectations", + "Label": "Poor", + "Value": 1 + }, + { + "Definition": "Partially meets expectations", + "Label": "Fair", + "Value": 2 + }, + { + "Definition": "Meets expectations", + "Label": "Good", + "Value": 3 + }, + { + "Definition": "Exceeds expectations", + "Label": "Very Good", + "Value": 4 + }, + { + "Definition": "Far exceeds expectations", + "Label": "Excellent", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "CompatRef_refjudge", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefjudge/Resource" + } + }, + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for code-based evaluator: refcode", + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/LambdaRole/Resource" + } + }, + "ApplicationEvaluatorRefcodeCustomPolicy07D71B240": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationEvaluatorRefcodeCustomPolicy07D71B240", + "Roles": [ + { + "Ref": "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/CustomPolicy0/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionAC701828": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "a73bbed718a1825e1454726fea814d69ae91ea9fcfd2d35f2b3c7b65f67bf8a7.zip" + }, + "FunctionName": "CompatRef-eval-refcode", + "Handler": "lambda_function.handler", + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ], + "Timeout": 60 + }, + "DependsOn": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ApplicationEvaluatorRefcodeFunctionAC701828" + } + ] + ] + }, + "RetentionInDays": 731, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/LogGroup/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionBedrockAgentCoreEvaluatorInvokeE8AA0D2E": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "Principal": "bedrock-agentcore.amazonaws.com" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/AllowBedrockAgentCoreInvoke" + } + }, + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreGetFunction83BAE833": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:GetFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "Principal": "bedrock-agentcore.amazonaws.com" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/AllowBedrockAgentCoreGetFunction" + } + }, + "ApplicationEvaluatorRefcodeD65DBAE2": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorName": "CompatRef_refcode", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ], + "EvaluatorConfig": { + "CodeBased": { + "LambdaConfig": { + "LambdaArn": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "LambdaTimeoutInSeconds": 60 + } + } + } + }, + "DependsOn": [ + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreGetFunction83BAE833", + "ApplicationEvaluatorRefcodeFunctionBedrockAgentCoreEvaluatorInvokeE8AA0D2E", + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F", + "ApplicationEvaluatorRefcodeFunctionAC701828" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Resource" + } + }, + "ApplicationPolicyEngineRefpe843FA582": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "CompatRef_refpe", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Resource" + } + }, + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource is AgentCore::Gateway);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "refpol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineId" + ] + }, + "ValidationMode": "IGNORE_ALL_FINDINGS" + }, + "DependsOn": [ + "ApplicationPolicyEngineRefpe843FA582" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Policyrefpol" + } + }, + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Runtime execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/ExecutionRole/Resource" + } + }, + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:DescribeLogGroups", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2", + "Roles": [ + { + "Ref": "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationRuntimeAgent1Runtime0F7E9C1B": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "CodeConfiguration": { + "Code": { + "S3": { + "Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "Prefix": "1594ba06e446c2df41b36b5fd5c2fda84138fad835ceda03bce4eae64b71d3a1.zip" + } + }, + "EntryPoint": [ + "opentelemetry-instrument", + "main.py" + ], + "Runtime": "PYTHON_3_14" + } + }, + "AgentRuntimeName": "CompatRef_agent1", + "Description": "AgentCore Runtime: CompatRef_agent1", + "EnvironmentVariables": { + "MEMORY_AGENT1MEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "AGENTCORE_GATEWAY_REFGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_REFGW_AUTH_TYPE": "NONE" + }, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "DependsOn": [ + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/Resource" + } + }, + "ApplicationConfigBundleRefcbComponents4EDA5741": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Components/Resource/Default" + } + }, + "ApplicationConfigBundleRefcb9FCFB8E4": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "CompatRefrefcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcbComponents4EDA5741", + "Value" + ] + }, + "BranchName": "mainline", + "CreatedBy": { + "Name": "user" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRole1AA01064": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Online evaluation execution role for refoec", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/ExecutionRole/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary", + "logs:DescribeLogGroups" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:GetQueryResults", + "logs:StartQuery" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans*", + { + "Fn::Join": [ + "", + [ + "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "*" + ] + ] + } + ] + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/results/CompatRef_refoec*" + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:*:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:us-east-1:123456789012:inference-profile/*", + "arn:aws:bedrock:us-east-1::foundation-model/*" + ] + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:*" + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/CompatRef_refoec-*" + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy5359FB71", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalRefoecExecutionRole1AA01064" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationOnlineEvalRefoec9D19D97C": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "CompatRef_agent1.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoecExecutionRole1AA01064", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "CompatRef_refoec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "DependsOn": [ + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C", + "ApplicationOnlineEvalRefoecExecutionRole1AA01064" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/Resource" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Role" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Handler", + "aws:asset:path": "asset.3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a", + "aws:asset:property": "Code" + } + }, + "ApplicationGatewayRefgwServiceRole669D3EE1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Gateway role for: refgw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Role/Resource" + } + }, + "McpGatewayRefgwRoleDefaultPolicy0A9745F9": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:CheckAuthorizePermissions", + "bedrock-agentcore:GetPolicyEngine", + "bedrock-agentcore:PartiallyAuthorizeActions" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineArn" + ] + }, + { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayArn" + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "McpGatewayRefgwRoleDefaultPolicy0A9745F9", + "Roles": [ + { + "Ref": "ApplicationGatewayRefgwServiceRole669D3EE1" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Role/DefaultPolicy/Resource" + } + }, + "ApplicationGatewayRefgw1FB17FC1": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for refgw", + "Name": "CompatRef-refgw", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgwServiceRole669D3EE1", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Resource" + } + }, + "ApplicationGatewayRefgwTargetRemoteC0E25011": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/TargetRemote" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+1WS28aMRD+LfERGbddpB64AUqjVn1EgdsKRcYeFoddD/IDRK3975WNF0JzaaJITVVOO/vNfPP4dmxtwYqPH9j7K76zfSHX/VotWJg6LtaU7+x9ULxh4Q5rKAPh1voG5HhPhoFsjNJCbXg9EgK9dmRIeoTmmEgYCadQJ7ilRIIVRm0eQa+bj5KGa16BvMVaCQWWDMtwhu1HJrPnqbhzXKw+61ppOPjJsCQ98gfO15ZByhkeq4TcXgK75E+8L2/+BcUulOdSLhtyobwFyj+7h3M6WerYFD3gZSAShW8g931izzCGPUn6GxxHfR4jDTZ13EEsas/mfcvw/zPp39ElbeZhKVu6AGlQrHkF2gk0wMJkqb9Bg2Yfw663vPbcoTlxrnWlNJze05577VSTwB/pLGSeQj1BvVRV9NxwBzu+f2TOuKnAtdQO7rm14CwbxQe1Azb2Yg1uzC2U6ZD3yJzWvFlIzsInr9NBLwNZZvM7byBfB+bQS3c5GLFSDoTz5hiQJErmimtZg8lvAmXniBnQdzdMk/SYqp+Qf7/iDF0XSQkwjbJWoW5pjZVl4StWNwb9pgykzuaxxwO/i2hbmqaeOl4pnZS6A4veiIOe3m28i9YXG4t567DpAk7anOO3BrdKgon6tVSjBPZg322LghUDVlw9WKX6WSeWv90v9DTPPEcLAAA=" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationAgentAgent1RuntimeIdOutput3F4BB331": { + "Description": "Runtime ID for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeId" + } + }, + "ApplicationAgentAgent1RuntimeArnOutputCFCB3B5B": { + "Description": "Runtime ARN for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeArn" + } + }, + "ApplicationAgentAgent1RoleArnOutput55C292CE": { + "Description": "Execution Role ARN for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RoleArn" + } + }, + "ApplicationMemoryAgent1MemoryIdOutput9E3F487A": { + "Description": "Memory ID for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Id" + } + }, + "ApplicationMemoryAgent1MemoryArnOutputF4907ADC": { + "Description": "Memory ARN for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "agent1Memory", + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryNames" + } + }, + "ApplicationEvaluatorRefjudgeIdOutput267F4A20": { + "Description": "Evaluator ID for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Id" + } + }, + "ApplicationEvaluatorRefjudgeArnOutputB07F6169": { + "Description": "Evaluator ARN for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Arn" + } + }, + "ApplicationEvaluatorRefcodeIdOutput5B188AAE": { + "Description": "Evaluator ID for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeD65DBAE2", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Id" + } + }, + "ApplicationEvaluatorRefcodeArnOutput4333BD8F": { + "Description": "Evaluator ARN for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeD65DBAE2", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Arn" + } + }, + "ApplicationOnlineEvalRefoecIdOutput25E346E7": { + "Description": "Online evaluation config ID for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoec9D19D97C", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Id" + } + }, + "ApplicationOnlineEvalRefoecArnOutput3A042A59": { + "Description": "Online evaluation config ARN for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoec9D19D97C", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Arn" + } + }, + "ApplicationPolicyEngineRefpeIdOutput39686D9A": { + "Description": "Policy Engine ID for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Id" + } + }, + "ApplicationPolicyEngineRefpeArnOutputE984417A": { + "Description": "Policy Engine ARN for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Arn" + } + }, + "ApplicationPolicyRefpeRefpolIdOutputEA2A24E1": { + "Description": "Policy ID for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Id" + } + }, + "ApplicationPolicyRefpeRefpolArnOutputC01C4870": { + "Description": "Policy ARN for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Arn" + } + }, + "ApplicationConfigBundleRefcbIdOutputEDC96682": { + "Description": "ConfigBundle ID for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Id" + } + }, + "ApplicationConfigBundleRefcbArnOutputF7EBB9BE": { + "Description": "ConfigBundle ARN for: refcb", + "Value": { + "Ref": "ApplicationConfigBundleRefcb9FCFB8E4" + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Arn" + } + }, + "ApplicationConfigBundleRefcbVersionIdOutput8E4C36AB": { + "Description": "ConfigBundle VersionId for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-VersionId" + } + }, + "GatewayRefgwIdOutput": { + "Description": "Gateway ID for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Id" + } + }, + "GatewayRefgwArnOutput": { + "Description": "Gateway ARN for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Arn" + } + }, + "GatewayRefgwUrlOutput": { + "Description": "Gateway URL for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Url" + } + }, + "GatewayTargetRemoteIdOutput": { + "Description": "Gateway Target ID for: remote (gateway: refgw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgwTargetRemoteC0E25011", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-GatewayTarget-remote-Id" + } + }, + "StackNameOutput": { + "Description": "Name of the CloudFormation Stack", + "Value": "AgentCore-CompatRef-default" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/codezip.mapping.json b/src/core/project/migrate/__fixtures__/templates/codezip.mapping.json new file mode 100644 index 000000000..36b85e7f0 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/codezip.mapping.json @@ -0,0 +1,27 @@ +{ + "ApplicationMemoryAgent1Memory7D7BD0A9": "ApplicationMemoryAgent1MemoryMemory16C002F9", + "ApplicationEvaluatorRefjudge38E5DE85": "ApplicationEvaluatorRefjudgeB84BC6F1", + "ApplicationEvaluatorRefcodeFunctionAC701828": "ApplicationEvaluatorRefcodeFunctionAC701828", + "ApplicationEvaluatorRefcodeFF098632": "ApplicationEvaluatorRefcodeD65DBAE2", + "ApplicationPolicyEngineRefpeBABB1DFE": "ApplicationPolicyEngineRefpe843FA582", + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474": "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "ApplicationAgentAgent1Runtime2012DAFC": "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "ApplicationConfigBundleRefcb9FCFB8E4": "ApplicationConfigBundleRefcb9FCFB8E4", + "ApplicationOnlineEvalRefoecB387E607": "ApplicationOnlineEvalRefoec9D19D97C", + "McpGatewayRefgwD3301142": "ApplicationGatewayRefgw1FB17FC1", + "McpGatewayRefgwTargetRemote784B41D0": "ApplicationGatewayRefgwTargetRemoteC0E25011", + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8": "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8", + "ApplicationEvaluatorRefcodeCustomPolicy07D71B240": "ApplicationEvaluatorRefcodeCustomPolicy07D71B240", + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F": "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F", + "ApplicationConfigBundleRefcbComponents4EDA5741": "ApplicationConfigBundleRefcbComponents4EDA5741", + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "CDKMetadata": "CDKMetadata", + "ApplicationMemoryAgent1MemoryExecutionRole12A34BCA": "ApplicationMemoryAgent1MemoryServiceRole069B32EF", + "ApplicationOnlineEvalRefoecExecutionRole9ADCEAD5": "ApplicationOnlineEvalRefoecExecutionRole1AA01064", + "McpGatewayRefgwRoleF885BDCA": "ApplicationGatewayRefgwServiceRole669D3EE1", + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy5359FB71": "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C", + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0": "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2": "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreInvokeC49B5081": "ApplicationEvaluatorRefcodeFunctionBedrockAgentCoreEvaluatorInvokeE8AA0D2E" +} diff --git a/src/core/project/migrate/__fixtures__/templates/codezip.v0.json b/src/core/project/migrate/__fixtures__/templates/codezip.v0.json new file mode 100644 index 000000000..d92d345aa --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/codezip.v0.json @@ -0,0 +1,1414 @@ +{ + "Description": "AgentCore stack for CompatRef deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryAgent1MemoryExecutionRole12A34BCA": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Memory execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/ExecutionRole/Resource" + } + }, + "ApplicationMemoryAgent1Memory7D7BD0A9": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryExecutionRole12A34BCA", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "agent1Memory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "agent1Memory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "agent1Memory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "agent1Memory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "CompatRef_agent1Memory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/Resource" + } + }, + "ApplicationEvaluatorRefjudge38E5DE85": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate the session. Context: {context}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "Fails to meet expectations", + "Label": "Poor", + "Value": 1 + }, + { + "Definition": "Partially meets expectations", + "Label": "Fair", + "Value": 2 + }, + { + "Definition": "Meets expectations", + "Label": "Good", + "Value": 3 + }, + { + "Definition": "Exceeds expectations", + "Label": "Very Good", + "Value": 4 + }, + { + "Definition": "Far exceeds expectations", + "Label": "Excellent", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "CompatRef_refjudge", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefjudge/Resource" + } + }, + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for code-based evaluator: refcode", + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/LambdaRole/Resource" + } + }, + "ApplicationEvaluatorRefcodeCustomPolicy07D71B240": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationEvaluatorRefcodeCustomPolicy07D71B240", + "Roles": [ + { + "Ref": "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/CustomPolicy0/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionAC701828": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "a73bbed718a1825e1454726fea814d69ae91ea9fcfd2d35f2b3c7b65f67bf8a7.zip" + }, + "FunctionName": "CompatRef-eval-refcode", + "Handler": "lambda_function.handler", + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ], + "Timeout": 60 + }, + "DependsOn": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ApplicationEvaluatorRefcodeFunctionAC701828" + } + ] + ] + }, + "RetentionInDays": 731, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/LogGroup/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreInvokeC49B5081": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "Principal": "bedrock-agentcore.amazonaws.com" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/AllowBedrockAgentCoreInvoke" + } + }, + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreGetFunction83BAE833": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:GetFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "Principal": "bedrock-agentcore.amazonaws.com" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/AllowBedrockAgentCoreGetFunction" + } + }, + "ApplicationEvaluatorRefcodeFF098632": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorName": "CompatRef_refcode", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ], + "EvaluatorConfig": { + "CodeBased": { + "LambdaConfig": { + "LambdaArn": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "LambdaTimeoutInSeconds": 60 + } + } + } + }, + "DependsOn": [ + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreGetFunction83BAE833", + "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreInvokeC49B5081", + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F", + "ApplicationEvaluatorRefcodeFunctionAC701828" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Resource" + } + }, + "ApplicationPolicyEngineRefpeBABB1DFE": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "CompatRef_refpe", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Resource" + } + }, + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource is AgentCore::Gateway);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "refpol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpeBABB1DFE", + "PolicyEngineId" + ] + }, + "ValidationMode": "IGNORE_ALL_FINDINGS" + }, + "DependsOn": [ + "ApplicationPolicyEngineRefpeBABB1DFE" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Policyrefpol" + } + }, + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Runtime execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/ExecutionRole/Resource" + } + }, + "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:DescribeLogGroups", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2", + "Roles": [ + { + "Ref": "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationAgentAgent1Runtime2012DAFC": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "CodeConfiguration": { + "Code": { + "S3": { + "Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "Prefix": "1594ba06e446c2df41b36b5fd5c2fda84138fad835ceda03bce4eae64b71d3a1.zip" + } + }, + "EntryPoint": [ + "opentelemetry-instrument", + "main.py" + ], + "Runtime": "PYTHON_3_14" + } + }, + "AgentRuntimeName": "CompatRef_agent1", + "Description": "AgentCore Runtime: CompatRef_agent1", + "EnvironmentVariables": { + "MEMORY_AGENT1MEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryId" + ] + }, + "AGENTCORE_GATEWAY_REFGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_REFGW_AUTH_TYPE": "NONE" + }, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "DependsOn": [ + "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2", + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/AgentAgent1/Runtime/Resource" + } + }, + "ApplicationConfigBundleRefcbComponents4EDA5741": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationAgentAgent1Runtime2012DAFC", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Components/Resource/Default" + } + }, + "ApplicationConfigBundleRefcb9FCFB8E4": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "CompatRefrefcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcbComponents4EDA5741", + "Value" + ] + }, + "BranchName": "mainline", + "CreatedBy": { + "Name": "user" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRole9ADCEAD5": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Online evaluation execution role for refoec", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/ExecutionRole/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy5359FB71": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary", + "logs:DescribeLogGroups" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:GetQueryResults", + "logs:StartQuery" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans*", + { + "Fn::Join": [ + "", + [ + "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationAgentAgent1Runtime2012DAFC", + "AgentRuntimeId" + ] + }, + "*" + ] + ] + } + ] + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/results/CompatRef_refoec*" + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:*:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*", + "arn:aws:bedrock:us-east-1:123456789012:inference-profile/*", + "arn:aws:bedrock:us-east-1::foundation-model/*" + ] + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:*" + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/CompatRef_refoec-*" + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy5359FB71", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalRefoecExecutionRole9ADCEAD5" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationOnlineEvalRefoecB387E607": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationAgentAgent1Runtime2012DAFC", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "CompatRef_agent1.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoecExecutionRole9ADCEAD5", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudge38E5DE85", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "CompatRef_refoec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "DependsOn": [ + "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2", + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0", + "ApplicationAgentAgent1Runtime2012DAFC", + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy5359FB71", + "ApplicationOnlineEvalRefoecExecutionRole9ADCEAD5" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/Resource" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Role" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Handler", + "aws:asset:path": "asset.3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a", + "aws:asset:property": "Code" + } + }, + "McpGatewayRefgwRoleF885BDCA": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Gateway role for: refgw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Role/Resource" + } + }, + "McpGatewayRefgwRoleDefaultPolicy0A9745F9": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:CheckAuthorizePermissions", + "bedrock-agentcore:GetPolicyEngine", + "bedrock-agentcore:PartiallyAuthorizeActions" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpeBABB1DFE", + "PolicyEngineArn" + ] + }, + { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayArn" + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "McpGatewayRefgwRoleDefaultPolicy0A9745F9", + "Roles": [ + { + "Ref": "McpGatewayRefgwRoleF885BDCA" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Role/DefaultPolicy/Resource" + } + }, + "McpGatewayRefgwD3301142": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for refgw", + "Name": "CompatRef-refgw", + "RoleArn": { + "Fn::GetAtt": [ + "McpGatewayRefgwRoleF885BDCA", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/Resource" + } + }, + "McpGatewayRefgwTargetRemote784B41D0": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Mcp/GatewayRefgw/TargetRemote" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+1WS28aMRD+LfERGbddpB64AUqjVn1EgdsKRcYeFoddD/IDRK3975WNF0JzaaJITVVOO/vNfPP4dmxtwYqPH9j7K76zfSHX/VotWJg6LtaU7+x9ULxh4Q5rKAPh1voG5HhPhoFsjNJCbXg9EgK9dmRIeoTmmEgYCadQJ7ilRIIVRm0eQa+bj5KGa16BvMVaCQWWDMtwhu1HJrPnqbhzXKw+61ppOPjJsCQ98gfO15ZByhkeq4TcXgK75E+8L2/+BcUulOdSLhtyobwFyj+7h3M6WerYFD3gZSAShW8g931izzCGPUn6GxxHfR4jDTZ13EEsas/mfcvw/zPp39ElbeZhKVu6AGlQrHkF2gk0wMJkqb9Bg2Yfw663vPbcoTlxrnWlNJze05577VSTwB/pLGSeQj1BvVRV9NxwBzu+f2TOuKnAtdQO7rm14CwbxQe1Azb2Yg1uzC2U6ZD3yJzWvFlIzsInr9NBLwNZZvM7byBfB+bQS3c5GLFSDoTz5hiQJErmimtZg8lvAmXniBnQdzdMk/SYqp+Qf7/iDF0XSQkwjbJWoW5pjZVl4StWNwb9pgykzuaxxwO/i2hbmqaeOl4pnZS6A4veiIOe3m28i9YXG4t567DpAk7anOO3BrdKgon6tVSjBPZg322LghUDVlw9WKX6WSeWv90v9DTPPEcLAAA=" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationAgentAgent1RuntimeIdOutput3F4BB331": { + "Description": "Runtime ID for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentAgent1Runtime2012DAFC", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeId" + } + }, + "ApplicationAgentAgent1RuntimeArnOutputCFCB3B5B": { + "Description": "Runtime ARN for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentAgent1Runtime2012DAFC", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeArn" + } + }, + "ApplicationAgentAgent1RoleArnOutput55C292CE": { + "Description": "Execution Role ARN for agent: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentAgent1RuntimeExecutionRoleDAB4C4A0", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RoleArn" + } + }, + "ApplicationMemoryAgent1MemoryIdOutput9E3F487A": { + "Description": "Memory ID for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Id" + } + }, + "ApplicationMemoryAgent1MemoryArnOutputF4907ADC": { + "Description": "Memory ARN for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1Memory7D7BD0A9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "agent1Memory", + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryNames" + } + }, + "ApplicationEvaluatorRefjudgeIdOutput267F4A20": { + "Description": "Evaluator ID for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudge38E5DE85", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Id" + } + }, + "ApplicationEvaluatorRefjudgeArnOutputB07F6169": { + "Description": "Evaluator ARN for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudge38E5DE85", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Arn" + } + }, + "ApplicationEvaluatorRefcodeIdOutput5B188AAE": { + "Description": "Evaluator ID for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFF098632", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Id" + } + }, + "ApplicationEvaluatorRefcodeArnOutput4333BD8F": { + "Description": "Evaluator ARN for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFF098632", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Arn" + } + }, + "ApplicationOnlineEvalRefoecIdOutput25E346E7": { + "Description": "Online evaluation config ID for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoecB387E607", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Id" + } + }, + "ApplicationOnlineEvalRefoecArnOutput3A042A59": { + "Description": "Online evaluation config ARN for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoecB387E607", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Arn" + } + }, + "ApplicationPolicyEngineRefpeIdOutput39686D9A": { + "Description": "Policy Engine ID for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpeBABB1DFE", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Id" + } + }, + "ApplicationPolicyEngineRefpeArnOutputE984417A": { + "Description": "Policy Engine ARN for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpeBABB1DFE", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Arn" + } + }, + "ApplicationPolicyRefpeRefpolIdOutputEA2A24E1": { + "Description": "Policy ID for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Id" + } + }, + "ApplicationPolicyRefpeRefpolArnOutputC01C4870": { + "Description": "Policy ARN for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Arn" + } + }, + "ApplicationConfigBundleRefcbIdOutputEDC96682": { + "Description": "ConfigBundle ID for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Id" + } + }, + "ApplicationConfigBundleRefcbArnOutputF7EBB9BE": { + "Description": "ConfigBundle ARN for: refcb", + "Value": { + "Ref": "ApplicationConfigBundleRefcb9FCFB8E4" + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Arn" + } + }, + "ApplicationConfigBundleRefcbVersionIdOutput8E4C36AB": { + "Description": "ConfigBundle VersionId for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-VersionId" + } + }, + "GatewayRefgwIdOutput": { + "Description": "Gateway ID for: refgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Id" + } + }, + "GatewayRefgwArnOutput": { + "Description": "Gateway ARN for: refgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Arn" + } + }, + "GatewayRefgwUrlOutput": { + "Description": "Gateway URL for: refgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayRefgwD3301142", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Url" + } + }, + "GatewayTargetRemoteIdOutput": { + "Description": "Gateway Target ID for: remote (gateway: refgw)", + "Value": { + "Fn::GetAtt": [ + "McpGatewayRefgwTargetRemote784B41D0", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-GatewayTarget-remote-Id" + } + }, + "StackNameOutput": { + "Description": "Name of the CloudFormation Stack", + "Value": "AgentCore-CompatRef-default" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/codezip.v1-rc3.json b/src/core/project/migrate/__fixtures__/templates/codezip.v1-rc3.json new file mode 100644 index 000000000..3cb810176 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/codezip.v1-rc3.json @@ -0,0 +1,1501 @@ +{ + "Description": "AgentCore stack for CompatRef deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryAgent1MemoryServiceRole069B32EF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/CompatRef_agent1Memory*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/Resource/ServiceRole/Resource" + } + }, + "ApplicationMemoryAgent1MemoryMemory16C002F9": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryServiceRole069B32EF", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "agent1Memory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "agent1Memory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "agent1Memory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "agent1Memory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "CompatRef_agent1Memory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/MemoryAgent1Memory/Resource/Memory" + } + }, + "ApplicationEvaluatorRefjudgeB84BC6F1": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate the session. Context: {context}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "Fails to meet expectations", + "Label": "Poor", + "Value": 1 + }, + { + "Definition": "Partially meets expectations", + "Label": "Fair", + "Value": 2 + }, + { + "Definition": "Meets expectations", + "Label": "Good", + "Value": 3 + }, + { + "Definition": "Exceeds expectations", + "Label": "Very Good", + "Value": 4 + }, + { + "Definition": "Far exceeds expectations", + "Label": "Excellent", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "CompatRef_refjudge", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefjudge/Resource/Resource" + } + }, + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for code-based evaluator: refcode", + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/LambdaRole/Resource" + } + }, + "ApplicationEvaluatorRefcodeCustomPolicy07D71B240": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:*:logs:*:*:log-group:/aws/lambda/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationEvaluatorRefcodeCustomPolicy07D71B240", + "Roles": [ + { + "Ref": "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/CustomPolicy0/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionAC701828": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Architectures": [ + "arm64" + ], + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "a73bbed718a1825e1454726fea814d69ae91ea9fcfd2d35f2b3c7b65f67bf8a7.zip" + }, + "FunctionName": "CompatRef-eval-refcode", + "Handler": "lambda_function.handler", + "MemorySize": 512, + "Role": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8", + "Arn" + ] + }, + "Runtime": "python3.12", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ], + "Timeout": 60 + }, + "DependsOn": [ + "ApplicationEvaluatorRefcodeLambdaRole91F4F9D8" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionLogGroup7073449F": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ApplicationEvaluatorRefcodeFunctionAC701828" + } + ] + ] + }, + "RetentionInDays": 731, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/LogGroup/Resource" + } + }, + "ApplicationEvaluatorRefcodeFunctionBedrockAgentCoreEvaluatorInvokeE8AA0D2E": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "Principal": "bedrock-agentcore.amazonaws.com", + "SourceAccount": "123456789012", + "SourceArn": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeD65DBAE2", + "EvaluatorArn" + ] + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Function/BedrockAgentCoreEvaluatorInvoke" + } + }, + "ApplicationEvaluatorRefcodeD65DBAE2": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "CodeBased": { + "LambdaConfig": { + "LambdaArn": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeFunctionAC701828", + "Arn" + ] + }, + "LambdaTimeoutInSeconds": 60 + } + } + }, + "EvaluatorName": "CompatRef_refcode", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/EvaluatorRefcode/Resource/Resource" + } + }, + "ApplicationPolicyEngineRefpe843FA582": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "CompatRef_refpe", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Resource/Resource" + } + }, + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource is AgentCore::Gateway);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "refpol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineId" + ] + }, + "ValidationMode": "IGNORE_ALL_FINDINGS" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/PolicyEngineRefpe/Policyrefpol" + } + }, + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/CompatRef_agent1*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock Agent Core Runtime", + "MaxSessionDuration": 28800, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/RuntimeAgent1/Runtime/Resource/ExecutionRole/Resource" + } + }, + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:DescribeLogStreams" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*", + "Sid": "LogGroupAccess" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:*", + "Sid": "DescribeLogGroups" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*", + "Sid": "LogStreamAccess" + }, + { + "Action": [ + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "XRayAccess" + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchMetrics" + }, + { + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default", + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default/workload-identity/*" + ], + "Sid": "GetAgentAccessToken" + }, + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + } + }, + { + "Action": [ + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1", + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "Roles": [ + { + "Ref": "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/RuntimeAgent1/Runtime/Resource/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationRuntimeAgent1Runtime0F7E9C1B": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "CodeConfiguration": { + "Code": { + "S3": { + "Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "Prefix": "5ec32564b5a3ad3cf2520cff699bd6e5e839c6e7261cafd0cbd00b73cae6f2d5.zip" + } + }, + "EntryPoint": [ + "opentelemetry-instrument", + "main.py" + ], + "Runtime": "PYTHON_3_14" + } + }, + "AgentRuntimeName": "CompatRef_agent1", + "Description": "AgentCore Runtime: CompatRef_agent1", + "EnvironmentVariables": { + "AGENTCORE_MEMORY_AGENT1MEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "AGENTCORE_MEMORY_AGENT1MEMORY_ARN": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + }, + "AGENTCORE_GATEWAY_REFGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_REFGW_ARN": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayArn" + ] + }, + "AGENTCORE_GATEWAY_REFGW_AUTH_TYPE": "NONE" + }, + "LifecycleConfiguration": {}, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "ProtocolConfiguration": "HTTP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "DependsOn": [ + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/RuntimeAgent1/Runtime/Resource/Resource" + } + }, + "ApplicationConfigBundleRefcbComponents4EDA5741": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Components/Resource/Default" + } + }, + "ApplicationConfigBundleRefcb9FCFB8E4": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BranchName": "mainline", + "BundleName": "CompatRef_refcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcbComponents4EDA5741", + "Value" + ] + }, + "CreatedBy": { + "Name": "user" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/ConfigBundleRefcb/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRole1AA01064": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012", + "aws:ResourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": [ + "arn:aws:bedrock-agentcore:us-east-1:123456789012:evaluator/*", + "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock AgentCore Online Evaluation", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/Resource/ExecutionRole/Resource" + } + }, + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchLogDescribeStatement" + }, + { + "Action": [ + "logs:GetQueryResults", + "logs:StartQuery" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans", + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans:*", + { + "Fn::Join": [ + "", + [ + "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "-DEFAULT:*" + ] + ] + } + ], + "Sid": "CloudWatchLogQueryStatement" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/*", + "Sid": "CloudWatchLogWriteStatement" + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans", + "arn:aws:logs:us-east-1:123456789012:log-group:aws/spans:*" + ], + "Sid": "CloudWatchIndexPolicyStatement" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + } + ], + "Sid": "BedrockInvokeStatement" + }, + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": "arn:aws:lambda:us-east-1:123456789012:function:*" + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/CompatRef_refoec-*" + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalRefoecExecutionRole1AA01064" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/Resource/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationOnlineEvalRefoec9D19D97C": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "CompatRef_agent1.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoecExecutionRole1AA01064", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "CompatRef_refoec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "DependsOn": [ + "ApplicationOnlineEvalRefoecExecutionRoleDefaultPolicy8C2E5D2C", + "ApplicationOnlineEvalRefoecExecutionRole1AA01064", + "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "ApplicationRuntimeAgent1Runtime0F7E9C1B" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/OnlineEvalRefoec/Resource/Resource" + } + }, + "ApplicationGatewayRefgwServiceRole669D3EE1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + }, + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/CompatRef-refgw*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Service role for Bedrock AgentCore Gateway CompatRef-refgw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatRef" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/GatewayRefgw/Resource/ServiceRole/Resource" + } + }, + "ApplicationGatewayRefgw1FB17FC1": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for refgw", + "Name": "CompatRef-refgw", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgwServiceRole669D3EE1", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatRef" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/GatewayRefgw/Resource/Resource" + } + }, + "ApplicationGatewayRefgwTargetRemoteC0E25011": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/Application/GatewayRefgw/Resource/TargetRemote/Resource" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Role" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Handler", + "aws:asset:path": "asset.3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a", + "aws:asset:property": "Code" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+VW24rbMBD9ltVjUdSSQKF5S3a3S0svIcmbWZaJNHG0saWgS9JU+N+LZTm3lpY22zawD8ajc2Y0NwlNl3Vfv2GvrmBjO1wsO4WcsTBxwJcUNvYhzFAYzZeQo3JcG2ThI5babDPygtCT755ez1VD09s1FB6cNtkhuUPpSBeSb29VLhVmLX0I7td07JWT5V4trWvxWqu5zL0BJ7UaeiUK3LsegkX6WRVSYcKkTha7vX5M0ztwuIHtUfAJa7kpmBxddsI2aEUllCyMdYFZIGCtL1EMt6QfyMpIxeUKigHn2itH+tFHo1MbDHgdR4Srip5jTYlAy41c7SBKSlCQo4iVlWhJPwtH2HZgkvV9dO4c8MW7WKSGJ/2Y8q/JJw77ywStlVrdpGYn3UBAiKketQ52cYSzE/j5vv+VvViTv8I+4Un60+TOPEUX269LafG/OwDNC6ILTE9QFojQ3JeYttp3dKprte8afQLX3n/PIqY7ceCwdmqPqvBc4edexYPxp6K29wDWorNsUP+o7bGh50t09UCzH0kKKGcCWHjrVTzyWSDzJH6CEtPFMM2w1F4TwxfSIXfe7BRiVaO4gHqAMmnFtWiJegft27tWxhFvIr/iwZVqo4iJoCllfK0rWujcsvBB53dG+1UWSJHEXYyNfatRVTRmPXGQS5XHGc27lXe19N7WDrx1uhyj1d5wPBrRWvBEZ2T0WgqMA2FFlRbIHu3LdbfLuj3WvXq0UnZSnVgaLr8BNaSa9RgLAAA=" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatRef-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationRuntimeAgent1RuntimeIdOutputE975D7E3": { + "Description": "Runtime ID for: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeId" + } + }, + "ApplicationRuntimeAgent1RuntimeArnOutput0D38885F": { + "Description": "Runtime ARN for: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1Runtime0F7E9C1B", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RuntimeArn" + } + }, + "ApplicationRuntimeAgent1RoleArnOutputB80B1317": { + "Description": "Execution Role ARN for: agent1", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeAgent1RuntimeExecutionRole37F71038", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-agent1-RoleArn" + } + }, + "ApplicationMemoryAgent1MemoryIdOutput9E3F487A": { + "Description": "Memory ID for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Id" + } + }, + "ApplicationMemoryAgent1MemoryArnOutputF4907ADC": { + "Description": "Memory ARN for: agent1Memory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Memory-agent1Memory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryAgent1MemoryMemory16C002F9", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "agent1Memory", + "Export": { + "Name": "AgentCore-CompatRef-default-MemoryNames" + } + }, + "ApplicationEvaluatorRefjudgeIdOutput267F4A20": { + "Description": "Evaluator ID for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Id" + } + }, + "ApplicationEvaluatorRefjudgeArnOutputB07F6169": { + "Description": "Evaluator ARN for: refjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefjudgeB84BC6F1", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refjudge-Arn" + } + }, + "ApplicationEvaluatorRefcodeIdOutput5B188AAE": { + "Description": "Evaluator ID for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeD65DBAE2", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Id" + } + }, + "ApplicationEvaluatorRefcodeArnOutput4333BD8F": { + "Description": "Evaluator ARN for: refcode", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorRefcodeD65DBAE2", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Evaluator-refcode-Arn" + } + }, + "ApplicationOnlineEvalRefoecIdOutput25E346E7": { + "Description": "Online evaluation config ID for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoec9D19D97C", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Id" + } + }, + "ApplicationOnlineEvalRefoecArnOutput3A042A59": { + "Description": "Online evaluation config ARN for: refoec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalRefoec9D19D97C", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-OnlineEval-refoec-Arn" + } + }, + "ApplicationPolicyEngineRefpeIdOutput39686D9A": { + "Description": "Policy Engine ID for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Id" + } + }, + "ApplicationPolicyEngineRefpeArnOutputE984417A": { + "Description": "Policy Engine ARN for: refpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpe843FA582", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-PolicyEngine-refpe-Arn" + } + }, + "ApplicationPolicyRefpeRefpolIdOutputEA2A24E1": { + "Description": "Policy ID for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Id" + } + }, + "ApplicationPolicyRefpeRefpolArnOutputC01C4870": { + "Description": "Policy ARN for: refpe/refpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineRefpePolicyrefpol6B84A474", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Policy-refpe-refpol-Arn" + } + }, + "ApplicationConfigBundleRefcbIdOutputEDC96682": { + "Description": "ConfigBundle ID for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Id" + } + }, + "ApplicationConfigBundleRefcbArnOutputF7EBB9BE": { + "Description": "ConfigBundle ARN for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "BundleArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-Arn" + } + }, + "ApplicationConfigBundleRefcbVersionIdOutput8E4C36AB": { + "Description": "ConfigBundle VersionId for: refcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleRefcb9FCFB8E4", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-ConfigBundle-refcb-VersionId" + } + }, + "ApplicationGatewayRefgwIdOutput5A9064E3": { + "Description": "Gateway ID for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Id" + } + }, + "ApplicationGatewayRefgwArnOutput234C2E43": { + "Description": "Gateway ARN for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Arn" + } + }, + "ApplicationGatewayRefgwUrlOutputD6C4D3B8": { + "Description": "Gateway URL for: refgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgw1FB17FC1", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-Gateway-refgw-Url" + } + }, + "ApplicationGatewayTargetRemoteIdOutputCE104075": { + "Description": "Gateway Target ID for: remote (gateway: refgw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayRefgwTargetRemoteC0E25011", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-CompatRef-default-GatewayTarget-remote-Id" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/container-live.v0.json b/src/core/project/migrate/__fixtures__/templates/container-live.v0.json new file mode 100644 index 000000000..224f30607 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container-live.v0.json @@ -0,0 +1,1042 @@ +{ + "Description": "AgentCore stack for SrWalkD deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryWalkagentMemoryExecutionRole43E13834": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Memory execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/MemoryWalkagentMemory/ExecutionRole/Resource" + } + }, + "ApplicationMemoryWalkagentMemoryD1E858D1": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryExecutionRole43E13834", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "walkagentMemory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "walkagentMemory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "walkagentMemory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "walkagentMemory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "SrWalkD_walkagentMemory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/MemoryWalkagentMemory/Resource" + } + }, + "ApplicationAgentWalkagentAgentEcrRepositoryEncryptionKeyA0946321": { + "Type": "AWS::KMS::Key", + "Properties": { + "Description": "ECR encryption key for SrWalkD/walkagent", + "EnableKeyRotation": true, + "KeyPolicy": { + "Statement": [ + { + "Action": "kms:*", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:root" + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/AgentEcrRepository/EncryptionKey/Resource" + } + }, + "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E": { + "Type": "AWS::ECR::Repository", + "Properties": { + "EmptyOnDelete": true, + "EncryptionConfiguration": { + "EncryptionType": "KMS", + "KmsKey": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryEncryptionKeyA0946321", + "Arn" + ] + } + }, + "ImageScanningConfiguration": { + "ScanOnPush": true + }, + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"description\":\"Keep last 10 images\",\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":10},\"action\":{\"type\":\"expire\"}}]}" + }, + "RepositoryName": "srwalkd/walkagent", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/AgentEcrRepository/Repository/Resource" + } + }, + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/ContainerImageBuilder/ContainerBuildHandler/ServiceRole/Resource" + } + }, + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "codebuild:BatchGetBuilds", + "codebuild:StartBuild" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ContainerBuildProject5A4F8922", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026", + "Roles": [ + { + "Ref": "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/ContainerImageBuilder/ContainerBuildHandler/ServiceRole/DefaultPolicy/Resource" + } + }, + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nconst { CodeBuildClient, StartBuildCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild');\nconst codebuild = new CodeBuildClient();\n\nexports.handler = async function(event, context) {\n const props = event.ResourceProperties;\n const requestType = event.RequestType;\n\n // Build the response object\n const response = {\n Status: 'SUCCESS',\n PhysicalResourceId: props.ImageUri || 'container-build',\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n Data: {},\n };\n\n try {\n if (requestType === 'Delete') {\n response.Data = {};\n await sendResponse(event, response);\n return;\n }\n\n // Docker build args: forward each as its own build environment variable and reference it with\n // '--build-arg NAME' (no value), so docker inherits the value from the environment. This keeps\n // arbitrary values off the shell command line (no quoting/word-splitting hazards). BUILD_ARG_FLAGS\n // is the space-separated flag list the buildspec interpolates; it is empty when there are no args.\n const buildArgs = props.BuildArgs ? JSON.parse(props.BuildArgs) : {};\n const buildArgNames = Object.keys(buildArgs);\n const buildArgEnv = buildArgNames.map(name => ({ name, value: String(buildArgs[name]), type: 'PLAINTEXT' }));\n const buildArgFlags = buildArgNames.map(name => '--build-arg ' + name).join(' ');\n\n // Start CodeBuild\n const startResult = await codebuild.send(new StartBuildCommand({\n projectName: props.ProjectName,\n sourceTypeOverride: 'S3',\n sourceLocationOverride: props.SourceBucket + '/' + props.SourceKey,\n environmentVariablesOverride: [\n { name: 'ECR_REGISTRY', value: props.EcrRegistry, type: 'PLAINTEXT' },\n { name: 'IMAGE_URI', value: props.ImageUri, type: 'PLAINTEXT' },\n { name: 'DOCKERFILE_PATH', value: props.DockerfilePath, type: 'PLAINTEXT' },\n { name: 'BUILD_ARG_FLAGS', value: buildArgFlags, type: 'PLAINTEXT' },\n ...buildArgEnv,\n ],\n }));\n\n const buildId = startResult.build.id;\n console.log('Started build:', buildId);\n\n // Poll until complete (14 minutes max to stay within Lambda's 15-minute timeout)\n const maxWaitMs = 14 * 60 * 1000;\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const result = await codebuild.send(new BatchGetBuildsCommand({ ids: [buildId] }));\n const build = result.builds[0];\n if (build.buildStatus !== 'IN_PROGRESS') {\n if (build.buildStatus === 'SUCCEEDED') {\n response.Data = { ContainerUri: props.ImageUri };\n await sendResponse(event, response);\n return;\n } else {\n const logsUrl = build.logs && build.logs.deepLink ? build.logs.deepLink : 'Check CloudWatch Logs';\n response.Status = 'FAILED';\n response.Reason = 'CodeBuild build failed. Status: ' + build.buildStatus + '. Logs: ' + logsUrl;\n await sendResponse(event, response);\n return;\n }\n }\n await new Promise(r => setTimeout(r, 10000));\n }\n\n response.Status = 'FAILED';\n response.Reason = 'Build timed out after 14 minutes';\n await sendResponse(event, response);\n } catch (err) {\n response.Status = 'FAILED';\n response.Reason = err.message || 'Unknown error';\n await sendResponse(event, response);\n }\n};\n\nasync function sendResponse(event, response) {\n const https = require('https');\n const responseBody = JSON.stringify(response);\n const parsedUrl = new URL(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.pathname + parsedUrl.search,\n method: 'PUT',\n headers: { 'content-type': '', 'content-length': responseBody.length },\n };\n return new Promise((resolve, reject) => {\n const req = https.request(options, resolve);\n req.on('error', reject);\n req.write(responseBody);\n req.end();\n });\n}\n" + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914", + "Arn" + ] + }, + "Runtime": "nodejs22.x", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ], + "Timeout": 900 + }, + "DependsOn": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/ContainerImageBuilder/ContainerBuildHandler/Resource" + } + }, + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerLogGroup7966FFE8": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966" + } + ] + ] + }, + "RetentionInDays": 731, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/ContainerImageBuilder/ContainerBuildHandler/LogGroup/Resource" + } + }, + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildTrigger8A44F26C": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966", + "Arn" + ] + }, + "ProjectName": { + "Ref": "ContainerBuildProject5A4F8922" + }, + "SourceBucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "SourceKey": "af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c.zip", + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E" + }, + ":af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c" + ] + ] + }, + "DockerfilePath": "Dockerfile", + "EcrRegistry": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceHash": "af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/ContainerImageBuilder/ContainerBuildTrigger/Default" + } + }, + "ApplicationAgentWalkagentRuntimeExecutionRole7C6BC99D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Runtime execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "DependsOn": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerLogGroup7966FFE8", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildTrigger8A44F26C" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/Runtime/ExecutionRole/Resource" + } + }, + "ApplicationAgentWalkagentRuntimeExecutionRoleDefaultPolicy167BB380": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "ecr:GetAuthorizationToken", + "logs:DescribeLogGroups", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E", + "Arn" + ] + } + }, + { + "Action": "kms:Decrypt", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryEncryptionKeyA0946321", + "Arn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentWalkagentRuntimeExecutionRoleDefaultPolicy167BB380", + "Roles": [ + { + "Ref": "ApplicationAgentWalkagentRuntimeExecutionRole7C6BC99D" + } + ] + }, + "DependsOn": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerLogGroup7966FFE8", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildTrigger8A44F26C" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/Runtime/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationAgentWalkagentRuntimeC5AE3095": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "ContainerConfiguration": { + "ContainerUri": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildTrigger8A44F26C", + "ContainerUri" + ] + } + } + }, + "AgentRuntimeName": "SrWalkD_walkagent", + "Description": "AgentCore Runtime: SrWalkD_walkagent", + "EnvironmentVariables": { + "MEMORY_WALKAGENTMEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryId" + ] + }, + "AGENTCORE_GATEWAY_WALKGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "McpGatewayWalkgw7E8BB0C3", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_WALKGW_AUTH_TYPE": "NONE" + }, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentRuntimeExecutionRole7C6BC99D", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "DependsOn": [ + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerLogGroup7966FFE8", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerF3A99966", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicy46D9B026", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildHandlerServiceRole1B1DB914", + "ApplicationAgentWalkagentContainerImageBuilderContainerBuildTrigger8A44F26C", + "ApplicationAgentWalkagentRuntimeExecutionRoleDefaultPolicy167BB380", + "ApplicationAgentWalkagentRuntimeExecutionRole7C6BC99D" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/AgentWalkagent/Runtime/Resource" + } + }, + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codebuild.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildExecutionRole/Resource" + } + }, + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/codebuild/*" + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentAgentEcrRepositoryAF162E4E", + "Arn" + ] + } + }, + { + "Action": [ + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1", + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "Roles": [ + { + "Ref": "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildExecutionRole/DefaultPolicy/Resource" + } + }, + "ContainerBuildProject5A4F8922": { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "NO_ARTIFACTS" + }, + "Cache": { + "Modes": [ + "LOCAL_DOCKER_LAYER_CACHE" + ], + "Type": "LOCAL" + }, + "EncryptionKey": "alias/aws/s3", + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/amazonlinux-aarch64-standard:3.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": true, + "Type": "ARM_CONTAINER" + }, + "Name": "AgentCore-SrWalkD-default-container-builder", + "ServiceRole": { + "Fn::GetAtt": [ + "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "Arn" + ] + }, + "Source": { + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"pre_build\": {\n \"commands\": [\n \"echo \\\"Logging in to Amazon ECR...\\\"\",\n \"aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"echo \\\"Building container image...\\\"\",\n \"docker build -t $IMAGE_URI -f $DOCKERFILE_PATH $BUILD_ARG_FLAGS .\"\n ]\n },\n \"post_build\": {\n \"commands\": [\n \"echo \\\"Pushing image to ECR...\\\"\",\n \"docker push $IMAGE_URI\",\n \"echo \\\"Image pushed: $IMAGE_URI\\\"\"\n ]\n }\n }\n}", + "Type": "NO_SOURCE" + }, + "TimeoutInMinutes": 30 + }, + "DependsOn": [ + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildProject/Resource" + } + }, + "McpGatewayWalkgwRole62539D6D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Gateway role for: walkgw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Mcp/GatewayWalkgw/Role/Resource" + } + }, + "McpGatewayWalkgw7E8BB0C3": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for walkgw", + "Name": "SrWalkD-walkgw", + "RoleArn": { + "Fn::GetAtt": [ + "McpGatewayWalkgwRole62539D6D", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Mcp/GatewayWalkgw/Resource" + } + }, + "McpGatewayWalkgwTargetKnowledge9E828856": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: knowledge", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "McpGatewayWalkgw7E8BB0C3", + "GatewayIdentifier" + ] + }, + "Name": "knowledge", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Mcp/GatewayWalkgw/TargetKnowledge" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+1WS28aMRD+LfUxctyGSD3kBoQiBAkI6AlFyNjD4uC1V36Qblf73yt7HyWPVlGaHNL2tPY87W++GW+HdD6fkU8f6J09ZXx/KsWGFAtH2R7TO7suBE1JMdcSVgWi1voUeC9HFwXKjFBMZFR2GdNeOXSBThCubYJDlzmhVRSXGHGwzIjsSPRn8VKqaAJ8pqVgAiy6WBX3ZHnX1LY3MRXnSz1rUlQWwafWOkfZbqSkUNDq0Al6hvK1IYnnfHi8tzr8C5L9d/lrXF5M3fdD1DfRvmbL3+D+VgU7XKUJV27RWOqgeQTSYzHnC0cdpKCcbQ76aoH+DfF7gCuSpeJJiTfAjWZ7moByTBsgRX+rriDVJo+c8sqJFMJySB3c0fxouaQmAVdie76m1oKzpBs+2J6Tnmd7cD1qIV4T71NLijHkq+IReTECRTcSxpDPtaOV3BkPGBlI9eFn6wRXZ3Re830MeYmBGVLMIdNWOG1CfNNurmkKdYpfRcII0szlU3UJEhw0iaXYAsuZhLmX7V/Bt1FKE+gf9ecTfYhEMFowqqZq5u2uiQiKmfz+pRvBGPLjh3tynPv5qSP1IqWqYdCiUGJJ0w2npPjiVRwjAaSqrnWwHVVcgql3TPNGEWy0b3KmkRYL8R2Opk4TtMRSJ5YUE50MjfbZqkCyXrZ1qDwaixKHTBsvJCfFzOhbYG4VZmFcHRcv9kuN2kEYrQKdw9yMzhGbsHN5Bi08W+ql6+s08w6WlaL3dTS5XA8H14N5d3K2nnTnwwGqKzbitWfczbyU7eSuvfvTy0GMgDCywAw4exX/FE3fAAflBJW2pVuDflMc9ruTLK66kwnC4Rk4CAkJ8Io25cMKMMp2zRXj3RcZsCNoaxTLEsdOXDiaCJXgvrdOp3Ow2hsWO7JlS8WVShHWU+8y7/BTthW7sNIcyK39eOh0SOecdD7cWiFOa0KRemD8AD7MLcgQDAAA" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationAgentWalkagentRuntimeIdOutput4CACBA4E": { + "Description": "Runtime ID for agent: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentRuntimeC5AE3095", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RuntimeId" + } + }, + "ApplicationAgentWalkagentRuntimeArnOutputE4D379B3": { + "Description": "Runtime ARN for agent: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentRuntimeC5AE3095", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RuntimeArn" + } + }, + "ApplicationAgentWalkagentRoleArnOutputB42FB609": { + "Description": "Execution Role ARN for agent: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentWalkagentRuntimeExecutionRole7C6BC99D", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RoleArn" + } + }, + "ApplicationMemoryWalkagentMemoryIdOutput9AF68C0C": { + "Description": "Memory ID for: walkagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Memory-walkagentMemory-Id" + } + }, + "ApplicationMemoryWalkagentMemoryArnOutput7B5295DC": { + "Description": "Memory ARN for: walkagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Memory-walkagentMemory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryD1E858D1", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "walkagentMemory", + "Export": { + "Name": "AgentCore-SrWalkD-default-MemoryNames" + } + }, + "GatewayWalkgwIdOutput": { + "Description": "Gateway ID for: walkgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayWalkgw7E8BB0C3", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Id" + } + }, + "GatewayWalkgwArnOutput": { + "Description": "Gateway ARN for: walkgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayWalkgw7E8BB0C3", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Arn" + } + }, + "GatewayWalkgwUrlOutput": { + "Description": "Gateway URL for: walkgw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayWalkgw7E8BB0C3", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Url" + } + }, + "GatewayTargetKnowledgeIdOutput": { + "Description": "Gateway Target ID for: knowledge (gateway: walkgw)", + "Value": { + "Fn::GetAtt": [ + "McpGatewayWalkgwTargetKnowledge9E828856", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-GatewayTarget-knowledge-Id" + } + }, + "StackNameOutput": { + "Description": "Name of the CloudFormation Stack", + "Value": "AgentCore-SrWalkD-default" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/container-live.v1.json b/src/core/project/migrate/__fixtures__/templates/container-live.v1.json new file mode 100644 index 000000000..eb557566c --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container-live.v1.json @@ -0,0 +1,1133 @@ +{ + "Description": "AgentCore stack for SrWalkD deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryWalkagentMemoryServiceRoleF62941F9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/SrWalkD_walkagentMemory*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/MemoryWalkagentMemory/Resource/ServiceRole/Resource" + } + }, + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryServiceRoleF62941F9", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "walkagentMemory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "walkagentMemory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "walkagentMemory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "walkagentMemory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "SrWalkD_walkagentMemory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/MemoryWalkagentMemory/Resource/Memory" + } + }, + "ApplicationRuntimeWalkagentContainerRepositoryEncryptionKeyBBC692CC": { + "Type": "AWS::KMS::Key", + "Properties": { + "Description": "ECR encryption key for srwalkd/walkagent", + "EnableKeyRotation": true, + "KeyPolicy": { + "Statement": [ + { + "Action": "kms:*", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:root" + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerRepository/EncryptionKey/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerRepository8839790C": { + "Type": "AWS::ECR::Repository", + "Properties": { + "EmptyOnDelete": true, + "EncryptionConfiguration": { + "EncryptionType": "KMS", + "KmsKey": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepositoryEncryptionKeyBBC692CC", + "Arn" + ] + } + }, + "ImageScanningConfiguration": { + "ScanOnPush": true + }, + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"description\":\"Keep last 10 images\",\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":10},\"action\":{\"type\":\"expire\"}}]}" + }, + "RepositoryName": "srwalkd/walkagent", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerRepository/Repository/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerImageBuilder/ContainerBuildHandler/ServiceRole/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "codebuild:BatchGetBuilds", + "codebuild:StartBuild" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ContainerBuildProject5A4F8922", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854", + "Roles": [ + { + "Ref": "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerImageBuilder/ContainerBuildHandler/ServiceRole/DefaultPolicy/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nconst { CodeBuildClient, StartBuildCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild');\nconst codebuild = new CodeBuildClient();\n\nexports.handler = async function(event, context) {\n const props = event.ResourceProperties;\n const requestType = event.RequestType;\n\n // Build the response object\n const response = {\n Status: 'SUCCESS',\n PhysicalResourceId: props.ImageUri || 'container-build',\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n Data: {},\n };\n\n try {\n if (requestType === 'Delete') {\n response.Data = {};\n await sendResponse(event, response);\n return;\n }\n\n // Docker build args: forward each as its own build environment variable and reference it with\n // '--build-arg NAME' (no value), so docker inherits the value from the environment. This keeps\n // arbitrary values off the shell command line (no quoting/word-splitting hazards). BUILD_ARG_FLAGS\n // is the space-separated flag list the buildspec interpolates; it is empty when there are no args.\n const buildArgs = props.BuildArgs ? JSON.parse(props.BuildArgs) : {};\n const buildArgNames = Object.keys(buildArgs);\n const buildArgEnv = buildArgNames.map(name => ({ name, value: String(buildArgs[name]), type: 'PLAINTEXT' }));\n const buildArgFlags = buildArgNames.map(name => '--build-arg ' + name).join(' ');\n\n // Start CodeBuild\n const startResult = await codebuild.send(new StartBuildCommand({\n projectName: props.ProjectName,\n sourceTypeOverride: 'S3',\n sourceLocationOverride: props.SourceBucket + '/' + props.SourceKey,\n environmentVariablesOverride: [\n { name: 'ECR_REGISTRY', value: props.EcrRegistry, type: 'PLAINTEXT' },\n { name: 'IMAGE_URI', value: props.ImageUri, type: 'PLAINTEXT' },\n { name: 'DOCKERFILE_PATH', value: props.DockerfilePath, type: 'PLAINTEXT' },\n { name: 'BUILD_ARG_FLAGS', value: buildArgFlags, type: 'PLAINTEXT' },\n ...buildArgEnv,\n ],\n }));\n\n const buildId = startResult.build.id;\n console.log('Started build:', buildId);\n\n // Poll until complete (14 minutes max to stay within Lambda's 15-minute timeout)\n const maxWaitMs = 14 * 60 * 1000;\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const result = await codebuild.send(new BatchGetBuildsCommand({ ids: [buildId] }));\n const build = result.builds[0];\n if (build.buildStatus !== 'IN_PROGRESS') {\n if (build.buildStatus === 'SUCCEEDED') {\n response.Data = { ContainerUri: props.ImageUri };\n await sendResponse(event, response);\n return;\n } else {\n const logsUrl = build.logs && build.logs.deepLink ? build.logs.deepLink : 'Check CloudWatch Logs';\n response.Status = 'FAILED';\n response.Reason = 'CodeBuild build failed. Status: ' + build.buildStatus + '. Logs: ' + logsUrl;\n await sendResponse(event, response);\n return;\n }\n }\n await new Promise(r => setTimeout(r, 10000));\n }\n\n response.Status = 'FAILED';\n response.Reason = 'Build timed out after 14 minutes';\n await sendResponse(event, response);\n } catch (err) {\n response.Status = 'FAILED';\n response.Reason = err.message || 'Unknown error';\n await sendResponse(event, response);\n }\n};\n\nasync function sendResponse(event, response) {\n const https = require('https');\n const responseBody = JSON.stringify(response);\n const parsedUrl = new URL(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.pathname + parsedUrl.search,\n method: 'PUT',\n headers: { 'content-type': '', 'content-length': responseBody.length },\n };\n return new Promise((resolve, reject) => {\n const req = https.request(options, resolve);\n req.on('error', reject);\n req.write(responseBody);\n req.end();\n });\n}\n" + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C", + "Arn" + ] + }, + "Runtime": "nodejs22.x", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ], + "Timeout": 900 + }, + "DependsOn": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerImageBuilder/ContainerBuildHandler/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerLogGroup281750FF": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": { + "Fn::Join": [ + "", + [ + "/aws/lambda/", + { + "Ref": "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018" + } + ] + ] + }, + "RetentionInDays": 731, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerImageBuilder/ContainerBuildHandler/LogGroup/Resource" + } + }, + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildTrigger7D5B0D82": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018", + "Arn" + ] + }, + "ProjectName": { + "Ref": "ContainerBuildProject5A4F8922" + }, + "SourceBucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "SourceKey": "af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c.zip", + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepository8839790C", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepository8839790C", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "ApplicationRuntimeWalkagentContainerRepository8839790C" + }, + ":af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c" + ] + ] + }, + "DockerfilePath": "Dockerfile", + "EcrRegistry": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceHash": "af2d0fe88fc528eb00c0f8a4e595bc8350ea7b57225a286ab7e664169665dd7c" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/ContainerImageBuilder/ContainerBuildTrigger/Default" + } + }, + "ApplicationRuntimeWalkagentRuntimeExecutionRole4A1AA2B0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/SrWalkD_walkagent*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock Agent Core Runtime", + "MaxSessionDuration": 28800, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "DependsOn": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerLogGroup281750FF", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildTrigger7D5B0D82" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/Runtime/Resource/ExecutionRole/Resource" + } + }, + "ApplicationRuntimeWalkagentRuntimeExecutionRoleDefaultPolicyD574FCD3": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:DescribeLogStreams" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*", + "Sid": "LogGroupAccess" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:*", + "Sid": "DescribeLogGroups" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*", + "Sid": "LogStreamAccess" + }, + { + "Action": [ + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "XRayAccess" + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchMetrics" + }, + { + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default", + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default/workload-identity/*" + ], + "Sid": "GetAgentAccessToken" + }, + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepository8839790C", + "Arn" + ] + } + }, + { + "Action": "kms:Decrypt", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepositoryEncryptionKeyBBC692CC", + "Arn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeWalkagentRuntimeExecutionRoleDefaultPolicyD574FCD3", + "Roles": [ + { + "Ref": "ApplicationRuntimeWalkagentRuntimeExecutionRole4A1AA2B0" + } + ] + }, + "DependsOn": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerLogGroup281750FF", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildTrigger7D5B0D82" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/Runtime/Resource/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationRuntimeWalkagentRuntime6C1B3D80": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "ContainerConfiguration": { + "ContainerUri": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildTrigger7D5B0D82", + "ContainerUri" + ] + } + } + }, + "AgentRuntimeName": "SrWalkD_walkagent", + "Description": "AgentCore Runtime: SrWalkD_walkagent", + "EnvironmentVariables": { + "AGENTCORE_MEMORY_WALKAGENTMEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryId" + ] + }, + "AGENTCORE_MEMORY_WALKAGENTMEMORY_ARN": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryArn" + ] + }, + "AGENTCORE_GATEWAY_WALKGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_WALKGW_ARN": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayArn" + ] + }, + "AGENTCORE_GATEWAY_WALKGW_AUTH_TYPE": "NONE" + }, + "LifecycleConfiguration": {}, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "ProtocolConfiguration": "HTTP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentRuntimeExecutionRole4A1AA2B0", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "DependsOn": [ + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerLogGroup281750FF", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandler0D8DD018", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyCCBAA854", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildHandlerServiceRole9E6DD78C", + "ApplicationRuntimeWalkagentContainerImageBuilderContainerBuildTrigger7D5B0D82", + "ApplicationRuntimeWalkagentRuntimeExecutionRoleDefaultPolicyD574FCD3", + "ApplicationRuntimeWalkagentRuntimeExecutionRole4A1AA2B0" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/RuntimeWalkagent/Runtime/Resource/Resource" + } + }, + "ApplicationGatewayWalkgwServiceRoleA08729FF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + }, + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/SrWalkD-walkgw*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Service role for Bedrock AgentCore Gateway SrWalkD-walkgw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "SrWalkD" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/GatewayWalkgw/Resource/ServiceRole/Resource" + } + }, + "ApplicationGatewayWalkgw83460894": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for walkgw", + "Name": "SrWalkD-walkgw", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgwServiceRoleA08729FF", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "SrWalkD" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/GatewayWalkgw/Resource/Resource" + } + }, + "ApplicationGatewayWalkgwTargetKnowledge4189AD6B": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: knowledge", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayIdentifier" + ] + }, + "Name": "knowledge", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/Application/GatewayWalkgw/Resource/TargetKnowledge/Resource" + } + }, + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codebuild.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildExecutionRole/Resource" + } + }, + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/codebuild/*" + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentContainerRepository8839790C", + "Arn" + ] + } + }, + { + "Action": [ + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1", + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "Roles": [ + { + "Ref": "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildExecutionRole/DefaultPolicy/Resource" + } + }, + "ContainerBuildProject5A4F8922": { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "NO_ARTIFACTS" + }, + "Cache": { + "Modes": [ + "LOCAL_DOCKER_LAYER_CACHE" + ], + "Type": "LOCAL" + }, + "EncryptionKey": "alias/aws/s3", + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/amazonlinux-aarch64-standard:3.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": true, + "Type": "ARM_CONTAINER" + }, + "Name": "AgentCore-SrWalkD-default-container-builder", + "ServiceRole": { + "Fn::GetAtt": [ + "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "Arn" + ] + }, + "Source": { + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"pre_build\": {\n \"commands\": [\n \"echo \\\"Logging in to Amazon ECR...\\\"\",\n \"aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"echo \\\"Building container image...\\\"\",\n \"docker build -t $IMAGE_URI -f $DOCKERFILE_PATH $BUILD_ARG_FLAGS .\"\n ]\n },\n \"post_build\": {\n \"commands\": [\n \"echo \\\"Pushing image to ECR...\\\"\",\n \"docker push $IMAGE_URI\",\n \"echo \\\"Image pushed: $IMAGE_URI\\\"\"\n ]\n }\n }\n}", + "Type": "NO_SOURCE" + }, + "TimeoutInMinutes": 30 + }, + "DependsOn": [ + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/ContainerBuildProject/ContainerBuildProject/Resource" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+1XW0/bMBT+LfhxMtlWpEnjrS0dQhSoWvYUIeTap8HUsSNfgCzKf5/s2Fm5jE0r05jEQ9X4fOeSc/E5J4Ns8Olz9mGH3Jpdyta7gi+zZmEJXWNyay6bJTCt6JoUIC1VGrLmBEql6xy9Q/jB7wKPV7KD8dxJy0vIEzme8SGxcEt68YBFWsLOiS7A5g/QjtpiTsqsmSsBeYOIMa4ENqrRfoMqzSXlFRFDSpWTFu0HGx2PFxhSy5UM5LbF20hjVBJJCmAzJTjlYNB+3tyj1UMdeS+CKcbO1SyZ6Di8TEStJfTqSAouoce8+78Gt3CBgaGaVz3JO3W3AGO4kgdOkw3ev+jA83r/Kfom8ipy8YIlvp2+Pw3NlpfjNeSha+FKAO7IoX2ntz9XHnnk1GMyYwtLLJQgrUlmX0zRG3lb8v+QilCIXQ222OxdEmPAmmzo/7DZy0aOrsGOiImbx7o0WXMMdd48Me9AkqWAY6jnysZxZ7UDjDSU6ubHFfCiVqsadeaPoW4xUJ01c6iU4davQw3S/eGUlBBN/EwTRlBWtj6TByDAQjIs+ApoTQXMnei3irujkhQw3uhPj/raBUbcMy0okWdy5sxV0giS6vq+04lwDPXmfJ9u2v590/3yF5tEH4UWC1IuGcmaL06GNuqD1K2BUdkVkUyAjieqWAI8j3LJZhlWygX/Bsnb8UompS0WqjBZM1XFoVauyhsk4mOfh04icbTYW1o6LljWzLS6BmpzPwvC02byQq3HqN1wraQvRT83gnCIjT/ZuoI+PCvihB2rsnIWzjtg9PVoenB5ODmdzIfTj5fT4fxwgmLGjliUDKeZE6LvwFF6fHYwCRoQRgaoBmtOwqapxxoYSMuJMH25pein5NDn3mRxMpxOEfZj8IYLKIB1ZdM+zAAl9Cq5GHxfVEA3Qhuj2LY43MSFJQWXBR47Y1U5B6OcppA//FRIgH8+c7ZyFj/F21UXlopBdm3e3wwG2WAvG+xcG853Y0Fl8fviO0mlDK3PDAAA" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-SrWalkD-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationRuntimeWalkagentRuntimeIdOutputB0AFF9F8": { + "Description": "Runtime ID for: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentRuntime6C1B3D80", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RuntimeId" + } + }, + "ApplicationRuntimeWalkagentRuntimeArnOutputAE857319": { + "Description": "Runtime ARN for: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentRuntime6C1B3D80", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RuntimeArn" + } + }, + "ApplicationRuntimeWalkagentRoleArnOutputB25D96D4": { + "Description": "Execution Role ARN for: walkagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeWalkagentRuntimeExecutionRole4A1AA2B0", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-walkagent-RoleArn" + } + }, + "ApplicationMemoryWalkagentMemoryIdOutput9AF68C0C": { + "Description": "Memory ID for: walkagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Memory-walkagentMemory-Id" + } + }, + "ApplicationMemoryWalkagentMemoryArnOutput7B5295DC": { + "Description": "Memory ARN for: walkagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Memory-walkagentMemory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryWalkagentMemoryMemoryC4494EA4", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "walkagentMemory", + "Export": { + "Name": "AgentCore-SrWalkD-default-MemoryNames" + } + }, + "ApplicationGatewayWalkgwIdOutput4B92C301": { + "Description": "Gateway ID for: walkgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Id" + } + }, + "ApplicationGatewayWalkgwArnOutputB4237287": { + "Description": "Gateway ARN for: walkgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Arn" + } + }, + "ApplicationGatewayWalkgwUrlOutputBFE61515": { + "Description": "Gateway URL for: walkgw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgw83460894", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-Gateway-walkgw-Url" + } + }, + "ApplicationGatewayTargetKnowledgeIdOutputD4F10D1F": { + "Description": "Gateway Target ID for: knowledge (gateway: walkgw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayWalkgwTargetKnowledge4189AD6B", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-SrWalkD-default-GatewayTarget-knowledge-Id" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/container.mapping.json b/src/core/project/migrate/__fixtures__/templates/container.mapping.json new file mode 100644 index 000000000..ed83315a4 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container.mapping.json @@ -0,0 +1,29 @@ +{ + "ApplicationMemoryMymem7BC1CC7C": "ApplicationMemoryMymemMemory928BF43A", + "ApplicationEvaluatorJudgeE3CC5B60": "ApplicationEvaluatorJudge1C623629", + "ApplicationPolicyEnginePe01E8C4CA": "ApplicationPolicyEnginePe4554234B", + "ApplicationPolicyEnginePePolicypolE882FB8C": "ApplicationPolicyEnginePePolicypolE882FB8C", + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF": "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "ApplicationAgentMyagentRuntime503F431F": "ApplicationRuntimeMyagentRuntime2D14D617", + "ApplicationConfigBundleCbB1B1719E": "ApplicationConfigBundleCbB1B1719E", + "ApplicationOnlineEvalOec1C574607": "ApplicationOnlineEvalOec22177B40", + "ContainerBuildProject5A4F8922": "ContainerBuildProject5A4F8922", + "McpGatewayMygw5B7E67C0": "ApplicationGatewayMygw7D2DB91C", + "McpGatewayMygwTargetRemote1B3FAEEB": "ApplicationGatewayMygwTargetRemoteD9CBC81E", + "ApplicationConfigBundleCbComponentsD49E974D": "ApplicationConfigBundleCbComponentsD49E974D", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "ApplicationMemoryMymemExecutionRoleCE1087A1": "ApplicationMemoryMymemServiceRole652AC67C", + "ApplicationAgentMyagentAgentEcrRepositoryEncryptionKey780410BD": "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + "ApplicationOnlineEvalOecExecutionRole96E15BA6": "ApplicationOnlineEvalOecExecutionRole247C8175", + "McpGatewayMygwRoleD49EE90D": "ApplicationGatewayMygwServiceRoleCAE5F0AD", + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyA31AA0F0": "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE": "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769": "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133" +} diff --git a/src/core/project/migrate/__fixtures__/templates/container.v0.json b/src/core/project/migrate/__fixtures__/templates/container.v0.json new file mode 100644 index 000000000..9c6d85d6a --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container.v0.json @@ -0,0 +1,1520 @@ +{ + "Description": "AgentCore stack for migproj deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryMymemExecutionRoleCE1087A1": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Memory execution role" + } + }, + "ApplicationMemoryMymem7BC1CC7C": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemExecutionRoleCE1087A1", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "mymem_Semantic" + } + } + ], + "Name": "migproj_mymem" + } + }, + "ApplicationEvaluatorJudgeE3CC5B60": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate {context} and {assistant_turn}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "bad", + "Label": "bad", + "Value": 1 + }, + { + "Definition": "good", + "Label": "good", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "migproj_judge", + "Level": "SESSION" + } + }, + "ApplicationPolicyEnginePe01E8C4CA": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "migproj_pe" + } + }, + "ApplicationPolicyEnginePePolicypolE882FB8C": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "pol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe01E8C4CA", + "PolicyEngineId" + ] + }, + "ValidationMode": "FAIL_ON_ANY_FINDINGS" + }, + "DependsOn": [ + "ApplicationPolicyEnginePe01E8C4CA" + ] + }, + "ApplicationAgentMyagentAgentEcrRepositoryEncryptionKey780410BD": { + "Type": "AWS::KMS::Key", + "Properties": { + "Description": "ECR encryption key for migproj/myagent", + "EnableKeyRotation": true, + "KeyPolicy": { + "Statement": [ + { + "Action": "kms:*", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::123456789012:root" + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF": { + "Type": "AWS::ECR::Repository", + "Properties": { + "EmptyOnDelete": true, + "EncryptionConfiguration": { + "EncryptionType": "KMS", + "KmsKey": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryEncryptionKey780410BD", + "Arn" + ] + } + }, + "ImageScanningConfiguration": { + "ScanOnPush": true + }, + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"description\":\"Keep last 10 images\",\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":10},\"action\":{\"type\":\"expire\"}}]}" + }, + "RepositoryName": "migproj/myagent" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "codebuild:StartBuild", + "codebuild:BatchGetBuilds" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ContainerBuildProject5A4F8922", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064", + "Roles": [ + { + "Ref": "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93" + } + ] + } + }, + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nconst { CodeBuildClient, StartBuildCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild');\nconst codebuild = new CodeBuildClient();\n\nexports.handler = async function(event, context) {\n const props = event.ResourceProperties;\n const requestType = event.RequestType;\n\n // Build the response object\n const response = {\n Status: 'SUCCESS',\n PhysicalResourceId: props.ImageUri || 'container-build',\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n Data: {},\n };\n\n try {\n if (requestType === 'Delete') {\n response.Data = {};\n await sendResponse(event, response);\n return;\n }\n\n // Docker build args: forward each as its own build environment variable and reference it with\n // '--build-arg NAME' (no value), so docker inherits the value from the environment. This keeps\n // arbitrary values off the shell command line (no quoting/word-splitting hazards). BUILD_ARG_FLAGS\n // is the space-separated flag list the buildspec interpolates; it is empty when there are no args.\n const buildArgs = props.BuildArgs ? JSON.parse(props.BuildArgs) : {};\n const buildArgNames = Object.keys(buildArgs);\n const buildArgEnv = buildArgNames.map(name => ({ name, value: String(buildArgs[name]), type: 'PLAINTEXT' }));\n const buildArgFlags = buildArgNames.map(name => '--build-arg ' + name).join(' ');\n\n // Start CodeBuild\n const startResult = await codebuild.send(new StartBuildCommand({\n projectName: props.ProjectName,\n sourceTypeOverride: 'S3',\n sourceLocationOverride: props.SourceBucket + '/' + props.SourceKey,\n environmentVariablesOverride: [\n { name: 'ECR_REGISTRY', value: props.EcrRegistry, type: 'PLAINTEXT' },\n { name: 'IMAGE_URI', value: props.ImageUri, type: 'PLAINTEXT' },\n { name: 'DOCKERFILE_PATH', value: props.DockerfilePath, type: 'PLAINTEXT' },\n { name: 'BUILD_ARG_FLAGS', value: buildArgFlags, type: 'PLAINTEXT' },\n ...buildArgEnv,\n ],\n }));\n\n const buildId = startResult.build.id;\n console.log('Started build:', buildId);\n\n // Poll until complete (14 minutes max to stay within Lambda's 15-minute timeout)\n const maxWaitMs = 14 * 60 * 1000;\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const result = await codebuild.send(new BatchGetBuildsCommand({ ids: [buildId] }));\n const build = result.builds[0];\n if (build.buildStatus !== 'IN_PROGRESS') {\n if (build.buildStatus === 'SUCCEEDED') {\n response.Data = { ContainerUri: props.ImageUri };\n await sendResponse(event, response);\n return;\n } else {\n const logsUrl = build.logs && build.logs.deepLink ? build.logs.deepLink : 'Check CloudWatch Logs';\n response.Status = 'FAILED';\n response.Reason = 'CodeBuild build failed. Status: ' + build.buildStatus + '. Logs: ' + logsUrl;\n await sendResponse(event, response);\n return;\n }\n }\n await new Promise(r => setTimeout(r, 10000));\n }\n\n response.Status = 'FAILED';\n response.Reason = 'Build timed out after 14 minutes';\n await sendResponse(event, response);\n } catch (err) {\n response.Status = 'FAILED';\n response.Reason = err.message || 'Unknown error';\n await sendResponse(event, response);\n }\n};\n\nasync function sendResponse(event, response) {\n const https = require('https');\n const responseBody = JSON.stringify(response);\n const parsedUrl = new URL(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.pathname + parsedUrl.search,\n method: 'PUT',\n headers: { 'content-type': '', 'content-length': responseBody.length },\n };\n return new Promise((resolve, reject) => {\n const req = https.request(options, resolve);\n req.on('error', reject);\n req.write(responseBody);\n req.end();\n });\n}\n" + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93", + "Arn" + ] + }, + "Runtime": "nodejs22.x", + "Timeout": 900 + }, + "DependsOn": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93" + ] + }, + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E", + "Arn" + ] + }, + "ProjectName": { + "Ref": "ContainerBuildProject5A4F8922" + }, + "SourceBucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "SourceKey": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b.zip", + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF" + }, + ":df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + ] + ] + }, + "DockerfilePath": "Dockerfile", + "EcrRegistry": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceHash": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Runtime execution role" + }, + "DependsOn": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99" + ] + }, + "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:CountTokens" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:123456789012:inference-profile/*" + ] + ] + } + ] + }, + { + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + ] + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:UpdateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:*:*:configuration-bundle/*" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF", + "Arn" + ] + } + }, + { + "Action": "kms:Decrypt", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryEncryptionKey780410BD", + "Arn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions", + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769", + "Roles": [ + { + "Ref": "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE" + } + ] + }, + "DependsOn": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99" + ] + }, + "ApplicationAgentMyagentRuntime503F431F": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "ContainerConfiguration": { + "ContainerUri": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99", + "ContainerUri" + ] + } + } + }, + "AgentRuntimeName": "migproj_myagent", + "Description": "AgentCore Runtime: migproj_myagent", + "EnvironmentVariables": { + "MEMORY_MYMEM_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryId" + ] + }, + "AGENTCORE_GATEWAY_MYGW_URL": { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayUrl" + ] + }, + "AGENTCORE_GATEWAY_MYGW_AUTH_TYPE": "NONE" + }, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE", + "Arn" + ] + } + }, + "DependsOn": [ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandler2C59A42E", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyAAED0064", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildHandlerServiceRoleD42AAE93", + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99", + "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769", + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE" + ] + }, + "ApplicationConfigBundleCbComponentsD49E974D": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntime503F431F", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationConfigBundleCbB1B1719E": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "migprojcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbComponentsD49E974D", + "Value" + ] + }, + "CreatedBy": { + "Name": "user" + } + } + }, + "ApplicationOnlineEvalOecExecutionRole96E15BA6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Online evaluation execution role for oec" + } + }, + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyA31AA0F0": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:DescribeLogStreams", + "logs:StartQuery", + "logs:GetQueryResults" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntime503F431F", + "AgentRuntimeId" + ] + }, + "*" + ] + ] + } + }, + { + "Action": [ + "logs:StartQuery", + "logs:GetQueryResults", + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:DescribeLogStreams" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans*" + ] + ] + } + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/results/migproj_oec*" + ] + ] + } + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans" + ] + ] + } + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:us-east-1::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:us-east-1:123456789012:inference-profile/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:*:inference-profile/*" + ] + ] + } + ] + }, + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":lambda:us-east-1:123456789012:function:*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/migproj_oec-*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyA31AA0F0", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalOecExecutionRole96E15BA6" + } + ] + } + }, + "ApplicationOnlineEvalOec1C574607": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntime503F431F", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "migproj_myagent.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOecExecutionRole96E15BA6", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudgeE3CC5B60", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "migproj_oec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + } + }, + "DependsOn": [ + "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769", + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE", + "ApplicationAgentMyagentRuntime503F431F", + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyA31AA0F0", + "ApplicationOnlineEvalOecExecutionRole96E15BA6" + ] + }, + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codebuild.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/codebuild/*" + ] + ] + } + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:UploadLayerPart", + "ecr:InitiateLayerUpload", + "ecr:PutImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentAgentEcrRepositoryD536A4BF", + "Arn" + ] + } + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "Roles": [ + { + "Ref": "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + } + ] + } + }, + "ContainerBuildProject5A4F8922": { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "NO_ARTIFACTS" + }, + "Cache": { + "Modes": [ + "LOCAL_DOCKER_LAYER_CACHE" + ], + "Type": "LOCAL" + }, + "EncryptionKey": "alias/aws/s3", + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/amazonlinux-aarch64-standard:3.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": true, + "Type": "ARM_CONTAINER" + }, + "Name": "AgentCore-migproj-default-container-builder", + "ServiceRole": { + "Fn::GetAtt": [ + "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "Arn" + ] + }, + "Source": { + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"pre_build\": {\n \"commands\": [\n \"echo \\\"Logging in to Amazon ECR...\\\"\",\n \"aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"echo \\\"Building container image...\\\"\",\n \"docker build -t $IMAGE_URI -f $DOCKERFILE_PATH $BUILD_ARG_FLAGS .\"\n ]\n },\n \"post_build\": {\n \"commands\": [\n \"echo \\\"Pushing image to ECR...\\\"\",\n \"docker push $IMAGE_URI\",\n \"echo \\\"Image pushed: $IMAGE_URI\\\"\"\n ]\n }\n }\n}", + "Type": "NO_SOURCE" + }, + "TimeoutInMinutes": 30 + }, + "DependsOn": [ + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + ] + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ] + }, + "McpGatewayMygwRoleD49EE90D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Gateway role for: mygw" + } + }, + "McpGatewayMygwRoleDefaultPolicy7AEB4302": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock-agentcore:GetPolicyEngine", + "bedrock-agentcore:CheckAuthorizePermissions", + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:PartiallyAuthorizeActions" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe01E8C4CA", + "PolicyEngineArn" + ] + }, + { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayArn" + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "McpGatewayMygwRoleDefaultPolicy7AEB4302", + "Roles": [ + { + "Ref": "McpGatewayMygwRoleD49EE90D" + } + ] + } + }, + "McpGatewayMygw5B7E67C0": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for migproj-mygw", + "Name": "migproj-mygw", + "ProtocolConfiguration": { + "Mcp": { + "SearchType": "SEMANTIC" + } + }, + "ProtocolType": "MCP", + "RoleArn": { + "Fn::GetAtt": [ + "McpGatewayMygwRoleD49EE90D", + "Arn" + ] + } + } + }, + "McpGatewayMygwTargetRemote1B3FAEEB": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://example.com/mcp" + } + } + } + } + } + }, + "Outputs": { + "ApplicationAgentMyagentRuntimeIdOutput6B972407": { + "Description": "Runtime ID for agent: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntime503F431F", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeId" + } + }, + "ApplicationAgentMyagentRuntimeArnOutputF39E0FAC": { + "Description": "Runtime ARN for agent: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntime503F431F", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeArn" + } + }, + "ApplicationAgentMyagentRoleArnOutputB7818AB3": { + "Description": "Execution Role ARN for agent: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMyagentRuntimeExecutionRoleA2391DCE", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RoleArn" + } + }, + "ApplicationMemoryMymemIdOutput91EC3802": { + "Description": "Memory ID for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Id" + } + }, + "ApplicationMemoryMymemArnOutputF2E354DD": { + "Description": "Memory ARN for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymem7BC1CC7C", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "mymem", + "Export": { + "Name": "AgentCore-migproj-default-MemoryNames" + } + }, + "ApplicationEvaluatorJudgeIdOutput974144CA": { + "Description": "Evaluator ID for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudgeE3CC5B60", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Id" + } + }, + "ApplicationEvaluatorJudgeArnOutputA688E8A7": { + "Description": "Evaluator ARN for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudgeE3CC5B60", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Arn" + } + }, + "ApplicationOnlineEvalOecIdOutput58DBD2BC": { + "Description": "Online evaluation config ID for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec1C574607", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Id" + } + }, + "ApplicationOnlineEvalOecArnOutputC7B09D76": { + "Description": "Online evaluation config ARN for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec1C574607", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Arn" + } + }, + "ApplicationPolicyEnginePeIdOutputBCC2C707": { + "Description": "Policy Engine ID for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe01E8C4CA", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Id" + } + }, + "ApplicationPolicyEnginePeArnOutput1F514AD6": { + "Description": "Policy Engine ARN for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe01E8C4CA", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Arn" + } + }, + "ApplicationPolicyPePolIdOutput1B4B255F": { + "Description": "Policy ID for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Id" + } + }, + "ApplicationPolicyPePolArnOutput7B80294B": { + "Description": "Policy ARN for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Arn" + } + }, + "ApplicationConfigBundleCbIdOutput337B92E4": { + "Description": "ConfigBundle ID for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Id" + } + }, + "ApplicationConfigBundleCbArnOutput032C9B18": { + "Description": "ConfigBundle ARN for: cb", + "Value": { + "Ref": "ApplicationConfigBundleCbB1B1719E" + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Arn" + } + }, + "ApplicationConfigBundleCbVersionIdOutput94FF6706": { + "Description": "ConfigBundle VersionId for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-VersionId" + } + }, + "GatewayMygwIdOutput": { + "Description": "Gateway ID for: mygw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Id" + } + }, + "GatewayMygwArnOutput": { + "Description": "Gateway ARN for: mygw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Arn" + } + }, + "GatewayMygwUrlOutput": { + "Description": "Gateway URL for: mygw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMygw5B7E67C0", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Url" + } + }, + "GatewayTargetRemoteIdOutput": { + "Description": "Gateway Target ID for: remote (gateway: mygw)", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMygwTargetRemote1B3FAEEB", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-GatewayTarget-remote-Id" + } + }, + "StackNameOutput": { + "Description": "Name of the CloudFormation Stack", + "Value": "AgentCore-migproj-default" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/container.v1-v0names.json b/src/core/project/migrate/__fixtures__/templates/container.v1-v0names.json new file mode 100644 index 000000000..d090e0f9f --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container.v1-v0names.json @@ -0,0 +1,1700 @@ +{ + "Description": "AgentCore stack for migproj deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryMymemServiceRole652AC67C": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:memory/migproj_mymem*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ApplicationMemoryMymemMemory928BF43A": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemServiceRole652AC67C", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "mymem_Semantic" + } + } + ], + "Name": "migproj_mymem" + } + }, + "ApplicationEvaluatorJudge1C623629": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate {context} and {assistant_turn}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "bad", + "Label": "bad", + "Value": 1 + }, + { + "Definition": "good", + "Label": "good", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "migproj_judge", + "Level": "SESSION" + } + }, + "ApplicationPolicyEnginePe4554234B": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "migproj_pe" + } + }, + "ApplicationPolicyEnginePePolicypolE882FB8C": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "pol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineId" + ] + }, + "ValidationMode": "FAIL_ON_ANY_FINDINGS" + } + }, + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE": { + "Type": "AWS::KMS::Key", + "Properties": { + "Description": "ECR encryption key for migproj/myagent", + "EnableKeyRotation": true, + "KeyPolicy": { + "Statement": [ + { + "Action": "kms:*", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::123456789012:root" + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77": { + "Type": "AWS::ECR::Repository", + "Properties": { + "EmptyOnDelete": true, + "EncryptionConfiguration": { + "EncryptionType": "KMS", + "KmsKey": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE", + "Arn" + ] + } + }, + "ImageScanningConfiguration": { + "ScanOnPush": true + }, + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"description\":\"Keep last 10 images\",\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":10},\"action\":{\"type\":\"expire\"}}]}" + }, + "RepositoryName": "migproj/myagent" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "codebuild:StartBuild", + "codebuild:BatchGetBuilds" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ContainerBuildProject5A4F8922", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "Roles": [ + { + "Ref": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE" + } + ] + } + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nconst { CodeBuildClient, StartBuildCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild');\nconst codebuild = new CodeBuildClient();\n\nexports.handler = async function(event, context) {\n const props = event.ResourceProperties;\n const requestType = event.RequestType;\n\n // Build the response object\n const response = {\n Status: 'SUCCESS',\n PhysicalResourceId: props.ImageUri || 'container-build',\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n Data: {},\n };\n\n try {\n if (requestType === 'Delete') {\n response.Data = {};\n await sendResponse(event, response);\n return;\n }\n\n // Docker build args: forward each as its own build environment variable and reference it with\n // '--build-arg NAME' (no value), so docker inherits the value from the environment. This keeps\n // arbitrary values off the shell command line (no quoting/word-splitting hazards). BUILD_ARG_FLAGS\n // is the space-separated flag list the buildspec interpolates; it is empty when there are no args.\n const buildArgs = props.BuildArgs ? JSON.parse(props.BuildArgs) : {};\n const buildArgNames = Object.keys(buildArgs);\n const buildArgEnv = buildArgNames.map(name => ({ name, value: String(buildArgs[name]), type: 'PLAINTEXT' }));\n const buildArgFlags = buildArgNames.map(name => '--build-arg ' + name).join(' ');\n\n // Start CodeBuild\n const startResult = await codebuild.send(new StartBuildCommand({\n projectName: props.ProjectName,\n sourceTypeOverride: 'S3',\n sourceLocationOverride: props.SourceBucket + '/' + props.SourceKey,\n environmentVariablesOverride: [\n { name: 'ECR_REGISTRY', value: props.EcrRegistry, type: 'PLAINTEXT' },\n { name: 'IMAGE_URI', value: props.ImageUri, type: 'PLAINTEXT' },\n { name: 'DOCKERFILE_PATH', value: props.DockerfilePath, type: 'PLAINTEXT' },\n { name: 'BUILD_ARG_FLAGS', value: buildArgFlags, type: 'PLAINTEXT' },\n ...buildArgEnv,\n ],\n }));\n\n const buildId = startResult.build.id;\n console.log('Started build:', buildId);\n\n // Poll until complete (14 minutes max to stay within Lambda's 15-minute timeout)\n const maxWaitMs = 14 * 60 * 1000;\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const result = await codebuild.send(new BatchGetBuildsCommand({ ids: [buildId] }));\n const build = result.builds[0];\n if (build.buildStatus !== 'IN_PROGRESS') {\n if (build.buildStatus === 'SUCCEEDED') {\n response.Data = { ContainerUri: props.ImageUri };\n await sendResponse(event, response);\n return;\n } else {\n const logsUrl = build.logs && build.logs.deepLink ? build.logs.deepLink : 'Check CloudWatch Logs';\n response.Status = 'FAILED';\n response.Reason = 'CodeBuild build failed. Status: ' + build.buildStatus + '. Logs: ' + logsUrl;\n await sendResponse(event, response);\n return;\n }\n }\n await new Promise(r => setTimeout(r, 10000));\n }\n\n response.Status = 'FAILED';\n response.Reason = 'Build timed out after 14 minutes';\n await sendResponse(event, response);\n } catch (err) {\n response.Status = 'FAILED';\n response.Reason = err.message || 'Unknown error';\n await sendResponse(event, response);\n }\n};\n\nasync function sendResponse(event, response) {\n const https = require('https');\n const responseBody = JSON.stringify(response);\n const parsedUrl = new URL(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.pathname + parsedUrl.search,\n method: 'PUT',\n headers: { 'content-type': '', 'content-length': responseBody.length },\n };\n return new Promise((resolve, reject) => {\n const req = https.request(options, resolve);\n req.on('error', reject);\n req.write(responseBody);\n req.end();\n });\n}\n" + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "Arn" + ] + }, + "Runtime": "nodejs22.x", + "Timeout": 900 + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE" + ] + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "Arn" + ] + }, + "ProjectName": { + "Ref": "ContainerBuildProject5A4F8922" + }, + "SourceBucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "SourceKey": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b.zip", + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "ApplicationRuntimeMyagentContainerRepositoryA2A11C77" + }, + ":df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + ] + ] + }, + "DockerfilePath": "Dockerfile", + "EcrRegistry": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceHash": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:runtime/migproj_myagent*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock Agent Core Runtime", + "MaxSessionDuration": 28800 + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871" + ] + }, + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:DescribeLogStreams", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + ] + ] + }, + "Sid": "LogGroupAccess" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:*" + ] + ] + }, + "Sid": "DescribeLogGroups" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*" + ] + ] + }, + "Sid": "LogStreamAccess" + }, + { + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "XRayAccess" + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchMetrics" + }, + { + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default/workload-identity/*" + ] + ] + } + ], + "Sid": "GetAgentAccessToken" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:CountTokens" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:123456789012:inference-profile/*" + ] + ] + } + ] + }, + { + "Action": [ + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + ] + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:UpdateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:*:*:configuration-bundle/*" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + }, + { + "Action": "kms:Decrypt", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE", + "Arn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions", + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "Roles": [ + { + "Ref": "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3" + } + ] + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871" + ] + }, + "ApplicationRuntimeMyagentRuntime2D14D617": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "ContainerConfiguration": { + "ContainerUri": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + "ContainerUri" + ] + } + } + }, + "AgentRuntimeName": "migproj_myagent", + "Description": "AgentCore Runtime: migproj_myagent", + "EnvironmentVariables": { + "AGENTCORE_MEMORY_MYMEM_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "AGENTCORE_MEMORY_MYMEM_ARN": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + }, + "AGENTCORE_GATEWAY_MYGW_URL": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayUrl" + ] + }, + "AGENTCORE_GATEWAY_MYGW_ARN": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayArn" + ] + }, + "AGENTCORE_GATEWAY_MYGW_AUTH_TYPE": "NONE" + }, + "LifecycleConfiguration": {}, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "ProtocolConfiguration": "HTTP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "Arn" + ] + } + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3" + ] + }, + "ApplicationConfigBundleCbComponentsD49E974D": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationConfigBundleCbB1B1719E": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "migproj_cb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbComponentsD49E974D", + "Value" + ] + }, + "CreatedBy": { + "Name": "user" + } + } + }, + "ApplicationOnlineEvalOecExecutionRole247C8175": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012", + "aws:ResourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:evaluator/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/*" + ] + ] + } + ] + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock AgentCore Online Evaluation" + } + }, + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchLogDescribeStatement" + }, + { + "Action": [ + "logs:GetQueryResults", + "logs:StartQuery" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT:*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans:*" + ] + ] + } + ], + "Sid": "CloudWatchLogQueryStatement" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/*" + ] + ] + }, + "Sid": "CloudWatchLogWriteStatement" + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans:*" + ] + ] + } + ], + "Sid": "CloudWatchIndexPolicyStatement" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:123456789012:inference-profile/*" + ] + ] + } + ], + "Sid": "BedrockInvokeStatement" + }, + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":lambda:us-east-1:123456789012:function:*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/migproj_oec-*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalOecExecutionRole247C8175" + } + ] + } + }, + "ApplicationOnlineEvalOec22177B40": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "migproj_myagent.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOecExecutionRole247C8175", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "migproj_oec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + } + }, + "DependsOn": [ + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA", + "ApplicationOnlineEvalOecExecutionRole247C8175", + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "ApplicationRuntimeMyagentRuntime2D14D617" + ] + }, + "ApplicationGatewayMygwServiceRoleCAE5F0AD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + }, + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:gateway/migproj-mygw*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Service role for Bedrock AgentCore Gateway migproj-mygw" + } + }, + "ApplicationGatewayMygw7D2DB91C": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for migproj-mygw", + "Name": "migproj-mygw", + "ProtocolConfiguration": { + "Mcp": { + "SearchType": "SEMANTIC" + } + }, + "ProtocolType": "MCP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayMygwServiceRoleCAE5F0AD", + "Arn" + ] + } + } + }, + "ApplicationGatewayMygwTargetRemoteD9CBC81E": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://example.com/mcp" + } + } + } + } + }, + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codebuild.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/codebuild/*" + ] + ] + } + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:UploadLayerPart", + "ecr:InitiateLayerUpload", + "ecr:PutImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "Roles": [ + { + "Ref": "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + } + ] + } + }, + "ContainerBuildProject5A4F8922": { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "NO_ARTIFACTS" + }, + "Cache": { + "Modes": [ + "LOCAL_DOCKER_LAYER_CACHE" + ], + "Type": "LOCAL" + }, + "EncryptionKey": "alias/aws/s3", + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/amazonlinux-aarch64-standard:3.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": true, + "Type": "ARM_CONTAINER" + }, + "Name": "AgentCore-migproj-default-container-builder", + "ServiceRole": { + "Fn::GetAtt": [ + "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "Arn" + ] + }, + "Source": { + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"pre_build\": {\n \"commands\": [\n \"echo \\\"Logging in to Amazon ECR...\\\"\",\n \"aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"echo \\\"Building container image...\\\"\",\n \"docker build -t $IMAGE_URI -f $DOCKERFILE_PATH $BUILD_ARG_FLAGS .\"\n ]\n },\n \"post_build\": {\n \"commands\": [\n \"echo \\\"Pushing image to ECR...\\\"\",\n \"docker push $IMAGE_URI\",\n \"echo \\\"Image pushed: $IMAGE_URI\\\"\"\n ]\n }\n }\n}", + "Type": "NO_SOURCE" + }, + "TimeoutInMinutes": 30 + }, + "DependsOn": [ + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + ] + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ] + } + }, + "Outputs": { + "ApplicationRuntimeMyagentRuntimeIdOutputE90A2D03": { + "Description": "Runtime ID for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeId" + } + }, + "ApplicationRuntimeMyagentRuntimeArnOutput1F9C2412": { + "Description": "Runtime ARN for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeArn" + } + }, + "ApplicationRuntimeMyagentRoleArnOutput96BFD8C7": { + "Description": "Execution Role ARN for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RoleArn" + } + }, + "ApplicationMemoryMymemIdOutput91EC3802": { + "Description": "Memory ID for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Id" + } + }, + "ApplicationMemoryMymemArnOutputF2E354DD": { + "Description": "Memory ARN for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "mymem", + "Export": { + "Name": "AgentCore-migproj-default-MemoryNames" + } + }, + "ApplicationEvaluatorJudgeIdOutput974144CA": { + "Description": "Evaluator ID for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Id" + } + }, + "ApplicationEvaluatorJudgeArnOutputA688E8A7": { + "Description": "Evaluator ARN for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Arn" + } + }, + "ApplicationOnlineEvalOecIdOutput58DBD2BC": { + "Description": "Online evaluation config ID for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec22177B40", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Id" + } + }, + "ApplicationOnlineEvalOecArnOutputC7B09D76": { + "Description": "Online evaluation config ARN for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec22177B40", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Arn" + } + }, + "ApplicationPolicyEnginePeIdOutputBCC2C707": { + "Description": "Policy Engine ID for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Id" + } + }, + "ApplicationPolicyEnginePeArnOutput1F514AD6": { + "Description": "Policy Engine ARN for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Arn" + } + }, + "ApplicationPolicyPePolIdOutput1B4B255F": { + "Description": "Policy ID for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Id" + } + }, + "ApplicationPolicyPePolArnOutput7B80294B": { + "Description": "Policy ARN for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Arn" + } + }, + "ApplicationConfigBundleCbIdOutput337B92E4": { + "Description": "ConfigBundle ID for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Id" + } + }, + "ApplicationConfigBundleCbArnOutput032C9B18": { + "Description": "ConfigBundle ARN for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "BundleArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Arn" + } + }, + "ApplicationConfigBundleCbVersionIdOutput94FF6706": { + "Description": "ConfigBundle VersionId for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-VersionId" + } + }, + "ApplicationGatewayMygwIdOutput3D3AC02C": { + "Description": "Gateway ID for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Id" + } + }, + "ApplicationGatewayMygwArnOutputED540F51": { + "Description": "Gateway ARN for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Arn" + } + }, + "ApplicationGatewayMygwUrlOutput0A6702E6": { + "Description": "Gateway URL for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Url" + } + }, + "ApplicationGatewayTargetRemoteIdOutputCE104075": { + "Description": "Gateway Target ID for: remote (gateway: mygw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygwTargetRemoteD9CBC81E", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-GatewayTarget-remote-Id" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/container.v1-v1names.json b/src/core/project/migrate/__fixtures__/templates/container.v1-v1names.json new file mode 100644 index 000000000..8fba0f846 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/container.v1-v1names.json @@ -0,0 +1,1700 @@ +{ + "Description": "AgentCore stack for migproj deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryMymemServiceRole652AC67C": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:memory/migproj_default_mymem*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ApplicationMemoryMymemMemory928BF43A": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemServiceRole652AC67C", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "mymem_Semantic" + } + } + ], + "Name": "migproj_default_mymem" + } + }, + "ApplicationEvaluatorJudge1C623629": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate {context} and {assistant_turn}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "bad", + "Label": "bad", + "Value": 1 + }, + { + "Definition": "good", + "Label": "good", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "migproj_default_judge", + "Level": "SESSION" + } + }, + "ApplicationPolicyEnginePe4554234B": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "migproj_default_pe" + } + }, + "ApplicationPolicyEnginePePolicypolE882FB8C": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "pol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineId" + ] + }, + "ValidationMode": "FAIL_ON_ANY_FINDINGS" + } + }, + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE": { + "Type": "AWS::KMS::Key", + "Properties": { + "Description": "ECR encryption key for migproj/default/myagent", + "EnableKeyRotation": true, + "KeyPolicy": { + "Statement": [ + { + "Action": "kms:*", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::123456789012:root" + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77": { + "Type": "AWS::ECR::Repository", + "Properties": { + "EmptyOnDelete": true, + "EncryptionConfiguration": { + "EncryptionType": "KMS", + "KmsKey": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE", + "Arn" + ] + } + }, + "ImageScanningConfiguration": { + "ScanOnPush": true + }, + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"description\":\"Keep last 10 images\",\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":10},\"action\":{\"type\":\"expire\"}}]}" + }, + "RepositoryName": "migproj/default/myagent" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "codebuild:StartBuild", + "codebuild:BatchGetBuilds" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ContainerBuildProject5A4F8922", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "Roles": [ + { + "Ref": "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE" + } + ] + } + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nconst { CodeBuildClient, StartBuildCommand, BatchGetBuildsCommand } = require('@aws-sdk/client-codebuild');\nconst codebuild = new CodeBuildClient();\n\nexports.handler = async function(event, context) {\n const props = event.ResourceProperties;\n const requestType = event.RequestType;\n\n // Build the response object\n const response = {\n Status: 'SUCCESS',\n PhysicalResourceId: props.ImageUri || 'container-build',\n StackId: event.StackId,\n RequestId: event.RequestId,\n LogicalResourceId: event.LogicalResourceId,\n Data: {},\n };\n\n try {\n if (requestType === 'Delete') {\n response.Data = {};\n await sendResponse(event, response);\n return;\n }\n\n // Docker build args: forward each as its own build environment variable and reference it with\n // '--build-arg NAME' (no value), so docker inherits the value from the environment. This keeps\n // arbitrary values off the shell command line (no quoting/word-splitting hazards). BUILD_ARG_FLAGS\n // is the space-separated flag list the buildspec interpolates; it is empty when there are no args.\n const buildArgs = props.BuildArgs ? JSON.parse(props.BuildArgs) : {};\n const buildArgNames = Object.keys(buildArgs);\n const buildArgEnv = buildArgNames.map(name => ({ name, value: String(buildArgs[name]), type: 'PLAINTEXT' }));\n const buildArgFlags = buildArgNames.map(name => '--build-arg ' + name).join(' ');\n\n // Start CodeBuild\n const startResult = await codebuild.send(new StartBuildCommand({\n projectName: props.ProjectName,\n sourceTypeOverride: 'S3',\n sourceLocationOverride: props.SourceBucket + '/' + props.SourceKey,\n environmentVariablesOverride: [\n { name: 'ECR_REGISTRY', value: props.EcrRegistry, type: 'PLAINTEXT' },\n { name: 'IMAGE_URI', value: props.ImageUri, type: 'PLAINTEXT' },\n { name: 'DOCKERFILE_PATH', value: props.DockerfilePath, type: 'PLAINTEXT' },\n { name: 'BUILD_ARG_FLAGS', value: buildArgFlags, type: 'PLAINTEXT' },\n ...buildArgEnv,\n ],\n }));\n\n const buildId = startResult.build.id;\n console.log('Started build:', buildId);\n\n // Poll until complete (14 minutes max to stay within Lambda's 15-minute timeout)\n const maxWaitMs = 14 * 60 * 1000;\n const start = Date.now();\n while (Date.now() - start < maxWaitMs) {\n const result = await codebuild.send(new BatchGetBuildsCommand({ ids: [buildId] }));\n const build = result.builds[0];\n if (build.buildStatus !== 'IN_PROGRESS') {\n if (build.buildStatus === 'SUCCEEDED') {\n response.Data = { ContainerUri: props.ImageUri };\n await sendResponse(event, response);\n return;\n } else {\n const logsUrl = build.logs && build.logs.deepLink ? build.logs.deepLink : 'Check CloudWatch Logs';\n response.Status = 'FAILED';\n response.Reason = 'CodeBuild build failed. Status: ' + build.buildStatus + '. Logs: ' + logsUrl;\n await sendResponse(event, response);\n return;\n }\n }\n await new Promise(r => setTimeout(r, 10000));\n }\n\n response.Status = 'FAILED';\n response.Reason = 'Build timed out after 14 minutes';\n await sendResponse(event, response);\n } catch (err) {\n response.Status = 'FAILED';\n response.Reason = err.message || 'Unknown error';\n await sendResponse(event, response);\n }\n};\n\nasync function sendResponse(event, response) {\n const https = require('https');\n const responseBody = JSON.stringify(response);\n const parsedUrl = new URL(event.ResponseURL);\n const options = {\n hostname: parsedUrl.hostname,\n port: 443,\n path: parsedUrl.pathname + parsedUrl.search,\n method: 'PUT',\n headers: { 'content-type': '', 'content-length': responseBody.length },\n };\n return new Promise((resolve, reject) => {\n const req = https.request(options, resolve);\n req.on('error', reject);\n req.write(responseBody);\n req.end();\n });\n}\n" + }, + "Handler": "index.handler", + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "Arn" + ] + }, + "Runtime": "nodejs22.x", + "Timeout": 900 + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE" + ] + }, + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "Arn" + ] + }, + "ProjectName": { + "Ref": "ContainerBuildProject5A4F8922" + }, + "SourceBucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "SourceKey": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b.zip", + "ImageUri": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + ] + } + ] + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "ApplicationRuntimeMyagentContainerRepositoryA2A11C77" + }, + ":df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + ] + ] + }, + "DockerfilePath": "Dockerfile", + "EcrRegistry": { + "Fn::Join": [ + "", + [ + { + "Ref": "AWS::AccountId" + }, + ".dkr.ecr.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceHash": "df5fdda5f167f94e085b33d75e5e3e9abbf7747ba4452289c1b6b3194728cd1b" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:runtime/migproj_default_myagent*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock Agent Core Runtime", + "MaxSessionDuration": 28800 + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871" + ] + }, + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:DescribeLogStreams", + "logs:CreateLogGroup" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + ] + ] + }, + "Sid": "LogGroupAccess" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:*" + ] + ] + }, + "Sid": "DescribeLogGroups" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*" + ] + ] + }, + "Sid": "LogStreamAccess" + }, + { + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "XRayAccess" + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchMetrics" + }, + { + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default/workload-identity/*" + ] + ] + } + ], + "Sid": "GetAgentAccessToken" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:CountTokens" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:123456789012:inference-profile/*" + ] + ] + } + ] + }, + { + "Action": [ + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + ] + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:UpdateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:*:*:configuration-bundle/*" + ] + ] + } + }, + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + }, + { + "Action": "kms:Decrypt", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryEncryptionKey6E95E0CE", + "Arn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions", + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "Roles": [ + { + "Ref": "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3" + } + ] + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871" + ] + }, + "ApplicationRuntimeMyagentRuntime2D14D617": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "ContainerConfiguration": { + "ContainerUri": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + "ContainerUri" + ] + } + } + }, + "AgentRuntimeName": "migproj_default_myagent", + "Description": "AgentCore Runtime: migproj_default_myagent", + "EnvironmentVariables": { + "AGENTCORE_MEMORY_MYMEM_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "AGENTCORE_MEMORY_MYMEM_ARN": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + }, + "AGENTCORE_GATEWAY_MYGW_URL": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayUrl" + ] + }, + "AGENTCORE_GATEWAY_MYGW_ARN": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayArn" + ] + }, + "AGENTCORE_GATEWAY_MYGW_AUTH_TYPE": "NONE" + }, + "LifecycleConfiguration": {}, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "ProtocolConfiguration": "HTTP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "Arn" + ] + } + }, + "DependsOn": [ + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRoleDefaultPolicyC0B3B8E3", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandlerServiceRole5F79FDAE", + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3" + ] + }, + "ApplicationConfigBundleCbComponentsD49E974D": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ApplicationConfigBundleCbB1B1719E": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "migproj_default_cb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbComponentsD49E974D", + "Value" + ] + }, + "CreatedBy": { + "Name": "user" + } + } + }, + "ApplicationOnlineEvalOecExecutionRole247C8175": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012", + "aws:ResourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:evaluator/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/*" + ] + ] + } + ] + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock AgentCore Online Evaluation" + } + }, + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchLogDescribeStatement" + }, + { + "Action": [ + "logs:GetQueryResults", + "logs:StartQuery" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT:*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans:*" + ] + ] + } + ], + "Sid": "CloudWatchLogQueryStatement" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/evaluations/*" + ] + ] + }, + "Sid": "CloudWatchLogWriteStatement" + }, + { + "Action": [ + "logs:DescribeIndexPolicies", + "logs:PutIndexPolicy" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:aws/spans:*" + ] + ] + } + ], + "Sid": "CloudWatchIndexPolicyStatement" + }, + { + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*::foundation-model/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock:*:123456789012:inference-profile/*" + ] + ] + } + ], + "Sid": "BedrockInvokeStatement" + }, + { + "Action": [ + "cloudwatch:GenerateQuery", + "cloudwatch:GenerateQueryResultsSummary" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "lambda:GetFunction", + "lambda:InvokeFunction" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":lambda:us-east-1:123456789012:function:*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:GetOnlineEvaluationConfig", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/migproj_default_oec-*" + ] + ] + } + }, + { + "Action": "bedrock-agentcore:StartBatchEvaluation", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:batch-evaluate/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA", + "Roles": [ + { + "Ref": "ApplicationOnlineEvalOecExecutionRole247C8175" + } + ] + } + }, + "ApplicationOnlineEvalOec22177B40": { + "Type": "AWS::BedrockAgentCore::OnlineEvaluationConfig", + "Properties": { + "DataSourceConfig": { + "CloudWatchLogs": { + "LogGroupNames": [ + { + "Fn::Join": [ + "", + [ + "/aws/bedrock-agentcore/runtimes/", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "-DEFAULT" + ] + ] + } + ], + "ServiceNames": [ + "migproj_default_myagent.DEFAULT" + ] + } + }, + "EvaluationExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOecExecutionRole247C8175", + "Arn" + ] + }, + "Evaluators": [ + { + "EvaluatorId": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorId" + ] + } + } + ], + "ExecutionStatus": "ENABLED", + "OnlineEvaluationConfigName": "migproj_default_oec", + "Rule": { + "SamplingConfig": { + "SamplingPercentage": 10 + }, + "SessionConfig": { + "SessionTimeoutMinutes": 5 + } + } + }, + "DependsOn": [ + "ApplicationOnlineEvalOecExecutionRoleDefaultPolicyB52B09CA", + "ApplicationOnlineEvalOecExecutionRole247C8175", + "ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133", + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "ApplicationRuntimeMyagentRuntime2D14D617" + ] + }, + "ApplicationGatewayMygwServiceRoleCAE5F0AD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + }, + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":bedrock-agentcore:us-east-1:123456789012:gateway/migproj-default-mygw*" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Service role for Bedrock AgentCore Gateway migproj-default-mygw" + } + }, + "ApplicationGatewayMygw7D2DB91C": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for migproj-default-mygw", + "Name": "migproj-default-mygw", + "ProtocolConfiguration": { + "Mcp": { + "SearchType": "SEMANTIC" + } + }, + "ProtocolType": "MCP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayMygwServiceRoleCAE5F0AD", + "Arn" + ] + } + } + }, + "ApplicationGatewayMygwTargetRemoteD9CBC81E": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: remote", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayIdentifier" + ] + }, + "Name": "remote", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://example.com/mcp" + } + } + } + } + }, + "ContainerBuildProjectContainerBuildExecutionRole69AD3217": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codebuild.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecr:GetAuthorizationToken", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":logs:us-east-1:123456789012:log-group:/aws/codebuild/*" + ] + ] + } + }, + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:UploadLayerPart", + "ecr:InitiateLayerUpload", + "ecr:PutImage" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentContainerRepositoryA2A11C77", + "Arn" + ] + } + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "Roles": [ + { + "Ref": "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + } + ] + } + }, + "ContainerBuildProject5A4F8922": { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "NO_ARTIFACTS" + }, + "Cache": { + "Modes": [ + "LOCAL_DOCKER_LAYER_CACHE" + ], + "Type": "LOCAL" + }, + "EncryptionKey": "alias/aws/s3", + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/amazonlinux-aarch64-standard:3.0", + "ImagePullCredentialsType": "CODEBUILD", + "PrivilegedMode": true, + "Type": "ARM_CONTAINER" + }, + "Name": "AgentCore-migproj-default-container-builder", + "ServiceRole": { + "Fn::GetAtt": [ + "ContainerBuildProjectContainerBuildExecutionRole69AD3217", + "Arn" + ] + }, + "Source": { + "BuildSpec": "{\n \"version\": \"0.2\",\n \"phases\": {\n \"pre_build\": {\n \"commands\": [\n \"echo \\\"Logging in to Amazon ECR...\\\"\",\n \"aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY\"\n ]\n },\n \"build\": {\n \"commands\": [\n \"echo \\\"Building container image...\\\"\",\n \"docker build -t $IMAGE_URI -f $DOCKERFILE_PATH $BUILD_ARG_FLAGS .\"\n ]\n },\n \"post_build\": {\n \"commands\": [\n \"echo \\\"Pushing image to ECR...\\\"\",\n \"docker push $IMAGE_URI\",\n \"echo \\\"Image pushed: $IMAGE_URI\\\"\"\n ]\n }\n }\n}", + "Type": "NO_SOURCE" + }, + "TimeoutInMinutes": 30 + }, + "DependsOn": [ + "ContainerBuildProjectContainerBuildExecutionRoleDefaultPolicyE43AE239", + "ContainerBuildProjectContainerBuildExecutionRole69AD3217" + ] + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ] + } + }, + "Outputs": { + "ApplicationRuntimeMyagentRuntimeIdOutputE90A2D03": { + "Description": "Runtime ID for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeId" + } + }, + "ApplicationRuntimeMyagentRuntimeArnOutput1F9C2412": { + "Description": "Runtime ARN for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntime2D14D617", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RuntimeArn" + } + }, + "ApplicationRuntimeMyagentRoleArnOutput96BFD8C7": { + "Description": "Execution Role ARN for: myagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMyagentRuntimeExecutionRole7FFDB3E3", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-myagent-RoleArn" + } + }, + "ApplicationMemoryMymemIdOutput91EC3802": { + "Description": "Memory ID for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Id" + } + }, + "ApplicationMemoryMymemArnOutputF2E354DD": { + "Description": "Memory ARN for: mymem", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Memory-mymem-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMymemMemory928BF43A", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "mymem", + "Export": { + "Name": "AgentCore-migproj-default-MemoryNames" + } + }, + "ApplicationEvaluatorJudgeIdOutput974144CA": { + "Description": "Evaluator ID for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Id" + } + }, + "ApplicationEvaluatorJudgeArnOutputA688E8A7": { + "Description": "Evaluator ARN for: judge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorJudge1C623629", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Evaluator-judge-Arn" + } + }, + "ApplicationOnlineEvalOecIdOutput58DBD2BC": { + "Description": "Online evaluation config ID for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec22177B40", + "OnlineEvaluationConfigId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Id" + } + }, + "ApplicationOnlineEvalOecArnOutputC7B09D76": { + "Description": "Online evaluation config ARN for: oec", + "Value": { + "Fn::GetAtt": [ + "ApplicationOnlineEvalOec22177B40", + "OnlineEvaluationConfigArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-OnlineEval-oec-Arn" + } + }, + "ApplicationPolicyEnginePeIdOutputBCC2C707": { + "Description": "Policy Engine ID for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Id" + } + }, + "ApplicationPolicyEnginePeArnOutput1F514AD6": { + "Description": "Policy Engine ARN for: pe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePe4554234B", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-PolicyEngine-pe-Arn" + } + }, + "ApplicationPolicyPePolIdOutput1B4B255F": { + "Description": "Policy ID for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Id" + } + }, + "ApplicationPolicyPePolArnOutput7B80294B": { + "Description": "Policy ARN for: pe/pol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEnginePePolicypolE882FB8C", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Policy-pe-pol-Arn" + } + }, + "ApplicationConfigBundleCbIdOutput337B92E4": { + "Description": "ConfigBundle ID for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Id" + } + }, + "ApplicationConfigBundleCbArnOutput032C9B18": { + "Description": "ConfigBundle ARN for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "BundleArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-Arn" + } + }, + "ApplicationConfigBundleCbVersionIdOutput94FF6706": { + "Description": "ConfigBundle VersionId for: cb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleCbB1B1719E", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-ConfigBundle-cb-VersionId" + } + }, + "ApplicationGatewayMygwIdOutput3D3AC02C": { + "Description": "Gateway ID for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Id" + } + }, + "ApplicationGatewayMygwArnOutputED540F51": { + "Description": "Gateway ARN for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Arn" + } + }, + "ApplicationGatewayMygwUrlOutput0A6702E6": { + "Description": "Gateway URL for: mygw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygw7D2DB91C", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-Gateway-mygw-Url" + } + }, + "ApplicationGatewayTargetRemoteIdOutputCE104075": { + "Description": "Gateway Target ID for: remote (gateway: mygw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMygwTargetRemoteD9CBC81E", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-migproj-default-GatewayTarget-remote-Id" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/pinned.v0.json b/src/core/project/migrate/__fixtures__/templates/pinned.v0.json new file mode 100644 index 000000000..f4af441f8 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/pinned.v0.json @@ -0,0 +1,940 @@ +{ + "Description": "AgentCore stack for CompatMig deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryMigagentMemoryExecutionRoleE88EA5A2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Memory execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/MemoryMigagentMemory/ExecutionRole/Resource" + } + }, + "ApplicationMemoryMigagentMemoryCA18A787": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryExecutionRoleE88EA5A2", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "migagentMemory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "migagentMemory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "migagentMemory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "migagentMemory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "CompatMig_migagentMemory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/MemoryMigagentMemory/Resource" + } + }, + "ApplicationEvaluatorMigjudgeC6FC9FC8": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate the session. Context: {context}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "Fails to meet expectations", + "Label": "Poor", + "Value": 1 + }, + { + "Definition": "Partially meets expectations", + "Label": "Fair", + "Value": 2 + }, + { + "Definition": "Meets expectations", + "Label": "Good", + "Value": 3 + }, + { + "Definition": "Exceeds expectations", + "Label": "Very Good", + "Value": 4 + }, + { + "Definition": "Far exceeds expectations", + "Label": "Excellent", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "CompatMig_migjudge", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/EvaluatorMigjudge/Resource" + } + }, + "ApplicationPolicyEngineMigpeE0487B41": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "CompatMig_migpe", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/PolicyEngineMigpe/Resource" + } + }, + "ApplicationPolicyEngineMigpePolicymigpolFB03816C": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource is AgentCore::Gateway);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "migpol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeE0487B41", + "PolicyEngineId" + ] + }, + "ValidationMode": "IGNORE_ALL_FINDINGS" + }, + "DependsOn": [ + "ApplicationPolicyEngineMigpeE0487B41" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/PolicyEngineMigpe/Policymigpol" + } + }, + "ApplicationAgentMigagentRuntimeExecutionRoleA76AD642": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "AgentCore Runtime execution role", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/AgentMigagent/Runtime/ExecutionRole/Resource" + } + }, + "ApplicationAgentMigagentRuntimeExecutionRoleDefaultPolicyA3970B19": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:DescribeLogGroups", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryArn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationAgentMigagentRuntimeExecutionRoleDefaultPolicyA3970B19", + "Roles": [ + { + "Ref": "ApplicationAgentMigagentRuntimeExecutionRoleA76AD642" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/AgentMigagent/Runtime/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationAgentMigagentRuntimeB6E7D059": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "CodeConfiguration": { + "Code": { + "S3": { + "Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "Prefix": "f4c1dbd54ddfab98557e680220ccc21f146c871a1e30ee5dba5fca7d30124518.zip" + } + }, + "EntryPoint": [ + "opentelemetry-instrument", + "main.py" + ], + "Runtime": "PYTHON_3_14" + } + }, + "AgentRuntimeName": "CompatMig_migagent", + "Description": "AgentCore Runtime: CompatMig_migagent", + "EnvironmentVariables": { + "MEMORY_MIGAGENTMEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryId" + ] + }, + "AGENTCORE_GATEWAY_MIGGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_MIGGW_AUTH_TYPE": "NONE" + }, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationAgentMigagentRuntimeExecutionRoleA76AD642", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "DependsOn": [ + "ApplicationAgentMigagentRuntimeExecutionRoleDefaultPolicyA3970B19", + "ApplicationAgentMigagentRuntimeExecutionRoleA76AD642" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/AgentMigagent/Runtime/Resource" + } + }, + "ApplicationConfigBundleMigcbComponentsE5AB18B9": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationAgentMigagentRuntimeB6E7D059", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/ConfigBundleMigcb/Components/Resource/Default" + } + }, + "ApplicationConfigBundleMigcbFF975EE3": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BundleName": "CompatMigmigcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbComponentsE5AB18B9", + "Value" + ] + }, + "BranchName": "mainline", + "CreatedBy": { + "Name": "user" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/ConfigBundleMigcb/Resource" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Role" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Handler", + "aws:asset:path": "asset.3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a", + "aws:asset:property": "Code" + } + }, + "McpGatewayMiggwRole7BBE6633": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Gateway role for: miggw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Mcp/GatewayMiggw/Role/Resource" + } + }, + "McpGatewayMiggwRoleDefaultPolicy403FA543": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:CheckAuthorizePermissions", + "bedrock-agentcore:GetPolicyEngine", + "bedrock-agentcore:PartiallyAuthorizeActions" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeE0487B41", + "PolicyEngineArn" + ] + }, + { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayArn" + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "McpGatewayMiggwRoleDefaultPolicy403FA543", + "Roles": [ + { + "Ref": "McpGatewayMiggwRole7BBE6633" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Mcp/GatewayMiggw/Role/DefaultPolicy/Resource" + } + }, + "McpGatewayMiggw6C5DF251": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for miggw", + "Name": "CompatMig-miggw", + "RoleArn": { + "Fn::GetAtt": [ + "McpGatewayMiggwRole7BBE6633", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Mcp/GatewayMiggw/Resource" + } + }, + "McpGatewayMiggwTargetKnowledge2C2802EB": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: knowledge", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayIdentifier" + ] + }, + "Name": "knowledge", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Mcp/GatewayMiggw/TargetKnowledge" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+2SwU4CMRCGn8UeTam6JB64ASFGEyMBbsaYoR3Wwm5LOlMI2ey7my4oKBc1mmjiqe33zz8znTZT2eWFOj+BNbW0WbQKO1XVmEEvJKzpsbJQqmrkC7yvBBDFEk1vIzqVWAbrtF1C0dXaR8eiI06F3MUkQ1ez9a7BtRQGSQe7PEA/kM+YiR/6wuqN6NxX9cMeviQ/UplBP127wjp81cSp+ID4hWL/ls9a/uwPeZD9mUtNyS1P9NUw8Uk5ynOMjRkzMJbomN40/Jvxd920GeF2erWcogleLyBHx9oHVFV/5m6x9GGTwgYrKCKwD3vPwOXW4f7cPEh0bMsGXgHjGjYH2wmEHLmW1H4EImRS3bRIaqte1AvkHhDuOm6UMUNuXZ5S3EVeRk67G/JO9iOxL0dIPgbdeNJUtn9iB9/FDINfWYMh1ail8wbVnM5WWaaytspO5mRtK2y7V7tbPAPmlkzVuwUAAA==" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationAgentMigagentRuntimeIdOutput9974F970": { + "Description": "Runtime ID for agent: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMigagentRuntimeB6E7D059", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RuntimeId" + } + }, + "ApplicationAgentMigagentRuntimeArnOutputB762B825": { + "Description": "Runtime ARN for agent: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMigagentRuntimeB6E7D059", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RuntimeArn" + } + }, + "ApplicationAgentMigagentRoleArnOutput09D1AC58": { + "Description": "Execution Role ARN for agent: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationAgentMigagentRuntimeExecutionRoleA76AD642", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RoleArn" + } + }, + "ApplicationMemoryMigagentMemoryIdOutputAA6574F9": { + "Description": "Memory ID for: migagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Memory-migagentMemory-Id" + } + }, + "ApplicationMemoryMigagentMemoryArnOutput1D1C7E26": { + "Description": "Memory ARN for: migagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Memory-migagentMemory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryCA18A787", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "migagentMemory", + "Export": { + "Name": "AgentCore-CompatMig-default-MemoryNames" + } + }, + "ApplicationEvaluatorMigjudgeIdOutputC87728F1": { + "Description": "Evaluator ID for: migjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorMigjudgeC6FC9FC8", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Evaluator-migjudge-Id" + } + }, + "ApplicationEvaluatorMigjudgeArnOutput60E53358": { + "Description": "Evaluator ARN for: migjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorMigjudgeC6FC9FC8", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Evaluator-migjudge-Arn" + } + }, + "ApplicationPolicyEngineMigpeIdOutput3A67DFCB": { + "Description": "Policy Engine ID for: migpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeE0487B41", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-PolicyEngine-migpe-Id" + } + }, + "ApplicationPolicyEngineMigpeArnOutput122EDEC2": { + "Description": "Policy Engine ARN for: migpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeE0487B41", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-PolicyEngine-migpe-Arn" + } + }, + "ApplicationPolicyMigpeMigpolIdOutputEE278BC5": { + "Description": "Policy ID for: migpe/migpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpePolicymigpolFB03816C", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Policy-migpe-migpol-Id" + } + }, + "ApplicationPolicyMigpeMigpolArnOutput51A0ACA2": { + "Description": "Policy ARN for: migpe/migpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpePolicymigpolFB03816C", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Policy-migpe-migpol-Arn" + } + }, + "ApplicationConfigBundleMigcbIdOutput18B5E83B": { + "Description": "ConfigBundle ID for: migcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbFF975EE3", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-Id" + } + }, + "ApplicationConfigBundleMigcbArnOutputE806D4B0": { + "Description": "ConfigBundle ARN for: migcb", + "Value": { + "Ref": "ApplicationConfigBundleMigcbFF975EE3" + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-Arn" + } + }, + "ApplicationConfigBundleMigcbVersionIdOutputF73A96A6": { + "Description": "ConfigBundle VersionId for: migcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbFF975EE3", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-VersionId" + } + }, + "GatewayMiggwIdOutput": { + "Description": "Gateway ID for: miggw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Id" + } + }, + "GatewayMiggwArnOutput": { + "Description": "Gateway ARN for: miggw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Arn" + } + }, + "GatewayMiggwUrlOutput": { + "Description": "Gateway URL for: miggw", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMiggw6C5DF251", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Url" + } + }, + "GatewayTargetKnowledgeIdOutput": { + "Description": "Gateway Target ID for: knowledge (gateway: miggw)", + "Value": { + "Fn::GetAtt": [ + "McpGatewayMiggwTargetKnowledge2C2802EB", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-GatewayTarget-knowledge-Id" + } + }, + "StackNameOutput": { + "Description": "Name of the CloudFormation Stack", + "Value": "AgentCore-CompatMig-default" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/templates/pinned.v1.json b/src/core/project/migrate/__fixtures__/templates/pinned.v1.json new file mode 100644 index 000000000..991e69a29 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/templates/pinned.v1.json @@ -0,0 +1,997 @@ +{ + "Description": "AgentCore stack for CompatMig deployed to default (us-east-1)", + "Resources": { + "ApplicationMemoryMigagentMemoryServiceRole962E4E0E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/CompatMig_migagentMemory*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/MemoryMigagentMemory/Resource/ServiceRole/Resource" + } + }, + "ApplicationMemoryMigagentMemoryMemoryA587350B": { + "Type": "AWS::BedrockAgentCore::Memory", + "Properties": { + "EventExpiryDuration": 30, + "MemoryExecutionRoleArn": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryServiceRole962E4E0E", + "Arn" + ] + }, + "MemoryStrategies": [ + { + "SemanticMemoryStrategy": { + "Name": "migagentMemory_Semantic", + "NamespaceTemplates": [ + "/users/{actorId}/facts" + ] + } + }, + { + "UserPreferenceMemoryStrategy": { + "Name": "migagentMemory_Userpreference", + "NamespaceTemplates": [ + "/users/{actorId}/preferences" + ] + } + }, + { + "SummaryMemoryStrategy": { + "Name": "migagentMemory_Summarization", + "NamespaceTemplates": [ + "/summaries/{actorId}/{sessionId}" + ] + } + }, + { + "EpisodicMemoryStrategy": { + "Name": "migagentMemory_Episodic", + "NamespaceTemplates": [ + "/episodes/{actorId}/{sessionId}" + ], + "ReflectionConfiguration": { + "NamespaceTemplates": [ + "/episodes/{actorId}" + ] + } + } + } + ], + "Name": "CompatMig_migagentMemory", + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/MemoryMigagentMemory/Resource/Memory" + } + }, + "ApplicationEvaluatorMigjudge2EE36356": { + "Type": "AWS::BedrockAgentCore::Evaluator", + "Properties": { + "EvaluatorConfig": { + "LlmAsAJudge": { + "Instructions": "Rate the session. Context: {context}", + "ModelConfig": { + "BedrockEvaluatorModelConfig": { + "InferenceConfig": { + "MaxTokens": 4096, + "Temperature": 0 + }, + "ModelId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + } + }, + "RatingScale": { + "Numerical": [ + { + "Definition": "Fails to meet expectations", + "Label": "Poor", + "Value": 1 + }, + { + "Definition": "Partially meets expectations", + "Label": "Fair", + "Value": 2 + }, + { + "Definition": "Meets expectations", + "Label": "Good", + "Value": 3 + }, + { + "Definition": "Exceeds expectations", + "Label": "Very Good", + "Value": 4 + }, + { + "Definition": "Far exceeds expectations", + "Label": "Excellent", + "Value": 5 + } + ] + } + } + }, + "EvaluatorName": "CompatMig_migjudge", + "Level": "SESSION", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/EvaluatorMigjudge/Resource/Resource" + } + }, + "ApplicationPolicyEngineMigpeFE026C1F": { + "Type": "AWS::BedrockAgentCore::PolicyEngine", + "Properties": { + "Name": "CompatMig_migpe", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/PolicyEngineMigpe/Resource/Resource" + } + }, + "ApplicationPolicyEngineMigpePolicymigpolFB03816C": { + "Type": "AWS::BedrockAgentCore::Policy", + "Properties": { + "Definition": { + "Policy": { + "Statement": "permit(principal, action, resource is AgentCore::Gateway);" + } + }, + "EnforcementMode": "ACTIVE", + "Name": "migpol", + "PolicyEngineId": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeFE026C1F", + "PolicyEngineId" + ] + }, + "ValidationMode": "IGNORE_ALL_FINDINGS" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/PolicyEngineMigpe/Policymigpol" + } + }, + "ApplicationRuntimeMigagentRuntimeExecutionRoleAA3B35E0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/CompatMig_migagent*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Execution role for Bedrock Agent Core Runtime", + "MaxSessionDuration": 28800, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/RuntimeMigagent/Runtime/Resource/ExecutionRole/Resource" + } + }, + "ApplicationRuntimeMigagentRuntimeExecutionRoleDefaultPolicy4779000F": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:DescribeLogStreams" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*", + "Sid": "LogGroupAccess" + }, + { + "Action": "logs:DescribeLogGroups", + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:*", + "Sid": "DescribeLogGroups" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*", + "Sid": "LogStreamAccess" + }, + { + "Action": [ + "xray:GetSamplingRules", + "xray:GetSamplingTargets", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "XRayAccess" + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "bedrock-agentcore" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "CloudWatchMetrics" + }, + { + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default", + "arn:aws:bedrock-agentcore:us-east-1:123456789012:workload-identity-directory/default/workload-identity/*" + ], + "Sid": "GetAgentAccessToken" + }, + { + "Action": [ + "bedrock:CountTokens", + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:bedrock:*:123456789012:inference-profile/*", + "arn:aws:bedrock:*::foundation-model/*" + ] + }, + { + "Action": [ + "logs:FilterLogEvents", + "logs:GetLogEvents", + "logs:PutResourcePolicy" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/bedrock-agentcore/runtimes/*" + }, + { + "Action": [ + "bedrock-agentcore:CreateConfigurationBundle", + "bedrock-agentcore:DeleteConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundle", + "bedrock-agentcore:GetConfigurationBundleVersion", + "bedrock-agentcore:ListConfigurationBundleVersions", + "bedrock-agentcore:ListConfigurationBundles", + "bedrock-agentcore:UpdateConfigurationBundle" + ], + "Effect": "Allow", + "Resource": "arn:aws:bedrock-agentcore:*:*:configuration-bundle/*" + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespace": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:ListMemoryRecords", + "bedrock-agentcore:RetrieveMemoryRecords" + ], + "Condition": { + "StringLike": { + "bedrock-agentcore:namespacePath": [ + "/users/*/facts", + "/users/*/preferences", + "/summaries/*/*", + "/episodes/*/*" + ] + } + }, + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryArn" + ] + } + }, + { + "Action": [ + "bedrock-agentcore:CreateEvent", + "bedrock-agentcore:DeleteEvent", + "bedrock-agentcore:DeleteMemoryRecord", + "bedrock-agentcore:GetEvent", + "bedrock-agentcore:GetMemory", + "bedrock-agentcore:GetMemoryRecord", + "bedrock-agentcore:ListActors", + "bedrock-agentcore:ListEvents", + "bedrock-agentcore:ListSessions" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryArn" + ] + } + }, + { + "Action": [ + "s3:GetBucket*", + "s3:GetObject*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1", + "arn:aws:s3:::cdk-hnb659fds-assets-123456789012-us-east-1/*" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ApplicationRuntimeMigagentRuntimeExecutionRoleDefaultPolicy4779000F", + "Roles": [ + { + "Ref": "ApplicationRuntimeMigagentRuntimeExecutionRoleAA3B35E0" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/RuntimeMigagent/Runtime/Resource/ExecutionRole/DefaultPolicy/Resource" + } + }, + "ApplicationRuntimeMigagentRuntime93E3CAC2": { + "Type": "AWS::BedrockAgentCore::Runtime", + "Properties": { + "AgentRuntimeArtifact": { + "CodeConfiguration": { + "Code": { + "S3": { + "Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "Prefix": "0b1a55f11346d409f729b7b0dd98653be08393210ee12c23066686f97c5830bf.zip" + } + }, + "EntryPoint": [ + "opentelemetry-instrument", + "main.py" + ], + "Runtime": "PYTHON_3_14" + } + }, + "AgentRuntimeName": "CompatMig_migagent", + "Description": "AgentCore Runtime: CompatMig_migagent", + "EnvironmentVariables": { + "MEMORY_MIGAGENTMEMORY_ID": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryId" + ] + }, + "MEMORY_MIGAGENTMEMORY_ARN": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryArn" + ] + }, + "AGENTCORE_GATEWAY_MIGGW_URL": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayUrl" + ] + }, + "/mcp" + ] + ] + }, + "AGENTCORE_GATEWAY_MIGGW_ARN": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayArn" + ] + }, + "AGENTCORE_GATEWAY_MIGGW_AUTH_TYPE": "NONE" + }, + "LifecycleConfiguration": {}, + "NetworkConfiguration": { + "NetworkMode": "PUBLIC" + }, + "ProtocolConfiguration": "HTTP", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationRuntimeMigagentRuntimeExecutionRoleAA3B35E0", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "DependsOn": [ + "ApplicationRuntimeMigagentRuntimeExecutionRoleDefaultPolicy4779000F", + "ApplicationRuntimeMigagentRuntimeExecutionRoleAA3B35E0" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/RuntimeMigagent/Runtime/Resource/Resource" + } + }, + "ApplicationConfigBundleMigcbComponentsE5AB18B9": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"", + { + "Fn::GetAtt": [ + "ApplicationRuntimeMigagentRuntime93E3CAC2", + "AgentRuntimeArn" + ] + }, + "\":{\"Configuration\":{\"k\":\"v\"}}}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/ConfigBundleMigcb/Components/Resource/Default" + } + }, + "ApplicationConfigBundleMigcbFF975EE3": { + "Type": "AWS::BedrockAgentCore::ConfigurationBundle", + "Properties": { + "BranchName": "mainline", + "BundleName": "CompatMigmigcb", + "Components": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbComponentsE5AB18B9", + "Value" + ] + }, + "CreatedBy": { + "Name": "user" + }, + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/ConfigBundleMigcb/Resource" + } + }, + "ApplicationGatewayMiggwServiceRoleAAE20EFE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + }, + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "123456789012" + }, + "ArnLike": { + "aws:SourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/CompatMig-miggw*" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "bedrock-agentcore.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Description": "Service role for Bedrock AgentCore Gateway CompatMig-miggw", + "Tags": [ + { + "Key": "agentcore:created-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:project-name", + "Value": "CompatMig" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/GatewayMiggw/Resource/ServiceRole/Resource" + } + }, + "ApplicationGatewayMiggw0873926D": { + "Type": "AWS::BedrockAgentCore::Gateway", + "Properties": { + "AuthorizerType": "NONE", + "Description": "Gateway for miggw", + "Name": "CompatMig-miggw", + "RoleArn": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggwServiceRoleAAE20EFE", + "Arn" + ] + }, + "Tags": { + "agentcore:created-by": "agentcore-cli", + "agentcore:project-name": "CompatMig" + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/GatewayMiggw/Resource/Resource" + } + }, + "ApplicationGatewayMiggwTargetKnowledgeDB1A5555": { + "Type": "AWS::BedrockAgentCore::GatewayTarget", + "Properties": { + "Description": "External MCP server: knowledge", + "GatewayIdentifier": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayIdentifier" + ] + }, + "Name": "knowledge", + "TargetConfiguration": { + "Mcp": { + "McpServer": { + "Endpoint": "https://knowledge-mcp.global.api.aws" + } + } + } + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/Application/GatewayMiggw/Resource/TargetKnowledge/Resource" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Role" + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "cdk-hnb659fds-assets-123456789012-us-east-1", + "S3Key": "3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a.zip" + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs24.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ], + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/AWSCDKCfnUtilsProviderCustomResourceProvider/Handler", + "aws:asset:path": "asset.3fc3a788e8730811e7ec758fb84134e1e24877f8248901484d1e046dee803b5a", + "aws:asset:property": "Code" + } + }, + "CDKMetadata": { + "Type": "AWS::CDK::Metadata", + "Properties": { + "Analytics": "v2:deflate64:H4sIAAAAAAAA/+WU32sbMQzH/5b6cTjeuMBgeUuyUjoYC0nejlJUW726ubODJScLx/3vw/erbQrrwzY22MNx9vcjWZaRlKns4yf14QKONNFmNyntnao3DHon4Ui39R2a4PUOCnSsfUBVf8XKh1Mu3gl59t3I5b3rsLw8QBmBfcgHMCpy5UurT5eusA5H/Fx82st1dGyrJ7N+n5ZL7+5tEQOw9W4RnSlRXgHjEcbbtS69NrAthAI5P6Od2kgLlarXvsS8FkAUKzSLk5jVYh+s03YP5VxrHx2LWRujs0kOc53u0cpNI3/FWwqDpIPdj5IUFXzfIJH17nOfcW9bCzBm61dDgO7dxCyvm5uWMoN+uHaldTiylPzb8Ofn/lX6z7r8EfobK6npusiX2Ldh2ypDJWx9Iq8K5LVszIaBsULH9CKN/1R+NsIaSdNbIEImNU8/SVO1iHqHvADC/g1bsmEorCuS77fI+8hp9YW8k8tI7Ks1ko9B44txNohnNqvgD9ZgSDEa6bxB9UjvD1mmsqnKLh7J2knohqfqh+gPhaUHyPwFAAA=" + }, + "Metadata": { + "aws:cdk:path": "AgentCore-CompatMig-default/CDKMetadata/Default" + } + } + }, + "Outputs": { + "ApplicationRuntimeMigagentRuntimeIdOutput1791FB8A": { + "Description": "Runtime ID for: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMigagentRuntime93E3CAC2", + "AgentRuntimeId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RuntimeId" + } + }, + "ApplicationRuntimeMigagentRuntimeArnOutput5180CD55": { + "Description": "Runtime ARN for: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMigagentRuntime93E3CAC2", + "AgentRuntimeArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RuntimeArn" + } + }, + "ApplicationRuntimeMigagentRoleArnOutputC2058FB0": { + "Description": "Execution Role ARN for: migagent", + "Value": { + "Fn::GetAtt": [ + "ApplicationRuntimeMigagentRuntimeExecutionRoleAA3B35E0", + "Arn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-migagent-RoleArn" + } + }, + "ApplicationMemoryMigagentMemoryIdOutputAA6574F9": { + "Description": "Memory ID for: migagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Memory-migagentMemory-Id" + } + }, + "ApplicationMemoryMigagentMemoryArnOutput1D1C7E26": { + "Description": "Memory ARN for: migagentMemory", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Memory-migagentMemory-Arn" + } + }, + "ApplicationMemoryIds538D889E": { + "Description": "Memory IDs for the project", + "Value": { + "Fn::GetAtt": [ + "ApplicationMemoryMigagentMemoryMemoryA587350B", + "MemoryId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-MemoryIds" + } + }, + "ApplicationMemoryNames676588DB": { + "Description": "Memory names for the project", + "Value": "migagentMemory", + "Export": { + "Name": "AgentCore-CompatMig-default-MemoryNames" + } + }, + "ApplicationEvaluatorMigjudgeIdOutputC87728F1": { + "Description": "Evaluator ID for: migjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorMigjudge2EE36356", + "EvaluatorId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Evaluator-migjudge-Id" + } + }, + "ApplicationEvaluatorMigjudgeArnOutput60E53358": { + "Description": "Evaluator ARN for: migjudge", + "Value": { + "Fn::GetAtt": [ + "ApplicationEvaluatorMigjudge2EE36356", + "EvaluatorArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Evaluator-migjudge-Arn" + } + }, + "ApplicationPolicyEngineMigpeIdOutput3A67DFCB": { + "Description": "Policy Engine ID for: migpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeFE026C1F", + "PolicyEngineId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-PolicyEngine-migpe-Id" + } + }, + "ApplicationPolicyEngineMigpeArnOutput122EDEC2": { + "Description": "Policy Engine ARN for: migpe", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpeFE026C1F", + "PolicyEngineArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-PolicyEngine-migpe-Arn" + } + }, + "ApplicationPolicyMigpeMigpolIdOutputEE278BC5": { + "Description": "Policy ID for: migpe/migpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpePolicymigpolFB03816C", + "PolicyId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Policy-migpe-migpol-Id" + } + }, + "ApplicationPolicyMigpeMigpolArnOutput51A0ACA2": { + "Description": "Policy ARN for: migpe/migpol", + "Value": { + "Fn::GetAtt": [ + "ApplicationPolicyEngineMigpePolicymigpolFB03816C", + "PolicyArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Policy-migpe-migpol-Arn" + } + }, + "ApplicationConfigBundleMigcbIdOutput18B5E83B": { + "Description": "ConfigBundle ID for: migcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbFF975EE3", + "BundleId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-Id" + } + }, + "ApplicationConfigBundleMigcbArnOutputE806D4B0": { + "Description": "ConfigBundle ARN for: migcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbFF975EE3", + "BundleArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-Arn" + } + }, + "ApplicationConfigBundleMigcbVersionIdOutputF73A96A6": { + "Description": "ConfigBundle VersionId for: migcb", + "Value": { + "Fn::GetAtt": [ + "ApplicationConfigBundleMigcbFF975EE3", + "VersionId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-ConfigBundle-migcb-VersionId" + } + }, + "ApplicationGatewayMiggwIdOutput20494AEF": { + "Description": "Gateway ID for: miggw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayIdentifier" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Id" + } + }, + "ApplicationGatewayMiggwArnOutput771373B9": { + "Description": "Gateway ARN for: miggw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayArn" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Arn" + } + }, + "ApplicationGatewayMiggwUrlOutputC8DB24C4": { + "Description": "Gateway URL for: miggw", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggw0873926D", + "GatewayUrl" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-Gateway-miggw-Url" + } + }, + "ApplicationGatewayTargetKnowledgeIdOutputD4F10D1F": { + "Description": "Gateway Target ID for: knowledge (gateway: miggw)", + "Value": { + "Fn::GetAtt": [ + "ApplicationGatewayMiggwTargetKnowledgeDB1A5555", + "TargetId" + ] + }, + "Export": { + "Name": "AgentCore-CompatMig-default-GatewayTarget-knowledge-Id" + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/app-0.3.0-preview.1.0.json b/src/core/project/migrate/__fixtures__/v0-app/app-0.3.0-preview.1.0.json new file mode 100644 index 000000000..c1d42644f --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/app-0.3.0-preview.1.0.json @@ -0,0 +1,13 @@ +{ + ".prettierrc": "{\n \"trailingComma\": \"es5\",\n \"printWidth\": 120,\n \"tabWidth\": 2,\n \"semi\": true,\n \"singleQuote\": true,\n \"arrowParens\": \"avoid\"\n}\n", + "README.md": "# Welcome to your CDK TypeScript project\n\nThis is a blank project for CDK development with TypeScript.\n\nThe `cdk.json` file tells the CDK Toolkit how to execute your app.\n\n## Useful commands\n\n- `npm run build` compile typescript to js\n- `npm run watch` watch for changes and compile\n- `npm run test` perform the jest unit tests\n- `npx cdk deploy` deploy this stack to your default AWS account/region\n- `npx cdk diff` compare deployed stack with current state\n- `npx cdk synth` emits the synthesized CloudFormation template\n", + "bin/cdk.ts": "#!/usr/bin/env node\nimport { AgentCoreStack } from '../lib/cdk-stack';\nimport { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk';\nimport { App, type Environment } from 'aws-cdk-lib';\nimport * as path from 'path';\n\nfunction toEnvironment(target: AwsDeploymentTarget): Environment {\n return {\n account: target.account,\n region: target.region,\n };\n}\n\nfunction toStackName(projectName: string, targetName: string): string {\n return `AgentCore-${projectName}-${targetName}`;\n}\n\nasync function main() {\n // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/.\n const configRoot = path.resolve(process.cwd(), '..');\n const configIO = new ConfigIO({ baseDir: configRoot });\n\n const spec = await configIO.readProjectSpec();\n const targets = await configIO.readAWSDeploymentTargets();\n\n if (targets.length === 0) {\n throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');\n }\n\n const app = new App();\n\n for (const target of targets) {\n const env = toEnvironment(target);\n const stackName = toStackName(spec.name, target.name);\n\n new AgentCoreStack(app, stackName, {\n spec,\n env,\n description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,\n tags: {\n 'agentcore:project-name': spec.name,\n 'agentcore:target-name': target.name,\n },\n });\n }\n\n app.synth();\n}\n\nmain();\n", + "cdk.json": "{\n \"app\": \"node dist/bin/cdk.js\",\n \"watch\": {\n \"include\": [\"**\"],\n \"exclude\": [\"README.md\", \"cdk*.json\", \"tsconfig.json\", \"package*.json\", \"yarn.lock\", \"node_modules\", \"dist\", \"test\"]\n },\n \"context\": {\n \"@aws-cdk/aws-signer:signingProfileNamePassedToCfn\": true,\n \"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener\": true,\n \"@aws-cdk/aws-lambda:recognizeLayerVersion\": true,\n \"@aws-cdk/core:checkSecretUsage\": true,\n \"@aws-cdk/core:target-partitions\": [\"aws\", \"aws-cn\"],\n \"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver\": true,\n \"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName\": true,\n \"@aws-cdk/aws-ecs:arnFormatIncludesClusterName\": true,\n \"@aws-cdk/aws-iam:minimizePolicies\": true,\n \"@aws-cdk/core:validateSnapshotRemovalPolicy\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName\": true,\n \"@aws-cdk/aws-s3:createDefaultLoggingPolicy\": true,\n \"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption\": true,\n \"@aws-cdk/aws-apigateway:disableCloudWatchRole\": true,\n \"@aws-cdk/core:enablePartitionLiterals\": true,\n \"@aws-cdk/aws-events:eventsTargetQueueSameAccount\": true,\n \"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker\": true,\n \"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName\": true,\n \"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy\": true,\n \"@aws-cdk/aws-route53-patters:useCertificate\": true,\n \"@aws-cdk/customresources:installLatestAwsSdkDefault\": false,\n \"@aws-cdk/aws-rds:databaseProxyUniqueResourceName\": true,\n \"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup\": true,\n \"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId\": true,\n \"@aws-cdk/aws-ec2:launchTemplateDefaultUserData\": true,\n \"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments\": true,\n \"@aws-cdk/aws-redshift:columnId\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2\": true,\n \"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup\": true,\n \"@aws-cdk/aws-apigateway:requestValidatorUniqueId\": true,\n \"@aws-cdk/aws-kms:aliasNameRef\": true,\n \"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal\": true,\n \"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig\": true,\n \"@aws-cdk/core:includePrefixInUniqueNameGeneration\": true,\n \"@aws-cdk/aws-efs:denyAnonymousAccess\": true,\n \"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby\": true,\n \"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion\": true,\n \"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId\": true,\n \"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters\": true,\n \"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier\": true,\n \"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials\": true,\n \"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource\": true,\n \"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse\": true,\n \"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2\": true,\n \"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope\": true,\n \"@aws-cdk/aws-eks:nodegroupNameAttribute\": true,\n \"@aws-cdk/aws-ec2:ebsDefaultGp3Volume\": true,\n \"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm\": true,\n \"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault\": false,\n \"@aws-cdk/aws-s3:keepNotificationInImportedBucket\": false,\n \"@aws-cdk/core:explicitStackTags\": true,\n \"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature\": false,\n \"@aws-cdk/aws-ecs:disableEcsImdsBlocking\": true,\n \"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions\": true,\n \"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica\": true,\n \"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled\": true,\n \"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission\": true,\n \"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId\": true,\n \"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics\": true,\n \"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy\": true,\n \"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault\": true,\n \"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource\": true,\n \"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault\": true,\n \"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections\": true,\n \"@aws-cdk/core:enableAdditionalMetadataCollection\": true,\n \"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy\": false,\n \"@aws-cdk/aws-s3:setUniqueReplicationRoleName\": true,\n \"@aws-cdk/aws-events:requireEventBusPolicySid\": true,\n \"@aws-cdk/core:aspectPrioritiesMutating\": true,\n \"@aws-cdk/aws-dynamodb:retainTableReplica\": true,\n \"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2\": true,\n \"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions\": true,\n \"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway\": true,\n \"@aws-cdk/aws-s3:publicAccessBlockedByDefault\": true,\n \"@aws-cdk/aws-lambda:useCdkManagedLogGroup\": true,\n \"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault\": true,\n \"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId\": true\n }\n}\n", + ".gitignore": "# Build output\ndist/\n\n# Dependencies\nnode_modules/\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "jest.config.js": "module.exports = {\n testEnvironment: 'node',\n roots: ['/test'],\n testMatch: ['**/*.test.ts'],\n transform: {\n '^.+\\\\.tsx?$': 'ts-jest',\n },\n setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],\n};\n", + "lib/cdk-stack.ts": "import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk';\nimport { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';\nimport { Construct } from 'constructs';\n\nexport interface AgentCoreStackProps extends StackProps {\n /**\n * The AgentCore project specification containing agents, memories, and credentials.\n */\n spec: AgentCoreProjectSpec;\n}\n\n/**\n * CDK Stack that deploys AgentCore infrastructure.\n *\n * This is a thin wrapper that instantiates L3 constructs.\n * All resource logic and outputs are contained within the L3 constructs.\n */\nexport class AgentCoreStack extends Stack {\n /** The AgentCore application containing all agent environments */\n public readonly application: AgentCoreApplication;\n\n constructor(scope: Construct, id: string, props: AgentCoreStackProps) {\n super(scope, id, props);\n\n const { spec } = props;\n\n // Create AgentCoreApplication with all agents\n this.application = new AgentCoreApplication(this, 'Application', {\n spec,\n });\n\n // Stack-level output\n new CfnOutput(this, 'StackNameOutput', {\n description: 'Name of the CloudFormation Stack',\n value: this.stackName,\n });\n }\n}\n", + ".npmignore": "*.ts\n!*.d.ts\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "package.json": "{\n \"name\": \"agentcore-cdk-app\",\n \"version\": \"0.1.0\",\n \"bin\": {\n \"cdk\": \"dist/bin/cdk.js\"\n },\n \"scripts\": {\n \"build\": \"tsc\",\n \"watch\": \"tsc -w\",\n \"test\": \"jest\",\n \"cdk\": \"npm run build && cdk\",\n \"clean\": \"rm -rf dist\",\n \"format\": \"prettier --write .\",\n \"format:check\": \"prettier --check .\"\n },\n \"devDependencies\": {\n \"@types/jest\": \"^29.5.14\",\n \"@types/node\": \"^24.10.1\",\n \"jest\": \"^29.7.0\",\n \"ts-jest\": \"^29.2.5\",\n \"aws-cdk\": \"2.1100.1\",\n \"prettier\": \"^3.4.2\",\n \"typescript\": \"~5.9.3\"\n },\n \"dependencies\": {\n \"@aws/agentcore-cdk\": \"^0.1.0-alpha.1\",\n \"aws-cdk-lib\": \"2.234.1\",\n \"constructs\": \"^10.0.0\"\n }\n}\n", + "test/cdk.test.ts": "// import * as cdk from 'aws-cdk-lib/core';\n// import { Template } from 'aws-cdk-lib/assertions';\n// import * as Cdk from '../lib/cdk-stack';\n\n// example test. To run these tests, uncomment this file along with the\n// example resource in lib/cdk-stack.ts\ntest('SQS Queue Created', () => {\n // const app = new cdk.App();\n // // WHEN\n // const stack = new Cdk.CdkStack(app, 'MyTestStack');\n // // THEN\n // const template = Template.fromStack(stack);\n // template.hasResourceProperties('AWS::SQS::Queue', {\n // VisibilityTimeout: 300\n // });\n});\n", + "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"CommonJS\",\n \"moduleResolution\": \"Node\",\n \"lib\": [\"es2022\"],\n \"declaration\": true,\n \"strict\": true,\n \"noImplicitAny\": true,\n \"strictNullChecks\": true,\n \"noImplicitThis\": true,\n \"alwaysStrict\": true,\n \"noUnusedLocals\": false,\n \"noUnusedParameters\": false,\n \"noImplicitReturns\": true,\n \"noFallthroughCasesInSwitch\": false,\n \"inlineSourceMap\": true,\n \"inlineSources\": true,\n \"experimentalDecorators\": true,\n \"strictPropertyInitialization\": false,\n \"skipLibCheck\": true,\n \"typeRoots\": [\"./node_modules/@types\"],\n \"rootDir\": \".\",\n \"outDir\": \"dist\"\n },\n \"include\": [\"bin/**/*\", \"lib/**/*\", \"test/**/*\"],\n \"exclude\": [\"node_modules\", \"cdk.out\", \"dist\"]\n}\n" +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/app-0.31.1.json b/src/core/project/migrate/__fixtures__/v0-app/app-0.31.1.json new file mode 100644 index 000000000..ff845126c --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/app-0.31.1.json @@ -0,0 +1,13 @@ +{ + ".prettierrc": "{\n \"trailingComma\": \"es5\",\n \"printWidth\": 120,\n \"tabWidth\": 2,\n \"semi\": true,\n \"singleQuote\": true,\n \"arrowParens\": \"avoid\"\n}\n", + "README.md": "# AgentCore CDK Project\n\nThis CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs.\n\n## Structure\n\n- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target.\n- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct.\n- `test/cdk.test.ts` — Unit tests for stack synthesis.\n\n## Useful commands\n\n- `npm run build` compile TypeScript to JavaScript\n- `npm run test` run unit tests\n- `npx cdk synth` emit the synthesized CloudFormation template\n- `npx cdk deploy` deploy this stack to your default AWS account/region\n- `npx cdk diff` compare deployed stack with current state\n\n## Usage\n\nYou typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment:\n\n```bash\nagentcore deploy # synthesizes and deploys via CDK\nagentcore status # checks deployment status\n```\n", + "bin/cdk.ts": "#!/usr/bin/env node\nimport { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack';\nimport { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk';\nimport { App, type Environment } from 'aws-cdk-lib';\nimport * as path from 'path';\nimport * as fs from 'fs';\n\nfunction toEnvironment(target: AwsDeploymentTarget): Environment {\n return {\n account: target.account,\n region: target.region,\n };\n}\n\nfunction sanitize(name: string): string {\n return name.replace(/_/g, '-');\n}\n\nfunction toStackName(projectName: string, targetName: string): string {\n return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`;\n}\n\nasync function main() {\n // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/.\n const configRoot = path.resolve(process.cwd(), '..');\n const configIO = new ConfigIO({ baseDir: configRoot });\n\n const spec = await configIO.readProjectSpec();\n const targets = await configIO.readAWSDeploymentTargets();\n\n // The vended CDK project compiles against the published @aws/agentcore-cdk\n // schema type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments,\n // harnesses, gateway fields). Cast once so those fields are reachable.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const specAny = spec as any;\n\n // Extract MCP configuration from project spec.\n // Gateway fields are stored in agentcore.json but may not yet be on the\n const mcpSpec = specAny.agentCoreGateways?.length\n ? {\n agentCoreGateways: specAny.agentCoreGateways,\n mcpRuntimeTools: specAny.mcpRuntimeTools,\n unassignedTargets: specAny.unassignedTargets,\n }\n : undefined;\n\n // Read deployed state for credential ARNs (populated by pre-deploy identity setup)\n let deployedState: Record | undefined;\n try {\n deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8'));\n } catch {\n // Deployed state may not exist on first deploy\n }\n\n if (targets.length === 0) {\n throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');\n }\n\n // Read harness configs: the full validated spec drives the CFN resource; the\n // role-scoped fields drive the IAM role + container build.\n const projectRoot = path.resolve(configRoot, '..');\n\n // Read non-S3 KB connector-config files and pass their parsed contents to the\n // L3 verbatim. The L3 does not read files; it expects the parsed\n // connectorParameters keyed by the data source's connectorConfigFile path.\n const connectorParametersByFile: Record> = {};\n for (const kb of specAny.knowledgeBases ?? []) {\n for (const ds of kb.dataSources ?? []) {\n if (ds.type !== 'S3' && ds.connectorConfigFile) {\n const abs = path.resolve(projectRoot, ds.connectorConfigFile);\n try {\n connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8'));\n } catch (err) {\n throw new Error(\n `Could not read connector config '${ds.connectorConfigFile}' for knowledge base '${kb.name}' at ${abs}: ${err instanceof Error ? err.message : err}`\n );\n }\n }\n }\n }\n\n // Synthesize an AWS::BedrockAgentCore::Harness resource for each harness entry in the spec.\n const harnessConfigs: HarnessConfig[] = [];\n for (const entry of specAny.harnesses ?? []) {\n const harnessDir = path.resolve(projectRoot, entry.path);\n const harnessPath = path.resolve(harnessDir, 'harness.json');\n try {\n const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8')));\n harnessConfigs.push({\n name: entry.name,\n executionRoleArn: harnessSpec.executionRoleArn,\n // Only an `existing` memory ref carries a name to wire IAM against; managed memory is\n // owned by the harness (no sibling) and disabled has none — both resolve to undefined.\n memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined,\n containerUri: harnessSpec.containerUri,\n hasDockerfile: !!harnessSpec.dockerfile,\n dockerfile: harnessSpec.dockerfile,\n codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,\n tools: harnessSpec.tools,\n skills: harnessSpec.skills,\n apiKeyArn: harnessSpec.model?.apiKeyArn,\n efsAccessPoints: harnessSpec.efsAccessPoints,\n s3AccessPoints: harnessSpec.s3AccessPoints,\n apiFormat: harnessSpec.model?.apiFormat,\n // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource.\n spec: harnessSpec,\n harnessDir,\n });\n } catch (err) {\n throw new Error(\n `Could not read harness.json for \"${entry.name}\" at ${harnessPath}: ${err instanceof Error ? err.message : err}`\n );\n }\n }\n\n const app = new App();\n\n for (const target of targets) {\n const env = toEnvironment(target);\n const stackName = toStackName(spec.name, target.name);\n\n // Extract credentials from deployed state for this target\n const targetState = (deployedState as Record)?.targets as\n Record> | undefined;\n const targetResources = targetState?.[target.name]?.resources as Record | undefined;\n const credentials = targetResources?.credentials as\n Record | undefined;\n\n // Payment credential provider ARNs live in the same credentials map as identity credentials\n const paymentCredentials = credentials;\n\n const paymentSpec = specAny.payments?.length\n ? specAny.payments.map(\n (p: {\n name: string;\n description?: string;\n authorizerType: 'AWS_IAM' | 'CUSTOM_JWT';\n authorizerConfiguration?: unknown;\n autoPayment?: boolean;\n paymentToolAllowlist?: string[];\n networkPreferences?: string[];\n connectors: {\n name: string;\n provider?: string;\n provisionMode?: 'MANUAL' | 'QUICK_CREATE';\n credentialName?: string;\n }[];\n }) => ({\n name: p.name,\n description: p.description,\n authorizerType: p.authorizerType,\n authorizerConfiguration: p.authorizerConfiguration,\n autoPayment: p.autoPayment,\n paymentToolAllowlist: p.paymentToolAllowlist,\n networkPreferences: p.networkPreferences,\n connectors: p.connectors.map(c => {\n if (c.provisionMode === 'QUICK_CREATE') {\n return {\n name: c.name,\n provider: 'CoinbaseCDP' as const,\n provisionMode: 'QUICK_CREATE' as const,\n };\n }\n\n if (!c.credentialName) {\n throw new Error(\n `Manual payment connector \"${c.name}\" on manager \"${p.name}\" is missing its credential name.`\n );\n }\n const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn;\n if (!credentialProviderArn) {\n // Fail fast with an actionable message rather than passing an empty\n // ARN that fails opaquely server-side at CreatePaymentConnector.\n throw new Error(\n `Payment connector \"${c.name}\" on manager \"${p.name}\" references credential ` +\n `\"${c.credentialName}\", but no deployed credential provider was found for it. ` +\n `Run \\`agentcore deploy\\` so the credential provider is created first.`\n );\n }\n return {\n name: c.name,\n provider: c.provider,\n ...(c.provisionMode && { provisionMode: c.provisionMode }),\n credentialName: c.credentialName,\n credentialProviderArn,\n };\n }),\n })\n )\n : undefined;\n\n new AgentCoreStack(app, stackName, {\n spec,\n mcpSpec,\n credentials,\n connectorParametersByFile,\n harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined,\n paymentSpec,\n env,\n description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,\n tags: {\n 'agentcore:project-name': spec.name,\n 'agentcore:target-name': target.name,\n },\n });\n }\n\n app.synth();\n}\n\nmain().catch((error: unknown) => {\n console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error);\n process.exit(1);\n});\n", + "cdk.json": "{\n \"app\": \"node dist/bin/cdk.js\",\n \"watch\": {\n \"include\": [\"**\"],\n \"exclude\": [\"README.md\", \"cdk*.json\", \"tsconfig.json\", \"package*.json\", \"yarn.lock\", \"node_modules\", \"dist\", \"test\"]\n },\n \"context\": {\n \"@aws-cdk/aws-signer:signingProfileNamePassedToCfn\": true,\n \"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener\": true,\n \"@aws-cdk/aws-lambda:recognizeLayerVersion\": true,\n \"@aws-cdk/core:checkSecretUsage\": true,\n \"@aws-cdk/core:target-partitions\": [\"aws\", \"aws-cn\", \"aws-us-gov\"],\n \"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver\": true,\n \"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName\": true,\n \"@aws-cdk/aws-ecs:arnFormatIncludesClusterName\": true,\n \"@aws-cdk/aws-iam:minimizePolicies\": true,\n \"@aws-cdk/core:validateSnapshotRemovalPolicy\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName\": true,\n \"@aws-cdk/aws-s3:createDefaultLoggingPolicy\": true,\n \"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption\": true,\n \"@aws-cdk/aws-apigateway:disableCloudWatchRole\": true,\n \"@aws-cdk/core:enablePartitionLiterals\": true,\n \"@aws-cdk/aws-events:eventsTargetQueueSameAccount\": true,\n \"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker\": true,\n \"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName\": true,\n \"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy\": true,\n \"@aws-cdk/aws-route53-patters:useCertificate\": true,\n \"@aws-cdk/customresources:installLatestAwsSdkDefault\": false,\n \"@aws-cdk/aws-rds:databaseProxyUniqueResourceName\": true,\n \"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup\": true,\n \"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId\": true,\n \"@aws-cdk/aws-ec2:launchTemplateDefaultUserData\": true,\n \"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments\": true,\n \"@aws-cdk/aws-redshift:columnId\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2\": true,\n \"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup\": true,\n \"@aws-cdk/aws-apigateway:requestValidatorUniqueId\": true,\n \"@aws-cdk/aws-kms:aliasNameRef\": true,\n \"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal\": true,\n \"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig\": true,\n \"@aws-cdk/core:includePrefixInUniqueNameGeneration\": true,\n \"@aws-cdk/aws-efs:denyAnonymousAccess\": true,\n \"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby\": true,\n \"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion\": true,\n \"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId\": true,\n \"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters\": true,\n \"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier\": true,\n \"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials\": true,\n \"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource\": true,\n \"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse\": true,\n \"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2\": true,\n \"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope\": true,\n \"@aws-cdk/aws-eks:nodegroupNameAttribute\": true,\n \"@aws-cdk/aws-ec2:ebsDefaultGp3Volume\": true,\n \"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm\": true,\n \"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault\": false,\n \"@aws-cdk/aws-s3:keepNotificationInImportedBucket\": false,\n \"@aws-cdk/core:explicitStackTags\": true,\n \"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature\": false,\n \"@aws-cdk/aws-ecs:disableEcsImdsBlocking\": true,\n \"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions\": true,\n \"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica\": true,\n \"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled\": true,\n \"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission\": true,\n \"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId\": true,\n \"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics\": true,\n \"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy\": true,\n \"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault\": true,\n \"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource\": true,\n \"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault\": true,\n \"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections\": true,\n \"@aws-cdk/core:enableAdditionalMetadataCollection\": true,\n \"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy\": false,\n \"@aws-cdk/aws-s3:setUniqueReplicationRoleName\": true,\n \"@aws-cdk/aws-events:requireEventBusPolicySid\": true,\n \"@aws-cdk/core:aspectPrioritiesMutating\": true,\n \"@aws-cdk/aws-dynamodb:retainTableReplica\": true,\n \"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2\": true,\n \"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions\": true,\n \"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway\": true,\n \"@aws-cdk/aws-s3:publicAccessBlockedByDefault\": true,\n \"@aws-cdk/aws-lambda:useCdkManagedLogGroup\": true,\n \"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault\": true,\n \"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId\": true\n }\n}\n", + ".gitignore": "# Build output\ndist/\n\n# Dependencies\nnode_modules/\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "jest.config.js": "module.exports = {\n testEnvironment: 'node',\n roots: ['/test'],\n testMatch: ['**/*.test.ts'],\n transform: {\n '^.+\\\\.tsx?$': 'ts-jest',\n },\n setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],\n};\n", + "lib/cdk-stack.ts": "import {\n AgentCoreApplication,\n AgentCoreMcp,\n AgentCorePaymentManager,\n AgentCorePaymentConnector,\n type AgentCoreProjectSpec,\n type AgentCoreMcpSpec,\n type CustomJWTAuthorizerConfig,\n type HarnessDeploymentConfig,\n} from '@aws/agentcore-cdk';\nimport { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';\nimport * as iam from 'aws-cdk-lib/aws-iam';\nimport { Construct } from 'constructs';\n\n/**\n * Harness deployment config: role-scoped fields (for IAM role + container build)\n * plus the full validated spec + its config directory so the L3 construct can\n * synthesize the AWS::BedrockAgentCore::Harness resource.\n */\nexport type HarnessConfig = HarnessDeploymentConfig;\n\nexport interface ManualPaymentConnectorSpec {\n name: string;\n provider: 'CoinbaseCDP' | 'StripePrivy';\n provisionMode?: 'MANUAL';\n credentialName: string;\n credentialProviderArn: string;\n}\n\nexport interface QuickCreatePaymentConnectorSpec {\n name: string;\n provider: 'CoinbaseCDP';\n provisionMode: 'QUICK_CREATE';\n credentialName?: never;\n credentialProviderArn?: never;\n}\n\nexport type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec;\n\nexport interface PaymentSpec {\n name: string;\n description?: string;\n authorizerType: 'AWS_IAM' | 'CUSTOM_JWT';\n authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig };\n autoPayment?: boolean;\n paymentToolAllowlist?: string[];\n networkPreferences?: string[];\n connectors: PaymentConnectorSpec[];\n}\n\nexport interface AgentCoreStackProps extends StackProps {\n /**\n * The AgentCore project specification containing agents, memories, and credentials.\n */\n spec: AgentCoreProjectSpec;\n /**\n * The MCP specification containing gateways and servers.\n */\n mcpSpec?: AgentCoreMcpSpec;\n /**\n * Credential provider ARNs from deployed state, keyed by credential name.\n */\n credentials?: Record;\n /**\n * Harness role configurations.\n */\n harnesses?: HarnessConfig[];\n /**\n * Parsed connectorParameters for non-S3 KB data sources, keyed by\n * connectorConfigFile path. Forwarded to AgentCoreApplication.\n */\n connectorParametersByFile?: Record>;\n /**\n * Payment specifications with resolved credential provider ARNs.\n */\n paymentSpec?: PaymentSpec[];\n}\n\nfunction toCdkId(name: string): string {\n return name.replace(/_/g, '');\n}\n\n/**\n * Decide whether a deployed runtime should receive payment env vars + IAM grants.\n * Payments today only ships a runtime shim for Python HTTP runtimes; injecting\n * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes\n * would surface env vars they cannot consume and would dilute least-privilege\n * IAM grants for runtimes that never call ProcessPayment.\n */\nfunction isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean {\n if (agent.protocol && agent.protocol !== 'HTTP') {\n return false;\n }\n const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : '';\n const entrypointFile = entrypoint.split(':')[0] ?? '';\n return entrypointFile.endsWith('.py');\n}\n\n/**\n * CDK Stack that deploys AgentCore infrastructure.\n *\n * This is a thin wrapper that instantiates L3 constructs.\n * All resource logic and outputs are contained within the L3 constructs.\n */\nexport class AgentCoreStack extends Stack {\n /** The AgentCore application containing all agent environments */\n public readonly application: AgentCoreApplication;\n\n constructor(scope: Construct, id: string, props: AgentCoreStackProps) {\n super(scope, id, props);\n\n const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props;\n\n // Create AgentCoreApplication with all agents and harness roles\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const appProps: Record = { spec };\n if (harnesses?.length) {\n appProps.harnesses = harnesses;\n }\n if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) {\n appProps.connectorParametersByFile = connectorParametersByFile;\n }\n if (credentials) {\n appProps.credentials = credentials;\n }\n this.application = new AgentCoreApplication(this, 'Application', appProps as any);\n\n // Create AgentCoreMcp if there are gateways configured\n if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {\n new AgentCoreMcp(this, 'Mcp', {\n projectName: spec.name,\n mcpSpec,\n agentCoreApplication: this.application,\n credentials,\n projectTags: spec.tags,\n });\n }\n\n // Create payment infrastructure via CFN constructs\n if (paymentSpec && paymentSpec.length > 0) {\n for (const payment of paymentSpec) {\n const mgrId = toCdkId(payment.name);\n const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, {\n projectName: spec.name,\n name: payment.name,\n authorizerType: payment.authorizerType,\n description: payment.description,\n authorizerConfiguration: payment.authorizerConfiguration,\n tags: spec.tags,\n });\n\n const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`;\n\n // Wire env vars from construct output tokens into eligible agent environments only.\n // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that\n // can consume these env vars, and giving them sts:AssumeRole on the\n // ProcessPaymentRole would broaden the privilege surface unnecessarily.\n for (const env of this.application.environments.values()) {\n if (!isPaymentEligibleAgent(env.agent)) {\n continue;\n }\n env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn);\n env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn);\n\n // Grant runtime execution role permission to assume the ProcessPaymentRole.\n // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the\n // caller still needs sts:AssumeRole on its own role to perform the assumption.\n env.runtime.role.addToPrincipalPolicy(\n new iam.PolicyStatement({\n actions: ['sts:AssumeRole'],\n resources: [manager.processPaymentRoleArn],\n })\n );\n\n // Grant payment data-plane actions directly to the runtime role.\n //\n // NOTE: This deviates from the canonical role model in the AgentCore Payments\n // beta guide, which assigns Get/List/Create instrument+session actions to a\n // separate ManagementRole and limits the agent's role to ProcessPayment only.\n // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header)\n // calls GetPaymentInstrument internally during the 402 auto-pay path, so the\n // runtime role needs read access. CreatePaymentSession is included so\n // `agentcore invoke --auto-session` works without a separate ManagementRole\n // call. Tighten this if the SDK is updated to accept pre-fetched instrument\n // details and split create-session into a backend-only flow.\n env.runtime.role.addToPrincipalPolicy(\n new iam.PolicyStatement({\n actions: [\n 'bedrock-agentcore:GetPaymentInstrument',\n 'bedrock-agentcore:ListPaymentInstruments',\n 'bedrock-agentcore:GetPaymentInstrumentBalance',\n 'bedrock-agentcore:GetPaymentSession',\n 'bedrock-agentcore:ListPaymentSessions',\n 'bedrock-agentcore:CreatePaymentSession',\n 'bedrock-agentcore:ProcessPayment',\n ],\n resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`],\n })\n );\n\n if (payment.autoPayment !== undefined) {\n env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment));\n }\n if (payment.paymentToolAllowlist) {\n env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(','));\n }\n if (payment.networkPreferences) {\n env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(','));\n }\n if (payment.authorizerType === 'CUSTOM_JWT') {\n env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer');\n }\n }\n\n // Create connectors for this manager\n for (const connector of payment.connectors) {\n const connId = toCdkId(connector.name);\n const schemaConnector =\n connector.provisionMode === 'QUICK_CREATE'\n ? connector\n : {\n name: connector.name,\n provider: connector.provider,\n ...(connector.provisionMode && { provisionMode: connector.provisionMode }),\n credentialName: connector.credentialName,\n };\n const compatibilityProps = {\n projectName: spec.name,\n paymentManager: manager,\n connector: schemaConnector,\n // Remove these legacy manual fields after the new L3 release is pinned.\n connectorName: connector.name,\n connectorType: connector.provider,\n ...(connector.provisionMode !== 'QUICK_CREATE' && {\n credentialProviderArn: connector.credentialProviderArn,\n }),\n };\n const conn = new AgentCorePaymentConnector(\n this,\n `Payment${mgrId}${connId}`,\n compatibilityProps as unknown as ConstructorParameters[2]\n );\n\n // Wire first connector's ID as env var (eligible agents only)\n if (connector === payment.connectors[0]) {\n for (const env of this.application.environments.values()) {\n if (!isPaymentEligibleAgent(env.agent)) continue;\n env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId);\n }\n }\n\n new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, {\n value: conn.paymentConnectorId,\n });\n if (connector.provisionMode === 'QUICK_CREATE') {\n const quickCreateConnector = conn as AgentCorePaymentConnector & {\n paymentConnectorStatus: string;\n authorizationUrl: string;\n };\n new CfnOutput(this, `Payment${mgrId}${connId}ConnectorStatus`, {\n value: quickCreateConnector.paymentConnectorStatus,\n });\n new CfnOutput(this, `Payment${mgrId}${connId}AuthorizationUrl`, {\n value: quickCreateConnector.authorizationUrl,\n });\n }\n }\n\n // CFN Outputs for post-deploy state parsing\n new CfnOutput(this, `Payment${mgrId}ManagerArn`, {\n value: manager.paymentManagerArn,\n });\n new CfnOutput(this, `Payment${mgrId}ManagerId`, {\n value: manager.paymentManagerId,\n });\n new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, {\n value: manager.processPaymentRoleArn,\n });\n new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, {\n value: manager.resourceRetrievalRoleArn,\n });\n }\n }\n\n // Stack-level output\n new CfnOutput(this, 'StackNameOutput', {\n description: 'Name of the CloudFormation Stack',\n value: this.stackName,\n });\n }\n}\n", + ".npmignore": "*.ts\n!*.d.ts\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "package.json": "{\n \"name\": \"agentcore-cdk-app\",\n \"version\": \"0.1.0\",\n \"bin\": {\n \"cdk\": \"dist/bin/cdk.js\"\n },\n \"scripts\": {\n \"build\": \"tsc\",\n \"watch\": \"tsc -w\",\n \"test\": \"jest\",\n \"cdk\": \"npm run build && cdk\",\n \"clean\": \"rm -rf dist\",\n \"format\": \"prettier --write .\",\n \"format:check\": \"prettier --check .\"\n },\n \"devDependencies\": {\n \"@types/jest\": \"~29.5.14\",\n \"@types/node\": \"~24.13.3\",\n \"jest\": \"~29.7.0\",\n \"ts-jest\": \"~29.4.11\",\n \"aws-cdk\": \"~2.1126.0\",\n \"prettier\": \"~3.9.5\",\n \"typescript\": \"~5.9.3\"\n },\n \"dependencies\": {\n \"@aws/agentcore-cdk\": \"0.1.0-alpha.54\",\n \"aws-cdk-lib\": \"~2.261.0\",\n \"constructs\": \"~10.7.0\"\n }\n}\n", + "test/cdk.test.ts": "import * as cdk from 'aws-cdk-lib';\nimport { Template } from 'aws-cdk-lib/assertions';\nimport { AgentCoreStack } from '../lib/cdk-stack';\n\ntest('AgentCoreStack synthesizes with empty spec', () => {\n const app = new cdk.App();\n const stack = new AgentCoreStack(app, 'TestStack', {\n spec: {\n name: 'testproject',\n version: 1,\n managedBy: 'CDK' as const,\n runtimes: [],\n memories: [],\n credentials: [],\n evaluators: [],\n onlineEvalConfigs: [],\n configBundles: [],\n policyEngines: [],\n payments: [],\n agentCoreGateways: [],\n mcpRuntimeTools: [],\n unassignedTargets: [],\n datasets: [],\n knowledgeBases: [],\n },\n });\n const template = Template.fromStack(stack);\n template.hasOutput('StackNameOutput', {\n Description: 'Name of the CloudFormation Stack',\n });\n});\n", + "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"CommonJS\",\n \"moduleResolution\": \"Node\",\n \"lib\": [\"es2022\"],\n \"declaration\": true,\n \"strict\": true,\n \"noImplicitAny\": true,\n \"strictNullChecks\": true,\n \"noImplicitThis\": true,\n \"alwaysStrict\": true,\n \"noUnusedLocals\": true,\n \"noUnusedParameters\": true,\n \"noImplicitReturns\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"inlineSourceMap\": true,\n \"inlineSources\": true,\n \"experimentalDecorators\": true,\n \"strictPropertyInitialization\": true,\n \"skipLibCheck\": true,\n \"typeRoots\": [\"./node_modules/@types\"],\n \"rootDir\": \".\",\n \"outDir\": \"dist\"\n },\n \"include\": [\"bin/**/*\", \"lib/**/*\", \"test/**/*\"],\n \"exclude\": [\"node_modules\", \"cdk.out\", \"dist\"]\n}\n" +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/app-0.4.0.json b/src/core/project/migrate/__fixtures__/v0-app/app-0.4.0.json new file mode 100644 index 000000000..10dcc96d6 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/app-0.4.0.json @@ -0,0 +1,13 @@ +{ + ".prettierrc": "{\n \"trailingComma\": \"es5\",\n \"printWidth\": 120,\n \"tabWidth\": 2,\n \"semi\": true,\n \"singleQuote\": true,\n \"arrowParens\": \"avoid\"\n}\n", + "README.md": "# AgentCore CDK Project\n\nThis CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs.\n\n## Structure\n\n- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target.\n- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct.\n- `test/cdk.test.ts` — Unit tests for stack synthesis.\n\n## Useful commands\n\n- `npm run build` compile TypeScript to JavaScript\n- `npm run test` run unit tests\n- `npx cdk synth` emit the synthesized CloudFormation template\n- `npx cdk deploy` deploy this stack to your default AWS account/region\n- `npx cdk diff` compare deployed stack with current state\n\n## Usage\n\nYou typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment:\n\n```bash\nagentcore deploy # synthesizes and deploys via CDK\nagentcore status # checks deployment status\n```\n", + "bin/cdk.ts": "#!/usr/bin/env node\nimport { AgentCoreStack } from '../lib/cdk-stack';\nimport { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk';\nimport { App, type Environment } from 'aws-cdk-lib';\nimport * as path from 'path';\nimport * as fs from 'fs';\n\nfunction toEnvironment(target: AwsDeploymentTarget): Environment {\n return {\n account: target.account,\n region: target.region,\n };\n}\n\nfunction sanitize(name: string): string {\n return name.replace(/_/g, '-');\n}\n\nfunction toStackName(projectName: string, targetName: string): string {\n return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`;\n}\n\nasync function main() {\n // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/.\n const configRoot = path.resolve(process.cwd(), '..');\n const configIO = new ConfigIO({ baseDir: configRoot });\n\n const spec = await configIO.readProjectSpec();\n const targets = await configIO.readAWSDeploymentTargets();\n\n // Extract MCP configuration from project spec.\n // Gateway fields are stored in agentcore.json but may not yet be on the\n // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them\n // dynamically and cast the resulting object.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const specAny = spec as any;\n const mcpSpec = specAny.agentCoreGateways?.length\n ? {\n agentCoreGateways: specAny.agentCoreGateways,\n mcpRuntimeTools: specAny.mcpRuntimeTools,\n unassignedTargets: specAny.unassignedTargets,\n }\n : undefined;\n\n // Read deployed state for credential ARNs (populated by pre-deploy identity setup)\n let deployedState: Record | undefined;\n try {\n deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8'));\n } catch {\n // Deployed state may not exist on first deploy\n }\n\n if (targets.length === 0) {\n throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');\n }\n\n const app = new App();\n\n for (const target of targets) {\n const env = toEnvironment(target);\n const stackName = toStackName(spec.name, target.name);\n\n // Extract credentials from deployed state for this target\n const targetState = (deployedState as Record)?.targets as\n | Record>\n | undefined;\n const targetResources = targetState?.[target.name]?.resources as Record | undefined;\n const credentials = targetResources?.credentials as\n | Record\n | undefined;\n\n new AgentCoreStack(app, stackName, {\n spec,\n mcpSpec,\n credentials,\n env,\n description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,\n tags: {\n 'agentcore:project-name': spec.name,\n 'agentcore:target-name': target.name,\n },\n });\n }\n\n app.synth();\n}\n\nmain().catch((error: unknown) => {\n console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error);\n process.exitCode = 1;\n});\n", + "cdk.json": "{\n \"app\": \"node dist/bin/cdk.js\",\n \"watch\": {\n \"include\": [\"**\"],\n \"exclude\": [\"README.md\", \"cdk*.json\", \"tsconfig.json\", \"package*.json\", \"yarn.lock\", \"node_modules\", \"dist\", \"test\"]\n },\n \"context\": {\n \"@aws-cdk/aws-signer:signingProfileNamePassedToCfn\": true,\n \"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener\": true,\n \"@aws-cdk/aws-lambda:recognizeLayerVersion\": true,\n \"@aws-cdk/core:checkSecretUsage\": true,\n \"@aws-cdk/core:target-partitions\": [\"aws\", \"aws-cn\"],\n \"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver\": true,\n \"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName\": true,\n \"@aws-cdk/aws-ecs:arnFormatIncludesClusterName\": true,\n \"@aws-cdk/aws-iam:minimizePolicies\": true,\n \"@aws-cdk/core:validateSnapshotRemovalPolicy\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName\": true,\n \"@aws-cdk/aws-s3:createDefaultLoggingPolicy\": true,\n \"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption\": true,\n \"@aws-cdk/aws-apigateway:disableCloudWatchRole\": true,\n \"@aws-cdk/core:enablePartitionLiterals\": true,\n \"@aws-cdk/aws-events:eventsTargetQueueSameAccount\": true,\n \"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker\": true,\n \"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName\": true,\n \"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy\": true,\n \"@aws-cdk/aws-route53-patters:useCertificate\": true,\n \"@aws-cdk/customresources:installLatestAwsSdkDefault\": false,\n \"@aws-cdk/aws-rds:databaseProxyUniqueResourceName\": true,\n \"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup\": true,\n \"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId\": true,\n \"@aws-cdk/aws-ec2:launchTemplateDefaultUserData\": true,\n \"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments\": true,\n \"@aws-cdk/aws-redshift:columnId\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2\": true,\n \"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup\": true,\n \"@aws-cdk/aws-apigateway:requestValidatorUniqueId\": true,\n \"@aws-cdk/aws-kms:aliasNameRef\": true,\n \"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal\": true,\n \"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig\": true,\n \"@aws-cdk/core:includePrefixInUniqueNameGeneration\": true,\n \"@aws-cdk/aws-efs:denyAnonymousAccess\": true,\n \"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby\": true,\n \"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion\": true,\n \"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId\": true,\n \"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters\": true,\n \"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier\": true,\n \"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials\": true,\n \"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource\": true,\n \"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse\": true,\n \"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2\": true,\n \"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope\": true,\n \"@aws-cdk/aws-eks:nodegroupNameAttribute\": true,\n \"@aws-cdk/aws-ec2:ebsDefaultGp3Volume\": true,\n \"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm\": true,\n \"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault\": false,\n \"@aws-cdk/aws-s3:keepNotificationInImportedBucket\": false,\n \"@aws-cdk/core:explicitStackTags\": true,\n \"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature\": false,\n \"@aws-cdk/aws-ecs:disableEcsImdsBlocking\": true,\n \"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions\": true,\n \"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica\": true,\n \"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled\": true,\n \"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission\": true,\n \"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId\": true,\n \"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics\": true,\n \"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy\": true,\n \"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault\": true,\n \"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource\": true,\n \"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault\": true,\n \"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections\": true,\n \"@aws-cdk/core:enableAdditionalMetadataCollection\": true,\n \"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy\": false,\n \"@aws-cdk/aws-s3:setUniqueReplicationRoleName\": true,\n \"@aws-cdk/aws-events:requireEventBusPolicySid\": true,\n \"@aws-cdk/core:aspectPrioritiesMutating\": true,\n \"@aws-cdk/aws-dynamodb:retainTableReplica\": true,\n \"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2\": true,\n \"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions\": true,\n \"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway\": true,\n \"@aws-cdk/aws-s3:publicAccessBlockedByDefault\": true,\n \"@aws-cdk/aws-lambda:useCdkManagedLogGroup\": true,\n \"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault\": true,\n \"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId\": true\n }\n}\n", + ".gitignore": "# Build output\ndist/\n\n# Dependencies\nnode_modules/\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "jest.config.js": "module.exports = {\n testEnvironment: 'node',\n roots: ['/test'],\n testMatch: ['**/*.test.ts'],\n transform: {\n '^.+\\\\.tsx?$': 'ts-jest',\n },\n setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],\n};\n", + "lib/cdk-stack.ts": "import {\n AgentCoreApplication,\n AgentCoreMcp,\n type AgentCoreProjectSpec,\n type AgentCoreMcpSpec,\n} from '@aws/agentcore-cdk';\nimport { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';\nimport { Construct } from 'constructs';\n\nexport interface AgentCoreStackProps extends StackProps {\n /**\n * The AgentCore project specification containing agents, memories, and credentials.\n */\n spec: AgentCoreProjectSpec;\n /**\n * The MCP specification containing gateways and servers.\n */\n mcpSpec?: AgentCoreMcpSpec;\n /**\n * Credential provider ARNs from deployed state, keyed by credential name.\n */\n credentials?: Record;\n}\n\n/**\n * CDK Stack that deploys AgentCore infrastructure.\n *\n * This is a thin wrapper that instantiates L3 constructs.\n * All resource logic and outputs are contained within the L3 constructs.\n */\nexport class AgentCoreStack extends Stack {\n /** The AgentCore application containing all agent environments */\n public readonly application: AgentCoreApplication;\n\n constructor(scope: Construct, id: string, props: AgentCoreStackProps) {\n super(scope, id, props);\n\n const { spec, mcpSpec, credentials } = props;\n\n // Create AgentCoreApplication with all agents\n this.application = new AgentCoreApplication(this, 'Application', {\n spec,\n });\n\n // Create AgentCoreMcp if there are gateways configured\n if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {\n new AgentCoreMcp(this, 'Mcp', {\n projectName: spec.name,\n mcpSpec,\n agentCoreApplication: this.application,\n credentials,\n projectTags: spec.tags,\n });\n }\n\n // Stack-level output\n new CfnOutput(this, 'StackNameOutput', {\n description: 'Name of the CloudFormation Stack',\n value: this.stackName,\n });\n }\n}\n", + ".npmignore": "*.ts\n!*.d.ts\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "package.json": "{\n \"name\": \"agentcore-cdk-app\",\n \"version\": \"0.1.0\",\n \"bin\": {\n \"cdk\": \"dist/bin/cdk.js\"\n },\n \"scripts\": {\n \"build\": \"tsc\",\n \"watch\": \"tsc -w\",\n \"test\": \"jest\",\n \"cdk\": \"npm run build && cdk\",\n \"clean\": \"rm -rf dist\",\n \"format\": \"prettier --write .\",\n \"format:check\": \"prettier --check .\"\n },\n \"devDependencies\": {\n \"@types/jest\": \"^29.5.14\",\n \"@types/node\": \"^24.10.1\",\n \"jest\": \"^29.7.0\",\n \"ts-jest\": \"^29.2.5\",\n \"aws-cdk\": \"2.1100.1\",\n \"prettier\": \"^3.4.2\",\n \"typescript\": \"~5.9.3\"\n },\n \"dependencies\": {\n \"@aws/agentcore-cdk\": \"^0.1.0-alpha.1\",\n \"aws-cdk-lib\": \"2.243.0\",\n \"constructs\": \"^10.0.0\"\n }\n}\n", + "test/cdk.test.ts": "import * as cdk from 'aws-cdk-lib';\nimport { Template } from 'aws-cdk-lib/assertions';\nimport { AgentCoreStack } from '../lib/cdk-stack';\n\ntest('AgentCoreStack synthesizes with empty spec', () => {\n const app = new cdk.App();\n const stack = new AgentCoreStack(app, 'TestStack', {\n spec: {\n name: 'testproject',\n version: 1,\n managedBy: 'CDK' as const,\n runtimes: [],\n memories: [],\n credentials: [],\n evaluators: [],\n onlineEvalConfigs: [],\n policyEngines: [],\n agentCoreGateways: [],\n mcpRuntimeTools: [],\n unassignedTargets: [],\n },\n });\n const template = Template.fromStack(stack);\n template.hasOutput('StackNameOutput', {\n Description: 'Name of the CloudFormation Stack',\n });\n});\n", + "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"CommonJS\",\n \"moduleResolution\": \"Node\",\n \"lib\": [\"es2022\"],\n \"declaration\": true,\n \"strict\": true,\n \"noImplicitAny\": true,\n \"strictNullChecks\": true,\n \"noImplicitThis\": true,\n \"alwaysStrict\": true,\n \"noUnusedLocals\": true,\n \"noUnusedParameters\": true,\n \"noImplicitReturns\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"inlineSourceMap\": true,\n \"inlineSources\": true,\n \"experimentalDecorators\": true,\n \"strictPropertyInitialization\": true,\n \"skipLibCheck\": true,\n \"typeRoots\": [\"./node_modules/@types\"],\n \"rootDir\": \".\",\n \"outDir\": \"dist\"\n },\n \"include\": [\"bin/**/*\", \"lib/**/*\", \"test/**/*\"],\n \"exclude\": [\"node_modules\", \"cdk.out\", \"dist\"]\n}\n" +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/app-1.0.0-preview.5.json b/src/core/project/migrate/__fixtures__/v0-app/app-1.0.0-preview.5.json new file mode 100644 index 000000000..9ea899e02 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/app-1.0.0-preview.5.json @@ -0,0 +1,13 @@ +{ + ".prettierrc": "{\n \"trailingComma\": \"es5\",\n \"printWidth\": 120,\n \"tabWidth\": 2,\n \"semi\": true,\n \"singleQuote\": true,\n \"arrowParens\": \"avoid\"\n}\n", + "README.md": "# AgentCore CDK Project\n\nThis CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs.\n\n## Structure\n\n- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target.\n- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct.\n- `test/cdk.test.ts` — Unit tests for stack synthesis.\n\n## Useful commands\n\n- `npm run build` compile TypeScript to JavaScript\n- `npm run test` run unit tests\n- `npx cdk synth` emit the synthesized CloudFormation template\n- `npx cdk deploy` deploy this stack to your default AWS account/region\n- `npx cdk diff` compare deployed stack with current state\n\n## Usage\n\nYou typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment:\n\n```bash\nagentcore deploy # synthesizes and deploys via CDK\nagentcore status # checks deployment status\n```\n", + "bin/cdk.ts": "#!/usr/bin/env node\nimport { AgentCoreStack } from '../lib/cdk-stack';\nimport { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk';\nimport { App, type Environment } from 'aws-cdk-lib';\nimport * as path from 'path';\nimport * as fs from 'fs';\n\nfunction toEnvironment(target: AwsDeploymentTarget): Environment {\n return {\n account: target.account,\n region: target.region,\n };\n}\n\nfunction sanitize(name: string): string {\n return name.replace(/_/g, '-');\n}\n\nfunction toStackName(projectName: string, targetName: string): string {\n return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`;\n}\n\nasync function main() {\n // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/.\n const configRoot = path.resolve(process.cwd(), '..');\n const configIO = new ConfigIO({ baseDir: configRoot });\n\n const spec = await configIO.readProjectSpec();\n const targets = await configIO.readAWSDeploymentTargets();\n\n // Extract MCP configuration from project spec.\n // Gateway fields are stored in agentcore.json but may not yet be on the\n // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them\n // dynamically and cast the resulting object.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const specAny = spec as any;\n const mcpSpec = specAny.agentCoreGateways?.length\n ? {\n agentCoreGateways: specAny.agentCoreGateways,\n mcpRuntimeTools: specAny.mcpRuntimeTools,\n unassignedTargets: specAny.unassignedTargets,\n }\n : undefined;\n\n // Read deployed state for credential ARNs (populated by pre-deploy identity setup)\n let deployedState: Record | undefined;\n try {\n deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8'));\n } catch {\n // Deployed state may not exist on first deploy\n }\n\n if (targets.length === 0) {\n throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');\n }\n\n // Read harness configs for role creation.\n // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk,\n // so we read them dynamically via specAny (same pattern as gateways above).\n // Harness paths in agentcore.json are relative to the project root (parent of agentcore/).\n const projectRoot = path.resolve(configRoot, '..');\n const harnessConfigs: {\n name: string;\n executionRoleArn?: string;\n memoryName?: string;\n containerUri?: string;\n hasDockerfile?: boolean;\n dockerfile?: string;\n codeLocation?: string;\n tools?: { type: string; name: string }[];\n apiKeyArn?: string;\n }[] = [];\n for (const entry of specAny.harnesses ?? []) {\n const harnessDir = path.resolve(projectRoot, entry.path);\n const harnessPath = path.resolve(harnessDir, 'harness.json');\n try {\n const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8'));\n harnessConfigs.push({\n name: entry.name,\n executionRoleArn: harnessSpec.executionRoleArn,\n memoryName: harnessSpec.memory?.name,\n containerUri: harnessSpec.containerUri,\n hasDockerfile: !!harnessSpec.dockerfile,\n dockerfile: harnessSpec.dockerfile,\n codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,\n tools: harnessSpec.tools,\n apiKeyArn: harnessSpec.model?.apiKeyArn,\n });\n } catch (err) {\n throw new Error(\n `Could not read harness.json for \"${entry.name}\" at ${harnessPath}: ${err instanceof Error ? err.message : err}`\n );\n }\n }\n\n const app = new App();\n\n for (const target of targets) {\n const env = toEnvironment(target);\n const stackName = toStackName(spec.name, target.name);\n\n // Extract credentials from deployed state for this target\n const targetState = (deployedState as Record)?.targets as\n | Record>\n | undefined;\n const targetResources = targetState?.[target.name]?.resources as Record | undefined;\n const credentials = targetResources?.credentials as\n | Record\n | undefined;\n\n new AgentCoreStack(app, stackName, {\n spec,\n mcpSpec,\n credentials,\n harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined,\n env,\n description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,\n tags: {\n 'agentcore:project-name': spec.name,\n 'agentcore:target-name': target.name,\n },\n });\n }\n\n app.synth();\n}\n\nmain().catch((error: unknown) => {\n console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error);\n process.exitCode = 1;\n});\n", + "cdk.json": "{\n \"app\": \"node dist/bin/cdk.js\",\n \"watch\": {\n \"include\": [\"**\"],\n \"exclude\": [\"README.md\", \"cdk*.json\", \"tsconfig.json\", \"package*.json\", \"yarn.lock\", \"node_modules\", \"dist\", \"test\"]\n },\n \"context\": {\n \"@aws-cdk/aws-signer:signingProfileNamePassedToCfn\": true,\n \"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener\": true,\n \"@aws-cdk/aws-lambda:recognizeLayerVersion\": true,\n \"@aws-cdk/core:checkSecretUsage\": true,\n \"@aws-cdk/core:target-partitions\": [\"aws\", \"aws-cn\", \"aws-us-gov\"],\n \"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver\": true,\n \"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName\": true,\n \"@aws-cdk/aws-ecs:arnFormatIncludesClusterName\": true,\n \"@aws-cdk/aws-iam:minimizePolicies\": true,\n \"@aws-cdk/core:validateSnapshotRemovalPolicy\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName\": true,\n \"@aws-cdk/aws-s3:createDefaultLoggingPolicy\": true,\n \"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption\": true,\n \"@aws-cdk/aws-apigateway:disableCloudWatchRole\": true,\n \"@aws-cdk/core:enablePartitionLiterals\": true,\n \"@aws-cdk/aws-events:eventsTargetQueueSameAccount\": true,\n \"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker\": true,\n \"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName\": true,\n \"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy\": true,\n \"@aws-cdk/aws-route53-patters:useCertificate\": true,\n \"@aws-cdk/customresources:installLatestAwsSdkDefault\": false,\n \"@aws-cdk/aws-rds:databaseProxyUniqueResourceName\": true,\n \"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup\": true,\n \"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId\": true,\n \"@aws-cdk/aws-ec2:launchTemplateDefaultUserData\": true,\n \"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments\": true,\n \"@aws-cdk/aws-redshift:columnId\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2\": true,\n \"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup\": true,\n \"@aws-cdk/aws-apigateway:requestValidatorUniqueId\": true,\n \"@aws-cdk/aws-kms:aliasNameRef\": true,\n \"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal\": true,\n \"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig\": true,\n \"@aws-cdk/core:includePrefixInUniqueNameGeneration\": true,\n \"@aws-cdk/aws-efs:denyAnonymousAccess\": true,\n \"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby\": true,\n \"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion\": true,\n \"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId\": true,\n \"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters\": true,\n \"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier\": true,\n \"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials\": true,\n \"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource\": true,\n \"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction\": true,\n \"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse\": true,\n \"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2\": true,\n \"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope\": true,\n \"@aws-cdk/aws-eks:nodegroupNameAttribute\": true,\n \"@aws-cdk/aws-ec2:ebsDefaultGp3Volume\": true,\n \"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm\": true,\n \"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault\": false,\n \"@aws-cdk/aws-s3:keepNotificationInImportedBucket\": false,\n \"@aws-cdk/core:explicitStackTags\": true,\n \"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature\": false,\n \"@aws-cdk/aws-ecs:disableEcsImdsBlocking\": true,\n \"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions\": true,\n \"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica\": true,\n \"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled\": true,\n \"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission\": true,\n \"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId\": true,\n \"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics\": true,\n \"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages\": true,\n \"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy\": true,\n \"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault\": true,\n \"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource\": true,\n \"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault\": true,\n \"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections\": true,\n \"@aws-cdk/core:enableAdditionalMetadataCollection\": true,\n \"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy\": false,\n \"@aws-cdk/aws-s3:setUniqueReplicationRoleName\": true,\n \"@aws-cdk/aws-events:requireEventBusPolicySid\": true,\n \"@aws-cdk/core:aspectPrioritiesMutating\": true,\n \"@aws-cdk/aws-dynamodb:retainTableReplica\": true,\n \"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2\": true,\n \"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions\": true,\n \"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway\": true,\n \"@aws-cdk/aws-s3:publicAccessBlockedByDefault\": true,\n \"@aws-cdk/aws-lambda:useCdkManagedLogGroup\": true,\n \"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault\": true,\n \"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId\": true\n }\n}\n", + ".gitignore": "# Build output\ndist/\n\n# Dependencies\nnode_modules/\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "jest.config.js": "module.exports = {\n testEnvironment: 'node',\n roots: ['/test'],\n testMatch: ['**/*.test.ts'],\n transform: {\n '^.+\\\\.tsx?$': 'ts-jest',\n },\n setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'],\n};\n", + "lib/cdk-stack.ts": "import {\n AgentCoreApplication,\n AgentCoreMcp,\n type AgentCoreProjectSpec,\n type AgentCoreMcpSpec,\n} from '@aws/agentcore-cdk';\nimport { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';\nimport { Construct } from 'constructs';\n\nexport interface HarnessConfig {\n name: string;\n executionRoleArn?: string;\n memoryName?: string;\n containerUri?: string;\n hasDockerfile?: boolean;\n dockerfile?: string;\n codeLocation?: string;\n tools?: { type: string; name: string }[];\n apiKeyArn?: string;\n}\n\nexport interface AgentCoreStackProps extends StackProps {\n /**\n * The AgentCore project specification containing agents, memories, and credentials.\n */\n spec: AgentCoreProjectSpec;\n /**\n * The MCP specification containing gateways and servers.\n */\n mcpSpec?: AgentCoreMcpSpec;\n /**\n * Credential provider ARNs from deployed state, keyed by credential name.\n */\n credentials?: Record;\n /**\n * Harness role configurations. Each entry creates an IAM execution role for a harness.\n *\n * When `hasDockerfile` is true and `codeLocation` is provided (without an explicit\n * `containerUri`), the L3 construct builds and pushes a container image via CodeBuild\n * and emits its URI as a stack output for the post-CDK harness deployer.\n */\n harnesses?: HarnessConfig[];\n}\n\n/**\n * CDK Stack that deploys AgentCore infrastructure.\n *\n * This is a thin wrapper that instantiates L3 constructs.\n * All resource logic and outputs are contained within the L3 constructs.\n */\nexport class AgentCoreStack extends Stack {\n /** The AgentCore application containing all agent environments */\n public readonly application: AgentCoreApplication;\n\n constructor(scope: Construct, id: string, props: AgentCoreStackProps) {\n super(scope, id, props);\n\n const { spec, mcpSpec, credentials, harnesses } = props;\n\n // Create AgentCoreApplication with all agents and harness roles\n this.application = new AgentCoreApplication(this, 'Application', {\n spec,\n harnesses: harnesses?.length ? harnesses : undefined,\n });\n\n // Create AgentCoreMcp if there are gateways configured\n if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {\n new AgentCoreMcp(this, 'Mcp', {\n projectName: spec.name,\n mcpSpec,\n agentCoreApplication: this.application,\n credentials,\n projectTags: spec.tags,\n });\n }\n\n // Stack-level output\n new CfnOutput(this, 'StackNameOutput', {\n description: 'Name of the CloudFormation Stack',\n value: this.stackName,\n });\n }\n}\n", + ".npmignore": "*.ts\n!*.d.ts\n\n# CDK asset staging directory\n.cdk.staging\ncdk.out\n", + "package.json": "{\n \"name\": \"agentcore-cdk-app\",\n \"version\": \"0.1.0\",\n \"bin\": {\n \"cdk\": \"dist/bin/cdk.js\"\n },\n \"scripts\": {\n \"build\": \"tsc\",\n \"watch\": \"tsc -w\",\n \"test\": \"jest\",\n \"cdk\": \"npm run build && cdk\",\n \"clean\": \"rm -rf dist\",\n \"format\": \"prettier --write .\",\n \"format:check\": \"prettier --check .\"\n },\n \"devDependencies\": {\n \"@types/jest\": \"^29.5.14\",\n \"@types/node\": \"^24.10.1\",\n \"jest\": \"^29.7.0\",\n \"ts-jest\": \"^29.2.5\",\n \"aws-cdk\": \"2.1100.1\",\n \"prettier\": \"^3.4.2\",\n \"typescript\": \"~5.9.3\"\n },\n \"dependencies\": {\n \"@aws/agentcore-cdk\": \"^0.1.0-alpha.19\",\n \"aws-cdk-lib\": \"^2.248.0\",\n \"constructs\": \"^10.0.0\"\n }\n}\n", + "test/cdk.test.ts": "import * as cdk from 'aws-cdk-lib';\nimport { Template } from 'aws-cdk-lib/assertions';\nimport { AgentCoreStack } from '../lib/cdk-stack';\n\ntest('AgentCoreStack synthesizes with empty spec', () => {\n const app = new cdk.App();\n const stack = new AgentCoreStack(app, 'TestStack', {\n spec: {\n name: 'testproject',\n version: 1,\n managedBy: 'CDK' as const,\n runtimes: [],\n memories: [],\n credentials: [],\n evaluators: [],\n onlineEvalConfigs: [],\n policyEngines: [],\n agentCoreGateways: [],\n mcpRuntimeTools: [],\n unassignedTargets: [],\n },\n });\n const template = Template.fromStack(stack);\n template.hasOutput('StackNameOutput', {\n Description: 'Name of the CloudFormation Stack',\n });\n});\n", + "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"CommonJS\",\n \"moduleResolution\": \"Node\",\n \"lib\": [\"es2022\"],\n \"declaration\": true,\n \"strict\": true,\n \"noImplicitAny\": true,\n \"strictNullChecks\": true,\n \"noImplicitThis\": true,\n \"alwaysStrict\": true,\n \"noUnusedLocals\": true,\n \"noUnusedParameters\": true,\n \"noImplicitReturns\": true,\n \"noFallthroughCasesInSwitch\": true,\n \"inlineSourceMap\": true,\n \"inlineSources\": true,\n \"experimentalDecorators\": true,\n \"strictPropertyInitialization\": true,\n \"skipLibCheck\": true,\n \"typeRoots\": [\"./node_modules/@types\"],\n \"rootDir\": \".\",\n \"outDir\": \"dist\"\n },\n \"include\": [\"bin/**/*\", \"lib/**/*\", \"test/**/*\"],\n \"exclude\": [\"node_modules\", \"cdk.out\", \"dist\"]\n}\n" +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-0.3.0-preview.1.0.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-0.3.0-preview.1.0.ts.txt new file mode 100644 index 000000000..92caa1f18 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-0.3.0-preview.1.0.ts.txt @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { AgentCoreStack } from '../lib/cdk-stack'; +import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${projectName}-${targetName}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + new AgentCoreStack(app, stackName, { + spec, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main(); diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-0.4.0.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-0.4.0.ts.txt new file mode 100644 index 000000000..7a78b71cd --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-0.4.0.ts.txt @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { AgentCoreStack } from '../lib/cdk-stack'; +import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; +import * as fs from 'fs'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function sanitize(name: string): string { + return name.replace(/_/g, '-'); +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + // Extract MCP configuration from project spec. + // Gateway fields are stored in agentcore.json but may not yet be on the + // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them + // dynamically and cast the resulting object. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + const mcpSpec = specAny.agentCoreGateways?.length + ? { + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, + } + : undefined; + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | undefined; + + new AgentCoreStack(app, stackName, { + spec, + mcpSpec, + credentials, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main().catch((error: unknown) => { + console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-1.0.0-preview.5.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-1.0.0-preview.5.ts.txt new file mode 100644 index 000000000..1c010e19b --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-1.0.0-preview.5.ts.txt @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { AgentCoreStack } from '../lib/cdk-stack'; +import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; +import * as fs from 'fs'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function sanitize(name: string): string { + return name.replace(/_/g, '-'); +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + // Extract MCP configuration from project spec. + // Gateway fields are stored in agentcore.json but may not yet be on the + // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them + // dynamically and cast the resulting object. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + const mcpSpec = specAny.agentCoreGateways?.length + ? { + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, + } + : undefined; + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + // Read harness configs for role creation. + // Harness fields may not yet be on the AgentCoreProjectSpec type from @aws/agentcore-cdk, + // so we read them dynamically via specAny (same pattern as gateways above). + // Harness paths in agentcore.json are relative to the project root (parent of agentcore/). + const projectRoot = path.resolve(configRoot, '..'); + const harnessConfigs: { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; + }[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + memoryName: harnessSpec.memory?.name, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + apiKeyArn: harnessSpec.model?.apiKeyArn, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | undefined; + + new AgentCoreStack(app, stackName, { + spec, + mcpSpec, + credentials, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main().catch((error: unknown) => { + console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.3.0-preview.1.0.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.3.0-preview.1.0.ts.txt new file mode 100644 index 000000000..051ad235b --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.3.0-preview.1.0.ts.txt @@ -0,0 +1,38 @@ +import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +export interface AgentCoreStackProps extends StackProps { + /** + * The AgentCore project specification containing agents, memories, and credentials. + */ + spec: AgentCoreProjectSpec; +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec } = props; + + // Create AgentCoreApplication with all agents + this.application = new AgentCoreApplication(this, 'Application', { + spec, + }); + + // Stack-level output + new CfnOutput(this, 'StackNameOutput', { + description: 'Name of the CloudFormation Stack', + value: this.stackName, + }); + } +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.4.0.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.4.0.ts.txt new file mode 100644 index 000000000..a4d277821 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-0.4.0.ts.txt @@ -0,0 +1,62 @@ +import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type AgentCoreMcpSpec, +} from '@aws/agentcore-cdk'; +import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +export interface AgentCoreStackProps extends StackProps { + /** + * The AgentCore project specification containing agents, memories, and credentials. + */ + spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec, mcpSpec, credentials } = props; + + // Create AgentCoreApplication with all agents + this.application = new AgentCoreApplication(this, 'Application', { + spec, + }); + + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } + + // Stack-level output + new CfnOutput(this, 'StackNameOutput', { + description: 'Name of the CloudFormation Stack', + value: this.stackName, + }); + } +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-1.0.0-preview.5.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-1.0.0-preview.5.ts.txt new file mode 100644 index 000000000..a89efc850 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack-1.0.0-preview.5.ts.txt @@ -0,0 +1,83 @@ +import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type AgentCoreMcpSpec, +} from '@aws/agentcore-cdk'; +import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +export interface HarnessConfig { + name: string; + executionRoleArn?: string; + memoryName?: string; + containerUri?: string; + hasDockerfile?: boolean; + dockerfile?: string; + codeLocation?: string; + tools?: { type: string; name: string }[]; + apiKeyArn?: string; +} + +export interface AgentCoreStackProps extends StackProps { + /** + * The AgentCore project specification containing agents, memories, and credentials. + */ + spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; + /** + * Harness role configurations. Each entry creates an IAM execution role for a harness. + * + * When `hasDockerfile` is true and `codeLocation` is provided (without an explicit + * `containerUri`), the L3 construct builds and pushes a container image via CodeBuild + * and emits its URI as a stack output for the post-CDK harness deployer. + */ + harnesses?: HarnessConfig[]; +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec, mcpSpec, credentials, harnesses } = props; + + // Create AgentCoreApplication with all agents and harness roles + this.application = new AgentCoreApplication(this, 'Application', { + spec, + harnesses: harnesses?.length ? harnesses : undefined, + }); + + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } + + // Stack-level output + new CfnOutput(this, 'StackNameOutput', { + description: 'Name of the CloudFormation Stack', + value: this.stackName, + }); + } +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk-stack.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack.ts.txt new file mode 100644 index 000000000..19ba23930 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk-stack.ts.txt @@ -0,0 +1,291 @@ +import { + AgentCoreApplication, + AgentCoreMcp, + AgentCorePaymentManager, + AgentCorePaymentConnector, + type AgentCoreProjectSpec, + type AgentCoreMcpSpec, + type CustomJWTAuthorizerConfig, + type HarnessDeploymentConfig, +} from '@aws/agentcore-cdk'; +import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +/** + * Harness deployment config: role-scoped fields (for IAM role + container build) + * plus the full validated spec + its config directory so the L3 construct can + * synthesize the AWS::BedrockAgentCore::Harness resource. + */ +export type HarnessConfig = HarnessDeploymentConfig; + +export interface ManualPaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP' | 'StripePrivy'; + provisionMode?: 'MANUAL'; + credentialName: string; + credentialProviderArn: string; +} + +export interface QuickCreatePaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP'; + provisionMode: 'QUICK_CREATE'; + credentialName?: never; + credentialProviderArn?: never; +} + +export type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec; + +export interface PaymentSpec { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: PaymentConnectorSpec[]; +} + +export interface AgentCoreStackProps extends StackProps { + /** + * The AgentCore project specification containing agents, memories, and credentials. + */ + spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; + /** + * Harness role configurations. + */ + harnesses?: HarnessConfig[]; + /** + * Parsed connectorParameters for non-S3 KB data sources, keyed by + * connectorConfigFile path. Forwarded to AgentCoreApplication. + */ + connectorParametersByFile?: Record>; + /** + * Payment specifications with resolved credential provider ARNs. + */ + paymentSpec?: PaymentSpec[]; +} + +function toCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Decide whether a deployed runtime should receive payment env vars + IAM grants. + * Payments today only ships a runtime shim for Python HTTP runtimes; injecting + * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes + * would surface env vars they cannot consume and would dilute least-privilege + * IAM grants for runtimes that never call ProcessPayment. + */ +function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { + if (agent.protocol && agent.protocol !== 'HTTP') { + return false; + } + const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + return entrypointFile.endsWith('.py'); +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; + + // Create AgentCoreApplication with all agents and harness roles + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appProps: Record = { spec }; + if (harnesses?.length) { + appProps.harnesses = harnesses; + } + if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) { + appProps.connectorParametersByFile = connectorParametersByFile; + } + if (credentials) { + appProps.credentials = credentials; + } + this.application = new AgentCoreApplication(this, 'Application', appProps as any); + + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } + + // Create payment infrastructure via CFN constructs + if (paymentSpec && paymentSpec.length > 0) { + for (const payment of paymentSpec) { + const mgrId = toCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { + projectName: spec.name, + name: payment.name, + authorizerType: payment.authorizerType, + description: payment.description, + authorizerConfiguration: payment.authorizerConfiguration, + tags: spec.tags, + }); + + const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; + + // Wire env vars from construct output tokens into eligible agent environments only. + // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that + // can consume these env vars, and giving them sts:AssumeRole on the + // ProcessPaymentRole would broaden the privilege surface unnecessarily. + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) { + continue; + } + env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); + env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); + + // Grant runtime execution role permission to assume the ProcessPaymentRole. + // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the + // caller still needs sts:AssumeRole on its own role to perform the assumption. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [manager.processPaymentRoleArn], + }) + ); + + // Grant payment data-plane actions directly to the runtime role. + // + // NOTE: This deviates from the canonical role model in the AgentCore Payments + // beta guide, which assigns Get/List/Create instrument+session actions to a + // separate ManagementRole and limits the agent's role to ProcessPayment only. + // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) + // calls GetPaymentInstrument internally during the 402 auto-pay path, so the + // runtime role needs read access. CreatePaymentSession is included so + // `agentcore invoke --auto-session` works without a separate ManagementRole + // call. Tighten this if the SDK is updated to accept pre-fetched instrument + // details and split create-session into a backend-only flow. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetPaymentInstrument', + 'bedrock-agentcore:ListPaymentInstruments', + 'bedrock-agentcore:GetPaymentInstrumentBalance', + 'bedrock-agentcore:GetPaymentSession', + 'bedrock-agentcore:ListPaymentSessions', + 'bedrock-agentcore:CreatePaymentSession', + 'bedrock-agentcore:ProcessPayment', + ], + resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], + }) + ); + + if (payment.autoPayment !== undefined) { + env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); + } + if (payment.paymentToolAllowlist) { + env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); + } + if (payment.networkPreferences) { + env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); + } + if (payment.authorizerType === 'CUSTOM_JWT') { + env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); + } + } + + // Create connectors for this manager + for (const connector of payment.connectors) { + const connId = toCdkId(connector.name); + const schemaConnector = + connector.provisionMode === 'QUICK_CREATE' + ? connector + : { + name: connector.name, + provider: connector.provider, + ...(connector.provisionMode && { provisionMode: connector.provisionMode }), + credentialName: connector.credentialName, + }; + const compatibilityProps = { + projectName: spec.name, + paymentManager: manager, + connector: schemaConnector, + // Remove these legacy manual fields after the new L3 release is pinned. + connectorName: connector.name, + connectorType: connector.provider, + ...(connector.provisionMode !== 'QUICK_CREATE' && { + credentialProviderArn: connector.credentialProviderArn, + }), + }; + const conn = new AgentCorePaymentConnector( + this, + `Payment${mgrId}${connId}`, + compatibilityProps as unknown as ConstructorParameters[2] + ); + + // Wire first connector's ID as env var (eligible agents only) + if (connector === payment.connectors[0]) { + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) continue; + env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); + } + } + + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + value: conn.paymentConnectorId, + }); + if (connector.provisionMode === 'QUICK_CREATE') { + const quickCreateConnector = conn as AgentCorePaymentConnector & { + paymentConnectorStatus: string; + authorizationUrl: string; + }; + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorStatus`, { + value: quickCreateConnector.paymentConnectorStatus, + }); + new CfnOutput(this, `Payment${mgrId}${connId}AuthorizationUrl`, { + value: quickCreateConnector.authorizationUrl, + }); + } + } + + // CFN Outputs for post-deploy state parsing + new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + value: manager.paymentManagerArn, + }); + new CfnOutput(this, `Payment${mgrId}ManagerId`, { + value: manager.paymentManagerId, + }); + new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + value: manager.processPaymentRoleArn, + }); + new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + value: manager.resourceRetrievalRoleArn, + }); + } + } + + // Stack-level output + new CfnOutput(this, 'StackNameOutput', { + description: 'Name of the CloudFormation Stack', + value: this.stackName, + }); + } +} diff --git a/src/core/project/migrate/__fixtures__/v0-app/cdk.ts.txt b/src/core/project/migrate/__fixtures__/v0-app/cdk.ts.txt new file mode 100644 index 000000000..3aafe43e0 --- /dev/null +++ b/src/core/project/migrate/__fixtures__/v0-app/cdk.ts.txt @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; +import * as fs from 'fs'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function sanitize(name: string): string { + return name.replace(/_/g, '-'); +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + // The vended CDK project compiles against the published @aws/agentcore-cdk + // schema type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, + // harnesses, gateway fields). Cast once so those fields are reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + + // Extract MCP configuration from project spec. + // Gateway fields are stored in agentcore.json but may not yet be on the + const mcpSpec = specAny.agentCoreGateways?.length + ? { + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, + } + : undefined; + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + // Read harness configs: the full validated spec drives the CFN resource; the + // role-scoped fields drive the IAM role + container build. + const projectRoot = path.resolve(configRoot, '..'); + + // Read non-S3 KB connector-config files and pass their parsed contents to the + // L3 verbatim. The L3 does not read files; it expects the parsed + // connectorParameters keyed by the data source's connectorConfigFile path. + const connectorParametersByFile: Record> = {}; + for (const kb of specAny.knowledgeBases ?? []) { + for (const ds of kb.dataSources ?? []) { + if (ds.type !== 'S3' && ds.connectorConfigFile) { + const abs = path.resolve(projectRoot, ds.connectorConfigFile); + try { + connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8')); + } catch (err) { + throw new Error( + `Could not read connector config '${ds.connectorConfigFile}' for knowledge base '${kb.name}' at ${abs}: ${err instanceof Error ? err.message : err}` + ); + } + } + } + } + + // Synthesize an AWS::BedrockAgentCore::Harness resource for each harness entry in the spec. + const harnessConfigs: HarnessConfig[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8'))); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + // Only an `existing` memory ref carries a name to wire IAM against; managed memory is + // owned by the harness (no sibling) and disabled has none — both resolve to undefined. + memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + skills: harnessSpec.skills, + apiKeyArn: harnessSpec.model?.apiKeyArn, + efsAccessPoints: harnessSpec.efsAccessPoints, + s3AccessPoints: harnessSpec.s3AccessPoints, + apiFormat: harnessSpec.model?.apiFormat, + // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource. + spec: harnessSpec, + harnessDir, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as + Record> | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as + Record | undefined; + + // Payment credential provider ARNs live in the same credentials map as identity credentials + const paymentCredentials = credentials; + + const paymentSpec = specAny.payments?.length + ? specAny.payments.map( + (p: { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: unknown; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { + name: string; + provider?: string; + provisionMode?: 'MANUAL' | 'QUICK_CREATE'; + credentialName?: string; + }[]; + }) => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => { + if (c.provisionMode === 'QUICK_CREATE') { + return { + name: c.name, + provider: 'CoinbaseCDP' as const, + provisionMode: 'QUICK_CREATE' as const, + }; + } + + if (!c.credentialName) { + throw new Error( + `Manual payment connector "${c.name}" on manager "${p.name}" is missing its credential name.` + ); + } + const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; + if (!credentialProviderArn) { + // Fail fast with an actionable message rather than passing an empty + // ARN that fails opaquely server-side at CreatePaymentConnector. + throw new Error( + `Payment connector "${c.name}" on manager "${p.name}" references credential ` + + `"${c.credentialName}", but no deployed credential provider was found for it. ` + + `Run \`agentcore deploy\` so the credential provider is created first.` + ); + } + return { + name: c.name, + provider: c.provider, + ...(c.provisionMode && { provisionMode: c.provisionMode }), + credentialName: c.credentialName, + credentialProviderArn, + }; + }), + }) + ) + : undefined; + + new AgentCoreStack(app, stackName, { + spec, + mcpSpec, + credentials, + connectorParametersByFile, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main().catch((error: unknown) => { + console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/core/project/migrate/apply.test.ts b/src/core/project/migrate/apply.test.ts new file mode 100644 index 000000000..9054844ce --- /dev/null +++ b/src/core/project/migrate/apply.test.ts @@ -0,0 +1,509 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { FileChange } from "../../../handlers/project/migrate/types"; +import type { ProcessRunner } from "../../../io"; +import { + actionsMismatch, + applyMigration, + physicalIdChanges, + undoInterruptedMigration, + type ApplierConfig, + type ApplyTarget, +} from "./apply"; +import { BACKUP_RELATIVE_PATH, type ProjectFilesBackup } from "./files"; +import { + readMigrationState, + STATE_RELATIVE_PATH, + writeMigrationState, + type MigrationState, +} from "./journal"; +import { + fakeCloudFormation, + moveActions, + NO_WAIT, + stackResources, +} from "./migrate-cfn-test-support"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "migrate-apply-")); + roots.push(root); + return root; +} + +const CHANGES: FileChange[] = [{ path: "agentcore/agentcore.json", kind: "modified" }]; + +/** An error the AWS SDK raises for a CloudFormation 4xx answer. */ +function clientError(message: string): Error { + return Object.assign(new Error(message), { $metadata: { httpStatusCode: 400 } }); +} + +/** A scripted target whose refactor succeeds unless the script says otherwise. */ +function target( + name: string, + script: { + execute?: unknown[]; + executeCall?: unknown; + actions?: Array<[string, string]>; + after?: Array<[string, string]>; + } = {}, +): { entry: ApplyTarget; sent: ReturnType["sent"] } { + const stackName = `AgentCore-Proj-${name}`; + const { client, sent } = fakeCloudFormation({ + ListStackResources: [ + stackResources([ + ["OldMemory", `mem-${name}`], + ["CDKMetadata", "meta"], + ]), + stackResources( + script.after ?? [ + ["NewMemory", `mem-${name}`], + ["CDKMetadata", "meta"], + ], + ), + ], + CreateStackRefactor: [{ StackRefactorId: `rf-${name}` }], + DescribeStackRefactor: [ + { Status: "CREATE_COMPLETE" }, + ...((script.execute as object[] | undefined) ?? [{ ExecutionStatus: "EXECUTE_COMPLETE" }]), + ], + ListStackRefactorActions: [ + moveActions(stackName, script.actions ?? [["OldMemory", "NewMemory"]]), + ], + ExecuteStackRefactor: [script.executeCall ?? {}], + }); + return { + entry: { + target: { name, account: "111111111111", region: "us-east-1" }, + stackName, + renames: new Map([["OldMemory", "NewMemory"]]), + bridge: { Resources: { NewMemory: { Type: "AWS::BedrockAgentCore::Memory" } } }, + client, + templates: async () => ({ put: async () => "", remove: async () => {} }), + }, + sent, + }; +} + +/** A fresh migration's record: files to write, and `targets` pending. */ +function freshState(...targets: string[]): MigrationState { + return { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: "writing", backup: BACKUP_RELATIVE_PATH, changes: CHANGES }, + refactored: [], + targets: targets.map((name) => ({ + name, + account: "111111111111", + region: "us-east-1", + stackName: `AgentCore-Proj-${name}`, + status: "pending", + })), + }; +} + +/** Records writes and whether they were put back or kept. */ +function fakeFiles() { + const events: string[] = []; + const backup: ProjectFilesBackup = { + restore: async () => void events.push("restore"), + discard: async () => void events.push("discard"), + }; + const writeFiles = (async () => { + events.push("write"); + return backup; + }) as unknown as NonNullable; + return { events, writeFiles }; +} + +function config( + rootPath: string, + state: MigrationState, + entry: ApplyTarget | undefined, + files = fakeFiles(), +) { + const npm: string[][] = []; + const runner: ProcessRunner = async (args) => { + npm.push(args); + }; + /** Every record saved, in order. */ + const saved: MigrationState[] = []; + const applier: ApplierConfig = { + rootPath, + state, + files: { workspaceDirectory: "/unused", writeCdkApp: async () => {} }, + ...(entry && { target: entry }), + runner, + poll: NO_WAIT, + writeFiles: files.writeFiles, + saveState: async (root, next) => { + saved.push(structuredClone(next)); + await writeMigrationState(root, next); + }, + }; + return { applier, npm, events: files.events, saved }; +} + +describe("applyMigration", () => { + test("records each step first, then writes, installs, refactors, and verifies", async () => { + const root = tempRoot(); + const { entry, sent } = target("default"); + const { applier, npm, events, saved } = config(root, freshState("default"), entry); + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(true); + expect(result.stacks).toEqual([ + { + target: "default", + stackName: "AgentCore-Proj-default", + region: "us-east-1", + outcome: "refactored", + refactorId: "rf-default", + }, + ]); + expect(result.files).toEqual(CHANGES); + expect(events).toEqual(["write"]); + expect(npm[0]?.slice(0, 2)).toEqual(["npm", "install"]); + expect(sent.map((command) => command.name)).toContain("ExecuteStackRefactor"); + // The record was on disk before the files, and held the refactor's ID while it ran. + expect(saved.map((state) => state.files?.status)).toEqual([ + "writing", + "written", + "installed", + "installed", + ]); + expect(saved[3]?.targets[0]).toMatchObject({ status: "refactoring", refactorId: "rf-default" }); + expect(saved[3]?.targets[0]?.renames).toEqual([["OldMemory", "NewMemory"]]); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); + + test("changes nothing when the record can't be written", async () => { + const root = tempRoot(); + const { entry, sent } = target("default"); + const { applier, events } = config(root, freshState("default"), entry); + applier.saveState = async () => { + throw new Error("read-only file system"); + }; + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(false); + expect(result.error).toContain("read-only file system"); + expect(result.error).toContain("nothing was changed"); + expect(events).toEqual([]); + expect(sent).toHaveLength(0); + }); + + test("doesn't execute when CloudFormation's actions differ, and puts the v0 files back", async () => { + const root = tempRoot(); + const { entry, sent } = target("default", { actions: [["OldMemory", "Elsewhere"]] }); + const { applier, events } = config(root, freshState("default"), entry); + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(false); + expect(result.restored).toBe(true); + expect(result.stacks[0]?.outcome).toBe("mismatch"); + expect(result.error).toContain("unplanned move OldMemory → Elsewhere"); + expect(sent.some((command) => command.name === "ExecuteStackRefactor")).toBe(false); + expect(events).toEqual(["write", "restore"]); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); + + test("a rolled-back refactor of the only target puts the v0 files back", async () => { + const root = tempRoot(); + const { entry } = target("default", { + execute: [{ ExecutionStatus: "ROLLBACK_COMPLETE", ExecutionStatusReason: "no" }], + }); + const { applier, events } = config(root, freshState("default"), entry); + + const result = await applyMigration(applier); + + expect(result.stacks[0]?.outcome).toBe("failed"); + expect(result.restored).toBe(true); + expect(events).toEqual(["write", "restore"]); + }); + + test("an Execute request CloudFormation refused (4xx) didn't start: the files are put back", async () => { + const root = tempRoot(); + const { entry } = target("default", { executeCall: clientError("Throttling: rate exceeded") }); + const { applier, events } = config(root, freshState("default"), entry); + + const result = await applyMigration(applier); + + expect(result.stacks[0]?.outcome).toBe("failed"); + expect(result.stacks[0]?.message).toContain("refused to start"); + expect(result.restored).toBe(true); + expect(events).toEqual(["write", "restore"]); + }); + + test("an Execute request whose outcome isn't known stops with the files and the refactor ID", async () => { + const root = tempRoot(); + const { entry } = target("default", { executeCall: new Error("socket hang up") }); + const { applier, events } = config(root, freshState("default"), entry); + + const result = await applyMigration(applier); + + expect(result.stacks[0]?.outcome).toBe("needs-repair"); + expect(result.restored).toBeUndefined(); + expect(events).toEqual(["write"]); + const recovery = result.recovery.join("\n"); + expect(recovery).toContain( + "aws cloudformation describe-stack-refactor --stack-refactor-id rf-default --region us-east-1", + ); + expect(recovery).toContain( + "aws cloudformation list-stack-resources --stack-name AgentCore-Proj-default --region us-east-1", + ); + const state = await readMigrationState(root); + expect(state?.targets[0]).toMatchObject({ status: "refactoring", refactorId: "rf-default" }); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(true); + }); + + test("with several deployed targets, refactors the selected one and records the rest", async () => { + const root = tempRoot(); + const { entry } = target("dev"); + const { applier, events } = config(root, freshState("dev", "prod", "test"), entry); + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(false); + expect(result.error).toBeUndefined(); + expect(result.stacks.map((stack) => stack.outcome)).toEqual(["refactored"]); + expect(result.pendingTargets).toEqual(["prod", "test"]); + expect(result.stateFile).toBe("agentcore/.cli/migration-state.json"); + expect(result.recovery.join("\n")).toContain("agentcore migrate --target prod"); + expect(events).toEqual(["write"]); + const state = await readMigrationState(root); + expect(state?.refactored).toEqual(["dev"]); + expect(state?.targets.map((pending) => [pending.name, pending.status])).toEqual([ + ["prod", "pending"], + ["test", "pending"], + ]); + // No precomputed refactor is stored for targets not migrated yet. + expect(state?.targets.every((pending) => pending.renames === undefined)).toBe(true); + // Once a stack has v1 IDs the v0 files can't come back: the backup is gone. + expect(existsSync(join(root, BACKUP_RELATIVE_PATH))).toBe(false); + }); + + test("finishing a pending target doesn't rewrite files, and clears the record when it's the last", async () => { + const root = tempRoot(); + const { entry } = target("prod"); + const state = { ...freshState("prod"), refactored: ["dev"] }; + state.files!.status = "installed"; + const { applier, events, npm } = config(root, state, entry); + delete applier.files; + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(true); + expect(events).toEqual([]); + expect(npm).toEqual([]); + expect(await readMigrationState(root)).toBeUndefined(); + }); + + test("finishing installs the CDK app's dependencies when an earlier run stopped before", async () => { + const root = tempRoot(); + const state = freshState(); + state.files!.status = "written"; + const { applier, npm } = config(root, state, undefined); + delete applier.files; + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(true); + expect(npm[0]?.slice(0, 2)).toEqual(["npm", "install"]); + }); + + test("finishing records a refactor found complete and clears the record", async () => { + const root = tempRoot(); + const state = freshState("prod"); + state.files!.status = "installed"; + state.targets[0]!.status = "refactoring"; + state.targets[0]!.refactorId = "rf-prod"; + const { applier } = config(root, state, undefined); + delete applier.files; + applier.completed = ["prod"]; + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(true); + expect(await readMigrationState(root)).toBeUndefined(); + }); + + test("finishing puts back the v0 files from the backup when nothing was ever refactored", async () => { + const root = tempRoot(); + const spec = join(root, "agentcore", "agentcore.json"); + const saved = join(root, BACKUP_RELATIVE_PATH, "files", "agentcore", "agentcore.json"); + mkdirSync(dirname(spec), { recursive: true }); + mkdirSync(dirname(saved), { recursive: true }); + writeFileSync(spec, '{"version":2}\n'); + writeFileSync(saved, '{"version":1}\n'); + const { entry } = target("default", { execute: [{ ExecutionStatus: "ROLLBACK_COMPLETE" }] }); + const state = freshState("default"); + state.files!.status = "installed"; + const { applier } = config(root, state, entry); + delete applier.files; + + const result = await applyMigration(applier); + + expect(result.restored).toBe(true); + expect(readFileSync(spec, "utf8")).toBe('{"version":1}\n'); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); + + test("a physical ID that changed asks for a look and keeps the refactor ID", async () => { + const root = tempRoot(); + const { entry } = target("dev", { + after: [ + ["NewMemory", "mem-other"], + ["CDKMetadata", "meta"], + ], + }); + const { applier } = config(root, freshState("dev"), entry); + + const result = await applyMigration(applier); + + expect(result.stacks.map((stack) => stack.outcome)).toEqual(["needs-repair"]); + expect(result.restored).toBeUndefined(); + expect(result.recovery.join("\n")).toContain( + "describe-stack-refactor --stack-refactor-id rf-dev", + ); + expect((await readMigrationState(root))?.targets[0]?.refactorId).toBe("rf-dev"); + }); + + test("Ctrl-C before the refactor starts puts the v0 files back", async () => { + const root = tempRoot(); + const { entry, sent } = target("dev"); + const { applier, events } = config(root, freshState("dev"), entry); + const steps: string[] = []; + const listenersBefore = process.listenerCount("SIGINT"); + + const result = await applyMigration(applier, { + onStep: (message) => { + steps.push(message); + if (message.startsWith("Installing")) process.emit("SIGINT"); + }, + }); + + expect(result.stacks.map((stack) => stack.outcome)).toEqual(["not-started"]); + expect(sent.some((command) => command.name === "CreateStackRefactor")).toBe(false); + expect(steps.some((step) => step.startsWith("Stopping once the current step ends"))).toBe(true); + expect(result.restored).toBe(true); + expect(events).toEqual(["write", "restore"]); + // The Ctrl-C handler is removed once applying ends. + expect(process.listenerCount("SIGINT")).toBe(listenersBefore); + }); + + test("a failed file write puts back what was written and clears the record", async () => { + const root = tempRoot(); + const { entry, sent } = target("default"); + const { applier } = config(root, freshState("default"), entry); + applier.writeFiles = (async () => { + throw new Error("disk full"); + }) as unknown as NonNullable; + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(false); + expect(result.error).toBe("disk full"); + expect(sent).toHaveLength(0); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); + + test("a project with no deployed target only converts its files", async () => { + const root = tempRoot(); + const { applier, events } = config(root, freshState(), undefined); + + const result = await applyMigration(applier); + + expect(result.migrated).toBe(true); + expect(result.stacks).toEqual([]); + expect(events).toEqual(["write"]); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); +}); + +describe("undoInterruptedMigration", () => { + test("puts the v0 files back from the backup and deletes the record", async () => { + const root = tempRoot(); + const spec = join(root, "agentcore", "agentcore.json"); + const added = join(root, "app", "h", "harness.yaml"); + const saved = join(root, BACKUP_RELATIVE_PATH, "files", "agentcore", "agentcore.json"); + for (const path of [spec, added, saved]) mkdirSync(dirname(path), { recursive: true }); + writeFileSync(spec, '{"version":2}\n'); + writeFileSync(added, "name: h\n"); + writeFileSync(saved, '{"version":1}\n'); + const state: MigrationState = { + ...freshState(), + files: { + status: "writing", + backup: BACKUP_RELATIVE_PATH, + changes: [...CHANGES, { path: "app/h/harness.yaml", kind: "added" }], + }, + }; + await writeMigrationState(root, state); + + const result = await undoInterruptedMigration(root, state); + + expect(result.undone).toBe(true); + expect(readFileSync(spec, "utf8")).toBe('{"version":1}\n'); + expect(existsSync(added)).toBe(false); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + expect(existsSync(join(root, BACKUP_RELATIVE_PATH))).toBe(false); + }); +}); + +describe("actionsMismatch", () => { + const renames = new Map([ + ["A", "B"], + ["C", "D"], + ]); + const action = (from: string, to: string, kind = "MOVE") => ({ + action: kind, + entity: "RESOURCE", + from, + to, + }); + + test("matching actions", () => { + expect(actionsMismatch([action("A", "B"), action("C", "D")], renames)).toBeUndefined(); + }); + + test("missing, unplanned, and non-MOVE actions", () => { + expect(actionsMismatch([action("A", "X"), action("E", "F", "CREATE")], renames)).toBe( + "unplanned move A → X, unexpected CREATE RESOURCE E → F, missing move A → B, missing move C → D", + ); + }); +}); + +describe("physicalIdChanges", () => { + test("follows moved resources to their new logical IDs", () => { + const renames = new Map([["Old", "New"]]); + expect( + physicalIdChanges( + new Map([ + ["Old", "p1"], + ["Same", "p2"], + ]), + new Map([ + ["New", "p1"], + ["Same", "p2"], + ]), + renames, + ), + ).toEqual([]); + expect(physicalIdChanges(new Map([["Old", "p1"]]), new Map([["New", "p9"]]), renames)).toEqual([ + "Old was p1, New is p9", + ]); + expect(physicalIdChanges(new Map([["Old", "p1"]]), new Map(), renames)).toEqual([ + "Old was p1, New is missing", + ]); + }); +}); diff --git a/src/core/project/migrate/apply.ts b/src/core/project/migrate/apply.ts new file mode 100644 index 000000000..b4672d766 --- /dev/null +++ b/src/core/project/migrate/apply.ts @@ -0,0 +1,556 @@ +import { join } from "node:path"; +import { + ListStackResourcesCommand, + type CloudFormationClient, + type StackResourceSummary, +} from "@aws-sdk/client-cloudformation"; +import type { + FileChange, + MigrationApplyOptions, + MigrationResult, + StackRefactorResult, +} from "../../../handlers/project/migrate/types"; +import type { ProcessRunner } from "../../../io"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import { + BACKUP_RELATIVE_PATH, + discardBackup, + restoreFromBackup, + writeMigratedFiles, + type ProjectFilesBackup, +} from "./files"; +import { + removeMigrationState, + STATE_RELATIVE_PATH, + writeMigrationState, + type JournalTarget, + type MigrationState, +} from "./journal"; +import { + createRefactor, + executeRefactor, + needsManualRepair, + StackRefactorError, + type PollOptions, + type RefactorAction, +} from "./refactor"; +import type { CfnTemplate } from "./templates"; +import type { TemplateStore } from "./templateStore"; + +/** The one deployment target whose stack this run refactors. */ +export type ApplyTarget = { + target: AwsDeploymentTarget; + stackName: string; + /** Logical ID renames, deployed ID → v1 ID; empty when nothing in the stack moves. */ + renames: ReadonlyMap; + /** The deployed template with the renames applied; absent when nothing moves. */ + bridge?: CfnTemplate; + /** A CloudFormation client for the target's account and region. */ + client: CloudFormationClient; + /** Where a template too big to pass inline is uploaded. */ + templates: () => Promise; +}; + +export type ApplierConfig = { + rootPath: string; + /** + * The migration's record as this run starts: a new one when the migration starts + * here (written before anything else changes), or the pending one being finished. + */ + state: MigrationState; + /** + * The converted files to write first: the workspace they're copied from and the + * writer of v1's vended CDK app. Absent when finishing a migration whose files are + * already written. + */ + files?: { + workspaceDirectory: string; + writeCdkApp: (cdkDirectory: string) => Promise; + }; + /** The target whose stack is refactored now; absent when none is. */ + target?: ApplyTarget; + /** Targets whose earlier refactor is now known to have completed; they leave the record. */ + completed?: string[]; + /** Runs `npm install` in the project's v1 CDK app. */ + runner: ProcessRunner; + poll?: PollOptions; + /** Writes the migrated files; injectable so tests can make writing fail. */ + writeFiles?: typeof writeMigratedFiles; + /** Persists the record; injectable so tests can make it fail. */ + saveState?: typeof writeMigrationState; +}; + +const INTERRUPTED = "Stopped by Ctrl-C before this stack was refactored."; + +/** + * Applies a ready migration, recording every step before it takes it, so a run + * stopped anywhere can be finished or undone by running `agentcore migrate` + * again: + * + * 1. The record is written first; if it can't be, nothing changes. + * 2. The converted files are written (the originals backed up), then the v1 CDK + * app's dependencies are installed. + * 3. The selected target's stack is refactored: CloudFormation's planned actions + * are checked against the plan first, and the physical IDs after. The record + * holds the refactor's ID while it runs. + * + * When nothing was refactored and the refactor didn't start, the files are put + * back and the project is v0 again. Other targets stay in the record until + * `agentcore migrate --target ` finishes them. A refactor whose outcome + * isn't known stops everything, keeping the files and the record. + */ +export async function applyMigration( + config: ApplierConfig, + options: MigrationApplyOptions = {}, +): Promise { + const { rootPath } = config; + const save = config.saveState ?? writeMigrationState; + const poll = { + ...config.poll, + ...(options.onStep && { onStep: options.onStep }), + ...(options.onOutput && { onOutput: options.onOutput }), + }; + const state: MigrationState = structuredClone(config.state); + const entry = config.target; + const results: StackRefactorResult[] = entry + ? [ + { + target: entry.target.name, + stackName: entry.stackName, + region: entry.target.region, + outcome: entry.renames.size === 0 ? "unchanged" : "not-started", + }, + ] + : []; + const result = (update: Partial) => { + results[0] = { ...results[0]!, ...update }; + }; + + // 1. The record comes first: nothing changes until it's safely on disk. + for (const name of config.completed ?? []) markRefactored(state, name); + options.onStep?.(`Recording the migration in ${STATE_RELATIVE_PATH}`); + try { + await save(rootPath, state); + } catch (error) { + return { + migrated: false, + stacks: results, + files: [], + error: + `Couldn't record the migration's progress in ${STATE_RELATIVE_PATH} ` + + `(${(error as Error).message}), so nothing was changed.`, + recovery: [], + }; + } + + const guard = guardInterrupts(options.onWarning ?? options.onStep); + try { + // 2. Files and dependencies. + let backup: ProjectFilesBackup | undefined; + const files = state.files?.changes ?? []; + if (config.files && state.files) { + try { + options.onStep?.("Writing the v1 project files"); + backup = await (config.writeFiles ?? writeMigratedFiles)({ + rootPath, + workspaceDirectory: config.files.workspaceDirectory, + changes: files, + writeCdkApp: config.files.writeCdkApp, + }); + state.files.status = "written"; + await save(rootPath, state); + } catch (error) { + return await putBackFiles(rootPath, error as Error, backup, results, files); + } + } + if (state.files?.status === "written") { + try { + options.onStep?.("Installing the v1 CDK app's dependencies (npm install)"); + await config.runner(["npm", "install", "--no-audit", "--no-fund"], { + cwd: join(rootPath, "agentcore", "cdk"), + }); + state.files.status = "installed"; + await save(rootPath, state); + } catch (error) { + if (state.refactored.length === 0) { + return await putBackFiles(rootPath, error as Error, backup, results, files); + } + return partial(state, results, files, (error as Error).message, [ + "Install the v1 CDK app's dependencies (`npm install` in agentcore/cdk), then run " + + "`agentcore migrate` again to finish the migration.", + ]); + } + } + + // 3. The selected target's stack. + let outcome: StackRefactorResult["outcome"] | undefined; + if (entry && entry.renames.size === 0) { + dropTarget(state, entry.target.name); + outcome = "unchanged"; + } else if (entry) { + if (guard.stop()) { + result({ outcome: "not-started", message: INTERRUPTED }); + } else { + try { + await refactorTarget(entry, poll, async (refactorId) => { + result({ refactorId }); + setTarget(state, { + name: entry.target.name, + account: entry.target.account, + region: entry.target.region, + stackName: entry.stackName, + status: "refactoring", + refactorId, + renames: [...entry.renames], + }); + await save(rootPath, state); + }); + result({ outcome: "refactored" }); + markRefactored(state, entry.target.name); + } catch (error) { + if (error instanceof ActionsMismatchError) { + result({ outcome: "mismatch", message: error.message }); + } else if (needsManualRepair(error)) { + result({ outcome: "needs-repair", message: (error as Error).message }); + } else { + result({ outcome: "failed", message: (error as Error).message }); + } + // A refactor known not to have run leaves the target as it was; one whose + // outcome isn't known keeps its ID in the record, for a rerun to check. + if (results[0]!.outcome !== "needs-repair") { + setTarget(state, { + name: entry.target.name, + account: entry.target.account, + region: entry.target.region, + stackName: entry.stackName, + status: "pending", + }); + } + } + } + outcome = results[0]!.outcome; + } + + // 4. Done, undone, or recorded for later. + const stackError = results + .filter((stack) => stack.message !== undefined) + .map((stack) => `${stack.stackName}: ${stack.message}`) + .join("\n"); + const failed = outcome !== undefined && !["refactored", "unchanged"].includes(outcome); + if (failed && outcome !== "needs-repair" && state.refactored.length === 0 && state.files) { + // Nothing moved anywhere: put the v0 files back, so the project is as it was. + return await putBackFiles( + rootPath, + new Error(stackError || INTERRUPTED), + backup, + results, + files, + ); + } + if (state.targets.length === 0 && !failed) { + await removeMigrationState(rootPath); + const warnings = await discard(rootPath); + return { + migrated: true, + stacks: results, + files, + recovery: [], + ...(warnings.length > 0 && { warnings }), + }; + } + + // Once a stack has its v1 logical IDs, only the v1 files match it: the backup + // of the v0 files can't be used any more. + const warnings = state.refactored.length > 0 ? await discard(rootPath) : []; + try { + await save(rootPath, state); + } catch (error) { + warnings.push( + `Couldn't update ${STATE_RELATIVE_PATH} (${(error as Error).message}); it may still list ` + + "a target this run refactored, which a rerun checks with CloudFormation.", + ); + } + const repair = results.filter((stack) => stack.outcome === "needs-repair"); + return { + ...partial(state, results, files, stackError || undefined, [ + ...repair.flatMap((stack) => repairSteps(stack)), + ...state.targets + .filter((pending) => pending.status === "pending") + .map( + (pending) => + `Target '${pending.name}' (stack ${pending.stackName}, account ${pending.account}, ` + + `${pending.region}) isn't migrated yet. Don't deploy it until it is: its stack ` + + "still has the v0 logical IDs, so a v1 deploy would replace its resources. Run " + + `\`agentcore migrate --target ${pending.name}\` with credentials for that account.`, + ), + ]), + ...(warnings.length > 0 && { warnings }), + }; + } finally { + guard.release(); + } +} + +/** A migration left part-way, its record kept for `agentcore migrate` to finish. */ +function partial( + state: MigrationState, + stacks: StackRefactorResult[], + files: FileChange[], + error: string | undefined, + recovery: string[], +): MigrationResult { + return { + migrated: false, + stacks, + files, + stateFile: STATE_RELATIVE_PATH, + pendingTargets: state.targets.map((pending) => pending.name), + ...(error !== undefined && { error }), + recovery, + }; +} + +function setTarget(state: MigrationState, target: JournalTarget): void { + const index = state.targets.findIndex((entry) => entry.name === target.name); + if (index === -1) state.targets.push(target); + else state.targets[index] = target; +} + +function dropTarget(state: MigrationState, name: string): void { + state.targets = state.targets.filter((entry) => entry.name !== name); +} + +function markRefactored(state: MigrationState, name: string): void { + dropTarget(state, name); + if (!state.refactored.includes(name)) state.refactored.push(name); +} + +/** CloudFormation's planned actions don't match the plan, so the refactor wasn't executed. */ +export class ActionsMismatchError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionsMismatchError"; + } +} + +/** + * Creates one stack's refactor, checks CloudFormation's actions are exactly the + * planned moves, records the refactor's ID, executes it, and checks that every + * resource kept its physical ID. + */ +async function refactorTarget( + entry: ApplyTarget, + poll: PollOptions, + onCreated: (refactorId: string) => Promise, +): Promise { + if (!entry.bridge) throw new Error(`No refactor template was planned for ${entry.stackName}.`); + const before = await physicalIds(entry.client, entry.stackName); + poll.onStep?.(`Creating the refactor of stack ${entry.stackName}`); + const created = await createRefactor( + { + target: entry.target.name, + region: entry.target.region, + stackName: entry.stackName, + renames: entry.renames, + template: entry.bridge, + client: entry.client, + templates: entry.templates, + }, + poll, + ); + const mismatch = actionsMismatch(created.actions, entry.renames); + if (mismatch) { + throw new ActionsMismatchError( + `CloudFormation's refactor actions don't match the plan (${mismatch}), so the refactor ` + + `(${created.refactorId}) wasn't executed and the stack is unchanged.`, + ); + } + await onCreated(created.refactorId); + await executeRefactor(created, poll); + const after = await physicalIds(entry.client, entry.stackName); + const changed = physicalIdChanges(before, after, entry.renames); + if (changed.length > 0) { + throw new StackRefactorError( + `The refactor of stack '${entry.stackName}' completed, but physical IDs changed: ` + + `${changed.join("; ")}.`, + { + kind: "unknown", + stackName: entry.stackName, + region: entry.target.region, + refactorId: created.refactorId, + }, + ); + } +} + +/** How CloudFormation's actions differ from the planned moves; undefined when they don't. */ +export function actionsMismatch( + actions: RefactorAction[], + renames: ReadonlyMap, +): string | undefined { + const problems: string[] = []; + const seen = new Set(); + for (const action of actions) { + if (action.action !== "MOVE" || action.entity !== "RESOURCE") { + problems.push(`unexpected ${action.action} ${action.entity} ${action.from} → ${action.to}`); + continue; + } + if (renames.get(action.from) !== action.to) { + problems.push(`unplanned move ${action.from} → ${action.to}`); + continue; + } + seen.add(action.from); + } + for (const [from, to] of renames) { + if (!seen.has(from)) problems.push(`missing move ${from} → ${to}`); + } + return problems.length > 0 ? problems.join(", ") : undefined; +} + +/** Physical IDs that changed: a moved resource under its new logical ID, or any other resource. */ +export function physicalIdChanges( + before: Map, + after: Map, + renames: ReadonlyMap, +): string[] { + const changes: string[] = []; + for (const [logicalId, physicalId] of before) { + const now = renames.get(logicalId) ?? logicalId; + const current = after.get(now); + if (current !== physicalId) { + changes.push(`${logicalId} was ${physicalId}, ${now} is ${current ?? "missing"}`); + } + } + return changes; +} + +/** A stack's physical IDs by logical ID. */ +export async function physicalIds( + client: CloudFormationClient, + stackName: string, +): Promise> { + const resources: StackResourceSummary[] = []; + let token: string | undefined; + do { + const page = await client.send( + new ListStackResourcesCommand({ StackName: stackName, ...(token && { NextToken: token }) }), + ); + resources.push(...(page.StackResourceSummaries ?? [])); + token = page.NextToken; + } while (token); + return new Map( + resources.flatMap((resource) => + resource.LogicalResourceId && resource.PhysicalResourceId + ? [[resource.LogicalResourceId, resource.PhysicalResourceId] as const] + : [], + ), + ); +} + +/** + * Puts the v0 files back after a failure before any stack was refactored, and + * deletes the record: the project is exactly as it was. Uses the backup this run + * made when there is one, and otherwise the one on disk. + */ +async function putBackFiles( + rootPath: string, + error: Error, + backup: ProjectFilesBackup | undefined, + stacks: StackRefactorResult[], + changes?: FileChange[], +): Promise { + try { + if (backup) await backup.restore(); + else if (changes) await restoreFromBackup(rootPath, changes); + } catch (restoreError) { + return { + migrated: false, + stacks, + files: [], + restored: false, + stateFile: STATE_RELATIVE_PATH, + error: error.message, + recovery: [ + `Couldn't put back all of the project's v0 files (${(restoreError as Error).message}). ` + + `The originals are in ${BACKUP_RELATIVE_PATH}: copy agentcore/cdk back from its cdk ` + + "directory and everything else from its files directory, or restore them from git " + + "with `git checkout -- .` and delete the files `git status` lists as new. Then delete " + + `${STATE_RELATIVE_PATH}.`, + ], + }; + } + await removeMigrationState(rootPath); + return { migrated: false, stacks, files: [], restored: true, error: error.message, recovery: [] }; +} + +/** + * Undoes a migration stopped while writing the project files: puts the v0 files + * back from the backup the record points to, and deletes the record. + */ +export async function undoInterruptedMigration( + rootPath: string, + state: MigrationState, +): Promise { + const result = await putBackFiles( + rootPath, + new Error("An earlier run stopped while writing the project files."), + undefined, + [], + state.files?.changes ?? [], + ); + return result.restored ? { ...result, error: undefined, undone: true } : result; +} + +async function discard(rootPath: string): Promise { + try { + await discardBackup(rootPath); + return []; + } catch (error) { + return [ + `Couldn't delete ${BACKUP_RELATIVE_PATH}: ${(error as Error).message}; delete it by hand.`, + ]; + } +} + +function repairSteps(entry: StackRefactorResult): string[] { + const id = entry.refactorId ?? ""; + const describe = `aws cloudformation describe-stack-refactor --stack-refactor-id ${id} --region ${entry.region}`; + const list = `aws cloudformation list-stack-resources --stack-name ${entry.stackName} --region ${entry.region}`; + return [ + `Stack ${entry.stackName} (target '${entry.target}') needs a look before anything else is ` + + `done to it: ${entry.message ?? "its refactor's outcome isn't known"}. No AWS resource was ` + + "deleted or replaced (a refactor only moves resources between logical IDs).", + `Check the refactor (${id}) with \`${describe}\` and the stack with \`${list}\`. Don't ` + + "deploy it until every resource is under the logical ID one CLI's template expects. " + + `${STATE_RELATIVE_PATH} keeps the refactor's ID: once CloudFormation reports it finished, ` + + `\`agentcore migrate --target ${entry.target}\` checks it and carries on.`, + ]; +} + +/** + * Turns Ctrl-C during apply into a request to stop: the step running finishes + * (a refactor in progress always runs to its end), then the stack isn't refactored. + * A second Ctrl-C exits at once. + */ +function guardInterrupts(notify: ((message: string) => void) | undefined): { + stop: () => string | undefined; + release: () => void; +} { + let interrupted = false; + const onInterrupt = () => { + if (interrupted) { + release(); + // Nothing else is listening any more, so this exits the way Ctrl-C does. + process.kill(process.pid, "SIGINT"); + return; + } + interrupted = true; + notify?.( + "Stopping once the current step ends; the stack won't be refactored. Press Ctrl-C " + + `again to exit at once (${STATE_RELATIVE_PATH} records where the migration is)`, + ); + }; + const release = () => process.off("SIGINT", onInterrupt); + process.on("SIGINT", onInterrupt); + return { stop: () => (interrupted ? INTERRUPTED : undefined), release }; +} diff --git a/src/core/project/migrate/bridge.test.ts b/src/core/project/migrate/bridge.test.ts new file mode 100644 index 000000000..cc4cacb3c --- /dev/null +++ b/src/core/project/migrate/bridge.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; +import { buildBridgeTemplate, verifyBridge } from "./bridge"; +import { resourceDigests } from "./digest"; +import { mapLogicalIds, renamesOf } from "./mapping"; +import { templateFixture } from "./migrate-test-support"; +import type { CfnTemplate } from "./templates"; + +const clone = (value: T): T => structuredClone(value); + +describe("buildBridgeTemplate", () => { + test("rebuilds the bridge template CloudFormation accepted in the live refactor", () => { + const deployed = templateFixture("codezip.v0"); + const renames = renamesOf(mapLogicalIds(deployed, templateFixture("codezip.v1-rc3"))); + const bridge = buildBridgeTemplate(deployed, renames); + expect(bridge).toEqual(templateFixture("codezip.bridge")); + expect(verifyBridge(deployed, bridge, renames)).toEqual([]); + }); + + test("keeps DefaultPolicy names and custom resources where they are", () => { + const deployed = templateFixture("container.v0"); + const renames = renamesOf(mapLogicalIds(deployed, templateFixture("container.v1-v0names"))); + const bridge = buildBridgeTemplate(deployed, renames); + const policy = + bridge.Resources!["ApplicationRuntimeMyagentRuntimeExecutionRoleDefaultPolicyA86B8133"]!; + expect(policy.Properties?.PolicyName).toBe( + "ApplicationAgentMyagentRuntimeExecutionRoleDefaultPolicyF8497769", + ); + // The build trigger can't move, but now points at the moved handler. + const trigger = + bridge.Resources![ + "ApplicationAgentMyagentContainerImageBuilderContainerBuildTrigger59360A99" + ]!; + expect(trigger.Type).toBe("AWS::CloudFormation::CustomResource"); + expect(JSON.stringify(trigger)).toContain( + "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildHandler10251CED", + ); + expect(verifyBridge(deployed, bridge, renames)).toEqual([]); + }); + + test("renames in resources, DependsOn (both forms), outputs, conditions, and Fn::Sub", () => { + const deployed: CfnTemplate = { + Conditions: { HasRole: { "Fn::Equals": [{ Ref: "Old" }, ""] } }, + Resources: { + Old: { Type: "AWS::IAM::Role", Properties: {} }, + One: { + Type: "AWS::IAM::Policy", + DependsOn: "Old", + Properties: { Roles: [{ Ref: "Old" }] }, + }, + Two: { + Type: "AWS::Lambda::Function", + DependsOn: ["Old", "One"], + Properties: { Role: { "Fn::Sub": "arn:${Old.Arn}" } }, + }, + }, + Outputs: { RoleArn: { Value: { "Fn::GetAtt": "Old.Arn" } } }, + }; + const renames = new Map([["Old", "New"]]); + const bridge = buildBridgeTemplate(deployed, renames); + expect(bridge).toEqual({ + Conditions: { HasRole: { "Fn::Equals": [{ Ref: "New" }, ""] } }, + Resources: { + New: { Type: "AWS::IAM::Role", Properties: {} }, + One: { + Type: "AWS::IAM::Policy", + DependsOn: "New", + Properties: { Roles: [{ Ref: "New" }] }, + }, + Two: { + Type: "AWS::Lambda::Function", + DependsOn: ["New", "One"], + Properties: { Role: { "Fn::Sub": "arn:${New.Arn}" } }, + }, + }, + Outputs: { RoleArn: { Value: { "Fn::GetAtt": "New.Arn" } } }, + }); + expect(verifyBridge(deployed, bridge, renames)).toEqual([]); + // The input is never modified. + expect(deployed.Resources!.Old).toBeDefined(); + }); + + test("refuses a rename onto an ID already in use, or of a resource that isn't there", () => { + const deployed: CfnTemplate = { + Resources: { A: { Type: "T" }, B: { Type: "T" }, C: { Type: "T" } }, + }; + expect(() => buildBridgeTemplate(deployed, new Map([["A", "B"]]))).toThrow( + "Can't rename 'A' to 'B': the deployed T 'B' keeps that logical ID.", + ); + expect(() => + buildBridgeTemplate( + deployed, + new Map([ + ["A", "X"], + ["C", "X"], + ]), + ), + ).toThrow("Can't rename 'C' to 'X': another resource is renamed to it too."); + expect(() => buildBridgeTemplate(deployed, new Map([["Z", "Y"]]))).toThrow( + "the deployed template has no such resource", + ); + }); + + test("applies chained and swapped renames at once, so every reference follows its resource", () => { + const deployed: CfnTemplate = { + Resources: { + A: { Type: "T", Properties: { Name: "a", Uses: { Ref: "B" } } }, + B: { Type: "T", Properties: { Name: "b", Uses: { "Fn::GetAtt": ["A", "Arn"] } } }, + C: { Type: "T", Properties: { Name: "c" }, DependsOn: ["A", "B"] }, + }, + Outputs: { O: { Value: { "Fn::Sub": "${A}/${B.Arn}" } } }, + }; + // Swap: A ↔ B. + const swapped = buildBridgeTemplate( + deployed, + new Map([ + ["A", "B"], + ["B", "A"], + ]), + ); + expect(swapped.Resources).toEqual({ + B: { Type: "T", Properties: { Name: "a", Uses: { Ref: "A" } } }, + A: { Type: "T", Properties: { Name: "b", Uses: { "Fn::GetAtt": ["B", "Arn"] } } }, + C: { Type: "T", Properties: { Name: "c" }, DependsOn: ["B", "A"] }, + }); + expect(swapped.Outputs).toEqual({ O: { Value: { "Fn::Sub": "${B}/${A.Arn}" } } }); + expect( + verifyBridge( + deployed, + swapped, + new Map([ + ["A", "B"], + ["B", "A"], + ]), + ), + ).toEqual([]); + + // Chain: A → B, B → C (C itself moves too, so nothing keeps a taken ID). + const chain = new Map([ + ["A", "B"], + ["B", "C"], + ["C", "D"], + ]); + const chained = buildBridgeTemplate(deployed, chain); + expect(Object.keys(chained.Resources ?? {}).sort()).toEqual(["B", "C", "D"]); + expect(chained.Resources?.B?.Properties).toEqual({ Name: "a", Uses: { Ref: "C" } }); + expect(chained.Resources?.D?.DependsOn).toEqual(["B", "C"]); + expect(verifyBridge(deployed, chained, chain)).toEqual([]); + }); +}); + +describe("verifyBridge", () => { + const deployed = templateFixture("pinned.v0"); + const renames = renamesOf(mapLogicalIds(deployed, templateFixture("pinned.v1"))); + + test("flags a property change, a missing or extra resource, a type change, and changed outputs", () => { + const changed = clone(buildBridgeTemplate(deployed, renames)); + const runtimeId = renames.get("ApplicationAgentMigagentRuntimeB6E7D059")!; + changed.Resources![runtimeId]!.Properties!.Description = "changed"; + // The config bundle's components reference the runtime, so they differ too. + expect(verifyBridge(deployed, changed, renames)).toEqual([ + `${runtimeId} (AWS::BedrockAgentCore::Runtime) differs from deployed ApplicationAgentMigagentRuntimeB6E7D059 beyond its logical ID.`, + "ApplicationConfigBundleMigcbComponentsE5AB18B9 (Custom::AWSCDKCfnJson) differs from deployed ApplicationConfigBundleMigcbComponentsE5AB18B9 beyond its logical ID.", + "ApplicationConfigBundleMigcbFF975EE3 (AWS::BedrockAgentCore::ConfigurationBundle) differs from deployed ApplicationConfigBundleMigcbFF975EE3 beyond its logical ID.", + ]); + + const missing = clone(buildBridgeTemplate(deployed, renames)); + delete missing.Resources![runtimeId]; + missing.Resources!.Extra = { Type: "AWS::SNS::Topic" }; + missing.Resources!.Extra2 = { Type: "AWS::SNS::Topic" }; + const problems = verifyBridge(deployed, missing, renames); + expect(problems[0]).toBe( + `ApplicationAgentMigagentRuntimeB6E7D059 (AWS::BedrockAgentCore::Runtime) is missing from the bridge template as ${runtimeId}.`, + ); + expect(problems.at(-1)).toBe("The bridge template has 1 more resources than the deployed one."); + + const retyped = clone(buildBridgeTemplate(deployed, renames)); + retyped.Resources![runtimeId]!.Type = "AWS::SNS::Topic"; + expect(verifyBridge(deployed, retyped, renames)[0]).toContain("changes type"); + + const outputs = clone(buildBridgeTemplate(deployed, renames)); + outputs.Outputs = { ...outputs.Outputs, Added: { Value: "x" } }; + expect(verifyBridge(deployed, outputs, renames)).toEqual([ + "The bridge template's Outputs section differs from the deployed one beyond logical IDs.", + ]); + + const conditions = clone(buildBridgeTemplate(deployed, renames)); + conditions.Conditions = { Never: { "Fn::Equals": ["a", "b"] } }; + delete conditions.Description; + expect(verifyBridge(deployed, conditions, renames)).toEqual([ + "The bridge template's Conditions section differs from the deployed one beyond logical IDs.", + "The bridge template's Description section differs from the deployed one beyond logical IDs.", + ]); + }); +}); + +describe("resourceDigests", () => { + test("is the same for a template with only logical IDs renamed", () => { + const deployed = templateFixture("container.v0"); + const renames = renamesOf(mapLogicalIds(deployed, templateFixture("container.v1-v0names"))); + const before = resourceDigests(deployed); + const after = resourceDigests(buildBridgeTemplate(deployed, renames)); + for (const [id, digest] of before) expect(after.get(renames.get(id) ?? id)).toBe(digest); + expect(before.has("CDKMetadata")).toBe(false); + }); + + test("changes for the resource that changed and everything that references it", () => { + const template: CfnTemplate = { + Resources: { + Role: { Type: "AWS::IAM::Role", Properties: { Path: "/" } }, + Policy: { Type: "AWS::IAM::Policy", Properties: { Roles: [{ Ref: "Role" }] } }, + Other: { Type: "AWS::SNS::Topic", Properties: {} }, + }, + }; + const changed = clone(template); + changed.Resources!.Role!.Properties!.Path = "/x/"; + const [a, b] = [resourceDigests(template), resourceDigests(changed)]; + expect(b.get("Role")).not.toBe(a.get("Role")); + expect(b.get("Policy")).not.toBe(a.get("Policy")); + expect(b.get("Other")).toBe(a.get("Other")); + }); + + test("tells apart the attribute a reference reads, but not which resource it names", () => { + const reading = (attribute: string, parameter = "P"): CfnTemplate => ({ + Parameters: { P: { Type: "String" }, Q: { Type: "String" } }, + Resources: { + Role: { Type: "AWS::IAM::Role", Properties: {} }, + Fn: { + Type: "AWS::Lambda::Function", + Properties: { Role: { "Fn::GetAtt": ["Role", attribute] }, Name: { Ref: parameter } }, + }, + }, + }); + expect(resourceDigests(reading("Arn")).get("Fn")).not.toBe( + resourceDigests(reading("RoleId")).get("Fn"), + ); + expect(resourceDigests(reading("Arn", "P")).get("Fn")).not.toBe( + resourceDigests(reading("Arn", "Q")).get("Fn"), + ); + }); + + test("ignores the CDK construct path and survives a reference cycle", () => { + const withPath = (path: string): CfnTemplate => ({ + Resources: { + A: { + Type: "T", + Metadata: { "aws:cdk:path": path, keep: 1 }, + Properties: { B: { Ref: "B" } }, + }, + B: { Type: "T", Properties: { A: { Ref: "A" } } }, + }, + }); + expect(resourceDigests(withPath("x/A"))).toEqual(resourceDigests(withPath("y/A"))); + }); +}); diff --git a/src/core/project/migrate/bridge.ts b/src/core/project/migrate/bridge.ts new file mode 100644 index 000000000..bea3cfd58 --- /dev/null +++ b/src/core/project/migrate/bridge.ts @@ -0,0 +1,118 @@ +import { resourceDigests } from "./digest"; +import { + canonicalJson, + dependsOnOf, + renameReferences, + resourcesOf, + type CfnResource, + type CfnTemplate, +} from "./templates"; + +/** + * The template a stack refactor moves the deployed stack to: the deployed template + * with only logical IDs renamed. Each resource in `renames` gets its new key, and + * every Ref, Fn::GetAtt, Fn::Sub placeholder, and DependsOn entry naming it (in + * resources, outputs, and conditions) follows. Resource definitions are otherwise + * untouched, which is what a refactor requires: it moves resources and changes + * nothing about them. Resources not in `renames` (custom resources among them, + * since CloudFormation can't move those) keep their IDs. + * + * Throws when a rename would land on a logical ID the template already uses. + */ +export function buildBridgeTemplate( + deployed: CfnTemplate, + renames: ReadonlyMap, +): CfnTemplate { + const resources = resourcesOf(deployed); + const keptIds = new Set(Object.keys(resources).filter((id) => !renames.has(id))); + const seen = new Set(); + for (const [from, to] of renames) { + if (!(from in resources)) { + throw new Error(`Can't rename '${from}': the deployed template has no such resource.`); + } + if (keptIds.has(to)) { + // Typically a deployed resource v1 drops, whose ID v1 reuses for another. + throw new Error( + `Can't rename '${from}' to '${to}': the deployed ${resources[to]!.Type} '${to}' keeps that logical ID.`, + ); + } + if (seen.has(to)) { + throw new Error(`Can't rename '${from}' to '${to}': another resource is renamed to it too.`); + } + seen.add(to); + } + + const bridged: Record = {}; + for (const [id, resource] of Object.entries(resources)) { + const renamed = renameReferences(resource, renames) as CfnResource; + if (resource.DependsOn !== undefined) { + const ids = dependsOnOf(resource).map((dependency) => renames.get(dependency) ?? dependency); + renamed.DependsOn = typeof resource.DependsOn === "string" ? ids[0]! : ids; + } + bridged[renames.get(id) ?? id] = renamed; + } + + const template: CfnTemplate = { ...deployed, Resources: bridged }; + if (deployed.Outputs !== undefined) { + template.Outputs = renameReferences(deployed.Outputs, renames) as CfnTemplate["Outputs"]; + } + if (deployed.Conditions !== undefined) { + template.Conditions = renameReferences( + deployed.Conditions, + renames, + ) as CfnTemplate["Conditions"]; + } + return template; +} + +/** + * Confirms that `bridge` is `deployed` with only logical IDs changed: every + * deployed resource has a bridge resource under its (possibly renamed) ID with the + * same digest, nothing extra was added, and the outputs are the same once the + * renames are undone. Returns what doesn't hold; empty means the bridge is safe + * to refactor to. + */ +export function verifyBridge( + deployed: CfnTemplate, + bridge: CfnTemplate, + renames: ReadonlyMap, +): string[] { + const problems: string[] = []; + const before = resourceDigests(deployed); + const after = resourceDigests(bridge); + const deployedResources = resourcesOf(deployed); + const bridgeResources = resourcesOf(bridge); + + for (const [id, resource] of Object.entries(deployedResources)) { + const bridgeId = renames.get(id) ?? id; + const counterpart = bridgeResources[bridgeId]; + if (counterpart === undefined) { + problems.push(`${id} (${resource.Type}) is missing from the bridge template as ${bridgeId}.`); + } else if (counterpart.Type !== resource.Type) { + problems.push(`${bridgeId} changes type from ${resource.Type} to ${counterpart.Type}.`); + } else if (before.get(id) !== after.get(bridgeId)) { + problems.push( + `${bridgeId} (${resource.Type}) differs from deployed ${id} beyond its logical ID.`, + ); + } + } + const extra = Object.keys(bridgeResources).length - Object.keys(deployedResources).length; + if (extra > 0) + problems.push(`The bridge template has ${extra} more resources than the deployed one.`); + + // Everything outside Resources (outputs, conditions, parameters, rules, ...) + // must be the deployed section once the renames are undone. + const inverse = new Map([...renames].map(([from, to]) => [to, from])); + const sections = new Set([...Object.keys(deployed), ...Object.keys(bridge)]); + sections.delete("Resources"); + for (const section of [...sections].sort()) { + if ( + canonicalJson(renameReferences(bridge[section], inverse)) !== canonicalJson(deployed[section]) + ) { + problems.push( + `The bridge template's ${section} section differs from the deployed one beyond logical IDs.`, + ); + } + } + return problems; +} diff --git a/src/core/project/migrate/contract.test.ts b/src/core/project/migrate/contract.test.ts new file mode 100644 index 000000000..910a92689 --- /dev/null +++ b/src/core/project/migrate/contract.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { v0Fixture } from "../legacy/fixtures-test-support"; +import { convertSpec } from "./convertSpec"; +import { maxprojDeployedState } from "./migrate-test-support"; + +/** + * The migratedFrom contract with `@aws/agentcore-cdk`: the physical name every + * deployed resource of the `maxproj` v0 fixture keeps, by ` `. + * The same golden file is asserted in the library + * (test/project/migrated-from-contract.test.ts), which synthesizes the converted + * project (physical-names-contract.agentcore.json, copied there verbatim as + * converted.json, with `migratedFrom: "v1"`) and requires the template to give each + * resource exactly that name. Change both together. + */ +const GOLDEN_SPEC = JSON.parse( + readFileSync( + join(import.meta.dir, "__fixtures__", "physical-names-contract.agentcore.json"), + "utf8", + ), +) as Record; + +const GOLDEN = JSON.parse( + readFileSync( + join(import.meta.dir, "__fixtures__", "physical-names-contract.golden.json"), + "utf8", + ), +) as { project: string; migratedFrom: string; names: Record }; + +describe("migratedFrom contract with @aws/agentcore-cdk", () => { + test("the golden files are the ones the library asserts, byte for byte", () => { + // The same hashes are asserted in the library's test/project/migrated-from-contract.test.ts. + const sha256 = (name: string) => + createHash("sha256") + .update(readFileSync(join(import.meta.dir, "__fixtures__", name))) + .digest("hex"); + expect(sha256("physical-names-contract.golden.json")).toBe( + "2dac5fc18e6893f04276cddab70ad50cd739968f6af25a314b34e8e5b7d5168c", + ); + expect(sha256("physical-names-contract.agentcore.json")).toBe( + "25e3f3a77da888068fba1709a8507419c33826b429e04b8d600e9e2b7f61db5d", + ); + }); + + test("migrate keeps the golden names for the maxproj fixture and marks it migratedFrom", () => { + const { json, pins, problems } = convertSpec( + v0Fixture("top-mcpRuntimeTools.json"), + maxprojDeployedState(), + { + // A Dockerfile harness the v0 app built (v0.16.0 and later). + h1: { dockerfile: "Dockerfile", hasDockerfile: true, codeLocation: "app/h1" }, + }, + ); + expect(problems).toEqual([]); + expect(GOLDEN.project).toBe("maxproj"); + expect(GOLDEN.migratedFrom).toBe("v1"); + expect( + Object.fromEntries(pins.map((pin) => [`${pin.resourceType} ${pin.name}`, pin.value])), + ).toEqual(GOLDEN.names); + expect(json).toEqual(GOLDEN_SPEC); + expect(json.migratedFrom).toBe("v1"); + }); +}); diff --git a/src/core/project/migrate/convertHarness.test.ts b/src/core/project/migrate/convertHarness.test.ts new file mode 100644 index 000000000..59d02fb47 --- /dev/null +++ b/src/core/project/migrate/convertHarness.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { HarnessSpecSchema, HarnessYamlSchema } from "../../../projectSchemas/harness"; +import { canonical, convertHarness, harnessJsonToYaml } from "./convertHarness"; +import { F1_HARNESS } from "./migrate-test-support"; + +const FILE = "app/h/harness.json"; +const SUBNET = "subnet-0123456789abcdef0"; +const SG = "sg-0123456789abcdef0"; +const EFS = "arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0"; +const S3_FILES = + "arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef0/access-point/fsap-0123456789abcdef0"; + +/** harness.json shapes v0 0.31.1 accepted, from the minimal scaffold to every optional field. */ +const HARNESSES: Record> = { + "the scaffolded default": F1_HARNESS, + "a fully configured harness": { + name: "full", + model: { + provider: "open_ai", + modelId: "gpt-5", + apiKeyArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/k", + apiFormat: "responses", + temperature: 0.2, + topP: 0.9, + maxTokens: 4096, + }, + systemPrompt: "You are helpful.\nAnswer briefly.", + tools: [ + { + type: "remote_mcp", + name: "research", + config: { remoteMcp: { url: "https://mcp.example.com/mcp", headers: { a: "b" } } }, + }, + { type: "agentcore_browser", name: "browser" }, + { type: "agentcore_code_interpreter", name: "ci", config: { agentCoreCodeInterpreter: {} } }, + { + type: "agentcore_gateway", + name: "gw", + config: { agentCoreGateway: { gatewayArn: "arn:gw", outboundAuth: { awsIam: {} } } }, + }, + { + type: "inline_function", + name: "fn", + config: { inlineFunction: { description: "d", inputSchema: { type: "object" } } }, + }, + ], + skills: [ + "./skills/local", + { path: "skills/other" }, + { s3Uri: "s3://bucket/skills" }, + { gitUrl: "https://github.com/org/skills", path: "sub", auth: { credentialName: "gh" } }, + { awsSkills: { paths: ["core-skills/aws-serverless"] } }, + ], + allowedTools: ["*"], + memory: { mode: "managed", strategies: ["SEMANTIC"], eventExpiryDuration: 30 }, + maxIterations: 10, + maxTokens: 2000, + timeoutSeconds: 300, + truncation: { strategy: "sliding_window", config: { slidingWindow: { messagesCount: 20 } } }, + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/harness:latest", + executionRoleArn: "arn:aws:iam::123456789012:role/harness", + networkMode: "VPC", + networkConfig: { subnets: [SUBNET], securityGroups: [SG], vpcId: "vpc-0123456789abcdef0" }, + lifecycleConfig: { idleRuntimeSessionTimeout: 900, maxLifetime: 3600 }, + sessionStoragePath: "/mnt/session", + efsAccessPoints: [{ accessPointArn: EFS, mountPath: "/mnt/efs" }], + s3AccessPoints: [{ accessPointArn: S3_FILES, mountPath: "/mnt/s3" }], + environmentVariables: { LOG_LEVEL: "debug" }, + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + allowedClients: ["cli"], + }, + }, + connections: [ + { + id: "mem", + to: { type: "memory", arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m-1" }, + access: "read", + }, + ], + tags: { team: "x" }, + }, + "an existing memory and a dockerfile": { + name: "existing", + model: { + provider: "lite_llm", + modelId: "bedrock/x", + apiBase: "https://llm.example.com", + additionalParams: { a: 1 }, + }, + memory: { + name: "projectMemory", + actorId: "user", + messagesCount: 5, + retrievalConfig: { topK: 3 }, + }, + dockerfile: "Dockerfile", + networkMode: "PUBLIC", + truncation: { strategy: "none" }, + }, + "a gemini model with session storage only": { + name: "gem", + model: { provider: "gemini", modelId: "gemini-x", apiKeyArn: "arn:k", topK: 10 }, + sessionStoragePath: "/mnt/data", + memory: { + mode: "existing", + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m-1", + }, + }, +}; + +describe("harnessJsonToYaml", () => { + test.each(Object.entries(HARNESSES))("round-trips %s through harness.yaml", (_label, json) => { + const { yaml, problems } = harnessJsonToYaml(json, FILE); + expect(problems).toEqual([]); + if (yaml === undefined) throw new Error("expected harness.yaml content"); + expect(yaml).toStartWith("# Harness configuration:"); + const decoded = HarnessYamlSchema.parse(parse(yaml)); + expect(canonical(decoded)).toEqual(canonical(HarnessSpecSchema.parse(json))); + }); + + test("writes the API-shaped nested form", () => { + const { yaml } = harnessJsonToYaml(HARNESSES["a fully configured harness"], FILE); + const doc = parse(yaml!) as Record; + expect(doc.model).toEqual({ + openAiModelConfig: expect.objectContaining({ modelId: "gpt-5" }), + }); + expect(doc.systemPrompt).toEqual([{ text: "You are helpful.\nAnswer briefly." }]); + expect(doc.memory).toEqual({ + managedMemoryConfiguration: { strategies: ["SEMANTIC"], eventExpiryDuration: 30 }, + }); + expect(doc.environmentArtifact).toEqual({ + containerConfiguration: { + containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/harness:latest", + }, + }); + expect(doc.networkConfig).toEqual({ vpcId: "vpc-0123456789abcdef0" }); + expect(doc.environment).toEqual({ + agentCoreRuntimeEnvironment: { + networkConfiguration: { + networkMode: "VPC", + networkModeConfig: { subnets: [SUBNET], securityGroups: [SG] }, + }, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 900, maxLifetime: 3600 }, + filesystemConfigurations: [ + { sessionStorage: { mountPath: "/mnt/session" } }, + { efsAccessPoint: { accessPointArn: EFS, mountPath: "/mnt/efs" } }, + { s3FilesAccessPoint: { accessPointArn: S3_FILES, mountPath: "/mnt/s3" } }, + ], + }, + }); + expect(doc.authorizerConfiguration).toEqual({ + customJWTAuthorizer: expect.objectContaining({ allowedClients: ["cli"] }), + }); + expect("authorizerType" in doc).toBe(false); + }); + + test("omits fields the harness doesn't set", () => { + const doc = parse(harnessJsonToYaml(F1_HARNESS, FILE).yaml!) as Record; + expect(doc).toEqual({ + name: "CompatAHarness", + model: { bedrockModelConfig: { modelId: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" } }, + tools: [], + skills: [], + memory: { disabled: {} }, + }); + }); + + test("reports a harness.json v0 itself rejects, with the field path", () => { + const { yaml, problems } = harnessJsonToYaml({ name: "h", model: { provider: "nope" } }, FILE); + expect(yaml).toBeUndefined(); + expect(problems.map((problem) => `${problem.file} ${problem.path}`)).toContain( + `${FILE} model.provider`, + ); + }); +}); + +describe("convertHarness", () => { + const roots: string[] = []; + afterEach(() => Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })))); + + async function root(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), "agentcore-harness-")); + roots.push(dir); + for (const [path, content] of Object.entries(files)) { + await mkdir(join(dir, path, ".."), { recursive: true }); + await writeFile(join(dir, path), content); + } + return dir; + } + + test("reads the entry's harness.json and normalizes its directory", async () => { + const dir = await root({ "app/h/harness.json": JSON.stringify(F1_HARNESS) }); + const result = await convertHarness(dir, { name: "h", path: "./app/h/" }); + expect(result.directory).toBe("app/h"); + expect(result.problems).toEqual([]); + expect(result.yaml).toContain("bedrockModelConfig"); + }); + + test.each(["../outside", "/abs/harness", "app/../../outside"])( + "refuses a harness path outside the project: %s", + async (path) => { + const result = await convertHarness(await root({}), { name: "h", path }); + expect(result.yaml).toBeUndefined(); + expect(result.problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "harnesses[h].path", + message: `'${path}' is outside the project; move the harness into the project first`, + }, + ]); + }, + ); + + test("returns the harness facts the pins depend on", async () => { + const dir = await root({ + "app/h/harness.json": JSON.stringify({ + ...F1_HARNESS, + dockerfile: "Dockerfile", + executionRoleArn: "arn:aws:iam::123456789012:role/mine", + }), + }); + const result = await convertHarness(dir, { name: "h", path: "app/h" }); + expect(result.facts).toEqual({ + dockerfile: "Dockerfile", + containerUri: undefined, + executionRoleArn: "arn:aws:iam::123456789012:role/mine", + }); + expect(result.warnings).toEqual([]); + }); + + test("keeps a file-backed prompt in system-prompt.md, inlining nothing", async () => { + const dir = await root({ + "app/h/harness.json": JSON.stringify(F1_HARNESS), + "app/h/system-prompt.md": "From the file.\n", + }); + const result = await convertHarness(dir, { name: "h", path: "app/h" }); + expect(parse(result.yaml!)).not.toHaveProperty("systemPrompt"); + expect(result.warnings).toEqual([]); + }); + + test("keeps an inline prompt inline and warns that system-prompt.md is ignored", async () => { + const dir = await root({ + "app/h/harness.json": JSON.stringify({ ...F1_HARNESS, systemPrompt: "Inline." }), + "app/h/system-prompt.md": "From the file.\n", + }); + const result = await convertHarness(dir, { name: "h", path: "app/h" }); + expect((parse(result.yaml!) as Record).systemPrompt).toEqual([ + { text: "Inline." }, + ]); + expect(result.warnings).toEqual([ + "app/h/system-prompt.md is ignored because harness.json sets systemPrompt inline; v1 does " + + "the same. Delete the file, or remove systemPrompt from harness.yaml to use it.", + ]); + }); + + test("reports a missing harness.json", async () => { + const result = await convertHarness(await root({}), { name: "h", path: "app/h" }); + expect(result.yaml).toBeUndefined(); + expect(result.problems).toEqual([ + { + file: "app/h/harness.json", + path: "(file)", + message: expect.stringContaining("Couldn't read it"), + }, + ]); + }); +}); diff --git a/src/core/project/migrate/convertHarness.ts b/src/core/project/migrate/convertHarness.ts new file mode 100644 index 000000000..2981c2ddf --- /dev/null +++ b/src/core/project/migrate/convertHarness.ts @@ -0,0 +1,200 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, posix } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { parse, stringify } from "yaml"; +import type { MigrationProblem } from "../../../handlers/project/migrate/types"; +import { + encodeHarnessYaml, + HarnessSpecSchema, + HarnessYamlSchema, + type HarnessSpec, +} from "../../../projectSchemas/harness"; +import { V0HarnessSpecSchema, type V0HarnessSpec } from "../../../projectSchemas/v0"; +import type { HarnessFacts } from "./convertSpec"; +import { describePath, droppedPaths, problemsFromZodError } from "./issues"; + +const YAML_HEADER = + "# Harness configuration: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/harness.html\n" + + "# Converted from harness.json by agentcore migrate.\n\n"; + +export type ConvertedHarness = { + name: string; + /** The harness directory, project-relative and `/`-separated. */ + directory: string; + /** The harness.yaml content; absent when the harness has problems. */ + yaml?: string; + problems: MigrationProblem[]; + warnings: string[]; + /** What harness.json says about the AWS resources v0 made; absent when it couldn't be read. */ + facts?: HarnessFacts; +}; + +/** Reads a harness registry entry's `harness.json` and converts it to `harness.yaml`. */ +export async function convertHarness( + rootPath: string, + entry: { name: string; path: string }, +): Promise { + const directory = posix.normalize(entry.path.replaceAll("\\", "/")).replace(/\/$/, ""); + const file = `${directory}/harness.json`; + if ( + directory === ".." || + directory.startsWith("../") || + posix.isAbsolute(directory) || + /^[a-zA-Z]:/.test(directory) + ) { + return { + name: entry.name, + directory, + warnings: [], + problems: [ + { + file: "agentcore/agentcore.json", + path: `harnesses[${entry.name}].path`, + message: `'${entry.path}' is outside the project; move the harness into the project first`, + }, + ], + }; + } + let raw: unknown; + try { + raw = JSON.parse(await readFile(join(rootPath, file), "utf8")); + } catch (error) { + return { + name: entry.name, + directory, + warnings: [], + problems: [ + { file, path: "(file)", message: `Couldn't read it: ${(error as Error).message}` }, + ], + }; + } + const converted = harnessJsonToYaml(raw, file); + const warnings: string[] = []; + // v0 and v1 resolve the prompt the same way: an inline systemPrompt wins, and + // system-prompt.md is read only when there is none. The conversion keeps the + // inline prompt inline and never inlines the file, so the deployed prompt stays + // the same; a file shadowed by an inline prompt is worth pointing out. + if ( + converted.facts?.systemPrompt !== undefined && + existsSync(join(rootPath, directory, "system-prompt.md")) + ) { + warnings.push( + `${directory}/system-prompt.md is ignored because harness.json sets systemPrompt inline; ` + + "v1 does the same. Delete the file, or remove systemPrompt from harness.yaml to use it.", + ); + } + const facts = converted.facts && { + dockerfile: converted.facts.dockerfile, + containerUri: converted.facts.containerUri, + executionRoleArn: converted.facts.executionRoleArn, + }; + return { + name: entry.name, + directory, + yaml: converted.yaml, + problems: converted.problems, + warnings, + facts, + }; +} + +/** + * Converts the content of a v0 `harness.json` into a v1 `harness.yaml`. + * + * The file is read with v0's schema (so v0's defaults apply, as they did when it + * deployed), validated with v1's harness spec, encoded, and read back with + * {@link HarnessYamlSchema}. Anything that doesn't survive that round trip unchanged + * is reported as a problem rather than silently changing the deployed harness. + */ +export function harnessJsonToYaml( + raw: unknown, + file: string, +): { yaml?: string; problems: MigrationProblem[]; facts?: V0HarnessSpec } { + const v0 = V0HarnessSpecSchema.safeParse(raw); + if (!v0.success) return { problems: problemsFromZodError(file, raw, v0.error) }; + const facts = v0.data; + + const v1 = HarnessSpecSchema.safeParse(v0.data); + if (!v1.success) return { facts, problems: problemsFromZodError(file, v0.data, v1.error) }; + const dropped = droppedPaths(v0.data, v1.data); + if (dropped.length > 0) { + return { + facts, + problems: dropped.map((path) => ({ + file, + path: describePath(v0.data, path), + message: "v1 harnesses don't support this field", + })), + }; + } + + const yaml = YAML_HEADER + stringify(encodeHarnessYaml(v1.data)); + const decoded = HarnessYamlSchema.safeParse(parse(yaml)); + if (!decoded.success) { + return { + facts, + problems: problemsFromZodError(file, v1.data, decoded.error).map((problem) => ({ + ...problem, + message: `can't be written to harness.yaml: ${problem.message}`, + })), + }; + } + const changed = changedPaths(canonical(v1.data), canonical(decoded.data)); + if (changed.length > 0) { + return { + facts, + problems: changed.map((path) => ({ + file, + path: describePath(v1.data, path), + message: "harness.yaml can't express this value", + })), + }; + } + return { yaml, facts, problems: [] }; +} + +/** + * The harness spec with the codec's built-in defaults applied, so a spec and its + * harness.yaml round trip compare equal: harness.yaml always yields a network mode + * and an authorizer type, and an empty list of access points means none. + */ +export function canonical(spec: HarnessSpec): unknown { + const { efsAccessPoints, s3AccessPoints, ...rest } = spec; + return JSON.parse( + JSON.stringify({ + ...rest, + networkMode: rest.networkMode ?? "PUBLIC", + authorizerType: rest.authorizerType ?? "AWS_IAM", + efsAccessPoints: efsAccessPoints?.length ? efsAccessPoints : undefined, + s3AccessPoints: s3AccessPoints?.length ? s3AccessPoints : undefined, + }), + ) as unknown; +} + +/** The paths at which two JSON values differ, stopping at the first differing level. */ +function changedPaths( + expected: unknown, + actual: unknown, + path: PropertyKey[] = [], +): PropertyKey[][] { + if (isDeepStrictEqual(expected, actual)) return []; + const bothObjects = + typeof expected === "object" && + expected !== null && + typeof actual === "object" && + actual !== null && + Array.isArray(expected) === Array.isArray(actual); + if (!bothObjects) return [path]; + if (Array.isArray(expected) && Array.isArray(actual) && expected.length !== actual.length) { + return [path]; + } + const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); + return [...keys].flatMap((key) => + changedPaths( + (expected as Record)[key], + (actual as Record)[key], + [...path, Array.isArray(expected) ? Number(key) : key], + ), + ); +} diff --git a/src/core/project/migrate/convertSpec.test.ts b/src/core/project/migrate/convertSpec.test.ts new file mode 100644 index 000000000..c80ddf35f --- /dev/null +++ b/src/core/project/migrate/convertSpec.test.ts @@ -0,0 +1,547 @@ +import { describe, expect, test } from "bun:test"; +import type { PinnedResourceType } from "../../../handlers/project/migrate/types"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { V0AgentCoreProjectSpecSchema, type V0DeployedState } from "../../../projectSchemas/v0"; +import { rawFixture, v0Fixture, validV0FixtureNames } from "../legacy/fixtures-test-support"; +import { convertSpec, convertTargets, deployedResources, type HarnessFacts } from "./convertSpec"; +import { F1_DEPLOYED_STATE, F1_SPEC } from "./migrate-test-support"; + +type Json = Record; +const NEVER_DEPLOYED: V0DeployedState = { targets: {} }; +const f1 = () => V0AgentCoreProjectSpecSchema.parse(structuredClone(F1_SPEC)); +const f1State = () => structuredClone(F1_DEPLOYED_STATE) as V0DeployedState; + +/** + * The audit fixtures v1 validates more strictly than v0 (the schema audit; see the design doc), + * with the field each one fails on. Every other v0-valid fixture converts cleanly. + */ +const EXPECTED_PROBLEMS: Record = { + "configbundle-branch-leading-digit.json": ["configBundles[cb1].branchName"], + "configbundle-branch-with-dot.json": ["configBundles[cb1].branchName"], + "credential-env-collision.json": ["credentials[svc_client_secret].name"], + "credential-hyphen-underscore-collision.json": ["credentials[my_key].name"], + "credential-name-1-char.json": ["credentials[k].name"], + "credential-name-2-chars.json": ["credentials[ab].name"], + "credential-oauth-no-discoveryUrl_import_.json": ["credentials[imported-oauth]"], + "credential-oauth-vendor-non-custom-no-url.json": ["credentials[github]"], + "credential-oauth-vendor-non-custom.json": ["credentials[google]"], + "gateway-target-apigw-empty-toolDefinitions.json": [ + "agentCoreGateways[gw1].targets[apigw].toolDefinitions", + ], + "gateway-target-connector-outboundAuth-NONE.json": [ + "agentCoreGateways[gw1].targets[websearch].outboundAuth", + ], + "gateway-target-httpRuntime-with-schemaSource.json": [ + "agentCoreGateways[gw2].targets[httprt].schemaSource", + ], + "gateway-target-lambda-with-endpoint.json": ["agentCoreGateways[gw1].targets[lambdaTs].endpoint"], + "gateway-target-mcpServer-empty-configurations.json": [ + "agentCoreGateways[gw1].targets[mcpExt].configurations", + ], + "gateway-target-mcpServer-with-passthrough.json": [ + "agentCoreGateways[gw1].targets[mcpExt].passthrough", + ], + "gateway-target-openapi-with-httpRuntime.json": [ + "agentCoreGateways[gw1].targets[openapi].httpRuntime", + ], + "gateway-target-smithy-outboundAuth-NONE.json": [ + "agentCoreGateways[gw1].targets[smithy].outboundAuth", + ], + "online-eval-11-evaluators.json": ["onlineEvalConfigs[oe1].evaluators"], + "payment-connector-provider-mismatch.json": ["payments[pm1].connectors[stripe1].provider"], + "payment-description-punctuation.json": ["payments[pm1].description"], + "payment-duplicate-connector-names.json": ["payments[pm1].connectors[cb_manual].name"], + "payment-jwt-discovery-not-wellknown.json": [ + "payments[pm2].authorizerConfiguration.customJWTAuthorizer.discoveryUrl", + ], + "payment-jwt-empty-arrays.json": [ + "payments[pm2].authorizerConfiguration.customJWTAuthorizer.allowedClients", + ], + "payment-jwt-scope-with-space.json": [ + "payments[pm2].authorizerConfiguration.customJWTAuthorizer.allowedScopes[0]", + ], + "payment-manager-case-collision.json": ["payments[PM1].name"], + "payment-spendlimit-nonnumeric.json": ["payments[pm1].defaultSpendLimit"], + "project-name-crewai.json": ["name"], + "project-name-tiktoken.json": ["name"], + "runtime-codezip-no-runtimeVersion.json": ["runtimes[agentD].runtimeVersion"], + "runtime-container-vpc-no-vpcId.json": ["runtimes[agentB].networkConfig.vpcId"], +}; + +describe("convertSpec on the v0 audit fixtures", () => { + test.each(validV0FixtureNames())("%s converts or names exactly what v1 rejects", (name) => { + const result = convertSpec(v0Fixture(name), NEVER_DEPLOYED); + const expected = EXPECTED_PROBLEMS[name]; + if (expected) { + expect(result.problems.map((problem) => problem.path)).toEqual(expected); + for (const problem of result.problems) { + expect(problem.file).toBe("agentcore/agentcore.json"); + expect(problem.message.length).toBeGreaterThan(0); + } + return; + } + expect(result.problems).toEqual([]); + // What gets written is a v1 spec, and reading it back changes nothing. + const reparsed = ProjectSpecSchema.parse(structuredClone(result.json)); + expect(JSON.parse(JSON.stringify(reparsed))).toEqual(JSON.parse(JSON.stringify(result.json))); + expect(result.json.version).toBe(2); + expect(result.json.name).toBe(String(rawFixture(name).name)); + }); + + test("every expected problem fixture exists", () => { + const names = new Set(validV0FixtureNames()); + expect(Object.keys(EXPECTED_PROBLEMS).filter((name) => !names.has(name))).toEqual([]); + }); + + test("warns about the fields v1 drops, naming the resource", () => { + const { warnings } = convertSpec(v0Fixture("baseline.json"), NEVER_DEPLOYED); + expect(warnings).toEqual([ + "Dropped fields v1 doesn't use: credentials[my-oauth].usage, credentials[gw1-oauth].usage", + ]); + }); + + test("warns about dropped unassigned targets and A/B tests", () => { + expect( + convertSpec(v0Fixture("top-unassignedTargets.json"), NEVER_DEPLOYED).warnings[0], + ).toStartWith("Dropped unassigned gateway targets (u1)"); + const { warnings, json } = convertSpec(v0Fixture("top-abTests-nonempty.json"), NEVER_DEPLOYED); + expect(warnings[0]).toStartWith("Removed abTests (ab1) from agentcore.json"); + expect("abTests" in json).toBe(false); + }); + + test("keeps tool runtimes under their v1 name", () => { + const { json } = convertSpec(v0Fixture("top-mcpRuntimeTools.json"), NEVER_DEPLOYED); + expect("mcpRuntimeTools" in json).toBe(false); + expect((json.toolRuntimes as Json[])[0]?.name).toBe("rtTool"); + }); +}); + +describe("convertSpec kept names", () => { + test('marks the project migratedFrom "v1" and lists every deployed resource\'s v0 name', () => { + const { pins, problems, json } = convertSpec(f1(), f1State()); + expect(problems).toEqual([]); + const pin = (resourceType: PinnedResourceType, name: string, value: string) => ({ + resourceType, + name, + value, + targets: ["default"], + }); + expect(pins).toEqual([ + pin("runtime", "CompatAAgent", "CompatA_CompatAAgent"), + pin("memory", "CompatAAgentMemory", "CompatA_CompatAAgentMemory"), + pin("evaluator", "CompatAJudge", "CompatA_CompatAJudge"), + pin("online-eval", "CompatAOnline", "CompatA_CompatAOnline"), + pin("policy-engine", "CompatAPE", "CompatA_CompatAPE"), + // Cedar policies keep their verbatim names, so none is listed. + pin("config-bundle", "CompatABundle", "CompatACompatABundle"), + pin("harness", "CompatAHarness", "CompatA_CompatAHarness"), + pin("gateway", "CompatAGw", "CompatA-CompatAGw"), + ]); + // The flag keeps v0 naming for the whole project; no per-resource names are written. + const spec = ProjectSpecSchema.parse(json); + expect(spec.migratedFrom).toBe("v1"); + expect(Object.keys(json).slice(0, 3)).toEqual(["name", "version", "migratedFrom"]); + expect(JSON.stringify(spec)).not.toContain("resourceName"); + expect(JSON.stringify(spec)).not.toContain("providerName"); + }); + + test("marks a never-deployed project migratedFrom too", () => { + expect(convertSpec(f1(), NEVER_DEPLOYED).json.migratedFrom).toBe("v1"); + }); + + test("leaves never-deployed resources unpinned", () => { + expect(convertSpec(f1(), NEVER_DEPLOYED).pins).toEqual([]); + }); + + test("pins what any target deployed, including gateways recorded under mcp", () => { + const state: V0DeployedState = { + targets: { + dev: { resources: { memories: f1State().targets.default!.resources!.memories } }, + prod: { + resources: { + mcp: { + gateways: { + CompatAGw: { gatewayId: "compata-compatagw-abcde12345", gatewayArn: "arn:gw" }, + }, + }, + }, + }, + }, + }; + const { pins } = convertSpec(f1(), state); + expect(pins.map((pin) => `${pin.resourceType}:${pin.value}@${pin.targets.join(",")}`)).toEqual([ + "memory:CompatA_CompatAAgentMemory@dev", + "gateway:CompatA-CompatAGw@prod", + ]); + }); + + test("keeps a gateway's explicit v0 resourceName in the spec, recording no name for it", () => { + const v0 = f1(); + v0.agentCoreGateways[0]!.resourceName = "custom-gw"; + const state = f1State(); + state.targets.default!.resources!.gateways!.CompatAGw!.gatewayId = "custom-gw-xyzab12345"; + const { json, pins, problems } = convertSpec(v0, state); + // The library prefers the spec's resourceName, so a recorded name would go unused. + expect(pins.some((p) => p.resourceType === "gateway")).toBe(false); + expect(problems).toEqual([]); + expect((json.agentCoreGateways as { resourceName?: string }[])[0]?.resourceName).toBe( + "custom-gw", + ); + + state.targets.default!.resources!.gateways!.CompatAGw!.gatewayId = "other-gw-xyzab12345"; + expect(convertSpec(v0, state).problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "gateway CompatAGw", + message: + "deployed as 'other-gw-xyzab12345', which doesn't match its resourceName 'custom-gw', so its name can't be kept safely", + }, + ]); + }); + + test("reports a deployed ID that doesn't match the derived name instead of pinning it", () => { + const state = f1State(); + state.targets.default!.resources!.runtimes!.CompatAAgent!.runtimeId = "Renamed_Agent-abc"; + const { pins, problems } = convertSpec(f1(), state); + expect(pins.some((pin) => pin.resourceType === "runtime")).toBe(false); + expect(problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "runtime CompatAAgent", + message: + "deployed as 'Renamed_Agent-abc', which doesn't match the name 'CompatA_CompatAAgent' " + + "v0 would have given it, so its name can't be kept safely", + }, + ]); + }); + + test("pins a deployed managed code-based evaluator's Lambda function name", () => { + const v0 = V0AgentCoreProjectSpecSchema.parse({ + ...structuredClone(F1_SPEC), + onlineEvalConfigs: [], + evaluators: [ + { + name: "CodeJudge", + level: "TRACE", + config: { codeBased: { managed: { codeLocation: "app/evaluators/CodeJudge" } } }, + }, + ], + }); + const state: V0DeployedState = { + targets: { + default: { + resources: { + evaluators: { + CodeJudge: { evaluatorId: "CompatA_CodeJudge-abcde12345", evaluatorArn: "arn:e" }, + }, + }, + }, + }, + }; + const { pins, problems } = convertSpec(v0, state); + expect(problems).toEqual([]); + expect(pins).toContainEqual({ + resourceType: "evaluator-function", + name: "CodeJudge", + value: "CompatA-eval-CodeJudge", + targets: ["default"], + }); + }); + + test("does not pin the function of an evaluator whose own name can't be kept", () => { + const v0 = V0AgentCoreProjectSpecSchema.parse({ + ...structuredClone(F1_SPEC), + onlineEvalConfigs: [], + evaluators: [ + { + name: "CodeJudge", + level: "TRACE", + config: { codeBased: { managed: { codeLocation: "app/evaluators/CodeJudge" } } }, + }, + ], + }); + const state: V0DeployedState = { + targets: { + default: { + resources: { + evaluators: { + CodeJudge: { evaluatorId: "Other_CodeJudge-abcde12345", evaluatorArn: "arn:e" }, + }, + }, + }, + }, + }; + const { pins, problems } = convertSpec(v0, state); + expect(pins).toEqual([]); + expect(problems.map((problem) => problem.path)).toEqual(["evaluator CodeJudge"]); + }); + + test("leaves credential provider names to credential provisioning", () => { + const { pins, warnings } = convertSpec(f1(), f1State()); + expect(pins.some((pin) => (pin.resourceType as string).startsWith("credential"))).toBe(false); + expect(warnings.some((warning) => warning.includes("providerName"))).toBe(false); + }); + + test("does not pin an evaluator function that was never deployed", () => { + const v0 = V0AgentCoreProjectSpecSchema.parse({ + ...structuredClone(F1_SPEC), + onlineEvalConfigs: [], + evaluators: [ + { + name: "CodeJudge", + level: "TRACE", + config: { codeBased: { managed: { codeLocation: "app/evaluators/CodeJudge" } } }, + }, + ], + }); + expect(convertSpec(v0, NEVER_DEPLOYED).pins).toEqual([]); + }); + + test("does not mutate the v0 spec", () => { + const v0 = f1(); + const before = JSON.stringify(v0); + convertSpec(v0, f1State()); + expect(JSON.stringify(v0)).toBe(before); + }); +}); + +describe("deployedResources", () => { + test("collects each target's recorded IDs per resource", () => { + const state = f1State(); + state.targets.prod = structuredClone(state.targets.default!); + const deployed = deployedResources(state); + expect(deployed.runtimes.get("CompatAAgent")).toEqual([ + { target: "default", id: "CompatA_CompatAAgent-5ITOUFEJg5" }, + { target: "prod", id: "CompatA_CompatAAgent-5ITOUFEJg5" }, + ]); + expect([...deployed.policies.keys()]).toEqual(["CompatAPE/CompatAPolicy"]); + }); +}); + +describe("convertTargets", () => { + test("accepts targets v1 can read", () => { + expect(convertTargets(rawFixture("aws-targets_default.json"))).toEqual([]); + }); + + test("accepts hyphenated target names, which v1 allows like v0 did", () => { + expect(convertTargets(rawFixture("aws-targets_hyphen-name.json"))).toEqual([]); + }); + + test("blocks target names that differ only by case", () => { + const problems = convertTargets(rawFixture("aws-targets_case-dup.json")); + expect(problems[0]?.message).toContain("Duplicate deployment target name"); + }); +}); + +describe("convertSpec pins for tool runtimes, gateway-target compute, and containers", () => { + const recorded = (resources: Record): V0DeployedState => + ({ targets: { default: { resources } } }) as V0DeployedState; + + test("pins deployed tool runtimes, target Lambdas, and Container runtime repositories", () => { + const state = recorded({ + runtimes: { + agentB: { + runtimeId: "maxproj_agentB-abcde12345", + runtimeArn: "arn:r", + roleArn: "arn:role", + }, + }, + mcp: { + runtimes: { + rtTool: { + runtimeId: "maxproj_rtToolRuntime-abcde12345", + runtimeArn: "arn:rt", + runtimeEndpoint: "https://rt", + }, + }, + lambdas: { + lambdaTs: { functionName: "maxproj-lambdaTs", functionArn: "arn:fn" }, + }, + }, + }); + const { pins, problems } = convertSpec(v0Fixture("top-mcpRuntimeTools.json"), state); + expect(problems).toEqual([]); + const gateway = pins.find((pin) => pin.resourceType === "gateway-target-function")?.name; + expect(pins.map(({ resourceType, value }) => ({ resourceType, value }))).toEqual([ + { resourceType: "runtime", value: "maxproj_agentB" }, + { resourceType: "runtime-repository", value: "maxproj/agentb" }, + { resourceType: "gateway-target-function", value: "maxproj-lambdaTs" }, + { resourceType: "tool-runtime", value: "maxproj_rtToolRuntime" }, + ]); + expect(gateway).toEndWith("/lambdaTs"); + expect(pins.every((pin) => pin.targets.join() === "default")).toBe(true); + }); + + test("pins a deployed gateway target's AgentCore Runtime", () => { + const state = recorded({ + mcp: { + runtimes: { + mcpRt: { + runtimeId: "maxproj_rtToolRuntime-abcde12345", + runtimeArn: "arn:rt", + runtimeEndpoint: "https://rt", + }, + }, + }, + }); + const { pins, problems } = convertSpec( + v0Fixture("gateway-target-mcpServer-agentcoreRuntime-compute.json"), + state, + ); + expect(problems).toEqual([]); + expect(pins.map(({ resourceType, value }) => ({ resourceType, value }))).toEqual([ + { resourceType: "gateway-target-runtime", value: "maxproj_rtToolRuntime" }, + ]); + expect(pins[0]?.name).toMatch(/\/mcpRt$/); + }); + + test("refuses one Lambda name when targets recorded different ones", () => { + const state: V0DeployedState = { + targets: { + dev: { + resources: { mcp: { lambdas: { lambdaTs: { functionName: "a", functionArn: "x" } } } }, + }, + prod: { + resources: { mcp: { lambdas: { lambdaTs: { functionName: "b", functionArn: "y" } } } }, + }, + }, + }; + const { problems } = convertSpec(v0Fixture("top-mcpRuntimeTools.json"), state); + expect(problems.map((problem) => problem.path)).toEqual([ + expect.stringMatching(/^gateway-target-function .+\/lambdaTs$/), + ]); + }); + + test("reports a tool runtime and a gateway target that share a name v0 records compute under", () => { + const v0 = v0Fixture("top-mcpRuntimeTools.json"); + // The tool runtime takes the name of gateway gw1's Lambda target. + (v0 as unknown as { mcpRuntimeTools: { name: string }[] }).mcpRuntimeTools[0]!.name = + "lambdaTs"; + const state = recorded({ + mcp: { lambdas: { lambdaTs: { functionName: "maxproj-lambdaTs", functionArn: "arn:fn" } } }, + }); + const { pins, problems } = convertSpec(v0, state); + expect(problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "gateway target 'gw1/lambdaTs', tool runtime 'lambdaTs'", + message: + "gateway target 'gw1/lambdaTs' and tool runtime 'lambdaTs' share the name 'lambdaTs', under which v0 records their deployed compute, so migrate can't tell which deployed function or runtime belongs to which; their names can't be kept safely", + }, + ]); + // Neither is pinned from the ambiguous record. + expect(pins.some((pin) => pin.name.includes("lambdaTs"))).toBe(false); + }); + + test('carries a v0 gateway\'s protocolType "None" over unchanged', () => { + const spec = structuredClone(F1_SPEC) as { agentCoreGateways: Record[] }; + spec.agentCoreGateways[0]!.protocolType = "None"; + const { json } = convertSpec(V0AgentCoreProjectSpecSchema.parse(spec), f1State()); + expect((json.agentCoreGateways as Record[])[0]?.protocolType).toBe("None"); + }); + + test("checks the whole recorded ID shape, not just its prefix", () => { + const state = f1State(); + state.targets.default!.resources!.runtimes!.CompatAAgent!.runtimeId = + "CompatA_CompatAAgent-short"; + const { problems } = convertSpec(f1(), state); + expect(problems.map((problem) => problem.path)).toEqual(["runtime CompatAAgent"]); + }); +}); + +describe("convertSpec harness pins", () => { + test("blocks a harness v0 created outside CloudFormation", () => { + const state = f1State(); + delete state.targets.default!.resources!.harnesses!.CompatAHarness!.provisioner; + const { pins, problems } = convertSpec(f1(), state); + expect(pins.some((pin) => pin.resourceType === "harness")).toBe(false); + expect(problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "harness CompatAHarness", + message: + "was created outside CloudFormation; remove it with " + + "'npx @aws/agentcore@0 remove harness CompatAHarness --keep' (or delete it) before migrating", + }, + ]); + }); + + test("pins the execution role v0 created, checked against the recorded role ARN", () => { + const { pins, problems } = convertSpec(f1(), f1State(), { CompatAHarness: {} }); + expect(problems).toEqual([]); + expect(pins).toContainEqual({ + resourceType: "harness-role", + name: "CompatAHarness", + value: "CompatA_CompatAHarness", + targets: ["default"], + }); + + const state = f1State(); + state.targets.default!.resources!.harnesses!.CompatAHarness!.roleArn = + "arn:aws:iam::998846730471:role/Other"; + const mismatched = convertSpec(f1(), state, { CompatAHarness: {} }); + expect(mismatched.problems.map((problem) => problem.path)).toEqual([ + "harness-role CompatAHarness", + ]); + }); + + test("pins no role for a harness with its own execution role", () => { + const { pins } = convertSpec(f1(), f1State(), { + CompatAHarness: { executionRoleArn: "arn:aws:iam::998846730471:role/Mine" }, + }); + expect(pins.some((pin) => pin.resourceType === "harness-role")).toBe(false); + }); + + test("pins the ECR repository only when the libraries create one", () => { + const repository = (facts: HarnessFacts) => + convertSpec(f1(), f1State(), { CompatAHarness: facts }).pins.find( + (pin) => pin.resourceType === "harness-repository", + ); + // The v0 app (v0.16.0 and later) passes hasDockerfile and the code location. + const built = { dockerfile: "Dockerfile", hasDockerfile: true, codeLocation: "app/h" }; + expect(repository(built)).toEqual({ + resourceType: "harness-repository", + name: "CompatAHarness", + value: "compata/harness-compataharness", + targets: ["default"], + }); + // A prebuilt image, or an app that builds the container itself, creates no repository. + expect(repository({ ...built, containerUri: "public.ecr.aws/x/y" })).toBeUndefined(); + expect(repository({ dockerfile: "Dockerfile", codeLocation: "app/h" })).toBeUndefined(); + expect(repository({ dockerfile: "Dockerfile", hasDockerfile: true })).toBeUndefined(); + }); +}); + +describe("convertSpec dropped fields", () => { + test("blocks a field v1 drops from a deployed resource, and only warns otherwise", () => { + // v1 drops a runtime's capacityProviderConfiguration (preflight also blocks it). + const v0 = v0Fixture("runtime-capacityProviderArn-only.json"); + const deployed: V0DeployedState = { + targets: { + default: { + resources: { + runtimes: { + agentB: { runtimeId: "maxproj_agentB-abcde12345", runtimeArn: "a", roleArn: "r" }, + }, + }, + }, + }, + }; + const result = convertSpec(v0, deployed); + expect(result.deployedChanges).toEqual([ + { + file: "agentcore/agentcore.json", + path: "runtimes[agentB].capacityProviderConfiguration", + message: "v1 drops this field, which changes the deployed resource on the next deploy", + }, + ]); + // A credential's usage label never reached AWS, so it only warns. + expect(result.warnings).toContain( + "Dropped fields v1 doesn't use: credentials[my-oauth].usage, credentials[gw1-oauth].usage", + ); + + const undeployed = convertSpec(v0, NEVER_DEPLOYED); + expect(undeployed.deployedChanges).toEqual([]); + expect(undeployed.warnings[0]).toContain("runtimes[agentB].capacityProviderConfiguration"); + }); +}); diff --git a/src/core/project/migrate/convertSpec.ts b/src/core/project/migrate/convertSpec.ts new file mode 100644 index 000000000..34ad434dd --- /dev/null +++ b/src/core/project/migrate/convertSpec.ts @@ -0,0 +1,507 @@ +import type { + MigrationProblem, + PinnedName, + PinnedResourceType, +} from "../../../handlers/project/migrate/types"; +import { DROPPED_FIELDS_WARNING } from "../../../handlers/project/migrate/types"; +import { AwsDeploymentTargetsSchema } from "../../../projectSchemas/aws-targets"; +import { ProjectSpecSchema } from "../../../projectSchemas/project"; +import type { V0DeployedState, V0ProjectSpec } from "../../../projectSchemas/v0"; +import { translateV0Spec } from "../legacy/translate"; +import { describePath, droppedPaths, problemsFromZodError } from "./issues"; +import { pinCollisions } from "./pinCollisions"; +import { onlineInsightConfigs } from "./preflight"; + +type Json = Record; + +/** Project-relative paths shown to users. Always forward slashes, on every platform. */ +export const SPEC_RELATIVE_PATH = "agentcore/agentcore.json"; +export const AWS_TARGETS_RELATIVE_PATH = "agentcore/aws-targets.json"; + +export type ConvertedSpec = { + /** The v1 agentcore.json content: the parsed v1 spec, or the unparsed candidate when it has problems. */ + json: Json; + pins: PinnedName[]; + warnings: string[]; + /** Why the converted spec doesn't parse as v1 (or a deployed name can't be kept); empty when it does. */ + problems: MigrationProblem[]; + /** + * Fields v1 drops from resources that are already deployed. Dropping one changes + * the deployed resource on the next deploy, so each blocks the migration. + */ + deployedChanges: MigrationProblem[]; +}; + +/** What a harness's harness.json says about the AWS resources v0 created for it. */ +export type HarnessFacts = { + dockerfile?: string; + containerUri?: string; + executionRoleArn?: string; + /** + * The v0 app passed the library `hasDockerfile` for this harness (v0.16.0 and + * later set it from the Dockerfile), so the library built its container and ECR + * repository. + */ + hasDockerfile?: boolean; + /** The harness's code location the v0 app passed the library; set only with a Dockerfile. */ + codeLocation?: string; +}; + +/** What one target recorded about a deployed resource: its ID (or name, or ARN). */ +export type Recorded = { target: string; id: string }; + +type HarnessRecord = Recorded & { roleArn: string; provisioner?: string }; + +/** The deployed resources a v0 deployed state records, by kind, with the target that recorded each. */ +export type DeployedResources = { + runtimes: Map; + memories: Map; + evaluators: Map; + onlineEvalConfigs: Map; + policyEngines: Map; + /** Keyed `engine/policy`, as v0 records them. */ + policies: Map; + configBundles: Map; + /** `id` is the harness ID. */ + harnesses: Map; + gateways: Map; + /** Credential provider ARNs. */ + credentials: Map; + /** Runtime IDs of tool runtimes and gateway-target runtimes, keyed by tool or target name. */ + mcpRuntimes: Map; + /** Function names of gateway-target Lambdas, keyed by target name. */ + mcpLambdas: Map; +}; + +/** + * Collects, for every resource any target deployed, what each target recorded + * about it. Recorded IDs are the resource's AWS name plus a 10-character suffix, + * which is how kept names are checked against what was actually deployed. + */ +export function deployedResources(state: V0DeployedState): DeployedResources { + const deployed: DeployedResources = { + runtimes: new Map(), + memories: new Map(), + evaluators: new Map(), + onlineEvalConfigs: new Map(), + policyEngines: new Map(), + policies: new Map(), + configBundles: new Map(), + harnesses: new Map(), + gateways: new Map(), + credentials: new Map(), + mcpRuntimes: new Map(), + mcpLambdas: new Map(), + }; + for (const [target, { resources }] of Object.entries(state.targets)) { + if (!resources) continue; + const add = ( + into: Map, + entries: Record | undefined, + record: (entry: T) => Omit, + ) => { + for (const [name, entry] of Object.entries(entries ?? {})) { + into.set(name, [...(into.get(name) ?? []), { target, ...record(entry) } as R]); + } + }; + add(deployed.runtimes, resources.runtimes, (r) => ({ id: r.runtimeId })); + add(deployed.memories, resources.memories, (m) => ({ id: m.memoryId })); + add(deployed.evaluators, resources.evaluators, (e) => ({ id: e.evaluatorId })); + add(deployed.onlineEvalConfigs, resources.onlineEvalConfigs, (o) => ({ + id: o.onlineEvaluationConfigId, + })); + add(deployed.policyEngines, resources.policyEngines, (p) => ({ id: p.policyEngineId })); + add(deployed.policies, resources.policies, (p) => ({ id: p.policyId })); + add(deployed.configBundles, resources.configBundles, (b) => ({ id: b.bundleId })); + add(deployed.harnesses, resources.harnesses, (h) => ({ + id: h.harnessId, + roleArn: h.roleArn, + provisioner: h.provisioner, + })); + add(deployed.gateways, resources.gateways, (g) => ({ id: g.gatewayId })); + add(deployed.gateways, resources.mcp?.gateways, (g) => ({ id: g.gatewayId })); + add(deployed.credentials, resources.credentials, (c) => ({ id: c.credentialProviderArn })); + add(deployed.mcpRuntimes, resources.mcp?.runtimes, (r) => ({ id: r.runtimeId })); + add(deployed.mcpLambdas, resources.mcp?.lambdas, (l) => ({ id: l.functionName })); + } + return deployed; +} + +/** The agentcore.json schema version the v0 CLI wrote, which `migratedFrom` names. */ +export const MIGRATED_FROM = "v1"; + +/** + * Converts a parsed v0 spec into v1's agentcore.json, marked `migratedFrom: "v1"` + * so the CDK library keeps v0's physical names for the whole project, and lists + * the AWS names of everything already deployed (checked against what each target + * recorded) for the plan to verify the v1 synth against. Never throws for a + * project v0 accepted: anything v1 rejects is returned as a problem with its + * field path. + * + * `harnesses` carries each harness's harness.json facts (by harness name), which + * decide whether v0 created an execution role and an ECR repository for it. + */ +export function convertSpec( + v0: V0ProjectSpec, + state: V0DeployedState, + harnesses: Record = {}, +): ConvertedSpec { + // The v0-only collections aren't in the translation; they stay on the v0 spec. + const { spec } = translateV0Spec(v0); + const { unassignedTargets, abTests } = v0; + const candidate: Json = { ...(spec as unknown as Json), migratedFrom: MIGRATED_FROM }; + // v1 has no online insights. Preflight blocks a project that uses them; dropping + // them here lets the rest of the project convert, so one preview reports everything. + const insights = new Set(onlineInsightConfigs(v0).map((config) => config.name)); + if (insights.size > 0 && Array.isArray(candidate.onlineEvalConfigs)) { + candidate.onlineEvalConfigs = (candidate.onlineEvalConfigs as Json[]).filter( + (config) => !insights.has(String(config.name)), + ); + } + const warnings: string[] = []; + + if (unassignedTargets?.length) { + warnings.push( + `Dropped unassigned gateway targets (${unassignedTargets.map((t) => t.name).join(", ")}): ` + + "v1 has no unassigned targets. Add them to a gateway with 'agentcore add gateway-target' if you still need them.", + ); + } + if (abTests?.length) { + warnings.push( + `Removed abTests (${abTests.map((t) => t.name).join(", ")}) from agentcore.json: ` + + "v1 doesn't track A/B tests there. They stay in AgentCore; manage them with 'agentcore eval ab-test'.", + ); + } + + const deployed = deployedResources(state); + const everDeployed = Object.values(state.targets).some( + ({ resources }) => resources !== undefined, + ); + const { pins, problems } = pinDeployedNames(v0.name, candidate, deployed, harnesses); + problems.push(...pinCollisions(v0.name, candidate, pins)); + + const parsed = ProjectSpecSchema.safeParse(candidate); + if (!parsed.success) { + return { + json: candidate, + pins, + warnings, + problems: [...problems, ...problemsFromZodError(SPEC_RELATIVE_PATH, candidate, parsed.error)], + deployedChanges: [], + }; + } + + const harmless: string[] = []; + const deployedChanges: MigrationProblem[] = []; + const undeployed: string[] = []; + for (const path of droppedPaths(candidate, parsed.data)) { + const described = describePath(candidate, path); + if (isHarmlessDrop(path)) harmless.push(described); + else if (isDeployedResource(candidate, path, deployed, everDeployed)) { + deployedChanges.push({ + file: SPEC_RELATIVE_PATH, + path: described, + message: "v1 drops this field, which changes the deployed resource on the next deploy", + }); + } else undeployed.push(described); + } + if (harmless.length > 0 || undeployed.length > 0) { + warnings.push(`${DROPPED_FIELDS_WARNING}: ${[...harmless, ...undeployed].join(", ")}`); + } + return { json: parsed.data as unknown as Json, pins, warnings, problems, deployedChanges }; +} + +/** + * Fields only the v0 CLI read, never sent to AWS: dropping them changes nothing + * deployed. Every other dropped field is assumed to reach AWS. + */ +const HARMLESS_DROPS: RegExp[] = [ + // How the v0 CLI labelled an OAuth credential (inbound or outbound); not a provider property. + /^credentials\.\d+\.usage$/, +]; + +function isHarmlessDrop(path: PropertyKey[]): boolean { + const key = path.map(String).join("."); + return HARMLESS_DROPS.some((pattern) => pattern.test(key)); +} + +/** The deployed-resource map for each top-level collection whose entries v0 records by name. */ +const RECORDED_COLLECTIONS: Record = { + runtimes: "runtimes", + memories: "memories", + evaluators: "evaluators", + onlineEvalConfigs: "onlineEvalConfigs", + policyEngines: "policyEngines", + configBundles: "configBundles", + harnesses: "harnesses", + agentCoreGateways: "gateways", + credentials: "credentials", + toolRuntimes: "mcpRuntimes", +}; + +/** + * Whether the resource a dropped field belongs to is deployed. For collections v0 + * records by name that is exact; for anything else (payments, knowledge bases, + * top-level fields) it is assumed to be deployed if the project ever was. + */ +function isDeployedResource( + spec: Json, + path: PropertyKey[], + deployed: DeployedResources, + everDeployed: boolean, +): boolean { + const [collection, index] = path; + const recorded = RECORDED_COLLECTIONS[String(collection)]; + if (!recorded || typeof index !== "number") return everDeployed; + const entry = (spec[String(collection)] as Json[] | undefined)?.[index]; + return deployed[recorded].has(String(entry?.name)); +} + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** A recorded resource ID: the AWS name, a hyphen, and the service's 10-character suffix. */ +function idOf(value: string, caseInsensitive = false): (id: string) => boolean { + const pattern = new RegExp( + `^${escapeRegExp(value)}-[A-Za-z0-9]{10}$`, + caseInsensitive ? "i" : "", + ); + return (id) => pattern.test(id); +} + +/** + * The v0 physical name of every deployed resource in `spec`, checked against what + * each target recorded, with the targets that recorded it. These are the names + * `migratedFrom: "v1"` makes the CDK library compose, and the plan checks the v1 + * synth against them. A recorded ID that doesn't match means v0 named the resource + * some other way, which v0 naming wouldn't reproduce, so it is reported as a + * problem. Credential provider names are kept by the credential provisioning + * itself, not listed here. + */ +function pinDeployedNames( + project: string, + spec: Json, + deployed: DeployedResources, + harnesses: Record, +): { pins: PinnedName[]; problems: MigrationProblem[] } { + const pins: PinnedName[] = []; + const problems: MigrationProblem[] = []; + const list = (key: string) => (spec[key] as Json[] | undefined) ?? []; + const problem = (resourceType: PinnedResourceType, name: string, message: string) => + problems.push({ file: SPEC_RELATIVE_PATH, path: `${resourceType} ${name}`, message }); + const set = ( + resourceType: PinnedResourceType, + name: string, + value: string, + recorded: Recorded[], + ) => { + const targets = [...new Set(recorded.map((entry) => entry.target))]; + pins.push({ resourceType, name, value, targets }); + }; + + /** Keeps `value` when every recorded entry matches it; `false` when it can't be kept. */ + const pin = ( + resourceType: PinnedResourceType, + name: string, + value: string, + recorded: Recorded[] | undefined, + matches: (recorded: string) => boolean = idOf(value), + ): boolean => { + if (!recorded) return false; + const mismatched = recorded.filter((entry) => !matches(entry.id)); + if (mismatched.length > 0) { + problem( + resourceType, + name, + `deployed as '${mismatched.map((entry) => entry.id).join("', '")}', which doesn't match the name ` + + `'${value}' v0 would have given it, so its name can't be kept safely`, + ); + return false; + } + set(resourceType, name, value, recorded); + return true; + }; + + for (const runtime of list("runtimes")) { + const name = String(runtime.name); + const recorded = deployed.runtimes.get(name); + const pinned = pin("runtime", name, `${project}_${name}`, recorded); + if (pinned && recorded && runtime.build === "Container") { + set("runtime-repository", name, `${project}/${name}`.toLowerCase(), recorded); + } + } + for (const memory of list("memories")) { + const name = String(memory.name); + pin("memory", name, `${project}_${name}`, deployed.memories.get(name)); + } + for (const evaluator of list("evaluators")) { + const name = String(evaluator.name); + const recorded = deployed.evaluators.get(name); + const pinned = pin("evaluator", name, `${project}_${name}`, recorded); + const managed = (evaluator.config as { codeBased?: { managed?: Json } } | undefined)?.codeBased + ?.managed; + // Deployed state records no ID for the function, so it rides on the evaluator's own check. + if (managed && pinned && recorded) { + set("evaluator-function", name, `${project}-eval-${name}`, recorded); + } + } + for (const config of list("onlineEvalConfigs")) { + const name = String(config.name); + pin("online-eval", name, `${project}_${name}`, deployed.onlineEvalConfigs.get(name)); + } + for (const engine of list("policyEngines")) { + const engineName = String(engine.name); + pin( + "policy-engine", + engineName, + `${project}_${engineName}`, + deployed.policyEngines.get(engineName), + ); + // Cedar policies are named verbatim in v0 and v1, so they keep their names unpinned. + } + for (const bundle of list("configBundles")) { + const name = String(bundle.name); + pin("config-bundle", name, `${project}${name}`, deployed.configBundles.get(name)); + } + for (const harness of list("harnesses")) { + const name = String(harness.name); + const records = deployed.harnesses.get(name); + if (!records) continue; + // A record without the CloudFormation marker is a harness the v0 preview built + // with API calls, outside any stack; CloudFormation can't adopt it. + if (records.some((record) => record.provisioner !== "cloudformation")) { + problem( + "harness", + name, + `was created outside CloudFormation; remove it with 'npx @aws/agentcore@0 remove harness ${name} --keep' ` + + "(or delete it) before migrating", + ); + continue; + } + const pinned = pin("harness", name, `${project}_${name}`, records); + const facts = harnesses[name]; + if (!pinned || !facts) continue; + if (!facts.executionRoleArn) { + const roleName = `${project}_${name}`; + pin( + "harness-role", + name, + roleName, + records.map((record) => ({ target: record.target, id: record.roleArn })), + (arn) => arn.endsWith(`/${roleName}`), + ); + } + // The libraries create the repository only under this condition (AgentCoreHarnessEnvironment). + if (facts.hasDockerfile && facts.codeLocation && facts.dockerfile && !facts.containerUri) { + set("harness-repository", name, `${project}/harness-${name}`.toLowerCase(), records); + } + } + // v0 records gateway target Lambdas and runtimes, and tool runtimes, by their bare + // name, so two of them with one name can't be told apart in the deployed state. + const computeNames = new Map(); + for (const gateway of list("agentCoreGateways")) { + for (const target of (gateway.targets as Json[] | undefined) ?? []) { + const host = (target.compute as Json | undefined)?.host; + if (host !== "Lambda" && host !== "AgentCoreRuntime") continue; + const owners = computeNames.get(String(target.name)) ?? []; + owners.push(`gateway target '${String(gateway.name)}/${String(target.name)}'`); + computeNames.set(String(target.name), owners); + } + } + for (const tool of list("toolRuntimes")) { + if (!(tool.compute as Json | undefined)?.runtime) continue; + const owners = computeNames.get(String(tool.name)) ?? []; + owners.push(`tool runtime '${String(tool.name)}'`); + computeNames.set(String(tool.name), owners); + } + const clashes = new Set(); + for (const [name, owners] of computeNames) { + const recorded = deployed.mcpRuntimes.has(name) || deployed.mcpLambdas.has(name); + if (owners.length < 2 || !recorded) continue; + clashes.add(name); + problems.push({ + file: SPEC_RELATIVE_PATH, + path: owners.join(", "), + message: + `${owners.join(" and ")} share the name '${name}', under which v0 records their deployed ` + + "compute, so migrate can't tell which deployed function or runtime belongs to which; " + + "their names can't be kept safely", + }); + } + + for (const gateway of list("agentCoreGateways")) { + const name = String(gateway.name); + // Gateway IDs are the lowercased name plus a suffix. + if (typeof gateway.resourceName === "string") { + // The spec's resourceName names the gateway (it wins over v0 naming in the + // library), so the deployed ID must match it. + const resourceName = gateway.resourceName; + const matches = idOf(resourceName.toLowerCase(), true); + const mismatched = (deployed.gateways.get(name) ?? []).filter((entry) => !matches(entry.id)); + if (mismatched.length > 0) { + problem( + "gateway", + name, + `deployed as '${mismatched.map((entry) => entry.id).join("', '")}', which doesn't match its ` + + `resourceName '${resourceName}', so its name can't be kept safely`, + ); + } + } else { + const value = `${project}-${name}`; + pin("gateway", name, value, deployed.gateways.get(name), idOf(value.toLowerCase(), true)); + } + for (const target of (gateway.targets as Json[] | undefined) ?? []) { + const targetName = String(target.name); + const key = `${name}/${targetName}`; + const compute = target.compute as Json | undefined; + if (clashes.has(targetName)) continue; + if (compute?.host === "Lambda") { + const recorded = deployed.mcpLambdas.get(targetName); + const unique = [...new Set((recorded ?? []).map((entry) => entry.id))]; + if (unique.length > 1) { + problem( + "gateway-target-function", + key, + `deployed under different function names ('${unique.join("', '")}'), so one name can't be kept`, + ); + } else if (recorded && unique[0] !== undefined) { + // The recorded function name is the deployed name itself. + set("gateway-target-function", key, unique[0], recorded); + } + } + const runtime = compute?.runtime as Json | undefined; + if (compute?.host === "AgentCoreRuntime" && runtime) { + pin( + "gateway-target-runtime", + key, + `${project}_${String(runtime.name)}`, + deployed.mcpRuntimes.get(targetName), + ); + } + } + } + for (const tool of list("toolRuntimes")) { + const name = String(tool.name); + const runtime = (tool.compute as Json | undefined)?.runtime as Json | undefined; + if (!runtime || clashes.has(name)) continue; + pin("tool-runtime", name, `${project}_${String(runtime.name)}`, deployed.mcpRuntimes.get(name)); + } + return { pins, problems }; +} + +/** + * v1 validates deployment targets more strictly than v0 (no hyphens in names, no + * names differing only by case). Target names are part of each target's stack + * name, so they can't simply be renamed. + */ +export function convertTargets(raw: unknown): MigrationProblem[] { + const parsed = AwsDeploymentTargetsSchema.safeParse(raw); + if (parsed.success) return []; + return problemsFromZodError(AWS_TARGETS_RELATIVE_PATH, raw, parsed.error).map((problem) => + /name/i.test(problem.path) || /target name/i.test(problem.message) + ? { + ...problem, + message: `${problem.message}. Renaming a target deploys a new stack, so this project can't be migrated as is.`, + } + : problem, + ); +} diff --git a/src/core/project/migrate/digest.ts b/src/core/project/migrate/digest.ts new file mode 100644 index 000000000..00d47b5e1 --- /dev/null +++ b/src/core/project/migrate/digest.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { + canonicalJson, + CDK_METADATA_TYPE, + dependsOnOf, + referencesIn, + resourcesOf, + stripReferences, + type CfnTemplate, +} from "./templates"; + +/** + * A digest per resource that doesn't depend on logical IDs: the hash of the + * resource's type, its definition with every reference to another resource + * replaced by a placeholder, and the digests of the resources it references, in + * the order it references them. Two templates that differ only in logical IDs + * (and the references to them) give every resource the same digest; any other + * difference changes the digest of the resource and of everything that + * references it. + * + * This is the digest the CDK Toolkit's refactoring support computes + * (`computeResourceDigests` in `@aws-cdk/toolkit-lib`), reimplemented because the + * Toolkit doesn't export it, and extended to Fn::Sub placeholders. + */ +export function resourceDigests(template: CfnTemplate): Map { + const resources = resourcesOf(template); + const isResource = (id: string) => id in resources && resources[id]!.Type !== CDK_METADATA_TYPE; + const digests = new Map(); + const visiting = new Set(); + + const digestOf = (id: string): string => { + const known = digests.get(id); + if (known !== undefined) return known; + // A cycle makes the template undeployable, so it never reaches a stack; break + // it with a fixed marker rather than recursing forever. + if (visiting.has(id)) return "cycle"; + visiting.add(id); + const { DependsOn: _dependsOn, ...definition } = resources[id]!; + const metadata = definition.Metadata as Record | undefined; + if (metadata && "aws:cdk:path" in metadata) { + // The construct path names the construct, so it changes with the logical ID. + const { "aws:cdk:path": _path, ...rest } = metadata; + definition.Metadata = rest; + } + const dependencies = [ + ...referencesIn(definition, isResource).map((ref) => ref.target), + ...dependsOnOf(resources[id]!).filter(isResource), + ]; + const hash = createHash("sha256") + .update(definition.Type) + .update(canonicalJson(stripReferences(definition, isResource))) + .update(dependencies.map(digestOf).join("")) + .digest("hex"); + visiting.delete(id); + digests.set(id, hash); + return hash; + }; + + for (const id of Object.keys(resources)) { + if (isResource(id)) digestOf(id); + } + return digests; +} diff --git a/src/core/project/migrate/files.test.ts b/src/core/project/migrate/files.test.ts new file mode 100644 index 000000000..c5fd38ecb --- /dev/null +++ b/src/core/project/migrate/files.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BACKUP_RELATIVE_PATH, writeMigratedFiles } from "./files"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function tree(files: Record, directories: string[] = []): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-files-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + for (const directory of directories) await mkdir(join(root, directory), { recursive: true }); + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + } + return root; +} + +/** Writes a stand-in for v1's vended CDK app. */ +const vendedApp = async (cdkDirectory: string) => { + await mkdir(join(cdkDirectory, "bin"), { recursive: true }); + await writeFile(join(cdkDirectory, "bin", "cdk.ts"), "v1 app"); +}; + +describe("writeMigratedFiles", () => { + test("writes, then restores exactly, keeping directories that were there", async () => { + const project = await tree( + { + "agentcore/agentcore.json": "v0", + "agentcore/cdk/bin/cdk.ts": "v0 app", + "agentcore/cdk/node_modules/x.js": "dep", + "app/h/harness.json": "{}", + }, + ["app/empty"], + ); + const workspace = await tree({ + "agentcore/agentcore.json": "v1", + "agentcore/cdk/bin/cdk.ts": "v1 app", + "agentcore/cdk/node_modules/y.js": "not copied", + "app/empty/new.txt": "new", + "app/fresh/deep/new.txt": "new", + }); + + const backup = await writeMigratedFiles({ + rootPath: project, + workspaceDirectory: workspace, + writeCdkApp: vendedApp, + changes: [ + { path: "agentcore/agentcore.json", kind: "modified" }, + { path: "agentcore/cdk/bin/cdk.ts", kind: "modified" }, + { path: "app/empty/new.txt", kind: "added" }, + { path: "app/fresh/deep/new.txt", kind: "added" }, + { path: "app/h/harness.json", kind: "removed" }, + ], + }); + + const read = (path: string) => readFileSync(join(project, path), "utf8"); + expect(read("agentcore/agentcore.json")).toBe("v1"); + expect(read("agentcore/cdk/bin/cdk.ts")).toBe("v1 app"); + expect(existsSync(join(project, "agentcore/cdk/node_modules"))).toBe(false); + expect(existsSync(join(project, "app/h"))).toBe(false); + expect(read("app/fresh/deep/new.txt")).toBe("new"); + + await backup.restore(); + + expect(read("agentcore/agentcore.json")).toBe("v0"); + expect(read("agentcore/cdk/bin/cdk.ts")).toBe("v0 app"); + expect(read("agentcore/cdk/node_modules/x.js")).toBe("dep"); + expect(read("app/h/harness.json")).toBe("{}"); + expect(existsSync(join(project, "app/empty"))).toBe(true); + expect(existsSync(join(project, "app/empty/new.txt"))).toBe(false); + expect(existsSync(join(project, "app/fresh"))).toBe(false); + expect(existsSync(join(project, BACKUP_RELATIVE_PATH))).toBe(false); + }); + + test("restores every other file when one can't be put back, keeping the backup", async () => { + const project = await tree({ + "agentcore/agentcore.json": "v0", + "agentcore/cdk/bin/cdk.ts": "v0 app", + "app/a.txt": "a0", + }); + const workspace = await tree({ + "agentcore/agentcore.json": "v1", + "agentcore/cdk/bin/cdk.ts": "v1 app", + "app/a.txt": "a1", + }); + const backup = await writeMigratedFiles({ + rootPath: project, + workspaceDirectory: workspace, + writeCdkApp: vendedApp, + changes: [ + { path: "agentcore/agentcore.json", kind: "modified" }, + { path: "app/a.txt", kind: "modified" }, + ], + }); + // Lose one backed-up original. + await rm(join(project, BACKUP_RELATIVE_PATH, "files", "app", "a.txt")); + + await expect(backup.restore()).rejects.toThrow("1 path couldn't be put back (app/a.txt:"); + + expect(readFileSync(join(project, "agentcore/agentcore.json"), "utf8")).toBe("v0"); + expect(readFileSync(join(project, "agentcore/cdk/bin/cdk.ts"), "utf8")).toBe("v0 app"); + expect(existsSync(join(project, BACKUP_RELATIVE_PATH))).toBe(true); + }); +}); diff --git a/src/core/project/migrate/files.ts b/src/core/project/migrate/files.ts new file mode 100644 index 000000000..bd793d95e --- /dev/null +++ b/src/core/project/migrate/files.ts @@ -0,0 +1,237 @@ +import { existsSync } from "node:fs"; +import { cp, mkdir, rename, rm, rmdir } from "node:fs/promises"; +import { dirname, join, relative, sep } from "node:path"; +import type { FileChange } from "../../../handlers/project/migrate/types"; + +/** + * Where the v0 files a migration replaces are kept, relative to the project root, + * until a stack is refactored (after which they can't come back) or the migration + * completes. + */ +export const BACKUP_RELATIVE_PATH = "agentcore/.cli/migrate-backup"; + +const CDK_RELATIVE_PATH = join("agentcore", "cdk"); + +/** Writing the files failed, and so did putting back what had been written. */ +export class FilesRestoreError extends Error { + constructor( + readonly original: Error, + readonly restoreError: Error, + ) { + super(`${original.message}; then restoring the files failed: ${restoreError.message}`); + this.name = "FilesRestoreError"; + } +} + +/** Undoes or keeps a set of file writes. */ +export type ProjectFilesBackup = { + /** + * Puts every file back as it was before the writes. Tries every step even when + * one fails, then throws the failures together; the backup is kept until + * everything is back. + */ + restore(): Promise; + /** Keeps the writes and deletes the backup. */ + discard(): Promise; +}; + +/** + * Writes the migrated project from the workspace into the project: v1's vended + * CDK app replaces agentcore/cdk wholesale (the v0 app, with its installed + * dependencies, is moved aside), and every other changed file is copied over from + * the workspace or deleted. The CDK app is written fresh by `writeCdkApp` rather + * than copied, because the workspace's copy was installed and synthesized (and its + * library may have been overridden for testing). Each file is backed up first, + * under agentcore/.cli (which git ignores), so a later failure can put the project + * back exactly; agentcore.json is written last, so until it is the project still + * reads as v0. + */ +export async function writeMigratedFiles(input: { + rootPath: string; + workspaceDirectory: string; + changes: FileChange[]; + /** Writes v1's vended CDK app into the (missing) directory given. */ + writeCdkApp: (cdkDirectory: string) => Promise; +}): Promise { + const { rootPath, workspaceDirectory } = input; + const backup = join(rootPath, BACKUP_RELATIVE_PATH); + await rm(backup, { recursive: true, force: true }); + await mkdir(backup, { recursive: true }); + + const files = input.changes + .filter((change) => !isUnder(change.path, CDK_RELATIVE_PATH)) + // agentcore.json last: an interrupted write leaves a project that is still v0. + .sort((a, b) => Number(isSpec(a.path)) - Number(isSpec(b.path))); + const written: FileChange[] = []; + /** Directories the writes created, outermost first; restore removes them again. */ + const createdDirectories: string[] = []; + /** Directories the writes emptied and removed; restore recreates them. */ + const removedDirectories: string[] = []; + let cdkMoved = false; + + const restore = async () => { + const failures: string[] = []; + const attempt = async (what: string, step: () => Promise) => { + try { + await step(); + } catch (error) { + failures.push(`${what}: ${(error as Error).message}`); + } + }; + for (const change of [...written].reverse()) { + const target = join(rootPath, change.path); + await attempt(change.path, async () => { + if (change.kind === "added") { + await rm(target, { force: true }); + } else { + await mkdir(dirname(target), { recursive: true }); + await cp(join(backup, "files", change.path), target); + } + }); + } + for (const directory of [...createdDirectories].reverse()) { + // Only if still empty: anything else in it isn't the migration's. + await rmdir(directory).catch(() => {}); + } + for (const directory of removedDirectories) { + await attempt(relative(rootPath, directory), () => mkdir(directory, { recursive: true })); + } + if (cdkMoved) { + const cdk = join(rootPath, CDK_RELATIVE_PATH); + await attempt(CDK_RELATIVE_PATH, async () => { + // Retried: a freshly installed node_modules can be busy for a moment on Windows. + await rm(cdk, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + if (existsSync(join(backup, "cdk"))) await rename(join(backup, "cdk"), cdk); + }); + } + if (failures.length > 0) { + throw new Error( + `${failures.length} ${failures.length === 1 ? "path" : "paths"} couldn't be put back (${failures.join("; ")})`, + ); + } + await rm(backup, { recursive: true, force: true }); + }; + + try { + const cdk = join(rootPath, CDK_RELATIVE_PATH); + if (existsSync(cdk)) await rename(cdk, join(backup, "cdk")); + cdkMoved = true; + await input.writeCdkApp(cdk); + + for (const change of files) { + const target = join(rootPath, change.path); + if (change.kind !== "added") { + const saved = join(backup, "files", change.path); + await mkdir(dirname(saved), { recursive: true }); + await cp(target, saved); + } + written.push(change); + if (change.kind === "removed") { + await rm(target, { force: true }); + removedDirectories.push(...(await removeEmptyParents(rootPath, dirname(target)))); + } else { + createdDirectories.push(...(await makeDirectories(rootPath, dirname(target)))); + await cp(join(workspaceDirectory, change.path), target); + } + } + } catch (error) { + try { + await restore(); + } catch (restoreError) { + throw new FilesRestoreError(error as Error, restoreError as Error); + } + throw error; + } + return { restore, discard: () => rm(backup, { recursive: true, force: true }) }; +} + +/** + * Puts the v0 files back from the backup a migration stopped part-way through + * writing left, using the changes its record lists: an added file is deleted, a + * modified or removed one is copied back if the backup has it (if not, it was + * never touched), and the v0 CDK app is moved back. Empty directories the writes + * created are left. Tries every step, then throws the failures together; the + * backup is deleted only once everything is back. + */ +export async function restoreFromBackup(rootPath: string, changes: FileChange[]): Promise { + const backup = join(rootPath, BACKUP_RELATIVE_PATH); + const failures: string[] = []; + const attempt = async (what: string, step: () => Promise) => { + try { + await step(); + } catch (error) { + failures.push(`${what}: ${(error as Error).message}`); + } + }; + for (const change of changes) { + if (isUnder(change.path, CDK_RELATIVE_PATH)) continue; + const target = join(rootPath, change.path); + await attempt(change.path, async () => { + if (change.kind === "added") { + await rm(target, { force: true }); + return; + } + const saved = join(backup, "files", change.path); + if (!existsSync(saved)) return; + await mkdir(dirname(target), { recursive: true }); + await cp(saved, target); + }); + } + if (existsSync(join(backup, "cdk"))) { + const cdk = join(rootPath, CDK_RELATIVE_PATH); + await attempt(CDK_RELATIVE_PATH, async () => { + await rm(cdk, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + await rename(join(backup, "cdk"), cdk); + }); + } + if (failures.length > 0) { + throw new Error( + `${failures.length} ${failures.length === 1 ? "path" : "paths"} couldn't be put back (${failures.join("; ")})`, + ); + } + await rm(backup, { recursive: true, force: true }); +} + +/** Deletes a migration's backup of the v0 files. */ +export async function discardBackup(rootPath: string): Promise { + await rm(join(rootPath, BACKUP_RELATIVE_PATH), { recursive: true, force: true }); +} + +function isUnder(path: string, directory: string): boolean { + const normalized = path.split("/").join(sep); + return normalized === directory || normalized.startsWith(`${directory}${sep}`); +} + +function isSpec(path: string): boolean { + return path === "agentcore/agentcore.json"; +} + +/** Creates `directory` and any missing parents; returns the ones it created, outermost first. */ +async function makeDirectories(rootPath: string, directory: string): Promise { + const missing: string[] = []; + for (let current = directory; current !== rootPath && !existsSync(current);) { + missing.unshift(current); + current = dirname(current); + } + await mkdir(directory, { recursive: true }); + return missing; +} + +/** + * Deletes `directory` and its parents while they're empty, stopping at the + * project root; returns the ones it deleted, deepest first. + */ +async function removeEmptyParents(rootPath: string, directory: string): Promise { + const removed: string[] = []; + for (let current = directory; current !== rootPath && current.startsWith(rootPath);) { + try { + await rmdir(current); + } catch { + // Not empty (or already gone): nothing above it can be empty either. + break; + } + removed.push(current); + current = dirname(current); + } + return removed; +} diff --git a/src/core/project/migrate/gitGate.test.ts b/src/core/project/migrate/gitGate.test.ts index 1df787289..155b2364d 100644 --- a/src/core/project/migrate/gitGate.test.ts +++ b/src/core/project/migrate/gitGate.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { InvalidEnvironmentError, ProjectStateError } from "../../../errors"; import { ProcessFailedError, runProcess, type ProcessRunner } from "../../../io"; +import { saveMigrationReport } from "./reports"; import { checkGitGate, MAX_LISTED_CHANGES } from "./gitGate"; const ROOT = "/work/orders"; @@ -62,6 +63,7 @@ describe("checkGitGate", () => { "--untracked-files=all", "--", ".", + ":(exclude)agentcore/.cli/migrate", ], ]); }); @@ -246,4 +248,14 @@ describe("checkGitGate with real git", () => { await expect(checkGitGate(runProcess, root)).rejects.toThrow("?? notes.txt"); }); + + test("ignores migrate's own reports even when .gitignore doesn't cover them", async () => { + const root = await projectDirectory(); + await git(root, "init", "--quiet"); + await git(root, "add", "."); + await git(root, "commit", "--quiet", "-m", "init"); + await saveMigrationReport(root, "plan-20261013T220000Z.md", "the plan\n"); + + expect(await checkGitGate(runProcess, root)).toEqual({ projectRoot: root }); + }); }); diff --git a/src/core/project/migrate/gitGate.ts b/src/core/project/migrate/gitGate.ts index 4fe0ef0e0..4d07b4713 100644 --- a/src/core/project/migrate/gitGate.ts +++ b/src/core/project/migrate/gitGate.ts @@ -1,5 +1,6 @@ import { InvalidEnvironmentError, ProjectStateError } from "../../../errors"; import { ProcessFailedError, type ProcessRunner } from "../../../io"; +import { REPORTS_RELATIVE_PATH } from "./reports"; /** The project spec as git sees it: repository-relative, always forward slashes. */ const PROJECT_SPEC_GIT_PATH = "agentcore/agentcore.json"; @@ -63,6 +64,8 @@ export async function checkGitGate( "--untracked-files=all", "--", ".", + // Migrate's own reports, which a project's .gitignore may not cover. + `:(exclude)${REPORTS_RELATIVE_PATH}`, ]); if (!status.ok) { throw new ProjectStateError( diff --git a/src/core/project/migrate/index.apply.test.ts b/src/core/project/migrate/index.apply.test.ts new file mode 100644 index 000000000..9330f2099 --- /dev/null +++ b/src/core/project/migrate/index.apply.test.ts @@ -0,0 +1,394 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { runProcess, type ProcessRunner } from "../../../io"; +import { createSilentLogger } from "../../../testing"; +import { FsAssetSource } from "../source"; +import { BACKUP_RELATIVE_PATH } from "./files"; +import { + assertNoPendingMigration, + readMigrationState, + STATE_RELATIVE_PATH, + writeMigrationState, +} from "./journal"; +import { CDK_LIBRARY_OVERRIDE_ENV, FsProjectMigrator } from "./index"; +import { + commitAll, + F1_DEPLOYED_STATE, + FAKE_AWS_IDENTITY, + fakeCloudFormation, + fakeSynth, + templateFixture, + vendedAssets, + writeV0Project, + type FakeCloudFormationOptions, + type V0ProjectFiles, +} from "./migrate-test-support"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +const STACK = "AgentCore-CompatA-default"; +const PROD_STACK = "AgentCore-CompatA-prod"; + +async function committedProject(options: V0ProjectFiles = {}): Promise { + const { root, cleanup } = await writeV0Project(options); + cleanups.push(cleanup); + commitAll(root); + return root; +} + +/** Real git; npm is recorded instead of run. */ +function runner(): ProcessRunner & { npm: string[][] } { + const npm: string[][] = []; + const run: ProcessRunner = async (args, options) => { + if (args[0] === "npm") { + npm.push([...args, `cwd=${options.cwd}`]); + return; + } + return runProcess(args, options); + }; + return Object.assign(run, { npm }); +} + +function migrator( + options: { + stacks?: Array<{ name: string; target: string }>; + cloudFormation?: FakeCloudFormationOptions; + /** Reuse another migrator's (stateful) CloudFormation, as a rerun would. */ + sharedCloudFormation?: ReturnType; + vendedVersion?: string; + env?: NodeJS.ProcessEnv; + account?: string; + } = {}, +) { + const stacks = options.stacks ?? [{ name: STACK, target: "default" }]; + const cloudFormation = + options.sharedCloudFormation ?? + fakeCloudFormation( + Object.fromEntries( + stacks.map((stack) => [stack.name, { template: templateFixture("pinned.v0") }]), + ), + options.cloudFormation, + ); + const run = runner(); + const subject = new FsProjectMigrator({ + logger: createSilentLogger(), + runner: run, + assetSource: vendedAssets(options.vendedVersion), + createCloudFormationClient: cloudFormation.create, + ...FAKE_AWS_IDENTITY, + ...(options.account && { resolveAccount: async () => options.account! }), + synthesize: fakeSynth( + stacks.map((stack) => ({ + target: stack.target, + stackName: stack.name, + template: templateFixture("pinned.v1"), + })), + ), + env: options.env ?? {}, + poll: { intervalMs: 0, sleep: async () => {} }, + }); + return { subject, cloudFormation, run }; +} + +const readJson = async (path: string) => JSON.parse(await readFile(path, "utf8")); + +describe("FsProjectMigrator: applying a migration", () => { + test("converts the files in place and refactors the stack, keeping physical IDs", async () => { + const root = await committedProject(); + const { subject, cloudFormation, run } = migrator(); + const resourcesBefore = Object.keys(templateFixture("pinned.v0").Resources ?? {}); + + const prepared = await subject.prepare(root); + expect(prepared.plan.ready).toBe(true); + const result = await prepared.apply(); + await prepared.dispose(); + + expect(result.migrated).toBe(true); + expect(result.stacks.map((stack) => stack.outcome)).toEqual(["refactored"]); + + const spec = await readJson(join(root, "agentcore", "agentcore.json")); + expect(spec.version).toBe(2); + expect(spec.migratedFrom).toBe("v1"); + expect(existsSync(join(root, "app", "CompatAHarness", "harness.yaml"))).toBe(true); + // v1's vended app replaced the v0 one wholesale. + const vendedBin = await new FsAssetSource().read("cdk/bin/cdk.ts"); + expect(await readFile(join(root, "agentcore", "cdk", "bin", "cdk.ts"), "utf8")).toBe(vendedBin); + expect(existsSync(join(root, "agentcore", "cdk", "jest.config.js"))).toBe(false); + expect(run.npm).toEqual([ + ["npm", "install", "--no-audit", "--no-fund", `cwd=${join(root, "agentcore", "cdk")}`], + ]); + expect(existsSync(join(root, BACKUP_RELATIVE_PATH))).toBe(false); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + + // The stack now has the v1 logical IDs, with every resource still there. + const moved = Object.keys(cloudFormation.stacks[STACK]!.template!.Resources ?? {}); + expect(moved).toHaveLength(resourcesBefore.length); + expect(moved).not.toEqual(resourcesBefore); + const executed = cloudFormation.calls.filter( + (call) => call.command === "ExecuteStackRefactorCommand", + ); + expect(executed).toHaveLength(1); + }); + + test("refuses to apply when the tree changed after planning", async () => { + const root = await committedProject(); + const { subject, cloudFormation } = migrator(); + const prepared = await subject.prepare(root); + await writeFile(join(root, "wip.txt"), "uncommitted\n"); + + await expect(prepared.apply()).rejects.toThrow(/Uncommitted changes/); + await prepared.dispose(); + + expect((await readJson(join(root, "agentcore", "agentcore.json"))).version).toBe(1); + expect(cloudFormation.calls.some((call) => call.command === "CreateStackRefactorCommand")).toBe( + false, + ); + }); + + test("puts the v0 files back when the only stack's refactor is rejected", async () => { + const root = await committedProject(); + const { subject } = migrator({ + cloudFormation: { refactors: () => ({ create: { status: "CREATE_FAILED", reason: "no" } }) }, + }); + + const prepared = await subject.prepare(root); + const result = await prepared.apply(); + await prepared.dispose(); + + expect(result.migrated).toBe(false); + expect(result.restored).toBe(true); + expect((await readJson(join(root, "agentcore", "agentcore.json"))).version).toBe(1); + let status = ""; + await runProcess(["git", "status", "--porcelain"], { + cwd: root, + onOutput: (text) => (status += text), + }); + expect(status.trim()).toBe(""); + }); + + test("blocks when the CLI's vended CDK library doesn't keep v0 names", async () => { + const root = await committedProject(); + const { subject } = migrator({ vendedVersion: "1.0.0-rc.3" }); + + const prepared = await subject.prepare(root); + await prepared.dispose(); + + expect(prepared.plan.ready).toBe(false); + const check = prepared.plan.checks.find((entry) => entry.name === "cdk library"); + expect(check?.ok).toBe(false); + expect(check?.message).toContain("doesn't keep v0 resource names"); + await expect(prepared.apply()).rejects.toThrow("isn't ready"); + }); + + test("the library override warns, and the project still gets the vended library", async () => { + const root = await committedProject(); + const { subject } = migrator({ + vendedVersion: "1.0.0-rc.3", + env: { [CDK_LIBRARY_OVERRIDE_ENV]: "/tmp/agentcore-cdk-test.tgz" }, + }); + + const prepared = await subject.prepare(root); + expect(prepared.plan.ready).toBe(true); + expect(prepared.plan.warnings.join("\n")).toContain(`${CDK_LIBRARY_OVERRIDE_ENV} is set`); + const result = await prepared.apply(); + await prepared.dispose(); + + expect(result.migrated).toBe(true); + const manifest = await readJson(join(root, "agentcore", "cdk", "package.json")); + expect(manifest.dependencies["@aws/agentcore-cdk"]).toBe("1.0.0-rc.3"); + expect(JSON.stringify(manifest)).not.toContain("agentcore-cdk-test.tgz"); + }); + + test("with several deployed targets, migrates the one --target names and records the rest", async () => { + const root = await committedProject(twoTargets()); + const stacks = [ + { name: STACK, target: "default" }, + { name: PROD_STACK, target: "prod" }, + ]; + const first = migrator({ stacks }); + + // Without --target, the plan asks for one. + const unselected = await first.subject.prepare(root); + await unselected.dispose(); + expect(unselected.plan.ready).toBe(false); + const target = unselected.plan.checks.find((check) => check.name === "target"); + expect(target?.ok).toBe(false); + expect(target?.message).toContain("2 targets (default, prod)"); + expect(target?.message).toContain("agentcore migrate --target "); + + const prepared = await first.subject.prepare(root, { target: "default" }); + expect(prepared.plan.ready).toBe(true); + expect(prepared.plan.selectedTarget).toBe("default"); + expect(prepared.plan.pendingTargets).toEqual(["prod"]); + expect(prepared.plan.stacks.map((stack) => stack.stackName)).toEqual([STACK]); + // Only the selected target's stack is read. + expect(first.cloudFormation.calls.some((call) => call.input.StackName === PROD_STACK)).toBe( + false, + ); + const partial = await prepared.apply(); + await prepared.dispose(); + + expect(partial.migrated).toBe(false); + expect(partial.error).toBeUndefined(); + expect(partial.stacks.map((stack) => stack.outcome)).toEqual(["refactored"]); + expect(partial.pendingTargets).toEqual(["prod"]); + expect((await readJson(join(root, "agentcore", "agentcore.json"))).migratedFrom).toBe("v1"); + // Deploying the pending target is blocked; the migrated one isn't. + await expect(assertNoPendingMigration(root, "prod")).rejects.toThrow( + "agentcore migrate --target prod", + ); + await assertNoPendingMigration(root, "default"); + + // The rerun plans prod afresh, from the v1 project and the still-v0 stack. + const rerun = migrator({ stacks, sharedCloudFormation: first.cloudFormation }); + const finishing = await rerun.subject.prepare(root, { target: "prod" }); + expect(finishing.plan.finishPending).toBe(true); + expect(finishing.plan.ready).toBe(true); + expect(finishing.plan.stacks.map((stack) => stack.stackName)).toEqual([PROD_STACK]); + expect(finishing.plan.stacks[0]?.moves.length).toBeGreaterThan(0); + const finished = await finishing.apply(); + await finishing.dispose(); + + expect(finished.migrated).toBe(true); + expect(await rerun.subject.hasPendingMigration(root)).toBe(false); + await assertNoPendingMigration(root, "prod"); + }); + + test("blocks a target in an account other than the active credentials'", async () => { + const root = await committedProject(); + const { subject } = migrator({ account: "111111111111" }); + + const prepared = await subject.prepare(root); + await prepared.dispose(); + + expect(prepared.plan.ready).toBe(false); + const check = prepared.plan.checks.find((entry) => entry.name === "stack (default)"); + expect(check?.message).toBe( + "Target 'default' is in AWS account 998846730471 (us-east-1), but the active credentials " + + "belong to account 111111111111. Switch to credentials for account 998846730471 and run " + + "`agentcore migrate --target default` again.", + ); + }); + + test("a rerun finds a refactor whose Execute call failed but ran, and finishes", async () => { + const root = await committedProject(); + const first = migrator({ + cloudFormation: { + refactors: () => ({ executeThrows: { message: "socket hang up", started: true } }), + }, + }); + const prepared = await first.subject.prepare(root); + const result = await prepared.apply(); + await prepared.dispose(); + + expect(result.stacks[0]?.outcome).toBe("needs-repair"); + expect(result.restored).toBeUndefined(); + const state = await readMigrationState(root); + expect(state?.targets[0]).toMatchObject({ status: "refactoring", refactorId: "refactor-1" }); + + const rerun = migrator({ sharedCloudFormation: first.cloudFormation }); + const finishing = await rerun.subject.prepare(root); + expect(finishing.plan.checks).toContainEqual({ + name: "refactor (default)", + ok: true, + message: `refactor refactor-1 of ${STACK} finished`, + }); + expect(finishing.plan.ready).toBe(true); + const finished = await finishing.apply(); + await finishing.dispose(); + + expect(finished.migrated).toBe(true); + expect(await rerun.subject.hasPendingMigration(root)).toBe(false); + }); + + test("a rerun finds a refactor that never ran, and plans and refactors it again", async () => { + const root = await committedProject(); + const first = migrator({ + cloudFormation: { + refactors: ({ index }) => + index === 0 ? { executeThrows: { message: "connection reset", started: false } } : {}, + }, + }); + const prepared = await first.subject.prepare(root); + const result = await prepared.apply(); + await prepared.dispose(); + expect(result.stacks[0]?.outcome).toBe("needs-repair"); + + const rerun = migrator({ sharedCloudFormation: first.cloudFormation }); + const finishing = await rerun.subject.prepare(root); + expect( + finishing.plan.checks.find((check) => check.name === "refactor (default)")?.message, + ).toContain("didn't run"); + expect(finishing.plan.stacks.map((stack) => stack.stackName)).toEqual([STACK]); + const finished = await finishing.apply(); + await finishing.dispose(); + + expect(finished.migrated).toBe(true); + }); + + test("undoes a migration stopped while writing the files", async () => { + const root = await committedProject(); + const spec = join(root, "agentcore", "agentcore.json"); + const original = await readFile(spec, "utf8"); + const saved = join(root, BACKUP_RELATIVE_PATH, "files", "agentcore", "agentcore.json"); + await mkdir(dirname(saved), { recursive: true }); + await writeFile(saved, original); + await writeFile(spec, '{"version":2,"name":"CompatA","migratedFrom":"v1"}\n'); + await writeMigrationState(root, { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { + status: "writing", + backup: BACKUP_RELATIVE_PATH, + changes: [{ path: "agentcore/agentcore.json", kind: "modified" }], + }, + refactored: [], + targets: [ + { + name: "default", + account: "998846730471", + region: "us-east-1", + stackName: STACK, + status: "pending", + }, + ], + }); + // Until it's undone, nothing deploys. + await expect(assertNoPendingMigration(root, "default")).rejects.toThrow( + "stopped while writing the project files", + ); + + const { subject } = migrator(); + const prepared = await subject.prepare(root); + expect(prepared.plan.undoInterrupted).toBe(true); + expect(prepared.plan.ready).toBe(true); + const result = await prepared.apply(); + await prepared.dispose(); + + expect(result.undone).toBe(true); + expect(await readFile(spec, "utf8")).toBe(original); + expect(existsSync(join(root, STATE_RELATIVE_PATH))).toBe(false); + }); +}); + +function twoTargets(): V0ProjectFiles { + return { + targets: [ + { name: "default", account: "998846730471", region: "us-east-1" }, + { name: "prod", account: "998846730471", region: "us-west-2" }, + ], + deployedState: { + ...F1_DEPLOYED_STATE, + targets: { + default: F1_DEPLOYED_STATE.targets.default, + prod: { + ...F1_DEPLOYED_STATE.targets.default, + resources: { ...F1_DEPLOYED_STATE.targets.default.resources, stackName: PROD_STACK }, + }, + }, + }, + }; +} diff --git a/src/core/project/migrate/index.test.ts b/src/core/project/migrate/index.test.ts new file mode 100644 index 000000000..99fe93581 --- /dev/null +++ b/src/core/project/migrate/index.test.ts @@ -0,0 +1,571 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createSilentLogger } from "../../../testing"; +import { + credentialNameWarnings, + deployedTargets, + FsProjectMigrator, + sharedEnvironmentWarnings, +} from "./index"; +import { + commitAll, + F1_DEPLOYED_STATE, + F1_SPEC, + FAKE_AWS_IDENTITY, + fakeCloudFormation, + fakeGit, + fakeSynth, + templateFixture, + writeV0Project, + type FakeStack, + type V0ProjectFiles, + vendedAssets, +} from "./migrate-test-support"; +import type { WorkspaceSynthesizer } from "./synth"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function project(options: V0ProjectFiles = {}): Promise { + const { root, cleanup } = await writeV0Project(options); + cleanups.push(cleanup); + return root; +} + +const STACK = "AgentCore-CompatA-default"; + +/** + * The real migrator with its AWS and synth seams faked: F1's stack is deployed + * with the live-tested CompatMig template, and the v1 app "synthesizes" the + * CompatMig v1 template, which keeps every deployed AgentCore resource. + */ +function fakes( + options: { + stacks?: Record; + synthesize?: WorkspaceSynthesizer; + /** Run real git rather than answering it from a script. */ + realGit?: boolean; + account?: string; + } = {}, +) { + const cloudFormation = fakeCloudFormation( + options.stacks ?? { [STACK]: { template: templateFixture("pinned.v0") } }, + ); + const synthesize = + options.synthesize ?? + fakeSynth([{ target: "default", stackName: STACK, template: templateFixture("pinned.v1") }]); + const migrator = new FsProjectMigrator({ + logger: createSilentLogger(), + ...(!options.realGit && { runner: fakeGit({ status: "" }) }), + assetSource: vendedAssets(), + createCloudFormationClient: cloudFormation.create, + resolveCredentials: FAKE_AWS_IDENTITY.resolveCredentials, + resolveAccount: async () => options.account ?? "998846730471", + synthesize, + }); + return { migrator, cloudFormation, synthesize }; +} + +const migrator = () => fakes().migrator; + +describe("FsProjectMigrator.plan", () => { + test("plans a deployed v0 project committed to git, without touching it", async () => { + const root = await project(); + commitAll(root); + const before = await readFile(join(root, "agentcore", "agentcore.json"), "utf8"); + + // The real process runner, so the git check runs real git. + const plan = await fakes({ realGit: true }).migrator.plan(root); + + expect(plan.ready).toBe(true); + expect(plan.projectName).toBe("CompatA"); + expect(plan.checks.every((check) => check.ok)).toBe(true); + expect(plan.problems).toEqual([]); + // Every pin convertSpec makes for F1, plus the harness role it can name only from harness.json. + expect(plan.pins).toHaveLength(9); + expect(plan.pins).toContainEqual({ + resourceType: "harness-role", + name: "CompatAHarness", + value: "CompatA_CompatAHarness", + targets: ["default"], + }); + expect(plan.warnings).toEqual([ + "Dropped fields v1 doesn't use: credentials[compatA-oauth].usage", + expect.stringContaining("This project has credentials (compatA-key, compatA-oauth)."), + ]); + // The project isn't written; deployed state isn't part of the migration. + expect(plan.changes).not.toContainEqual( + expect.objectContaining({ path: "agentcore/.cli/deployed-state.json" }), + ); + expect(plan.skippedStacks).toEqual([]); + expect(plan.cdkLibraryOverride).toBeUndefined(); + expect(plan.changes).toContainEqual({ path: "agentcore/agentcore.json", kind: "modified" }); + expect(plan.changes).toContainEqual({ path: "app/CompatAHarness/harness.yaml", kind: "added" }); + expect(plan.changes).toContainEqual({ + path: "app/CompatAHarness/harness.json", + kind: "removed", + }); + expect(await readFile(join(root, "agentcore", "agentcore.json"), "utf8")).toBe(before); + }); + + test("plans only the target --target names, and blocks one in another account", async () => { + const memory = (id: string) => ({ + memories: { + CompatAAgentMemory: { + memoryId: id, + memoryArn: `arn:aws:bedrock-agentcore:us-west-2:998846730471:memory/${id}`, + }, + }, + }); + const root = await project({ + targets: [ + { name: "default", account: "998846730471", region: "us-east-1" }, + { name: "prod", account: "998846730471", region: "us-west-2" }, + { name: "other", account: "111111111111", region: "eu-west-1" }, + ], + deployedState: { + targets: { + ...F1_DEPLOYED_STATE.targets, + // prod and other deployed only the memory. + prod: { resources: memory("CompatA_CompatAAgentMemory-Pr0dPr0dPr") }, + other: { resources: memory("CompatA_CompatAAgentMemory-0th3r0th3r") }, + }, + }, + }); + commitAll(root); + const v0 = templateFixture("pinned.v0"); + const v1 = templateFixture("pinned.v1"); + const { migrator } = fakes({ + realGit: true, + stacks: { + [STACK]: { template: v0 }, + "AgentCore-CompatA-prod": { template: v0 }, + "AgentCore-CompatA-other": { template: v0 }, + }, + synthesize: fakeSynth([ + { target: "default", stackName: STACK, template: v1 }, + { target: "prod", stackName: "AgentCore-CompatA-prod", template: v1 }, + { target: "other", stackName: "AgentCore-CompatA-other", template: v1 }, + ]), + }); + + // Several deployed targets: the plan asks which one to migrate. + const unselected = await migrator.plan(root); + expect(unselected.stacks).toEqual([]); + expect(unselected.checks.find((check) => check.name === "target")).toEqual({ + name: "target", + ok: false, + message: + "This project is deployed to 3 targets (default, prod, other). Migrate one target at a " + + "time: run `agentcore migrate --target `.", + }); + expect(unselected.ready).toBe(false); + + const plan = await migrator.plan(root, { target: "prod" }); + + const pin = (resourceType: string, name: string) => + plan.pins.find((entry) => entry.resourceType === resourceType && entry.name === name); + // Each kept name lists the targets that deployed the resource. + expect(pin("memory", "CompatAAgentMemory")?.targets).toEqual(["default", "prod", "other"]); + expect(pin("runtime", "CompatAAgent")?.targets).toEqual(["default"]); + // Only the selected target is planned; the others are recorded as pending. + expect(plan.stacks.map((stack) => stack.target)).toEqual(["prod"]); + expect(plan.selectedTarget).toBe("prod"); + expect(plan.pendingTargets).toEqual(["default", "other"]); + expect(plan.checks.find((check) => check.name === "target")?.message).toBe( + "migrating 'prod' (998846730471/us-west-2); default, other stay pending until migrated with --target", + ); + + // A target in another account is blocked, naming both accounts and the region. + const other = await migrator.plan(root, { target: "other" }); + expect(other.skippedStacks).toEqual([ + { + target: "other", + reason: + "Target 'other' is in AWS account 111111111111 (eu-west-1), but the active " + + "credentials belong to account 998846730471. Switch to credentials for account " + + "111111111111 and run `agentcore migrate --target other` again.", + }, + ]); + expect(other.ready).toBe(false); + }); + + test("still plans the stacks when the git check fails", async () => { + const root = await project(); + commitAll(root); + await writeFile(join(root, "notes.txt"), "wip\n"); + const plan = await fakes({ realGit: true }).migrator.plan(root); + expect(plan.checks.find((check) => check.name === "git")?.ok).toBe(false); + expect(plan.stacks).toHaveLength(1); + expect(plan.stacks[0]?.ready).toBe(true); + expect(plan.ready).toBe(false); + }); + + test("fails the git check for uncommitted changes", async () => { + const root = await project(); + commitAll(root); + await writeFile(join(root, "notes.txt"), "wip\n"); + const plan = await fakes({ realGit: true }).migrator.plan(root); + expect(plan.ready).toBe(false); + expect(plan.checks.find((check) => check.name === "git")?.message).toContain("?? notes.txt"); + }); + + test("returns only the format check for a project that isn't v0", async () => { + const plan = await migrator().plan(await project({ spec: { name: "Already", version: 2 } })); + expect(plan).toMatchObject({ ready: false, pins: [], changes: [], problems: [] }); + expect(plan.projectName).toBeUndefined(); + expect(plan.checks.map((check) => check.name)).toEqual(["v0 project"]); + }); + + test("collects problems from the spec, harnesses, and deployment targets", async () => { + const plan = await migrator().plan( + await project({ + spec: { + ...F1_SPEC, + credentials: [{ authorizerType: "ApiKeyCredentialProvider", name: "ab" }], + }, + targets: [{ name: "prod-us", account: "998846730471", region: "us-east-1" }], + files: { "app/CompatAHarness/harness.json": JSON.stringify({ name: "CompatAHarness" }) }, + deployedState: null, + }), + ); + expect(plan.ready).toBe(false); + // A hyphenated target name (prod-us) is valid in v1 too, so only the spec and harness fail. + expect(plan.problems.map((problem) => `${problem.file} ${problem.path}`)).toEqual([ + "agentcore/agentcore.json credentials[ab].name", + "app/CompatAHarness/harness.json model", + ]); + // The unconvertible harness keeps its harness.json in the plan. + expect(plan.changes.some((change) => change.path.startsWith("app/CompatAHarness/"))).toBe( + false, + ); + }); + + test("says why stack plans were skipped when the project doesn't convert", async () => { + const plan = await migrator().plan( + await project({ + spec: { + ...F1_SPEC, + credentials: [{ authorizerType: "ApiKeyCredentialProvider", name: "ab" }], + }, + }), + ); + expect(plan.problems).toHaveLength(1); + expect(plan.stacks).toEqual([]); + expect(plan.skippedStacks).toEqual([ + { + reason: + "the project doesn't convert to v1 until the issues under 'Fix before migrating' are fixed", + }, + ]); + }); + + test("blocks a customized v0 CDK app", async () => { + const plan = await migrator().plan( + await project({ + files: { "agentcore/cdk/lib/cdk-stack.ts": "// my own stack\nnew Bucket(this, 'b');\n" }, + }), + ); + expect(plan.ready).toBe(false); + expect(plan.checks.find((check) => check.name === "cdk app")).toMatchObject({ + ok: false, + message: expect.stringContaining(" modified agentcore/cdk/lib/cdk-stack.ts"), + }); + }); + + test("blocks on unsupported resources and an old CDK app", async () => { + const plan = await migrator().plan( + await project({ + cdkPin: "0.1.0-alpha.30", + spec: { + ...F1_SPEC, + datasets: [ + { + name: "ds1", + schemaType: "AGENTCORE_EVALUATION_PREDEFINED_V1", + config: { managed: { location: "d/x.jsonl" } }, + }, + ], + }, + }), + ); + const failed = plan.checks.filter((check) => !check.ok).map((check) => check.name); + expect(failed).toEqual(["cdk dependencies", "supported resources"]); + expect(plan.ready).toBe(false); + }); +}); + +describe("FsProjectMigrator.plan, deployed stacks", () => { + test("reads each deployed stack, synthesizes the converted project, and plans the refactor", async () => { + const root = await project(); + const subject = fakes(); + const steps: string[] = []; + + const plan = await subject.migrator.plan(root, { onStep: (step) => steps.push(step) }); + + expect(plan.ready).toBe(true); + expect(plan.checks.slice(-2)).toEqual([ + { + name: "stack (default)", + ok: true, + message: `${STACK} is UPDATE_COMPLETE, with no stack policy`, + }, + { name: "v1 synth", ok: true, message: "the converted project synthesizes" }, + ]); + expect(subject.cloudFormation.calls).toEqual([ + { command: "DescribeStacksCommand", input: { StackName: STACK }, region: "us-east-1" }, + { command: "GetStackPolicyCommand", input: { StackName: STACK }, region: "us-east-1" }, + { + command: "GetTemplateCommand", + input: { StackName: STACK, TemplateStage: "Original" }, + region: "us-east-1", + }, + ]); + expect(plan.stacks).toHaveLength(1); + expect(plan.stacks[0]).toMatchObject({ + target: "default", + account: "998846730471", + region: "us-east-1", + stackName: STACK, + unchanged: 5, + added: [], + replaced: [], + blockers: [], + ready: true, + }); + expect(plan.stacks[0]!.moves).toHaveLength(10); + expect(steps).toEqual([ + `Reading stack ${STACK} (998846730471/us-east-1)`, + `Planning the refactor of ${STACK}`, + ]); + }); + + test("synthesizes the converted copy, not the project", async () => { + const root = await project(); + const seen: string[] = []; + const synth = fakeSynth([ + { target: "default", stackName: STACK, template: templateFixture("pinned.v1") }, + ]); + const subject = fakes({ + synthesize: async (workspace, options) => { + seen.push(await readFile(join(workspace, "agentcore", "agentcore.json"), "utf8")); + return synth(workspace, options); + }, + }); + + await subject.migrator.plan(root); + + expect(synth.workspaces).toHaveLength(1); + expect(synth.workspaces[0]).not.toBe(root); + expect(JSON.parse(seen[0]!)).toMatchObject({ name: "CompatA", version: 2 }); + }); + + test("reports a v1 deploy that would replace an AgentCore resource", async () => { + const subject = fakes({ + stacks: { [STACK]: { template: templateFixture("codezip.v0") } }, + synthesize: fakeSynth([ + { target: "default", stackName: STACK, template: templateFixture("codezip.v1-rc3") }, + ]), + }); + + const plan = await subject.migrator.plan(await project()); + + expect(plan.ready).toBe(false); + expect(plan.checks.every((check) => check.ok)).toBe(true); + expect(plan.stacks[0]!.ready).toBe(false); + expect( + plan.stacks[0]!.replaced.filter((change) => change.severity === "blocker"), + ).toMatchObject([{ type: "AWS::BedrockAgentCore::ConfigurationBundle" }]); + }); + + test("never reads AWS or synthesizes for a project that was never deployed", async () => { + const synth = fakeSynth([]); + const subject = fakes({ synthesize: synth }); + const plan = await subject.migrator.plan(await project({ deployedState: null })); + expect(plan.ready).toBe(true); + expect(plan.stacks).toEqual([]); + expect(subject.cloudFormation.calls).toEqual([]); + expect(synth.workspaces).toEqual([]); + }); + + test("fails the stack check when the credentials are for another account", async () => { + const subject = fakes({ account: "111122223333" }); + const plan = await subject.migrator.plan(await project()); + const check = plan.checks.find((entry) => entry.name === "stack (default)"); + expect(check).toMatchObject({ ok: false }); + expect(check?.message).toContain("the active credentials belong to account 111122223333"); + expect(subject.cloudFormation.calls).toEqual([]); + expect(plan.stacks).toEqual([]); + expect(plan.ready).toBe(false); + }); + + test("doesn't synthesize when a stack can't be refactored", async () => { + const synth = fakeSynth([]); + const subject = fakes({ + stacks: { + [STACK]: { template: templateFixture("pinned.v0"), policy: '{"Statement":[]}' }, + }, + synthesize: synth, + }); + const plan = await subject.migrator.plan(await project()); + expect(plan.checks.find((check) => check.name === "stack (default)")?.message).toContain( + "doesn't support stacks with a stack policy", + ); + expect(synth.workspaces).toEqual([]); + expect(subject.cloudFormation.calls.map((call) => call.command)).not.toContain( + "GetTemplateCommand", + ); + expect(plan.ready).toBe(false); + }); + + test("turns an AWS error into a failed stack check", async () => { + const failing = new FsProjectMigrator({ + logger: createSilentLogger(), + runner: fakeGit({ status: "" }), + assetSource: vendedAssets(), + createCloudFormationClient: () => + ({ + send: async () => { + throw Object.assign(new Error("User is not authorized to perform DescribeStacks"), { + name: "AccessDenied", + }); + }, + }) as never, + ...FAKE_AWS_IDENTITY, + synthesize: fakeSynth([]), + }); + const plan = await failing.plan(await project()); + expect(plan.checks.find((check) => check.name === "stack (default)")).toEqual({ + name: "stack (default)", + ok: false, + message: `Couldn't read stack '${STACK}' in 998846730471/us-east-1: User is not authorized to perform DescribeStacks`, + }); + }); + + test("says when the CDK library doesn't support migratedFrom yet", async () => { + const plan = await fakes({ + synthesize: async () => { + throw new Error( + "'npm run cdk -- synth' failed (exit code 1).\n\nAgentCore CDK synthesis failed: " + + 'Unrecognized key: "migratedFrom"', + ); + }, + }).migrator.plan(await project()); + const check = plan.checks.at(-1); + expect(check).toMatchObject({ name: "v1 synth", ok: false }); + expect(check?.message).toStartWith( + "The CDK library the v1 app synthesized with doesn't support migratedFrom yet, so it " + + "can't keep this project's v0 resource names.", + ); + expect(plan.ready).toBe(false); + }); + + test("reports a v1 app that doesn't synthesize", async () => { + const plan = await fakes({ + synthesize: async () => { + throw new Error("'npm run cdk -- synth' failed (exit code 1).\n\nboom"); + }, + }).migrator.plan(await project()); + expect(plan.checks.at(-1)).toEqual({ + name: "v1 synth", + ok: false, + message: + "The converted project's v1 CDK app didn't synthesize:\n'npm run cdk -- synth' failed (exit code 1).\n\nboom", + }); + expect(plan.stacks).toEqual([]); + expect(plan.ready).toBe(false); + }); + + test("reports a target the v1 app has no stack for, and still returns the plan", async () => { + const plan = await fakes({ + synthesize: fakeSynth([{ target: "other", stackName: STACK, template: {} }]), + }).migrator.plan(await project()); + const check = plan.checks.at(-1); + expect(check).toMatchObject({ name: "plan (default)", ok: false }); + expect(check?.message).toStartWith( + `Couldn't plan the refactor of '${STACK}': The synthesized cloud assembly has no stack for deployment target 'default'.`, + ); + expect(plan.stacks).toEqual([]); + expect(plan.ready).toBe(false); + }); + + test("still checks the stacks, but doesn't synthesize, when the files need fixing first", async () => { + const synth = fakeSynth([]); + const subject = fakes({ synthesize: synth }); + const plan = await subject.migrator.plan( + await project({ + spec: { + ...F1_SPEC, + credentials: [{ authorizerType: "ApiKeyCredentialProvider", name: "ab" }], + }, + }), + ); + expect(plan.problems).not.toEqual([]); + expect(plan.checks.find((check) => check.name === "stack (default)")?.ok).toBe(true); + expect(synth.workspaces).toEqual([]); + }); +}); + +describe("deployedTargets", () => { + const target = { name: "default", account: "998846730471", region: "us-east-1" as const }; + + test("uses the recorded stack name, or derives v0's", () => { + expect(deployedTargets("CompatA", [target], F1_DEPLOYED_STATE as never).targets).toEqual([ + { target, stackName: STACK }, + ]); + expect( + deployedTargets("My_Proj", [{ ...target, name: "dev_1" }], { + targets: { dev_1: { resources: {} } }, + }).targets, + ).toEqual([{ target: { ...target, name: "dev_1" }, stackName: "AgentCore-My-Proj-dev-1" }]); + // A hyphenated target keeps its hyphen in the stack name; v0 resource names never include it. + expect( + deployedTargets("Proj", [{ ...target, name: "prod-us" }], { + targets: { "prod-us": { resources: {} } }, + }).targets, + ).toEqual([{ target: { ...target, name: "prod-us" }, stackName: "AgentCore-Proj-prod-us" }]); + }); + + test("skips targets with nothing deployed and fails ones missing from aws-targets.json", () => { + const result = deployedTargets("CompatA", [], { + targets: { gone: { resources: {} }, empty: {} }, + }); + expect(result.targets).toEqual([]); + expect(result.checks).toHaveLength(1); + expect(result.checks[0]).toMatchObject({ name: "stack (gone)", ok: false }); + expect(result.checks[0]!.message).toContain("target 'gone', which isn't in"); + }); +}); + +describe("sharedEnvironmentWarnings", () => { + test("warns about targets that share an account and region, since v0 names have no target", () => { + const warnings = sharedEnvironmentWarnings([ + { name: "default", account: "111111111111", region: "us-east-1" }, + { name: "staging", account: "111111111111", region: "us-east-1" }, + { name: "prod", account: "111111111111", region: "us-west-2" }, + ]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toStartWith( + "Targets 'default', 'staging' are all in 111111111111/us-east-1. A migrated project keeps v0's resource names", + ); + }); + + test("says nothing for targets in different accounts or regions", () => { + expect( + sharedEnvironmentWarnings([ + { name: "default", account: "111111111111", region: "us-east-1" }, + { name: "prod", account: "222222222222", region: "us-east-1" }, + ]), + ).toEqual([]); + }); +}); + +describe("credentialNameWarnings", () => { + test("names the credentials whose v0 provider names the migrated project keeps", () => { + const warnings = credentialNameWarnings(["key", "oauth"]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toStartWith("This project has credentials (key, oauth)."); + expect(credentialNameWarnings([])).toEqual([]); + }); +}); diff --git a/src/core/project/migrate/index.ts b/src/core/project/migrate/index.ts new file mode 100644 index 000000000..78223e189 --- /dev/null +++ b/src/core/project/migrate/index.ts @@ -0,0 +1,1034 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { CloudFormationClient } from "@aws-sdk/client-cloudformation"; +import semver from "semver"; +import { z } from "zod"; +import { ProjectStateError } from "../../../errors"; +import { FsReadWriteJson, runProcess, type ProcessRunner, type ReadWriteJson } from "../../../io"; +import type { Logger } from "../../../logging"; +import type { + MigrationApplyOptions, + MigrationCheck, + MigrationPlan, + MigrationPlanOptions, + MigrationProblem, + MigrationResult, + PinnedName, + PreparedMigration, + ProjectMigrator, + SkippedStackPlan, +} from "../../../handlers/project/migrate/types"; +import { + AwsDeploymentTargetsSchema, + type AwsDeploymentTarget, +} from "../../../projectSchemas/aws-targets"; +import type { V0DeployedState } from "../../../projectSchemas/v0"; +import { createCloudFormationClient } from "../../factories"; +import type { CreateCloudFormationClient } from "../../types"; +import { stackArtifactForTarget } from "../backends/cdk/assembly"; +import { resolveAwsAccount, type AccountResolver } from "../backends/cdk/environment"; +import { + createCdkCredentialResolver, + type CdkCredentialProvider, + type CdkCredentialResolver, +} from "../backends/cdk/toolkit"; +import { defaultSource, type AssetSource } from "../source"; +import { FsTreeNode } from "../templates/fsTree"; +import { applyMigration, physicalIds, undoInterruptedMigration, type ApplyTarget } from "./apply"; +import { BACKUP_RELATIVE_PATH } from "./files"; +import { + readMigrationState, + STATE_RELATIVE_PATH, + type JournalTarget, + type MigrationState, +} from "./journal"; +import { convertHarness } from "./convertHarness"; +import { checkGitGate } from "./gitGate"; +import { + AWS_TARGETS_RELATIVE_PATH, + convertSpec, + convertTargets, + SPEC_RELATIVE_PATH, +} from "./convertSpec"; +import { planStack, type PlannedStack } from "./plan"; +import { describe as describeRefactor, type PollOptions } from "./refactor"; +import { preflight } from "./preflight"; +import { saveMigrationReport } from "./reports"; +import { checkStack, cloudFormationStackReader } from "./stacks"; +import { + CDK_LIBRARY_OVERRIDE_ENV, + createWorkspaceSynthesizer, + librarySpec, + type WorkspaceSynthesizer, +} from "./synth"; +import type { CfnTemplate } from "./templates"; +import { bootstrapBucketStore, type TemplateStore } from "./templateStore"; +import { appBuildsHarnessContainers } from "./v0CdkApp"; +import { buildMigrationWorkspace, type MigrationWorkspace } from "./workspace"; + +export { CDK_LIBRARY_OVERRIDE_ENV } from "./synth"; +export { REPORTS_RELATIVE_PATH } from "./reports"; + +/** Just enough of a synthesized template to know it is one; the rest is read as is. */ +const TemplateSchema = z.looseObject({ Resources: z.record(z.string(), z.unknown()).optional() }); + +export type FsProjectMigratorConfig = { + logger: Logger; + /** Runs git (and, through the default synthesizer, npm); injectable so tests never spawn processes. */ + runner?: ProcessRunner; + /** Where v1's vended CDK app is read from. */ + assetSource?: AssetSource; + /** Builds the CloudFormation client the deployed stacks are read through. */ + createCloudFormationClient?: CreateCloudFormationClient; + /** Resolves the AWS credentials a target's stack is read with. */ + resolveCredentials?: CdkCredentialResolver; + /** The account behind those credentials, checked against the target's. */ + resolveAccount?: AccountResolver; + /** Installs and synthesizes the v1 CDK app in the migration workspace. */ + synthesize?: WorkspaceSynthesizer; + /** Read for {@link CDK_LIBRARY_OVERRIDE_ENV}; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** How stack refactors are polled; injectable so tests don't wait. */ + poll?: PollOptions; + /** Builds the store large templates are uploaded to; defaults to the CDK bootstrap bucket. */ + templateStore?: (target: AwsDeploymentTarget) => TemplateStore; +}; + +/** A deployment target with a deployed stack, and what planning has read about it. */ +export type DeployedTarget = { + target: AwsDeploymentTarget; + stackName: string; + /** Set once the stack passed its checks and its template was read. */ + template?: CfnTemplate; + /** The client the stack was read through, for refactoring it later. */ + client?: CloudFormationClient; + credentials?: CdkCredentialProvider; + /** Why the stack couldn't be read, when it couldn't. */ + skipReason?: string; +}; + +/** A planned stack, with what refactoring it needs. */ +type PlannedTarget = DeployedTarget & { planned: PlannedStack }; + +/** + * The first `@aws/agentcore-cdk` release whose naming honors `migratedFrom: "v1"`. + * A migrated project deploys with the library pinned in the CLI's vended CDK app + * (src/assets/cdk/package.json), so migrating with an older one would let the + * first v1 deploy replace every migrated resource. Raised with the library + * release that adds migratedFrom, together with the vended pin. + */ +export const MIN_MIGRATED_FROM_CDK_VERSION = "1.0.0-rc.4"; + +const CDK_LIBRARY = "@aws/agentcore-cdk"; + +/** + * What a CDK library without `migratedFrom` support says when it validates the + * converted agentcore.json: its project schema rejects unknown keys. + */ +const UNSUPPORTED_MIGRATED_FROM = /migratedFrom/; + +/** + * Plans a v0 → v1 migration from the project on disk. Planning reads the project, + * builds the converted copy in a temporary directory, and synthesizes it; from + * AWS it only reads the deployed stacks. It never writes to the project or to AWS. + */ +export class FsProjectMigrator implements ProjectMigrator { + private readonly runner: ProcessRunner; + private readonly assetSource: AssetSource; + private readonly json: ReadWriteJson; + private readonly createCloudFormationClient: CreateCloudFormationClient; + private readonly resolveCredentials: CdkCredentialResolver; + private readonly resolveAccount: AccountResolver; + private readonly synthesize: WorkspaceSynthesizer; + private readonly env: NodeJS.ProcessEnv; + private readonly poll?: PollOptions; + private readonly templateStore?: (target: AwsDeploymentTarget) => TemplateStore; + + constructor(config: FsProjectMigratorConfig) { + this.runner = config.runner ?? runProcess; + this.assetSource = config.assetSource ?? defaultSource(); + this.json = new FsReadWriteJson({ logger: config.logger }); + this.createCloudFormationClient = + config.createCloudFormationClient ?? createCloudFormationClient; + this.resolveCredentials = + config.resolveCredentials ?? createCdkCredentialResolver(config.logger); + this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; + this.env = config.env ?? process.env; + this.synthesize = + config.synthesize ?? createWorkspaceSynthesizer({ runner: this.runner, env: this.env }); + if (config.poll) this.poll = config.poll; + if (config.templateStore) this.templateStore = config.templateStore; + } + + public async plan(rootPath: string, options: MigrationPlanOptions = {}): Promise { + const prepared = await this.prepare(rootPath, options); + try { + return prepared.plan; + } finally { + await prepared.dispose(); + } + } + + public async hasPendingMigration(rootPath: string): Promise { + return existsSync(join(rootPath, STATE_RELATIVE_PATH)); + } + + public async saveReport(rootPath: string, fileName: string, contents: string): Promise { + return saveMigrationReport(rootPath, fileName, contents); + } + + public async prepare( + rootPath: string, + options: MigrationPlanOptions = {}, + ): Promise { + const state = await readMigrationState(rootPath); + if (state) return this.prepareFinishPending(rootPath, state, options); + + const { checks, context } = await preflight(rootPath, this.runner); + if (!context) { + return notApplicable({ + rootPath, + checks, + problems: [], + deployedChanges: [], + pins: [], + warnings: [], + changes: [], + stacks: [], + skippedStacks: [], + pendingTargets: [], + ready: false, + }); + } + const { v0Spec, deployedState } = context; + + const harnesses = await Promise.all( + v0Spec.harnesses.map((entry) => convertHarness(rootPath, entry)), + ); + // The v0 app builds a harness's container (and its ECR repository) itself only + // from v0.16.0, which passes the library hasDockerfile and the code location. + const buildsContainers = await appBuildsHarnessContainers(rootPath); + const converted = convertSpec( + v0Spec, + deployedState, + Object.fromEntries( + harnesses.flatMap(({ name, directory, facts }) => + facts + ? [ + [ + name, + { + ...facts, + hasDockerfile: buildsContainers && facts.dockerfile !== undefined, + ...(facts.dockerfile !== undefined && { codeLocation: directory }), + }, + ] as const, + ] + : [], + ), + ), + ); + const targets = await this.readTargets(rootPath); + const problems: MigrationProblem[] = [ + ...converted.problems, + ...harnesses.flatMap((harness) => harness.problems), + ...targets.problems, + ]; + + const override = this.env[CDK_LIBRARY_OVERRIDE_ENV]?.trim(); + const cdkLibraryOverride = override ? librarySpec(override, process.cwd()) : undefined; + const library = await this.libraryCheck(cdkLibraryOverride); + checks.push(library.check); + + const deployed = deployedTargets(v0Spec.name, targets.targets, deployedState); + checks.push(...deployed.checks); + const skippedStacks: SkippedStackPlan[] = deployed.skipped; + const selection = selectTarget(deployed.targets, options.target, "deployed"); + if (selection.check) checks.push(selection.check); + const { selected } = selection; + if (selected) await this.readSelectedStack(selected, checks, skippedStacks, options); + + const warnings = [ + ...converted.warnings, + ...harnesses.flatMap((harness) => harness.warnings), + ...sharedEnvironmentWarnings(targets.targets), + ...credentialNameWarnings(v0Spec.credentials.map((credential) => credential.name)), + ...library.warnings, + ...overrideWarnings(cdkLibraryOverride), + ]; + + const workspace = await buildMigrationWorkspace({ + rootPath, + spec: converted.json, + harnesses: harnesses.flatMap(({ directory, yaml }) => + yaml === undefined ? [] : [{ directory, yaml }], + ), + assetSource: this.assetSource, + }); + try { + // The selected stack is planned whatever else failed, so one run shows + // everything; only a project that doesn't convert can't synthesize. + let planned: PlannedTarget | undefined; + if (selected?.template !== undefined && problems.length > 0) { + skippedStacks.push({ + reason: + "the project doesn't convert to v1 until the issues under 'Fix before migrating' are fixed", + }); + } else if (selected?.template !== undefined) { + const result = await this.planStacks( + workspace.directory, + [selected], + checks, + converted.pins, + options, + ); + planned = result.planned[0]; + skippedStacks.push(...result.skipped); + } + const stacks = planned ? [planned.planned.plan] : []; + const plan: MigrationPlan = { + rootPath, + projectName: v0Spec.name, + checks, + problems, + deployedChanges: converted.deployedChanges, + pins: converted.pins, + warnings, + changes: workspace.changes, + stacks, + skippedStacks, + ...(cdkLibraryOverride !== undefined && { cdkLibraryOverride }), + ...(selected && { selectedTarget: selected.target.name }), + pendingTargets: selection.others.map((entry) => entry.target.name), + ready: + checks.every((check) => check.ok) && + problems.length === 0 && + converted.deployedChanges.length === 0 && + skippedStacks.length === 0 && + stacks.every((stack) => stack.ready), + }; + const state: MigrationState = { + version: 2, + startedAt: new Date().toISOString(), + files: { status: "writing", backup: BACKUP_RELATIVE_PATH, changes: plan.changes }, + refactored: [], + targets: [...(selected ? [selected] : []), ...selection.others].map(pendingTarget), + }; + return this.prepared( + plan, + async (applyOptions) => { + // The plan reported the git check; it's enforced again here, since the + // tree may have changed while the user read the plan. + await checkGitGate(this.runner, plan.rootPath); + return applyMigration( + { + rootPath, + state, + files: { + workspaceDirectory: workspace.directory, + writeCdkApp: (cdkDirectory) => this.writeVendedCdkApp(cdkDirectory), + }, + ...(planned && { target: this.applyTarget(planned) }), + runner: this.runner, + ...(this.poll && { poll: this.poll }), + }, + applyOptions, + ); + }, + workspace, + ); + } catch (error) { + await workspace.dispose(); + throw error; + } + } + + /** A plan with what applying it needs; applying refuses a plan that isn't ready. */ + private prepared( + plan: MigrationPlan, + apply: (options: MigrationApplyOptions) => Promise, + workspace: MigrationWorkspace | undefined, + ): PreparedMigration { + return { + plan, + apply: async (options: MigrationApplyOptions = {}): Promise => { + if (!plan.ready) { + throw new ProjectStateError("This migration plan isn't ready, so it can't be applied."); + } + return apply(options); + }, + dispose: async () => { + await workspace?.dispose(); + }, + }; + } + + private applyTarget(entry: PlannedTarget): ApplyTarget { + const { target, stackName, planned } = entry; + let store: TemplateStore | undefined; + return { + target, + stackName, + renames: planned.renames, + ...(planned.bridge && { bridge: planned.bridge }), + client: entry.client!, + templates: async () => + (store ??= + this.templateStore?.(target) ?? + bootstrapBucketStore({ + cloudFormation: entry.client!, + credentials: entry.credentials!, + region: target.region, + })), + }; + } + + /** Reads the selected target's stack, recording why it was skipped when it can't be. */ + private async readSelectedStack( + entry: DeployedTarget, + checks: MigrationCheck[], + skippedStacks: SkippedStackPlan[], + options: MigrationPlanOptions, + ): Promise { + options.onStep?.( + `Reading stack ${entry.stackName} (${entry.target.account}/${entry.target.region})`, + ); + const check = await this.readStack(entry); + checks.push(check); + if (entry.template === undefined) { + skippedStacks.push({ target: entry.target.name, reason: entry.skipReason ?? check.message }); + } + } + + /** v1's vended CDK app, exactly as this CLI ships it (never an overridden library). */ + private async writeVendedCdkApp(cdkDirectory: string): Promise { + const cdkApp = await FsTreeNode.fromAssetSource( + { assetSource: this.assetSource }, + { assetDir: "cdk" }, + ); + await cdkApp.write(dirname(cdkDirectory)); + } + + /** + * Whether the CDK library this CLI vends keeps v0 names for a project marked + * migratedFrom. Without it the first deploy after migrating would replace every + * migrated resource, so it blocks the migration; with the library overridden + * for testing it only warns, loudly. + */ + private async libraryCheck( + override: string | undefined, + ): Promise<{ check: MigrationCheck; warnings: string[] }> { + const name = "cdk library"; + let vended: string | undefined; + try { + const manifest = JSON.parse(await this.assetSource.read("cdk/package.json")) as { + dependencies?: Record; + }; + vended = manifest.dependencies?.[CDK_LIBRARY]; + } catch { + vended = undefined; + } + const version = vended + ? (semver.valid(vended.trim().replace(/^[~^=]/, "")) ?? undefined) + : undefined; + const supported = version !== undefined && semver.gte(version, MIN_MIGRATED_FROM_CDK_VERSION); + if (supported) { + return { + check: { + name, + ok: true, + message: `${CDK_LIBRARY} ${vended} keeps v0 names (migratedFrom)`, + }, + warnings: [], + }; + } + const unsupported = + `This CLI's v1 CDK app uses ${CDK_LIBRARY} ${vended ?? "(unknown)"}, which doesn't keep v0 ` + + `resource names (migratedFrom needs ${MIN_MIGRATED_FROM_CDK_VERSION} or later)`; + if (override !== undefined) { + return { + check: { + name, + ok: true, + message: `planned with ${CDK_LIBRARY} from ${CDK_LIBRARY_OVERRIDE_ENV} (${override})`, + }, + warnings: [ + `${CDK_LIBRARY_OVERRIDE_ENV} is set, for testing only. ${unsupported}. Migrating writes ` + + `that app into the project; install a ${CDK_LIBRARY} with migratedFrom in agentcore/cdk ` + + "before you deploy, or the first deploy replaces the project's resources.", + ], + }; + } + return { + check: { + name, + ok: false, + message: + `${unsupported}, so the first deploy after migrating would replace this project's ` + + "resources. Update the AgentCore CLI to a release with that library, then run " + + "agentcore migrate again.", + }, + warnings: [], + }; + } + + /** + * Plans finishing a pending migration, from scratch like any other run. A run + * stopped while writing the project files is undone: the plan puts the v0 files + * back. Otherwise the project files are already v1: a refactor the record says + * was running is checked with CloudFormation first, and then the selected pending + * target is planned fresh, from the current project and its still-v0 stack. + */ + private async prepareFinishPending( + rootPath: string, + state: MigrationState, + options: MigrationPlanOptions, + ): Promise { + const projectName = await this.projectName(rootPath); + if (state.files?.status === "writing") { + const plan: MigrationPlan = { + rootPath, + ...(projectName !== undefined && { projectName }), + checks: [ + { + name: "interrupted migration", + ok: true, + message: + "an earlier `agentcore migrate` stopped while writing the project files, so " + + `they are part v0 and part v1. Applying puts the v0 files back from ` + + `${BACKUP_RELATIVE_PATH}; then run \`agentcore migrate\` again.`, + }, + ], + problems: [], + deployedChanges: [], + pins: [], + warnings: [], + changes: state.files.changes, + stacks: [], + skippedStacks: [], + pendingTargets: [], + undoInterrupted: true, + ready: true, + }; + return this.prepared(plan, () => undoInterruptedMigration(rootPath, state), undefined); + } + + const checks: MigrationCheck[] = []; + const skippedStacks: SkippedStackPlan[] = []; + const completed: string[] = []; + const pending: DeployedTarget[] = []; + for (const entry of state.targets) { + const deployed = deployedFromRecord(entry); + if (entry.status === "refactoring" && entry.refactorId) { + options.onStep?.(`Checking the refactor of stack ${entry.stackName} (${entry.refactorId})`); + const reconciled = await this.reconcile(deployed, entry); + checks.push(reconciled.check); + if (reconciled.outcome === "completed") { + completed.push(entry.name); + continue; + } + if (reconciled.outcome === "blocked") { + skippedStacks.push({ target: entry.name, reason: reconciled.check.message }); + continue; + } + } + pending.push(deployed); + } + checks.unshift({ + name: "pending migration", + ok: true, + message: + `finishing a pending migration of this project: ${state.targets.length} ` + + `${state.targets.length === 1 ? "target isn't" : "targets aren't"} migrated yet ` + + `(${state.targets.map((entry) => entry.name).join(", ")})`, + }); + + const selection = selectTarget(pending, options.target, "pending"); + if (selection.check) checks.push(selection.check); + const { selected } = selection; + + const override = this.env[CDK_LIBRARY_OVERRIDE_ENV]?.trim(); + const cdkLibraryOverride = override ? librarySpec(override, process.cwd()) : undefined; + const warnings = overrideWarnings(cdkLibraryOverride); + let planned: PlannedTarget | undefined; + let workspace: MigrationWorkspace | undefined; + if (selected) { + const library = await this.libraryCheck(cdkLibraryOverride); + checks.push(library.check); + warnings.unshift(...library.warnings); + await this.readSelectedStack(selected, checks, skippedStacks, options); + } + try { + if (selected?.template !== undefined) { + workspace = await buildMigrationWorkspace({ + rootPath, + spec: JSON.parse(await readFile(join(rootPath, SPEC_RELATIVE_PATH), "utf8")), + harnesses: [], + assetSource: this.assetSource, + }); + const result = await this.planStacks(workspace.directory, [selected], checks, [], options); + planned = result.planned[0]; + skippedStacks.push(...result.skipped); + } + const stacks = planned ? [planned.planned.plan] : []; + const plan: MigrationPlan = { + rootPath, + ...(projectName !== undefined && { projectName }), + checks, + problems: [], + deployedChanges: [], + pins: [], + warnings, + changes: [], + stacks, + skippedStacks, + ...(cdkLibraryOverride !== undefined && { cdkLibraryOverride }), + finishPending: true, + ...(selected && { selectedTarget: selected.target.name }), + pendingTargets: selection.others.map((entry) => entry.target.name), + ready: + checks.every((check) => check.ok) && + skippedStacks.length === 0 && + stacks.every((stack) => stack.ready), + }; + return this.prepared( + plan, + (applyOptions) => + applyMigration( + { + rootPath, + state, + completed, + ...(planned && { target: this.applyTarget(planned) }), + runner: this.runner, + ...(this.poll && { poll: this.poll }), + }, + applyOptions, + ), + workspace, + ); + } catch (error) { + await workspace?.dispose(); + throw error; + } + } + + /** + * Reads back the refactor the record says was running when an earlier run + * stopped: finished (and every resource under its v1 logical ID), known not to + * have run (the target is pending again), or neither, which needs a person. + */ + private async reconcile( + entry: DeployedTarget, + recorded: JournalTarget, + ): Promise<{ outcome: "completed" | "pending" | "blocked"; check: MigrationCheck }> { + const { target, stackName } = entry; + const name = `refactor (${target.name})`; + const id = recorded.refactorId!; + const commands = + `Check it with \`aws cloudformation describe-stack-refactor --stack-refactor-id ${id} ` + + `--region ${target.region}\` and \`aws cloudformation list-stack-resources --stack-name ` + + `${stackName} --region ${target.region}\`.`; + try { + const credentials = await this.resolveCredentials(target.region); + const account = await this.resolveAccount(target.region, credentials); + if (account !== target.account) { + return { + outcome: "blocked", + check: { name, ok: false, message: accountMismatch(target, account) }, + }; + } + const client = this.createCloudFormationClient({ credentials, region: target.region }); + const refactor = await describeRefactor(client, id); + const status = refactor.ExecutionStatus; + if (status === "EXECUTE_COMPLETE") { + const ids = await physicalIds(client, stackName); + const renames = recorded.renames ?? []; + const misplaced = renames.filter(([from, to]) => ids.has(from) || !ids.has(to)); + if (misplaced.length > 0) { + return { + outcome: "blocked", + check: { + name, + ok: false, + message: + `Refactor ${id} of stack ${stackName} finished, but ${misplaced.length} ` + + `${misplaced.length === 1 ? "resource isn't" : "resources aren't"} under the ` + + `planned logical ID (${misplaced.map(([from, to]) => `${from} → ${to}`).join(", ")}). ${commands}`, + }, + }; + } + return { + outcome: "completed", + check: { name, ok: true, message: `refactor ${id} of ${stackName} finished` }, + }; + } + if (status?.endsWith("_IN_PROGRESS")) { + return { + outcome: "blocked", + check: { + name, + ok: false, + message: + `Refactor ${id} of stack ${stackName} is still running (${status}). Wait for it ` + + `to finish, then run agentcore migrate --target ${target.name} again. ${commands}`, + }, + }; + } + if ( + status === "AVAILABLE" || + status === "EXECUTE_FAILED" || + status === "ROLLBACK_COMPLETE" || + refactor.Status === "CREATE_FAILED" + ) { + return { + outcome: "pending", + check: { + name, + ok: true, + message: `refactor ${id} of ${stackName} didn't run (${status ?? refactor.Status}); it's planned again`, + }, + }; + } + return { + outcome: "blocked", + check: { + name, + ok: false, + message: + `Refactor ${id} of stack ${stackName} ended ${status ?? "with no status"}` + + `${refactor.ExecutionStatusReason ? ` (${refactor.ExecutionStatusReason})` : ""}, so ` + + `whether it moved anything isn't known. ${commands}`, + }, + }; + } catch (error) { + return { + outcome: "blocked", + check: { + name, + ok: false, + message: `Couldn't read refactor ${id} of stack ${stackName}: ${(error as Error).message}. ${commands}`, + }, + }; + } + } + + /** The project's name for the report; the project gate already read the spec. */ + private async projectName(rootPath: string): Promise { + try { + const spec = JSON.parse(await readFile(join(rootPath, SPEC_RELATIVE_PATH), "utf8")) as { + name?: unknown; + }; + return typeof spec.name === "string" ? spec.name : undefined; + } catch { + return undefined; + } + } + + private async readTargets( + rootPath: string, + ): Promise<{ targets: AwsDeploymentTarget[]; problems: MigrationProblem[] }> { + let raw: unknown; + try { + raw = JSON.parse(await readFile(join(rootPath, AWS_TARGETS_RELATIVE_PATH), "utf8")); + } catch (error) { + return { + targets: [], + problems: [ + { + file: AWS_TARGETS_RELATIVE_PATH, + path: "(file)", + message: `Couldn't read it: ${(error as Error).message}`, + }, + ], + }; + } + const parsed = AwsDeploymentTargetsSchema.safeParse(raw); + return { targets: parsed.success ? parsed.data : [], problems: convertTargets(raw) }; + } + + /** + * Checks one deployed target's stack with the target's credentials, and reads + * its deployed template when the stack can be refactored. AWS errors become a + * failed check, so one plan still reports everything. + */ + private async readStack(entry: DeployedTarget): Promise { + const { target, stackName } = entry; + try { + const credentials = await this.resolveCredentials(target.region); + const account = await this.resolveAccount(target.region, credentials); + if (account !== target.account) { + entry.skipReason = accountMismatch(target, account); + return { name: `stack (${target.name})`, ok: false, message: entry.skipReason }; + } + const client = this.createCloudFormationClient({ credentials, region: target.region }); + const reader = cloudFormationStackReader(client); + const check = await checkStack(reader, target.name, stackName); + if (check.ok) { + entry.template = await reader.template(stackName); + entry.client = client; + entry.credentials = credentials; + } + return check; + } catch (error) { + return { + name: `stack (${target.name})`, + ok: false, + message: `Couldn't read stack '${stackName}' in ${target.account}/${target.region}: ${(error as Error).message}`, + }; + } + } + + private async planStacks( + workspaceDirectory: string, + targets: DeployedTarget[], + checks: MigrationCheck[], + pins: PinnedName[], + options: MigrationPlanOptions, + ): Promise<{ planned: PlannedTarget[]; skipped: SkippedStackPlan[] }> { + let assemblyDirectory: string; + try { + assemblyDirectory = await this.synthesize(workspaceDirectory, options); + } catch (error) { + const detail = (error as Error).message; + checks.push({ + name: "v1 synth", + ok: false, + message: UNSUPPORTED_MIGRATED_FROM.test(detail) + ? "The CDK library the v1 app synthesized with doesn't support migratedFrom yet, so it " + + "can't keep this project's v0 resource names. Plan again with an @aws/agentcore-cdk " + + `release that supports migratedFrom.\n${detail}` + : `The converted project's v1 CDK app didn't synthesize:\n${detail}`, + }); + return { + planned: [], + skipped: [ + { + reason: "the converted project's v1 CDK app didn't synthesize (see the v1 synth check)", + }, + ], + }; + } + checks.push({ name: "v1 synth", ok: true, message: "the converted project synthesizes" }); + + const planned: PlannedTarget[] = []; + const skipped: SkippedStackPlan[] = []; + for (const entry of targets) { + const { target, stackName, template } = entry; + options.onStep?.(`Planning the refactor of ${stackName}`); + try { + const artifact = await stackArtifactForTarget(this.json, assemblyDirectory, target.name); + if (artifact.templateFile === undefined) { + throw new ProjectStateError( + `Stack artifact '${artifact.id}' names no template file in the v1 cloud assembly.`, + ); + } + const v1 = (await this.json.read( + join(assemblyDirectory, artifact.templateFile), + TemplateSchema, + )) as CfnTemplate; + const stack = await planStack({ + target, + stackName, + // Only targets whose stack was read are planned. + deployed: template!, + v1, + v1StackName: artifact.stackName, + pins: pins.filter((pin) => pin.targets.includes(target.name)), + }); + planned.push({ ...entry, planned: stack }); + } catch (error) { + // One target that can't be planned still leaves the rest of the report. + const message = `Couldn't plan the refactor of '${stackName}': ${(error as Error).message}`; + checks.push({ name: `plan (${target.name})`, ok: false, message }); + skipped.push({ target: target.name, reason: message }); + } + } + return { planned, skipped }; + } +} + +/** A plan that can't be applied, with nothing to clean up. */ +function notApplicable(plan: MigrationPlan): PreparedMigration { + return { + plan, + apply: () => { + throw new ProjectStateError("This migration plan isn't ready, so it can't be applied."); + }, + dispose: async () => {}, + }; +} + +/** + * Targets that share an AWS account and region. A migrated project keeps v0's + * physical names, which have no target segment, so such targets claim the same + * resource names: only one of them can be deployed, as with the v0 CLI. + */ +export function sharedEnvironmentWarnings(targets: AwsDeploymentTarget[]): string[] { + const byEnvironment = new Map(); + for (const target of targets) { + const key = `${target.account}/${target.region}`; + byEnvironment.set(key, [...(byEnvironment.get(key) ?? []), target.name]); + } + return [...byEnvironment] + .filter(([, names]) => names.length > 1) + .map( + ([environment, names]) => + `Targets '${names.join("', '")}' are all in ${environment}. A migrated project keeps v0's ` + + "resource names, which have no target segment, so these targets claim the same names " + + "and only one of them can be deployed (as with the v0 CLI). Move the others to another " + + "account or region.", + ); +} + +/** + * Credential providers aren't stack resources: the CLI provisions them, and a + * migrated project must keep v0's bare provider names, which the CLI's + * provisioning does only for projects it treats as v0. + */ +export function credentialNameWarnings(names: string[]): string[] { + if (names.length === 0) return []; + return [ + `This project has credentials (${names.join(", ")}). The migrated project keeps v0's ` + + "credential provider names (the bare credential names) and, like a v0 project, never " + + "deletes its API key or OAuth providers, since other v0 projects may share them.", + ]; +} + +/** + * The deployment targets that have a deployed stack: those the deployed state + * records resources for. A deployed target missing from aws-targets.json can't be + * planned (its account and region are unknown, and the v1 app wouldn't + * synthesize its stack), so it fails a check. + */ +export function deployedTargets( + projectName: string, + targets: AwsDeploymentTarget[], + state: V0DeployedState, +): { targets: DeployedTarget[]; checks: MigrationCheck[]; skipped: SkippedStackPlan[] } { + const checks: MigrationCheck[] = []; + const deployed: DeployedTarget[] = []; + const skipped: SkippedStackPlan[] = []; + for (const [name, recorded] of Object.entries(state.targets)) { + if (recorded.resources === undefined) continue; + const target = targets.find((candidate) => candidate.name === name); + if (!target) { + const message = + `The deployed state records a deployment to target '${name}', which isn't in ` + + `${AWS_TARGETS_RELATIVE_PATH}. Add the target back, or tear its stack down with the ` + + "v0 CLI first."; + checks.push({ name: `stack (${name})`, ok: false, message }); + skipped.push({ target: name, reason: message }); + continue; + } + deployed.push({ + target, + // v0 records the stack name; older deploys derive it the same way v1 does. + stackName: + recorded.resources.stackName ?? + `AgentCore-${projectName.replace(/_/g, "-")}-${name.replace(/_/g, "-")}`, + }); + } + return { targets: deployed, checks, skipped }; +} + +/** + * Picks the one deployment target a run migrates. One candidate is picked + * automatically; several need `--target`, and the rest stay pending. `kind` says + * what the candidates are, for the messages. + */ +export function selectTarget( + candidates: DeployedTarget[], + requested: string | undefined, + kind: "deployed" | "pending", +): { selected?: DeployedTarget; others: DeployedTarget[]; check?: MigrationCheck } { + const names = candidates.map((entry) => entry.target.name); + const described = kind === "deployed" ? "is deployed to" : "has pending migrations for"; + if (requested !== undefined) { + const selected = candidates.find((entry) => entry.target.name === requested); + if (!selected) { + return { + others: candidates, + check: { + name: "target", + ok: false, + message: + names.length === 0 + ? `--target ${requested}: this project ${kind === "deployed" ? "isn't deployed to any target" : "has no pending targets"}.` + : `--target ${requested}: this project ${described} ${names.map((name) => `'${name}'`).join(", ")}, not '${requested}'.`, + }, + }; + } + return { + selected, + others: candidates.filter((entry) => entry !== selected), + check: targetCheck(selected, candidates), + }; + } + if (candidates.length <= 1) { + const [selected] = candidates; + return { ...(selected && { selected, check: targetCheck(selected, candidates) }), others: [] }; + } + return { + others: candidates, + check: { + name: "target", + ok: false, + message: + `This project ${described} ${candidates.length} targets (${names.join(", ")}). ` + + "Migrate one target at a time: run `agentcore migrate --target `.", + }, + }; +} + +function targetCheck(selected: DeployedTarget, candidates: DeployedTarget[]): MigrationCheck { + const others = candidates.filter((entry) => entry !== selected).map((entry) => entry.target.name); + return { + name: "target", + ok: true, + message: + `migrating '${selected.target.name}' (${selected.target.account}/${selected.target.region})` + + (others.length > 0 + ? `; ${others.join(", ")} ${others.length === 1 ? "stays" : "stay"} pending until migrated with --target` + : ""), + }; +} + +function accountMismatch(target: AwsDeploymentTarget, active: string): string { + return ( + `Target '${target.name}' is in AWS account ${target.account} (${target.region}), but the ` + + `active credentials belong to account ${active}. Switch to credentials for account ` + + `${target.account} and run \`agentcore migrate --target ${target.name}\` again.` + ); +} + +function overrideWarnings(cdkLibraryOverride: string | undefined): string[] { + return cdkLibraryOverride === undefined + ? [] + : [ + `Synthesized with @aws/agentcore-cdk from ${CDK_LIBRARY_OVERRIDE_ENV} ` + + `(${cdkLibraryOverride}), not the library this CLI vends; the plan holds only for that library.`, + ]; +} + +function pendingTarget(entry: DeployedTarget): JournalTarget { + return { + name: entry.target.name, + account: entry.target.account, + region: entry.target.region, + stackName: entry.stackName, + status: "pending", + }; +} + +function deployedFromRecord(entry: JournalTarget): DeployedTarget { + return { + target: { + name: entry.name, + account: entry.account, + region: entry.region as AwsDeploymentTarget["region"], + }, + stackName: entry.stackName, + }; +} diff --git a/src/core/project/migrate/issues.test.ts b/src/core/project/migrate/issues.test.ts new file mode 100644 index 000000000..0be19628c --- /dev/null +++ b/src/core/project/migrate/issues.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { describePath, droppedPaths, problemsFromZodError } from "./issues"; + +describe("describePath", () => { + const root = { + credentials: [{ name: "key" }, { other: true }], + gateways: [{ name: "gw", targets: [{ name: "t1", outboundAuth: {} }] }], + }; + + test("names array entries by their name field", () => { + expect(describePath(root, ["credentials", 0, "name"])).toBe("credentials[key].name"); + expect(describePath(root, ["gateways", 0, "targets", 0, "outboundAuth"])).toBe( + "gateways[gw].targets[t1].outboundAuth", + ); + }); + + test("falls back to the index for entries without a name", () => { + expect(describePath(root, ["credentials", 1, "other"])).toBe("credentials[1].other"); + }); + + test("describes the empty path as the top level", () => { + expect(describePath(root, [])).toBe("(top level)"); + }); +}); + +describe("problemsFromZodError", () => { + test("turns each issue into a problem against the input", () => { + const input = { items: [{ name: "a", size: "big" }] }; + const result = z + .object({ items: z.array(z.object({ name: z.string(), size: z.number() })) }) + .safeParse(input); + if (result.success) throw new Error("expected a failure"); + expect(problemsFromZodError("f.json", input, result.error)).toEqual([ + { file: "f.json", path: "items[a].size", message: expect.stringContaining("number") }, + ]); + }); +}); + +describe("droppedPaths", () => { + test("finds keys the parse stripped, at any depth", () => { + const before = { a: 1, list: [{ keep: 1, drop: 2 }], nested: { gone: true }, unset: undefined }; + const after = { a: 1, list: [{ keep: 1 }], nested: {} }; + expect(droppedPaths(before, after)).toEqual([ + ["list", 0, "drop"], + ["nested", "gone"], + ]); + }); + + test("reports nothing when nothing was stripped", () => { + expect(droppedPaths({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }], extra: 3 })).toEqual([]); + }); +}); diff --git a/src/core/project/migrate/issues.ts b/src/core/project/migrate/issues.ts new file mode 100644 index 000000000..9cdf89b44 --- /dev/null +++ b/src/core/project/migrate/issues.ts @@ -0,0 +1,69 @@ +import type { z } from "zod"; +import type { MigrationProblem } from "../../../handlers/project/migrate/types"; + +/** + * Renders a field path inside `root` for a person to find: array entries are named + * by their `name` field where they have one, so `credentials.5.name` reads + * `credentials[svc_client_secret].name`. + */ +export function describePath(root: unknown, path: readonly PropertyKey[]): string { + let current: unknown = root; + let rendered = ""; + for (const segment of path) { + const key = typeof segment === "symbol" ? segment.toString() : segment; + if (Array.isArray(current) && typeof key === "number") { + const element: unknown = current[key]; + const name = + typeof element === "object" && element !== null + ? (element as { name?: unknown }).name + : undefined; + rendered += typeof name === "string" ? `[${name}]` : `[${key}]`; + current = element; + continue; + } + rendered += rendered === "" ? String(key) : `.${String(key)}`; + current = + typeof current === "object" && current !== null + ? (current as Record)[String(key)] + : undefined; + } + return rendered === "" ? "(top level)" : rendered; +} + +/** One problem per zod issue, with its path described against the input that failed. */ +export function problemsFromZodError( + file: string, + input: unknown, + error: z.ZodError, +): MigrationProblem[] { + return error.issues.map((issue) => ({ + file, + path: describePath(input, issue.path), + message: issue.message, + })); +} + +/** + * The paths of every value in `before` that has no counterpart in `after`: what a + * schema's parse stripped. Arrays are compared index by index, which holds because + * parsing never reorders them. + */ +export function droppedPaths( + before: unknown, + after: unknown, + path: PropertyKey[] = [], +): PropertyKey[][] { + if (Array.isArray(before)) { + if (!Array.isArray(after)) return []; + return before.flatMap((element, index) => + droppedPaths(element, after[index], [...path, index]), + ); + } + if (typeof before !== "object" || before === null) return []; + if (typeof after !== "object" || after === null || Array.isArray(after)) return []; + return Object.entries(before).flatMap(([key, value]) => { + if (value === undefined) return []; + if (!(key in after)) return [[...path, key]]; + return droppedPaths(value, (after as Record)[key], [...path, key]); + }); +} diff --git a/src/core/project/migrate/journal.test.ts b/src/core/project/migrate/journal.test.ts new file mode 100644 index 000000000..a675ca254 --- /dev/null +++ b/src/core/project/migrate/journal.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { ProjectStateError } from "../../../errors"; +import { + assertNoPendingMigration, + readMigrationState, + removeMigrationState, + STATE_RELATIVE_PATH, + writeMigrationState, + type MigrationState, +} from "./journal"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "migrate-journal-")); + roots.push(root); + return root; +} + +const STATE: MigrationState = { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: "installed", backup: "agentcore/.cli/migrate-backup", changes: [] }, + refactored: ["dev"], + targets: [ + { + name: "prod", + account: "444455556666", + region: "eu-west-1", + stackName: "AgentCore-P-prod", + status: "pending", + }, + ], +}; + +describe("the migration record", () => { + test("round-trips, written atomically with no temporary file left", async () => { + const root = tempRoot(); + await writeMigrationState(root, STATE); + + expect(await readMigrationState(root)).toEqual(STATE); + expect(readdirSync(dirname(join(root, STATE_RELATIVE_PATH)))).toEqual(["migration-state.json"]); + await removeMigrationState(root); + expect(await readMigrationState(root)).toBeUndefined(); + }); +}); + +describe("assertNoPendingMigration", () => { + test("allows any target when there's no pending migration", async () => { + await assertNoPendingMigration(tempRoot(), "prod"); + }); + + test("blocks a pending target, naming its account and region, and allows the rest", async () => { + const root = tempRoot(); + await writeMigrationState(root, STATE); + + const error = await assertNoPendingMigration(root, "prod").catch((caught) => caught); + expect(error).toBeInstanceOf(ProjectStateError); + expect(error.message).toContain("Target 'prod' (AWS account 444455556666, eu-west-1)"); + expect(error.message).toContain("agentcore migrate --target prod"); + await assertNoPendingMigration(root, "dev"); + }); + + test("blocks a target whose refactor's outcome isn't known yet", async () => { + const root = tempRoot(); + await writeMigrationState(root, { + ...STATE, + targets: [{ ...STATE.targets[0]!, status: "refactoring", refactorId: "rf-1" }], + }); + await expect(assertNoPendingMigration(root, "prod")).rejects.toThrow("hasn't been migrated"); + }); + + test("blocks every target while the project files are half-written", async () => { + const root = tempRoot(); + await writeMigrationState(root, { + ...STATE, + files: { ...STATE.files!, status: "writing" }, + targets: [], + }); + await expect(assertNoPendingMigration(root, "anything")).rejects.toThrow( + "stopped while writing the project files", + ); + }); + + test("blocks when the record can't be read", async () => { + const root = tempRoot(); + await writeMigrationState(root, STATE); + writeFileSync(join(root, STATE_RELATIVE_PATH), "{ not json"); + await expect(assertNoPendingMigration(root, "dev")).rejects.toThrow("Couldn't read"); + expect(readFileSync(join(root, STATE_RELATIVE_PATH), "utf8")).toBe("{ not json"); + }); +}); diff --git a/src/core/project/migrate/journal.ts b/src/core/project/migrate/journal.ts new file mode 100644 index 000000000..342e29fc7 --- /dev/null +++ b/src/core/project/migrate/journal.ts @@ -0,0 +1,120 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { z } from "zod"; +import { ProjectStateError } from "../../../errors"; +import { atomicWrite } from "../../../io"; + +/** + * The record of a migration in progress, relative to the project root. It's + * written before `agentcore migrate` changes anything, and updated (atomically) + * as each step completes, so a migration stopped at any point can be finished or + * undone: rerunning `agentcore migrate` reads it. Deleted once the migration is + * done. + */ +export const STATE_RELATIVE_PATH = "agentcore/.cli/migration-state.json"; + +const FileChangeSchema = z.object({ + path: z.string(), + kind: z.enum(["added", "modified", "removed"]), +}); + +/** + * A deployment target whose stack still has its v0 logical IDs. `pending` hasn't + * been refactored; `refactoring` had a refactor created (with `refactorId`), whose + * outcome a rerun reads back before doing anything else. + */ +const JournalTargetSchema = z.object({ + name: z.string(), + account: z.string(), + region: z.string(), + stackName: z.string(), + status: z.enum(["pending", "refactoring"]), + refactorId: z.string().optional(), + /** The refactor's logical ID renames (deployed → v1), recorded with `refactorId`. */ + renames: z.array(z.tuple([z.string(), z.string()])).optional(), +}); + +export const MigrationStateSchema = z.object({ + version: z.literal(2), + startedAt: z.string(), + /** + * The project files the migration converts: `writing` until every file is in + * place (the originals are in the backup until then), `written` once they are, + * `installed` once the v1 CDK app's dependencies are installed. Absent once a + * migration no longer converts files. + */ + files: z + .object({ + status: z.enum(["writing", "written", "installed"]), + /** Where the replaced v0 files are kept, relative to the project root. */ + backup: z.string(), + changes: z.array(FileChangeSchema), + }) + .optional(), + /** Targets this migration has refactored; once any has, the v0 files can't come back. */ + refactored: z.array(z.string()), + targets: z.array(JournalTargetSchema), +}); + +export type MigrationState = z.infer; +export type JournalTarget = z.infer; + +/** Reads the record of a migration in progress; undefined when there is none. */ +export async function readMigrationState(rootPath: string): Promise { + let text: string; + try { + text = await readFile(join(rootPath, STATE_RELATIVE_PATH), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + return MigrationStateSchema.parse(JSON.parse(text)); +} + +/** Writes the record atomically (temporary file, then rename), so it's never half-written. */ +export async function writeMigrationState(rootPath: string, state: MigrationState): Promise { + const path = join(rootPath, STATE_RELATIVE_PATH); + await mkdir(dirname(path), { recursive: true }); + await atomicWrite(path, `${JSON.stringify(state, null, 2)}\n`); +} + +export async function removeMigrationState(rootPath: string): Promise { + await rm(join(rootPath, STATE_RELATIVE_PATH), { force: true }); +} + +/** + * Refuses to deploy a target a migration hasn't finished with: its stack still + * has the v0 logical IDs, so deploying the v1 project to it would replace its + * resources. A migration stopped while writing the project files blocks every + * target, since the files are part v0 and part v1. Other targets deploy as usual. + */ +export async function assertNoPendingMigration(rootPath: string, target: string): Promise { + let state: MigrationState | undefined; + try { + state = await readMigrationState(rootPath); + } catch (error) { + throw new ProjectStateError( + `Couldn't read ${STATE_RELATIVE_PATH}, the record of a migration in progress ` + + `(${(error as Error).message}), so deploying could replace this project's resources. ` + + "Run `agentcore migrate` to finish the migration.", + { cause: error }, + ); + } + if (!state) return; + if (state.files?.status === "writing") { + throw new ProjectStateError( + "A migration of this project stopped while writing the project files, so they are part " + + "v0 and part v1. Run `agentcore migrate` to put them back before you deploy.", + { meta: { reason: "migration-interrupted" } }, + ); + } + const pending = state.targets.find((entry) => entry.name === target); + if (!pending) return; + throw new ProjectStateError( + `Target '${pending.name}' (AWS account ${pending.account}, ${pending.region}) hasn't been ` + + `migrated yet: its stack ${pending.stackName} still has the v0 logical IDs, so a v1 deploy ` + + "would replace its resources. Run `agentcore migrate --target " + + `${pending.name}\` with credentials for account ${pending.account}, then deploy.`, + { meta: { reason: "migration-pending", target: pending.name } }, + ); +} diff --git a/src/core/project/migrate/mapping.test.ts b/src/core/project/migrate/mapping.test.ts new file mode 100644 index 000000000..f50b1b80b --- /dev/null +++ b/src/core/project/migrate/mapping.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isMovable, mapLogicalIds, renamesOf, type LogicalIdMapping } from "./mapping"; +import { templateFixture } from "./migrate-test-support"; +import type { CfnTemplate } from "./templates"; + +/** The mapping a live refactor of the same stacks used (`/tmp/compat-e2e/B1`, `/tmp/mig-synth`). */ +function liveMapping(name: string): Record { + return JSON.parse( + readFileSync(join(import.meta.dir, "__fixtures__", "templates", `${name}.json`), "utf8"), + ) as Record; +} + +const pairs = (mapping: LogicalIdMapping) => + Object.fromEntries(mapping.matches.map((match) => [match.from, match.to])); + +describe("mapLogicalIds on saved templates", () => { + test("CodeZip project: matches the mapping the live refactor used, roles and policies included", () => { + const mapping = mapLogicalIds(templateFixture("codezip.v0"), templateFixture("codezip.v1-rc3")); + // The hand-written live mapping had to add the runtime role, its DefaultPolicy, + // and the evaluator Lambda's invoke permission by hand; all three come from + // the reference graph here. + expect(pairs(mapping)).toEqual(liveMapping("codezip.mapping")); + expect( + mapping.matches.find((m) => m.from.includes("Agent1RuntimeExecutionRoleDefault")), + ).toEqual({ + from: "ApplicationAgentAgent1RuntimeExecutionRoleDefaultPolicy550C71C2", + to: "ApplicationRuntimeAgent1RuntimeExecutionRoleDefaultPolicyC2FC8CDC", + type: "AWS::IAM::Policy", + reason: "reference", + }); + expect(mapping.onlyInOld).toEqual([ + { + logicalId: "ApplicationEvaluatorRefcodeFunctionAllowBedrockAgentCoreGetFunction83BAE833", + type: "AWS::Lambda::Permission", + }, + { logicalId: "McpGatewayRefgwRoleDefaultPolicy0A9745F9", type: "AWS::IAM::Policy" }, + ]); + expect(mapping.onlyInNew).toEqual([]); + }); + + test("Container project: matches the live mapping, ECR repository and build chain included", () => { + const mapping = mapLogicalIds( + templateFixture("container.v0"), + templateFixture("container.v1-v0names"), + ); + expect(pairs(mapping)).toEqual(liveMapping("container.mapping")); + expect(mapping.onlyInOld).toEqual([ + { logicalId: "McpGatewayMygwRoleDefaultPolicy7AEB4302", type: "AWS::IAM::Policy" }, + ]); + }); + + test("pairs resources whose names differ through their references", () => { + // v1's default names differ from v0's for every AgentCore resource; the + // gateway target keeps its name, and the gateway pairs through it. + const mapping = mapLogicalIds( + templateFixture("container.v0"), + templateFixture("container.v1-v1names"), + ); + expect(pairs(mapping)["McpGatewayMygw5B7E67C0"]).toBe("ApplicationGatewayMygw7D2DB91C"); + expect(mapping.matches.find((m) => m.from === "McpGatewayMygw5B7E67C0")?.reason).toBe( + "reference", + ); + }); + + test("the live CompatMig stack with pinned names", () => { + const mapping = mapLogicalIds(templateFixture("pinned.v0"), templateFixture("pinned.v1")); + expect(renamesOf(mapping).size).toBe(10); + expect(mapping.onlyInOld.map((entry) => entry.type)).toEqual(["AWS::IAM::Policy"]); + expect(mapping.onlyInNew).toEqual([]); + }); +}); + +const role = (extra: Record = {}) => ({ + Type: "AWS::IAM::Role", + Properties: { AssumeRolePolicyDocument: {}, ...extra }, +}); + +describe("mapLogicalIds rules", () => { + test("named child resources with the same name pair up under their own parents", () => { + const template = (prefix: string): CfnTemplate => ({ + Resources: Object.fromEntries( + ["a", "b"].flatMap((gateway) => [ + [ + `${prefix}Gw${gateway}`, + { Type: "AWS::BedrockAgentCore::Gateway", Properties: { Name: gateway } }, + ], + [ + `${prefix}Gw${gateway}Target`, + { + Type: "AWS::BedrockAgentCore::GatewayTarget", + Properties: { Name: "remote", GatewayIdentifier: { Ref: `${prefix}Gw${gateway}` } }, + }, + ], + ]), + ), + }); + expect(pairs(mapLogicalIds(template("Old"), template("New")))).toEqual({ + OldGwa: "NewGwa", + OldGwb: "NewGwb", + OldGwaTarget: "NewGwaTarget", + OldGwbTarget: "NewGwbTarget", + }); + }); + + test("breaks a tie by the resources' own properties, and leaves a real tie unmatched", () => { + const permission = (action: string) => ({ + Type: "AWS::Lambda::Permission", + Properties: { Action: action, FunctionName: { "Fn::GetAtt": ["Fn", "Arn"] } }, + }); + const fn = { Type: "AWS::Lambda::Function", Properties: { FunctionName: "f" } }; + const resolved = mapLogicalIds( + { + Resources: { + Fn: fn, + OldInvoke: permission("lambda:InvokeFunction"), + OldGet: permission("lambda:GetFunction"), + }, + }, + { Resources: { Fn: fn, NewInvoke: permission("lambda:InvokeFunction") } }, + ); + expect(pairs(resolved)).toEqual({ Fn: "Fn", OldInvoke: "NewInvoke" }); + expect(resolved.onlyInOld).toEqual([{ logicalId: "OldGet", type: "AWS::Lambda::Permission" }]); + + const tied = mapLogicalIds( + { Resources: { Fn: fn, A: permission("x"), B: permission("x") } }, + { Resources: { Fn: fn, C: permission("x"), D: permission("x") } }, + ); + expect(pairs(tied)).toEqual({ Fn: "Fn" }); + }); + + test("skips a name built from another resource's attribute, and pairs it by reference instead", () => { + const named = (id: string): CfnTemplate => ({ + Resources: { + Bucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: "b" } }, + [id]: { + Type: "AWS::IAM::Role", + Properties: { RoleName: { "Fn::Join": ["-", [{ Ref: "Bucket" }, "role"]] } }, + }, + }, + }); + const mapping = mapLogicalIds(named("OldRole"), named("NewRole")); + expect(mapping.matches.find((m) => m.from === "OldRole")).toEqual({ + from: "OldRole", + to: "NewRole", + type: "AWS::IAM::Role", + reason: "reference", + }); + }); + + test("doesn't pair on DependsOn alone, or when a shared property points at different resources", () => { + const queue = (extra: Record) => ({ Type: "AWS::SQS::Queue", ...extra }); + const runtime = { + Type: "AWS::BedrockAgentCore::Runtime", + Properties: { AgentRuntimeName: "a" }, + }; + const dependsOnly = mapLogicalIds( + { + Resources: { Rt: runtime, Q: queue({ DependsOn: "Rt", Properties: { DelaySeconds: 1 } }) }, + }, + { + Resources: { + Rt2: runtime, + Other: queue({ DependsOn: "Rt2", Properties: { DelaySeconds: 9 } }), + }, + }, + ); + expect(pairs(dependsOnly)).toEqual({ Rt: "Rt2" }); + + const role = (name: string) => ({ Type: "AWS::IAM::Role", Properties: { RoleName: name } }); + const policy = (...roles: string[]) => ({ + Type: "AWS::IAM::Policy", + Properties: { Roles: roles.map((id) => ({ Ref: id })) }, + }); + const conflicting = mapLogicalIds( + { Resources: { A: role("a"), B: role("b"), Old: policy("A", "B") } }, + { Resources: { A2: role("a"), B2: role("b"), C2: role("c"), New: policy("A2", "C2") } }, + ); + // Old and New share role A but disagree on the other role in the same property. + expect(pairs(conflicting)).toEqual({ A: "A2", B: "B2" }); + expect(conflicting.onlyInOld).toEqual([{ logicalId: "Old", type: "AWS::IAM::Policy" }]); + }); + + test("never pairs resources of different types, and leaves CDK metadata out of the lists", () => { + const mapping = mapLogicalIds( + { Resources: { Same: role(), CDKMetadata: { Type: "AWS::CDK::Metadata" } } }, + { Resources: { Same: { Type: "AWS::IAM::Policy", Properties: {} } } }, + ); + expect(mapping.matches).toEqual([]); + expect(mapping.onlyInOld).toEqual([{ logicalId: "Same", type: "AWS::IAM::Role" }]); + expect(mapping.onlyInNew).toEqual([{ logicalId: "Same", type: "AWS::IAM::Policy" }]); + }); +}); + +describe("renamesOf", () => { + test("keeps only changed IDs of movable resources", () => { + const renames = renamesOf({ + matches: [ + { from: "A", to: "B", type: "AWS::IAM::Role", reason: "reference" }, + { from: "C", to: "C", type: "AWS::IAM::Role", reason: "logical-id" }, + { from: "D", to: "E", type: "AWS::CloudFormation::CustomResource", reason: "reference" }, + { from: "F", to: "G", type: "Custom::AWSCDKCfnJson", reason: "reference" }, + ], + onlyInOld: [], + onlyInNew: [], + }); + expect([...renames]).toEqual([["A", "B"]]); + expect(isMovable("AWS::CDK::Metadata")).toBe(false); + }); +}); diff --git a/src/core/project/migrate/mapping.ts b/src/core/project/migrate/mapping.ts new file mode 100644 index 000000000..6f1782a9a --- /dev/null +++ b/src/core/project/migrate/mapping.ts @@ -0,0 +1,324 @@ +import { + canonicalJson, + CDK_METADATA_TYPE, + dependsOnOf, + isCustomResource, + referencesIn, + resourcesOf, + stripReferences, + type CfnResource, + type CfnTemplate, + type ResourceReference, +} from "./templates"; + +/** The path recorded for a DependsOn entry, which no property path can equal. */ +const DEPENDS_ON_PATH = "(DependsOn)"; + +/** How a resource in the deployed template was paired with one in the v1 template. */ +export type MatchReason = "name" | "logical-id" | "reference"; + +export type ResourceMatch = { + /** Logical ID in the deployed (v0) template. */ + from: string; + /** Logical ID in the v1 template. */ + to: string; + type: string; + reason: MatchReason; +}; + +export type UnmatchedResource = { logicalId: string; type: string }; + +export type LogicalIdMapping = { + /** Every pair of resources that are the same AWS resource, whether or not the logical ID changes. */ + matches: ResourceMatch[]; + /** Deployed resources with no v1 counterpart; the v1 deploy deletes them. */ + onlyInOld: UnmatchedResource[]; + /** v1 resources with no deployed counterpart; the v1 deploy creates them. */ + onlyInNew: UnmatchedResource[]; +}; + +/** + * The property that holds each resource type's physical name. Two resources of + * the same type with the same name are the same AWS resource, whatever their + * logical IDs. IAM policies are deliberately absent: CDK names a DefaultPolicy + * after its own logical ID, so its PolicyName changes whenever the ID does. + */ +export const NAME_PROPERTIES: Readonly> = { + "AWS::BedrockAgentCore::Runtime": "AgentRuntimeName", + "AWS::BedrockAgentCore::RuntimeEndpoint": "Name", + "AWS::BedrockAgentCore::Memory": "Name", + "AWS::BedrockAgentCore::Gateway": "Name", + "AWS::BedrockAgentCore::GatewayTarget": "Name", + "AWS::BedrockAgentCore::Evaluator": "EvaluatorName", + "AWS::BedrockAgentCore::OnlineEvaluationConfig": "OnlineEvaluationConfigName", + "AWS::BedrockAgentCore::PolicyEngine": "Name", + "AWS::BedrockAgentCore::Policy": "Name", + "AWS::BedrockAgentCore::ConfigurationBundle": "BundleName", + "AWS::BedrockAgentCore::Harness": "HarnessName", + "AWS::BedrockAgentCore::BrowserCustom": "Name", + "AWS::BedrockAgentCore::CodeInterpreterCustom": "Name", + "AWS::BedrockAgentCore::WorkloadIdentity": "Name", + "AWS::BedrockAgentCore::PaymentManager": "Name", + "AWS::BedrockAgentCore::PaymentConnector": "Name", + "AWS::Lambda::Function": "FunctionName", + "AWS::IAM::Role": "RoleName", + "AWS::IAM::ManagedPolicy": "ManagedPolicyName", + "AWS::ECR::Repository": "RepositoryName", + "AWS::Logs::LogGroup": "LogGroupName", + "AWS::CodeBuild::Project": "Name", + "AWS::S3::Bucket": "BucketName", + "AWS::KMS::Alias": "AliasName", + "AWS::SecretsManager::Secret": "Name", +}; + +/** + * Whether CloudFormation stack refactoring can move a resource of this type to a + * new logical ID. Custom resources can't be moved; CDK's metadata resource is + * never worth moving. + */ +export function isMovable(type: string): boolean { + return !isCustomResource(type) && type !== CDK_METADATA_TYPE; +} + +/** + * The logical ID renames the bridge template applies: every matched pair whose + * ID changes and whose type stack refactoring can move. + */ +export function renamesOf(mapping: LogicalIdMapping): Map { + return new Map( + mapping.matches + .filter((match) => match.from !== match.to && isMovable(match.type)) + .map((match) => [match.from, match.to]), + ); +} + +/** + * Pairs each resource in the deployed template with the v1 resource that is the + * same AWS resource, in three passes: + * + * 1. Named resources, by type and physical name (see {@link NAME_PROPERTIES}). + * 2. Remaining resources that keep their logical ID and type. + * 3. Unnamed resources (IAM roles and policies, Lambda permissions, KMS keys, ...) + * through the reference graph, repeated until nothing changes: a resource a + * matched resource references at some property path pairs with what its + * counterpart references at the same path, and a resource that references a + * matched resource (a role's policy, a function's permission) pairs with the + * v1 resource that references the counterpart at the same path. + * + * When several candidates fit, the one whose own properties agree most wins; + * a tie leaves them unmatched rather than guessing. + */ +export function mapLogicalIds( + oldTemplate: CfnTemplate, + newTemplate: CfnTemplate, +): LogicalIdMapping { + const oldResources = resourcesOf(oldTemplate); + const newResources = resourcesOf(newTemplate); + const forward = new Map(); + const backward = new Map(); + const match = (from: string, to: string, reason: MatchReason) => { + forward.set(from, { from, to, type: oldResources[from]!.Type, reason }); + backward.set(to, from); + }; + const unmatchedOld = () => Object.keys(oldResources).filter((id) => !forward.has(id)); + const unmatchedNew = () => Object.keys(newResources).filter((id) => !backward.has(id)); + + // 1. By physical name. + const byName = (resources: Record, ids: string[]) => { + const groups = new Map(); + for (const id of ids) { + const key = nameKey(resources, id); + if (key !== undefined) groups.set(key, [...(groups.get(key) ?? []), id]); + } + return groups; + }; + const newByName = byName(newResources, Object.keys(newResources)); + for (const [key, oldIds] of byName(oldResources, Object.keys(oldResources))) { + const newIds = newByName.get(key) ?? []; + // Names are unique per type in an account, except for child resources such + // as gateway targets; those pair up through their parents in pass 3. + if (oldIds.length === 1 && newIds.length === 1) match(oldIds[0]!, newIds[0]!, "name"); + } + + // 2. Same logical ID and type. + for (const id of unmatchedOld()) { + if (!backward.has(id) && newResources[id]?.Type === oldResources[id]!.Type) { + match(id, id, "logical-id"); + } + } + + // 3. Through references, to a fixed point. + const oldRefs = referenceIndex(oldResources); + const newRefs = referenceIndex(newResources); + const similarity = (from: string, to: string) => + propertySimilarity(oldResources, from, newResources, to); + for (let progress = true; progress;) { + progress = false; + + // 3a. What matched resources reference. + for (const { from, to } of [...forward.values()]) { + const oldTargets = groupTargets(oldRefs.get(from) ?? [], (id) => !forward.has(id)); + const newTargets = groupTargets(newRefs.get(to) ?? [], (id) => !backward.has(id)); + for (const [slot, oldIds] of oldTargets) { + const newIds = newTargets.get(slot) ?? []; + const candidates = oldIds.flatMap((x) => + newIds + .filter((y) => oldResources[x]!.Type === newResources[y]!.Type) + .map((y) => ({ x, y, score: 1 + similarity(x, y) })), + ); + for (const pair of assign(candidates)) { + if (forward.has(pair.x) || backward.has(pair.y)) continue; + match(pair.x, pair.y, "reference"); + progress = true; + } + } + } + + // 3b. What references matched resources. Only property references count: + // CDK adds DependsOn widely, so sharing one says little about identity. + const slots = (refs: ResourceReference[], translate: (id: string) => string | undefined) => { + const bySlot = new Map>(); + for (const ref of refs) { + const target = translate(ref.target); + if (target === undefined || ref.path === DEPENDS_ON_PATH) continue; + const slot = `${ref.path}|${ref.attribute ?? ""}`; + bySlot.set(slot, new Set([...(bySlot.get(slot) ?? []), target])); + } + return bySlot; + }; + const newIds = unmatchedNew(); + const newSlots = new Map( + newIds.map((y) => [ + y, + slots(newRefs.get(y) ?? [], (id) => (backward.has(id) ? id : undefined)), + ]), + ); + const candidates = unmatchedOld().flatMap((x) => { + const oldSlots = slots(oldRefs.get(x) ?? [], (id) => forward.get(id)?.to); + if (oldSlots.size === 0) return []; + return newIds.flatMap((y) => { + if (oldResources[x]!.Type !== newResources[y]!.Type) return []; + let shared = 0; + for (const [slot, oldTargets] of oldSlots) { + const newTargets = newSlots.get(y)!.get(slot); + if (newTargets === undefined) continue; + // The same property pointing at different matched resources is evidence + // against the pair, not merely an absence of evidence for it. + const same = + oldTargets.size === newTargets.size && + [...oldTargets].every((target) => newTargets.has(target)); + if (!same) return []; + shared += oldTargets.size; + } + // Shared references dominate; property agreement only breaks ties. + return shared === 0 ? [] : [{ x, y, score: shared * 1000 + similarity(x, y) }]; + }); + }); + for (const pair of assign(candidates)) { + match(pair.x, pair.y, "reference"); + progress = true; + } + } + + const unmatched = (resources: Record, ids: string[]) => + ids + .filter((id) => resources[id]!.Type !== CDK_METADATA_TYPE) + .map((logicalId) => ({ logicalId, type: resources[logicalId]!.Type })); + return { + matches: [...forward.values()], + onlyInOld: unmatched(oldResources, unmatchedOld()), + onlyInNew: unmatched(newResources, unmatchedNew()), + }; +} + +/** `Type` plus the canonical physical name, for a named resource whose name references no other resource. */ +function nameKey(resources: Record, id: string): string | undefined { + const resource = resources[id]!; + const property = NAME_PROPERTIES[resource.Type]; + const name = property === undefined ? undefined : resource.Properties?.[property]; + if (name === undefined) return undefined; + // A name built from another resource's attribute would have to be mapped + // itself before it could be compared; pass 3 pairs those instead. + if (referencesIn(name, (ref) => ref in resources).length > 0) return undefined; + return `${resource.Type}\u0000${canonicalJson(name)}`; +} + +/** Each resource's references (properties and DependsOn) to other resources in the template. */ +function referenceIndex(resources: Record): Map { + const isResource = (id: string) => id in resources; + return new Map( + Object.entries(resources).map(([id, resource]) => [ + id, + [ + ...referencesIn(resource.Properties ?? {}, isResource), + ...dependsOnOf(resource) + .filter(isResource) + .map((target) => ({ target, path: DEPENDS_ON_PATH })), + ], + ]), + ); +} + +/** The still-unmatched targets of `refs`, grouped by where they're referenced from. */ +function groupTargets( + refs: ResourceReference[], + unmatched: (id: string) => boolean, +): Map { + const groups = new Map(); + for (const ref of refs) { + if (!unmatched(ref.target) || ref.path === DEPENDS_ON_PATH) continue; + const slot = `${ref.path}|${ref.attribute ?? ""}`; + const targets = groups.get(slot) ?? []; + if (!targets.includes(ref.target)) groups.set(slot, [...targets, ref.target]); + } + return groups; +} + +/** How many top-level properties two resources share exactly, references aside. */ +function propertySimilarity( + oldResources: Record, + from: string, + newResources: Record, + to: string, +): number { + const strip = (resources: Record, id: string) => + Object.fromEntries( + Object.entries(resources[id]!.Properties ?? {}).map(([key, value]) => [ + key, + canonicalJson(stripReferences(value, (ref) => ref in resources)), + ]), + ); + const a = strip(oldResources, from); + const b = strip(newResources, to); + return Object.keys(a).filter((key) => key in b && a[key] === b[key]).length; +} + +/** + * Picks pairs from scored candidates, best first. A pair is taken only when no + * other open candidate for either side scores as high, so a tie is never + * resolved by order. + */ +function assign(candidates: T[]): T[] { + const chosen: T[] = []; + const usedX = new Set(); + const usedY = new Set(); + const open = [...candidates].sort((a, b) => b.score - a.score); + for (const pair of open) { + if (usedX.has(pair.x) || usedY.has(pair.y)) continue; + const rivals = open.filter( + (other) => + other !== pair && + other.score === pair.score && + (other.x === pair.x || other.y === pair.y) && + !usedX.has(other.x) && + !usedY.has(other.y), + ); + // A tie leaves every resource in it unmatched, including for lower-scored pairs. + for (const taken of [pair, ...rivals]) { + usedX.add(taken.x); + usedY.add(taken.y); + } + if (rivals.length === 0) chosen.push(pair); + } + return chosen; +} diff --git a/src/core/project/migrate/migrate-cfn-test-support.ts b/src/core/project/migrate/migrate-cfn-test-support.ts new file mode 100644 index 000000000..d366e1c7c --- /dev/null +++ b/src/core/project/migrate/migrate-cfn-test-support.ts @@ -0,0 +1,60 @@ +import type { CloudFormationClient } from "@aws-sdk/client-cloudformation"; + +/** A CloudFormation command as the fake sees it: its class name and input. */ +export type SentCommand = { name: string; input: Record }; + +type Responder = (input: Record) => unknown; + +/** + * A scripted CloudFormation client for migrate's refactor tests. Each command + * name maps to a queue of responses (a value, a function of the input, or an + * Error to throw); the last one repeats once the queue runs out. + */ +export function fakeCloudFormation(script: Record>): { + client: CloudFormationClient; + sent: SentCommand[]; +} { + const sent: SentCommand[] = []; + const queues = new Map(Object.entries(script).map(([name, list]) => [name, [...list]])); + const client = { + send: async (command: { constructor: { name: string }; input: Record }) => { + const name = command.constructor.name.replace(/Command$/, ""); + sent.push({ name, input: command.input }); + const queue = queues.get(name); + if (!queue || queue.length === 0) throw new Error(`unexpected ${name}`); + const next = queue.length > 1 ? queue.shift() : queue[0]; + const value = typeof next === "function" ? (next as Responder)(command.input) : next; + if (value instanceof Error) throw value; + return value; + }, + }; + return { client: client as unknown as CloudFormationClient, sent }; +} + +/** ListStackRefactorActions output moving each `[from, to]` within `stackName`. */ +export function moveActions(stackName: string, moves: Array<[string, string]>) { + return { + StackRefactorActions: moves.map(([from, to]) => ({ + Action: "MOVE", + Entity: "RESOURCE", + PhysicalResourceId: `phys-${from}`, + ResourceMapping: { + Source: { StackName: stackName, LogicalResourceId: from }, + Destination: { StackName: stackName, LogicalResourceId: to }, + }, + })), + }; +} + +/** ListStackResources output for logical ID → physical ID pairs. */ +export function stackResources(pairs: Array<[string, string]>) { + return { + StackResourceSummaries: pairs.map(([logical, physical]) => ({ + LogicalResourceId: logical, + PhysicalResourceId: physical, + })), + }; +} + +/** Poll options that never wait. */ +export const NO_WAIT = { intervalMs: 0, sleep: async () => {} }; diff --git a/src/core/project/migrate/migrate-test-support.ts b/src/core/project/migrate/migrate-test-support.ts new file mode 100644 index 000000000..b53bdc22d --- /dev/null +++ b/src/core/project/migrate/migrate-test-support.ts @@ -0,0 +1,747 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + CreateStackRefactorCommand, + DescribeStackRefactorCommand, + DescribeStacksCommand, + ExecuteStackRefactorCommand, + GetStackPolicyCommand, + GetTemplateCommand, + ListStackRefactorActionsCommand, + ListStackResourcesCommand, + type CloudFormationClient, + type ResourceMapping, +} from "@aws-sdk/client-cloudformation"; +import { ProcessFailedError, type ProcessRunner } from "../../../io"; +import type { V0DeployedState } from "../../../projectSchemas/v0"; +import type { CreateCloudFormationClient } from "../../types"; +import type { AccountResolver } from "../backends/cdk/environment"; +import type { CdkCredentialResolver } from "../backends/cdk/toolkit"; +import type { WorkspaceSynthesizer } from "./synth"; +import { FsAssetSource, type AssetSource } from "../source"; +import { MIN_MIGRATED_FROM_CDK_VERSION } from "./index"; +import type { CfnTemplate } from "./templates"; +import type { TemplateStore } from "./templateStore"; + +/** A deployed v0 project shaped like the live-tested F1 fixture, trimmed to one of each resource. */ +export const F1_SPEC = { + $schema: "https://schema.agentcore.aws.dev/v1/agentcore.json", + name: "CompatA", + version: 1, + managedBy: "CDK", + runtimes: [ + { + name: "CompatAAgent", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/CompatAAgent/", + runtimeVersion: "PYTHON_3_14", + networkMode: "PUBLIC", + protocol: "HTTP", + }, + ], + memories: [ + { + name: "CompatAAgentMemory", + eventExpiryDuration: 30, + strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }], + }, + ], + knowledgeBases: [], + credentials: [ + { authorizerType: "ApiKeyCredentialProvider", name: "compatA-key" }, + { + authorizerType: "OAuthCredentialProvider", + name: "compatA-oauth", + discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration", + scopes: ["openid"], + vendor: "CustomOauth2", + usage: "outbound", + }, + ], + evaluators: [ + { + name: "CompatAJudge", + level: "TRACE", + config: { + llmAsAJudge: { + model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + instructions: "Context: {context}. Assistant turn: {assistant_turn}. Rate it.", + ratingScale: { numerical: [{ value: 1, label: "Poor", definition: "Bad" }] }, + }, + }, + }, + ], + onlineEvalConfigs: [ + { + name: "CompatAOnline", + agent: "CompatAAgent", + evaluators: ["CompatAJudge"], + samplingRate: 10, + }, + ], + agentCoreGateways: [ + { + name: "CompatAGw", + targets: [ + { + name: "awsknowledge", + targetType: "mcpServer", + endpoint: "https://knowledge-mcp.global.api.aws", + }, + ], + authorizerType: "AWS_IAM", + policyEngineConfiguration: { policyEngineName: "CompatAPE", mode: "LOG_ONLY" }, + }, + ], + policyEngines: [ + { + name: "CompatAPE", + policies: [{ name: "CompatAPolicy", statement: "permit(principal, action, resource);" }], + }, + ], + configBundles: [ + { + name: "CompatABundle", + type: "ConfigurationBundle", + components: { "{{runtime:CompatAAgent}}": { configuration: { systemPrompt: "Hi." } } }, + }, + ], + abTests: [], + harnesses: [{ name: "CompatAHarness", path: "app/CompatAHarness" }], + datasets: [], + capacityProviders: [], + payments: [], +}; + +const ARN = "arn:aws:bedrock-agentcore:us-east-1:998846730471"; + +/** F1's deployed state as v0 0.31.1 recorded it after a deploy to `default`. */ +export const F1_DEPLOYED_STATE = { + targets: { + default: { + resources: { + runtimes: { + CompatAAgent: { + runtimeId: "CompatA_CompatAAgent-5ITOUFEJg5", + runtimeArn: `${ARN}:runtime/CompatA_CompatAAgent-5ITOUFEJg5`, + roleArn: "arn:aws:iam::998846730471:role/r", + }, + }, + memories: { + CompatAAgentMemory: { + memoryId: "CompatA_CompatAAgentMemory-ZnaBgH2oeH", + memoryArn: `${ARN}:memory/CompatA_CompatAAgentMemory-ZnaBgH2oeH`, + }, + }, + gateways: { + CompatAGw: { + gatewayId: "compata-compatagw-nmv1g37e5a", + gatewayArn: `${ARN}:gateway/compata-compatagw-nmv1g37e5a`, + }, + }, + credentials: { + "compatA-key": { + credentialProviderArn: `${ARN}:token-vault/default/apikeycredentialprovider/compatA-key`, + }, + "compatA-oauth": { + credentialProviderArn: `${ARN}:token-vault/default/oauth2credentialprovider/compatA-oauth`, + }, + }, + evaluators: { + CompatAJudge: { + evaluatorId: "CompatA_CompatAJudge-e9RwOP3mWm", + evaluatorArn: `${ARN}:evaluator/CompatA_CompatAJudge-e9RwOP3mWm`, + }, + }, + onlineEvalConfigs: { + CompatAOnline: { + onlineEvaluationConfigId: "CompatA_CompatAOnline-rfslDjHfL1", + onlineEvaluationConfigArn: `${ARN}:online-evaluation-config/CompatA_CompatAOnline-rfslDjHfL1`, + }, + }, + configBundles: { + CompatABundle: { + bundleId: "CompatACompatABundle-40Z0rLD2Ri", + bundleArn: `${ARN}:configuration-bundle/CompatACompatABundle-40Z0rLD2Ri`, + versionId: "64dcd57f-bdaa-4493-834c-a813e53a0a9a", + }, + }, + policyEngines: { + CompatAPE: { + policyEngineId: "CompatA_CompatAPE-xmuuz2hdnb", + policyEngineArn: `${ARN}:policy-engine/CompatA_CompatAPE-xmuuz2hdnb`, + }, + }, + policies: { + "CompatAPE/CompatAPolicy": { + policyId: "CompatAPolicy-mh6mcusj2j", + policyArn: `${ARN}:policy-engine/CompatA_CompatAPE-xmuuz2hdnb/policy/CompatAPolicy-mh6mcusj2j`, + engineName: "CompatAPE", + }, + }, + harnesses: { + CompatAHarness: { + harnessId: "CompatA_CompatAHarness-frP175fqBI", + harnessArn: `${ARN}:harness/CompatA_CompatAHarness-frP175fqBI`, + roleArn: "arn:aws:iam::998846730471:role/CompatA_CompatAHarness", + status: "READY", + provisioner: "cloudformation", + }, + }, + stackName: "AgentCore-CompatA-default", + }, + }, + }, +}; + +export const F1_HARNESS = { + name: "CompatAHarness", + model: { provider: "bedrock", modelId: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" }, + tools: [], + skills: [], + memory: { mode: "disabled" }, +}; + +export type V0ProjectFiles = { + spec?: unknown; + /** `null` leaves the file out. */ + deployedState?: unknown; + targets?: unknown; + /** The `@aws/agentcore-cdk` pin in agentcore/cdk/package.json; `null` leaves the file out. */ + cdkPin?: string | null; + /** The release whose vended agentcore/cdk app to write (a `__fixtures__/v0-app/app-*.json`); 0.31.1 by default. */ + app?: string; + /** Extra files by project-relative path. */ + files?: Record; +}; + +/** Writes a v0 project into a fresh temporary directory and returns its root. */ +export async function writeV0Project( + options: V0ProjectFiles = {}, +): Promise<{ root: string; cleanup: () => Promise }> { + const root = await mkdtemp(join(tmpdir(), "agentcore-migrate-test-")); + const files: Record = { + "agentcore/agentcore.json": json(options.spec ?? F1_SPEC), + "agentcore/aws-targets.json": json( + options.targets ?? [{ name: "default", account: "998846730471", region: "us-east-1" }], + ), + "agentcore/.gitignore": + ".env.local\ncdk/cdk.out/\ncdk/node_modules/\n.cli/*\n!.cli/deployed-state.json\n", + // The app the release vends, as `agentcore create` wrote it (package.json below). + ...Object.fromEntries( + Object.entries(v0App(options.app ?? "0.31.1")) + .filter(([file]) => file !== "package.json") + .map(([file, content]) => [`agentcore/cdk/${file}`, content]), + ), + "agentcore/cdk/node_modules/@aws/agentcore-cdk/index.js": "// dependency\n", + "agentcore/cdk/cdk.out/manifest.json": "{}\n", + "app/CompatAHarness/harness.json": json(F1_HARNESS), + "app/CompatAHarness/system-prompt.md": "You are helpful.\n", + "app/CompatAAgent/main.py": "print('hi')\n", + ...options.files, + }; + if (options.deployedState !== null) { + files["agentcore/.cli/deployed-state.json"] = json(options.deployedState ?? F1_DEPLOYED_STATE); + } + if (options.cdkPin !== null) { + const manifest = JSON.parse(v0App(options.app ?? "0.31.1")["package.json"]!) as { + dependencies: Record; + }; + if (options.cdkPin !== undefined) manifest.dependencies["@aws/agentcore-cdk"] = options.cdkPin; + files["agentcore/cdk/package.json"] = json(manifest); + } + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + } + return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +/** Commits everything in `root` to a new git repository, with a fixed identity. */ +export function commitAll(root: string): void { + const git = (...args: string[]) => + execFileSync( + "git", + [ + "-c", + "user.email=test@example.com", + "-c", + "user.name=test", + "-c", + "commit.gpgsign=false", + ...args, + ], + { cwd: root, stdio: "ignore" }, + ); + git("init", "-q"); + git("add", "-A"); + git("commit", "-q", "-m", "v0 project"); +} + +/** A process runner that answers git commands from a script instead of spawning git. */ +export function fakeGit(responses: { + /** git isn't installed: every command fails to spawn. */ + missing?: boolean; + insideWorkTree?: boolean; + /** agentcore/agentcore.json is tracked; defaults to true. */ + specTracked?: boolean; + status?: string; +}): ProcessRunner { + return async (command, options) => { + if (responses.missing) { + throw new ProcessFailedError(command, options.cwd, null, "Error: spawn git ENOENT"); + } + // The git gate runs `git -C ...`; the root doesn't matter here. + const args = command[1] === "-C" ? [command[0], ...command.slice(3)] : command; + const joined = args.join(" "); + if (joined === "git rev-parse --is-inside-work-tree") { + if (responses.insideWorkTree === false) { + throw new ProcessFailedError(command, options.cwd, 128, "fatal: not a git repository"); + } + options.onOutput?.("true\n"); + return; + } + if (joined === "git ls-files --error-unmatch agentcore/agentcore.json") { + if (responses.specTracked === false) { + throw new ProcessFailedError(command, options.cwd, 1, "error: pathspec did not match"); + } + options.onOutput?.("agentcore/agentcore.json\n"); + return; + } + if ( + joined === + "git --no-optional-locks status --porcelain --untracked-files=all -- . :(exclude)agentcore/.cli/migrate" + ) { + options.onOutput?.(responses.status ?? ""); + return; + } + throw new Error(`unexpected command: ${joined}`); + }; +} + +function json(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** A v0 CDK app source file from `__fixtures__/v0-app`, stored as `.txt` so it isn't compiled. */ +/** Every file a release's vended agentcore/cdk app has, by path relative to agentcore/cdk. */ +export function v0App(release: string): Record { + return JSON.parse( + readFileSync(join(import.meta.dir, "__fixtures__", "v0-app", `app-${release}.json`), "utf8"), + ) as Record; +} + +export function v0AppFixture(name: string): string { + return readFileSync(join(import.meta.dir, "__fixtures__", "v0-app", `${name}.txt`), "utf8"); +} + +/** A saved CloudFormation template from `__fixtures__/templates` (account IDs sanitized). */ +export function templateFixture(name: string): CfnTemplate { + return JSON.parse( + readFileSync(join(import.meta.dir, "__fixtures__", "templates", `${name}.json`), "utf8"), + ) as CfnTemplate; +} + +/** A stack as the fake CloudFormation client reports it. */ +export type FakeStack = { + status?: string; + /** A stack policy document; absent means none. */ + policy?: string; + template?: CfnTemplate; + /** Raw TemplateBody, overriding `template`. */ + templateBody?: string; +}; + +/** How a fake stack refactor turns out; by default it's created and executes. */ +export type FakeRefactorOutcome = { + /** The refactor's Status after creation; anything but CREATE_COMPLETE fails it. */ + create?: { status: string; reason?: string }; + /** Its ExecutionStatus once executed; anything but EXECUTE_COMPLETE fails it. */ + execute?: { status: string; reason?: string }; + /** ExecutionStatus for successive reads after execution, before `execute`'s. */ + executeSteps?: string[]; + /** ExecuteStackRefactor throws this; `started` says whether CloudFormation ran it anyway. */ + executeThrows?: { message: string; started: boolean }; + /** ListStackRefactorActions reports these moves instead of the requested mappings. */ + actions?: { from: string; to: string }[]; + /** Physical IDs that change when the refactor executes (a broken refactor), by new logical ID. */ + changedPhysicalIds?: Record; +}; + +export type FakeCloudFormationOptions = { + /** Decides each refactor's outcome; `index` counts refactors created for that stack, from 0. */ + refactors?: (call: { stackName: string; index: number }) => FakeRefactorOutcome; + /** The CDK bootstrap bucket CDKToolkit reports. */ + bootstrapBucket?: string; +}; + +type FakeRefactor = { + stackName: string; + template: CfnTemplate; + mappings: ResourceMapping[]; + outcome: FakeRefactorOutcome; + /** DescribeStackRefactor calls so far; the first reads still in progress. */ + reads: number; + executed: boolean; +}; + +/** Templates uploaded to a {@link fakeTemplateStore}, by URL, so refactors made from a URL can read them. */ +const TEMPLATES_BY_URL = new Map(); + +/** A template store that keeps uploads in memory and records them. */ +export function fakeTemplateStore(): TemplateStore & { puts: string[]; removed: string[] } { + const puts: string[] = []; + const removed: string[] = []; + return { + puts, + removed, + async put(key, body) { + puts.push(key); + const url = `https://bootstrap.example/${key}`; + TEMPLATES_BY_URL.set(url, JSON.parse(body)); + return url; + }, + async remove(key) { + removed.push(key); + }, + }; +} + +/** + * A CloudFormation client factory answering, from `stacks`: DescribeStacks, + * GetStackPolicy, GetTemplate, and ListStackResources (each resource's physical ID + * is `physical-`, kept as refactors move it); and + * stack refactors (created, listed, and executed, moving the stack to the + * refactor's template). Any other command fails the test. Records every request, + * with the region the client was built for; `stacks` is updated as refactors + * execute. + */ +export function fakeCloudFormation( + stacks: Record, + options: FakeCloudFormationOptions = {}, +): { + create: CreateCloudFormationClient; + calls: { command: string; input: Record; region: string }[]; + stacks: Record; +} { + const calls: { command: string; input: Record; region: string }[] = []; + const refactors = new Map(); + const created = new Map(); + /** Each stack's physical IDs by logical ID, fixed the first time they're read. */ + const physicalIds = new Map>(); + const notFound = (id: string) => + Object.assign(new Error(`Stack with id ${id} does not exist`), { name: "ValidationError" }); + const templateOf = (stack: FakeStack): CfnTemplate => + stack.templateBody !== undefined + ? (JSON.parse(stack.templateBody) as CfnTemplate) + : (stack.template ?? {}); + const idsOf = (stackName: string): Map => { + let ids = physicalIds.get(stackName); + if (!ids) { + ids = new Map( + Object.keys(templateOf(stacks[stackName]!).Resources ?? {}).map((id) => [ + id, + `physical-${id}`, + ]), + ); + physicalIds.set(stackName, ids); + } + return ids; + }; + + const refactorCommand = (name: string, input: Record) => { + if (name === CreateStackRefactorCommand.name) { + const [definition] = input.StackDefinitions as { + StackName: string; + TemplateBody?: string; + TemplateURL?: string; + }[]; + const stackName = definition!.StackName; + if (!stacks[stackName]) throw notFound(stackName); + idsOf(stackName); + const index = created.get(stackName) ?? 0; + created.set(stackName, index + 1); + const id = `refactor-${refactors.size + 1}`; + refactors.set(id, { + stackName, + template: JSON.parse( + definition!.TemplateBody ?? + JSON.stringify(TEMPLATES_BY_URL.get(definition!.TemplateURL!)), + ) as CfnTemplate, + mappings: (input.ResourceMappings as ResourceMapping[]) ?? [], + outcome: options.refactors?.({ stackName, index }) ?? {}, + reads: 0, + executed: false, + }); + return { StackRefactorId: id }; + } + const id = String(input.StackRefactorId); + const refactor = refactors.get(id); + if (!refactor) throw new Error(`no stack refactor ${id}`); + if (name === DescribeStackRefactorCommand.name) { + refactor.reads += 1; + const create = refactor.outcome.create ?? { status: "CREATE_COMPLETE" }; + if (!refactor.executed) { + return refactor.reads === 1 + ? { StackRefactorId: id, Status: "CREATE_IN_PROGRESS", ExecutionStatus: "UNAVAILABLE" } + : { + StackRefactorId: id, + Status: create.status, + StatusReason: create.reason, + ExecutionStatus: create.status === "CREATE_COMPLETE" ? "AVAILABLE" : "UNAVAILABLE", + }; + } + const step = refactor.outcome.executeSteps?.shift(); + if (step) return { StackRefactorId: id, Status: "CREATE_COMPLETE", ExecutionStatus: step }; + const execute = refactor.outcome.execute ?? { status: "EXECUTE_COMPLETE" }; + return { + StackRefactorId: id, + Status: "CREATE_COMPLETE", + ExecutionStatus: execute.status, + ExecutionStatusReason: execute.reason, + }; + } + if (name === ListStackRefactorActionsCommand.name) { + const moves = + refactor.outcome.actions ?? + refactor.mappings.map((mapping) => ({ + from: mapping.Source!.LogicalResourceId!, + to: mapping.Destination!.LogicalResourceId!, + })); + // Pages of two, so callers have to follow NextToken. + const start = Number(input.NextToken ?? 0); + return { + StackRefactorActions: moves.slice(start, start + 2).map(({ from, to }) => ({ + Action: "MOVE", + Entity: "RESOURCE", + PhysicalResourceId: idsOf(refactor.stackName).get(from), + Description: "No configuration changes detected.", + ResourceMapping: { + Source: { StackName: refactor.stackName, LogicalResourceId: from }, + Destination: { StackName: refactor.stackName, LogicalResourceId: to }, + }, + })), + ...(start + 2 < moves.length && { NextToken: String(start + 2) }), + }; + } + if (name === ExecuteStackRefactorCommand.name) { + const throws = refactor.outcome.executeThrows; + if (throws && !throws.started) throw new Error(throws.message); + refactor.executed = true; + // A completed refactor leaves the stack on the refactor's template, with each + // physical ID under its new logical ID; a failed one rolls back. + if ((refactor.outcome.execute?.status ?? "EXECUTE_COMPLETE") === "EXECUTE_COMPLETE") { + const ids = idsOf(refactor.stackName); + const moved = new Map(ids); + for (const mapping of refactor.mappings) { + const from = mapping.Source!.LogicalResourceId!; + const to = mapping.Destination!.LogicalResourceId!; + moved.delete(from); + moved.set(to, ids.get(from)!); + } + for (const [logicalId, physicalId] of Object.entries( + refactor.outcome.changedPhysicalIds ?? {}, + )) { + moved.set(logicalId, physicalId); + } + physicalIds.set(refactor.stackName, moved); + stacks[refactor.stackName]!.template = refactor.template; + delete stacks[refactor.stackName]!.templateBody; + } + if (throws) throw new Error(throws.message); + return {}; + } + return undefined; + }; + + const create: CreateCloudFormationClient = (config) => + ({ + send: async (command: { constructor: { name: string }; input: Record }) => { + const name = command.constructor.name; + calls.push({ command: name, input: command.input, region: config.region }); + if (name.includes("StackRefactor")) { + const response = refactorCommand(name, command.input); + if (response !== undefined) return response; + } + const stackName = String(command.input.StackName); + if (name === DescribeStacksCommand.name && stackName === "CDKToolkit") { + if (!options.bootstrapBucket) throw notFound(stackName); + return { + Stacks: [ + { + StackName: stackName, + StackStatus: "UPDATE_COMPLETE", + Outputs: [{ OutputKey: "BucketName", OutputValue: options.bootstrapBucket }], + }, + ], + }; + } + const stack = stacks[stackName]; + if (!stack) throw notFound(stackName); + if (name === DescribeStacksCommand.name) { + return { + Stacks: [{ StackName: stackName, StackStatus: stack.status ?? "UPDATE_COMPLETE" }], + }; + } + if (name === GetStackPolicyCommand.name) return { StackPolicyBody: stack.policy }; + if (name === GetTemplateCommand.name) { + return { TemplateBody: stack.templateBody ?? JSON.stringify(stack.template ?? {}) }; + } + if (name === ListStackResourcesCommand.name) { + const resources = templateOf(stack).Resources ?? {}; + const ids = idsOf(stackName); + return { + StackResourceSummaries: Object.entries(resources).map(([logicalId, resource]) => ({ + LogicalResourceId: logicalId, + PhysicalResourceId: ids.get(logicalId) ?? `physical-${logicalId}`, + ResourceType: resource.Type, + ResourceStatus: "UPDATE_COMPLETE", + })), + }; + } + throw new Error(`unexpected CloudFormation command ${name}`); + }, + }) as unknown as CloudFormationClient; + return { create, calls, stacks }; +} + +/** + * A synthesizer that writes a cloud assembly with one stack per target instead + * of installing and running the CDK app. Records the workspaces it was given. + */ +export function fakeSynth( + stacks: { target: string; stackName: string; template: CfnTemplate }[], +): WorkspaceSynthesizer & { workspaces: string[] } { + const workspaces: string[] = []; + const synthesize = async (workspaceDirectory: string) => { + workspaces.push(workspaceDirectory); + const assembly = join(workspaceDirectory, "agentcore", "cdk", "cdk.out"); + await mkdir(assembly, { recursive: true }); + const artifacts: Record = {}; + for (const stack of stacks) { + const templateFile = `${stack.stackName}.template.json`; + artifacts[stack.stackName] = { + type: "aws:cloudformation:stack", + properties: { + templateFile, + stackName: stack.stackName, + tags: { "agentcore:target-name": stack.target }, + }, + }; + await writeFile(join(assembly, templateFile), JSON.stringify(stack.template)); + } + await writeFile(join(assembly, "manifest.json"), JSON.stringify({ artifacts })); + return assembly; + }; + return Object.assign(synthesize, { workspaces }); +} + +/** Credentials and an account lookup that never leave the process. */ +export const FAKE_AWS_IDENTITY: { + resolveCredentials: CdkCredentialResolver; + resolveAccount: AccountResolver; +} = { + resolveCredentials: async () => async () => ({ + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "secret", + }), + resolveAccount: async () => "998846730471", +}; + +const MAXPROJ_ARN = "arn:aws:bedrock-agentcore:us-east-1:998846730471"; +const maxprojId = (name: string) => `${name}-Abcde12345`; + +/** + * Every resource in the `top-mcpRuntimeTools.json` fixture (project maxproj) deployed to + * `default`, under its v0 name. The migratedFrom contract test with `@aws/agentcore-cdk` + * converts the fixture with it. + */ +export function maxprojDeployedState(): V0DeployedState { + const runtime = (name: string) => ({ + runtimeId: maxprojId(`maxproj_${name}`), + runtimeArn: `${MAXPROJ_ARN}:runtime/${maxprojId(`maxproj_${name}`)}`, + roleArn: "arn:aws:iam::998846730471:role/r", + }); + const byName = (names: string[], make: (name: string) => T) => + Object.fromEntries(names.map((name) => [name, make(name)])); + return { + targets: { + default: { + resources: { + runtimes: byName(["agentA", "agentB", "agentC", "agentD"], runtime), + memories: byName(["mem1", "mem2"], (name) => ({ + memoryId: maxprojId(`maxproj_${name}`), + memoryArn: `${MAXPROJ_ARN}:memory/${maxprojId(`maxproj_${name}`)}`, + })), + evaluators: byName(["evalNum", "evalCat", "evalCode", "evalExt"], (name) => ({ + evaluatorId: maxprojId(`maxproj_${name}`), + evaluatorArn: `${MAXPROJ_ARN}:evaluator/${maxprojId(`maxproj_${name}`)}`, + })), + onlineEvalConfigs: byName(["oe1", "oe2"], (name) => ({ + onlineEvaluationConfigId: maxprojId(`maxproj_${name}`), + onlineEvaluationConfigArn: `${MAXPROJ_ARN}:online-evaluation-config/${maxprojId(`maxproj_${name}`)}`, + })), + policyEngines: byName(["pe1"], (name) => ({ + policyEngineId: maxprojId(`maxproj_${name}`), + policyEngineArn: `${MAXPROJ_ARN}:policy-engine/${maxprojId(`maxproj_${name}`)}`, + })), + configBundles: byName(["cb1"], (name) => ({ + bundleId: maxprojId(`maxproj${name}`), + bundleArn: `${MAXPROJ_ARN}:configuration-bundle/${maxprojId(`maxproj${name}`)}`, + versionId: "64dcd57f-bdaa-4493-834c-a813e53a0a9a", + })), + harnesses: byName(["h1"], (name) => ({ + harnessId: maxprojId(`maxproj_${name}`), + harnessArn: `${MAXPROJ_ARN}:harness/${maxprojId(`maxproj_${name}`)}`, + roleArn: `arn:aws:iam::998846730471:role/maxproj_${name}`, + status: "READY", + provisioner: "cloudformation", + })), + // gw1 sets its own resourceName, gw1-res. + gateways: byName(["gw1", "gw2"], (name) => { + const gatewayId = maxprojId( + name === "gw1" ? "gw1-res" : `maxproj-${name}`, + ).toLowerCase(); + return { gatewayId, gatewayArn: `${MAXPROJ_ARN}:gateway/${gatewayId}` }; + }), + mcp: { + runtimes: { + rtTool: { + runtimeId: maxprojId("maxproj_rtToolRuntime"), + runtimeArn: `${MAXPROJ_ARN}:runtime/${maxprojId("maxproj_rtToolRuntime")}`, + runtimeEndpoint: "https://rt", + }, + }, + lambdas: byName(["lambdaPy", "lambdaTs"], (name) => ({ + functionName: `maxproj-${name}`, + functionArn: `arn:aws:lambda:us-east-1:998846730471:function:maxproj-${name}`, + })), + }, + }, + }, + }, + } as V0DeployedState; +} + +/** + * v1's vended assets, with the CDK app's `@aws/agentcore-cdk` pin at `version`, + * so the planner's library check passes (or fails) as it will once the vended pin + * moves to a release with migratedFrom. + */ +export function vendedAssets(version = MIN_MIGRATED_FROM_CDK_VERSION): AssetSource { + const source = new FsAssetSource(); + return { + list: (assetDir) => source.list(assetDir), + read: async (assetPath) => { + const text = await source.read(assetPath); + if (assetPath !== "cdk/package.json") return text; + const manifest = JSON.parse(text) as { dependencies: Record }; + manifest.dependencies["@aws/agentcore-cdk"] = version; + return `${JSON.stringify(manifest, null, 2)}\n`; + }, + }; +} diff --git a/src/core/project/migrate/pinCollisions.test.ts b/src/core/project/migrate/pinCollisions.test.ts new file mode 100644 index 000000000..572a6d347 --- /dev/null +++ b/src/core/project/migrate/pinCollisions.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import type { PinnedName, PinnedResourceType } from "../../../handlers/project/migrate/types"; +import { pinCollisions } from "./pinCollisions"; + +const pin = (resourceType: PinnedResourceType, name: string, value: string): PinnedName => ({ + resourceType, + name, + value, + targets: ["default"], +}); + +describe("pinCollisions", () => { + test("accepts resources whose v0 names don't line up", () => { + expect( + pinCollisions("P", { runtimes: [{ name: "a" }, { name: "b" }], memories: [{ name: "a" }] }, [ + pin("runtime", "a", "P_a"), + pin("memory", "a", "P_a"), + ]), + ).toEqual([]); + }); + + test("rejects a resource whose v0 name another resource already claims", () => { + // Undeployed `y` gets the v0 name P_y too: a migrated project names everything the v0 way. + const problems = pinCollisions("P", { runtimes: [{ name: "x" }, { name: "y" }] }, [ + pin("runtime", "x", "P_y"), + ]); + expect(problems).toEqual([ + { + file: "agentcore/agentcore.json", + path: "runtimes[y]", + message: + 'runtime "y" and runtime "x" both get the runtime name \'P_y\' (a migrated project ' + + "keeps v0's names, which have no target segment); rename one of them before migrating", + }, + ]); + }); + + test("rejects an agent runtime and a tool runtime that share a runtime name, deployed or not", () => { + const spec = { + runtimes: [{ name: "shared" }], + toolRuntimes: [{ name: "tool", compute: { runtime: { name: "shared" } } }], + }; + expect(pinCollisions("P", spec, []).map((problem) => problem.path)).toEqual([ + "toolRuntimes[tool]", + ]); + expect( + pinCollisions("P", spec, [pin("runtime", "shared", "P_shared")]).map( + (problem) => problem.path, + ), + ).toEqual(["toolRuntimes[tool]"]); + }); + + test("compares ECR repositories case-insensitively", () => { + expect( + pinCollisions( + "P", + { + runtimes: [ + { name: "One", build: "Container" }, + { name: "one", build: "Container" }, + ], + }, + [], + ).map((problem) => problem.path), + ).toEqual(["runtimes[one]"]); + }); + + test("checks evaluator and gateway-target Lambda names together", () => { + expect( + pinCollisions( + "P", + { + evaluators: [{ name: "judge", config: { codeBased: { managed: {} } } }], + agentCoreGateways: [ + { name: "gw", targets: [{ name: "eval-judge", compute: { host: "Lambda" } }] }, + ], + }, + [pin("evaluator-function", "judge", "P-eval-judge")], + ).map((problem) => problem.path), + ).toEqual(["agentCoreGateways[gw].targets[eval-judge]"]); + }); + + test("treats a runtime's and a harness's ECR repositories as one namespace", () => { + expect( + pinCollisions( + "P", + { runtimes: [{ name: "harness-h", build: "Container" }], harnesses: [{ name: "h" }] }, + [], + ).map((problem) => problem.path), + ).toEqual(["harnesses[h]"]); + }); +}); diff --git a/src/core/project/migrate/pinCollisions.ts b/src/core/project/migrate/pinCollisions.ts new file mode 100644 index 000000000..690d0b9d3 --- /dev/null +++ b/src/core/project/migrate/pinCollisions.ts @@ -0,0 +1,187 @@ +import { SPEC_RELATIVE_PATH } from "./convertSpec"; +import type { + MigrationProblem, + PinnedName, + PinnedResourceType, +} from "../../../handlers/project/migrate/types"; +import { describePath } from "./issues"; + +type Json = Record; + +/** One resource that takes an AWS physical name: its deployed name, or the one v0 naming composes. */ +type NameEntry = { + label: string; + path: (string | number)[]; + /** The deployed name the resource keeps, when it is deployed. */ + pinned?: string; + /** The segment the v0 name ends with: ``. */ + suffix: string; + separator: "_" | "-" | "/"; + /** The service lowercases the name (ECR repositories). */ + lowercase?: boolean; +}; + +const list = (spec: Json, key: string) => (spec[key] as Json[] | undefined) ?? []; + +/** The pinned AWS name of a resource, by its pin's resource type and name. */ +type PinnedOf = (resourceType: PinnedResourceType, name: string) => string | undefined; + +/** + * Groups every named resource by the AWS namespace its name lives in, so two + * resources that would claim one AWS name are caught here. The `@aws/agentcore-cdk` + * library doesn't check physical names for uniqueness, so this is the only check + * before the deploy would fail. + */ +function nameGroups(spec: Json, pinnedOf: PinnedOf): Map { + const named = ( + kind: string, + resourceType: PinnedResourceType, + collection: string, + separator: "_" | "-", + ) => + list(spec, collection).map((item, i) => ({ + label: `${kind} "${String(item.name)}"`, + path: [collection, i], + pinned: pinnedOf(resourceType, String(item.name)), + suffix: String(item.name), + separator, + })); + + const runtimes: NameEntry[] = named("runtime", "runtime", "runtimes", "_"); + for (const [i, tool] of list(spec, "toolRuntimes").entries()) { + const runtime = (tool.compute as Json | undefined)?.runtime as Json | undefined; + if (!runtime) continue; + runtimes.push({ + label: `tool runtime "${String(tool.name)}"`, + path: ["toolRuntimes", i], + pinned: pinnedOf("tool-runtime", String(tool.name)), + suffix: String(runtime.name), + separator: "_", + }); + } + const lambdas: NameEntry[] = []; + for (const [i, evaluator] of list(spec, "evaluators").entries()) { + const managed = (evaluator.config as { codeBased?: { managed?: Json } } | undefined)?.codeBased + ?.managed; + if (!managed) continue; + lambdas.push({ + label: `evaluator "${String(evaluator.name)}" Lambda function`, + path: ["evaluators", i], + pinned: pinnedOf("evaluator-function", String(evaluator.name)), + suffix: `eval-${String(evaluator.name)}`, + separator: "-", + }); + } + for (const [g, gateway] of list(spec, "agentCoreGateways").entries()) { + for (const [t, target] of ((gateway.targets as Json[] | undefined) ?? []).entries()) { + const compute = target.compute as Json | undefined; + const runtime = compute?.runtime as Json | undefined; + if (compute?.host === "Lambda") { + lambdas.push({ + label: `gateway target "${String(target.name)}" Lambda function`, + path: ["agentCoreGateways", g, "targets", t], + pinned: pinnedOf( + "gateway-target-function", + `${String(gateway.name)}/${String(target.name)}`, + ), + suffix: String(target.name), + separator: "-", + }); + } else if (runtime) { + runtimes.push({ + label: `gateway target "${String(target.name)}" runtime`, + path: ["agentCoreGateways", g, "targets", t], + pinned: pinnedOf( + "gateway-target-runtime", + `${String(gateway.name)}/${String(target.name)}`, + ), + suffix: String(runtime.name), + separator: "_", + }); + } + } + } + const repositories: NameEntry[] = [ + ...list(spec, "runtimes").flatMap((runtime, i) => + runtime.build === "Container" + ? [ + { + label: `runtime "${String(runtime.name)}" ECR repository`, + path: ["runtimes", i], + pinned: pinnedOf("runtime-repository", String(runtime.name)), + suffix: String(runtime.name), + separator: "/" as const, + lowercase: true, + }, + ] + : [], + ), + ...list(spec, "harnesses").map((harness, i) => ({ + label: `harness "${String(harness.name)}" ECR repository`, + path: ["harnesses", i], + pinned: pinnedOf("harness-repository", String(harness.name)), + suffix: `harness-${String(harness.name)}`, + separator: "/" as const, + lowercase: true, + })), + ]; + const roles: NameEntry[] = list(spec, "harnesses").map((harness, i) => ({ + label: `harness "${String(harness.name)}" execution role`, + path: ["harnesses", i], + pinned: pinnedOf("harness-role", String(harness.name)), + suffix: String(harness.name), + separator: "_" as const, + })); + + return new Map([ + ["runtime", runtimes], + ["memory", named("memory", "memory", "memories", "_")], + ["evaluator", named("evaluator", "evaluator", "evaluators", "_")], + ["online eval config", named("online eval config", "online-eval", "onlineEvalConfigs", "_")], + ["policy engine", named("policy engine", "policy-engine", "policyEngines", "_")], + ["gateway", named("gateway", "gateway", "agentCoreGateways", "-")], + ["harness", named("harness", "harness", "harnesses", "_")], + ["Lambda function", lambdas], + ["ECR repository", repositories], + ["IAM role", roles], + ]); +} + +/** The AWS name a resource gets in a migrated project: its deployed name, or v0's composed one. */ +function v0Name(project: string, entry: NameEntry): string { + if (entry.pinned !== undefined) return entry.pinned; + const name = `${project}${entry.separator}${entry.suffix}`; + return entry.lowercase ? name.toLowerCase() : name; +} + +/** + * Resources that would claim one AWS name in the migrated project. A project + * migrated from v0 (`migratedFrom: "v1"`) names every resource the v0 way, + * `` with no target segment, including resources added + * after the migration, so two resources whose names line up (a runtime and a tool + * runtime with one runtime name, say) collide on every deploy. + */ +export function pinCollisions(project: string, spec: Json, pins: PinnedName[]): MigrationProblem[] { + const byResource = new Map(pins.map((pin) => [`${pin.resourceType} ${pin.name}`, pin.value])); + const pinnedOf: PinnedOf = (resourceType, name) => byResource.get(`${resourceType} ${name}`); + const problems: MigrationProblem[] = []; + for (const [kind, entries] of nameGroups(spec, pinnedOf)) { + const claimedBy = new Map(); + for (const entry of entries) { + const name = v0Name(project, entry); + const other = claimedBy.get(name); + if (other) { + problems.push({ + file: SPEC_RELATIVE_PATH, + path: describePath(spec, entry.path), + message: + `${entry.label} and ${other.label} both get the ${kind} name '${name}' (a migrated ` + + `project keeps v0's names, which have no target segment); rename one of them before migrating`, + }); + } else { + claimedBy.set(name, entry); + } + } + } + return problems; +} diff --git a/src/core/project/migrate/plan.test.ts b/src/core/project/migrate/plan.test.ts new file mode 100644 index 000000000..b74266a57 --- /dev/null +++ b/src/core/project/migrate/plan.test.ts @@ -0,0 +1,585 @@ +import { describe, expect, test } from "bun:test"; +import type { PinnedName } from "../../../handlers/project/migrate/types"; +import { templateFixture } from "./migrate-test-support"; +import { chainedRenames, planStack, stackBlockerCount } from "./plan"; +import { deployImpact } from "./replacements"; +import type { CfnTemplate } from "./templates"; + +const target = { name: "default", account: "123456789012", region: "us-east-1" as const }; +const STACK = "AgentCore-CompatMig-default"; + +const plan = async (deployed: string, v1: string, v1StackName = STACK, pins: PinnedName[] = []) => + planStack({ + target, + stackName: STACK, + v1StackName, + deployed: templateFixture(deployed), + v1: templateFixture(v1), + pins, + }); + +describe("planStack", () => { + test("the live CompatMig stack with pinned names: 10 moves and 0 replacements", async () => { + const { plan: stack, bridge, renames } = await plan("pinned.v0", "pinned.v1"); + expect(stack).toMatchObject({ + target: "default", + account: "123456789012", + region: "us-east-1", + stackName: STACK, + unchanged: 5, + added: [], + replaced: [], + blockers: [], + ready: true, + }); + expect(stack.moves).toContainEqual({ + type: "AWS::BedrockAgentCore::Memory", + from: "ApplicationMemoryMigagentMemoryCA18A787", + to: "ApplicationMemoryMigagentMemoryMemoryA587350B", + }); + expect(stack.moves).toHaveLength(10); + // v1's gateway role has no DefaultPolicy; the deploy deletes v0's. + expect(stack.removed).toEqual([ + { + logicalId: "McpGatewayMiggwRoleDefaultPolicy403FA543", + type: "AWS::IAM::Policy", + severity: "warning", + reason: "the v1 template has no counterpart, so the v1 deploy deletes it", + }, + ]); + expect(renames.size).toBe(10); + expect(Object.keys(bridge!.Resources!)).toContain("ApplicationRuntimeMigagentRuntime93E3CAC2"); + }); + + test("a library without pinned names replaces the config bundle: a blocker", async () => { + const { plan: stack } = await plan("codezip.v0", "codezip.v1-rc3"); + expect(stack.ready).toBe(false); + expect(stack.blockers).toEqual([]); + expect(stack.replaced).toEqual([ + { + logicalId: "ApplicationEvaluatorRefcodeFunctionBedrockAgentCoreEvaluatorInvokeE8AA0D2E", + type: "AWS::Lambda::Permission", + severity: "warning", + reason: + "the v1 deploy replaces it because SourceAccount, SourceArn change; resources that use it are updated to the new one", + }, + { + logicalId: "ApplicationConfigBundleRefcb9FCFB8E4", + type: "AWS::BedrockAgentCore::ConfigurationBundle", + severity: "blocker", + reason: + "the v1 deploy replaces it because BundleName changes; its ID, ARN, and data would be lost", + }, + ]); + }); + + test("container project: AgentCore and ECR repository renames are blockers", async () => { + const { plan: stack } = await plan("container.v0", "container.v1-v1names"); + const bySeverity = (severity: string) => + stack.replaced.filter((change) => change.severity === severity).map((change) => change.type); + expect(bySeverity("warning")).toEqual([]); + expect(bySeverity("blocker")).toEqual([ + "AWS::BedrockAgentCore::Memory", + "AWS::BedrockAgentCore::Evaluator", + "AWS::BedrockAgentCore::PolicyEngine", + "AWS::BedrockAgentCore::Policy", + "AWS::ECR::Repository", + "AWS::BedrockAgentCore::Runtime", + "AWS::BedrockAgentCore::ConfigurationBundle", + "AWS::BedrockAgentCore::OnlineEvaluationConfig", + ]); + // The build trigger can't move, so it's recreated: expected, not a warning. + expect(stack.removed.find((change) => change.type.includes("CustomResource"))).toMatchObject({ + severity: "info", + }); + expect(stack.added).toEqual([ + { + logicalId: "ApplicationRuntimeMyagentContainerImageBuilderContainerBuildTriggerC9AB9871", + type: "AWS::CloudFormation::CustomResource", + }, + ]); + }); + + test("says v0 naming didn't reproduce a kept name when that's why a resource is replaced", async () => { + // container.v1-v1names was synthesized by a library that composes v1 names, as one + // that doesn't honor migratedFrom for a kind of resource would. + const runtime = Object.values(templateFixture("container.v0").Resources ?? {}).find( + (resource) => resource.Type === "AWS::BedrockAgentCore::Runtime", + )?.Properties?.AgentRuntimeName as string; + const { plan: stack } = await plan("container.v0", "container.v1-v1names", STACK, [ + { resourceType: "runtime", name: "myagent", value: runtime, targets: ["default"] }, + ]); + const replaced = stack.replaced.find( + (change) => change.type === "AWS::BedrockAgentCore::Runtime", + ); + expect(replaced).toMatchObject({ severity: "blocker" }); + expect(replaced?.reason).toMatch( + new RegExp( + `^v0 naming doesn't reproduce runtime 'myagent' \\(deployed as '${runtime}', v1 renders '[^']+'\\), so the v1 deploy would replace it\\.`, + ), + ); + // Resources without a recorded name keep the generic reason. + expect( + stack.replaced.find((change) => change.type === "AWS::BedrockAgentCore::Memory")?.reason, + ).toStartWith("the v1 deploy replaces it"); + expect(stack.ready).toBe(false); + }); + + test("a live v0 container project (SrWalkD, deployed by v0 0.31.1) plans Ready", async () => { + // Deployed with its aws:cdk:path metadata: the library's ContainerBuildProject sits at the + // stack root, beside Application and Mcp. + const deployed = templateFixture("container-live.v0"); + expect( + Object.values(deployed.Resources ?? {}).some((resource) => + String((resource.Metadata as Record)["aws:cdk:path"]).includes( + "/ContainerBuildProject/", + ), + ), + ).toBe(true); + const { plan: stack } = await planStack({ + target, + stackName: "AgentCore-SrWalkD-default", + v1StackName: "AgentCore-SrWalkD-default", + deployed, + // Synthesized by migrate's planner with a library that keeps v0 names (migratedFrom). + v1: templateFixture("container-live.v1"), + pins: [], + }); + expect(stack.blockers).toEqual([]); + expect(stack.moves).toHaveLength(14); + expect(stack.replaced).toEqual([]); + expect(stack.ready).toBe(true); + }); + + test("a v1 stack name that differs from the deployed one is a blocker", async () => { + const { plan: stack } = await plan("pinned.v0", "pinned.v1", "AgentCore-Other-default"); + expect(stack.ready).toBe(false); + expect(stack.blockers).toEqual([ + `The v1 app names this target's stack 'AgentCore-Other-default', not '${STACK}', so a v1 deploy would create a new stack instead of updating this one.`, + ]); + }); + + const planOf = (deployed: CfnTemplate, v1: CfnTemplate) => + planStack({ target, stackName: STACK, v1StackName: STACK, deployed, v1, pins: [] }); + + test("nested stacks are a blocker", async () => { + const nested = { Type: "AWS::CloudFormation::Stack", Properties: { TemplateURL: "u" } }; + const { plan: stack } = await planOf( + { Resources: { Child: nested } }, + { Resources: { Child: nested } }, + ); + expect(stack.ready).toBe(false); + expect(stack.blockers).toEqual([ + "The stack has nested stacks (Child), which the v0 CDK app doesn't create and migrate can't plan.", + ]); + }); + + test("resources outside the v0 app's constructs are a blocker", async () => { + const bucket = { + Type: "AWS::S3::Bucket", + Metadata: { "aws:cdk:path": `${STACK}/Uploads/Resource` }, + }; + const { plan: stack } = await planOf( + { Resources: { Uploads: bucket } }, + { Resources: { Uploads: bucket } }, + ); + expect(stack.ready).toBe(false); + expect(stack.blockers[0]).toStartWith( + `The deployed stack has resources the v0 CDK app doesn't create: Uploads (AWS::S3::Bucket, ${STACK}/Uploads/Resource).`, + ); + }); + + test("chained or swapped renames are a blocker", async () => { + expect( + chainedRenames( + new Map([ + ["A", "B"], + ["B", "A"], + ["C", "D"], + ]), + ), + ).toEqual(["A → B", "B → A"]); + // Two named runtimes whose v1 logical IDs are each other's deployed ones. + const runtime = (name: string) => ({ + Type: "AWS::BedrockAgentCore::Runtime", + Properties: { AgentRuntimeName: name }, + }); + const { plan: stack } = await planOf( + { Resources: { X: runtime("p_a"), Y: runtime("p_b") } }, + { Resources: { Y: runtime("p_a"), X: runtime("p_b") } }, + ); + expect(stack.moves).toHaveLength(2); + expect(stack.ready).toBe(false); + expect(stack.blockers).toEqual([ + "The refactor would rename into logical IDs it also renames away from (X → Y, Y → X). Chained and swapped renames haven't been verified with CloudFormation stack refactoring, so migrate doesn't attempt them.", + ]); + }); + + test("a gateway ProtocolType change is a blocker; keeping v0's (none) is clean", async () => { + const gateway = (extra: Record) => ({ + Type: "AWS::BedrockAgentCore::Gateway", + Properties: { Name: "P-gw", AuthorizerType: "NONE", ...extra }, + }); + const clean = await planOf( + { Resources: { Gw: gateway({}) } }, + { Resources: { Gw: gateway({}) } }, + ); + expect(clean.plan).toMatchObject({ ready: true, updated: [] }); + + const changed = await planOf( + { Resources: { Gw: gateway({}) } }, + { Resources: { Gw: gateway({ ProtocolType: "MCP" }) } }, + ); + expect(changed.plan.ready).toBe(false); + expect(changed.plan.updated).toMatchObject([{ logicalId: "Gw", severity: "blocker" }]); + expect(stackBlockerCount(changed.plan)).toBe(1); + }); + + test("a bridge that can't be built is a blocker", async () => { + // v1 moves the role to an ID the deployed template keeps for a topic v1 drops. + const deployed: CfnTemplate = { + Resources: { + Role: { Type: "AWS::IAM::Role", Properties: { RoleName: "r" } }, + Taken: { Type: "AWS::SNS::Topic", Properties: {} }, + }, + }; + const v1: CfnTemplate = { + Resources: { Taken: { Type: "AWS::IAM::Role", Properties: { RoleName: "r" } } }, + }; + const { plan: stack, bridge } = await planStack({ + target, + stackName: STACK, + v1StackName: STACK, + deployed, + v1, + pins: [], + }); + expect(bridge).toBeUndefined(); + expect(stack.ready).toBe(false); + expect(stack.blockers).toEqual([ + "Couldn't build the refactor template: Can't rename 'Role' to 'Taken': the deployed AWS::SNS::Topic 'Taken' keeps that logical ID.", + ]); + }); +}); + +describe("deployImpact", () => { + const resource = (Type: string, Properties: Record = {}, extra = {}) => ({ + Type, + Properties, + ...extra, + }); + + test("classifies deletions: AgentCore, stateful, and unknown types block; retained is info; stateless warns", async () => { + const impact = await deployImpact( + { + Resources: { + Memory: resource("AWS::BedrockAgentCore::Memory", { Name: "m", EventExpiryDuration: 30 }), + Logs: resource("AWS::Logs::LogGroup", {}, { DeletionPolicy: "Retain" }), + Topic: resource("AWS::SNS::Topic"), + Role: resource("AWS::IAM::Role", { AssumeRolePolicyDocument: {} }), + Keep: resource("AWS::SNS::Topic"), + }, + }, + { Resources: { Keep: resource("AWS::SNS::Topic"), New: resource("AWS::SNS::Topic") } }, + ); + expect(impact.added).toEqual([{ logicalId: "New", type: "AWS::SNS::Topic" }]); + expect(impact.removed.map((change) => [change.logicalId, change.severity])).toEqual([ + ["Memory", "blocker"], + ["Logs", "info"], + ["Topic", "blocker"], + ["Role", "warning"], + ]); + expect(impact.removed[2]?.reason).toContain("migrate can't tell whether this type holds data"); + expect(impact.replaced).toEqual([]); + }); + + test("deleting an un-retained stateful resource blocks; retaining it doesn't", async () => { + const stateful = { + Bucket: resource("AWS::S3::Bucket"), + Table: resource("AWS::DynamoDB::Table", { KeySchema: [] }), + Key: resource("AWS::KMS::Key", { KeyPolicy: {} }), + Logs: resource("AWS::Logs::LogGroup"), + Secret: resource("AWS::SecretsManager::Secret"), + Db: resource("AWS::RDS::DBInstance"), + Fs: resource("AWS::EFS::FileSystem"), + // Snapshot keeps a backup, but the resource itself is deleted. + Snap: resource("AWS::RDS::DBCluster", {}, { DeletionPolicy: "Snapshot" }), + }; + const impact = await deployImpact({ Resources: stateful }, { Resources: {} }); + expect(impact.removed.every((change) => change.severity === "blocker")).toBe(true); + expect(impact.removed).toHaveLength(Object.keys(stateful).length); + const reason = impact.removed.find((change) => change.logicalId === "Bucket")?.reason; + expect(reason).toStartWith( + "the v1 template has no counterpart, so the v1 deploy would delete it and its data. To keep it: ", + ); + // The order that works: retention is read from the deployed template. + expect(reason).toContain( + "1. set its removal policy to retain in agentcore/cdk/lib/cdk-stack.ts", + ); + expect(reason).toContain( + "2. run agentcore deploy (it deploys a v0 project with the v0 CLI), 3. undo the change to cdk-stack.ts, " + + "4. run agentcore migrate again", + ); + expect(impact.removed.find((change) => change.logicalId === "Snap")?.reason).toContain( + "(DeletionPolicy: Snapshot keeps only a final snapshot)", + ); + + const retained = await deployImpact( + { Resources: { Bucket: resource("AWS::S3::Bucket", {}, { DeletionPolicy: "Retain" }) } }, + { Resources: {} }, + ); + expect(retained.removed).toMatchObject([{ logicalId: "Bucket", severity: "info" }]); + }); + + test("replacing a stateful resource blocks, saying what its UpdateReplacePolicy does", async () => { + const table = (name: string, policy?: string) => + resource( + "AWS::DynamoDB::Table", + { TableName: name, KeySchema: [] }, + policy === undefined ? {} : { UpdateReplacePolicy: policy }, + ); + const reasonFor = async (policy?: string) => + ( + await deployImpact( + { Resources: { T: table("a", policy) } }, + { Resources: { T: table("b") } }, + ) + ).replaced[0]; + expect(await reasonFor()).toMatchObject({ + severity: "blocker", + reason: + "the v1 deploy replaces it because TableName changes; the new one starts empty, and the deployed one is deleted with its data", + }); + expect((await reasonFor("Retain"))?.reason).toEndWith( + "the new one starts empty, and the deployed one is left in the account outside the stack (UpdateReplacePolicy: Retain), unused", + ); + expect((await reasonFor("Snapshot"))?.reason).toEndWith( + "the new one starts empty, and the deployed one is deleted after a final snapshot (UpdateReplacePolicy: Snapshot)", + ); + }); + + test("replacing a resource that keeps its custom name blocks: CloudFormation can't do it", async () => { + const impact = await deployImpact( + { + Resources: { + Fn: resource("AWS::Lambda::Function", { + FunctionName: "P-eval-a", + PackageType: "Zip", + Role: "r", + }), + }, + }, + { + Resources: { + Fn: resource("AWS::Lambda::Function", { + FunctionName: "P-eval-a", + PackageType: "Image", + Role: "r", + }), + }, + }, + ); + expect(impact.replaced).toMatchObject([ + { + logicalId: "Fn", + severity: "blocker", + reason: + "the v1 deploy replaces it because PackageType changes; CloudFormation can't replace a resource that keeps its custom name ('P-eval-a'), so the deploy would fail", + }, + ]); + }); + + test("a deleted resource whose name a new one takes blocks: the create would fail", async () => { + const impact = await deployImpact( + { + Resources: { + Old: resource("AWS::Lambda::Function", { FunctionName: "P-tool", Role: "r" }), + }, + }, + { + Resources: { + New: resource("AWS::Lambda::Function", { FunctionName: "P-tool", Role: "r" }), + }, + }, + ); + expect(impact.removed).toMatchObject([ + { + logicalId: "Old", + severity: "blocker", + reason: + "the v1 deploy creates New with the same name 'P-tool' while this one still holds it, so the create fails; the mapping couldn't pair the two", + }, + ]); + }); + + test("lists in-place updates of AgentCore resources, blocking ones the service rejects", async () => { + const gateway = (properties: Record) => + resource("AWS::BedrockAgentCore::Gateway", { + Name: "P-gw", + AuthorizerType: "NONE", + RoleArn: "arn:aws:iam::1:role/r", + ...properties, + }); + const memory = (description: string) => + resource("AWS::BedrockAgentCore::Memory", { Name: "m", Description: description }); + + // A v0 protocolType "None" gateway has no ProtocolType; v1 keeping it that way is clean. + const kept = await deployImpact( + { Resources: { Gw: gateway({}) } }, + { Resources: { Gw: gateway({}) } }, + ); + expect(kept.updated).toEqual([]); + + const changed = await deployImpact( + { Resources: { Gw: gateway({}), Mem: memory("a") } }, + { Resources: { Gw: gateway({ ProtocolType: "MCP" }), Mem: memory("b") } }, + ); + expect(changed.replaced).toEqual([]); + expect(changed.updated).toEqual([ + { + logicalId: "Gw", + type: "AWS::BedrockAgentCore::Gateway", + severity: "blocker", + reason: + 'the v1 deploy changes ProtocolType from (unset) to "MCP", which the service doesn\'t allow on an existing resource, so the deploy would fail and roll back. Set the matching field in agentcore.json so v1 keeps the deployed value (for a gateway: protocolType "None" when ProtocolType is unset, and authorizerType to the deployed AuthorizerType)', + }, + { + logicalId: "Mem", + type: "AWS::BedrockAgentCore::Memory", + severity: "warning", + reason: "the v1 deploy updates it in place: Description changes", + }, + ]); + }); + + test("names the properties that force a replacement, and ignores in-place updates", async () => { + const impact = await deployImpact( + { + Resources: { + Runtime: resource("AWS::BedrockAgentCore::Runtime", { + AgentRuntimeName: "a", + Description: "x", + }), + Repo: resource("AWS::ECR::Repository", { RepositoryName: "p/a" }), + }, + }, + { + Resources: { + Runtime: resource("AWS::BedrockAgentCore::Runtime", { + AgentRuntimeName: "a", + Description: "y", + }), + Repo: resource("AWS::ECR::Repository", { RepositoryName: "p/default/a" }), + }, + }, + ); + expect(impact.replaced).toEqual([ + { + logicalId: "Repo", + type: "AWS::ECR::Repository", + severity: "blocker", + reason: + "the v1 deploy replaces it because RepositoryName changes; its images and repository URI would be lost", + }, + ]); + }); + + test("follows a replacement to what reads the replaced resource's ID in a create-only property", async () => { + const before: CfnTemplate = { + Resources: { + Fn: resource("AWS::Lambda::Function", { FunctionName: "eval-a", Role: "r" }), + Invoke: resource("AWS::Lambda::Permission", { + Action: "lambda:InvokeFunction", + Principal: "bedrock-agentcore.amazonaws.com", + FunctionName: { "Fn::GetAtt": ["Fn", "Arn"] }, + }), + Logs: resource("AWS::Logs::LogGroup", { LogGroupName: { "Fn::Sub": "/aws/lambda/${Fn}" } }), + // Reads the function's name in a property that updates in place. + Topic: resource("AWS::SNS::Topic", { DisplayName: { Ref: "Fn" } }), + }, + }; + const after = structuredClone(before); + after.Resources!.Fn!.Properties!.FunctionName = "eval-b"; + const impact = await deployImpact(before, after); + expect(impact.replaced.map((change) => change.logicalId)).toEqual(["Fn", "Invoke", "Logs"]); + // The diff itself follows Fn::GetAtt to a replaced resource; the Fn::Sub + // reference is found by probing the log group with Fn's ID changed. + expect(impact.replaced[1]?.reason).toBe( + "the v1 deploy replaces it because FunctionName changes; resources that use it are updated to the new one", + ); + // A log group holds data, so losing it to the cascade blocks. + expect(impact.replaced[2]).toMatchObject({ + severity: "blocker", + reason: + "the v1 deploy replaces it because it references Fn, which is replaced; the new one starts empty, and the deployed one is deleted with its data", + }); + }); + + test("a logical ID that changes type always blocks, even for a stateless type", async () => { + const impact = await deployImpact( + { Resources: { X: resource("AWS::IAM::Policy", { PolicyDocument: {}, PolicyName: "p" }) } }, + { Resources: { X: resource("AWS::IAM::Role", { AssumeRolePolicyDocument: {} }) } }, + ); + expect(impact.removed).toEqual([ + { + logicalId: "X", + type: "AWS::IAM::Policy", + severity: "blocker", + reason: + "the v1 template declares a AWS::IAM::Role under this logical ID; CloudFormation can't change a resource's type under one logical ID, so the deploy would fail", + }, + ]); + }); + + test("a logical ID v1 reuses for another type deletes the deployed resource", async () => { + const impact = await deployImpact( + { Resources: { X: resource("AWS::BedrockAgentCore::Runtime", { AgentRuntimeName: "a" }) } }, + { Resources: { X: resource("AWS::IAM::Role", { AssumeRolePolicyDocument: {} }) } }, + ); + expect(impact.removed).toMatchObject([ + { logicalId: "X", type: "AWS::BedrockAgentCore::Runtime", severity: "blocker" }, + ]); + expect(impact.added).toEqual([{ logicalId: "X", type: "AWS::IAM::Role" }]); + expect(impact.replaced).toEqual([]); + }); + + test("a retained AgentCore resource, a harness role, and an unknown custom resource", async () => { + const harness = (role: string) => + resource("AWS::BedrockAgentCore::Harness", { + HarnessName: "h", + ExecutionRoleArn: { "Fn::GetAtt": [role, "Arn"] }, + }); + const impact = await deployImpact( + { + Resources: { + Memory: resource( + "AWS::BedrockAgentCore::Memory", + { Name: "m" }, + { DeletionPolicy: "Retain" }, + ), + Role: resource("AWS::IAM::Role", { RoleName: "P_h", AssumeRolePolicyDocument: {} }), + Harness: harness("Role"), + Hook: resource("Custom::Something", { ServiceToken: "arn" }), + }, + }, + { + Resources: { + Role: resource("AWS::IAM::Role", { + RoleName: "P_default_h", + AssumeRolePolicyDocument: {}, + }), + Harness: harness("Role"), + }, + }, + ); + expect(impact.removed.map((change) => [change.logicalId, change.severity])).toEqual([ + ["Memory", "blocker"], + ["Hook", "warning"], + ]); + expect(impact.replaced.find((change) => change.logicalId === "Role")).toMatchObject({ + severity: "blocker", + reason: + "the v1 deploy replaces it because RoleName changes; the harness's role would get a new name and ARN", + }); + }); +}); diff --git a/src/core/project/migrate/plan.ts b/src/core/project/migrate/plan.ts new file mode 100644 index 000000000..34d7fc971 --- /dev/null +++ b/src/core/project/migrate/plan.ts @@ -0,0 +1,196 @@ +import { + stackBlockerCount, + type PinnedName, + type PinnedResourceType, + type StackMigrationPlan, + type StackResourceChange, +} from "../../../handlers/project/migrate/types"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import { buildBridgeTemplate, verifyBridge } from "./bridge"; +import { mapLogicalIds, NAME_PROPERTIES, renamesOf, type LogicalIdMapping } from "./mapping"; +import { deployImpact } from "./replacements"; +import { CDK_METADATA_TYPE, resourcesOf, type CfnTemplate } from "./templates"; +import { resourcesOutsideV0App } from "./v0CdkApp"; + +export type StackPlanInput = { + target: AwsDeploymentTarget; + /** The deployed stack's name. */ + stackName: string; + /** The template the stack was last deployed with. */ + deployed: CfnTemplate; + /** The v1 app's template for the same target. */ + v1: CfnTemplate; + /** The stack name the v1 app synthesized for the target. */ + v1StackName: string; + /** The deployed names this target's resources keep, which the v1 synth must render. */ + pins: PinnedName[]; +}; + +/** A stack's plan, with what applying it needs. */ +export type PlannedStack = { + plan: StackMigrationPlan; + mapping: LogicalIdMapping; + /** Logical ID renames the refactor applies. */ + renames: Map; + /** The template the refactor moves the stack to; absent when it couldn't be built. */ + bridge?: CfnTemplate; +}; + +/** + * Plans how one stack moves from its deployed v0 template to the v1 template: + * which resources a stack refactor moves to new logical IDs, the bridge template + * the refactor applies (verified to change nothing but logical IDs), and what + * the first v1 deploy after it creates, deletes, and replaces. Reads nothing + * from AWS; the templates are passed in. + */ +export async function planStack(input: StackPlanInput): Promise { + const { target, stackName, deployed, v1 } = input; + const mapping = mapLogicalIds(deployed, v1); + const renames = renamesOf(mapping); + const plan: StackMigrationPlan = { + target: target.name, + account: target.account, + region: target.region, + stackName, + moves: mapping.matches + .filter((match) => renames.has(match.from)) + .map(({ type, from, to }) => ({ type, from, to })), + unchanged: mapping.matches.filter( + (match) => match.from === match.to && match.type !== CDK_METADATA_TYPE, + ).length, + added: [], + removed: [], + replaced: [], + updated: [], + blockers: [], + ready: false, + }; + + if (input.v1StackName !== stackName) { + plan.blockers.push( + `The v1 app names this target's stack '${input.v1StackName}', not '${stackName}', so a v1 ` + + "deploy would create a new stack instead of updating this one.", + ); + } + + const nested = Object.entries(resourcesOf(deployed)) + .filter(([, resource]) => resource.Type === NESTED_STACK_TYPE) + .map(([id]) => id); + if (nested.length > 0) { + plan.blockers.push( + `The stack has nested stacks (${nested.join(", ")}), which the v0 CDK app doesn't create ` + + "and migrate can't plan.", + ); + } + const outside = resourcesOutsideV0App(deployed); + if (outside.length > 0) { + plan.blockers.push( + `The deployed stack has resources the v0 CDK app doesn't create: ${outside.join("; ")}. ` + + "Migrating replaces agentcore/cdk with v1's app, so the first v1 deploy would delete " + + "them. Move them out of the stack (or remove them) with the v0 CLI before migrating.", + ); + } + const chained = chainedRenames(renames); + if (chained.length > 0) { + plan.blockers.push( + `The refactor would rename into logical IDs it also renames away from (${chained.join(", ")}). ` + + "Chained and swapped renames haven't been verified with CloudFormation stack refactoring, " + + "so migrate doesn't attempt them.", + ); + } + + let bridge: CfnTemplate | undefined; + try { + bridge = buildBridgeTemplate(deployed, renames); + } catch (error) { + plan.blockers.push(`Couldn't build the refactor template: ${(error as Error).message}`); + } + if (bridge) { + plan.blockers.push( + ...verifyBridge(deployed, bridge, renames).map( + (problem) => `The refactor template would change more than logical IDs: ${problem}`, + ), + ); + const impact = await deployImpact(bridge, v1); + const notKept = nameNotKept(bridge, v1, input.pins); + plan.added = impact.added; + plan.removed = impact.removed.map(notKept); + plan.replaced = impact.replaced.map(notKept); + plan.updated = impact.updated; + } + + plan.ready = bridge !== undefined && stackBlockerCount(plan) === 0; + return { plan, mapping, renames, ...(bridge && { bridge }) }; +} + +/** How a kept name's resource kind reads in a message. */ +const KIND_LABELS: Record = { + runtime: "runtime", + "runtime-repository": "runtime ECR repository", + memory: "memory", + evaluator: "evaluator", + "evaluator-function": "evaluator Lambda function", + "online-eval": "online eval config", + "policy-engine": "policy engine", + "config-bundle": "config bundle", + harness: "harness", + "harness-role": "harness execution role", + "harness-repository": "harness ECR repository", + gateway: "gateway", + "gateway-target-function": "gateway target Lambda function", + "gateway-target-runtime": "gateway target runtime", + "tool-runtime": "tool runtime", +}; + +/** + * Rewrites the reason a resource is replaced or deleted when the cause is that the + * v1 template doesn't render the name the resource is deployed under: it has a + * kept name, and no v1 resource of its type has that name. `migratedFrom: "v1"` + * should make the CDK library compose v0's names, so this means v0 naming doesn't + * reproduce that name. + */ +function nameNotKept( + bridge: CfnTemplate, + v1: CfnTemplate, + pins: PinnedName[], +): (change: StackResourceChange) => StackResourceChange { + const pinByName = new Map(pins.map((pin) => [pin.value, pin])); + const deployedResources = resourcesOf(bridge); + const v1Resources = resourcesOf(v1); + const v1Names = new Set( + Object.values(v1Resources).map((resource) => + nameKey(resource.Type, resource.Properties?.[NAME_PROPERTIES[resource.Type] ?? ""]), + ), + ); + return (change) => { + if (change.severity !== "blocker") return change; + const resource = deployedResources[change.logicalId]; + const name = resource?.Properties?.[NAME_PROPERTIES[change.type] ?? ""]; + const pin = typeof name === "string" ? pinByName.get(name) : undefined; + if (pin === undefined || v1Names.has(nameKey(change.type, name))) return change; + const rendered = + v1Resources[change.logicalId]?.Properties?.[NAME_PROPERTIES[change.type] ?? ""]; + const renders = + typeof rendered === "string" + ? `v1 renders '${rendered}'` + : "v1 renders no resource by that name"; + return { + ...change, + reason: + `v0 naming doesn't reproduce ${KIND_LABELS[pin.resourceType]} '${pin.name}' (deployed as ` + + `'${pin.value}', ${renders}), so the v1 deploy would replace it. Make sure the project's ` + + '@aws/agentcore-cdk supports migratedFrom: "v1" for this kind of resource.', + }; + }; +} + +const nameKey = (type: string, name: unknown) => `${type} ${String(name)}`; + +const NESTED_STACK_TYPE = "AWS::CloudFormation::Stack"; + +/** Renames whose destination is also renamed away from: `A → B` with `B → C`, or a swap. */ +export function chainedRenames(renames: ReadonlyMap): string[] { + return [...renames].filter(([, to]) => renames.has(to)).map(([from, to]) => `${from} → ${to}`); +} + +export { stackBlockerCount }; diff --git a/src/core/project/migrate/preflight.test.ts b/src/core/project/migrate/preflight.test.ts new file mode 100644 index 000000000..3391f811b --- /dev/null +++ b/src/core/project/migrate/preflight.test.ts @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { V0AgentCoreProjectSpecSchema } from "../../../projectSchemas/v0"; +import { v0Fixture } from "../legacy/fixtures-test-support"; +import { F1_SPEC, fakeGit, writeV0Project, type V0ProjectFiles } from "./migrate-test-support"; +import { + checkCdkPin, + checkDeployedState, + checkGit, + checkProjectFormat, + checkSupportedResources, + OLD_CDK_MESSAGE, + PRESERVE_RESOURCES_DOCS_URL, + preflight, +} from "./preflight"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function project(options: V0ProjectFiles = {}): Promise { + const { root, cleanup } = await writeV0Project(options); + cleanups.push(cleanup); + return root; +} + +describe("checkProjectFormat", () => { + test("accepts a v0 project and returns its parsed spec", async () => { + const result = await checkProjectFormat(await project()); + expect(result.check).toEqual({ + name: "v0 project", + ok: true, + message: "'CompatA' is a v0 project", + }); + expect(result.v0Spec?.name).toBe("CompatA"); + }); + + test("says a v1 project has nothing to migrate", async () => { + const result = await checkProjectFormat( + await project({ spec: { name: "Already", version: 2, managedBy: "CDK" } }), + ); + expect(result.check.ok).toBe(false); + expect(result.check.message).toContain("already a v1 project"); + expect(result.v0Spec).toBeUndefined(); + }); + + test("rejects an unknown version", async () => { + const result = await checkProjectFormat(await project({ spec: { ...F1_SPEC, version: 7 } })); + expect(result.check.message).toContain("has version 7; only v0 projects (version 1)"); + }); + + test("reports a spec the v0 schema rejects", async () => { + const result = await checkProjectFormat(await project({ spec: { ...F1_SPEC, name: "1bad" } })); + expect(result.check.ok).toBe(false); + expect(result.check.message).toContain("isn't a valid v0 project"); + }); + + test("reports unreadable JSON", async () => { + const root = await project(); + await writeFile(join(root, "agentcore", "agentcore.json"), "{ not json"); + const result = await checkProjectFormat(root); + expect(result.check.message).toStartWith("Couldn't read agentcore/agentcore.json"); + }); +}); + +describe("checkGit", () => { + // The gate itself is tested in gitGate.test.ts; this covers reporting it as a check. + test("passes a clean working tree", async () => { + expect(await checkGit("/p", fakeGit({ status: "" }))).toEqual({ + name: "git", + ok: true, + message: "working tree is clean", + }); + }); + + test("reports each gate failure as a failed check with the gate's message", async () => { + const outside = await checkGit("/p", fakeGit({ insideWorkTree: false })); + expect(outside.ok).toBe(false); + expect(outside.message).toContain("Run `git init` and commit your project first."); + + const dirty = await checkGit("/p", fakeGit({ status: " M agentcore/agentcore.json\n" })); + expect(dirty.ok).toBe(false); + expect(dirty.message).toContain("Uncommitted changes:\n M agentcore/agentcore.json"); + + const untracked = await checkGit("/p", fakeGit({ specTracked: false })); + expect(untracked.message).toContain("agentcore/agentcore.json isn't committed"); + + const missing = await checkGit("/p", fakeGit({ missing: true })); + expect(missing.message).toContain("git isn't installed or isn't on PATH"); + }); + + test("asks git about the project directory", async () => { + const commands: string[][] = []; + const git = fakeGit({}); + await checkGit("/p", async (command, options) => { + commands.push(command); + await git(command, options); + }); + expect(commands.every((command) => command.slice(0, 3).join(" ") === "git -C /p")).toBe(true); + }); +}); + +describe("checkCdkPin", () => { + test.each(["0.1.0-alpha.45", "0.1.0-alpha.54", "0.1.0-alpha.100"])("accepts %s", async (pin) => { + expect((await checkCdkPin(await project({ cdkPin: pin }))).ok).toBe(true); + }); + + test.each(["0.1.0-alpha.44", "0.1.0-alpha.9", "^0.1.0-alpha.54", "1.0.0"])( + "rejects %s with the update guidance", + async (pin) => { + expect(await checkCdkPin(await project({ cdkPin: pin }))).toEqual({ + name: "cdk dependencies", + ok: false, + message: OLD_CDK_MESSAGE, + }); + }, + ); + + test("reports a missing CDK app manifest", async () => { + const check = await checkCdkPin(await project({ cdkPin: null })); + expect(check.ok).toBe(false); + expect(check.message).toStartWith("Couldn't read agentcore/cdk/package.json"); + }); +}); + +describe("checkSupportedResources", () => { + test("passes a project without unsupported resources", () => { + expect(checkSupportedResources(V0AgentCoreProjectSpecSchema.parse(F1_SPEC))).toEqual({ + name: "supported resources", + ok: true, + message: "no knowledge bases, datasets, capacity providers, or online insights", + }); + }); + + test("blocks online insights, which v1 removed", () => { + const check = checkSupportedResources(v0Fixture("baseline.json")); + expect(check.ok).toBe(false); + expect(check.message).toContain("online insights (oe2)"); + }); + + test("blocks knowledge bases, naming them", () => { + const check = checkSupportedResources(v0Fixture("baseline.json")); + expect(check.ok).toBe(false); + expect(check.message).toMatch( + /^This project uses knowledge bases \(.+\), which v1 doesn't support yet/, + ); + expect(check.message).toContain("compatibility mode"); + }); + + test("gives a keep-the-data path and a delete path, linking the preserve guide", () => { + const message = checkSupportedResources(v0Fixture("baseline.json")).message; + expect(message).toContain(" To keep them and their data in AWS:\n"); + expect(message).toContain( + " 1. In agentcore/cdk/lib/cdk-stack.ts, set DeletionPolicy Retain on them", + ); + expect(message).toContain( + " 2. Run `agentcore deploy` (it deploys a v0 project with the v0 CLI).\n", + ); + expect(message).toContain("They leave the stack but stay in AWS.\n"); + expect(message).toContain( + " 4. Undo the change to cdk-stack.ts, commit, and run `agentcore migrate --dry-run`.\n", + ); + expect(message).toContain("This deletes them from AWS.\n"); + expect(message).toEndWith(`See ${PRESERVE_RESOURCES_DOCS_URL}`); + }); + + test("tells online insights alone to just be removed, with no keep path", () => { + const spec = v0Fixture("baseline.json"); + const insightsOnly = { + ...spec, + knowledgeBases: [], + datasets: [], + capacityProviders: [], + runtimes: spec.runtimes.map( + ({ capacityProviderConfiguration: _cp, filesystemConfigurations: _fs, ...runtime }) => + runtime, + ), + } as typeof spec; + const check = checkSupportedResources(insightsOnly); + expect(check.ok).toBe(false); + expect(check.message).toStartWith("This project runs online insights (oe2)"); + expect(check.message).toContain("insights hold no data to keep."); + expect(check.message).not.toContain("To keep them"); + }); + + test("blocks datasets", () => { + expect(checkSupportedResources(v0Fixture("top-datasets-nonempty.json")).message).toContain( + "datasets (", + ); + }); + + test("blocks capacity providers and the runtimes that use them", () => { + const check = checkSupportedResources(v0Fixture("runtime-capacityProviderArn-only.json")); + expect(check.ok).toBe(false); + expect(check.message).toContain("runtimes on capacity providers ("); + expect(check.message).toContain(" First move runtimes off their capacity providers:"); + expect(checkSupportedResources(v0Fixture("runtime-capacityProvider.json")).message).toContain( + "capacity providers (", + ); + }); +}); + +describe("checkDeployedState", () => { + test("treats a never-deployed project as having nothing to keep", async () => { + const result = await checkDeployedState(await project({ deployedState: null })); + expect(result.check).toEqual({ + name: "deployed state", + ok: true, + message: "never deployed; no names to keep", + }); + expect(result.state).toEqual({ targets: {} }); + }); + + test("names the deployed targets", async () => { + const result = await checkDeployedState(await project()); + expect(result.check.message).toBe("deployed to 'default'"); + expect(Object.keys(result.state.targets)).toEqual(["default"]); + }); + + test("fails on state v0 couldn't have written", async () => { + const result = await checkDeployedState( + await project({ + deployedState: { targets: { default: { resources: { runtimes: { a: {} } } } } }, + }), + ); + expect(result.check.ok).toBe(false); + expect(result.check.message).toContain("isn't valid v0 deployed state"); + }); +}); + +describe("preflight", () => { + test("runs every check for a v0 project", async () => { + const result = await preflight( + await project({ cdkPin: "0.1.0-alpha.40" }), + fakeGit({ status: "?? x" }), + ); + expect(result.checks.map((check) => [check.name, check.ok])).toEqual([ + ["v0 project", true], + ["git", false], + ["cdk dependencies", false], + ["cdk app", true], + ["supported resources", true], + ["deployed state", true], + ]); + expect(result.context?.v0Spec.name).toBe("CompatA"); + }); + + test("stops after the format check when the project isn't v0", async () => { + const result = await preflight( + await project({ spec: { name: "Already", version: 2 } }), + fakeGit({ status: "" }), + ); + expect(result.checks.map((check) => check.name)).toEqual(["v0 project"]); + expect(result.context).toBeUndefined(); + }); +}); diff --git a/src/core/project/migrate/preflight.ts b/src/core/project/migrate/preflight.ts new file mode 100644 index 000000000..8f3f34fae --- /dev/null +++ b/src/core/project/migrate/preflight.ts @@ -0,0 +1,283 @@ +import { SPEC_RELATIVE_PATH } from "./convertSpec"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import type { ProcessRunner } from "../../../io"; +import type { MigrationCheck } from "../../../handlers/project/migrate/types"; +import { + V0AgentCoreProjectSpecSchema, + V0DeployedStateSchema, + type V0DeployedState, + type V0ProjectSpec, +} from "../../../projectSchemas/v0"; +import { projectFormat } from "../legacy/detect"; +import { DEPLOYED_STATE_RELATIVE_PATH } from "../backends/cdk/deployedState"; +import { checkGitGate } from "./gitGate"; +import { checkCdkApp } from "./v0CdkApp"; + +/** The CDK app's manifest, relative to the project root. */ +export const CDK_PACKAGE_RELATIVE_PATH = "agentcore/cdk/package.json"; + +/** The first `@aws/agentcore-cdk` release the v0.24.2 CLI vended; older apps can't be migrated. */ +const MIN_CDK_ALPHA = 45; +const CDK_PIN_PATTERN = /^0\.1\.0-alpha\.(\d+)$/; + +export const OLD_CDK_MESSAGE = + "This project's CDK dependencies are older than v0.24.2. Run `npx @aws/agentcore@0 deploy` " + + "once to update them, then try again."; + +/** What later steps need from a project that passed the format check. */ +export type PreflightContext = { + v0Spec: V0ProjectSpec; + /** Empty when the project was never deployed. */ + deployedState: V0DeployedState; +}; + +export type PreflightResult = { + checks: MigrationCheck[]; + /** Absent when the project could not be read as a v0 project; nothing else can run. */ + context?: PreflightContext; +}; + +/** + * Checks whether a project can be migrated. Every check runs, so one plan shows + * everything to fix, except when agentcore.json isn't a v0 project at all. + */ +export async function preflight(rootPath: string, runner: ProcessRunner): Promise { + const format = await checkProjectFormat(rootPath); + if (!format.v0Spec) return { checks: [format.check] }; + + const deployed = await checkDeployedState(rootPath); + return { + checks: [ + format.check, + await checkGit(rootPath, runner), + await checkCdkPin(rootPath), + await checkCdkApp(rootPath), + checkSupportedResources(format.v0Spec), + deployed.check, + ], + context: { v0Spec: format.v0Spec, deployedState: deployed.state }, + }; +} + +/** The project's agentcore.json must be a valid v0 (version 1) spec. */ +export async function checkProjectFormat( + rootPath: string, +): Promise<{ check: MigrationCheck; v0Spec?: V0ProjectSpec }> { + const fail = (message: string) => ({ check: { name: "v0 project", ok: false, message } }); + let raw: unknown; + try { + raw = JSON.parse(await readFile(join(rootPath, SPEC_RELATIVE_PATH), "utf8")); + } catch (error) { + return fail(`Couldn't read ${SPEC_RELATIVE_PATH}: ${(error as Error).message}`); + } + const version = + typeof raw === "object" && raw !== null ? (raw as { version?: unknown }).version : undefined; + if (version === 2) { + return fail( + "This project is already a v1 project (agentcore.json version 2); there is nothing to migrate.", + ); + } + if (projectFormat(raw) !== "v0") { + return fail( + `${SPEC_RELATIVE_PATH} has version ${JSON.stringify(version)}; only v0 projects (version 1) can be migrated.`, + ); + } + const parsed = V0AgentCoreProjectSpecSchema.safeParse(raw); + if (!parsed.success) { + return fail( + `${SPEC_RELATIVE_PATH} isn't a valid v0 project. Fix it with the v0 CLI first:\n${z.prettifyError(parsed.error)}`, + ); + } + return { + check: { name: "v0 project", ok: true, message: `'${parsed.data.name}' is a v0 project` }, + v0Spec: parsed.data, + }; +} + +/** + * The git gate `agentcore migrate` applies before changing anything (git + * installed, the project in a work tree with agentcore.json committed and nothing + * uncommitted), reported as a check so a plan lists it with everything else. + */ +export async function checkGit(rootPath: string, runner: ProcessRunner): Promise { + try { + await checkGitGate(runner, rootPath); + return { name: "git", ok: true, message: "working tree is clean" }; + } catch (error) { + return { name: "git", ok: false, message: (error as Error).message }; + } +} + +/** The project's CDK app must pin an `@aws/agentcore-cdk` new enough to migrate from. */ +export async function checkCdkPin(rootPath: string): Promise { + const name = "cdk dependencies"; + let manifest: { dependencies?: Record }; + try { + manifest = JSON.parse( + await readFile(join(rootPath, CDK_PACKAGE_RELATIVE_PATH), "utf8"), + ) as typeof manifest; + } catch (error) { + return { + name, + ok: false, + message: `Couldn't read ${CDK_PACKAGE_RELATIVE_PATH}: ${(error as Error).message}`, + }; + } + const pin = manifest.dependencies?.["@aws/agentcore-cdk"]; + const match = typeof pin === "string" ? CDK_PIN_PATTERN.exec(pin) : null; + if (!match || Number(match[1]) < MIN_CDK_ALPHA) { + return { name, ok: false, message: OLD_CDK_MESSAGE }; + } + return { name, ok: true, message: `@aws/agentcore-cdk ${String(pin)}` }; +} + +/** v0 online eval configs that run insights, which v1 doesn't support. */ +export function onlineInsightConfigs(v0: V0ProjectSpec): V0ProjectSpec["onlineEvalConfigs"] { + return v0.onlineEvalConfigs.filter((config) => config.insights !== undefined); +} + +/** + * The guide to keeping resources a migration can't carry over (docs CR-309021468). + * The final page ID is TBD until the guide is published; this is the expected location. + */ +export const PRESERVE_RESOURCES_DOCS_URL = + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/preserve-resources.html"; + +/** Knowledge bases, datasets, capacity providers, and online insights have no v1 equivalent. */ +export function checkSupportedResources(v0: V0ProjectSpec): MigrationCheck { + const name = "supported resources"; + const runtimesOnCapacityProviders = v0.runtimes + .filter( + (runtime) => + runtime.capacityProviderConfiguration !== undefined || + (runtime.filesystemConfigurations ?? []).some((mount) => "capacityProviderVolume" in mount), + ) + .map((runtime) => runtime.name); + // Resources AWS keeps when they leave the stack, so their data can outlive the migration. + const keepable = [ + ["knowledge bases", v0.knowledgeBases.map((kb) => kb.name)], + ["datasets", (v0.datasets ?? []).map((dataset) => dataset.name)], + ["capacity providers", (v0.capacityProviders ?? []).map((provider) => provider.name)], + ] as const; + const insights = onlineInsightConfigs(v0).map((config) => config.name); + + const listed = (groups: ReadonlyArray) => + groups + .filter(([, names]) => names.length > 0) + .map(([label, names]) => `${label} (${names.join(", ")})`) + .join("; "); + const resources = listed(keepable); + if (resources === "" && insights.length === 0 && runtimesOnCapacityProviders.length === 0) { + return { + name, + ok: true, + message: "no knowledge bases, datasets, capacity providers, or online insights", + }; + } + + const lines: string[] = []; + if (resources !== "" || runtimesOnCapacityProviders.length > 0) { + lines.push( + `This project uses ${listed([ + ...keepable, + ["runtimes on capacity providers", runtimesOnCapacityProviders], + ])}, which v1 doesn't support yet. Take them out of the project before migrating:`, + ); + if (runtimesOnCapacityProviders.length > 0) { + lines.push( + ` First move runtimes off their capacity providers: remove capacityProviderConfiguration ` + + `and capacity-provider volumes from them in ${SPEC_RELATIVE_PATH}, then ` + + "run `agentcore deploy`.", + ); + } + if (resources !== "") { + lines.push( + " To keep them and their data in AWS:", + " 1. In agentcore/cdk/lib/cdk-stack.ts, set DeletionPolicy Retain on them (for example " + + "with an Aspect that calls applyRemovalPolicy(RemovalPolicy.RETAIN)).", + " 2. Run `agentcore deploy` (it deploys a v0 project with the v0 CLI).", + ` 3. Remove them from ${SPEC_RELATIVE_PATH} and run \`agentcore deploy\` again. ` + + "They leave the stack but stay in AWS.", + " 4. Undo the change to cdk-stack.ts, commit, and run `agentcore migrate --dry-run`.", + ` If you don't need them: remove them from ${SPEC_RELATIVE_PATH} and run ` + + "`agentcore deploy`. This deletes them from AWS.", + ); + } + } + if (insights.length > 0) { + lines.push( + `This project runs online insights (${insights.join(", ")}), which v1 doesn't support. ` + + `Remove the insights settings from those online eval configs in ${SPEC_RELATIVE_PATH} ` + + "and run `agentcore deploy`; insights hold no data to keep.", + ); + } + lines.push( + "Or keep the project unmigrated and use it in compatibility mode.", + `See ${PRESERVE_RESOURCES_DOCS_URL}`, + ); + return { name, ok: false, message: lines.join("\n") }; +} + +/** The v0 deployed state, which says which resources already exist in AWS. */ +export async function checkDeployedState( + rootPath: string, +): Promise<{ check: MigrationCheck; state: V0DeployedState }> { + const name = "deployed state"; + const empty: V0DeployedState = { targets: {} }; + let text: string; + try { + text = await readFile(join(rootPath, DEPLOYED_STATE_RELATIVE_PATH), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + check: { name, ok: true, message: "never deployed; no names to keep" }, + state: empty, + }; + } + return { + check: { + name, + ok: false, + message: `Couldn't read ${DEPLOYED_STATE_RELATIVE_PATH}: ${(error as Error).message}`, + }, + state: empty, + }; + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + return { + check: { + name, + ok: false, + message: `${DEPLOYED_STATE_RELATIVE_PATH} isn't valid JSON: ${(error as Error).message}`, + }, + state: empty, + }; + } + const parsed = V0DeployedStateSchema.safeParse(raw); + if (!parsed.success) { + return { + check: { + name, + ok: false, + message: `${DEPLOYED_STATE_RELATIVE_PATH} isn't valid v0 deployed state:\n${z.prettifyError(parsed.error)}`, + }, + state: empty, + }; + } + const targets = Object.keys(parsed.data.targets); + return { + check: { + name, + ok: true, + message: targets.length + ? `deployed to ${targets.map((t) => `'${t}'`).join(", ")}` + : "never deployed; no names to keep", + }, + state: parsed.data, + }; +} diff --git a/src/core/project/migrate/refactor.test.ts b/src/core/project/migrate/refactor.test.ts new file mode 100644 index 000000000..e9f0b9622 --- /dev/null +++ b/src/core/project/migrate/refactor.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from "bun:test"; +import { fakeCloudFormation, moveActions, NO_WAIT } from "./migrate-cfn-test-support"; +import { createRefactor, executeRefactor, needsManualRepair, StackRefactorError } from "./refactor"; +import type { TemplateStore } from "./templateStore"; + +const STACK = "AgentCore-Proj-default"; +const RENAMES = new Map([["OldMemory", "NewMemory"]]); + +function stack(client: ReturnType["client"], store?: TemplateStore) { + return { + target: "default", + region: "us-east-1", + stackName: STACK, + renames: RENAMES, + template: { Resources: { NewMemory: { Type: "AWS::BedrockAgentCore::Memory" } } }, + client, + templates: async () => store ?? noStore(), + }; +} + +function noStore(): TemplateStore { + return { + put: async () => { + throw new Error("a small template must not be uploaded"); + }, + remove: async () => {}, + }; +} + +describe("createRefactor", () => { + test("creates the refactor, waits for it, and lists its actions", async () => { + const { client, sent } = fakeCloudFormation({ + CreateStackRefactor: [{ StackRefactorId: "rf-1" }], + DescribeStackRefactor: [{ Status: "CREATE_IN_PROGRESS" }, { Status: "CREATE_COMPLETE" }], + ListStackRefactorActions: [moveActions(STACK, [["OldMemory", "NewMemory"]])], + }); + + const created = await createRefactor(stack(client), NO_WAIT); + + expect(created.refactorId).toBe("rf-1"); + expect(created.actions).toEqual([ + { + action: "MOVE", + entity: "RESOURCE", + from: "OldMemory", + to: "NewMemory", + physicalResourceId: "phys-OldMemory", + }, + ]); + const create = sent.find((command) => command.name === "CreateStackRefactor")!; + expect(create.input.EnableStackCreation).toBe(false); + expect(create.input.ResourceMappings).toEqual([ + { + Source: { StackName: STACK, LogicalResourceId: "OldMemory" }, + Destination: { StackName: STACK, LogicalResourceId: "NewMemory" }, + }, + ]); + const [definition] = create.input.StackDefinitions as Array>; + expect(definition!.StackName).toBe(STACK); + expect(typeof definition!.TemplateBody).toBe("string"); + expect(sent.some((command) => command.name === "ExecuteStackRefactor")).toBe(false); + }); + + test("uploads a template over the inline limit and passes its URL", async () => { + const uploads: string[] = []; + const removed: string[] = []; + const store: TemplateStore = { + put: async (key) => { + uploads.push(key); + return `https://bucket.s3.amazonaws.com/${key}`; + }, + remove: async (key) => { + removed.push(key); + }, + }; + const { client, sent } = fakeCloudFormation({ + CreateStackRefactor: [{ StackRefactorId: "rf-big" }], + DescribeStackRefactor: [{ Status: "CREATE_COMPLETE" }], + ListStackRefactorActions: [moveActions(STACK, [["OldMemory", "NewMemory"]])], + }); + const big = { + ...stack(client, store), + template: { Resources: { NewMemory: { Type: "X", Metadata: { pad: "x".repeat(60_000) } } } }, + }; + + await createRefactor(big, NO_WAIT); + + const [definition] = sent.find((command) => command.name === "CreateStackRefactor")!.input + .StackDefinitions as Array>; + expect(definition!.TemplateBody).toBeUndefined(); + expect(definition!.TemplateURL).toBe(`https://bucket.s3.amazonaws.com/${uploads[0]}`); + expect(removed).toEqual(uploads); + }); + + test("a rejected refactor is a create failure that changed nothing", async () => { + const { client } = fakeCloudFormation({ + CreateStackRefactor: [{ StackRefactorId: "rf-2" }], + DescribeStackRefactor: [{ Status: "CREATE_FAILED", StatusReason: "bad mapping" }], + }); + + const error = await createRefactor(stack(client), NO_WAIT).catch((caught) => caught); + + expect(error).toBeInstanceOf(StackRefactorError); + expect(error.details).toMatchObject({ kind: "create-failed", refactorId: "rf-2" }); + expect(error.message).toContain("bad mapping"); + expect(needsManualRepair(error)).toBe(false); + }); +}); + +describe("executeRefactor", () => { + const created = (client: ReturnType["client"]) => ({ + stack: stack(client), + refactorId: "rf-1", + actions: [], + }); + + test("waits for EXECUTE_COMPLETE", async () => { + const { client, sent } = fakeCloudFormation({ + ExecuteStackRefactor: [{}], + DescribeStackRefactor: [ + { ExecutionStatus: "AVAILABLE" }, + { ExecutionStatus: "EXECUTE_IN_PROGRESS" }, + { ExecutionStatus: "EXECUTE_COMPLETE" }, + ], + }); + + await executeRefactor(created(client), NO_WAIT); + + expect(sent.filter((command) => command.name === "DescribeStackRefactor")).toHaveLength(3); + }); + + test("shows the stack's resource events under the step, like deploy, without failing on them", async () => { + const event = (id: string, status: string, type: string, logicalId: string) => ({ + EventId: id, + ResourceStatus: status, + ResourceType: type, + LogicalResourceId: logicalId, + }); + const before = event("e0", "UPDATE_COMPLETE", "AWS::BedrockAgentCore::Memory", "OldMemory"); + const memory = "AWS::BedrockAgentCore::Memory"; + const { client } = fakeCloudFormation({ + ExecuteStackRefactor: [{}], + // Newest first, as CloudFormation returns them. + DescribeStackEvents: [ + { StackEvents: [before] }, + new Error("throttled"), + { + StackEvents: [ + event("e2", "EXPORT_COMPLETE", memory, "OldMemory"), + event("e1", "UPDATE_IN_PROGRESS", "AWS::CloudFormation::Stack", STACK), + before, + ], + }, + { + StackEvents: [ + event("e3", "IMPORT_COMPLETE", memory, "NewMemory"), + event("e2", "EXPORT_COMPLETE", memory, "OldMemory"), + before, + ], + }, + ], + DescribeStackRefactor: [ + { ExecutionStatus: "EXECUTE_IN_PROGRESS" }, + { ExecutionStatus: "EXECUTE_IN_PROGRESS" }, + { ExecutionStatus: "EXECUTE_COMPLETE" }, + ], + }); + const lines: string[] = []; + + await executeRefactor(created(client), { ...NO_WAIT, onOutput: (line) => lines.push(line) }); + + expect(lines).toEqual([ + `${STACK} | 1/2 | EXPORT_COMPLETE | ${memory} | OldMemory`, + `${STACK} | 2/2 | IMPORT_COMPLETE | ${memory} | NewMemory`, + ]); + }); + + test("a failure CloudFormation rolled back left the stack unchanged", async () => { + const { client } = fakeCloudFormation({ + ExecuteStackRefactor: [{}], + DescribeStackRefactor: [ + { ExecutionStatus: "EXECUTE_FAILED", ExecutionStatusReason: "Property changes detected" }, + { ExecutionStatus: "ROLLBACK_IN_PROGRESS" }, + { + ExecutionStatus: "ROLLBACK_COMPLETE", + ExecutionStatusReason: "Property changes detected", + }, + ], + }); + + const error = await executeRefactor(created(client), NO_WAIT).catch((caught) => caught); + + expect(error.details.kind).toBe("execute-failed"); + expect(error.message).toContain("rolled it back"); + expect(needsManualRepair(error)).toBe(false); + }); + + test("a failed rollback needs manual repair", async () => { + const { client } = fakeCloudFormation({ + ExecuteStackRefactor: [{}], + DescribeStackRefactor: [{ ExecutionStatus: "ROLLBACK_FAILED" }], + }); + + const error = await executeRefactor(created(client), NO_WAIT).catch((caught) => caught); + + expect(error.details.kind).toBe("rollback-failed"); + expect(needsManualRepair(error)).toBe(true); + }); + + test("an Execute call CloudFormation refused (4xx) didn't start the refactor", async () => { + const { client, sent } = fakeCloudFormation({ + ExecuteStackRefactor: [ + Object.assign(new Error("Rate exceeded"), { $metadata: { httpStatusCode: 400 } }), + ], + }); + + const error = await executeRefactor(created(client), NO_WAIT).catch((caught) => caught); + + expect(error.details.kind).toBe("execute-failed"); + expect(error.message).toContain("refused to start"); + expect(needsManualRepair(error)).toBe(false); + expect(sent.some((command) => command.name === "DescribeStackRefactor")).toBe(false); + }); + + for (const [label, failure] of [ + [ + "a server error (5xx)", + Object.assign(new Error("Internal"), { $metadata: { httpStatusCode: 500 } }), + ], + ["a lost response", new Error("socket hang up")], + ["a timeout", Object.assign(new Error("timed out"), { name: "TimeoutError" })], + ] as const) { + test(`an Execute call that ended in ${label} is unknown, however AVAILABLE the refactor reads`, async () => { + const { client } = fakeCloudFormation({ + ExecuteStackRefactor: [failure], + DescribeStackRefactor: [{ ExecutionStatus: "AVAILABLE" }], + }); + + const error = await executeRefactor(created(client), NO_WAIT).catch((caught) => caught); + + expect(error.details.kind).toBe("unknown"); + expect(error.details.refactorId).toBe("rf-1"); + expect(error.message).toContain("isn't known"); + expect(needsManualRepair(error)).toBe(true); + }); + } + + test("losing track of the refactor is unknown, which needs a look", async () => { + const { client } = fakeCloudFormation({ + ExecuteStackRefactor: [{}], + DescribeStackRefactor: [new Error("network down")], + }); + + const error = await executeRefactor(created(client), NO_WAIT).catch((caught) => caught); + + expect(error.details.kind).toBe("unknown"); + expect(needsManualRepair(error)).toBe(true); + }); +}); diff --git a/src/core/project/migrate/refactor.ts b/src/core/project/migrate/refactor.ts new file mode 100644 index 000000000..fad73c676 --- /dev/null +++ b/src/core/project/migrate/refactor.ts @@ -0,0 +1,376 @@ +import { + CreateStackRefactorCommand, + DescribeStackEventsCommand, + DescribeStackRefactorCommand, + ExecuteStackRefactorCommand, + ListStackRefactorActionsCommand, + type CloudFormationClient, + type DescribeStackRefactorCommandOutput, + type StackRefactorAction, +} from "@aws-sdk/client-cloudformation"; +import type { CfnTemplate } from "./templates"; +import { templateLocation, type TemplateStore } from "./templateStore"; + +/** One deployed stack to refactor onto new logical IDs. */ +export type RefactorStack = { + target: string; + region: string; + stackName: string; + /** Logical ID renames, deployed ID → new ID. */ + renames: ReadonlyMap; + /** The deployed template with the renames applied. */ + template: CfnTemplate; + /** A CloudFormation client for the stack's account and region. */ + client: CloudFormationClient; + /** Where a template too big to pass inline is uploaded. */ + templates: () => Promise; +}; + +export type PollOptions = { + /** Between status reads; defaults to 5 s. */ + intervalMs?: number; + /** Gives up after this long; defaults to 30 minutes (memory moves take over a minute each). */ + timeoutMs?: number; + /** Injectable for tests. */ + sleep?: (ms: number) => Promise; + onStep?: (message: string) => void; + /** Gets a line per stack resource event while a refactor executes. */ + onOutput?: (line: string) => void; +}; + +/** + * Why a refactor stopped: it was never created; it failed and left the stack + * unchanged; it couldn't roll back, leaving the stack part-moved; or its outcome + * couldn't be read. The last two need a person to look at the stack. + */ +export type RefactorFailureKind = + "create-failed" | "execute-failed" | "rollback-failed" | "unknown"; + +/** Whether a failure leaves the stack in a state only a person can sort out. */ +export function needsManualRepair(error: unknown): error is StackRefactorError { + return ( + error instanceof StackRefactorError && + (error.details.kind === "rollback-failed" || error.details.kind === "unknown") + ); +} + +/** A stack refactor that didn't finish. */ +export class StackRefactorError extends Error { + constructor( + message: string, + readonly details: { + kind: RefactorFailureKind; + stackName: string; + region: string; + refactorId?: string; + status?: string; + reason?: string; + }, + ) { + super(message); + this.name = "StackRefactorError"; + } +} + +/** One action a created stack refactor will take, as CloudFormation lists it. */ +export type RefactorAction = { + /** `MOVE` for every resource a migration refactor moves. */ + action: string; + entity: string; + /** Logical ID the resource moves from. */ + from: string; + /** Logical ID the resource moves to. */ + to: string; + physicalResourceId?: string; + /** CloudFormation's note, e.g. "No configuration changes detected." */ + description?: string; +}; + +/** A created stack refactor, not yet executed, with what it would do. */ +export type CreatedRefactor = { + stack: RefactorStack; + refactorId: string; + actions: RefactorAction[]; +}; + +const sleepFor = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Creates the stack refactor that moves `stack`'s resources to their new logical + * IDs, waits for CloudFormation to validate it, and lists the actions it would + * take. Nothing in the stack changes until it's executed. + */ +export async function createRefactor( + stack: RefactorStack, + options: PollOptions & { description?: string } = {}, +): Promise { + const { stackName, region, client } = stack; + const template = await templateLocation(stack.template, stackName, stack.templates); + let refactorId: string | undefined; + try { + const response = await client.send( + new CreateStackRefactorCommand({ + Description: + options.description ?? `agentcore migrate: move ${stackName} to v1 logical IDs`, + EnableStackCreation: false, + StackDefinitions: [{ StackName: stackName, ...template.location }], + ResourceMappings: [...stack.renames].map(([from, to]) => ({ + Source: { StackName: stackName, LogicalResourceId: from }, + Destination: { StackName: stackName, LogicalResourceId: to }, + })), + }), + ); + refactorId = response.StackRefactorId; + if (!refactorId) throw new Error("CloudFormation returned no stack refactor ID."); + const id = refactorId; + const created = await poll( + () => describe(client, id), + (state) => !state.Status?.endsWith("_IN_PROGRESS"), + options, + ); + if (created.Status !== "CREATE_COMPLETE") { + throw new StackRefactorError( + `CloudFormation rejected the refactor of stack '${stackName}' (${created.Status ?? "UNKNOWN"}): ` + + `${created.StatusReason ?? "no reason given"}. Nothing was changed.`, + { + kind: "create-failed", + stackName, + region, + refactorId: id, + ...(created.Status && { status: created.Status }), + ...(created.StatusReason && { reason: created.StatusReason }), + }, + ); + } + return { stack, refactorId: id, actions: await listActions(client, id) }; + } catch (error) { + if (error instanceof StackRefactorError) throw error; + throw new StackRefactorError( + `Couldn't create the refactor of stack '${stackName}': ${(error as Error).message}. Nothing was changed.`, + { kind: "create-failed", stackName, region, ...(refactorId && { refactorId }) }, + ); + } finally { + await template.release(); + } +} + +/** + * Executes a created refactor and waits for it to finish. A refactor that fails + * and rolls back leaves the stack as it was; one that can't roll back leaves it + * part-moved, which only manual work can repair; one whose outcome couldn't be + * read is `unknown`, and must be checked before anything else is done to it. + */ +export async function executeRefactor( + created: CreatedRefactor, + options: PollOptions = {}, +): Promise { + const { stack, refactorId } = created; + const { client, stackName, region } = stack; + const details = (state?: DescribeStackRefactorCommandOutput, fallbackReason?: string) => { + const status = state?.ExecutionStatus; + const reason = state?.ExecutionStatusReason ?? state?.StatusReason ?? fallbackReason; + return { stackName, region, refactorId, ...(status && { status }), ...(reason && { reason }) }; + }; + const lost = (error: Error): never => { + throw new StackRefactorError( + `Lost track of the refactor of stack '${stackName}' (${error.message}), so whether it ` + + "finished isn't known.", + { kind: "unknown", ...details(undefined, error.message) }, + ); + }; + + // Each move is an export and an import: two completed events per resource. + const events = options.onOutput + ? await stackEvents(client, stackName, stack.renames.size * 2, options.onOutput) + : undefined; + try { + await client.send(new ExecuteStackRefactorCommand({ StackRefactorId: refactorId })); + } catch (error) { + // Only CloudFormation answering with a client error proves it didn't take the + // request. A timeout, a server error, or a lost response may have reached it + // anyway, and an accepted execution can read AVAILABLE for a while before it + // starts, so its outcome is unknown until a person (or a rerun) checks. + const message = (error as Error).message; + if (isClientError(error)) { + throw new StackRefactorError( + `CloudFormation refused to start the refactor of stack '${stackName}': ${message}. ` + + "The stack is unchanged.", + { kind: "execute-failed", ...details(undefined, message) }, + ); + } + throw new StackRefactorError( + `The request to start the refactor of stack '${stackName}' failed (${message}), so ` + + "whether CloudFormation started it isn't known.", + { kind: "unknown", ...details(undefined, message) }, + ); + } + options.onStep?.(`Refactoring stack ${stackName}`); + let state: DescribeStackRefactorCommandOutput; + try { + const read = async () => { + await events?.(); + return describe(client, refactorId); + }; + state = await poll( + read, + // Until the execution is picked up the refactor can still read AVAILABLE. + (current) => + current.ExecutionStatus !== "AVAILABLE" && + !current.ExecutionStatus?.endsWith("_IN_PROGRESS"), + options, + ); + if (state.ExecutionStatus === "EXECUTE_FAILED") { + // A failed execution may still be about to roll back: give it a few more + // reads to show ROLLBACK_IN_PROGRESS, and follow the rollback if it does. + let reads = 0; + state = await poll( + read, + (current) => + (current.ExecutionStatus === "EXECUTE_FAILED" && ++reads > EXECUTE_FAILED_GRACE_READS) || + (current.ExecutionStatus !== "EXECUTE_FAILED" && + !current.ExecutionStatus?.endsWith("_IN_PROGRESS")), + options, + ); + } + } catch (error) { + lost(error as Error); + } + await events?.(); + const status = state!.ExecutionStatus; + if (status === "EXECUTE_COMPLETE") return; + const reason = details(state!).reason ?? "no reason given"; + if (status === "ROLLBACK_FAILED") { + throw new StackRefactorError( + `The refactor of stack '${stackName}' failed and CloudFormation couldn't roll it back ` + + `(${reason}). Some resources may be under their old logical IDs and some under their new ones.`, + { kind: "rollback-failed", ...details(state!) }, + ); + } + if (status === "EXECUTE_FAILED" || status === "ROLLBACK_COMPLETE") { + throw new StackRefactorError( + `The refactor of stack '${stackName}' failed (${status}): ${reason}. CloudFormation ` + + "rolled it back, so the stack is unchanged.", + { kind: "execute-failed", ...details(state!) }, + ); + } + // OBSOLETE, UNAVAILABLE, or a status this code doesn't know. + throw new StackRefactorError( + `The refactor of stack '${stackName}' ended ${status ?? "with no status"} (${reason}), so ` + + "whether it moved anything isn't known.", + { kind: "unknown", ...details(state!) }, + ); +} + +/** + * Reports the stack's resource events as they happen, one deploy-style line each + * (` | / | | | `, where n counts the + * completed ones), skipping events from before the refactor and the stack's own. + * Returns a function to call on each poll. Events are only shown: reading them + * never fails the refactor. + */ +async function stackEvents( + client: CloudFormationClient, + stackName: string, + total: number, + onOutput: (line: string) => void, +): Promise<() => Promise> { + const seen = new Set(); + let completed = 0; + const read = async (report: boolean) => { + try { + const output = await client.send(new DescribeStackEventsCommand({ StackName: stackName })); + // Newest first; report oldest first. + for (const event of [...(output.StackEvents ?? [])].reverse()) { + if (!event.EventId || seen.has(event.EventId)) continue; + seen.add(event.EventId); + if (!report || event.ResourceType === "AWS::CloudFormation::Stack") continue; + if (event.ResourceStatus?.endsWith("_COMPLETE")) completed++; + onOutput( + `${stackName} | ${completed}/${total} | ${event.ResourceStatus} | ` + + `${event.ResourceType} | ${event.LogicalResourceId}`, + ); + } + } catch { + // Showing progress is best effort. + } + }; + // What's there before the refactor starts is history, not progress. + await read(false); + return () => read(true); +} + +/** Reads after EXECUTE_FAILED in which a rollback may still start. */ +const EXECUTE_FAILED_GRACE_READS = 3; + +/** Whether an AWS SDK error is CloudFormation's own answer that it refused the request (HTTP 4xx). */ +export function isClientError(error: unknown): boolean { + const status = (error as { $metadata?: { httpStatusCode?: number } } | undefined)?.$metadata + ?.httpStatusCode; + return typeof status === "number" && status >= 400 && status < 500; +} + +export async function describe( + client: CloudFormationClient, + refactorId: string, +): Promise { + return client.send(new DescribeStackRefactorCommand({ StackRefactorId: refactorId })); +} + +async function listActions( + client: CloudFormationClient, + refactorId: string, +): Promise { + const actions: StackRefactorAction[] = []; + let token: string | undefined; + do { + const page = await client.send( + new ListStackRefactorActionsCommand({ + StackRefactorId: refactorId, + ...(token && { NextToken: token }), + }), + ); + actions.push(...(page.StackRefactorActions ?? [])); + token = page.NextToken; + } while (token); + return actions.map((action) => ({ + action: action.Action ?? "UNKNOWN", + entity: action.Entity ?? "UNKNOWN", + from: action.ResourceMapping?.Source?.LogicalResourceId ?? "", + to: action.ResourceMapping?.Destination?.LogicalResourceId ?? "", + ...(action.PhysicalResourceId && { physicalResourceId: action.PhysicalResourceId }), + ...(action.Description && { description: action.Description }), + })); +} + +/** Consecutive failed reads a poll rides out before failing. */ +const MAX_FAILED_READS = 3; + +/** Reads until `done` holds, sleeping between reads, failing after the timeout. */ +export async function poll( + read: () => Promise, + done: (value: T) => boolean, + options: PollOptions = {}, +): Promise { + const interval = options.intervalMs ?? 5_000; + const timeout = options.timeoutMs ?? 30 * 60_000; + const sleep = options.sleep ?? sleepFor; + const deadline = Date.now() + timeout; + let failedReads = 0; + for (;;) { + let value: T; + try { + value = await read(); + failedReads = 0; + } catch (error) { + // The SDK has already retried; a few more reads ride out a longer blip. + if (++failedReads > MAX_FAILED_READS || Date.now() >= deadline) throw error; + await sleep(interval); + continue; + } + if (done(value)) return value; + if (Date.now() >= deadline) { + throw new Error(`still not finished after ${Math.round(timeout / 1000)} s`); + } + await sleep(interval); + } +} diff --git a/src/core/project/migrate/replacements.ts b/src/core/project/migrate/replacements.ts new file mode 100644 index 000000000..d913c97e3 --- /dev/null +++ b/src/core/project/migrate/replacements.ts @@ -0,0 +1,455 @@ +import type { + StackChangeSeverity, + StackResource, + StackResourceChange, +} from "../../../handlers/project/migrate/types"; +import { NAME_PROPERTIES } from "./mapping"; +import { + CDK_METADATA_TYPE, + isCustomResource, + referencesIn, + resourcesOf, + substituteReferences, + type CfnResource, + type CfnTemplate, +} from "./templates"; + +/** What the first v1 deploy after the refactor does to the stack's resources. */ +export type DeployImpact = { + added: StackResource[]; + removed: StackResourceChange[]; + replaced: StackResourceChange[]; + /** AgentCore resources the deploy changes in place (property updates, not replacements). */ + updated: StackResourceChange[]; +}; + +const AGENTCORE_TYPE_PREFIX = "AWS::BedrockAgentCore::"; + +/** + * Types whose replacement loses something the migration promises to keep, beyond + * AgentCore resources: a container runtime's ECR repository holds its images and + * its pinned name. + */ +const KEPT_TYPES = new Set(["AWS::ECR::Repository"]); + +/** + * Types that hold data or secure other resources: deleting or replacing one loses + * its contents (a KMS key is scheduled for deletion, which breaks whatever it + * encrypts). Unless retained, losing one blocks the migration. + */ +const STATEFUL_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::DynamoDB::Table", + "AWS::DynamoDB::GlobalTable", + "AWS::KMS::Key", + "AWS::Logs::LogGroup", + "AWS::SecretsManager::Secret", +]); +const STATEFUL_PREFIXES = ["AWS::RDS::", "AWS::EFS::"]; + +function isStateful(type: string): boolean { + return STATEFUL_TYPES.has(type) || STATEFUL_PREFIXES.some((prefix) => type.startsWith(prefix)); +} + +/** + * Types the v0 CDK app creates that hold no data, so deleting or replacing one + * only recreates it. Any other type is one migrate can't vouch for: losing it + * blocks the migration, since only a customized app would have created it. + */ +const STATELESS_TYPES = new Set([ + "AWS::IAM::Role", + "AWS::IAM::Policy", + "AWS::IAM::ManagedPolicy", + "AWS::Lambda::Function", + "AWS::Lambda::Permission", + "AWS::Lambda::Version", + "AWS::Lambda::Alias", + "AWS::Lambda::EventInvokeConfig", + "AWS::CodeBuild::Project", + "AWS::KMS::Alias", + "AWS::EC2::SecurityGroup", + "AWS::EC2::SecurityGroupIngress", + "AWS::EC2::SecurityGroupEgress", +]); + +/** + * AgentCore properties a resource can't change after creation even though the + * resource specification calls the change an in-place update: the service rejects + * the update ("Protocol type cannot be updated for an existing gateway"), so the + * deploy fails and rolls back. + */ +const SERVICE_IMMUTABLE_PROPERTIES: Readonly> = { + "AWS::BedrockAgentCore::Gateway": ["ProtocolType", "AuthorizerType"], +}; + +/** DeletionPolicy values that keep the resource in the account when the stack drops it. */ +const RETAINING_POLICIES = new Set(["Retain", "RetainExceptOnCreate"]); + +/** + * Custom resources whose provider does nothing worth keeping on delete: CDK's + * JSON helper, and the container build trigger, whose recreation just rebuilds + * the image. + */ +function isHarmlessCustomResource(logicalId: string, type: string): boolean { + return type === "Custom::AWSCDKCfnJson" || /ContainerBuildTrigger/.test(logicalId); +} + +type Diff = typeof import("@aws-cdk/cloudformation-diff"); + +/** + * Compares the bridge template (the deployed stack once the refactor has moved + * it) with the v1 template, the way `cdk diff` does, and reports what the first + * v1 deploy would create, delete, and replace. Replacement comes from the AWS + * resource specification: a change to a create-only property replaces the + * resource. A replaced resource gets a new physical ID, so a resource that reads + * that ID in a create-only property is replaced too; those are found by diffing + * each referencing resource against itself with the reference changed. + */ +export async function deployImpact(bridge: CfnTemplate, v1: CfnTemplate): Promise { + // Loaded on first use: the diff library reads a large resource database. + const diff = await import("@aws-cdk/cloudformation-diff"); + const impact: DeployImpact = { added: [], removed: [], replaced: [], updated: [] }; + const harnessRoles = rolesUsedByHarnesses(bridge, v1); + const bridgeResources = resourcesOf(bridge); + const v1Resources = resourcesOf(v1); + const replacedIds = new Set(); + + diff.fullDiff(bridge, v1).resources.forEachDifference((logicalId, change) => { + const oldType = change.oldResourceType; + const newType = change.newResourceType; + if ((oldType ?? newType) === CDK_METADATA_TYPE) return; + if (oldType !== undefined && newType !== undefined && oldType !== newType) { + // v1 reuses the logical ID for another kind of resource, which CloudFormation + // rejects ("Update of resource type is not permitted"), whatever the type. + impact.removed.push({ + logicalId, + type: oldType, + severity: "blocker", + reason: `the v1 template declares a ${newType} under this logical ID; CloudFormation can't change a resource's type under one logical ID, so the deploy would fail`, + }); + impact.added.push({ logicalId, type: newType }); + } else if (change.isAddition) { + impact.added.push({ logicalId, type: newType! }); + } else if (change.isRemoval) { + const deployed = bridgeResources[logicalId]; + impact.removed.push( + removal(logicalId, oldType!, isRetained(deployed), deployed?.DeletionPolicy), + ); + } else if (isReplacement(diff, change.changeImpact)) { + const properties = Object.entries(change.propertyUpdates) + .filter(([, update]) => isReplacement(diff, update.changeImpact)) + .map(([name]) => name); + replacedIds.add(logicalId); + impact.replaced.push( + replacement(logicalId, newType!, { + because: properties.length === 0 ? "" : ` because ${changes(properties)}`, + conditional: change.changeImpact === diff.ResourceImpact.MAY_REPLACE, + harnessRole: harnessRoles.has(logicalId), + keptName: keptCustomName(bridgeResources[logicalId], v1Resources[logicalId]), + }), + ); + } else if (newType?.startsWith(AGENTCORE_TYPE_PREFIX)) { + const updates = Object.entries(change.propertyUpdates).map(([name, diff]) => ({ + name, + before: diff.oldValue, + after: diff.newValue, + })); + if (updates.length > 0) impact.updated.push(update(logicalId, newType, updates)); + } + }); + + // Follow replacements through references until nothing more is replaced. + const bridgeIds = new Set(Object.keys(bridgeResources)); + for (let progress = true; progress;) { + progress = false; + for (const [logicalId, resource] of Object.entries(v1Resources)) { + if (replacedIds.has(logicalId) || !bridgeIds.has(logicalId)) continue; + const cause = referencesIn(resource.Properties ?? {}, (id) => replacedIds.has(id))[0]; + if (!cause) continue; + const probe = cascadeProbe(diff, logicalId, resource, replacedIds); + if (!probe) continue; + replacedIds.add(logicalId); + // A cascaded replacement supersedes any in-place update reported for it. + impact.updated = impact.updated.filter((entry) => entry.logicalId !== logicalId); + impact.replaced.push( + replacement(logicalId, resource.Type, { + because: ` because it references ${cause.target}, which is replaced`, + conditional: probe === "may", + harnessRole: harnessRoles.has(logicalId), + keptName: keptCustomName(bridgeResources[logicalId], resource), + }), + ); + progress = true; + } + } + + impact.removed = impact.removed.map((entry) => + sameNameAddition(entry, bridgeResources, v1Resources, impact.added), + ); + // Replacing a stateful resource: say what happens to the deployed one. + impact.replaced = impact.replaced.map((entry) => + entry.reason.endsWith(STATEFUL_REPLACEMENT) + ? { ...entry, reason: entry.reason + replacedFate(bridgeResources[entry.logicalId]) } + : entry, + ); + return impact; +} + +/** The end of a stateful resource's replacement reason, completed by {@link replacedFate}. */ +const STATEFUL_REPLACEMENT = "the new one starts empty"; + +/** What a replacement does with the deployed resource, by its UpdateReplacePolicy. */ +function replacedFate(resource: CfnResource | undefined): string { + switch (resource?.UpdateReplacePolicy) { + case "Retain": + return ", and the deployed one is left in the account outside the stack (UpdateReplacePolicy: Retain), unused"; + case "Snapshot": + return ", and the deployed one is deleted after a final snapshot (UpdateReplacePolicy: Snapshot)"; + default: + return ", and the deployed one is deleted with its data"; + } +} + +/** + * How to keep a stateful resource the v1 template drops. Retention is read from the + * deployed template, so the policy only has to reach the stack once: the app is + * changed back before migrating, and the app check passes again. + */ +const RETAIN_STEPS = + "To keep it: 1. set its removal policy to retain in agentcore/cdk/lib/cdk-stack.ts (for " + + "example `application.node.findChild(...)` then `applyRemovalPolicy(RemovalPolicy.RETAIN)`), " + + "2. run agentcore deploy (it deploys a v0 project with the v0 CLI), 3. undo the change to cdk-stack.ts, " + + "4. run agentcore migrate again; the deployed stack keeps the policy, so the v1 deploy leaves " + + "it in the account"; + +/** Whether the deployed resource's DeletionPolicy keeps it in the account when the stack drops it. */ +function isRetained(resource: CfnResource | undefined): boolean { + return ( + typeof resource?.DeletionPolicy === "string" && RETAINING_POLICIES.has(resource.DeletionPolicy) + ); +} + +/** The resource's literal physical name, when its type takes one and it's set to a plain string. */ +function literalName(resource: CfnResource | undefined): string | undefined { + if (resource === undefined) return undefined; + const value = resource.Properties?.[NAME_PROPERTIES[resource.Type] ?? ""]; + return typeof value === "string" ? value : undefined; +} + +/** + * The custom name a replaced resource keeps: CloudFormation creates the replacement + * before deleting the original, so it can't replace a resource whose custom name + * stays the same, and the deploy fails. + */ +function keptCustomName( + deployed: CfnResource | undefined, + v1: CfnResource | undefined, +): string | undefined { + const name = literalName(deployed); + return name !== undefined && name === literalName(v1) ? name : undefined; +} + +/** + * A deleted resource whose name a resource the deploy creates takes: CloudFormation + * creates the new resource first, while the old one still holds the name, so the + * create fails (and a retained one keeps the name for good). + */ +function sameNameAddition( + entry: StackResourceChange, + bridgeResources: Record, + v1Resources: Record, + added: StackResource[], +): StackResourceChange { + const name = literalName(bridgeResources[entry.logicalId]); + if (name === undefined) return entry; + const taker = added.find( + (addition) => + addition.type === entry.type && literalName(v1Resources[addition.logicalId]) === name, + ); + if (taker === undefined) return entry; + return { + ...entry, + severity: "blocker", + reason: + `the v1 deploy creates ${taker.logicalId} with the same name '${name}' while this one ` + + "still holds it, so the create fails; the mapping couldn't pair the two", + }; +} + +type PropertyUpdate = { name: string; before: unknown; after: unknown }; + +const shown = (value: unknown) => (value === undefined ? "(unset)" : JSON.stringify(value)); + +function update(logicalId: string, type: string, updates: PropertyUpdate[]): StackResourceChange { + const immutable = updates.filter((property) => + (SERVICE_IMMUTABLE_PROPERTIES[type] ?? []).includes(property.name), + ); + if (immutable.length > 0) { + const listed = immutable + .map( + (property) => `${property.name} from ${shown(property.before)} to ${shown(property.after)}`, + ) + .join(", "); + return { + logicalId, + type, + severity: "blocker", + reason: + `the v1 deploy changes ${listed}, which the service doesn't allow on an existing resource, ` + + "so the deploy would fail and roll back. Set the matching field in agentcore.json so v1 " + + 'keeps the deployed value (for a gateway: protocolType "None" when ProtocolType is unset, ' + + "and authorizerType to the deployed AuthorizerType)", + }; + } + return { + logicalId, + type, + severity: "warning", + reason: `the v1 deploy updates it in place: ${changes(updates.map((property) => property.name))}`, + }; +} + +function isReplacement(diff: Diff, impact: string | undefined): boolean { + return impact === diff.ResourceImpact.WILL_REPLACE || impact === diff.ResourceImpact.MAY_REPLACE; +} + +/** + * Whether a new physical ID for any of `replacedIds` replaces `resource`: diffs + * the resource against itself with those references changed from one stand-in + * value to another, so the resource specification decides. + */ +function cascadeProbe( + diff: Diff, + logicalId: string, + resource: CfnResource, + replacedIds: ReadonlySet, +): "will" | "may" | undefined { + const isReplaced = (id: string) => replacedIds.has(id); + const before = substituteReferences(resource, isReplaced, "physical-id-before"); + const after = substituteReferences(resource, isReplaced, "physical-id-after"); + const change = diff + .fullDiff({ Resources: { [logicalId]: before } }, { Resources: { [logicalId]: after } }) + .resources.get(logicalId); + if (change?.changeImpact === diff.ResourceImpact.WILL_REPLACE) return "will"; + if (change?.changeImpact === diff.ResourceImpact.MAY_REPLACE) return "may"; + return undefined; +} + +/** IAM roles a harness runs as, in either template; a harness role keeps its pinned name. */ +function rolesUsedByHarnesses(...templates: CfnTemplate[]): Set { + const roles = new Set(); + for (const template of templates) { + const resources = resourcesOf(template); + for (const resource of Object.values(resources)) { + if (resource.Type !== "AWS::BedrockAgentCore::Harness") continue; + const isRole = (id: string) => resources[id]?.Type === "AWS::IAM::Role"; + for (const ref of referencesIn(resource.Properties ?? {}, isRole)) roles.add(ref.target); + } + } + return roles; +} + +function changes(properties: string[]): string { + return `${properties.join(", ")} ${properties.length === 1 ? "changes" : "change"}`; +} + +/** A Snapshot DeletionPolicy still deletes the resource; only a final snapshot is kept. */ +function snapshotNote(policy: unknown): string { + return policy === "Snapshot" ? " (DeletionPolicy: Snapshot keeps only a final snapshot)" : ""; +} + +function removal( + logicalId: string, + type: string, + retained: boolean, + policy?: unknown, +): StackResourceChange { + const change = (severity: StackChangeSeverity, reason: string) => ({ + logicalId, + type, + severity, + reason, + }); + if (type.startsWith(AGENTCORE_TYPE_PREFIX) || KEPT_TYPES.has(type)) { + return change( + "blocker", + retained + ? "the v1 template has no counterpart; it's retained, so the v1 deploy leaves it in the account outside the stack, where v1 no longer manages it" + : "the v1 template has no counterpart, so the v1 deploy would delete it", + ); + } + if (retained) { + return change( + "info", + "the v1 template no longer declares it; it's retained, so it stays in the account outside the stack", + ); + } + if (isCustomResource(type)) { + return isHarmlessCustomResource(logicalId, type) + ? change( + "info", + "custom resources can't be moved; the v1 deploy recreates it under its v1 logical ID", + ) + : change( + "warning", + "custom resources can't be moved; the v1 deploy deletes it (running its provider's delete) and creates its v1 counterpart", + ); + } + if (isStateful(type)) { + return change( + "blocker", + `the v1 template has no counterpart, so the v1 deploy would delete it and its data${snapshotNote(policy)}. ${RETAIN_STEPS}`, + ); + } + if (!STATELESS_TYPES.has(type)) { + return change( + "blocker", + "the v1 template has no counterpart, so the v1 deploy would delete it, and migrate can't " + + `tell whether this type holds data${snapshotNote(policy)}. ${RETAIN_STEPS}; or remove it with the v0 CLI before migrating`, + ); + } + return change("warning", "the v1 template has no counterpart, so the v1 deploy deletes it"); +} + +function replacement( + logicalId: string, + type: string, + details: { because: string; conditional: boolean; harnessRole: boolean; keptName?: string }, +): StackResourceChange { + const verb = details.conditional ? "may replace" : "replaces"; + const change = (severity: StackChangeSeverity, consequence: string) => ({ + logicalId, + type, + severity, + reason: `the v1 deploy ${verb} it${details.because}; ${consequence}`, + }); + if (type.startsWith(AGENTCORE_TYPE_PREFIX)) { + return change("blocker", "its ID, ARN, and data would be lost"); + } + if (type === "AWS::ECR::Repository") { + return change("blocker", "its images and repository URI would be lost"); + } + if (details.harnessRole) { + return change("blocker", "the harness's role would get a new name and ARN"); + } + if (details.keptName !== undefined) { + return change( + "blocker", + `CloudFormation can't replace a resource that keeps its custom name ('${details.keptName}'), so the deploy would fail`, + ); + } + if (isCustomResource(type)) { + return change("warning", "its provider runs again"); + } + if (isStateful(type)) { + // Completed by replacedFate() once the deployed resource's policy is known. + return change("blocker", STATEFUL_REPLACEMENT); + } + if (!STATELESS_TYPES.has(type)) { + return change( + "blocker", + "migrate can't tell whether this type holds data; a resource the v0 CDK app doesn't create", + ); + } + return change("warning", "resources that use it are updated to the new one"); +} diff --git a/src/core/project/migrate/reports.ts b/src/core/project/migrate/reports.ts new file mode 100644 index 000000000..3cd217a90 --- /dev/null +++ b/src/core/project/migrate/reports.ts @@ -0,0 +1,28 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { atomicWrite } from "../../../io"; + +/** + * Where `agentcore migrate` writes the reports a user reads for the full detail + * behind its summary: the plan, and what applying it did. Project-relative, with + * forward slashes. agentcore/.cli/* is gitignored, and the git gate ignores this + * directory too, so a report never counts as an uncommitted change. + */ +export const REPORTS_RELATIVE_PATH = "agentcore/.cli/migrate"; + +const REPORT_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** Writes a report atomically and returns its project-relative path. */ +export async function saveMigrationReport( + rootPath: string, + fileName: string, + contents: string, +): Promise { + if (!REPORT_FILE_NAME.test(fileName)) { + throw new Error(`Invalid migration report file name '${fileName}'.`); + } + const directory = join(rootPath, REPORTS_RELATIVE_PATH); + await mkdir(directory, { recursive: true }); + await atomicWrite(join(directory, fileName), contents); + return `${REPORTS_RELATIVE_PATH}/${fileName}`; +} diff --git a/src/core/project/migrate/stacks.test.ts b/src/core/project/migrate/stacks.test.ts new file mode 100644 index 000000000..12d8fcae4 --- /dev/null +++ b/src/core/project/migrate/stacks.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { fakeCloudFormation } from "./migrate-test-support"; +import { checkStack, cloudFormationStackReader } from "./stacks"; + +const reader = (stacks: Parameters[0]) => + cloudFormationStackReader( + fakeCloudFormation(stacks).create({ + region: "us-east-1", + credentials: { accessKeyId: "a", secretAccessKey: "b" }, + }), + ); + +describe("checkStack", () => { + test("passes a stable stack without a stack policy", async () => { + for (const status of ["CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE"]) { + expect(await checkStack(reader({ S: { status } }), "default", "S")).toEqual({ + name: "stack (default)", + ok: true, + message: `S is ${status}, with no stack policy`, + }); + } + }); + + test("fails a stack that is changing, failed, or rolled back", async () => { + const message = async (status: string) => + (await checkStack(reader({ S: { status } }), "default", "S")).message; + expect(await message("UPDATE_IN_PROGRESS")).toBe( + "Stack 'S' is UPDATE_IN_PROGRESS; it must be CREATE_COMPLETE, UPDATE_COMPLETE, IMPORT_COMPLETE. Wait for the operation in progress to finish, then try again.", + ); + for (const status of [ + "UPDATE_ROLLBACK_COMPLETE", + "ROLLBACK_COMPLETE", + "UPDATE_FAILED", + "ROLLBACK_FAILED", + "UPDATE_ROLLBACK_IN_PROGRESS", + ]) { + expect(await message(status)).toStartWith(`Stack 'S' is ${status}; it must be`); + } + expect(await message("UPDATE_ROLLBACK_COMPLETE")).toEndWith( + "Run `agentcore deploy` until it succeeds, then try again.", + ); + }); + + test("fails a stack with a stack policy, whatever it says", async () => { + const check = await checkStack( + reader({ S: { policy: '{"Statement":[{"Effect":"Allow","Action":"Update:*"}]}' } }), + "dev", + "S", + ); + expect(check.ok).toBe(false); + expect(check.name).toBe("stack (dev)"); + expect(check.message).toContain( + "CloudFormation stack refactoring doesn't support stacks with a stack policy", + ); + }); + + test("treats an empty policy body as no policy", async () => { + expect((await checkStack(reader({ S: { policy: " " } }), "default", "S")).ok).toBe(true); + }); + + test("fails a stack the deployed state records but that doesn't exist", async () => { + const check = await checkStack(reader({}), "default", "Gone"); + expect(check.ok).toBe(false); + expect(check.message).toStartWith( + "The deployed state records stack 'Gone' for target 'default', but it doesn't exist.", + ); + }); +}); + +describe("cloudFormationStackReader", () => { + test("reads the deployed template as submitted", async () => { + const cloudFormation = fakeCloudFormation({ S: { template: { Resources: {} } } }); + const read = cloudFormationStackReader( + cloudFormation.create({ + region: "eu-west-1", + credentials: { accessKeyId: "a", secretAccessKey: "b" }, + }), + ); + expect(await read.template("S")).toEqual({ Resources: {} }); + expect(cloudFormation.calls).toEqual([ + { + command: "GetTemplateCommand", + input: { StackName: "S", TemplateStage: "Original" }, + region: "eu-west-1", + }, + ]); + }); + + test("rejects a template that isn't JSON, or a missing one", async () => { + await expect(reader({ S: { templateBody: "Resources: {}" } }).template("S")).rejects.toThrow( + "Stack 'S' was deployed with a template that isn't JSON.", + ); + await expect(reader({ S: { templateBody: "" } }).template("S")).rejects.toThrow( + "CloudFormation returned no template for stack 'S'.", + ); + }); + + test("passes through errors other than a missing stack", async () => { + const failing = cloudFormationStackReader({ + send: async () => { + throw Object.assign(new Error("Rate exceeded"), { name: "Throttling" }); + }, + } as never); + await expect(failing.describe("S")).rejects.toThrow("Rate exceeded"); + }); +}); diff --git a/src/core/project/migrate/stacks.ts b/src/core/project/migrate/stacks.ts new file mode 100644 index 000000000..f006f4a79 --- /dev/null +++ b/src/core/project/migrate/stacks.ts @@ -0,0 +1,106 @@ +import { + DescribeStacksCommand, + GetStackPolicyCommand, + GetTemplateCommand, + type CloudFormationClient, + type Stack, +} from "@aws-sdk/client-cloudformation"; +import type { MigrationCheck } from "../../../handlers/project/migrate/types"; +import { isStackNotFound } from "../backends/cdk/environment"; +import type { CfnTemplate } from "./templates"; + +/** The CloudFormation reads migration planning makes; all read-only. */ +export type StackReader = { + /** The stack, or undefined when it doesn't exist. */ + describe(stackName: string): Promise; + /** The stack policy document, or undefined when the stack has none. */ + stackPolicy(stackName: string): Promise; + /** The template the stack was last deployed with, exactly as submitted. */ + template(stackName: string): Promise; +}; + +/** Reads stacks through one CloudFormation client. */ +export function cloudFormationStackReader(client: CloudFormationClient): StackReader { + return { + async describe(stackName) { + try { + const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); + return response.Stacks?.[0]; + } catch (error) { + if (isStackNotFound(error)) return undefined; + throw error; + } + }, + async stackPolicy(stackName) { + const response = await client.send(new GetStackPolicyCommand({ StackName: stackName })); + return response.StackPolicyBody?.trim() ? response.StackPolicyBody : undefined; + }, + async template(stackName) { + // "Original" is the template as deployed, before transforms; it's what a + // stack refactor's templates are compared against. + const response = await client.send( + new GetTemplateCommand({ StackName: stackName, TemplateStage: "Original" }), + ); + const body = response.TemplateBody; + if (!body) throw new Error(`CloudFormation returned no template for stack '${stackName}'.`); + let template: unknown; + try { + template = JSON.parse(body); + } catch { + throw new Error( + `Stack '${stackName}' was deployed with a template that isn't JSON. The CDK always ` + + "deploys JSON, so the stack was last updated outside the AgentCore CLI; redeploy it " + + "with `agentcore deploy`, then try again.", + ); + } + return template as CfnTemplate; + }, + }; +} + +/** + * The states a stack can be refactored from. A stack still changing can't be + * refactored, and one whose last operation failed or rolled back (including + * UPDATE_ROLLBACK_COMPLETE, whose resources are back at the previous template + * while the project files describe the failed one) may not match the project, so + * both stop the migration until a v0 deploy succeeds. + */ +const STABLE_STATUSES = new Set(["CREATE_COMPLETE", "UPDATE_COMPLETE", "IMPORT_COMPLETE"]); + +/** + * A deployed target's stack must exist, be in a stable state, and have no stack + * policy (CloudFormation's stack refactoring rejects any stack that has one). + */ +export async function checkStack( + reader: StackReader, + target: string, + stackName: string, +): Promise { + const name = `stack (${target})`; + const fail = (message: string) => ({ name, ok: false, message }); + const stack = await reader.describe(stackName); + if (!stack) { + return fail( + `The deployed state records stack '${stackName}' for target '${target}', but it doesn't ` + + "exist. Redeploy the target with `agentcore deploy`, or remove the target " + + "from agentcore/.cli/deployed-state.json if it was deleted on purpose.", + ); + } + const status = stack.StackStatus ?? "UNKNOWN"; + if (!STABLE_STATUSES.has(status)) { + const advice = status.endsWith("_IN_PROGRESS") + ? "Wait for the operation in progress to finish, then try again." + : "Run `agentcore deploy` until it succeeds, then try again."; + return fail( + `Stack '${stackName}' is ${status}; it must be ${[...STABLE_STATUSES].join(", ")}. ${advice}`, + ); + } + if ((await reader.stackPolicy(stackName)) !== undefined) { + return fail( + `Stack '${stackName}' has a stack policy, and CloudFormation stack refactoring doesn't ` + + "support stacks with a stack policy (whatever it allows). Migration needs a refactor to " + + "keep the deployed resources, so it can't migrate this stack.", + ); + } + return { name, ok: true, message: `${stackName} is ${status}, with no stack policy` }; +} diff --git a/src/core/project/migrate/synth.test.ts b/src/core/project/migrate/synth.test.ts new file mode 100644 index 000000000..1f76bc5c9 --- /dev/null +++ b/src/core/project/migrate/synth.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { ProcessRunner } from "../../../io"; +import { CDK_LIBRARY_OVERRIDE_ENV, createWorkspaceSynthesizer, librarySpec } from "./synth"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function workspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-synth-test-")); + cleanups.push(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "agentcore", "cdk"), { recursive: true }); + await writeFile( + join(root, "agentcore", "cdk", "package.json"), + JSON.stringify({ dependencies: { "@aws/agentcore-cdk": "1.0.0-rc.3", constructs: "~10.7.0" } }), + ); + return root; +} + +function recordingRunner(): { + runner: ProcessRunner; + commands: { command: string[]; cwd: string }[]; +} { + const commands: { command: string[]; cwd: string }[] = []; + return { + commands, + runner: async (command, { cwd }) => { + commands.push({ command, cwd }); + }, + }; +} + +const manifest = async (root: string) => + JSON.parse(await readFile(join(root, "agentcore", "cdk", "package.json"), "utf8")) as { + dependencies: Record; + }; + +describe("createWorkspaceSynthesizer", () => { + test("installs and synthesizes in the workspace's CDK app, with deploy's synth command", async () => { + const root = await workspace(); + const { runner, commands } = recordingRunner(); + const steps: string[] = []; + + const assembly = await createWorkspaceSynthesizer({ runner, env: {} })(root, { + onStep: (step) => steps.push(step), + }); + + const cdk = join(root, "agentcore", "cdk"); + expect(assembly).toBe(join(cdk, "cdk.out")); + expect(commands).toEqual([ + { command: ["npm", "install", "--no-audit", "--no-fund"], cwd: cdk }, + { + command: ["npm", "run", "cdk", "--", "synth", "--quiet", "--output", join(cdk, "cdk.out")], + cwd: cdk, + }, + ]); + expect(steps).toHaveLength(2); + expect((await manifest(root)).dependencies["@aws/agentcore-cdk"]).toBe("1.0.0-rc.3"); + }); + + test(`${CDK_LIBRARY_OVERRIDE_ENV} replaces the library dependency before installing`, async () => { + const root = await workspace(); + const { runner } = recordingRunner(); + await createWorkspaceSynthesizer({ + runner, + env: { [CDK_LIBRARY_OVERRIDE_ENV]: "./lib.tgz" }, + cwd: "/work", + })(root); + expect((await manifest(root)).dependencies).toEqual({ + "@aws/agentcore-cdk": `file:${resolve("/work", "lib.tgz")}`, + constructs: "~10.7.0", + }); + }); + + test("propagates a failed install or synth", async () => { + const root = await workspace(); + const runner: ProcessRunner = async (command) => { + if (command[1] === "run") throw new Error("synth failed"); + }; + await expect(createWorkspaceSynthesizer({ runner, env: {} })(root)).rejects.toThrow( + "synth failed", + ); + }); +}); + +describe("librarySpec", () => { + test("turns paths into absolute file: specs and passes npm specs through", () => { + // Resolved the platform's way, so these hold on Windows too. + const file = (...segments: string[]) => `file:${resolve(...segments)}`; + expect(librarySpec("./x.tgz", "/w")).toBe(file("/w", "x.tgz")); + expect(librarySpec("../lib", "/w/a")).toBe(file("/w", "lib")); + expect(librarySpec("/abs/lib.tgz", "/w")).toBe(file("/abs/lib.tgz")); + expect(librarySpec("aws-agentcore-cdk-1.0.0.tgz", "/w")).toBe( + file("/w", "aws-agentcore-cdk-1.0.0.tgz"), + ); + expect(librarySpec("file:../lib", "/w")).toBe("file:../lib"); + expect(librarySpec("1.0.0-rc.4", "/w")).toBe("1.0.0-rc.4"); + expect(librarySpec("@aws/agentcore-cdk@next", "/w")).toBe("@aws/agentcore-cdk@next"); + }); +}); diff --git a/src/core/project/migrate/synth.ts b/src/core/project/migrate/synth.ts new file mode 100644 index 000000000..cc68d3b36 --- /dev/null +++ b/src/core/project/migrate/synth.ts @@ -0,0 +1,78 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import type { ProcessRunner } from "../../../io"; +import { CDK_LIBRARY_OVERRIDE_ENV } from "../../../handlers/project/migrate/types"; +import { synthCommand } from "../backends/cdk/assembly"; + +/** + * Development and test override for the `@aws/agentcore-cdk` dependency of the + * v1 CDK app that `migrate` synthesizes: an npm version or spec, or a path to a + * tarball or package directory (made absolute, so `npm pack` output in the + * current directory works). Lets the planner run against an unpublished library + * build; never needed with a released CLI, whose vended app already pins a + * library that understands the pinned names. + */ +export { CDK_LIBRARY_OVERRIDE_ENV }; + +const CDK_LIBRARY = "@aws/agentcore-cdk"; + +/** Synthesizes the migration workspace's v1 CDK app; returns the cloud assembly directory. */ +export type WorkspaceSynthesizer = ( + workspaceDirectory: string, + options?: { onStep?: (message: string) => void }, +) => Promise; + +export type WorkspaceSynthesizerConfig = { + runner: ProcessRunner; + /** Read for {@link CDK_LIBRARY_OVERRIDE_ENV}; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Where a relative override path is resolved from; defaults to the current directory. */ + cwd?: string; +}; + +/** + * Installs the v1 CDK app's dependencies in the workspace and synthesizes it, + * with the same command `agentcore deploy` builds a project with. Only the + * workspace (a temporary copy) is written to. + */ +export function createWorkspaceSynthesizer( + config: WorkspaceSynthesizerConfig, +): WorkspaceSynthesizer { + const env = config.env ?? process.env; + return async (workspaceDirectory, options = {}) => { + const cdkDirectory = join(workspaceDirectory, "agentcore", "cdk"); + const override = env[CDK_LIBRARY_OVERRIDE_ENV]?.trim(); + if (override) { + await overrideCdkLibrary(cdkDirectory, librarySpec(override, config.cwd ?? process.cwd())); + } + + options.onStep?.("Installing the v1 CDK app's dependencies (npm install)"); + await config.runner(["npm", "install", "--no-audit", "--no-fund"], { cwd: cdkDirectory }); + + options.onStep?.("Synthesizing the v1 CloudFormation templates"); + const assemblyDirectory = join(cdkDirectory, "cdk.out"); + await config.runner(synthCommand(assemblyDirectory), { cwd: cdkDirectory }); + return assemblyDirectory; + }; +} + +/** A path (to a tarball or package directory) becomes a `file:` spec; anything else is an npm spec. */ +export function librarySpec(override: string, cwd: string): string { + const looksLikePath = + override.startsWith(".") || + isAbsolute(override) || + override.endsWith(".tgz") || + existsSync(resolve(cwd, override)); + if (override.startsWith("file:") || !looksLikePath) return override; + return `file:${resolve(cwd, override)}`; +} + +async function overrideCdkLibrary(cdkDirectory: string, spec: string): Promise { + const manifestPath = join(cdkDirectory, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + dependencies?: Record; + }; + manifest.dependencies = { ...manifest.dependencies, [CDK_LIBRARY]: spec }; + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +} diff --git a/src/core/project/migrate/templateStore.test.ts b/src/core/project/migrate/templateStore.test.ts new file mode 100644 index 000000000..f460b1662 --- /dev/null +++ b/src/core/project/migrate/templateStore.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { ProjectStateError } from "../../../errors"; +import { fakeCloudFormation, fakeTemplateStore } from "./migrate-test-support"; +import { + bootstrapBucket, + MAX_TEMPLATE_BODY_BYTES, + templateLocation, + templateUrl, +} from "./templateStore"; + +describe("templateLocation", () => { + test("passes a template inline up to the limit, and uploads a bigger one", async () => { + const store = fakeTemplateStore(); + const small = await templateLocation({ Resources: {} }, "S", async () => store); + expect(small.location).toEqual({ TemplateBody: '{"Resources":{}}' }); + + // Exactly at the limit still fits. + const padding = MAX_TEMPLATE_BODY_BYTES - JSON.stringify({ p: "" }).length; + const atLimit = await templateLocation({ p: "x".repeat(padding) }, "S", async () => store); + expect("TemplateBody" in atLimit.location).toBe(true); + + const big = await templateLocation({ p: "x".repeat(padding + 1) }, "S", async () => store); + expect(big.location).toEqual({ TemplateURL: `https://bootstrap.example/${store.puts[0]}` }); + await big.release(); + expect(store.removed).toEqual(store.puts); + }); +}); + +describe("bootstrapBucket", () => { + test("reads the bucket from the CDKToolkit stack's outputs", async () => { + const cloudFormation = fakeCloudFormation( + {}, + { bootstrapBucket: "cdk-hnb659fds-assets-1-us-east-1" }, + ); + expect(await bootstrapBucket(cloudFormation.create({ region: "us-east-1" } as never))).toBe( + "cdk-hnb659fds-assets-1-us-east-1", + ); + }); + + test("explains what to do when there's no bootstrap stack", async () => { + const cloudFormation = fakeCloudFormation({}); + const read = bootstrapBucket(cloudFormation.create({ region: "us-east-1" } as never)); + await expect(read).rejects.toBeInstanceOf(ProjectStateError); + await expect( + bootstrapBucket(cloudFormation.create({ region: "us-east-1" } as never)), + ).rejects.toThrow("the CDKToolkit stack has no BucketName output"); + }); +}); + +describe("templateUrl", () => { + test("is path-style, in the region's partition", () => { + expect( + templateUrl("cdk-hnb659fds-assets-1-us-east-1", "us-east-1", "agentcore-migrate/S-1.json"), + ).toBe( + "https://s3.us-east-1.amazonaws.com/cdk-hnb659fds-assets-1-us-east-1/agentcore-migrate/S-1.json", + ); + expect(templateUrl("b.with.dots", "cn-north-1", "k.json")).toBe( + "https://s3.cn-north-1.amazonaws.com.cn/b.with.dots/k.json", + ); + }); +}); diff --git a/src/core/project/migrate/templateStore.ts b/src/core/project/migrate/templateStore.ts new file mode 100644 index 000000000..c974956b7 --- /dev/null +++ b/src/core/project/migrate/templateStore.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import { DescribeStacksCommand, type CloudFormationClient } from "@aws-sdk/client-cloudformation"; +import { ProjectStateError } from "../../../errors"; +import type { CdkCredentialProvider } from "../backends/cdk/toolkit"; +import { isStackNotFound } from "../backends/cdk/environment"; + +/** + * The largest template CloudFormation accepts inline (TemplateBody), in bytes. + * Anything bigger has to be uploaded to S3 and passed as a TemplateURL. + */ +export const MAX_TEMPLATE_BODY_BYTES = 51_200; + +/** Where a stack refactor or change set reads a template from: inline, or from S3. */ +export type TemplateLocation = { TemplateBody: string } | { TemplateURL: string }; + +/** Holds templates too big to pass inline, for the length of one CloudFormation call. */ +export type TemplateStore = { + /** Uploads `body` and returns its URL. */ + put(key: string, body: string): Promise; + /** Deletes what `put` uploaded under `key`; never fails the migration. */ + remove(key: string): Promise; +}; + +/** Builds the store for a region, once a template turns out to need one. */ +export type TemplateStoreFactory = (region: string) => Promise; + +/** + * A template's location for a CloudFormation call: inline when it fits, otherwise + * uploaded to the store under a key unique to its content. `release` deletes the + * upload once CloudFormation has read it. + */ +export async function templateLocation( + template: unknown, + name: string, + store: () => Promise, +): Promise<{ location: TemplateLocation; release(): Promise }> { + const body = JSON.stringify(template); + if (Buffer.byteLength(body, "utf8") <= MAX_TEMPLATE_BODY_BYTES) { + return { location: { TemplateBody: body }, release: async () => {} }; + } + const digest = createHash("sha256").update(body).digest("hex").slice(0, 16); + const key = `agentcore-migrate/${name}-${digest}.json`; + const resolved = await store(); + const url = await resolved.put(key, body); + return { location: { TemplateURL: url }, release: () => resolved.remove(key) }; +} + +const BOOTSTRAP_STACK_NAME = "CDKToolkit"; +const BUCKET_OUTPUT = "BucketName"; + +/** + * The CDK bootstrap bucket of the account and region `client` is for, read from + * the bootstrap stack's outputs the way the CDK finds it. v0 deploys always + * bootstrap, so a deployed project's region has one. + */ +export async function bootstrapBucket(client: CloudFormationClient): Promise { + let outputs; + try { + const response = await client.send( + new DescribeStacksCommand({ StackName: BOOTSTRAP_STACK_NAME }), + ); + outputs = response.Stacks?.[0]?.Outputs; + } catch (error) { + if (!isStackNotFound(error)) throw error; + } + const bucket = outputs?.find((output) => output.OutputKey === BUCKET_OUTPUT)?.OutputValue; + if (!bucket) { + throw new ProjectStateError( + `A template is larger than CloudFormation's ${MAX_TEMPLATE_BODY_BYTES}-byte inline limit, ` + + `so it has to be uploaded to the CDK bootstrap bucket, but the ${BOOTSTRAP_STACK_NAME} ` + + `stack has no ${BUCKET_OUTPUT} output. Bootstrap the environment with ` + + "`agentcore deploy`, then try again.", + ); + } + return bucket; +} + +/** + * A template store backed by the CDK bootstrap bucket of `region`. The S3 client is + * loaded on first use, since most templates fit inline. + */ +export function bootstrapBucketStore(config: { + cloudFormation: CloudFormationClient; + credentials: CdkCredentialProvider; + region: string; +}): TemplateStore { + let ready: Promise<{ bucket: string; send: (command: unknown) => Promise }> | undefined; + const connect = () => + (ready ??= (async () => { + const bucket = await bootstrapBucket(config.cloudFormation); + const { S3Client } = await import("@aws-sdk/client-s3"); + const client = new S3Client({ credentials: config.credentials, region: config.region }); + return { bucket, send: (command: unknown) => client.send(command as never) }; + })()); + return { + async put(key, body) { + const { bucket, send } = await connect(); + const { PutObjectCommand } = await import("@aws-sdk/client-s3"); + await send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentType: "application/json", + }), + ); + return templateUrl(bucket, config.region, key); + }, + async remove(key) { + try { + const { bucket, send } = await connect(); + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + await send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + } catch { + // A leftover template in the bootstrap bucket is harmless (the CDK leaves + // its own there too); failing the migration over it would not be. + } + }, + }; +} + +/** + * The URL CloudFormation reads an uploaded template from. Path-style, as the CDK + * uses for templates, so a bucket name with dots still matches the certificate; + * China regions use their own domain. + */ +export function templateUrl(bucket: string, region: string, key: string): string { + const domain = region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com"; + return `https://s3.${region}.${domain}/${bucket}/${key}`; +} diff --git a/src/core/project/migrate/templates.test.ts b/src/core/project/migrate/templates.test.ts new file mode 100644 index 000000000..9ea9d9187 --- /dev/null +++ b/src/core/project/migrate/templates.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { + canonicalJson, + dependsOnOf, + isCustomResource, + referencesIn, + renameReferences, + stripReferences, +} from "./templates"; + +const RESOURCES = new Set(["Role", "Fn", "Bucket"]); +const isResource = (id: string) => RESOURCES.has(id); + +describe("referencesIn", () => { + test("finds Ref, both Fn::GetAtt forms, and Fn::Sub placeholders, with their paths", () => { + const refs = referencesIn( + { + RoleArn: { "Fn::GetAtt": ["Role", "Arn"] }, + FunctionName: { "Fn::GetAtt": "Fn.Arn" }, + Roles: [{ Ref: "Role" }], + Policy: { + Statement: [ + { Resource: { "Fn::Join": ["", [{ "Fn::GetAtt": ["Bucket", "Arn"] }, "/*"]] } }, + ], + }, + Url: { "Fn::Sub": "https://${Bucket}/${Fn.Arn}" }, + }, + isResource, + ); + expect(refs).toEqual([ + { target: "Role", path: "RoleArn", attribute: "Arn" }, + { target: "Fn", path: "FunctionName", attribute: "Arn" }, + { target: "Role", path: "Roles.[]" }, + { target: "Bucket", path: "Policy.Statement.[].Resource.[]", attribute: "Arn" }, + { target: "Bucket", path: "Url" }, + { target: "Fn", path: "Url", attribute: "Arn" }, + ]); + }); + + test("skips parameters, pseudo parameters, Fn::Sub variables, and escapes", () => { + const refs = referencesIn( + { + A: { Ref: "AWS::Region" }, + B: { Ref: "BootstrapVersion" }, + C: { + "Fn::Sub": ["${Role}-${!Fn}-${AWS::AccountId}", { Role: { Ref: "Bucket" } }], + }, + }, + isResource, + ); + expect(refs).toEqual([{ target: "Bucket", path: "C.Role" }]); + }); +}); + +describe("renameReferences", () => { + const renames = new Map([ + ["Role", "NewRole"], + ["Fn", "NewFn"], + ]); + + test("rewrites every reference form, and nothing else", () => { + expect( + renameReferences( + { + RoleArn: { "Fn::GetAtt": ["Role", "Arn"] }, + FunctionName: { "Fn::GetAtt": "Fn.Arn" }, + Whole: { "Fn::GetAtt": "Fn" }, + Roles: [{ Ref: "Role" }, { Ref: "Other" }], + PolicyName: "Role", + Url: { "Fn::Sub": "${Role}/${Fn.Arn}/${!Role}/${Other}" }, + WithVars: { "Fn::Sub": ["${Role}-${Fn}", { Role: { Ref: "Fn" } }] }, + }, + renames, + ), + ).toEqual({ + RoleArn: { "Fn::GetAtt": ["NewRole", "Arn"] }, + FunctionName: { "Fn::GetAtt": "NewFn.Arn" }, + Whole: { "Fn::GetAtt": "NewFn" }, + Roles: [{ Ref: "NewRole" }, { Ref: "Other" }], + PolicyName: "Role", + Url: { "Fn::Sub": "${NewRole}/${NewFn.Arn}/${!Role}/${Other}" }, + // ${Role} is the Fn::Sub's own variable here, so only ${Fn} and the map's Ref change. + WithVars: { "Fn::Sub": ["${Role}-${NewFn}", { Role: { Ref: "NewFn" } }] }, + }); + }); + + test("leaves objects that only look like intrinsics alone", () => { + const value = { Ref: "Role", Extra: 1 }; + expect(renameReferences(value, renames)).toEqual(value); + }); +}); + +describe("stripReferences", () => { + test("replaces references with placeholders, so renamed values compare equal", () => { + const a = { + X: { Ref: "Role" }, + Y: { "Fn::GetAtt": ["Fn", "Arn"] }, + Z: { "Fn::Sub": "${Role}-${AWS::Region}" }, + }; + const b = { + X: { Ref: "Other" }, + Y: { "Fn::GetAtt": ["Else", "Arn"] }, + Z: { "Fn::Sub": "${Other}-${AWS::Region}" }, + }; + const anyResource = (id: string) => !id.startsWith("AWS::"); + expect(canonicalJson(stripReferences(a, anyResource))).toBe( + canonicalJson(stripReferences(b, anyResource)), + ); + expect(stripReferences({ Z: { "Fn::Sub": ["${Role}", { Role: "x" }] } }, anyResource)).toEqual({ + Z: { "Fn::Sub": ["${Role}", { Role: "x" }] }, + }); + }); +}); + +describe("helpers", () => { + test("canonicalJson sorts keys at every level and drops undefined", () => { + expect(canonicalJson({ b: [{ d: 1, c: 2 }], a: undefined, e: null })).toBe( + '{"b":[{"c":2,"d":1}],"e":null}', + ); + }); + + test("dependsOnOf reads both forms", () => { + expect(dependsOnOf({ Type: "T", DependsOn: "A" })).toEqual(["A"]); + expect(dependsOnOf({ Type: "T", DependsOn: ["A", "B"] })).toEqual(["A", "B"]); + expect(dependsOnOf({ Type: "T" })).toEqual([]); + }); + + test("isCustomResource covers both custom resource spellings", () => { + expect(isCustomResource("Custom::AWSCDKCfnJson")).toBe(true); + expect(isCustomResource("AWS::CloudFormation::CustomResource")).toBe(true); + expect(isCustomResource("AWS::Lambda::Function")).toBe(false); + }); +}); diff --git a/src/core/project/migrate/templates.ts b/src/core/project/migrate/templates.ts new file mode 100644 index 000000000..fd60142b4 --- /dev/null +++ b/src/core/project/migrate/templates.ts @@ -0,0 +1,276 @@ +/** + * The parts of a CloudFormation template migration planning reads, and the + * intrinsic-function walking shared by the logical ID mapping, the bridge + * template, and its digest check. + */ + +export type CfnResource = { + Type: string; + Properties?: Record; + DependsOn?: string | string[]; + [key: string]: unknown; +}; + +export type CfnTemplate = { + Resources?: Record; + Outputs?: Record; + Conditions?: Record; + [key: string]: unknown; +}; + +/** One reference from a resource to another resource in the same template. */ +export type ResourceReference = { + /** The referenced logical ID. */ + target: string; + /** + * Where in the referencing resource's properties the reference sits: property + * keys joined with `.`, every array level written `[]`, intrinsic function + * names left out. Stable across CDK versions that lay a property out the same + * way, which is what lets references be paired between two templates. + */ + path: string; + /** The attribute read with Fn::GetAtt; absent for a Ref or a `${Id}` substitution. */ + attribute?: string; +}; + +/** The resources of a template, or none. */ +export function resourcesOf(template: CfnTemplate): Record { + return template.Resources ?? {}; +} + +/** Custom resources run a provider on every create and delete, and CloudFormation can't move them. */ +export function isCustomResource(type: string): boolean { + return type.startsWith("Custom::") || type === "AWS::CloudFormation::CustomResource"; +} + +/** The resource every CDK stack carries for version reporting; never worth moving or reporting. */ +export const CDK_METADATA_TYPE = "AWS::CDK::Metadata"; + +/** `${Name}` and `${Name.Attribute}` placeholders in an Fn::Sub string; `${!Literal}` is an escape. */ +const SUB_PLACEHOLDER = /\$\{([^}!][^}]*)\}/g; + +/** + * Every reference in `value` to a resource for which `isResource` holds: Ref, + * Fn::GetAtt in both its array and dotted-string forms, and Fn::Sub placeholders + * (except the ones the Fn::Sub's own variable map defines). Refs to parameters + * and pseudo parameters are not resources and are skipped. + */ +export function referencesIn( + value: unknown, + isResource: (logicalId: string) => boolean, +): ResourceReference[] { + const found: ResourceReference[] = []; + const visit = (node: unknown, path: string[]): void => { + if (Array.isArray(node)) { + const inArray = path.at(-1) === "[]" ? path : [...path, "[]"]; + for (const element of node) visit(element, inArray); + return; + } + if (typeof node !== "object" || node === null) return; + const record = node as Record; + const at = path.join("."); + + const ref = record.Ref; + if (typeof ref === "string" && Object.keys(record).length === 1) { + if (isResource(ref)) found.push({ target: ref, path: at }); + return; + } + const getAtt = getAttParts(record["Fn::GetAtt"]); + if (getAtt && Object.keys(record).length === 1) { + if (isResource(getAtt.target)) { + found.push({ target: getAtt.target, path: at, attribute: getAtt.attribute }); + } + return; + } + const sub = subParts(record["Fn::Sub"]); + if (sub && Object.keys(record).length === 1) { + for (const placeholder of sub.template.matchAll(SUB_PLACEHOLDER)) { + const [target = "", ...attribute] = placeholder[1]!.split("."); + if (sub.variables.has(target) || !isResource(target)) continue; + found.push({ + target, + path: at, + ...(attribute.length > 0 && { attribute: attribute.join(".") }), + }); + } + visit(sub.variableMap, path); + return; + } + for (const [key, child] of Object.entries(record)) { + visit(child, key.startsWith("Fn::") ? path : [...path, key]); + } + }; + visit(value, []); + return found; +} + +/** The logical IDs a resource names in DependsOn. */ +export function dependsOnOf(resource: CfnResource): string[] { + const dependsOn = resource.DependsOn; + if (typeof dependsOn === "string") return [dependsOn]; + return Array.isArray(dependsOn) ? dependsOn.filter((id) => typeof id === "string") : []; +} + +/** + * Returns `value` with every reference to a logical ID in `renames` rewritten to + * the new ID: Ref, Fn::GetAtt (array and dotted-string forms), and Fn::Sub + * placeholders that aren't the Fn::Sub's own variables. Nothing else changes, so + * a literal that happens to equal a logical ID (such as the PolicyName CDK gives + * a DefaultPolicy) is left as it is. + */ +export function renameReferences(value: unknown, renames: ReadonlyMap): unknown { + if (Array.isArray(value)) return value.map((element) => renameReferences(element, renames)); + if (typeof value !== "object" || value === null) return value; + const record = value as Record; + const keys = Object.keys(record); + + if (keys.length === 1 && typeof record.Ref === "string") { + return { Ref: renames.get(record.Ref) ?? record.Ref }; + } + if (keys.length === 1 && "Fn::GetAtt" in record) { + const getAtt = record["Fn::GetAtt"]; + if (Array.isArray(getAtt) && typeof getAtt[0] === "string") { + return { + "Fn::GetAtt": [ + renames.get(getAtt[0]) ?? getAtt[0], + ...getAtt.slice(1).map((part) => renameReferences(part, renames)), + ], + }; + } + if (typeof getAtt === "string") { + const dot = getAtt.indexOf("."); + const target = dot === -1 ? getAtt : getAtt.slice(0, dot); + const renamed = renames.get(target); + return { "Fn::GetAtt": renamed ? `${renamed}${getAtt.slice(target.length)}` : getAtt }; + } + } + if (keys.length === 1 && "Fn::Sub" in record) { + const sub = subParts(record["Fn::Sub"]); + if (sub) { + const template = sub.template.replace(SUB_PLACEHOLDER, (whole, inner: string) => { + const [target = "", ...rest] = inner.split("."); + const renamed = sub.variables.has(target) ? undefined : renames.get(target); + return renamed ? `\${${[renamed, ...rest].join(".")}}` : whole; + }); + return { + "Fn::Sub": Array.isArray(record["Fn::Sub"]) + ? [template, renameReferences(sub.variableMap, renames)] + : template, + }; + } + } + return Object.fromEntries( + Object.entries(record).map(([key, child]) => [key, renameReferences(child, renames)]), + ); +} + +function getAttParts(value: unknown): { target: string; attribute: string } | undefined { + if (Array.isArray(value) && typeof value[0] === "string") { + return { target: value[0], attribute: typeof value[1] === "string" ? value[1] : "" }; + } + if (typeof value === "string") { + const dot = value.indexOf("."); + return dot === -1 + ? { target: value, attribute: "" } + : { target: value.slice(0, dot), attribute: value.slice(dot + 1) }; + } + return undefined; +} + +function subParts( + value: unknown, +): { template: string; variables: Set; variableMap: unknown } | undefined { + if (typeof value === "string") return { template: value, variables: new Set(), variableMap: {} }; + if (Array.isArray(value) && typeof value[0] === "string") { + const map = value[1]; + const variables = + typeof map === "object" && map !== null && !Array.isArray(map) ? Object.keys(map) : []; + return { template: value[0], variables: new Set(variables), variableMap: map ?? {} }; + } + return undefined; +} + +/** A canonical string for a JSON value: object keys sorted, so key order never matters. */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** + * `value` with every reference for which `isTarget` holds rewritten by `replace`, + * which receives the referenced ID and attribute (absent for a Ref) and returns + * the value to put in place of a Ref or Fn::GetAtt, or the text to put in place + * of an Fn::Sub placeholder. + */ +function mapReferences( + value: unknown, + isTarget: (logicalId: string) => boolean, + replace: (target: string, attribute: string | undefined, inSub: boolean) => unknown, +): unknown { + const visit = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(visit); + if (typeof node !== "object" || node === null) return node; + const record = node as Record; + if (Object.keys(record).length === 1) { + if (typeof record.Ref === "string") { + return isTarget(record.Ref) ? replace(record.Ref, undefined, false) : record; + } + const getAtt = getAttParts(record["Fn::GetAtt"]); + if (getAtt) { + return isTarget(getAtt.target) ? replace(getAtt.target, getAtt.attribute, false) : record; + } + const sub = subParts(record["Fn::Sub"]); + if (sub) { + const template = sub.template.replace(SUB_PLACEHOLDER, (whole, inner: string) => { + const [target = "", ...attribute] = inner.split("."); + if (sub.variables.has(target) || !isTarget(target)) return whole; + return String( + replace(target, attribute.length > 0 ? attribute.join(".") : undefined, true), + ); + }); + return { + "Fn::Sub": Array.isArray(record["Fn::Sub"]) + ? [template, visit(sub.variableMap)] + : template, + }; + } + } + return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, visit(child)])); + }; + return visit(value); +} + +/** + * `value` with every reference to a resource (per `isResource`) replaced by a + * placeholder that keeps the attribute read, so two values that differ only in + * which logical IDs they point at compare equal. Refs to parameters and pseudo + * parameters stay as they are. + */ +export function stripReferences( + value: unknown, + isResource: (logicalId: string) => boolean, +): unknown { + return mapReferences(value, isResource, (_target, attribute, inSub) => { + if (inSub) return attribute === undefined ? "${__ref__}" : `\${__ref__.${attribute}}`; + return attribute === undefined ? { __ref__: "Ref" } : { __ref__: "Fn::GetAtt", attribute }; + }); +} + +/** + * `value` with every reference to a resource in `isTarget` replaced by the + * literal `marker`: how a resource reads once those resources resolve to some + * physical ID. + */ +export function substituteReferences( + value: T, + isTarget: (logicalId: string) => boolean, + marker: string, +): T { + return mapReferences(value, isTarget, () => marker) as T; +} diff --git a/src/core/project/migrate/v0CdkApp.test.ts b/src/core/project/migrate/v0CdkApp.test.ts new file mode 100644 index 000000000..7ba30fed1 --- /dev/null +++ b/src/core/project/migrate/v0CdkApp.test.ts @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { v0App, v0AppFixture, writeV0Project, type V0ProjectFiles } from "./migrate-test-support"; +import { deployImpact } from "./replacements"; +import { + appBuildsHarnessContainers, + checkCdkApp, + resourcesOutsideV0App, + sourceFingerprint, +} from "./v0CdkApp"; +import { V0_CDK_APPS } from "./v0CdkApps"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +async function project(options: V0ProjectFiles = {}): Promise { + const { root, cleanup } = await writeV0Project(options); + cleanups.push(cleanup); + return root; +} + +const STACK_FILE = join("agentcore", "cdk", "lib", "cdk-stack.ts"); + +async function write(root: string, path: string, content: string): Promise { + await mkdir(join(root, path, ".."), { recursive: true }); + await writeFile(join(root, path), content); +} + +describe("sourceFingerprint", () => { + test("ignores formatting, comments, and quotes, but not code", () => { + const source = "const a: string | undefined = 'x'; // note\nfoo(a, b);\n"; + expect(sourceFingerprint(source)).toBe( + sourceFingerprint( + '/* header */\nconst a:\n | string\n | undefined = "x"\nfoo(\n a,\n b,\n)\n', + ), + ); + expect(sourceFingerprint(source)).not.toBe(sourceFingerprint(`${source}bar();\n`)); + // A URL in a string isn't a comment. + expect(sourceFingerprint("const u = 'https://a';")).not.toBe( + sourceFingerprint("const u = 'https:';"), + ); + }); + + test("recognizes the apps v0 created, as `agentcore create` formatted them", () => { + const known = (bin: string, lib: string) => + V0_CDK_APPS.filter( + (app) => + app.files["bin/cdk.ts"] === sourceFingerprint(v0AppFixture(bin)) && + app.files["lib/cdk-stack.ts"] === sourceFingerprint(v0AppFixture(lib)), + ).map((app) => app.releases); + expect(known("cdk.ts", "cdk-stack.ts")).toEqual([ + "0.28.0 to 0.31.1 or 1.0.0-preview.28 to 1.0.0-preview.33", + ]); + expect(known("cdk-0.4.0.ts", "cdk-stack-0.4.0.ts")).toContain("0.4.0 to 0.9.1"); + // The previews from 3 to 9 shipped this source, with different other files. + expect(known("cdk-1.0.0-preview.5.ts", "cdk-stack-1.0.0-preview.5.ts")).toEqual([ + "1.0.0-preview.3 to 1.0.0-preview.5", + "1.0.0-preview.6 to 1.0.0-preview.9", + ]); + }); +}); + +describe("checkCdkApp", () => { + test("passes for an unchanged v0 app, including old releases and the 1.0.0 previews", async () => { + expect(await checkCdkApp(await project())).toEqual({ + name: "cdk app", + ok: true, + message: + "unchanged from the app v0 0.28.0 to 0.31.1 or 1.0.0-preview.28 to 1.0.0-preview.33 created", + }); + for (const app of ["0.4.0", "1.0.0-preview.5"]) { + const check = await checkCdkApp(await project({ app, cdkPin: "^0.1.0-alpha.1" })); + expect(check.ok, `${app}: ${check.message}`).toBe(true); + } + }); + + test("passes when a v0 deploy re-pinned the dependencies and npm wrote a lockfile", async () => { + const manifest = JSON.parse(v0App("0.4.0")["package.json"]!) as { + dependencies: Record; + devDependencies: Record; + }; + manifest.dependencies["aws-cdk-lib"] = "2.261.0"; + manifest.devDependencies["aws-cdk"] = "2.1126.0"; + const lockfile = { + lockfileVersion: 3, + packages: { + "": { dependencies: manifest.dependencies, devDependencies: manifest.devDependencies }, + "node_modules/aws-cdk-lib": { version: "2.261.0" }, + }, + }; + const root = await project({ + app: "0.4.0", + files: { + "agentcore/cdk/package.json": JSON.stringify(manifest), + "agentcore/cdk/package-lock.json": JSON.stringify(lockfile), + "agentcore/cdk/dist/bin/cdk.js": "// build output\n", + "agentcore/cdk/cdk.out/tree.json": "{}\n", + }, + }); + expect((await checkCdkApp(root)).ok).toBe(true); + }); + + test("blocks an edited source file, naming it", async () => { + const root = await project(); + await writeFile( + join(root, STACK_FILE), + `${v0AppFixture("cdk-stack.ts")}\nnew s3.Bucket(this, 'Uploads');\n`, + ); + const check = await checkCdkApp(root); + expect(check.ok).toBe(false); + expect(check.message).toContain(" modified agentcore/cdk/lib/cdk-stack.ts"); + expect(check.message).toContain("compared with v0 0.28.0 to 0.31.1"); + expect(check.message).toContain("Move custom code and files out of agentcore/cdk"); + expect(check.message).toContain("If you didn't change these files"); + }); + + test("blocks every file added or modified anywhere in agentcore/cdk", async () => { + const root = await project(); + await write(root, "agentcore/cdk/lib/uploads.ts", "export const bucket = 1;\n"); + await write(root, "agentcore/cdk/notes.txt", "todo\n"); + await write(root, "agentcore/cdk/README.md", "# My app\n"); + await write(root, "agentcore/cdk/cdk.json", JSON.stringify({ app: "npx ts-node bin/cdk.ts" })); + const check = await checkCdkApp(root); + expect(check.ok).toBe(false); + const listed = check.message.split("\n").filter((line) => /^ {2}(added|modified) /.test(line)); + expect(listed).toEqual([ + " added agentcore/cdk/lib/uploads.ts", + " added agentcore/cdk/notes.txt", + " modified agentcore/cdk/README.md", + " modified agentcore/cdk/cdk.json", + ]); + }); + + test("blocks a package.json with another dependency or changed scripts", async () => { + const withDependency = await project(); + const manifest = JSON.parse(v0App("0.31.1")["package.json"]!) as Record< + string, + Record + >; + await write( + withDependency, + "agentcore/cdk/package.json", + JSON.stringify({ ...manifest, dependencies: { ...manifest.dependencies, lodash: "4" } }), + ); + expect((await checkCdkApp(withDependency)).message).toContain( + " modified agentcore/cdk/package.json", + ); + + const withScript = await project(); + await write( + withScript, + "agentcore/cdk/package.json", + JSON.stringify({ ...manifest, scripts: { ...manifest.scripts, deploy: "cdk deploy" } }), + ); + expect((await checkCdkApp(withScript)).message).toContain( + " modified agentcore/cdk/package.json", + ); + }); + + test("blocks a lockfile recording a package the manifest doesn't declare", async () => { + const root = await project({ + files: { + "agentcore/cdk/package-lock.json": JSON.stringify({ + lockfileVersion: 3, + packages: { "": { dependencies: { lodash: "4" } } }, + }), + }, + }); + expect((await checkCdkApp(root)).message).toContain( + " modified agentcore/cdk/package-lock.json", + ); + }); + + test("says a newer v0 release isn't recognized, from the CDK library it pinned", async () => { + const root = await project({ cdkPin: "0.1.0-alpha.55" }); + await writeFile(join(root, STACK_FILE), "// a newer release's stack\n"); + expect((await checkCdkApp(root)).message).toBe( + "This project's CDK app comes from a v0 CLI release newer than this CLI recognizes " + + "(@aws/agentcore-cdk 0.1.0-alpha.55). Update the AgentCore CLI and run agentcore migrate again.", + ); + }); + + test("declares the pre-GA 0.3.0 previews unsupported", async () => { + const root = await project({ app: "0.3.0-preview.1.0", cdkPin: "^0.1.0-alpha.1" }); + expect((await checkCdkApp(root)).message).toStartWith( + "This project was created by a 0.3.0-preview release, which agentcore migrate doesn't support.", + ); + }); + + test("fails when a source file is missing", async () => { + const root = await project(); + await rm(join(root, STACK_FILE)); + expect((await checkCdkApp(root)).message).toBe( + "Couldn't read the CDK app: agentcore/cdk/lib/cdk-stack.ts is missing.", + ); + }); + + test("the documented way to keep a resource passes: retain, deploy with v0, undo, migrate", async () => { + // 1. The user adds a removal policy to the v0 app: the app no longer matches. + const root = await project(); + const original = v0AppFixture("cdk-stack.ts"); + await writeFile( + join(root, STACK_FILE), + original.replace( + "super(scope, id, props);", + "super(scope, id, props);\n // keep the bucket\n this.node.findChild('Uploads');", + ), + ); + expect((await checkCdkApp(root)).ok).toBe(false); + // 2. (deploy with the v0 CLI, which writes Retain into the deployed template) + // 3. They undo the change: the app matches again. + await writeFile(join(root, STACK_FILE), original); + expect((await checkCdkApp(root)).ok).toBe(true); + // 4. migrate reads retention from the deployed template, so dropping it isn't a blocker. + const impact = await deployImpact( + { Resources: { Uploads: { Type: "AWS::S3::Bucket", DeletionPolicy: "Retain" } } }, + { Resources: {} }, + ); + expect(impact.removed).toMatchObject([{ logicalId: "Uploads", severity: "info" }]); + }); +}); + +describe("appBuildsHarnessContainers", () => { + test("is true for apps that pass hasDockerfile (v0.16.0 and later), false before", async () => { + expect(await appBuildsHarnessContainers(await project())).toBe(true); + const old = await project({ app: "0.4.0" }); + expect(await appBuildsHarnessContainers(old)).toBe(false); + }); +}); + +describe("resourcesOutsideV0App", () => { + const at = (path: string, Type = "AWS::IAM::Role") => ({ + Type, + Metadata: { "aws:cdk:path": `AgentCore-P-default/${path}` }, + }); + + test("flags resources under constructs the v0 stack doesn't define", () => { + expect( + resourcesOutsideV0App({ + Resources: { + Runtime: at("Application/AgentA/Runtime/Resource", "AWS::BedrockAgentCore::Runtime"), + Gateway: at("Mcp/GatewayG/Resource", "AWS::BedrockAgentCore::Gateway"), + Payment: at("PaymentPm/Resource", "AWS::BedrockAgentCore::PaymentManager"), + Meta: at("CDKMetadata/Default", "AWS::CDK::Metadata"), + Provider: at("AWSCDKCfnUtilsProviderCustomResourceProvider/Role"), + Unannotated: { Type: "AWS::SNS::Topic" }, + Uploads: at("Uploads/Resource", "AWS::S3::Bucket"), + }, + }), + ).toEqual(["Uploads (AWS::S3::Bucket, AgentCore-P-default/Uploads/Resource)"]); + }); + + test("accepts the container build project the v0 library adds at the stack root", () => { + expect( + resourcesOutsideV0App({ + Resources: { + Project: at("ContainerBuildProject/Project/Resource", "AWS::CodeBuild::Project"), + Role: at("ContainerBuildProject/ContainerBuildExecutionRole/Resource"), + // A VPC build's project, suffixed with the 8-hex network hash. + VpcProject: at( + "ContainerBuildProject-1a2b3c4d/Project/Resource", + "AWS::CodeBuild::Project", + ), + Other: at("ContainerBuildProjectX/Resource", "AWS::CodeBuild::Project"), + }, + }), + ).toEqual([ + "Other (AWS::CodeBuild::Project, AgentCore-P-default/ContainerBuildProjectX/Resource)", + ]); + }); +}); diff --git a/src/core/project/migrate/v0CdkApp.ts b/src/core/project/migrate/v0CdkApp.ts new file mode 100644 index 000000000..72bfd3110 --- /dev/null +++ b/src/core/project/migrate/v0CdkApp.ts @@ -0,0 +1,339 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import type { MigrationCheck } from "../../../handlers/project/migrate/types"; +import type { CfnTemplate } from "./templates"; +import { resourcesOf } from "./templates"; +import { V0_CDK_APPS, V0_CDK_DEPENDENCY_NAMES, type V0CdkApp } from "./v0CdkApps"; + +/** The v0 CDK app's directory, relative to the project root; migrating replaces it. */ +export const CDK_APP_DIRECTORY = "agentcore/cdk"; + +/** The v0 CDK app's source files, relative to the project root. */ +export const V0_APP_FILES = [ + join("agentcore", "cdk", "bin", "cdk.ts"), + join("agentcore", "cdk", "lib", "cdk-stack.ts"), +] as const; + +/** + * Directories in agentcore/cdk that aren't the app's own files: installed + * dependencies, synth output, and the TypeScript build. Never compared. + */ +const GENERATED_DIRECTORIES = new Set(["node_modules", "cdk.out", "dist"]); + +/** Written by `npm install`, not vended; checked only for the packages it records. */ +const LOCKFILE = "package-lock.json"; +const MANIFEST = "package.json"; + +/** The newest `@aws/agentcore-cdk` v0 library (0.1.0-alpha.N) a recognized release vended. */ +export const NEWEST_KNOWN_V0_LIBRARY_ALPHA = 54; + +/** + * The fingerprint of a source file, insensitive to formatting: comments, + * whitespace, quotes, and the punctuation Prettier adds or drops (semicolons, + * trailing commas, leading union bars, arrow-function parentheses) are ignored, + * so a file `agentcore create` reformatted still matches the asset it came from. + */ +export function sourceFingerprint(source: string): string { + const normalized = source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`])\/\/.*$/gm, "$1") + .replace(/["`]/g, "'") + .replace(/[\s;,|()]/g, ""); + return createHash("sha256").update(normalized).digest("hex").slice(0, 16); +} + +type Manifest = Record & { + dependencies?: Record; + devDependencies?: Record; +}; + +/** + * The fingerprint of a package.json without its dependency versions: the v0 + * CLI's deploy re-pins the CDK app's dependencies (and restores managed ones a + * user deleted), so only the rest of the manifest has to match what was vended. + */ +export function manifestFingerprint(manifest: Manifest): string { + const { dependencies: _dependencies, devDependencies: _devDependencies, ...rest } = manifest; + return createHash("sha256").update(stableJson(rest)).digest("hex").slice(0, 16); +} + +/** The names of a manifest's dependencies and devDependencies. */ +export function dependencyNames(manifest: Manifest): string[] { + return [ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.devDependencies ?? {}), + ]; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +/** + * Apps of releases migrate doesn't support: the pre-GA 0.3.0 previews laid + * projects out differently. (0.3.0-preview.9.0 vended the same app as v0.4.0.) + */ +const UNSUPPORTED_APP_FINGERPRINTS: readonly { bin: string; lib: string; releases: string }[] = [ + { bin: "bb8eb101dfdd3f64", lib: "861c221446b7a5ae", releases: "0.3.0-preview" }, +]; + +/** The 0.1.0-alpha.N of an `@aws/agentcore-cdk` dependency spec (`0.1.0-alpha.54`, `^0.1.0-alpha.19`). */ +function v0LibraryAlpha(spec: unknown): number | undefined { + const match = typeof spec === "string" ? /^\^?0\.1\.0-alpha\.(\d+)$/.exec(spec.trim()) : null; + return match ? Number(match[1]) : undefined; +} + +/** Every file under agentcore/cdk, relative to it and `/`-separated, outside the generated directories. */ +async function cdkAppFiles(rootPath: string): Promise { + const root = join(rootPath, ...CDK_APP_DIRECTORY.split("/")); + const files: string[] = []; + const walk = async (directory: string, prefix: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + // Skipped by name, whatever they are: node_modules is often a symlink. + if (GENERATED_DIRECTORIES.has(entry.name)) continue; + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = join(directory, entry.name); + const isDirectory = entry.isSymbolicLink() + ? (await stat(full).catch(() => undefined))?.isDirectory() === true + : entry.isDirectory(); + if (isDirectory) await walk(full, path); + else files.push(path); + } + }; + await walk(root, ""); + return files.sort(); +} + +/** What a project's agentcore/cdk is made of, read once for comparing with each vended app. */ +type CdkAppContents = { + /** Fingerprint of each file but the manifest and lockfile, by path relative to agentcore/cdk. */ + sources: Map; + /** The manifest's fingerprint and dependency names; undefined when it's missing or invalid. */ + manifest?: { fingerprint: string; dependencies: string[]; library: unknown }; + hasManifest: boolean; + /** Whether a lockfile is present, and whether it's valid and records only the manifest's packages. */ + lockfile?: { valid: boolean }; +}; + +async function readCdkApp(rootPath: string): Promise { + const directory = join(rootPath, ...CDK_APP_DIRECTORY.split("/")); + const files = await cdkAppFiles(rootPath); + const contents: CdkAppContents = { sources: new Map(), hasManifest: files.includes(MANIFEST) }; + const read = (file: string) => readFile(join(directory, ...file.split("/")), "utf8"); + for (const file of files) { + if (file === MANIFEST || file === LOCKFILE) continue; + contents.sources.set(file, sourceFingerprint(await read(file))); + } + if (contents.hasManifest) { + try { + const manifest = JSON.parse(await read(MANIFEST)) as Manifest; + contents.manifest = { + fingerprint: manifestFingerprint(manifest), + dependencies: dependencyNames(manifest), + library: manifest.dependencies?.["@aws/agentcore-cdk"], + }; + } catch { + // Reported as a modified file. + } + } + if (files.includes(LOCKFILE)) { + contents.lockfile = { valid: false }; + try { + const lock = JSON.parse(await read(LOCKFILE)) as { + lockfileVersion?: number; + packages?: Record; + dependencies?: unknown; + }; + const root = lock.packages?.[""]; + const allowed = new Set(contents.manifest?.dependencies ?? V0_CDK_DEPENDENCY_NAMES); + // npm 7+ records the project's own dependencies under packages[""]; npm 6's + // version-1 lockfile lists only resolved packages, so there's nothing to compare. + contents.lockfile.valid = root + ? dependencyNames(root).every((name) => allowed.has(name)) + : lock.lockfileVersion === 1 && typeof lock.dependencies === "object"; + } catch { + // Reported as a modified file. + } + } + return contents; +} + +/** How a project's CDK app differs from one vended app: files it added, and files it changed. */ +function differences( + contents: CdkAppContents, + app: V0CdkApp, +): { added: string[]; modified: string[] } { + const added: string[] = []; + const modified: string[] = []; + for (const [file, fingerprint] of contents.sources) { + const vended = app.files[file]; + if (vended === undefined) added.push(file); + else if (vended !== fingerprint) modified.push(file); + } + if (contents.hasManifest) { + const known = new Set(V0_CDK_DEPENDENCY_NAMES); + if ( + contents.manifest === undefined || + contents.manifest.fingerprint !== app.packageJson || + !contents.manifest.dependencies.every((name) => known.has(name)) + ) { + modified.push(MANIFEST); + } + } + if (contents.lockfile && !contents.lockfile.valid) modified.push(LOCKFILE); + return { added, modified }; +} + +/** + * The project's agentcore/cdk must be exactly what a v0 release vended: migrating + * replaces the whole directory with v1's app, so anything the user added or + * changed there (custom constructs, escape hatches, helper files, extra + * dependencies) would be lost, and resources it creates deleted by the first v1 + * deploy. Every file is compared with each known release's (formatting aside), + * except what isn't the app's own: node_modules, cdk.out, dist, and the lockfile + * (checked only for the packages it records). The manifest may differ only in its + * dependency versions, which the v0 CLI re-pins on deploy. An app that matches no + * release is either edited or from a release this CLI doesn't know; the + * `@aws/agentcore-cdk` version tells them apart. + */ +export async function checkCdkApp(rootPath: string): Promise { + const name = "cdk app"; + let contents: CdkAppContents; + try { + contents = await readCdkApp(rootPath); + } catch (error) { + return { name, ok: false, message: `Couldn't read the CDK app: ${(error as Error).message}` }; + } + const bin = contents.sources.get("bin/cdk.ts"); + const lib = contents.sources.get("lib/cdk-stack.ts"); + if (bin === undefined || lib === undefined) { + const missing = [bin === undefined && "bin/cdk.ts", lib === undefined && "lib/cdk-stack.ts"] + .filter(Boolean) + .map((file) => `${CDK_APP_DIRECTORY}/${file}`); + return { + name, + ok: false, + message: `Couldn't read the CDK app: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} missing.`, + }; + } + + const comparisons = V0_CDK_APPS.map((app) => ({ app, ...differences(contents, app) })); + const exact = comparisons.find( + (comparison) => comparison.added.length + comparison.modified.length === 0, + ); + if (exact) { + return { name, ok: true, message: `unchanged from the app v0 ${exact.app.releases} created` }; + } + const unsupported = UNSUPPORTED_APP_FINGERPRINTS.find( + (app) => app.bin === bin && app.lib === lib, + ); + if (unsupported) { + return { + name, + ok: false, + message: + `This project was created by a ${unsupported.releases} release, which agentcore migrate ` + + "doesn't support. Create a new project with the current CLI and move your agent code into it.", + }; + } + const alpha = v0LibraryAlpha(contents.manifest?.library); + if (alpha !== undefined && alpha > NEWEST_KNOWN_V0_LIBRARY_ALPHA) { + return { + name, + ok: false, + message: + `This project's CDK app comes from a v0 CLI release newer than this CLI recognizes ` + + `(@aws/agentcore-cdk ${String(contents.manifest?.library)}). Update the AgentCore CLI and run agentcore migrate again.`, + }; + } + + // Report against the release the project is closest to: its source files say which. + const closest = [...comparisons].sort( + (a, b) => + Number(b.app.files["bin/cdk.ts"] === bin && b.app.files["lib/cdk-stack.ts"] === lib) - + Number(a.app.files["bin/cdk.ts"] === bin && a.app.files["lib/cdk-stack.ts"] === lib) || + a.added.length + a.modified.length - (b.added.length + b.modified.length), + )[0]!; + const rows = [ + ...closest.added.map((file) => ` added ${CDK_APP_DIRECTORY}/${file}`), + ...closest.modified.map((file) => ` modified ${CDK_APP_DIRECTORY}/${file}`), + ]; + return { + name, + ok: false, + message: [ + `${CDK_APP_DIRECTORY} isn't the CDK app a release that creates v0 projects (v0.4.0 to ` + + `v0.31.1, 1.0.0-preview.1 to 1.0.0-preview.33) created; compared with v0 ` + + `${closest.app.releases}:`, + ...rows, + "Migrating replaces agentcore/cdk with v1's app, so these files would be lost, and any " + + "resources custom code there creates would be deleted by the first v1 deploy. Move custom " + + "code and files out of agentcore/cdk, or revert them (`git checkout -- `, and delete " + + "added files); if that changes the stack, deploy with the v0 CLI " + + "(`npx @aws/agentcore@0 deploy`). Then run agentcore migrate again. If you didn't change " + + "these files, the project comes from a CLI release this CLI doesn't recognize; update the " + + "AgentCore CLI and run agentcore migrate again.", + ].join("\n"), + }; +} + +/** Whether the project's v0 app builds harness containers itself (`hasDockerfile`, v0.16.0 and later). */ +export async function appBuildsHarnessContainers(rootPath: string): Promise { + try { + const source = await readFile(join(rootPath, V0_APP_FILES[0]), "utf8"); + return /\bhasDockerfile\s*:/.test(source); + } catch { + return false; + } +} + +/** + * The construct IDs directly under a v0 stack: what the v0 app creates there + * (`Application`, `Mcp`, and `Payment…`), what the v0 library adds at the + * stack root (in every `@aws/agentcore-cdk` 0.1.0-alpha.45 to alpha.54, the + * versions migrate accepts: `ContainerBuildProject.getOrCreate` puts the shared + * container build project at `ContainerBuildProject`, or + * `ContainerBuildProject-<8-hex network hash>` for a VPC build, and + * `AgentCorePayments` puts managers and connectors at `Payment…`; nothing + * else in the library scopes a construct to `Stack.of(...)`), and what CDK itself + * adds: the `CDKMetadata` resource and singleton providers (`AWSCDK…`, + * `AWS`, `Custom…`, `LogRetention`, `BucketNotificationsHandler`). + */ +const V0_ROOT_CONSTRUCTS = [ + /^Application$/, + /^Mcp$/, + /^Payment/, + /^ContainerBuildProject(-[0-9a-f]{8})?$/, + /^CDKMetadata$/, + /^AWSCDK/, + /^AWS[0-9a-f]{32}$/, + /^Custom/, + /^LogRetention[0-9a-f]+$/, + /^BucketNotificationsHandler/, +]; + +/** + * Deployed resources the v0 app couldn't have created: their `aws:cdk:path` + * metadata puts them under a construct the v0 stack doesn't define, so a + * customized app (or another tool) added them. Resources without the metadata + * aren't judged. + */ +export function resourcesOutsideV0App(deployed: CfnTemplate): string[] { + const outside: string[] = []; + for (const [logicalId, resource] of Object.entries(resourcesOf(deployed))) { + const path = (resource.Metadata as Record | undefined)?.["aws:cdk:path"]; + if (typeof path !== "string") continue; + const root = path.split("/")[1]; + if (root === undefined || V0_ROOT_CONSTRUCTS.some((pattern) => pattern.test(root))) continue; + outside.push(`${logicalId} (${resource.Type}, ${path})`); + } + return outside; +} diff --git a/src/core/project/migrate/v0CdkApps.ts b/src/core/project/migrate/v0CdkApps.ts new file mode 100644 index 000000000..ec699a43e --- /dev/null +++ b/src/core/project/migrate/v0CdkApps.ts @@ -0,0 +1,252 @@ +// Generated by scripts/generate-v0-cdk-apps.ts from the release tags. Do not edit. + +/** The agentcore/cdk app one or more releases vended: each file's fingerprint, and the manifest's. */ +export type V0CdkApp = { + releases: string; + /** {@link sourceFingerprint} of each file but package.json, by path relative to agentcore/cdk. */ + files: Record; + /** {@link manifestFingerprint} of package.json. */ + packageJson: string; +}; + +/** Every app a release that creates v0 projects vended, oldest first. */ +export const V0_CDK_APPS: readonly V0CdkApp[] = [ + { + releases: "0.4.0 to 0.9.1", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "939928eb214837eb", + "cdk.json": "5940d2d42372c36b", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "a2995c1a9881a2a5", + "test/cdk.test.ts": "1b65be22d5c0c303", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.10.0 to 0.12.2 or 0.14.0 to 0.14.2", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "939928eb214837eb", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "a2995c1a9881a2a5", + "test/cdk.test.ts": "1b65be22d5c0c303", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.13.0 to 0.13.1", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "939928eb214837eb", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "a2995c1a9881a2a5", + "test/cdk.test.ts": "d0bd62b84be68057", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.15.0", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "939928eb214837eb", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "a2995c1a9881a2a5", + "test/cdk.test.ts": "d7602d76d699984e", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.16.0 to 0.17.0 or 1.0.0-preview.10 to 1.0.0-preview.11", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "4100b1dbac5ccac8", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "c92e475915811be8", + "test/cdk.test.ts": "d7602d76d699984e", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.18.0 or 1.0.0-preview.12", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "8645a7e38c1cf08c", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "87bd49189dbb019f", + "test/cdk.test.ts": "d7602d76d699984e", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.19.0 or 1.0.0-preview.13", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "5457351a7ae08a4c", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "35ace78befc76290", + "test/cdk.test.ts": "a5356e92212ce24a", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.20.0 to 0.20.2 or 1.0.0-preview.14 to 1.0.0-preview.16", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "ace6911c433bc096", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "ffd63ec083bd516d", + "test/cdk.test.ts": "00b92449a5af2ad2", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.21.0 to 0.27.1 or 1.0.0-preview.17 to 1.0.0-preview.27", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "fad990c293e481f5", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "ffd63ec083bd516d", + "test/cdk.test.ts": "00b92449a5af2ad2", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "0.28.0 to 0.31.1 or 1.0.0-preview.28 to 1.0.0-preview.33", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "fa9de0623ed2fb12", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "7108ea4646f58bfe", + "test/cdk.test.ts": "00b92449a5af2ad2", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "1.0.0-preview.1", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "e6361583bcabff7d", + "cdk.json": "5940d2d42372c36b", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "492e03576aa99074", + "test/cdk.test.ts": "1b65be22d5c0c303", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "1.0.0-preview.2", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "95a220333094e555", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "6a9d901b35269b5b", + "test/cdk.test.ts": "1b65be22d5c0c303", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "1.0.0-preview.3 to 1.0.0-preview.5", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "4100b1dbac5ccac8", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "386b0e93ff9d4f15", + "test/cdk.test.ts": "1b65be22d5c0c303", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, + { + releases: "1.0.0-preview.6 to 1.0.0-preview.9", + files: { + ".gitignore": "4ba87bb54e442d8d", + ".npmignore": "4abbea67b57b9c74", + ".prettierrc": "c7434c09ff6bc4e1", + "README.md": "c3cae9aca742f1c5", + "bin/cdk.ts": "4100b1dbac5ccac8", + "cdk.json": "8d8607c63f647265", + "jest.config.js": "54181afd04480cfc", + "lib/cdk-stack.ts": "386b0e93ff9d4f15", + "test/cdk.test.ts": "d0bd62b84be68057", + "tsconfig.json": "adc10935b9aaab1b", + }, + packageJson: "2532612c904a371a", + }, +]; + +/** Every dependency any of those apps' package.json declared; a v0 deploy may restore any of them. */ +export const V0_CDK_DEPENDENCY_NAMES: readonly string[] = [ + "@aws/agentcore-cdk", + "@types/jest", + "@types/node", + "aws-cdk", + "aws-cdk-lib", + "constructs", + "jest", + "prettier", + "ts-jest", + "typescript", +]; diff --git a/src/core/project/migrate/workspace.test.ts b/src/core/project/migrate/workspace.test.ts new file mode 100644 index 000000000..a3de53bb4 --- /dev/null +++ b/src/core/project/migrate/workspace.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FsAssetSource } from "../source"; +import { writeV0Project } from "./migrate-test-support"; +import { buildMigrationWorkspace, diffTrees, removeOnInterrupt } from "./workspace"; + +const cleanups: Array<() => Promise> = []; +afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); + +describe("buildMigrationWorkspace", () => { + test("builds the converted project beside the original, leaving the original untouched", async () => { + const { root, cleanup } = await writeV0Project(); + cleanups.push(cleanup); + const originalSpec = await readFile(join(root, "agentcore", "agentcore.json"), "utf8"); + + const workspace = await buildMigrationWorkspace({ + rootPath: root, + spec: { name: "CompatA", version: 2 }, + harnesses: [{ directory: "app/CompatAHarness", yaml: "name: CompatAHarness\n" }], + assetSource: new FsAssetSource(), + }); + cleanups.push(workspace.dispose); + const ws = workspace.directory; + + expect(await readFile(join(ws, "agentcore", "agentcore.json"), "utf8")).toBe( + `${JSON.stringify({ name: "CompatA", version: 2 }, null, 2)}\n`, + ); + expect(existsSync(join(ws, "app", "CompatAHarness", "harness.json"))).toBe(false); + expect(await readFile(join(ws, "app", "CompatAHarness", "harness.yaml"), "utf8")).toBe( + "name: CompatAHarness\n", + ); + expect(existsSync(join(ws, "app", "CompatAHarness", "system-prompt.md"))).toBe(true); + // v1's vended CDK app replaces the v0 one, without dependencies or synth output. + const vended = JSON.parse(await readFile(join(ws, "agentcore", "cdk", "package.json"), "utf8")); + expect(Object.keys(vended.dependencies)).toContain("@aws/agentcore-cdk"); + expect(existsSync(join(ws, "agentcore", "cdk", ".gitignore"))).toBe(true); + expect(existsSync(join(ws, "agentcore", "cdk", "jest.config.js"))).toBe(false); + expect(existsSync(join(ws, "agentcore", "cdk", "node_modules"))).toBe(false); + expect(existsSync(join(ws, "agentcore", "cdk", "cdk.out"))).toBe(false); + // Ignored-but-needed project files come along. + expect(existsSync(join(ws, "agentcore", ".cli", "deployed-state.json"))).toBe(true); + + expect(await readFile(join(root, "agentcore", "agentcore.json"), "utf8")).toBe(originalSpec); + expect(existsSync(join(root, "app", "CompatAHarness", "harness.json"))).toBe(true); + + const byPath = Object.fromEntries( + workspace.changes.map((change) => [change.path, change.kind]), + ); + expect(byPath).toMatchObject({ + "agentcore/agentcore.json": "modified", + "app/CompatAHarness/harness.json": "removed", + "app/CompatAHarness/harness.yaml": "added", + "agentcore/cdk/package.json": "modified", + "agentcore/cdk/bin/cdk.ts": "modified", + "agentcore/cdk/jest.config.js": "removed", + "agentcore/cdk/lib/cdk-stack.ts": "modified", + }); + expect(Object.keys(byPath).some((path) => path.includes("node_modules"))).toBe(false); + expect(Object.keys(byPath).some((path) => path.includes("cdk.out"))).toBe(false); + expect(byPath["app/CompatAAgent/main.py"]).toBeUndefined(); + }); + + test("copies the deployed state and the vended app as they are", async () => { + const { root, cleanup } = await writeV0Project(); + cleanups.push(cleanup); + const statePath = join("agentcore", ".cli", "deployed-state.json"); + const originalState = await readFile(join(root, statePath), "utf8"); + + const workspace = await buildMigrationWorkspace({ + rootPath: root, + spec: { name: "CompatA", version: 2, migratedFrom: "v1" }, + harnesses: [], + assetSource: new FsAssetSource(), + }); + cleanups.push(workspace.dispose); + + // migratedFrom in agentcore.json keeps the names; deployed state isn't touched. + expect(await readFile(join(workspace.directory, statePath), "utf8")).toBe(originalState); + expect(workspace.changes).not.toContainEqual( + expect.objectContaining({ path: "agentcore/.cli/deployed-state.json" }), + ); + const app = await readFile( + join(workspace.directory, "agentcore", "cdk", "bin", "cdk.ts"), + "utf8", + ); + expect(app).toBe(await readFile(join("src", "assets", "cdk", "bin", "cdk.ts"), "utf8")); + }); + + test("refuses to write through a symlink that leads out of the workspace", async () => { + const { root, cleanup } = await writeV0Project(); + cleanups.push(cleanup); + // The harness directory is a link to a directory outside the project. Copied as + // is, the workspace's link still points there, so writing the converted harness + // through it would change files outside the workspace. + const outside = await mkdtemp(join(tmpdir(), "agentcore-outside-")); + cleanups.push(() => rm(outside, { recursive: true, force: true })); + await writeFile(join(outside, "harness.json"), "{}\n"); + await rm(join(root, "app", "CompatAHarness"), { recursive: true }); + await symlink(outside, join(root, "app", "CompatAHarness"), "junction"); + + await expect( + buildMigrationWorkspace({ + rootPath: root, + spec: {}, + harnesses: [{ directory: "app/CompatAHarness", yaml: "name: x\n" }], + assetSource: new FsAssetSource(), + }), + ).rejects.toThrow("resolves outside the migration workspace through a symlink"); + + expect(await readFile(join(outside, "harness.json"), "utf8")).toBe("{}\n"); + expect(existsSync(join(outside, "harness.yaml"))).toBe(false); + }); + + test("leaves secrets, virtual environments, and caches out of the copy and the changes", async () => { + const { root, cleanup } = await writeV0Project({ + files: { + "agentcore/.env.local": "AGENTCORE_CREDENTIAL_KEY=secret\n", + ".env": "X=1\n", + "app/CompatAAgent/venv/bin/python": "x", + "app/CompatAAgent/__pycache__/main.cpython-313.pyc": "x", + "app/CompatAAgent/.pytest_cache/v/cache": "x", + }, + }); + cleanups.push(cleanup); + const workspace = await buildMigrationWorkspace({ + rootPath: root, + spec: {}, + harnesses: [], + assetSource: new FsAssetSource(), + }); + cleanups.push(workspace.dispose); + + for (const path of [ + "agentcore/.env.local", + ".env", + "app/CompatAAgent/venv", + "app/CompatAAgent/__pycache__", + "app/CompatAAgent/.pytest_cache", + ]) { + expect({ path, copied: existsSync(join(workspace.directory, path)) }).toEqual({ + path, + copied: false, + }); + } + const paths = workspace.changes.map((change) => change.path); + expect(paths.some((path) => /\.env|venv|__pycache__|pytest_cache/.test(path))).toBe(false); + }); + + test("dispose removes the workspace", async () => { + const { root, cleanup } = await writeV0Project(); + cleanups.push(cleanup); + const workspace = await buildMigrationWorkspace({ + rootPath: root, + spec: {}, + harnesses: [], + assetSource: new FsAssetSource(), + }); + await workspace.dispose(); + expect(existsSync(workspace.directory)).toBe(false); + }); +}); + +describe("diffTrees", () => { + test("lists added, modified, and removed files in path order, skipping build output", async () => { + const base = await mkdtemp(join(tmpdir(), "agentcore-diff-")); + cleanups.push(() => rm(base, { recursive: true, force: true })); + const write = async (path: string, content: string) => { + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, content); + }; + const before = join(base, "before"); + const after = join(base, "after"); + await write(join(before, "same.txt"), "x"); + await write(join(after, "same.txt"), "x"); + await write(join(before, "b", "changed.txt"), "1"); + await write(join(after, "b", "changed.txt"), "2"); + await write(join(before, "gone.txt"), "x"); + await write(join(after, "a", "new.txt"), "x"); + await write(join(before, "agentcore", "cdk", "dist", "bin", "cdk.js"), "x"); + await write(join(before, "node_modules", "dep", "index.js"), "x"); + await write(join(before, "app", "agent", ".venv", "bin", "python"), "x"); + await write(join(after, ".git", "HEAD"), "x"); + + expect(await diffTrees(before, after)).toEqual([ + { path: "a/new.txt", kind: "added" }, + { path: "b/changed.txt", kind: "modified" }, + { path: "gone.txt", kind: "removed" }, + ]); + }); +}); + +describe("removeOnInterrupt", () => { + test("removes the directory on exit or interrupt until detached", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-interrupt-")); + const exitListeners = process.listenerCount("exit"); + const sigintListeners = process.listenerCount("SIGINT"); + + const detach = removeOnInterrupt(directory); + expect(process.listenerCount("exit")).toBe(exitListeners + 1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners + 1); + + // Another SIGINT handler stays in charge of exiting, so invoking ours here only cleans up. + const keepAlive = () => {}; + process.on("SIGINT", keepAlive); + try { + const onInterrupt = process.listeners("SIGINT").at(-2) as () => void; + onInterrupt(); + } finally { + process.off("SIGINT", keepAlive); + } + expect(existsSync(directory)).toBe(false); + expect(process.listenerCount("exit")).toBe(exitListeners); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners); + detach(); + }); + + test("also cleans up on SIGTERM and SIGHUP", async () => { + for (const signal of ["SIGTERM", "SIGHUP"] as const) { + const directory = await mkdtemp(join(tmpdir(), "agentcore-interrupt-")); + const before = process.listenerCount(signal); + const detach = removeOnInterrupt(directory); + expect(process.listenerCount(signal)).toBe(before + 1); + const keepAlive = () => {}; + process.on(signal, keepAlive); + try { + (process.listeners(signal).at(-2) as () => void)(); + } finally { + process.off(signal, keepAlive); + } + expect(existsSync(directory)).toBe(false); + expect(process.listenerCount(signal)).toBe(before); + detach(); + } + }); + + test("on Windows, ends with 128 + the signal number for a signal it can't raise (SIGHUP)", async () => { + const directory = await mkdtemp(join(tmpdir(), "agentcore-interrupt-")); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const exit = process.exit; + const kill = process.kill; + const exits: (number | string | null | undefined)[] = []; + const kills: unknown[] = []; + Object.defineProperty(process, "platform", { value: "win32" }); + process.exit = ((code?: number) => { + exits.push(code); + }) as typeof process.exit; + process.kill = ((...args: unknown[]) => { + kills.push(args[1]); + return true; + }) as typeof process.kill; + // Other SIGHUP listeners (from the test runner) would take over the exit; set them aside. + const others = process.listeners("SIGHUP"); + for (const listener of others) process.off("SIGHUP", listener); + try { + const detach = removeOnInterrupt(directory); + (process.listeners("SIGHUP").at(-1) as () => void)(); + detach(); + } finally { + for (const listener of others) process.on("SIGHUP", listener); + Object.defineProperty(process, "platform", platform); + process.exit = exit; + process.kill = kill; + } + expect(existsSync(directory)).toBe(false); + expect(exits).toEqual([129]); + }); +}); diff --git a/src/core/project/migrate/workspace.ts b/src/core/project/migrate/workspace.ts new file mode 100644 index 000000000..1d23f9815 --- /dev/null +++ b/src/core/project/migrate/workspace.ts @@ -0,0 +1,236 @@ +import { rmSync } from "node:fs"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { constants, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { ProjectStateError } from "../../../errors"; +import type { FileChange } from "../../../handlers/project/migrate/types"; +import type { AssetSource } from "../source"; +import { PROJECT_SPEC_RELATIVE_PATH } from "../fsUtils"; +import { FsTreeNode } from "../templates/fsTree"; + +/** + * Directories never copied into or compared across a workspace: installed + * dependencies (npm and Python virtual environments), caches, synth output, and git. + */ +const EXCLUDED_DIRECTORIES = new Set([ + "node_modules", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + "cdk.out", + ".git", +]); + +/** Files and directories never copied: `.env*` holds secrets, and planning doesn't need them. */ +const EXCLUDED_NAME = /^\.env/; + +function excludedName(name: string): boolean { + return EXCLUDED_DIRECTORIES.has(name) || EXCLUDED_NAME.test(name); +} + +/** Build output of the CDK app, regenerated by its own build; not a change worth listing. */ +const IGNORED_IN_CHANGES = ["agentcore/cdk/dist/"]; + +export type WorkspaceInput = { + rootPath: string; + /** The v1 agentcore.json content. */ + spec: unknown; + /** Converted harnesses: each directory's harness.json is replaced with this harness.yaml. */ + harnesses: { directory: string; yaml: string }[]; + /** Where v1's vended CDK app (`cdk/`) is read from. */ + assetSource: AssetSource; +}; + +export type MigrationWorkspace = { + /** The converted copy of the project. */ + directory: string; + /** How the converted copy differs from the project. */ + changes: FileChange[]; + /** Deletes the workspace. */ + dispose(): Promise; +}; + +/** + * Builds the migrated project in a temporary directory, leaving the project itself + * untouched: a copy of the project (without dependencies, synth output, or git + * metadata) with v1's agentcore.json, harness.yaml files in place of harness.json, + * and v1's vended CDK app in place of the v0 one. Dependencies aren't installed. + */ +export async function buildMigrationWorkspace(input: WorkspaceInput): Promise { + const directory = await realpath(await mkdtemp(join(tmpdir(), "agentcore-migrate-"))); + const detach = removeOnInterrupt(directory); + const dispose = async () => { + detach(); + await rm(directory, { recursive: true, force: true }); + }; + try { + // Symlinks are copied as they are rather than rewritten to point back into the + // project, and every path written below is checked to stay in the workspace, so + // a symlinked directory can never route a write into the project. + await cp(input.rootPath, directory, { + recursive: true, + verbatimSymlinks: true, + filter: async (source) => { + const path = relative(input.rootPath, source); + if (path === "") return true; + if (excluded(path)) return false; + // Sockets, FIFOs, and devices can't be copied and are never project files. + const stats = await lstat(source); + return stats.isFile() || stats.isDirectory() || stats.isSymbolicLink(); + }, + }); + const inWorkspace = (...segments: string[]) => insideWorkspace(directory, join(...segments)); + + await writeFile( + await inWorkspace(directory, PROJECT_SPEC_RELATIVE_PATH), + `${JSON.stringify(input.spec, null, 2)}\n`, + ); + for (const harness of input.harnesses) { + const harnessDirectory = await inWorkspace(directory, ...harness.directory.split("/")); + await mkdir(harnessDirectory, { recursive: true }); + await rm(await inWorkspace(harnessDirectory, "harness.json"), { force: true }); + await writeFile(await inWorkspace(harnessDirectory, "harness.yaml"), harness.yaml); + } + + const cdkDirectory = await inWorkspace(directory, "agentcore", "cdk"); + await rm(cdkDirectory, { recursive: true, force: true }); + const cdkApp = await FsTreeNode.fromAssetSource( + { assetSource: input.assetSource }, + { assetDir: "cdk" }, + ); + await cdkApp.write(dirname(cdkDirectory)); + + return { directory, changes: await diffTrees(input.rootPath, directory), dispose }; + } catch (error) { + await dispose(); + throw error; + } +} + +/** Signals that end the process by default, after which the workspace must be gone. */ +const TERMINATING_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + +/** + * Removes the workspace if the process exits, is interrupted, or is terminated + * before it is disposed, so a Ctrl-C (or a closed terminal, or a `kill`) during a + * preview leaves no copy of the project behind. Returns the function that + * detaches the handlers. + */ +export function removeOnInterrupt(directory: string): () => void { + const remove = () => rmSync(directory, { recursive: true, force: true }); + const handlers = TERMINATING_SIGNALS.map((signal) => { + const handler = () => { + detach(); + remove(); + // Listening for a signal replaces Node's default exit; restore it unless + // something else handles the signal. Windows can only raise SIGINT and SIGTERM + // on a process, so there the exit is the conventional 128 + signal number. + if (process.listenerCount(signal) > 0) return; + if (process.platform === "win32" && signal === "SIGHUP") { + process.exit(128 + constants.signals.SIGHUP); + } + process.kill(process.pid, signal); + }; + return [signal, handler] as const; + }); + const detach = () => { + process.off("exit", remove); + for (const [signal, handler] of handlers) process.off(signal, handler); + }; + process.on("exit", remove); + for (const [signal, handler] of handlers) process.on(signal, handler); + return detach; +} + +/** Compares two directory trees file by file, skipping excluded directories and build output. */ +export async function diffTrees(before: string, after: string): Promise { + const [beforeFiles, afterFiles] = await Promise.all([listFiles(before), listFiles(after)]); + const changes: FileChange[] = []; + for (const path of new Set([...beforeFiles, ...afterFiles])) { + if (IGNORED_IN_CHANGES.some((prefix) => path.startsWith(prefix))) continue; + if (!afterFiles.has(path)) { + changes.push({ path, kind: "removed" }); + } else if (!beforeFiles.has(path)) { + changes.push({ path, kind: "added" }); + } else { + const [a, b] = await Promise.all([readFile(join(before, path)), readFile(join(after, path))]); + if (!a.equals(b)) changes.push({ path, kind: "modified" }); + } + } + return changes.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** + * Returns `path` if it resolves inside the workspace once every symlink on the way + * is followed, and throws otherwise. The deepest existing ancestor is resolved, + * since the path itself may not exist yet. + */ +async function insideWorkspace(workspace: string, path: string): Promise { + const resolved = await resolveExisting(path); + const fromWorkspace = resolved === undefined ? ".." : relative(workspace, resolved); + if (fromWorkspace === ".." || fromWorkspace.startsWith(`..${sep}`) || isAbsolute(fromWorkspace)) { + throw new ProjectStateError( + `Can't build the migrated project: '${relative(workspace, path)}' resolves outside the ` + + "migration workspace through a symlink. Replace the symlink with the files it points " + + "to, then try again.", + ); + } + return path; +} + +/** + * `path` with every symlink resolved, for a path whose last segments may not exist + * yet. A dangling symlink on the way resolves to nowhere we can check, so it is + * reported as outside the workspace (`undefined`). + */ +async function resolveExisting(path: string): Promise { + const missing: string[] = []; + for (let existing = path; ; existing = dirname(existing)) { + try { + return join(await realpath(existing), ...missing); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(existing) === existing) { + throw error; + } + const isDanglingLink = await lstat(existing).then( + (stats) => stats.isSymbolicLink(), + () => false, + ); + if (isDanglingLink) return undefined; + missing.unshift(basename(existing)); + } + } +} + +function excluded(relativePath: string): boolean { + return relativePath.split(sep).some(excludedName); +} + +/** Every regular file under `root`, as `/`-separated relative paths, never entering excluded directories. */ +async function listFiles( + root: string, + directory = root, + files = new Set(), +): Promise> { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (excludedName(entry.name)) continue; + if (entry.isDirectory()) { + await listFiles(root, path, files); + } else if (entry.isFile()) { + files.add(relative(root, path).split(sep).join("/")); + } + } + return files; +} diff --git a/src/core/project/templates/harness.ts b/src/core/project/templates/harness.ts index 684360687..3abc42935 100644 --- a/src/core/project/templates/harness.ts +++ b/src/core/project/templates/harness.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { stringify } from "yaml"; import { ZodError, z } from "zod"; import { + encodeHarnessYaml, HarnessSpecSchema, type HarnessModelProvider, type HarnessSpec, @@ -102,84 +103,15 @@ export function getHarnessTemplateResolver( } function buildTemplateContext(spec: HarnessSpec) { - const { - model, - memory, - skills, - containerUri, - networkMode, - networkConfig, - lifecycleConfig, - sessionStoragePath, - efsAccessPoints, - s3AccessPoints, - authorizerType: _authorizerType, - authorizerConfiguration, - ...settings - } = spec; + const { model, memory } = spec; const { provider, ...modelConfig } = model; - const modelKey = { - bedrock: "bedrockModelConfig", - open_ai: "openAiModelConfig", - gemini: "geminiModelConfig", - lite_llm: "liteLlmModelConfig", - }[provider]; const { mode, ...memoryConfig } = memory ?? {}; - const memoryKey = - mode && - { - managed: "managedMemoryConfiguration", - existing: "agentCoreMemoryConfiguration", - disabled: "disabled", - }[mode]; - const mounts = [ - ...(sessionStoragePath ? [{ sessionStorage: { mountPath: sessionStoragePath } }] : []), - ...(efsAccessPoints ?? []).map((efsAccessPoint) => ({ efsAccessPoint })), - ...(s3AccessPoints ?? []).map((s3FilesAccessPoint) => ({ s3FilesAccessPoint })), - ]; - const runtime = Object.fromEntries( - Object.entries({ - networkConfiguration: networkMode - ? { - networkMode, - networkModeConfig: networkConfig && { - subnets: networkConfig.subnets, - securityGroups: networkConfig.securityGroups, - }, - } - : undefined, - lifecycleConfiguration: lifecycleConfig, - filesystemConfigurations: mounts.length ? mounts : undefined, - }).filter(([, value]) => value !== undefined), - ); const yaml = Object.fromEntries( - Object.entries({ - ...settings, - model: { [modelKey]: modelConfig }, - memory: memoryKey ? { [memoryKey]: memoryConfig } : undefined, - skills: skills.map((skill) => { - if ("s3Uri" in skill) return { s3: { uri: skill.s3Uri } }; - if ("gitUrl" in skill) { - const { gitUrl, ...git } = skill; - return { git: { url: gitUrl, ...git } }; - } - return skill; - }), - environmentArtifact: containerUri ? { containerConfiguration: { containerUri } } : undefined, - environment: Object.keys(runtime).length - ? { agentCoreRuntimeEnvironment: runtime } - : undefined, - networkConfig: networkConfig?.vpcId ? { vpcId: networkConfig.vpcId } : undefined, - authorizerConfiguration: authorizerConfiguration && { - customJWTAuthorizer: authorizerConfiguration.customJwtAuthorizer, - }, - }) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => [ - key, - // Quoted multiline values cannot absorb the template's section-separating blank lines. - stringify({ [key]: value }, { blockQuote: false }).slice(0, -1), - ]), + Object.entries(encodeHarnessYaml(spec)).map(([key, value]) => [ + key, + // Quoted multiline values cannot absorb the template's section-separating blank lines. + stringify({ [key]: value }, { blockQuote: false }).slice(0, -1), + ]), ); return { ...spec, diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 91ade62e3..45db805cd 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { QueryClient } from "@tanstack/react-query"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { writeMigrationState } from "../../core/project/migrate/journal"; import type { DeployBackendInput, ProjectBackend } from "../../core/project"; import { CommandRunMetricEventKey } from "../../router"; import { @@ -478,6 +479,35 @@ describe("project deploy screen", () => { r.unmount(); }); + test("refuses a target a migration hasn't finished, before any backend work", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + const projectRoot = await inProject(core); + await writeMigrationState(projectRoot, { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: "installed", backup: "agentcore/.cli/migrate-backup", changes: [] }, + refactored: [], + targets: [ + { + name: "default", + account: "111122223333", + region: "us-east-1", + stackName: "AgentCore-orders-default", + status: "pending", + }, + ], + }); + const r = renderScreen("/agentcore/deploy", { core }); + + await waitForFlatText(r.lastFrame, "agentcore migrate --target default"); + expect(flatFrame(r.lastFrame)).toContain( + "Target 'default' (AWS account 111122223333, us-east-1) hasn't been migrated yet", + ); + expect(deploys).toEqual([]); + r.unmount(); + }); + test("a failing deploy keeps the completed steps above the error", async () => { const { backend } = fakeBackend({ failure: new Error("stack rolled back") }); const core = new TestCoreClient({ backends: { CDK: backend } }); diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 785613948..ae2165167 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -17,6 +17,8 @@ import { } from "../../../testing"; import type { DeployBackendInput, ProjectBackend } from "../../../core/project"; import type { V0DeployInput, V0DeployRunner } from "../../../core/project/legacy/deploy"; +import { writeMigrationState } from "../../../core/project/migrate/journal"; +import { ProjectStateError } from "../../../errors"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { DeployResult, Project, ProjectEvent, TeardownConfirmationRequest } from "../types"; @@ -160,6 +162,75 @@ async function emptyProjectSpec(projectRoot: string): Promise { ); } +/** Records a pending migration of `pending` (or one stopped while writing files). */ +async function pendingMigration( + projectRoot: string, + pending: AwsDeploymentTarget[], + filesStatus: "writing" | "installed" = "installed", +): Promise { + await writeMigrationState(projectRoot, { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: filesStatus, backup: "agentcore/.cli/migrate-backup", changes: [] }, + refactored: [], + targets: pending.map((target) => ({ + name: target.name, + account: target.account, + region: target.region, + stackName: `AgentCore-orders-${target.name}`, + status: "pending" as const, + })), + }); +} + +describe("project deploy handler, with a pending migration", () => { + test("refuses a target the migration hasn't finished, naming its account and region", async () => { + const projectRoot = await inProjectWithTargets(); + await pendingMigration(projectRoot, [STAGING_TARGET]); + const subject = await testDeployCommand({ outputs: {} }); + + const error = await subject.run(["--target", "staging", "--yes"]).catch((caught) => caught); + + expect(error).toBeInstanceOf(ProjectStateError); + expect(error.message).toBe( + "Target 'staging' (AWS account 444455556666, eu-west-1) hasn't been migrated yet: its " + + "stack AgentCore-orders-staging still has the v0 logical IDs, so a v1 deploy would " + + "replace its resources. Run `agentcore migrate --target staging` with credentials for " + + "account 444455556666, then deploy.", + ); + expect(subject.calls).toEqual([]); + }); + + test("still deploys a target the migration finished", async () => { + const projectRoot = await inProjectWithTargets(); + await pendingMigration(projectRoot, [STAGING_TARGET]); + const subject = await testDeployCommand({ outputs: {} }); + + await subject.run(["--target", "default", "--yes"]); + + expect(subject.calls.map((call) => call.input.target.name)).toEqual(["default"]); + }); + + test("refuses a v0 deploy while a migration stopped writing the project files", async () => { + const projectRoot = await inProjectWithTargets(); + await writeFile( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify({ name: "orders", version: 1 }), + ); + await pendingMigration(projectRoot, [DEFAULT_TARGET], "writing"); + const legacyDeploys: V0DeployInput[] = []; + const subject = await testDeployCommand({ outputs: {} }, [], { + runV0Deploy: async (input) => { + legacyDeploys.push(input); + return 0; + }, + }); + + await expect(subject.run(["--yes"])).rejects.toThrow("stopped while writing the project files"); + expect(legacyDeploys).toEqual([]); + }); +}); + describe("project deploy handler", () => { test.each(["--yes", "--json"])( "delegates a legacy deploy with %s before resolving an account or using v1 CDK", diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index 26aea01db..8495d8825 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises"; import z from "zod"; import { enclosingProjectRoot, isV0Project } from "../../../core/project/fsUtils"; import { runV0Deploy, type V0DeployRunner } from "../../../core/project/legacy/deploy"; +import { assertNoPendingMigration } from "../../../core/project/migrate/journal"; import { SilentCLIError, UserCancellationError } from "../../../errors"; import type { AppIO } from "../../../io"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; @@ -210,6 +211,9 @@ async function delegateV0Deploy( config: DeployProjectHandlerConfig, input: Parameters[0], ): Promise { + // A migration stopped while writing the files leaves a v0 spec beside a v1 CDK + // app; the v0 deployer mustn't deploy that. + await assertNoPendingMigration(input.projectRoot, input.target); if (!input.json) { config.io.stderr.write( "This is a v0 project; running the latest v0 AgentCore CLI deploy in compatibility mode.\n", diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index dfd1550d1..b71b709ad 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; +import { writeMigrationState } from "../../../core/project/migrate/journal"; import { InternalServerException, type InvokeHarnessStreamOutput, @@ -118,6 +121,8 @@ type HarnessOptions = { checkPort?: PortChecker; json?: boolean; loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; + /** Deploy through the real project manager instead of recording deployments. */ + realDeploy?: boolean; }; function harness(options: HarnessOptions = {}) { @@ -139,11 +144,13 @@ function harness(options: HarnessOptions = {}) { harness: { arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/test" }, } as GetHarnessResponse); const projectManager = core.projectManager; - projectManager.deploy = async function* (configuredProject, input) { - deployments.push({ project: configuredProject, input }); - yield { type: "output", line: "Deploying harnesses" }; - return { outputs: {} }; - }; + if (!options.realDeploy) { + projectManager.deploy = async function* (configuredProject, input) { + deployments.push({ project: configuredProject, input }); + yield { type: "output", line: "Deploying harnesses" }; + return { outputs: {} }; + }; + } projectManager.resolveTarget = async () => undefined; projectManager.resolveDeployedResource = async (_project, input) => { resolutions.push(input); @@ -712,6 +719,36 @@ describe("project dev harnesses", () => { expect(subject.io.stderr()).toContain("running the latest v0 AgentCore CLI deploy"); }); + test("harness dev's deploy refuses a target a migration hasn't finished", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-dev-migrate-")); + try { + await writeMigrationState(root, { + version: 2, + startedAt: "2026-10-13T00:00:00.000Z", + files: { status: "installed", backup: "agentcore/.cli/migrate-backup", changes: [] }, + refactored: [], + targets: [ + { + name: "default", + account: "111122223333", + region: "us-east-1", + stackName: "AgentCore-testProject-default", + status: "pending", + }, + ], + }); + const configured = { ...harnessProject(["support"]), rootPath: root }; + const subject = harness({ project: configured, realDeploy: true }); + + await expect(subject.route([])).rejects.toThrow( + "Target 'default' (AWS account 111122223333, us-east-1) hasn't been migrated yet", + ); + expect(subject.ui.starts).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test.each([ { names: ["support"], args: [], json: false }, { names: ["orders", "support"], args: ["--mode", "headless"], json: true }, diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 06922d6a2..e193388ba 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -3,7 +3,6 @@ import { checkPort, openBrowser, runInheritedProcess, - runProcess, startHttpServer, watchFile, type AppIO, @@ -54,9 +53,7 @@ export function createProjectHandlers( // Returned in workflow order, which is the order `--help` and the TUI menu list them in: // the inner loop (dev, deploy, invoke), then observing it, then changing the project. // build is a step deploy already runs, so it goes last and the TUI menu hides it. - // migrate is a one-off upgrade from a v0 project, so it follows build. It - // locates the raw project itself because migration validates the source files - // before the compatibility loader translates them into the current shape. + // migrate is a one-off upgrade from a v0 project, so it follows build. const projectBoundHandlers = [ createDevProjectHandler({ projectManager, @@ -102,7 +99,7 @@ export function createProjectHandlers( io, middlewares: [withProjectMiddleware], }), - createMigrateProjectHandler({ runner: runProcess }), + createMigrateProjectHandler({ projectManager, migrator: core.projectMigrator, io }), ]; return [createHandler, ...projectBoundHandlers]; diff --git a/src/handlers/project/migrate/index.test.ts b/src/handlers/project/migrate/index.test.ts index 0228117a0..27736936a 100644 --- a/src/handlers/project/migrate/index.test.ts +++ b/src/handlers/project/migrate/index.test.ts @@ -1,109 +1,1004 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { createRootHandler } from "../../index"; -import { NotImplementedError, ProjectStateError } from "../../../errors"; -import type { ProcessRunner } from "../../../io"; -import { Router } from "../../../router"; -import { withJsonRenderer } from "../../../middleware"; +import { ProjectStateError, SilentCLIError } from "../../../errors"; +import { + CDK_LIBRARY_OVERRIDE_ENV, + FsProjectMigrator, + REPORTS_RELATIVE_PATH, +} from "../../../core/project/migrate"; +import { + commitAll, + FAKE_AWS_IDENTITY, + fakeCloudFormation, + fakeSynth, + templateFixture, + vendedAssets, + writeV0Project, +} from "../../../core/project/migrate/migrate-test-support"; import { createSilentLogger, - expectError, inTempDirectory, TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../testing"; -import { JsonKey } from "../../keys"; -import { createMigrateProjectHandler, MIGRATE_NOT_AVAILABLE_MESSAGE } from "./index"; +import { + ALREADY_V1_MESSAGE, + APPLY_HINT, + CONFIRM_QUESTION, + formatMigrationPlan, + formatMigrationResult, + formatMigrationSummary, + formatResultSummary, + formatStackPlan, + MIGRATION_DOCS_URL, + overrideInstallCommand, + reportStamp, +} from "./index"; +import type { MigrationPlan, MigrationResult, ProjectMigrator, StackMigrationPlan } from "./types"; +const originalCwd = process.cwd(); const cleanups: Array<() => Promise> = []; -afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); +}); + +/** Writes the F1 v0 project, commits it, and cds into it. */ +async function inCommittedV0Project( + options: Parameters[0] = {}, +): Promise { + const { root, cleanup } = await writeV0Project(options); + cleanups.push(cleanup); + commitAll(root); + process.chdir(join(root, "app")); + return root; +} -/** A temp directory holding only a v0-shaped project marker, which withProject couldn't parse. */ -async function inV0Project(): Promise { +/** A temp directory holding a project already in the v1 format. */ +async function inV1Project(): Promise { const { path, cleanup } = await inTempDirectory("agentcore-migrate-"); cleanups.push(cleanup); await mkdir(join(path, "agentcore")); - await writeFile(join(path, "agentcore", "agentcore.json"), '{"version":0,"agents":[]}\n'); + await writeFile( + join(path, "agentcore", "agentcore.json"), + `${JSON.stringify({ name: "orders", version: 2 })}\n`, + ); return path; } -/** Answers git as a clean repository with a tracked spec, recording each call. */ -function cleanGit() { - const calls: string[][] = []; - const runner: ProcessRunner = async (command, { onOutput }) => { - calls.push(command); - if (command.includes("rev-parse")) onOutput?.("true\n"); - }; - return { runner, calls }; +/** A temp directory with no project anywhere above it. */ +async function outsideAnyProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), "agentcore-no-project-")); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + process.chdir(dir); + return dir; } -function migrate(runner: ProcessRunner, args: string[] = []) { +describe("agentcore migrate", () => { + test("is one command on the root, with no subcommands", async () => { + await outsideAnyProject(); + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + + await expect(root.route(["node", "agentcore", "migrate"])).rejects.toThrow( + /No AgentCore project found/, + ); + const migrateCommand = root.children().find((handler) => handler.name() === "migrate"); + expect(migrateCommand?.children() ?? []).toEqual([]); + }); +}); + +const STACK = "AgentCore-CompatA-default"; + +/** + * The real root handler and migrator, so real git and the vended CDK assets are in + * the path. Only AWS and the v1 synth are faked: F1's stack is deployed with the + * live-tested CompatMig template, and the v1 app synthesizes its v1 template. + */ +function cli(v1 = templateFixture("pinned.v1")) { const io = testIO(); - const app = new Router("agentcore", "test").groupFlags(JsonKey); - app.use(withJsonRenderer(io.io)); - app.handler(createMigrateProjectHandler({ runner })); - return { io, run: () => app.route(["node", "agentcore", "migrate", ...args]) }; + const projectMigrator = new FsProjectMigrator({ + logger: createSilentLogger(), + assetSource: vendedAssets(), + createCloudFormationClient: fakeCloudFormation({ + [STACK]: { template: templateFixture("pinned.v0") }, + }).create, + ...FAKE_AWS_IDENTITY, + synthesize: fakeSynth([{ target: "default", stackName: STACK, template: v1 }]), + }); + const root = createRootHandler(new TestCoreClient({ projectMigrator }), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + return { io, run: (...args: string[]) => root.route(["node", "agentcore", "migrate", ...args]) }; } -describe("migrate handler", () => { - test("runs the git gate from the enclosing project root, then reports not available", async () => { - const root = await inV0Project(); - await mkdir(join(root, "app")); - process.chdir(join(root, "app")); - const { runner, calls } = cleanGit(); - - await expectError(migrate(runner).run(), MIGRATE_NOT_AVAILABLE_MESSAGE, NotImplementedError); - expect(calls.map((command) => command.slice(0, 3))).toEqual([ - ["git", "-C", root], - ["git", "-C", root], - ["git", "-C", root], - ]); +describe("agentcore migrate, planning", () => { + test("prints a summary, and writes the full plan to a report git ignores", async () => { + const root = await inCommittedV0Project(); + const subject = cli(); + + await subject.run(); + + const out = subject.io.stdout(); + expect(out).toStartWith( + "Migration plan: CompatA → v1 (target default, 998846730471/us-east-1)\n", + ); + expect(out).toMatch(/\n {2}Keeps the names of \d+ deployed resources \(runtime CompatAAgent, /); + expect(out).toContain( + `\n Moves 10 resources in ${STACK} to new logical IDs (stack refactor)\n`, + ); + expect(out).toContain( + "\n First v1 deploy: deletes 1 resource (none that must be kept); updates 1 resource in place\n", + ); + expect(out).toMatch(/\n {2}Changes \d+ files \(agentcore\/agentcore\.json, /); + expect(out).toContain("\n ! This project has credentials (compatA-key, compatA-oauth).\n"); + // The detail is in the report, not on stdout. + expect(out).not.toContain("Checks"); + expect(out).not.toContain(" ok git"); + expect(out).not.toContain("Dropped fields"); + expect(out).toMatch(/\nFull plan: agentcore\/\.cli\/migrate\/plan-\d{8}T\d{6}Z\.md\n/); + expect(out).toEndWith(APPLY_HINT); + + const reports = await readdir(join(root, REPORTS_RELATIVE_PATH)); + expect(reports).toHaveLength(1); + expect(out).toContain(`Full plan: ${REPORTS_RELATIVE_PATH}/${reports[0]}\n`); + const plan = await readFile(join(root, REPORTS_RELATIVE_PATH, reports[0]!), "utf8"); + expect(plan).toStartWith("# agentcore migrate: plan\n"); + expect(plan).toContain(" ok git "); + expect(plan).toMatch(/runtime\s+CompatAAgent\s+CompatA_CompatAAgent\s+default\n/); + expect(plan).toContain(" - Dropped fields v1 doesn't use: credentials[compatA-oauth].usage\n"); + expect(plan).toContain(`\nStack ${STACK} (target 'default', 998846730471/us-east-1)\n`); + expect(plan).toContain(`See ${MIGRATION_DOCS_URL}`); + // Previewing changed nothing git would report. + expect(execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" })).toBe( + "", + ); + + expect(subject.io.stderr()).toBe( + `Checking the project\nReading stack ${STACK} (998846730471/us-east-1)\n` + + `Planning the refactor of ${STACK}`, + ); }); - test("stops at the git gate before reporting not available", async () => { - await inV0Project(); - const runner: ProcessRunner = async (command, { onOutput }) => { - if (command.includes("rev-parse")) onOutput?.("true\n"); - if (command.includes("status")) onOutput?.(" M agentcore/agentcore.json\n"); - }; + test("every run writes its own plan, and a later run still passes the git check", async () => { + const root = await inCommittedV0Project(); + await cli().run(); + await new Promise((resolve) => setTimeout(resolve, 1100)); + const subject = cli(); + await subject.run(); + expect(subject.io.stdout()).toEndWith(APPLY_HINT); + expect(await readdir(join(root, REPORTS_RELATIVE_PATH))).toHaveLength(2); + }); + + test("exits non-zero when the v1 deploy would replace an AgentCore resource", async () => { + await inCommittedV0Project(); + const subject = cli(templateFixture("codezip.v1-rc3")); + + await expect(subject.run()).rejects.toBeInstanceOf(SilentCLIError); - await expectError(migrate(runner).run(), "Commit or stash your changes", ProjectStateError); + const out = subject.io.stdout(); + // Every blocking change counts, one line each: 5 deletions and 3 replacements. + expect(out).toContain("\nNot ready to migrate: 8 issues need fixing first.\n"); + expect(out).toMatch(/\n {2}✖ ConfigurationBundle \w+: /); + expect(out.match(/^ {2}✖ /gm)).toHaveLength(8); + expect(out).toMatch(/\n\nFull plan: agentcore\/\.cli\/migrate\/plan-\S+\.md$/); }); - test("uses the shared no-project message without running git", async () => { - const { path, cleanup } = await inTempDirectory("agentcore-migrate-"); - cleanups.push(cleanup); - const { runner, calls } = cleanGit(); + test("--json renders the plan as JSON", async () => { + const root = await inCommittedV0Project(); + const subject = cli(); - await expectError( - migrate(runner).run(), - `No AgentCore project found at ${path} or any parent directory`, - ProjectStateError, + await subject.run("--json"); + + const plan = JSON.parse(subject.io.stdout()) as MigrationPlan; + expect(plan.ready).toBe(true); + expect(plan.rootPath).toBe(realpathSync(root)); + expect(plan.pins).toContainEqual({ + resourceType: "harness", + name: "CompatAHarness", + value: "CompatA_CompatAHarness", + targets: ["default"], + }); + expect(plan.stacks).toHaveLength(1); + expect(plan.stacks[0]).toMatchObject({ stackName: STACK, ready: true, replaced: [] }); + }); + + test("prints the plan and exits non-zero when the project can't migrate yet", async () => { + const root = await inCommittedV0Project(); + await writeFile(join(root, "wip.txt"), "uncommitted\n"); + const subject = cli(); + + await expect(subject.run()).rejects.toBeInstanceOf(SilentCLIError); + + expect(subject.io.stdout()).toContain("Not ready to migrate: 1 issue needs fixing first.\n"); + expect(subject.io.stdout()).toContain( + "\n ✖ git: Commit or stash your changes before migrating", + ); + // A failed git check doesn't keep the stack from being planned: the report has it. + const [report] = await readdir(join(root, REPORTS_RELATIVE_PATH)); + expect(await readFile(join(root, REPORTS_RELATIVE_PATH, report!), "utf8")).toContain( + `\nStack ${STACK} (target 'default', 998846730471/us-east-1)\n`, ); - expect(calls).toEqual([]); }); - test("--json writes the error to stdout", async () => { - await inV0Project(); - const { io, run } = migrate(cleanGit().runner, ["--json"]); + test("--json reports the CDK library override", async () => { + await inCommittedV0Project(); + const previous = process.env[CDK_LIBRARY_OVERRIDE_ENV]; + process.env[CDK_LIBRARY_OVERRIDE_ENV] = "/tmp/lib.tgz"; + try { + const subject = cli(); + await subject.run("--json"); + const plan = JSON.parse(subject.io.stdout()) as MigrationPlan; + expect(plan.cdkLibraryOverride).toBe(`file:${resolve("/tmp/lib.tgz")}`); + expect(plan.warnings).toContainEqual( + `Synthesized with @aws/agentcore-cdk from ${CDK_LIBRARY_OVERRIDE_ENV} (file:${resolve("/tmp/lib.tgz")}), not the library this CLI vends; the plan holds only for that library.`, + ); + } finally { + if (previous === undefined) delete process.env[CDK_LIBRARY_OVERRIDE_ENV]; + else process.env[CDK_LIBRARY_OVERRIDE_ENV] = previous; + } + }); - await expect(run()).rejects.toThrow(NotImplementedError); - expect(JSON.parse(io.stdout())).toEqual({ error: MIGRATE_NOT_AVAILABLE_MESSAGE }); + test("puts the CDK library override's install command on stdout", async () => { + await inCommittedV0Project(); + const previous = process.env[CDK_LIBRARY_OVERRIDE_ENV]; + process.env[CDK_LIBRARY_OVERRIDE_ENV] = "/tmp/lib.tgz"; + try { + const subject = cli(); + await subject.run(); + const out = subject.io.stdout(); + expect(out).toContain(` ! ${CDK_LIBRARY_OVERRIDE_ENV} is set (testing only)`); + expect(out).toContain(` ${overrideInstallCommand(`file:${resolve("/tmp/lib.tgz")}`)}\n`); + // The override's own long warnings stay in the report. + expect(out).not.toContain("Synthesized with @aws/agentcore-cdk from"); + } finally { + if (previous === undefined) delete process.env[CDK_LIBRARY_OVERRIDE_ENV]; + else process.env[CDK_LIBRARY_OVERRIDE_ENV] = previous; + } }); - test("is registered on the root command", async () => { - // A temp directory with no project: the real wiring fails before git runs. - cleanups.push((await inTempDirectory("agentcore-migrate-")).cleanup); - const root = createRootHandler(new TestCoreClient(), { - io: testIO().io, - globalConfigAccessor: new TestGlobalConfigAccessor(), - logger: createSilentLogger(), + test("has no --dry-run flag: every run previews, and only --yes or a yes applies", async () => { + await inCommittedV0Project(); + await expect(cli().run("--dry-run")).rejects.toThrow(/unknown option '--dry-run'/); + }); + + test("rejects the removed subcommands", async () => { + await inCommittedV0Project(); + await expect(cli().run("preview")).rejects.toThrow(); + await expect(cli().run("apply")).rejects.toThrow(); + }); + + test("fails outside a project", async () => { + await outsideAnyProject(); + await expect(cli().run()).rejects.toThrow("No AgentCore project found"); + }); + + test("refuses a project that is already v1", async () => { + await inV1Project(); + await expect(cli().run()).rejects.toThrow(ALREADY_V1_MESSAGE); + }); + + test("--json writes the already-v1 error to stdout", async () => { + await inV1Project(); + const subject = cli(); + await expect(subject.run("--json")).rejects.toThrow(ProjectStateError); + expect(JSON.parse(subject.io.stdout())).toEqual({ error: ALREADY_V1_MESSAGE }); + }); +}); + +/** A ready plan, for driving the confirm/apply paths with a fake migrator. */ +function readyPlan(rootPath: string): MigrationPlan { + return { + rootPath, + projectName: "CompatA", + checks: [{ name: "v0 project", ok: true, message: "'CompatA' is a v0 project" }], + problems: [], + deployedChanges: [], + pins: [], + warnings: [], + changes: [{ path: "agentcore/agentcore.json", kind: "modified" }], + stacks: [], + skippedStacks: [], + pendingTargets: [], + ready: true, + }; +} + +const MIGRATED: MigrationResult = { + migrated: true, + stacks: [ + { + target: "default", + stackName: STACK, + region: "us-east-1", + outcome: "refactored", + refactorId: "rf-1", + }, + ], + files: [{ path: "agentcore/agentcore.json", kind: "modified" }], + recovery: [], +}; + +/** A migrator whose plan and apply are scripted, recording whether apply ran. */ +function scriptedCli( + options: { + ready?: boolean; + result?: MigrationResult; + isTTY?: boolean; + stdin?: string; + plan?: Partial; + failReports?: boolean; + } = {}, +) { + const io = testIO({ + ...(options.isTTY !== undefined && { isTTY: options.isTTY }), + ...(options.stdin !== undefined && { stdin: options.stdin }), + }); + const calls = { + apply: 0, + dispose: 0, + targets: [] as Array, + reports: [] as Array<{ fileName: string; contents: string }>, + }; + const planFor = (rootPath: string): MigrationPlan => ({ + ...readyPlan(rootPath), + ready: options.ready ?? true, + ...options.plan, + }); + const projectMigrator: ProjectMigrator = { + plan: async (rootPath) => planFor(rootPath), + hasPendingMigration: async () => false, + saveReport: async (_rootPath, fileName, contents) => { + if (options.failReports) throw new Error("disk full"); + calls.reports.push({ fileName, contents }); + return `agentcore/.cli/migrate/${fileName}`; + }, + prepare: async (rootPath, prepareOptions) => ( + calls.targets.push(prepareOptions?.target), + prepareOptions?.onStep?.("Reading stack S"), + { + plan: planFor(rootPath), + apply: async (applyOptions) => { + calls.apply++; + applyOptions?.onStep?.("Refactoring stack S"); + applyOptions?.onWarning?.("Press Ctrl-C again to exit"); + return options.result ?? MIGRATED; + }, + dispose: async () => { + calls.dispose++; + }, + } + ), + }; + const root = createRootHandler(new TestCoreClient({ projectMigrator }), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + return { + io, + calls, + run: (...args: string[]) => root.route(["node", "agentcore", "migrate", ...args]), + }; +} + +describe("agentcore migrate, applying", () => { + test("passes --target to the plan", async () => { + await inCommittedV0Project(); + const subject = scriptedCli(); + + await subject.run("--target", "prod"); + await subject.run(); + + expect(subject.calls.targets).toEqual(["prod", undefined]); + }); + + test("migrating the selected target with others left pending exits zero and names them", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ + result: { + ...MIGRATED, + migrated: false, + stateFile: "agentcore/.cli/migration-state.json", + pendingTargets: ["prod"], + recovery: ["Target 'prod' isn't migrated yet."], + }, }); - await expect(root.route(["node", "agentcore", "migrate"])).rejects.toThrow( - /No AgentCore project found/, + await subject.run("--yes"); + + const out = subject.io.stdout(); + expect(out).toContain( + "Migrated this target. 'prod' is still pending: run `agentcore migrate --target ` for each.", + ); + }); + + test("undoing an interrupted migration exits zero", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ + result: { + migrated: false, + undone: true, + restored: true, + stacks: [], + files: [], + recovery: [], + }, + }); + + await subject.run("--yes"); + + expect(subject.io.stdout()).toContain("Undid the interrupted migration"); + }); + + test("without a terminal or --yes, prints the plan and how to apply it", async () => { + await inCommittedV0Project(); + const subject = scriptedCli(); + + await subject.run(); + + expect(subject.calls.apply).toBe(0); + expect(subject.io.stdout()).toEndWith(APPLY_HINT); + expect(subject.calls.dispose).toBe(1); + }); + + test("--yes applies a ready plan without asking", async () => { + await inCommittedV0Project(); + const subject = scriptedCli(); + + await subject.run("--yes"); + + expect(subject.calls.apply).toBe(1); + const out = subject.io.stdout(); + expect(out).not.toContain(CONFIRM_QUESTION.trim()); + expect(out).toContain(`\n✔ ${STACK}: refactored, every physical ID unchanged\n`); + expect(out).toContain( + "Migrated. Review the changes with `git diff`, then run `agentcore deploy`.\n", + ); + expect(out).toMatch(/Details: agentcore\/\.cli\/migrate\/result-\d{8}T\d{6}Z\.md$/); + // The plan and the result share the run's timestamp. + const [planReport, result] = subject.calls.reports; + expect(planReport?.fileName.replace("plan-", "")).toBe(result?.fileName.replace("result-", "")); + expect(result?.contents).toContain("# agentcore migrate: result"); + // Apply's steps and warnings go through the progress lines on stderr. + expect(subject.io.stderr()).toContain( + "Refactoring stack S\nWarning: Press Ctrl-C again to exit", + ); + }); + + test("--yes never applies a plan that isn't ready", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ ready: false }); + + await expect(subject.run("--yes")).rejects.toBeInstanceOf(SilentCLIError); + expect(subject.calls.apply).toBe(0); + }); + + test("on a terminal, asks first and applies on yes", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ isTTY: true, stdin: "y\n" }); + + await subject.run(); + + expect(subject.io.stderr()).toContain(CONFIRM_QUESTION.trim()); + expect(subject.calls.apply).toBe(1); + // On a terminal the steps render in the progress task list. + expect(subject.io.stderr()).toContain("Checking the project"); + expect(subject.io.stderr()).toContain("Refactoring stack S"); + // The question comes after planning's progress and before apply's. + const err = subject.io.stderr(); + expect(err.indexOf(CONFIRM_QUESTION.trim())).toBeGreaterThan(err.indexOf("Reading stack S")); + expect(err.indexOf(CONFIRM_QUESTION.trim())).toBeLessThan(err.indexOf("Refactoring stack S")); + }); + + test("on a terminal, anything but yes cancels", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ isTTY: true, stdin: "\n" }); + + await subject.run(); + + expect(subject.calls.apply).toBe(0); + expect(subject.io.stdout()).toContain("Migration cancelled. Nothing was changed."); + }); + + test("--json without --yes only renders the plan", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ isTTY: true }); + + await subject.run("--json"); + + expect(subject.calls.apply).toBe(0); + expect(JSON.parse(subject.io.stdout()).ready).toBe(true); + }); + + test("--json --yes renders the plan and the result", async () => { + await inCommittedV0Project(); + const subject = scriptedCli(); + + await subject.run("--json", "--yes"); + + const out = JSON.parse(subject.io.stdout()) as { plan: MigrationPlan; result: MigrationResult }; + expect(out.plan.ready).toBe(true); + expect(out.result.migrated).toBe(true); + }); + + test("an incomplete migration exits non-zero after reporting it", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ + result: { + migrated: false, + restored: true, + stacks: [{ ...MIGRATED.stacks[0]!, outcome: "failed", message: "rolled back" }], + files: [], + error: "rolled back", + recovery: [], + }, + }); + + await expect(subject.run("--yes")).rejects.toBeInstanceOf(SilentCLIError); + expect(subject.io.stdout()).toContain("the project's files were put back: it's v0 again"); + }); +}); + +describe("agentcore migrate, reports", () => { + test("after applying with the library override, installing it is the next step", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ plan: { cdkLibraryOverride: "file:/tmp/lib.tgz" } }); + + await subject.run("--yes"); + + const out = subject.io.stdout(); + expect(out).toContain( + "Migrated. Review the changes with `git diff`, then:\n" + + ` ! ${CDK_LIBRARY_OVERRIDE_ENV} is set (testing only): the migrated app uses the released\n` + + " library, so before `agentcore deploy` install the one this plan was made with:\n" + + " cd agentcore/cdk && npm install /tmp/lib.tgz --no-save\n" + + " agentcore deploy\n", + ); + }); + + test("an unknown refactor outcome keeps its ID and the commands to check it on stdout", async () => { + await inCommittedV0Project(); + const check = `aws cloudformation describe-stack-refactor --stack-refactor-id rf-9 --region us-east-1`; + const subject = scriptedCli({ + result: { + migrated: false, + stacks: [ + { + ...MIGRATED.stacks[0]!, + outcome: "needs-repair", + refactorId: "rf-9", + message: "Whether the refactor ran isn't known.", + }, + ], + files: [], + stateFile: "agentcore/.cli/migration-state.json", + error: "unknown", + recovery: [`Check the refactor: ${check}`], + }, + }); + + await expect(subject.run("--yes")).rejects.toBeInstanceOf(SilentCLIError); + + const out = subject.io.stdout(); + expect(out).toContain(`✖ ${STACK}: needs a look (see below) [rf-9]\n`); + expect(out).toContain(" Whether the refactor ran isn't known.\n"); + expect(out).toContain(`What to do\n - Check the refactor: ${check}\n`); + }); + + test("shows the full plan when the report can't be written", async () => { + await inCommittedV0Project(); + const subject = scriptedCli({ failReports: true }); + + await subject.run(); + + const out = subject.io.stdout(); + expect(out).toContain("Checks\n"); + expect(out).toContain("Warning: couldn't save the full plan (disk full)"); + expect(out).toEndWith(APPLY_HINT); + }); +}); + +describe("formatMigrationSummary", () => { + const base: MigrationPlan = { + rootPath: "/p", + projectName: "Proj", + checks: [{ name: "v0 project", ok: true, message: "'Proj' is a v0 project" }], + problems: [], + deployedChanges: [], + pins: [ + { resourceType: "runtime", name: "agent", value: "Proj_agent", targets: ["default"] }, + { resourceType: "memory", name: "mem", value: "Proj_mem", targets: ["default"] }, + ], + warnings: ["Dropped unassigned gateway targets (t1): v1 has no unassigned targets. More."], + changes: [ + { path: "agentcore/agentcore.json", kind: "modified" }, + { path: "agentcore/cdk/bin/cdk.ts", kind: "modified" }, + { path: "agentcore/cdk/lib/cdk-stack.ts", kind: "modified" }, + ], + stacks: [ + { + target: "default", + account: "123456789012", + region: "us-east-1", + stackName: "AgentCore-Proj-default", + moves: [{ type: "AWS::BedrockAgentCore::Memory", from: "A", to: "B" }], + unchanged: 0, + added: [], + removed: [], + replaced: [], + updated: [ + { + logicalId: "Rt", + type: "AWS::BedrockAgentCore::Runtime", + severity: "warning", + reason: "EnvironmentVariables change", + }, + ], + blockers: [], + ready: true, + }, + ], + skippedStacks: [], + pendingTargets: [], + ready: true, + }; + + test("a ready plan: a few lines and the report", () => { + expect(formatMigrationSummary(base, "agentcore/.cli/migrate/plan-1.md")).toBe( + [ + "Migration plan: Proj → v1 (target default, 123456789012/us-east-1)", + " Keeps the names of 2 deployed resources (runtime agent, memory mem)", + " Moves 1 resource in AgentCore-Proj-default to new logical IDs (stack refactor)", + " First v1 deploy: replaces nothing; updates 1 resource in place", + " Changes 3 files (agentcore/agentcore.json, agentcore/cdk/…)", + " ! Dropped unassigned gateway targets (t1): v1 has no unassigned targets.", + "", + "Full plan: agentcore/.cli/migrate/plan-1.md", + "", + ].join("\n"), + ); + }); + + test("a plan that isn't ready: one line per blocker", () => { + const stack = base.stacks[0]!; + const out = formatMigrationSummary( + { + ...base, + ready: false, + checks: [ + ...base.checks, + { name: "cdk app", ok: false, message: "agentcore/cdk was changed.\n added x.ts" }, + ], + stacks: [ + { + ...stack, + ready: false, + replaced: [ + { + logicalId: "Mem", + type: "AWS::BedrockAgentCore::Memory", + severity: "blocker", + reason: "the v1 deploy would replace it. Its data would be lost.", + }, + ], + }, + ], + }, + "agentcore/.cli/migrate/plan-1.md", + ); + expect(out).toBe( + [ + "Migration plan: Proj → v1 (target default, 123456789012/us-east-1)", + "", + "Not ready to migrate: 2 issues need fixing first.", + " ✖ cdk app: agentcore/cdk was changed.", + " ✖ Memory Mem: the v1 deploy would replace it.", + "", + "Full plan: agentcore/.cli/migrate/plan-1.md", + "", + ].join("\n"), + ); + }); + + test("names the targets left pending, and titles a pending migration it finishes", () => { + expect(formatMigrationSummary({ ...base, pendingTargets: ["prod"] }, "p.md")).toContain( + " Leaves prod pending: deploying it is blocked until each is migrated with " + + "`agentcore migrate --target `\n", + ); + expect(formatMigrationSummary({ ...base, finishPending: true }, "p.md").split("\n")[0]).toBe( + "Finish the pending migration of Proj (target default, 123456789012/us-east-1)", + ); + }); +}); + +describe("report helpers", () => { + test("reportStamp is a compact UTC timestamp", () => { + expect(reportStamp(new Date("2026-10-13T22:30:05.123Z"))).toBe("20261013T223005Z"); + }); + + test("overrideInstallCommand uses a file override's path, quoting when needed", () => { + expect(overrideInstallCommand("file:/tmp/lib.tgz")).toBe( + "cd agentcore/cdk && npm install /tmp/lib.tgz --no-save", + ); + expect(overrideInstallCommand("@aws/agentcore-cdk@1.0.0-rc.4")).toBe( + "cd agentcore/cdk && npm install @aws/agentcore-cdk@1.0.0-rc.4 --no-save", + ); + expect(overrideInstallCommand("file:/tmp/my lib.tgz")).toBe( + "cd agentcore/cdk && npm install '/tmp/my lib.tgz' --no-save", + ); + }); + + test("formatResultSummary points at the details", () => { + expect(formatResultSummary(MIGRATED, "r.md")).toBe( + [ + `✔ ${STACK}: refactored, every physical ID unchanged`, + "", + "Migrated. Review the changes with `git diff`, then run `agentcore deploy`.", + "Details: r.md", + "", + ].join("\n"), + ); + }); +}); + +describe("formatMigrationResult", () => { + test("a part-way migration says how to finish it", () => { + const out = formatMigrationResult({ + migrated: false, + stacks: [ + { ...MIGRATED.stacks[0]!, target: "dev" }, + { ...MIGRATED.stacks[0]!, target: "prod", outcome: "failed", message: "throttled" }, + ], + files: [], + stateFile: "agentcore/.cli/migration-state.json", + error: "throttled", + recovery: ["Target 'prod' wasn't refactored."], + }); + expect(out).toContain("failed; CloudFormation left the stack as it was"); + expect(out).toContain(" - Target 'prod' wasn't refactored."); + expect(out).toContain( + "agentcore/.cli/migration-state.json records where it is. Fix the cause above, then run " + + "`agentcore migrate` again", + ); + expect(out).toContain(MIGRATION_DOCS_URL); + }); +}); + +describe("formatMigrationPlan", () => { + test("renders checks, pins, changes, warnings, and problems", () => { + const plan: MigrationPlan = { + rootPath: "/p", + projectName: "Proj", + checks: [ + { name: "v0 project", ok: true, message: "'Proj' is a v0 project" }, + { name: "git", ok: false, message: "Commit or stash these changes first:\n ?? x" }, + ], + deployedChanges: [ + { + file: "agentcore/agentcore.json", + path: "payments[pm].x", + message: "v1 drops this field", + }, + ], + problems: [ + { file: "agentcore/agentcore.json", path: "credentials[ab].name", message: "too short" }, + ], + pins: [ + { + resourceType: "runtime", + name: "agent", + value: "Proj_agent", + targets: ["default"], + }, + ], + warnings: ["Dropped fields v1 doesn't use: credentials[o].usage"], + changes: [ + { path: "agentcore/agentcore.json", kind: "modified" }, + { path: "app/h/harness.yaml", kind: "added" }, + ], + stacks: [], + skippedStacks: [{ target: "prod", reason: "credentials for account 2 are needed" }], + pendingTargets: [], + ready: false, + }; + expect(formatMigrationPlan(plan)).toBe( + [ + "Migration plan for project 'Proj' (nothing was changed)", + "", + "Checks", + " ok v0 project 'Proj' is a v0 project", + " FAIL git Commit or stash these changes first:", + " ?? x", + "", + 'Names kept for deployed resources (migratedFrom: "v1" keeps v0 naming)', + " TYPE NAME AWS NAME TARGETS", + " runtime agent Proj_agent default", + "", + "File changes", + " modified agentcore/agentcore.json", + " added app/h/harness.yaml", + "", + "Stack plans skipped (prod): credentials for account 2 are needed", + "", + "Warnings", + " - Dropped fields v1 doesn't use: credentials[o].usage", + "", + "Would change deployed configuration", + " - agentcore/agentcore.json payments[pm].x: v1 drops this field", + "", + "Fix before migrating", + " - agentcore/agentcore.json credentials[ab].name: too short", + "", + "Not ready to migrate: 3 issues need fixing first.", + `See ${MIGRATION_DOCS_URL} for what migrating changes and what to do after.`, + "", + ].join("\n"), + ); + }); + + test("says when nothing is deployed and skips sections for a project it couldn't read", () => { + const base = { + rootPath: "/p", + problems: [], + deployedChanges: [], + pins: [], + warnings: [], + changes: [], + stacks: [], + skippedStacks: [], + pendingTargets: [], + }; + expect( + formatMigrationPlan({ + ...base, + projectName: "Proj", + checks: [{ name: "v0 project", ok: true, message: "ok" }], + ready: true, + }), + ).toContain( + 'Names kept for deployed resources (migratedFrom: "v1" keeps v0 naming)\n none (nothing is deployed yet)\n', + ); + const unreadable = formatMigrationPlan({ + ...base, + checks: [{ name: "v0 project", ok: false, message: "already v1" }], + ready: false, + }); + expect(unreadable).toStartWith("Migration plan for /p (nothing was changed)"); + expect(unreadable).not.toContain("File changes"); + }); +}); + +describe("formatMigrationPlan, targets", () => { + const base: MigrationPlan = { + rootPath: "/p", + projectName: "Proj", + checks: [{ name: "target", ok: true, message: "migrating 'dev'" }], + problems: [], + deployedChanges: [], + pins: [], + warnings: [], + changes: [], + stacks: [], + skippedStacks: [], + pendingTargets: ["prod", "test"], + selectedTarget: "dev", + ready: true, + }; + + test("names the deployed targets recorded as pending", () => { + expect(formatMigrationPlan(base)).toContain( + "Other deployed targets, recorded as pending: prod, test\n" + + " Their stacks keep the v0 logical IDs, and deploying them is blocked, until each is " + + "migrated with `agentcore migrate --target `.\n", + ); + }); + + test("titles a plan that finishes a pending migration, or undoes an interrupted one", () => { + expect(formatMigrationPlan({ ...base, finishPending: true })).toStartWith( + "Finishing the pending migration of project 'Proj' (nothing was changed yet)\n", + ); + const undo = formatMigrationPlan({ + ...base, + pendingTargets: [], + undoInterrupted: true, + changes: [{ path: "agentcore/agentcore.json", kind: "modified" }], + }); + expect(undo).toStartWith( + "Undoing the interrupted migration of project 'Proj' (nothing was changed yet)\n", + ); + expect(undo).toContain( + "Files put back from the backup\n modified agentcore/agentcore.json\n", + ); + }); +}); + +describe("formatStackPlan", () => { + const stack: StackMigrationPlan = { + target: "default", + account: "123456789012", + region: "us-east-1", + stackName: "AgentCore-P-default", + moves: [{ type: "AWS::BedrockAgentCore::Memory", from: "OldMem", to: "NewMem" }], + unchanged: 1, + added: [{ logicalId: "Trigger2", type: "AWS::CloudFormation::CustomResource" }], + removed: [ + { + logicalId: "Trigger1", + type: "AWS::CloudFormation::CustomResource", + severity: "info", + reason: "recreated", + }, + ], + replaced: [ + { logicalId: "Repo", type: "AWS::ECR::Repository", severity: "warning", reason: "rebuilt" }, + { + logicalId: "Bundle", + type: "AWS::BedrockAgentCore::ConfigurationBundle", + severity: "blocker", + reason: "lost", + }, + ], + updated: [ + { + logicalId: "Gw", + type: "AWS::BedrockAgentCore::Gateway", + severity: "blocker", + reason: "ProtocolType changes", + }, + ], + blockers: ["the stack name changes"], + ready: false, + }; + + test("lists moves, the first v1 deploy's changes, and the verdict", () => { + expect(formatStackPlan(stack)).toEqual([ + "Stack AgentCore-P-default (target 'default', 123456789012/us-east-1)", + " Refactor: move 1 resource to new logical IDs", + " TYPE FROM TO", + " AWS::BedrockAgentCore::Memory OldMem NewMem", + " 1 resource keep their logical IDs", + " Then the first v1 deploy:", + " creates", + " AWS::CloudFormation::CustomResource Trigger2", + " deletes", + " AWS::CloudFormation::CustomResource Trigger1: recreated", + " replaces", + " warning AWS::ECR::Repository Repo: rebuilt", + " BLOCKER AWS::BedrockAgentCore::ConfigurationBundle Bundle: lost", + " updates in place", + " BLOCKER AWS::BedrockAgentCore::Gateway Gw: ProtocolType changes", + " Replacements: 1 resource that must be kept would be replaced or deleted", + " BLOCKER: the stack name changes", + ]); + }); + + test("says when the deploy changes nothing", () => { + const quiet = formatStackPlan({ + ...stack, + moves: [], + added: [], + removed: [], + replaced: [], + updated: [], + blockers: [], + }); + expect(quiet).toContain(" Refactor: move 0 resources to new logical IDs"); + expect(quiet).toContain(" creates, deletes, and replaces nothing"); + expect(quiet.at(-1)).toBe( + " Replacements: 0 replacements; no resource that must be kept is replaced or deleted", ); }); }); diff --git a/src/handlers/project/migrate/index.ts b/src/handlers/project/migrate/index.ts index 00dfd1b90..5ed11ad14 100644 --- a/src/handlers/project/migrate/index.ts +++ b/src/handlers/project/migrate/index.ts @@ -1,43 +1,731 @@ -import { ERROR_SOURCE, NotImplementedError, ProjectStateError } from "../../../errors"; -import type { ProcessRunner } from "../../../io"; -import { createHandler, type Middleware } from "../../../router"; -import { enclosingProjectRoot } from "../../../core/project/fsUtils"; -import { checkGitGate } from "../../../core/project/migrate/gitGate"; -import { projectNotFoundMessage } from "../../../middleware/withProject"; +import { createInterface } from "node:readline/promises"; +import z from "zod"; +import { + ERROR_SOURCE, + ProjectStateError, + SilentCLIError, + UserCancellationError, +} from "../../../errors"; +import type { AppIO } from "../../../io"; +import { withProject } from "../../../middleware/withProject"; +import { createHandler, flag, ProjectKey, type Middleware } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import { progressFrom, runWithProgress, type ProgressEvent } from "../../../tui/progress"; import { JsonKey } from "../../keys"; import { renderJsonError } from "../../utils"; +import type { ProjectManager } from "../types"; +import { + CDK_LIBRARY_OVERRIDE_ENV, + DROPPED_FIELDS_WARNING, + stackBlockerCount, + type MigrationPlan, + type MigrationResult, + type ProjectMigrator, + type StackMigrationPlan, + type StackRefactorResult, + type StackResourceChange, +} from "./types"; + +export type { MigrationPlan, ProjectMigrator } from "./types"; type MigrateProjectHandlerConfig = { - /** Runs git; injectable so tests can fake it. */ - runner: ProcessRunner; + projectManager: ProjectManager; + migrator: ProjectMigrator; + io: AppIO; /** Directory to search upwards from. Defaults to the cwd at invocation time. */ cwd?: string; middlewares?: Middleware[]; }; -export const MIGRATE_NOT_AVAILABLE_MESSAGE = "agentcore migrate isn't available yet."; +export const ALREADY_V1_MESSAGE = + "This project is already in the v1 format; there's nothing to migrate."; + +/** + * The migration guide for existing projects (docs CR-309021468). The final URL is + * TBD until the guide is published; this is the expected location. + */ +export const MIGRATION_DOCS_URL = + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/migration-existing-project.html"; + +export const CONFIRM_QUESTION = "Apply these changes? (y/N) "; +/** Printed after a ready plan when nothing can confirm applying it. */ +export const APPLY_HINT = + "Nothing was changed. Run `agentcore migrate --yes` to apply these changes."; + +/** + * `agentcore migrate`: upgrades the enclosing v0 project to v1, one flow every + * time. It checks the project and prints the plan (what changes in the project + * and in the selected target's stack, and whether anything would be replaced), + * calculated afresh: planning works in a temporary copy of the project and only + * reads from AWS, and nothing from an earlier run is reused. A plan that isn't + * ready stops there (exit 1). A ready one is applied once confirmed: by answering + * "Apply these changes?" on an interactive terminal, or with --yes, which skips + * only the prompt. Without --yes, a non-interactive or --json run only prints the + * plan. + * + * One deployed target is migrated automatically; with several, --target picks + * the one to migrate, and the others stay pending. In a project with a pending + * migration, the same command finishes it: `agentcore migrate --target ` + * migrates that pending target. + */ export const createMigrateProjectHandler = (config: MigrateProjectHandlerConfig) => createHandler({ name: "migrate", - description: "migrate an AgentCore CLI v0 project to the current project format", - middlewares: config.middlewares, - handle: async (ctx) => { + description: + "migrate an AgentCore CLI v0 project to the current project format, keeping its deployed resources", + middlewares: [ + withProject({ projectManager: config.projectManager, cwd: config.cwd }), + ...(config.middlewares ?? []), + ], + flags: [ + flag( + "target", + "the deployment target to migrate (required when the project is deployed to several)", + z.string().min(1).optional(), + ), + flag( + "yes", + "apply the changes without asking, when they are ready", + z.boolean().default(false), + ), + ], + handle: async (ctx, flags) => { + const json = ctx.require(JsonKey); try { - // Located by its marker file rather than withProject: a v0 project's - // agentcore.json doesn't parse as a current project spec. - const from = config.cwd ?? process.cwd(); - const projectRoot = enclosingProjectRoot(from); - if (!projectRoot) throw new ProjectStateError(projectNotFoundMessage(from)); + const project = ctx.require(ProjectKey); + if (!project.legacy && !(await config.migrator.hasPendingMigration(project.rootPath))) { + throw new ProjectStateError(ALREADY_V1_MESSAGE); + } + const io = config.io; + // Progress goes to stderr, so it never mixes with the summary (or the JSON) + // on stdout; --json keeps it to plain lines. + const progress = { io, ...(json && { interactive: false }) }; + const stamp = reportStamp(new Date()); + const prepared = await runWithProgress( + progressFrom((emit) => { + emit({ type: "step", message: "Checking the project" }); + return config.migrator.prepare(project.rootPath, { + onStep: stepTo(emit), + ...(flags.target !== undefined && { target: flags.target }), + }); + }), + progress, + ); + try { + const { plan } = prepared; + const notReady = () => + // Not being ready is about the user's project, not a CLI failure. + new SilentCLIError("the project can't be migrated yet", { source: ERROR_SOURCE.USER }); + const planFile = await saveReport( + config.migrator, + project.rootPath, + `plan-${stamp}.md`, + planReport(plan, stamp), + ); + const showPlan = (footer?: string) => { + if (json) { + ctx.require(JsonRendererKey).renderJson(plan); + return; + } + const summary = planFile.path + ? formatMigrationSummary(plan, planFile.path) + : // Without the report the summary would point nowhere: show everything. + `${formatMigrationPlan(plan)}\nWarning: couldn't save the full plan (${planFile.error})\n`; + io.stdout.write(`${summary}${footer ? `\n${footer}\n` : ""}`); + }; - await checkGitGate(config.runner, projectRoot); + if (!plan.ready) { + showPlan(); + throw notReady(); + } + const canPrompt = + !flags.yes && !json && io.stdin.isTTY && io.stdout.isTTY && io.stderr.isTTY; + if (!flags.yes && !canPrompt) return showPlan(APPLY_HINT); + // With --json the plan goes out once, with the result. + if (!json) showPlan(); + // The question is settled here, before apply's progress UI mounts: nothing + // may wait on input once it does. + if (canPrompt && !(await confirm(io))) { + io.stdout.write("\nMigration cancelled. Nothing was changed.\n"); + return; + } - throw new NotImplementedError(MIGRATE_NOT_AVAILABLE_MESSAGE, { - source: ERROR_SOURCE.USER, - }); + const result = await runWithProgress( + progressFrom((emit) => + prepared.apply({ + onStep: stepTo(emit), + onWarning: (message) => emit({ type: "warning", message }), + onOutput: (line) => emit({ type: "output", line }), + }), + ), + progress, + ); + const resultFile = await saveReport( + config.migrator, + project.rootPath, + `result-${stamp}.md`, + resultReport(result, stamp), + ); + if (json) ctx.require(JsonRendererKey).renderJson({ plan, result }); + else { + io.stdout.write( + resultFile.path + ? `\n${formatResultSummary(result, resultFile.path, plan.cdkLibraryOverride)}` + : `\n${formatMigrationResult(result)}`, + ); + } + if (!succeeded(result)) { + throw new SilentCLIError("the migration didn't complete", { + source: ERROR_SOURCE.USER, + }); + } + } finally { + await prepared.dispose(); + } } catch (error) { - if (ctx.require(JsonKey)) renderJsonError(ctx, error); + // A not-ready plan or an incomplete migration is already on stdout. + if (json && !(error instanceof SilentCLIError)) renderJsonError(ctx, error); throw error; } }, }); + +/** + * Whether the run did what it set out to: migrated everything, migrated the + * selected target with others left pending, or undid an interrupted migration. + */ +function succeeded(result: MigrationResult): boolean { + if (result.migrated || result.undone) return true; + return ( + result.error === undefined && + result.restored !== true && + result.stacks.every((stack) => ["refactored", "unchanged"].includes(stack.outcome)) + ); +} + +/** Reports the migrator's step messages as progress steps. */ +function stepTo(emit: (event: ProgressEvent) => void): (message: string) => void { + return (message) => emit({ type: "step", message }); +} + +/** A UTC timestamp for report file names: 20261013T223000Z. */ +export function reportStamp(now: Date): string { + return now + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "Z"); +} + +/** Saves a report, reporting a failure instead of throwing: a report is never worth failing for. */ +async function saveReport( + migrator: ProjectMigrator, + rootPath: string, + fileName: string, + contents: string, +): Promise<{ path?: string; error?: string }> { + try { + return { path: await migrator.saveReport(rootPath, fileName, contents) }; + } catch (error) { + return { error: (error as Error).message }; + } +} + +function report(title: string, stamp: string, body: string): string { + return `# ${title}\n\nWritten by \`agentcore migrate\` at ${stamp}.\n\n\`\`\`text\n${body}\`\`\`\n`; +} + +/** The full plan, for the report file. */ +export function planReport(plan: MigrationPlan, stamp: string): string { + return report("agentcore migrate: plan", stamp, formatMigrationPlan(plan)); +} + +/** The full result, for the report file. */ +export function resultReport(result: MigrationResult, stamp: string): string { + return report("agentcore migrate: result", stamp, formatMigrationResult(result)); +} + +const SUMMARY_TEXT_LIMIT = 160; + +/** The first line and sentence of a message, cut at a word near the limit. */ +function brief(message: string): string { + const firstLine = message.split("\n")[0]!.trim(); + const sentence = /^.*?[.!?](?=\s|$)/.exec(firstLine)?.[0] ?? firstLine; + if (sentence.length <= SUMMARY_TEXT_LIMIT) return sentence; + const cut = sentence.slice(0, SUMMARY_TEXT_LIMIT); + return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), 0) || SUMMARY_TEXT_LIMIT)}…`; +} + +/** `AWS::BedrockAgentCore::Memory` → `Memory`. */ +function shortType(type: string): string { + return type.split("::").at(-1) ?? type; +} + +function listed(items: string[], limit = 5): string { + if (items.length <= limit) return items.join(", "); + return `${items.slice(0, limit).join(", ")}, and ${items.length - limit} more`; +} + +/** + * The command that installs the CDK library a plan was made with, run from the + * project root: the migrated app vends the released library, which a plan made + * with AGENTCORE_MIGRATE_CDK_LIBRARY doesn't match. + */ +export function overrideInstallCommand(cdkLibraryOverride: string): string { + const spec = cdkLibraryOverride.startsWith("file:") + ? cdkLibraryOverride.slice("file:".length) + : cdkLibraryOverride; + const quoted = /^[A-Za-z0-9@%+=:,./_-]+$/.test(spec) ? spec : `'${spec.replace(/'/g, "'\\''")}'`; + return `cd agentcore/cdk && npm install ${quoted} --no-save`; +} + +function overrideWarningLines(cdkLibraryOverride: string, indent: string): string[] { + return [ + `${indent}! ${CDK_LIBRARY_OVERRIDE_ENV} is set (testing only): the migrated app uses the released`, + `${indent} library, so before \`agentcore deploy\` install the one this plan was made with:`, + `${indent} ${overrideInstallCommand(cdkLibraryOverride)}`, + ]; +} + +/** One line per thing that keeps the plan from being ready. */ +export function planBlockers(plan: MigrationPlan): string[] { + const lines: string[] = []; + for (const check of plan.checks) { + if (!check.ok) lines.push(`${check.name}: ${brief(check.message)}`); + } + for (const problem of [...plan.problems, ...plan.deployedChanges]) { + lines.push(`${problem.file} ${problem.path}: ${brief(problem.message)}`); + } + for (const skipped of plan.skippedStacks) { + const which = skipped.target === undefined ? "" : ` (${skipped.target})`; + lines.push(`stack plan skipped${which}: ${brief(skipped.reason)}`); + } + for (const stack of plan.stacks) { + for (const blocker of stack.blockers) lines.push(`${stack.stackName}: ${brief(blocker)}`); + for (const change of [...stack.removed, ...stack.replaced, ...stack.updated]) { + if (change.severity === "blocker") { + lines.push(`${shortType(change.type)} ${change.logicalId}: ${brief(change.reason)}`); + } + } + } + return lines; +} + +/** What the first v1 deploy does to the stack, in one line. */ +function deployImpact(stack: StackMigrationPlan): string { + const lost = [ + stack.replaced.length > 0 ? `replaces ${count(stack.replaced.length, "resource")}` : "", + stack.removed.length > 0 ? `deletes ${count(stack.removed.length, "resource")}` : "", + ].filter(Boolean); + const parts = [ + lost.length === 0 ? "replaces nothing" : `${lost.join(", ")} (none that must be kept)`, + stack.updated.length > 0 ? `updates ${count(stack.updated.length, "resource")} in place` : "", + stack.added.length > 0 ? `creates ${count(stack.added.length, "resource")}` : "", + ].filter(Boolean); + return parts.join("; "); +} + +/** The top-level names of the changed files: agentcore/cdk/… stands for the CDK app. */ +function changedFileNames(plan: MigrationPlan): string[] { + const names = plan.changes.map((change) => + change.path.startsWith("agentcore/cdk/") ? "agentcore/cdk/…" : change.path, + ); + return [...new Set(names)]; +} + +/** + * What `agentcore migrate` prints: the plan in a few lines (or why it isn't + * ready), pointing to the report with the full detail. + */ +export function formatMigrationSummary(plan: MigrationPlan, planPath: string): string { + const lines: string[] = []; + const title = plan.projectName ?? plan.rootPath; + const [only] = plan.stacks; + const where = + plan.stacks.length === 1 && only + ? ` (target ${only.target}, ${only.account}/${only.region})` + : plan.selectedTarget + ? ` (target ${plan.selectedTarget})` + : ""; + lines.push( + plan.undoInterrupted + ? `Undo the interrupted migration of ${title}` + : plan.finishPending + ? `Finish the pending migration of ${title}${where}` + : `Migration plan: ${title} → v1${where}`, + ); + + if (!plan.ready) { + const blockers = planBlockers(plan); + const failed = Math.max(blockers.length, 1); + lines.push( + "", + `Not ready to migrate: ${failed} ${failed === 1 ? "issue needs" : "issues need"} fixing first.`, + ...blockers.map((blocker) => ` ✖ ${blocker}`), + "", + `Full plan: ${planPath}`, + ); + return `${lines.join("\n")}\n`; + } + + if (plan.undoInterrupted) { + lines.push(` Puts back ${count(plan.changes.length, "file")} from the backup`); + } else { + if (!plan.finishPending) { + lines.push( + plan.pins.length === 0 + ? " Nothing is deployed yet, so only the project files change" + : ` Keeps the names of ${count(plan.pins.length, "deployed resource")} ` + + `(${listed(plan.pins.map((pin) => `${pin.resourceType} ${pin.name}`))})`, + ); + } + for (const stack of plan.stacks) { + lines.push( + ` Moves ${count(stack.moves.length, "resource")} in ${stack.stackName} to new logical IDs (stack refactor)`, + ` First v1 deploy: ${deployImpact(stack)}`, + ); + } + if (!plan.finishPending && plan.changes.length > 0) { + lines.push( + ` Changes ${count(plan.changes.length, "file")} (${listed(changedFileNames(plan), 4)})`, + ); + } + if (plan.pendingTargets.length > 0) { + lines.push( + ` Leaves ${listed(plan.pendingTargets)} pending: deploying ${plan.pendingTargets.length === 1 ? "it" : "them"} ` + + "is blocked until each is migrated with `agentcore migrate --target `", + ); + } + } + const override = plan.cdkLibraryOverride; + for (const warning of plan.warnings) { + if (warning.startsWith(DROPPED_FIELDS_WARNING)) continue; + if (override !== undefined && warning.includes(CDK_LIBRARY_OVERRIDE_ENV)) continue; + lines.push(` ! ${brief(warning)}`); + } + if (override !== undefined) lines.push(...overrideWarningLines(override, " ")); + lines.push("", `Full plan: ${planPath}`); + return `${lines.join("\n")}\n`; +} + +/** The outcome line and what to run next: review, install a test library if one was used, deploy. */ +function nextSteps(outcome: string, cdkLibraryOverride: string | undefined): string[] { + if (cdkLibraryOverride === undefined) { + return [`${outcome} Review the changes with \`git diff\`, then run \`agentcore deploy\`.`]; + } + return [ + `${outcome} Review the changes with \`git diff\`, then:`, + ...overrideWarningLines(cdkLibraryOverride, "").map((line) => ` ${line}`), + " agentcore deploy", + ]; +} + +/** What `agentcore migrate` prints after applying: a line per stack, and the next step. */ +export function formatResultSummary( + result: MigrationResult, + resultPath: string, + cdkLibraryOverride?: string, +): string { + const lines: string[] = []; + for (const stack of result.stacks) { + const ok = stack.outcome === "refactored" || stack.outcome === "unchanged"; + lines.push( + `${ok ? "✔" : "✖"} ${stack.stackName}: ${OUTCOME_LABELS[stack.outcome]}` + + (stack.refactorId && !ok ? ` [${stack.refactorId}]` : ""), + ); + if (stack.message && !ok) lines.push(` ${stack.message}`); + } + for (const warning of result.warnings ?? []) lines.push(`! ${brief(warning)}`); + // What to do by hand stays in full: it names the refactor and the commands to run. + if (result.recovery.length > 0) { + lines.push("", "What to do"); + for (const step of result.recovery) lines.push(` - ${step}`); + } + lines.push(""); + const pending = result.pendingTargets ?? []; + if (result.undone) { + lines.push("Undid the interrupted migration: the project's v0 files are back."); + } else if (result.migrated) { + lines.push(...nextSteps("Migrated.", cdkLibraryOverride)); + } else if (result.restored) { + lines.push( + "The migration didn't complete, and the project's files were put back: it's v0 again.", + ); + } else if (succeeded(result) && pending.length > 0) { + lines.push( + ...nextSteps( + `Migrated this target. ${listed(pending.map((name) => `'${name}'`))} ` + + `${pending.length === 1 ? "is" : "are"} still pending: run \`agentcore migrate --target \` ` + + "for each.", + cdkLibraryOverride, + ), + ); + } else if (result.stateFile) { + lines.push( + `The migration is part-way through (${result.stateFile}). Fix the cause above, then run ` + + "`agentcore migrate` again to finish it.", + ); + } else { + lines.push("The migration didn't complete."); + } + lines.push(`Details: ${resultPath}`); + return `${lines.join("\n")}\n`; +} + +/** Asks the user to confirm applying the plan; Ctrl-C or a closed stdin cancels. */ +async function confirm(io: AppIO): Promise { + const readline = createInterface({ input: io.stdin, output: io.stderr }); + try { + const cancelled = new Promise((_resolve, reject) => { + const cancel = () => reject(new UserCancellationError()); + readline.once("SIGINT", cancel); + readline.once("close", cancel); + }); + const answer = await Promise.race([readline.question(`\n${CONFIRM_QUESTION}`), cancelled]); + return /^(?:y|yes)$/i.test(answer.trim()); + } finally { + readline.close(); + } +} + +const OUTCOME_LABELS: Record = { + refactored: "refactored, every physical ID unchanged", + unchanged: "nothing to move", + "not-started": "not started", + failed: "failed; CloudFormation left the stack as it was", + mismatch: "not executed: CloudFormation's actions didn't match the plan", + "needs-repair": "needs a look (see below)", +}; + +/** The human-readable account of what applying the migration did. */ +export function formatMigrationResult(result: MigrationResult): string { + const lines: string[] = []; + lines.push("Stacks"); + for (const stack of result.stacks) { + lines.push( + ` ${stack.stackName} (target '${stack.target}'): ${OUTCOME_LABELS[stack.outcome]}` + + (stack.refactorId ? ` [${stack.refactorId}]` : ""), + ); + if (stack.message && stack.outcome !== "refactored") lines.push(` ${stack.message}`); + } + if (result.migrated) { + lines.push("", "Files (uncommitted, so you can review them with `git diff`)"); + for (const change of result.files) lines.push(` ${change.kind.padEnd(8)} ${change.path}`); + } + for (const warning of result.warnings ?? []) lines.push("", `Warning: ${warning}`); + if (result.recovery.length > 0) { + lines.push("", "What to do"); + for (const step of result.recovery) lines.push(` - ${step}`); + } + lines.push(""); + const pending = result.pendingTargets ?? []; + if (result.undone) { + lines.push( + "The interrupted migration was undone: the project's v0 files are back. Run " + + "`agentcore migrate` again to migrate it.", + ); + } else if (result.migrated) { + lines.push( + "Migrated. The project is now v1; its resources kept their names, and nothing was " + + "deployed. Next: review the changes, commit them, and run `agentcore deploy`.", + ); + } else if (result.restored) { + lines.push( + "The migration didn't complete, and the project's files were put back: it's v0 again.", + ); + } else if (succeeded(result) && pending.length > 0) { + lines.push( + `Migrated this target. The project files are v1, and ${pending.length === 1 ? "target" : "targets"} ` + + `${pending.map((name) => `'${name}'`).join(", ")} ${pending.length === 1 ? "is" : "are"} still ` + + `pending (recorded in ${result.stateFile}): deploying ${pending.length === 1 ? "it" : "them"} is ` + + "blocked until each is migrated with `agentcore migrate --target `. The migrated " + + "target can be deployed now.", + ); + } else if (result.stateFile) { + lines.push( + `The migration is part-way through: ${result.stateFile} records where it is. Fix the ` + + "cause above, then run `agentcore migrate` again to finish it.", + ); + } else { + lines.push("The migration didn't complete."); + } + lines.push(`See ${MIGRATION_DOCS_URL} for what migrating changes and what to do after.`); + return `${lines.join("\n")}\n`; +} + +/** The human-readable plan. */ +export function formatMigrationPlan(plan: MigrationPlan): string { + const lines: string[] = []; + const title = plan.projectName ? `project '${plan.projectName}'` : plan.rootPath; + lines.push( + plan.undoInterrupted + ? `Undoing the interrupted migration of ${title} (nothing was changed yet)` + : plan.finishPending + ? `Finishing the pending migration of ${title} (nothing was changed yet)` + : `Migration plan for ${title} (nothing was changed)`, + "", + ); + + lines.push("Checks"); + const nameWidth = Math.max(...plan.checks.map((check) => check.name.length)); + for (const check of plan.checks) { + const [first = "", ...rest] = check.message.split("\n"); + lines.push(` ${check.ok ? "ok " : "FAIL"} ${check.name.padEnd(nameWidth)} ${first}`); + for (const line of rest) lines.push(` ${" ".repeat(nameWidth)} ${line}`); + } + + if (plan.undoInterrupted) { + lines.push("", "Files put back from the backup"); + for (const change of plan.changes) lines.push(` ${change.kind.padEnd(8)} ${change.path}`); + } else if (plan.finishPending) { + for (const stack of plan.stacks) lines.push("", ...formatStackPlan(stack)); + if (plan.pendingTargets.length > 0) { + lines.push("", `Still pending after this run: ${plan.pendingTargets.join(", ")}`); + } + } else if (plan.projectName !== undefined) { + lines.push("", 'Names kept for deployed resources (migratedFrom: "v1" keeps v0 naming)'); + if (plan.pins.length === 0) { + lines.push(" none (nothing is deployed yet)"); + } else { + lines.push( + ...table( + ["TYPE", "NAME", "AWS NAME", "TARGETS"], + plan.pins.map((pin) => [pin.resourceType, pin.name, pin.value, pin.targets.join(", ")]), + ).map((line) => ` ${line}`), + ); + } + + lines.push("", "File changes"); + if (plan.changes.length === 0) lines.push(" none"); + for (const change of plan.changes) lines.push(` ${change.kind.padEnd(8)} ${change.path}`); + + for (const stack of plan.stacks) lines.push("", ...formatStackPlan(stack)); + if (plan.pendingTargets.length > 0) { + lines.push( + "", + `Other deployed targets, recorded as pending: ${plan.pendingTargets.join(", ")}`, + " Their stacks keep the v0 logical IDs, and deploying them is blocked, until each is " + + "migrated with `agentcore migrate --target `.", + ); + } + } + + if (plan.skippedStacks.length > 0) { + lines.push(""); + for (const skipped of plan.skippedStacks) { + const which = skipped.target === undefined ? "" : ` (${skipped.target})`; + lines.push(`Stack plans skipped${which}: ${skipped.reason}`); + } + } + + if (plan.warnings.length > 0) { + lines.push("", "Warnings"); + for (const warning of plan.warnings) lines.push(` - ${warning}`); + } + + if (plan.deployedChanges.length > 0) { + lines.push("", "Would change deployed configuration"); + for (const change of plan.deployedChanges) { + lines.push(` - ${change.file} ${change.path}: ${change.message}`); + } + } + + if (plan.problems.length > 0) { + lines.push("", "Fix before migrating"); + for (const problem of plan.problems) { + lines.push(` - ${problem.file} ${problem.path}: ${problem.message}`); + } + } + + const failed = + plan.checks.filter((check) => !check.ok).length + + plan.problems.length + + plan.deployedChanges.length + + plan.stacks.reduce((sum, stack) => sum + stackBlockerCount(stack), 0); + lines.push( + "", + plan.ready + ? "Ready to migrate." + : `Not ready to migrate: ${failed} ${failed === 1 ? "issue needs" : "issues need"} fixing first.`, + `See ${MIGRATION_DOCS_URL} for what migrating changes and what to do after.`, + ); + return `${lines.join("\n")}\n`; +} + +/** One stack's refactor: what moves, then what the first v1 deploy changes, then the verdict. */ +export function formatStackPlan(stack: StackMigrationPlan): string[] { + const lines = [ + `Stack ${stack.stackName} (target '${stack.target}', ${stack.account}/${stack.region})`, + ]; + const indent = (rows: string[]) => rows.map((row) => ` ${row}`); + + lines.push(` Refactor: move ${count(stack.moves.length, "resource")} to new logical IDs`); + if (stack.moves.length > 0) { + lines.push( + ...indent( + table( + ["TYPE", "FROM", "TO"], + stack.moves.map((move) => [move.type, move.from, move.to]), + ), + ), + ); + } + lines.push(` ${count(stack.unchanged, "resource")} keep their logical IDs`); + + lines.push(" Then the first v1 deploy:"); + const changes = (label: string, entries: StackResourceChange[]) => { + if (entries.length === 0) return; + lines.push(` ${label}`); + for (const entry of entries) { + lines.push(` ${severityLabel(entry)}${entry.type} ${entry.logicalId}: ${entry.reason}`); + } + }; + if (stack.added.length > 0) { + lines.push(" creates"); + for (const entry of stack.added) lines.push(` ${entry.type} ${entry.logicalId}`); + } + changes("deletes", stack.removed); + changes("replaces", stack.replaced); + changes("updates in place", stack.updated); + if (stack.added.length + stack.removed.length + stack.replaced.length === 0) { + lines.push( + stack.updated.length === 0 + ? " creates, deletes, and replaces nothing" + : " creates, deletes, and replaces nothing else", + ); + } + + const lost = [...stack.removed, ...stack.replaced].filter( + (change) => change.severity === "blocker", + ).length; + lines.push( + lost === 0 + ? ` Replacements: ${count(stack.replaced.length, "replacement")}; no resource that must be kept is replaced or deleted` + : ` Replacements: ${count(lost, "resource")} that must be kept would be replaced or deleted`, + ); + for (const blocker of stack.blockers) lines.push(` BLOCKER: ${blocker}`); + return lines; +} + +function severityLabel(change: StackResourceChange): string { + return change.severity === "blocker" + ? "BLOCKER " + : change.severity === "warning" + ? "warning " + : ""; +} + +function count(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + +function table(header: string[], rows: string[][]): string[] { + const widths = header.map((cell, column) => + Math.max(cell.length, ...rows.map((row) => row[column]?.length ?? 0)), + ); + const render = (row: string[]) => + row + .map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column]!))) + .join(" "); + return [header, ...rows].map(render); +} diff --git a/src/handlers/project/migrate/types.ts b/src/handlers/project/migrate/types.ts new file mode 100644 index 000000000..4e552e2f4 --- /dev/null +++ b/src/handlers/project/migrate/types.ts @@ -0,0 +1,299 @@ +/** + * Replaces the `@aws/agentcore-cdk` the migrated app synthesizes with, for + * testing against an unreleased library build. Defined here so the command can + * name it without importing core. + */ +export const CDK_LIBRARY_OVERRIDE_ENV = "AGENTCORE_MIGRATE_CDK_LIBRARY"; + +/** + * Starts the plan warning that lists v0 fields v1 drops harmlessly: worth a line + * in the full plan, but nothing to act on, so the summary leaves it out. + */ +export const DROPPED_FIELDS_WARNING = "Dropped fields v1 doesn't use"; + +/** One precondition `agentcore migrate` checks before it changes anything. */ +export type MigrationCheck = { + /** Short label, e.g. `git`. */ + name: string; + ok: boolean; + /** What was found, or what to do about it when the check failed. */ + message: string; +}; + +/** + * Something in the project that converts to v1 only after the user changes it: + * a field v1 validates more strictly than v0, or one v1 cannot express. + */ +export type MigrationProblem = { + /** Project-relative file the problem is in. */ + file: string; + /** Field path inside the file, with array entries named where they have a name. */ + path: string; + message: string; +}; + +/** The resource kinds whose deployed AWS names a migrated project keeps. */ +export type PinnedResourceType = + | "runtime" + | "runtime-repository" + | "memory" + | "evaluator" + | "evaluator-function" + | "online-eval" + | "policy-engine" + | "config-bundle" + | "harness" + | "harness-role" + | "harness-repository" + | "gateway" + | "gateway-target-function" + | "gateway-target-runtime" + | "tool-runtime"; + +/** + * A deployed resource's existing AWS name, which v1 must keep: these names are + * create-only, so a different one replaces the resource. The migrated + * agentcore.json sets `migratedFrom: "v1"`, which makes the CDK library compose + * v0's names for the whole project; the plan checks that the v1 synth renders + * each of these. + */ +export type PinnedName = { + resourceType: PinnedResourceType; + /** The resource's name in agentcore.json (`gateway/target` for a gateway target). */ + name: string; + /** The AWS name. */ + value: string; + /** The deployment targets that deployed the resource. */ + targets: string[]; +}; + +export type FileChangeKind = "added" | "modified" | "removed"; + +export type FileChange = { + /** Project-relative path, `/`-separated. */ + path: string; + kind: FileChangeKind; +}; + +/** A resource in a stack, by logical ID. */ +export type StackResource = { + logicalId: string; + type: string; +}; + +/** A deployed resource that a stack refactor moves to a new logical ID, keeping the AWS resource. */ +export type StackResourceMove = { + type: string; + /** Logical ID in the deployed (v0) stack. */ + from: string; + /** Logical ID in the v1 template. */ + to: string; +}; + +/** + * How much a change the v1 deploy makes after the refactor matters: a blocker + * stops the migration (an AgentCore resource would be lost), a warning is safe + * but worth knowing, and info is expected housekeeping. + */ +export type StackChangeSeverity = "blocker" | "warning" | "info"; + +/** A deployed resource the first v1 deploy after the refactor replaces, deletes, or updates. */ +export type StackResourceChange = StackResource & { + severity: StackChangeSeverity; + /** Why it happens and what it means. */ + reason: string; +}; + +/** How migrating moves one deployment target's stack onto the v1 template. */ +export type StackMigrationPlan = { + target: string; + account: string; + region: string; + stackName: string; + /** Deployed resources that move to a new logical ID. */ + moves: StackResourceMove[]; + /** How many deployed resources keep their logical ID. */ + unchanged: number; + /** Resources the first v1 deploy creates. */ + added: StackResource[]; + /** Deployed resources the first v1 deploy deletes (or, when retained, leaves in the account). */ + removed: StackResourceChange[]; + /** Deployed resources the first v1 deploy replaces with new physical resources. */ + replaced: StackResourceChange[]; + /** + * AgentCore resources the first v1 deploy changes in place. A change the service + * rejects on an existing resource (a gateway's ProtocolType) is a blocker. + */ + updated: StackResourceChange[]; + /** + * Problems with the stack as a whole (its name, the refactor template) that stop + * it migrating. Resource-level blockers are the removed and replaced entries + * with severity `blocker`. + */ + blockers: string[]; + /** No blockers of either kind: the stack can be refactored and deployed as v1 without losing a resource. */ + ready: boolean; +}; + +/** Everything that stops one stack migrating: stack-level problems and blocking resource changes. */ +export function stackBlockerCount(plan: StackMigrationPlan): number { + return ( + plan.blockers.length + + [...plan.removed, ...plan.replaced, ...plan.updated].filter( + (change) => change.severity === "blocker", + ).length + ); +} + +/** A deployment target whose stack wasn't planned, and why. */ +export type SkippedStackPlan = { + /** Absent when every stack plan was skipped for one reason. */ + target?: string; + reason: string; +}; + +/** Everything a migration would do to a project, and whether it can. */ +export type MigrationPlan = { + rootPath: string; + /** Absent when agentcore.json could not be read as a v0 project. */ + projectName?: string; + checks: MigrationCheck[]; + problems: MigrationProblem[]; + /** + * Fields v1 drops from already-deployed resources, which would change them on the + * next deploy. They block the migration like problems do, but are listed apart. + */ + deployedChanges: MigrationProblem[]; + /** The deployed names the migrated project keeps, which the v1 synth is checked against. */ + pins: PinnedName[]; + /** Things the migration drops or the user should know about; none block it. */ + warnings: string[]; + changes: FileChange[]; + /** One per deployment target whose deployed stack could be read, once the v1 app synthesizes. */ + stacks: StackMigrationPlan[]; + /** Deployed targets with no stack plan, and why; any entry keeps the plan from being ready. */ + skippedStacks: SkippedStackPlan[]; + /** The `@aws/agentcore-cdk` spec the plan synthesized with, when overridden for testing. */ + cdkLibraryOverride?: string; + /** + * Set when the plan finishes a pending migration: the project files are already + * v1, and the stack listed is the pending target to refactor now. + */ + finishPending?: boolean; + /** + * Set when an earlier run stopped while writing the project files: applying the + * plan puts the v0 files back from the backup, and nothing else. + */ + undoInterrupted?: boolean; + /** The deployment target whose stack this run refactors, when one is. */ + selectedTarget?: string; + /** + * Deployed targets this run doesn't refactor: each is recorded as pending, and + * `agentcore migrate --target ` migrates it later. + */ + pendingTargets: string[]; + /** Every check passed, nothing needs fixing first, and no stack has a blocker. */ + ready: boolean; +}; + +export type MigrationPlanOptions = { + /** Called as planning starts each slow step (reading stacks, installing, synthesizing). */ + onStep?: (message: string) => void; + /** + * The deployment target to migrate. Required when several targets are deployed; + * one deployed target is picked automatically. + */ + target?: string; +}; + +/** What happened to one deployment target's stack when the migration was applied. */ +export type StackRefactorOutcome = + /** Refactored to the v1 logical IDs, with every physical ID unchanged. */ + | "refactored" + /** Nothing in the stack moves, so it needed no refactor. */ + | "unchanged" + /** Not attempted: an earlier step failed or the migration was stopped. */ + | "not-started" + /** The refactor failed and CloudFormation left the stack as it was. */ + | "failed" + /** CloudFormation's refactor actions didn't match the plan, so it wasn't executed. */ + | "mismatch" + /** The refactor couldn't roll back, or its outcome is unknown: the stack needs a person. */ + | "needs-repair"; + +export type StackRefactorResult = { + target: string; + stackName: string; + region: string; + outcome: StackRefactorOutcome; + refactorId?: string; + /** Why it failed, for the failed outcomes. */ + message?: string; +}; + +/** What applying a migration did. */ +export type MigrationResult = { + /** Every target's stack is refactored and the project files are v1; next is `agentcore deploy`. */ + migrated: boolean; + stacks: StackRefactorResult[]; + /** Project files the migration changed and left changed; empty when they were put back. */ + files: FileChange[]; + /** After a failure: whether the project files were put back (the project is v0 again). */ + restored?: boolean; + /** + * After a partial migration: the project-relative file that records the targets + * still to refactor; `agentcore migrate --target ` finishes each. + */ + stateFile?: string; + /** Deployed targets still to migrate, in the record. */ + pendingTargets?: string[]; + /** An interrupted migration was undone: the v0 files are back. */ + undone?: boolean; + /** What went wrong, when the migration didn't complete. */ + error?: string; + /** What the user has to do by hand, if anything. */ + recovery: string[]; + warnings?: string[]; +}; + +export type MigrationApplyOptions = { + /** Called as each slow step starts. */ + onStep?: (message: string) => void; + /** Called with something the user should act on while applying, such as how to stop. */ + onWarning?: (message: string) => void; + /** Called with a line of detail for the running step, such as a stack resource event. */ + onOutput?: (line: string) => void; +}; + +/** + * A planned migration, kept with its working copy (the converted project) until + * disposed, so it can be applied exactly as planned. + */ +export interface PreparedMigration { + plan: MigrationPlan; + /** + * Writes the converted files into the project and installs the v1 CDK app, then + * refactors the selected target's stack, checking CloudFormation's actions + * against the plan first and the physical IDs after. Every step is recorded + * first. When no stack was refactored the files are put back; other deployed + * targets stay recorded for `agentcore migrate --target `. + */ + apply(options?: MigrationApplyOptions): Promise; + /** Deletes the working copy. */ + dispose(): Promise; +} + +/** Plans the migration of a v0 project to v1, and applies it. Implemented in src/core/project/migrate. */ +export interface ProjectMigrator { + plan(rootPath: string, options?: MigrationPlanOptions): Promise; + /** Plans the migration and keeps what applying it needs. */ + prepare(rootPath: string, options?: MigrationPlanOptions): Promise; + /** Whether a pending migration is waiting to be finished in the project. */ + hasPendingMigration(rootPath: string): Promise; + /** + * Writes a report (the full plan, or what applying it did) for the user to read + * under agentcore/.cli/migrate/, and returns its project-relative path with + * forward slashes. Migrate never reads these back. + */ + saveReport(rootPath: string, fileName: string, contents: string): Promise; +} diff --git a/src/handlers/types.tsx b/src/handlers/types.tsx index d5d6b6ee6..2d82f0979 100644 --- a/src/handlers/types.tsx +++ b/src/handlers/types.tsx @@ -11,6 +11,7 @@ import type { Context } from "../router"; import type { CoreFetch } from "../core/types"; import type { ProjectManager } from "./project/types.ts"; import type { CoreBedrockAgentImporter } from "../core/project/bedrockAgentImport"; +import type { ProjectMigrator } from "./project/migrate/types.ts"; export interface Core { harness: CoreHarnessClient; @@ -25,6 +26,8 @@ export interface Core { projectManager: ProjectManager; /** Imports an alias-pinned Bedrock Agent definition into owned runtime code. */ bedrockAgentImporter: CoreBedrockAgentImporter; + /** Plans the upgrade of a v0 project to v1 (`agentcore migrate`). */ + projectMigrator: ProjectMigrator; /** Shared outbound HTTP for handlers that call non-AWS APIs directly (e.g. feedback → Aperture). */ fetch: CoreFetch; } diff --git a/src/projectSchemas/harness.ts b/src/projectSchemas/harness.ts index 373a8b020..f2a574481 100644 --- a/src/projectSchemas/harness.ts +++ b/src/projectSchemas/harness.ts @@ -806,6 +806,97 @@ export const HarnessYamlSchema = z ) .pipe(HarnessSpecSchema); +const MODEL_CONFIG_KEYS = { + bedrock: "bedrockModelConfig", + open_ai: "openAiModelConfig", + gemini: "geminiModelConfig", + lite_llm: "liteLlmModelConfig", +} as const satisfies Record; + +const MEMORY_CONFIG_KEYS = { + managed: "managedMemoryConfiguration", + existing: "agentCoreMemoryConfiguration", + disabled: "disabled", +} as const satisfies Record; + +/** + * Encodes a harness spec as a `harness.yaml` document: the inverse of + * {@link HarnessYamlSchema}'s transform. Fields that are unset are omitted. + * + * The on-disk format cannot say "no network mode" or "no authorizer type": reading + * it back always yields `networkMode: PUBLIC` and `authorizerType: AWS_IAM` + * (or `CUSTOM_JWT` with an authorizer configuration), which is what an unset + * field means. + */ +export function encodeHarnessYaml(spec: HarnessSpec): Record { + const { + name, + model, + systemPrompt, + tools, + skills, + allowedTools, + memory, + containerUri, + networkMode, + networkConfig, + lifecycleConfig, + sessionStoragePath, + efsAccessPoints, + s3AccessPoints, + authorizerType: _authorizerType, + authorizerConfiguration, + ...settings + } = spec; + const { provider, ...modelConfig } = model; + const { mode, ...memoryConfig } = memory ?? {}; + const mounts = [ + ...(sessionStoragePath ? [{ sessionStorage: { mountPath: sessionStoragePath } }] : []), + ...(efsAccessPoints ?? []).map((efsAccessPoint) => ({ efsAccessPoint })), + ...(s3AccessPoints ?? []).map((s3FilesAccessPoint) => ({ s3FilesAccessPoint })), + ]; + const runtime = withoutUndefined({ + networkConfiguration: networkMode + ? withoutUndefined({ + networkMode, + networkModeConfig: networkConfig && { + subnets: networkConfig.subnets, + securityGroups: networkConfig.securityGroups, + }, + }) + : undefined, + lifecycleConfiguration: lifecycleConfig, + filesystemConfigurations: mounts.length ? mounts : undefined, + }); + return withoutUndefined({ + name, + model: { [MODEL_CONFIG_KEYS[provider]]: modelConfig }, + systemPrompt: systemPrompt === undefined ? undefined : [{ text: systemPrompt }], + tools, + skills: skills.map((skill) => { + if ("s3Uri" in skill) return { s3: { uri: skill.s3Uri } }; + if ("gitUrl" in skill) { + const { gitUrl, ...git } = skill; + return { git: { url: gitUrl, ...git } }; + } + return skill; + }), + allowedTools, + memory: mode ? { [MEMORY_CONFIG_KEYS[mode]]: memoryConfig } : undefined, + ...settings, + environmentArtifact: containerUri ? { containerConfiguration: { containerUri } } : undefined, + environment: Object.keys(runtime).length ? { agentCoreRuntimeEnvironment: runtime } : undefined, + networkConfig: networkConfig?.vpcId ? { vpcId: networkConfig.vpcId } : undefined, + authorizerConfiguration: authorizerConfiguration && { + customJWTAuthorizer: authorizerConfiguration.customJwtAuthorizer, + }, + }); +} + +function withoutUndefined(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} + export const HarnessRegistryEntrySchema = z.object({ name: HarnessNameSchema, path: z.string().min(1, "Path to harness config directory is required"), diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 757e830c8..fbba50833 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -21,6 +21,16 @@ describe("project custom validation", () => { } }); + test('accepts migratedFrom "v1" (the v0 CLI\'s schema version) and nothing else', () => { + expect(ProjectSpecSchema.parse({ ...minimalProject, migratedFrom: "v1" }).migratedFrom).toBe( + "v1", + ); + expect(ProjectSpecSchema.parse(minimalProject).migratedFrom).toBeUndefined(); + for (const migratedFrom of ["v0", "v2", 1, ""]) { + expect(ProjectSpecSchema.safeParse({ ...minimalProject, migratedFrom }).success).toBe(false); + } + }); + test("requires an explicit project version", () => { expect(ProjectSpecSchema.safeParse({ name: "project" }).success).toBe(false); }); diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 355cd8314..756e6eaec 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -39,6 +39,14 @@ export const ProjectSpecSchema = z .object({ name: ProjectNameSchema, version: z.literal(2), + /** + * Set by `agentcore migrate` on a project it converted from the v0 CLI. `"v1"` is + * the agentcore.json schema version the v0 CLI wrote (not the v1 CLI). It tells the + * CDK library to keep v0's physical names for the whole project: no target segment, + * and v0's fixed harness role names. Those names are create-only, so removing this + * from a deployed project replaces its resources. + */ + migratedFrom: z.literal("v1").optional(), managedBy: ManagedBySchema, tags: TagsSchema.optional(), runtimes: z.array(ProjectRuntimeSchema).default([]).superRefine(uniqueNames("agent")), diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 38a544120..117f137c0 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -212,6 +212,8 @@ import type { Logger } from "../logging"; import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; import { FsProjectManager, type ProjectBackend } from "../core/project"; +import { FsProjectMigrator } from "../core/project/migrate"; +import type { ProjectMigrator } from "../handlers/project/migrate/types"; import type { BedrockAgentImportPlan, BedrockAgentImportRequest, @@ -1365,6 +1367,8 @@ export class TestGatewayClient implements CoreGatewayClient { type TestCoreClientOptions = { logger?: Logger; + /** Defaults to the real migrator, which reads the project on disk and runs git. */ + projectMigrator?: ProjectMigrator; json?: ReadWriteJson; backends?: Partial>; createCloudFormationClient?: CreateCloudFormationClient; @@ -2537,6 +2541,7 @@ export class TestCoreClient implements Core { throw new Error("TestCoreClient.fetch is not configured; set it in the test that needs it"); }) as unknown as CoreFetch; readonly projectManager: ProjectManager; + readonly projectMigrator: ProjectMigrator; // Commands the project manager would have run (npm install, git init, ...), // recorded instead of spawned so tests stay fast and hermetic. @@ -2564,6 +2569,13 @@ export class TestCoreClient implements Core { }; constructor(options?: TestCoreClientOptions) { + this.projectMigrator = + options?.projectMigrator ?? + new FsProjectMigrator({ + logger: options?.logger ?? createSilentLogger(), + // Tests that reach AWS reads pass their own migrator with fake seams. + createCloudFormationClient: options?.createCloudFormationClient, + }); this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger(), createCloudFormationClient: options?.createCloudFormationClient, diff --git a/src/tui/progress.test.tsx b/src/tui/progress.test.tsx index 6387c5986..b60a45f2b 100644 --- a/src/tui/progress.test.tsx +++ b/src/tui/progress.test.tsx @@ -3,6 +3,7 @@ import { testIO, tick, waitFor } from "../testing"; import { applyProgressEvent, driveProgress, + progressFrom, runWithProgress, settleProgress, type ProgressEvent, @@ -273,3 +274,42 @@ describe("driveProgress", () => { expect(last).toBe("failed:one"); }); }); + +describe("progressFrom", () => { + test("yields the events a callback operation emits, then returns its result", async () => { + const generator = progressFrom(async (emit) => { + emit({ type: "step", message: "one" }); + await tick(); + emit({ type: "warning", message: "careful" }); + emit({ type: "step", message: "two" }); + return 42; + }); + const events: ProgressEvent[] = []; + let next = await generator.next(); + while (!next.done) { + events.push(next.value); + next = await generator.next(); + } + expect(events).toEqual([ + { type: "step", message: "one" }, + { type: "warning", message: "careful" }, + { type: "step", message: "two" }, + ]); + expect(next.value).toBe(42); + }); + + test("rethrows the operation's error after its events", async () => { + const io = testIO(); + const failure = new Error("boom"); + await expect( + runWithProgress( + progressFrom(async (emit) => { + emit({ type: "step", message: "working" }); + throw failure; + }), + { io: io.io }, + ), + ).rejects.toBe(failure); + expect(io.stderr()).toBe("working"); + }); +}); diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx index fd38c5969..ab6a11ea9 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -22,6 +22,36 @@ export function isProgressGenerator( return typeof (result as AsyncGenerator)[Symbol.asyncIterator] === "function"; } +/** + * Adapts an operation that reports progress through callbacks into a progress + * generator: each event `run` emits is yielded in order, and the generator + * returns `run`'s result (or throws its error) once every event is out. + */ +export async function* progressFrom( + run: (emit: (event: ProgressEvent) => void) => Promise, +): AsyncGenerator { + const queue: ProgressEvent[] = []; + let wake: (() => void) | undefined; + let settled = false; + const operation = run((event) => { + queue.push(event); + wake?.(); + }); + const settle = () => { + settled = true; + wake?.(); + }; + operation.then(settle, settle); + while (true) { + while (queue.length > 0) yield queue.shift()!; + if (settled) return await operation; + await new Promise((resolve) => { + wake = resolve; + }); + wake = undefined; + } +} + export type RunWithProgressOptions = { io: AppIO; /** A single waiting step for callback operations; cleared before their first output. */