openship 0.5.0 → 0.6.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 (104) hide show
  1. package/README.md +26 -1
  2. package/dist/bare-IWS2QHJ3.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/{chunk-BS2AXYZX.js → chunk-2NZSUXOB.js} +647 -214
  6. package/dist/chunk-3FYIXVX5.js +8051 -0
  7. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  8. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  9. package/dist/{chunk-NNPEG6HM.js → chunk-3NZICQHF.js} +2 -2
  10. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  11. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  12. package/dist/{chunk-ZCOQJCZO.js → chunk-6ODUBKUB.js} +3 -3
  13. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  14. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  15. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  16. package/dist/{chunk-V7BZMGOT.js → chunk-EMXI4UCU.js} +5 -5
  17. package/dist/{chunk-RB35YN3J.js → chunk-EW6KB2J7.js} +391 -63
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-NXFIRK2Z.js → chunk-IOT5ZX23.js} +573 -459
  20. package/dist/{chunk-KZOBYJUW.js → chunk-IW2UKBVF.js} +3 -3
  21. package/dist/{chunk-YWMODKWL.js → chunk-IXM5245F.js} +165 -13
  22. package/dist/{chunk-KNTJ7PSF.js → chunk-JIMUYEKN.js} +4 -4
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-UMQP7SYT.js → chunk-OTLSJYAC.js} +1316 -53
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  27. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  28. package/dist/{chunk-JLKKYUZA.js → chunk-RRJJGVKE.js} +7 -7
  29. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  30. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  31. package/dist/{chunk-6LKHXB6T.js → chunk-TWJCLD4W.js} +48 -13
  32. package/dist/{chunk-K2RPVGPN.js → chunk-UC7YIEHT.js} +36 -17
  33. package/dist/{chunk-I6PSTQIR.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-6PIYRG5I.js → chunk-VZ2ZHPS2.js} +52 -42
  35. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  36. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  37. package/dist/chunk-YIPMDGG6.js +703 -0
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  42. package/dist/cloud-M2YLWEXO.js +31 -0
  43. package/dist/{detect-BAJMY6KM.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-SAYTRJLO.js +48 -0
  55. package/dist/ensure-container-edge-CFSCPGZK.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-LOYMPU67.js → executor-PT6UEJ5M.js} +8 -8
  58. package/dist/index.js +1228 -463
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-YOEEFBTE.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-WGBO2M3Z.js +81 -0
  65. package/dist/server/index.js +23276 -8015
  66. package/dist/server/lua/geo_country.lua +34 -8
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_stream.lua +52 -39
  70. package/dist/server/lua/site_logger.lua +236 -30
  71. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  72. package/dist/server/migrations/0083_service_incident.sql +84 -0
  73. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  74. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  75. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  76. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  77. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  78. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  79. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  80. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  81. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  82. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  83. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  84. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  85. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  86. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  87. package/dist/server/migrations/meta/_journal.json +112 -0
  88. package/dist/setup-M2ZDGH3O.js +21 -0
  89. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  90. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  91. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  92. package/package.json +6 -3
  93. package/dist/bare-VRGE5MXH.js +0 -23
  94. package/dist/chunk-CUJXGK7R.js +0 -3167
  95. package/dist/chunk-EAAGSQXN.js +0 -377
  96. package/dist/cloud-3M2WZ624.js +0 -30
  97. package/dist/docker-2NGBRJFJ.js +0 -18
  98. package/dist/docker-edge-executor-5I4M5QT7.js +0 -13
  99. package/dist/edge-import-RIWNEIXQ.js +0 -47
  100. package/dist/ensure-container-edge-3NSYKHRX.js +0 -31
  101. package/dist/nginx-QE4DOM5J.js +0 -24
  102. package/dist/openresty-lua-BTAKECCF.js +0 -42
  103. package/dist/server/lua/pipe_log.lua +0 -71
  104. package/dist/setup-KELALK7H.js +0 -20
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env sh
2
- ":" //# ; exec "$(command -v bun || command -v node)" "$0" "$@"
2
+ ":" //# ; exec "$(command -v node || command -v bun)" "$0" "$@"
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  ENV_KEY,
7
7
  ENV_SECRET,
8
8
  fromEnv
9
- } from "./chunk-FRNAR33O.js";
9
+ } from "./chunk-XZYFZ6A5.js";
10
10
  import {
11
11
  SignatureV4MultiRegion,
12
12
  XmlNode,
13
13
  XmlShapeDeserializer,
14
14
  XmlText
15
- } from "./chunk-6APIPIJY.js";
15
+ } from "./chunk-QWHBRY2K.js";
16
16
  import {
17
17
  AwsSdkSigV4ASigner,
18
18
  AwsSdkSigV4Signer,
@@ -28,10 +28,10 @@ import {
28
28
  resolveAwsSdkSigV4AConfig,
29
29
  resolveAwsSdkSigV4Config,
30
30
  streamCollector
31
- } from "./chunk-IXCHURVK.js";
31
+ } from "./chunk-DOD2BXHQ.js";
32
32
  import {
33
33
  NodeHttpHandler
34
- } from "./chunk-OWED2GRM.js";
34
+ } from "./chunk-42WQABXM.js";
35
35
  import {
36
36
  BinaryDecisionDiagram,
37
37
  Client,
@@ -67,7 +67,7 @@ import {
67
67
  resolveRetryConfig,
68
68
  resolveUserAgentConfig,
69
69
  setFeature
70
- } from "./chunk-VWPEKOH6.js";
70
+ } from "./chunk-DRK3MEDS.js";
71
71
  import {
72
72
  CredentialsProviderError,
73
73
  ENV_PROFILE,
@@ -98,13 +98,13 @@ import {
98
98
  resolveHttpHandlerRuntimeConfig,
99
99
  resolveRegionConfig,
100
100
  toEndpointV1
101
- } from "./chunk-W73X43R7.js";
101
+ } from "./chunk-3NQQXMDR.js";
102
102
  import {
103
103
  CloudRuntime
104
- } from "./chunk-JLKKYUZA.js";
104
+ } from "./chunk-RRJJGVKE.js";
105
105
  import {
106
106
  systemCatalog
107
- } from "./chunk-NXFIRK2Z.js";
107
+ } from "./chunk-IOT5ZX23.js";
108
108
  import {
109
109
  AwsCrc32,
110
110
  __awaiter,
@@ -116,7 +116,7 @@ import {
116
116
  numToUint8,
117
117
  resolveEventStreamSerdeConfig,
118
118
  uint32ArrayFrom
119
- } from "./chunk-O6ZZF52V.js";
119
+ } from "./chunk-ONKEYSMY.js";
120
120
  import {
121
121
  Hash,
122
122
  NumericValue,
@@ -134,33 +134,39 @@ import {
134
134
  toBase64,
135
135
  toUint8Array,
136
136
  toUtf8
137
- } from "./chunk-JYGD5WSN.js";
137
+ } from "./chunk-ZWLDLTJP.js";
138
138
  import {
139
139
  DockerRuntime,
140
+ daemonConnectionFrom,
140
141
  isHostPathSource,
141
- scopedVolumeName
142
- } from "./chunk-CUJXGK7R.js";
142
+ resolveExecExitCode,
143
+ scopedVolumeName,
144
+ startAttachStream,
145
+ startExecStream
146
+ } from "./chunk-3FYIXVX5.js";
143
147
  import {
144
148
  BareRuntime
145
- } from "./chunk-YWMODKWL.js";
149
+ } from "./chunk-IXM5245F.js";
146
150
  import {
147
151
  LocalExecutor
148
- } from "./chunk-TYVME3XD.js";
152
+ } from "./chunk-6FH4AXVY.js";
149
153
  import {
150
154
  EDGE_CONTAINER_NAME,
151
155
  edgeCrashReason,
152
156
  invalidateEdgeContainer,
153
157
  sanitizeEdgeVhosts
154
- } from "./chunk-6PIYRG5I.js";
158
+ } from "./chunk-VZ2ZHPS2.js";
155
159
  import {
156
160
  EDGE_CONTAINER_MOUNTS,
157
- EDGE_HOST_STATE_DIR
158
- } from "./chunk-EAAGSQXN.js";
161
+ EDGE_HOST_STATE_DIR,
162
+ OPENRESTY_DEFAULT_PATHS
163
+ } from "./chunk-YIPMDGG6.js";
159
164
  import {
160
165
  DEFAULT_IMAGE_REGISTRY,
161
166
  isAwsS3Endpoint,
162
- safeErrorMessage
163
- } from "./chunk-UMQP7SYT.js";
167
+ safeErrorMessage,
168
+ withTimeout
169
+ } from "./chunk-OTLSJYAC.js";
164
170
 
165
171
  // src/lib/edge-import.ts
166
172
  import chalk from "chalk";
@@ -169,8 +175,42 @@ import ora from "ora";
169
175
  // ../../packages/adapters/src/runtime/image-transfer.ts
170
176
  import { Transform } from "stream";
171
177
 
172
- // ../../packages/adapters/src/runtime/route-registration.ts
173
- import { posix as pathPosix } from "path";
178
+ // ../../packages/adapters/src/infra/edge-baked-conf.ts
179
+ var LOGS_DIR = OPENRESTY_DEFAULT_PATHS.pidPath.replace(/\/[^/]+$/, "");
180
+
181
+ // ../../packages/adapters/src/infra/mail-container.ts
182
+ var MAIL_HOST_STATE_DIR = "/var/lib/openship/mail";
183
+ var MAIL_CONTAINER_MOUNTS = [
184
+ { host: `${MAIL_HOST_STATE_DIR}/vmail`, container: "/var/vmail" },
185
+ // The Postfix queue. A recreate on an ephemeral layer would drop in-flight and
186
+ // deferred mail without this — keep it on the host.
187
+ { host: `${MAIL_HOST_STATE_DIR}/spool`, container: "/var/spool/postfix" },
188
+ // DKIM private keys. NEVER baked, never regenerated on pull — a fresh selector
189
+ // breaks DMARC until DNS repropagates.
190
+ { host: `${MAIL_HOST_STATE_DIR}/dkim`, container: "/var/lib/dkim" },
191
+ // Mutable daemon config the admin layer edits (sasl_passwd, relayhost maps,
192
+ // amavis 50-user). Seeded from baked defaults on first boot.
193
+ { host: `${MAIL_HOST_STATE_DIR}/config/postfix`, container: "/etc/postfix", seed: true },
194
+ { host: `${MAIL_HOST_STATE_DIR}/config/dovecot`, container: "/etc/dovecot", seed: true },
195
+ { host: `${MAIL_HOST_STATE_DIR}/config/amavis`, container: "/etc/amavis/conf.d", seed: true },
196
+ // ClamAV signatures — bind-mounted so a pull doesn't force a multi-hundred-MB
197
+ // freshclam re-download and delay readiness.
198
+ { host: `${MAIL_HOST_STATE_DIR}/clamav`, container: "/var/lib/clamav" },
199
+ { host: "/etc/letsencrypt", container: "/etc/letsencrypt", readonly: true }
200
+ ];
201
+ var MAIL_DB_HOST_DATA_DIR = `${MAIL_HOST_STATE_DIR}/pgdata`;
202
+ var MAIL_DB_CONTAINER_DATA_DIR = "/var/lib/postgresql/data";
203
+ var MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
204
+ var MAIL_HOST_PATHS = {
205
+ saslPasswd: `${MAIL_HOST_STATE_DIR}/config/postfix/sasl_passwd`,
206
+ senderRelayhost: `${MAIL_HOST_STATE_DIR}/config/postfix/sender_relayhost`,
207
+ amavisUserConf: `${MAIL_HOST_STATE_DIR}/config/amavis/50-user`
208
+ };
209
+
210
+ // ../../packages/adapters/src/system/mail/ensure-container-mail.ts
211
+ var MAIL_DB_IMAGE = process.env.OPENSHIP_MAIL_DB_IMAGE?.trim() || "postgres:16-alpine";
212
+ var ENGINE_ENV_FILE = `${MAIL_HOST_STATE_DIR}/engine.env`;
213
+ var DB_ENV_FILE = `${MAIL_HOST_STATE_DIR}/db.env`;
174
214
 
175
215
  // ../../packages/adapters/src/system/modules/verify.ts
176
216
  import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
@@ -244,6 +284,25 @@ function decryptCredential(stored) {
244
284
  import { createHash as createHash3 } from "crypto";
245
285
  import { Transform as Transform2 } from "stream";
246
286
 
287
+ // ../../packages/adapters/src/backup/common/source-match.ts
288
+ function matchBackupSource(sources, targetSourceId) {
289
+ const wanted = targetSourceId.trim();
290
+ if (!wanted) return null;
291
+ const exact = sources.find((s2) => s2.id === wanted);
292
+ if (exact) return exact;
293
+ const byPhysical = sources.filter((s2) => s2.source === wanted);
294
+ if (byPhysical.length === 1) return byPhysical[0];
295
+ if (byPhysical.length > 1) return null;
296
+ const stripped = wanted.replace(/-\d+$/, "");
297
+ if (stripped !== wanted) {
298
+ const byStripped = sources.filter(
299
+ (s2) => s2.id === stripped || s2.source === stripped
300
+ );
301
+ if (byStripped.length === 1) return byStripped[0];
302
+ }
303
+ return null;
304
+ }
305
+
247
306
  // ../../packages/adapters/src/backup/volume-transfer.ts
248
307
  import { Transform as Transform3 } from "stream";
249
308
 
@@ -486,8 +545,7 @@ var CustomCommandProducerImpl = class {
486
545
  kind = "custom_command";
487
546
  // No detect — explicit selection only.
488
547
  async *produce(service, executor, opts) {
489
- const cfg = opts;
490
- const produceCommand = cfg.produceCommand ?? opts.command;
548
+ const produceCommand = opts.produceCommand ?? opts.command;
491
549
  if (!produceCommand) {
492
550
  throw new Error("custom_command producer requires `produceCommand` in policy payload config");
493
551
  }
@@ -497,12 +555,15 @@ var CustomCommandProducerImpl = class {
497
555
  produceCommand
498
556
  ]);
499
557
  yield {
500
- name: cfg.artifactName ?? "custom-backup.bin",
558
+ name: opts.artifactName ?? "custom-backup.bin",
501
559
  stream: stdout,
502
560
  payloadKind: "custom_command",
503
561
  metadata: {
504
562
  produceCommand,
505
- restoreCommand: cfg.restoreCommand ?? null
563
+ // The ONLY record of how to put this artifact back. Nothing downstream
564
+ // can reconstruct it, which is why losing it here made every captured
565
+ // run unrestorable.
566
+ restoreCommand: opts.restoreCommand ?? null
506
567
  }
507
568
  };
508
569
  const exit = await awaitExit;
@@ -516,7 +577,7 @@ var CustomCommandProducerImpl = class {
516
577
  const restoreCommand = artifact.metadata.restoreCommand ?? null;
517
578
  if (!restoreCommand) {
518
579
  throw new Error(
519
- "Backup has no restoreCommand recorded \u2014 restore is not possible for this artifact"
580
+ "Backup has no restoreCommand recorded \u2014 restore is not possible for this artifact. Re-run the backup policy to capture a restorable one."
520
581
  );
521
582
  }
522
583
  const body = await artifact.open();
@@ -583,7 +644,8 @@ var VolumeCopyProducerImpl = class {
583
644
  const stream = await artifact.open();
584
645
  await executor.receiveStream(service, volumeId, stream, {
585
646
  compression: artifact.metadata.compression ?? "zstd",
586
- clearTarget: opts.clearTarget ?? true
647
+ clearTarget: opts.clearTarget ?? true,
648
+ signal: opts.signal
587
649
  });
588
650
  }
589
651
  };
@@ -596,6 +658,58 @@ registerProducer(VolumeCopyProducer);
596
658
  // ../../packages/adapters/src/backup/executors/docker.ts
597
659
  import { PassThrough } from "stream";
598
660
  var HELPER_IMAGE = "alpine:3";
661
+ var DEFAULT_HELPER_IDLE_MS = 10 * 60 * 1e3;
662
+ var DEFAULT_HELPER_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
663
+ var EXIT_POLL_INTERVAL_MS = 2e3;
664
+ function createIdleWatchdog(ms, message) {
665
+ let timer;
666
+ let done = false;
667
+ let fire = () => {
668
+ };
669
+ const promise = new Promise((_resolve, reject) => {
670
+ fire = reject;
671
+ });
672
+ const arm = () => {
673
+ timer = setTimeout(() => {
674
+ done = true;
675
+ fire(new Error(message));
676
+ }, ms);
677
+ timer.unref?.();
678
+ };
679
+ arm();
680
+ return {
681
+ touch: () => {
682
+ if (done) return;
683
+ if (timer) clearTimeout(timer);
684
+ arm();
685
+ },
686
+ promise,
687
+ dispose: () => {
688
+ done = true;
689
+ if (timer) clearTimeout(timer);
690
+ }
691
+ };
692
+ }
693
+ function createAbortWatch(signal, message) {
694
+ let fire = () => {
695
+ };
696
+ const promise = new Promise((_resolve, reject) => {
697
+ fire = reject;
698
+ });
699
+ const onAbort = () => fire(new Error(message));
700
+ if (signal.aborted) onAbort();
701
+ else signal.addEventListener("abort", onAbort, { once: true });
702
+ return {
703
+ promise,
704
+ dispose: () => signal.removeEventListener("abort", onAbort)
705
+ };
706
+ }
707
+ function destroyQuietly(stream) {
708
+ try {
709
+ stream?.destroy?.();
710
+ } catch {
711
+ }
712
+ }
599
713
  function shellEscape4(s2) {
600
714
  return `'${s2.replace(/'/g, "'\\''")}'`;
601
715
  }
@@ -669,12 +783,12 @@ var DockerBackupExecutor = class {
669
783
  WorkingDir: opts?.cwd,
670
784
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
671
785
  });
672
- const stream = await exec.start({ hijack: true, stdin: false });
786
+ const stream = await exec.start({ stdin: false });
673
787
  return this.attachDemuxed(this.dockerode, exec.id, stream, opts?.timeoutMs);
674
788
  }
675
789
  async streamPath(service, sourceId, opts) {
676
790
  const sources = await this.listSources(service);
677
- const source = sources.find((s2) => s2.id === sourceId);
791
+ const source = matchBackupSource(sources, sourceId);
678
792
  if (!source) {
679
793
  throw new Error(`Backup source "${sourceId}" not found on service ${service.name}`);
680
794
  }
@@ -692,7 +806,12 @@ var DockerBackupExecutor = class {
692
806
  await this.ensureImage(helperImage);
693
807
  const hostConfig = {
694
808
  Binds: [`${source.source}:/mnt:ro`],
695
- AutoRemove: true,
809
+ // OFF, for the reason demuxContainerStream already documents but this line
810
+ // used to contradict: AutoRemove lets the daemon reap the helper before
811
+ // container.wait() answers and before the attach stream has drained, which
812
+ // is a 404 on the exit status or a truncated tar. demuxContainerStream
813
+ // removes it once the archive is fully demuxed.
814
+ AutoRemove: false,
696
815
  // The archive streams over `attach()` below — the daemon's default
697
816
  // json-file driver would ALSO write every byte of it to
698
817
  // /var/lib/docker/containers as a log. That's a second copy of the whole
@@ -702,29 +821,37 @@ var DockerBackupExecutor = class {
702
821
  // the helper exits. Nothing ever reads these logs.
703
822
  LogConfig: { Type: "none", Config: {} }
704
823
  };
705
- const helper = await this.dockerode.createContainer({
706
- Image: helperImage,
707
- Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
708
- HostConfig: hostConfig,
709
- AttachStdout: true,
710
- AttachStderr: true,
711
- Tty: false,
712
- // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
713
- // egress; gzip/none use busybox built-ins and stay network-isolated.
714
- NetworkDisabled: compression !== "zstd"
715
- });
716
- const stream = await helper.attach({
717
- stream: true,
718
- stdout: true,
719
- stderr: true
720
- });
721
- await helper.start();
722
- const timeoutMs = opts ? opts.timeoutMs : void 0;
723
- return this.demuxContainerStream(helper, stream, timeoutMs ?? 60 * 60 * 1e3);
824
+ return this.handOffHelper(
825
+ {
826
+ Image: helperImage,
827
+ Cmd: ["sh", "-c", compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${tarCmd}` : tarCmd],
828
+ HostConfig: hostConfig,
829
+ AttachStdout: true,
830
+ AttachStderr: true,
831
+ Tty: false,
832
+ // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
833
+ // egress; gzip/none use busybox built-ins and stay network-isolated.
834
+ NetworkDisabled: compression !== "zstd"
835
+ },
836
+ async (helper) => {
837
+ const stream = await helper.attach({
838
+ stream: true,
839
+ stdout: true,
840
+ stderr: true
841
+ });
842
+ await helper.start();
843
+ return this.demuxContainerStream(helper, stream, {
844
+ idleTimeoutMs: opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS,
845
+ timeoutMs: opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS,
846
+ label: `Backup of "${sourceId}" on service ${service.name}`
847
+ });
848
+ }
849
+ );
724
850
  }
725
851
  async receiveStream(service, targetSourceId, body, opts) {
852
+ opts?.signal?.throwIfAborted();
726
853
  const sources = await this.listSources(service);
727
- const source = sources.find((s2) => s2.id === targetSourceId);
854
+ const source = matchBackupSource(sources, targetSourceId);
728
855
  if (!source) {
729
856
  throw new Error(`Restore target "${targetSourceId}" not found on service ${service.name}`);
730
857
  }
@@ -736,49 +863,188 @@ var DockerBackupExecutor = class {
736
863
  await this.ensureImage(helperImage);
737
864
  const clearCmd = opts?.clearTarget ? `find /mnt -mindepth 1 -delete 2>/dev/null || true; ` : "";
738
865
  const untarCmd = compression === "zstd" ? `${clearCmd}zstd -d -c | tar -x -C /mnt` : `${clearCmd}tar -x${compressionFlag(compression)}f - -C /mnt`;
739
- const helper = await this.dockerode.createContainer({
740
- Image: helperImage,
741
- Cmd: [
742
- "sh",
743
- "-c",
744
- compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${untarCmd}` : untarCmd
745
- ],
746
- HostConfig: { Binds: [`${source.source}:/mnt`], AutoRemove: true },
747
- AttachStdin: true,
748
- AttachStdout: true,
749
- AttachStderr: true,
750
- OpenStdin: true,
751
- StdinOnce: true,
752
- Tty: false,
753
- // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
754
- // egress; gzip/none use busybox built-ins and stay network-isolated.
755
- NetworkDisabled: compression !== "zstd"
756
- });
757
- const stream = await helper.attach({
758
- stream: true,
759
- hijack: true,
760
- stdin: true,
761
- stdout: true,
762
- stderr: true
763
- });
764
- await helper.start();
765
- let bytesWritten = 0;
766
- body.on("data", (chunk) => {
767
- bytesWritten += chunk.byteLength;
768
- });
769
- const errChunks = [];
770
- stream.on("data", (c2) => {
771
- if (errChunks.length < 32) errChunks.push(c2);
772
- });
773
- body.pipe(stream);
774
- const waitResult = await helper.wait();
775
- if (waitResult.StatusCode !== 0) {
776
- const detail = Buffer.concat(errChunks).toString("utf8").replace(/[^\x20-\x7e\n]+/g, " ").trim().slice(-500);
777
- throw new Error(
778
- `Restore helper exited with code ${waitResult.StatusCode}${detail ? `: ${detail}` : ""}`
866
+ return this.withHelper(
867
+ {
868
+ Image: helperImage,
869
+ Cmd: [
870
+ "sh",
871
+ "-c",
872
+ compression === "zstd" ? `apk add --no-cache zstd >/dev/null 2>&1; ${untarCmd}` : untarCmd
873
+ ],
874
+ // AutoRemove is OFF deliberately, and this is the fix for the `404 no such
875
+ // container` in #434: with it on, the daemon could reap the helper before
876
+ // /containers/{id}/wait answered, and the exit status was then unknowable.
877
+ // withHelper reaps instead — which also plugs a leak AutoRemove never
878
+ // covered, since it only fires for a container that actually started.
879
+ HostConfig: { Binds: [`${source.source}:/mnt`], AutoRemove: false },
880
+ AttachStdin: true,
881
+ AttachStdout: true,
882
+ AttachStderr: true,
883
+ OpenStdin: true,
884
+ StdinOnce: true,
885
+ Tty: false,
886
+ // zstd isn't in alpine:3 and is apk-installed at runtime, which needs
887
+ // egress; gzip/none use busybox built-ins and stay network-isolated.
888
+ NetworkDisabled: compression !== "zstd"
889
+ },
890
+ async (helper) => {
891
+ let stream;
892
+ const idleMs = opts?.idleTimeoutMs ?? DEFAULT_HELPER_IDLE_MS;
893
+ const watchdog = createIdleWatchdog(
894
+ idleMs,
895
+ `Restore of "${targetSourceId}" on service ${service.name} moved no data for ${Math.round(idleMs / 1e3)}s and was abandoned. The extract helper has been removed; the target may hold partial data.`
896
+ );
897
+ const abortWatch = opts?.signal ? createAbortWatch(
898
+ opts.signal,
899
+ `Restore of "${targetSourceId}" on service ${service.name} was cancelled mid-extract. The target holds partial data.`
900
+ ) : void 0;
901
+ try {
902
+ stream = await startAttachStream(daemonConnectionFrom(this.dockerode), helper.id, {
903
+ stdin: true,
904
+ stdout: true,
905
+ stderr: true
906
+ });
907
+ await helper.start();
908
+ let bytesWritten = 0;
909
+ let bodyEnded = false;
910
+ body.on("data", (chunk) => {
911
+ bytesWritten += chunk.byteLength;
912
+ watchdog.touch();
913
+ });
914
+ const errChunks = [];
915
+ stream.on("data", (c2) => {
916
+ watchdog.touch();
917
+ if (errChunks.length < 32) errChunks.push(c2);
918
+ });
919
+ body.on("end", () => {
920
+ bodyEnded = true;
921
+ watchdog.touch();
922
+ });
923
+ body.pipe(stream);
924
+ const waitResult = await this.awaitHelperExit(helper, watchdog, () => bodyEnded, {
925
+ timeoutMs: opts?.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS,
926
+ label: `Restore of "${targetSourceId}" on service ${service.name}`,
927
+ note: "The target may hold partial data.",
928
+ cancelled: abortWatch?.promise
929
+ });
930
+ if (waitResult.StatusCode !== 0) {
931
+ const detail = Buffer.concat(errChunks).toString("utf8").replace(/[^\x20-\x7e\n]+/g, " ").trim().slice(-500);
932
+ throw new Error(
933
+ `Restore helper exited with code ${waitResult.StatusCode}${detail ? `: ${detail}` : ""}`
934
+ );
935
+ }
936
+ return { bytesWritten };
937
+ } finally {
938
+ watchdog.dispose();
939
+ abortWatch?.dispose();
940
+ destroyQuietly(stream);
941
+ destroyQuietly(body);
942
+ }
943
+ }
944
+ );
945
+ }
946
+ /**
947
+ * Create a helper, run `fn`, reap the helper however `fn` ends.
948
+ *
949
+ * Every helper here is disposable and every one of them must be removed by us:
950
+ * `AutoRemove` is off on purpose (it races /wait, and it only ever fires for a
951
+ * container that actually started, so a throw between create and start leaked
952
+ * one permanently — the leak behind #434). Four call sites each had their own
953
+ * correct copy of that reap, which is exactly how one of them came to be the
954
+ * one that didn't.
955
+ *
956
+ * NOT for helpers whose output outlives the call: streamPath hands its
957
+ * container to demuxContainerStream, which must reap only after the archive
958
+ * has drained. `handOffHelper` covers that shape.
959
+ */
960
+ async withHelper(spec, fn) {
961
+ const helper = await this.dockerode.createContainer(spec);
962
+ try {
963
+ return await fn(helper);
964
+ } finally {
965
+ await helper.remove({ force: true }).catch(() => {
966
+ });
967
+ }
968
+ }
969
+ /**
970
+ * Create a helper whose lifetime outlives this call: reap it only if `setup`
971
+ * throws, then hand ownership to whatever `setup` returned. Without this, a
972
+ * failure between createContainer and the hand-off leaks the container
973
+ * forever — nothing downstream knows it exists yet.
974
+ */
975
+ async handOffHelper(spec, setup) {
976
+ const helper = await this.dockerode.createContainer(spec);
977
+ try {
978
+ return await setup(helper);
979
+ } catch (err) {
980
+ await helper.remove({ force: true }).catch(() => {
981
+ });
982
+ throw err;
983
+ }
984
+ }
985
+ /**
986
+ * Wait for a helper to exit without trusting any single signal.
987
+ *
988
+ * `helper.wait()` alone is what hung in #434: /containers/{id}/wait is a
989
+ * long-poll with no timeout on either the modem or our transport, so a lost
990
+ * response is indistinguishable from a running container — forever. Three
991
+ * backstops race it:
992
+ *
993
+ * 1. An exit poll that asks the daemon directly. This is what turns the
994
+ * common hang into a few seconds. It's the same "don't trust the attach
995
+ * stream's terminal signal" reasoning already documented in
996
+ * demuxContainerStream; receiveStream was the path without a backstop.
997
+ * 2. The caller's idle watchdog (fed by real traffic, not by this function).
998
+ * 3. An absolute ceiling, so even a helper that somehow keeps trickling
999
+ * bytes cannot run unbounded.
1000
+ *
1001
+ * The poll only starts once the body has been fully written: before that a
1002
+ * not-yet-running helper is normal, and inspecting on a loop would be noise.
1003
+ * It is gated on the body rather than on the attach stream closing because
1004
+ * over an SSH-tunneled attach that close is not reliably delivered — the very
1005
+ * reason this backstop has to exist.
1006
+ */
1007
+ async awaitHelperExit(helper, watchdog, bodyEnded, opts) {
1008
+ let polling = true;
1009
+ const pollExit = async () => {
1010
+ while (polling) {
1011
+ await new Promise((r2) => {
1012
+ const t8 = setTimeout(r2, EXIT_POLL_INTERVAL_MS);
1013
+ t8.unref?.();
1014
+ });
1015
+ if (!polling || !bodyEnded()) continue;
1016
+ let state;
1017
+ try {
1018
+ state = (await helper.inspect()).State ?? {};
1019
+ } catch (err) {
1020
+ if (err?.statusCode === 404) {
1021
+ throw new Error(
1022
+ `${opts.label}: the helper disappeared before its exit status could be read.${opts.note ? ` ${opts.note}` : ""}`
1023
+ );
1024
+ }
1025
+ continue;
1026
+ }
1027
+ if (state.Running === false && state.Status !== "created" && typeof state.ExitCode === "number") {
1028
+ return { StatusCode: state.ExitCode };
1029
+ }
1030
+ }
1031
+ return new Promise(() => {
1032
+ });
1033
+ };
1034
+ try {
1035
+ return await withTimeout(
1036
+ Promise.race([
1037
+ helper.wait(),
1038
+ pollExit(),
1039
+ watchdog.promise,
1040
+ ...opts.cancelled ? [opts.cancelled] : []
1041
+ ]),
1042
+ opts.timeoutMs,
1043
+ `${opts.label} exceeded its ${Math.round(opts.timeoutMs / 1e3)}s ceiling and was abandoned.${opts.note ? ` ${opts.note}` : ""}`
779
1044
  );
1045
+ } finally {
1046
+ polling = false;
780
1047
  }
781
- return { bytesWritten };
782
1048
  }
783
1049
  /**
784
1050
  * SAME-DAEMON volume→volume copy in ONE helper container that mounts both
@@ -792,8 +1058,8 @@ var DockerBackupExecutor = class {
792
1058
  * network-isolated; source is mounted read-only.
793
1059
  */
794
1060
  async copyVolumeLocal(srcService, srcSourceId, dstService, dstSourceId, opts) {
795
- const src = (await this.listSources(srcService)).find((s2) => s2.id === srcSourceId);
796
- const dst = (await this.listSources(dstService)).find((s2) => s2.id === dstSourceId);
1061
+ const src = matchBackupSource(await this.listSources(srcService), srcSourceId);
1062
+ const dst = matchBackupSource(await this.listSources(dstService), dstSourceId);
797
1063
  if (!src) throw new Error(`Copy source "${srcSourceId}" not found on ${srcService.name}`);
798
1064
  if (!dst) throw new Error(`Copy target "${dstSourceId}" not found on ${dstService.name}`);
799
1065
  if (src.type === "tmpfs" || dst.type === "tmpfs") {
@@ -802,31 +1068,30 @@ var DockerBackupExecutor = class {
802
1068
  await this.ensureImage(HELPER_IMAGE);
803
1069
  const clearCmd = opts?.clearTarget ? "find /to -mindepth 1 -delete 2>/dev/null || true; " : "";
804
1070
  const copyCmd = `${clearCmd}{ tar -C /from -cf - . ; echo $? > /tmp/src.rc ; } | tar -C /to -xf - ; rc=$? ; [ "$(cat /tmp/src.rc 2>/dev/null)" = 0 ] && [ "$rc" = 0 ] && du -sk /to 2>/dev/null | cut -f1`;
805
- const helper = await this.dockerode.createContainer({
806
- Image: HELPER_IMAGE,
807
- Cmd: ["sh", "-c", copyCmd],
808
- HostConfig: {
809
- Binds: [`${src.source}:/from:ro`, `${dst.source}:/to`]
1071
+ return this.withHelper(
1072
+ {
1073
+ Image: HELPER_IMAGE,
1074
+ Cmd: ["sh", "-c", copyCmd],
1075
+ HostConfig: {
1076
+ Binds: [`${src.source}:/from:ro`, `${dst.source}:/to`]
1077
+ },
1078
+ Tty: true,
1079
+ // merged raw stdout so the trailing `du` number reads cleanly
1080
+ NetworkDisabled: true
810
1081
  },
811
- Tty: true,
812
- // merged raw stdout so the trailing `du` number reads cleanly
813
- NetworkDisabled: true
814
- });
815
- try {
816
- await helper.start();
817
- const res = await helper.wait();
818
- const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString().trim()).catch(() => "");
819
- if (res.StatusCode !== 0) {
820
- throw new Error(
821
- `Local volume copy failed (${srcSourceId}\u2192${dstSourceId}): ${out.slice(0, 500) || `exit ${res.StatusCode}`}`
822
- );
1082
+ async (helper) => {
1083
+ await helper.start();
1084
+ const res = await helper.wait();
1085
+ const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString().trim()).catch(() => "");
1086
+ if (res.StatusCode !== 0) {
1087
+ throw new Error(
1088
+ `Local volume copy failed (${srcSourceId}\u2192${dstSourceId}): ${out.slice(0, 500) || `exit ${res.StatusCode}`}`
1089
+ );
1090
+ }
1091
+ const kb = parseInt(out.split(/\s+/).pop() || "0", 10);
1092
+ return { bytesWritten: Number.isFinite(kb) ? kb * 1024 : 0 };
823
1093
  }
824
- const kb = parseInt(out.split(/\s+/).pop() || "0", 10);
825
- return { bytesWritten: Number.isFinite(kb) ? kb * 1024 : 0 };
826
- } finally {
827
- await helper.remove({ force: true }).catch(() => {
828
- });
829
- }
1094
+ );
830
1095
  }
831
1096
  /**
832
1097
  * Does the named volume behind `sourceId` already exist on this daemon, and
@@ -836,7 +1101,7 @@ var DockerBackupExecutor = class {
836
1101
  * so we err on the side of NOT clobbering.
837
1102
  */
838
1103
  async probeVolume(service, sourceId) {
839
- const source = (await this.listSources(service)).find((s2) => s2.id === sourceId);
1104
+ const source = matchBackupSource(await this.listSources(service), sourceId);
840
1105
  if (!source || source.type !== "volume" || !source.source) {
841
1106
  return { exists: false, empty: true };
842
1107
  }
@@ -846,26 +1111,25 @@ var DockerBackupExecutor = class {
846
1111
  return { exists: false, empty: true };
847
1112
  }
848
1113
  await this.ensureImage(HELPER_IMAGE);
849
- const helper = await this.dockerode.createContainer({
850
- Image: HELPER_IMAGE,
851
- Cmd: [
852
- "sh",
853
- "-c",
854
- 'if [ -z "$(ls -A /probe 2>/dev/null)" ]; then echo VOLEMPTY; else echo VOLDATA; fi'
855
- ],
856
- HostConfig: { Binds: [`${source.source}:/probe:ro`] },
857
- Tty: true,
858
- NetworkDisabled: true
859
- });
860
- try {
861
- await helper.start();
862
- await helper.wait();
863
- const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString()).catch(() => "");
864
- return { exists: true, empty: out.includes("VOLEMPTY") };
865
- } finally {
866
- await helper.remove({ force: true }).catch(() => {
867
- });
868
- }
1114
+ return this.withHelper(
1115
+ {
1116
+ Image: HELPER_IMAGE,
1117
+ Cmd: [
1118
+ "sh",
1119
+ "-c",
1120
+ 'if [ -z "$(ls -A /probe 2>/dev/null)" ]; then echo VOLEMPTY; else echo VOLDATA; fi'
1121
+ ],
1122
+ HostConfig: { Binds: [`${source.source}:/probe:ro`] },
1123
+ Tty: true,
1124
+ NetworkDisabled: true
1125
+ },
1126
+ async (helper) => {
1127
+ await helper.start();
1128
+ await helper.wait();
1129
+ const out = await helper.logs({ follow: false, stdout: true, stderr: true }).then((b2) => b2.toString()).catch(() => "");
1130
+ return { exists: true, empty: out.includes("VOLEMPTY") };
1131
+ }
1132
+ );
869
1133
  }
870
1134
  async pipeIntoCommand(service, cmd, body, opts) {
871
1135
  if (!service.containerId) {
@@ -883,7 +1147,10 @@ var DockerBackupExecutor = class {
883
1147
  WorkingDir: opts?.cwd,
884
1148
  Env: opts?.env ? Object.entries(opts.env).map(([k2, v2]) => `${k2}=${v2}`) : void 0
885
1149
  });
886
- const stream = await exec.start({ hijack: true, stdin: true });
1150
+ const stream = await startExecStream(daemonConnectionFrom(this.dockerode), exec.id, {
1151
+ tty: false,
1152
+ stdin: true
1153
+ });
887
1154
  const stderrChunks = [];
888
1155
  const { PassThrough: PassThrough3 } = await import("stream");
889
1156
  const stdoutSink = new PassThrough3();
@@ -915,9 +1182,8 @@ var DockerBackupExecutor = class {
915
1182
  stream.on("end", async () => {
916
1183
  if (timer) clearTimeout(timer);
917
1184
  try {
918
- const info = await this.dockerode.getExec(exec.id).inspect();
919
1185
  resolve4({
920
- code: info.ExitCode ?? 0,
1186
+ code: await resolveExecExitCode(exec, exec.id),
921
1187
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
922
1188
  });
923
1189
  } catch (err) {
@@ -980,9 +1246,8 @@ var DockerBackupExecutor = class {
980
1246
  stream.on("end", async () => {
981
1247
  if (timer) clearTimeout(timer);
982
1248
  try {
983
- const info = await docker.getExec(execId).inspect();
984
1249
  resolve4({
985
- code: info.ExitCode ?? 0,
1250
+ code: await resolveExecExitCode(docker.getExec(execId), execId),
986
1251
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
987
1252
  });
988
1253
  } catch (err) {
@@ -996,7 +1261,7 @@ var DockerBackupExecutor = class {
996
1261
  });
997
1262
  return { stdout, awaitExit };
998
1263
  }
999
- demuxContainerStream(container, stream, timeoutMs) {
1264
+ demuxContainerStream(container, stream, opts) {
1000
1265
  const stdout = new PassThrough();
1001
1266
  const stderrChunks = [];
1002
1267
  const stderrSink = new PassThrough();
@@ -1004,29 +1269,49 @@ var DockerBackupExecutor = class {
1004
1269
  if (stderrChunks.length < 16) stderrChunks.push(chunk);
1005
1270
  });
1006
1271
  container.modem.demuxStream(stream, stdout, stderrSink);
1272
+ let sinksEnded = false;
1273
+ let exited = false;
1274
+ const reapIfDone = () => {
1275
+ if (!sinksEnded || !exited) return;
1276
+ void container.remove({ force: true }).catch(() => {
1277
+ });
1278
+ };
1007
1279
  const endSinks = () => {
1008
1280
  stdout.end();
1009
1281
  stderrSink.end();
1282
+ sinksEnded = true;
1283
+ reapIfDone();
1010
1284
  };
1011
1285
  stream.on("end", endSinks);
1012
1286
  stream.on("close", endSinks);
1013
- const awaitExit = new Promise((resolve4, reject) => {
1014
- const timer = timeoutMs ? setTimeout(() => {
1015
- stdout.destroy(new Error(`helper container timed out after ${timeoutMs}ms`));
1016
- reject(new Error(`helper container timed out after ${timeoutMs}ms`));
1017
- }, timeoutMs) : null;
1018
- container.wait().then((res) => {
1019
- if (timer) clearTimeout(timer);
1287
+ const watchdog = createIdleWatchdog(
1288
+ opts.idleTimeoutMs,
1289
+ `${opts.label} produced no data for ${Math.round(opts.idleTimeoutMs / 1e3)}s and was abandoned. Nothing was written; the archive is incomplete and was not kept.`
1290
+ );
1291
+ stream.on("data", () => watchdog.touch());
1292
+ const awaitExit = (async () => {
1293
+ try {
1294
+ const res = await this.awaitHelperExit(container, watchdog, () => sinksEnded, {
1295
+ timeoutMs: opts.timeoutMs,
1296
+ label: opts.label,
1297
+ note: "Nothing was written; the archive is incomplete and was not kept."
1298
+ });
1299
+ exited = true;
1020
1300
  setTimeout(endSinks, 3e3);
1021
- resolve4({
1301
+ reapIfDone();
1302
+ return {
1022
1303
  code: res.StatusCode,
1023
1304
  stderr: Buffer.concat(stderrChunks).toString("utf8").slice(0, 16 * 1024)
1305
+ };
1306
+ } catch (err) {
1307
+ stdout.destroy(err);
1308
+ void container.remove({ force: true }).catch(() => {
1024
1309
  });
1025
- }).catch((err) => {
1026
- if (timer) clearTimeout(timer);
1027
- reject(err);
1028
- });
1029
- });
1310
+ throw err;
1311
+ } finally {
1312
+ watchdog.dispose();
1313
+ }
1314
+ })();
1030
1315
  return { stdout, awaitExit };
1031
1316
  }
1032
1317
  };
@@ -1268,7 +1553,7 @@ var BareBackupExecutor = class {
1268
1553
  ]);
1269
1554
  const path = sourceId;
1270
1555
  const tarCmd = compression === "zstd" ? `tar -c -C ${shellEscape6(path)} ${excludeArgs.join(" ")} . | zstd -c -3` : compression === "gzip" ? `tar -cz -C ${shellEscape6(path)} ${excludeArgs.join(" ")} .` : `tar -c -C ${shellEscape6(path)} ${excludeArgs.join(" ")} .`;
1271
- return this.execStream(service, ["sh", "-c", tarCmd]);
1556
+ return this.execStream(service, ["sh", "-c", tarCmd], { timeoutMs: opts?.timeoutMs });
1272
1557
  }
1273
1558
  async receiveStream(service, targetSourceId, body, opts) {
1274
1559
  const compression = opts?.compression ?? "zstd";
@@ -1379,7 +1664,7 @@ var LocalDestinationImpl = class {
1379
1664
  return { ok: false, reason: message };
1380
1665
  }
1381
1666
  }
1382
- async put(key, body, _opts) {
1667
+ async put(key, body, opts) {
1383
1668
  const targetPath = this.resolveKey(key);
1384
1669
  await fs2.mkdir(dirname(targetPath), { recursive: true });
1385
1670
  const tmpPath = `${targetPath}.tmp-${randomBytes3(4).toString("hex")}`;
@@ -1392,15 +1677,23 @@ var LocalDestinationImpl = class {
1392
1677
  cb(null, chunk);
1393
1678
  }
1394
1679
  });
1680
+ let digest;
1395
1681
  try {
1396
1682
  await pipelineP(body, counterAndHasher, createWriteStream2(tmpPath));
1683
+ digest = hash.digest("hex");
1684
+ const expected = opts.sha256?.trim().replace(/^sha256:/i, "").toLowerCase();
1685
+ if (expected && expected !== digest) {
1686
+ throw new Error(
1687
+ `sha256 mismatch writing ${key}: caller declared ${expected}, bytes hashed to ${digest}`
1688
+ );
1689
+ }
1397
1690
  await fs2.rename(tmpPath, targetPath);
1398
1691
  } catch (err) {
1399
1692
  await fs2.unlink(tmpPath).catch(() => {
1400
1693
  });
1401
1694
  throw err;
1402
1695
  }
1403
- return { bytesWritten, etag: hash.digest("hex") };
1696
+ return { bytesWritten, etag: digest };
1404
1697
  }
1405
1698
  async get(key) {
1406
1699
  const path = this.resolveKey(key);
@@ -10985,10 +11278,10 @@ var package_default = {
10985
11278
  // ../../node_modules/@aws-sdk/credential-provider-node/dist-es/remoteProvider.js
10986
11279
  var ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED";
10987
11280
  var remoteProvider = async (init) => {
10988
- const { ENV_CMDS_FULL_URI, ENV_CMDS_RELATIVE_URI, fromContainerMetadata, fromInstanceMetadata } = await import("./dist-es-VNKHQVIB.js");
11281
+ const { ENV_CMDS_FULL_URI, ENV_CMDS_RELATIVE_URI, fromContainerMetadata, fromInstanceMetadata } = await import("./dist-es-IOH6G4GM.js");
10989
11282
  if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) {
10990
11283
  init.logger?.debug("@aws-sdk/credential-provider-node - remoteProvider::fromHttp/fromContainerMetadata");
10991
- const { fromHttp } = await import("./dist-es-5DY7K32L.js");
11284
+ const { fromHttp } = await import("./dist-es-UYDDOVZ3.js");
10992
11285
  return chain(fromHttp(init), fromContainerMetadata(init));
10993
11286
  }
10994
11287
  if (process.env[ENV_IMDS_DISABLED] && process.env[ENV_IMDS_DISABLED] !== "false") {
@@ -11100,22 +11393,22 @@ var defaultProvider = (init = {}) => memoizeChain([
11100
11393
  if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName && !ssoSession) {
11101
11394
  throw new CredentialsProviderError("Skipping SSO provider in default chain (inputs do not include SSO fields).", { logger: init.logger });
11102
11395
  }
11103
- const { fromSSO } = await import("./dist-es-WV4AMOII.js");
11396
+ const { fromSSO } = await import("./dist-es-XUOR4NQN.js");
11104
11397
  return fromSSO(init)(awsIdentityProperties);
11105
11398
  },
11106
11399
  async (awsIdentityProperties) => {
11107
11400
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromIni");
11108
- const { fromIni } = await import("./dist-es-RYHIPOTN.js");
11401
+ const { fromIni } = await import("./dist-es-JGBPABJI.js");
11109
11402
  return fromIni(init)(awsIdentityProperties);
11110
11403
  },
11111
11404
  async (awsIdentityProperties) => {
11112
11405
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromProcess");
11113
- const { fromProcess } = await import("./dist-es-OSNJBLXB.js");
11406
+ const { fromProcess } = await import("./dist-es-CCVZEFRZ.js");
11114
11407
  return fromProcess(init)(awsIdentityProperties);
11115
11408
  },
11116
11409
  async (awsIdentityProperties) => {
11117
11410
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromTokenFile");
11118
- const { fromTokenFile } = await import("./dist-es-X3B5QYKB.js");
11411
+ const { fromTokenFile } = await import("./dist-es-2YRDXMUU.js");
11119
11412
  return fromTokenFile(init)(awsIdentityProperties);
11120
11413
  },
11121
11414
  async () => {
@@ -12690,6 +12983,10 @@ var DEFAULT_HOME = join3(homedir(), ".openship");
12690
12983
  var override = process.env.OPENSHIP_HOME?.trim();
12691
12984
  var OS_DIR = override ? resolve2(override) : DEFAULT_HOME;
12692
12985
  var IS_ALT_HOME = OS_DIR !== DEFAULT_HOME;
12986
+ var DATA_DIR = join3(OS_DIR, "data");
12987
+ var LOG_DIR = join3(OS_DIR, "logs");
12988
+ var AUTH_SECRET_FILE = join3(OS_DIR, "auth-secret");
12989
+ var INTERNAL_TOKEN_FILE = join3(OS_DIR, "internal-token");
12693
12990
 
12694
12991
  // src/lib/ports.ts
12695
12992
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -12851,13 +13148,13 @@ function stalePortOrigins(movedFrom, urls) {
12851
13148
  }
12852
13149
  return stale;
12853
13150
  }
12854
- async function resolvePorts(prefs) {
13151
+ async function resolvePorts(prefs, opts = {}) {
12855
13152
  const resolved = await resolvePortPair({
12856
13153
  ...prefs,
12857
13154
  stored: readStoredPorts(),
12858
13155
  defaults: { api: DEFAULT_API_PORT, dashboard: DEFAULT_DASHBOARD_PORT }
12859
13156
  });
12860
- saveStoredPorts(resolved.api, resolved.dashboard);
13157
+ if (opts.persist !== false) saveStoredPorts(resolved.api, resolved.dashboard);
12861
13158
  return resolved;
12862
13159
  }
12863
13160
 
@@ -12871,6 +13168,8 @@ import { spawn, spawnSync } from "child_process";
12871
13168
  import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
12872
13169
  import { join as join5, resolve as resolve3 } from "path";
12873
13170
  var DEFAULT_REPO = "https://github.com/oblien/openship.git";
13171
+ var SOURCE_CHECKOUT_DIR = join5(OS_DIR, "src");
13172
+ var SOURCE_DIST_DIR = join5(OS_DIR, "from-source-dist");
12874
13173
  function has(cmd) {
12875
13174
  try {
12876
13175
  return spawnSync(cmd, ["--version"], { stdio: "ignore" }).status === 0;
@@ -12923,7 +13222,7 @@ async function prepareFromSource(opts) {
12923
13222
  }
12924
13223
  ref = (opts.ref || "main").trim();
12925
13224
  const repoUrl = opts.repo || DEFAULT_REPO;
12926
- sourceDir = join5(OS_DIR, "src");
13225
+ sourceDir = SOURCE_CHECKOUT_DIR;
12927
13226
  mkdirSync2(OS_DIR, { recursive: true });
12928
13227
  if (!existsSync2(join5(sourceDir, ".git"))) {
12929
13228
  console.log(` Cloning ${repoUrl} \u2192 ${sourceDir}`);
@@ -12940,7 +13239,7 @@ async function prepareFromSource(opts) {
12940
13239
  console.log(` Source: ${sourceDir} @ ${ref} (${sha})`);
12941
13240
  console.log(" Installing workspace dependencies (bun install)\u2026");
12942
13241
  await run("bun", ["install"], sourceDir);
12943
- const distDir = join5(OS_DIR, "from-source-dist");
13242
+ const distDir = SOURCE_DIST_DIR;
12944
13243
  console.log(" Building release dist (compiles the dashboard \u2014 needs RAM/CPU)\u2026");
12945
13244
  await run(
12946
13245
  "bun",
@@ -13071,20 +13370,33 @@ function dockerGap(state = dockerState()) {
13071
13370
  function hasDockerCompose() {
13072
13371
  return dockerGap() === null;
13073
13372
  }
13074
- function composeIsViableDefault() {
13075
- return process.platform === "linux" && hasDockerCompose();
13076
- }
13077
13373
  function hasCmd(cmd) {
13078
13374
  return spawnSync3(cmd, ["--version"], { stdio: "ignore" }).status === 0;
13079
13375
  }
13080
13376
  function shQuote(s2) {
13081
13377
  return `'${s2.replace(/'/g, `'\\''`)}'`;
13082
13378
  }
13379
+ function dockerInstallPreview(state = dockerState()) {
13380
+ const gap = dockerGap(state);
13381
+ if (!gap) return { state, gap: null, wouldInstall: false };
13382
+ if (process.platform !== "linux" || !gap.installable) return { state, gap, wouldInstall: false };
13383
+ const plan = systemCatalog.installs.docker({
13384
+ os: "linux",
13385
+ serviceManager: "systemd"
13386
+ });
13387
+ if (!plan.supported || !plan.installCommand) return { state, gap, wouldInstall: false };
13388
+ return {
13389
+ state,
13390
+ gap,
13391
+ wouldInstall: true,
13392
+ installCommand: plan.installCommand,
13393
+ ...plan.startCommand ? { startCommand: plan.startCommand } : {}
13394
+ };
13395
+ }
13083
13396
  async function ensureDocker(opts = {}) {
13084
13397
  const notice = opts.onNotice ?? ((line) => process.stderr.write(` ${line}
13085
13398
  `));
13086
- const state = dockerState();
13087
- const gap = dockerGap(state);
13399
+ const { state, gap, wouldInstall, installCommand, startCommand } = dockerInstallPreview();
13088
13400
  if (!gap) return true;
13089
13401
  if (process.platform !== "linux") return false;
13090
13402
  if (!gap.installable) {
@@ -13092,11 +13404,7 @@ async function ensureDocker(opts = {}) {
13092
13404
  if (gap.hint) notice(gap.hint);
13093
13405
  return false;
13094
13406
  }
13095
- const plan = systemCatalog.installs.docker({
13096
- os: "linux",
13097
- serviceManager: "systemd"
13098
- });
13099
- if (!plan.supported || !plan.installCommand) return false;
13407
+ if (!wouldInstall || !installCommand) return false;
13100
13408
  const asRoot = typeof process.getuid === "function" && process.getuid() === 0;
13101
13409
  const sudo = !asRoot && hasCmd("sudo") ? "sudo " : "";
13102
13410
  notice("This can take 2-5 minutes on a small VPS (~150 MB of packages) and stays quiet while apt works.");
@@ -13119,8 +13427,8 @@ async function ensureDocker(opts = {}) {
13119
13427
  child.on("error", () => done(1));
13120
13428
  child.on("close", (code) => done(code ?? 1));
13121
13429
  });
13122
- if (await sh(plan.installCommand) !== 0) return false;
13123
- if (plan.startCommand) await sh(plan.startCommand);
13430
+ if (await sh(installCommand) !== 0) return false;
13431
+ if (startCommand) await sh(startCommand);
13124
13432
  const after = dockerGap();
13125
13433
  if (!after) {
13126
13434
  notice("Docker ready.");
@@ -13168,7 +13476,10 @@ services:
13168
13476
  api:
13169
13477
  image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-api:\${OPENSHIP_VERSION:-latest}
13170
13478
  restart: unless-stopped
13171
- ports: ["\${OPENSHIP_BIND_ADDR:-0.0.0.0}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
13479
+ # Loopback by default \u2014 the host-net edge reaches it over loopback, so nothing
13480
+ # sits on a public interface. OPENSHIP_BIND_ADDR opts into a public/LAN interface
13481
+ # (set by \`openship up\` when a public URL is configured for off-box access).
13482
+ ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${API_PORT:-4000}:\${API_PORT:-4000}"]
13172
13483
  volumes:
13173
13484
  - /var/run/docker.sock:/var/run/docker.sock
13174
13485
  # Routing state shared with the edge, as HOST BIND MOUNTS (generated from
@@ -13203,7 +13514,8 @@ ${edgeVolumeYaml(" ")}
13203
13514
  dashboard:
13204
13515
  image: \${OPENSHIP_IMAGE_REGISTRY:-ghcr.io/oblien}/openship-dashboard:\${OPENSHIP_VERSION:-latest}
13205
13516
  restart: unless-stopped
13206
- ports: ["\${OPENSHIP_BIND_ADDR:-0.0.0.0}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
13517
+ # Loopback by default (see api note); OPENSHIP_BIND_ADDR opts into a public interface.
13518
+ ports: ["\${OPENSHIP_BIND_ADDR:-127.0.0.1}:\${DASHBOARD_PORT:-3001}:\${DASHBOARD_PORT:-3001}"]
13207
13519
  env_file: [.env]
13208
13520
  environment:
13209
13521
  NODE_ENV: production
@@ -13254,12 +13566,22 @@ function rewriteHostAuthorizedKeys(existing, pub) {
13254
13566
  const kept = existing.split("\n").filter((line) => line.trim() && !line.includes(HOST_KEY_COMMENT));
13255
13567
  return [...kept, hostKeyAuthLine(pub)].join("\n") + "\n";
13256
13568
  }
13257
- function provisionHostSshChannel() {
13258
- if (process.platform !== "linux") return null;
13569
+ function plannedHostChannel(hostControl) {
13570
+ if (!hostControl || process.platform !== "linux") return null;
13571
+ let user;
13572
+ try {
13573
+ user = userInfo().username;
13574
+ } catch {
13575
+ user = process.env.USER || process.env.LOGNAME || "root";
13576
+ }
13577
+ return { user, keyPath: join7(COMPOSE_DIR, "host-ssh", "id_ed25519") };
13578
+ }
13579
+ function provisionHostSshChannel(hostControl) {
13580
+ const target = plannedHostChannel(hostControl);
13581
+ if (!target) return null;
13582
+ const { user, keyPath } = target;
13259
13583
  try {
13260
- const sshDir = join7(COMPOSE_DIR, "host-ssh");
13261
- mkdirSync3(sshDir, { recursive: true, mode: 448 });
13262
- const keyPath = join7(sshDir, "id_ed25519");
13584
+ mkdirSync3(join7(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 448 });
13263
13585
  if (!existsSync4(keyPath)) {
13264
13586
  const g2 = spawnSync3(
13265
13587
  "ssh-keygen",
@@ -13270,13 +13592,6 @@ function provisionHostSshChannel() {
13270
13592
  }
13271
13593
  const pub = readFileSync3(`${keyPath}.pub`, "utf8").trim();
13272
13594
  if (!pub) return null;
13273
- const user = (() => {
13274
- try {
13275
- return userInfo().username;
13276
- } catch {
13277
- return process.env.USER || process.env.LOGNAME || "root";
13278
- }
13279
- })();
13280
13595
  const userSshDir = join7(homedir2(), ".ssh");
13281
13596
  mkdirSync3(userSshDir, { recursive: true, mode: 448 });
13282
13597
  const authKeys = join7(userSshDir, "authorized_keys");
@@ -13385,6 +13700,25 @@ function removeOrphanedStack(project) {
13385
13700
  );
13386
13701
  spawnSync3("docker", ["rm", "-f", ...orphans.map((o2) => o2.name)], { stdio: "ignore" });
13387
13702
  }
13703
+ var EDGE_SWEEP_FORMAT = '{{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.config_files"}}';
13704
+ function edgeNameNeedsReclaim(psStdout, project) {
13705
+ const row = psStdout.split("\n").map((l2) => l2.trim()).find((l2) => l2.split(" ")[0] === "openship-edge");
13706
+ if (!row) return false;
13707
+ const [, proj = "", configFiles = ""] = row.split(" ");
13708
+ const ours = proj === project && configFiles.split(",").some((f2) => f2.trim() === COMPOSE_FILE);
13709
+ return !ours;
13710
+ }
13711
+ function reconcileEdgeContainerName(project) {
13712
+ const r2 = spawnSync3("docker", ["ps", "-a", "--format", EDGE_SWEEP_FORMAT], {
13713
+ encoding: "utf8"
13714
+ });
13715
+ if (r2.status !== 0 || !r2.stdout) return;
13716
+ if (!edgeNameNeedsReclaim(r2.stdout, project)) return;
13717
+ console.log(
13718
+ " Replacing an unmanaged `openship-edge` container so compose can own it (config is on the host, so no routes are lost)."
13719
+ );
13720
+ spawnSync3("docker", ["rm", "-f", "openship-edge"], { stdio: "ignore" });
13721
+ }
13388
13722
  function migrateLegacyEdgeVolumes(project) {
13389
13723
  const pairs = [
13390
13724
  { volume: `${project}_openship_sites`, host: EDGE_SITES_HOST_DIR },
@@ -13447,9 +13781,26 @@ function keepConfig(prev, key, explicit) {
13447
13781
  const carried = prev[key]?.trim();
13448
13782
  return carried || void 0;
13449
13783
  }
13784
+ var ACME_ENV_KEYS = [
13785
+ "OPENSHIP_ACME_EMAIL",
13786
+ "OPENSHIP_ACME_DIRECTORY_URL",
13787
+ "OPENSHIP_ACME_EAB_KID",
13788
+ "OPENSHIP_ACME_EAB_HMAC_KEY",
13789
+ "OPENSHIP_ACME_KEY_TYPE",
13790
+ "OPENSHIP_ACME_CA_BUNDLE",
13791
+ "OPENSHIP_ACME_TOS_AGREED"
13792
+ ];
13793
+ function renderAcmeEnv(prev) {
13794
+ return ACME_ENV_KEYS.flatMap((key) => {
13795
+ const value = process.env[key]?.trim() || prev[key]?.trim();
13796
+ if (!value) return [];
13797
+ if (/[\r\n]/.test(value)) throw new Error(`${key} must be a single-line value`);
13798
+ return [`${key}=${value}`];
13799
+ });
13800
+ }
13450
13801
  function resolveEnvConfig(prev, opts) {
13451
13802
  const publicUrl = keepConfig(prev, "OPENSHIP_PUBLIC_URL", opts.publicUrl);
13452
- const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR");
13803
+ const bindAddr = keepConfig(prev, "OPENSHIP_BIND_ADDR") ?? (publicUrl ? "0.0.0.0" : void 0);
13453
13804
  return {
13454
13805
  apiPort: keepConfig(prev, "API_PORT", opts.apiPort) ?? String(DEFAULT_API_PORT),
13455
13806
  dashPort: keepConfig(prev, "DASHBOARD_PORT", opts.dashboardPort) ?? String(DEFAULT_DASHBOARD_PORT),
@@ -13480,7 +13831,7 @@ function composeEnvPorts() {
13480
13831
  return { api: toPort(env.API_PORT), dashboard: toPort(env.DASHBOARD_PORT) };
13481
13832
  }
13482
13833
  function composeBindAddr() {
13483
- return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "0.0.0.0";
13834
+ return readEnvFile().OPENSHIP_BIND_ADDR?.trim() || "127.0.0.1";
13484
13835
  }
13485
13836
  function composeTrustedOriginUrls() {
13486
13837
  const env = readEnvFile();
@@ -13503,14 +13854,17 @@ function composeHeldPorts() {
13503
13854
  }
13504
13855
  return [...ports];
13505
13856
  }
13506
- async function resolveComposePorts(prefs) {
13507
- return resolvePorts({
13508
- api: toPort(prefs.api),
13509
- dashboard: toPort(prefs.dashboard),
13510
- previous: composeEnvPorts(),
13511
- bindAddr: composeBindAddr(),
13512
- reclaimable: composeHeldPorts()
13513
- });
13857
+ async function resolveComposePorts(prefs, opts = {}) {
13858
+ return resolvePorts(
13859
+ {
13860
+ api: toPort(prefs.api),
13861
+ dashboard: toPort(prefs.dashboard),
13862
+ previous: composeEnvPorts(),
13863
+ bindAddr: composeBindAddr(),
13864
+ reclaimable: composeHeldPorts()
13865
+ },
13866
+ opts
13867
+ );
13514
13868
  }
13515
13869
  function renderEnv(opts, host, cfg) {
13516
13870
  const prev = readEnvFile();
@@ -13525,7 +13879,7 @@ function renderEnv(opts, host, cfg) {
13525
13879
  // (hides the local row). Written explicitly so the policy is visible in .env.
13526
13880
  `OPENSHIP_HOST_CONTROL=${cfg.hostControl ? "true" : "false"}`,
13527
13881
  `OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
13528
- `OPENSHIP_VERSION=${opts.version || (true ? "0.5.0" : "latest")}`,
13882
+ `OPENSHIP_VERSION=${opts.version || (true ? "0.6.1" : "latest")}`,
13529
13883
  `POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
13530
13884
  // Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
13531
13885
  `OPENSHIP_PGDATA=${resolvePgData(prev)}`,
@@ -13537,7 +13891,10 @@ function renderEnv(opts, host, cfg) {
13537
13891
  // the operator's domain at the dashboard through this (self-deploy.ts), and it
13538
13892
  // silently defaults to 3001 when unset. Ports are dynamic now, so leaving it
13539
13893
  // out publishes a domain routed to a port nothing is listening on.
13540
- `OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`
13894
+ `OPENSHIP_DASHBOARD_PORT=${cfg.dashPort}`,
13895
+ // Alternate CA/EAB values are operator configuration (including one secret),
13896
+ // so a routine `openship up`/upgrade must not silently discard them.
13897
+ ...renderAcmeEnv(prev)
13541
13898
  ];
13542
13899
  if (cfg.bindAddr) lines.push(`OPENSHIP_BIND_ADDR=${cfg.bindAddr}`);
13543
13900
  if (cfg.publicUrl) lines.push(`OPENSHIP_PUBLIC_URL=${cfg.publicUrl}`);
@@ -13578,7 +13935,7 @@ function materialize(opts) {
13578
13935
  mkdirSync3(COMPOSE_DIR, { recursive: true, mode: 448 });
13579
13936
  const prev = readEnvFile();
13580
13937
  const cfg = resolveEnvConfig(prev, opts);
13581
- const host = cfg.hostControl ? provisionHostSshChannel() : null;
13938
+ const host = provisionHostSshChannel(cfg.hostControl);
13582
13939
  let before = "";
13583
13940
  try {
13584
13941
  before = readFileSync3(ENV_FILE, "utf8");
@@ -13591,6 +13948,40 @@ function materialize(opts) {
13591
13948
  if (buildDir) writeFileSync2(BUILD_FILE, renderBuildOverride(buildDir));
13592
13949
  return { buildDir, cfg, envChanged: before !== "" && rendered !== before };
13593
13950
  }
13951
+ var SECRET_ENV_KEY = /(PASSWORD|SECRET|TOKEN|HMAC_KEY)$/;
13952
+ function composePlan(opts) {
13953
+ const prev = readEnvFile();
13954
+ const cfg = resolveEnvConfig(prev, opts);
13955
+ const buildDir = opts.build === false ? null : sourceBuildDir();
13956
+ const hostChannel = plannedHostChannel(cfg.hostControl);
13957
+ const settings = [];
13958
+ const newSecrets = [];
13959
+ for (const line of renderEnv(opts, hostChannel, cfg).split("\n")) {
13960
+ const at2 = line.indexOf("=");
13961
+ if (at2 < 1 || line.startsWith("#")) continue;
13962
+ const key = line.slice(0, at2);
13963
+ if (!SECRET_ENV_KEY.test(key)) {
13964
+ settings.push({ key, value: line.slice(at2 + 1) });
13965
+ continue;
13966
+ }
13967
+ const minted = !prev[key];
13968
+ if (minted) newSecrets.push(key);
13969
+ settings.push({ key, value: minted ? "<generated on this run>" : "<preserved>" });
13970
+ }
13971
+ return {
13972
+ composeFile: COMPOSE_FILE,
13973
+ envFile: ENV_FILE,
13974
+ installMethodFile: INSTALL_METHOD_FILE,
13975
+ ...buildDir ? { buildFile: BUILD_FILE } : {},
13976
+ yaml: COMPOSE_YAML,
13977
+ settings,
13978
+ newSecrets,
13979
+ existing: Object.keys(prev).length > 0,
13980
+ mountDirs: EDGE_CONTAINER_MOUNTS.map((m2) => m2.host),
13981
+ buildDir,
13982
+ hostChannel
13983
+ };
13984
+ }
13594
13985
  function dbVolumeExists(project) {
13595
13986
  const r2 = spawnSync3("docker", ["volume", "inspect", `${project}_postgres_data`], {
13596
13987
  stdio: "ignore"
@@ -13613,6 +14004,23 @@ function composePrefetch(opts) {
13613
14004
  }
13614
14005
  return compose(["pull"]) === 0;
13615
14006
  }
14007
+ function ensureEdgeMountDirs() {
14008
+ const failed = [];
14009
+ for (const { host } of EDGE_CONTAINER_MOUNTS) {
14010
+ try {
14011
+ mkdirSync3(host, { recursive: true });
14012
+ } catch (err) {
14013
+ if (err.code !== "EEXIST") failed.push(host);
14014
+ }
14015
+ }
14016
+ return failed;
14017
+ }
14018
+ function isRootlessDocker() {
14019
+ const r2 = spawnSync3("docker", ["info", "-f", "{{println .SecurityOptions}}"], {
14020
+ encoding: "utf8"
14021
+ });
14022
+ return r2.status === 0 && /rootless/i.test(r2.stdout ?? "");
14023
+ }
13616
14024
  async function composeUp(opts) {
13617
14025
  const { buildDir, cfg, envChanged } = materialize(opts);
13618
14026
  const apiPort = cfg.apiPort;
@@ -13624,8 +14032,24 @@ async function composeUp(opts) {
13624
14032
  const env = readEnvFile();
13625
14033
  const project = env.COMPOSE_PROJECT_NAME || "openship";
13626
14034
  removeOrphanedStack(project);
14035
+ reconcileEdgeContainerName(project);
13627
14036
  migrateLegacyEdgeVolumes(project);
13628
14037
  warnOrphanedVolumes(project);
14038
+ const unmakeableMounts = ensureEdgeMountDirs();
14039
+ if (unmakeableMounts.length && isRootlessDocker()) {
14040
+ const dirs = unmakeableMounts.join(" ");
14041
+ console.error(
14042
+ `
14043
+ Rootless Docker can't create the edge's host directories under root-owned paths:
14044
+ ${unmakeableMounts.join("\n ")}
14045
+
14046
+ Create them once (owned by your user), then re-run \`openship up --compose\`:
14047
+ sudo mkdir -p ${dirs}
14048
+ sudo chown -R "$(id -un)" ${dirs}
14049
+ `
14050
+ );
14051
+ return { ok: false, apiPort, dashPort };
14052
+ }
13629
14053
  if (dbVolumeExists(project)) {
13630
14054
  reconcileDbPassword(env.POSTGRES_USER || "openship", env.POSTGRES_PASSWORD ?? "");
13631
14055
  }
@@ -13698,6 +14122,7 @@ function composeRunning() {
13698
14122
  return r2.status === 0 && r2.stdout.trim().length > 0;
13699
14123
  }
13700
14124
  function composeStart() {
14125
+ reconcileEdgeContainerName(readEnvFile().COMPOSE_PROJECT_NAME || "openship");
13701
14126
  return compose(["up", "-d"], { quiet: true }) === 0;
13702
14127
  }
13703
14128
  function composeRestart() {
@@ -13728,7 +14153,7 @@ async function waitForEdgeRunning(tries) {
13728
14153
  }
13729
14154
  return false;
13730
14155
  }
13731
- async function importMigratedSites(apiPort, sites, certPems) {
14156
+ async function importMigratedSites(apiPort, sites, certPems, staticRootOverrides) {
13732
14157
  const token = composeInternalToken();
13733
14158
  if (!token) {
13734
14159
  const error = "couldn't read the stack's internal token";
@@ -13753,7 +14178,7 @@ async function importMigratedSites(apiPort, sites, certPems) {
13753
14178
  const r2 = await fetch(`http://127.0.0.1:${apiPort}/api/system/edge/import-sites`, {
13754
14179
  method: "POST",
13755
14180
  headers: { "Content-Type": "application/json", "X-Internal-Token": token },
13756
- body: JSON.stringify({ sites, certPems }),
14181
+ body: JSON.stringify({ sites, certPems, staticRootOverrides }),
13757
14182
  signal: AbortSignal.timeout(12e4)
13758
14183
  });
13759
14184
  const data = await r2.json().catch(() => ({}));
@@ -13808,8 +14233,11 @@ async function importMigratedSites(apiPort, sites, certPems) {
13808
14233
  export {
13809
14234
  OS_DIR,
13810
14235
  IS_ALT_HOME,
13811
- DEFAULT_API_PORT,
13812
- DEFAULT_DASHBOARD_PORT,
14236
+ DATA_DIR,
14237
+ LOG_DIR,
14238
+ AUTH_SECRET_FILE,
14239
+ INTERNAL_TOKEN_FILE,
14240
+ PORTS_FILE,
13813
14241
  saveInstanceUrl,
13814
14242
  readInstanceUrl,
13815
14243
  readStoredPorts,
@@ -13817,6 +14245,10 @@ export {
13817
14245
  storedDashboardPort,
13818
14246
  portMoveNotice,
13819
14247
  resolvePorts,
14248
+ DEFAULT_REPO,
14249
+ SOURCE_CHECKOUT_DIR,
14250
+ SOURCE_DIST_DIR,
14251
+ has,
13820
14252
  shortSha,
13821
14253
  prepareFromSource,
13822
14254
  readSourceInstall,
@@ -13825,11 +14257,12 @@ export {
13825
14257
  readInstallMethod,
13826
14258
  dockerGap,
13827
14259
  hasDockerCompose,
13828
- composeIsViableDefault,
14260
+ dockerInstallPreview,
13829
14261
  ensureDocker,
13830
14262
  composeTrustedOriginUrls,
13831
14263
  resolveComposePorts,
13832
14264
  sourceBuildDir,
14265
+ composePlan,
13833
14266
  composePrefetch,
13834
14267
  composeUp,
13835
14268
  composeDown,