Skip to content

Commit 555a6da

Browse files
Add VPC Services bindings (#10647)
* Add VPC Services bindings: `"vpc_services": [{ "binding": "MYAPI", "service_id": "0199295b-b3ac-7760-8246-bca40877b3e9" }]` * add vpc_services to convertBindingsToCfWorkerInitBindings * simplified a few things for vpc service crud: - removed connectivity from the name - made `type` option required - default ports for http/https - removed tcp service type - made resolver-ips optional * add vpc services support to wrangler dev mixed mode * add remote?: boolean to vpc_services binding * handle when resolver_ips are empty * update VPC type generation test * remove setting default ports from wrangler * e2e test for VPC Service binding * rename connectivity_service_binding to vpc_service * disable e2e until account check is removed next week * fix formatting * don't send empty resolver_ips array when not specified by user --------- Co-authored-by: Peter Bacon Darwin <[email protected]>
1 parent b4a4311 commit 555a6da

File tree

36 files changed

+758
-307
lines changed

36 files changed

+758
-307
lines changed

.changeset/clever-snails-give.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"miniflare": minor
3+
"wrangler": minor
4+
---
5+
6+
VPC service binding support

packages/miniflare/src/plugins/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { R2_PLUGIN, R2_PLUGIN_NAME } from "./r2";
3131
import { RATELIMIT_PLUGIN, RATELIMIT_PLUGIN_NAME } from "./ratelimit";
3232
import { SECRET_STORE_PLUGIN, SECRET_STORE_PLUGIN_NAME } from "./secret-store";
3333
import { VECTORIZE_PLUGIN, VECTORIZE_PLUGIN_NAME } from "./vectorize";
34+
import { VPC_SERVICES_PLUGIN, VPC_SERVICES_PLUGIN_NAME } from "./vpc-services";
3435
import {
3536
WORKER_LOADER_PLUGIN,
3637
WORKER_LOADER_PLUGIN_NAME,
@@ -58,6 +59,7 @@ export const PLUGINS = {
5859
[DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN,
5960
[IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN,
6061
[VECTORIZE_PLUGIN_NAME]: VECTORIZE_PLUGIN,
62+
[VPC_SERVICES_PLUGIN_NAME]: VPC_SERVICES_PLUGIN,
6163
[MTLS_PLUGIN_NAME]: MTLS_PLUGIN,
6264
[HELLO_WORLD_PLUGIN_NAME]: HELLO_WORLD_PLUGIN,
6365
[WORKER_LOADER_PLUGIN_NAME]: WORKER_LOADER_PLUGIN,
@@ -119,6 +121,7 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
119121
z.input<typeof DISPATCH_NAMESPACE_PLUGIN.options> &
120122
z.input<typeof IMAGES_PLUGIN.options> &
121123
z.input<typeof VECTORIZE_PLUGIN.options> &
124+
z.input<typeof VPC_SERVICES_PLUGIN.options> &
122125
z.input<typeof MTLS_PLUGIN.options> &
123126
z.input<typeof HELLO_WORLD_PLUGIN.options> &
124127
z.input<typeof WORKER_LOADER_PLUGIN.options>;
@@ -190,6 +193,7 @@ export * from "./browser-rendering";
190193
export * from "./dispatch-namespace";
191194
export * from "./images";
192195
export * from "./vectorize";
196+
export * from "./vpc-services";
193197
export * from "./mtls";
194198
export * from "./hello-world";
195199
export * from "./worker-loader";
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import assert from "node:assert";
2+
import { z } from "zod";
3+
import {
4+
getUserBindingServiceName,
5+
Plugin,
6+
ProxyNodeBinding,
7+
remoteProxyClientWorker,
8+
RemoteProxyConnectionString,
9+
} from "../shared";
10+
11+
const VpcServicesSchema = z.object({
12+
service_id: z.string(),
13+
remoteProxyConnectionString: z.custom<RemoteProxyConnectionString>(),
14+
});
15+
16+
export const VpcServicesOptionsSchema = z.object({
17+
vpcServices: z.record(VpcServicesSchema).optional(),
18+
});
19+
20+
export const VPC_SERVICES_PLUGIN_NAME = "vpc-services";
21+
22+
export const VPC_SERVICES_PLUGIN: Plugin<typeof VpcServicesOptionsSchema> = {
23+
options: VpcServicesOptionsSchema,
24+
async getBindings(options) {
25+
if (!options.vpcServices) {
26+
return [];
27+
}
28+
29+
return Object.entries(options.vpcServices).map(
30+
([name, { service_id, remoteProxyConnectionString }]) => {
31+
assert(
32+
remoteProxyConnectionString,
33+
"VPC Services only supports running remotely"
34+
);
35+
36+
return {
37+
name,
38+
39+
service: {
40+
name: getUserBindingServiceName(
41+
VPC_SERVICES_PLUGIN_NAME,
42+
service_id,
43+
remoteProxyConnectionString
44+
),
45+
},
46+
};
47+
}
48+
);
49+
},
50+
getNodeBindings(options: z.infer<typeof VpcServicesOptionsSchema>) {
51+
if (!options.vpcServices) {
52+
return {};
53+
}
54+
return Object.fromEntries(
55+
Object.keys(options.vpcServices).map((name) => [
56+
name,
57+
new ProxyNodeBinding(),
58+
])
59+
);
60+
},
61+
async getServices({ options }) {
62+
if (!options.vpcServices) {
63+
return [];
64+
}
65+
66+
return Object.entries(options.vpcServices).map(
67+
([name, { service_id, remoteProxyConnectionString }]) => {
68+
assert(
69+
remoteProxyConnectionString,
70+
"VPC Services only supports running remotely"
71+
);
72+
73+
return {
74+
name: getUserBindingServiceName(
75+
VPC_SERVICES_PLUGIN_NAME,
76+
service_id,
77+
remoteProxyConnectionString
78+
),
79+
worker: remoteProxyClientWorker(remoteProxyConnectionString, name),
80+
};
81+
}
82+
);
83+
},
84+
};

packages/wrangler/e2e/helpers/e2e-wrangler-test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,45 @@ export class WranglerE2ETestHelper {
290290
return { deployedUrl, stdout, cleanup };
291291
}
292292
}
293+
294+
async tunnel(): Promise<string> {
295+
const Cloudflare = (await import("cloudflare")).default;
296+
297+
const name = generateResourceName("tunnel");
298+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
299+
if (!accountId) {
300+
throw new Error("CLOUDFLARE_ACCOUNT_ID environment variable is required");
301+
}
302+
303+
// Create Cloudflare client directly
304+
const client = new Cloudflare({
305+
apiToken: process.env.CLOUDFLARE_API_TOKEN,
306+
});
307+
308+
// Create tunnel via Cloudflare SDK
309+
const tunnel = await client.zeroTrust.tunnels.cloudflared.create({
310+
account_id: accountId,
311+
name,
312+
config_src: "cloudflare",
313+
});
314+
315+
if (!tunnel.id) {
316+
throw new Error("Failed to create tunnel: tunnel ID is undefined");
317+
}
318+
319+
const tunnelId = tunnel.id;
320+
321+
onTestFinished(async () => {
322+
try {
323+
await client.zeroTrust.tunnels.cloudflared.delete(tunnelId, {
324+
account_id: accountId,
325+
});
326+
} catch (error) {
327+
// Ignore deletion errors in cleanup
328+
console.warn(`Failed to delete tunnel ${tunnelId}:`, error);
329+
}
330+
});
331+
332+
return tunnelId;
333+
}
293334
}

packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,58 @@ const testCases: TestCase<string>[] = [
379379
),
380380
],
381381
},
382+
/* {
383+
// Enable post announcement
384+
name: "VPC Service",
385+
scriptPath: "vpc-service.js",
386+
setup: async (helper) => {
387+
const serviceName = generateResourceName();
388+
389+
// Create a real Cloudflare tunnel for testing
390+
const tunnelId = await helper.tunnel();
391+
392+
const output = await helper.run(
393+
`wrangler vpc service create ${serviceName} --type http --ipv4 10.0.0.1 --http-port 8080 --tunnel-id ${tunnelId}`
394+
);
395+
396+
// Extract service_id from output
397+
const match = output.stdout.match(
398+
/Created VPC service:\s+(?<serviceId>[\w-]+)/
399+
);
400+
const serviceId = match?.groups?.serviceId;
401+
assert(
402+
serviceId,
403+
"Failed to extract service ID from VPC service creation output"
404+
);
405+
406+
onTestFinished(async () => {
407+
await helper.run(`wrangler vpc service delete ${serviceId}`);
408+
});
409+
410+
return serviceId;
411+
},
412+
remoteProxySessionConfig: (serviceId) => [
413+
{
414+
VPC_SERVICE: {
415+
type: "vpc_service",
416+
service_id: serviceId,
417+
},
418+
},
419+
],
420+
miniflareConfig: (connection, serviceId) => ({
421+
vpcServices: {
422+
VPC_SERVICE: {
423+
service_id: serviceId,
424+
remoteProxyConnectionString: connection,
425+
},
426+
},
427+
}),
428+
matches: [
429+
// Since we're using a real tunnel but no actual network connectivity, Iris will report back an error
430+
// but this is considered an effective test for wrangler and vpc service bindings
431+
expect.stringMatching(/CONNECT failed: 503 Service Unavailable/),
432+
],
433+
}, */
382434
];
383435

384436
const mtlsTest: TestCase<{ certificateId: string; workerName: string }> = {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
async fetch(request, env, ctx) {
3+
const response = await env.VPC_SERVICE.fetch("http://10.0.0.1:8080/");
4+
return new Response(await response.text());
5+
},
6+
};

packages/wrangler/src/__tests__/api/startDevWorker/utils.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
7474
class_name: "MyWorkflow",
7575
},
7676
],
77+
vpc_services: [
78+
{
79+
binding: "MY_VPC_SERVICE",
80+
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
81+
},
82+
],
7783
});
7884
expect(result).toEqual({
7985
AI: {
@@ -125,6 +131,10 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
125131
name: "workflow",
126132
type: "workflow",
127133
},
134+
MY_VPC_SERVICE: {
135+
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
136+
type: "vpc_service",
137+
},
128138
});
129139
});
130140

@@ -165,6 +175,7 @@ describe("convertConfigBindingsToStartWorkerBindings", () => {
165175
],
166176
mtls_certificates: [],
167177
workflows: [],
178+
vpc_services: [],
168179
});
169180

170181
assert(result);

packages/wrangler/src/__tests__/config/configuration.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe("normalizeAndValidateConfig()", () => {
108108
secrets_store_secrets: [],
109109
unsafe_hello_world: [],
110110
ratelimits: [],
111+
vpc_services: [],
111112
services: [],
112113
analytics_engine_datasets: [],
113114
route: undefined,
@@ -4281,6 +4282,69 @@ describe("normalizeAndValidateConfig()", () => {
42814282
});
42824283
});
42834284

4285+
describe("[vpc_services]", () => {
4286+
it("should accept valid bindings", () => {
4287+
const { config, diagnostics } = normalizeAndValidateConfig(
4288+
{
4289+
vpc_services: [
4290+
{
4291+
binding: "MYAPI",
4292+
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
4293+
},
4294+
{
4295+
binding: "DATABASE",
4296+
service_id: "0299295b-b3ac-7760-8246-bca40877b3e0",
4297+
},
4298+
],
4299+
} as RawConfig,
4300+
undefined,
4301+
undefined,
4302+
{ env: undefined }
4303+
);
4304+
4305+
expect(config.vpc_services).toEqual([
4306+
{
4307+
binding: "MYAPI",
4308+
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
4309+
},
4310+
{
4311+
binding: "DATABASE",
4312+
service_id: "0299295b-b3ac-7760-8246-bca40877b3e0",
4313+
},
4314+
]);
4315+
expect(diagnostics.hasErrors()).toBe(false);
4316+
});
4317+
4318+
it("should error if vpc_services bindings are not valid", () => {
4319+
const { diagnostics } = normalizeAndValidateConfig(
4320+
{
4321+
vpc_services: [
4322+
{},
4323+
{
4324+
binding: "VALID",
4325+
service_id: "0199295b-b3ac-7760-8246-bca40877b3e9",
4326+
},
4327+
{ binding: null, service_id: 123, invalid: true },
4328+
{ binding: "MISSING_SERVICE_ID" },
4329+
],
4330+
} as unknown as RawConfig,
4331+
undefined,
4332+
undefined,
4333+
{ env: undefined }
4334+
);
4335+
4336+
expect(diagnostics.hasErrors()).toBe(true);
4337+
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
4338+
"Processing wrangler configuration:
4339+
- \\"vpc_services[0]\\" bindings should have a string \\"binding\\" field but got {}.
4340+
- \\"vpc_services[0]\\" bindings must have a \\"service_id\\" field but got {}.
4341+
- \\"vpc_services[2]\\" bindings should have a string \\"binding\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}.
4342+
- \\"vpc_services[2]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":null,\\"service_id\\":123,\\"invalid\\":true}.
4343+
- \\"vpc_services[3]\\" bindings must have a \\"service_id\\" field but got {\\"binding\\":\\"MISSING_SERVICE_ID\\"}."
4344+
`);
4345+
});
4346+
});
4347+
42844348
describe("[unsafe.bindings]", () => {
42854349
it("should error if unsafe is an array", () => {
42864350
const { diagnostics } = normalizeAndValidateConfig(

0 commit comments

Comments
 (0)