@ajnart/self-host-testkit 1.2.1 → 1.3.0
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 +311 -227
- package/binary.mjs +24 -12
- package/examples/homarr-sonarr.mjs +66 -0
- package/examples/sonarr.mjs +3 -8
- package/index.d.mts +65 -3
- package/index.mjs +480 -84
- package/package.json +4 -1
- package/reasons.d.mts +15 -0
- package/reasons.mjs +14 -0
- package/setup.mjs +58 -34
package/index.mjs
CHANGED
|
@@ -5,6 +5,22 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { isAbsolute, join } from "node:path";
|
|
6
6
|
import { inspect } from "node:util";
|
|
7
7
|
import { installedBinary } from "./binary.mjs";
|
|
8
|
+
import { startupReasons } from "./reasons.mjs";
|
|
9
|
+
const pairStates = [
|
|
10
|
+
"running",
|
|
11
|
+
"exited",
|
|
12
|
+
"created",
|
|
13
|
+
"restarting",
|
|
14
|
+
"paused",
|
|
15
|
+
"missing",
|
|
16
|
+
"unknown",
|
|
17
|
+
"dead",
|
|
18
|
+
"removing",
|
|
19
|
+
"degraded",
|
|
20
|
+
];
|
|
21
|
+
function pairState(value) {
|
|
22
|
+
return pairStates.includes(value);
|
|
23
|
+
}
|
|
8
24
|
const MAX_MESSAGE = 65536;
|
|
9
25
|
const CLEANUP_TIMEOUT = 50000;
|
|
10
26
|
const messages = new Map([
|
|
@@ -16,49 +32,31 @@ const messages = new Map([
|
|
|
16
32
|
["CLEANUP", "Fixture cleanup failed; run manifest cleanup."],
|
|
17
33
|
["TIMEOUT", "Fixture operation timed out."],
|
|
18
34
|
["TRANSPORT", "Testkit helper transport failed."],
|
|
35
|
+
["OPERATION", "Fixture pair action or inspection failed."],
|
|
36
|
+
["CANCELED", "Fixture operation was canceled."],
|
|
19
37
|
[
|
|
20
38
|
"BINARY",
|
|
21
39
|
"Testkit helper not found. Run self-host-testkit-setup or set SELF_HOST_TESTKIT_BINARY.",
|
|
22
40
|
],
|
|
23
41
|
]);
|
|
24
|
-
const startupReasons = new Map([
|
|
25
|
-
["APP", "Choose one supported Catalog App."],
|
|
26
|
-
["OPTIONS", "Check the selected image and lifetime options."],
|
|
27
|
-
["DOCKER", "Check the local Unix Docker socket, daemon and Compose plugin."],
|
|
28
|
-
["CAPABILITIES", "Docker must support memory, CPU quota and PID limits."],
|
|
29
|
-
["PORT", "A required local port could not be reserved."],
|
|
30
|
-
["OWNERSHIP", "Fixture ownership or isolation could not be verified."],
|
|
31
|
-
[
|
|
32
|
-
"LIMITS",
|
|
33
|
-
"Container resource caps or selected image could not be verified.",
|
|
34
|
-
],
|
|
35
|
-
["LOCK", "The private startup lock is unavailable."],
|
|
36
|
-
[
|
|
37
|
-
"UNSUPPORTED",
|
|
38
|
-
"This App's Compose configuration or operations are unsupported.",
|
|
39
|
-
],
|
|
40
|
-
[
|
|
41
|
-
"STORAGE",
|
|
42
|
-
"Private fixture storage or the recovery manifest is unavailable.",
|
|
43
|
-
],
|
|
44
|
-
["CONTAINER", "The selected App container is absent."],
|
|
45
|
-
["SETUP", "App Setup failed or requires a Manual step."],
|
|
46
|
-
]);
|
|
47
42
|
export class TestkitError extends Error {
|
|
48
43
|
code;
|
|
49
44
|
reason;
|
|
50
|
-
|
|
45
|
+
cleanupFailed;
|
|
46
|
+
constructor(code, reason, recoveryPath, cleanupFailed = false) {
|
|
51
47
|
let message = messages.get(code);
|
|
52
|
-
if (code === "START" && reason)
|
|
53
|
-
message = `${message} ${startupReasons
|
|
48
|
+
if ((code === "START" || code === "OPERATION") && reason)
|
|
49
|
+
message = `${message} ${startupReasons[reason]}`;
|
|
54
50
|
super(message);
|
|
55
51
|
this.name = "TestkitError";
|
|
56
52
|
this.code = code;
|
|
57
53
|
this.reason = reason;
|
|
54
|
+
this.cleanupFailed = cleanupFailed;
|
|
55
|
+
Object.defineProperty(this, "recoveryPath", { value: recoveryPath });
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
|
-
function fault(code, reason) {
|
|
61
|
-
return new TestkitError(code, reason);
|
|
58
|
+
function fault(code, reason, recoveryPath, cleanupFailed = false) {
|
|
59
|
+
return new TestkitError(code, reason, recoveryPath, cleanupFailed);
|
|
62
60
|
}
|
|
63
61
|
function record(value) {
|
|
64
62
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -85,20 +83,20 @@ function privateCredentials(value) {
|
|
|
85
83
|
function runCleanup(binary, manifest, owner) {
|
|
86
84
|
return new Promise((resolve, reject) => {
|
|
87
85
|
const child = spawn(binary, ["--cleanup-manifest", manifest, "--owner", owner], { stdio: "ignore" });
|
|
86
|
+
let failed = false;
|
|
88
87
|
const timer = setTimeout(() => {
|
|
88
|
+
failed = true;
|
|
89
89
|
child.kill("SIGKILL");
|
|
90
|
-
reject(fault("CLEANUP"));
|
|
91
90
|
}, CLEANUP_TIMEOUT);
|
|
92
91
|
child.once("error", () => {
|
|
93
|
-
|
|
94
|
-
reject(fault("CLEANUP"));
|
|
92
|
+
failed = true;
|
|
95
93
|
});
|
|
96
|
-
child.once("
|
|
94
|
+
child.once("close", (code) => {
|
|
97
95
|
clearTimeout(timer);
|
|
98
|
-
if (code === 0)
|
|
96
|
+
if (!failed && code === 0)
|
|
99
97
|
resolve(undefined);
|
|
100
98
|
else
|
|
101
|
-
reject(fault("CLEANUP"));
|
|
99
|
+
reject(fault("CLEANUP", undefined, manifest));
|
|
102
100
|
});
|
|
103
101
|
});
|
|
104
102
|
}
|
|
@@ -137,12 +135,18 @@ class Bridge {
|
|
|
137
135
|
this.#child.stdin.on("error", () => this.#fail("TRANSPORT"));
|
|
138
136
|
this.#child.stdout.on("data", (chunk) => this.#receive(chunk));
|
|
139
137
|
}
|
|
138
|
+
get recoveryPath() {
|
|
139
|
+
return this.#manifest;
|
|
140
|
+
}
|
|
141
|
+
#fault(code, reason, cleanupFailed = false) {
|
|
142
|
+
return fault(code, reason, this.#manifest, cleanupFailed);
|
|
143
|
+
}
|
|
140
144
|
#fail(code) {
|
|
141
145
|
this.#failed = true;
|
|
142
146
|
this.#buffer = Buffer.alloc(0);
|
|
143
147
|
for (const pending of this.#pending.values()) {
|
|
144
148
|
clearTimeout(pending.timer);
|
|
145
|
-
pending.reject(fault(code));
|
|
149
|
+
pending.reject(this.#fault(code));
|
|
146
150
|
}
|
|
147
151
|
this.#pending.clear();
|
|
148
152
|
this.#child.kill("SIGTERM");
|
|
@@ -167,33 +171,40 @@ class Bridge {
|
|
|
167
171
|
!Number.isSafeInteger(response.id) ||
|
|
168
172
|
!this.#pending.has(response.id) ||
|
|
169
173
|
Object.hasOwn(response, "error") === Object.hasOwn(response, "result")) {
|
|
170
|
-
throw fault("PROTOCOL");
|
|
174
|
+
throw this.#fault("PROTOCOL");
|
|
171
175
|
}
|
|
172
176
|
const pending = this.#pending.get(response.id);
|
|
173
177
|
if (!pending)
|
|
174
|
-
throw fault("PROTOCOL");
|
|
178
|
+
throw this.#fault("PROTOCOL");
|
|
175
179
|
this.#pending.delete(response.id);
|
|
176
180
|
clearTimeout(pending.timer);
|
|
177
181
|
if (Object.hasOwn(response, "error")) {
|
|
178
182
|
if (!record(response.error) ||
|
|
179
183
|
typeof response.error.code !== "string" ||
|
|
180
184
|
!isErrorCode(response.error.code)) {
|
|
181
|
-
pending.reject(fault("PROTOCOL"));
|
|
185
|
+
pending.reject(this.#fault("PROTOCOL"));
|
|
182
186
|
this.#fail("PROTOCOL");
|
|
183
187
|
return;
|
|
184
188
|
}
|
|
185
189
|
let reason;
|
|
186
190
|
if (response.error.reason !== undefined) {
|
|
187
|
-
if (response.error.code !== "START"
|
|
191
|
+
if ((response.error.code !== "START" &&
|
|
192
|
+
response.error.code !== "OPERATION") ||
|
|
188
193
|
typeof response.error.reason !== "string" ||
|
|
189
194
|
!isStartupReason(response.error.reason)) {
|
|
190
|
-
pending.reject(fault("PROTOCOL"));
|
|
195
|
+
pending.reject(this.#fault("PROTOCOL"));
|
|
191
196
|
this.#fail("PROTOCOL");
|
|
192
197
|
return;
|
|
193
198
|
}
|
|
194
199
|
reason = response.error.reason;
|
|
195
200
|
}
|
|
196
|
-
|
|
201
|
+
if (response.error.cleanupFailed !== undefined &&
|
|
202
|
+
typeof response.error.cleanupFailed !== "boolean") {
|
|
203
|
+
pending.reject(this.#fault("PROTOCOL"));
|
|
204
|
+
this.#fail("PROTOCOL");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
pending.reject(this.#fault(response.error.code, reason, response.error.cleanupFailed === true));
|
|
197
208
|
}
|
|
198
209
|
else {
|
|
199
210
|
pending.resolve(response.result);
|
|
@@ -208,7 +219,9 @@ class Bridge {
|
|
|
208
219
|
}
|
|
209
220
|
request(method, params = {}, timeout = 35000) {
|
|
210
221
|
if (this.#failed)
|
|
211
|
-
return Promise.reject(fault("TRANSPORT"));
|
|
222
|
+
return Promise.reject(this.#fault("TRANSPORT"));
|
|
223
|
+
if (this.#closing)
|
|
224
|
+
return Promise.reject(this.#fault("STATE"));
|
|
212
225
|
const id = ++this.#sequence;
|
|
213
226
|
return new Promise((resolve, reject) => {
|
|
214
227
|
const timer = setTimeout(() => this.#fail("TIMEOUT"), timeout);
|
|
@@ -224,19 +237,37 @@ class Bridge {
|
|
|
224
237
|
});
|
|
225
238
|
});
|
|
226
239
|
}
|
|
240
|
+
async failAndClose(primary) {
|
|
241
|
+
let code = "PROTOCOL";
|
|
242
|
+
if (primary instanceof TestkitError)
|
|
243
|
+
code = primary.code;
|
|
244
|
+
this.#fail(code);
|
|
245
|
+
try {
|
|
246
|
+
await this.close();
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
if (primary instanceof TestkitError)
|
|
250
|
+
throw this.#fault(primary.code, primary.reason, true);
|
|
251
|
+
throw primary;
|
|
252
|
+
}
|
|
253
|
+
throw primary;
|
|
254
|
+
}
|
|
227
255
|
close() {
|
|
228
|
-
if (!this.#closing)
|
|
229
|
-
this.#closing = this.#close()
|
|
256
|
+
if (!this.#closing) {
|
|
257
|
+
this.#closing = this.#close().catch((error) => {
|
|
258
|
+
this.#closing = undefined;
|
|
259
|
+
throw error;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
230
262
|
return this.#closing;
|
|
231
263
|
}
|
|
232
264
|
async #close() {
|
|
233
|
-
let failure;
|
|
234
265
|
if (!this.#failed) {
|
|
235
266
|
try {
|
|
236
267
|
await this.request("close", {}, CLEANUP_TIMEOUT);
|
|
237
268
|
}
|
|
238
269
|
catch {
|
|
239
|
-
|
|
270
|
+
this.#fail("TRANSPORT");
|
|
240
271
|
}
|
|
241
272
|
}
|
|
242
273
|
this.#child.stdin.end();
|
|
@@ -245,14 +276,7 @@ class Bridge {
|
|
|
245
276
|
clearTimeout(kill);
|
|
246
277
|
if (!this.#spawned)
|
|
247
278
|
return;
|
|
248
|
-
|
|
249
|
-
await runCleanup(this.#binary, this.#manifest, this.#owner);
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
failure = fault("CLEANUP");
|
|
253
|
-
}
|
|
254
|
-
if (failure)
|
|
255
|
-
throw failure;
|
|
279
|
+
await runCleanup(this.#binary, this.#manifest, this.#owner);
|
|
256
280
|
}
|
|
257
281
|
[inspect.custom]() {
|
|
258
282
|
return "[TestkitBridge]";
|
|
@@ -262,46 +286,74 @@ class AppHandle {
|
|
|
262
286
|
#bridge;
|
|
263
287
|
#url;
|
|
264
288
|
#closed = false;
|
|
265
|
-
|
|
289
|
+
#app;
|
|
290
|
+
constructor(bridge, url, app) {
|
|
266
291
|
this.#bridge = bridge;
|
|
267
292
|
this.#url = url;
|
|
293
|
+
this.#app = app;
|
|
268
294
|
}
|
|
269
295
|
get url() {
|
|
270
296
|
return this.#url;
|
|
271
297
|
}
|
|
298
|
+
get recoveryPath() {
|
|
299
|
+
return this.#bridge.recoveryPath;
|
|
300
|
+
}
|
|
301
|
+
#validate(read) {
|
|
302
|
+
try {
|
|
303
|
+
return read();
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (this.#app !== undefined)
|
|
307
|
+
return this.#bridge.failAndClose(error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
272
311
|
async getApiKey(name = "") {
|
|
273
312
|
if (this.#closed || typeof name !== "string")
|
|
274
313
|
throw fault("STATE");
|
|
275
|
-
const value = await this.#bridge.request("getApiKey", {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
314
|
+
const value = await this.#bridge.request("getApiKey", {
|
|
315
|
+
name,
|
|
316
|
+
app: this.#app,
|
|
317
|
+
});
|
|
318
|
+
return this.#validate(() => {
|
|
319
|
+
if (!strings(value, ["apiKey"]))
|
|
320
|
+
throw fault("PROTOCOL");
|
|
321
|
+
return value.apiKey;
|
|
322
|
+
});
|
|
279
323
|
}
|
|
280
324
|
async credentials() {
|
|
281
325
|
if (this.#closed)
|
|
282
326
|
throw fault("STATE");
|
|
283
|
-
|
|
327
|
+
const value = await this.#bridge.request("credentials", { app: this.#app });
|
|
328
|
+
return this.#validate(() => privateCredentials(value));
|
|
284
329
|
}
|
|
285
330
|
async info() {
|
|
286
331
|
if (this.#closed)
|
|
287
332
|
throw fault("STATE");
|
|
288
|
-
const value = await this.#bridge.request("info");
|
|
289
|
-
|
|
290
|
-
!
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
333
|
+
const value = await this.#bridge.request("info", { app: this.#app });
|
|
334
|
+
return this.#validate(() => {
|
|
335
|
+
if (!strings(value, ["id", "url", "image", "imageId"]) ||
|
|
336
|
+
!Array.isArray(value.repoDigests) ||
|
|
337
|
+
!value.repoDigests.every((digest) => typeof digest === "string")) {
|
|
338
|
+
throw fault("PROTOCOL");
|
|
339
|
+
}
|
|
340
|
+
if (this.#app !== undefined &&
|
|
341
|
+
(value.id !== this.#app ||
|
|
342
|
+
!loopback(value.url) ||
|
|
343
|
+
value.url !== this.#url))
|
|
344
|
+
throw fault("PROTOCOL");
|
|
345
|
+
return {
|
|
346
|
+
id: value.id,
|
|
347
|
+
url: value.url,
|
|
348
|
+
image: value.image,
|
|
349
|
+
imageId: value.imageId,
|
|
350
|
+
repoDigests: [...value.repoDigests],
|
|
351
|
+
};
|
|
352
|
+
});
|
|
301
353
|
}
|
|
302
|
-
|
|
354
|
+
close() {
|
|
303
355
|
this.#closed = true;
|
|
304
|
-
|
|
356
|
+
return this.#bridge.close();
|
|
305
357
|
}
|
|
306
358
|
toJSON() {
|
|
307
359
|
return "[App]";
|
|
@@ -310,6 +362,240 @@ class AppHandle {
|
|
|
310
362
|
return "[App]";
|
|
311
363
|
}
|
|
312
364
|
}
|
|
365
|
+
function appID(value) {
|
|
366
|
+
return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value);
|
|
367
|
+
}
|
|
368
|
+
function loopback(value) {
|
|
369
|
+
try {
|
|
370
|
+
const address = new URL(value);
|
|
371
|
+
return (address.protocol === "http:" &&
|
|
372
|
+
address.hostname === "127.0.0.1" &&
|
|
373
|
+
address.port !== "" &&
|
|
374
|
+
address.username === "" &&
|
|
375
|
+
address.password === "" &&
|
|
376
|
+
address.search === "" &&
|
|
377
|
+
address.hash === "");
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function pairAppStatus(value, id, url) {
|
|
384
|
+
if (!strings(value, ["id", "url", "state", "setup"]) ||
|
|
385
|
+
value.id !== id ||
|
|
386
|
+
!loopback(value.url) ||
|
|
387
|
+
value.url !== url ||
|
|
388
|
+
typeof value.installed !== "boolean" ||
|
|
389
|
+
!pairState(value.state) ||
|
|
390
|
+
(value.setup !== "ok" &&
|
|
391
|
+
value.setup !== "manual" &&
|
|
392
|
+
value.setup !== "incomplete" &&
|
|
393
|
+
value.setup !== "pending") ||
|
|
394
|
+
!Array.isArray(value.steps))
|
|
395
|
+
throw fault("PROTOCOL");
|
|
396
|
+
const seen = new Set();
|
|
397
|
+
const steps = value.steps.map((step) => {
|
|
398
|
+
if (!strings(step, ["id", "status"]) ||
|
|
399
|
+
!appID(step.id) ||
|
|
400
|
+
(step.status !== "ok" &&
|
|
401
|
+
step.status !== "manual" &&
|
|
402
|
+
step.status !== "fail" &&
|
|
403
|
+
step.status !== "skip") ||
|
|
404
|
+
(step.verified !== undefined && typeof step.verified !== "boolean") ||
|
|
405
|
+
(step.verified === true && step.status !== "skip"))
|
|
406
|
+
throw fault("PROTOCOL");
|
|
407
|
+
if (seen.has(step.id))
|
|
408
|
+
throw fault("PROTOCOL");
|
|
409
|
+
seen.add(step.id);
|
|
410
|
+
return {
|
|
411
|
+
id: step.id,
|
|
412
|
+
status: step.status,
|
|
413
|
+
verified: step.verified === true,
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
id,
|
|
418
|
+
url: value.url,
|
|
419
|
+
installed: value.installed,
|
|
420
|
+
state: value.state,
|
|
421
|
+
setup: value.setup,
|
|
422
|
+
steps,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function linked(step) {
|
|
426
|
+
return step.status === "ok" || (step.status === "skip" && step.verified);
|
|
427
|
+
}
|
|
428
|
+
function pairInspection(value, ids, urls) {
|
|
429
|
+
if (!strings(value, ["link", "outcome", "reason"]) ||
|
|
430
|
+
!appID(value.link) ||
|
|
431
|
+
!Array.isArray(value.apps) ||
|
|
432
|
+
value.apps.length !== 2)
|
|
433
|
+
throw fault("PROTOCOL");
|
|
434
|
+
const rawApps = value.apps;
|
|
435
|
+
const apps = ids.map((id, index) => pairAppStatus(rawApps[index], id, urls[index] ?? ""));
|
|
436
|
+
const base = { link: value.link, apps };
|
|
437
|
+
const failed = apps.some((app) => app.steps.some((step) => step.status === "fail"));
|
|
438
|
+
if ((value.outcome === "failed") !== failed)
|
|
439
|
+
throw fault("PROTOCOL");
|
|
440
|
+
if (!failed &&
|
|
441
|
+
(value.outcome === "manual") !== apps.some((app) => app.setup === "manual"))
|
|
442
|
+
throw fault("PROTOCOL");
|
|
443
|
+
if (value.outcome === "complete" && value.reason === "verified") {
|
|
444
|
+
if (apps.some((app) => !app.installed ||
|
|
445
|
+
app.setup !== "ok" ||
|
|
446
|
+
app.steps.some((step) => step.status === "fail" || step.status === "manual")) ||
|
|
447
|
+
!apps[0]?.steps.some((step) => step.id === base.link && linked(step)))
|
|
448
|
+
throw fault("PROTOCOL");
|
|
449
|
+
return { ...base, outcome: "complete", reason: "verified" };
|
|
450
|
+
}
|
|
451
|
+
if ((value.outcome === "manual" || value.outcome === "failed") &&
|
|
452
|
+
value.reason === "setup")
|
|
453
|
+
return { ...base, outcome: value.outcome, reason: "setup" };
|
|
454
|
+
if (value.outcome === "prerequisite" &&
|
|
455
|
+
(value.reason === "absent" ||
|
|
456
|
+
value.reason === "unready" ||
|
|
457
|
+
value.reason === "not-run")) {
|
|
458
|
+
const absent = apps.some((app) => !app.installed);
|
|
459
|
+
const unready = apps.some((app) => app.setup !== "ok");
|
|
460
|
+
if ((value.reason === "absent") !== absent ||
|
|
461
|
+
(!absent && (value.reason === "unready") !== unready) ||
|
|
462
|
+
(!absent &&
|
|
463
|
+
!unready &&
|
|
464
|
+
apps[0]?.steps.some((step) => step.id === base.link && linked(step))))
|
|
465
|
+
throw fault("PROTOCOL");
|
|
466
|
+
return { ...base, outcome: "prerequisite", reason: value.reason };
|
|
467
|
+
}
|
|
468
|
+
throw fault("PROTOCOL");
|
|
469
|
+
}
|
|
470
|
+
function sonarrFilterWitness(value) {
|
|
471
|
+
if (!record(value) ||
|
|
472
|
+
Object.keys(value).length !== 3 ||
|
|
473
|
+
value.kind !== "sonarr-custom-filter-v1" ||
|
|
474
|
+
typeof value.objectId !== "number" ||
|
|
475
|
+
!Number.isSafeInteger(value.objectId) ||
|
|
476
|
+
value.objectId < 1 ||
|
|
477
|
+
value.objectId > 2147483647 ||
|
|
478
|
+
typeof value.contentSha256 !== "string" ||
|
|
479
|
+
!/^[a-f0-9]{64}$/.test(value.contentSha256))
|
|
480
|
+
throw fault("PROTOCOL");
|
|
481
|
+
return Object.freeze({
|
|
482
|
+
kind: "sonarr-custom-filter-v1",
|
|
483
|
+
objectId: value.objectId,
|
|
484
|
+
contentSha256: value.contentSha256,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
class PairAppHandle {
|
|
488
|
+
#view;
|
|
489
|
+
constructor(bridge, id, url) {
|
|
490
|
+
this.#view = new AppHandle(bridge, url, id);
|
|
491
|
+
}
|
|
492
|
+
get url() {
|
|
493
|
+
return this.#view.url;
|
|
494
|
+
}
|
|
495
|
+
get recoveryPath() {
|
|
496
|
+
return this.#view.recoveryPath;
|
|
497
|
+
}
|
|
498
|
+
getApiKey(name) {
|
|
499
|
+
return this.#view.getApiKey(name);
|
|
500
|
+
}
|
|
501
|
+
credentials() {
|
|
502
|
+
return this.#view.credentials();
|
|
503
|
+
}
|
|
504
|
+
info() {
|
|
505
|
+
return this.#view.info();
|
|
506
|
+
}
|
|
507
|
+
toJSON() {
|
|
508
|
+
return "[PairApp]";
|
|
509
|
+
}
|
|
510
|
+
[inspect.custom]() {
|
|
511
|
+
return "[PairApp]";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
class PairHandle {
|
|
515
|
+
#bridge;
|
|
516
|
+
#ids;
|
|
517
|
+
#initial;
|
|
518
|
+
#timeout;
|
|
519
|
+
#consumer;
|
|
520
|
+
#target;
|
|
521
|
+
#witnessEnabled;
|
|
522
|
+
constructor(bridge, consumer, target, consumerURL, targetURL, initial, timeout, witnessEnabled) {
|
|
523
|
+
this.#bridge = bridge;
|
|
524
|
+
this.#ids = [consumer, target];
|
|
525
|
+
this.#initial = initial;
|
|
526
|
+
this.#timeout = timeout;
|
|
527
|
+
this.#witnessEnabled = witnessEnabled;
|
|
528
|
+
this.#consumer = new PairAppHandle(bridge, consumer, consumerURL);
|
|
529
|
+
this.#target = new PairAppHandle(bridge, target, targetURL);
|
|
530
|
+
}
|
|
531
|
+
get consumer() {
|
|
532
|
+
return this.#consumer;
|
|
533
|
+
}
|
|
534
|
+
get target() {
|
|
535
|
+
return this.#target;
|
|
536
|
+
}
|
|
537
|
+
get initial() {
|
|
538
|
+
return pairInspection(this.#initial, this.#ids, this.#initial.apps.map((app) => app.url));
|
|
539
|
+
}
|
|
540
|
+
get recoveryPath() {
|
|
541
|
+
return this.#bridge.recoveryPath;
|
|
542
|
+
}
|
|
543
|
+
async #action(method, app) {
|
|
544
|
+
if (app !== undefined && !this.#ids.includes(app))
|
|
545
|
+
throw fault("STATE");
|
|
546
|
+
const result = await this.#bridge.request(method, { app }, this.#timeout + 1000);
|
|
547
|
+
try {
|
|
548
|
+
if (!record(result) || result.link !== this.#initial.link)
|
|
549
|
+
throw fault("PROTOCOL");
|
|
550
|
+
return pairInspection(result, this.#ids, this.#initial.apps.map((app) => app.url));
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
return this.#bridge.failAndClose(error);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
inspect() {
|
|
557
|
+
return this.#action("inspect");
|
|
558
|
+
}
|
|
559
|
+
setup(app) {
|
|
560
|
+
return this.#action("setup", app);
|
|
561
|
+
}
|
|
562
|
+
remove(app) {
|
|
563
|
+
if (!this.#ids.includes(app))
|
|
564
|
+
return Promise.reject(fault("STATE"));
|
|
565
|
+
return this.#action("remove", app);
|
|
566
|
+
}
|
|
567
|
+
install(app) {
|
|
568
|
+
if (!this.#ids.includes(app))
|
|
569
|
+
return Promise.reject(fault("STATE"));
|
|
570
|
+
return this.#action("install", app);
|
|
571
|
+
}
|
|
572
|
+
async #sonarrWitness(method) {
|
|
573
|
+
if (!this.#witnessEnabled)
|
|
574
|
+
throw fault("STATE");
|
|
575
|
+
const value = await this.#bridge.request(method);
|
|
576
|
+
try {
|
|
577
|
+
return sonarrFilterWitness(value);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
return this.#bridge.failAndClose(error);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
createSonarrFilterWitness() {
|
|
584
|
+
return this.#sonarrWitness("createSonarrFilterWitness");
|
|
585
|
+
}
|
|
586
|
+
readSonarrFilterWitness() {
|
|
587
|
+
return this.#sonarrWitness("readSonarrFilterWitness");
|
|
588
|
+
}
|
|
589
|
+
close() {
|
|
590
|
+
return this.#bridge.close();
|
|
591
|
+
}
|
|
592
|
+
toJSON() {
|
|
593
|
+
return "[Pair]";
|
|
594
|
+
}
|
|
595
|
+
[inspect.custom]() {
|
|
596
|
+
return "[Pair]";
|
|
597
|
+
}
|
|
598
|
+
}
|
|
313
599
|
class SelfHostHandle {
|
|
314
600
|
#binary;
|
|
315
601
|
#timeout;
|
|
@@ -350,6 +636,9 @@ class SelfHostHandle {
|
|
|
350
636
|
}
|
|
351
637
|
}
|
|
352
638
|
}
|
|
639
|
+
get recoveryPath() {
|
|
640
|
+
return this.#manifest;
|
|
641
|
+
}
|
|
353
642
|
async spinup(app, options) {
|
|
354
643
|
const selected = options ?? {};
|
|
355
644
|
if (this.#closed)
|
|
@@ -378,23 +667,99 @@ class SelfHostHandle {
|
|
|
378
667
|
image: options.image,
|
|
379
668
|
timeoutMs: this.#timeout,
|
|
380
669
|
}, this.#timeout + 1000);
|
|
381
|
-
if (!strings(value, ["url"]) ||
|
|
382
|
-
|
|
383
|
-
throw fault("PROTOCOL");
|
|
670
|
+
if (!strings(value, ["url"]) || !loopback(value.url)) {
|
|
671
|
+
throw fault("PROTOCOL", undefined, this.#manifest);
|
|
384
672
|
}
|
|
385
673
|
if (this.#closed)
|
|
386
|
-
throw fault("STATE");
|
|
674
|
+
throw fault("STATE", undefined, this.#manifest);
|
|
387
675
|
return new AppHandle(bridge, value.url);
|
|
388
676
|
}
|
|
389
677
|
catch (error) {
|
|
390
|
-
|
|
678
|
+
try {
|
|
679
|
+
await bridge.close();
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
391
684
|
throw error;
|
|
392
685
|
}
|
|
393
686
|
}
|
|
687
|
+
async spinupPair(consumer, target, options) {
|
|
688
|
+
if (this.#closed)
|
|
689
|
+
throw fault("STATE");
|
|
690
|
+
if (!appID(consumer) ||
|
|
691
|
+
!appID(target) ||
|
|
692
|
+
consumer === target ||
|
|
693
|
+
!record(options) ||
|
|
694
|
+
(options.order !== "consumer-first" && options.order !== "target-first"))
|
|
695
|
+
throw fault("PROTOCOL");
|
|
696
|
+
if (options.sonarrFilterWitness !== undefined &&
|
|
697
|
+
(options.sonarrFilterWitness !== true ||
|
|
698
|
+
consumer !== "homarr" ||
|
|
699
|
+
target !== "sonarr" ||
|
|
700
|
+
typeof options.images?.sonarr !== "string" ||
|
|
701
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._/:-]*@sha256:[a-f0-9]{64}$/.test(options.images.sonarr) ||
|
|
702
|
+
options.images.sonarr.length > 512))
|
|
703
|
+
throw fault("PROTOCOL");
|
|
704
|
+
if (options.images !== undefined &&
|
|
705
|
+
(!record(options.images) ||
|
|
706
|
+
Object.entries(options.images).some(([id, image]) => (id !== consumer && id !== target) || typeof image !== "string")))
|
|
707
|
+
throw fault("PROTOCOL");
|
|
708
|
+
const bridge = new Bridge(this.#binary, this.#manifest);
|
|
709
|
+
this.#bridges.add(bridge);
|
|
710
|
+
const start = this.#spinupPair(bridge, consumer, target, options);
|
|
711
|
+
this.#starts.add(start);
|
|
712
|
+
try {
|
|
713
|
+
return await start;
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
this.#starts.delete(start);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async #spinupPair(bridge, consumer, target, options) {
|
|
720
|
+
try {
|
|
721
|
+
if (options.sonarrFilterWitness) {
|
|
722
|
+
const capabilities = await bridge.request("capabilities", {}, Math.min(35000, this.#timeout));
|
|
723
|
+
if (!record(capabilities) ||
|
|
724
|
+
Object.keys(capabilities).length !== 1 ||
|
|
725
|
+
capabilities.sonarrFilterWitness !== 1)
|
|
726
|
+
throw fault("PROTOCOL");
|
|
727
|
+
}
|
|
728
|
+
const value = await bridge.request("spinupPair", {
|
|
729
|
+
consumer,
|
|
730
|
+
target,
|
|
731
|
+
order: options.order,
|
|
732
|
+
images: options.images,
|
|
733
|
+
timeoutMs: this.#timeout,
|
|
734
|
+
sonarrFilterWitness: options.sonarrFilterWitness,
|
|
735
|
+
}, this.#timeout + 1000);
|
|
736
|
+
if (!record(value) ||
|
|
737
|
+
!strings(value.consumer, ["id", "url"]) ||
|
|
738
|
+
!strings(value.target, ["id", "url"]) ||
|
|
739
|
+
value.consumer.id !== consumer ||
|
|
740
|
+
value.target.id !== target ||
|
|
741
|
+
!loopback(value.consumer.url) ||
|
|
742
|
+
!loopback(value.target.url) ||
|
|
743
|
+
value.consumer.url === value.target.url)
|
|
744
|
+
throw fault("PROTOCOL", undefined, this.#manifest);
|
|
745
|
+
const initial = pairInspection(value.initial, [consumer, target], [value.consumer.url, value.target.url]);
|
|
746
|
+
if (options.sonarrFilterWitness && value.sonarrFilterWitness !== 1)
|
|
747
|
+
throw fault("PROTOCOL");
|
|
748
|
+
if (this.#closed)
|
|
749
|
+
throw fault("STATE", undefined, this.#manifest);
|
|
750
|
+
return new PairHandle(bridge, consumer, target, value.consumer.url, value.target.url, initial, this.#timeout, options.sonarrFilterWitness === true);
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
return bridge.failAndClose(error);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
394
756
|
close() {
|
|
395
757
|
if (!this.#closing) {
|
|
396
758
|
this.#closed = true;
|
|
397
|
-
this.#closing = this.#close()
|
|
759
|
+
this.#closing = this.#close().catch((error) => {
|
|
760
|
+
this.#closing = undefined;
|
|
761
|
+
throw error;
|
|
762
|
+
});
|
|
398
763
|
}
|
|
399
764
|
return this.#closing;
|
|
400
765
|
}
|
|
@@ -402,7 +767,7 @@ class SelfHostHandle {
|
|
|
402
767
|
const results = await Promise.allSettled([...this.#bridges].map((bridge) => bridge.close()));
|
|
403
768
|
await Promise.allSettled([...this.#starts]);
|
|
404
769
|
if (results.some((result) => result.status === "rejected"))
|
|
405
|
-
throw fault("CLEANUP");
|
|
770
|
+
throw fault("CLEANUP", undefined, this.#manifest);
|
|
406
771
|
if (this.#directory) {
|
|
407
772
|
try {
|
|
408
773
|
try {
|
|
@@ -417,7 +782,7 @@ class SelfHostHandle {
|
|
|
417
782
|
rmdirSync(this.#directory);
|
|
418
783
|
}
|
|
419
784
|
catch {
|
|
420
|
-
throw fault("CLEANUP");
|
|
785
|
+
throw fault("CLEANUP", undefined, this.#manifest);
|
|
421
786
|
}
|
|
422
787
|
}
|
|
423
788
|
}
|
|
@@ -432,10 +797,41 @@ function isErrorCode(value) {
|
|
|
432
797
|
return [...messages.keys()].some((code) => code === value);
|
|
433
798
|
}
|
|
434
799
|
function isStartupReason(value) {
|
|
435
|
-
return
|
|
800
|
+
return Object.hasOwn(startupReasons, value);
|
|
436
801
|
}
|
|
437
802
|
export function createSelfHost(options = {}) {
|
|
438
803
|
if (!record(options))
|
|
439
804
|
throw fault("PROTOCOL");
|
|
440
805
|
return new SelfHostHandle(options);
|
|
441
806
|
}
|
|
807
|
+
async function scopedLifetime(work, close, onCleanupError) {
|
|
808
|
+
let result;
|
|
809
|
+
try {
|
|
810
|
+
result = { ok: true, value: await work() };
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
result = { ok: false, error };
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
await close();
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (result.ok)
|
|
820
|
+
throw error;
|
|
821
|
+
if (onCleanupError && error instanceof TestkitError)
|
|
822
|
+
await Promise.allSettled([
|
|
823
|
+
Promise.resolve().then(() => onCleanupError(error)),
|
|
824
|
+
]);
|
|
825
|
+
}
|
|
826
|
+
if (!result.ok)
|
|
827
|
+
throw result.error;
|
|
828
|
+
return result.value;
|
|
829
|
+
}
|
|
830
|
+
export async function withApp(app, callback, options = {}) {
|
|
831
|
+
const selfHost = createSelfHost(options);
|
|
832
|
+
return scopedLifetime(async () => callback(await selfHost.spinup(app, options)), () => selfHost.close(), options.onCleanupError);
|
|
833
|
+
}
|
|
834
|
+
export async function withPair(consumer, target, callback, options) {
|
|
835
|
+
const selfHost = createSelfHost(options);
|
|
836
|
+
return scopedLifetime(async () => callback(await selfHost.spinupPair(consumer, target, options)), () => selfHost.close(), options.onCleanupError);
|
|
837
|
+
}
|