@peerbit/server 6.0.42 → 7.0.1

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.
Files changed (84) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +161 -352
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +1 -1
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +2 -5
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/docker.d.ts +70 -1
  9. package/dist/src/docker.d.ts.map +1 -1
  10. package/dist/src/docker.js +559 -14
  11. package/dist/src/docker.js.map +1 -1
  12. package/dist/src/domain-lease.d.ts +126 -0
  13. package/dist/src/domain-lease.d.ts.map +1 -0
  14. package/dist/src/domain-lease.js +800 -0
  15. package/dist/src/domain-lease.js.map +1 -0
  16. package/dist/src/domain.d.ts +50 -2
  17. package/dist/src/domain.d.ts.map +1 -1
  18. package/dist/src/domain.js +484 -67
  19. package/dist/src/domain.js.map +1 -1
  20. package/dist/src/hetzner.d.ts +4 -21
  21. package/dist/src/hetzner.d.ts.map +1 -1
  22. package/dist/src/hetzner.js +4 -116
  23. package/dist/src/hetzner.js.map +1 -1
  24. package/dist/src/nginx-template.conf +19 -1
  25. package/dist/src/remotes.browser.d.ts +4 -1
  26. package/dist/src/remotes.browser.d.ts.map +1 -1
  27. package/dist/src/remotes.browser.js +1 -2
  28. package/dist/src/remotes.browser.js.map +1 -1
  29. package/dist/src/remotes.d.ts +2 -1
  30. package/dist/src/remotes.d.ts.map +1 -1
  31. package/dist/src/remotes.js +1 -0
  32. package/dist/src/remotes.js.map +1 -1
  33. package/dist/src/server.d.ts.map +1 -1
  34. package/dist/src/server.js +3 -5
  35. package/dist/src/server.js.map +1 -1
  36. package/dist/test/api.spec.js +2 -1
  37. package/dist/test/api.spec.js.map +1 -1
  38. package/dist/test/cli.spec.js +48 -1
  39. package/dist/test/cli.spec.js.map +1 -1
  40. package/dist/test/docker.spec.d.ts +2 -0
  41. package/dist/test/docker.spec.d.ts.map +1 -0
  42. package/dist/test/docker.spec.js +822 -0
  43. package/dist/test/docker.spec.js.map +1 -0
  44. package/dist/test/domain-lease.spec.d.ts +2 -0
  45. package/dist/test/domain-lease.spec.d.ts.map +1 -0
  46. package/dist/test/domain-lease.spec.js +530 -0
  47. package/dist/test/domain-lease.spec.js.map +1 -0
  48. package/dist/test/domain.integration.spec.js +358 -75
  49. package/dist/test/domain.integration.spec.js.map +1 -1
  50. package/dist/test/remotes.spec.d.ts +2 -0
  51. package/dist/test/remotes.spec.d.ts.map +1 -0
  52. package/dist/test/remotes.spec.js +15 -0
  53. package/dist/test/remotes.spec.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -1
  55. package/dist/ui/assets/index-CjZwCewh.js +10 -0
  56. package/dist/ui/assets/index-e3g42MbC.js +9 -0
  57. package/dist/ui/index.html +1 -1
  58. package/package.json +14 -19
  59. package/src/cli.ts +204 -408
  60. package/src/client.ts +2 -6
  61. package/src/docker.ts +841 -20
  62. package/src/domain-lease.ts +1118 -0
  63. package/src/domain.ts +650 -90
  64. package/src/hetzner.ts +4 -184
  65. package/src/nginx-template.conf +19 -1
  66. package/src/remotes.browser.ts +7 -1
  67. package/src/remotes.ts +6 -1
  68. package/src/server.ts +3 -5
  69. package/dist/src/aws.browser.d.ts +0 -2
  70. package/dist/src/aws.browser.d.ts.map +0 -1
  71. package/dist/src/aws.browser.js +0 -3
  72. package/dist/src/aws.browser.js.map +0 -1
  73. package/dist/src/aws.d.ts +0 -30
  74. package/dist/src/aws.d.ts.map +0 -1
  75. package/dist/src/aws.js +0 -240
  76. package/dist/src/aws.js.map +0 -1
  77. package/dist/test/launch.spec.d.ts +0 -2
  78. package/dist/test/launch.spec.d.ts.map +0 -1
  79. package/dist/test/launch.spec.js +0 -23
  80. package/dist/test/launch.spec.js.map +0 -1
  81. package/dist/ui/assets/index-BBTKOM0z.js +0 -6
  82. package/dist/ui/assets/index-DVtj_GOf.js +0 -13
  83. package/src/aws.browser.ts +0 -1
  84. package/src/aws.ts +0 -327
package/src/docker.ts CHANGED
@@ -1,6 +1,30 @@
1
1
  import { delay, waitFor } from "@peerbit/time";
2
2
 
3
- type ExecResult = { stdout: string; stderr: string };
3
+ export type ExecResult = { stdout: string; stderr: string };
4
+
5
+ export type DockerExecutor = (args: readonly string[]) => Promise<ExecResult>;
6
+
7
+ export type DockerContainerInspection = {
8
+ Id?: string;
9
+ Name?: string;
10
+ RestartCount?: number;
11
+ Config?: {
12
+ Env?: string[];
13
+ Image?: string;
14
+ Labels?: Record<string, string> | null;
15
+ };
16
+ Mounts?: Array<{
17
+ Destination?: string;
18
+ Source?: string;
19
+ }>;
20
+ State?: {
21
+ Health?: {
22
+ Status?: string;
23
+ };
24
+ Restarting?: boolean;
25
+ Running?: boolean;
26
+ };
27
+ };
4
28
 
5
29
  const execCommand = async (cmd: string): Promise<ExecResult> => {
6
30
  const { exec } = await import("child_process");
@@ -17,6 +41,23 @@ const execCommand = async (cmd: string): Promise<ExecResult> => {
17
41
  });
18
42
  };
19
43
 
44
+ const execDockerCommand: DockerExecutor = async (
45
+ args: readonly string[],
46
+ ): Promise<ExecResult> => {
47
+ const { execFile } = await import("child_process");
48
+ return new Promise((resolve, reject) => {
49
+ execFile("docker", [...args], (error, stdout, stderr) => {
50
+ if (error) {
51
+ (error as any).stdout = stdout;
52
+ (error as any).stderr = stderr;
53
+ reject(error);
54
+ return;
55
+ }
56
+ resolve({ stdout, stderr });
57
+ });
58
+ });
59
+ };
60
+
20
61
  const commandExists = async (command: string): Promise<boolean> => {
21
62
  try {
22
63
  await execCommand(`command -v ${command}`);
@@ -151,27 +192,807 @@ export const installDocker = async () => {
151
192
  }
152
193
  };
153
194
 
154
- export const startContainer = async (cmd: string, errorMessage?: string) => {
155
- const { exec } = await import("child_process");
156
- const startContainer = () =>
157
- new Promise((resolve, reject) => {
158
- exec(cmd, (error, stdout, stderr) => {
159
- if (error) {
160
- reject(
161
- (errorMessage || "Failed to start docker container: ") +
162
- error.message,
195
+ const runDockerWithRetry = async (
196
+ args: readonly string[],
197
+ execute: DockerExecutor,
198
+ ) => {
199
+ try {
200
+ return await execute(args);
201
+ } catch {
202
+ await delay(10000);
203
+ return execute(args);
204
+ }
205
+ };
206
+
207
+ const isMissingContainerError = (error: any): boolean => {
208
+ const details = `${error?.stderr || ""}\n${error?.message || ""}`;
209
+ return /no such (object|container)/i.test(details);
210
+ };
211
+
212
+ export const inspectDockerContainer = async (
213
+ name: string,
214
+ execute: DockerExecutor = execDockerCommand,
215
+ ): Promise<DockerContainerInspection | undefined> => {
216
+ try {
217
+ const result = await execute(["container", "inspect", name]);
218
+ const parsed = JSON.parse(result.stdout);
219
+ if (!Array.isArray(parsed) || parsed.length !== 1) {
220
+ throw new Error(`Unexpected Docker inspection result for ${name}`);
221
+ }
222
+ return parsed[0] as DockerContainerInspection;
223
+ } catch (error: any) {
224
+ if (isMissingContainerError(error)) {
225
+ return undefined;
226
+ }
227
+ throw error;
228
+ }
229
+ };
230
+
231
+ export const pullDockerImage = async (
232
+ image: string,
233
+ execute: DockerExecutor = execDockerCommand,
234
+ ) => {
235
+ try {
236
+ await runDockerWithRetry(["pull", image], execute);
237
+ } catch (error: any) {
238
+ throw new Error(`Failed to pull Docker image ${image}: ${error?.message}`);
239
+ }
240
+ };
241
+
242
+ const errorMessage = (error: any): string =>
243
+ error?.message || error?.stderr || String(error);
244
+
245
+ export const DOCKER_REPLACEMENT_LABEL = "org.peerbit.replacement";
246
+ export const DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL =
247
+ "org.peerbit.replacement.original-id";
248
+ export const DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL =
249
+ "org.peerbit.replacement.original-running";
250
+
251
+ const getContainerReplacementLockAddress = async (name: string) => {
252
+ const { createHash } = await import("crypto");
253
+ const digest = createHash("sha256").update(name).digest("hex").slice(0, 32);
254
+ if (process.platform === "linux") {
255
+ // Linux abstract sockets have no filesystem entry and are released by the
256
+ // kernel if this process exits, including on an unclean exit.
257
+ return `\0peerbit-docker-replace-${digest}`;
258
+ }
259
+ if (process.platform === "win32") {
260
+ return `\\\\.\\pipe\\peerbit-docker-replace-${digest}`;
261
+ }
262
+ const path = await import("path");
263
+ return path.join("/tmp", `peerbit-docker-replace-${digest}.sock`);
264
+ };
265
+
266
+ const acquireContainerReplacementLock = async (name: string) => {
267
+ if (process.platform !== "linux" && process.platform !== "win32") {
268
+ const { createRequire } = await import("module");
269
+ const properLockfile = createRequire(import.meta.url)(
270
+ "proper-lockfile",
271
+ ) as {
272
+ lock: (
273
+ file: string,
274
+ options: { realpath: boolean; stale: number; update: number },
275
+ ) => Promise<() => Promise<void>>;
276
+ };
277
+ try {
278
+ return await properLockfile.lock(
279
+ await getContainerReplacementLockAddress(name),
280
+ {
281
+ realpath: false,
282
+ stale: 10_000,
283
+ update: 2_000,
284
+ },
285
+ );
286
+ } catch (error: any) {
287
+ if (error?.code === "ELOCKED") {
288
+ throw new Error(
289
+ `Another Peerbit configuration is already replacing Docker container ${name}`,
290
+ );
291
+ }
292
+ throw error;
293
+ }
294
+ }
295
+
296
+ const { createServer } = await import("net");
297
+ const address = await getContainerReplacementLockAddress(name);
298
+ const server = createServer();
299
+ try {
300
+ await new Promise<void>((resolve, reject) => {
301
+ const onError = (error: Error) => {
302
+ server.off("listening", onListening);
303
+ reject(error);
304
+ };
305
+ const onListening = () => {
306
+ server.off("error", onError);
307
+ resolve();
308
+ };
309
+ server.once("error", onError);
310
+ server.once("listening", onListening);
311
+ server.listen(address);
312
+ });
313
+ } catch (error: any) {
314
+ if (error?.code === "EADDRINUSE") {
315
+ throw new Error(
316
+ `Another Peerbit configuration is already replacing Docker container ${name}`,
317
+ );
318
+ }
319
+ throw error;
320
+ }
321
+
322
+ return () =>
323
+ new Promise<void>((resolve) => {
324
+ server.close(() => resolve());
325
+ });
326
+ };
327
+
328
+ /**
329
+ * Briefly stop a known running container while holding the same lock used by
330
+ * replacement transactions. The immutable container ID is checked before and
331
+ * after both mutations so an unrelated or concurrently replaced container is
332
+ * never stopped or started by name.
333
+ */
334
+ export const withQuiescedDockerContainer = async <T>(
335
+ name: string,
336
+ validate: (inspection: DockerContainerInspection) => boolean,
337
+ action: () => Promise<T>,
338
+ options: { execute?: DockerExecutor } = {},
339
+ ): Promise<T> => {
340
+ const execute = options.execute || execDockerCommand;
341
+ const releaseLock = await acquireContainerReplacementLock(name);
342
+ try {
343
+ const initial = await inspectDockerContainer(name, execute);
344
+ if (
345
+ !initial?.Id ||
346
+ initial.Name !== `/${name}` ||
347
+ initial.State?.Running !== true ||
348
+ !validate(initial)
349
+ ) {
350
+ throw new Error(
351
+ `Port owner is not the running recognized Peerbit-managed Docker container ${name}; refusing to stop anything`,
352
+ );
353
+ }
354
+ const id = initial.Id;
355
+ let shouldRestart = false;
356
+ let result: T | undefined;
357
+ let operationError: unknown;
358
+ try {
359
+ try {
360
+ await execute(["container", "stop", id]);
361
+ shouldRestart = true;
362
+ } catch (error) {
363
+ const afterFailedStop = await inspectDockerContainer(id, execute);
364
+ if (
365
+ afterFailedStop?.Id !== id ||
366
+ afterFailedStop.State?.Running !== false
367
+ ) {
368
+ throw error;
369
+ }
370
+ shouldRestart = true;
371
+ }
372
+ const stoppedById = await inspectDockerContainer(id, execute);
373
+ const stoppedByName = await inspectDockerContainer(name, execute);
374
+ if (
375
+ stoppedById?.Id !== id ||
376
+ stoppedById.State?.Running !== false ||
377
+ stoppedByName?.Id !== id ||
378
+ stoppedByName.Name !== `/${name}` ||
379
+ !validate(stoppedByName)
380
+ ) {
381
+ throw new Error(
382
+ `Docker container ${name} could not be verified after stopping`,
383
+ );
384
+ }
385
+ result = await action();
386
+ } catch (error) {
387
+ operationError = error;
388
+ }
389
+
390
+ let restartError: unknown;
391
+ if (shouldRestart) {
392
+ try {
393
+ const beforeRestartById = await inspectDockerContainer(id, execute);
394
+ const beforeRestartByName = await inspectDockerContainer(name, execute);
395
+ if (
396
+ beforeRestartById?.Id !== id ||
397
+ beforeRestartByName?.Id !== id ||
398
+ beforeRestartByName.Name !== `/${name}` ||
399
+ !validate(beforeRestartByName)
400
+ ) {
401
+ throw new Error(
402
+ `Docker container ${name} changed while quiesced; refusing an unsafe restart`,
163
403
  );
164
404
  }
165
- resolve(stdout);
166
- });
167
- });
405
+ try {
406
+ await execute(["container", "start", id]);
407
+ } catch (error) {
408
+ const afterFailedStart = await inspectDockerContainer(id, execute);
409
+ if (
410
+ afterFailedStart?.Id !== id ||
411
+ afterFailedStart.State?.Running !== true
412
+ ) {
413
+ throw error;
414
+ }
415
+ }
416
+ const restarted = await inspectDockerContainer(id, execute);
417
+ const restartedByName = await inspectDockerContainer(name, execute);
418
+ if (
419
+ restarted?.Id !== id ||
420
+ restarted.State?.Running !== true ||
421
+ restartedByName?.Id !== id ||
422
+ restartedByName.Name !== `/${name}` ||
423
+ !validate(restartedByName)
424
+ ) {
425
+ throw new Error(
426
+ `Docker container ${name} did not restart after DNS verification`,
427
+ );
428
+ }
429
+ } catch (error) {
430
+ restartError = error;
431
+ }
432
+ }
433
+ if (operationError && restartError) {
434
+ const error = new Error(
435
+ `DNS verification failed and Docker container ${name} could not be safely restarted: ${errorMessage(restartError)}`,
436
+ );
437
+ (error as any).cause = operationError;
438
+ throw error;
439
+ }
440
+ if (restartError) throw restartError;
441
+ if (operationError) throw operationError;
442
+ return result as T;
443
+ } finally {
444
+ await releaseLock();
445
+ }
446
+ };
447
+
448
+ const inspectOwnedReplacement = async (
449
+ reference: string,
450
+ replacementToken: string,
451
+ execute: DockerExecutor,
452
+ ) => {
453
+ const inspection = await inspectDockerContainer(reference, execute);
454
+ if (
455
+ inspection?.Config?.Labels?.[DOCKER_REPLACEMENT_LABEL] !== replacementToken
456
+ ) {
457
+ return undefined;
458
+ }
459
+ if (!inspection.Id) {
460
+ throw new Error(
461
+ `Owned Docker replacement ${reference} has no inspection ID`,
462
+ );
463
+ }
464
+ return inspection;
465
+ };
466
+
467
+ const isCanonicalReplacement = (
468
+ inspection: DockerContainerInspection | undefined,
469
+ replacementId: string,
470
+ name: string,
471
+ ) => inspection?.Id === replacementId && inspection.Name === `/${name}`;
472
+
473
+ const removeOwnedReplacement = async (
474
+ name: string,
475
+ replacementId: string | undefined,
476
+ replacementToken: string,
477
+ execute: DockerExecutor,
478
+ ) => {
479
+ const owned = await inspectOwnedReplacement(
480
+ replacementId || name,
481
+ replacementToken,
482
+ execute,
483
+ );
484
+ if (owned?.Id) {
485
+ await execute(["container", "rm", "--force", owned.Id]);
486
+ }
487
+ };
488
+
489
+ const restoreDockerContainer = async (
490
+ name: string,
491
+ existing: DockerContainerInspection,
492
+ wasRunning: boolean,
493
+ execute: DockerExecutor,
494
+ ) => {
495
+ if (!existing.Id) {
496
+ throw new Error(`Previous Docker container ${name} has no inspection ID`);
497
+ }
498
+
499
+ // Restore the state while the state-encoded backup name still exists. If the
500
+ // process exits between these operations, the next attempt can still infer the
501
+ // intended state from that name. Rename to the canonical name last.
502
+ let target = await inspectDockerContainer(existing.Id, execute);
503
+ if (!target || target.Id !== existing.Id) {
504
+ throw new Error(`Previous Docker container ${name} could not be inspected`);
505
+ }
506
+ if (wasRunning && target.State?.Running !== true) {
507
+ await execute(["container", "start", existing.Id]);
508
+ } else if (!wasRunning && target.State?.Running === true) {
509
+ await execute(["container", "stop", existing.Id]);
510
+ }
511
+ target = await inspectDockerContainer(existing.Id, execute);
512
+ if (
513
+ target?.Id !== existing.Id ||
514
+ (wasRunning && target.State?.Running !== true) ||
515
+ (!wasRunning && target.State?.Running === true)
516
+ ) {
517
+ throw new Error(
518
+ `Previous Docker container ${name} did not return to its original state`,
519
+ );
520
+ }
521
+
522
+ const canonical = await inspectDockerContainer(name, execute);
523
+ if (canonical?.Id !== existing.Id) {
524
+ await execute(["container", "rename", existing.Id, name]);
525
+ }
526
+
527
+ target = await inspectDockerContainer(name, execute);
528
+ if (
529
+ target?.Id !== existing.Id ||
530
+ (wasRunning && target.State?.Running !== true) ||
531
+ (!wasRunning && target.State?.Running === true)
532
+ ) {
533
+ throw new Error(
534
+ `Previous Docker container ${name} did not return to its original state`,
535
+ );
536
+ }
537
+ };
538
+
539
+ type DockerRunArguments =
540
+ | readonly string[]
541
+ | ((
542
+ existing: DockerContainerInspection | undefined,
543
+ ) => Promise<readonly string[]> | readonly string[]);
544
+
545
+ export type InterruptedDockerReplacementResolution =
546
+ | "keep-current"
547
+ | "restore-backup";
548
+
549
+ const RESERVED_REPLACEMENT_LABELS = new Set([
550
+ DOCKER_REPLACEMENT_LABEL,
551
+ DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL,
552
+ DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL,
553
+ ]);
554
+
555
+ const assertNoReservedReplacementLabels = (args: readonly string[]) => {
556
+ for (let index = 0; index < args.length; index++) {
557
+ let label: string | undefined;
558
+ if (
559
+ args[index] === "--label-file" ||
560
+ args[index].startsWith("--label-file=")
561
+ ) {
562
+ throw new Error(
563
+ "Docker replacement arguments cannot use --label-file because it could override reserved transaction labels",
564
+ );
565
+ }
566
+ if (args[index] === "--label" || args[index] === "-l") {
567
+ label = args[index + 1];
568
+ index += 1;
569
+ } else if (args[index].startsWith("--label=")) {
570
+ label = args[index].slice("--label=".length);
571
+ } else if (args[index].startsWith("-l=")) {
572
+ label = args[index].slice("-l=".length);
573
+ } else if (args[index].startsWith("-l") && args[index].length > 2) {
574
+ label = args[index].slice(2);
575
+ }
576
+ if (!label) continue;
577
+ const separator = label.indexOf("=");
578
+ const key = separator === -1 ? label : label.slice(0, separator);
579
+ if (RESERVED_REPLACEMENT_LABELS.has(key)) {
580
+ throw new Error(
581
+ `Docker replacement arguments cannot override reserved label ${key}`,
582
+ );
583
+ }
584
+ }
585
+ };
586
+
587
+ const assertDockerRunName = (args: readonly string[], expectedName: string) => {
588
+ const nameOptions = args.filter(
589
+ (argument) => argument === "--name" || argument.startsWith("--name="),
590
+ );
591
+ const hasCanonicalPrefix =
592
+ (args[1] === "--name" && args[2] === expectedName) ||
593
+ args[1] === `--name=${expectedName}`;
594
+ if (!hasCanonicalPrefix || nameOptions.length !== 1) {
595
+ throw new Error(
596
+ `Docker replacement arguments must place exactly one canonical --name ${expectedName} immediately after 'run'`,
597
+ );
598
+ }
599
+ };
600
+
601
+ /**
602
+ * Replace a named container with rollback. An existing container is renamed and
603
+ * stopped, not deleted, until the replacement has remained stable and the
604
+ * caller's commit callback has completed. A cross-working-directory process
605
+ * lock serializes cooperative Peerbit replacements of the same Docker name.
606
+ */
607
+ export const replaceDockerContainer = async (
608
+ name: string,
609
+ runArgs: DockerRunArguments,
610
+ options: {
611
+ backupName?: string;
612
+ execute?: DockerExecutor;
613
+ expectedContainerId?: string | null;
614
+ onCommit?: () => Promise<void> | void;
615
+ onCommitted?: () => Promise<void> | void;
616
+ /**
617
+ * Runs under the replacement lock after rollback attempts, and only after
618
+ * all current, backup, and known immutable container references have been
619
+ * inspected successfully. Callers may use the complete remaining set to
620
+ * decide whether transaction-scoped resources are safe to discard.
621
+ */
622
+ onRollbackInspected?: (
623
+ remainingContainers: readonly DockerContainerInspection[],
624
+ ) => Promise<void> | void;
625
+ onStarted?: () => Promise<void> | void;
626
+ resolveInterruptedPair?: (
627
+ current: DockerContainerInspection,
628
+ backup: DockerContainerInspection,
629
+ ) =>
630
+ | Promise<InterruptedDockerReplacementResolution | undefined>
631
+ | InterruptedDockerReplacementResolution
632
+ | undefined;
633
+ replacementToken?: string;
634
+ retryDelayMs?: number;
635
+ startupProbeAttempts?: number;
636
+ startupProbeIntervalMs?: number;
637
+ validateExisting?: (inspection: DockerContainerInspection) => boolean;
638
+ } = {},
639
+ ) => {
640
+ const execute = options.execute || execDockerCommand;
641
+ const { randomUUID } = await import("crypto");
642
+ const replacementToken = options.replacementToken || randomUUID();
643
+ const releaseLock = await acquireContainerReplacementLock(name);
644
+ const backupNameBase = options.backupName || `${name}-peerbit-backup`;
645
+ const runningBackupName = `${backupNameBase}-running`;
646
+ const stoppedBackupName = `${backupNameBase}-stopped`;
647
+ let backupName = backupNameBase;
648
+ let existing: DockerContainerInspection | undefined;
649
+ let wasRunning = false;
650
+ let replacementId: string | undefined;
651
+ let rollbackExisting: DockerContainerInspection | undefined;
652
+ let rollbackWasRunning = false;
653
+ let transactionStarted = false;
654
+ let committed = false;
168
655
  try {
169
- await startContainer();
170
- } catch (error) {
171
- // try again no matter what?
172
- // or
173
- // typeof error === "string" && error.indexOf("Cannot connect to the Docker daemon") != -1
174
- await delay(10000);
175
- await startContainer();
656
+ existing = await inspectDockerContainer(name, execute);
657
+ const interruptedBackups = (
658
+ await Promise.all(
659
+ [runningBackupName, stoppedBackupName, backupNameBase].map(
660
+ async (candidateName) => ({
661
+ inspection: await inspectDockerContainer(candidateName, execute),
662
+ name: candidateName,
663
+ }),
664
+ ),
665
+ )
666
+ ).filter(
667
+ (
668
+ candidate,
669
+ ): candidate is {
670
+ inspection: DockerContainerInspection;
671
+ name: string;
672
+ } => candidate.inspection !== undefined,
673
+ );
674
+ if (interruptedBackups.length > 1) {
675
+ throw new Error(
676
+ `Multiple Docker backups exist for ${name}; refusing ambiguous recovery`,
677
+ );
678
+ }
679
+ const interrupted = interruptedBackups[0];
680
+ const interruptedBackup = interrupted?.inspection;
681
+ if (interruptedBackup) {
682
+ backupName = interrupted.name;
683
+ const backupWasRunning =
684
+ backupName === runningBackupName
685
+ ? true
686
+ : backupName === stoppedBackupName
687
+ ? false
688
+ : undefined;
689
+ if (
690
+ !options.validateExisting?.(interruptedBackup) ||
691
+ !interruptedBackup.Id
692
+ ) {
693
+ throw new Error(
694
+ `Docker backup ${backupName} is not a recognized recoverable container`,
695
+ );
696
+ }
697
+ if (existing) {
698
+ if (!options.validateExisting?.(existing) || !existing.Id) {
699
+ throw new Error(
700
+ `Docker container ${name} is not a recognized recoverable replacement`,
701
+ );
702
+ }
703
+ const labels = existing.Config?.Labels;
704
+ const interruptedToken = labels?.[DOCKER_REPLACEMENT_LABEL];
705
+ const originalId = labels?.[DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL];
706
+ const originalRunning =
707
+ labels?.[DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL];
708
+ if (
709
+ !interruptedToken ||
710
+ originalId !== interruptedBackup.Id ||
711
+ (originalRunning !== "true" && originalRunning !== "false") ||
712
+ backupWasRunning === undefined ||
713
+ (originalRunning === "true") !== backupWasRunning
714
+ ) {
715
+ throw new Error(
716
+ `Docker containers ${name} and ${backupName} do not form a recognized interrupted replacement; refusing to mutate them`,
717
+ );
718
+ }
719
+ const resolution = await options.resolveInterruptedPair?.(
720
+ existing,
721
+ interruptedBackup,
722
+ );
723
+ if (resolution === "keep-current") {
724
+ await execute(["container", "rm", "--force", interruptedBackup.Id]);
725
+ } else if (resolution === "restore-backup") {
726
+ rollbackExisting = interruptedBackup;
727
+ rollbackWasRunning = backupWasRunning;
728
+ await removeOwnedReplacement(
729
+ name,
730
+ existing.Id,
731
+ interruptedToken,
732
+ execute,
733
+ );
734
+ await restoreDockerContainer(
735
+ name,
736
+ interruptedBackup,
737
+ backupWasRunning,
738
+ execute,
739
+ );
740
+ existing = await inspectDockerContainer(name, execute);
741
+ } else {
742
+ throw new Error(
743
+ `Docker containers ${name} and ${backupName} require an unambiguous interrupted-replacement decision; refusing to mutate them`,
744
+ );
745
+ }
746
+ } else {
747
+ if (backupWasRunning === undefined) {
748
+ throw new Error(
749
+ `Docker backup ${backupName} predates crash-safe state metadata; resolve it manually before retrying`,
750
+ );
751
+ }
752
+ rollbackExisting = interruptedBackup;
753
+ rollbackWasRunning = backupWasRunning;
754
+ await restoreDockerContainer(
755
+ name,
756
+ interruptedBackup,
757
+ backupWasRunning,
758
+ execute,
759
+ );
760
+ existing = await inspectDockerContainer(name, execute);
761
+ }
762
+ }
763
+ wasRunning = existing?.State?.Running === true;
764
+ backupName = wasRunning ? runningBackupName : stoppedBackupName;
765
+
766
+ if (
767
+ existing &&
768
+ options.validateExisting &&
769
+ !options.validateExisting(existing)
770
+ ) {
771
+ throw new Error(
772
+ `Docker container ${name} is not a recognized Peerbit-managed container; refusing to replace it`,
773
+ );
774
+ }
775
+ if (existing && !existing.Id) {
776
+ throw new Error(
777
+ `Docker container ${name} has no inspection ID; refusing to replace it`,
778
+ );
779
+ }
780
+ if (options.expectedContainerId !== undefined) {
781
+ const actualId = existing?.Id || null;
782
+ if (actualId !== options.expectedContainerId) {
783
+ throw new Error(
784
+ `Docker container ${name} changed during configuration; refusing to replace it`,
785
+ );
786
+ }
787
+ }
788
+
789
+ const baseRunArgs =
790
+ typeof runArgs === "function" ? await runArgs(existing) : runArgs;
791
+ if (baseRunArgs[0] !== "run") {
792
+ throw new Error("Docker replacement arguments must start with 'run'");
793
+ }
794
+ assertNoReservedReplacementLabels(baseRunArgs);
795
+ assertDockerRunName(baseRunArgs, name);
796
+ const labeledRunArgs = [
797
+ "run",
798
+ "--label",
799
+ `${DOCKER_REPLACEMENT_LABEL}=${replacementToken}`,
800
+ "--label",
801
+ `${DOCKER_REPLACEMENT_ORIGINAL_ID_LABEL}=${existing?.Id || "none"}`,
802
+ "--label",
803
+ `${DOCKER_REPLACEMENT_ORIGINAL_RUNNING_LABEL}=${wasRunning}`,
804
+ ...baseRunArgs.slice(1),
805
+ ];
806
+ const startupProbeAttempts = options.startupProbeAttempts ?? 4;
807
+ const startupProbeIntervalMs = options.startupProbeIntervalMs ?? 1000;
808
+ if (!Number.isInteger(startupProbeAttempts) || startupProbeAttempts < 1) {
809
+ throw new Error(
810
+ "Docker startup probe attempts must be a positive integer",
811
+ );
812
+ }
813
+ if (startupProbeIntervalMs < 0) {
814
+ throw new Error("Docker startup probe interval must not be negative");
815
+ }
816
+
817
+ const startReplacement = async () => {
818
+ const result = await execute(labeledRunArgs);
819
+ const returnedId = result.stdout.trim();
820
+ if (returnedId) replacementId = returnedId;
821
+ const started = await inspectOwnedReplacement(
822
+ returnedId || name,
823
+ replacementToken,
824
+ execute,
825
+ );
826
+ if (!started?.Id) {
827
+ throw new Error(
828
+ `Docker container ${name} did not return a verifiable replacement ID`,
829
+ );
830
+ }
831
+ replacementId = started.Id;
832
+ if (!isCanonicalReplacement(started, replacementId, name)) {
833
+ throw new Error(
834
+ `Docker replacement ${replacementId} did not use canonical name ${name}`,
835
+ );
836
+ }
837
+
838
+ for (let attempt = 0; attempt < startupProbeAttempts; attempt++) {
839
+ if (attempt > 0 && startupProbeIntervalMs > 0) {
840
+ await delay(startupProbeIntervalMs);
841
+ }
842
+ const probe = await inspectOwnedReplacement(
843
+ replacementId,
844
+ replacementToken,
845
+ execute,
846
+ );
847
+ if (
848
+ !isCanonicalReplacement(probe, replacementId, name) ||
849
+ probe?.State?.Running !== true ||
850
+ probe.State.Restarting === true ||
851
+ (probe.RestartCount ?? 0) > 0 ||
852
+ probe.State.Health?.Status === "unhealthy"
853
+ ) {
854
+ throw new Error(
855
+ `Docker container ${name} did not remain stably running`,
856
+ );
857
+ }
858
+ }
859
+ };
860
+
861
+ if (existing) {
862
+ rollbackExisting = existing;
863
+ rollbackWasRunning = wasRunning;
864
+ transactionStarted = true;
865
+ await execute(["container", "rename", existing.Id!, backupName]);
866
+ if (wasRunning) {
867
+ await execute(["container", "stop", existing.Id!]);
868
+ }
869
+ } else {
870
+ transactionStarted = true;
871
+ }
872
+
873
+ try {
874
+ await startReplacement();
875
+ } catch {
876
+ await removeOwnedReplacement(
877
+ name,
878
+ replacementId,
879
+ replacementToken,
880
+ execute,
881
+ );
882
+ replacementId = undefined;
883
+ if ((options.retryDelayMs ?? 10000) > 0) {
884
+ await delay(options.retryDelayMs ?? 10000);
885
+ }
886
+ await startReplacement();
887
+ }
888
+ await options.onStarted?.();
889
+ const finalProbe = replacementId
890
+ ? await inspectOwnedReplacement(replacementId, replacementToken, execute)
891
+ : undefined;
892
+ if (
893
+ !finalProbe ||
894
+ !replacementId ||
895
+ !isCanonicalReplacement(finalProbe, replacementId, name) ||
896
+ finalProbe.State?.Running !== true ||
897
+ finalProbe.State.Restarting === true ||
898
+ (finalProbe.RestartCount ?? 0) > 0 ||
899
+ finalProbe.State.Health?.Status === "unhealthy"
900
+ ) {
901
+ throw new Error(
902
+ `Docker container ${name} did not remain stably running through activation`,
903
+ );
904
+ }
905
+ await options.onCommit?.();
906
+ committed = true;
907
+
908
+ if (existing?.Id) {
909
+ try {
910
+ await execute(["container", "rm", "--force", existing.Id]);
911
+ } catch (error: any) {
912
+ throw new Error(
913
+ `Docker container ${name} started, but backup ${backupName} could not be removed: ${errorMessage(error)}`,
914
+ );
915
+ }
916
+ }
917
+ await options.onCommitted?.();
918
+ } catch (error: any) {
919
+ if (committed) {
920
+ throw error;
921
+ }
922
+ const rollbackFailures: string[] = [];
923
+ try {
924
+ if (transactionStarted) {
925
+ await removeOwnedReplacement(
926
+ name,
927
+ replacementId,
928
+ replacementToken,
929
+ execute,
930
+ );
931
+ }
932
+ } catch (rollbackError) {
933
+ rollbackFailures.push(
934
+ `replacement cleanup failed: ${errorMessage(rollbackError)}`,
935
+ );
936
+ }
937
+ try {
938
+ if (rollbackExisting) {
939
+ await restoreDockerContainer(
940
+ name,
941
+ rollbackExisting,
942
+ rollbackWasRunning,
943
+ execute,
944
+ );
945
+ }
946
+ } catch (rollbackError) {
947
+ rollbackFailures.push(
948
+ `previous-container restore failed: ${errorMessage(rollbackError)}`,
949
+ );
950
+ }
951
+ if (options.onRollbackInspected) {
952
+ try {
953
+ const references = new Set(
954
+ [
955
+ name,
956
+ backupNameBase,
957
+ runningBackupName,
958
+ stoppedBackupName,
959
+ backupName,
960
+ existing?.Id,
961
+ rollbackExisting?.Id,
962
+ replacementId,
963
+ ].filter((reference): reference is string => Boolean(reference)),
964
+ );
965
+ const inspected = await Promise.all(
966
+ [...references].map((reference) =>
967
+ inspectDockerContainer(reference, execute),
968
+ ),
969
+ );
970
+ const seen = new Set<string>();
971
+ const remainingContainers = inspected.filter(
972
+ (inspection): inspection is DockerContainerInspection => {
973
+ if (!inspection) return false;
974
+ const identity = inspection.Id || inspection.Name;
975
+ if (identity && seen.has(identity)) return false;
976
+ if (identity) seen.add(identity);
977
+ return true;
978
+ },
979
+ );
980
+ await options.onRollbackInspected(remainingContainers);
981
+ } catch (rollbackError) {
982
+ rollbackFailures.push(
983
+ `rollback inspection callback failed: ${errorMessage(rollbackError)}`,
984
+ );
985
+ }
986
+ }
987
+ const rollbackDetails = rollbackFailures.length
988
+ ? `; rollback also failed: ${rollbackFailures.join("; ")}`
989
+ : rollbackExisting
990
+ ? "; previous container restored"
991
+ : "";
992
+ throw new Error(
993
+ `Failed to replace Docker container ${name}: ${errorMessage(error)}${rollbackDetails}`,
994
+ );
995
+ } finally {
996
+ await releaseLock();
176
997
  }
177
998
  };