From 4db14aab0c9c652af7c16dcc6f8bff05015bfef7 Mon Sep 17 00:00:00 2001 From: Ricardo Antunes Date: Thu, 26 Mar 2026 10:21:41 +0000 Subject: [PATCH] [wrangler] Add vpc network commands --- .changeset/wvpc-192-vpc-network-commands.md | 15 + .../miniflare-remote-resources.test.ts | 54 +++ .../workers/vpc-network-uuid.js | 15 + packages/wrangler/src/__tests__/vpc.test.ts | 423 ++++++++++++++++++ packages/wrangler/src/index.ts | 27 ++ packages/wrangler/src/vpc/network/client.ts | 88 ++++ packages/wrangler/src/vpc/network/create.ts | 53 +++ packages/wrangler/src/vpc/network/delete.ts | 26 ++ packages/wrangler/src/vpc/network/get.ts | 35 ++ packages/wrangler/src/vpc/network/index.ts | 29 ++ packages/wrangler/src/vpc/network/list.ts | 25 ++ packages/wrangler/src/vpc/network/shared.ts | 12 + packages/wrangler/src/vpc/network/update.ts | 50 +++ .../wrangler/src/vpc/network/validation.ts | 22 + 14 files changed, 874 insertions(+) create mode 100644 .changeset/wvpc-192-vpc-network-commands.md create mode 100644 packages/wrangler/e2e/remote-binding/workers/vpc-network-uuid.js create mode 100644 packages/wrangler/src/vpc/network/client.ts create mode 100644 packages/wrangler/src/vpc/network/create.ts create mode 100644 packages/wrangler/src/vpc/network/delete.ts create mode 100644 packages/wrangler/src/vpc/network/get.ts create mode 100644 packages/wrangler/src/vpc/network/index.ts create mode 100644 packages/wrangler/src/vpc/network/list.ts create mode 100644 packages/wrangler/src/vpc/network/shared.ts create mode 100644 packages/wrangler/src/vpc/network/update.ts create mode 100644 packages/wrangler/src/vpc/network/validation.ts diff --git a/.changeset/wvpc-192-vpc-network-commands.md b/.changeset/wvpc-192-vpc-network-commands.md new file mode 100644 index 0000000000..3db90d7de4 --- /dev/null +++ b/.changeset/wvpc-192-vpc-network-commands.md @@ -0,0 +1,15 @@ +--- +"wrangler": minor +--- + +Add `wrangler vpc network` commands for managing VPC networks + +New CLI commands for creating and managing VPC networks: + +- `wrangler vpc network create ` — create a network with a required `--tunnel-id` and optional `--resolver-ips` +- `wrangler vpc network list` — list all networks for the account +- `wrangler vpc network get ` — retrieve a single network by ID +- `wrangler vpc network update ` — update name and/or resolver IPs +- `wrangler vpc network delete ` — delete a network + +Also lifts the restriction that required `network_id` in `vpc_networks` bindings to equal `"cf1:network"` — any string value is now accepted, enabling bindings to explicitly created network entities. diff --git a/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts b/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts index fdf893696a..f8c985b758 100644 --- a/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts +++ b/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts @@ -577,6 +577,60 @@ const testCases: TestCase[] = [ expect.stringMatching(/ProxyError.*ProxyError/), ], }, + { + name: "VPC Network (UUID)", + scriptPath: "vpc-network-uuid.js", + setup: async (helper) => { + const networkName = generateResourceName(); + + // Create a real Cloudflare tunnel for testing + const tunnelId = await helper.tunnel(); + + const output = await helper.run( + `wrangler vpc network create ${networkName} --tunnel-id ${tunnelId}` + ); + + // Extract network_id from output: "✅ Created VPC network: " + const match = output.stdout.match( + /Created VPC network:\s+(?[\w-]+)/ + ); + const networkId = match?.groups?.networkId; + assert( + networkId, + "Failed to extract network ID from VPC network creation output" + ); + + // Teardown runs LIFO: network is deleted before its tunnel. + helper.onTeardown(async () => { + await helper.run(`wrangler vpc network delete ${networkId}`); + }); + + return { + remoteProxySessionConfig: { + bindings: { + VPC_NETWORK_UUID: { + type: "vpc_network", + network_id: networkId, + }, + } as unknown as StartDevWorkerInput["bindings"], + }, + miniflareConfig: (connection) => + ({ + vpcNetworks: { + VPC_NETWORK_UUID: { + network_id: networkId, + remoteProxyConnectionString: connection, + }, + }, + }) as unknown as Partial, + }; + }, + getExpectFetchToMatch: (expect) => [ + // Tunnel has no running cloudflared connector, so the internal service + // returns a ProxyError — proving the UUID network binding was wired correctly. + expect.stringContaining("ProxyError"), + ], + }, { name: "VPC Service", scriptPath: "vpc-service.js", diff --git a/packages/wrangler/e2e/remote-binding/workers/vpc-network-uuid.js b/packages/wrangler/e2e/remote-binding/workers/vpc-network-uuid.js new file mode 100644 index 0000000000..d8d91fa8a4 --- /dev/null +++ b/packages/wrangler/e2e/remote-binding/workers/vpc-network-uuid.js @@ -0,0 +1,15 @@ +export default { + async fetch(request, env, ctx) { + const results = {}; + try { + const response = await env.VPC_NETWORK_UUID.fetch( + "http://10.0.0.1:8080/" + ); + results.VPC_NETWORK_UUID = await response.text(); + } catch (e) { + const name = e.constructor?.name ?? "Error"; + results.VPC_NETWORK_UUID = `${name}: ${e.message}`; + } + return new Response(JSON.stringify(results)); + }, +}; diff --git a/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index b13a72e7b7..bc2c58462e 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -1,6 +1,7 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { ServiceType } from "../vpc/index"; +import { validateResolverIps } from "../vpc/network/validation"; import { extractPortFromHostname, validateHostname, @@ -18,6 +19,11 @@ import type { ConnectivityService, ConnectivityServiceRequest, } from "../vpc/index"; +import type { + ConnectivityNetwork, + CreateConnectivityNetworkRequest, + UpdateConnectivityNetworkRequest, +} from "../vpc/network/index"; import type { ServiceArgs } from "../vpc/validation"; describe("vpc help", () => { @@ -38,6 +44,7 @@ describe("vpc help", () => { COMMANDS wrangler vpc service 🔗 Manage VPC services + wrangler vpc network 🌐 Manage VPC networks [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -1207,3 +1214,419 @@ function mockWvpcTcpServiceList() { ) ); } + +// ──────────────────────────────────────────────────────────────────────────── +// vpc network tests +// ──────────────────────────────────────────────────────────────────────────── + +const mockNetwork: ConnectivityNetwork = { + network_id: "network-uuid", + name: "test-network", + tunnel_id: "tunnel-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + resolver_ips: ["8.8.8.8", "8.8.4.4"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", +}; + +describe("vpc network commands", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + + const std = mockConsoleMethods(); + + beforeEach(() => { + // @ts-expect-error we're using a very simple setTimeout mock here + vi.spyOn(global, "setTimeout").mockImplementation((fn, _period) => { + setImmediate(fn); + }); + setIsTTY(true); + }); + + afterEach(() => { + clearDialogs(); + }); + + it("should show network help text", async ({ expect }) => { + await runWrangler("vpc network"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler vpc network + + 🌐 Manage VPC networks [open beta] + + COMMANDS + wrangler vpc network create Create a new VPC network [open beta] + wrangler vpc network delete Delete a VPC network [open beta] + wrangler vpc network get Get a VPC network [open beta] + wrangler vpc network list List VPC networks [open beta] + wrangler vpc network update Update a VPC network [open beta] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + it("should handle creating a network without resolver IPs", async ({ + expect, + }) => { + const reqProm = mockWvpcNetworkCreate(); + await runWrangler( + "vpc network create test-network --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "name": "test-network", + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC network 'test-network' + ✅ Created VPC network: network-uuid + Name: test-network + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000 + Resolver IPs: Default" + `); + }); + + it("should handle creating a network with resolver IPs", async ({ + expect, + }) => { + const reqProm = mockWvpcNetworkCreate(); + await runWrangler( + "vpc network create test-network --tunnel-id 550e8400-e29b-41d4-a716-446655440000 --resolver-ips 8.8.8.8,8.8.4.4" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "name": "test-network", + "resolver_ips": [ + "8.8.8.8", + "8.8.4.4", + ], + "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", + } + `); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🚧 Creating VPC network 'test-network' + ✅ Created VPC network: network-uuid + Name: test-network + Tunnel ID: 550e8400-e29b-41d4-a716-446655440000 + Resolver IPs: 8.8.8.8, 8.8.4.4" + `); + }); + + it("should handle listing networks", async ({ expect }) => { + mockWvpcNetworkList(); + await runWrangler("vpc network list"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 📋 Listing VPC networks + ┌─┬─┬─┬─┬─┬─┐ + │ id │ name │ tunnel │ resolver_ips │ created │ modified │ + ├─┼─┼─┼─┼─┼─┤ + │ network-uuid │ test-network │ tunnel-y... │ 8.8.8.8, 8.8.4.4 │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ + └─┴─┴─┴─┴─┴─┘" + `); + }); + + it("should show 'No VPC networks found' when list is empty", async ({ + expect, + }) => { + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/networks", + () => { + return HttpResponse.json(createFetchResult([], true)); + }, + { once: true } + ) + ); + await runWrangler("vpc network list"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 📋 Listing VPC networks + No VPC networks found" + `); + }); + + it("should handle getting a network", async ({ expect }) => { + mockWvpcNetworkGetUpdateDelete(); + await runWrangler("vpc network get network-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🔍 Getting VPC network 'network-uuid' + ✅ Retrieved VPC network: network-uuid + Name: test-network + Tunnel ID: tunnel-yyyy-yyyy-yyyy-yyyyyyyyyyyy + Resolver IPs: 8.8.8.8, 8.8.4.4 + Created: 1/1/2024, 12:00:00 AM + Modified: 1/1/2024, 12:00:00 AM" + `); + }); + + it("should handle getting a network without resolver IPs", async ({ + expect, + }) => { + const networkWithoutResolverIps: ConnectivityNetwork = { + ...mockNetwork, + resolver_ips: undefined, + }; + + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/networks/:networkId", + () => { + return HttpResponse.json( + createFetchResult(networkWithoutResolverIps, true) + ); + }, + { once: true } + ) + ); + + await runWrangler("vpc network get network-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🔍 Getting VPC network 'network-uuid' + ✅ Retrieved VPC network: network-uuid + Name: test-network + Tunnel ID: tunnel-yyyy-yyyy-yyyy-yyyyyyyyyyyy + Resolver IPs: Default + Created: 1/1/2024, 12:00:00 AM + Modified: 1/1/2024, 12:00:00 AM" + `); + }); + + it("should handle deleting a network", async ({ expect }) => { + mockWvpcNetworkGetUpdateDelete(); + await runWrangler("vpc network delete network-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + " + ⛅️ wrangler x.x.x + ────────────────── + 🗑️ Deleting VPC network 'network-uuid' + ✅ Deleted VPC network: network-uuid" + `); + }); + + it("should surface bound Worker names when deletion is blocked", async ({ + expect, + }) => { + // The API returns 400 Bad Request with a NetworkInUse error listing the + // bound Worker names when a network cannot be deleted because of active + // bindings. The CLI should surface those names to the user. + msw.use( + http.delete( + "*/accounts/:accountId/connectivity/directory/networks/:networkId", + () => + HttpResponse.json( + createFetchResult(null, false, [ + { + // 1003 is a generic InvalidRequest code; anything non-10000 + // avoids wrangler's auth-error branch which would call `/user`. + code: 1003, + message: + "Network is bound to 2 Worker(s): my-worker, another-worker. Unbind them before deletion.", + }, + ]), + { status: 400 } + ) + ) + ); + + await expect( + runWrangler("vpc network delete network-uuid") + ).rejects.toMatchObject({ + notes: expect.arrayContaining([ + { + text: expect.stringContaining( + "Network is bound to 2 Worker(s): my-worker, another-worker" + ), + }, + ]), + }); + }); + + it("should handle updating a network name", async ({ expect }) => { + const reqProm = mockWvpcNetworkUpdate(); + await runWrangler("vpc network update network-uuid --name new-name"); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "name": "new-name", + } + `); + }); + + it("should handle updating a network resolver IPs", async ({ expect }) => { + const reqProm = mockWvpcNetworkUpdate(); + await runWrangler( + "vpc network update network-uuid --resolver-ips 1.1.1.1,1.0.0.1" + ); + + await expect(reqProm).resolves.toMatchInlineSnapshot(` + { + "resolver_ips": [ + "1.1.1.1", + "1.0.0.1", + ], + } + `); + }); +}); + +describe("vpc network resolver IP validation", () => { + it("should accept valid IPv4 resolver IPs", ({ expect }) => { + expect(() => validateResolverIps("8.8.8.8")).not.toThrow(); + expect(() => validateResolverIps("8.8.8.8,1.1.1.1")).not.toThrow(); + }); + + it("should accept valid IPv6 resolver IPs", ({ expect }) => { + expect(() => validateResolverIps("2001:db8::1")).not.toThrow(); + }); + + it("should reject empty resolver IPs string", ({ expect }) => { + expect(() => validateResolverIps("")).toThrow( + "--resolver-ips must not be empty" + ); + expect(() => validateResolverIps(" ")).toThrow( + "--resolver-ips must not be empty" + ); + }); + + it("should reject invalid IP addresses", ({ expect }) => { + expect(() => validateResolverIps("not-an-ip")).toThrow( + "Invalid resolver IP address(es): 'not-an-ip'" + ); + expect(() => validateResolverIps("8.8.8.8,bad-ip")).toThrow( + "Invalid resolver IP address(es): 'bad-ip'" + ); + }); + + it("should trim whitespace around IPs", ({ expect }) => { + expect(() => validateResolverIps(" 8.8.8.8 , 1.1.1.1 ")).not.toThrow(); + expect(validateResolverIps(" 8.8.8.8 , 1.1.1.1 ")).toEqual([ + "8.8.8.8", + "1.1.1.1", + ]); + }); +}); + +// Mock API Handlers for networks +function mockWvpcNetworkCreate(): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/accounts/:accountId/connectivity/directory/networks", + async ({ request }) => { + const reqBody = + (await request.json()) as CreateConnectivityNetworkRequest; + resolve(reqBody); + + return HttpResponse.json( + createFetchResult( + { + network_id: "network-uuid", + name: reqBody.name, + tunnel_id: reqBody.tunnel_id, + resolver_ips: reqBody.resolver_ips, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} + +function mockWvpcNetworkUpdate(): Promise { + return new Promise((resolve) => { + msw.use( + http.put( + "*/accounts/:accountId/connectivity/directory/networks/:networkId", + async ({ request }) => { + const reqBody = + (await request.json()) as UpdateConnectivityNetworkRequest; + resolve(reqBody); + + return HttpResponse.json( + createFetchResult( + { + ...mockNetwork, + ...reqBody, + }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} + +function mockWvpcNetworkGetUpdateDelete() { + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/networks/:networkId", + () => { + return HttpResponse.json(createFetchResult(mockNetwork, true)); + }, + { once: true } + ), + http.delete( + "*/accounts/:accountId/connectivity/directory/networks/:networkId", + () => { + return HttpResponse.json(createFetchResult(null, true)); + }, + { once: true } + ) + ); +} + +function mockWvpcNetworkList() { + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/networks", + () => { + return HttpResponse.json(createFetchResult([mockNetwork], true)); + }, + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 0e5820f5bc..4147fcdd7c 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -422,6 +422,12 @@ import { vpcServiceDeleteCommand } from "./vpc/delete"; import { vpcServiceGetCommand } from "./vpc/get"; import { vpcNamespace, vpcServiceNamespace } from "./vpc/index"; import { vpcServiceListCommand } from "./vpc/list"; +import { vpcNetworkCreateCommand } from "./vpc/network/create"; +import { vpcNetworkDeleteCommand } from "./vpc/network/delete"; +import { vpcNetworkGetCommand } from "./vpc/network/get"; +import { vpcNetworkNamespace } from "./vpc/network/index"; +import { vpcNetworkListCommand } from "./vpc/network/list"; +import { vpcNetworkUpdateCommand } from "./vpc/network/update"; import { vpcServiceUpdateCommand } from "./vpc/update"; import { workflowsInstanceNamespace, workflowsNamespace } from "./workflows"; import { workflowsDeleteCommand } from "./workflows/commands/delete"; @@ -1901,6 +1907,27 @@ export function createCLIParser(argv: string[]) { command: "wrangler vpc service update", definition: vpcServiceUpdateCommand, }, + { command: "wrangler vpc network", definition: vpcNetworkNamespace }, + { + command: "wrangler vpc network create", + definition: vpcNetworkCreateCommand, + }, + { + command: "wrangler vpc network delete", + definition: vpcNetworkDeleteCommand, + }, + { + command: "wrangler vpc network get", + definition: vpcNetworkGetCommand, + }, + { + command: "wrangler vpc network list", + definition: vpcNetworkListCommand, + }, + { + command: "wrangler vpc network update", + definition: vpcNetworkUpdateCommand, + }, ]); registry.registerNamespace("vpc"); diff --git a/packages/wrangler/src/vpc/network/client.ts b/packages/wrangler/src/vpc/network/client.ts new file mode 100644 index 0000000000..15fbcd0599 --- /dev/null +++ b/packages/wrangler/src/vpc/network/client.ts @@ -0,0 +1,88 @@ +import { fetchPagedListResult, fetchResult } from "../../cfetch"; +import { requireAuth } from "../../user"; +import type { + ConnectivityNetwork, + CreateConnectivityNetworkRequest, + UpdateConnectivityNetworkRequest, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +export async function createNetwork( + config: Config, + body: CreateConnectivityNetworkRequest +): Promise { + const accountId = await requireAuth(config); + + return await fetchResult( + config, + `/accounts/${accountId}/connectivity/directory/networks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + } + ); +} + +export async function deleteNetwork( + config: Config, + networkId: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/connectivity/directory/networks/${networkId}`, + { + method: "DELETE", + } + ); +} + +export async function getNetwork( + config: Config, + networkId: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/connectivity/directory/networks/${networkId}`, + { + method: "GET", + } + ); +} + +export async function listNetworks( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/connectivity/directory/networks`, + { + method: "GET", + } + ); +} + +export async function updateNetwork( + config: Config, + networkId: string, + body: UpdateConnectivityNetworkRequest +): Promise { + const accountId = await requireAuth(config); + + return await fetchResult( + config, + `/accounts/${accountId}/connectivity/directory/networks/${networkId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + } + ); +} diff --git a/packages/wrangler/src/vpc/network/create.ts b/packages/wrangler/src/vpc/network/create.ts new file mode 100644 index 0000000000..9e04f26db0 --- /dev/null +++ b/packages/wrangler/src/vpc/network/create.ts @@ -0,0 +1,53 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { createNetwork } from "./client"; +import { validateResolverIps } from "./validation"; + +export const vpcNetworkCreateCommand = createCommand({ + metadata: { + description: "Create a new VPC network", + status: "open beta", + owner: "Product: WVPC", + }, + args: { + name: { + type: "string", + demandOption: true, + description: "The name of the VPC network", + }, + "tunnel-id": { + type: "string", + demandOption: true, + description: + "UUID of the Cloudflare tunnel to associate with this network", + }, + "resolver-ips": { + type: "string", + description: + "Comma-separated list of custom DNS resolver IPs (optional). When omitted, the tunnel's default DNS is used.", + }, + }, + positionalArgs: ["name"], + async handler(args, { config }) { + logger.log(`🚧 Creating VPC network '${args.name}'`); + + const resolverIps = args.resolverIps + ? validateResolverIps(args.resolverIps) + : undefined; + + const network = await createNetwork(config, { + name: args.name, + tunnel_id: args.tunnelId, + ...(resolverIps && { resolver_ips: resolverIps }), + }); + + logger.log(`✅ Created VPC network: ${network.network_id}`); + logger.log(` Name: ${network.name}`); + logger.log(` Tunnel ID: ${network.tunnel_id}`); + if (network.resolver_ips && network.resolver_ips.length > 0) { + logger.log(` Resolver IPs: ${network.resolver_ips.join(", ")}`); + } else { + logger.log(` Resolver IPs: Default`); + } + }, +}); diff --git a/packages/wrangler/src/vpc/network/delete.ts b/packages/wrangler/src/vpc/network/delete.ts new file mode 100644 index 0000000000..0192fecfdf --- /dev/null +++ b/packages/wrangler/src/vpc/network/delete.ts @@ -0,0 +1,26 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { deleteNetwork } from "./client"; + +export const vpcNetworkDeleteCommand = createCommand({ + metadata: { + description: "Delete a VPC network", + status: "open beta", + owner: "Product: WVPC", + }, + args: { + "network-id": { + type: "string", + demandOption: true, + description: "The ID of the network to delete", + }, + }, + positionalArgs: ["network-id"], + async handler(args, { config }) { + logger.log(`🗑️ Deleting VPC network '${args.networkId}'`); + + await deleteNetwork(config, args.networkId); + + logger.log(`✅ Deleted VPC network: ${args.networkId}`); + }, +}); diff --git a/packages/wrangler/src/vpc/network/get.ts b/packages/wrangler/src/vpc/network/get.ts new file mode 100644 index 0000000000..868cbdaac2 --- /dev/null +++ b/packages/wrangler/src/vpc/network/get.ts @@ -0,0 +1,35 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getNetwork } from "./client"; + +export const vpcNetworkGetCommand = createCommand({ + metadata: { + description: "Get a VPC network", + status: "open beta", + owner: "Product: WVPC", + }, + args: { + "network-id": { + type: "string", + demandOption: true, + description: "The ID of the VPC network", + }, + }, + positionalArgs: ["network-id"], + async handler(args, { config }) { + logger.log(`🔍 Getting VPC network '${args.networkId}'`); + + const network = await getNetwork(config, args.networkId); + + logger.log(`✅ Retrieved VPC network: ${network.network_id}`); + logger.log(` Name: ${network.name}`); + logger.log(` Tunnel ID: ${network.tunnel_id}`); + if (network.resolver_ips && network.resolver_ips.length > 0) { + logger.log(` Resolver IPs: ${network.resolver_ips.join(", ")}`); + } else { + logger.log(` Resolver IPs: Default`); + } + logger.log(` Created: ${new Date(network.created_at).toLocaleString()}`); + logger.log(` Modified: ${new Date(network.updated_at).toLocaleString()}`); + }, +}); diff --git a/packages/wrangler/src/vpc/network/index.ts b/packages/wrangler/src/vpc/network/index.ts new file mode 100644 index 0000000000..2cdac2b360 --- /dev/null +++ b/packages/wrangler/src/vpc/network/index.ts @@ -0,0 +1,29 @@ +import { createNamespace } from "../../core/create-command"; + +export const vpcNetworkNamespace = createNamespace({ + metadata: { + description: "🌐 Manage VPC networks", + status: "open beta", + owner: "Product: WVPC", + }, +}); + +export interface ConnectivityNetwork { + network_id: string; + name: string; + tunnel_id: string; + resolver_ips?: string[]; + created_at: string; + updated_at: string; +} + +export interface CreateConnectivityNetworkRequest { + name: string; + tunnel_id: string; + resolver_ips?: string[]; +} + +export interface UpdateConnectivityNetworkRequest { + name?: string; + resolver_ips?: string[]; +} diff --git a/packages/wrangler/src/vpc/network/list.ts b/packages/wrangler/src/vpc/network/list.ts new file mode 100644 index 0000000000..e08e9f2e86 --- /dev/null +++ b/packages/wrangler/src/vpc/network/list.ts @@ -0,0 +1,25 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { listNetworks } from "./client"; +import { formatNetworkForTable } from "./shared"; + +export const vpcNetworkListCommand = createCommand({ + metadata: { + description: "List VPC networks", + status: "open beta", + owner: "Product: WVPC", + }, + args: {}, + async handler(_args, { config }) { + logger.log(`📋 Listing VPC networks`); + + const networks = await listNetworks(config); + + if (networks.length === 0) { + logger.log("No VPC networks found"); + return; + } + + logger.table(networks.map(formatNetworkForTable)); + }, +}); diff --git a/packages/wrangler/src/vpc/network/shared.ts b/packages/wrangler/src/vpc/network/shared.ts new file mode 100644 index 0000000000..26872400ea --- /dev/null +++ b/packages/wrangler/src/vpc/network/shared.ts @@ -0,0 +1,12 @@ +import type { ConnectivityNetwork } from "./index"; + +export function formatNetworkForTable(network: ConnectivityNetwork) { + return { + id: network.network_id, + name: network.name, + tunnel: network.tunnel_id.substring(0, 8) + "...", + resolver_ips: network.resolver_ips?.join(", ") ?? "Default", + created: new Date(network.created_at).toLocaleString(), + modified: new Date(network.updated_at).toLocaleString(), + }; +} diff --git a/packages/wrangler/src/vpc/network/update.ts b/packages/wrangler/src/vpc/network/update.ts new file mode 100644 index 0000000000..68628874f9 --- /dev/null +++ b/packages/wrangler/src/vpc/network/update.ts @@ -0,0 +1,50 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { updateNetwork } from "./client"; +import { validateResolverIps } from "./validation"; + +export const vpcNetworkUpdateCommand = createCommand({ + metadata: { + description: "Update a VPC network", + status: "open beta", + owner: "Product: WVPC", + }, + args: { + "network-id": { + type: "string", + demandOption: true, + description: "The ID of the VPC network to update", + }, + name: { + type: "string", + description: "New name for the VPC network", + }, + "resolver-ips": { + type: "string", + description: + "Comma-separated list of custom DNS resolver IPs. Pass an empty string to reset to default.", + }, + }, + positionalArgs: ["network-id"], + async handler(args, { config }) { + logger.log(`🚧 Updating VPC network '${args.networkId}'`); + + const resolverIps = args.resolverIps + ? validateResolverIps(args.resolverIps) + : undefined; + + const network = await updateNetwork(config, args.networkId, { + ...(args.name !== undefined && { name: args.name }), + ...(resolverIps !== undefined && { resolver_ips: resolverIps }), + }); + + logger.log(`✅ Updated VPC network: ${network.network_id}`); + logger.log(` Name: ${network.name}`); + logger.log(` Tunnel ID: ${network.tunnel_id}`); + if (network.resolver_ips && network.resolver_ips.length > 0) { + logger.log(` Resolver IPs: ${network.resolver_ips.join(", ")}`); + } else { + logger.log(` Resolver IPs: Default`); + } + }, +}); diff --git a/packages/wrangler/src/vpc/network/validation.ts b/packages/wrangler/src/vpc/network/validation.ts new file mode 100644 index 0000000000..5ff5a97fd3 --- /dev/null +++ b/packages/wrangler/src/vpc/network/validation.ts @@ -0,0 +1,22 @@ +import net from "node:net"; +import { UserError } from "@cloudflare/workers-utils"; + +export function validateResolverIps(resolverIps: string): string[] { + const ips = resolverIps.split(",").map((ip) => ip.trim()); + const nonEmpty = ips.filter((ip) => ip.length > 0); + + if (nonEmpty.length === 0) { + throw new UserError( + "--resolver-ips must not be empty. Provide at least one valid IPv4 or IPv6 address." + ); + } + + const invalid = nonEmpty.filter((ip) => !net.isIPv4(ip) && !net.isIPv6(ip)); + if (invalid.length > 0) { + throw new UserError( + `Invalid resolver IP address(es): ${invalid.map((ip) => `'${ip}'`).join(", ")}. Provide valid IPv4 or IPv6 addresses.` + ); + } + + return nonEmpty; +}