diff --git a/.changeset/clever-snails-give.md b/.changeset/clever-snails-give.md new file mode 100644 index 000000000000..d852e635fa00 --- /dev/null +++ b/.changeset/clever-snails-give.md @@ -0,0 +1,6 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +VPC service binding support diff --git a/packages/miniflare/src/plugins/index.ts b/packages/miniflare/src/plugins/index.ts index 70571d60970d..7114acb208f7 100644 --- a/packages/miniflare/src/plugins/index.ts +++ b/packages/miniflare/src/plugins/index.ts @@ -31,6 +31,7 @@ import { R2_PLUGIN, R2_PLUGIN_NAME } from "./r2"; import { RATELIMIT_PLUGIN, RATELIMIT_PLUGIN_NAME } from "./ratelimit"; import { SECRET_STORE_PLUGIN, SECRET_STORE_PLUGIN_NAME } from "./secret-store"; import { VECTORIZE_PLUGIN, VECTORIZE_PLUGIN_NAME } from "./vectorize"; +import { VPC_SERVICES_PLUGIN, VPC_SERVICES_PLUGIN_NAME } from "./vpc-services"; import { WORKER_LOADER_PLUGIN, WORKER_LOADER_PLUGIN_NAME, @@ -58,6 +59,7 @@ export const PLUGINS = { [DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN, [IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN, [VECTORIZE_PLUGIN_NAME]: VECTORIZE_PLUGIN, + [VPC_SERVICES_PLUGIN_NAME]: VPC_SERVICES_PLUGIN, [MTLS_PLUGIN_NAME]: MTLS_PLUGIN, [HELLO_WORLD_PLUGIN_NAME]: HELLO_WORLD_PLUGIN, [WORKER_LOADER_PLUGIN_NAME]: WORKER_LOADER_PLUGIN, @@ -119,6 +121,7 @@ export type WorkerOptions = z.input & z.input & z.input & z.input & + z.input & z.input & z.input & z.input; @@ -190,6 +193,7 @@ export * from "./browser-rendering"; export * from "./dispatch-namespace"; export * from "./images"; export * from "./vectorize"; +export * from "./vpc-services"; export * from "./mtls"; export * from "./hello-world"; export * from "./worker-loader"; diff --git a/packages/miniflare/src/plugins/vpc-services/index.ts b/packages/miniflare/src/plugins/vpc-services/index.ts new file mode 100644 index 000000000000..743fae90b0e3 --- /dev/null +++ b/packages/miniflare/src/plugins/vpc-services/index.ts @@ -0,0 +1,84 @@ +import assert from "node:assert"; +import { z } from "zod"; +import { + getUserBindingServiceName, + Plugin, + ProxyNodeBinding, + remoteProxyClientWorker, + RemoteProxyConnectionString, +} from "../shared"; + +const VpcServicesSchema = z.object({ + service_id: z.string(), + remoteProxyConnectionString: z.custom(), +}); + +export const VpcServicesOptionsSchema = z.object({ + vpcServices: z.record(VpcServicesSchema).optional(), +}); + +export const VPC_SERVICES_PLUGIN_NAME = "vpc-services"; + +export const VPC_SERVICES_PLUGIN: Plugin = { + options: VpcServicesOptionsSchema, + async getBindings(options) { + if (!options.vpcServices) { + return []; + } + + return Object.entries(options.vpcServices).map( + ([name, { service_id, remoteProxyConnectionString }]) => { + assert( + remoteProxyConnectionString, + "VPC Services only supports running remotely" + ); + + return { + name, + + service: { + name: getUserBindingServiceName( + VPC_SERVICES_PLUGIN_NAME, + service_id, + remoteProxyConnectionString + ), + }, + }; + } + ); + }, + getNodeBindings(options: z.infer) { + if (!options.vpcServices) { + return {}; + } + return Object.fromEntries( + Object.keys(options.vpcServices).map((name) => [ + name, + new ProxyNodeBinding(), + ]) + ); + }, + async getServices({ options }) { + if (!options.vpcServices) { + return []; + } + + return Object.entries(options.vpcServices).map( + ([name, { service_id, remoteProxyConnectionString }]) => { + assert( + remoteProxyConnectionString, + "VPC Services only supports running remotely" + ); + + return { + name: getUserBindingServiceName( + VPC_SERVICES_PLUGIN_NAME, + service_id, + remoteProxyConnectionString + ), + worker: remoteProxyClientWorker(remoteProxyConnectionString, name), + }; + } + ); + }, +}; diff --git a/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts b/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts index 7787f7bf2527..d63ae737b1a6 100644 --- a/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts +++ b/packages/wrangler/e2e/helpers/e2e-wrangler-test.ts @@ -290,4 +290,45 @@ export class WranglerE2ETestHelper { return { deployedUrl, stdout, cleanup }; } } + + async tunnel(): Promise { + const Cloudflare = (await import("cloudflare")).default; + + const name = generateResourceName("tunnel"); + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + if (!accountId) { + throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required"); + } + + // Create Cloudflare client directly + const client = new Cloudflare({ + apiToken: process.env.CLOUDFLARE_API_TOKEN, + }); + + // Create tunnel via Cloudflare SDK + const tunnel = await client.zeroTrust.tunnels.cloudflared.create({ + account_id: accountId, + name, + config_src: "cloudflare", + }); + + if (!tunnel.id) { + throw new Error("Failed to create tunnel: tunnel ID is undefined"); + } + + const tunnelId = tunnel.id; + + onTestFinished(async () => { + try { + await client.zeroTrust.tunnels.cloudflared.delete(tunnelId, { + account_id: accountId, + }); + } catch (error) { + // Ignore deletion errors in cleanup + console.warn(`Failed to delete tunnel ${tunnelId}:`, error); + } + }); + + return tunnelId; + } } 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 a655c4f32207..60672a34f71e 100644 --- a/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts +++ b/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts @@ -379,6 +379,58 @@ const testCases: TestCase[] = [ ), ], }, + /* { + // Enable post announcement + name: "VPC Service", + scriptPath: "vpc-service.js", + setup: async (helper) => { + const serviceName = generateResourceName(); + + // Create a real Cloudflare tunnel for testing + const tunnelId = await helper.tunnel(); + + const output = await helper.run( + `wrangler vpc service create ${serviceName} --type http --ipv4 10.0.0.1 --http-port 8080 --tunnel-id ${tunnelId}` + ); + + // Extract service_id from output + const match = output.stdout.match( + /Created VPC service:\s+(?[\w-]+)/ + ); + const serviceId = match?.groups?.serviceId; + assert( + serviceId, + "Failed to extract service ID from VPC service creation output" + ); + + onTestFinished(async () => { + await helper.run(`wrangler vpc service delete ${serviceId}`); + }); + + return serviceId; + }, + remoteProxySessionConfig: (serviceId) => [ + { + VPC_SERVICE: { + type: "vpc_service", + service_id: serviceId, + }, + }, + ], + miniflareConfig: (connection, serviceId) => ({ + vpcServices: { + VPC_SERVICE: { + service_id: serviceId, + remoteProxyConnectionString: connection, + }, + }, + }), + matches: [ + // Since we're using a real tunnel but no actual network connectivity, Iris will report back an error + // but this is considered an effective test for wrangler and vpc service bindings + expect.stringMatching(/CONNECT failed: 503 Service Unavailable/), + ], + }, */ ]; const mtlsTest: TestCase<{ certificateId: string; workerName: string }> = { diff --git a/packages/wrangler/e2e/remote-binding/workers/vpc-service.js b/packages/wrangler/e2e/remote-binding/workers/vpc-service.js new file mode 100644 index 000000000000..844e93e627e0 --- /dev/null +++ b/packages/wrangler/e2e/remote-binding/workers/vpc-service.js @@ -0,0 +1,6 @@ +export default { + async fetch(request, env, ctx) { + const response = await env.VPC_SERVICE.fetch("http://10.0.0.1:8080/"); + return new Response(await response.text()); + }, +}; diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts index 6848c1380cbf..4696df4209e4 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts @@ -74,6 +74,12 @@ describe("convertConfigBindingsToStartWorkerBindings", () => { class_name: "MyWorkflow", }, ], + vpc_services: [ + { + binding: "MY_VPC_SERVICE", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + ], }); expect(result).toEqual({ AI: { @@ -125,6 +131,10 @@ describe("convertConfigBindingsToStartWorkerBindings", () => { name: "workflow", type: "workflow", }, + MY_VPC_SERVICE: { + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + type: "vpc_service", + }, }); }); @@ -165,6 +175,7 @@ describe("convertConfigBindingsToStartWorkerBindings", () => { ], mtls_certificates: [], workflows: [], + vpc_services: [], }); assert(result); diff --git a/packages/wrangler/src/__tests__/config/configuration.test.ts b/packages/wrangler/src/__tests__/config/configuration.test.ts index cc7eeaaea0bf..b1d798a4e8db 100644 --- a/packages/wrangler/src/__tests__/config/configuration.test.ts +++ b/packages/wrangler/src/__tests__/config/configuration.test.ts @@ -108,6 +108,7 @@ describe("normalizeAndValidateConfig()", () => { secrets_store_secrets: [], unsafe_hello_world: [], ratelimits: [], + vpc_services: [], services: [], analytics_engine_datasets: [], route: undefined, @@ -4281,6 +4282,69 @@ describe("normalizeAndValidateConfig()", () => { }); }); + describe("[vpc_services]", () => { + it("should accept valid bindings", () => { + const { config, diagnostics } = normalizeAndValidateConfig( + { + vpc_services: [ + { + binding: "MYAPI", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { + binding: "DATABASE", + service_id: "0299295b-b3ac-7760-8246-bca40877b3e0", + }, + ], + } as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(config.vpc_services).toEqual([ + { + binding: "MYAPI", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { + binding: "DATABASE", + service_id: "0299295b-b3ac-7760-8246-bca40877b3e0", + }, + ]); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("should error if vpc_services bindings are not valid", () => { + const { diagnostics } = normalizeAndValidateConfig( + { + vpc_services: [ + {}, + { + binding: "VALID", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { binding: null, service_id: 123, invalid: true }, + { binding: "MISSING_SERVICE_ID" }, + ], + } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"vpc_services[0]\\" bindings should have a string \\"binding\\" field but got {}. + - \\"vpc_services[0]\\" bindings must have a \\"service_id\\" field but got {}. + - \\"vpc_services[2]\\" bindings should have a string \\"binding\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}. + - \\"vpc_services[2]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}. + - \\"vpc_services[3]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":\\"MISSING_SERVICE_ID\\"}." + `); + }); + }); + describe("[unsafe.bindings]", () => { it("should error if unsafe is an array", () => { const { diagnostics } = normalizeAndValidateConfig( diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 2cf0ac8b18b1..a615176cc3ef 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -12705,6 +12705,90 @@ export default{ }); }); + describe("vpc_services", () => { + it("should upload VPC services bindings", async () => { + writeWranglerConfig({ + vpc_services: [ + { + binding: "VPC_SERVICE", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + ], + }); + await fs.promises.writeFile("index.js", `export default {};`); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: [ + { + type: "vpc_service", + name: "VPC_SERVICE", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + ], + }); + + await runWrangler("deploy index.js"); + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your Worker has access to the following bindings: + Binding Resource + env.VPC_SERVICE (0199295b-b3ac-7760-8246-bca40877b3e9) VPC Service + + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + }); + + it("should upload multiple VPC services bindings", async () => { + writeWranglerConfig({ + vpc_services: [ + { + binding: "VPC_API", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { + binding: "VPC_DATABASE", + service_id: "0299295b-b3ac-7760-8246-bca40877b3e0", + }, + ], + }); + await fs.promises.writeFile("index.js", `export default {};`); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: [ + { + type: "vpc_service", + name: "VPC_API", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { + type: "vpc_service", + name: "VPC_DATABASE", + service_id: "0299295b-b3ac-7760-8246-bca40877b3e0", + }, + ], + }); + + await runWrangler("deploy index.js"); + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your Worker has access to the following bindings: + Binding Resource + env.VPC_API (0199295b-b3ac-7760-8246-bca40877b3e9) VPC Service + env.VPC_DATABASE (0299295b-b3ac-7760-8246-bca40877b3e0) VPC Service + + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + }); + }); + describe("mtls_certificates", () => { it("should upload mtls_certificate bindings", async () => { writeWranglerConfig({ diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index 745aa33741d5..993328307e89 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -65,7 +65,7 @@ describe("wrangler", () => { wrangler secrets-store 🔐 Manage the Secrets Store [alpha] wrangler workflows 🔁 Manage Workflows wrangler pipelines 🚰 Manage Cloudflare Pipelines [open-beta] - wrangler vpc 🌐 Manage VPC connectivity [private-beta] + wrangler vpc 🌐 Manage VPC [open-beta] wrangler login 🔓 Login to Cloudflare wrangler logout 🚪 Logout from Cloudflare wrangler whoami 🕵️ Retrieve your user information @@ -127,7 +127,7 @@ describe("wrangler", () => { wrangler secrets-store 🔐 Manage the Secrets Store [alpha] wrangler workflows 🔁 Manage Workflows wrangler pipelines 🚰 Manage Cloudflare Pipelines [open-beta] - wrangler vpc 🌐 Manage VPC connectivity [private-beta] + wrangler vpc 🌐 Manage VPC [open-beta] wrangler login 🔓 Login to Cloudflare wrangler logout 🚪 Logout from Cloudflare wrangler whoami 🕵️ Retrieve your user information diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 6cca2cb249eb..698ee243e2de 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -268,6 +268,12 @@ const bindingsConfigMock: Omit< binding: "WORKER_LOADER_BINDING", }, ], + vpc_services: [ + { + binding: "VPC_SERVICE_BINDING", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + ], }; describe("generate types", () => { @@ -502,6 +508,7 @@ describe("generate types", () => { SEND_EMAIL_BINDING: SendEmail; VECTORIZE_BINDING: VectorizeIndex; HYPERDRIVE_BINDING: Hyperdrive; + VPC_SERVICE_BINDING: Fetcher; MTLS_BINDING: Fetcher; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -602,6 +609,7 @@ describe("generate types", () => { SEND_EMAIL_BINDING: SendEmail; VECTORIZE_BINDING: VectorizeIndex; HYPERDRIVE_BINDING: Hyperdrive; + VPC_SERVICE_BINDING: Fetcher; MTLS_BINDING: Fetcher; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -766,6 +774,7 @@ describe("generate types", () => { SEND_EMAIL_BINDING: SendEmail; VECTORIZE_BINDING: VectorizeIndex; HYPERDRIVE_BINDING: Hyperdrive; + VPC_SERVICE_BINDING: Fetcher; MTLS_BINDING: Fetcher; BROWSER_BINDING: Fetcher; AI_BINDING: Ai; @@ -1661,4 +1670,52 @@ describe("generate types", () => { `); }); }); + + it("should generate types for VPC services bindings", async () => { + fs.writeFileSync( + "./index.ts", + `export default { async fetch(request, env) { return await env.VPC_API.fetch(request); } };` + ); + fs.writeFileSync( + "./wrangler.json", + JSON.stringify({ + compatibility_date: "2022-01-12", + name: "test-vpc-services", + main: "./index.ts", + vpc_services: [ + { + binding: "VPC_API", + service_id: "0199295b-b3ac-7760-8246-bca40877b3e9", + }, + { + binding: "VPC_DATABASE", + service_id: "0299295b-b3ac-7760-8246-bca40877b3e0", + }, + ], + }), + "utf-8" + ); + + await runWrangler("types --include-runtime=false"); + expect(std.out).toMatchInlineSnapshot(` + "Generating project types... + + declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import(\\"./index\\"); + } + interface Env { + VPC_API: Fetcher; + VPC_DATABASE: Fetcher; + } + } + interface Env extends Cloudflare.Env {} + + ──────────────────────────────────────────────────────────── + ✨ Types written to worker-configuration.d.ts + + 📣 Remember to rerun 'wrangler types' after you change your wrangler.json file. + " + `); + }); }); diff --git a/packages/wrangler/src/__tests__/vpc.test.ts b/packages/wrangler/src/__tests__/vpc.test.ts index 78afcfe40143..494e34d49891 100644 --- a/packages/wrangler/src/__tests__/vpc.test.ts +++ b/packages/wrangler/src/__tests__/vpc.test.ts @@ -26,10 +26,10 @@ describe("vpc help", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler vpc - 🌐 Manage VPC connectivity [private-beta] + 🌐 Manage VPC [open-beta] COMMANDS - wrangler vpc service 🔗 Manage VPC connectivity services + wrangler vpc service 🔗 Manage VPC services GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -49,14 +49,14 @@ describe("vpc help", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler vpc service - 🔗 Manage VPC connectivity services + 🔗 Manage VPC services COMMANDS - wrangler vpc service create Create a new VPC connectivity service - wrangler vpc service delete Delete a VPC connectivity service - wrangler vpc service get Get a VPC connectivity service - wrangler vpc service list List VPC connectivity services - wrangler vpc service update Update a VPC connectivity service + wrangler vpc service create Create a new VPC service + wrangler vpc service delete Delete a VPC service + wrangler vpc service get Get a VPC service + wrangler vpc service list List VPC services + wrangler vpc service update Update a VPC service GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -89,66 +89,39 @@ describe("vpc service commands", () => { clearDialogs(); }); - // TCP Service Creation Tests - it("should handle creating a TCP service with IPv4", async () => { + it("should handle creating an HTTP service with IPv4", async () => { const reqProm = mockWvpcServiceCreate(); await runWrangler( - "vpc service create test-tcp --tcp-port 5432 --app-protocol postgresql --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + "vpc service create test-http-ipv4 --type http --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" ); await expect(reqProm).resolves.toMatchInlineSnapshot(` Object { - "app_protocol": "postgresql", "host": Object { "ipv4": "10.0.0.1", "network": Object { "tunnel_id": "550e8400-e29b-41d4-a716-446655440000", }, }, - "name": "test-tcp", - "tcp_port": 5432, - "type": "tcp", + "name": "test-http-ipv4", + "type": "http", } `); expect(std.out).toMatchInlineSnapshot(` - "🚧 Creating VPC connectivity service 'test-tcp' - ✅ Created VPC connectivity service: tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx - Name: test-tcp - Type: tcp - TCP Port: 5432 - Protocol: postgresql + "🚧 Creating VPC service 'test-http-ipv4' + ✅ Created VPC service: service-uuid + Name: test-http-ipv4 + Type: http IPv4: 10.0.0.1 Tunnel ID: 550e8400-e29b-41d4-a716-446655440000" `); }); - it("should handle creating a TCP service with IPv6", async () => { - const reqProm = mockWvpcServiceCreate(); - await runWrangler( - "vpc service create test-tcp-v6 --tcp-port 3306 --app-protocol mysql --ipv6 2001:db8::1 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" - ); - - await expect(reqProm).resolves.toMatchInlineSnapshot(` - Object { - "app_protocol": "mysql", - "host": Object { - "ipv6": "2001:db8::1", - "network": Object { - "tunnel_id": "550e8400-e29b-41d4-a716-446655440001", - }, - }, - "name": "test-tcp-v6", - "tcp_port": 3306, - "type": "tcp", - } - `); - }); - it("should handle creating a service with hostname and resolver network", async () => { const reqProm = mockWvpcServiceCreate(); await runWrangler( - "vpc service create test-hostname --http-port 80 --hostname db.example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440002 --resolver-ips 8.8.8.8,8.8.4.4" + "vpc service create test-hostname --type http --http-port 80 --hostname db.example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440002 --resolver-ips 8.8.8.8,8.8.4.4" ); await expect(reqProm).resolves.toMatchInlineSnapshot(` @@ -170,68 +143,10 @@ describe("vpc service commands", () => { `); }); - // HTTP Service Creation Tests - it("should handle creating an HTTP service with dual ports", async () => { - const reqProm = mockWvpcServiceCreate(); - await runWrangler( - "vpc service create test-web --http-port 80 --https-port 443 --ipv4 10.0.0.2 --tunnel-id 550e8400-e29b-41d4-a716-446655440003" - ); - - await expect(reqProm).resolves.toMatchInlineSnapshot(` - Object { - "host": Object { - "ipv4": "10.0.0.2", - "network": Object { - "tunnel_id": "550e8400-e29b-41d4-a716-446655440003", - }, - }, - "http_port": 80, - "https_port": 443, - "name": "test-web", - "type": "http", - } - `); - }); - - it("should handle creating an HTTP service with only HTTPS port", async () => { - const reqProm = mockWvpcServiceCreate(); - await runWrangler( - "vpc service create test-https --https-port 8443 --ipv4 10.0.0.3 --tunnel-id 550e8400-e29b-41d4-a716-446655440004" - ); - - await expect(reqProm).resolves.toMatchInlineSnapshot(` - Object { - "host": Object { - "ipv4": "10.0.0.3", - "network": Object { - "tunnel_id": "550e8400-e29b-41d4-a716-446655440004", - }, - }, - "https_port": 8443, - "name": "test-https", - "type": "http", - } - `); - }); - - // Validation Error Tests - it("should reject service creation with out either tcp-port or http-port/https-port", async () => { - await expect(() => - runWrangler( - "vpc service create test-tcp --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" - ) - ).rejects.toThrow(); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Must specify either TCP options (--tcp-port/--app-protocol) or HTTP options (--http-port/--https-port) - - " - `); - }); - it("should reject service creation with both IP addresses and hostname", async () => { await expect(() => runWrangler( - "vpc service create test-invalid --http-port 80 --ipv4 10.0.0.1 --hostname example.com --resolver-ips=1.1.1.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" + "vpc service create test-invalid --type http --http-port 80 --ipv4 10.0.0.1 --hostname example.com --resolver-ips=1.1.1.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" ) ).rejects.toThrow(); expect(std.err).toMatchInlineSnapshot(` @@ -241,63 +156,34 @@ describe("vpc service commands", () => { `); }); - it("should reject service creation with hostname but no resolver IPs", async () => { - await expect(() => - runWrangler( - "vpc service create test-no-resolvers --http-port 80 --hostname example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440000" - ) - ).rejects.toThrow(); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing dependent arguments: - - hostname -> resolver-ips - - " - `); - }); - - it("should reject TCP service creation with HTTP-specific arguments", async () => { - await expect(() => - runWrangler( - "vpc service create test-mixed --tcp-port 5432 --app-protocol=postgresql --https-port 443 --http-port 80 --ipv4 10.0.0.1 --tunnel-id 550e8400-e29b-41d4-a716-446655440000" - ) - ).rejects.toThrow(); - expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Arguments tcp-port and http-port are mutually exclusive - - " - `); - }); - it("should handle listing services", async () => { mockWvpcServiceList(); await runWrangler("vpc service list"); expect(std.out).toMatchInlineSnapshot(` - "📋 Listing VPC connectivity services + "📋 Listing VPC services ┌─┬─┬─┬─┬─┬─┬─┬─┐ │ id │ name │ type │ ports │ host │ tunnel │ created │ modified │ ├─┼─┼─┼─┼─┼─┼─┼─┤ - │ tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx │ test-tcp-service │ tcp │ 5432 (postgresql) │ 10.0.0.1 │ tunnel-x... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ - ├─┼─┼─┼─┼─┼─┼─┼─┤ - │ http-xxxx-xxxx-xxxx-xxxxxxxxxxxx │ test-web-service │ http │ HTTP:80, HTTPS:443 │ web.example.com │ tunnel-y... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ + │ service-uuid │ test-web-service │ http │ HTTP:80, HTTPS:443 │ web.example.com │ tunnel-y... │ 1/1/2024, 12:00:00 AM │ 1/1/2024, 12:00:00 AM │ └─┴─┴─┴─┴─┴─┴─┴─┘" `); }); it("should handle getting a service", async () => { mockWvpcServiceGetUpdateDelete(); - await runWrangler("vpc service get tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); + await runWrangler("vpc service get service-uuid"); expect(std.out).toMatchInlineSnapshot(` - "🔍 Getting VPC connectivity service 'tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ✅ Retrieved VPC connectivity service: tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx - Name: test-tcp-service - Type: tcp - TCP Port: 5432 - Protocol: postgresql - IPv4: 10.0.0.1 - Tunnel ID: tunnel-xxxx-xxxx-xxxx-xxxxxxxxxxxx + "🔍 Getting VPC service 'service-uuid' + ✅ Retrieved VPC service: service-uuid + Name: test-web-service + Type: http + HTTP Port: 80 + HTTPS Port: 443 + Hostname: web.example.com + 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" `); @@ -305,18 +191,18 @@ describe("vpc service commands", () => { it("should handle deleting a service", async () => { mockWvpcServiceGetUpdateDelete(); - await runWrangler("vpc service delete tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx"); + await runWrangler("vpc service delete service-uuid"); expect(std.out).toMatchInlineSnapshot(` - "🗑️ Deleting VPC connectivity service 'tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - ✅ Deleted VPC connectivity service: tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + "🗑️ Deleting VPC service 'service-uuid' + ✅ Deleted VPC service: service-uuid" `); }); it("should handle updating a service", async () => { const reqProm = mockWvpcServiceUpdate(); await runWrangler( - "vpc service update tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx --name test-updated --http-port 80 --ipv4 10.0.0.2 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" + "vpc service update service-uuid --name test-updated --type http --http-port 80 --ipv4 10.0.0.2 --tunnel-id 550e8400-e29b-41d4-a716-446655440001" ); await expect(reqProm).resolves.toMatchInlineSnapshot(` @@ -333,25 +219,94 @@ describe("vpc service commands", () => { } `); }); -}); -// Mock Data -const mockTcpService: ConnectivityService = { - service_id: "tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - type: ServiceType.Tcp, - name: "test-tcp-service", - tcp_port: 5432, - app_protocol: "postgresql", - host: { - ipv4: "10.0.0.1", - network: { tunnel_id: "tunnel-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }, - }, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", -}; + it("should handle getting a service without resolver_ips", async () => { + const serviceWithoutResolverIps: ConnectivityService = { + ...mockService, + host: { + hostname: "web.example.com", + resolver_network: { + tunnel_id: "tunnel-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + // No resolver_ips property + }, + }, + }; + + msw.use( + http.get( + "*/accounts/:accountId/connectivity/directory/services/:serviceId", + () => { + return HttpResponse.json( + createFetchResult(serviceWithoutResolverIps, true) + ); + }, + { once: true } + ) + ); + + await runWrangler("vpc service get service-uuid"); + + expect(std.out).toMatchInlineSnapshot(` + "🔍 Getting VPC service 'service-uuid' + ✅ Retrieved VPC service: service-uuid + Name: test-web-service + Type: http + HTTP Port: 80 + HTTPS Port: 443 + Hostname: web.example.com + Tunnel ID: tunnel-yyyy-yyyy-yyyy-yyyyyyyyyyyy + Created: 1/1/2024, 12:00:00 AM + Modified: 1/1/2024, 12:00:00 AM" + `); + }); + + it("should handle creating a service and display without resolver_ips", async () => { + const serviceResponse = { + service_id: "service-uuid", + type: "http", + name: "test-no-resolver", + http_port: 80, + https_port: 443, + host: { + hostname: "db.example.com", + resolver_network: { + tunnel_id: "550e8400-e29b-41d4-a716-446655440002", + // No resolver_ips + }, + }, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }; + + msw.use( + http.post( + "*/accounts/:accountId/connectivity/directory/services", + () => { + return HttpResponse.json(createFetchResult(serviceResponse, true)); + }, + { once: true } + ) + ); + + await runWrangler( + "vpc service create test-no-resolver --type http --hostname db.example.com --tunnel-id 550e8400-e29b-41d4-a716-446655440002" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🚧 Creating VPC service 'test-no-resolver' + ✅ Created VPC service: service-uuid + Name: test-no-resolver + Type: http + HTTP Port: 80 + HTTPS Port: 443 + Hostname: db.example.com + Tunnel ID: 550e8400-e29b-41d4-a716-446655440002" + `); + }); +}); -const mockHttpService: ConnectivityService = { - service_id: "http-xxxx-xxxx-xxxx-xxxxxxxxxxxx", +const mockService: ConnectivityService = { + service_id: "service-uuid", type: ServiceType.Http, name: "test-web-service", http_port: 80, @@ -380,7 +335,7 @@ function mockWvpcServiceCreate(): Promise { return HttpResponse.json( createFetchResult( { - service_id: "tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + service_id: "service-uuid", type: reqBody.type, name: reqBody.name, tcp_port: reqBody.tcp_port, @@ -413,7 +368,7 @@ function mockWvpcServiceUpdate(): Promise { return HttpResponse.json( createFetchResult( { - service_id: "tcp-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + service_id: "service-uuid", type: reqBody.type, name: reqBody.name, tcp_port: reqBody.tcp_port, @@ -439,7 +394,7 @@ function mockWvpcServiceGetUpdateDelete() { http.get( "*/accounts/:accountId/connectivity/directory/services/:serviceId", () => { - return HttpResponse.json(createFetchResult(mockTcpService, true)); + return HttpResponse.json(createFetchResult(mockService, true)); }, { once: true } ), @@ -458,9 +413,7 @@ function mockWvpcServiceList() { http.get( "*/accounts/:accountId/connectivity/directory/services", () => { - return HttpResponse.json( - createFetchResult([mockTcpService, mockHttpService], true) - ); + return HttpResponse.json(createFetchResult([mockService], true)); }, { once: true } ) diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index d88e5056a229..ffeb6b1dae42 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -25,6 +25,11 @@ export function pickRemoteBindings( return true; } + if (binding.type === "vpc_service") { + // VPC Service is always remote + return true; + } + return "remote" in binding && binding["remote"]; }) ); diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index f1bb9c3c4809..46c27adc9e50 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -34,6 +34,7 @@ import type { CfTailConsumer, CfUnsafe, CfVectorize, + CfVpcService, CfWorkerLoader, CfWorkflow, } from "../../deployment-bundle/worker"; @@ -308,6 +309,7 @@ export type Binding = | ({ type: "unsafe_hello_world" } & BindingOmit) | ({ type: "ratelimit" } & NameOmit) | ({ type: "worker_loader" } & BindingOmit) + | ({ type: "vpc_service" } & BindingOmit) | { type: `unsafe_${string}` } | { type: "assets" }; diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index 6411471d0440..94db0720deb0 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -293,6 +293,12 @@ export function convertCfWorkerInitBindingsToBindings( } break; } + case "vpc_services": { + for (const { binding, ...x } of info) { + output[binding] = { type: "vpc_service", ...x }; + } + break; + } default: { assertNever(type); } @@ -328,6 +334,7 @@ export async function convertBindingsToCfWorkerInitBindings( hyperdrive: undefined, secrets_store_secrets: undefined, services: undefined, + vpc_services: undefined, analytics_engine_datasets: undefined, dispatch_namespaces: undefined, mtls_certificates: undefined, @@ -433,6 +440,9 @@ export async function convertBindingsToCfWorkerInitBindings( } else if (binding.type === "worker_loader") { bindings.worker_loaders ??= []; bindings.worker_loaders.push({ ...binding, binding: name }); + } else if (binding.type === "vpc_service") { + bindings.vpc_services ??= []; + bindings.vpc_services.push({ ...binding, binding: name }); } else if (isUnsafeBindingType(binding.type)) { bindings.unsafe ??= { bindings: [], diff --git a/packages/wrangler/src/config/config.ts b/packages/wrangler/src/config/config.ts index c89c083d462d..4d0c6b611d04 100644 --- a/packages/wrangler/src/config/config.ts +++ b/packages/wrangler/src/config/config.ts @@ -391,4 +391,5 @@ export const defaultWranglerConfig: Config = { mtls_certificates: [], tail_consumers: undefined, pipelines: [], + vpc_services: [], }; diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index 1858827c44c0..2bcebd8ef794 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -1188,6 +1188,24 @@ export interface EnvironmentNonInheritable { /** The binding name used to refer to the Worker Loader in the Worker. */ binding: string; }[]; + + /** + * Specifies VPC services that are bound to this Worker environment. + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default [] + * @nonInheritable + */ + vpc_services: { + /** The binding name used to refer to the VPC service in the Worker. */ + binding: string; + /** The service ID of the VPC connectivity service. */ + service_id: string; + /** Whether the VPC service is remote or not */ + remote?: boolean; + }[]; } /** diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index da10cba2f107..8869439e322b 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -139,6 +139,7 @@ export type ConfigBindingOptions = Pick< | "mtls_certificates" | "vectorize" | "workflows" + | "vpc_services" >; /** diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index 33b3805e9b8e..1b2393496f44 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -1474,6 +1474,16 @@ function normalizeAndValidateEnvironment( validateBindingArray(envName, validateRateLimitBinding), [] ), + vpc_services: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "vpc_services", + validateBindingArray(envName, validateVpcServiceBinding), + [] + ), version_metadata: notInheritable( diagnostics, topLevelEnv, @@ -2396,6 +2406,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => { "mtls_certificate", "pipeline", "worker-loader", + "vpc_service", ]; if (safeBindings.includes(value.type)) { @@ -3289,6 +3300,43 @@ const validateHyperdriveBinding: ValidatorFn = (diagnostics, field, value) => { return isValid; }; +const validateVpcServiceBinding: ValidatorFn = (diagnostics, field, value) => { + if (typeof value !== "object" || value === null) { + diagnostics.errors.push( + `"vpc_services" bindings should be objects, but got ${JSON.stringify( + value + )}` + ); + return false; + } + let isValid = true; + // VPC service bindings must have a binding and a service_id. + if (!isRequiredProperty(value, "binding", "string")) { + diagnostics.errors.push( + `"${field}" bindings should have a string "binding" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + if (!isRequiredProperty(value, "service_id", "string")) { + diagnostics.errors.push( + `"${field}" bindings must have a "service_id" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + + validateAdditionalProperties(diagnostics, field, Object.keys(value), [ + "binding", + "service_id", + "remote", + ]); + + return isValid; +}; + /** * Check that bindings whose names might conflict, don't. * diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index 7b2e1cd0b377..fe10223533c3 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -77,6 +77,7 @@ export function getBindings( unsafe_hello_world: options?.pages ? undefined : config?.unsafe_hello_world, ratelimits: config?.ratelimits, worker_loaders: config?.worker_loaders, + vpc_services: config?.vpc_services, }; } diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 1403988363cf..940a45ef84e3 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -158,6 +158,7 @@ export type WorkerMetadataBinding = namespace_id: string; simple: { limit: number; period: 10 | 60 }; } + | { type: "vpc_service"; name: string; service_id: string } | { type: "worker-loader"; name: string; @@ -449,6 +450,14 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { }); }); + bindings.vpc_services?.forEach(({ binding, service_id }) => { + metadataBindings.push({ + name: binding, + type: "vpc_service", + service_id, + }); + }); + bindings.services?.forEach( ({ binding, service, environment, entrypoint, props }) => { metadataBindings.push({ diff --git a/packages/wrangler/src/deployment-bundle/worker.ts b/packages/wrangler/src/deployment-bundle/worker.ts index 3b4bd2e014a3..8dc6ac581a1b 100644 --- a/packages/wrangler/src/deployment-bundle/worker.ts +++ b/packages/wrangler/src/deployment-bundle/worker.ts @@ -254,6 +254,12 @@ export interface CfService { remote?: boolean; } +export interface CfVpcService { + binding: string; + service_id: string; + remote?: boolean; +} + export interface CfAnalyticsEngineDataset { binding: string; dataset?: string; @@ -411,6 +417,7 @@ export interface CfWorkerInit { hyperdrive: CfHyperdrive[] | undefined; secrets_store_secrets: CfSecretsStoreSecrets[] | undefined; services: CfService[] | undefined; + vpc_services: CfVpcService[] | undefined; analytics_engine_datasets: CfAnalyticsEngineDataset[] | undefined; dispatch_namespaces: CfDispatchNamespace[] | undefined; mtls_certificates: CfMTlsCertificate[] | undefined; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index ced91fb01ccf..14911a7f5bfa 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -666,6 +666,7 @@ export function getBindings( hyperdrive: hyperdriveBindings, secrets_store_secrets: configParam.secrets_store_secrets, services: mergedServiceBindings, + vpc_services: configParam.vpc_services, analytics_engine_datasets: configParam.analytics_engine_datasets, browser: configParam.browser, ai: args.ai || configParam.ai, diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index fc6b46f5baa2..19b8277e2485 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -415,6 +415,7 @@ type WorkerOptionsBindings = Pick< | "tails" | "browserRendering" | "vectorize" + | "vpcServices" | "dispatchNamespaces" | "mtlsCertificates" | "helloWorld" @@ -796,6 +797,24 @@ export function buildMiniflareBindingOptions( ) : undefined, + vpcServices: + remoteBindingsEnabled && remoteProxyConnectionString + ? Object.fromEntries( + bindings.vpc_services + ?.filter((vpc) => { + warnOrError("vpc_services", vpc.remote, "remote"); + return vpc.remote; + }) + .map((vpc) => [ + vpc.binding, + { + service_id: vpc.service_id, + remoteProxyConnectionString, + }, + ]) ?? [] + ) + : undefined, + dispatchNamespaces: remoteBindingsEnabled && remoteProxyConnectionString ? Object.fromEntries( diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index 76ccce31872b..a03b0d7854a7 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -91,6 +91,7 @@ async function createDraftWorker({ hyperdrive: [], secrets_store_secrets: [], services: [], + vpc_services: [], analytics_engine_datasets: [], wasm_modules: {}, browser: undefined, diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 8b22b2f0bf92..5eabf4138878 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -350,6 +350,7 @@ export async function generateEnvTypes( unsafe_hello_world: config.unsafe_hello_world, ratelimits: config.ratelimits, worker_loaders: config.worker_loaders, + vpc_services: config.vpc_services, }; const entrypointFormat = entrypoint?.format ?? "modules"; @@ -577,6 +578,12 @@ export async function generateEnvTypes( } } + if (configToDTS.vpc_services) { + for (const vpcService of configToDTS.vpc_services) { + envTypeStructure.push([constructTypeKey(vpcService.binding), "Fetcher"]); + } + } + if (configToDTS.mtls_certificates) { for (const mtlsCertificate of configToDTS.mtls_certificates) { envTypeStructure.push([ diff --git a/packages/wrangler/src/utils/map-worker-metadata-bindings.ts b/packages/wrangler/src/utils/map-worker-metadata-bindings.ts index 9ab3fa6158f5..8a7976919b5a 100644 --- a/packages/wrangler/src/utils/map-worker-metadata-bindings.ts +++ b/packages/wrangler/src/utils/map-worker-metadata-bindings.ts @@ -344,6 +344,17 @@ export async function mapWorkerMetadataBindings( ]; } break; + case "vpc_service": + { + configObj.vpc_services = [ + ...(configObj.vpc_services ?? []), + { + binding: binding.name, + service_id: binding.service_id, + }, + ]; + } + break; default: { configObj.unsafe = { bindings: [...(configObj.unsafe?.bindings ?? []), binding], diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index 51f0649dfd6c..56bcc8f6a80d 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -40,6 +40,7 @@ export const friendlyBindingNames: Record< assets: "Assets", unsafe_hello_world: "Hello World", worker_loaders: "Worker Loader", + vpc_services: "VPC Service", } as const; /** @@ -93,6 +94,7 @@ export function printBindings( logfwdr, secrets_store_secrets, services, + vpc_services, analytics_engine_datasets, text_blobs, browser, @@ -310,6 +312,19 @@ export function printBindings( ); } + if (vpc_services !== undefined && vpc_services.length > 0) { + output.push( + ...vpc_services.map(({ binding, service_id }) => { + return { + name: binding, + type: friendlyBindingNames.vpc_services, + value: service_id, + mode: getMode({ isSimulatedLocally: false }), + }; + }) + ); + } + if (r2_buckets !== undefined && r2_buckets.length > 0) { output.push( ...r2_buckets.map(({ binding, bucket_name, jurisdiction, remote }) => { diff --git a/packages/wrangler/src/vpc/create.ts b/packages/wrangler/src/vpc/create.ts index 4b29d8452334..a9ee89d76e39 100644 --- a/packages/wrangler/src/vpc/create.ts +++ b/packages/wrangler/src/vpc/create.ts @@ -6,7 +6,7 @@ import { serviceOptions, ServiceType } from "./index"; export const vpcServiceCreateCommand = createCommand({ metadata: { - description: "Create a new VPC connectivity service", + description: "Create a new VPC service", status: "stable", owner: "Product: WVPC", }, @@ -18,8 +18,7 @@ export const vpcServiceCreateCommand = createCommand({ // Validate arguments - this will throw UserError if validation fails validateRequest({ name: args.name, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, + type: args.type as ServiceType, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -30,12 +29,11 @@ export const vpcServiceCreateCommand = createCommand({ }); }, async handler(args, { config }) { - logger.log(`🚧 Creating VPC connectivity service '${args.name}'`); + logger.log(`🚧 Creating VPC service '${args.name}'`); const request = buildRequest({ name: args.name, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, + type: args.type as ServiceType, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -47,19 +45,12 @@ export const vpcServiceCreateCommand = createCommand({ const service = await createService(config, request); - logger.log(`✅ Created VPC connectivity service: ${service.service_id}`); + logger.log(`✅ Created VPC service: ${service.service_id}`); logger.log(` Name: ${service.name}`); logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Tcp) { - if (service.tcp_port) { - logger.log(` TCP Port: ${service.tcp_port}`); - } - if (service.app_protocol) { - logger.log(` Protocol: ${service.app_protocol}`); - } - } else if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } @@ -84,9 +75,11 @@ export const vpcServiceCreateCommand = createCommand({ logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); } else if (service.host.resolver_network) { logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); + if (service.host.resolver_network.resolver_ips) { + logger.log( + ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` + ); + } } }, }); diff --git a/packages/wrangler/src/vpc/delete.ts b/packages/wrangler/src/vpc/delete.ts index 9bd2f80b6223..b54909bd627d 100644 --- a/packages/wrangler/src/vpc/delete.ts +++ b/packages/wrangler/src/vpc/delete.ts @@ -4,7 +4,7 @@ import { deleteService } from "./client"; export const vpcServiceDeleteCommand = createCommand({ metadata: { - description: "Delete a VPC connectivity service", + description: "Delete a VPC service", status: "stable", owner: "Product: WVPC", }, @@ -12,15 +12,15 @@ export const vpcServiceDeleteCommand = createCommand({ "service-id": { type: "string", demandOption: true, - description: "The ID of the connectivity service to delete", + description: "The ID of the service to delete", }, }, positionalArgs: ["service-id"], async handler(args, { config }) { - logger.log(`🗑️ Deleting VPC connectivity service '${args.serviceId}'`); + logger.log(`🗑️ Deleting VPC service '${args.serviceId}'`); await deleteService(config, args.serviceId); - logger.log(`✅ Deleted VPC connectivity service: ${args.serviceId}`); + logger.log(`✅ Deleted VPC service: ${args.serviceId}`); }, }); diff --git a/packages/wrangler/src/vpc/get.ts b/packages/wrangler/src/vpc/get.ts index eeec762c570c..6a6880c26769 100644 --- a/packages/wrangler/src/vpc/get.ts +++ b/packages/wrangler/src/vpc/get.ts @@ -5,7 +5,7 @@ import { ServiceType } from "./index"; export const vpcServiceGetCommand = createCommand({ metadata: { - description: "Get a VPC connectivity service", + description: "Get a VPC service", status: "stable", owner: "Product: WVPC", }, @@ -13,28 +13,21 @@ export const vpcServiceGetCommand = createCommand({ "service-id": { type: "string", demandOption: true, - description: "The ID of the connectivity service", + description: "The ID of the VPC service", }, }, positionalArgs: ["service-id"], async handler(args, { config }) { - logger.log(`🔍 Getting VPC connectivity service '${args.serviceId}'`); + logger.log(`🔍 Getting VPC service '${args.serviceId}'`); const service = await getService(config, args.serviceId); - logger.log(`✅ Retrieved VPC connectivity service: ${service.service_id}`); + logger.log(`✅ Retrieved VPC service: ${service.service_id}`); logger.log(` Name: ${service.name}`); logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Tcp) { - if (service.tcp_port) { - logger.log(` TCP Port: ${service.tcp_port}`); - } - if (service.app_protocol) { - logger.log(` Protocol: ${service.app_protocol}`); - } - } else if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } @@ -59,9 +52,11 @@ export const vpcServiceGetCommand = createCommand({ logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); } else if (service.host.resolver_network) { logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); + if (service.host.resolver_network.resolver_ips) { + logger.log( + ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` + ); + } } logger.log(` Created: ${new Date(service.created_at).toLocaleString()}`); diff --git a/packages/wrangler/src/vpc/index.ts b/packages/wrangler/src/vpc/index.ts index 9cf988f492c3..26872d4bf40e 100644 --- a/packages/wrangler/src/vpc/index.ts +++ b/packages/wrangler/src/vpc/index.ts @@ -2,22 +2,21 @@ import { createNamespace } from "../core/create-command"; export const vpcNamespace = createNamespace({ metadata: { - description: "🌐 Manage VPC connectivity", - status: "private-beta", + description: "🌐 Manage VPC", + status: "open-beta", owner: "Product: WVPC", }, }); export const vpcServiceNamespace = createNamespace({ metadata: { - description: "🔗 Manage VPC connectivity services", + description: "🔗 Manage VPC services", status: "stable", owner: "Product: WVPC", }, }); export enum ServiceType { - Tcp = "tcp", Http = "http", } @@ -46,7 +45,7 @@ export interface Network { export interface ResolverNetwork { tunnel_id: string; - resolver_ips: string[]; + resolver_ips?: string[]; } export interface ServiceHost { @@ -88,32 +87,25 @@ export const serviceOptions = { name: { type: "string", demandOption: true, - description: "The name of the connectivity service", + group: "Required Configuration", + description: "The name of the VPC service", + }, + type: { + type: "string", + demandOption: true, + choices: ["http"], + group: "Required Configuration", + description: "The type of the VPC service", }, "http-port": { type: "number", - description: "HTTP port", - conflicts: ["tcp-port", "app-protocol"], - group: "HTTP Options", + description: "HTTP port (default: 80)", + group: "Port Configuration", }, "https-port": { type: "number", - description: "HTTPS port number", - conflicts: ["tcp-port", "app-protocol"], - group: "HTTP Options", - }, - "tcp-port": { - type: "number", - conflicts: ["http-port", "https-port"], - description: "TCP port number", - group: "TCP Options", - }, - "app-protocol": { - type: "string", - implies: ["tcp-port"], - conflicts: ["http-port", "https-port"], - description: "Application protocol (e.g., postgresql, mysql)", - group: "TCP Options", + description: "HTTPS port number (default: 443)", + group: "Port Configuration", }, ipv4: { type: "string", @@ -130,7 +122,6 @@ export const serviceOptions = { hostname: { type: "string", description: "Hostname for the host", - implies: ["resolver-ips"], conflicts: ["ipv4", "ipv6"], group: "Hostname Configuration [conflicts with --ipv4, --ipv6]", }, @@ -144,6 +135,7 @@ export const serviceOptions = { "tunnel-id": { type: "string", demandOption: true, + group: "Required Configuration", description: "UUID of the Cloudflare tunnel", }, } as const; diff --git a/packages/wrangler/src/vpc/list.ts b/packages/wrangler/src/vpc/list.ts index 53bb7856de82..031e4c60b2ee 100644 --- a/packages/wrangler/src/vpc/list.ts +++ b/packages/wrangler/src/vpc/list.ts @@ -5,18 +5,18 @@ import { formatServiceForTable } from "./shared"; export const vpcServiceListCommand = createCommand({ metadata: { - description: "List VPC connectivity services", + description: "List VPC services", status: "stable", owner: "Product: WVPC", }, args: {}, async handler(args, { config }) { - logger.log(`📋 Listing VPC connectivity services`); + logger.log(`📋 Listing VPC services`); const services = await listServices(config); if (services.length === 0) { - logger.log("No VPC connectivity services found"); + logger.log("No VPC services found"); return; } diff --git a/packages/wrangler/src/vpc/shared.ts b/packages/wrangler/src/vpc/shared.ts index 4426b680797f..d0163acb6c4e 100644 --- a/packages/wrangler/src/vpc/shared.ts +++ b/packages/wrangler/src/vpc/shared.ts @@ -3,12 +3,7 @@ import type { ConnectivityService } from "./index"; export function formatServiceForTable(service: ConnectivityService) { // Build port info based on service type let ports = ""; - if (service.type === "tcp" && service.tcp_port) { - ports = `${service.tcp_port}`; - if (service.app_protocol) { - ports += ` (${service.app_protocol})`; - } - } else if (service.type === "http") { + if (service.type === "http") { const httpPorts = []; if (service.http_port) { httpPorts.push(`HTTP:${service.http_port}`); diff --git a/packages/wrangler/src/vpc/update.ts b/packages/wrangler/src/vpc/update.ts index 115dd66dec71..c0817addbcb4 100644 --- a/packages/wrangler/src/vpc/update.ts +++ b/packages/wrangler/src/vpc/update.ts @@ -6,7 +6,7 @@ import { serviceOptions, ServiceType } from "./index"; export const vpcServiceUpdateCommand = createCommand({ metadata: { - description: "Update a VPC connectivity service", + description: "Update a VPC service", status: "stable", owner: "Product: WVPC", }, @@ -14,7 +14,7 @@ export const vpcServiceUpdateCommand = createCommand({ "service-id": { type: "string", demandOption: true, - description: "The ID of the connectivity service to update", + description: "The ID of the VPC service to update", }, ...serviceOptions, }, @@ -23,8 +23,7 @@ export const vpcServiceUpdateCommand = createCommand({ // Validate arguments - this will throw UserError if validation fails validateRequest({ name: args.name, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, + type: args.type as ServiceType, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -35,12 +34,11 @@ export const vpcServiceUpdateCommand = createCommand({ }); }, async handler(args, { config }) { - logger.log(`🚧 Updating VPC connectivity service '${args.serviceId}'`); + logger.log(`🚧 Updating VPC service '${args.serviceId}'`); const request = buildRequest({ name: args.name, - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, + type: args.type as ServiceType, httpPort: args.httpPort, httpsPort: args.httpsPort, ipv4: args.ipv4, @@ -52,19 +50,12 @@ export const vpcServiceUpdateCommand = createCommand({ const service = await updateService(config, args.serviceId, request); - logger.log(`✅ Updated VPC connectivity service: ${service.service_id}`); + logger.log(`✅ Updated VPC service: ${service.service_id}`); logger.log(` Name: ${service.name}`); logger.log(` Type: ${service.type}`); // Display service-specific details - if (service.type === ServiceType.Tcp) { - if (service.tcp_port) { - logger.log(` TCP Port: ${service.tcp_port}`); - } - if (service.app_protocol) { - logger.log(` Protocol: ${service.app_protocol}`); - } - } else if (service.type === ServiceType.Http) { + if (service.type === ServiceType.Http) { if (service.http_port) { logger.log(` HTTP Port: ${service.http_port}`); } @@ -89,9 +80,11 @@ export const vpcServiceUpdateCommand = createCommand({ logger.log(` Tunnel ID: ${service.host.network.tunnel_id}`); } else if (service.host.resolver_network) { logger.log(` Tunnel ID: ${service.host.resolver_network.tunnel_id}`); - logger.log( - ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` - ); + if (service.host.resolver_network.resolver_ips) { + logger.log( + ` Resolver IPs: ${service.host.resolver_network.resolver_ips.join(", ")}` + ); + } } }, }); diff --git a/packages/wrangler/src/vpc/validation.ts b/packages/wrangler/src/vpc/validation.ts index 800e926a63f5..7aa82e06545a 100644 --- a/packages/wrangler/src/vpc/validation.ts +++ b/packages/wrangler/src/vpc/validation.ts @@ -1,16 +1,10 @@ import { UserError } from "../errors"; import { ServiceType } from "./index"; -import type { - ConnectivityServiceRequest, - ServiceHost, - ServicePortOptions, -} from "./index"; +import type { ConnectivityServiceRequest, ServiceHost } from "./index"; export interface ServiceArgs { name: string; - type?: ServiceType; - tcpPort?: number; - appProtocol?: string; + type: ServiceType; httpPort?: number; httpsPort?: number; ipv4?: string; @@ -20,19 +14,6 @@ export interface ServiceArgs { resolverIps?: string; } -function inferServiceType(options: ServicePortOptions): ServiceType { - const hasTcpOptions = Boolean(options.tcpPort || options.appProtocol); - const hasHttpOptions = Boolean(options.httpPort || options.httpsPort); - - if (!hasTcpOptions && !hasHttpOptions) { - throw new Error( - "Must specify either TCP options (--tcp-port/--app-protocol) or HTTP options (--http-port/--https-port)" - ); - } - - return hasTcpOptions ? ServiceType.Tcp : ServiceType.Http; -} - export function validateRequest(args: ServiceArgs) { // Validate host configuration - must have either IP addresses or hostname, not both const hasIpAddresses = Boolean(args.ipv4 || args.ipv6); @@ -47,7 +28,7 @@ export function validateRequest(args: ServiceArgs) { export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { // Parse resolver IPs if provided - let resolverIpsList: string[] = []; + let resolverIpsList: string[] | undefined = undefined; if (args.resolverIps) { resolverIpsList = args.resolverIps.split(",").map((ip) => ip.trim()); } @@ -62,7 +43,7 @@ export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { if (args.hostname) { host.resolver_network = { tunnel_id: args.tunnelId, - resolver_ips: resolverIpsList, + ...(resolverIpsList && { resolver_ips: resolverIpsList }), }; } else { host.network = { @@ -70,33 +51,17 @@ export function buildRequest(args: ServiceArgs): ConnectivityServiceRequest { }; } - const serviceType = inferServiceType({ - tcpPort: args.tcpPort, - appProtocol: args.appProtocol, - httpPort: args.httpPort, - httpsPort: args.httpsPort, - }); - // Build the complete request const request: ConnectivityServiceRequest = { name: args.name, - type: serviceType, + type: args.type, host, }; // Add service-specific fields - if (serviceType == ServiceType.Tcp) { - request.tcp_port = args.tcpPort; - if (args.appProtocol) { - request.app_protocol = args.appProtocol; - } - } else if (serviceType == ServiceType.Http) { - if (args.httpPort) { - request.http_port = args.httpPort; - } - if (args.httpsPort) { - request.https_port = args.httpsPort; - } + if (args.type === ServiceType.Http) { + request.http_port = args.httpPort; + request.https_port = args.httpsPort; } return request;