underpost 3.2.8 → 3.2.10

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 (92) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/settings.json +10 -5
  5. package/CHANGELOG.md +223 -2
  6. package/CLI-HELP.md +36 -7
  7. package/README.md +38 -9
  8. package/bin/build.js +27 -11
  9. package/bin/deploy.js +20 -21
  10. package/bin/file.js +32 -13
  11. package/bin/index.js +2 -1
  12. package/bin/vs.js +1 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -4
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +2 -2
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/mongodb/pv-pvc.yaml +44 -8
  21. package/manifests/mongodb/statefulset.yaml +55 -68
  22. package/package.json +40 -25
  23. package/scripts/k3s-node-setup.sh +30 -11
  24. package/scripts/nat-iptables.sh +103 -18
  25. package/src/api/core/core.router.js +19 -14
  26. package/src/api/core/core.service.js +5 -5
  27. package/src/api/default/default.router.js +22 -18
  28. package/src/api/default/default.service.js +5 -5
  29. package/src/api/document/document.router.js +28 -23
  30. package/src/api/document/document.service.js +100 -23
  31. package/src/api/file/file.router.js +19 -13
  32. package/src/api/file/file.service.js +9 -7
  33. package/src/api/test/test.router.js +17 -12
  34. package/src/api/types.js +24 -0
  35. package/src/api/user/guest.service.js +5 -4
  36. package/src/api/user/user.router.js +297 -288
  37. package/src/api/user/user.service.js +100 -35
  38. package/src/cli/baremetal.js +20 -11
  39. package/src/cli/cluster.js +243 -55
  40. package/src/cli/db.js +106 -62
  41. package/src/cli/deploy.js +297 -154
  42. package/src/cli/fs.js +19 -3
  43. package/src/cli/index.js +37 -9
  44. package/src/cli/ipfs.js +4 -6
  45. package/src/cli/kubectl.js +4 -1
  46. package/src/cli/lxd.js +217 -135
  47. package/src/cli/release.js +289 -131
  48. package/src/cli/repository.js +91 -34
  49. package/src/cli/run.js +297 -56
  50. package/src/cli/test.js +9 -3
  51. package/src/client/Default.index.js +9 -3
  52. package/src/client/components/core/Auth.js +19 -5
  53. package/src/client/components/core/Docs.js +6 -34
  54. package/src/client/components/core/FileExplorer.js +6 -6
  55. package/src/client/components/core/Modal.js +65 -2
  56. package/src/client/components/core/PanelForm.js +56 -52
  57. package/src/client/components/core/Recover.js +4 -4
  58. package/src/client/components/core/Worker.js +170 -350
  59. package/src/client/services/default/default.management.js +20 -25
  60. package/src/client/services/user/guest.service.js +10 -3
  61. package/src/client/sw/core.sw.js +174 -112
  62. package/src/db/DataBaseProvider.js +120 -20
  63. package/src/db/mongo/MongoBootstrap.js +587 -0
  64. package/src/db/mongo/MongooseDB.js +126 -22
  65. package/src/index.js +1 -1
  66. package/src/runtime/express/Express.js +2 -2
  67. package/src/runtime/wp/Wp.js +8 -5
  68. package/src/server/auth.js +2 -2
  69. package/src/server/client-build-docs.js +1 -1
  70. package/src/server/client-build.js +94 -129
  71. package/src/server/conf.js +20 -65
  72. package/src/server/data-query.js +32 -20
  73. package/src/server/dns.js +22 -0
  74. package/src/server/process.js +180 -19
  75. package/src/server/runtime.js +1 -1
  76. package/src/server/start.js +26 -7
  77. package/src/server/valkey.js +9 -2
  78. package/src/ws/IoInterface.js +16 -16
  79. package/src/ws/core/channels/core.ws.chat.js +11 -11
  80. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  81. package/src/ws/core/channels/core.ws.stream.js +19 -19
  82. package/src/ws/core/core.ws.connection.js +8 -8
  83. package/src/ws/core/core.ws.server.js +6 -5
  84. package/src/ws/default/channels/default.ws.main.js +10 -10
  85. package/src/ws/default/default.ws.connection.js +4 -4
  86. package/src/ws/default/default.ws.server.js +4 -3
  87. package/typedoc.json +10 -1
  88. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  89. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  90. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  91. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  92. /package/src/client/ssr/{pages → views}/Test.js +0 -0
package/src/cli/deploy.js CHANGED
@@ -78,8 +78,7 @@ class UnderpostDeploy {
78
78
  return `
79
79
  - conditions:
80
80
  - prefix: ${path}
81
- ${
82
- pathRewritePolicy
81
+ ${pathRewritePolicy
83
82
  ? `pathRewritePolicy:
84
83
  replacePrefix:
85
84
  ${pathRewritePolicy.map(
@@ -89,30 +88,26 @@ class UnderpostDeploy {
89
88
  ).join(`
90
89
  `)}`
91
90
  : ''
92
- }${
93
- timeoutPolicy
94
- ? `\n timeoutPolicy:\n${timeoutPolicy.response ? ` response: ${timeoutPolicy.response}\n` : ''}${
95
- timeoutPolicy.idle ? ` idle: ${timeoutPolicy.idle}\n` : ''
96
- }`
91
+ }${timeoutPolicy
92
+ ? `\n timeoutPolicy:\n${timeoutPolicy.response ? ` response: ${timeoutPolicy.response}\n` : ''}${timeoutPolicy.idle ? ` idle: ${timeoutPolicy.idle}\n` : ''
93
+ }`
97
94
  : ''
98
- }${
99
- retryPolicy
100
- ? `\n retryPolicy:\n${retryPolicy.count !== undefined ? ` count: ${retryPolicy.count}\n` : ''}${
101
- retryPolicy.perTryTimeout ? ` perTryTimeout: ${retryPolicy.perTryTimeout}\n` : ''
102
- }`
95
+ }${retryPolicy
96
+ ? `\n retryPolicy:\n${retryPolicy.count !== undefined ? ` count: ${retryPolicy.count}\n` : ''}${retryPolicy.perTryTimeout ? ` perTryTimeout: ${retryPolicy.perTryTimeout}\n` : ''
97
+ }`
103
98
  : ''
104
- }
99
+ }
105
100
  enableWebsockets: true
106
101
  services:
107
102
  ${deploymentVersions
108
- .map(
109
- (version, i) =>
110
- ` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
103
+ .map(
104
+ (version, i) =>
105
+ ` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
111
106
  port: ${port}
112
107
  weight: ${i === 0 ? 100 : 0}
113
108
  `,
114
- )
115
- .join('')}`;
109
+ )
110
+ .join('')}`;
116
111
  },
117
112
  /**
118
113
  * Creates a YAML deployment configuration for a deployment.
@@ -125,17 +120,51 @@ class UnderpostDeploy {
125
120
  * @param {string} namespace - Kubernetes namespace for the deployment.
126
121
  * @param {Array<object>} volumes - Volume configurations for the deployment.
127
122
  * @param {Array<string>} cmd - Command to run in the deployment container.
123
+ * @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
124
+ * @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.
125
+ * @param {string} [imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`). When omitted, defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
128
126
  * @returns {string} - YAML deployment configuration for the specified deployment.
129
127
  * @memberof UnderpostDeploy
130
128
  */
131
- deploymentYamlPartsFactory({ deployId, env, suffix, resources, replicas, image, namespace, volumes, cmd }) {
129
+ deploymentYamlPartsFactory({
130
+ deployId,
131
+ env,
132
+ suffix,
133
+ resources,
134
+ replicas,
135
+ image,
136
+ namespace,
137
+ volumes,
138
+ cmd,
139
+ skipFullBuild,
140
+ pullBundle,
141
+ imagePullPolicy,
142
+ // K8S lifecycle + probe wiring. Pass-through structures shaped like the
143
+ // upstream Kubernetes API, spliced verbatim into the container spec.
144
+ // lifecycle: { postStart: { exec: { command: [...] } }, preStop: { exec: { command: [...] } } }
145
+ // readinessProbe: { tcpSocket: { port: 8081 }, ... }
146
+ // livenessProbe: { tcpSocket: { port: 8081 }, ... }
147
+ // containerPort: integer; rendered as ports[0].containerPort. Optional.
148
+ lifecycle,
149
+ readinessProbe,
150
+ livenessProbe,
151
+ containerPort,
152
+ }) {
132
153
  if (!cmd)
133
- cmd = [
134
- // `npm install -g npm@11.2.0`,
135
- // `npm install -g underpost`,
136
- `underpost secret underpost --create-from-env`,
137
- `underpost start --build --run ${deployId} ${env}`,
138
- ];
154
+ cmd =
155
+ pullBundle || skipFullBuild
156
+ ? [
157
+ // When pullBundle (or skipFullBuild) is set the container pulls the pre-built client
158
+ // bundle from Cloudinary (push-bundle must have been run on the dev machine beforehand).
159
+ `underpost secret underpost --create-from-env`,
160
+ `underpost start --build --run --pull-bundle --skip-full-build ${deployId} ${env}`,
161
+ ]
162
+ : [
163
+ // `npm install -g npm@11.2.0`,
164
+ // `npm install -g underpost`,
165
+ `underpost secret underpost --create-from-env`,
166
+ `underpost start --build --run ${deployId} ${env}`,
167
+ ];
139
168
  const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
140
169
  if (!volumes) volumes = [];
141
170
  const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
@@ -166,36 +195,64 @@ spec:
166
195
  containers:
167
196
  - name: ${deployId}-${env}-${suffix}
168
197
  image: ${containerImage}
169
- imagePullPolicy: ${containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
198
+ imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
170
199
  envFrom:
171
200
  - secretRef:
172
201
  name: underpost-config
173
- ${
174
- resources
175
- ? ` resources:
202
+ ${containerPort
203
+ ? ` ports:
204
+ - containerPort: ${containerPort}
205
+ `
206
+ : ''
207
+ }${resources
208
+ ? ` resources:
176
209
  requests:
177
210
  memory: "${resources.requests.memory}"
178
211
  cpu: "${resources.requests.cpu}"
179
212
  limits:
180
213
  memory: "${resources.limits.memory}"
181
214
  cpu: "${resources.limits.cpu}"`
182
- : ''
183
- }
215
+ : ''
216
+ }
184
217
  command:
185
218
  - /bin/sh
186
219
  - -c
187
220
  - >
188
221
  ${cmd.join(' &&\n ')}
222
+ ${readinessProbe
223
+ ? ` readinessProbe:
224
+ ${JSON.stringify(readinessProbe, null, 2)
225
+ .split('\n')
226
+ .map((l) => ' ' + l)
227
+ .join('\n')}
228
+ `
229
+ : ''
230
+ }${livenessProbe
231
+ ? ` livenessProbe:
232
+ ${JSON.stringify(livenessProbe, null, 2)
233
+ .split('\n')
234
+ .map((l) => ' ' + l)
235
+ .join('\n')}
236
+ `
237
+ : ''
238
+ }${lifecycle
239
+ ? ` lifecycle:
240
+ ${JSON.stringify(lifecycle, null, 2)
241
+ .split('\n')
242
+ .map((l) => ' ' + l)
243
+ .join('\n')}
244
+ `
245
+ : ''
246
+ }
189
247
 
190
- ${
191
- volumes.length > 0
192
- ? Underpost.deploy
193
- .volumeFactory(volumes.map((v) => ((v.version = `${deployId}-${env}-${suffix}`), v)))
194
- .render.split(`\n`)
195
- .map((l) => ' ' + l)
196
- .join(`\n`)
197
- : ''
198
- }
248
+ ${volumes.length > 0
249
+ ? Underpost.deploy
250
+ .volumeFactory(volumes.map((v) => ((v.version = `${deployId}-${env}-${suffix}`), v)))
251
+ .render.split(`\n`)
252
+ .map((l) => ' ' + l)
253
+ .join(`\n`)
254
+ : ''
255
+ }
199
256
  ---
200
257
  apiVersion: v1
201
258
  kind: Service
@@ -224,6 +281,9 @@ spec:
224
281
  * @param {string} [options.retryPerTryTimeout] - Retry per-try timeout setting for the deployment.
225
282
  * @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy.
226
283
  * @param {string} [options.traffic] - Traffic status for the deployment.
284
+ * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory to generate a pull-bundle startup command.
285
+ * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
286
+ * @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.
227
287
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
228
288
  * @memberof UnderpostDeploy
229
289
  */
@@ -252,16 +312,19 @@ spec:
252
312
  for (const deploymentVersion of deploymentVersions) {
253
313
  deploymentYamlParts += `---
254
314
  ${Underpost.deploy
255
- .deploymentYamlPartsFactory({
256
- deployId,
257
- env,
258
- suffix: deploymentVersion,
259
- replicas,
260
- image,
261
- namespace: options.namespace,
262
- cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
263
- })
264
- .replace('{{ports}}', buildKindPorts(fromPort, toPort))}
315
+ .deploymentYamlPartsFactory({
316
+ deployId,
317
+ env,
318
+ suffix: deploymentVersion,
319
+ replicas,
320
+ image,
321
+ namespace: options.namespace,
322
+ cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
323
+ skipFullBuild: options.skipFullBuild,
324
+ pullBundle: options.pullBundle,
325
+ imagePullPolicy: options.imagePullPolicy,
326
+ })
327
+ .replace('{{ports}}', buildKindPorts(fromPort, toPort))}
265
328
  `;
266
329
  }
267
330
  fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
@@ -313,20 +376,20 @@ ${Underpost.deploy
313
376
  let proxyRoutes = '';
314
377
  const globalTimeoutPolicy =
315
378
  (options.timeoutResponse && options.timeoutResponse !== '') ||
316
- (options.timeoutIdle && options.timeoutIdle !== '')
379
+ (options.timeoutIdle && options.timeoutIdle !== '')
317
380
  ? {
318
- response: options.timeoutResponse,
319
- idle: options.timeoutIdle,
320
- }
381
+ response: options.timeoutResponse,
382
+ idle: options.timeoutIdle,
383
+ }
321
384
  : undefined;
322
385
  const globalRetryPolicy =
323
386
  options.retryCount ||
324
- options.retryCount === 0 ||
325
- (options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
387
+ options.retryCount === 0 ||
388
+ (options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
326
389
  ? {
327
- count: options.retryCount,
328
- perTryTimeout: options.retryPerTryTimeout,
329
- }
390
+ count: options.retryCount,
391
+ perTryTimeout: options.retryPerTryTimeout,
392
+ }
330
393
  : undefined;
331
394
  if (!options.disableDeploymentProxy)
332
395
  for (const conditionObj of pathPortAssignment) {
@@ -483,10 +546,15 @@ spec:
483
546
  const hostTest = options?.hostTest
484
547
  ? options.hostTest
485
548
  : Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
549
+ // Missing HTTPProxy is the canonical "no traffic colour set yet" state
550
+ // for blue/green rollouts. silentOnError swallows kubectl's NotFound
551
+ // exit so the function can return null cleanly.
486
552
  const info = shellExec(`sudo kubectl get HTTPProxy/${hostTest} -n ${options.namespace} -o yaml`, {
487
553
  silent: true,
488
554
  stdout: true,
555
+ silentOnError: true,
489
556
  });
557
+ if (!info) return null;
490
558
  return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
491
559
  },
492
560
 
@@ -509,13 +577,12 @@ metadata:
509
577
  namespace: ${options.namespace}
510
578
  spec:
511
579
  virtualhost:
512
- fqdn: ${host}${
513
- env === 'development'
514
- ? ''
515
- : `
580
+ fqdn: ${host}${env === 'development'
581
+ ? ''
582
+ : `
516
583
  tls:
517
584
  secretName: ${host}`
518
- }
585
+ }
519
586
  routes:`;
520
587
  },
521
588
 
@@ -553,10 +620,14 @@ spec:
553
620
  * @param {string} [options.kindType] - Type of Kubernetes resource to retrieve information for.
554
621
  * @param {number} [options.port] - Port number for exposing the deployment.
555
622
  * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
623
+ * @param {number} [options.exposePort] - Local:remote port override when --expose is active (overrides auto-detected service port).
556
624
  * @param {boolean} [options.k3s] - Whether to use k3s cluster context.
557
625
  * @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
558
626
  * @param {boolean} [options.kind] - Whether to use kind cluster context.
559
627
  * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
628
+ * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
629
+ * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
630
+ * @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.
560
631
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
561
632
  * @memberof UnderpostDeploy
562
633
  */
@@ -598,6 +669,7 @@ spec:
598
669
  kubeadm: false,
599
670
  kind: false,
600
671
  gitClean: false,
672
+ imagePullPolicy: '',
601
673
  },
602
674
  ) {
603
675
  const namespace = options.namespace ? options.namespace : 'default';
@@ -772,25 +844,41 @@ EOF`);
772
844
  * @memberof UnderpostDeploy
773
845
  */
774
846
  async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
775
- const cmd = `underpost config get container-status`;
776
847
  const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
777
848
  const readyPods = [];
778
849
  const notReadyPods = [];
850
+
851
+ // Readiness signal: the pod's Kubernetes `Ready` condition driven by the
852
+ // container's readinessProbe (TCP socket, HTTP get, or exec). Set by kubelet
853
+ // when the probe passes. A failed or crashing runtime never becomes Ready —
854
+ // kubelet surfaces CrashLoopBackOff and this gate stays closed.
779
855
  for (const pod of pods) {
780
856
  const { NAME } = pod;
781
857
  if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
782
- const out = await new Promise((resolve) => {
783
- shellExec(`sudo kubectl exec -i ${NAME} -n ${namespace} -- sh -c "${cmd}"`, {
858
+
859
+ let podJson = null;
860
+ try {
861
+ // Pod may not exist yet (between deployment apply and pod
862
+ // scheduling). silentOnError lets the monitor loop continue
863
+ // instead of aborting on the transient NotFound exit.
864
+ const raw = shellExec(`sudo kubectl get pod ${NAME} -n ${namespace} -o json`, {
784
865
  silent: true,
785
866
  disableLog: true,
786
- callback: function (code, stdout, stderr) {
787
- return resolve(JSON.stringify({ code, stdout, stderr }));
788
- },
867
+ stdout: true,
868
+ silentOnError: true,
789
869
  });
790
- });
791
- pod.out = out;
792
- const ready = out.match(`${deployId}-${env}-running-deployment`);
793
- ready ? readyPods.push(pod) : notReadyPods.push(pod);
870
+ podJson = raw ? JSON.parse(raw) : null;
871
+ } catch (_) {
872
+ podJson = null;
873
+ }
874
+ const conditions = podJson?.status?.conditions || [];
875
+ const readyCondition = conditions.find((c) => c.type === 'Ready');
876
+ const k8sReady = readyCondition?.status === 'True';
877
+
878
+ pod.out = JSON.stringify({ k8sReady, condition: readyCondition ?? null });
879
+
880
+ if (k8sReady) readyPods.push(pod);
881
+ else notReadyPods.push(pod);
794
882
  }
795
883
  return {
796
884
  ready: pods.length > 0 && notReadyPods.length === 0,
@@ -824,6 +912,7 @@ EOF`);
824
912
  * @param {string} options.timeoutIdle - Timeout idle setting for the deployment.
825
913
  * @param {string} options.retryCount - Retry count setting for the deployment.
826
914
  * @param {string} options.retryPerTryTimeout - Retry per-try timeout setting for the deployment.
915
+ * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override; forwarded to the manifest rebuild triggered here.
827
916
  * @memberof UnderpostDeploy
828
917
  */
829
918
  switchTraffic(
@@ -837,12 +926,14 @@ EOF`);
837
926
  timeoutIdle: '',
838
927
  retryCount: '',
839
928
  retryPerTryTimeout: '',
929
+ imagePullPolicy: '',
840
930
  },
841
931
  ) {
842
932
  const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
933
+ const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
843
934
 
844
935
  shellExec(
845
- `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags} ${deployId} ${env}`,
936
+ `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag} ${deployId} ${env}`,
846
937
  );
847
938
 
848
939
  shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
@@ -911,10 +1002,10 @@ EOF`);
911
1002
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
912
1003
  shellExec(`kubectl apply -f - -n ${namespace} <<EOF
913
1004
  ${Underpost.deploy.persistentVolumeFactory({
914
- hostPath: rootVolumeHostPath,
915
- pvcId,
916
- namespace,
917
- })}
1005
+ hostPath: rootVolumeHostPath,
1006
+ pvcId,
1007
+ namespace,
1008
+ })}
918
1009
  EOF
919
1010
  `);
920
1011
  },
@@ -973,23 +1064,22 @@ ${secret ? ` readOnly: true\n` : ''}`;
973
1064
 
974
1065
  _volumes += `
975
1066
  - name: ${volumeName}
976
- ${
977
- emptyDir
978
- ? ` emptyDir: {}`
979
- : secret
980
- ? ` secret:
1067
+ ${emptyDir
1068
+ ? ` emptyDir: {}`
1069
+ : secret
1070
+ ? ` secret:
981
1071
  secretName: ${secret}`
982
- : configMap
983
- ? ` configMap:
1072
+ : configMap
1073
+ ? ` configMap:
984
1074
  name: ${configMap}`
985
- : claimName
986
- ? ` persistentVolumeClaim:
1075
+ : claimName
1076
+ ? ` persistentVolumeClaim:
987
1077
  claimName: ${claimName}`
988
- : ` hostPath:
1078
+ : ` hostPath:
989
1079
  path: ${volumeHostPath}
990
1080
  type: ${volumeType}
991
1081
  `
992
- }
1082
+ }
993
1083
 
994
1084
  `;
995
1085
  });
@@ -1070,7 +1160,7 @@ spec:
1070
1160
  fs.writeFileSync(
1071
1161
  `/etc/hosts`,
1072
1162
  fs.readFileSync(`/etc/hosts`, 'utf8') +
1073
- `
1163
+ `
1074
1164
  ${renderHosts}`,
1075
1165
  'utf8',
1076
1166
  );
@@ -1090,9 +1180,25 @@ ${renderHosts}`,
1090
1180
  env === 'production' &&
1091
1181
  options.cert === true &&
1092
1182
  (!options.certHosts || options.certHosts.split(',').includes(host)),
1093
-
1094
1183
  /**
1095
1184
  * Monitors the ready status of a deployment.
1185
+ *
1186
+ * Ready signal:
1187
+ * The orchestrator gate is the Kubernetes pod Ready condition. When the
1188
+ * container's `readinessProbe` succeeds, kubelet flips
1189
+ * `status.conditions[Ready]` to True and `checkDeploymentReadyStatus`
1190
+ * returns the pod in `readyPods`. This is the only required signal — see
1191
+ * `src/client/public/nexodev/docs/references/Deploy custom instance to K8S.md`.
1192
+ *
1193
+ * Container-status:
1194
+ * `underpost config get container-status` is still read from each pod for
1195
+ * the display column and for early-abort on `error`, but it is no longer
1196
+ * required to equal `<deploy>-running-deployment` to finish the monitor.
1197
+ * Older implementations gated on it; that produced false timeouts for
1198
+ * runtimes (e.g. cyberia-server's Go binary, cyberia-client's server.py)
1199
+ * whose startup sequence didn't reliably overwrite the
1200
+ * `initializing-deployment` stamp set by the postStart lifecycle hook.
1201
+ *
1096
1202
  * @param {string} deployId - Deployment ID for which the ready status is being monitored.
1097
1203
  * @param {string} env - Environment for which the ready status is being monitored.
1098
1204
  * @param {string} targetTraffic - Target traffic status for the deployment.
@@ -1102,79 +1208,88 @@ ${renderHosts}`,
1102
1208
  * @memberof UnderpostDeploy
1103
1209
  */
1104
1210
  async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
1105
- let checkStatusIteration = 0;
1106
- const checkStatusIterationMsDelay = 1000;
1211
+ const delayMs = 1000;
1107
1212
  const maxIterations = 3000;
1108
1213
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
1109
- const iteratorTag = `[${deploymentId}]`;
1110
- logger.info('Deployment init', { deployId, env, targetTraffic, checkStatusIterationMsDelay, namespace });
1111
- const minReadyOk = 3;
1112
- let readyOk = 0;
1113
- let result = {
1114
- ready: false,
1115
- notReadyPods: [],
1116
- readyPods: [],
1117
- };
1118
- let lastMsg = {};
1119
- while (readyOk < minReadyOk) {
1120
- if (checkStatusIteration >= maxIterations) {
1121
- logger.error(
1122
- `${iteratorTag} | Deployment check ready status timeout. Max iterations reached: ${maxIterations}`,
1214
+ const expectedContainerStatus = `${deployId}-${env}-running-deployment`;
1215
+ const tag = `[${deploymentId}]`;
1216
+ const containerStatusDefault = 'waiting for status';
1217
+
1218
+ logger.info('Deployment init', { deployId, env, targetTraffic, namespace });
1219
+
1220
+ // Per-pod cache of last-known container-status (persists across retries)
1221
+ const podStatusCache = new Map();
1222
+
1223
+ const readContainerStatus = (podName) => {
1224
+ try {
1225
+ const raw = shellExec(
1226
+ `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
1227
+ { silent: true, disableLog: true, stdout: true, silentOnError: true },
1123
1228
  );
1124
- break;
1229
+ const val = raw ? raw.toString().trim() : '';
1230
+ return val && val !== 'undefined' ? val : containerStatusDefault;
1231
+ } catch (_) {
1232
+ // exec failed (e.g. pod not yet running) — preserve last known value
1233
+ return podStatusCache.get(podName) || containerStatusDefault;
1125
1234
  }
1126
- result = await Underpost.deploy.checkDeploymentReadyStatus(deployId, env, targetTraffic, ignorePods, namespace);
1127
- if (result.ready === true) {
1128
- readyOk++;
1129
- logger.info(`${iteratorTag} | Deployment ready. Verification number: ${readyOk}`);
1130
- for (const pod of result.readyPods) {
1131
- const { NAME } = pod;
1132
- lastMsg[NAME] = 'Deployment ready';
1133
- console.log(
1134
- 'Target pod:',
1135
- NAME[NAME.match('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1136
- '| Status:',
1137
- lastMsg[NAME].bold.magenta,
1138
- );
1139
- }
1235
+ };
1236
+
1237
+
1238
+ for (let i = 0; i < maxIterations; i++) {
1239
+ const result = await Underpost.deploy.checkDeploymentReadyStatus(
1240
+ deployId, env, targetTraffic, ignorePods, namespace,
1241
+ );
1242
+
1243
+ const allPods = [...result.readyPods, ...result.notReadyPods];
1244
+
1245
+ // Update cache with latest status for each pod (informational + error gate)
1246
+ for (const pod of allPods) {
1247
+ if (!pod?.NAME) continue;
1248
+ const status = readContainerStatus(pod.NAME);
1249
+ if (status === 'error')
1250
+ throw new Error(`Pod ${pod.NAME} has error status`);
1251
+ podStatusCache.set(pod.NAME, status);
1140
1252
  }
1141
1253
 
1142
- {
1143
- let indexOf = -1;
1144
- for (const pod of result.notReadyPods) {
1145
- indexOf++;
1146
- const { NAME, out } = pod;
1254
+ const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
1147
1255
 
1148
- if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match(deploymentId))
1149
- lastMsg[NAME] = 'Starting deployment';
1150
- // else if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match('underpost'))
1151
- // lastMsg[NAME] = 'Installing underpost cli';
1152
- else if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match('task'))
1153
- lastMsg[NAME] = 'Initializing setup task';
1154
- else if (out.match('Empty environment variables')) lastMsg[NAME] = 'Setup environment';
1155
- else if (out.match(`${deployId}-${env}-build-deployment`)) lastMsg[NAME] = 'Building apps/services';
1156
- else if (out.match(`${deployId}-${env}-initializing-deployment`))
1157
- lastMsg[NAME] = 'Initializing apps/services';
1158
- else if (!lastMsg[NAME]) lastMsg[NAME] = `Waiting for status`;
1256
+ // Print snapshot for every pod annotate when container-status hasn't caught
1257
+ // up to the K8S Ready condition (informational only; no longer gates exit).
1258
+ for (const pod of allPods) {
1259
+ const status = podStatusCache.get(pod.NAME) || containerStatusDefault;
1260
+ const podStatus = pod.STATUS || 'Unknown';
1261
+ const statusMatchesExpected = status === expectedContainerStatus;
1262
+ const statusDisplay = statusMatchesExpected ? status : `${status} (advisory)`;
1159
1263
 
1160
- console.log(
1161
- 'Target pod:',
1162
- NAME[NAME.match('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1163
- '| Status:',
1164
- lastMsg[NAME].bold.magenta,
1165
- );
1166
- }
1264
+ console.log(
1265
+ 'Target pod:',
1266
+ pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1267
+ '| Pod status:',
1268
+ podStatus.bold.yellow,
1269
+ '| Runtime status:',
1270
+ statusDisplay.bold.cyan,
1271
+ );
1272
+ }
1273
+
1274
+ // Finish as soon as every pod is K8S-Ready. The readinessProbe (TCP
1275
+ // socket on the listening port) is the source of truth — a runtime
1276
+ // that can't bind never reaches Ready and the monitor will time out.
1277
+ if (allPodsK8sReady) {
1278
+ logger.info(`${tag} | All pods Ready (K8S readinessProbe satisfied)`);
1279
+ return result;
1280
+ }
1281
+
1282
+ await timer(delayMs);
1283
+
1284
+ if ((i + 1) % 10 === 0) {
1285
+ logger.info(`${tag} | In progress... iteration ${i + 1}`);
1167
1286
  }
1168
- await timer(checkStatusIterationMsDelay);
1169
- checkStatusIteration++;
1170
- logger.info(
1171
- `${iteratorTag} | Deployment in progress... | Delay number monitor iterations: ${checkStatusIteration}`,
1172
- );
1173
1287
  }
1174
- logger.info(
1175
- `${iteratorTag} | Deployment ready. | Total delay number monitor iterations: ${checkStatusIteration}`,
1288
+
1289
+ logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
1290
+ throw new Error(
1291
+ `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
1176
1292
  );
1177
- return result;
1178
1293
  },
1179
1294
 
1180
1295
  /**
@@ -1334,6 +1449,34 @@ ${renderHosts}`,
1334
1449
  return undefined;
1335
1450
  },
1336
1451
 
1452
+ /**
1453
+ * Extracts a non-standard `imagePullPolicy` key from an env-resolved
1454
+ * instance lifecycle block (the convention used in `conf.instances.json`,
1455
+ * where `imagePullPolicy` sits alongside `postStart`/`preStop` for
1456
+ * per-instance ergonomics) and returns a clean lifecycle hash that is
1457
+ * safe to splice into the K8S container spec.
1458
+ *
1459
+ * Returns `{ lifecycle, imagePullPolicy }`:
1460
+ * - `lifecycle` — the input minus `imagePullPolicy`, or `undefined` when
1461
+ * the resulting block is empty.
1462
+ * - `imagePullPolicy` — the extracted value, or `undefined` if absent.
1463
+ *
1464
+ * @param {object|undefined} lifecycle - Env-resolved lifecycle block
1465
+ * (already passed through pickEnv). May be `undefined`.
1466
+ * @returns {{ lifecycle: (object|undefined), imagePullPolicy: (string|undefined) }}
1467
+ * @memberof UnderpostDeploy
1468
+ */
1469
+ extractInstanceImagePullPolicy(lifecycle) {
1470
+ if (!lifecycle || typeof lifecycle !== 'object' || !('imagePullPolicy' in lifecycle)) {
1471
+ return { lifecycle, imagePullPolicy: undefined };
1472
+ }
1473
+ const { imagePullPolicy, ...rest } = lifecycle;
1474
+ return {
1475
+ lifecycle: Object.keys(rest).length > 0 ? rest : undefined,
1476
+ imagePullPolicy,
1477
+ };
1478
+ },
1479
+
1337
1480
  /**
1338
1481
  * Generates timeout flags string for deployment commands.
1339
1482
  * @param {object} options - Options containing timeout settings.