first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
@@ -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
}