Skip to content

Commit a2d028d

Browse files
committed
Added SpannerContainerEmulator
1 parent 63c49fe commit a2d028d

File tree

3 files changed

+258
-10
lines changed

3 files changed

+258
-10
lines changed

docs/modules/gcloud.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ Testcontainers module for the Google Cloud Platform's [Cloud SDK](https://cloud.
88
npm install @testcontainers/gcloud --save-dev
99
```
1010

11-
The module now supports multiple emulators, including `firestore`, which offers both `native` and `datastore` modes.
12-
To utilize these emulators, you should employ the following classes:
11+
The module supports multiple emulators. Use the following classes:
1312

14-
Emulator | Class | Container Image
13+
Emulator | Class | Container Image
1514
-|-|-
16-
Firestore (Native mode) | FirestoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli)
17-
Firestore (Datastore mode) | DatastoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli)
15+
Firestore (Native mode) | FirestoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli)
16+
Firestore (Datastore mode) | DatastoreEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli)
1817
Cloud PubSub | PubSubEmulatorContainer | [gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators](https://gcr.io/google.com/cloudsdktool/google-cloud-cli)
19-
Cloud Storage | CloudStorageEmulatorContainer | [https://hub.docker.com/r/fsouza/fake-gcs-server](https://hub.docker.com/r/fsouza/fake-gcs-server)
20-
BigQuery | BigQueryEmulatorContainer | [ghcr.io/goccy/bigquery-emulator](ghcr.io/goccy/bigquery-emulator)
18+
Cloud Storage | CloudStorageEmulatorContainer | [fsouza/fake-gcs-server:1.52.2](https://hub.docker.com/r/fsouza/fake-gcs-server)
19+
BigQuery | BigQueryEmulatorContainer | [ghcr.io/goccy/bigquery-emulator:0.6.6](https://ghcr.io/goccy/bigquery-emulator)
20+
Cloud Spanner | SpannerEmulatorContainer | [gcr.io/cloud-spanner-emulator/emulator:1.5.37](https://gcr.io/cloud-spanner-emulator/emulator:1.5.37)
2121

2222
## Examples
2323

@@ -49,16 +49,28 @@ BigQuery | BigQueryEmulatorContainer | [ghcr.io/goccy/bigquery-emulator](ghcr.
4949

5050
### Cloud Storage
5151

52-
The Cloud Storage container doesn't rely on a built-in emulator created by Google but instead depends on a fake Cloud Storage server implemented by [Francisco Souza](https://github.com/fsouza). The project is open-source, and the repository can be found at [fsouza/fake-gcs-server](https://github.com/fsouza/fake-gcs-server).
52+
The Cloud Storage container uses a fake Cloud Storage server by [Francisco Souza](https://github.com/fsouza).
53+
5354
<!--codeinclude-->
5455
[Starting a Cloud Storage Emulator container with the default image](../../packages/modules/gcloud/src/cloudstorage-emulator-container.test.ts) inside_block:cloud-storage
5556
<!--/codeinclude-->
5657

5758
### BigQuery
5859

59-
The BigQuery container doesn't rely on a built-in emulator created by Google, but instead depends on an implementation written in Go by [Masaaki Goshima](https://github.com/goccy). The project is open-source, and the repository can be found at [goccy/bigquery-emulator](https://github.com/goccy/bigquery-emulator).
60+
The BigQuery emulator is by [Masaaki Goshima](https://github.com/goccy) and uses [go-zetasqlite](https://github.com/goccy/go-zetasqlite).
6061

61-
BigQuery emulator uses [go-zetasqlite](https://github.com/goccy/go-zetasqlite) to interpret ZetaSQL (the language used in BigQuery) and runs it in SQLite. The [README](https://github.com/goccy/go-zetasqlite?tab=readme-ov-file#status) lists BigQuery features currently supported.
6262
<!--codeinclude-->
6363
[Starting a BigQuery Emulator container with the default image](../../packages/modules/gcloud/src/bigquery-emulator-container.test.ts)
6464
<!--/codeinclude-->
65+
66+
### Cloud Spanner
67+
68+
The Cloud Spanner emulator container wraps Google's official emulator image. It exposes gRPC and HTTP ports, and provides a `helper` for instance/database operations.
69+
70+
<!--codeinclude-->
71+
[Starting a Spanner Emulator container and exposing endpoints](../../packages/modules/gcloud/src/spanner-emulator-container.test.ts) inside_block:startup
72+
<!--/codeinclude-->
73+
74+
<!--codeinclude-->
75+
[Creating and deleting instance and database via helper](../../packages/modules/gcloud/src/spanner-emulator-container.test.ts) inside_block:createAndDelete
76+
<!--/codeinclude-->
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { getImage } from "../../../testcontainers/src/utils/test-helper";
2+
import { SpannerEmulatorContainer } from "./spanner-emulator-container";
3+
4+
// select the fourth FROM in the Dockerfile (spanner emulator)
5+
const IMAGE = getImage(__dirname, 3);
6+
7+
describe("SpannerEmulatorContainer", { timeout: 240_000 }, () => {
8+
it("should start and expose endpoints", async () => {
9+
// <example startup> {
10+
const container = await new SpannerEmulatorContainer(IMAGE).withProjectId("test-project").start();
11+
const grpcEndpoint = container.getEmulatorGrpcEndpoint();
12+
13+
expect(grpcEndpoint).toMatch(/localhost:\d+/);
14+
15+
await container.stop();
16+
// }
17+
});
18+
19+
it("should create and delete instance and database via helper", async () => {
20+
// <example createAndDelete> {
21+
const container = await new SpannerEmulatorContainer(IMAGE).start();
22+
const helper = container.helper;
23+
const instanceId = "test-instance";
24+
const databaseId = "test-db";
25+
26+
// must set env for client operations
27+
helper.setAsEmulatorHost();
28+
29+
// create resources
30+
await helper.createInstance(instanceId);
31+
await helper.createDatabase(instanceId, databaseId);
32+
33+
const client = helper.client;
34+
35+
// verify instance exists
36+
const [instanceExists] = await client.instance(instanceId).exists();
37+
expect(instanceExists).toBe(true);
38+
39+
// verify database exists
40+
const [dbExists] = await client.instance(instanceId).database(databaseId).exists();
41+
expect(dbExists).toBe(true);
42+
43+
// delete resources
44+
await helper.deleteDatabase(instanceId, databaseId);
45+
await helper.deleteInstance(instanceId);
46+
47+
// verify deletions
48+
const [dbExistsAfter] = await client.instance(instanceId).database(databaseId).exists();
49+
expect(dbExistsAfter).toBe(false);
50+
51+
const [instanceExistsAfter] = await client.instance(instanceId).exists();
52+
expect(instanceExistsAfter).toBe(false);
53+
54+
await container.stop();
55+
// }
56+
});
57+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Spanner } from "@google-cloud/spanner";
2+
import type { IInstance } from "@google-cloud/spanner/build/src/instance";
3+
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
4+
5+
const GRPC_PORT = 9010;
6+
7+
/**
8+
* SpannerEmulatorContainer runs the Cloud Spanner emulator via the GCloud CLI image.
9+
*/
10+
export class SpannerEmulatorContainer extends GenericContainer {
11+
private projectId?: string;
12+
13+
constructor(image: string) {
14+
super(image);
15+
16+
// only gRPC port is supported
17+
this.withExposedPorts(GRPC_PORT).withWaitStrategy(Wait.forLogMessage(/.*Cloud Spanner emulator running\..*/, 1));
18+
}
19+
20+
/**
21+
* Sets the GCP project ID to use with the emulator.
22+
*/
23+
public withProjectId(projectId: string): this {
24+
this.projectId = projectId;
25+
return this;
26+
}
27+
28+
public override async start(): Promise<StartedSpannerEmulatorContainer> {
29+
const selectedProject = this.projectId ?? "test-project";
30+
31+
const started = await super.start();
32+
return new StartedSpannerEmulatorContainer(started, selectedProject);
33+
}
34+
}
35+
36+
/**
37+
* A running Spanner emulator instance with endpoint getters and helper access.
38+
*/
39+
export class StartedSpannerEmulatorContainer extends AbstractStartedContainer {
40+
constructor(
41+
startedTestContainer: StartedTestContainer,
42+
private readonly projectId: string
43+
) {
44+
super(startedTestContainer);
45+
}
46+
47+
/**
48+
* @returns host:port for gRPC.
49+
*/
50+
public getEmulatorGrpcEndpoint(): string {
51+
return `${this.getHost()}:${this.getMappedPort(GRPC_PORT)}`;
52+
}
53+
54+
/**
55+
* @returns the GCP project ID used by the emulator.
56+
*/
57+
public getProjectId(): string {
58+
return this.projectId;
59+
}
60+
61+
/**
62+
* @returns a helper for Spanner resource operations against the emulator.
63+
*/
64+
public get helper(): SpannerEmulatorHelper {
65+
return new SpannerEmulatorHelper(this);
66+
}
67+
}
68+
69+
/**
70+
* Helper class that encapsulates all Spanner client interactions against the emulator.
71+
* Clients and configs are lazily instantiated; user must call setAsEmulatorHost().
72+
*/
73+
export class SpannerEmulatorHelper {
74+
private clientInstance?: Spanner;
75+
private instanceAdminClientInstance?: ReturnType<Spanner["getInstanceAdminClient"]>;
76+
private databaseAdminClientInstance?: ReturnType<Spanner["getDatabaseAdminClient"]>;
77+
private instanceConfigValue?: string;
78+
79+
constructor(private readonly emulator: StartedSpannerEmulatorContainer) {}
80+
81+
/**
82+
* Must be called by the user to configure the SPANNER_EMULATOR_HOST env var.
83+
*/
84+
public setAsEmulatorHost(): void {
85+
process.env.SPANNER_EMULATOR_HOST = this.emulator.getEmulatorGrpcEndpoint();
86+
}
87+
88+
/**
89+
* Lazily get or create the Spanner client.
90+
*/
91+
public get client(): Spanner {
92+
if (!this.clientInstance) {
93+
if (!process.env.SPANNER_EMULATOR_HOST) {
94+
throw new Error("SPANNER_EMULATOR_HOST is not set. Call setAsEmulatorHost() before using the client.");
95+
}
96+
// Provide fake credentials so the auth library never tries metadata
97+
this.clientInstance = new Spanner({
98+
projectId: this.emulator.getProjectId(),
99+
credentials: {
100+
client_email: "[email protected]",
101+
private_key: "not-a-real-key",
102+
},
103+
});
104+
}
105+
return this.clientInstance;
106+
}
107+
108+
/**
109+
* Lazily get or create the InstanceAdminClient.
110+
*/
111+
private get instanceAdminClient(): ReturnType<Spanner["getInstanceAdminClient"]> {
112+
if (!this.instanceAdminClientInstance) {
113+
this.instanceAdminClientInstance = this.client.getInstanceAdminClient();
114+
}
115+
return this.instanceAdminClientInstance;
116+
}
117+
118+
/**
119+
* Lazily get or create the DatabaseAdminClient.
120+
*/
121+
private get databaseAdminClient(): ReturnType<Spanner["getDatabaseAdminClient"]> {
122+
if (!this.databaseAdminClientInstance) {
123+
this.databaseAdminClientInstance = this.client.getDatabaseAdminClient();
124+
}
125+
return this.databaseAdminClientInstance;
126+
}
127+
128+
/**
129+
* Lazily compute the instanceConfig path.
130+
*/
131+
public get instanceConfig(): string {
132+
if (!this.instanceConfigValue) {
133+
this.instanceConfigValue = this.instanceAdminClient.instanceConfigPath(
134+
this.emulator.getProjectId(),
135+
"emulator-config"
136+
);
137+
}
138+
return this.instanceConfigValue;
139+
}
140+
141+
/**
142+
* Creates a new Spanner instance in the emulator.
143+
*/
144+
public async createInstance(instanceId: string, options?: IInstance): Promise<unknown> {
145+
const [operation] = await this.instanceAdminClient.createInstance({
146+
instanceId,
147+
parent: this.instanceAdminClient.projectPath(this.emulator.getProjectId()),
148+
instance: options,
149+
});
150+
const [result] = await operation.promise();
151+
return result;
152+
}
153+
154+
/**
155+
* Deletes an existing Spanner instance in the emulator.
156+
*/
157+
public async deleteInstance(instanceId: string): Promise<void> {
158+
await this.client.instance(instanceId).delete();
159+
}
160+
161+
/**
162+
* Creates a new database under the specified instance in the emulator.
163+
*/
164+
public async createDatabase(instanceId: string, databaseId: string): Promise<unknown> {
165+
const [operation] = await this.databaseAdminClient.createDatabase({
166+
parent: this.databaseAdminClient.instancePath(this.emulator.getProjectId(), instanceId),
167+
createStatement: `CREATE DATABASE \`${databaseId}\``,
168+
});
169+
const [result] = await operation.promise();
170+
return result;
171+
}
172+
173+
/**
174+
* Deletes a database under the specified instance in the emulator.
175+
*/
176+
public async deleteDatabase(instanceId: string, databaseId: string): Promise<void> {
177+
await this.client.instance(instanceId).database(databaseId).delete();
178+
}
179+
}

0 commit comments

Comments
 (0)