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 buildConfig?: { runtime?: string entryPoint?: string source?: Record } 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 { const body = await this.client.json(this.functionsPath()) return filterBySearch((body?.functions ?? []).map(toResource), query.search) } async get(id: string): Promise { const fn = await this.client.json( `${this.functionsPath()}/${encodeURIComponent(id)}`, {method: 'GET'}, {emptyOnNotFound: true}, ) return fn ? toResource(fn) : null } async create(input: CreateResourceInput): Promise { 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( `${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 { 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)) }