first commit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpCloudFunctionsAdapter} from './GcpCloudFunctionsAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const FUNCTIONS_PATH = '/v2/projects/floci-local/locations/us-central1/functions'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpCloudFunctionsAdapter {
|
||||
return new GcpCloudFunctionsAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function gen2Function(name: string) {
|
||||
return {
|
||||
name: `projects/floci-local/locations/us-central1/functions/${name}`,
|
||||
buildConfig: {runtime: 'nodejs20', entryPoint: 'helloWorld'},
|
||||
serviceConfig: {
|
||||
uri: `https://us-central1-floci-local.cloudfunctions.net/${name}`,
|
||||
availableMemory: '256M',
|
||||
service: `projects/floci-local/locations/us-central1/services/${name}`,
|
||||
revision: `projects/floci-local/locations/us-central1/functions/${name}/revisions/${name}-00001`,
|
||||
allTrafficOnLatestRevision: true,
|
||||
},
|
||||
state: 'ACTIVE',
|
||||
environment: 'GEN_2',
|
||||
url: `https://us-central1-floci-local.cloudfunctions.net/${name}`,
|
||||
createTime: '2026-06-22T05:29:13Z',
|
||||
updateTime: '2026-06-22T05:29:13Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpCloudFunctionsAdapter', () => {
|
||||
test('lists functions and maps the Gen2 resource shape', async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
functions: [gen2Function('hello')],
|
||||
}), {status: 200})) as unknown as typeof fetch
|
||||
|
||||
const resources = await adapter().list()
|
||||
expect(resources).toEqual([
|
||||
{
|
||||
id: 'hello',
|
||||
name: 'hello',
|
||||
cloud: 'gcp',
|
||||
service: 'serverless',
|
||||
type: 'gcp-function',
|
||||
region: 'us-central1',
|
||||
createdAt: '2026-06-22T05:29:13Z',
|
||||
status: 'ACTIVE',
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
serverlessService: 'cloud-functions',
|
||||
resourceName: 'projects/floci-local/locations/us-central1/functions/hello',
|
||||
environment: 'GEN_2',
|
||||
runtime: 'nodejs20',
|
||||
entryPoint: 'helloWorld',
|
||||
availableMemory: '256M',
|
||||
timeoutSeconds: undefined,
|
||||
uri: 'https://us-central1-floci-local.cloudfunctions.net/hello',
|
||||
service: 'projects/floci-local/locations/us-central1/services/hello',
|
||||
revision: 'projects/floci-local/locations/us-central1/functions/hello/revisions/hello-00001',
|
||||
allTrafficOnLatestRevision: true,
|
||||
updateTime: '2026-06-22T05:29:13Z',
|
||||
// Shared key so one serverless column works across all clouds.
|
||||
lastModified: '2026-06-22T05:29:13Z',
|
||||
labels: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
globalThis.fetch = (async () => new Response('{}', {status: 200})) as unknown as typeof fetch
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
functions: [gen2Function('alpha'), gen2Function('beta')],
|
||||
}), {status: 200})) as unknown as typeof fetch
|
||||
|
||||
const resources = await adapter().list({search: 'bet'})
|
||||
expect(resources.map((r) => r.name)).toEqual(['beta'])
|
||||
})
|
||||
|
||||
test('get returns null when the function is missing', async () => {
|
||||
globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch
|
||||
await expect(adapter().get('missing')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('create posts buildConfig with functionId and unwraps the operation response', async () => {
|
||||
const calls: Array<{url: string; init: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return new Response(JSON.stringify({
|
||||
done: true,
|
||||
response: gen2Function('hello'),
|
||||
}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const resource = await adapter().create({
|
||||
values: {functionName: 'hello', runtime: 'nodejs20', entryPoint: 'helloWorld'},
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe(`${ENDPOINT}${FUNCTIONS_PATH}?functionId=hello`)
|
||||
expect(calls[0].init.method).toBe('POST')
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
buildConfig: {runtime: 'nodejs20', entryPoint: 'helloWorld'},
|
||||
})
|
||||
expect(resource.id).toBe('hello')
|
||||
expect(resource.status).toBe('ACTIVE')
|
||||
expect(resource.metadata.runtime).toBe('nodejs20')
|
||||
})
|
||||
|
||||
test('create includes inline code as buildConfig.source when provided', async () => {
|
||||
let sentBody = ''
|
||||
globalThis.fetch = (async (_url: RequestInfo | URL, init: RequestInit) => {
|
||||
sentBody = String(init.body)
|
||||
return new Response(JSON.stringify({done: true, response: gen2Function('hello')}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
await adapter().create({
|
||||
values: {functionName: 'hello', runtime: 'nodejs20', entryPoint: 'helloWorld', code: 'exports.helloWorld = () => {}'},
|
||||
})
|
||||
expect(JSON.parse(sentBody)).toEqual({
|
||||
buildConfig: {
|
||||
runtime: 'nodejs20',
|
||||
entryPoint: 'helloWorld',
|
||||
source: {inlineCode: 'exports.helloWorld = () => {}'},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('create rejects when required fields are missing', async () => {
|
||||
await expect(adapter().create({values: {functionName: 'hello'}})).rejects.toThrow('runtime is required')
|
||||
})
|
||||
|
||||
test('delete issues a DELETE against the function path', async () => {
|
||||
const calls: Array<{url: string; method?: string}> = []
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => {
|
||||
calls.push({url: String(url), method: init.method})
|
||||
return new Response(JSON.stringify({done: true, response: {}}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
await adapter().delete('hello')
|
||||
expect(calls[0].url).toBe(`${ENDPOINT}${FUNCTIONS_PATH}/hello`)
|
||||
expect(calls[0].method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpServerlessSchema} from '../cloud-spi/serverlessSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Talks to the Floci-GCP emulator's Cloud Functions service. The emulator
|
||||
* mirrors the public Cloud Functions Gen2 (v2) REST API — verified against
|
||||
* `floci/floci-gcp`:
|
||||
*
|
||||
* list GET /v2/projects/{project}/locations/{location}/functions -> {functions: [...]}
|
||||
* get GET /v2/projects/{project}/locations/{location}/functions/{id} -> Function
|
||||
* create POST /v2/projects/{project}/locations/{location}/functions?functionId={id} -> Operation
|
||||
* delete DELETE /v2/projects/{project}/locations/{location}/functions/{id} -> Operation
|
||||
*
|
||||
* Notes from probing the emulator:
|
||||
* - The `locations/-` wildcard is NOT supported (returns `{}`), so list is
|
||||
* scoped to the configured location (FLOCI_GCP_LOCATION, default us-central1).
|
||||
* - create/delete return a long-running Operation envelope with `done: true`;
|
||||
* the function resource lives under `operation.response`.
|
||||
* - A function's resource `name` is the fully-qualified path
|
||||
* `projects/{project}/locations/{location}/functions/{shortName}`; we expose
|
||||
* the short name as the resource id.
|
||||
* - Gen2 nests runtime/entryPoint under `buildConfig` and the trigger/uri under
|
||||
* `serviceConfig`; the deploy state is `state` (not `status`).
|
||||
*/
|
||||
interface GcpFunction {
|
||||
name?: string
|
||||
state?: string
|
||||
environment?: string
|
||||
url?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
labels?: Record<string, string>
|
||||
buildConfig?: {
|
||||
runtime?: string
|
||||
entryPoint?: string
|
||||
source?: Record<string, unknown>
|
||||
}
|
||||
serviceConfig?: {
|
||||
uri?: string
|
||||
availableMemory?: string
|
||||
timeoutSeconds?: number
|
||||
service?: string
|
||||
revision?: string
|
||||
allTrafficOnLatestRevision?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface GcpFunctionList {
|
||||
functions?: GcpFunction[]
|
||||
nextPageToken?: string
|
||||
}
|
||||
|
||||
interface GcpOperation {
|
||||
done?: boolean
|
||||
response?: GcpFunction
|
||||
}
|
||||
|
||||
export class GcpCloudFunctionsAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'serverless' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpServerlessSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<GcpFunctionList>(this.functionsPath())
|
||||
return filterBySearch((body?.functions ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const fn = await this.client.json<GcpFunction>(
|
||||
`${this.functionsPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return fn ? toResource(fn) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const functionName = stringValue(input.values.functionName ?? input.values.name)
|
||||
const runtime = stringValue(input.values.runtime)
|
||||
const entryPoint = stringValue(input.values.entryPoint)
|
||||
const code = stringValue(input.values.code)
|
||||
|
||||
if (!functionName) throw new ValidationError('functionName is required')
|
||||
if (!runtime) throw new ValidationError('runtime is required')
|
||||
if (!entryPoint) throw new ValidationError('entryPoint is required')
|
||||
|
||||
const operation = await this.client.json<GcpOperation>(
|
||||
`${this.functionsPath()}?functionId=${encodeURIComponent(functionName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
buildConfig: {
|
||||
runtime,
|
||||
entryPoint,
|
||||
...(code ? {source: {inlineCode: code}} : {}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// create returns an Operation envelope; the function is under `response`.
|
||||
const fn = operation?.response ?? (operation as unknown as GcpFunction | null)
|
||||
return toResource(fn ?? {name: functionName})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true})
|
||||
}
|
||||
|
||||
private functionsPath(): string {
|
||||
return `/v2/projects/${encodeURIComponent(this.client.project)}/locations/${encodeURIComponent(this.client.location)}/functions`
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function toResource(fn: GcpFunction): CloudResource {
|
||||
const name = shortName(fn.name ?? '')
|
||||
const build = fn.buildConfig ?? {}
|
||||
const serviceConfig = fn.serviceConfig ?? {}
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'serverless',
|
||||
type: 'gcp-function',
|
||||
region: locationOf(fn.name ?? ''),
|
||||
createdAt: fn.createTime ?? fn.updateTime ?? null,
|
||||
status: fn.state ?? null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
serverlessService: 'cloud-functions',
|
||||
resourceName: fn.name,
|
||||
environment: fn.environment,
|
||||
runtime: build.runtime,
|
||||
entryPoint: build.entryPoint,
|
||||
availableMemory: serviceConfig.availableMemory,
|
||||
timeoutSeconds: serviceConfig.timeoutSeconds,
|
||||
uri: serviceConfig.uri ?? fn.url,
|
||||
service: serviceConfig.service,
|
||||
revision: serviceConfig.revision,
|
||||
allTrafficOnLatestRevision: serviceConfig.allTrafficOnLatestRevision,
|
||||
updateTime: fn.updateTime,
|
||||
// Shared key so the serverless schema can surface one column for all clouds.
|
||||
lastModified: fn.updateTime,
|
||||
labels: fn.labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shortName(resourceName: string): string {
|
||||
const match = resourceName.match(/functions\/([^/]+)$/)
|
||||
return match ? match[1] : resourceName
|
||||
}
|
||||
|
||||
function locationOf(resourceName: string): string | null {
|
||||
const match = resourceName.match(/locations\/([^/]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpCloudRunAdapter} from './GcpCloudRunAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const SERVICES_PATH = '/v2/projects/floci-local/locations/us-central1/services'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpCloudRunAdapter {
|
||||
return new GcpCloudRunAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
/** Records every request so path, method and body can be asserted. */
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{url: string; init?: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return handler(String(url), init)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {status})
|
||||
}
|
||||
|
||||
function cloudRunService(name: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: `projects/floci-local/locations/us-central1/services/${name}`,
|
||||
uid: '4cd5586e-3fca-460b-ac38-2267fc4b8b79',
|
||||
generation: '1',
|
||||
createTime: '2026-07-28T09:21:30.065937592Z',
|
||||
updateTime: '2026-07-28T09:21:30.065937592Z',
|
||||
template: {containers: [{image: 'nginx:alpine'}]},
|
||||
traffic: [{type: 'TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST', percent: 100}],
|
||||
urls: [`http://${name}-1efb2c36c23f.us-central1.run.localhost.floci.io:4588`],
|
||||
observedGeneration: '1',
|
||||
terminalCondition: {
|
||||
type: 'Ready',
|
||||
state: 'CONDITION_SUCCEEDED',
|
||||
message: 'Ready',
|
||||
lastTransitionTime: '2026-07-28T09:21:30.065937592Z',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpCloudRunAdapter', () => {
|
||||
test('identifies itself as the GCP containers adapter', () => {
|
||||
const a = adapter()
|
||||
|
||||
expect(a.cloud).toBe('gcp')
|
||||
expect(a.service).toBe('containers')
|
||||
expect(a.schema().displayName).toBe('Cloud Run')
|
||||
})
|
||||
|
||||
test('lists services and maps the v2 resource shape', async () => {
|
||||
const calls = stubFetch(() => json({services: [cloudRunService('web')]}))
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}`)
|
||||
expect(resource).toMatchObject({
|
||||
id: 'web',
|
||||
name: 'web',
|
||||
cloud: 'gcp',
|
||||
service: 'containers',
|
||||
type: 'cloud-run-service',
|
||||
region: 'us-central1',
|
||||
createdAt: '2026-07-28T09:21:30.065937592Z',
|
||||
status: 'SUCCEEDED',
|
||||
})
|
||||
expect(resource?.metadata).toMatchObject({
|
||||
image: 'nginx:alpine',
|
||||
uri: 'http://web-1efb2c36c23f.us-central1.run.localhost.floci.io:4588',
|
||||
generation: '1',
|
||||
})
|
||||
})
|
||||
|
||||
test('reports an empty list when the runtime returns no services key', async () => {
|
||||
// The runtime answers `{}` rather than `{"services": []}` when empty.
|
||||
stubFetch(() => json({}))
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('surfaces a failed readiness condition instead of hiding it', async () => {
|
||||
// A container that does not listen on $PORT never becomes ready. The state
|
||||
// and the runtime's explanation both have to reach the UI.
|
||||
stubFetch(() =>
|
||||
json({
|
||||
services: [
|
||||
cloudRunService('broken', {
|
||||
terminalCondition: {
|
||||
type: 'Ready',
|
||||
state: 'CONDITION_FAILED',
|
||||
message: 'Cloud Run runtime did not become ready before timeout: Connection refused',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource?.status).toBe('FAILED')
|
||||
expect(resource?.metadata.terminalMessage).toContain('did not become ready')
|
||||
})
|
||||
|
||||
test('follows nextPageToken until the runtime stops paging', async () => {
|
||||
let call = 0
|
||||
const calls = stubFetch(() => {
|
||||
call += 1
|
||||
if (call === 1) return json({services: [cloudRunService('one')], nextPageToken: 'page-2'})
|
||||
return json({services: [cloudRunService('two')]})
|
||||
})
|
||||
|
||||
const resources = await adapter().list()
|
||||
|
||||
expect(resources.map((r) => r.id)).toEqual(['one', 'two'])
|
||||
expect(calls[1]?.url).toContain('pageToken=page-2')
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
stubFetch(() => json({services: [cloudRunService('web'), cloudRunService('api')]}))
|
||||
const a = adapter()
|
||||
|
||||
await expect(a.list({search: 'we'})).resolves.toHaveLength(1)
|
||||
await expect(a.list({search: 'nope'})).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('inspects a service by short name', async () => {
|
||||
const calls = stubFetch(() => json(cloudRunService('web')))
|
||||
const resource = await adapter().get('web')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`)
|
||||
expect(resource?.id).toBe('web')
|
||||
})
|
||||
|
||||
test('returns null when a service does not exist', async () => {
|
||||
stubFetch(() => json({error: {code: 404, message: 'Cloud Run service not found', status: 'NOT_FOUND'}}, 404))
|
||||
await expect(adapter().get('missing')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('deploys a service with the image under the template', async () => {
|
||||
const calls = stubFetch((url, init) =>
|
||||
init?.method === 'POST'
|
||||
? json({name: 'projects/floci-local/locations/us-central1/operations/op-1'})
|
||||
: json(cloudRunService('web')),
|
||||
)
|
||||
const resource = await adapter().create({values: {name: 'web', image: 'nginx:alpine'}})
|
||||
|
||||
const post = calls[0]
|
||||
expect(post?.url).toBe(`${ENDPOINT}${SERVICES_PATH}?serviceId=web`)
|
||||
expect(post?.init?.method).toBe('POST')
|
||||
expect(JSON.parse(String(post?.init?.body))).toEqual({
|
||||
template: {containers: [{image: 'nginx:alpine'}]},
|
||||
})
|
||||
// create returns an Operation, so the service is read back rather than
|
||||
// parsed out of the envelope.
|
||||
expect(calls[1]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`)
|
||||
expect(resource.id).toBe('web')
|
||||
})
|
||||
|
||||
test('passes a container port through when one is given', async () => {
|
||||
const calls = stubFetch((url, init) =>
|
||||
init?.method === 'POST' ? json({name: 'op'}) : json(cloudRunService('web')),
|
||||
)
|
||||
await adapter().create({values: {name: 'web', image: 'nginx:alpine', port: '3000'}})
|
||||
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
|
||||
template: {containers: [{image: 'nginx:alpine', ports: [{containerPort: 3000}]}]},
|
||||
})
|
||||
})
|
||||
|
||||
test('requires the fields the schema marks required', async () => {
|
||||
const a = adapter()
|
||||
|
||||
await expect(a.create({values: {}})).rejects.toThrow(new ValidationError('name is required'))
|
||||
await expect(a.create({values: {name: 'web'}})).rejects.toThrow(new ValidationError('image is required'))
|
||||
})
|
||||
|
||||
test('rejects a name the runtime would refuse', async () => {
|
||||
const a = adapter()
|
||||
|
||||
for (const name of ['Web', '1web', 'web_service', 'web-']) {
|
||||
await expect(a.create({values: {name, image: 'nginx'}})).rejects.toThrow(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects a port that is not a number', async () => {
|
||||
await expect(
|
||||
adapter().create({values: {name: 'web', image: 'nginx', port: 'http'}}),
|
||||
).rejects.toThrow(ValidationError)
|
||||
})
|
||||
|
||||
test('deletes a service by short name', async () => {
|
||||
const calls = stubFetch(() => json({name: 'op-delete'}))
|
||||
await adapter().delete('web')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${SERVICES_PATH}/web`)
|
||||
expect(calls[0]?.init?.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import {
|
||||
CLOUD_RUN_NAME_MAX_LENGTH,
|
||||
CLOUD_RUN_NAME_PATTERN,
|
||||
gcpContainersSchema,
|
||||
} from '../cloud-spi/containersSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Talks to the Floci-GCP emulator's Cloud Run service. The emulator mirrors the
|
||||
* public Cloud Run Admin v2 REST API — verified against `floci/floci-gcp` 0.5.0:
|
||||
*
|
||||
* list GET /v2/projects/{p}/locations/{l}/services -> {services: [...]}
|
||||
* get GET /v2/projects/{p}/locations/{l}/services/{id} -> Service
|
||||
* create POST /v2/projects/{p}/locations/{l}/services?serviceId={id} -> Operation
|
||||
* delete DELETE /v2/projects/{p}/locations/{l}/services/{id} -> Operation
|
||||
*
|
||||
* Notes from probing the emulator:
|
||||
* - An empty project returns `{}`, not `{"services": []}`.
|
||||
* - create really does start a container: a service with `nginx:alpine` produced
|
||||
* a `floci-cloudrun-*` container running that image.
|
||||
* - create is asynchronous. It returns an Operation whose `metadata` holds the
|
||||
* Service, and the service is immediately listable as `CONDITION_PENDING`, so
|
||||
* this adapter reads the service back rather than unpacking the envelope.
|
||||
* - Readiness is real: a container that does not listen on $PORT settles at
|
||||
* `CONDITION_FAILED` with "did not become ready before timeout". That state is
|
||||
* surfaced, not hidden — it is the single most common deploy mistake.
|
||||
*/
|
||||
interface CloudRunContainer {
|
||||
image?: string
|
||||
ports?: Array<{containerPort?: number; name?: string}>
|
||||
resources?: {limits?: Record<string, string>}
|
||||
}
|
||||
|
||||
interface CloudRunService {
|
||||
name?: string
|
||||
uid?: string
|
||||
generation?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
template?: {containers?: CloudRunContainer[]; revision?: string; serviceAccount?: string}
|
||||
traffic?: Array<{type?: string; percent?: number; revision?: string}>
|
||||
urls?: string[]
|
||||
observedGeneration?: string
|
||||
terminalCondition?: {type?: string; state?: string; message?: string; lastTransitionTime?: string}
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
interface CloudRunServiceList {
|
||||
services?: CloudRunService[]
|
||||
nextPageToken?: string
|
||||
}
|
||||
|
||||
export class GcpCloudRunAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'containers' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpContainersSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const services: CloudRunService[] = []
|
||||
let pageToken: string | undefined
|
||||
|
||||
do {
|
||||
const path = pageToken
|
||||
? `${this.servicesPath()}?pageToken=${encodeURIComponent(pageToken)}`
|
||||
: this.servicesPath()
|
||||
const body = await this.client.json<CloudRunServiceList>(path)
|
||||
services.push(...(body?.services ?? []))
|
||||
pageToken = body?.nextPageToken
|
||||
} while (pageToken)
|
||||
|
||||
return filterBySearch(services.map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const service = await this.client.json<CloudRunService>(
|
||||
`${this.servicesPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return service ? toResource(service) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = requiredString(input.values.name, 'name')
|
||||
if (name.length > CLOUD_RUN_NAME_MAX_LENGTH || !new RegExp(CLOUD_RUN_NAME_PATTERN).test(name)) {
|
||||
throw new ValidationError(
|
||||
'name must start with a letter and contain only lowercase letters, digits and hyphens',
|
||||
)
|
||||
}
|
||||
const image = requiredString(input.values.image, 'image')
|
||||
const port = optionalPort(input.values.port)
|
||||
|
||||
const container: CloudRunContainer = {image, ...(port ? {ports: [{containerPort: port}]} : {})}
|
||||
|
||||
await this.client.json(this.servicesPath(`?serviceId=${encodeURIComponent(name)}`), {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({template: {containers: [container]}}),
|
||||
})
|
||||
|
||||
// The Operation envelope carries the Service under `metadata`, but reading
|
||||
// it back is what the runtime actually holds — including the pending
|
||||
// readiness state a fresh deploy starts in.
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new RuntimeError(`Cloud Run service ${name} was created but could not be read back`)
|
||||
return created
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(
|
||||
`${this.servicesPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'DELETE'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
}
|
||||
|
||||
private servicesPath(suffix = ''): string {
|
||||
const project = encodeURIComponent(this.client.project)
|
||||
const location = encodeURIComponent(this.client.location)
|
||||
return `/v2/projects/${project}/locations/${location}/services${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(service: CloudRunService): CloudResource {
|
||||
const name = shortName(service.name ?? '')
|
||||
const container = service.template?.containers?.[0] ?? {}
|
||||
const condition = service.terminalCondition ?? {}
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'containers',
|
||||
type: 'cloud-run-service',
|
||||
region: locationOf(service.name ?? ''),
|
||||
createdAt: service.createTime ?? service.updateTime ?? null,
|
||||
// CONDITION_SUCCEEDED / CONDITION_PENDING / CONDITION_FAILED — the prefix
|
||||
// is noise in a table cell, the value itself is the runtime's.
|
||||
status: readinessState(condition.state),
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
containerService: 'cloud-run',
|
||||
resourceName: service.name,
|
||||
uid: service.uid,
|
||||
image: container.image,
|
||||
containerPort: container.ports?.[0]?.containerPort,
|
||||
resourceLimits: container.resources?.limits,
|
||||
urls: service.urls,
|
||||
uri: service.urls?.[0],
|
||||
traffic: service.traffic,
|
||||
generation: service.generation,
|
||||
observedGeneration: service.observedGeneration,
|
||||
/** Why the service is not ready, straight from the runtime. */
|
||||
terminalMessage: condition.message,
|
||||
serviceAccount: service.template?.serviceAccount,
|
||||
updateTime: service.updateTime,
|
||||
lastModified: service.updateTime,
|
||||
labels: service.labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** `CONDITION_FAILED` -> `FAILED`; anything unexpected passes through unchanged. */
|
||||
function readinessState(state: string | undefined): string | null {
|
||||
if (!state) return null
|
||||
return state.startsWith('CONDITION_') ? state.slice('CONDITION_'.length) : state
|
||||
}
|
||||
|
||||
function shortName(resourceName: string): string {
|
||||
const match = resourceName.match(/services\/([^/]+)$/)
|
||||
return match ? match[1] : resourceName
|
||||
}
|
||||
|
||||
function locationOf(resourceName: string): string | null {
|
||||
const match = resourceName.match(/locations\/([^/]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'string' ? value.trim() : ''
|
||||
if (!raw) throw new ValidationError(`${field} is required`)
|
||||
return raw
|
||||
}
|
||||
|
||||
function optionalPort(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const port = Number(value)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new ValidationError('port must be an integer between 1 and 65535')
|
||||
}
|
||||
return port
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpCloudSqlAdapter} from './GcpCloudSqlAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const INSTANCES_PATH = '/sql/v1beta4/projects/floci-local/instances'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpCloudSqlAdapter {
|
||||
return new GcpCloudSqlAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{url: string; init?: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return handler(String(url), init)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
/** Create answers with this receipt, not the instance. Captured from floci-gcp 0.5.0. */
|
||||
function sqlOperation(targetId: string) {
|
||||
return {
|
||||
kind: 'sql#operation',
|
||||
name: 'b4e23845-3325-48e4-95a3-d558f44540e8',
|
||||
targetId,
|
||||
targetProject: 'floci-local',
|
||||
status: 'DONE',
|
||||
operationType: 'CREATE',
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape captured from floci-gcp 0.5.0. */
|
||||
function sqlInstance(name: string) {
|
||||
return {
|
||||
name,
|
||||
databaseVersion: 'POSTGRES_15',
|
||||
region: 'us-central1',
|
||||
settings: {tier: 'db-f1-micro'},
|
||||
kind: 'sql#instance',
|
||||
project: 'floci-local',
|
||||
backendType: 'SECOND_GEN',
|
||||
instanceType: 'CLOUD_SQL_INSTANCE',
|
||||
state: 'RUNNABLE',
|
||||
gceZone: 'us-central1-a',
|
||||
connectionName: `floci-local:us-central1:${name}`,
|
||||
ipAddresses: [{type: 'PRIMARY', ipAddress: '172.20.0.5', port: 5432}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpCloudSqlAdapter', () => {
|
||||
test('identifies itself as the GCP database adapter', () => {
|
||||
const instance = adapter()
|
||||
expect(instance.cloud).toBe('gcp')
|
||||
expect(instance.service).toBe('database')
|
||||
expect(instance.schema().displayName).toBe('Cloud SQL')
|
||||
})
|
||||
|
||||
test('lists instances and normalizes the sqladmin shape', async () => {
|
||||
const calls = stubFetch(() => new Response(
|
||||
JSON.stringify({kind: 'sql#instancesList', items: [sqlInstance('orders-db')]}),
|
||||
{status: 200},
|
||||
))
|
||||
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}`)
|
||||
expect(resource).toMatchObject({
|
||||
id: 'orders-db',
|
||||
name: 'orders-db',
|
||||
cloud: 'gcp',
|
||||
service: 'database',
|
||||
type: 'db-instance',
|
||||
region: 'us-central1',
|
||||
status: 'RUNNABLE',
|
||||
engine: 'POSTGRES_15',
|
||||
instanceClass: 'db-f1-micro',
|
||||
})
|
||||
// The schema surfaces the connection endpoint through a metadata path.
|
||||
expect(resource?.metadata.connectionName).toBe('floci-local:us-central1:orders-db')
|
||||
expect(resource?.metadata.ipAddress).toBe('172.20.0.5')
|
||||
expect(resource?.metadata.port).toBe(5432)
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({kind: 'sql#instancesList'}), {status: 200}))
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({items: [sqlInstance('orders-db'), sqlInstance('billing-db')]}),
|
||||
{status: 200},
|
||||
))
|
||||
|
||||
await expect(adapter().list({search: 'orders'})).resolves.toHaveLength(1)
|
||||
await expect(adapter().list({search: 'db'})).resolves.toHaveLength(2)
|
||||
await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('inspects a single instance', async () => {
|
||||
const calls = stubFetch(() => new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}))
|
||||
const resource = await adapter().get('orders-db')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`)
|
||||
expect(resource?.id).toBe('orders-db')
|
||||
})
|
||||
|
||||
test('returns null when the instance does not exist', async () => {
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope', status: 'NOT_FOUND'}}),
|
||||
{status: 404},
|
||||
))
|
||||
await expect(adapter().get('nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('creates an instance with the documented defaults', async () => {
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200})
|
||||
: new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}),
|
||||
)
|
||||
await adapter().create({values: {instanceName: 'orders-db'}})
|
||||
|
||||
const body = JSON.parse(String(calls[0]?.init?.body))
|
||||
expect(calls[0]?.init?.method).toBe('POST')
|
||||
expect(body).toEqual({
|
||||
name: 'orders-db',
|
||||
databaseVersion: 'POSTGRES_15',
|
||||
region: 'us-central1',
|
||||
settings: {tier: 'db-f1-micro'},
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves the operation receipt into the created instance', async () => {
|
||||
// Create returns a sql#operation naming the instance, not the instance —
|
||||
// echoing the receipt would surface the operation UUID as the resource name.
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200})
|
||||
: new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}),
|
||||
)
|
||||
|
||||
const resource = await adapter().create({values: {instanceName: 'orders-db'}})
|
||||
|
||||
expect(resource.id).toBe('orders-db')
|
||||
expect(resource.status).toBe('RUNNABLE')
|
||||
// POST, then a read-back of the named instance.
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`)
|
||||
})
|
||||
|
||||
test('uses an embedded resource when the runtime provides one', async () => {
|
||||
const calls = stubFetch(() => new Response(
|
||||
JSON.stringify({done: true, response: sqlInstance('orders-db')}),
|
||||
{status: 200},
|
||||
))
|
||||
|
||||
const resource = await adapter().create({values: {instanceName: 'orders-db'}})
|
||||
|
||||
expect(resource.id).toBe('orders-db')
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('passes through an explicit version, region and tier', async () => {
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(sqlOperation('orders-db')), {status: 200})
|
||||
: new Response(JSON.stringify(sqlInstance('orders-db')), {status: 200}),
|
||||
)
|
||||
await adapter().create({
|
||||
values: {instanceName: 'orders-db', databaseVersion: 'POSTGRES_16', region: 'europe-west1', tier: 'db-g1-small'},
|
||||
})
|
||||
|
||||
const body = JSON.parse(String(calls[0]?.init?.body))
|
||||
expect(body.databaseVersion).toBe('POSTGRES_16')
|
||||
expect(body.region).toBe('europe-west1')
|
||||
expect(body.settings.tier).toBe('db-g1-small')
|
||||
})
|
||||
|
||||
test('requires an instance name', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
})
|
||||
|
||||
test('rejects a name the runtime would refuse', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
for (const name of ['1starts-with-digit', 'Has-Upper', 'has_underscore', 'a'.repeat(63)]) {
|
||||
await expect(adapter().create({values: {instanceName: name}})).rejects.toBeInstanceOf(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('deletes an instance', async () => {
|
||||
const calls = stubFetch(() => new Response('{}', {status: 200}))
|
||||
await adapter().delete('orders-db')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${INSTANCES_PATH}/orders-db`)
|
||||
expect(calls[0]?.init?.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
test('surfaces a missing instance on delete rather than silently succeeding', async () => {
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({error: {code: 404, message: 'Cloud SQL instance not found: nope'}}),
|
||||
{status: 404},
|
||||
))
|
||||
await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
test("surfaces the runtime's engine restriction verbatim", async () => {
|
||||
// The emulator only supports PostgreSQL; the reason must reach the user.
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({error: {code: 400, message: 'Only PostgreSQL Cloud SQL instances are supported'}}),
|
||||
{status: 400},
|
||||
))
|
||||
|
||||
await expect(adapter().create({values: {instanceName: 'mysql-db', databaseVersion: 'MYSQL_8_0'}}))
|
||||
.rejects.toThrow('Only PostgreSQL Cloud SQL instances are supported')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpDatabaseSchema} from '../cloud-spi/databaseSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Cloud SQL through the Floci-GCP emulator, which mirrors the public
|
||||
* `sqladmin` v1beta4 REST API. Verified against `floci/floci-gcp` 0.5.0:
|
||||
*
|
||||
* GET /sql/v1beta4/projects/{project}/instances
|
||||
* POST /sql/v1beta4/projects/{project}/instances
|
||||
* GET /sql/v1beta4/projects/{project}/instances/{instance}
|
||||
* DELETE /sql/v1beta4/projects/{project}/instances/{instance}
|
||||
*
|
||||
* The runtime backs each instance with a real Postgres container, so it only
|
||||
* accepts PostgreSQL and rejects other engines with a 400.
|
||||
*/
|
||||
|
||||
interface GcpSqlIpAddress {
|
||||
type?: string
|
||||
ipAddress?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
interface GcpSqlInstance {
|
||||
name?: string
|
||||
databaseVersion?: string
|
||||
region?: string
|
||||
state?: string
|
||||
project?: string
|
||||
gceZone?: string
|
||||
backendType?: string
|
||||
instanceType?: string
|
||||
connectionName?: string
|
||||
createTime?: string
|
||||
ipAddresses?: GcpSqlIpAddress[]
|
||||
settings?: {tier?: string; dataDiskSizeGb?: string; activationPolicy?: string}
|
||||
}
|
||||
|
||||
interface GcpSqlInstanceList {
|
||||
items?: GcpSqlInstance[]
|
||||
}
|
||||
|
||||
export class GcpCloudSqlAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'database' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpDatabaseSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<GcpSqlInstanceList>(this.instancesPath())
|
||||
return filterBySearch((body?.items ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const instance = await this.client.json<GcpSqlInstance>(
|
||||
`${this.instancesPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return instance ? toResource(instance) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = stringValue(input.values.instanceName ?? input.values.name)
|
||||
const databaseVersion = stringValue(input.values.databaseVersion) || 'POSTGRES_15'
|
||||
const region = stringValue(input.values.region) || 'us-central1'
|
||||
const tier = stringValue(input.values.tier) || 'db-f1-micro'
|
||||
|
||||
if (!name) throw new ValidationError('instanceName is required')
|
||||
if (!isValidInstanceName(name)) {
|
||||
throw new ValidationError(
|
||||
'Use a valid Cloud SQL instance name: 1-62 lowercase letters, numbers, or hyphens, starting with a letter.',
|
||||
)
|
||||
}
|
||||
|
||||
const result = await this.client.json<GcpOperationEnvelope<GcpSqlInstance> | GcpSqlInstance>(
|
||||
this.instancesPath(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name, databaseVersion, region, settings: {tier}}),
|
||||
},
|
||||
)
|
||||
|
||||
// sqladmin answers with a `sql#operation` that names the instance but does
|
||||
// not embed it, so read it back rather than echoing the request.
|
||||
const embedded = operationResponse<GcpSqlInstance>(result)
|
||||
if (embedded) return toResource(embedded)
|
||||
|
||||
const created = await this.get(operationTargetId(result) ?? name)
|
||||
return created ?? toResource({name, databaseVersion, region, settings: {tier}})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.instancesPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
/** Cheaper than list(): the runtime keeps instance metadata in memory. */
|
||||
async health(): Promise<void> {
|
||||
await this.client.fetch(this.instancesPath(), {method: 'GET'})
|
||||
}
|
||||
|
||||
private instancesPath(): string {
|
||||
return `/sql/v1beta4/projects/${encodeURIComponent(this.client.project)}/instances`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(instance: GcpSqlInstance): CloudResource {
|
||||
const name = instance.name ?? ''
|
||||
const primaryIp = instance.ipAddresses?.find((address) => address.type === 'PRIMARY')
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'database',
|
||||
type: 'db-instance',
|
||||
region: instance.region ?? null,
|
||||
createdAt: instance.createTime ?? null,
|
||||
status: instance.state ?? null,
|
||||
engine: instance.databaseVersion ?? null,
|
||||
version: instance.databaseVersion ?? null,
|
||||
instanceClass: instance.settings?.tier ?? null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
databaseService: 'cloud-sql',
|
||||
project: instance.project,
|
||||
gceZone: instance.gceZone,
|
||||
backendType: instance.backendType,
|
||||
instanceType: instance.instanceType,
|
||||
connectionName: instance.connectionName,
|
||||
tier: instance.settings?.tier,
|
||||
ipAddress: primaryIp?.ipAddress,
|
||||
port: primaryIp?.port,
|
||||
ipAddresses: instance.ipAddresses,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function isValidInstanceName(value: string): boolean {
|
||||
return /^[a-z][a-z0-9-]{0,61}$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpGkeAdapter} from './GcpGkeAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const CLUSTERS_PATH = '/container/v1/projects/floci-local/locations/us-central1/clusters'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpGkeAdapter {
|
||||
return new GcpGkeAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{url: string; init?: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return handler(String(url), init)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
/** Shape captured from floci-gcp 0.5.0. */
|
||||
function gkeCluster(name: string) {
|
||||
return {
|
||||
name,
|
||||
status: 'RUNNING',
|
||||
location: 'us-central1',
|
||||
endpoint: 'localhost:6550',
|
||||
network: 'default',
|
||||
subnetwork: 'default',
|
||||
createTime: '2026-07-28T04:01:57.839066676Z',
|
||||
currentMasterVersion: '1.30.5-gke.1014001',
|
||||
currentNodeVersion: '1.30.5-gke.1014001',
|
||||
initialClusterVersion: '1.30.5-gke.1014001',
|
||||
nodePools: [{name: 'default-pool', status: 'RUNNING'}],
|
||||
resourceLabels: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** GKE create answers with an Operation carrying a targetLink, not the cluster. */
|
||||
function gkeOperation(clusterName: string) {
|
||||
return {
|
||||
name: 'operation-28fabcf7-9df7-44d5-b84a-b59435fd9093',
|
||||
operationType: 'CREATE_CLUSTER',
|
||||
status: 'DONE',
|
||||
zone: 'us-central1',
|
||||
location: 'us-central1',
|
||||
targetLink: `projects/floci-local/locations/us-central1/clusters/${clusterName}`,
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpGkeAdapter', () => {
|
||||
test('identifies itself as the GCP k8s adapter', () => {
|
||||
const instance = adapter()
|
||||
expect(instance.cloud).toBe('gcp')
|
||||
expect(instance.service).toBe('k8s')
|
||||
expect(instance.schema().displayName).toBe('Google GKE')
|
||||
})
|
||||
|
||||
test('talks to the container.googleapis.com path, not the unprefixed one', async () => {
|
||||
// /v1/projects/{p}/locations/{l}/clusters on this runtime is Managed Service
|
||||
// for Apache Kafka — same path shape, entirely different resource. Binding
|
||||
// GKE there would surface Redpanda brokers as Kubernetes clusters.
|
||||
const calls = stubFetch(() => new Response(JSON.stringify({clusters: []}), {status: 200}))
|
||||
await adapter().list()
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}`)
|
||||
expect(calls[0]?.url).toContain('/container/v1/')
|
||||
})
|
||||
|
||||
test('lists clusters and normalizes the GKE shape', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({clusters: [gkeCluster('prod')]}), {status: 200}))
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource).toMatchObject({
|
||||
id: 'prod',
|
||||
name: 'prod',
|
||||
cloud: 'gcp',
|
||||
service: 'k8s',
|
||||
type: 'cluster',
|
||||
region: 'us-central1',
|
||||
status: 'RUNNING',
|
||||
version: '1.30.5-gke.1014001',
|
||||
})
|
||||
expect(resource?.metadata.endpoint).toBe('localhost:6550')
|
||||
expect(resource?.metadata.nodePoolCount).toBe(1)
|
||||
})
|
||||
|
||||
test('reduces a fully qualified cluster path to its name', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({
|
||||
clusters: [{...gkeCluster('prod'), name: 'projects/floci-local/locations/us-central1/clusters/prod'}],
|
||||
}), {status: 200}))
|
||||
|
||||
const [resource] = await adapter().list()
|
||||
expect(resource?.id).toBe('prod')
|
||||
expect(resource?.name).toBe('prod')
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({clusters: [gkeCluster('prod'), gkeCluster('staging')]}),
|
||||
{status: 200},
|
||||
))
|
||||
|
||||
await expect(adapter().list({search: 'prod'})).resolves.toHaveLength(1)
|
||||
await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('inspects a single cluster', async () => {
|
||||
const calls = stubFetch(() => new Response(JSON.stringify(gkeCluster('prod')), {status: 200}))
|
||||
const resource = await adapter().get('prod')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`)
|
||||
expect(resource?.id).toBe('prod')
|
||||
})
|
||||
|
||||
test('returns null when the cluster does not exist', async () => {
|
||||
stubFetch(() => new Response(
|
||||
JSON.stringify({error: {code: 404, message: 'cluster not found'}}),
|
||||
{status: 404},
|
||||
))
|
||||
await expect(adapter().get('nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('resolves the operation targetLink into the created cluster', async () => {
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(gkeOperation('prod')), {status: 200})
|
||||
: new Response(JSON.stringify(gkeCluster('prod')), {status: 200}),
|
||||
)
|
||||
|
||||
const resource = await adapter().create({values: {clusterName: 'prod'}})
|
||||
|
||||
// The operation names the cluster only via a path, so it is read back.
|
||||
expect(resource.id).toBe('prod')
|
||||
expect(resource.status).toBe('RUNNING')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`)
|
||||
})
|
||||
|
||||
test('sends the cluster body the runtime expects', async () => {
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(gkeOperation('prod')), {status: 200})
|
||||
: new Response(JSON.stringify(gkeCluster('prod')), {status: 200}),
|
||||
)
|
||||
await adapter().create({values: {clusterName: 'prod', initialNodeCount: '3'}})
|
||||
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
|
||||
cluster: {name: 'prod', initialNodeCount: 3},
|
||||
})
|
||||
})
|
||||
|
||||
test('defaults the node count', async () => {
|
||||
const calls = stubFetch((_url, init) =>
|
||||
init?.method === 'POST'
|
||||
? new Response(JSON.stringify(gkeOperation('prod')), {status: 200})
|
||||
: new Response(JSON.stringify(gkeCluster('prod')), {status: 200}),
|
||||
)
|
||||
await adapter().create({values: {clusterName: 'prod'}})
|
||||
|
||||
expect(JSON.parse(String(calls[0]?.init?.body)).cluster.initialNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('requires a cluster name', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
})
|
||||
|
||||
test('rejects a name the runtime would refuse', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
for (const name of ['1prod', 'Prod', 'has_underscore', 'a'.repeat(41)]) {
|
||||
await expect(adapter().create({values: {clusterName: name}})).rejects.toBeInstanceOf(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('deletes a cluster', async () => {
|
||||
const calls = stubFetch(() => new Response('{}', {status: 200}))
|
||||
await adapter().delete('prod')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${CLUSTERS_PATH}/prod`)
|
||||
expect(calls[0]?.init?.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
test('surfaces a missing cluster on delete', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'not found'}}), {status: 404}))
|
||||
await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpGkeSchema} from '../cloud-spi/eksSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import {type GcpOperationEnvelope, operationResponse, operationTargetId} from './operations'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* GKE through the Floci-GCP emulator, which mirrors the public
|
||||
* `container.googleapis.com` v1 REST API and backs each cluster with a real k3s
|
||||
* container. Verified against `floci/floci-gcp` 0.5.0:
|
||||
*
|
||||
* GET /container/v1/projects/{project}/locations/{location}/clusters
|
||||
* POST /container/v1/projects/{project}/locations/{location}/clusters
|
||||
* GET /container/v1/projects/{project}/locations/{location}/clusters/{cluster}
|
||||
* DELETE /container/v1/projects/{project}/locations/{location}/clusters/{cluster}
|
||||
*
|
||||
* Note the `/container/v1` prefix. The runtime also serves
|
||||
* `/v1/projects/{p}/locations/{l}/clusters`, but that is Managed Service for
|
||||
* Apache Kafka — same path shape, entirely different resource — so binding GKE
|
||||
* to the unprefixed path would surface Kafka brokers as Kubernetes clusters.
|
||||
*/
|
||||
|
||||
interface GkeNodePool {
|
||||
name?: string
|
||||
status?: string
|
||||
initialNodeCount?: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
interface GkeCluster {
|
||||
name?: string
|
||||
status?: string
|
||||
location?: string
|
||||
endpoint?: string
|
||||
network?: string
|
||||
subnetwork?: string
|
||||
createTime?: string
|
||||
currentMasterVersion?: string
|
||||
currentNodeVersion?: string
|
||||
initialClusterVersion?: string
|
||||
currentNodeCount?: number
|
||||
nodePools?: GkeNodePool[]
|
||||
resourceLabels?: Record<string, string>
|
||||
}
|
||||
|
||||
interface GkeClusterList {
|
||||
clusters?: GkeCluster[]
|
||||
}
|
||||
|
||||
export class GcpGkeAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'k8s' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpGkeSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<GkeClusterList>(this.clustersPath())
|
||||
return filterBySearch((body?.clusters ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const cluster = await this.client.json<GkeCluster>(
|
||||
`${this.clustersPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return cluster ? toResource(cluster) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = stringValue(input.values.clusterName ?? input.values.name)
|
||||
const initialNodeCount = Number(input.values.initialNodeCount ?? 1)
|
||||
|
||||
if (!name) throw new ValidationError('clusterName is required')
|
||||
if (!isValidClusterName(name)) {
|
||||
throw new ValidationError(
|
||||
'Use a valid GKE cluster name: 1-40 lowercase letters, numbers, or hyphens, starting with a letter.',
|
||||
)
|
||||
}
|
||||
|
||||
const result = await this.client.json<GcpOperationEnvelope<GkeCluster> | GkeCluster>(this.clustersPath(), {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
cluster: {
|
||||
name,
|
||||
initialNodeCount: Number.isFinite(initialNodeCount) ? initialNodeCount : 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// GKE answers with an Operation carrying a targetLink, not the cluster.
|
||||
const embedded = operationResponse<GkeCluster>(result)
|
||||
if (embedded) return toResource(embedded)
|
||||
|
||||
const created = await this.get(operationTargetId(result) ?? name)
|
||||
return created ?? toResource({name})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.clustersPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
private clustersPath(): string {
|
||||
const {project, location} = this.client
|
||||
return `/container/v1/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/clusters`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(cluster: GkeCluster): CloudResource {
|
||||
// The list response returns bare names while some paths return full paths.
|
||||
const name = (cluster.name ?? '').split('/').pop() ?? ''
|
||||
const version = cluster.currentMasterVersion ?? cluster.initialClusterVersion ?? null
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'k8s',
|
||||
type: 'cluster',
|
||||
region: cluster.location ?? null,
|
||||
createdAt: cluster.createTime ?? null,
|
||||
status: cluster.status ?? null,
|
||||
version,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
k8sService: 'gke',
|
||||
endpoint: cluster.endpoint,
|
||||
network: cluster.network,
|
||||
subnetwork: cluster.subnetwork,
|
||||
currentMasterVersion: cluster.currentMasterVersion,
|
||||
currentNodeVersion: cluster.currentNodeVersion,
|
||||
nodePools: cluster.nodePools,
|
||||
nodePoolCount: cluster.nodePools?.length ?? 0,
|
||||
currentNodeCount: cluster.currentNodeCount,
|
||||
labels: cluster.resourceLabels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function isValidClusterName(value: string): boolean {
|
||||
return /^[a-z][a-z0-9-]{0,39}$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpPubSubAdapter} from './GcpPubSubAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const TOPICS_PATH = '/v1/projects/floci-local/topics'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpPubSubAdapter {
|
||||
return new GcpPubSubAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{url: string; init?: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return handler(String(url), init)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
function topic(name: string) {
|
||||
return {name: `projects/floci-local/topics/${name}`}
|
||||
}
|
||||
|
||||
describe('GcpPubSubAdapter', () => {
|
||||
test('identifies itself as the GCP messaging adapter', () => {
|
||||
const instance = adapter()
|
||||
expect(instance.cloud).toBe('gcp')
|
||||
expect(instance.service).toBe('messaging')
|
||||
expect(instance.schema().displayName).toBe('Cloud Pub/Sub')
|
||||
})
|
||||
|
||||
test('lists topics and reduces the resource path to a short id', async () => {
|
||||
const calls = stubFetch(() => new Response(JSON.stringify({topics: [topic('events')]}), {status: 200}))
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}`)
|
||||
expect(resource).toMatchObject({
|
||||
id: 'events',
|
||||
name: 'events',
|
||||
cloud: 'gcp',
|
||||
service: 'messaging',
|
||||
type: 'topic',
|
||||
})
|
||||
// The full path stays available for anyone who needs to address it.
|
||||
expect(resource?.metadata.resourcePath).toBe('projects/floci-local/topics/events')
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({topics: [topic('events'), topic('audit')]}), {status: 200}))
|
||||
|
||||
await expect(adapter().list({search: 'even'})).resolves.toHaveLength(1)
|
||||
await expect(adapter().list({search: 'nope'})).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
test('inspects a single topic', async () => {
|
||||
const calls = stubFetch(() => new Response(JSON.stringify(topic('events')), {status: 200}))
|
||||
const resource = await adapter().get('events')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`)
|
||||
expect(resource?.id).toBe('events')
|
||||
})
|
||||
|
||||
test('returns null when the topic does not exist', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Topic not found'}}), {status: 404}))
|
||||
await expect(adapter().get('nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('creates with PUT on the resource path, not POST to the collection', async () => {
|
||||
// Pub/Sub's create verb is PUT; POSTing to /topics would 404 or 405.
|
||||
const calls = stubFetch(() => new Response(JSON.stringify(topic('events')), {status: 200}))
|
||||
const resource = await adapter().create({values: {topicName: 'events'}})
|
||||
|
||||
expect(calls[0]?.init?.method).toBe('PUT')
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`)
|
||||
expect(resource.id).toBe('events')
|
||||
})
|
||||
|
||||
test('requires a topic name', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
})
|
||||
|
||||
test('rejects a name Pub/Sub would refuse', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
for (const name of ['1starts-with-digit', 'ab', 'has space', 'has/slash']) {
|
||||
await expect(adapter().create({values: {topicName: name}})).rejects.toBeInstanceOf(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts the punctuation Pub/Sub allows', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify(topic('a.b-c_d~e')), {status: 200}))
|
||||
await expect(adapter().create({values: {topicName: 'a.b-c_d~e'}})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
test('deletes a topic', async () => {
|
||||
const calls = stubFetch(() => new Response('{}', {status: 200}))
|
||||
await adapter().delete('events')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${TOPICS_PATH}/events`)
|
||||
expect(calls[0]?.init?.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
test('surfaces a missing topic on delete', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Topic not found'}}), {status: 404}))
|
||||
await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpMessagingSchema} from '../cloud-spi/messagingSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Pub/Sub topics as `messaging` resources. Verified against `floci/floci-gcp`
|
||||
* 0.5.0, which mirrors the public Pub/Sub v1 REST API:
|
||||
*
|
||||
* GET /v1/projects/{project}/topics
|
||||
* PUT /v1/projects/{project}/topics/{topic} (create — not POST)
|
||||
* GET /v1/projects/{project}/topics/{topic}
|
||||
* DELETE /v1/projects/{project}/topics/{topic}
|
||||
*
|
||||
* Topics are addressed by the fully qualified `projects/{p}/topics/{id}` path;
|
||||
* the console uses the short id and rebuilds the path per call.
|
||||
*/
|
||||
|
||||
interface PubSubTopic {
|
||||
name?: string
|
||||
labels?: Record<string, string>
|
||||
kmsKeyName?: string
|
||||
messageRetentionDuration?: string
|
||||
}
|
||||
|
||||
interface PubSubTopicList {
|
||||
topics?: PubSubTopic[]
|
||||
}
|
||||
|
||||
export class GcpPubSubAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'messaging' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpMessagingSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<PubSubTopicList>(this.topicsPath())
|
||||
return filterBySearch((body?.topics ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const topic = await this.client.json<PubSubTopic>(
|
||||
`${this.topicsPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return topic ? toResource(topic) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = stringValue(input.values.topicName ?? input.values.name)
|
||||
if (!name) throw new ValidationError('topicName is required')
|
||||
if (!isValidTopicName(name)) {
|
||||
throw new ValidationError(
|
||||
'Use a valid Pub/Sub topic name: 3-255 characters starting with a letter, using letters, numbers, or - _ . ~ + %',
|
||||
)
|
||||
}
|
||||
|
||||
// Pub/Sub creates with PUT on the resource path, not POST to a collection.
|
||||
const topic = await this.client.json<PubSubTopic>(`${this.topicsPath()}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
|
||||
return toResource(topic ?? {name: `${this.projectPrefix()}/topics/${name}`})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.topicsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
private projectPrefix(): string {
|
||||
return `projects/${this.client.project}`
|
||||
}
|
||||
|
||||
private topicsPath(): string {
|
||||
return `/v1/projects/${encodeURIComponent(this.client.project)}/topics`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(topic: PubSubTopic): CloudResource {
|
||||
const path = topic.name ?? ''
|
||||
const name = path.split('/').pop() ?? path
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'messaging',
|
||||
type: 'topic',
|
||||
region: null,
|
||||
createdAt: null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
messagingService: 'pubsub',
|
||||
resourcePath: path,
|
||||
labels: topic.labels,
|
||||
kmsKeyName: topic.kmsKeyName,
|
||||
messageRetentionDuration: topic.messageRetentionDuration,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function isValidTopicName(value: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9\-_.~+%]{2,254}$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpSchedulerAdapter} from './GcpSchedulerAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const JOBS_PATH = '/v1/projects/floci-local/locations/us-central1/jobs'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpSchedulerAdapter {
|
||||
return new GcpSchedulerAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function sampleJob(name: string) {
|
||||
return {
|
||||
name: `projects/floci-local/locations/us-central1/jobs/${name}`,
|
||||
description: 'Daily database backup',
|
||||
schedule: '0 2 * * *',
|
||||
timeZone: 'UTC',
|
||||
state: 'ENABLED',
|
||||
httpTarget: {
|
||||
uri: 'https://api.floci-local.net/backup',
|
||||
httpMethod: 'POST',
|
||||
},
|
||||
userUpdateTime: '2026-06-22T05:29:13Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpSchedulerAdapter', () => {
|
||||
test('lists jobs and maps the resource shape', async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
jobs: [sampleJob('db-backup')],
|
||||
}), {status: 200})) as unknown as typeof fetch
|
||||
|
||||
const resources = await adapter().list()
|
||||
expect(resources).toEqual([
|
||||
{
|
||||
id: 'db-backup',
|
||||
name: 'db-backup',
|
||||
cloud: 'gcp',
|
||||
service: 'scheduler',
|
||||
type: 'scheduler-job',
|
||||
region: 'us-central1',
|
||||
createdAt: '2026-06-22T05:29:13Z',
|
||||
status: 'ENABLED',
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
resourceName: 'projects/floci-local/locations/us-central1/jobs/db-backup',
|
||||
schedule: '0 2 * * *',
|
||||
timeZone: 'UTC',
|
||||
target: 'https://api.floci-local.net/backup',
|
||||
httpMethod: 'POST',
|
||||
description: 'Daily database backup',
|
||||
scheduleTime: undefined,
|
||||
lastAttemptTime: undefined,
|
||||
userUpdateTime: '2026-06-22T05:29:13Z',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
globalThis.fetch = (async () => new Response('{}', {status: 200})) as unknown as typeof fetch
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('follows nextPageToken pagination and collects all jobs', async () => {
|
||||
const calls: string[] = []
|
||||
globalThis.fetch = (async (url: RequestInfo | URL) => {
|
||||
const urlStr = String(url)
|
||||
calls.push(urlStr)
|
||||
if (urlStr.includes('pageToken=token-page-2')) {
|
||||
return new Response(JSON.stringify({
|
||||
jobs: [sampleJob('job-page-2')],
|
||||
}), {status: 200})
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
jobs: [sampleJob('job-page-1')],
|
||||
nextPageToken: 'token-page-2',
|
||||
}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const resources = await adapter().list()
|
||||
expect(calls).toEqual([
|
||||
`${ENDPOINT}${JOBS_PATH}`,
|
||||
`${ENDPOINT}${JOBS_PATH}?pageToken=token-page-2`,
|
||||
])
|
||||
expect(resources.map((r) => r.name)).toEqual(['job-page-1', 'job-page-2'])
|
||||
})
|
||||
|
||||
test('filters across paginated results by search term', async () => {
|
||||
globalThis.fetch = (async (url: RequestInfo | URL) => {
|
||||
const urlStr = String(url)
|
||||
if (urlStr.includes('pageToken=page2')) {
|
||||
return new Response(JSON.stringify({
|
||||
jobs: [sampleJob('nightly-sync')],
|
||||
}), {status: 200})
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
jobs: [sampleJob('hourly-cleanup')],
|
||||
nextPageToken: 'page2',
|
||||
}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const resources = await adapter().list({search: 'sync'})
|
||||
expect(resources.map((r) => r.name)).toEqual(['nightly-sync'])
|
||||
})
|
||||
|
||||
test('get returns null when the job is not found', async () => {
|
||||
globalThis.fetch = (async () => new Response('Not Found', {status: 404})) as unknown as typeof fetch
|
||||
await expect(adapter().get('nonexistent')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('create posts job payload and returns mapped resource', async () => {
|
||||
const calls: Array<{url: string; init: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return new Response(JSON.stringify(sampleJob('db-backup')), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const resource = await adapter().create({
|
||||
values: {
|
||||
jobName: 'db-backup',
|
||||
schedule: '0 2 * * *',
|
||||
timeZone: 'UTC',
|
||||
targetUri: 'https://api.floci-local.net/backup',
|
||||
httpMethod: 'POST',
|
||||
description: 'Daily database backup',
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe(`${ENDPOINT}${JOBS_PATH}`)
|
||||
expect(calls[0].init.method).toBe('POST')
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
name: 'projects/floci-local/locations/us-central1/jobs/db-backup',
|
||||
description: 'Daily database backup',
|
||||
schedule: '0 2 * * *',
|
||||
timeZone: 'UTC',
|
||||
httpTarget: {
|
||||
uri: 'https://api.floci-local.net/backup',
|
||||
httpMethod: 'POST',
|
||||
},
|
||||
})
|
||||
expect(resource.id).toBe('db-backup')
|
||||
expect(resource.status).toBe('ENABLED')
|
||||
expect(resource.metadata.schedule).toBe('0 2 * * *')
|
||||
})
|
||||
|
||||
test('create rejects when required fields are missing', async () => {
|
||||
await expect(adapter().create({values: {jobName: 'test'}})).rejects.toThrow('schedule is required')
|
||||
})
|
||||
|
||||
test('delete issues a DELETE against the job path', async () => {
|
||||
const calls: Array<{url: string; method?: string}> = []
|
||||
globalThis.fetch = (async (url: RequestInfo | URL, init: RequestInit) => {
|
||||
calls.push({url: String(url), method: init.method})
|
||||
return new Response(JSON.stringify({}), {status: 200})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
await adapter().delete('db-backup')
|
||||
expect(calls[0].url).toBe(`${ENDPOINT}${JOBS_PATH}/db-backup`)
|
||||
expect(calls[0].method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpSchedulerSchema} from '../cloud-spi/schedulerSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Talks to the Floci-GCP emulator's Cloud Scheduler service. The emulator
|
||||
* mirrors the public Cloud Scheduler v1 REST API:
|
||||
*
|
||||
* list GET /v1/projects/{project}/locations/{location}/jobs -> {jobs: [...], nextPageToken?: string}
|
||||
* get GET /v1/projects/{project}/locations/{location}/jobs/{id} -> Job
|
||||
* create POST /v1/projects/{project}/locations/{location}/jobs -> Job
|
||||
* delete DELETE /v1/projects/{project}/locations/{location}/jobs/{id} -> {}
|
||||
*/
|
||||
interface GcpSchedulerJob {
|
||||
name?: string
|
||||
description?: string
|
||||
schedule?: string
|
||||
timeZone?: string
|
||||
state?: string
|
||||
httpTarget?: {
|
||||
uri?: string
|
||||
httpMethod?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
}
|
||||
userUpdateTime?: string
|
||||
scheduleTime?: string
|
||||
lastAttemptTime?: string
|
||||
}
|
||||
|
||||
interface GcpSchedulerJobList {
|
||||
jobs?: GcpSchedulerJob[]
|
||||
nextPageToken?: string
|
||||
}
|
||||
|
||||
export class GcpSchedulerAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'scheduler' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpSchedulerSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const jobs: GcpSchedulerJob[] = []
|
||||
let pageToken: string | undefined
|
||||
|
||||
do {
|
||||
const queryParams = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : ''
|
||||
const body: GcpSchedulerJobList | null = await this.client.json<GcpSchedulerJobList>(`${this.jobsPath()}${queryParams}`)
|
||||
if (body?.jobs) {
|
||||
jobs.push(...body.jobs)
|
||||
}
|
||||
pageToken = body?.nextPageToken || undefined
|
||||
} while (pageToken)
|
||||
|
||||
return filterBySearch(jobs.map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const job = await this.client.json<GcpSchedulerJob>(
|
||||
`${this.jobsPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return job ? toResource(job) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const jobName = stringValue(input.values.jobName ?? input.values.name)
|
||||
const schedule = stringValue(input.values.schedule)
|
||||
const timeZone = stringValue(input.values.timeZone) || 'UTC'
|
||||
const targetUri = stringValue(input.values.targetUri ?? input.values.uri)
|
||||
const httpMethod = stringValue(input.values.httpMethod) || 'POST'
|
||||
const description = stringValue(input.values.description)
|
||||
|
||||
if (!jobName) throw new ValidationError('jobName is required')
|
||||
if (!schedule) throw new ValidationError('schedule is required')
|
||||
if (!targetUri) throw new ValidationError('targetUri is required')
|
||||
|
||||
const fullName = `projects/${this.client.project}/locations/${this.client.location}/jobs/${jobName}`
|
||||
|
||||
const job = await this.client.json<GcpSchedulerJob>(
|
||||
this.jobsPath(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: fullName,
|
||||
description,
|
||||
schedule,
|
||||
timeZone,
|
||||
httpTarget: {
|
||||
uri: targetUri,
|
||||
httpMethod,
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
return toResource(job ?? {name: fullName, schedule, timeZone, description, httpTarget: {uri: targetUri, httpMethod}})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.jobsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true})
|
||||
}
|
||||
|
||||
private jobsPath(): string {
|
||||
return `/v1/projects/${encodeURIComponent(this.client.project)}/locations/${encodeURIComponent(this.client.location)}/jobs`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(job: GcpSchedulerJob): CloudResource {
|
||||
const name = shortName(job.name ?? '')
|
||||
const target = job.httpTarget?.uri ?? ''
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'scheduler',
|
||||
type: 'scheduler-job',
|
||||
region: locationOf(job.name ?? ''),
|
||||
createdAt: job.userUpdateTime ?? null,
|
||||
status: job.state ?? 'ENABLED',
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
resourceName: job.name,
|
||||
schedule: job.schedule,
|
||||
timeZone: job.timeZone,
|
||||
target,
|
||||
httpMethod: job.httpTarget?.httpMethod,
|
||||
description: job.description,
|
||||
scheduleTime: job.scheduleTime,
|
||||
lastAttemptTime: job.lastAttemptTime,
|
||||
userUpdateTime: job.userUpdateTime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function shortName(resourceName: string): string {
|
||||
const match = resourceName.match(/jobs\/([^/]+)$/)
|
||||
return match ? match[1] : resourceName
|
||||
}
|
||||
|
||||
function locationOf(resourceName: string): string | null {
|
||||
const match = resourceName.match(/locations\/([^/]+)/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {GcpSecretManagerAdapter} from './GcpSecretManagerAdapter'
|
||||
import {GcpRestRuntimeClient} from '../gcp'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const ENDPOINT = 'http://localhost:4588'
|
||||
const SECRETS_PATH = '/v1/projects/floci-local/secrets'
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function adapter(): GcpSecretManagerAdapter {
|
||||
return new GcpSecretManagerAdapter(new GcpRestRuntimeClient(ENDPOINT, 'floci-local', 'us-central1'))
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{url: string; init?: RequestInit}> = []
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({url: String(url), init})
|
||||
return handler(String(url), init)
|
||||
}) as unknown as typeof fetch
|
||||
return calls
|
||||
}
|
||||
|
||||
/** Shape captured from floci-gcp 0.5.0. */
|
||||
function secret(name: string) {
|
||||
return {
|
||||
name: `projects/floci-local/secrets/${name}`,
|
||||
createTime: '2026-07-28T04:34:51.773522423Z',
|
||||
replication: {automatic: {}},
|
||||
}
|
||||
}
|
||||
|
||||
describe('GcpSecretManagerAdapter', () => {
|
||||
test('identifies itself as the GCP secrets adapter', () => {
|
||||
const instance = adapter()
|
||||
expect(instance.cloud).toBe('gcp')
|
||||
expect(instance.service).toBe('secrets')
|
||||
expect(instance.schema().displayName).toBe('Secret Manager')
|
||||
})
|
||||
|
||||
test('lists secrets and reduces the resource path to a short id', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password')]}), {status: 200}))
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(resource).toMatchObject({id: 'db-password', name: 'db-password', type: 'secret', service: 'secrets'})
|
||||
expect(resource?.metadata.resourcePath).toBe('projects/floci-local/secrets/db-password')
|
||||
expect(resource?.metadata.replication).toBe('automatic')
|
||||
})
|
||||
|
||||
test('never fetches or exposes a secret payload', async () => {
|
||||
// Reading a value needs versions/latest:access; the adapter must not.
|
||||
const calls = stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password')]}), {status: 200}))
|
||||
const [resource] = await adapter().list()
|
||||
|
||||
expect(calls.every((c) => !c.url.includes(':access'))).toBe(true)
|
||||
expect(calls.every((c) => !c.url.includes('/versions'))).toBe(true)
|
||||
expect(JSON.stringify(resource)).not.toContain('payload')
|
||||
})
|
||||
|
||||
test('reports a user-managed replication policy distinctly', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({
|
||||
secrets: [{...secret('db-password'), replication: {userManaged: {replicas: []}}}],
|
||||
}), {status: 200}))
|
||||
|
||||
const [resource] = await adapter().list()
|
||||
expect(resource?.metadata.replication).toBe('user-managed')
|
||||
})
|
||||
|
||||
test('normalizes an empty list payload', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().list()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('filters the list by search term', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({secrets: [secret('db-password'), secret('api-key')]}), {status: 200}))
|
||||
await expect(adapter().list({search: 'api'})).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
test('returns null when the secret does not exist', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Secret not found'}}), {status: 404}))
|
||||
await expect(adapter().get('nope')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('creates with the id as a query parameter and a replication body', async () => {
|
||||
const calls = stubFetch(() => new Response(JSON.stringify(secret('api-key')), {status: 200}))
|
||||
const resource = await adapter().create({values: {secretName: 'api-key'}})
|
||||
|
||||
expect(calls[0]?.init?.method).toBe('POST')
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${SECRETS_PATH}?secretId=api-key`)
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({replication: {automatic: {}}})
|
||||
expect(resource.id).toBe('api-key')
|
||||
})
|
||||
|
||||
test('requires a secret name and rejects one the runtime would refuse', async () => {
|
||||
stubFetch(() => new Response('{}', {status: 200}))
|
||||
await expect(adapter().create({values: {}})).rejects.toBeInstanceOf(ValidationError)
|
||||
for (const name of ['has space', 'has/slash', 'a'.repeat(256)]) {
|
||||
await expect(adapter().create({values: {secretName: name}})).rejects.toBeInstanceOf(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('deletes a secret', async () => {
|
||||
const calls = stubFetch(() => new Response('{}', {status: 200}))
|
||||
await adapter().delete('api-key')
|
||||
|
||||
expect(calls[0]?.url).toBe(`${ENDPOINT}${SECRETS_PATH}/api-key`)
|
||||
expect(calls[0]?.init?.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
test('surfaces a missing secret on delete', async () => {
|
||||
stubFetch(() => new Response(JSON.stringify({error: {code: 404, message: 'Secret not found'}}), {status: 404}))
|
||||
await expect(adapter().delete('nope')).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpSecretsSchema} from '../cloud-spi/secretsSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/**
|
||||
* GCP Secret Manager metadata as `secrets` resources. Verified against
|
||||
* `floci/floci-gcp` 0.5.0, which mirrors the public v1 REST API:
|
||||
*
|
||||
* GET /v1/projects/{project}/secrets
|
||||
* POST /v1/projects/{project}/secrets?secretId={id}
|
||||
* GET /v1/projects/{project}/secrets/{id}
|
||||
* DELETE /v1/projects/{project}/secrets/{id}
|
||||
*
|
||||
* Like the AWS adapter, this never reads a secret payload — that would require
|
||||
* `secrets/{id}/versions/latest:access`, and the value has no business on a
|
||||
* cached resource object.
|
||||
*/
|
||||
|
||||
interface GcpSecret {
|
||||
name?: string
|
||||
createTime?: string
|
||||
labels?: Record<string, string>
|
||||
replication?: {automatic?: unknown; userManaged?: unknown}
|
||||
}
|
||||
|
||||
interface GcpSecretList {
|
||||
secrets?: GcpSecret[]
|
||||
}
|
||||
|
||||
export class GcpSecretManagerAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'secrets' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpSecretsSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<GcpSecretList>(this.secretsPath())
|
||||
return filterBySearch((body?.secrets ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const secret = await this.client.json<GcpSecret>(
|
||||
`${this.secretsPath()}/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return secret ? toResource(secret) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const name = stringValue(input.values.secretName ?? input.values.name)
|
||||
if (!name) throw new ValidationError('secretName is required')
|
||||
if (!isValidSecretName(name)) {
|
||||
throw new ValidationError(
|
||||
'Use a valid Secret Manager name: 1-255 letters, numbers, hyphens, or underscores.',
|
||||
)
|
||||
}
|
||||
|
||||
// The id travels as a query parameter; the body carries the replication policy.
|
||||
const secret = await this.client.json<GcpSecret>(
|
||||
`${this.secretsPath()}?secretId=${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({replication: {automatic: {}}}),
|
||||
},
|
||||
)
|
||||
|
||||
return toResource(secret ?? {name: `projects/${this.client.project}/secrets/${name}`})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`${this.secretsPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'})
|
||||
}
|
||||
|
||||
private secretsPath(): string {
|
||||
return `/v1/projects/${encodeURIComponent(this.client.project)}/secrets`
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(secret: GcpSecret): CloudResource {
|
||||
const path = secret.name ?? ''
|
||||
const name = path.split('/').pop() ?? path
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'secrets',
|
||||
type: 'secret',
|
||||
region: null,
|
||||
createdAt: secret.createTime ?? null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
secretsService: 'secretmanager',
|
||||
resourcePath: path,
|
||||
replication: secret.replication?.userManaged ? 'user-managed' : 'automatic',
|
||||
labels: secret.labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function isValidSecretName(value: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{1,255}$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import {gcpStorageSchema} from '../cloud-spi/storageSchema'
|
||||
import {gcp, type GcpRuntimeClient} from '../gcp'
|
||||
import type {
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
StorageObject,
|
||||
StorageObjectDownload,
|
||||
StorageObjectList,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
interface GcpBucket {
|
||||
id?: string
|
||||
name?: string
|
||||
location?: string
|
||||
timeCreated?: string
|
||||
updated?: string
|
||||
storageClass?: string
|
||||
}
|
||||
|
||||
interface GcpObject {
|
||||
name?: string
|
||||
bucket?: string
|
||||
size?: string
|
||||
updated?: string
|
||||
timeCreated?: string
|
||||
contentType?: string
|
||||
storageClass?: string
|
||||
etag?: string
|
||||
}
|
||||
|
||||
export class GcpStorageAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'gcp' as const
|
||||
readonly service = 'storage' as const
|
||||
|
||||
constructor(private readonly client: GcpRuntimeClient = gcp) {}
|
||||
|
||||
private get project(): string {
|
||||
return this.client.project
|
||||
}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return gcpStorageSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const body = await this.client.json<{items?: GcpBucket[]}>(`/storage/v1/b?project=${encodeURIComponent(this.project)}`)
|
||||
return filterBySearch((body?.items ?? []).map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const bucket = await this.client.json<GcpBucket>(
|
||||
`/storage/v1/b/${encodeURIComponent(id)}`,
|
||||
{method: 'GET'},
|
||||
{emptyOnNotFound: true},
|
||||
)
|
||||
return bucket ? toResource(bucket) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const bucketName = stringValue(input.values.bucketName)
|
||||
if (!bucketName) throw new ValidationError('bucketName is required')
|
||||
if (!isValidBucketName(bucketName)) {
|
||||
throw new ValidationError('Use a valid GCS bucket name: 3-63 lowercase characters, numbers, dots, underscores, or hyphens.')
|
||||
}
|
||||
const body = await this.client.json<GcpBucket>(`/storage/v1/b?project=${encodeURIComponent(this.project)}`, {
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({name: bucketName}),
|
||||
})
|
||||
return toResource(body ?? {name: bucketName})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.fetch(`/storage/v1/b/${encodeURIComponent(id)}`, {method: 'DELETE'}, {emptyOnNotFound: true})
|
||||
}
|
||||
|
||||
async listObjects(resourceId: string, prefix = ''): Promise<StorageObjectList> {
|
||||
const qs = new URLSearchParams({delimiter: '/'})
|
||||
if (prefix) qs.set('prefix', prefix)
|
||||
const body = await this.client.json<{items?: GcpObject[]; prefixes?: string[]}>(`/storage/v1/b/${encodeURIComponent(resourceId)}/o?${qs}`)
|
||||
return {
|
||||
prefix,
|
||||
objects: [
|
||||
...(body?.prefixes ?? []).map((key): StorageObject => ({
|
||||
key,
|
||||
name: objectName(key, prefix),
|
||||
type: 'folder',
|
||||
size: null,
|
||||
lastModified: null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
storageService: 'cloud-storage',
|
||||
prefix: key,
|
||||
},
|
||||
})),
|
||||
...(body?.items ?? [])
|
||||
.filter((item) => item.name && item.name !== prefix)
|
||||
.map((item): StorageObject => ({
|
||||
key: item.name ?? '',
|
||||
name: objectName(item.name ?? '', prefix),
|
||||
type: 'object',
|
||||
size: numberValue(item.size),
|
||||
lastModified: item.updated ?? item.timeCreated ?? null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
storageService: 'cloud-storage',
|
||||
contentType: item.contentType,
|
||||
storageClass: item.storageClass,
|
||||
etag: item.etag,
|
||||
},
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async putObject(resourceId: string, key: string, body: Uint8Array, contentType: string): Promise<void> {
|
||||
const path = `/upload/storage/v1/b/${encodeURIComponent(resourceId)}/o?uploadType=media&name=${encodeURIComponent(key)}`
|
||||
await this.client.fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {'content-type': contentType},
|
||||
body: copyBytes(body),
|
||||
})
|
||||
}
|
||||
|
||||
async getObject(resourceId: string, key: string): Promise<StorageObjectDownload> {
|
||||
const res = await this.client.fetch(
|
||||
`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}?alt=media`,
|
||||
{method: 'GET'},
|
||||
)
|
||||
if (!res) throw new NotFoundError(`Object ${key} not found in bucket ${resourceId}`)
|
||||
return {
|
||||
body: await res.arrayBuffer(),
|
||||
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
|
||||
contentLength: numberValue(res.headers.get('content-length')),
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(resourceId: string, key: string): Promise<void> {
|
||||
await this.client.fetch(`/storage/v1/b/${encodeURIComponent(resourceId)}/o/${encodeURIComponent(key)}`, {method: 'DELETE'}, {emptyOnNotFound: true})
|
||||
}
|
||||
|
||||
async copyObject(srcResourceId: string, srcKey: string, destKey: string, destResourceId?: string): Promise<void> {
|
||||
const destBucket = destResourceId ?? srcResourceId
|
||||
await this.client.fetch(
|
||||
`/storage/v1/b/${encodeURIComponent(srcResourceId)}/o/${encodeURIComponent(srcKey)}/copyTo/b/${encodeURIComponent(destBucket)}/o/${encodeURIComponent(destKey)}`,
|
||||
{method: 'POST'},
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function toResource(bucket: GcpBucket): CloudResource {
|
||||
const name = bucket.name ?? bucket.id ?? ''
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'gcp',
|
||||
service: 'storage',
|
||||
type: 'bucket',
|
||||
region: bucket.location ?? null,
|
||||
createdAt: bucket.timeCreated ?? null,
|
||||
metadata: {
|
||||
provider: 'gcp',
|
||||
storageService: 'cloud-storage',
|
||||
storageClass: bucket.storageClass,
|
||||
updated: bucket.updated,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
|
||||
const normalized = search?.trim().toLowerCase()
|
||||
if (!normalized) return resources
|
||||
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function objectName(key: string, prefix: string): string {
|
||||
const relative = key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
return relative.replace(/\/$/, '') || key
|
||||
}
|
||||
|
||||
function numberValue(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function copyBytes(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength)
|
||||
copy.set(bytes)
|
||||
return copy.buffer
|
||||
}
|
||||
|
||||
function isValidBucketName(value: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/.test(value)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Google's REST APIs answer mutations with a long-running-operation envelope
|
||||
* rather than the resource, and the shape differs per service. All three are
|
||||
* present on the local runtime:
|
||||
*
|
||||
* - Cloud Functions / Cloud Run: `{done, response: <resource>}`
|
||||
* - Cloud SQL (`sql#operation`): `{status: 'DONE', targetId: <name>}`
|
||||
* - GKE: `{status: 'DONE', operationType: 'CREATE_CLUSTER', targetLink: <path>}`
|
||||
*
|
||||
* Only the first embeds the resource; the others name it and expect a re-read.
|
||||
* These helpers keep that discrimination in one place instead of each adapter
|
||||
* guessing whether it received a resource or a receipt for one.
|
||||
*/
|
||||
|
||||
export interface GcpOperationEnvelope<T> {
|
||||
kind?: string
|
||||
/** Cloud Functions / GKE / Cloud Run. */
|
||||
done?: boolean
|
||||
response?: T
|
||||
/** Cloud SQL. */
|
||||
status?: string
|
||||
targetId?: string
|
||||
operationType?: string
|
||||
/** GKE: a resource path whose last segment is the name. */
|
||||
targetLink?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
/** True when the payload is an operation receipt rather than the resource. */
|
||||
export function isOperationEnvelope(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== 'object') return false
|
||||
const envelope = payload as GcpOperationEnvelope<unknown>
|
||||
return (
|
||||
envelope.kind?.endsWith('#operation') === true ||
|
||||
typeof envelope.done === 'boolean' ||
|
||||
(typeof envelope.status === 'string' && typeof envelope.operationType === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap an embedded resource. Returns null when the operation carries only a
|
||||
* reference, in which case the caller should read the resource back by name.
|
||||
*/
|
||||
export function operationResponse<T>(payload: GcpOperationEnvelope<T> | T | null): T | null {
|
||||
if (!payload) return null
|
||||
if (!isOperationEnvelope(payload)) return payload as T
|
||||
|
||||
const envelope = payload as GcpOperationEnvelope<T>
|
||||
return envelope.response ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The resource name an operation acted on, when it reports one. Accepts either a
|
||||
* bare id (Cloud SQL) or a resource path (GKE), returning the final segment.
|
||||
*/
|
||||
export function operationTargetId<T>(payload: GcpOperationEnvelope<T> | T | null): string | null {
|
||||
if (!payload || !isOperationEnvelope(payload)) return null
|
||||
const envelope = payload as GcpOperationEnvelope<T>
|
||||
if (envelope.targetId) return envelope.targetId
|
||||
if (envelope.targetLink) return envelope.targetLink.split('/').pop() ?? null
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user