Skip to content

Commit f1b74ea

Browse files
authored
Implement Cloudflare cloud adapter (#747)
* Implement Cloudflare cloud adapter * Address Cloudflare adapter review feedback * Fix Cloudflare review follow-ups
1 parent 5138990 commit f1b74ea

4 files changed

Lines changed: 796 additions & 44 deletions

File tree

packages/cloud/cloudflare/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
# Cloudflare (Workers / R2 / D1 / Queues)
1+
# Cloudflare (R2 / D1 / Queues / Tunnels)
22

3-
Provides the Cloudflare (Workers / R2 / D1 / Queues) cloud provider adapter for sh1pt scale and deploy workflows.
3+
Provides the Cloudflare (R2 / D1 / Queues / Tunnels) cloud provider adapter for sh1pt scale and deploy workflows.
44

55
## What it does
66

7-
- Connects cloud provider credentials and project settings.
8-
- Supports infrastructure planning, deployment, or status workflows where implemented.
9-
- Includes a connection flow for account or credential setup.
10-
- Includes setup guidance for required credentials or provider configuration.
7+
- Connects with `CLOUDFLARE_API_TOKEN` and an optional `accountId`.
8+
- Quotes R2 storage using the per-GB monthly storage rate and reports zero-dollar base quotes for usage-priced D1, Queues, and Tunnels.
9+
- Provisions, lists, checks status, and destroys R2 buckets, D1 databases, Queues, and Cloudflare Tunnels through the Cloudflare REST API.
10+
- Leaves Worker script deployment to the `deploy-workers` target.
11+
12+
Set `resourceType` to one of `r2-bucket`, `d1-database`, `queue`, or `tunnel` when provisioning a specific resource. Without `resourceType`, `object-storage` specs create R2 buckets and `managed-db` specs create D1 databases.
13+
14+
Tunnel provisioning requires `tunnelSecret` in the Cloudflare cloud config. The adapter sends that caller-owned secret to Cloudflare and returns Cloudflare's `tunnel_token` in the provisioned instance metadata when the API provides it, so callers can hand the token to `cloudflared`.
1115

1216
## Package
1317

Lines changed: 244 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,246 @@
1-
import { smokeTest } from '@profullstack/sh1pt-core/testing';
1+
import { contractTestCloud } from '@profullstack/sh1pt-core/testing';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import adapter from './index.js';
34

4-
smokeTest(adapter, { idPrefix: 'cloud', requireSupports: true });
5+
const API = 'https://api.cloudflare.com/client/v4';
6+
7+
afterEach(() => {
8+
vi.unstubAllGlobals();
9+
});
10+
11+
describe('Cloudflare cloud adapter', () => {
12+
it('connects to a configured account', async () => {
13+
const fetchMock = vi.fn(async () => ok({ id: 'acct-1', name: 'Example' }));
14+
vi.stubGlobal('fetch', fetchMock);
15+
16+
await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' })).resolves.toEqual({ accountId: 'acct-1' });
17+
expect(fetchMock).toHaveBeenCalledWith(`${API}/accounts/acct-1`, expect.objectContaining({
18+
method: 'GET',
19+
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
20+
}));
21+
});
22+
23+
it('discovers the first accessible account when accountId is omitted', async () => {
24+
vi.stubGlobal('fetch', vi.fn(async () => ok([{ id: 'acct-2', name: 'First' }])));
25+
26+
await expect(adapter.connect(connectCtx(), {})).resolves.toEqual({ accountId: 'acct-2' });
27+
});
28+
29+
it('paginates account discovery when accountId is omitted', async () => {
30+
const fetchMock = vi.fn(async (url: string) => {
31+
const { searchParams } = new URL(url);
32+
if (searchParams.get('page') === '1') return ok([], { total_pages: 2 });
33+
if (searchParams.get('page') === '2') return ok([{ id: 'acct-2', name: 'Second page' }], { total_pages: 2 });
34+
throw new Error(`unexpected url ${url}`);
35+
});
36+
vi.stubGlobal('fetch', fetchMock);
37+
38+
await expect(adapter.connect(connectCtx(), {})).resolves.toEqual({ accountId: 'acct-2' });
39+
expect(fetchMock).toHaveBeenCalledTimes(2);
40+
});
41+
42+
it('creates an R2 bucket', async () => {
43+
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
44+
expect(url).toBe(`${API}/accounts/acct-1/r2/buckets`);
45+
expect(init.method).toBe('POST');
46+
expect(JSON.parse(String(init.body))).toEqual({ name: 'assets' });
47+
return ok({ name: 'assets', creation_date: '2026-06-14T00:00:00Z', location: 'WNAM' });
48+
});
49+
vi.stubGlobal('fetch', fetchMock);
50+
51+
const instance = await adapter.provision(
52+
provisionCtx(),
53+
{ kind: 'object-storage', storage: 10, region: 'auto' },
54+
{ accountId: 'acct-1', resourceType: 'r2-bucket', name: 'assets' },
55+
);
56+
57+
expect(instance).toMatchObject({
58+
id: 'r2:assets',
59+
kind: 'object-storage',
60+
status: 'running',
61+
sku: 'r2-bucket',
62+
region: 'WNAM',
63+
});
64+
});
65+
66+
it('creates a D1 database with a location hint', async () => {
67+
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
68+
expect(url).toBe(`${API}/accounts/acct-1/d1/database`);
69+
expect(init.method).toBe('POST');
70+
expect(JSON.parse(String(init.body))).toEqual({ name: 'main-db', primary_location_hint: 'weur' });
71+
return ok({ uuid: 'db-1', name: 'main-db', created_at: '2026-06-14T00:00:00Z' });
72+
});
73+
vi.stubGlobal('fetch', fetchMock);
74+
75+
const instance = await adapter.provision(
76+
provisionCtx(),
77+
{ kind: 'managed-db', region: 'weur' },
78+
{ accountId: 'acct-1', name: 'main-db' },
79+
);
80+
81+
expect(instance).toMatchObject({
82+
id: 'd1:db-1',
83+
kind: 'managed-db',
84+
status: 'running',
85+
sku: 'd1-database',
86+
region: 'weur',
87+
});
88+
});
89+
90+
it('does not call the API in dry-run provision or destroy', async () => {
91+
const fetchMock = vi.fn();
92+
vi.stubGlobal('fetch', fetchMock);
93+
94+
const instance = await adapter.provision(
95+
provisionCtx(true),
96+
{ kind: 'object-storage', storage: 10 },
97+
{ accountId: 'acct-1', name: 'assets' },
98+
);
99+
await adapter.destroy(provisionCtx(true), 'r2:assets', { accountId: 'acct-1' });
100+
101+
expect(instance.id).toBe('r2:dry-run-assets');
102+
expect(fetchMock).not.toHaveBeenCalled();
103+
});
104+
105+
it('lists supported Cloudflare resources, including nested R2 bucket responses', async () => {
106+
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
107+
const { pathname } = new URL(url);
108+
if (pathname.endsWith('/r2/buckets')) return ok({ buckets: [{ name: 'assets', creation_date: '2026-06-14T00:00:00Z' }] });
109+
if (pathname.endsWith('/d1/database')) return ok([{ uuid: 'db-1', name: 'main', created_at: '2026-06-14T00:00:00Z' }]);
110+
if (pathname.endsWith('/queues')) return ok([{ queue_id: 'queue-1', queue_name: 'jobs', created_on: '2026-06-14T00:00:00Z' }]);
111+
if (pathname.endsWith('/cfd_tunnel')) return ok([{ id: 'tun-1', name: 'edge', status: 'healthy', created_at: '2026-06-14T00:00:00Z' }]);
112+
throw new Error(`unexpected url ${url}`);
113+
}));
114+
115+
const instances = await adapter.list(connectCtx(), { accountId: 'acct-1' });
116+
117+
expect(instances.map((instance) => instance.id).sort()).toEqual([
118+
'd1:db-1',
119+
'queue:queue-1',
120+
'r2:assets',
121+
'tunnel:tun-1',
122+
]);
123+
expect(instances.find((instance) => instance.id === 'queue:queue-1')?.kind).toBe('object-storage');
124+
expect(instances.find((instance) => instance.id === 'tunnel:tun-1')?.kind).toBe('object-storage');
125+
expect(instances.find((instance) => instance.id === 'tunnel:tun-1')?.status).toBe('running');
126+
});
127+
128+
it('checks status using the prefixed resource id', async () => {
129+
const fetchMock = vi.fn(async (url: string) => {
130+
expect(url).toBe(`${API}/accounts/acct-1/queues/queue-1`);
131+
return ok({ queue_id: 'queue-1', queue_name: 'jobs', created_on: '2026-06-14T00:00:00Z' });
132+
});
133+
vi.stubGlobal('fetch', fetchMock);
134+
135+
const instance = await adapter.status(connectCtx(), 'queue:queue-1', { accountId: 'acct-1' });
136+
137+
expect(instance).toMatchObject({ id: 'queue:queue-1', status: 'running', sku: 'queue' });
138+
});
139+
140+
it('requires a caller-supplied tunnel secret when creating a tunnel', async () => {
141+
const fetchMock = vi.fn();
142+
vi.stubGlobal('fetch', fetchMock);
143+
144+
await expect(adapter.provision(
145+
provisionCtx(),
146+
{ kind: 'object-storage', region: 'auto' },
147+
{ accountId: 'acct-1', resourceType: 'tunnel', name: 'edge' },
148+
)).rejects.toThrow('Cloudflare tunnel provisioning requires config.tunnelSecret');
149+
expect(fetchMock).not.toHaveBeenCalled();
150+
});
151+
152+
it('creates a tunnel with a caller-supplied tunnel secret', async () => {
153+
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
154+
expect(url).toBe(`${API}/accounts/acct-1/cfd_tunnel`);
155+
expect(init.method).toBe('POST');
156+
expect(JSON.parse(String(init.body))).toEqual({
157+
name: 'edge',
158+
config_src: 'cloudflare',
159+
tunnel_secret: 'known-secret',
160+
});
161+
return ok({
162+
id: 'tun-1',
163+
name: 'edge',
164+
status: 'healthy',
165+
tunnel_token: 'cloudflared-token',
166+
created_at: '2026-06-14T00:00:00Z',
167+
});
168+
});
169+
vi.stubGlobal('fetch', fetchMock);
170+
171+
const instance = await adapter.provision(
172+
provisionCtx(),
173+
{ kind: 'managed-db', region: 'auto' },
174+
{ accountId: 'acct-1', resourceType: 'tunnel', name: 'edge', tunnelSecret: 'known-secret' },
175+
);
176+
177+
expect(instance).toMatchObject({
178+
id: 'tunnel:tun-1',
179+
kind: 'object-storage',
180+
status: 'running',
181+
sku: 'tunnel',
182+
metadata: { cloudflareTunnelToken: 'cloudflared-token' },
183+
});
184+
});
185+
186+
it('deletes the prefixed resource id', async () => {
187+
const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
188+
expect(url).toBe(`${API}/accounts/acct-1/cfd_tunnel/tun-1`);
189+
expect(init.method).toBe('DELETE');
190+
return ok({ id: 'tun-1' });
191+
});
192+
vi.stubGlobal('fetch', fetchMock);
193+
194+
await adapter.destroy(provisionCtx(), 'tunnel:tun-1', { accountId: 'acct-1' });
195+
196+
expect(fetchMock).toHaveBeenCalledTimes(1);
197+
});
198+
199+
it('reports Cloudflare API errors', async () => {
200+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
201+
success: false,
202+
errors: [{ code: 10000, message: 'Authentication error' }],
203+
result: null,
204+
}))));
205+
206+
await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' }))
207+
.rejects.toThrow('Cloudflare GET /accounts/acct-1 failed: Authentication error');
208+
});
209+
210+
it('reports non-JSON error responses without masking the provider response', async () => {
211+
vi.stubGlobal('fetch', vi.fn(async () => new Response('maintenance', { status: 503, statusText: 'Service Unavailable' })));
212+
213+
await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' }))
214+
.rejects.toThrow('Cloudflare GET /accounts/acct-1 failed: 503 maintenance');
215+
});
216+
});
217+
218+
contractTestCloud(adapter, {
219+
sampleConfig: { accountId: 'acct-1', resourceType: 'r2-bucket', name: 'assets' },
220+
sampleSpec: { kind: 'object-storage', storage: 10, region: 'auto' },
221+
requiredSecrets: ['CLOUDFLARE_API_TOKEN'],
222+
});
223+
224+
function connectCtx() {
225+
return {
226+
secret: (key: string) => key === 'CLOUDFLARE_API_TOKEN' ? 'test-token' : undefined,
227+
log: vi.fn(),
228+
};
229+
}
230+
231+
function provisionCtx(dryRun = false) {
232+
return {
233+
...connectCtx(),
234+
dryRun,
235+
};
236+
}
237+
238+
function ok(result: unknown, resultInfo?: unknown) {
239+
return new Response(JSON.stringify({
240+
success: true,
241+
errors: [],
242+
messages: [],
243+
result,
244+
result_info: resultInfo,
245+
}));
246+
}

0 commit comments

Comments
 (0)