cyberia 3.2.12 → 3.2.70

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 (194) hide show
  1. package/.env.example +127 -68
  2. package/.github/workflows/cyberia-client.cd.yml +40 -0
  3. package/.github/workflows/cyberia-server.cd.yml +40 -0
  4. package/.github/workflows/docker-image.cyberia-client.ci.yml +49 -0
  5. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +48 -0
  6. package/.github/workflows/docker-image.cyberia-server.ci.yml +69 -0
  7. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +69 -0
  8. package/.github/workflows/docker-image.engine-cyberia.ci.yml +52 -0
  9. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +52 -0
  10. package/.github/workflows/engine-cyberia.cd.yml +34 -24
  11. package/.github/workflows/engine-cyberia.ci.yml +27 -5
  12. package/.github/workflows/ghpkg.ci.yml +89 -1
  13. package/.github/workflows/gitlab.ci.yml +1 -1
  14. package/.github/workflows/hardhat.ci.yml +1 -1
  15. package/.github/workflows/npmpkg.ci.yml +19 -12
  16. package/.github/workflows/publish.ci.yml +2 -2
  17. package/.github/workflows/publish.cyberia.ci.yml +5 -16
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -8
  19. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  20. package/CHANGELOG.md +451 -1
  21. package/CLI-HELP.md +1040 -1130
  22. package/Dockerfile +141 -43
  23. package/Dockerfile.dev +143 -0
  24. package/Dockerfile.test +165 -0
  25. package/README.md +1 -1
  26. package/baremetal/commission-workflows.json +1 -0
  27. package/bin/build.js +128 -136
  28. package/bin/build.template.js +25 -179
  29. package/bin/cyberia.js +1089 -188
  30. package/bin/deploy.js +6 -3
  31. package/bin/index.js +1089 -188
  32. package/bump.config.js +1 -0
  33. package/compose.env +131 -0
  34. package/conf.js +19 -1
  35. package/deployment.yaml +74 -2
  36. package/docker-compose.yml +316 -0
  37. package/hardhat/hardhat.config.js +2 -2
  38. package/hardhat/package-lock.json +620 -2041
  39. package/hardhat/package.json +7 -5
  40. package/hardhat/scripts/deployObjectLayerToken.js +18 -18
  41. package/hardhat/test/ObjectLayerToken.js +378 -274
  42. package/ipfs/configure-ipfs.sh +13 -0
  43. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  44. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  45. package/manifests/deployment/dd-cyberia-development/deployment.yaml +74 -2
  46. package/manifests/deployment/dd-cyberia-development/grpc-service.yaml +17 -0
  47. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +32 -0
  48. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  49. package/mongodb/entrypoint.sh +76 -0
  50. package/nginx.conf +87 -0
  51. package/package.json +34 -22
  52. package/pv-pvc.yaml +32 -0
  53. package/scripts/disk-clean.sh +85 -60
  54. package/scripts/kubeadm-node-setup.sh +317 -0
  55. package/scripts/link-local-underpost-cli.sh +6 -0
  56. package/scripts/rocky-kickstart.sh +877 -185
  57. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  58. package/scripts/test-monitor.sh +248 -0
  59. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +43 -7
  60. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +17 -25
  61. package/src/api/cyberia-action/cyberia-action.controller.js +19 -0
  62. package/src/api/cyberia-action/cyberia-action.model.js +21 -29
  63. package/src/api/cyberia-action/cyberia-action.router.js +42 -7
  64. package/src/api/cyberia-action/cyberia-action.service.js +20 -4
  65. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +58 -57
  66. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +37 -6
  67. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +8 -2
  68. package/src/api/cyberia-entity/cyberia-entity.router.js +39 -7
  69. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +74 -0
  70. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +67 -0
  71. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +63 -0
  72. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +46 -0
  73. package/src/api/cyberia-instance/cyberia-fallback-world.js +62 -10
  74. package/src/api/cyberia-instance/cyberia-instance.model.js +32 -2
  75. package/src/api/cyberia-instance/cyberia-instance.router.js +6 -6
  76. package/src/api/cyberia-instance/cyberia-world-generator.js +116 -3
  77. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +3 -0
  78. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +39 -7
  79. package/src/api/cyberia-map/cyberia-map.router.js +21 -6
  80. package/src/api/cyberia-quest/cyberia-quest.controller.js +38 -0
  81. package/src/api/cyberia-quest/cyberia-quest.router.js +47 -7
  82. package/src/api/cyberia-quest/cyberia-quest.service.js +59 -4
  83. package/src/api/cyberia-saga/cyberia-saga.controller.js +74 -0
  84. package/src/api/cyberia-saga/cyberia-saga.model.js +59 -0
  85. package/src/api/cyberia-saga/cyberia-saga.router.js +63 -0
  86. package/src/api/cyberia-saga/cyberia-saga.service.js +42 -0
  87. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +551 -122
  88. package/src/api/cyberia-skill/cyberia-skill.controller.js +74 -0
  89. package/src/api/cyberia-skill/cyberia-skill.model.js +50 -0
  90. package/src/api/cyberia-skill/cyberia-skill.router.js +63 -0
  91. package/src/api/cyberia-skill/cyberia-skill.service.js +42 -0
  92. package/src/api/ipfs/ipfs.service.js +28 -15
  93. package/src/api/object-layer/object-layer.router.js +25 -15
  94. package/src/api/object-layer/object-layer.service.js +15 -20
  95. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +39 -7
  96. package/src/cli/baremetal.js +717 -16
  97. package/src/cli/cluster.js +80 -5
  98. package/src/cli/deploy.js +326 -292
  99. package/src/cli/docker-compose.js +648 -0
  100. package/src/cli/env.js +12 -6
  101. package/src/cli/fs.js +75 -30
  102. package/src/cli/image.js +187 -55
  103. package/src/cli/index.js +157 -6
  104. package/src/cli/kickstart.js +142 -20
  105. package/src/cli/monitor.js +387 -6
  106. package/src/cli/release.js +72 -15
  107. package/src/cli/repository.js +339 -40
  108. package/src/cli/run.js +678 -186
  109. package/src/cli/secrets.js +82 -48
  110. package/src/cli/ssh.js +234 -0
  111. package/src/cli/static.js +2 -2
  112. package/src/client/components/core/PanelForm.js +44 -44
  113. package/src/client/components/cyberia/ActionEngineCyberia.js +1867 -0
  114. package/src/client/components/cyberia/EntityEngineCyberia.js +585 -0
  115. package/src/client/components/cyberia/InstanceEngineCyberia.js +219 -13
  116. package/src/client/components/cyberia/MapEngineCyberia.js +154 -71
  117. package/src/client/components/cyberia/ObjectLayerEngineModal.js +40 -63
  118. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +34 -20
  119. package/src/client/components/cyberia/SharedDefaultsCyberia.js +195 -4
  120. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +66 -0
  121. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +8 -0
  122. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +8 -0
  123. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +92 -20
  124. package/src/client/public/cyberia-docs/ARCHITECTURE.md +272 -50
  125. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +3 -3
  126. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +39 -22
  127. package/src/client/public/cyberia-docs/CYBERIA-LORE.md +88 -0
  128. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +394 -0
  129. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +28 -12
  130. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  131. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +27 -5
  132. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  133. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  134. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +99 -0
  135. package/src/client/services/cyberia-instance/cyberia-instance.management.js +2 -2
  136. package/src/client/services/cyberia-map/cyberia-map.management.js +2 -2
  137. package/src/client/services/cyberia-saga/cyberia-saga.service.js +99 -0
  138. package/src/client/services/cyberia-skill/cyberia-skill.service.js +99 -0
  139. package/src/client/services/object-layer/object-layer.management.js +6 -6
  140. package/src/{server → client-builder}/client-build-docs.js +15 -5
  141. package/src/{server → client-builder}/client-build-live.js +3 -3
  142. package/src/{server → client-builder}/client-build.js +25 -22
  143. package/src/{server → client-builder}/client-dev-server.js +3 -3
  144. package/src/{server → client-builder}/client-icons.js +2 -2
  145. package/src/{server → client-builder}/ssr.js +5 -5
  146. package/src/client.build.js +1 -3
  147. package/src/client.dev.js +1 -1
  148. package/src/db/mongo/MongoBootstrap.js +12 -12
  149. package/src/db/mongo/MongooseDB.js +2 -1
  150. package/src/grpc/cyberia/grpc-server.js +255 -70
  151. package/src/index.js +12 -1
  152. package/src/mailer/EmailRender.js +1 -1
  153. package/src/{server → projects/cyberia}/atlas-sprite-sheet-generator.js +2 -2
  154. package/src/{server → projects/cyberia}/besu-genesis-generator.js +3 -3
  155. package/src/projects/cyberia/catalog-cyberia.js +81 -0
  156. package/src/projects/cyberia/gemini-client.js +175 -0
  157. package/src/projects/cyberia/generate-saga.js +2107 -0
  158. package/src/{server → projects/cyberia}/ipfs-client.js +6 -4
  159. package/src/{server → projects/cyberia}/object-layer.js +12 -108
  160. package/src/{server → projects/cyberia}/semantic-layer-generator-floor.js +1 -1
  161. package/src/{server → projects/cyberia}/semantic-layer-generator-resource.js +1 -1
  162. package/src/{server → projects/cyberia}/semantic-layer-generator-skin.js +1 -1
  163. package/src/{server → projects/cyberia}/semantic-layer-generator.js +2 -2
  164. package/src/{server → projects/cyberia}/shape-generator.js +2 -2
  165. package/src/projects/underpost/catalog-underpost.js +60 -0
  166. package/src/runtime/cyberia-client/Dockerfile +31 -83
  167. package/src/runtime/cyberia-client/Dockerfile.dev +23 -30
  168. package/src/runtime/cyberia-server/Dockerfile +23 -42
  169. package/src/runtime/cyberia-server/Dockerfile.dev +20 -35
  170. package/src/runtime/engine-cyberia/Dockerfile +143 -0
  171. package/src/runtime/engine-cyberia/Dockerfile.dev +143 -0
  172. package/src/runtime/engine-cyberia/Dockerfile.test +165 -0
  173. package/src/runtime/engine-cyberia/compose.env +131 -0
  174. package/src/runtime/engine-cyberia/docker-compose.yml +316 -0
  175. package/src/runtime/engine-cyberia/ipfs/configure-ipfs.sh +13 -0
  176. package/src/runtime/engine-cyberia/mongodb/entrypoint.sh +76 -0
  177. package/src/runtime/engine-cyberia/nginx.conf +87 -0
  178. package/src/runtime/express/Express.js +2 -2
  179. package/src/runtime/nginx/Nginx.js +250 -0
  180. package/src/runtime/wp/Dockerfile +3 -3
  181. package/src/server/catalog.js +72 -0
  182. package/src/server/conf.js +415 -60
  183. package/src/server/runtime-status.js +252 -0
  184. package/src/server/start.js +42 -11
  185. package/src/server.js +6 -2
  186. package/test/cyberia-instance-conf-defaults.test.js +140 -0
  187. package/test/deploy-monitor.test.js +267 -0
  188. package/test/shape-generator.test.js +7 -1
  189. package/typedoc.dd-cyberia.json +3 -1
  190. package/typedoc.json +3 -1
  191. package/manifests/deployment/dd-test-development/deployment.yaml +0 -256
  192. package/manifests/deployment/dd-test-development/proxy.yaml +0 -102
  193. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  194. /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/deploy.js CHANGED
@@ -18,9 +18,9 @@ import {
18
18
  } from '../server/conf.js';
19
19
  import { loggerFactory } from '../server/logger.js';
20
20
  import { shellExec } from '../server/process.js';
21
+ import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-status.js';
21
22
  import fs from 'fs-extra';
22
23
  import dotenv from 'dotenv';
23
- import { timer } from '../client/components/core/CommonJs.js';
24
24
  import os from 'node:os';
25
25
  import Underpost from '../index.js';
26
26
 
@@ -114,6 +114,64 @@ class UnderpostDeploy {
114
114
  )
115
115
  .join('')}`;
116
116
  },
117
+ /**
118
+ * Builds Kubernetes probes that gate on the in-pod internal status endpoint.
119
+ *
120
+ * HTTP mode (default) aligns Kubernetes pod readiness with actual Underpost
121
+ * runtime readiness:
122
+ * - readinessProbe → GET /_internal/ready (200 only when running-deployment)
123
+ * - livenessProbe → GET /_internal/health (deadlock / hung-process detection)
124
+ * - startupProbe → GET /_internal/ready (long window for hot-built/slow boots)
125
+ *
126
+ * Migration: pass `useHttp: false` to emit the legacy TCP socket probes
127
+ * (port-bound only) for deployments not yet serving the internal endpoint.
128
+ *
129
+ * @param {object} opts
130
+ * @param {number} opts.port - In-pod internal status port (deployment base PORT).
131
+ * @param {boolean} [opts.useHttp=true] - Emit HTTP probes; false → legacy TCP.
132
+ * @param {boolean} [opts.liveness=true] - Include a livenessProbe.
133
+ * @param {boolean} [opts.startup=true] - Include a startupProbe.
134
+ * @returns {{readinessProbe: object, livenessProbe?: object, startupProbe?: object}}
135
+ * @memberof UnderpostDeploy
136
+ */
137
+ runtimeProbesFactory({ port, useHttp = true, liveness = true, startup = true } = {}) {
138
+ if (!port) return {};
139
+ if (!useHttp) {
140
+ const tcp = { tcpSocket: { port }, initialDelaySeconds: 5, periodSeconds: 10, failureThreshold: 6 };
141
+ const probes = { readinessProbe: tcp };
142
+ if (liveness) probes.livenessProbe = { ...tcp, initialDelaySeconds: 30 };
143
+ return probes;
144
+ }
145
+ const probes = {
146
+ readinessProbe: {
147
+ httpGet: { path: INTERNAL_READY_PATH, port },
148
+ initialDelaySeconds: 5,
149
+ periodSeconds: 5,
150
+ timeoutSeconds: 3,
151
+ failureThreshold: 3,
152
+ },
153
+ };
154
+ if (liveness)
155
+ probes.livenessProbe = {
156
+ httpGet: { path: INTERNAL_HEALTH_PATH, port },
157
+ initialDelaySeconds: 30,
158
+ periodSeconds: 15,
159
+ timeoutSeconds: 3,
160
+ failureThreshold: 3,
161
+ };
162
+ if (startup)
163
+ // A startupProbe suspends readiness/liveness until it first succeeds, so
164
+ // its window bounds in-container hot builds and slow boots. 180 × 10s =
165
+ // 30 min before the pod is considered failed to start.
166
+ probes.startupProbe = {
167
+ httpGet: { path: INTERNAL_READY_PATH, port },
168
+ initialDelaySeconds: 10,
169
+ periodSeconds: 10,
170
+ timeoutSeconds: 3,
171
+ failureThreshold: 180,
172
+ };
173
+ return probes;
174
+ },
117
175
  /**
118
176
  * Creates a YAML deployment configuration for a deployment.
119
177
  * @param {string} deployId - Deployment ID for which the deployment is being created.
@@ -128,6 +186,11 @@ class UnderpostDeploy {
128
186
  * @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
129
187
  * @param {boolean} pullBundle - Whether to pull the pre-built client bundle from Cloudinary before starting. Use together with skipFullBuild to skip the local build entirely.
130
188
  * @param {string} [imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`). When omitted, defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
189
+ * @param {object} lifecycle - Kubernetes lifecycle hooks configuration for the deployment container.
190
+ * @param {object} readinessProbe - Kubernetes readiness probe configuration for the deployment container.
191
+ * @param {object} livenessProbe - Kubernetes liveness probe configuration for the deployment container.
192
+ * @param {object} startupProbe - Kubernetes startup probe configuration for the deployment container.
193
+ * @param {number} containerPort - Container port to expose for the deployment.
131
194
  * @returns {string} - YAML deployment configuration for the specified deployment.
132
195
  * @memberof UnderpostDeploy
133
196
  */
@@ -153,7 +216,12 @@ class UnderpostDeploy {
153
216
  lifecycle,
154
217
  readinessProbe,
155
218
  livenessProbe,
219
+ startupProbe,
156
220
  containerPort,
221
+ // Explicit, secret-free internal status port injected as an env var so the
222
+ // in-pod endpoint binds exactly what the probes and the monitor target,
223
+ // independent of the ambient `PORT` baked into the image/secret.
224
+ internalStatusPort,
157
225
  }) {
158
226
  if (!cmd)
159
227
  cmd =
@@ -205,12 +273,19 @@ spec:
205
273
  - secretRef:
206
274
  name: underpost-config
207
275
  ${
208
- containerPort
209
- ? ` ports:
210
- - containerPort: ${containerPort}
276
+ internalStatusPort
277
+ ? ` env:
278
+ - name: UNDERPOST_INTERNAL_PORT
279
+ value: "${internalStatusPort}"
211
280
  `
212
281
  : ''
213
282
  }${
283
+ containerPort
284
+ ? ` ports:
285
+ - containerPort: ${containerPort}
286
+ `
287
+ : ''
288
+ }${
214
289
  resources
215
290
  ? ` resources:
216
291
  requests:
@@ -242,6 +317,15 @@ ${JSON.stringify(livenessProbe, null, 2)
242
317
  .split('\n')
243
318
  .map((l) => ' ' + l)
244
319
  .join('\n')}
320
+ `
321
+ : ''
322
+ }${
323
+ startupProbe
324
+ ? ` startupProbe:
325
+ ${JSON.stringify(startupProbe, null, 2)
326
+ .split('\n')
327
+ .map((l) => ' ' + l)
328
+ .join('\n')}
245
329
  `
246
330
  : ''
247
331
  }${
@@ -283,18 +367,26 @@ spec:
283
367
  * @param {object} options - Options for the manifest build process.
284
368
  * @param {string} options.replicas - Number of replicas for each deployment.
285
369
  * @param {string} options.image - Docker image for the deployment.
286
- * @param {string} options.namespace - Kubernetes namespace for the deployment.
370
+ * @param {string} options.namespace - Kubernetes namespace for the deployment (defaults to "default").
287
371
  * @param {string} [options.versions] - Comma-separated list of versions to deploy.
288
372
  * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
289
- * @param {string} [options.timeoutResponse] - Timeout response setting for the deployment.
290
- * @param {string} [options.timeoutIdle] - Timeout idle setting for the deployment.
291
- * @param {string} [options.retryCount] - Retry count setting for the deployment.
292
- * @param {string} [options.retryPerTryTimeout] - Retry per-try timeout setting for the deployment.
293
- * @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy.
294
- * @param {string} [options.traffic] - Traffic status for the deployment.
295
- * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory to generate a pull-bundle startup command.
373
+ * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
374
+ * @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
375
+ * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
376
+ * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
377
+ * @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy route generation.
378
+ * @param {string} [options.traffic] - Comma-separated active traffic colour(s) used to select which versions receive traffic (e.g. "blue", "green").
379
+ * @param {boolean} [options.cert] - Whether to include cert-manager Certificate resources in secret.yaml (production only).
380
+ * @param {boolean} [options.selfSigned] - Whether to include TLS block in HTTPProxy using a pre-created self-signed secret. Enables HTTPS for development without cert-manager.
381
+ * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory.
296
382
  * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
297
- * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. When omitted, the builder defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
383
+ * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
384
+ * @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
385
+ * @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
386
+ * @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
387
+ * @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
388
+ * @param {boolean} [options.kubeadm] - Kubeadm cluster context; affects the cluster-type node default when no explicit node is set.
389
+ * @param {boolean} [options.k3s] - K3s cluster context; affects the cluster-type node default when no explicit node is set.
298
390
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
299
391
  * @memberof UnderpostDeploy
300
392
  */
@@ -319,6 +411,17 @@ spec:
319
411
 
320
412
  logger.info('port range', { deployId, fromPort, toPort });
321
413
 
414
+ // The internal status endpoint binds `fromPort - 1`: app instances bind
415
+ // the router range starting at `fromPort`, so this slot is always free
416
+ // inside the pod. It is injected into the pod env (UNDERPOST_INTERNAL_PORT)
417
+ // and used for both the probes and the monitor's port-forward target so
418
+ // all three agree regardless of the image's ambient PORT.
419
+ // Opt out with `--disable-runtime-probes` to keep legacy probe-less pods.
420
+ const internalPort = fromPort - 1;
421
+ const probes = options.disableRuntimeProbes
422
+ ? {}
423
+ : Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
424
+
322
425
  let deploymentYamlParts = '';
323
426
  for (const deploymentVersion of deploymentVersions) {
324
427
  deploymentYamlParts += `---
@@ -334,6 +437,10 @@ ${Underpost.deploy
334
437
  skipFullBuild: options.skipFullBuild,
335
438
  pullBundle: options.pullBundle,
336
439
  imagePullPolicy: options.imagePullPolicy,
440
+ internalStatusPort: options.disableRuntimeProbes ? undefined : internalPort,
441
+ readinessProbe: probes.readinessProbe,
442
+ livenessProbe: probes.livenessProbe,
443
+ startupProbe: probes.startupProbe,
337
444
  })
338
445
  .replace('{{ports}}', buildKindPorts(fromPort, toPort))}
339
446
  `;
@@ -352,6 +459,15 @@ ${Underpost.deploy
352
459
  ? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
353
460
  : [];
354
461
  if (confVolume.length > 0) {
462
+ // Mirror deployVolume's data-node resolution so the generated manifest
463
+ // pins the PV to the same node that physically receives the volume data.
464
+ const pvDataNode = Underpost.deploy.resolveDeployNode({
465
+ node: options.node,
466
+ kind: options.kind,
467
+ kubeadm: options.kubeadm,
468
+ k3s: options.k3s,
469
+ env,
470
+ });
355
471
  let volumeYaml = '';
356
472
  for (const deploymentVersion of deploymentVersions) {
357
473
  for (const volume of confVolume) {
@@ -363,6 +479,7 @@ ${Underpost.deploy
363
479
  pvcId,
364
480
  namespace: options.namespace,
365
481
  hostPath,
482
+ nodeName: pvDataNode,
366
483
  })}\n`;
367
484
  }
368
485
  }
@@ -376,7 +493,7 @@ ${Underpost.deploy
376
493
  : [];
377
494
 
378
495
  for (const host of Object.keys(confServer)) {
379
- if (env === 'production')
496
+ if (env === 'production' && options.cert === true)
380
497
  secretYaml += Underpost.deploy.buildCertManagerCertificate({ host, namespace: options.namespace });
381
498
 
382
499
  const pathPortAssignment = pathPortAssignmentData[host];
@@ -445,7 +562,7 @@ ${Underpost.deploy
445
562
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
446
563
  fs.writeFileSync(yamlPath, secretYaml, 'utf8');
447
564
  } else {
448
- const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml'];
565
+ const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
449
566
  for (const file of deploymentsFiles) {
450
567
  if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
451
568
  fs.copyFileSync(
@@ -579,6 +696,7 @@ spec:
579
696
  * @memberof UnderpostDeploy
580
697
  */
581
698
  baseProxyYamlFactory({ host, env, options }) {
699
+ const includeTls = env !== 'development' || options.selfSigned === true;
582
700
  return `
583
701
  ---
584
702
  apiVersion: projectcontour.io/v1
@@ -589,11 +707,11 @@ metadata:
589
707
  spec:
590
708
  virtualhost:
591
709
  fqdn: ${host}${
592
- env === 'development'
593
- ? ''
594
- : `
710
+ includeTls
711
+ ? `
595
712
  tls:
596
713
  secretName: ${host}`
714
+ : ''
597
715
  }
598
716
  routes:`;
599
717
  },
@@ -609,35 +727,42 @@ spec:
609
727
  * @param {boolean} options.buildManifest - Whether to build the deployment manifest.
610
728
  * @param {boolean} options.infoUtil - Whether to display utility information.
611
729
  * @param {boolean} options.expose - Whether to expose the deployment.
612
- * @param {boolean} options.cert - Whether to create certificates for the deployment.
613
- * @param {string} options.certHosts - Comma-separated list of hosts for which to create certificates.
730
+ * @param {boolean} options.cert - Whether to create cert-manager Certificate resources for the deployment.
731
+ * @param {string} options.certHosts - Comma-separated list of hosts for which to create cert-manager certificates.
732
+ * @param {boolean} options.selfSigned - Use a pre-created self-signed TLS secret instead of cert-manager. The secret must already exist in the namespace with the same name as the host. Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.
614
733
  * @param {string} options.versions - Comma-separated list of versions to deploy.
615
734
  * @param {string} options.image - Docker image for the deployment.
616
735
  * @param {string} options.traffic - Traffic status for the deployment.
617
736
  * @param {string} options.replicas - Number of replicas for the deployment.
618
- * @param {string} options.node - Node name for resource allocation.
737
+ * @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
738
+ * @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
619
739
  * @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
620
740
  * @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
621
741
  * @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
622
742
  * @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
623
743
  * @param {boolean} options.status - Whether to display deployment status.
624
744
  * @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
625
- * @param {string} [options.namespace] - Kubernetes namespace for the deployment.
626
- * @param {string} [options.timeoutResponse] - Timeout response setting for the deployment.
627
- * @param {string} [options.timeoutIdle] - Timeout idle setting for the deployment.
628
- * @param {string} [options.retryCount] - Retry count setting for the deployment.
629
- * @param {string} [options.retryPerTryTimeout] - Retry per-try timeout setting for the deployment.
630
- * @param {string} [options.kindType] - Type of Kubernetes resource to retrieve information for.
631
- * @param {number} [options.port] - Port number for exposing the deployment.
632
- * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
633
- * @param {number} [options.exposePort] - Local:remote port override when --expose is active (overrides auto-detected service port).
745
+ * @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
746
+ * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
747
+ * @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
748
+ * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
749
+ * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
750
+ * @param {string} [options.kindType] - Kubernetes resource kind to target when using --expose (defaults to "svc").
751
+ * @param {number} [options.port] - Port number override for exposing the deployment.
752
+ * @param {string} [options.cmd] - Custom initialization command (comma-separated) for deploymentYamlPartsFactory.
753
+ * @param {number} [options.exposePort] - Remote port override when --expose is active (overrides auto-detected service port). Used as both local and remote port unless exposeLocalPort is also set.
754
+ * @param {number} [options.exposeLocalPort] - Local port override for --expose (e.g. 80); remote port is still auto-detected. Enables /etc/hosts access without a port in the browser URL.
755
+ * @param {boolean} [options.localProxy] - When true (with --expose), forward all service TCP ports locally and start the Node.js path-routing proxy for full path-based routing (e.g. /wp alongside /).
756
+ * @param {boolean} [options.tls] - When true (with --expose --local-proxy), start the proxy on port 443 with TLS using self-signed certificates resolved from the local SSL store.
634
757
  * @param {boolean} [options.k3s] - Whether to use k3s cluster context.
635
758
  * @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
636
759
  * @param {boolean} [options.kind] - Whether to use kind cluster context.
637
760
  * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
638
761
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
639
762
  * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
640
- * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); passed through to buildManifest/deploymentYamlPartsFactory. When omitted, the builder defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
763
+ * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); passed through to buildManifest/deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
764
+ * @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
765
+ * @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes.
641
766
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
642
767
  * @memberof UnderpostDeploy
643
768
  */
@@ -672,6 +797,10 @@ spec:
672
797
  kindType: '',
673
798
  port: 0,
674
799
  exposePort: 0,
800
+ exposeLocalPort: 0,
801
+ localProxy: false,
802
+ tls: false,
803
+ selfSigned: false,
675
804
  cmd: '',
676
805
  k3s: false,
677
806
  kubeadm: false,
@@ -757,20 +886,50 @@ EOF`);
757
886
  logger.error(`No ${kindType} found matching '${deployId}', skipping expose`);
758
887
  continue;
759
888
  }
760
- const port = options.exposePort
761
- ? parseInt(options.exposePort)
762
- : options.port
763
- ? parseInt(options.port)
764
- : kindType !== 'svc'
765
- ? 80
766
- : parseInt(svc[`PORT(S)`].split('/TCP')[0]);
767
- logger.info(deployId, {
768
- svc,
769
- port,
770
- });
771
- shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${port}:${port}`, {
772
- async: true,
773
- });
889
+ if (options.localProxy) {
890
+ const svcPorts = [
891
+ ...new Set(
892
+ svc['PORT(S)']
893
+ .split(',')
894
+ .filter((p) => p.includes('/TCP'))
895
+ .map((p) => parseInt(p.split(':')[0])),
896
+ ),
897
+ ];
898
+ for (const svcPort of svcPorts) {
899
+ shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${svcPort}:${svcPort}`, {
900
+ async: true,
901
+ });
902
+ }
903
+ const envFile = `./engine-private/conf/${deployId}/.env.${env}`;
904
+ let basePort = svcPorts[0] - 1;
905
+ if (fs.existsSync(envFile)) {
906
+ const portMatch = fs.readFileSync(envFile, 'utf8').match(/^PORT=(\d+)/m);
907
+ if (portMatch) basePort = parseInt(portMatch[1]);
908
+ }
909
+ logger.info(deployId, { svc, svcPorts, basePort });
910
+ const tlsFlag = options.tls ? ' tls' : '';
911
+ shellExec(
912
+ `NODE_ENV=${env} PORT=${basePort} DEV_PROXY_PORT_OFFSET=0 node src/proxy proxy ${deployId} ${env}${tlsFlag}`,
913
+ { async: true },
914
+ );
915
+ } else {
916
+ const remotePort = options.exposePort
917
+ ? parseInt(options.exposePort)
918
+ : options.port
919
+ ? parseInt(options.port)
920
+ : kindType !== 'svc'
921
+ ? 80
922
+ : parseInt(svc[`PORT(S)`].split('/TCP')[0]);
923
+ const localPort = options.exposeLocalPort ? parseInt(options.exposeLocalPort) : remotePort;
924
+ logger.info(deployId, {
925
+ svc,
926
+ localPort,
927
+ remotePort,
928
+ });
929
+ shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${localPort}:${remotePort}`, {
930
+ async: true,
931
+ });
932
+ }
774
933
  continue;
775
934
  }
776
935
 
@@ -794,9 +953,16 @@ EOF`);
794
953
  env,
795
954
  version,
796
955
  namespace,
797
- nodeName: options.node ? options.node : env === 'development' ? 'kind-worker' : os.hostname(),
956
+ nodeName: Underpost.deploy.resolveDeployNode({
957
+ node: options.node,
958
+ kind: options.kind,
959
+ kubeadm: options.kubeadm,
960
+ k3s: options.k3s,
961
+ env,
962
+ }),
798
963
  clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
799
964
  gitClean: options.gitClean || false,
965
+ sshKeyPath: options.sshKeyPath || '',
800
966
  });
801
967
  }
802
968
 
@@ -822,63 +988,17 @@ EOF`);
822
988
  if (!options.disableUpdateProxy)
823
989
  shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
824
990
 
825
- if (Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }))
826
- shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml -n ${namespace}`);
827
- }
828
- }
829
- },
830
- /**
831
- * Checks the status of a deployment.
832
- * @param {string} deployId - Deployment ID for which the status is being checked.
833
- * @param {string} env - Environment for which the status is being checked.
834
- * @param {string} traffic - Current traffic status for the deployment.
835
- * @param {Array<string>} ignoresNames - List of pod names to ignore.
836
- * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
837
- * @returns {object} - Object containing the status of the deployment.
838
- * @memberof UnderpostDeploy
839
- */
840
- async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
841
- const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
842
- const readyPods = [];
843
- const notReadyPods = [];
844
-
845
- // Readiness signal: the pod's Kubernetes `Ready` condition driven by the
846
- // container's readinessProbe (TCP socket, HTTP get, or exec). Set by kubelet
847
- // when the probe passes. A failed or crashing runtime never becomes Ready —
848
- // kubelet surfaces CrashLoopBackOff and this gate stays closed.
849
- for (const pod of pods) {
850
- const { NAME } = pod;
851
- if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
852
-
853
- let podJson = null;
854
- try {
855
- // Pod may not exist yet (between deployment apply and pod
856
- // scheduling). silentOnError lets the monitor loop continue
857
- // instead of aborting on the transient NotFound exit.
858
- const raw = shellExec(`sudo kubectl get pod ${NAME} -n ${namespace} -o json`, {
859
- silent: true,
860
- disableLog: true,
861
- stdout: true,
862
- silentOnError: true,
863
- });
864
- podJson = raw ? JSON.parse(raw) : null;
865
- } catch (_) {
866
- podJson = null;
991
+ if (
992
+ Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }) &&
993
+ !options.selfSigned
994
+ ) {
995
+ const secretPath = `./${manifestsPath}/secret.yaml`;
996
+ if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
997
+ shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
998
+ } else logger.info('Skipping secret.yaml apply (no objects yet; applied by the --cert step)');
999
+ }
867
1000
  }
868
- const conditions = podJson?.status?.conditions || [];
869
- const readyCondition = conditions.find((c) => c.type === 'Ready');
870
- const k8sReady = readyCondition?.status === 'True';
871
-
872
- pod.out = JSON.stringify({ k8sReady, condition: readyCondition ?? null });
873
-
874
- if (k8sReady) readyPods.push(pod);
875
- else notReadyPods.push(pod);
876
1001
  }
877
- return {
878
- ready: pods.length > 0 && notReadyPods.length === 0,
879
- notReadyPods,
880
- readyPods,
881
- };
882
1002
  },
883
1003
  /**
884
1004
  * Creates a Kubernetes Secret for a deployment (replaces configMap for secret data).
@@ -889,10 +1009,17 @@ EOF`);
889
1009
  */
890
1010
  configMap(env, namespace = 'default') {
891
1011
  const cronDeployId = cronDeployIdResolve() || 'dd-cron';
1012
+ const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
1013
+ // `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
1014
+ // `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
1015
+ // overrides the image's own and breaks coreutils/sudo resolution inside the pod.
1016
+ const sanitizedEnvPath = `${envFilePath}.secret`;
1017
+ fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
892
1018
  shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
893
1019
  shellExec(
894
- `kubectl create secret generic underpost-config --from-env-file=/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
1020
+ `kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
895
1021
  );
1022
+ fs.removeSync(sanitizedEnvPath);
896
1023
  },
897
1024
  /**
898
1025
  * Switches the traffic for a deployment.
@@ -938,6 +1065,37 @@ EOF`);
938
1065
  Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
939
1066
  },
940
1067
 
1068
+ /**
1069
+ * Resolves the effective target node for a deployment, applying a single
1070
+ * precedence chain shared by every deploy workflow — the default `deploy`
1071
+ * callback, `run sync`, and custom `run instance` — so node customization
1072
+ * behaves identically everywhere:
1073
+ *
1074
+ * 1. **Explicit node** — `node` (the resolved `--node` value). Upstream
1075
+ * runners derive it from the comma-path field or `--node-name`
1076
+ * (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
1077
+ * `--node-name` directly (`run instance`).
1078
+ * 2. **Cluster-type default** — when no explicit node is given: `kind-worker`
1079
+ * for a kind cluster (the node that hosts kind hostPath volumes),
1080
+ * otherwise the control-plane / current host (`os.hostname()`) for
1081
+ * kubeadm / k3s. With no explicit cluster flag, `development` is treated
1082
+ * as kind and `production` as the host, preserving legacy behaviour.
1083
+ *
1084
+ * @param {object} params
1085
+ * @param {string} [params.node=''] - Explicit node (`--node`); highest precedence.
1086
+ * @param {boolean} [params.kind=false] - Kind cluster context.
1087
+ * @param {boolean} [params.kubeadm=false] - Kubeadm cluster context.
1088
+ * @param {boolean} [params.k3s=false] - K3s cluster context.
1089
+ * @param {string} [params.env=''] - Deployment environment; tie-breaker when no cluster flag is set.
1090
+ * @returns {string} The effective node name.
1091
+ * @memberof UnderpostDeploy
1092
+ */
1093
+ resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
1094
+ if (node) return node;
1095
+ const isKind = kind || (!kubeadm && !k3s && env !== 'production');
1096
+ return isKind ? 'kind-worker' : os.hostname();
1097
+ },
1098
+
941
1099
  /**
942
1100
  * Deploys a volume for a deployment.
943
1101
  * @param {object} volume - Volume configuration.
@@ -949,9 +1107,10 @@ EOF`);
949
1107
  * @param {string} options.env - Environment for the deployment.
950
1108
  * @param {string} options.version - Version of the deployment.
951
1109
  * @param {string} options.namespace - Kubernetes namespace for the deployment.
952
- * @param {string} options.nodeName - Node name for the deployment.
1110
+ * @param {string} options.nodeName - Effective target node (already resolved via {@link UnderpostDeploy.resolveDeployNode}). The volume data is written/shipped here and the PV is pinned to it; an empty value falls back to the cluster-type default inside this method.
953
1111
  * @param {string} [options.clusterContext='kind'] - Cluster context type ('kind', 'kubeadm', or 'k3s').
954
1112
  * @param {boolean} [options.gitClean=false] - Whether to run git clean on volumeMountPath before copying.
1113
+ * @param {string} [options.sshKeyPath=''] - Private key path used when the target node is remote and the volume is shipped over SSH. Empty falls back to copyDirToNode's default (engine-private/deploy/id_rsa).
955
1114
  * @memberof UnderpostDeploy
956
1115
  */
957
1116
  deployVolume(
@@ -964,6 +1123,7 @@ EOF`);
964
1123
  nodeName: '',
965
1124
  clusterContext: 'kind',
966
1125
  gitClean: false,
1126
+ sshKeyPath: '',
967
1127
  },
968
1128
  ) {
969
1129
  if (!volume.claimName) {
@@ -978,19 +1138,48 @@ EOF`);
978
1138
  if (options.gitClean && volume.volumeMountPath) {
979
1139
  Underpost.repo.clean({ paths: [volume.volumeMountPath] });
980
1140
  }
981
- if (options.nodeName) {
982
- if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
983
- fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
984
- } else if (clusterContext === 'kind') {
985
- shellExec(`docker exec -i kind-worker bash -c "mkdir -p ${rootVolumeHostPath}"`);
986
- // shellExec(`docker cp ${volume.volumeMountPath} kind-worker:${rootVolumeHostPath}`);
987
- shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - kind-worker:${rootVolumeHostPath}`);
1141
+ // The node that physically receives the volume data. hostPath volumes are
1142
+ // node-local, so the data must land on the node where the pod will run, and
1143
+ // the PV is pinned there (nodeAffinity) so the scheduler co-locates the pod
1144
+ // with its volume never mounting an empty DirectoryOrCreate on another node.
1145
+ let dataNode;
1146
+ if (clusterContext === 'kind') {
1147
+ const kindNode = options.nodeName || 'kind-worker';
1148
+ dataNode = kindNode;
1149
+ shellExec(`docker exec -i ${kindNode} bash -c "mkdir -p ${rootVolumeHostPath}"`);
1150
+ shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - ${kindNode}:${rootVolumeHostPath}`);
988
1151
  shellExec(
989
- `docker exec -i kind-worker bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
1152
+ `docker exec -i ${kindNode} bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
990
1153
  );
991
1154
  } else {
992
- if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
993
- fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
1155
+ const localHost = os.hostname();
1156
+ dataNode = options.nodeName || localHost;
1157
+ if (dataNode === localHost) {
1158
+ // Target node is the control plane / current host: write directly.
1159
+ if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
1160
+ fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
1161
+ } else {
1162
+ // Target node is remote: fs.copySync would only write the control-plane
1163
+ // filesystem, leaving the real node's hostPath empty. Ship the folder to
1164
+ // the node over SSH so the data exists where the pod is pinned.
1165
+ const nodeHost =
1166
+ shellExec(
1167
+ `kubectl get node ${dataNode} -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'`,
1168
+ { stdout: true, silent: true, silentOnError: true },
1169
+ ).trim() || dataNode;
1170
+ logger.info('Shipping volume to remote node over SSH', {
1171
+ node: dataNode,
1172
+ host: nodeHost,
1173
+ src: volume.volumeMountPath,
1174
+ dest: rootVolumeHostPath,
1175
+ });
1176
+ Underpost.ssh.copyDirToNode({
1177
+ host: nodeHost,
1178
+ localDir: volume.volumeMountPath,
1179
+ remoteDir: rootVolumeHostPath,
1180
+ ...(options.sshKeyPath ? { keyPath: options.sshKeyPath } : {}),
1181
+ });
1182
+ }
994
1183
  }
995
1184
  shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
996
1185
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
@@ -999,6 +1188,7 @@ ${Underpost.deploy.persistentVolumeFactory({
999
1188
  hostPath: rootVolumeHostPath,
1000
1189
  pvcId,
1001
1190
  namespace,
1191
+ nodeName: dataNode,
1002
1192
  })}
1003
1193
  EOF
1004
1194
  `);
@@ -1087,11 +1277,28 @@ ${secret ? ` readOnly: true\n` : ''}`;
1087
1277
  * @param {string} options.hostPath - Host path for the persistent volume.
1088
1278
  * @param {string} options.pvcId - Persistent volume claim ID.
1089
1279
  * @param {string} [options.namespace='default'] - Kubernetes namespace for the PVC claimRef.
1280
+ * @param {string} [options.nodeName=''] - Node name to which the persistent volume is pinned (optional).
1090
1281
  * @returns {string} - YAML configuration for the persistent volume and claim.
1091
1282
  * @memberof UnderpostDeploy
1092
1283
  */
1093
- persistentVolumeFactory({ hostPath, pvcId, namespace = 'default' }) {
1284
+ persistentVolumeFactory({ hostPath, pvcId, namespace = 'default', nodeName = '' }) {
1094
1285
  const pvId = pvcId.replace(/^pvc-/, 'pv-');
1286
+ // hostPath volumes are node-local: deployVolume writes the content to the
1287
+ // filesystem of a single node. Without nodeAffinity the scheduler can place
1288
+ // the pod on a different node and mount an empty DirectoryOrCreate hostPath
1289
+ // (missing the materialized assets). Pin the PV to the node that holds the
1290
+ // data so the pod is always co-located with its volume.
1291
+ const nodeAffinity = nodeName
1292
+ ? `
1293
+ nodeAffinity:
1294
+ required:
1295
+ nodeSelectorTerms:
1296
+ - matchExpressions:
1297
+ - key: kubernetes.io/hostname
1298
+ operator: In
1299
+ values:
1300
+ - ${nodeName}`
1301
+ : '';
1095
1302
  return `apiVersion: v1
1096
1303
  kind: PersistentVolume
1097
1304
  metadata:
@@ -1102,7 +1309,7 @@ spec:
1102
1309
  accessModes:
1103
1310
  - ReadWriteOnce
1104
1311
  persistentVolumeReclaimPolicy: Retain
1105
- storageClassName: manual
1312
+ storageClassName: manual${nodeAffinity}
1106
1313
  claimRef:
1107
1314
  apiVersion: v1
1108
1315
  kind: PersistentVolumeClaim
@@ -1136,183 +1343,10 @@ spec:
1136
1343
  * @memberof UnderpostDeploy
1137
1344
  */
1138
1345
  isValidTLSContext: ({ host, env, options }) =>
1139
- env === 'production' &&
1140
- options.cert === true &&
1141
- (!options.certHosts || options.certHosts.split(',').includes(host)),
1142
- /**
1143
- * Monitors the ready status of a deployment.
1144
- *
1145
- * Ready signal:
1146
- * The orchestrator gate is the Kubernetes pod Ready condition. When the
1147
- * container's `readinessProbe` succeeds, kubelet flips
1148
- * `status.conditions[Ready]` to True and `checkDeploymentReadyStatus`
1149
- * returns the pod in `readyPods`. This is the only required signal — see
1150
- * `src/client/public/nexodev/docs/references/Deploy custom instance to K8S.md`.
1151
- *
1152
- * Container-status:
1153
- * `underpost config get container-status` is read from each pod for both
1154
- * the display column and as a second ready gate alongside the K8S Ready
1155
- * condition. Both must be satisfied before the monitor exits:
1156
- * 1. K8S readinessProbe (TCP socket) — ensures the port is bound.
1157
- * 2. container-status == `<deploy>-<env>-running-deployment` — ensures
1158
- * the application has completed its own startup sequence.
1159
- * Early-abort on `error` container-status remains in effect.
1160
- *
1161
- * @param {string} deployId - Deployment ID for which the ready status is being monitored.
1162
- * @param {string} env - Environment for which the ready status is being monitored.
1163
- * @param {string} targetTraffic - Target traffic status for the deployment.
1164
- * @param {Array<string>} ignorePods - List of pod names to ignore.
1165
- * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
1166
- * @returns {object} - Object containing the ready status of the deployment.
1167
- * @memberof UnderpostDeploy
1168
- */
1169
- async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
1170
- const delayMs = 1000;
1171
- const maxIterations = 3000;
1172
- const deploymentId = `${deployId}-${env}-${targetTraffic}`;
1173
- const expectedContainerStatus = `${deployId}-${env}-running-deployment`;
1174
- const tag = `[${deploymentId}]`;
1175
- const containerStatusDefault = 'waiting for status';
1176
-
1177
- logger.info('Deployment init', { deployId, env, targetTraffic, namespace });
1178
-
1179
- // Per-pod cache of last-known container-status (persists across retries)
1180
- const podStatusCache = new Map();
1181
-
1182
- const readContainerStatus = (podName) => {
1183
- try {
1184
- const raw = shellExec(
1185
- `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
1186
- { silent: true, disableLog: true, stdout: true, silentOnError: true },
1187
- );
1188
- const val = raw ? raw.toString().trim() : '';
1189
- return val && val !== 'undefined' ? val : containerStatusDefault;
1190
- } catch (_) {
1191
- // exec failed (e.g. pod not yet running) — preserve last known value
1192
- return podStatusCache.get(podName) || containerStatusDefault;
1193
- }
1194
- };
1195
-
1196
- for (let i = 0; i < maxIterations; i++) {
1197
- const result = await Underpost.deploy.checkDeploymentReadyStatus(
1198
- deployId,
1199
- env,
1200
- targetTraffic,
1201
- ignorePods,
1202
- namespace,
1203
- );
1204
-
1205
- const allPods = [...result.readyPods, ...result.notReadyPods];
1206
-
1207
- // Update cache with latest status for each pod (informational + error gate)
1208
- for (const pod of allPods) {
1209
- if (!pod?.NAME) continue;
1210
- const status = readContainerStatus(pod.NAME);
1211
- if (status === 'error') throw new Error(`Pod ${pod.NAME} has error status`);
1212
- podStatusCache.set(pod.NAME, status);
1213
- }
1214
-
1215
- const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
1216
-
1217
- const allPodsStatusReady =
1218
- allPods.length > 0 && allPods.every((pod) => podStatusCache.get(pod.NAME) === expectedContainerStatus);
1219
-
1220
- // Print snapshot for every pod — annotate when container-status hasn't caught
1221
- // up to the K8S Ready condition yet.
1222
- for (const pod of allPods) {
1223
- const status = podStatusCache.get(pod.NAME) || containerStatusDefault;
1224
- const podStatus = pod.STATUS || 'Unknown';
1225
- const statusMatchesExpected = status === expectedContainerStatus;
1226
- const statusDisplay = statusMatchesExpected ? status : `${status} (pending)`;
1227
-
1228
- console.log(
1229
- 'Target pod:',
1230
- pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1231
- '| Pod status:',
1232
- podStatus.bold.yellow,
1233
- '| Runtime status:',
1234
- statusDisplay.bold.cyan,
1235
- );
1236
- }
1237
-
1238
- // Both K8S readinessProbe AND container-status must be satisfied before
1239
- // declaring the deployment ready. The TCP probe ensures the port is bound;
1240
- // container-status == running-deployment ensures the application has
1241
- // completed its own startup sequence so traffic is not switched prematurely.
1242
- if (allPodsK8sReady && allPodsStatusReady) {
1243
- logger.info(`${tag} | All pods Ready (K8S readinessProbe satisfied)`);
1244
- return result;
1245
- }
1246
-
1247
- await timer(delayMs);
1248
-
1249
- if ((i + 1) % 10 === 0) {
1250
- logger.info(`${tag} | In progress... iteration ${i + 1}`);
1251
- }
1252
- }
1253
-
1254
- logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
1255
- throw new Error(
1256
- `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
1257
- );
1258
- },
1259
-
1260
- /**
1261
- * Retrieves the currently loaded images in the Kubernetes cluster.
1262
- * @param {string} [node='kind-worker'] - Node name to check for loaded images.
1263
- * @param {object} options - Options for the image retrieval.
1264
- * @param {boolean} options.spec - Whether to retrieve images from the pod specifications.
1265
- * @param {string} options.namespace - Kubernetes namespace to filter pods.
1266
- * @returns {Array<object>} - Array of objects containing pod names and their corresponding images.
1267
- * @memberof UnderpostDeploy
1268
- */
1269
- getCurrentLoadedImages(node = 'kind-worker', options = { spec: false, namespace: '' }) {
1270
- if (options.spec) {
1271
- const raw = shellExec(
1272
- `kubectl get pods ${options.namespace ? `--namespace ${options.namespace}` : `--all-namespaces`} -o=jsonpath='{range .items[*]}{"\\n"}{.metadata.namespace}{"/"}{.metadata.name}{":\\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}'`,
1273
- {
1274
- stdout: true,
1275
- silent: true,
1276
- },
1277
- );
1278
- return raw
1279
- .split(`\n`)
1280
- .map((lines) => ({
1281
- pod: lines.split('\t')[0].replaceAll(':', '').trim(),
1282
- image: lines.split('\t')[1] ? lines.split('\t')[1].replaceAll(',', '').trim() : null,
1283
- }))
1284
- .filter((o) => o.image);
1285
- }
1286
- const raw = shellExec(node === 'kind-worker' ? `docker exec -i ${node} crictl images` : `crictl images`, {
1287
- stdout: true,
1288
- silent: true,
1289
- });
1290
-
1291
- const heads = raw
1292
- .split(`\n`)[0]
1293
- .split(' ')
1294
- .filter((_r) => _r.trim());
1295
-
1296
- const pods = raw
1297
- .split(`\n`)
1298
- .filter((r) => !r.match('IMAGE'))
1299
- .map((r) => r.split(' ').filter((_r) => _r.trim()));
1300
-
1301
- const result = [];
1302
-
1303
- for (const row of pods) {
1304
- if (row.length === 0) continue;
1305
- const pod = {};
1306
- let index = -1;
1307
- for (const head of heads) {
1308
- if (head in pod) continue;
1309
- index++;
1310
- pod[head] = row[index];
1311
- }
1312
- result.push(pod);
1313
- }
1314
- return result;
1315
- },
1346
+ (env === 'production' &&
1347
+ options.cert === true &&
1348
+ (!options.certHosts || options.certHosts.split(',').includes(host))) ||
1349
+ options.selfSigned === true,
1316
1350
 
1317
1351
  /**
1318
1352
  * Predefined resource templates for Kubernetes deployments.