diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts index 749f9cada..96fdd38d9 100644 --- a/src/core/gateway.test.ts +++ b/src/core/gateway.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; import { + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, GetGatewayCommand, GetGatewayTargetCommand, UpdateGatewayCommand, @@ -58,6 +61,30 @@ function target(): GetGatewayTargetResponse { } as unknown as GetGatewayTargetResponse; } +test("maps Gateway, Target, and Rule selectors to their delete commands", async () => { + const { client, commands } = gatewayClient([{}, {}, {}]); + + await client.deleteGateway("gateway-1", OPTIONS); + await client.deleteGatewayTarget("gateway-1", "target-1", OPTIONS); + await client.deleteGatewayRule("gateway-1", "rule-1", OPTIONS); + + expect(commands).toHaveLength(3); + expect(commands[0]).toBeInstanceOf(DeleteGatewayCommand); + expect((commands[0] as DeleteGatewayCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + }); + expect(commands[1]).toBeInstanceOf(DeleteGatewayTargetCommand); + expect((commands[1] as DeleteGatewayTargetCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + targetId: "target-1", + }); + expect(commands[2]).toBeInstanceOf(DeleteGatewayRuleCommand); + expect((commands[2] as DeleteGatewayRuleCommand).input).toEqual({ + gatewayIdentifier: "gateway-1", + ruleId: "rule-1", + }); +}); + function gatewayClient(responses: unknown[]): { client: GatewayClient; commands: unknown[]; diff --git a/src/core/gateway.tsx b/src/core/gateway.tsx index 8dca91334..4c3aa074a 100644 --- a/src/core/gateway.tsx +++ b/src/core/gateway.tsx @@ -2,6 +2,9 @@ import { CreateGatewayCommand, CreateGatewayRuleCommand, CreateGatewayTargetCommand, + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, GetGatewayCommand, GetGatewayRuleCommand, GetGatewayTargetCommand, @@ -14,6 +17,9 @@ import { type CreateGatewayResponse, type CreateGatewayRuleResponse, type CreateGatewayTargetResponse, + type DeleteGatewayResponse, + type DeleteGatewayRuleResponse, + type DeleteGatewayTargetResponse, type GetGatewayResponse, type GetGatewayRuleResponse, type GetGatewayTargetResponse, @@ -144,6 +150,12 @@ export class GatewayClient implements CoreGatewayClient { .send(new ListGatewaysCommand({ nextToken, maxResults })); } + async deleteGateway(id: string, options: CoreOptions): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeleteGatewayCommand({ gatewayIdentifier: id })); + } + async getGatewayTarget( gatewayId: string, targetId: string, @@ -195,6 +207,19 @@ export class GatewayClient implements CoreGatewayClient { return this.updateTarget(patch, options, true); } + async deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: gatewayId, + targetId, + }), + ); + } + async getGatewayRule( gatewayId: string, ruleId: string, @@ -237,6 +262,19 @@ export class GatewayClient implements CoreGatewayClient { return this.clients.control(toClientConfig(options)).send(new UpdateGatewayRuleCommand(input)); } + async deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise { + return this.clients.control(toClientConfig(options)).send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: gatewayId, + ruleId, + }), + ); + } + private async updateTarget( patch: GatewayTargetUpdatePatch, options: CoreOptions, diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json new file mode 100644 index 000000000..a7fa04545 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayCommand.e015bbd46d1859f1.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json new file mode 100644 index 000000000..1b1f7d63b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayRuleCommand.cee854fd6fa9fd16.json @@ -0,0 +1,4 @@ +{ + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json new file mode 100644 index 000000000..1cbd45898 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.94eb9a3262a6cda2.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json new file mode 100644 index 000000000..deb34f170 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/DeleteGatewayTargetCommand.afcce9d3abeac495.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json new file mode 100644 index 000000000..b824529b7 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/GetGatewayTargetCommand.94eb9a3262a6cda2.json @@ -0,0 +1,34 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", + "createdAt": { + "$date": "2026-08-07T17:54:45.522Z" + }, + "updatedAt": { + "$date": "2026-08-07T17:54:46.453Z" + }, + "status": "READY", + "name": "web-search-delete-fixture", + "targetConfiguration": { + "mcp": { + "connector": { + "source": { + "connectorId": "web-search" + }, + "configurations": [ + { + "name": "WebSearch", + "parameterValues": { + "maxResults": 10 + } + } + ] + } + } + }, + "credentialProviderConfigurations": [ + { + "credentialProviderType": "GATEWAY_IAM_ROLE" + } + ] +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json new file mode 100644 index 000000000..1cbd45898 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/connector-delete.golden.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "U9OM2R9I8Q", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json new file mode 100644 index 000000000..a7fa04545 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/gateway-delete.golden.json @@ -0,0 +1,4 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/resources.json b/src/handlers/gateway/__fixtures__/delete/resources.json new file mode 100644 index 000000000..fa612366a --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/resources.json @@ -0,0 +1,7 @@ +{ + "gatewayId": "agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", + "connectorId": "U9OM2R9I8Q", + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c" +} diff --git a/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json new file mode 100644 index 000000000..1b1f7d63b --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/rule-delete.golden.json @@ -0,0 +1,4 @@ +{ + "ruleId": "80e073e4-eb05-4371-8e6a-f4bde522699c", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json new file mode 100644 index 000000000..deb34f170 --- /dev/null +++ b/src/handlers/gateway/__fixtures__/delete/target-delete.golden.json @@ -0,0 +1,5 @@ +{ + "gatewayArn": "arn:aws:bedrock-agentcore:us-east-1:685197708687:gateway/agentcore-cli-gateway-delete-fixture-oiemu02wfc", + "targetId": "JYNNGDZ42F", + "status": "DELETING" +} \ No newline at end of file diff --git a/src/handlers/gateway/connector/delete/index.tsx b/src/handlers/gateway/connector/delete/index.tsx new file mode 100644 index 000000000..52df6eba2 --- /dev/null +++ b/src/handlers/gateway/connector/delete/index.tsx @@ -0,0 +1,34 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; + +export const createDeleteGatewayConnectorHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a connector-backed Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("id", "the connector-backed Gateway Target ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + + const options = coreOptsFromCtx(ctx); + const target = await core.gateway.getGatewayTarget(flags["gateway-id"], flags.id, options); + if (!GatewayConnectorTarget.is(target.targetConfiguration)) { + throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); + } + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.deleteGatewayTarget(flags["gateway-id"], flags.id, options)); + }, + }); diff --git a/src/handlers/gateway/connector/index.tsx b/src/handlers/gateway/connector/index.tsx index 188b3f263..587c05e69 100644 --- a/src/handlers/gateway/connector/index.tsx +++ b/src/handlers/gateway/connector/index.tsx @@ -3,6 +3,7 @@ import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; import { createCreateGatewayConnectorHandler } from "./create"; +import { createDeleteGatewayConnectorHandler } from "./delete"; import { createGetGatewayConnectorHandler } from "./get"; import { createListGatewayConnectorsHandler } from "./list"; import { createUpdateGatewayConnectorHandler } from "./update"; @@ -13,5 +14,6 @@ export function createGatewayConnectorHandler(core: Core, io: AppIO): Router { .handler(createCreateGatewayConnectorHandler(core, io)) .handler(createUpdateGatewayConnectorHandler(core, io)) .handler(createGetGatewayConnectorHandler(core)) - .handler(createListGatewayConnectorsHandler(core)); + .handler(createListGatewayConnectorsHandler(core)) + .handler(createDeleteGatewayConnectorHandler(core)); } diff --git a/src/handlers/gateway/delete/index.tsx b/src/handlers/gateway/delete/index.tsx new file mode 100644 index 000000000..ddd27389a --- /dev/null +++ b/src/handlers/gateway/delete/index.tsx @@ -0,0 +1,21 @@ +import z from "zod"; +import { InputValidationError } from "../../../errors"; +import { createHandler, flag } from "../../../router"; +import { JsonRendererKey } from "../../../tui"; +import type { Core } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +export const createDeleteGatewayHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete an AgentCore Gateway", + flags: [flag("id", "the Gateway ID", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson(await core.gateway.deleteGateway(flags.id, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/gateway/gateway.delete.test.tsx b/src/handlers/gateway/gateway.delete.test.tsx new file mode 100644 index 000000000..e25f47e0f --- /dev/null +++ b/src/handlers/gateway/gateway.delete.test.tsx @@ -0,0 +1,572 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CreateGatewayCommand, + CreateGatewayRuleCommand, + CreateGatewayTargetCommand, + DeleteGatewayCommand, + DeleteGatewayRuleCommand, + DeleteGatewayTargetCommand, + type DeleteGatewayResponse, + type DeleteGatewayRuleResponse, + type DeleteGatewayTargetResponse, + GetGatewayCommand, + GetGatewayRuleCommand, + GetGatewayTargetCommand, + type GetGatewayTargetResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + CreateRoleCommand, + DeleteRoleCommand, + DeleteRolePolicyCommand, + PutRolePolicyCommand, +} from "@aws-sdk/client-iam"; +import { CoreClient } from "../../core"; +import { createControlClient, createIamClient } from "../../core/factories"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../testing"; +import { createRootHandler } from "../index"; + +const REGION = "us-west-2"; +const GATEWAY_ID = "gateway-1"; +const TARGET_ID = "target-1"; +const RULE_ID = "rule-1"; + +async function run( + args: string[], + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stdout: string }> { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return { core, stdout: io.stdout() }; +} + +describe("gateway delete commands", () => { + test("deletes a Gateway", async () => { + const response = { gatewayId: GATEWAY_ID, status: "DELETING" } as DeleteGatewayResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteResponse(response); + + const result = await run(["gateway", "delete", "--id", GATEWAY_ID], core); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGateway", + args: [GATEWAY_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("deletes a Target", async () => { + const response = { targetId: TARGET_ID, status: "DELETING" } as DeleteGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteTargetResponse(response); + + const result = await run( + ["gateway", "target", "delete", "--gateway-id", GATEWAY_ID, "--target-id", TARGET_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("deletes a connector-backed Target", async () => { + const response = { targetId: TARGET_ID, status: "DELETING" } as DeleteGatewayTargetResponse; + const core = new TestCoreClient(); + core.gateway + .setGetTargetResponse({ + targetId: TARGET_ID, + targetConfiguration: { + mcp: { connector: { source: { connectorId: "web-search" } } }, + }, + } as GetGatewayTargetResponse) + .setDeleteTargetResponse(response); + + const result = await run( + ["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + { + method: "deleteGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); + + test("rejects a non-connector Target without deleting it", async () => { + const core = new TestCoreClient(); + core.gateway.setGetTargetResponse({ + targetId: TARGET_ID, + targetConfiguration: { + http: { passthrough: { endpoint: "https://example.test", protocolType: "CUSTOM" } }, + }, + } as GetGatewayTargetResponse); + + await expect( + run(["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID, "--id", TARGET_ID], core), + ).rejects.toThrow(/not connector-backed/); + expect(core.gateway.calls).toEqual([ + { + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: REGION }], + }, + ]); + }); + + test("deletes a Rule", async () => { + const response = { ruleId: RULE_ID, status: "DELETING" } as DeleteGatewayRuleResponse; + const core = new TestCoreClient(); + core.gateway.setDeleteRuleResponse(response); + + const result = await run( + ["gateway", "rule", "delete", "--gateway-id", GATEWAY_ID, "--rule-id", RULE_ID], + core, + ); + + expect(core.gateway.calls).toEqual([ + { + method: "deleteGatewayRule", + args: [GATEWAY_ID, RULE_ID, { region: REGION }], + }, + ]); + expect(JSON.parse(result.stdout)).toEqual(response); + }); +}); + +describe("gateway delete validation", () => { + test.each([ + ["Gateway selector", ["gateway", "delete"], /--id/], + ["Target parent", ["gateway", "target", "delete"], /--gateway-id/], + ["Target selector", ["gateway", "target", "delete", "--gateway-id", GATEWAY_ID], /--target-id/], + ["Connector parent", ["gateway", "connector", "delete"], /--gateway-id/], + ["Connector selector", ["gateway", "connector", "delete", "--gateway-id", GATEWAY_ID], /--id/], + ["Rule parent", ["gateway", "rule", "delete"], /--gateway-id/], + ["Rule selector", ["gateway", "rule", "delete", "--gateway-id", GATEWAY_ID], /--rule-id/], + ] as const)("rejects a missing %s before calling Core", async (_name, args, error) => { + const core = new TestCoreClient(); + + await expect(run([...args], core)).rejects.toThrow(error); + expect(core.gateway.calls).toEqual([]); + }); +}); + +const FIXTURES = join(import.meta.dir, "__fixtures__", "delete"); +const RESOURCE_STATE = join(FIXTURES, "resources.json"); +const GATEWAY_NAME = "agentcore-cli-gateway-delete-fixture"; +const ROLE_NAME = "AgentCoreCliGatewayDeleteFixtureRole"; +const POLICY_NAME = "AgentCoreCliGatewayDeleteFixture"; +const HTTP_TARGET_NAME = "http-delete-fixture"; +const CONNECTOR_TARGET_NAME = "web-search-delete-fixture"; +const FLOW_TIMEOUT = 600_000; + +type FixtureState = { + gatewayId: string; + gatewayArn: string; + targetId: string; + connectorId: string; + ruleId: string; +}; + +type FixtureResources = { + gatewayId?: string; + targetIds: string[]; + ruleId?: string; +}; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function runFixture(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", ...args, "--region", "us-east-1"]); + return io.stdout(); +} + +class GatewayDeleteFixture { + private readonly control = createControlClient({ region: "us-east-1" }); + private readonly iam = createIamClient({ region: "us-east-1" }); + + async setup(resources: FixtureResources): Promise { + await this.ignoreMissing(() => + this.iam.send(new DeleteRolePolicyCommand({ RoleName: ROLE_NAME, PolicyName: POLICY_NAME })), + ); + await this.ignoreMissing(() => this.iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); + const role = await this.iam.send( + new CreateRoleCommand({ + RoleName: ROLE_NAME, + AssumeRolePolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }), + }), + ); + if (!role.Role?.Arn) throw new Error("IAM did not return the fixture role ARN"); + await Bun.sleep(10_000); + + const gateway = await this.control.send( + new CreateGatewayCommand({ + name: GATEWAY_NAME, + roleArn: role.Role.Arn, + authorizerType: "NONE", + description: "Disposable Gateway Delete fixture", + }), + ); + if (!gateway.gatewayId || !gateway.gatewayArn) { + throw new Error("CreateGateway did not return fixture identifiers"); + } + resources.gatewayId = gateway.gatewayId; + await this.waitUntil( + () => this.control.send(new GetGatewayCommand({ gatewayIdentifier: gateway.gatewayId })), + (response) => response.status === "READY", + ); + + await this.iam.send( + new PutRolePolicyCommand({ + RoleName: ROLE_NAME, + PolicyName: POLICY_NAME, + PolicyDocument: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeGateway", + Resource: gateway.gatewayArn, + }, + { + Effect: "Allow", + Action: "bedrock-agentcore:InvokeWebSearch", + Resource: "arn:aws:bedrock-agentcore:us-east-1:aws:tool/web-search.v1", + }, + ], + }), + }), + ); + + const target = await this.control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: HTTP_TARGET_NAME, + targetConfiguration: { + http: { + passthrough: { + endpoint: "https://example.com", + protocolType: "CUSTOM", + }, + }, + }, + }), + ); + if (!target.targetId) { + throw new Error("CreateGatewayTarget did not return the fixture Target ID"); + } + resources.targetIds.push(target.targetId); + + const connector = await this.control.send( + new CreateGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + name: CONNECTOR_TARGET_NAME, + targetConfiguration: { + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + parameterValues: { maxResults: 10 }, + }, + ], + }, + }, + }, + credentialProviderConfigurations: [{ credentialProviderType: "GATEWAY_IAM_ROLE" }], + }), + ); + if (!connector.targetId) { + throw new Error("CreateGatewayTarget did not return the fixture Connector ID"); + } + resources.targetIds.push(connector.targetId); + await Promise.all( + [target.targetId, connector.targetId].map((targetId) => + this.waitUntil( + () => + this.control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: gateway.gatewayId, + targetId, + }), + ), + (response) => response.status === "READY", + ), + ), + ); + + const rule = await this.control.send( + new CreateGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + priority: 10, + actions: [ + { + routeToTarget: { + staticRoute: { + targetName: HTTP_TARGET_NAME, + }, + }, + }, + ], + }), + ); + if (!rule.ruleId) throw new Error("CreateGatewayRule did not return the fixture rule ID"); + resources.ruleId = rule.ruleId; + await this.waitUntil( + () => + this.control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: gateway.gatewayId, + ruleId: rule.ruleId, + }), + ), + (response) => response.status === "ACTIVE", + ); + + const state = { + gatewayId: gateway.gatewayId, + gatewayArn: gateway.gatewayArn, + targetId: target.targetId, + connectorId: connector.targetId, + ruleId: rule.ruleId, + }; + mkdirSync(FIXTURES, { recursive: true }); + writeFileSync(RESOURCE_STATE, `${JSON.stringify(state, null, 2)}\n`); + return state; + } + + async verifyMissing(operation: () => Promise): Promise { + if (!isRecording()) return; + await this.waitUntilMissing(operation); + } + + async cleanup(resources: FixtureResources): Promise { + if (resources.gatewayId && resources.ruleId) { + await this.ignoreMissing(() => + this.control.send( + new DeleteGatewayRuleCommand({ + gatewayIdentifier: resources.gatewayId, + ruleId: resources.ruleId, + }), + ), + ); + await this.waitUntilMissing(() => + this.control.send( + new GetGatewayRuleCommand({ + gatewayIdentifier: resources.gatewayId, + ruleId: resources.ruleId, + }), + ), + ); + } + + if (resources.gatewayId) { + for (const targetId of resources.targetIds) { + await this.ignoreMissing(() => + this.control.send( + new DeleteGatewayTargetCommand({ + gatewayIdentifier: resources.gatewayId, + targetId, + }), + ), + ); + await this.waitUntilMissing(() => + this.control.send( + new GetGatewayTargetCommand({ + gatewayIdentifier: resources.gatewayId, + targetId, + }), + ), + ); + } + await this.ignoreMissing(() => + this.control.send(new DeleteGatewayCommand({ gatewayIdentifier: resources.gatewayId })), + ); + await this.waitUntilMissing(() => + this.control.send(new GetGatewayCommand({ gatewayIdentifier: resources.gatewayId })), + ); + } + + await this.ignoreMissing(() => + this.iam.send( + new DeleteRolePolicyCommand({ + RoleName: ROLE_NAME, + PolicyName: POLICY_NAME, + }), + ), + ); + await this.ignoreMissing(() => this.iam.send(new DeleteRoleCommand({ RoleName: ROLE_NAME }))); + } + + private async waitUntil( + operation: () => Promise, + done: (response: T) => boolean, + ): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + const response = await operation(); + if (done(response)) return response; + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource state"); + } + + private async waitUntilMissing(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await operation(); + } catch (error) { + if ((error as Error).name === "ResourceNotFoundException") return; + throw error; + } + await Bun.sleep(2_000); + } + throw new Error("Timed out waiting for fixture resource deletion"); + } + + private async ignoreMissing(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + if (!["ResourceNotFoundException", "NoSuchEntityException"].includes((error as Error).name)) { + throw error; + } + } + } +} + +test( + "deletes a Rule, Target, Connector, and Gateway through the real Core", + async () => { + const fixture = new GatewayDeleteFixture(); + const resources: FixtureResources = { targetIds: [] }; + + try { + const state = isRecording() + ? await fixture.setup(resources) + : (JSON.parse(readFileSync(RESOURCE_STATE, "utf8")) as FixtureState); + + const ruleStdout = await runFixture([ + "gateway", + "rule", + "delete", + "--gateway-id", + state.gatewayId, + "--rule-id", + state.ruleId, + ]); + matchGolden(FIXTURES, "rule-delete.golden.json", ruleStdout); + expect(JSON.parse(ruleStdout).ruleId).toBe(state.ruleId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayRuleCommand({ + gatewayIdentifier: state.gatewayId, + ruleId: state.ruleId, + }), + ), + ); + + const targetStdout = await runFixture([ + "gateway", + "target", + "delete", + "--gateway-id", + state.gatewayId, + "--target-id", + state.targetId, + ]); + matchGolden(FIXTURES, "target-delete.golden.json", targetStdout); + expect(JSON.parse(targetStdout).targetId).toBe(state.targetId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.targetId, + }), + ), + ); + + const connectorStdout = await runFixture([ + "gateway", + "connector", + "delete", + "--gateway-id", + state.gatewayId, + "--id", + state.connectorId, + ]); + matchGolden(FIXTURES, "connector-delete.golden.json", connectorStdout); + expect(JSON.parse(connectorStdout).targetId).toBe(state.connectorId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayTargetCommand({ + gatewayIdentifier: state.gatewayId, + targetId: state.connectorId, + }), + ), + ); + + const gatewayStdout = await runFixture(["gateway", "delete", "--id", state.gatewayId]); + matchGolden(FIXTURES, "gateway-delete.golden.json", gatewayStdout); + expect(JSON.parse(gatewayStdout).gatewayId).toBe(state.gatewayId); + await fixture.verifyMissing(() => + createControlClient({ region: "us-east-1" }).send( + new GetGatewayCommand({ gatewayIdentifier: state.gatewayId }), + ), + ); + } finally { + if (isRecording()) await fixture.cleanup(resources); + } + }, + FLOW_TIMEOUT, +); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index aa73ccec6..22fb48711 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -76,6 +76,7 @@ describe("gateway command hierarchy", () => { "update", "get", "list", + "delete", "target", "connector", "rule", @@ -85,18 +86,21 @@ describe("gateway command hierarchy", () => { "update", "get", "list", + "delete", ]); expect(connector?.children().map((child) => child.name())).toEqual([ "create", "update", "get", "list", + "delete", ]); expect(rule?.children().map((child) => child.name())).toEqual([ "create", "update", "get", "list", + "delete", ]); }); diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 7d3e62409..891aaf4c5 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -4,6 +4,7 @@ import { createHelpDefault } from "../help"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; import { createCreateGatewayHandler } from "./create"; +import { createDeleteGatewayHandler } from "./delete"; import { createGetGatewayHandler } from "./get"; import { createListGatewaysHandler } from "./list"; import { createGatewayRuleHandler } from "./rule"; @@ -17,6 +18,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router { .handler(createUpdateGatewayHandler(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) + .handler(createDeleteGatewayHandler(core)) .handler(createGatewayTargetHandler(core, io)) .handler(createGatewayConnectorHandler(core, io)) .handler(createGatewayRuleHandler(core, io)); diff --git a/src/handlers/gateway/rule/delete/index.tsx b/src/handlers/gateway/rule/delete/index.tsx new file mode 100644 index 000000000..b79ec0eaa --- /dev/null +++ b/src/handlers/gateway/rule/delete/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteGatewayRuleHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a Gateway Rule", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("rule-id", "the Rule ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["rule-id"]) { + throw new InputValidationError("required option '--rule-id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson( + await core.gateway.deleteGatewayRule( + flags["gateway-id"], + flags["rule-id"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/gateway/rule/index.tsx b/src/handlers/gateway/rule/index.tsx index bb7869dfd..4f54a3b4f 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -3,6 +3,7 @@ import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; import { createCreateGatewayRuleHandler } from "./create"; +import { createDeleteGatewayRuleHandler } from "./delete"; import { createGetGatewayRuleHandler } from "./get"; import { createListGatewayRulesHandler } from "./list"; import { createUpdateGatewayRuleHandler } from "./update"; @@ -13,5 +14,6 @@ export function createGatewayRuleHandler(core: Core, io: AppIO): Router { .handler(createCreateGatewayRuleHandler(core, io)) .handler(createUpdateGatewayRuleHandler(core, io)) .handler(createGetGatewayRuleHandler(core)) - .handler(createListGatewayRulesHandler(core)); + .handler(createListGatewayRulesHandler(core)) + .handler(createDeleteGatewayRuleHandler(core)); } diff --git a/src/handlers/gateway/target/delete/index.tsx b/src/handlers/gateway/target/delete/index.tsx new file mode 100644 index 000000000..75c3626fb --- /dev/null +++ b/src/handlers/gateway/target/delete/index.tsx @@ -0,0 +1,33 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteGatewayTargetHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a Gateway Target", + flags: [ + flag("gateway-id", "the parent Gateway ID", z.string().optional()), + flag("target-id", "the Target ID", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["gateway-id"]) { + throw new InputValidationError("required option '--gateway-id ' not specified"); + } + if (!flags["target-id"]) { + throw new InputValidationError("required option '--target-id ' not specified"); + } + ctx + .require(JsonRendererKey) + .renderJson( + await core.gateway.deleteGatewayTarget( + flags["gateway-id"], + flags["target-id"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/gateway/target/index.tsx b/src/handlers/gateway/target/index.tsx index 59ffa3043..d7076257a 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -3,6 +3,7 @@ import { Router } from "../../../router"; import { createHelpDefault } from "../../help"; import type { Core } from "../../types"; import { createCreateGatewayTargetHandler } from "./create"; +import { createDeleteGatewayTargetHandler } from "./delete"; import { createGetGatewayTargetHandler } from "./get"; import { createListGatewayTargetsHandler } from "./list"; import { createUpdateGatewayTargetHandler } from "./update"; @@ -13,5 +14,6 @@ export function createGatewayTargetHandler(core: Core, io: AppIO): Router { .handler(createCreateGatewayTargetHandler(core, io)) .handler(createUpdateGatewayTargetHandler(core, io)) .handler(createGetGatewayTargetHandler(core)) - .handler(createListGatewayTargetsHandler(core)); + .handler(createListGatewayTargetsHandler(core)) + .handler(createDeleteGatewayTargetHandler(core)); } diff --git a/src/handlers/gateway/types.tsx b/src/handlers/gateway/types.tsx index ccb215277..2b5e8c421 100644 --- a/src/handlers/gateway/types.tsx +++ b/src/handlers/gateway/types.tsx @@ -7,6 +7,9 @@ import type { CreateGatewayTargetRequest, CreateGatewayTargetResponse, CustomTransformConfiguration, + DeleteGatewayResponse, + DeleteGatewayRuleResponse, + DeleteGatewayTargetResponse, ExceptionLevel, GatewayInterceptorConfiguration, GatewayPolicyEngineConfiguration, @@ -76,6 +79,7 @@ export interface CoreGatewayClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + deleteGateway(id: string, options: CoreOptions): Promise; getGatewayTarget( gatewayId: string, targetId: string, @@ -99,6 +103,11 @@ export interface CoreGatewayClient { patch: GatewayTargetUpdatePatch, options: CoreOptions, ): Promise; + deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise; getGatewayRule( gatewayId: string, ruleId: string, @@ -118,4 +127,9 @@ export interface CoreGatewayClient { input: GatewayRuleUpdateInput, options: CoreOptions, ): Promise; + deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 572ffc2f3..383a7cda1 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -8,6 +8,9 @@ import type { CreateHarnessEndpointResponse, CreateHarnessResponse, DeleteApiKeyCredentialProviderResponse, + DeleteGatewayResponse, + DeleteGatewayRuleResponse, + DeleteGatewayTargetResponse, DeleteOauth2CredentialProviderResponse, DeleteHarnessEndpointRequest, DeleteHarnessEndpointResponse, @@ -178,14 +181,17 @@ const DEFAULT_CREATE_GATEWAY_RESPONSE = {} as CreateGatewayResponse; const DEFAULT_UPDATE_GATEWAY_RESPONSE = {} as UpdateGatewayResponse; const DEFAULT_GET_GATEWAY_RESPONSE = {} as GetGatewayResponse; const DEFAULT_LIST_GATEWAYS_RESPONSE: ListGatewaysResponse = { items: [] }; +const DEFAULT_DELETE_GATEWAY_RESPONSE = {} as DeleteGatewayResponse; const DEFAULT_CREATE_GATEWAY_TARGET_RESPONSE = {} as CreateGatewayTargetResponse; const DEFAULT_UPDATE_GATEWAY_TARGET_RESPONSE = {} as UpdateGatewayTargetResponse; const DEFAULT_GET_GATEWAY_TARGET_RESPONSE = {} as GetGatewayTargetResponse; const DEFAULT_LIST_GATEWAY_TARGETS_RESPONSE: ListGatewayTargetsResponse = { items: [] }; +const DEFAULT_DELETE_GATEWAY_TARGET_RESPONSE = {} as DeleteGatewayTargetResponse; const DEFAULT_CREATE_GATEWAY_RULE_RESPONSE = {} as CreateGatewayRuleResponse; const DEFAULT_UPDATE_GATEWAY_RULE_RESPONSE = {} as UpdateGatewayRuleResponse; const DEFAULT_GET_GATEWAY_RULE_RESPONSE = {} as GetGatewayRuleResponse; const DEFAULT_LIST_GATEWAY_RULES_RESPONSE: ListGatewayRulesResponse = { gatewayRules: [] }; +const DEFAULT_DELETE_GATEWAY_RULE_RESPONSE = {} as DeleteGatewayRuleResponse; const DEFAULT_CREATE_OAUTH2_RESPONSE = {} as CreateOauth2CredentialProviderResponse; const DEFAULT_GET_OAUTH2_RESPONSE = {} as GetOauth2CredentialProviderResponse; const DEFAULT_LIST_OAUTH2_RESPONSE: ListOauth2CredentialProvidersResponse = { @@ -829,10 +835,14 @@ export class TestGatewayClient implements CoreGatewayClient { private getResponse: GetGatewayResponse = DEFAULT_GET_GATEWAY_RESPONSE; private listResponses = new Map(); + private deleteResponse: DeleteGatewayResponse = DEFAULT_DELETE_GATEWAY_RESPONSE; private getTargetResponse: GetGatewayTargetResponse = DEFAULT_GET_GATEWAY_TARGET_RESPONSE; private listTargetResponses = new Map(); + private deleteTargetResponse: DeleteGatewayTargetResponse = + DEFAULT_DELETE_GATEWAY_TARGET_RESPONSE; private getRuleResponse: GetGatewayRuleResponse = DEFAULT_GET_GATEWAY_RULE_RESPONSE; private listRuleResponses = new Map(); + private deleteRuleResponse: DeleteGatewayRuleResponse = DEFAULT_DELETE_GATEWAY_RULE_RESPONSE; private error?: Error; setGetResponse(response: GetGatewayResponse): this { @@ -845,6 +855,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteResponse(response: DeleteGatewayResponse): this { + this.deleteResponse = response; + return this; + } + setGetTargetResponse(response: GetGatewayTargetResponse): this { this.getTargetResponse = response; return this; @@ -855,6 +870,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteTargetResponse(response: DeleteGatewayTargetResponse): this { + this.deleteTargetResponse = response; + return this; + } + setGetRuleResponse(response: GetGatewayRuleResponse): this { this.getRuleResponse = response; return this; @@ -865,6 +885,11 @@ export class TestGatewayClient implements CoreGatewayClient { return this; } + setDeleteRuleResponse(response: DeleteGatewayRuleResponse): this { + this.deleteRuleResponse = response; + return this; + } + setError(error: Error | undefined): this { this.error = error; return this; @@ -908,6 +933,12 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async deleteGateway(id: string, options: CoreOptions): Promise { + this.calls.push({ method: "deleteGateway", args: [id, options] }); + if (this.error) throw this.error; + return this.deleteResponse; + } + async createGatewayTarget( input: CreateGatewayTargetInput, options: CoreOptions, @@ -963,6 +994,16 @@ export class TestGatewayClient implements CoreGatewayClient { ); } + async deleteGatewayTarget( + gatewayId: string, + targetId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deleteGatewayTarget", args: [gatewayId, targetId, options] }); + if (this.error) throw this.error; + return this.deleteTargetResponse; + } + async createGatewayRule( input: CreateGatewayRuleInput, options: CoreOptions, @@ -1008,6 +1049,15 @@ export class TestGatewayClient implements CoreGatewayClient { DEFAULT_LIST_GATEWAY_RULES_RESPONSE ); } + async deleteGatewayRule( + gatewayId: string, + ruleId: string, + options: CoreOptions, + ): Promise { + this.calls.push({ method: "deleteGatewayRule", args: [gatewayId, ruleId, options] }); + if (this.error) throw this.error; + return this.deleteRuleResponse; + } } type TestCoreClientOptions = {