cyberia 3.2.9 → 3.2.22

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 (184) hide show
  1. package/.github/workflows/engine-cyberia.cd.yml +7 -0
  2. package/.github/workflows/engine-cyberia.ci.yml +14 -2
  3. package/.github/workflows/ghpkg.ci.yml +1 -0
  4. package/.github/workflows/npmpkg.ci.yml +10 -5
  5. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  6. package/.github/workflows/release.cd.yml +1 -0
  7. package/.vscode/extensions.json +9 -9
  8. package/.vscode/settings.json +20 -4
  9. package/CHANGELOG.md +363 -1
  10. package/CLI-HELP.md +975 -1061
  11. package/README.md +190 -348
  12. package/bin/build.js +102 -125
  13. package/bin/build.template.js +33 -0
  14. package/bin/cyberia.js +238 -56
  15. package/bin/deploy.js +16 -3
  16. package/bin/index.js +238 -56
  17. package/bump.config.js +26 -0
  18. package/conf.js +131 -24
  19. package/deployment.yaml +76 -2
  20. package/hardhat/package-lock.json +113 -144
  21. package/hardhat/package.json +4 -3
  22. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  23. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  24. package/manifests/deployment/dd-cyberia-development/deployment.yaml +76 -2
  25. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  26. package/manifests/kind-config-dev.yaml +8 -0
  27. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  28. package/manifests/mongodb/pv-pvc.yaml +44 -8
  29. package/manifests/mongodb/statefulset.yaml +55 -68
  30. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  31. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  32. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  33. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  34. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  35. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  36. package/manifests/valkey/statefulset.yaml +1 -1
  37. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  38. package/package.json +31 -19
  39. package/scripts/ipxe-setup.sh +52 -49
  40. package/scripts/k3s-node-setup.sh +81 -46
  41. package/scripts/link-local-underpost-cli.sh +6 -0
  42. package/scripts/lxd-vm-setup.sh +193 -8
  43. package/scripts/maas-nat-firewalld.sh +145 -0
  44. package/scripts/test-monitor.sh +250 -0
  45. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +38 -33
  46. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +16 -16
  47. package/src/api/core/core.router.js +19 -14
  48. package/src/api/core/core.service.js +5 -5
  49. package/src/api/crypto/crypto.router.js +18 -12
  50. package/src/api/crypto/crypto.service.js +3 -3
  51. package/src/api/cyberia-action/cyberia-action.model.js +1 -1
  52. package/src/api/cyberia-action/cyberia-action.router.js +22 -18
  53. package/src/api/cyberia-action/cyberia-action.service.js +5 -5
  54. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +74 -0
  55. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +99 -0
  56. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +98 -0
  57. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +152 -0
  58. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +25 -20
  59. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +6 -6
  60. package/src/api/cyberia-entity/cyberia-entity.router.js +22 -18
  61. package/src/api/cyberia-entity/cyberia-entity.service.js +5 -5
  62. package/src/api/cyberia-instance/cyberia-fallback-world.js +79 -4
  63. package/src/api/cyberia-instance/cyberia-instance.router.js +57 -52
  64. package/src/api/cyberia-instance/cyberia-instance.service.js +10 -10
  65. package/src/api/cyberia-instance/cyberia-world-generator.js +3 -3
  66. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +14 -48
  67. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +22 -18
  68. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +5 -5
  69. package/src/api/cyberia-map/cyberia-map.router.js +35 -30
  70. package/src/api/cyberia-map/cyberia-map.service.js +7 -7
  71. package/src/api/cyberia-quest/cyberia-quest.model.js +1 -1
  72. package/src/api/cyberia-quest/cyberia-quest.router.js +22 -18
  73. package/src/api/cyberia-quest/cyberia-quest.service.js +5 -5
  74. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +22 -18
  75. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +5 -5
  76. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +458 -0
  77. package/src/api/default/default.router.js +22 -18
  78. package/src/api/default/default.service.js +5 -5
  79. package/src/api/document/document.router.js +28 -23
  80. package/src/api/document/document.service.js +100 -23
  81. package/src/api/file/file.router.js +19 -13
  82. package/src/api/file/file.service.js +9 -7
  83. package/src/api/instance/instance.router.js +29 -24
  84. package/src/api/instance/instance.service.js +6 -6
  85. package/src/api/ipfs/ipfs.router.js +21 -16
  86. package/src/api/ipfs/ipfs.service.js +8 -8
  87. package/src/api/object-layer/object-layer.router.js +512 -507
  88. package/src/api/object-layer/object-layer.service.js +17 -14
  89. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +22 -18
  90. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +5 -5
  91. package/src/api/test/test.router.js +17 -12
  92. package/src/api/types.js +24 -0
  93. package/src/api/user/guest.service.js +5 -4
  94. package/src/api/user/user.router.js +297 -288
  95. package/src/api/user/user.service.js +100 -35
  96. package/src/cli/baremetal.js +132 -101
  97. package/src/cli/cluster.js +700 -232
  98. package/src/cli/db.js +59 -60
  99. package/src/cli/deploy.js +291 -294
  100. package/src/cli/env.js +1 -4
  101. package/src/cli/fs.js +13 -3
  102. package/src/cli/image.js +58 -4
  103. package/src/cli/index.js +127 -15
  104. package/src/cli/ipfs.js +4 -6
  105. package/src/cli/kubectl.js +4 -1
  106. package/src/cli/lxd.js +1099 -223
  107. package/src/cli/monitor.js +396 -9
  108. package/src/cli/release.js +355 -146
  109. package/src/cli/repository.js +169 -30
  110. package/src/cli/run.js +347 -117
  111. package/src/cli/secrets.js +11 -2
  112. package/src/cli/test.js +9 -3
  113. package/src/client/Default.index.js +9 -3
  114. package/src/client/components/core/Auth.js +5 -0
  115. package/src/client/components/core/ClientEvents.js +76 -0
  116. package/src/client/components/core/EventBus.js +4 -0
  117. package/src/client/components/core/Modal.js +82 -41
  118. package/src/client/components/core/PanelForm.js +14 -10
  119. package/src/client/components/core/Worker.js +162 -363
  120. package/src/client/components/cyberia/MapEngineCyberia.js +1 -1
  121. package/src/client/components/cyberia/SharedDefaultsCyberia.js +330 -0
  122. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +55 -1
  123. package/src/client/public/cyberia-docs/ARCHITECTURE.md +223 -361
  124. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +114 -327
  125. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +200 -222
  126. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +212 -185
  127. package/src/client/public/cyberia-docs/CYBERIA.md +259 -0
  128. package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +2 -2
  129. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +23 -1
  130. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  131. package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +106 -0
  132. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  133. package/src/client/services/cyberia-client-hints/cyberia-client-hints.service.js +99 -0
  134. package/src/client/ssr/views/CyberiaServerMetrics.js +982 -0
  135. package/src/client/sw/core.sw.js +174 -112
  136. package/src/db/DataBaseProvider.js +115 -15
  137. package/src/db/mariadb/MariaDB.js +2 -1
  138. package/src/db/mongo/MongoBootstrap.js +657 -0
  139. package/src/db/mongo/MongooseDB.js +130 -21
  140. package/src/grpc/cyberia/grpc-server.js +25 -57
  141. package/src/index.js +1 -1
  142. package/src/runtime/cyberia-client/Dockerfile +10 -7
  143. package/src/runtime/cyberia-client/Dockerfile.dev +67 -0
  144. package/src/runtime/cyberia-server/Dockerfile +11 -6
  145. package/src/runtime/cyberia-server/Dockerfile.dev +47 -0
  146. package/src/runtime/express/Express.js +2 -2
  147. package/src/runtime/wp/Dockerfile +3 -3
  148. package/src/runtime/wp/Wp.js +8 -5
  149. package/src/server/auth.js +2 -2
  150. package/src/server/catalog-underpost.js +61 -0
  151. package/src/server/catalog.js +77 -0
  152. package/src/server/client-build-docs.js +1 -1
  153. package/src/server/client-build.js +94 -129
  154. package/src/server/conf.js +496 -135
  155. package/src/server/ipfs-client.js +5 -3
  156. package/src/server/process.js +180 -19
  157. package/src/server/proxy.js +9 -2
  158. package/src/server/runtime-status.js +235 -0
  159. package/src/server/runtime.js +1 -1
  160. package/src/server/start.js +44 -11
  161. package/src/server/valkey.js +2 -0
  162. package/src/ws/IoInterface.js +16 -16
  163. package/src/ws/core/channels/core.ws.chat.js +11 -11
  164. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  165. package/src/ws/core/channels/core.ws.stream.js +19 -19
  166. package/src/ws/core/core.ws.connection.js +8 -8
  167. package/src/ws/core/core.ws.server.js +6 -5
  168. package/src/ws/default/channels/default.ws.main.js +10 -10
  169. package/src/ws/default/default.ws.connection.js +4 -4
  170. package/src/ws/default/default.ws.server.js +4 -3
  171. package/test/deploy-monitor.test.js +251 -0
  172. package/bin/file.js +0 -202
  173. package/bin/vs.js +0 -74
  174. package/bin/zed.js +0 -84
  175. package/manifests/deployment/dd-test-development/deployment.yaml +0 -254
  176. package/manifests/deployment/dd-test-development/proxy.yaml +0 -102
  177. package/src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js +0 -574
  178. package/src/client/components/cyberia-portal/CommonCyberiaPortal.js +0 -467
  179. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  180. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  181. package/src/client/ssr/pages/CyberiaServerMetrics.js +0 -461
  182. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  183. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  184. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -4,10 +4,25 @@
4
4
  * @namespace UnderpostMonitor
5
5
  */
6
6
 
7
- import { loadReplicas, pathPortAssignmentFactory, loadConfServerJson, loadCronDeployEnv } from '../server/conf.js';
7
+ import {
8
+ loadReplicas,
9
+ pathPortAssignmentFactory,
10
+ loadConfServerJson,
11
+ loadCronDeployEnv,
12
+ etcHostFactory,
13
+ deployRangePortFactory,
14
+ } from '../server/conf.js';
8
15
  import { loggerFactory } from '../server/logger.js';
16
+ import { timer } from '../client/components/core/CommonJs.js';
17
+ import {
18
+ RUNTIME_STATUS,
19
+ INTERNAL_STATUS_PATH,
20
+ normalizeContainerStatus,
21
+ deployStatusPort,
22
+ } from '../server/runtime-status.js';
9
23
  import axios from 'axios';
10
24
  import fs from 'fs-extra';
25
+ import net from 'node:net';
11
26
  import { shellExec } from '../server/process.js';
12
27
  import Underpost from '../index.js';
13
28
 
@@ -87,13 +102,13 @@ class UnderpostMonitor {
87
102
  }
88
103
 
89
104
  if (options.readyDeployment) {
90
- for (const version of options.versions.split(',')) {
91
- (async () => {
92
- await Underpost.deploy.monitorReadyRunner(deployId, env, version, [], options.namespace, 'underpost');
105
+ await Promise.all(
106
+ options.versions.split(',').map(async (version) => {
107
+ await Underpost.monitor.monitorReadyRunner(deployId, env, version, [], options.namespace);
93
108
  if (options.promote)
94
109
  Underpost.deploy.switchTraffic(deployId, env, version, options.replicas, options.namespace, options);
95
- })();
96
- }
110
+ }),
111
+ );
97
112
  return;
98
113
  }
99
114
 
@@ -144,7 +159,7 @@ class UnderpostMonitor {
144
159
  if (path.match('peer') || path.match('socket')) continue;
145
160
  const urlTest = `http${env === 'development' ? '' : 's'}://${host}${path}`;
146
161
  if (env === 'development') {
147
- const { renderHosts } = Underpost.deploy.etcHostFactory([host]);
162
+ const { renderHosts } = etcHostFactory([host]);
148
163
  logger.info('renderHosts', renderHosts);
149
164
  }
150
165
  await axios.get(urlTest, { timeout: 10000 }).catch((error) => {
@@ -200,7 +215,7 @@ class UnderpostMonitor {
200
215
  let monitorPodName;
201
216
  const monitorCallBack = (resolve, reject) => {
202
217
  if (env === 'development') {
203
- const { renderHosts } = Underpost.deploy.etcHostFactory([]);
218
+ const { renderHosts } = etcHostFactory([]);
204
219
  logger.info('renderHosts', renderHosts);
205
220
  }
206
221
  const envMsTimeout = Underpost.env.get(`${deployId}-${env}-monitor-ms`);
@@ -221,7 +236,7 @@ class UnderpostMonitor {
221
236
  monitorPodName = undefined;
222
237
  }
223
238
  const checkDeploymentReadyStatus = async () => {
224
- const { ready, notReadyPods, readyPods } = await Underpost.deploy.checkDeploymentReadyStatus(
239
+ const { ready, notReadyPods, readyPods } = await Underpost.monitor.checkDeploymentReadyStatus(
225
240
  deployId,
226
241
  env,
227
242
  traffic,
@@ -266,6 +281,378 @@ class UnderpostMonitor {
266
281
  };
267
282
  return new Promise((...args) => monitorCallBack(...args));
268
283
  },
284
+ /**
285
+ * Checks the status of a deployment.
286
+ * @param {string} deployId - Deployment ID for which the status is being checked.
287
+ * @param {string} env - Environment for which the status is being checked.
288
+ * @param {string} traffic - Current traffic status for the deployment.
289
+ * @param {Array<string>} ignoresNames - List of pod names to ignore.
290
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
291
+ * @returns {object} - Object containing the status of the deployment.
292
+ * @memberof UnderpostMonitor
293
+ */
294
+ async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
295
+ const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
296
+ const readyPods = [];
297
+ const notReadyPods = [];
298
+
299
+ // Readiness signal: the pod's Kubernetes `Ready` condition driven by the
300
+ // container's readinessProbe (TCP socket, HTTP get, or exec). Set by kubelet
301
+ // when the probe passes. A failed or crashing runtime never becomes Ready —
302
+ // kubelet surfaces CrashLoopBackOff and this gate stays closed.
303
+ for (const pod of pods) {
304
+ const { NAME } = pod;
305
+ if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
306
+
307
+ let podJson = null;
308
+ try {
309
+ // Pod may not exist yet (between deployment apply and pod
310
+ // scheduling). silentOnError lets the monitor loop continue
311
+ // instead of aborting on the transient NotFound exit.
312
+ const raw = shellExec(`sudo kubectl get pod ${NAME} -n ${namespace} -o json`, {
313
+ silent: true,
314
+ disableLog: true,
315
+ stdout: true,
316
+ silentOnError: true,
317
+ });
318
+ podJson = raw ? JSON.parse(raw) : null;
319
+ } catch (_) {
320
+ podJson = null;
321
+ }
322
+ const conditions = podJson?.status?.conditions || [];
323
+ const readyCondition = conditions.find((c) => c.type === 'Ready');
324
+ const k8sReady = readyCondition?.status === 'True';
325
+
326
+ pod.out = JSON.stringify({ k8sReady, condition: readyCondition ?? null });
327
+
328
+ if (k8sReady) readyPods.push(pod);
329
+ else notReadyPods.push(pod);
330
+ }
331
+ const consideredCount = readyPods.length + notReadyPods.length;
332
+ return {
333
+ ready: consideredCount > 0 && notReadyPods.length === 0,
334
+ notReadyPods,
335
+ readyPods,
336
+ };
337
+ },
338
+ /**
339
+ * Resolves a free ephemeral TCP port on the loopback interface, used as the
340
+ * local end of the `kubectl port-forward` tunnel so it never collides with
341
+ * host-local services.
342
+ * @returns {Promise<number>}
343
+ * @memberof UnderpostMonitor
344
+ */
345
+ findFreePort() {
346
+ return new Promise((resolve) => {
347
+ const srv = net.createServer();
348
+ srv.once('error', () => resolve(20000 + Math.floor(Math.random() * 20000)));
349
+ srv.listen(0, '127.0.0.1', () => {
350
+ const { port } = srv.address();
351
+ srv.close(() => resolve(port));
352
+ });
353
+ });
354
+ },
355
+ /**
356
+ * Resolves the deployment's internal status port (Phase-2 transport target).
357
+ *
358
+ * Canonical value is `fromPort - 1` from the deployment router — the exact
359
+ * port `buildManifest` injects into the pod (UNDERPOST_INTERNAL_PORT) and
360
+ * uses for the probes — so the tunnel target always matches the in-pod bind.
361
+ * `UNDERPOST_INTERNAL_PORT` overrides; ambient resolution is the last resort.
362
+ *
363
+ * @param {string} deployId
364
+ * @param {string} env
365
+ * @returns {Promise<number>}
366
+ * @memberof UnderpostMonitor
367
+ */
368
+ async deployInternalPort(deployId, env) {
369
+ const override = parseInt(process.env.UNDERPOST_INTERNAL_PORT);
370
+ if (!Number.isNaN(override)) return override;
371
+ try {
372
+ const router = await Underpost.deploy.routerFactory(deployId, env);
373
+ const { fromPort } = deployRangePortFactory(router);
374
+ if (Number.isFinite(fromPort) && fromPort > 0) return fromPort - 1;
375
+ } catch (_) {
376
+ /* fall through to ambient resolution */
377
+ }
378
+ return deployStatusPort(deployId, env) ?? 3000;
379
+ },
380
+ /**
381
+ * Reads Phase-2 runtime status from a single pod using the selected transport.
382
+ *
383
+ * - `exec` (default): `kubectl exec … underpost config get container-status`
384
+ * reads the env-file value. Synchronous, no background process — required
385
+ * for custom instances (cyberia-server/client) and the safe choice for
386
+ * CI/SSH. See `Deploy custom instance to K8S.md`.
387
+ * - `http`: port-forward to the in-pod `/_internal/status` endpoint served
388
+ * by the `underpost start` launcher (dd-* runtime deploys). Opt-in.
389
+ *
390
+ * Transport failures are reported as `{ ok: false }` and must never be read
391
+ * as success — they are retried, not promoted.
392
+ *
393
+ * @param {string} podName
394
+ * @param {string} namespace
395
+ * @param {number} internalPort
396
+ * @param {('http'|'exec')} [transport='exec']
397
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
398
+ * @memberof UnderpostMonitor
399
+ */
400
+ async readRuntimeStatus(podName, namespace, internalPort, transport = 'exec') {
401
+ return transport === 'exec'
402
+ ? Underpost.monitor.readRuntimeStatusViaExec(podName, namespace)
403
+ : Underpost.monitor.readRuntimeStatusViaHttp(podName, namespace, internalPort);
404
+ },
405
+ /**
406
+ * Phase-2 read over `kubectl exec` (env-file transport). Works for any pod
407
+ * whose image bakes the underpost CLI — notably custom instances that stamp
408
+ * `container-status` from `lifecycle.postStart`/`preStop` hooks.
409
+ * @param {string} podName
410
+ * @param {string} namespace
411
+ * @returns {{ok: boolean, status?: (string|null), transportError?: string}}
412
+ * @memberof UnderpostMonitor
413
+ */
414
+ readRuntimeStatusViaExec(podName, namespace) {
415
+ try {
416
+ const raw = shellExec(
417
+ `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
418
+ { silent: true, disableLog: true, stdout: true, silentOnError: true },
419
+ );
420
+ const status = normalizeContainerStatus(raw ? raw.toString().trim() : '');
421
+ return status === undefined ? { ok: false, transportError: 'empty_status' } : { ok: true, status };
422
+ } catch (error) {
423
+ return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
424
+ }
425
+ },
426
+ /**
427
+ * Phase-2 read over `kubectl port-forward` + HTTP `/_internal/status`.
428
+ *
429
+ * The local side of the tunnel MUST be an ephemeral free port: pinning it to
430
+ * internalPort collides with any host-local service on that number (e.g. a
431
+ * dev runtime on the same machine as the cluster), making port-forward fail
432
+ * to bind and every read return a false transport error.
433
+ *
434
+ * @param {string} podName
435
+ * @param {string} namespace
436
+ * @param {number} internalPort
437
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
438
+ * @memberof UnderpostMonitor
439
+ */
440
+ async readRuntimeStatusViaHttp(podName, namespace, internalPort) {
441
+ const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
442
+ const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
443
+ const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
444
+ let portForward;
445
+ try {
446
+ // `exec` makes the tracked child the sudo/kubectl process (so kill
447
+ // reaches it); stdio is redirected to /dev/null so the tunnel never
448
+ // inherits — and therefore never holds open — a CI/SSH session's pipes,
449
+ // which would hang the job after a successful deploy.
450
+ portForward = shellExec(
451
+ `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace} </dev/null >/dev/null 2>&1`,
452
+ { async: true, silent: true, disableLog: true, silentOnError: true },
453
+ );
454
+ } catch (_) {
455
+ portForward = undefined;
456
+ }
457
+ try {
458
+ let lastError;
459
+ const attempts = parseInt(process.env.UNDERPOST_PF_ATTEMPTS) || 20;
460
+ for (let attempt = 0; attempt < attempts; attempt++) {
461
+ try {
462
+ const res = await axios.get(url, { timeout: 2500 });
463
+ const raw = res?.data?.status ?? null;
464
+ return { ok: true, status: normalizeContainerStatus(raw) ?? raw, payload: res.data };
465
+ } catch (error) {
466
+ lastError = error;
467
+ await timer(350);
468
+ }
469
+ }
470
+ return { ok: false, transportError: lastError?.code || lastError?.message || 'transport_failed' };
471
+ } finally {
472
+ if (portForward && typeof portForward.kill === 'function') {
473
+ try {
474
+ portForward.kill('SIGTERM');
475
+ } catch (_) {
476
+ /* tunnel already gone */
477
+ }
478
+ }
479
+ }
480
+ },
481
+ /**
482
+ * Monitors a deployment to terminal readiness using a deterministic
483
+ * two-phase state machine.
484
+ *
485
+ * Phase 1 (Kubernetes): pod `Ready` condition via `checkDeploymentReadyStatus`.
486
+ * Phase 2 (Runtime): `container-status`, read via the selected transport.
487
+ *
488
+ * Two deployment shapes are supported via `options`:
489
+ * - `runtime` gate (default, dd-* deploys): the `underpost start` launcher
490
+ * stamps `running-deployment`. Success requires K8S Ready AND every pod
491
+ * reporting `running-deployment`.
492
+ * - `kubernetes` gate (custom instances, e.g. cyberia): the runtime is a
493
+ * bare binary; K8S `readinessProbe` (TCP) IS the running signal and
494
+ * `container-status` is stamped to `initializing`/`stopping` by lifecycle
495
+ * hooks. Success requires K8S Ready; the status read is used only for
496
+ * fast `error` detection and display.
497
+ *
498
+ * Phase-2 transport defaults to `exec` (`kubectl exec`, no background
499
+ * process). The `http` transport (`kubectl port-forward` → `/_internal/status`)
500
+ * is opt-in via `options.statusTransport='http'` or
501
+ * `UNDERPOST_STATUS_TRANSPORT=http`; it must not be used in CI/SSH sessions
502
+ * where a stray tunnel can hang the job.
503
+ *
504
+ * Contract (both shapes):
505
+ * - Runtime readiness is never declared before Kubernetes readiness.
506
+ * - An explicit runtime `error` (or a fatal pod status) transitions
507
+ * immediately to `failed` (throw → CD exit 1).
508
+ * - Transport failures never count as success and never advance state.
509
+ * - `timeout` is a distinct terminal state from `failed`.
510
+ * - Every transition emits a structured, secret-free event.
511
+ *
512
+ * @param {string} deployId - Deployment ID for which the ready status is being monitored.
513
+ * @param {string} env - Environment for which the ready status is being monitored.
514
+ * @param {string} targetTraffic - Target traffic status for the deployment.
515
+ * @param {Array<string>} ignorePods - List of pod names to ignore.
516
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
517
+ * @param {object} [options] - Monitoring shape.
518
+ * @param {('runtime'|'kubernetes')} [options.readyGate='runtime'] - Running-signal owner.
519
+ * @param {('http'|'exec')} [options.statusTransport='http'] - Phase-2 read transport.
520
+ * @returns {object} - Object containing the ready status of the deployment.
521
+ * @memberof UnderpostMonitor
522
+ */
523
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', options = {}) {
524
+ const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
525
+ const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
526
+ const deploymentId = `${deployId}-${env}-${targetTraffic}`;
527
+ const tag = `[${deploymentId}]`;
528
+ const expectedStatus = RUNTIME_STATUS.RUNNING;
529
+ const readyGate = options.readyGate === 'kubernetes' ? 'kubernetes' : 'runtime';
530
+ // Default to `exec`: a single synchronous `kubectl exec` read leaves no
531
+ // background process behind. The `http` transport spawns `kubectl
532
+ // port-forward` children that, if orphaned, inherit a CI/SSH session's
533
+ // stdio and hang the job after a successful deploy — opt in explicitly.
534
+ const statusTransport =
535
+ (options.statusTransport || process.env.UNDERPOST_STATUS_TRANSPORT) === 'http' ? 'http' : 'exec';
536
+ const internalPort =
537
+ statusTransport === 'http' ? await Underpost.monitor.deployInternalPort(deployId, env) : null;
538
+ const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
539
+
540
+ const emit = (state, status) =>
541
+ logger.info('deploy-monitor', {
542
+ deployId: deploymentId,
543
+ phase: state.startsWith('runtime') ? 'runtime' : 'kubernetes',
544
+ state,
545
+ status: status ?? null,
546
+ timestamp: new Date().toISOString(),
547
+ });
548
+
549
+ logger.info('Deployment init', {
550
+ deployId,
551
+ env,
552
+ targetTraffic,
553
+ namespace,
554
+ internalPort,
555
+ readyGate,
556
+ statusTransport,
557
+ });
558
+ emit('pending');
559
+
560
+ const runtimeStatusCache = new Map();
561
+ const advancedPods = new Set();
562
+
563
+ for (let i = 0; i < maxIterations; i++) {
564
+ const result = await Underpost.monitor.checkDeploymentReadyStatus(
565
+ deployId,
566
+ env,
567
+ targetTraffic,
568
+ ignorePods,
569
+ namespace,
570
+ );
571
+ const allPods = [...result.readyPods, ...result.notReadyPods];
572
+
573
+ if (allPods.length === 0) {
574
+ emit('pending');
575
+ await timer(delayMs);
576
+ continue;
577
+ }
578
+ emit('pod_scheduled');
579
+
580
+ // Phase 1 fatal: a Kubernetes-level pod failure is terminal (failed,
581
+ // not timeout) — fail the CD runner immediately instead of waiting out
582
+ // the full window.
583
+ for (const pod of allPods) {
584
+ const podStatus = (pod.STATUS || '').toLowerCase().trim();
585
+ if (podErrorStates.find((s) => podStatus.includes(s)))
586
+ throw new Error(`Pod ${pod.NAME} has error pod status: ${pod.STATUS}`);
587
+ }
588
+
589
+ const allPodsK8sReady = result.notReadyPods.length === 0;
590
+ if (allPodsK8sReady) emit('pod_ready');
591
+
592
+ // Phase 2: runtime status via the selected transport. Transport failures
593
+ // neither advance state nor count as success; explicit `error` is terminal.
594
+ let allRuntimeRead = true;
595
+ for (const pod of allPods) {
596
+ if (!pod?.NAME) continue;
597
+ const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort, statusTransport);
598
+ if (!read.ok) {
599
+ allRuntimeRead = false;
600
+ emit('runtime_booting', `transport:${read.transportError}`);
601
+ continue;
602
+ }
603
+ const status = read.status;
604
+ if (status === RUNTIME_STATUS.ERROR) throw new Error(`Pod ${pod.NAME} reported runtime status=error`);
605
+ // Regression (advanced → empty/build) means a pod restarted. Under the
606
+ // kubernetes gate the runtime never advances past `initializing`, so
607
+ // only treat a drop to empty/build as a regression there.
608
+ if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
609
+ throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
610
+ if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
611
+ runtimeStatusCache.set(pod.NAME, status);
612
+ emit('runtime_booting', status);
613
+ }
614
+
615
+ // Under the kubernetes gate the readinessProbe is the running signal, so
616
+ // K8S Ready alone confirms Phase 2; the status read above is kept only
617
+ // for `error` fast-fail and display.
618
+ const allRuntimeReady =
619
+ readyGate === 'kubernetes'
620
+ ? true
621
+ : allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
622
+
623
+ for (const pod of allPods) {
624
+ const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
625
+ const podStatus = pod.STATUS || 'Unknown';
626
+ const statusDisplay = status === expectedStatus ? status : `${status} (pending)`;
627
+ console.log(
628
+ 'Target pod:',
629
+ pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
630
+ '| Pod status:',
631
+ podStatus.bold.yellow,
632
+ '| Runtime status:',
633
+ statusDisplay.bold.cyan,
634
+ );
635
+ }
636
+
637
+ // Terminal success requires both phases. runtime_ready cannot precede
638
+ // Kubernetes readiness.
639
+ if (allPodsK8sReady && allRuntimeReady) {
640
+ const readySignal = readyGate === 'kubernetes' ? 'K8S readinessProbe' : `runtime ${expectedStatus}`;
641
+ emit('runtime_ready', readyGate === 'kubernetes' ? 'k8s-ready' : expectedStatus);
642
+ logger.info(`${tag} | Deployment ready (K8S Ready + ${readySignal})`);
643
+ return result;
644
+ }
645
+
646
+ await timer(delayMs);
647
+ if ((i + 1) % 10 === 0) logger.info(`${tag} | In progress... iteration ${i + 1}`);
648
+ }
649
+
650
+ emit('timeout');
651
+ logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
652
+ throw new Error(
653
+ `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
654
+ );
655
+ },
269
656
  };
270
657
  }
271
658