underpost 3.2.9 → 3.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.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/extensions.json +9 -9
  5. package/.vscode/settings.json +20 -4
  6. package/CHANGELOG.md +195 -1
  7. package/CLI-HELP.md +92 -23
  8. package/README.md +38 -9
  9. package/bin/build.js +27 -7
  10. package/bin/build.template.js +187 -0
  11. package/bin/deploy.js +12 -2
  12. package/bin/index.js +2 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -7
  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/lxd/lxd-admin-profile.yaml +12 -3
  21. package/manifests/mongodb/pv-pvc.yaml +44 -8
  22. package/manifests/mongodb/statefulset.yaml +55 -68
  23. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  24. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  25. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  26. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  27. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  28. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  29. package/manifests/valkey/statefulset.yaml +1 -1
  30. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  31. package/package.json +27 -12
  32. package/scripts/ipxe-setup.sh +52 -49
  33. package/scripts/k3s-node-setup.sh +81 -46
  34. package/scripts/lxd-vm-setup.sh +193 -8
  35. package/scripts/maas-nat-firewalld.sh +145 -0
  36. package/src/api/core/core.router.js +19 -14
  37. package/src/api/core/core.service.js +5 -5
  38. package/src/api/default/default.router.js +22 -18
  39. package/src/api/default/default.service.js +5 -5
  40. package/src/api/document/document.router.js +28 -23
  41. package/src/api/document/document.service.js +100 -23
  42. package/src/api/file/file.router.js +19 -13
  43. package/src/api/file/file.service.js +9 -7
  44. package/src/api/test/test.router.js +17 -12
  45. package/src/api/types.js +24 -0
  46. package/src/api/user/guest.service.js +5 -4
  47. package/src/api/user/user.router.js +297 -288
  48. package/src/api/user/user.service.js +100 -35
  49. package/src/cli/baremetal.js +132 -101
  50. package/src/cli/cluster.js +700 -232
  51. package/src/cli/db.js +59 -60
  52. package/src/cli/deploy.js +216 -137
  53. package/src/cli/fs.js +13 -3
  54. package/src/cli/index.js +80 -15
  55. package/src/cli/ipfs.js +4 -6
  56. package/src/cli/kubectl.js +4 -1
  57. package/src/cli/lxd.js +1099 -223
  58. package/src/cli/monitor.js +9 -3
  59. package/src/cli/release.js +334 -140
  60. package/src/cli/repository.js +68 -23
  61. package/src/cli/run.js +191 -47
  62. package/src/cli/secrets.js +11 -2
  63. package/src/cli/test.js +9 -3
  64. package/src/client/Default.index.js +9 -3
  65. package/src/client/components/core/Auth.js +5 -0
  66. package/src/client/components/core/ClientEvents.js +76 -0
  67. package/src/client/components/core/EventBus.js +4 -0
  68. package/src/client/components/core/Modal.js +82 -41
  69. package/src/client/components/core/PanelForm.js +56 -52
  70. package/src/client/components/core/Worker.js +162 -363
  71. package/src/client/sw/core.sw.js +174 -112
  72. package/src/db/DataBaseProvider.js +115 -15
  73. package/src/db/mariadb/MariaDB.js +2 -1
  74. package/src/db/mongo/MongoBootstrap.js +657 -0
  75. package/src/db/mongo/MongooseDB.js +129 -21
  76. package/src/index.js +1 -1
  77. package/src/runtime/express/Express.js +2 -2
  78. package/src/runtime/wp/Wp.js +8 -5
  79. package/src/server/auth.js +2 -2
  80. package/src/server/client-build-docs.js +1 -1
  81. package/src/server/client-build.js +94 -129
  82. package/src/server/conf.js +81 -79
  83. package/src/server/process.js +180 -19
  84. package/src/server/proxy.js +9 -2
  85. package/src/server/runtime.js +1 -1
  86. package/src/server/start.js +16 -4
  87. package/src/server/valkey.js +2 -0
  88. package/src/ws/IoInterface.js +16 -16
  89. package/src/ws/core/channels/core.ws.chat.js +11 -11
  90. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  91. package/src/ws/core/channels/core.ws.stream.js +19 -19
  92. package/src/ws/core/core.ws.connection.js +8 -8
  93. package/src/ws/core/core.ws.server.js +6 -5
  94. package/src/ws/default/channels/default.ws.main.js +10 -10
  95. package/src/ws/default/default.ws.connection.js +4 -4
  96. package/src/ws/default/default.ws.server.js +4 -3
  97. package/bin/file.js +0 -202
  98. package/bin/vs.js +0 -74
  99. package/bin/zed.js +0 -84
  100. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  101. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  102. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  103. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  104. /package/src/client/ssr/{pages → views}/Test.js +0 -0
package/src/cli/deploy.js CHANGED
@@ -127,6 +127,7 @@ class UnderpostDeploy {
127
127
  * @param {Array<string>} cmd - Command to run in the deployment container.
128
128
  * @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
129
129
  * @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
+ * @param {string} [imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`). When omitted, defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
130
131
  * @returns {string} - YAML deployment configuration for the specified deployment.
131
132
  * @memberof UnderpostDeploy
132
133
  */
@@ -142,6 +143,17 @@ class UnderpostDeploy {
142
143
  cmd,
143
144
  skipFullBuild,
144
145
  pullBundle,
146
+ imagePullPolicy,
147
+ // K8S lifecycle + probe wiring. Pass-through structures shaped like the
148
+ // upstream Kubernetes API, spliced verbatim into the container spec.
149
+ // lifecycle: { postStart: { exec: { command: [...] } }, preStop: { exec: { command: [...] } } }
150
+ // readinessProbe: { tcpSocket: { port: 8081 }, ... }
151
+ // livenessProbe: { tcpSocket: { port: 8081 }, ... }
152
+ // containerPort: integer; rendered as ports[0].containerPort. Optional.
153
+ lifecycle,
154
+ readinessProbe,
155
+ livenessProbe,
156
+ containerPort,
145
157
  }) {
146
158
  if (!cmd)
147
159
  cmd =
@@ -188,26 +200,60 @@ spec:
188
200
  containers:
189
201
  - name: ${deployId}-${env}-${suffix}
190
202
  image: ${containerImage}
191
- imagePullPolicy: ${containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
203
+ imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
192
204
  envFrom:
193
205
  - secretRef:
194
206
  name: underpost-config
195
207
  ${
196
- resources
197
- ? ` resources:
208
+ containerPort
209
+ ? ` ports:
210
+ - containerPort: ${containerPort}
211
+ `
212
+ : ''
213
+ }${
214
+ resources
215
+ ? ` resources:
198
216
  requests:
199
217
  memory: "${resources.requests.memory}"
200
218
  cpu: "${resources.requests.cpu}"
201
219
  limits:
202
220
  memory: "${resources.limits.memory}"
203
221
  cpu: "${resources.limits.cpu}"`
204
- : ''
205
- }
222
+ : ''
223
+ }
206
224
  command:
207
225
  - /bin/sh
208
226
  - -c
209
227
  - >
210
228
  ${cmd.join(' &&\n ')}
229
+ ${
230
+ readinessProbe
231
+ ? ` readinessProbe:
232
+ ${JSON.stringify(readinessProbe, null, 2)
233
+ .split('\n')
234
+ .map((l) => ' ' + l)
235
+ .join('\n')}
236
+ `
237
+ : ''
238
+ }${
239
+ livenessProbe
240
+ ? ` livenessProbe:
241
+ ${JSON.stringify(livenessProbe, null, 2)
242
+ .split('\n')
243
+ .map((l) => ' ' + l)
244
+ .join('\n')}
245
+ `
246
+ : ''
247
+ }${
248
+ lifecycle
249
+ ? ` lifecycle:
250
+ ${JSON.stringify(lifecycle, null, 2)
251
+ .split('\n')
252
+ .map((l) => ' ' + l)
253
+ .join('\n')}
254
+ `
255
+ : ''
256
+ }
211
257
 
212
258
  ${
213
259
  volumes.length > 0
@@ -248,6 +294,7 @@ spec:
248
294
  * @param {string} [options.traffic] - Traffic status for the deployment.
249
295
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory to generate a pull-bundle startup command.
250
296
  * @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.
251
298
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
252
299
  * @memberof UnderpostDeploy
253
300
  */
@@ -286,6 +333,7 @@ ${Underpost.deploy
286
333
  cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
287
334
  skipFullBuild: options.skipFullBuild,
288
335
  pullBundle: options.pullBundle,
336
+ imagePullPolicy: options.imagePullPolicy,
289
337
  })
290
338
  .replace('{{ports}}', buildKindPorts(fromPort, toPort))}
291
339
  `;
@@ -509,10 +557,15 @@ spec:
509
557
  const hostTest = options?.hostTest
510
558
  ? options.hostTest
511
559
  : Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
560
+ // Missing HTTPProxy is the canonical "no traffic colour set yet" state
561
+ // for blue/green rollouts. silentOnError swallows kubectl's NotFound
562
+ // exit so the function can return null cleanly.
512
563
  const info = shellExec(`sudo kubectl get HTTPProxy/${hostTest} -n ${options.namespace} -o yaml`, {
513
564
  silent: true,
514
565
  stdout: true,
566
+ silentOnError: true,
515
567
  });
568
+ if (!info) return null;
516
569
  return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
517
570
  },
518
571
 
@@ -563,13 +616,11 @@ spec:
563
616
  * @param {string} options.traffic - Traffic status for the deployment.
564
617
  * @param {string} options.replicas - Number of replicas for the deployment.
565
618
  * @param {string} options.node - Node name for resource allocation.
566
- * @param {boolean} options.restoreHosts - Whether to restore the hosts file.
567
619
  * @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
568
620
  * @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
569
621
  * @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
570
622
  * @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
571
623
  * @param {boolean} options.status - Whether to display deployment status.
572
- * @param {boolean} options.etcHosts - Whether to display the /etc/hosts file.
573
624
  * @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
574
625
  * @param {string} [options.namespace] - Kubernetes namespace for the deployment.
575
626
  * @param {string} [options.timeoutResponse] - Timeout response setting for the deployment.
@@ -586,6 +637,7 @@ spec:
586
637
  * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
587
638
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
588
639
  * @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.
589
641
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
590
642
  * @memberof UnderpostDeploy
591
643
  */
@@ -606,13 +658,11 @@ spec:
606
658
  traffic: '',
607
659
  replicas: '',
608
660
  node: '',
609
- restoreHosts: false,
610
661
  disableUpdateDeployment: false,
611
662
  disableUpdateProxy: false,
612
663
  disableDeploymentProxy: false,
613
664
  disableUpdateVolume: false,
614
665
  status: false,
615
- etcHosts: false,
616
666
  disableUpdateUnderpostConfig: false,
617
667
  namespace: '',
618
668
  timeoutResponse: '',
@@ -627,6 +677,7 @@ spec:
627
677
  kubeadm: false,
628
678
  kind: false,
629
679
  gitClean: false,
680
+ imagePullPolicy: '',
630
681
  },
631
682
  ) {
632
683
  const namespace = options.namespace ? options.namespace : 'default';
@@ -695,14 +746,6 @@ EOF`);
695
746
  return;
696
747
  }
697
748
  if (!options.disableUpdateUnderpostConfig) Underpost.deploy.configMap(env);
698
- let renderHosts = '';
699
- let etcHosts = [];
700
- if (options.restoreHosts === true) {
701
- const factoryResult = Underpost.deploy.etcHostFactory(etcHosts);
702
- renderHosts = factoryResult.renderHosts;
703
- logger.info(renderHosts);
704
- return;
705
- }
706
749
 
707
750
  for (const _deployId of deployList.split(',')) {
708
751
  const deployId = _deployId.trim();
@@ -710,6 +753,10 @@ EOF`);
710
753
  if (options.expose === true) {
711
754
  const kindType = options.kindType ? options.kindType : 'svc';
712
755
  const svc = Underpost.kubectl.get(deployId, kindType)[0];
756
+ if (!svc) {
757
+ logger.error(`No ${kindType} found matching '${deployId}', skipping expose`);
758
+ continue;
759
+ }
713
760
  const port = options.exposePort
714
761
  ? parseInt(options.exposePort)
715
762
  : options.port
@@ -759,7 +806,6 @@ EOF`);
759
806
  if (Underpost.deploy.isValidTLSContext({ host, env, options }))
760
807
  shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
761
808
  }
762
- if (!options.remove) etcHosts.push(host);
763
809
  }
764
810
 
765
811
  const manifestsPath =
@@ -780,15 +826,6 @@ EOF`);
780
826
  shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml -n ${namespace}`);
781
827
  }
782
828
  }
783
- if (options.etcHosts === true) {
784
- const factoryResult = Underpost.deploy.etcHostFactory(etcHosts);
785
- renderHosts = factoryResult.renderHosts;
786
- }
787
- if (renderHosts)
788
- logger.info(
789
- `
790
- ` + renderHosts,
791
- );
792
829
  },
793
830
  /**
794
831
  * Checks the status of a deployment.
@@ -801,25 +838,41 @@ EOF`);
801
838
  * @memberof UnderpostDeploy
802
839
  */
803
840
  async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
804
- const cmd = `underpost config get container-status`;
805
841
  const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
806
842
  const readyPods = [];
807
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.
808
849
  for (const pod of pods) {
809
850
  const { NAME } = pod;
810
851
  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}"`, {
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`, {
813
859
  silent: true,
814
860
  disableLog: true,
815
- callback: function (code, stdout, stderr) {
816
- return resolve(JSON.stringify({ code, stdout, stderr }));
817
- },
861
+ stdout: true,
862
+ silentOnError: true,
818
863
  });
819
- });
820
- pod.out = out;
821
- const ready = out.match(`${deployId}-${env}-running-deployment`);
822
- ready ? readyPods.push(pod) : notReadyPods.push(pod);
864
+ podJson = raw ? JSON.parse(raw) : null;
865
+ } catch (_) {
866
+ podJson = null;
867
+ }
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);
823
876
  }
824
877
  return {
825
878
  ready: pods.length > 0 && notReadyPods.length === 0,
@@ -853,6 +906,7 @@ EOF`);
853
906
  * @param {string} options.timeoutIdle - Timeout idle setting for the deployment.
854
907
  * @param {string} options.retryCount - Retry count setting for the deployment.
855
908
  * @param {string} options.retryPerTryTimeout - Retry per-try timeout setting for the deployment.
909
+ * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override; forwarded to the manifest rebuild triggered here.
856
910
  * @memberof UnderpostDeploy
857
911
  */
858
912
  switchTraffic(
@@ -866,12 +920,14 @@ EOF`);
866
920
  timeoutIdle: '',
867
921
  retryCount: '',
868
922
  retryPerTryTimeout: '',
923
+ imagePullPolicy: '',
869
924
  },
870
925
  ) {
871
926
  const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
927
+ const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
872
928
 
873
929
  shellExec(
874
- `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags} ${deployId} ${env}`,
930
+ `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag} ${deployId} ${env}`,
875
931
  );
876
932
 
877
933
  shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
@@ -1070,42 +1126,6 @@ spec:
1070
1126
  storage: 5Gi`;
1071
1127
  },
1072
1128
 
1073
- /**
1074
- * Creates a hosts file for a deployment.
1075
- * @param {Array<string>} hosts - List of hosts to be added to the hosts file.
1076
- * @param {object} options - Options for the hosts file creation.
1077
- * @param {boolean} options.append - Whether to append to the existing hosts file.
1078
- * @returns {object} - Object containing the rendered hosts file.
1079
- * @memberof UnderpostDeploy
1080
- */
1081
- etcHostFactory(hosts = [], options = { append: false }) {
1082
- hosts = hosts.map((host) => {
1083
- try {
1084
- if (!host.startsWith('http')) host = `http://${host}`;
1085
- const hostname = new URL(host).hostname;
1086
- logger.info('Hostname extract valid', { host, hostname });
1087
- return hostname;
1088
- } catch (e) {
1089
- logger.warn('No hostname extract valid', host);
1090
- return host;
1091
- }
1092
- });
1093
- const renderHosts = `127.0.0.1 ${hosts.join(
1094
- ' ',
1095
- )} localhost localhost.localdomain localhost4 localhost4.localdomain4
1096
- ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6`;
1097
-
1098
- if (options && options.append && fs.existsSync(`/etc/hosts`)) {
1099
- fs.writeFileSync(
1100
- `/etc/hosts`,
1101
- fs.readFileSync(`/etc/hosts`, 'utf8') +
1102
- `
1103
- ${renderHosts}`,
1104
- 'utf8',
1105
- );
1106
- } else fs.writeFileSync(`/etc/hosts`, renderHosts, 'utf8');
1107
- return { renderHosts };
1108
- },
1109
1129
  /**
1110
1130
  * Checks if a TLS context is valid.
1111
1131
  * @param {object} options - Options for the check.
@@ -1119,9 +1139,25 @@ ${renderHosts}`,
1119
1139
  env === 'production' &&
1120
1140
  options.cert === true &&
1121
1141
  (!options.certHosts || options.certHosts.split(',').includes(host)),
1122
-
1123
1142
  /**
1124
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
+ *
1125
1161
  * @param {string} deployId - Deployment ID for which the ready status is being monitored.
1126
1162
  * @param {string} env - Environment for which the ready status is being monitored.
1127
1163
  * @param {string} targetTraffic - Target traffic status for the deployment.
@@ -1131,79 +1167,94 @@ ${renderHosts}`,
1131
1167
  * @memberof UnderpostDeploy
1132
1168
  */
1133
1169
  async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
1134
- let checkStatusIteration = 0;
1135
- const checkStatusIterationMsDelay = 1000;
1170
+ const delayMs = 1000;
1136
1171
  const maxIterations = 3000;
1137
1172
  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}`,
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 },
1152
1187
  );
1153
- break;
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;
1154
1193
  }
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
- }
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);
1169
1213
  }
1170
1214
 
1171
- {
1172
- let indexOf = -1;
1173
- for (const pod of result.notReadyPods) {
1174
- indexOf++;
1175
- const { NAME, out } = pod;
1215
+ const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
1176
1216
 
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`;
1217
+ const allPodsStatusReady =
1218
+ allPods.length > 0 && allPods.every((pod) => podStatusCache.get(pod.NAME) === expectedContainerStatus);
1188
1219
 
1189
- console.log(
1190
- 'Target pod:',
1191
- NAME[NAME.match('green') ? 'bgGreen' : 'bgBlue'].bold.black,
1192
- '| Status:',
1193
- lastMsg[NAME].bold.magenta,
1194
- );
1195
- }
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}`);
1196
1251
  }
1197
- await timer(checkStatusIterationMsDelay);
1198
- checkStatusIteration++;
1199
- logger.info(
1200
- `${iteratorTag} | Deployment in progress... | Delay number monitor iterations: ${checkStatusIteration}`,
1201
- );
1202
1252
  }
1203
- logger.info(
1204
- `${iteratorTag} | Deployment ready. | Total delay number monitor iterations: ${checkStatusIteration}`,
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`,
1205
1257
  );
1206
- return result;
1207
1258
  },
1208
1259
 
1209
1260
  /**
@@ -1363,6 +1414,34 @@ ${renderHosts}`,
1363
1414
  return undefined;
1364
1415
  },
1365
1416
 
1417
+ /**
1418
+ * Extracts a non-standard `imagePullPolicy` key from an env-resolved
1419
+ * instance lifecycle block (the convention used in `conf.instances.json`,
1420
+ * where `imagePullPolicy` sits alongside `postStart`/`preStop` for
1421
+ * per-instance ergonomics) and returns a clean lifecycle hash that is
1422
+ * safe to splice into the K8S container spec.
1423
+ *
1424
+ * Returns `{ lifecycle, imagePullPolicy }`:
1425
+ * - `lifecycle` — the input minus `imagePullPolicy`, or `undefined` when
1426
+ * the resulting block is empty.
1427
+ * - `imagePullPolicy` — the extracted value, or `undefined` if absent.
1428
+ *
1429
+ * @param {object|undefined} lifecycle - Env-resolved lifecycle block
1430
+ * (already passed through pickEnv). May be `undefined`.
1431
+ * @returns {{ lifecycle: (object|undefined), imagePullPolicy: (string|undefined) }}
1432
+ * @memberof UnderpostDeploy
1433
+ */
1434
+ extractInstanceImagePullPolicy(lifecycle) {
1435
+ if (!lifecycle || typeof lifecycle !== 'object' || !('imagePullPolicy' in lifecycle)) {
1436
+ return { lifecycle, imagePullPolicy: undefined };
1437
+ }
1438
+ const { imagePullPolicy, ...rest } = lifecycle;
1439
+ return {
1440
+ lifecycle: Object.keys(rest).length > 0 ? rest : undefined,
1441
+ imagePullPolicy,
1442
+ };
1443
+ },
1444
+
1366
1445
  /**
1367
1446
  * Generates timeout flags string for deployment commands.
1368
1447
  * @param {object} options - Options containing timeout settings.
package/src/cli/fs.js CHANGED
@@ -127,7 +127,11 @@ class UnderpostFileStorage {
127
127
  if (options.git === true) {
128
128
  const gitPath = hasPathFilter ? basePath : '.';
129
129
  shellExec(`cd ${gitPath} && git add .`);
130
- shellExec(`underpost cmt ${gitPath} feat`);
130
+ shellExec(`underpost cmt ${gitPath} feat`, {
131
+ silentOnError: true,
132
+ silent: true,
133
+ disableLog: true,
134
+ });
131
135
  }
132
136
 
133
137
  return;
@@ -154,7 +158,9 @@ class UnderpostFileStorage {
154
158
  // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
155
159
  if (options.git === true) {
156
160
  Underpost.repo.initLocalRepo({ path });
157
- shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`);
161
+ shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`, {
162
+ silentOnError: true,
163
+ });
158
164
  }
159
165
  } else {
160
166
  const files =
@@ -173,7 +179,11 @@ class UnderpostFileStorage {
173
179
  Underpost.fs.writeStorageConf(storage, storageConf);
174
180
  if (options.git === true) {
175
181
  shellExec(`cd ${path} && git add .`);
176
- shellExec(`underpost cmt ${path} feat`);
182
+ shellExec(`underpost cmt ${path} feat`, {
183
+ silentOnError: true,
184
+ silent: true,
185
+ disableLog: true,
186
+ });
177
187
  }
178
188
  },
179
189
  /**