underpost 3.2.9 → 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 (81) 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 +122 -1
  6. package/CLI-HELP.md +22 -7
  7. package/README.md +37 -8
  8. package/bin/build.js +26 -9
  9. package/bin/deploy.js +20 -21
  10. package/bin/file.js +31 -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 +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  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 +27 -12
  23. package/scripts/k3s-node-setup.sh +28 -9
  24. package/src/api/core/core.router.js +19 -14
  25. package/src/api/core/core.service.js +5 -5
  26. package/src/api/default/default.router.js +22 -18
  27. package/src/api/default/default.service.js +5 -5
  28. package/src/api/document/document.router.js +28 -23
  29. package/src/api/document/document.service.js +100 -23
  30. package/src/api/file/file.router.js +19 -13
  31. package/src/api/file/file.service.js +9 -7
  32. package/src/api/test/test.router.js +17 -12
  33. package/src/api/types.js +24 -0
  34. package/src/api/user/guest.service.js +5 -4
  35. package/src/api/user/user.router.js +297 -288
  36. package/src/api/user/user.service.js +100 -35
  37. package/src/cli/baremetal.js +20 -11
  38. package/src/cli/cluster.js +196 -55
  39. package/src/cli/db.js +59 -60
  40. package/src/cli/deploy.js +273 -159
  41. package/src/cli/fs.js +3 -1
  42. package/src/cli/index.js +16 -9
  43. package/src/cli/ipfs.js +4 -6
  44. package/src/cli/kubectl.js +4 -1
  45. package/src/cli/lxd.js +217 -135
  46. package/src/cli/release.js +289 -131
  47. package/src/cli/repository.js +58 -7
  48. package/src/cli/run.js +152 -25
  49. package/src/cli/test.js +9 -3
  50. package/src/client/Default.index.js +9 -3
  51. package/src/client/components/core/Auth.js +4 -0
  52. package/src/client/components/core/PanelForm.js +56 -52
  53. package/src/client/components/core/Worker.js +162 -363
  54. package/src/client/sw/core.sw.js +174 -112
  55. package/src/db/DataBaseProvider.js +120 -20
  56. package/src/db/mongo/MongoBootstrap.js +587 -0
  57. package/src/db/mongo/MongooseDB.js +126 -22
  58. package/src/index.js +1 -1
  59. package/src/runtime/express/Express.js +2 -2
  60. package/src/runtime/wp/Wp.js +8 -5
  61. package/src/server/auth.js +2 -2
  62. package/src/server/client-build-docs.js +1 -1
  63. package/src/server/client-build.js +94 -129
  64. package/src/server/conf.js +20 -65
  65. package/src/server/process.js +180 -19
  66. package/src/server/runtime.js +1 -1
  67. package/src/server/start.js +12 -4
  68. package/src/ws/IoInterface.js +16 -16
  69. package/src/ws/core/channels/core.ws.chat.js +11 -11
  70. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  71. package/src/ws/core/channels/core.ws.stream.js +19 -19
  72. package/src/ws/core/core.ws.connection.js +8 -8
  73. package/src/ws/core/core.ws.server.js +6 -5
  74. package/src/ws/default/channels/default.ws.main.js +10 -10
  75. package/src/ws/default/default.ws.connection.js +4 -4
  76. package/src/ws/default/default.ws.server.js +4 -3
  77. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  78. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  79. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  80. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  81. /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.
@@ -127,6 +122,7 @@ class UnderpostDeploy {
127
122
  * @param {Array<string>} cmd - Command to run in the deployment container.
128
123
  * @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
129
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.
130
126
  * @returns {string} - YAML deployment configuration for the specified deployment.
131
127
  * @memberof UnderpostDeploy
132
128
  */
@@ -142,22 +138,33 @@ class UnderpostDeploy {
142
138
  cmd,
143
139
  skipFullBuild,
144
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,
145
152
  }) {
146
153
  if (!cmd)
147
154
  cmd =
148
155
  pullBundle || skipFullBuild
149
156
  ? [
150
- // When pullBundle (or skipFullBuild) is set the container pulls the pre-built client
151
- // bundle from Cloudinary (push-bundle must have been run on the dev machine beforehand).
152
- `underpost secret underpost --create-from-env`,
153
- `underpost start --build --run --pull-bundle --skip-full-build ${deployId} ${env}`,
154
- ]
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
+ ]
155
162
  : [
156
- // `npm install -g npm@11.2.0`,
157
- // `npm install -g underpost`,
158
- `underpost secret underpost --create-from-env`,
159
- `underpost start --build --run ${deployId} ${env}`,
160
- ];
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
+ ];
161
168
  const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
162
169
  if (!volumes) volumes = [];
163
170
  const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
@@ -188,36 +195,64 @@ spec:
188
195
  containers:
189
196
  - name: ${deployId}-${env}-${suffix}
190
197
  image: ${containerImage}
191
- imagePullPolicy: ${containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
198
+ imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
192
199
  envFrom:
193
200
  - secretRef:
194
201
  name: underpost-config
195
- ${
196
- resources
197
- ? ` resources:
202
+ ${containerPort
203
+ ? ` ports:
204
+ - containerPort: ${containerPort}
205
+ `
206
+ : ''
207
+ }${resources
208
+ ? ` resources:
198
209
  requests:
199
210
  memory: "${resources.requests.memory}"
200
211
  cpu: "${resources.requests.cpu}"
201
212
  limits:
202
213
  memory: "${resources.limits.memory}"
203
214
  cpu: "${resources.limits.cpu}"`
204
- : ''
205
- }
215
+ : ''
216
+ }
206
217
  command:
207
218
  - /bin/sh
208
219
  - -c
209
220
  - >
210
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
+ }
211
247
 
212
- ${
213
- volumes.length > 0
214
- ? Underpost.deploy
215
- .volumeFactory(volumes.map((v) => ((v.version = `${deployId}-${env}-${suffix}`), v)))
216
- .render.split(`\n`)
217
- .map((l) => ' ' + l)
218
- .join(`\n`)
219
- : ''
220
- }
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
+ }
221
256
  ---
222
257
  apiVersion: v1
223
258
  kind: Service
@@ -248,6 +283,7 @@ spec:
248
283
  * @param {string} [options.traffic] - Traffic status for the deployment.
249
284
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory to generate a pull-bundle startup command.
250
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.
251
287
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
252
288
  * @memberof UnderpostDeploy
253
289
  */
@@ -276,18 +312,19 @@ spec:
276
312
  for (const deploymentVersion of deploymentVersions) {
277
313
  deploymentYamlParts += `---
278
314
  ${Underpost.deploy
279
- .deploymentYamlPartsFactory({
280
- deployId,
281
- env,
282
- suffix: deploymentVersion,
283
- replicas,
284
- image,
285
- namespace: options.namespace,
286
- cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
287
- skipFullBuild: options.skipFullBuild,
288
- pullBundle: options.pullBundle,
289
- })
290
- .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))}
291
328
  `;
292
329
  }
293
330
  fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
@@ -339,20 +376,20 @@ ${Underpost.deploy
339
376
  let proxyRoutes = '';
340
377
  const globalTimeoutPolicy =
341
378
  (options.timeoutResponse && options.timeoutResponse !== '') ||
342
- (options.timeoutIdle && options.timeoutIdle !== '')
379
+ (options.timeoutIdle && options.timeoutIdle !== '')
343
380
  ? {
344
- response: options.timeoutResponse,
345
- idle: options.timeoutIdle,
346
- }
381
+ response: options.timeoutResponse,
382
+ idle: options.timeoutIdle,
383
+ }
347
384
  : undefined;
348
385
  const globalRetryPolicy =
349
386
  options.retryCount ||
350
- options.retryCount === 0 ||
351
- (options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
387
+ options.retryCount === 0 ||
388
+ (options.retryPerTryTimeout && options.retryPerTryTimeout !== '')
352
389
  ? {
353
- count: options.retryCount,
354
- perTryTimeout: options.retryPerTryTimeout,
355
- }
390
+ count: options.retryCount,
391
+ perTryTimeout: options.retryPerTryTimeout,
392
+ }
356
393
  : undefined;
357
394
  if (!options.disableDeploymentProxy)
358
395
  for (const conditionObj of pathPortAssignment) {
@@ -509,10 +546,15 @@ spec:
509
546
  const hostTest = options?.hostTest
510
547
  ? options.hostTest
511
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.
512
552
  const info = shellExec(`sudo kubectl get HTTPProxy/${hostTest} -n ${options.namespace} -o yaml`, {
513
553
  silent: true,
514
554
  stdout: true,
555
+ silentOnError: true,
515
556
  });
557
+ if (!info) return null;
516
558
  return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
517
559
  },
518
560
 
@@ -535,13 +577,12 @@ metadata:
535
577
  namespace: ${options.namespace}
536
578
  spec:
537
579
  virtualhost:
538
- fqdn: ${host}${
539
- env === 'development'
540
- ? ''
541
- : `
580
+ fqdn: ${host}${env === 'development'
581
+ ? ''
582
+ : `
542
583
  tls:
543
584
  secretName: ${host}`
544
- }
585
+ }
545
586
  routes:`;
546
587
  },
547
588
 
@@ -586,6 +627,7 @@ spec:
586
627
  * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
587
628
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
588
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.
589
631
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
590
632
  * @memberof UnderpostDeploy
591
633
  */
@@ -627,6 +669,7 @@ spec:
627
669
  kubeadm: false,
628
670
  kind: false,
629
671
  gitClean: false,
672
+ imagePullPolicy: '',
630
673
  },
631
674
  ) {
632
675
  const namespace = options.namespace ? options.namespace : 'default';
@@ -801,25 +844,41 @@ EOF`);
801
844
  * @memberof UnderpostDeploy
802
845
  */
803
846
  async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
804
- const cmd = `underpost config get container-status`;
805
847
  const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
806
848
  const readyPods = [];
807
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.
808
855
  for (const pod of pods) {
809
856
  const { NAME } = pod;
810
857
  if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
811
- const out = await new Promise((resolve) => {
812
- 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`, {
813
865
  silent: true,
814
866
  disableLog: true,
815
- callback: function (code, stdout, stderr) {
816
- return resolve(JSON.stringify({ code, stdout, stderr }));
817
- },
867
+ stdout: true,
868
+ silentOnError: true,
818
869
  });
819
- });
820
- pod.out = out;
821
- const ready = out.match(`${deployId}-${env}-running-deployment`);
822
- 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);
823
882
  }
824
883
  return {
825
884
  ready: pods.length > 0 && notReadyPods.length === 0,
@@ -853,6 +912,7 @@ EOF`);
853
912
  * @param {string} options.timeoutIdle - Timeout idle setting for the deployment.
854
913
  * @param {string} options.retryCount - Retry count setting for the deployment.
855
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.
856
916
  * @memberof UnderpostDeploy
857
917
  */
858
918
  switchTraffic(
@@ -866,12 +926,14 @@ EOF`);
866
926
  timeoutIdle: '',
867
927
  retryCount: '',
868
928
  retryPerTryTimeout: '',
929
+ imagePullPolicy: '',
869
930
  },
870
931
  ) {
871
932
  const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
933
+ const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
872
934
 
873
935
  shellExec(
874
- `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}`,
875
937
  );
876
938
 
877
939
  shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
@@ -940,10 +1002,10 @@ EOF`);
940
1002
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
941
1003
  shellExec(`kubectl apply -f - -n ${namespace} <<EOF
942
1004
  ${Underpost.deploy.persistentVolumeFactory({
943
- hostPath: rootVolumeHostPath,
944
- pvcId,
945
- namespace,
946
- })}
1005
+ hostPath: rootVolumeHostPath,
1006
+ pvcId,
1007
+ namespace,
1008
+ })}
947
1009
  EOF
948
1010
  `);
949
1011
  },
@@ -1002,23 +1064,22 @@ ${secret ? ` readOnly: true\n` : ''}`;
1002
1064
 
1003
1065
  _volumes += `
1004
1066
  - name: ${volumeName}
1005
- ${
1006
- emptyDir
1007
- ? ` emptyDir: {}`
1008
- : secret
1009
- ? ` secret:
1067
+ ${emptyDir
1068
+ ? ` emptyDir: {}`
1069
+ : secret
1070
+ ? ` secret:
1010
1071
  secretName: ${secret}`
1011
- : configMap
1012
- ? ` configMap:
1072
+ : configMap
1073
+ ? ` configMap:
1013
1074
  name: ${configMap}`
1014
- : claimName
1015
- ? ` persistentVolumeClaim:
1075
+ : claimName
1076
+ ? ` persistentVolumeClaim:
1016
1077
  claimName: ${claimName}`
1017
- : ` hostPath:
1078
+ : ` hostPath:
1018
1079
  path: ${volumeHostPath}
1019
1080
  type: ${volumeType}
1020
1081
  `
1021
- }
1082
+ }
1022
1083
 
1023
1084
  `;
1024
1085
  });
@@ -1099,7 +1160,7 @@ spec:
1099
1160
  fs.writeFileSync(
1100
1161
  `/etc/hosts`,
1101
1162
  fs.readFileSync(`/etc/hosts`, 'utf8') +
1102
- `
1163
+ `
1103
1164
  ${renderHosts}`,
1104
1165
  'utf8',
1105
1166
  );
@@ -1119,9 +1180,25 @@ ${renderHosts}`,
1119
1180
  env === 'production' &&
1120
1181
  options.cert === true &&
1121
1182
  (!options.certHosts || options.certHosts.split(',').includes(host)),
1122
-
1123
1183
  /**
1124
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
+ *
1125
1202
  * @param {string} deployId - Deployment ID for which the ready status is being monitored.
1126
1203
  * @param {string} env - Environment for which the ready status is being monitored.
1127
1204
  * @param {string} targetTraffic - Target traffic status for the deployment.
@@ -1131,79 +1208,88 @@ ${renderHosts}`,
1131
1208
  * @memberof UnderpostDeploy
1132
1209
  */
1133
1210
  async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
1134
- let checkStatusIteration = 0;
1135
- const checkStatusIterationMsDelay = 1000;
1211
+ const delayMs = 1000;
1136
1212
  const maxIterations = 3000;
1137
1213
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
1138
- const iteratorTag = `[${deploymentId}]`;
1139
- logger.info('Deployment init', { deployId, env, targetTraffic, checkStatusIterationMsDelay, namespace });
1140
- const minReadyOk = 3;
1141
- let readyOk = 0;
1142
- let result = {
1143
- ready: false,
1144
- notReadyPods: [],
1145
- readyPods: [],
1146
- };
1147
- let lastMsg = {};
1148
- while (readyOk < minReadyOk) {
1149
- if (checkStatusIteration >= maxIterations) {
1150
- logger.error(
1151
- `${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 },
1152
1228
  );
1153
- 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;
1154
1234
  }
1155
- result = await Underpost.deploy.checkDeploymentReadyStatus(deployId, env, targetTraffic, ignorePods, namespace);
1156
- if (result.ready === true) {
1157
- readyOk++;
1158
- logger.info(`${iteratorTag} | Deployment ready. Verification number: ${readyOk}`);
1159
- for (const pod of result.readyPods) {
1160
- const { NAME } = pod;
1161
- lastMsg[NAME] = 'Deployment ready';
1162
- console.log(
1163
- 'Target pod:',
1164
- NAME[NAME.match('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1165
- '| Status:',
1166
- lastMsg[NAME].bold.magenta,
1167
- );
1168
- }
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);
1169
1252
  }
1170
1253
 
1171
- {
1172
- let indexOf = -1;
1173
- for (const pod of result.notReadyPods) {
1174
- indexOf++;
1175
- const { NAME, out } = pod;
1254
+ const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
1176
1255
 
1177
- if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match(deploymentId))
1178
- lastMsg[NAME] = 'Starting deployment';
1179
- // else if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match('underpost'))
1180
- // lastMsg[NAME] = 'Installing underpost cli';
1181
- else if (out.match('not') && out.match('found') && checkStatusIteration <= 20 && out.match('task'))
1182
- lastMsg[NAME] = 'Initializing setup task';
1183
- else if (out.match('Empty environment variables')) lastMsg[NAME] = 'Setup environment';
1184
- else if (out.match(`${deployId}-${env}-build-deployment`)) lastMsg[NAME] = 'Building apps/services';
1185
- else if (out.match(`${deployId}-${env}-initializing-deployment`))
1186
- lastMsg[NAME] = 'Initializing apps/services';
1187
- 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)`;
1188
1263
 
1189
- console.log(
1190
- 'Target pod:',
1191
- NAME[NAME.match('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1192
- '| Status:',
1193
- lastMsg[NAME].bold.magenta,
1194
- );
1195
- }
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}`);
1196
1286
  }
1197
- await timer(checkStatusIterationMsDelay);
1198
- checkStatusIteration++;
1199
- logger.info(
1200
- `${iteratorTag} | Deployment in progress... | Delay number monitor iterations: ${checkStatusIteration}`,
1201
- );
1202
1287
  }
1203
- logger.info(
1204
- `${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`,
1205
1292
  );
1206
- return result;
1207
1293
  },
1208
1294
 
1209
1295
  /**
@@ -1363,6 +1449,34 @@ ${renderHosts}`,
1363
1449
  return undefined;
1364
1450
  },
1365
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
+
1366
1480
  /**
1367
1481
  * Generates timeout flags string for deployment commands.
1368
1482
  * @param {object} options - Options containing timeout settings.
package/src/cli/fs.js CHANGED
@@ -154,7 +154,9 @@ class UnderpostFileStorage {
154
154
  // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
155
155
  if (options.git === true) {
156
156
  Underpost.repo.initLocalRepo({ path });
157
- shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`);
157
+ shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`, {
158
+ silentOnError: true
159
+ });
158
160
  }
159
161
  } else {
160
162
  const files =