underpost 3.2.12 → 3.2.14

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.
@@ -12,6 +12,7 @@ import {
12
12
  etcHostFactory,
13
13
  } from '../server/conf.js';
14
14
  import { loggerFactory } from '../server/logger.js';
15
+ import { timer } from '../client/components/core/CommonJs.js';
15
16
  import axios from 'axios';
16
17
  import fs from 'fs-extra';
17
18
  import { shellExec } from '../server/process.js';
@@ -93,13 +94,13 @@ class UnderpostMonitor {
93
94
  }
94
95
 
95
96
  if (options.readyDeployment) {
96
- for (const version of options.versions.split(',')) {
97
- (async () => {
98
- await Underpost.deploy.monitorReadyRunner(deployId, env, version, [], options.namespace, 'underpost');
97
+ await Promise.all(
98
+ options.versions.split(',').map(async (version) => {
99
+ await Underpost.monitor.monitorReadyRunner(deployId, env, version, [], options.namespace);
99
100
  if (options.promote)
100
101
  Underpost.deploy.switchTraffic(deployId, env, version, options.replicas, options.namespace, options);
101
- })();
102
- }
102
+ }),
103
+ );
103
104
  return;
104
105
  }
105
106
 
@@ -227,7 +228,7 @@ class UnderpostMonitor {
227
228
  monitorPodName = undefined;
228
229
  }
229
230
  const checkDeploymentReadyStatus = async () => {
230
- const { ready, notReadyPods, readyPods } = await Underpost.deploy.checkDeploymentReadyStatus(
231
+ const { ready, notReadyPods, readyPods } = await Underpost.monitor.checkDeploymentReadyStatus(
231
232
  deployId,
232
233
  env,
233
234
  traffic,
@@ -272,6 +273,189 @@ class UnderpostMonitor {
272
273
  };
273
274
  return new Promise((...args) => monitorCallBack(...args));
274
275
  },
276
+ /**
277
+ * Checks the status of a deployment.
278
+ * @param {string} deployId - Deployment ID for which the status is being checked.
279
+ * @param {string} env - Environment for which the status is being checked.
280
+ * @param {string} traffic - Current traffic status for the deployment.
281
+ * @param {Array<string>} ignoresNames - List of pod names to ignore.
282
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
283
+ * @returns {object} - Object containing the status of the deployment.
284
+ * @memberof UnderpostMonitor
285
+ */
286
+ async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
287
+ const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
288
+ const readyPods = [];
289
+ const notReadyPods = [];
290
+
291
+ // Readiness signal: the pod's Kubernetes `Ready` condition driven by the
292
+ // container's readinessProbe (TCP socket, HTTP get, or exec). Set by kubelet
293
+ // when the probe passes. A failed or crashing runtime never becomes Ready —
294
+ // kubelet surfaces CrashLoopBackOff and this gate stays closed.
295
+ for (const pod of pods) {
296
+ const { NAME } = pod;
297
+ if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
298
+
299
+ let podJson = null;
300
+ try {
301
+ // Pod may not exist yet (between deployment apply and pod
302
+ // scheduling). silentOnError lets the monitor loop continue
303
+ // instead of aborting on the transient NotFound exit.
304
+ const raw = shellExec(`sudo kubectl get pod ${NAME} -n ${namespace} -o json`, {
305
+ silent: true,
306
+ disableLog: true,
307
+ stdout: true,
308
+ silentOnError: true,
309
+ });
310
+ podJson = raw ? JSON.parse(raw) : null;
311
+ } catch (_) {
312
+ podJson = null;
313
+ }
314
+ const conditions = podJson?.status?.conditions || [];
315
+ const readyCondition = conditions.find((c) => c.type === 'Ready');
316
+ const k8sReady = readyCondition?.status === 'True';
317
+
318
+ pod.out = JSON.stringify({ k8sReady, condition: readyCondition ?? null });
319
+
320
+ if (k8sReady) readyPods.push(pod);
321
+ else notReadyPods.push(pod);
322
+ }
323
+ const consideredCount = readyPods.length + notReadyPods.length;
324
+ return {
325
+ ready: consideredCount > 0 && notReadyPods.length === 0,
326
+ notReadyPods,
327
+ readyPods,
328
+ };
329
+ },
330
+ /**
331
+ * Monitors the ready status of a deployment.
332
+ *
333
+ * Ready signal:
334
+ * The orchestrator gate is the Kubernetes pod Ready condition. When the
335
+ * container's `readinessProbe` succeeds, kubelet flips
336
+ * `status.conditions[Ready]` to True and `checkDeploymentReadyStatus`
337
+ * returns the pod in `readyPods`. This is the only required signal — see
338
+ * `src/client/public/nexodev/docs/references/Deploy custom instance to K8S.md`.
339
+ *
340
+ * Container-status:
341
+ * `underpost config get container-status` is read from each pod for both
342
+ * the display column and as a second ready gate alongside the K8S Ready
343
+ * condition. Both must be satisfied before the monitor exits:
344
+ * 1. K8S readinessProbe (TCP socket) — ensures the port is bound.
345
+ * 2. container-status == `<deploy>-<env>-running-deployment` — ensures
346
+ * the application has completed its own startup sequence.
347
+ * Early-abort on `error` container-status remains in effect: a failing
348
+ * runtime keeps its pod alive (not Ready) with `container-status=error`,
349
+ * so this `exec`-read surfaces the failure and the monitor aborts —
350
+ * failing the CD runner instead of waiting out the full timeout.
351
+ *
352
+ * @param {string} deployId - Deployment ID for which the ready status is being monitored.
353
+ * @param {string} env - Environment for which the ready status is being monitored.
354
+ * @param {string} targetTraffic - Target traffic status for the deployment.
355
+ * @param {Array<string>} ignorePods - List of pod names to ignore.
356
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
357
+ * @returns {object} - Object containing the ready status of the deployment.
358
+ * @memberof UnderpostMonitor
359
+ */
360
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
361
+ const delayMs = 1000;
362
+ const maxIterations = 3000;
363
+ const deploymentId = `${deployId}-${env}-${targetTraffic}`;
364
+ const expectedContainerStatus = `${deployId}-${env}-running-deployment`;
365
+ const tag = `[${deploymentId}]`;
366
+ const containerStatusDefault = 'waiting for status';
367
+
368
+ logger.info('Deployment init', { deployId, env, targetTraffic, namespace });
369
+
370
+ const podStatusCache = new Map();
371
+ const advancedPods = new Set();
372
+
373
+ const readContainerStatus = (podName) => {
374
+ try {
375
+ const raw = shellExec(
376
+ `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
377
+ { silent: true, disableLog: true, stdout: true, silentOnError: true },
378
+ );
379
+ const val = raw ? raw.toString().trim() : '';
380
+ return val && val !== 'undefined' ? val : containerStatusDefault;
381
+ } catch (_) {
382
+ // exec failed (e.g. pod not yet running) — preserve last known value
383
+ return podStatusCache.get(podName) || containerStatusDefault;
384
+ }
385
+ };
386
+
387
+ for (let i = 0; i < maxIterations; i++) {
388
+ const result = await Underpost.monitor.checkDeploymentReadyStatus(
389
+ deployId,
390
+ env,
391
+ targetTraffic,
392
+ ignorePods,
393
+ namespace,
394
+ );
395
+
396
+ const allPods = [...result.readyPods, ...result.notReadyPods];
397
+
398
+ for (const pod of allPods) {
399
+ if (!pod?.NAME) continue;
400
+ const podStatus = (pod.STATUS || '').toLowerCase().trim();
401
+ if (
402
+ ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'].find((s) =>
403
+ podStatus.match(s),
404
+ )
405
+ )
406
+ throw new Error(`Pod ${pod.NAME} has error pod status: ${pod.STATUS}`);
407
+ const status = readContainerStatus(pod.NAME);
408
+ if (status === 'error') throw new Error(`Pod ${pod.NAME} has error container-status`);
409
+ if (advancedPods.has(pod.NAME) && status === containerStatusDefault)
410
+ throw new Error(`Pod ${pod.NAME} container-status regressed to default — pod likely restarted`);
411
+ if (status !== containerStatusDefault) advancedPods.add(pod.NAME);
412
+ podStatusCache.set(pod.NAME, status);
413
+ }
414
+
415
+ const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
416
+
417
+ const allPodsStatusReady =
418
+ allPods.length > 0 && allPods.every((pod) => podStatusCache.get(pod.NAME) === expectedContainerStatus);
419
+
420
+ // Print snapshot for every pod — annotate when container-status hasn't caught
421
+ // up to the K8S Ready condition yet.
422
+ for (const pod of allPods) {
423
+ const status = podStatusCache.get(pod.NAME) || containerStatusDefault;
424
+ const podStatus = pod.STATUS || 'Unknown';
425
+ const statusMatchesExpected = status === expectedContainerStatus;
426
+ const statusDisplay = statusMatchesExpected ? status : `${status} (pending)`;
427
+
428
+ console.log(
429
+ 'Target pod:',
430
+ pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
431
+ '| Pod status:',
432
+ podStatus.bold.yellow,
433
+ '| Runtime status:',
434
+ statusDisplay.bold.cyan,
435
+ );
436
+ }
437
+
438
+ // Both K8S readinessProbe AND container-status must be satisfied before
439
+ // declaring the deployment ready. The TCP probe ensures the port is bound;
440
+ // container-status == running-deployment ensures the application has
441
+ // completed its own startup sequence so traffic is not switched prematurely.
442
+ if (allPodsK8sReady && allPodsStatusReady) {
443
+ logger.info(`${tag} | All pods Ready (K8S readinessProbe satisfied)`);
444
+ return result;
445
+ }
446
+
447
+ await timer(delayMs);
448
+
449
+ if ((i + 1) % 10 === 0) {
450
+ logger.info(`${tag} | In progress... iteration ${i + 1}`);
451
+ }
452
+ }
453
+
454
+ logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
455
+ throw new Error(
456
+ `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
457
+ );
458
+ },
275
459
  };
276
460
  }
277
461
 
@@ -268,7 +268,7 @@ async function buildAndTestTemplate() {
268
268
  killDevServers();
269
269
  Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
270
270
  shellExec(`node bin pull . ${process.env.GITHUB_USERNAME}/engine`);
271
- shellExec(`npm run update:template`);
271
+ shellExec(`npm run build:template`);
272
272
  shellExec(`node bin run shared-dir ${TEMPLATE_PATH}`);
273
273
 
274
274
  const dhcpHostIp = Dns.getLocalIPv4Address();
@@ -392,7 +392,7 @@ class UnderpostRelease {
392
392
  shellExec(`node bin/build dd`);
393
393
  shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd production`);
394
394
  shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd development`);
395
- shellExec(`node bin/deploy build-default-confs`);
395
+ shellExec(`node bin new --default-conf --conf-workflow-id template`);
396
396
  shellExec(`sudo rm -rf ./engine-private/conf/dd-default`);
397
397
  shellExec(`node bin new --deploy-id dd-default`);
398
398
  console.log(fs.existsSync(`./engine-private/conf/dd-default`));
@@ -460,7 +460,7 @@ class UnderpostRelease {
460
460
  * Runs the pwa-microservices-template update and push flow locally.
461
461
  *
462
462
  * Always removes and re-clones pwa-microservices-template, then:
463
- * 1. Runs update:template (node bin/build.template) to sync engine sources.
463
+ * 1. Runs build:template (node bin/build.template) to sync engine sources.
464
464
  * 2. Installs dependencies and builds the template.
465
465
  * 3. Commits and pushes to the pwa-microservices-template remote repository.
466
466
  *
@@ -488,7 +488,7 @@ class UnderpostRelease {
488
488
  shellExec(`sudo rm -rf /home/dd/pwa-microservices-template`);
489
489
  shellExec(`node engine/bin clone ${githubOrg}/pwa-microservices-template`);
490
490
  shellCd('/home/dd/engine');
491
- shellExec(`npm run update:template`);
491
+ shellExec(`npm run build:template`);
492
492
  shellExec(`cd ../pwa-microservices-template && npm install && npm run build`);
493
493
  shellCd('/home/dd/pwa-microservices-template');
494
494
  shellExec(`git add .`);
@@ -520,7 +520,7 @@ class UnderpostRelease {
520
520
  shellExec(
521
521
  `node bin secret underpost --create-from-file /home/dd/engine/engine-private/conf/dd-cron/.env.production`,
522
522
  );
523
- shellExec(`node bin/build dd conf`);
523
+ shellExec(`node bin/build dd --conf`);
524
524
  shellExec(`git add . && cd ./engine-private && git add .`);
525
525
  shellExec(`node bin cmt . ci package-pwa-microservices-template 'New release v:${version}'`);
526
526
  shellExec(`node bin cmt ./engine-private ci package-pwa-microservices-template`);
@@ -556,7 +556,7 @@ class UnderpostRepository {
556
556
  // Handle sync-conf operation
557
557
  if (options.syncConf) {
558
558
  logger.info(`Syncing configuration for deploy ID: ${deployId}`);
559
- shellExec(`node bin/build ${deployId} conf`);
559
+ shellExec(`node bin/build ${deployId} --conf`);
560
560
  logger.info('Configuration synced successfully');
561
561
  return resolve(true);
562
562
  }
@@ -852,6 +852,7 @@ class UnderpostRepository {
852
852
  }
853
853
  }
854
854
  await buildClient({
855
+ deployId: resolvedDeployId,
855
856
  buildZip: options.buildZip || false,
856
857
  split: options.split || '',
857
858
  fullBuild: options.liteBuild ? false : true,
@@ -862,7 +863,12 @@ class UnderpostRepository {
862
863
  logger.warn('Skip replica client build: replica folder not found', { replicaDeployId });
863
864
  continue;
864
865
  }
865
- await Underpost.repo.client(replicaDeployId);
866
+ await Underpost.repo.client(replicaDeployId, '', '', '', {
867
+ buildZip: options.buildZip || false,
868
+ split: options.split || '',
869
+ liteBuild: options.liteBuild || false,
870
+ iconsBuild: options.iconsBuild || false,
871
+ });
866
872
  }
867
873
 
868
874
  return resolve(true);
@@ -937,7 +943,7 @@ Prevent build private config repo.`,
937
943
  deployVersion: packageJsonDeploy.version,
938
944
  };
939
945
  }
940
- shellExec(`node bin/build ${deployId} conf`);
946
+ shellExec(`node bin/build ${deployId} --conf`);
941
947
  return {
942
948
  validVersion: true,
943
949
  engineVersion: packageJsonEngine.version,
@@ -1653,6 +1659,77 @@ Prevent build private config repo.`,
1653
1659
  }
1654
1660
  return fallback;
1655
1661
  },
1662
+
1663
+ /**
1664
+ * Performs a shallow sparse Git checkout of a single subdirectory from any
1665
+ * GitHub repository into a local target directory.
1666
+ *
1667
+ * Uses `--depth 1 --no-checkout` + `git sparse-checkout` so only the
1668
+ * requested path is fetched — no full clone of the remote repo.
1669
+ * Skips the clone entirely when `<targetDir>/<subPath>` already exists on
1670
+ * disk (idempotent).
1671
+ *
1672
+ * Requires `GITHUB_TOKEN` to be set in the environment for authenticated
1673
+ * access to private repositories.
1674
+ *
1675
+ * @param {string} subPath - The subdirectory path within the remote repo to
1676
+ * check out (e.g. `'conf/dd-prototype'`, `'src/api/payments'`).
1677
+ * @param {object} [options]
1678
+ * @param {string} [options.repoOwner='underpostnet'] - GitHub organisation or
1679
+ * user that owns the repository.
1680
+ * @param {string} [options.repoName='engine-private'] - Name of the
1681
+ * repository on GitHub.
1682
+ * @param {string} [options.targetDir='./engine-private'] - Local directory
1683
+ * where the repo will be cloned.
1684
+ * @returns {boolean} `true` when the checkout was performed, `false` when it
1685
+ * was skipped because the target path already existed.
1686
+ * @memberof UnderpostRepository
1687
+ */
1688
+ sparseCheckoutDirectory(
1689
+ subPath,
1690
+ options = { repoOwner: 'underpostnet', repoName: 'engine-private', targetDir: './engine-private' },
1691
+ ) {
1692
+ const { repoOwner = 'underpostnet', repoName = 'engine-private', targetDir = './engine-private' } = options;
1693
+ const localPath = `${targetDir}/${subPath}`;
1694
+ if (fs.existsSync(localPath)) {
1695
+ logger.info('[sparseCheckoutDirectory] path already present, skipping', localPath);
1696
+ return false;
1697
+ }
1698
+ const authUrl = `https://${process.env.GITHUB_TOKEN}@github.com/${repoOwner}/${repoName}.git`;
1699
+ shellExec(`git clone --depth 1 --no-checkout ${authUrl} ${targetDir}`, { disableLog: true });
1700
+ shellExec(`cd ${targetDir} && git sparse-checkout set ${subPath} && git checkout`, { disableLog: true });
1701
+ logger.info('[sparseCheckoutDirectory] sparse checkout complete', localPath);
1702
+ return true;
1703
+ },
1704
+
1705
+ /**
1706
+ * Ensures a deploy's public source repo (e.g. `engine-prototype`) is present
1707
+ * next to the engine and reset to a pristine HEAD, so catalog `sourceMoves`
1708
+ * can (re)pull custom sources even after a previous build moved them out of
1709
+ * the source tree.
1710
+ *
1711
+ * Clones `../<repoName>` when missing; otherwise restores a clean checkout
1712
+ * (`git checkout .` brings back any moved-out tracked files) and pulls latest.
1713
+ * Mirrors the sibling-repo handling used by `syncPrivateConf`.
1714
+ *
1715
+ * @param {string} repoName - Public source repo name (e.g. `engine-prototype`).
1716
+ * @returns {boolean} `true` when the repo is available on disk.
1717
+ * @memberof UnderpostRepository
1718
+ */
1719
+ pullSourceRepo(repoName) {
1720
+ const username = process.env.GITHUB_USERNAME;
1721
+ if (!username || !repoName) return false;
1722
+ const repoPath = `../${repoName}`;
1723
+ const gitUri = `${username}/${repoName}`;
1724
+ if (!fs.existsSync(repoPath)) {
1725
+ shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
1726
+ } else {
1727
+ shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
1728
+ silent: true,
1729
+ });
1730
+ }
1731
+ return fs.existsSync(repoPath);
1732
+ },
1656
1733
  };
1657
1734
  }
1658
1735