systemd-ts 0.3.0 → 0.3.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 +61 -0
- package/dist/index-DeNBLyGA.d.mts +1613 -0
- package/dist/index.d.mts +2 -1612
- package/dist/index.mjs +1 -1609
- package/dist/main-BXq7TQdg.mjs +1612 -0
- package/dist/main-BXq7TQdg.mjs.map +1 -0
- package/dist/test.d.mts +30 -1
- package/dist/test.mjs +95 -16
- package/dist/test.mjs.map +1 -0
- package/package.json +4 -3
- package/src/main/errors.ts +348 -0
- package/src/main/executable.ts +175 -0
- package/src/main/index.ts +76 -0
- package/src/main/internal.ts +406 -0
- package/src/main/notify.ts +67 -0
- package/src/main/systemctl.ts +326 -0
- package/src/main/systemd-service.ts +80 -0
- package/src/main/systemd-timer.ts +80 -0
- package/src/main/systemd.ts +991 -0
- package/src/main/types.ts +1231 -0
- package/src/test/harness.ts +106 -0
- package/src/test/host.ts +676 -0
- package/src/test/index.ts +17 -0
- package/src/test/sandbox.ts +94 -0
- package/src/test/systemd-container.Dockerfile +30 -0
package/src/test/host.ts
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const commandMaxBufferBytes = 16 * 1024 * 1024;
|
|
9
|
+
const dockerBuildTimeoutMs = 5 * 60 * 1_000;
|
|
10
|
+
const dockerExecTimeoutMs = 60 * 1_000;
|
|
11
|
+
const guestCommandTimeoutMs = 10 * 1_000;
|
|
12
|
+
const repoRoot = fileURLToPath(new URL(`../../../../`, import.meta.url));
|
|
13
|
+
const dockerfilePath = fileURLToPath(new URL(`./systemd-container.Dockerfile`, import.meta.url));
|
|
14
|
+
const selectedBackendName = resolveBackendName(process.env[`SYSTEMD_TS_TEST_HOST_BACKEND`]);
|
|
15
|
+
const colimaHome = process.env[`COLIMA_HOME`] ?? `${repoRoot}.colima`;
|
|
16
|
+
const limaHome = process.env[`LIMA_HOME`] ?? `${colimaHome}/_lima`;
|
|
17
|
+
const colimaProfile = process.env[`COLIMA_PROFILE`] ?? `systemd-ts`;
|
|
18
|
+
const dockerStateRoot = process.env[`SYSTEMD_TS_TEST_HOST_ROOT`] ?? `${repoRoot}.docker`;
|
|
19
|
+
const dockerImage = process.env[`SYSTEMD_TS_TEST_DOCKER_IMAGE`] ?? `systemd-ts-test-host:local`;
|
|
20
|
+
const dockerUser = process.env[`SYSTEMD_TS_TEST_DOCKER_USER`] ?? `runner`;
|
|
21
|
+
const dockerConfiguredUserId = process.env[`SYSTEMD_TS_TEST_DOCKER_UID`];
|
|
22
|
+
const dockerContainer =
|
|
23
|
+
process.env[`SYSTEMD_TS_TEST_DOCKER_CONTAINER`] ?? defaultDockerContainerName();
|
|
24
|
+
|
|
25
|
+
export interface TestHostInfo {
|
|
26
|
+
readonly backend: TestHostBackendName;
|
|
27
|
+
readonly colimaHome?: string;
|
|
28
|
+
readonly colimaProfile?: string;
|
|
29
|
+
readonly limaHome?: string;
|
|
30
|
+
readonly repoRoot: string;
|
|
31
|
+
readonly stateRoot: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type TestHostBackendName = `colima` | `docker`;
|
|
35
|
+
|
|
36
|
+
interface TestHostBackend {
|
|
37
|
+
readonly info: TestHostInfo;
|
|
38
|
+
ensureHost(): Promise<void>;
|
|
39
|
+
createGuestShellProcess(): ChildProcessWithoutNullStreams;
|
|
40
|
+
runIsolatedCommand(script: string): Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let ensuredHost: Promise<TestHostInfo> | undefined;
|
|
44
|
+
let guestShell: GuestShell | undefined;
|
|
45
|
+
|
|
46
|
+
export function getTestHostInfo(): TestHostInfo {
|
|
47
|
+
return backend.info;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ensureTestHost(): Promise<TestHostInfo> {
|
|
51
|
+
ensuredHost ??= ensureTestHostInner();
|
|
52
|
+
return ensuredHost;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function closeTestHost(): void {
|
|
56
|
+
closeGuestShell(`warmup complete`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runGuestCommand(script: string): Promise<string> {
|
|
60
|
+
const shell = getGuestShell();
|
|
61
|
+
return shell.run(script);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runIsolatedGuestCommand(script: string): Promise<string> {
|
|
65
|
+
return backend.runIsolatedCommand(script);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ensureTestHostInner(): Promise<TestHostInfo> {
|
|
69
|
+
logTestHost(`Ensuring ${backend.info.backend} test host`);
|
|
70
|
+
await backend.ensureHost();
|
|
71
|
+
logTestHost(`Waiting for systemd --user readiness`);
|
|
72
|
+
await waitForUserSystemd();
|
|
73
|
+
logTestHost(`Test host ready`);
|
|
74
|
+
return getTestHostInfo();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function waitForUserSystemd(): Promise<void> {
|
|
78
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
79
|
+
const state = await runGuestCommandWithTimeout(
|
|
80
|
+
`printf 'pid1=%s\n' "$(ps -p 1 -o comm=)"; systemctl --user is-system-running || true; printf 'xdg=%s\n' "$XDG_RUNTIME_DIR"`,
|
|
81
|
+
guestCommandTimeoutMs,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
state.includes(`pid1=systemd`) &&
|
|
86
|
+
state.includes(`xdg=/run/user/`) &&
|
|
87
|
+
[`running`, `degraded`].some((status) => state.includes(status))
|
|
88
|
+
) {
|
|
89
|
+
logTestHost(
|
|
90
|
+
`systemd --user ready after ${attempt + 1} attempt${attempt === 0 ? `` : `s`}: ${summarizeSystemdState(state)}`,
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (attempt === 0 || (attempt + 1) % 5 === 0) {
|
|
96
|
+
logTestHost(
|
|
97
|
+
`systemd --user not ready yet (${attempt + 1}/30): ${summarizeSystemdState(state)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await delay(1_000);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new Error(`Timed out waiting for systemd --user in the ${backend.info.backend} guest`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getGuestShell(): GuestShell {
|
|
108
|
+
guestShell ??= new GuestShell(
|
|
109
|
+
() => backend.createGuestShellProcess(),
|
|
110
|
+
() => {
|
|
111
|
+
if (guestShell !== undefined) {
|
|
112
|
+
guestShell = undefined;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
return guestShell;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function closeGuestShell(reason: string): void {
|
|
120
|
+
if (guestShell === undefined) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
logTestHost(`Resetting guest shell: ${reason}`);
|
|
125
|
+
guestShell.close();
|
|
126
|
+
guestShell = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runGuestCommandWithTimeout(script: string, timeoutMs: number): Promise<string> {
|
|
130
|
+
const shell = getGuestShell();
|
|
131
|
+
try {
|
|
132
|
+
return await shell.run(script, timeoutMs);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof Error && error.message.includes(`timed out`)) {
|
|
135
|
+
closeGuestShell(error.message);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mergeOutput(stdout: string, stderr: string): string {
|
|
143
|
+
return [stdout, stderr].filter((output) => output.length > 0).join(`\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function summarizeSystemdState(state: string): string {
|
|
147
|
+
return state
|
|
148
|
+
.split(`\n`)
|
|
149
|
+
.map((line) => line.trim())
|
|
150
|
+
.filter((line) => line.length > 0)
|
|
151
|
+
.join(`; `);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function inferBackendName(): TestHostBackendName {
|
|
155
|
+
if (process.platform === `darwin`) {
|
|
156
|
+
return `colima`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (process.platform === `linux`) {
|
|
160
|
+
return `docker`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Unsupported test host platform: ${process.platform}. Set SYSTEMD_TS_TEST_HOST_BACKEND explicitly if you have a compatible backend.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveBackendName(value: string | undefined): TestHostBackendName {
|
|
169
|
+
if (value === undefined) {
|
|
170
|
+
return inferBackendName();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (value === `colima` || value === `docker`) {
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Unsupported SYSTEMD_TS_TEST_HOST_BACKEND value: ${value}. Expected "colima" or "docker".`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function defaultDockerContainerName(): string {
|
|
183
|
+
const digest = createHash(`sha256`).update(repoRoot).digest(`hex`).slice(0, 12);
|
|
184
|
+
return `systemd-ts-${digest}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function logTestHost(message: string): void {
|
|
188
|
+
console.info(`[systemd-ts:test-host] ${message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createBackend(name: TestHostBackendName): TestHostBackend {
|
|
192
|
+
switch (name) {
|
|
193
|
+
case `colima`:
|
|
194
|
+
return new ColimaBackend();
|
|
195
|
+
case `docker`:
|
|
196
|
+
return new DockerBackend();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
class ColimaBackend implements TestHostBackend {
|
|
201
|
+
public readonly info: TestHostInfo = {
|
|
202
|
+
backend: `colima`,
|
|
203
|
+
colimaHome,
|
|
204
|
+
colimaProfile,
|
|
205
|
+
limaHome,
|
|
206
|
+
repoRoot,
|
|
207
|
+
stateRoot: colimaHome,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
public async ensureHost(): Promise<void> {
|
|
211
|
+
if (await this.isRunning()) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.info(`Starting repo-local Colima host for integration tests...`);
|
|
216
|
+
await this.execColima([
|
|
217
|
+
`start`,
|
|
218
|
+
`--cpu`,
|
|
219
|
+
`2`,
|
|
220
|
+
`--memory`,
|
|
221
|
+
`4`,
|
|
222
|
+
`--disk`,
|
|
223
|
+
`30`,
|
|
224
|
+
`--runtime`,
|
|
225
|
+
`containerd`,
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public createGuestShellProcess(): ChildProcessWithoutNullStreams {
|
|
230
|
+
return spawn(`colima`, [`ssh`, `--`, `bash`, `--noprofile`, `--norc`, `-s`], {
|
|
231
|
+
env: this.hostEnv,
|
|
232
|
+
stdio: [`pipe`, `pipe`, `pipe`],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public async runIsolatedCommand(script: string): Promise<string> {
|
|
237
|
+
return this.execColima([`ssh`, `--`, `bash`, `-lc`, script]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private readonly hostEnv = {
|
|
241
|
+
...process.env,
|
|
242
|
+
COLIMA_HOME: colimaHome,
|
|
243
|
+
COLIMA_PROFILE: colimaProfile,
|
|
244
|
+
LIMA_HOME: limaHome,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
private async isRunning(): Promise<boolean> {
|
|
248
|
+
try {
|
|
249
|
+
const status = await this.execColima([`status`]);
|
|
250
|
+
return status.includes(`is running`);
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async execColima(args: readonly string[]): Promise<string> {
|
|
257
|
+
const result = await execFileAsync(`colima`, args, {
|
|
258
|
+
env: this.hostEnv,
|
|
259
|
+
maxBuffer: commandMaxBufferBytes,
|
|
260
|
+
});
|
|
261
|
+
return mergeOutput(result.stdout, result.stderr);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class DockerBackend implements TestHostBackend {
|
|
266
|
+
public readonly info: TestHostInfo = {
|
|
267
|
+
backend: `docker`,
|
|
268
|
+
repoRoot,
|
|
269
|
+
stateRoot: dockerStateRoot,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
private runtimeInfo:
|
|
273
|
+
| {
|
|
274
|
+
readonly home: string;
|
|
275
|
+
readonly runtimeDir: string;
|
|
276
|
+
readonly userId: number;
|
|
277
|
+
}
|
|
278
|
+
| undefined;
|
|
279
|
+
|
|
280
|
+
public async ensureHost(): Promise<void> {
|
|
281
|
+
logTestHost(`Building Docker test host image ${dockerImage}`);
|
|
282
|
+
await this.execDocker(
|
|
283
|
+
[`build`, `--quiet`, `--tag`, dockerImage, `--file`, dockerfilePath, repoRoot],
|
|
284
|
+
{ timeout: dockerBuildTimeoutMs },
|
|
285
|
+
);
|
|
286
|
+
logTestHost(`Docker test host image ready`);
|
|
287
|
+
|
|
288
|
+
if (!(await this.isContainerRunning())) {
|
|
289
|
+
await this.removeExistingContainer();
|
|
290
|
+
logTestHost(`Starting Docker test host container ${dockerContainer}`);
|
|
291
|
+
await this.execDocker([
|
|
292
|
+
`run`,
|
|
293
|
+
`--detach`,
|
|
294
|
+
`--name`,
|
|
295
|
+
dockerContainer,
|
|
296
|
+
`--hostname`,
|
|
297
|
+
`systemd-ts-test-host`,
|
|
298
|
+
`--privileged`,
|
|
299
|
+
`--cgroupns=host`,
|
|
300
|
+
`--tmpfs`,
|
|
301
|
+
`/run`,
|
|
302
|
+
`--tmpfs`,
|
|
303
|
+
`/run/lock`,
|
|
304
|
+
`--volume`,
|
|
305
|
+
`/sys/fs/cgroup:/sys/fs/cgroup:rw`,
|
|
306
|
+
`--volume`,
|
|
307
|
+
`${repoRoot}:${repoRoot}`,
|
|
308
|
+
`--volume`,
|
|
309
|
+
`${dockerStateRoot}:${dockerStateRoot}`,
|
|
310
|
+
`--workdir`,
|
|
311
|
+
repoRoot,
|
|
312
|
+
dockerImage,
|
|
313
|
+
]);
|
|
314
|
+
logTestHost(`Docker test host container started`);
|
|
315
|
+
} else {
|
|
316
|
+
logTestHost(`Reusing running Docker test host container ${dockerContainer}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
await this.ensureUserSession();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
public createGuestShellProcess(): ChildProcessWithoutNullStreams {
|
|
323
|
+
const runtimeInfo = this.requireRuntimeInfo();
|
|
324
|
+
return spawn(
|
|
325
|
+
`docker`,
|
|
326
|
+
[
|
|
327
|
+
`exec`,
|
|
328
|
+
`--interactive`,
|
|
329
|
+
`--user`,
|
|
330
|
+
dockerUser,
|
|
331
|
+
`--env`,
|
|
332
|
+
`HOME=${runtimeInfo.home}`,
|
|
333
|
+
`--env`,
|
|
334
|
+
`XDG_RUNTIME_DIR=${runtimeInfo.runtimeDir}`,
|
|
335
|
+
`--env`,
|
|
336
|
+
`DBUS_SESSION_BUS_ADDRESS=unix:path=${runtimeInfo.runtimeDir}/bus`,
|
|
337
|
+
dockerContainer,
|
|
338
|
+
`bash`,
|
|
339
|
+
`--noprofile`,
|
|
340
|
+
`--norc`,
|
|
341
|
+
`-s`,
|
|
342
|
+
],
|
|
343
|
+
{
|
|
344
|
+
stdio: [`pipe`, `pipe`, `pipe`],
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
public async runIsolatedCommand(script: string): Promise<string> {
|
|
350
|
+
const runtimeInfo = this.requireRuntimeInfo();
|
|
351
|
+
const result = await this.execDockerAsUser(runtimeInfo, [`bash`, `-lc`, script]);
|
|
352
|
+
|
|
353
|
+
return mergeOutput(result.stdout, result.stderr);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private async isContainerRunning(): Promise<boolean> {
|
|
357
|
+
try {
|
|
358
|
+
const status = await this.execDocker([
|
|
359
|
+
`inspect`,
|
|
360
|
+
`--format`,
|
|
361
|
+
`{{.State.Running}}`,
|
|
362
|
+
dockerContainer,
|
|
363
|
+
]);
|
|
364
|
+
return status.trim() === `true`;
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async removeExistingContainer(): Promise<void> {
|
|
371
|
+
try {
|
|
372
|
+
await this.execDocker([`rm`, `--force`, dockerContainer]);
|
|
373
|
+
} catch {
|
|
374
|
+
// Ignore missing containers so the first run can proceed.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async ensureUserSession(): Promise<void> {
|
|
379
|
+
const runtimeInfo = await this.resolveRuntimeInfo();
|
|
380
|
+
logTestHost(
|
|
381
|
+
`Ensuring user session for ${dockerUser} (uid=${runtimeInfo.userId}, home=${runtimeInfo.home})`,
|
|
382
|
+
);
|
|
383
|
+
await this.execDocker(
|
|
384
|
+
[
|
|
385
|
+
`exec`,
|
|
386
|
+
dockerContainer,
|
|
387
|
+
`bash`,
|
|
388
|
+
`-lc`,
|
|
389
|
+
[
|
|
390
|
+
`mkdir -p ${shellQuote(dockerStateRoot)}`,
|
|
391
|
+
`chown ${runtimeInfo.userId}:${runtimeInfo.userId} ${shellQuote(dockerStateRoot)}`,
|
|
392
|
+
`loginctl enable-linger ${shellQuote(dockerUser)}`,
|
|
393
|
+
`systemctl start user@${runtimeInfo.userId}.service`,
|
|
394
|
+
].join(`\n`),
|
|
395
|
+
],
|
|
396
|
+
{ timeout: dockerExecTimeoutMs },
|
|
397
|
+
);
|
|
398
|
+
await this.execDockerAsUser(runtimeInfo, [
|
|
399
|
+
`bash`,
|
|
400
|
+
`-lc`,
|
|
401
|
+
`mkdir -p ${shellQuote(`${dockerStateRoot}/tests`)}`,
|
|
402
|
+
]);
|
|
403
|
+
logTestHost(`User session started for ${dockerUser}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private async execDocker(
|
|
407
|
+
args: readonly string[],
|
|
408
|
+
options?: {
|
|
409
|
+
readonly timeout?: number;
|
|
410
|
+
},
|
|
411
|
+
): Promise<string> {
|
|
412
|
+
const result = await execFileAsync(`docker`, args, {
|
|
413
|
+
maxBuffer: commandMaxBufferBytes,
|
|
414
|
+
timeout: options?.timeout,
|
|
415
|
+
});
|
|
416
|
+
return mergeOutput(result.stdout, result.stderr);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private execDockerAsUser(
|
|
420
|
+
runtimeInfo: {
|
|
421
|
+
readonly home: string;
|
|
422
|
+
readonly runtimeDir: string;
|
|
423
|
+
readonly userId: number;
|
|
424
|
+
},
|
|
425
|
+
command: readonly string[],
|
|
426
|
+
) {
|
|
427
|
+
return execFileAsync(
|
|
428
|
+
`docker`,
|
|
429
|
+
[
|
|
430
|
+
`exec`,
|
|
431
|
+
`--user`,
|
|
432
|
+
dockerUser,
|
|
433
|
+
`--env`,
|
|
434
|
+
`HOME=${runtimeInfo.home}`,
|
|
435
|
+
`--env`,
|
|
436
|
+
`XDG_RUNTIME_DIR=${runtimeInfo.runtimeDir}`,
|
|
437
|
+
`--env`,
|
|
438
|
+
`DBUS_SESSION_BUS_ADDRESS=unix:path=${runtimeInfo.runtimeDir}/bus`,
|
|
439
|
+
dockerContainer,
|
|
440
|
+
...command,
|
|
441
|
+
],
|
|
442
|
+
{ maxBuffer: commandMaxBufferBytes },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private requireRuntimeInfo(): {
|
|
447
|
+
readonly home: string;
|
|
448
|
+
readonly runtimeDir: string;
|
|
449
|
+
readonly userId: number;
|
|
450
|
+
} {
|
|
451
|
+
if (this.runtimeInfo === undefined) {
|
|
452
|
+
throw new Error(`Docker test host runtime info is unavailable before ensureHost() completes`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return this.runtimeInfo;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async resolveRuntimeInfo(): Promise<{
|
|
459
|
+
readonly home: string;
|
|
460
|
+
readonly runtimeDir: string;
|
|
461
|
+
readonly userId: number;
|
|
462
|
+
}> {
|
|
463
|
+
if (this.runtimeInfo !== undefined) {
|
|
464
|
+
return this.runtimeInfo;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const userId =
|
|
468
|
+
dockerConfiguredUserId === undefined
|
|
469
|
+
? await this.lookupUserId()
|
|
470
|
+
: Number(dockerConfiguredUserId);
|
|
471
|
+
const home = await this.lookupUserHome();
|
|
472
|
+
|
|
473
|
+
this.runtimeInfo = {
|
|
474
|
+
home,
|
|
475
|
+
runtimeDir: `/run/user/${userId}`,
|
|
476
|
+
userId,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
return this.runtimeInfo;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async lookupUserHome(): Promise<string> {
|
|
483
|
+
logTestHost(`Resolving home directory for Docker test user ${dockerUser}`);
|
|
484
|
+
const output = await this.execDocker([
|
|
485
|
+
`exec`,
|
|
486
|
+
dockerContainer,
|
|
487
|
+
`bash`,
|
|
488
|
+
`-lc`,
|
|
489
|
+
`getent passwd ${shellQuote(dockerUser)} | cut -d: -f6`,
|
|
490
|
+
]);
|
|
491
|
+
|
|
492
|
+
const home = output.trim();
|
|
493
|
+
if (home.length === 0) {
|
|
494
|
+
throw new Error(`Could not resolve home directory for Docker test user ${dockerUser}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
logTestHost(`Resolved home directory for ${dockerUser}: ${home}`);
|
|
498
|
+
return home;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async lookupUserId(): Promise<number> {
|
|
502
|
+
logTestHost(`Resolving uid for Docker test user ${dockerUser}`);
|
|
503
|
+
const output = await this.execDocker([
|
|
504
|
+
`exec`,
|
|
505
|
+
dockerContainer,
|
|
506
|
+
`bash`,
|
|
507
|
+
`-lc`,
|
|
508
|
+
`id -u ${shellQuote(dockerUser)}`,
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
const parsed = Number(output.trim());
|
|
512
|
+
if (!Number.isInteger(parsed)) {
|
|
513
|
+
throw new Error(`Could not resolve uid for Docker test user ${dockerUser}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
logTestHost(`Resolved uid for ${dockerUser}: ${parsed}`);
|
|
517
|
+
return parsed;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
class GuestShell {
|
|
522
|
+
private readonly process: ChildProcessWithoutNullStreams;
|
|
523
|
+
private readonly onExitCleanup: () => void;
|
|
524
|
+
private closed = false;
|
|
525
|
+
private buffer = ``;
|
|
526
|
+
private current:
|
|
527
|
+
| {
|
|
528
|
+
marker: string;
|
|
529
|
+
timeoutId: NodeJS.Timeout | undefined;
|
|
530
|
+
reject: (error: Error) => void;
|
|
531
|
+
resolve: (output: string) => void;
|
|
532
|
+
}
|
|
533
|
+
| undefined;
|
|
534
|
+
private readonly queue: Array<{
|
|
535
|
+
script: string;
|
|
536
|
+
reject: (error: Error) => void;
|
|
537
|
+
resolve: (output: string) => void;
|
|
538
|
+
timeoutMs: number | undefined;
|
|
539
|
+
}> = [];
|
|
540
|
+
|
|
541
|
+
public constructor(
|
|
542
|
+
createProcess: () => ChildProcessWithoutNullStreams,
|
|
543
|
+
onExitCleanup: () => void,
|
|
544
|
+
) {
|
|
545
|
+
this.process = createProcess();
|
|
546
|
+
this.onExitCleanup = onExitCleanup;
|
|
547
|
+
this.process.stdout.setEncoding(`utf8`);
|
|
548
|
+
this.process.stderr.setEncoding(`utf8`);
|
|
549
|
+
this.process.stdout.on(`data`, (chunk: string) => this.onData(chunk));
|
|
550
|
+
this.process.stderr.on(`data`, (chunk: string) => this.onData(chunk));
|
|
551
|
+
this.process.on(`exit`, (code, signal) => {
|
|
552
|
+
const reason = new Error(
|
|
553
|
+
`Persistent guest shell exited unexpectedly (code=${code ?? `null`}, signal=${signal ?? `null`})`,
|
|
554
|
+
);
|
|
555
|
+
this.failCurrent(reason);
|
|
556
|
+
while (this.queue.length > 0) {
|
|
557
|
+
this.queue.shift()?.reject(reason);
|
|
558
|
+
}
|
|
559
|
+
this.onExitCleanup();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
process.once(`exit`, () => {
|
|
563
|
+
this.process.kill();
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
public run(script: string, timeoutMs?: number): Promise<string> {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
this.queue.push({ script, resolve, reject, timeoutMs });
|
|
570
|
+
this.drain();
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public close(): void {
|
|
575
|
+
if (this.closed) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
this.closed = true;
|
|
580
|
+
this.process.kill();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private drain(): void {
|
|
584
|
+
if (this.current !== undefined) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const next = this.queue.shift();
|
|
589
|
+
if (next === undefined) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const marker = `__SYSTEMD_TS_END_${randomUUID()}__`;
|
|
594
|
+
const timeoutId =
|
|
595
|
+
next.timeoutMs === undefined
|
|
596
|
+
? undefined
|
|
597
|
+
: setTimeout(() => {
|
|
598
|
+
if (this.current?.marker !== marker) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const reason = new Error(`Guest command timed out after ${next.timeoutMs}ms`);
|
|
603
|
+
this.failCurrent(reason);
|
|
604
|
+
this.close();
|
|
605
|
+
}, next.timeoutMs);
|
|
606
|
+
this.current = {
|
|
607
|
+
marker,
|
|
608
|
+
timeoutId,
|
|
609
|
+
reject: next.reject,
|
|
610
|
+
resolve: next.resolve,
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
this.buffer = ``;
|
|
614
|
+
const command = `(
|
|
615
|
+
${next.script}
|
|
616
|
+
) 2>&1
|
|
617
|
+
status=$?
|
|
618
|
+
printf '\\n${marker}:%s\\n' "$status"
|
|
619
|
+
`;
|
|
620
|
+
|
|
621
|
+
this.process.stdin.write(command);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private failCurrent(error: Error): void {
|
|
625
|
+
const current = this.current;
|
|
626
|
+
this.current = undefined;
|
|
627
|
+
if (current?.timeoutId !== undefined) {
|
|
628
|
+
clearTimeout(current.timeoutId);
|
|
629
|
+
}
|
|
630
|
+
current?.reject(error);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private onData(chunk: string): void {
|
|
634
|
+
if (this.current === undefined) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this.buffer += chunk;
|
|
639
|
+
const markerIndex = this.buffer.indexOf(this.current.marker);
|
|
640
|
+
if (markerIndex === -1) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const output = this.buffer.slice(0, markerIndex).replace(/\n$/, ``);
|
|
645
|
+
const statusMatch = this.buffer
|
|
646
|
+
.slice(markerIndex)
|
|
647
|
+
.match(/^__SYSTEMD_TS_END_[^:]+__:(\d+)\r?\n?/u);
|
|
648
|
+
|
|
649
|
+
if (statusMatch === null) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const status = Number(statusMatch[1]);
|
|
654
|
+
const remainder = this.buffer.slice(markerIndex + statusMatch[0].length);
|
|
655
|
+
const current = this.current;
|
|
656
|
+
this.current = undefined;
|
|
657
|
+
this.buffer = remainder;
|
|
658
|
+
if (current.timeoutId !== undefined) {
|
|
659
|
+
clearTimeout(current.timeoutId);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (status === 0) {
|
|
663
|
+
current.resolve(output);
|
|
664
|
+
} else {
|
|
665
|
+
current.reject(new Error(output.length > 0 ? output : `Guest command failed`));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
this.drain();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function shellQuote(value: string): string {
|
|
673
|
+
return `'${value.replaceAll(`'`, `'\\''`)}'`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const backend = createBackend(selectedBackendName);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
closeTestHost,
|
|
3
|
+
ensureTestHost,
|
|
4
|
+
getTestHostInfo,
|
|
5
|
+
runGuestCommand,
|
|
6
|
+
runIsolatedGuestCommand,
|
|
7
|
+
} from "./host.ts";
|
|
8
|
+
export {
|
|
9
|
+
createGuestCommandExecutor,
|
|
10
|
+
guestCommandExecutor,
|
|
11
|
+
isolatedGuestCommandExecutor,
|
|
12
|
+
isolatedSandboxSystemd,
|
|
13
|
+
sandboxSystemd,
|
|
14
|
+
} from "./harness.ts";
|
|
15
|
+
export { createTestSandbox, destroyCurrentTestSandbox, useCurrentTestSandbox } from "./sandbox.ts";
|
|
16
|
+
export type { TestHostInfo } from "./host.ts";
|
|
17
|
+
export type { TestSandbox } from "./sandbox.ts";
|