From 278974160f49935eb0502401e75967a9c10bd3e0 Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 09:41:22 +0800 Subject: [PATCH 1/3] Implement Cloudflare cloud adapter --- packages/cloud/cloudflare/README.md | 14 +- packages/cloud/cloudflare/src/index.test.ts | 185 ++++++- packages/cloud/cloudflare/src/index.ts | 562 ++++++++++++++++++-- 3 files changed, 717 insertions(+), 44 deletions(-) diff --git a/packages/cloud/cloudflare/README.md b/packages/cloud/cloudflare/README.md index df10cf8e..e5d70d03 100644 --- a/packages/cloud/cloudflare/README.md +++ b/packages/cloud/cloudflare/README.md @@ -1,13 +1,15 @@ -# Cloudflare (Workers / R2 / D1 / Queues) +# Cloudflare (R2 / D1 / Queues / Tunnels) -Provides the Cloudflare (Workers / R2 / D1 / Queues) cloud provider adapter for sh1pt scale and deploy workflows. +Provides the Cloudflare (R2 / D1 / Queues / Tunnels) cloud provider adapter for sh1pt scale and deploy workflows. ## What it does -- Connects cloud provider credentials and project settings. -- Supports infrastructure planning, deployment, or status workflows where implemented. -- Includes a connection flow for account or credential setup. -- Includes setup guidance for required credentials or provider configuration. +- Connects with `CLOUDFLARE_API_TOKEN` and an optional `accountId`. +- Quotes R2 storage using the per-GB monthly storage rate and reports zero-dollar base quotes for usage-priced D1, Queues, and Tunnels. +- Provisions, lists, checks status, and destroys R2 buckets, D1 databases, Queues, and Cloudflare Tunnels through the Cloudflare REST API. +- Leaves Worker script deployment to the `deploy-workers` target. + +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. ## Package diff --git a/packages/cloud/cloudflare/src/index.test.ts b/packages/cloud/cloudflare/src/index.test.ts index 54867f66..915fa373 100644 --- a/packages/cloud/cloudflare/src/index.test.ts +++ b/packages/cloud/cloudflare/src/index.test.ts @@ -1,4 +1,185 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { contractTestCloud } from '@profullstack/sh1pt-core/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; -smokeTest(adapter, { idPrefix: 'cloud', requireSupports: true }); +const API = 'https://api.cloudflare.com/client/v4'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Cloudflare cloud adapter', () => { + it('connects to a configured account', async () => { + const fetchMock = vi.fn(async () => ok({ id: 'acct-1', name: 'Example' })); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' })).resolves.toEqual({ accountId: 'acct-1' }); + expect(fetchMock).toHaveBeenCalledWith(`${API}/accounts/acct-1`, expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + })); + }); + + it('discovers the first accessible account when accountId is omitted', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ok([{ id: 'acct-2', name: 'First' }]))); + + await expect(adapter.connect(connectCtx(), {})).resolves.toEqual({ accountId: 'acct-2' }); + }); + + it('creates an R2 bucket', async () => { + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe(`${API}/accounts/acct-1/r2/buckets`); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ name: 'assets' }); + return ok({ name: 'assets', creation_date: '2026-06-14T00:00:00Z', location: 'WNAM' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision( + provisionCtx(), + { kind: 'object-storage', storage: 10, region: 'auto' }, + { accountId: 'acct-1', resourceType: 'r2-bucket', name: 'assets' }, + ); + + expect(instance).toMatchObject({ + id: 'r2:assets', + kind: 'object-storage', + status: 'running', + sku: 'r2-bucket', + region: 'WNAM', + }); + }); + + it('creates a D1 database with a location hint', async () => { + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe(`${API}/accounts/acct-1/d1/database`); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ name: 'main-db', primary_location_hint: 'weur' }); + return ok({ uuid: 'db-1', name: 'main-db', created_at: '2026-06-14T00:00:00Z' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision( + provisionCtx(), + { kind: 'managed-db', region: 'weur' }, + { accountId: 'acct-1', name: 'main-db' }, + ); + + expect(instance).toMatchObject({ + id: 'd1:db-1', + kind: 'managed-db', + status: 'running', + sku: 'd1-database', + region: 'weur', + }); + }); + + it('does not call the API in dry-run provision or destroy', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision( + provisionCtx(true), + { kind: 'object-storage', storage: 10 }, + { accountId: 'acct-1', name: 'assets' }, + ); + await adapter.destroy(provisionCtx(true), 'r2:assets', { accountId: 'acct-1' }); + + expect(instance.id).toBe('r2:dry-run-assets'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('lists supported Cloudflare resources', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + const { pathname } = new URL(url); + if (pathname.endsWith('/r2/buckets')) return ok([{ name: 'assets', creation_date: '2026-06-14T00:00:00Z' }]); + if (pathname.endsWith('/d1/database')) return ok([{ uuid: 'db-1', name: 'main', created_at: '2026-06-14T00:00:00Z' }]); + if (pathname.endsWith('/queues')) return ok([{ queue_id: 'queue-1', queue_name: 'jobs', created_on: '2026-06-14T00:00:00Z' }]); + if (pathname.endsWith('/cfd_tunnel')) return ok([{ id: 'tun-1', name: 'edge', status: 'healthy', created_at: '2026-06-14T00:00:00Z' }]); + throw new Error(`unexpected url ${url}`); + })); + + const instances = await adapter.list(connectCtx(), { accountId: 'acct-1' }); + + expect(instances.map((instance) => instance.id).sort()).toEqual([ + 'd1:db-1', + 'queue:queue-1', + 'r2:assets', + 'tunnel:tun-1', + ]); + expect(instances.find((instance) => instance.id === 'tunnel:tun-1')?.status).toBe('running'); + }); + + it('checks status using the prefixed resource id', async () => { + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe(`${API}/accounts/acct-1/queues/queue-1`); + return ok({ queue_id: 'queue-1', queue_name: 'jobs', created_on: '2026-06-14T00:00:00Z' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.status(connectCtx(), 'queue:queue-1', { accountId: 'acct-1' }); + + expect(instance).toMatchObject({ id: 'queue:queue-1', status: 'running', sku: 'queue' }); + }); + + it('deletes the prefixed resource id', async () => { + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe(`${API}/accounts/acct-1/cfd_tunnel/tun-1`); + expect(init.method).toBe('DELETE'); + return ok({ id: 'tun-1' }); + }); + vi.stubGlobal('fetch', fetchMock); + + await adapter.destroy(provisionCtx(), 'tunnel:tun-1', { accountId: 'acct-1' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('reports Cloudflare API errors', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + success: false, + errors: [{ code: 10000, message: 'Authentication error' }], + result: null, + })))); + + await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' })) + .rejects.toThrow('Cloudflare GET /accounts/acct-1 failed: Authentication error'); + }); + + it('reports non-JSON error responses without masking the provider response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('maintenance', { status: 503, statusText: 'Service Unavailable' }))); + + await expect(adapter.connect(connectCtx(), { accountId: 'acct-1' })) + .rejects.toThrow('Cloudflare GET /accounts/acct-1 failed: 503 maintenance'); + }); +}); + +contractTestCloud(adapter, { + sampleConfig: { accountId: 'acct-1', resourceType: 'r2-bucket', name: 'assets' }, + sampleSpec: { kind: 'object-storage', storage: 10, region: 'auto' }, + requiredSecrets: ['CLOUDFLARE_API_TOKEN'], +}); + +function connectCtx() { + return { + secret: (key: string) => key === 'CLOUDFLARE_API_TOKEN' ? 'test-token' : undefined, + log: vi.fn(), + }; +} + +function provisionCtx(dryRun = false) { + return { + ...connectCtx(), + dryRun, + }; +} + +function ok(result: unknown, resultInfo?: unknown) { + return new Response(JSON.stringify({ + success: true, + errors: [], + messages: [], + result, + result_info: resultInfo, + })); +} diff --git a/packages/cloud/cloudflare/src/index.ts b/packages/cloud/cloudflare/src/index.ts index e2c2a6f0..3a565096 100644 --- a/packages/cloud/cloudflare/src/index.ts +++ b/packages/cloud/cloudflare/src/index.ts @@ -1,64 +1,554 @@ -import { defineCloud, tokenSetup, type Instance } from '@profullstack/sh1pt-core'; +import { randomBytes } from 'node:crypto'; +import { + defineCloud, + tokenSetup, + type CloudConnectContext, + type Instance, + type InstanceKind, + type InstanceSpec, + type ProvisionContext, + type Quote, +} from '@profullstack/sh1pt-core'; + +type ResourceType = 'r2-bucket' | 'd1-database' | 'queue' | 'tunnel'; +type ConfigResourceType = ResourceType | 'worker'; -// Cloudflare — not a traditional IaaS (no VMs to rent), but sh1pt models -// the provisionable primitives: Workers (compute), R2 (object storage), -// D1 (managed SQL), Queues, Tunnels. Pair with deploy-workers target for -// the actual code deployment. interface Config { accountId?: string; - // which Cloudflare resource to create when .provision() is called - resourceType?: 'worker' | 'r2-bucket' | 'd1-database' | 'queue' | 'tunnel'; + name?: string; + defaultRegion?: string; + resourceType?: ConfigResourceType; + tunnelSecret?: string; + apiBaseUrl?: string; +} + +interface CloudflareEnvelope { + success?: boolean; + errors?: Array<{ code?: number; message?: string }>; + messages?: Array<{ code?: number; message?: string } | string>; + result?: T; + result_info?: { + page?: number; + per_page?: number; + total_pages?: number; + total_count?: number; + }; +} + +interface CloudflareAccount { + id?: string; + name?: string; +} + +interface R2Bucket { + name?: string; + creation_date?: string; + jurisdiction?: string; + location?: string; +} + +interface D1Database { + uuid?: string; + name?: string; + created_at?: string; + jurisdiction?: string; +} + +interface Queue { + queue_id?: string; + queue_name?: string; + created_on?: string; + modified_on?: string; +} + +interface Tunnel { + id?: string; + name?: string; + created_at?: string; + status?: string; } +const API = 'https://api.cloudflare.com/client/v4'; + export default defineCloud({ id: 'cloud-cloudflare', - label: 'Cloudflare (Workers / R2 / D1 / Queues)', + label: 'Cloudflare (R2 / D1 / Queues / Tunnels)', supports: ['object-storage', 'managed-db'], - // note: cpu-vps and gpu aren't in .supports because CF doesn't sell raw - // VMs. If you need raw compute pair this with cloud-runpod / cloud-do. - async connect(ctx) { - if (!ctx.secret('CLOUDFLARE_API_TOKEN')) throw new Error('CLOUDFLARE_API_TOKEN not set'); - return { accountId: 'cloudflare' }; + async connect(ctx, config) { + requireToken(ctx); + + if (config.accountId) { + const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(config.accountId)}`); + return { accountId: result.id ?? config.accountId }; + } + + return { accountId: await resolveAccountId(ctx, config) }; }, - async quote(ctx) { - ctx.log('cloudflare quote'); - // R2: $0.015/GB/month storage + $0 egress. Workers: included on paid plans. - // D1: generous free tier, then read/write row pricing. - return { hourly: 0, monthly: 0, currency: 'USD', provider: 'cloudflare', sku: 'stub', spot: false }; + async quote(_ctx, spec, config) { + const resourceType = resourceTypeFor(spec, config); + const monthly = resourceType === 'r2-bucket' && spec.storage ? spec.storage * 0.015 : 0; + + return { + hourly: monthly / 730, + monthly, + currency: 'USD', + provider: 'cloudflare', + sku: resourceType, + spot: false, + availabilityZone: spec.region, + }; }, async provision(ctx, spec, config) { - ctx.log(`wrangler ${config.resourceType ?? 'r2-bucket'} create`); - if (ctx.dryRun) return stub('dry-run', 'provisioning', spec.kind); - // TODO per resourceType: - // 'r2-bucket' → POST /accounts/:id/r2/buckets - // 'd1-database' → POST /accounts/:id/d1/database - // 'queue' → POST /accounts/:id/queues - // 'tunnel' → POST /accounts/:id/cfd_tunnel - // 'worker' → PUT /accounts/:id/workers/scripts/:name - return stub(`cf_${Date.now()}`, 'provisioning', spec.kind); + const resourceType = resourceTypeFor(spec, config); + const quote = await this.quote(ctx, spec, config); + if (spec.maxHourlyPrice !== undefined && quote.hourly > spec.maxHourlyPrice) { + throw new Error(`Cloudflare quote ${quote.hourly} USD/hr exceeds maxHourlyPrice ${spec.maxHourlyPrice}`); + } + + const name = safeName(config.name ?? `sh1pt-${resourceType}-${Date.now().toString(36)}`); + if (ctx.dryRun) return dryRunInstance(resourceType, name, spec.kind, quote, spec.region); + + const accountId = await resolveAccountId(ctx, config); + + switch (resourceType) { + case 'r2-bucket': { + const { result } = await cfRequest( + ctx, + config, + 'POST', + `/accounts/${encodeURIComponent(accountId)}/r2/buckets`, + { name }, + ); + return bucketInstance(result, spec.kind, quote, spec.region); + } + case 'd1-database': { + const { result } = await cfRequest( + ctx, + config, + 'POST', + `/accounts/${encodeURIComponent(accountId)}/d1/database`, + { + name, + primary_location_hint: spec.region ?? config.defaultRegion, + }, + ); + return d1Instance(result, spec.kind, quote, spec.region ?? config.defaultRegion); + } + case 'queue': { + const { result } = await cfRequest( + ctx, + config, + 'POST', + `/accounts/${encodeURIComponent(accountId)}/queues`, + { queue_name: name }, + ); + return queueInstance(result, spec.kind, quote, spec.region); + } + case 'tunnel': { + const { result } = await cfRequest( + ctx, + config, + 'POST', + `/accounts/${encodeURIComponent(accountId)}/cfd_tunnel`, + { + name, + config_src: 'cloudflare', + tunnel_secret: config.tunnelSecret ?? randomBytes(32).toString('base64'), + }, + ); + return tunnelInstance(result, spec.kind, quote, spec.region); + } + } + }, + + async list(ctx, config) { + const accountId = await resolveAccountId(ctx, config); + const resourceTypes = listResourceTypes(config); + const lists = await Promise.all(resourceTypes.map(async (resourceType) => { + try { + return await listResource(ctx, config, accountId, resourceType); + } catch (error) { + ctx.log(`Cloudflare list ${resourceType} skipped: ${errorMessage(error)}`, 'warn'); + return []; + } + })); + + return lists.flat(); }, - async list(ctx) { ctx.log('wrangler whoami && wrangler r2 bucket list'); return []; }, - async destroy(ctx, id) { ctx.log(`wrangler delete ${id}`); }, - async status(ctx, id) { ctx.log(`wrangler deployments list --name ${id}`); return stub(id, 'running', 'object-storage'); }, + async destroy(ctx, instanceId, config) { + const resource = parseResourceId(instanceId, config); + if (ctx.dryRun) { + ctx.log(`Cloudflare dry-run destroy ${instanceId}`); + return; + } + + const accountId = await resolveAccountId(ctx, config); + await cfRequest(ctx, config, 'DELETE', deletePath(accountId, resource)); + }, + + async status(ctx, instanceId, config) { + const accountId = await resolveAccountId(ctx, config); + const resource = parseResourceId(instanceId, config); + const quote = zeroQuote(resource.type); + + switch (resource.type) { + case 'r2-bucket': { + const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(resource.nativeId)}`); + return bucketInstance(result, 'object-storage', quote); + } + case 'd1-database': { + const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(resource.nativeId)}`); + return d1Instance(result, 'managed-db', quote); + } + case 'queue': { + const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/queues/${encodeURIComponent(resource.nativeId)}`); + return queueInstance(result, 'object-storage', quote); + } + case 'tunnel': { + const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/cfd_tunnel/${encodeURIComponent(resource.nativeId)}`); + return tunnelInstance(result, 'object-storage', quote); + } + } + }, setup: tokenSetup({ secretKey: 'CLOUDFLARE_API_TOKEN', label: 'Cloudflare (cloud)', vendorDocUrl: 'https://dash.cloudflare.com/profile/api-tokens', steps: [ - 'Install with mise: mise use npm:wrangler', - 'Authenticate locally: wrangler login', 'Open https://dash.cloudflare.com/profile/api-tokens', - 'Create an API token with full / read-write scope', - 'Copy the token (usually shown once)', + 'Create an API token with the account permissions needed for R2, D1, Queues, or Cloudflare Tunnel', + 'Copy the token', + 'Run: sh1pt secret set CLOUDFLARE_API_TOKEN ', + 'Set accountId in the Cloudflare cloud config, or allow sh1pt to discover the first accessible account', ], }), }); -function stub(id: string, status: Instance['status'], kind: Instance['kind']): Instance { - return { id, kind, status, createdAt: new Date().toISOString(), hourlyRate: 0, currency: 'USD' }; +async function listResource( + ctx: CloudConnectContext, + config: Config, + accountId: string, + resourceType: ResourceType, +): Promise { + const account = encodeURIComponent(accountId); + + switch (resourceType) { + case 'r2-bucket': + return (await cfListAll(ctx, config, `/accounts/${account}/r2/buckets`, 'buckets')) + .map((bucket) => bucketInstance(bucket, 'object-storage', zeroQuote('r2-bucket'))); + case 'd1-database': + return (await cfListAll(ctx, config, `/accounts/${account}/d1/database`, 'databases')) + .map((db) => d1Instance(db, 'managed-db', zeroQuote('d1-database'))); + case 'queue': + return (await cfListAll(ctx, config, `/accounts/${account}/queues`, 'queues')) + .map((queue) => queueInstance(queue, 'object-storage', zeroQuote('queue'))); + case 'tunnel': + return (await cfListAll(ctx, config, `/accounts/${account}/cfd_tunnel`, 'tunnels')) + .map((tunnel) => tunnelInstance(tunnel, 'object-storage', zeroQuote('tunnel'))); + } +} + +async function cfListAll( + ctx: CloudConnectContext, + config: Config, + path: string, + arrayKey: string, +): Promise { + const items: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const separator = path.includes('?') ? '&' : '?'; + const { result, resultInfo } = await cfRequest(ctx, config, 'GET', `${path}${separator}page=${page}&per_page=100`); + items.push(...arrayFromResult(result, arrayKey)); + totalPages = typeof resultInfo?.total_pages === 'number' ? resultInfo.total_pages : 1; + page += 1; + } while (page <= totalPages); + + return items; +} + +async function cfRequest( + ctx: CloudConnectContext | ProvisionContext, + config: Config, + method: string, + path: string, + body?: unknown, +): Promise<{ result: T; resultInfo?: CloudflareEnvelope['result_info'] }> { + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${requireToken(ctx)}`, + }; + const init: RequestInit = { method, headers }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(stripUndefined(body)); + } + + const response = await fetch(`${config.apiBaseUrl ?? API}${path}`, init); + if (response.status === 204) return { result: undefined as T }; + + const text = await response.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : undefined; + } catch (error) { + if (response.ok) throw error; + data = { errors: [{ message: text || response.statusText }] }; + } + + if (!response.ok) { + throw new Error(`Cloudflare ${method} ${path} failed: ${response.status} ${cloudflareError(data, response.statusText)}`); + } + + if (!isRecord(data)) return { result: data as T }; + const envelope = data as CloudflareEnvelope; + if (envelope.success === false) { + throw new Error(`Cloudflare ${method} ${path} failed: ${cloudflareError(envelope, 'request failed')}`); + } + + return { result: envelope.result as T, resultInfo: envelope.result_info }; +} + +async function resolveAccountId(ctx: CloudConnectContext | ProvisionContext, config: Config): Promise { + if (config.accountId) return config.accountId; + + const { result } = await cfRequest(ctx, config, 'GET', '/accounts'); + const accounts = arrayFromResult(result, 'accounts'); + const first = accounts[0]; + if (!first?.id) throw new Error('Cloudflare accountId not found; set accountId in cloud config'); + return first.id; +} + +function requireToken(ctx: CloudConnectContext | ProvisionContext): string { + const token = ctx.secret('CLOUDFLARE_API_TOKEN'); + if (!token) { + throw new Error('CLOUDFLARE_API_TOKEN not in vault - run: sh1pt secret set CLOUDFLARE_API_TOKEN '); + } + return token; +} + +function resourceTypeFor(spec: InstanceSpec, config: Config): ResourceType { + if (config.resourceType === 'worker') { + throw new Error('Cloudflare Workers scripts are handled by the deploy-workers target, not cloud-cloudflare'); + } + if (config.resourceType) return config.resourceType; + if (spec.kind === 'managed-db') return 'd1-database'; + if (spec.kind === 'object-storage') return 'r2-bucket'; + throw new Error(`cloud-cloudflare supports object-storage and managed-db specs; got ${spec.kind}`); +} + +function listResourceTypes(config: Config): ResourceType[] { + if (config.resourceType === 'worker') { + throw new Error('Cloudflare Workers scripts are handled by the deploy-workers target, not cloud-cloudflare'); + } + return config.resourceType ? [config.resourceType] : ['r2-bucket', 'd1-database', 'queue', 'tunnel']; +} + +function parseResourceId(instanceId: string, config: Config): { type: ResourceType; nativeId: string } { + const [prefix, ...rest] = instanceId.split(':'); + const nativeId = rest.join(':'); + + if (prefix && nativeId) { + if (prefix === 'r2') return { type: 'r2-bucket', nativeId }; + if (prefix === 'd1') return { type: 'd1-database', nativeId }; + if (prefix === 'queue') return { type: 'queue', nativeId }; + if (prefix === 'tunnel') return { type: 'tunnel', nativeId }; + } + + if (config.resourceType === 'worker') { + throw new Error('Cloudflare Workers scripts are handled by the deploy-workers target, not cloud-cloudflare'); + } + + return { type: config.resourceType ?? 'r2-bucket', nativeId: instanceId }; +} + +function deletePath(accountId: string, resource: { type: ResourceType; nativeId: string }): string { + const account = encodeURIComponent(accountId); + const nativeId = encodeURIComponent(resource.nativeId); + + switch (resource.type) { + case 'r2-bucket': + return `/accounts/${account}/r2/buckets/${nativeId}`; + case 'd1-database': + return `/accounts/${account}/d1/database/${nativeId}`; + case 'queue': + return `/accounts/${account}/queues/${nativeId}`; + case 'tunnel': + return `/accounts/${account}/cfd_tunnel/${nativeId}`; + } +} + +function dryRunInstance( + resourceType: ResourceType, + name: string, + kind: InstanceKind, + quote: Quote, + region?: string, +): Instance { + return { + id: prefixedId(resourceType, `dry-run-${name}`), + kind, + status: 'provisioning', + createdAt: new Date().toISOString(), + hourlyRate: quote.hourly, + currency: quote.currency, + sku: quote.sku, + region, + }; +} + +function bucketInstance(bucket: R2Bucket, kind: InstanceKind, quote: Quote, fallbackRegion?: string): Instance { + const name = requiredId(bucket.name, 'Cloudflare R2 bucket'); + return { + id: prefixedId('r2-bucket', name), + kind, + status: 'running', + createdAt: iso(bucket.creation_date), + hourlyRate: quote.hourly, + currency: quote.currency, + sku: quote.sku, + region: bucket.location ?? bucket.jurisdiction ?? fallbackRegion, + }; +} + +function d1Instance(db: D1Database, kind: InstanceKind, quote: Quote, fallbackRegion?: string): Instance { + const id = requiredId(db.uuid ?? db.name, 'Cloudflare D1 database'); + return { + id: prefixedId('d1-database', id), + kind, + status: 'running', + createdAt: iso(db.created_at), + hourlyRate: quote.hourly, + currency: quote.currency, + sku: quote.sku, + region: db.jurisdiction ?? fallbackRegion, + }; +} + +function queueInstance(queue: Queue, kind: InstanceKind, quote: Quote, fallbackRegion?: string): Instance { + const id = requiredId(queue.queue_id ?? queue.queue_name, 'Cloudflare Queue'); + return { + id: prefixedId('queue', id), + kind, + status: 'running', + createdAt: iso(queue.created_on ?? queue.modified_on), + hourlyRate: quote.hourly, + currency: quote.currency, + sku: quote.sku, + region: fallbackRegion, + }; +} + +function tunnelInstance(tunnel: Tunnel, kind: InstanceKind, quote: Quote, fallbackRegion?: string): Instance { + const id = requiredId(tunnel.id ?? tunnel.name, 'Cloudflare Tunnel'); + return { + id: prefixedId('tunnel', id), + kind, + status: tunnelStatus(tunnel.status), + createdAt: iso(tunnel.created_at), + hourlyRate: quote.hourly, + currency: quote.currency, + sku: quote.sku, + region: fallbackRegion, + }; +} + +function tunnelStatus(status: string | undefined): Instance['status'] { + if (status === 'healthy') return 'running'; + if (status === 'inactive' || status === 'down') return 'stopped'; + if (status === 'degraded') return 'failed'; + return 'provisioning'; +} + +function prefixedId(resourceType: ResourceType, nativeId: string): string { + const prefix: Record = { + 'r2-bucket': 'r2', + 'd1-database': 'd1', + queue: 'queue', + tunnel: 'tunnel', + }; + return `${prefix[resourceType]}:${nativeId}`; +} + +function zeroQuote(resourceType: ResourceType): Quote { + return { + hourly: 0, + monthly: 0, + currency: 'USD', + provider: 'cloudflare', + sku: resourceType, + spot: false, + }; +} + +function safeName(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + const clipped = normalized.slice(0, 60).replace(/-+$/g, ''); + if (clipped.length >= 3) return clipped; + return `sh1pt-${clipped || 'resource'}`.slice(0, 63); +} + +function requiredId(value: string | undefined, label: string): string { + if (!value) throw new Error(`${label} response did not include an id`); + return value; +} + +function iso(value: string | undefined): string { + return value ? new Date(value).toISOString() : new Date().toISOString(); +} + +function arrayFromResult(result: unknown, arrayKey: string): T[] { + if (Array.isArray(result)) return result as T[]; + if (!isRecord(result)) return []; + + const keyed = result[arrayKey]; + if (Array.isArray(keyed)) return keyed as T[]; + return []; +} + +function cloudflareError(data: unknown, fallback: string): string { + if (!isRecord(data)) return fallback; + + const errors = Array.isArray(data.errors) ? data.errors : []; + const messages = Array.isArray(data.messages) ? data.messages : []; + const details = [...errors, ...messages] + .map((item) => { + if (typeof item === 'string') return item; + if (isRecord(item)) return item.message ?? item.code; + return undefined; + }) + .filter((item): item is string | number => item !== undefined) + .join('; '); + + return details || fallback; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function stripUndefined(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripUndefined); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, stripUndefined(v)]), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; } From 39fcff4d94ff9edf2d2ee08177c2980350368390 Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 10:47:19 +0800 Subject: [PATCH 2/3] Address Cloudflare adapter review feedback --- packages/cloud/cloudflare/README.md | 2 + packages/cloud/cloudflare/src/index.test.ts | 43 +++++++++++++++++++- packages/cloud/cloudflare/src/index.ts | 44 ++++++++++++++------- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/cloud/cloudflare/README.md b/packages/cloud/cloudflare/README.md index e5d70d03..23a36a36 100644 --- a/packages/cloud/cloudflare/README.md +++ b/packages/cloud/cloudflare/README.md @@ -11,6 +11,8 @@ Provides the Cloudflare (R2 / D1 / Queues / Tunnels) cloud provider adapter for 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. +Tunnel provisioning requires `tunnelSecret` in the Cloudflare cloud config. The adapter sends that caller-owned secret to Cloudflare and does not generate or return connector credentials. + ## Package - Name: `@profullstack/sh1pt-cloud-cloudflare` diff --git a/packages/cloud/cloudflare/src/index.test.ts b/packages/cloud/cloudflare/src/index.test.ts index 915fa373..aeeb2f8a 100644 --- a/packages/cloud/cloudflare/src/index.test.ts +++ b/packages/cloud/cloudflare/src/index.test.ts @@ -92,7 +92,7 @@ describe('Cloudflare cloud adapter', () => { it('lists supported Cloudflare resources', async () => { vi.stubGlobal('fetch', vi.fn(async (url: string) => { const { pathname } = new URL(url); - if (pathname.endsWith('/r2/buckets')) return ok([{ name: 'assets', creation_date: '2026-06-14T00:00:00Z' }]); + if (pathname.endsWith('/r2/buckets')) return ok({ buckets: [{ name: 'assets', creation_date: '2026-06-14T00:00:00Z' }] }); if (pathname.endsWith('/d1/database')) return ok([{ uuid: 'db-1', name: 'main', created_at: '2026-06-14T00:00:00Z' }]); if (pathname.endsWith('/queues')) return ok([{ queue_id: 'queue-1', queue_name: 'jobs', created_on: '2026-06-14T00:00:00Z' }]); if (pathname.endsWith('/cfd_tunnel')) return ok([{ id: 'tun-1', name: 'edge', status: 'healthy', created_at: '2026-06-14T00:00:00Z' }]); @@ -107,6 +107,8 @@ describe('Cloudflare cloud adapter', () => { 'r2:assets', 'tunnel:tun-1', ]); + expect(instances.find((instance) => instance.id === 'queue:queue-1')?.kind).toBe('object-storage'); + expect(instances.find((instance) => instance.id === 'tunnel:tun-1')?.kind).toBe('object-storage'); expect(instances.find((instance) => instance.id === 'tunnel:tun-1')?.status).toBe('running'); }); @@ -122,6 +124,45 @@ describe('Cloudflare cloud adapter', () => { expect(instance).toMatchObject({ id: 'queue:queue-1', status: 'running', sku: 'queue' }); }); + it('requires a caller-supplied tunnel secret when creating a tunnel', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.provision( + provisionCtx(), + { kind: 'object-storage', region: 'auto' }, + { accountId: 'acct-1', resourceType: 'tunnel', name: 'edge' }, + )).rejects.toThrow('Cloudflare tunnel provisioning requires config.tunnelSecret'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('creates a tunnel with a caller-supplied tunnel secret', async () => { + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + expect(url).toBe(`${API}/accounts/acct-1/cfd_tunnel`); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ + name: 'edge', + config_src: 'cloudflare', + tunnel_secret: 'known-secret', + }); + return ok({ id: 'tun-1', name: 'edge', status: 'healthy', created_at: '2026-06-14T00:00:00Z' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const instance = await adapter.provision( + provisionCtx(), + { kind: 'managed-db', region: 'auto' }, + { accountId: 'acct-1', resourceType: 'tunnel', name: 'edge', tunnelSecret: 'known-secret' }, + ); + + expect(instance).toMatchObject({ + id: 'tunnel:tun-1', + kind: 'object-storage', + status: 'running', + sku: 'tunnel', + }); + }); + it('deletes the prefixed resource id', async () => { const fetchMock = vi.fn(async (url: string, init: RequestInit) => { expect(url).toBe(`${API}/accounts/acct-1/cfd_tunnel/tun-1`); diff --git a/packages/cloud/cloudflare/src/index.ts b/packages/cloud/cloudflare/src/index.ts index 3a565096..ce4c3319 100644 --- a/packages/cloud/cloudflare/src/index.ts +++ b/packages/cloud/cloudflare/src/index.ts @@ -1,4 +1,3 @@ -import { randomBytes } from 'node:crypto'; import { defineCloud, tokenSetup, @@ -109,7 +108,8 @@ export default defineCloud({ } const name = safeName(config.name ?? `sh1pt-${resourceType}-${Date.now().toString(36)}`); - if (ctx.dryRun) return dryRunInstance(resourceType, name, spec.kind, quote, spec.region); + const kind = kindForResource(resourceType); + if (ctx.dryRun) return dryRunInstance(resourceType, name, kind, quote, spec.region); const accountId = await resolveAccountId(ctx, config); @@ -122,7 +122,7 @@ export default defineCloud({ `/accounts/${encodeURIComponent(accountId)}/r2/buckets`, { name }, ); - return bucketInstance(result, spec.kind, quote, spec.region); + return bucketInstance(result, kind, quote, spec.region); } case 'd1-database': { const { result } = await cfRequest( @@ -135,7 +135,7 @@ export default defineCloud({ primary_location_hint: spec.region ?? config.defaultRegion, }, ); - return d1Instance(result, spec.kind, quote, spec.region ?? config.defaultRegion); + return d1Instance(result, kind, quote, spec.region ?? config.defaultRegion); } case 'queue': { const { result } = await cfRequest( @@ -145,9 +145,12 @@ export default defineCloud({ `/accounts/${encodeURIComponent(accountId)}/queues`, { queue_name: name }, ); - return queueInstance(result, spec.kind, quote, spec.region); + return queueInstance(result, kind, quote, spec.region); } case 'tunnel': { + if (!config.tunnelSecret) { + throw new Error('Cloudflare tunnel provisioning requires config.tunnelSecret so the connector secret is not generated and lost'); + } const { result } = await cfRequest( ctx, config, @@ -156,10 +159,10 @@ export default defineCloud({ { name, config_src: 'cloudflare', - tunnel_secret: config.tunnelSecret ?? randomBytes(32).toString('base64'), + tunnel_secret: config.tunnelSecret, }, ); - return tunnelInstance(result, spec.kind, quote, spec.region); + return tunnelInstance(result, kind, quote, spec.region); } } }, @@ -198,19 +201,19 @@ export default defineCloud({ switch (resource.type) { case 'r2-bucket': { const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/r2/buckets/${encodeURIComponent(resource.nativeId)}`); - return bucketInstance(result, 'object-storage', quote); + return bucketInstance(result, kindForResource(resource.type), quote); } case 'd1-database': { const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(resource.nativeId)}`); - return d1Instance(result, 'managed-db', quote); + return d1Instance(result, kindForResource(resource.type), quote); } case 'queue': { const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/queues/${encodeURIComponent(resource.nativeId)}`); - return queueInstance(result, 'object-storage', quote); + return queueInstance(result, kindForResource(resource.type), quote); } case 'tunnel': { const { result } = await cfRequest(ctx, config, 'GET', `/accounts/${encodeURIComponent(accountId)}/cfd_tunnel/${encodeURIComponent(resource.nativeId)}`); - return tunnelInstance(result, 'object-storage', quote); + return tunnelInstance(result, kindForResource(resource.type), quote); } } }, @@ -240,16 +243,16 @@ async function listResource( switch (resourceType) { case 'r2-bucket': return (await cfListAll(ctx, config, `/accounts/${account}/r2/buckets`, 'buckets')) - .map((bucket) => bucketInstance(bucket, 'object-storage', zeroQuote('r2-bucket'))); + .map((bucket) => bucketInstance(bucket, kindForResource('r2-bucket'), zeroQuote('r2-bucket'))); case 'd1-database': return (await cfListAll(ctx, config, `/accounts/${account}/d1/database`, 'databases')) - .map((db) => d1Instance(db, 'managed-db', zeroQuote('d1-database'))); + .map((db) => d1Instance(db, kindForResource('d1-database'), zeroQuote('d1-database'))); case 'queue': return (await cfListAll(ctx, config, `/accounts/${account}/queues`, 'queues')) - .map((queue) => queueInstance(queue, 'object-storage', zeroQuote('queue'))); + .map((queue) => queueInstance(queue, kindForResource('queue'), zeroQuote('queue'))); case 'tunnel': return (await cfListAll(ctx, config, `/accounts/${account}/cfd_tunnel`, 'tunnels')) - .map((tunnel) => tunnelInstance(tunnel, 'object-storage', zeroQuote('tunnel'))); + .map((tunnel) => tunnelInstance(tunnel, kindForResource('tunnel'), zeroQuote('tunnel'))); } } @@ -345,6 +348,17 @@ function resourceTypeFor(spec: InstanceSpec, config: Config): ResourceType { throw new Error(`cloud-cloudflare supports object-storage and managed-db specs; got ${spec.kind}`); } +function kindForResource(resourceType: ResourceType): InstanceKind { + switch (resourceType) { + case 'r2-bucket': + case 'queue': + case 'tunnel': + return 'object-storage'; + case 'd1-database': + return 'managed-db'; + } +} + function listResourceTypes(config: Config): ResourceType[] { if (config.resourceType === 'worker') { throw new Error('Cloudflare Workers scripts are handled by the deploy-workers target, not cloud-cloudflare'); From 78b689877219da32e014a98d3707cd801c497057 Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 13:08:27 +0800 Subject: [PATCH 3/3] Fix Cloudflare review follow-ups --- packages/cloud/cloudflare/README.md | 2 +- packages/cloud/cloudflare/src/index.test.ts | 24 +++++++++++++++++++-- packages/cloud/cloudflare/src/index.ts | 5 +++-- packages/core/src/cloud.ts | 1 + 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/cloud/cloudflare/README.md b/packages/cloud/cloudflare/README.md index 23a36a36..670f5238 100644 --- a/packages/cloud/cloudflare/README.md +++ b/packages/cloud/cloudflare/README.md @@ -11,7 +11,7 @@ Provides the Cloudflare (R2 / D1 / Queues / Tunnels) cloud provider adapter for 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. -Tunnel provisioning requires `tunnelSecret` in the Cloudflare cloud config. The adapter sends that caller-owned secret to Cloudflare and does not generate or return connector credentials. +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`. ## Package diff --git a/packages/cloud/cloudflare/src/index.test.ts b/packages/cloud/cloudflare/src/index.test.ts index aeeb2f8a..8cbf25ee 100644 --- a/packages/cloud/cloudflare/src/index.test.ts +++ b/packages/cloud/cloudflare/src/index.test.ts @@ -26,6 +26,19 @@ describe('Cloudflare cloud adapter', () => { await expect(adapter.connect(connectCtx(), {})).resolves.toEqual({ accountId: 'acct-2' }); }); + it('paginates account discovery when accountId is omitted', async () => { + const fetchMock = vi.fn(async (url: string) => { + const { searchParams } = new URL(url); + if (searchParams.get('page') === '1') return ok([], { total_pages: 2 }); + if (searchParams.get('page') === '2') return ok([{ id: 'acct-2', name: 'Second page' }], { total_pages: 2 }); + throw new Error(`unexpected url ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(adapter.connect(connectCtx(), {})).resolves.toEqual({ accountId: 'acct-2' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('creates an R2 bucket', async () => { const fetchMock = vi.fn(async (url: string, init: RequestInit) => { expect(url).toBe(`${API}/accounts/acct-1/r2/buckets`); @@ -89,7 +102,7 @@ describe('Cloudflare cloud adapter', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('lists supported Cloudflare resources', async () => { + it('lists supported Cloudflare resources, including nested R2 bucket responses', async () => { vi.stubGlobal('fetch', vi.fn(async (url: string) => { const { pathname } = new URL(url); if (pathname.endsWith('/r2/buckets')) return ok({ buckets: [{ name: 'assets', creation_date: '2026-06-14T00:00:00Z' }] }); @@ -145,7 +158,13 @@ describe('Cloudflare cloud adapter', () => { config_src: 'cloudflare', tunnel_secret: 'known-secret', }); - return ok({ id: 'tun-1', name: 'edge', status: 'healthy', created_at: '2026-06-14T00:00:00Z' }); + return ok({ + id: 'tun-1', + name: 'edge', + status: 'healthy', + tunnel_token: 'cloudflared-token', + created_at: '2026-06-14T00:00:00Z', + }); }); vi.stubGlobal('fetch', fetchMock); @@ -160,6 +179,7 @@ describe('Cloudflare cloud adapter', () => { kind: 'object-storage', status: 'running', sku: 'tunnel', + metadata: { cloudflareTunnelToken: 'cloudflared-token' }, }); }); diff --git a/packages/cloud/cloudflare/src/index.ts b/packages/cloud/cloudflare/src/index.ts index ce4c3319..9b65b4d2 100644 --- a/packages/cloud/cloudflare/src/index.ts +++ b/packages/cloud/cloudflare/src/index.ts @@ -65,6 +65,7 @@ interface Tunnel { name?: string; created_at?: string; status?: string; + tunnel_token?: string; } const API = 'https://api.cloudflare.com/client/v4'; @@ -323,8 +324,7 @@ async function cfRequest( async function resolveAccountId(ctx: CloudConnectContext | ProvisionContext, config: Config): Promise { if (config.accountId) return config.accountId; - const { result } = await cfRequest(ctx, config, 'GET', '/accounts'); - const accounts = arrayFromResult(result, 'accounts'); + const accounts = await cfListAll(ctx, config, '/accounts', 'accounts'); const first = accounts[0]; if (!first?.id) throw new Error('Cloudflare accountId not found; set accountId in cloud config'); return first.id; @@ -472,6 +472,7 @@ function tunnelInstance(tunnel: Tunnel, kind: InstanceKind, quote: Quote, fallba currency: quote.currency, sku: quote.sku, region: fallbackRegion, + ...(tunnel.tunnel_token ? { metadata: { cloudflareTunnelToken: tunnel.tunnel_token } } : {}), }; } diff --git a/packages/core/src/cloud.ts b/packages/core/src/cloud.ts index f0295724..d11b0484 100644 --- a/packages/core/src/cloud.ts +++ b/packages/core/src/cloud.ts @@ -52,6 +52,7 @@ export interface Instance { sku?: string; region?: string; tags?: string[]; + metadata?: Record; } export interface ProvisionContext {