testcontainers-understudy 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # testcontainers-understudy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Runs [understudy](https://github.com/sustinbebustin/understudy), the local AWS emulator, as a [Testcontainers for Node](https://node.testcontainers.org/) module.
4
+
5
+ It is released with understudy: version `X.Y.Z` of this package exports `UNDERSTUDY_IMAGE`, `ghcr.io/sustinbebustin/understudy:X.Y.Z`. Nothing is published yet, so build the image with `just image`, pass `understudy:dev`, and install the package from a clone, here at `../understudy`, with your own `testcontainers` (a peer dependency, `^12.2.1`):
6
+
7
+ ```sh
8
+ (cd ../understudy/testcontainers/node && bun install --frozen-lockfile && bun run build && bun pm pack --destination "$OLDPWD")
9
+ bun add -d ./testcontainers-understudy-0.0.0.tgz testcontainers@^12.2.1
10
+ ```
11
+
12
+ The package is ESM and needs Node 22.22 or later. Its tests run on Node; Bun is untested.
13
+
14
+ ## Use
15
+
16
+ ```ts
17
+ import { after, before, test } from "node:test";
18
+ import { type StartedUnderstudyContainer, UnderstudyContainer } from "testcontainers-understudy";
19
+
20
+ let understudy: StartedUnderstudyContainer;
21
+
22
+ before(async () => {
23
+ understudy = await new UnderstudyContainer("understudy:dev").start();
24
+ Object.assign(process.env, understudy.getEnv()); // AWS_ENDPOINT_URL, test credentials, us-east-1
25
+ });
26
+
27
+ after(async () => {
28
+ await understudy?.stop();
29
+ });
30
+
31
+ test("orders", async () => {
32
+ // new SQSClient({}) and the other v3 clients now reach the container.
33
+ });
34
+ ```
35
+
36
+ `start()` publishes port 4566 on `127.0.0.1` only and resolves once `GET /_understudy/v1/health` answers 200 with `"ready": true`; if understudy exits first, it rejects with understudy's log. `stop()`, and `await using`, stops understudy with a 10-second grace period unless you pass another `timeout`, which lets it remove the Lambda and DynamoDB Local containers it started, and every such container carries the session's label, so Ryuk removes them too.
37
+
38
+ ## Options
39
+
40
+ | Method | Effect |
41
+ |---|---|
42
+ | `withDockerSocket()` | mounts the Docker socket and adds its group (0 on Docker Desktop, whose VM owns the socket), for Lambda and DynamoDB Local; `start()` rejects with `UnderstudyDockerError` unless understudy then reports Docker enabled |
43
+ | `withDockerSocket(gid)` | the same, with the group given, for a daemon that is not a local unix socket |
44
+ | `withClock({ frozenAt, scale })` | the emulator clock |
45
+ | `withSeed(n)` | repeatable generated identifiers, as a `bigint` |
46
+ | `withSigV4Verify()` | verify request signatures |
47
+ | `withSnapshot(path)` | load a snapshot archive at start |
48
+ | `withAdminToken()` | a random admin token, sent by the wait and the helpers |
49
+ | `withPublishedOn(addr)` | publish on another address; beyond loopback only with `withAdminToken()`; a loopback address other than `127.0.0.1` is refused, since the endpoint never dials it |
50
+
51
+ `withEnvironment` sets any other `UNDERSTUDY_*` setting. `settle(timeoutMs)`, `advanceClock(byMs)` and `reset()` wrap the common admin calls and reject with `UnderstudyAdminError` on an error answer. Misconfigurations throw or reject with `UnderstudyConfigError`, before any container is created. The exception is a missing snapshot archive: `withSnapshot` throws Node's own `ENOENT` error for it.
52
+
53
+ The [Testcontainers guide](https://github.com/sustinbebustin/understudy/blob/main/docs/guide/testcontainers-modules.md) covers Lambda, Terraform and cleanup in more detail.
54
+
55
+ ## Development
56
+
57
+ ```sh
58
+ just image
59
+ testcontainers/node/run.sh # bun install, typecheck, build, sealed node --test against understudy:dev
60
+ ```
61
+
62
+ `UNDERSTUDY_IMAGE` selects another image; extra arguments are `node --test` options.
@@ -0,0 +1,133 @@
1
+ import { AbstractStartedContainer, GenericContainer, type StartedTestContainer, type StopOptions, type StoppedTestContainer } from "testcontainers";
2
+ /** The release image of the understudy version this package is released with. */
3
+ export declare const UNDERSTUDY_IMAGE = "ghcr.io/sustinbebustin/understudy:0.1.2";
4
+ /** A module setting refused before any container is created (testcontainers.md 9.5). */
5
+ export declare class UnderstudyConfigError extends Error {
6
+ name: string;
7
+ }
8
+ /** A mounted Docker socket understudy reports it cannot use after start (testcontainers.md 8.5). */
9
+ export declare class UnderstudyDockerError extends Error {
10
+ name: string;
11
+ readonly status: string;
12
+ readonly reason: string;
13
+ constructor(status: string, reason: string);
14
+ }
15
+ /** An admin API call understudy answered outside 2xx, with its error body. */
16
+ export declare class UnderstudyAdminError extends Error {
17
+ name: string;
18
+ readonly status: number;
19
+ readonly code: string;
20
+ readonly hint: string;
21
+ constructor(status: number, code: string, message: string, hint?: string);
22
+ }
23
+ /** How the emulator clock starts: frozen at an instant, or running scaled. */
24
+ export interface ClockOptions {
25
+ frozenAt?: Date;
26
+ scale?: number;
27
+ }
28
+ /**
29
+ * understudy in a container, configured by the with* methods below.
30
+ *
31
+ * The module owns port 4566's binding and the wait. `start()` refuses,
32
+ * before creating anything, publishing beyond loopback without
33
+ * `withAdminToken()` and a Docker socket whose group it cannot read; both
34
+ * reject with UnderstudyConfigError. Argument shapes are checked when each
35
+ * option is called, so option order never changes the outcome
36
+ * (testcontainers.md 9.5).
37
+ */
38
+ export declare class UnderstudyContainer extends GenericContainer {
39
+ #private;
40
+ constructor(image: string);
41
+ /**
42
+ * Mount the daemon's Docker socket at /var/run/docker.sock and add its numeric group.
43
+ *
44
+ * Lambda and DynamoDB Local need it (testcontainers.md 8.5). Without
45
+ * `gid`, start() reads the group of the local unix socket tc-node's client
46
+ * uses, and refuses any other daemon. A group name cannot be given: the
47
+ * image has no /etc/group to resolve it. A later call without `gid` keeps
48
+ * a group given earlier. After start, a socket understudy does not report
49
+ * enabled rejects with UnderstudyDockerError.
50
+ */
51
+ withDockerSocket(gid?: number): this;
52
+ /**
53
+ * Freeze the emulator clock at `frozenAt`, or scale it by `scale`.
54
+ *
55
+ * Sets UNDERSTUDY_CLOCK=frozen@<RFC 3339> and UNDERSTUDY_CLOCK_SCALE
56
+ * (testcontainers.md 8.2). understudy checks the scale's range and refuses
57
+ * a frozen clock with a scale itself; start() then rejects with the
58
+ * container's log (testcontainers.md 9.5).
59
+ */
60
+ withClock(clock: ClockOptions): this;
61
+ /** Set UNDERSTUDY_SEED; understudy checks its range itself (testcontainers.md 9.5). */
62
+ withSeed(seed: bigint): this;
63
+ /** Set UNDERSTUDY_SIGV4=verify, so understudy checks every request signature. */
64
+ withSigV4Verify(): this;
65
+ /**
66
+ * Load the snapshot archive at `path` at start.
67
+ *
68
+ * The file is copied to /snapshot/archive in the container, mode 0644, and
69
+ * UNDERSTUDY_LOAD_SNAPSHOT names it (testcontainers.md 8.2). A missing file
70
+ * throws fs.statSync's error here, before any container; a later call
71
+ * replaces the archive.
72
+ */
73
+ withSnapshot(path: string): this;
74
+ /**
75
+ * Guard the admin API with a random token the wait and the helpers send.
76
+ *
77
+ * Sets UNDERSTUDY_ADMIN_TOKEN to 64 hex characters from 32 random bytes
78
+ * and UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API=true, which understudy
79
+ * requires beside the token; health then answers 401 without it
80
+ * (testcontainers.md 8.2, 3.3).
81
+ */
82
+ withAdminToken(): this;
83
+ /**
84
+ * Publish port 4566 on `addr` instead of 127.0.0.1.
85
+ *
86
+ * An address beyond loopback, 0.0.0.0 and :: included, needs
87
+ * withAdminToken(), or start() refuses it (testcontainers.md 8.4). Any
88
+ * other loopback address is refused here: the endpoint and the wait dial
89
+ * the daemon's host, 127.0.0.1 for a local daemon, so they could never
90
+ * reach it (testcontainers.md 8.2, 9.5).
91
+ */
92
+ withPublishedOn(addr: string): this;
93
+ /**
94
+ * Start understudy and resolve once it reports ready (testcontainers.md 8.3).
95
+ *
96
+ * A container that exits first rejects with its log. With the Docker
97
+ * socket, a socket understudy does not report enabled rejects with
98
+ * UnderstudyDockerError. A start that fails after creating the container
99
+ * removes it.
100
+ */
101
+ start(): Promise<StartedUnderstudyContainer>;
102
+ protected beforeContainerCreated(): Promise<void>;
103
+ protected containerCreated(containerId: string): Promise<void>;
104
+ protected containerStarting(): Promise<void>;
105
+ }
106
+ /** A started understudy container. */
107
+ export declare class StartedUnderstudyContainer extends AbstractStartedContainer {
108
+ #private;
109
+ constructor(startedTestContainer: StartedTestContainer, adminToken?: string);
110
+ /** `http://<host>:<mapped 4566>`, with localhost as 127.0.0.1 (testcontainers.md 8.2). */
111
+ getEndpoint(): string;
112
+ /** The environment that points an SDK at the container, as understudytest.Env (testcontainers.md 3.8). */
113
+ getEnv(): Record<string, string>;
114
+ /**
115
+ * Wait up to `timeoutMs` for understudy's tracked work to finish.
116
+ *
117
+ * Sent as `<timeoutMs>ms` to GET /_understudy/v1/settle (testcontainers.md
118
+ * 3.7); understudy answers 412 condition_not_met on timeout
119
+ * (admin.ConditionNotMet in admin/errors.go).
120
+ */
121
+ settle(timeoutMs: number): Promise<void>;
122
+ /** Move the emulator clock forward by `byMs` milliseconds (testcontainers.md 3.7). */
123
+ advanceClock(byMs: number): Promise<void>;
124
+ /** Drop all service state, keeping the clock and the fault rules (testcontainers.md 3.7). */
125
+ reset(): Promise<void>;
126
+ /**
127
+ * Stop with a 10 s grace unless `options.timeout` says otherwise, then remove the container and its volumes.
128
+ *
129
+ * The grace lets understudy remove the containers it started before it
130
+ * exits (testcontainers.md 8.6). `await using` disposal calls this too.
131
+ */
132
+ stop(options?: Partial<StopOptions>): Promise<StoppedTestContainer>;
133
+ }
package/dist/index.js ADDED
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Testcontainers module for understudy, the local AWS emulator.
3
+ *
4
+ * `UnderstudyContainer` publishes port 4566 on 127.0.0.1 only, waits for
5
+ * `GET /_understudy/v1/health` to answer 200 with `"ready": true`, labels
6
+ * every container understudy starts with the session's Ryuk filter label,
7
+ * and stops with a 10 s grace so understudy removes its own containers first
8
+ * (testcontainers.md 8).
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ import { statSync } from "node:fs";
12
+ import { BlockList, isIP } from "node:net";
13
+ import { resolve } from "node:path";
14
+ import { text } from "node:stream/consumers";
15
+ import { AbstractStartedContainer, GenericContainer, getContainerRuntimeClient, getReaper, LABEL_TESTCONTAINERS_SESSION_ID, Wait, } from "testcontainers";
16
+ /** The package version; the default image and the npm version move in lockstep (testcontainers.md 8.8). */
17
+ const VERSION = "0.1.2";
18
+ /** The release image of the understudy version this package is released with. */
19
+ export const UNDERSTUDY_IMAGE = `ghcr.io/sustinbebustin/understudy:${VERSION}`;
20
+ // understudy's listener in the container (testcontainers.md 3.2).
21
+ const API_PORT = 4566;
22
+ const API_PORT_KEY = `${API_PORT}/tcp`;
23
+ const ADMIN_PREFIX = "/_understudy/v1";
24
+ // The wait's bound, though understudy is ready in milliseconds: it bounds only
25
+ // image pulls and failures. tc-node's module rule asks for 120 s
26
+ // (testcontainers.md 8.3).
27
+ const STARTUP_TIMEOUT_MS = 60_000;
28
+ // tc-node stops with no grace by default; understudy needs it to remove its
29
+ // own containers before it exits (testcontainers.md 8.6, finding 4).
30
+ const STOP_GRACE_MS = 10_000;
31
+ // How long an admin call may take beyond the time it asks understudy to wait.
32
+ const ADMIN_CALL_MS = 30_000;
33
+ // How often start() looks for a container that exited before Docker bound
34
+ // its port, and how many of its log lines it reports then.
35
+ const EXIT_POLL_MS = 100;
36
+ const EXIT_LOG_LINES = 50;
37
+ // The period of the no-op timer that keeps Node running during start().
38
+ const HOLD_INTERVAL_MS = 60_000;
39
+ const LOOPBACK = "127.0.0.1";
40
+ const SOCKET_TARGET = "/var/run/docker.sock";
41
+ const SNAPSHOT_PATH = "/snapshot/archive";
42
+ // A GID is a 32-bit unsigned integer.
43
+ const MAX_GID = 4294967295;
44
+ // 127.0.0.0/8 and ::1; BlockList also matches their IPv4-mapped IPv6 forms.
45
+ const loopback = new BlockList();
46
+ loopback.addSubnet("127.0.0.0", 8, "ipv4");
47
+ loopback.addAddress("::1", "ipv6");
48
+ /** A module setting refused before any container is created (testcontainers.md 9.5). */
49
+ export class UnderstudyConfigError extends Error {
50
+ name = "UnderstudyConfigError";
51
+ }
52
+ /** A mounted Docker socket understudy reports it cannot use after start (testcontainers.md 8.5). */
53
+ export class UnderstudyDockerError extends Error {
54
+ name = "UnderstudyDockerError";
55
+ status;
56
+ reason;
57
+ constructor(status, reason) {
58
+ super(`understudy: the Docker socket is mounted but understudy reports Docker ${status}: ${reason}; ` +
59
+ "Lambda and DynamoDB will not run. Check the socket group (withDockerSocket(gid)) or run without withDockerSocket().");
60
+ this.status = status;
61
+ this.reason = reason;
62
+ }
63
+ }
64
+ /** An admin API call understudy answered outside 2xx, with its error body. */
65
+ export class UnderstudyAdminError extends Error {
66
+ name = "UnderstudyAdminError";
67
+ status;
68
+ code;
69
+ hint;
70
+ constructor(status, code, message, hint = "") {
71
+ const text = `understudy: admin API answered ${status} ${code}: ${message}`;
72
+ super(hint === "" ? text : `${text}; ${hint}`);
73
+ this.status = status;
74
+ this.code = code;
75
+ this.hint = hint;
76
+ }
77
+ }
78
+ /**
79
+ * understudy in a container, configured by the with* methods below.
80
+ *
81
+ * The module owns port 4566's binding and the wait. `start()` refuses,
82
+ * before creating anything, publishing beyond loopback without
83
+ * `withAdminToken()` and a Docker socket whose group it cannot read; both
84
+ * reject with UnderstudyConfigError. Argument shapes are checked when each
85
+ * option is called, so option order never changes the outcome
86
+ * (testcontainers.md 9.5).
87
+ */
88
+ export class UnderstudyContainer extends GenericContainer {
89
+ #token;
90
+ #publishedOn = LOOPBACK;
91
+ #socket;
92
+ // Set by start() for the hooks tc-node calls while it creates the container,
93
+ // and by those hooks for start().
94
+ #mount;
95
+ #createdId;
96
+ #exitWatch;
97
+ constructor(image) {
98
+ super(image);
99
+ this.withExposedPorts(API_PORT);
100
+ this.withStartupTimeout(STARTUP_TIMEOUT_MS);
101
+ }
102
+ /**
103
+ * Mount the daemon's Docker socket at /var/run/docker.sock and add its numeric group.
104
+ *
105
+ * Lambda and DynamoDB Local need it (testcontainers.md 8.5). Without
106
+ * `gid`, start() reads the group of the local unix socket tc-node's client
107
+ * uses, and refuses any other daemon. A group name cannot be given: the
108
+ * image has no /etc/group to resolve it. A later call without `gid` keeps
109
+ * a group given earlier. After start, a socket understudy does not report
110
+ * enabled rejects with UnderstudyDockerError.
111
+ */
112
+ withDockerSocket(gid) {
113
+ if (gid !== undefined && !(Number.isInteger(gid) && gid >= 0 && gid <= MAX_GID)) {
114
+ throw new UnderstudyConfigError(`understudy: the Docker socket group must be a non-negative integer group ID, not ${String(gid)}.`);
115
+ }
116
+ if (gid !== undefined) {
117
+ this.#socket = { kind: "gid", gid };
118
+ }
119
+ else if (this.#socket === undefined) {
120
+ this.#socket = { kind: "stat" };
121
+ }
122
+ return this;
123
+ }
124
+ /**
125
+ * Freeze the emulator clock at `frozenAt`, or scale it by `scale`.
126
+ *
127
+ * Sets UNDERSTUDY_CLOCK=frozen@<RFC 3339> and UNDERSTUDY_CLOCK_SCALE
128
+ * (testcontainers.md 8.2). understudy checks the scale's range and refuses
129
+ * a frozen clock with a scale itself; start() then rejects with the
130
+ * container's log (testcontainers.md 9.5).
131
+ */
132
+ withClock(clock) {
133
+ const { frozenAt, scale } = clock;
134
+ if (frozenAt !== undefined && Number.isNaN(frozenAt.getTime())) {
135
+ throw new UnderstudyConfigError('understudy: withClock({frozenAt}) got an invalid Date; pass one such as new Date("2026-01-01T00:00:00Z").');
136
+ }
137
+ if (frozenAt !== undefined) {
138
+ this.withEnvironment({ UNDERSTUDY_CLOCK: `frozen@${frozenAt.toISOString()}` });
139
+ }
140
+ if (scale !== undefined) {
141
+ this.withEnvironment({ UNDERSTUDY_CLOCK_SCALE: String(scale) });
142
+ }
143
+ return this;
144
+ }
145
+ /** Set UNDERSTUDY_SEED; understudy checks its range itself (testcontainers.md 9.5). */
146
+ withSeed(seed) {
147
+ return this.withEnvironment({ UNDERSTUDY_SEED: seed.toString() });
148
+ }
149
+ /** Set UNDERSTUDY_SIGV4=verify, so understudy checks every request signature. */
150
+ withSigV4Verify() {
151
+ return this.withEnvironment({ UNDERSTUDY_SIGV4: "verify" });
152
+ }
153
+ /**
154
+ * Load the snapshot archive at `path` at start.
155
+ *
156
+ * The file is copied to /snapshot/archive in the container, mode 0644, and
157
+ * UNDERSTUDY_LOAD_SNAPSHOT names it (testcontainers.md 8.2). A missing file
158
+ * throws fs.statSync's error here, before any container; a later call
159
+ * replaces the archive.
160
+ */
161
+ withSnapshot(path) {
162
+ const source = resolve(path);
163
+ if (statSync(source).isDirectory()) {
164
+ throw new UnderstudyConfigError(`understudy: withSnapshot(${JSON.stringify(path)}) names a directory; ` +
165
+ "pass the archive file, such as one POST /_understudy/v1/snapshots answered with.");
166
+ }
167
+ this.filesToCopy = [
168
+ ...this.filesToCopy.filter((f) => f.target !== SNAPSHOT_PATH),
169
+ { source, target: SNAPSHOT_PATH, mode: 0o644 },
170
+ ];
171
+ return this.withEnvironment({ UNDERSTUDY_LOAD_SNAPSHOT: SNAPSHOT_PATH });
172
+ }
173
+ /**
174
+ * Guard the admin API with a random token the wait and the helpers send.
175
+ *
176
+ * Sets UNDERSTUDY_ADMIN_TOKEN to 64 hex characters from 32 random bytes
177
+ * and UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API=true, which understudy
178
+ * requires beside the token; health then answers 401 without it
179
+ * (testcontainers.md 8.2, 3.3).
180
+ */
181
+ withAdminToken() {
182
+ this.#token = randomBytes(32).toString("hex");
183
+ return this.withEnvironment({
184
+ UNDERSTUDY_ADMIN_TOKEN: this.#token,
185
+ UNDERSTUDY_ALLOW_UNAUTHENTICATED_AWS_API: "true",
186
+ });
187
+ }
188
+ /**
189
+ * Publish port 4566 on `addr` instead of 127.0.0.1.
190
+ *
191
+ * An address beyond loopback, 0.0.0.0 and :: included, needs
192
+ * withAdminToken(), or start() refuses it (testcontainers.md 8.4). Any
193
+ * other loopback address is refused here: the endpoint and the wait dial
194
+ * the daemon's host, 127.0.0.1 for a local daemon, so they could never
195
+ * reach it (testcontainers.md 8.2, 9.5).
196
+ */
197
+ withPublishedOn(addr) {
198
+ if (isIP(addr) === 0) {
199
+ throw new UnderstudyConfigError(`understudy: withPublishedOn(${JSON.stringify(addr)}) is not an IP address; pass one such as 127.0.0.1.`);
200
+ }
201
+ if (isLoopback(addr) && addr !== LOOPBACK) {
202
+ throw new UnderstudyConfigError(`understudy: withPublishedOn(${JSON.stringify(addr)}) is a loopback address the endpoint never dials: ` +
203
+ `the endpoint and the readiness wait reach port 4566 at the Docker host's address, ${LOOPBACK} for a ` +
204
+ `local daemon; publish on ${LOOPBACK}, or on 0.0.0.0 with withAdminToken().`);
205
+ }
206
+ this.#publishedOn = addr;
207
+ return this;
208
+ }
209
+ /**
210
+ * Start understudy and resolve once it reports ready (testcontainers.md 8.3).
211
+ *
212
+ * A container that exits first rejects with its log. With the Docker
213
+ * socket, a socket understudy does not report enabled rejects with
214
+ * UnderstudyDockerError. A start that fails after creating the container
215
+ * removes it.
216
+ */
217
+ async start() {
218
+ // tc-node unrefs the socket of the log it reads to report an exit, so a
219
+ // process with nothing else pending would exit with start() unsettled;
220
+ // this timer holds the event loop until start() settles.
221
+ const hold = setInterval(() => undefined, HOLD_INTERVAL_MS);
222
+ try {
223
+ return await this.#start();
224
+ }
225
+ finally {
226
+ clearInterval(hold);
227
+ }
228
+ }
229
+ async #start() {
230
+ this.#checkExposure();
231
+ const client = await getContainerRuntimeClient();
232
+ this.#mount = this.#socket === undefined ? undefined : resolveSocket(client, this.#socket);
233
+ this.#createdId = undefined;
234
+ let started;
235
+ try {
236
+ started = await super.start();
237
+ }
238
+ catch (err) {
239
+ // An exit the watch already saw still resolves after stop().
240
+ this.#exitWatch?.stop();
241
+ const exited = await this.#exitWatch?.exited;
242
+ const failure = exited === undefined ? err : new Error(exited, { cause: err });
243
+ if (this.#createdId !== undefined)
244
+ await removeLeftover(client, this.#createdId, failure);
245
+ throw failure;
246
+ }
247
+ finally {
248
+ this.#exitWatch?.stop();
249
+ this.#exitWatch = undefined;
250
+ }
251
+ const u = new StartedUnderstudyContainer(started, this.#token);
252
+ if (this.#mount !== undefined) {
253
+ try {
254
+ const { status, reason } = dockerState(await adminCall(u.getEndpoint(), this.#token, "GET", "/health"));
255
+ // A mounted socket never fails silently (testcontainers.md 8.5, 9.7).
256
+ if (status !== "enabled")
257
+ throw new UnderstudyDockerError(status, reason);
258
+ }
259
+ catch (err) {
260
+ await stopAfterFailedStart(u, err);
261
+ throw err;
262
+ }
263
+ }
264
+ return u;
265
+ }
266
+ async beforeContainerCreated() {
267
+ // After any withExposedPorts, which binds every interface (testcontainers.md 8.4, 6.3).
268
+ this.hostConfig.PortBindings = {
269
+ ...this.hostConfig.PortBindings,
270
+ [API_PORT_KEY]: [{ HostIp: this.#publishedOn, HostPort: "0" }],
271
+ };
272
+ this.withWaitStrategy(readyWait(this.#token));
273
+ // The session's Ryuk filter label goes on every container understudy
274
+ // starts, so Ryuk reaps them too; a value the user set wins
275
+ // (testcontainers.md 8.6, 6.4, 9.3).
276
+ if (this.environment.UNDERSTUDY_DOCKER_LABELS === undefined) {
277
+ const { sessionId } = await getReaper(await getContainerRuntimeClient());
278
+ this.withEnvironment({
279
+ UNDERSTUDY_DOCKER_LABELS: JSON.stringify({ [LABEL_TESTCONTAINERS_SESSION_ID]: sessionId }),
280
+ });
281
+ }
282
+ if (this.#mount !== undefined)
283
+ this.#addSocket(this.#mount);
284
+ }
285
+ async containerCreated(containerId) {
286
+ this.#createdId = containerId;
287
+ this.#exitWatch = watchExit(await getContainerRuntimeClient(), containerId);
288
+ }
289
+ async containerStarting() {
290
+ // Docker bound the port; from here the wait's abortOnContainerExit reports an exit.
291
+ this.#exitWatch?.stop();
292
+ }
293
+ /**
294
+ * Append the socket's bind mount and group to the user's, never replacing them.
295
+ *
296
+ * A bind mount, unlike a Binds entry, fails on a missing source instead of
297
+ * creating a root-owned directory (testcontainers.md 3.4, 8.5).
298
+ */
299
+ #addSocket(mount) {
300
+ const mounts = this.hostConfig.Mounts ?? [];
301
+ if (!mounts.some((m) => m.Type === "bind" && m.Source === mount.source && m.Target === SOCKET_TARGET)) {
302
+ this.hostConfig.Mounts = [...mounts, { Type: "bind", Source: mount.source, Target: SOCKET_TARGET }];
303
+ }
304
+ const groups = this.hostConfig.GroupAdd ?? [];
305
+ if (!groups.includes(String(mount.gid))) {
306
+ this.hostConfig.GroupAdd = [...groups, String(mount.gid)];
307
+ }
308
+ }
309
+ /** Refuse publishing beyond loopback without the token (testcontainers.md 8.4, 9.5). */
310
+ #checkExposure() {
311
+ if (this.#token === undefined && !isLoopback(this.#publishedOn)) {
312
+ throw new UnderstudyConfigError(`understudy: publishing port 4566 on ${this.#publishedOn} exposes the admin API, which has no token in the container; ` +
313
+ "add withAdminToken() or publish on 127.0.0.1.");
314
+ }
315
+ }
316
+ }
317
+ /** A started understudy container. */
318
+ export class StartedUnderstudyContainer extends AbstractStartedContainer {
319
+ #token;
320
+ constructor(startedTestContainer, adminToken) {
321
+ super(startedTestContainer);
322
+ this.#token = adminToken;
323
+ }
324
+ /** `http://<host>:<mapped 4566>`, with localhost as 127.0.0.1 (testcontainers.md 8.2). */
325
+ getEndpoint() {
326
+ return endpointUrl(this.getHost(), this.getMappedPort(API_PORT));
327
+ }
328
+ /** The environment that points an SDK at the container, as understudytest.Env (testcontainers.md 3.8). */
329
+ getEnv() {
330
+ return {
331
+ AWS_ENDPOINT_URL: this.getEndpoint(),
332
+ AWS_ACCESS_KEY_ID: "test",
333
+ AWS_SECRET_ACCESS_KEY: "test",
334
+ AWS_REGION: "us-east-1",
335
+ AWS_DEFAULT_REGION: "us-east-1",
336
+ };
337
+ }
338
+ /**
339
+ * Wait up to `timeoutMs` for understudy's tracked work to finish.
340
+ *
341
+ * Sent as `<timeoutMs>ms` to GET /_understudy/v1/settle (testcontainers.md
342
+ * 3.7); understudy answers 412 condition_not_met on timeout
343
+ * (admin.ConditionNotMet in admin/errors.go).
344
+ */
345
+ async settle(timeoutMs) {
346
+ const callMs = Math.max(timeoutMs, 0) + ADMIN_CALL_MS;
347
+ await adminCall(this.getEndpoint(), this.#token, "GET", `/settle?timeout=${timeoutMs}ms`, undefined, callMs);
348
+ }
349
+ /** Move the emulator clock forward by `byMs` milliseconds (testcontainers.md 3.7). */
350
+ async advanceClock(byMs) {
351
+ await adminCall(this.getEndpoint(), this.#token, "POST", "/clock", { op: "advance", by: `${byMs}ms` });
352
+ }
353
+ /** Drop all service state, keeping the clock and the fault rules (testcontainers.md 3.7). */
354
+ async reset() {
355
+ await adminCall(this.getEndpoint(), this.#token, "POST", "/reset", {});
356
+ }
357
+ /**
358
+ * Stop with a 10 s grace unless `options.timeout` says otherwise, then remove the container and its volumes.
359
+ *
360
+ * The grace lets understudy remove the containers it started before it
361
+ * exits (testcontainers.md 8.6). `await using` disposal calls this too.
362
+ */
363
+ async stop(options) {
364
+ return super.stop({ ...options, timeout: options?.timeout ?? STOP_GRACE_MS });
365
+ }
366
+ }
367
+ /** The HTTP wait: health answers 200 with "ready": true, and an exit fails it at once (testcontainers.md 8.3). */
368
+ function readyWait(token) {
369
+ const wait = Wait.forHttp(`${ADMIN_PREFIX}/health`, API_PORT, { abortOnContainerExit: true })
370
+ .forStatusCode(200)
371
+ .forResponsePredicate(reportsReady)
372
+ .withStartupTimeout(STARTUP_TIMEOUT_MS);
373
+ return token === undefined ? wait : wait.withHeaders({ Authorization: `Bearer ${token}` });
374
+ }
375
+ /**
376
+ * One admin API call, with the token when set; the decoded JSON answer, or undefined when empty.
377
+ *
378
+ * A non-2xx answer rejects with UnderstudyAdminError.
379
+ */
380
+ async function adminCall(endpoint, token, method, path, body, timeoutMs = ADMIN_CALL_MS) {
381
+ const headers = {};
382
+ if (body !== undefined)
383
+ headers["Content-Type"] = "application/json";
384
+ if (token !== undefined)
385
+ headers.Authorization = `Bearer ${token}`;
386
+ const resp = await fetch(endpoint + ADMIN_PREFIX + path, {
387
+ method,
388
+ headers,
389
+ body: body === undefined ? undefined : JSON.stringify(body),
390
+ signal: AbortSignal.timeout(timeoutMs),
391
+ });
392
+ const raw = await resp.text();
393
+ if (!resp.ok)
394
+ throw adminError(resp.status, raw);
395
+ return raw === "" ? undefined : parseJson(raw);
396
+ }
397
+ /** Stop and remove a container whose post-start check failed; a failed stop names the container left behind. */
398
+ async function stopAfterFailedStart(u, cause) {
399
+ try {
400
+ await u.stop();
401
+ }
402
+ catch (stopErr) {
403
+ throw new Error(`understudy: start failed (${String(cause)}), and stopping container ${u.getId()} then failed too: ` +
404
+ `${String(stopErr)}; remove it with 'docker rm -f -v ${u.getId()}'.`, { cause });
405
+ }
406
+ }
407
+ /**
408
+ * Remove the container of a failed start unless it is already gone.
409
+ *
410
+ * tc-node removes it when the wait fails, but not when the port binding or
411
+ * its lookup fails after the container started.
412
+ */
413
+ async function removeLeftover(client, id, failure) {
414
+ try {
415
+ await client.container.getById(id).remove({ force: true, v: true });
416
+ }
417
+ catch (err) {
418
+ if (isRecord(err) && err.statusCode === 404)
419
+ return;
420
+ throw new Error(`understudy: start failed (${String(failure)}), and removing container ${id} then failed too: ` +
421
+ `${String(err)}; remove it with 'docker rm -f -v ${id}'.`, { cause: failure });
422
+ }
423
+ }
424
+ function isLoopback(addr) {
425
+ return loopback.check(addr, isIP(addr) === 6 ? "ipv6" : "ipv4");
426
+ }
427
+ /**
428
+ * The socket to mount and its group (testcontainers.md 8.5).
429
+ *
430
+ * The source is the path tc-node mounts into Ryuk. The group is the one
431
+ * given, else 0 on Docker Desktop, whose VM socket is root:root 0660, else
432
+ * the gid of the local unix socket the client uses.
433
+ */
434
+ function resolveSocket(client, socket) {
435
+ const source = client.info.containerRuntime.remoteSocketPath;
436
+ if (socket.kind === "gid")
437
+ return { source, gid: socket.gid };
438
+ const daemon = daemonOf(client);
439
+ if (daemon.kind === "other") {
440
+ throw new UnderstudyConfigError(`understudy: cannot read the group of the Docker socket on ${daemon.address}, which is not a local unix socket; ` +
441
+ "pass withDockerSocket(<gid>), the numeric group owning /var/run/docker.sock on the daemon's host.");
442
+ }
443
+ // Docker Desktop mounts its VM's socket, not the host's (TC-01).
444
+ if (client.info.containerRuntime.operatingSystem.includes("Docker Desktop"))
445
+ return { source, gid: 0 };
446
+ try {
447
+ return { source, gid: statSync(daemon.socketPath).gid };
448
+ }
449
+ catch (err) {
450
+ throw new UnderstudyConfigError(`understudy: cannot read the group of the Docker socket ${daemon.socketPath}: ${String(err)}; ` +
451
+ "pass withDockerSocket(<gid>), the numeric group owning the socket.", { cause: err });
452
+ }
453
+ }
454
+ /**
455
+ * Where tc-node's client reaches the daemon, read from its dockerode modem.
456
+ *
457
+ * tc-node keeps the daemon URI to itself. docker-modem holds a socketPath
458
+ * for a unix socket or a Windows named pipe, else a host, port and protocol;
459
+ * its type declarations name none of them.
460
+ */
461
+ function daemonOf(client) {
462
+ const modem = client.container.dockerode.modem;
463
+ const socketPath = "socketPath" in modem ? modem.socketPath : undefined;
464
+ if (typeof socketPath === "string") {
465
+ return process.platform === "win32"
466
+ ? { kind: "other", address: `npipe://${socketPath}` }
467
+ : { kind: "unix", socketPath };
468
+ }
469
+ const host = "host" in modem ? modem.host : undefined;
470
+ const port = "port" in modem ? modem.port : undefined;
471
+ const protocol = "protocol" in modem ? modem.protocol : undefined;
472
+ if (typeof host !== "string")
473
+ return { kind: "other", address: "the daemon tc-node is configured for" };
474
+ const scheme = typeof protocol === "string" ? protocol : "tcp";
475
+ const suffix = typeof port === "string" || typeof port === "number" ? `:${port}` : "";
476
+ return { kind: "other", address: `${scheme}://${host}${suffix}` };
477
+ }
478
+ /**
479
+ * Watch a created container until stop(); `exited` resolves with start()'s message if it exits first.
480
+ *
481
+ * tc-node waits up to 30 s for Docker to bind the published port before the
482
+ * wait strategy runs, and a container that already exited never binds it.
483
+ * Removing the container ends that wait at once, so understudy refusing its
484
+ * settings fails start() in well under a second, with its log
485
+ * (testcontainers.md 9.5).
486
+ */
487
+ function watchExit(client, containerId) {
488
+ const container = client.container.getById(containerId);
489
+ let watching = true;
490
+ const exited = (async () => {
491
+ while (watching) {
492
+ await new Promise((done) => setTimeout(done, EXIT_POLL_MS));
493
+ if (!watching)
494
+ return undefined;
495
+ // Not started yet, or already gone: tc-node reports what it sees.
496
+ const state = await client.container.inspect(container).then((info) => info.State, () => undefined);
497
+ if (!watching || state?.Status !== "exited")
498
+ continue;
499
+ let log;
500
+ try {
501
+ log = (await text(await client.container.logs(container, { tail: EXIT_LOG_LINES }))).trim();
502
+ }
503
+ catch (err) {
504
+ log = `(the log could not be read: ${String(err)})`;
505
+ }
506
+ let removed = "the container was removed";
507
+ try {
508
+ await client.container.remove(container, { removeVolumes: true });
509
+ }
510
+ catch (err) {
511
+ removed = `removing it failed (${String(err)}); remove it with 'docker rm -f -v ${containerId}'`;
512
+ }
513
+ return (`understudy: the container exited (code ${state.ExitCode}) before it reported ready, so understudy ` +
514
+ `refused its settings or could not start; ${removed}. Its last ${EXIT_LOG_LINES} log lines:\n${log}`);
515
+ }
516
+ return undefined;
517
+ })();
518
+ return {
519
+ stop: () => {
520
+ watching = false;
521
+ },
522
+ exited,
523
+ };
524
+ }
525
+ /** Join a daemon host, as tc-node reports it, and a port; an IPv6 host is bracketed. */
526
+ function endpointUrl(host, port) {
527
+ let h = host.replace(/^\[(.*)\]$/, "$1");
528
+ // SDKs address S3 path style only at an IP endpoint (testcontainers.md 8.2).
529
+ if (h === "localhost")
530
+ h = LOOPBACK;
531
+ if (h.includes(":"))
532
+ h = `[${h}]`;
533
+ return `http://${h}:${port}`;
534
+ }
535
+ function parseJson(raw) {
536
+ try {
537
+ return JSON.parse(raw);
538
+ }
539
+ catch {
540
+ return undefined;
541
+ }
542
+ }
543
+ function isRecord(v) {
544
+ return typeof v === "object" && v !== null && !Array.isArray(v);
545
+ }
546
+ function reportsReady(body) {
547
+ const health = parseJson(body);
548
+ return isRecord(health) && health.ready === true;
549
+ }
550
+ /** Docker's status and reason in a health answer (testcontainers.md 3.1). */
551
+ function dockerState(health) {
552
+ const docker = isRecord(health) ? health.docker : undefined;
553
+ if (!isRecord(docker))
554
+ return { status: "unreported", reason: "the health answer has no docker field" };
555
+ return {
556
+ status: typeof docker.status === "string" ? docker.status : "",
557
+ reason: typeof docker.reason === "string" ? docker.reason : "",
558
+ };
559
+ }
560
+ /**
561
+ * The error for a non-2xx admin answer: its envelope's fields, or the body text when it has none.
562
+ *
563
+ * The envelope is {"error": {"code", "message", "hint"}} (ARCHITECTURE.md 17).
564
+ */
565
+ function adminError(status, raw) {
566
+ const doc = parseJson(raw);
567
+ const err = isRecord(doc) ? doc.error : undefined;
568
+ if (isRecord(err) && typeof err.code === "string" && typeof err.message === "string") {
569
+ return new UnderstudyAdminError(status, err.code, err.message, typeof err.hint === "string" ? err.hint : "");
570
+ }
571
+ return new UnderstudyAdminError(status, "", raw);
572
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "testcontainers-understudy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Testcontainers module for understudy, the local AWS emulator",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/sustinbebustin/understudy.git",
9
+ "directory": "testcontainers/node"
10
+ },
11
+ "homepage": "https://github.com/sustinbebustin/understudy/tree/main/testcontainers/node",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": ["dist"],
20
+ "engines": {
21
+ "node": ">= 22.22"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "../../scripts/sealed node --test --test-concurrency=1 --test-timeout=300000 'test/*.test.ts'"
27
+ },
28
+ "peerDependencies": {
29
+ "testcontainers": "^12.2.1"
30
+ },
31
+ "devDependencies": {
32
+ "@aws-sdk/client-iam": "^3.1149.0",
33
+ "@aws-sdk/client-lambda": "^3.1149.0",
34
+ "@aws-sdk/client-sqs": "^3.1149.0",
35
+ "testcontainers": "^12.2.1",
36
+ "typescript": "^7.0.2"
37
+ }
38
+ }