diff --git a/README.md b/README.md index d50b655..341b142 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ cd packages/api && bun run scripts/service-matrix.ts | Group | Service | AWS | Azure | GCP | |---|---|---|---|---| | Compute | Compute | Yes (list, inspect, create, delete) | No | No | -| Compute | EKS / AKS / GKE | Yes (list, inspect) | No | No | +| Compute | EKS / AKS / GKE | Yes (list, inspect) | No | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | -| Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | No | +| Databases | Database | Yes (list, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Networking | Networking | Yes (list) | No | No | | Security | Secrets Manager | Yes (legacy page) | No | No | diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts new file mode 100644 index 0000000..f834e38 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.test.ts @@ -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') + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts new file mode 100644 index 0000000..75cce2c --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpCloudSqlAdapter.ts @@ -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 { + const body = await this.client.json(this.instancesPath()) + return filterBySearch((body?.items ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const instance = await this.client.json( + `${this.instancesPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return instance ? toResource(instance) : null + } + + async create(input: CreateResourceInput): Promise { + 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 | 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(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 { + await this.client.fetch(`${this.instancesPath()}/${encodeURIComponent(id)}`, {method: 'DELETE'}) + } + + /** Cheaper than list(): the runtime keeps instance metadata in memory. */ + async health(): Promise { + 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) +} diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts new file mode 100644 index 0000000..426d287 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.test.ts @@ -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) + }) +}) diff --git a/packages/api/src/adapter-gcp/GcpGkeAdapter.ts b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts new file mode 100644 index 0000000..eb99b51 --- /dev/null +++ b/packages/api/src/adapter-gcp/GcpGkeAdapter.ts @@ -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 +} + +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 { + const body = await this.client.json(this.clustersPath()) + return filterBySearch((body?.clusters ?? []).map(toResource), query.search) + } + + async get(id: string): Promise { + const cluster = await this.client.json( + `${this.clustersPath()}/${encodeURIComponent(id)}`, + {method: 'GET'}, + {emptyOnNotFound: true}, + ) + return cluster ? toResource(cluster) : null + } + + async create(input: CreateResourceInput): Promise { + 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 | 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(result) + if (embedded) return toResource(embedded) + + const created = await this.get(operationTargetId(result) ?? name) + return created ?? toResource({name}) + } + + async delete(id: string): Promise { + 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) +} diff --git a/packages/api/src/adapter-gcp/operations.ts b/packages/api/src/adapter-gcp/operations.ts new file mode 100644 index 0000000..207108f --- /dev/null +++ b/packages/api/src/adapter-gcp/operations.ts @@ -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: }` + * - Cloud SQL (`sql#operation`): `{status: 'DONE', targetId: }` + * - GKE: `{status: 'DONE', operationType: 'CREATE_CLUSTER', targetLink: }` + * + * 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 { + 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 + 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(payload: GcpOperationEnvelope | T | null): T | null { + if (!payload) return null + if (!isOperationEnvelope(payload)) return payload as T + + const envelope = payload as GcpOperationEnvelope + 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(payload: GcpOperationEnvelope | T | null): string | null { + if (!payload || !isOperationEnvelope(payload)) return null + const envelope = payload as GcpOperationEnvelope + if (envelope.targetId) return envelope.targetId + if (envelope.targetLink) return envelope.targetLink.split('/').pop() ?? null + return null +} diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index f02f053..5befc83 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -8,6 +8,16 @@ const databaseColumns: TableColumnSchema[] = [ {name: 'instanceClass', label: 'Class'}, ] +/** Cloud SQL reports a connection endpoint, which RDS does not surface here. */ +const cloudSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'engine', label: 'Version'}, + {name: 'region', label: 'Region'}, + {name: 'instanceClass', label: 'Tier'}, + {name: 'connectionName', label: 'Connection', path: 'metadata.connectionName', format: 'code'}, +] + const databaseFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] @@ -67,10 +77,53 @@ export function gcpDatabaseSchema(): ServiceSchema { cloud: 'gcp', service: 'database', displayName: 'Cloud SQL', - fields: [], - actions: ['list', 'inspect'], + fields: [ + { + name: 'instanceName', + label: 'Instance Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'databaseVersion', + label: 'Database Version', + type: 'select', + required: false, + // The runtime backs instances with real Postgres containers and + // rejects every other engine, so this is not the full GCP list. + description: 'The local runtime supports PostgreSQL only.', + options: [ + {label: 'PostgreSQL 15', value: 'POSTGRES_15'}, + {label: 'PostgreSQL 16', value: 'POSTGRES_16'}, + ], + }, + { + name: 'region', + label: 'Region', + type: 'text', + required: false, + description: 'Defaults to us-central1.', + }, + { + name: 'tier', + label: 'Machine Tier', + type: 'text', + required: false, + description: 'Defaults to db-f1-micro.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], filters: databaseFilters, - columns: databaseColumns, + columns: cloudSqlColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List instances', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete instance', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect instance', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, } } diff --git a/packages/api/src/cloud-spi/eksSchema.ts b/packages/api/src/cloud-spi/eksSchema.ts index 80c57d2..beaa942 100644 --- a/packages/api/src/cloud-spi/eksSchema.ts +++ b/packages/api/src/cloud-spi/eksSchema.ts @@ -7,6 +7,16 @@ const eksColumns: TableColumnSchema[] = [ {name: 'createdAt', label: 'Created At'}, ] +/** GKE reports an API endpoint and node pools, which the EKS list does not. */ +const gkeColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'version', label: 'Version'}, + {name: 'region', label: 'Location'}, + {name: 'endpoint', label: 'Endpoint', path: 'metadata.endpoint', format: 'code'}, + {name: 'createdAt', label: 'Created At', format: 'datetime'}, +] + const eksFilters: FieldSchema[] = [ {name: 'search', label: 'Search', type: 'text', required: false}, ] @@ -40,10 +50,33 @@ export function gcpGkeSchema(): ServiceSchema { cloud: 'gcp', service: 'k8s', displayName: 'Google GKE', - fields: [], - actions: ['list', 'inspect'], + fields: [ + { + name: 'clusterName', + label: 'Cluster Name', + type: 'text', + required: true, + description: 'Lowercase letters, numbers, and hyphens; must start with a letter.', + }, + { + name: 'initialNodeCount', + label: 'Initial Node Count', + type: 'text', + required: false, + description: 'Defaults to 1. The local runtime backs the cluster with a single k3s container.', + }, + ], + actions: ['list', 'create', 'inspect', 'delete'], filters: eksFilters, - columns: eksColumns, + columns: gkeColumns, + capabilities: { + resourceActions: [ + {name: 'list', label: 'List clusters', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete cluster', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect cluster', enabled: true, status: 'available', runtimeRequired: false}, + ], + }, } } diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 0791bfa..4b66bff 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -8,6 +8,8 @@ import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' +import {GcpCloudSqlAdapter} from './adapter-gcp/GcpCloudSqlAdapter' +import {GcpGkeAdapter} from './adapter-gcp/GcpGkeAdapter' import {CloudProxyService} from './service/CloudProxyService' import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter' import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter' @@ -40,6 +42,8 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AzureDatabaseAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), + new GcpCloudSqlAdapter(), + new GcpGkeAdapter(), new AzureServerlessAdapter(), ]) }