underpost 3.2.12 → 3.2.21

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.
package/src/cli/image.js CHANGED
@@ -122,6 +122,63 @@ class UnderpostImage {
122
122
  else if (kubeadm === true) shellExec(`sudo ctr -n k8s.io images import ${tarFile}`);
123
123
  else if (k3s === true) shellExec(`sudo k3s ctr images import ${tarFile}`);
124
124
  },
125
+ /**
126
+ * @method getCurrentLoaded
127
+ * @description Retrieves the currently loaded images in the Kubernetes cluster.
128
+ * @param {string} [node='kind-worker'] - Node name to check for loaded images.
129
+ * @param {object} options - Options for the image retrieval.
130
+ * @param {boolean} options.spec - Whether to retrieve images from the pod specifications.
131
+ * @param {string} options.namespace - Kubernetes namespace to filter pods.
132
+ * @returns {Array<object>} - Array of objects containing pod names and their corresponding images.
133
+ * @memberof UnderpostImage
134
+ */
135
+ getCurrentLoaded(node = 'kind-worker', options = { spec: false, namespace: '' }) {
136
+ if (options.spec) {
137
+ const raw = shellExec(
138
+ `kubectl get pods ${options.namespace ? `--namespace ${options.namespace}` : `--all-namespaces`} -o=jsonpath='{range .items[*]}{"\\n"}{.metadata.namespace}{"/"}{.metadata.name}{":\\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}'`,
139
+ {
140
+ stdout: true,
141
+ silent: true,
142
+ },
143
+ );
144
+ return raw
145
+ .split(`\n`)
146
+ .map((lines) => ({
147
+ pod: lines.split('\t')[0].replaceAll(':', '').trim(),
148
+ image: lines.split('\t')[1] ? lines.split('\t')[1].replaceAll(',', '').trim() : null,
149
+ }))
150
+ .filter((o) => o.image);
151
+ }
152
+ const raw = shellExec(node === 'kind-worker' ? `docker exec -i ${node} crictl images` : `crictl images`, {
153
+ stdout: true,
154
+ silent: true,
155
+ });
156
+
157
+ const heads = raw
158
+ .split(`\n`)[0]
159
+ .split(' ')
160
+ .filter((_r) => _r.trim());
161
+
162
+ const pods = raw
163
+ .split(`\n`)
164
+ .filter((r) => !r.match('IMAGE'))
165
+ .map((r) => r.split(' ').filter((_r) => _r.trim()));
166
+
167
+ const result = [];
168
+
169
+ for (const row of pods) {
170
+ if (row.length === 0) continue;
171
+ const pod = {};
172
+ let index = -1;
173
+ for (const head of heads) {
174
+ if (head in pod) continue;
175
+ index++;
176
+ pod[head] = row[index];
177
+ }
178
+ result.push(pod);
179
+ }
180
+ return result;
181
+ },
125
182
  /**
126
183
  * @method list
127
184
  * @description Lists currently loaded Docker images in the specified Kubernetes cluster node.
@@ -139,10 +196,7 @@ class UnderpostImage {
139
196
  list(options = { nodeName: '', namespace: '', spec: false, log: false, k3s: false, kubeadm: false, kind: false }) {
140
197
  if ((options.kubeadm === true || options.k3s === true) && !options.nodeName)
141
198
  options.nodeName = shellExec('echo $HOSTNAME', { stdout: true, silent: true }).trim();
142
- const list = Underpost.deploy.getCurrentLoadedImages(
143
- options.nodeName ? options.nodeName : 'kind-worker',
144
- options,
145
- );
199
+ const list = Underpost.image.getCurrentLoaded(options.nodeName ? options.nodeName : 'kind-worker', options);
146
200
  if (options.log) console.table(list);
147
201
  return list;
148
202
  },
package/src/cli/index.js CHANGED
@@ -124,6 +124,10 @@ program
124
124
  '--is-remote-repo <url-repo>',
125
125
  'Checks whether a remote Git repository URL is reachable. Prints true or false.',
126
126
  )
127
+ .option(
128
+ '--has-changes',
129
+ 'Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise.',
130
+ )
127
131
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
128
132
  .action(Underpost.repo.commit);
129
133
 
@@ -315,6 +319,12 @@ program
315
319
  .option('--expose', 'Exposes services matching the provided deployment ID list.')
316
320
  .option('--cert', 'Resets TLS/SSL certificate secrets for deployments.')
317
321
  .option('--cert-hosts <hosts>', 'Resets TLS/SSL certificate secrets for specified hosts.')
322
+ .option(
323
+ '--self-signed',
324
+ 'Use a pre-created self-signed TLS secret (kubernetes.io/tls) instead of cert-manager. ' +
325
+ 'The secret must already exist in the namespace with the same name as the host. ' +
326
+ 'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
327
+ )
318
328
  .option('--node <node>', 'Sets optional node for deployment operations.')
319
329
  .option(
320
330
  '--build-manifest',
@@ -332,6 +342,8 @@ program
332
342
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
333
343
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
334
344
  .option('--disable-update-deployment', 'Disables updates to deployments.')
345
+ .option('--disable-runtime-probes', 'Omits the internal-status HTTP probes from generated deployment manifests.')
346
+ .option('--tcp-probes', 'Generates legacy TCP socket probes instead of HTTP internal-status probes (migration).')
335
347
  .option('--disable-update-proxy', 'Disables updates to proxies.')
336
348
  .option('--disable-deployment-proxy', 'Disables proxies of deployments.')
337
349
  .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
@@ -351,6 +363,14 @@ program
351
363
  '--expose-port <port>',
352
364
  'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
353
365
  )
366
+ .option(
367
+ '--expose-local-port <port>',
368
+ 'Sets a different local port for --expose (e.g. 80) while keeping the remote service port. Useful for /etc/hosts local access without specifying a port in the browser.',
369
+ )
370
+ .option(
371
+ '--local-proxy',
372
+ 'Forward all service TCP ports locally and start the Node.js path-routing proxy. Enables full path-based routing (e.g. /wp alongside /) without needing --expose-local-port. Requires --expose.',
373
+ )
354
374
  .option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
355
375
  .option(
356
376
  '--skip-full-build',
@@ -364,6 +384,12 @@ program
364
384
  '--image-pull-policy <policy>',
365
385
  'Override container imagePullPolicy in the generated deployment manifest (Always, IfNotPresent, Never). Defaults to Never for localhost/ images and IfNotPresent otherwise.',
366
386
  )
387
+ .option(
388
+ '--tls',
389
+ 'Enables TLS for the local proxy started by --expose --local-proxy. ' +
390
+ 'The proxy will serve HTTPS on port 443 using self-signed certificates resolved from the local SSL store. ' +
391
+ 'Use together with --expose and --local-proxy.',
392
+ )
367
393
  .description('Manages application deployments, defaulting to deploying development pods.')
368
394
  .action(Underpost.deploy.callback);
369
395
 
@@ -889,6 +915,19 @@ program
889
915
  '--dry-run',
890
916
  'For --build: previews version-bump changes (per-file substitution counts) without writing files or running downstream commands.',
891
917
  )
918
+ .option(
919
+ '--mongo-host <host>',
920
+ 'For --build: override DB_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82:27017").',
921
+ )
922
+ .option('--mongo-user <user>', 'For --build: override DB_USER in the template .env.example for the smoke test.')
923
+ .option(
924
+ '--mongo-password <password>',
925
+ 'For --build: override DB_PASSWORD in the template .env.example for the smoke test.',
926
+ )
927
+ .option(
928
+ '--valkey-host <host>',
929
+ 'For --build: override VALKEY_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82").',
930
+ )
892
931
  .description('Release orchestrator for building new versions and deploying releases of the Underpost CLI.')
893
932
  .action(async (version, options) => {
894
933
  if (options.build) return Underpost.release.build(version, options);
@@ -10,10 +10,19 @@ import {
10
10
  loadConfServerJson,
11
11
  loadCronDeployEnv,
12
12
  etcHostFactory,
13
+ deployRangePortFactory,
13
14
  } from '../server/conf.js';
14
15
  import { loggerFactory } from '../server/logger.js';
16
+ import { timer } from '../client/components/core/CommonJs.js';
17
+ import {
18
+ RUNTIME_STATUS,
19
+ INTERNAL_STATUS_PATH,
20
+ normalizeContainerStatus,
21
+ deployStatusPort,
22
+ } from '../server/runtime-status.js';
15
23
  import axios from 'axios';
16
24
  import fs from 'fs-extra';
25
+ import net from 'node:net';
17
26
  import { shellExec } from '../server/process.js';
18
27
  import Underpost from '../index.js';
19
28
 
@@ -93,13 +102,13 @@ class UnderpostMonitor {
93
102
  }
94
103
 
95
104
  if (options.readyDeployment) {
96
- for (const version of options.versions.split(',')) {
97
- (async () => {
98
- await Underpost.deploy.monitorReadyRunner(deployId, env, version, [], options.namespace, 'underpost');
105
+ await Promise.all(
106
+ options.versions.split(',').map(async (version) => {
107
+ await Underpost.monitor.monitorReadyRunner(deployId, env, version, [], options.namespace);
99
108
  if (options.promote)
100
109
  Underpost.deploy.switchTraffic(deployId, env, version, options.replicas, options.namespace, options);
101
- })();
102
- }
110
+ }),
111
+ );
103
112
  return;
104
113
  }
105
114
 
@@ -227,7 +236,7 @@ class UnderpostMonitor {
227
236
  monitorPodName = undefined;
228
237
  }
229
238
  const checkDeploymentReadyStatus = async () => {
230
- const { ready, notReadyPods, readyPods } = await Underpost.deploy.checkDeploymentReadyStatus(
239
+ const { ready, notReadyPods, readyPods } = await Underpost.monitor.checkDeploymentReadyStatus(
231
240
  deployId,
232
241
  env,
233
242
  traffic,
@@ -272,6 +281,293 @@ class UnderpostMonitor {
272
281
  };
273
282
  return new Promise((...args) => monitorCallBack(...args));
274
283
  },
284
+ /**
285
+ * Checks the status of a deployment.
286
+ * @param {string} deployId - Deployment ID for which the status is being checked.
287
+ * @param {string} env - Environment for which the status is being checked.
288
+ * @param {string} traffic - Current traffic status for the deployment.
289
+ * @param {Array<string>} ignoresNames - List of pod names to ignore.
290
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
291
+ * @returns {object} - Object containing the status of the deployment.
292
+ * @memberof UnderpostMonitor
293
+ */
294
+ async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
295
+ const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
296
+ const readyPods = [];
297
+ const notReadyPods = [];
298
+
299
+ // Readiness signal: the pod's Kubernetes `Ready` condition driven by the
300
+ // container's readinessProbe (TCP socket, HTTP get, or exec). Set by kubelet
301
+ // when the probe passes. A failed or crashing runtime never becomes Ready —
302
+ // kubelet surfaces CrashLoopBackOff and this gate stays closed.
303
+ for (const pod of pods) {
304
+ const { NAME } = pod;
305
+ if (ignoresNames && ignoresNames.find((t) => NAME.trim().toLowerCase().match(t.trim().toLowerCase()))) continue;
306
+
307
+ let podJson = null;
308
+ try {
309
+ // Pod may not exist yet (between deployment apply and pod
310
+ // scheduling). silentOnError lets the monitor loop continue
311
+ // instead of aborting on the transient NotFound exit.
312
+ const raw = shellExec(`sudo kubectl get pod ${NAME} -n ${namespace} -o json`, {
313
+ silent: true,
314
+ disableLog: true,
315
+ stdout: true,
316
+ silentOnError: true,
317
+ });
318
+ podJson = raw ? JSON.parse(raw) : null;
319
+ } catch (_) {
320
+ podJson = null;
321
+ }
322
+ const conditions = podJson?.status?.conditions || [];
323
+ const readyCondition = conditions.find((c) => c.type === 'Ready');
324
+ const k8sReady = readyCondition?.status === 'True';
325
+
326
+ pod.out = JSON.stringify({ k8sReady, condition: readyCondition ?? null });
327
+
328
+ if (k8sReady) readyPods.push(pod);
329
+ else notReadyPods.push(pod);
330
+ }
331
+ const consideredCount = readyPods.length + notReadyPods.length;
332
+ return {
333
+ ready: consideredCount > 0 && notReadyPods.length === 0,
334
+ notReadyPods,
335
+ readyPods,
336
+ };
337
+ },
338
+ /**
339
+ * Resolves a free ephemeral TCP port on the loopback interface, used as the
340
+ * local end of the `kubectl port-forward` tunnel so it never collides with
341
+ * host-local services.
342
+ * @returns {Promise<number>}
343
+ * @memberof UnderpostMonitor
344
+ */
345
+ findFreePort() {
346
+ return new Promise((resolve) => {
347
+ const srv = net.createServer();
348
+ srv.once('error', () => resolve(20000 + Math.floor(Math.random() * 20000)));
349
+ srv.listen(0, '127.0.0.1', () => {
350
+ const { port } = srv.address();
351
+ srv.close(() => resolve(port));
352
+ });
353
+ });
354
+ },
355
+ /**
356
+ * Resolves the deployment's internal status port (Phase-2 transport target).
357
+ *
358
+ * Canonical value is `fromPort - 1` from the deployment router — the exact
359
+ * port `buildManifest` injects into the pod (UNDERPOST_INTERNAL_PORT) and
360
+ * uses for the probes — so the tunnel target always matches the in-pod bind.
361
+ * `UNDERPOST_INTERNAL_PORT` overrides; ambient resolution is the last resort.
362
+ *
363
+ * @param {string} deployId
364
+ * @param {string} env
365
+ * @returns {Promise<number>}
366
+ * @memberof UnderpostMonitor
367
+ */
368
+ async deployInternalPort(deployId, env) {
369
+ const override = parseInt(process.env.UNDERPOST_INTERNAL_PORT);
370
+ if (!Number.isNaN(override)) return override;
371
+ try {
372
+ const router = await Underpost.deploy.routerFactory(deployId, env);
373
+ const { fromPort } = deployRangePortFactory(router);
374
+ if (Number.isFinite(fromPort) && fromPort > 0) return fromPort - 1;
375
+ } catch (_) {
376
+ /* fall through to ambient resolution */
377
+ }
378
+ return deployStatusPort(deployId, env) ?? 3000;
379
+ },
380
+ /**
381
+ * Reads Phase-2 runtime readiness from a single pod over HTTP, tunneling
382
+ * through `kubectl port-forward` to the in-pod internal status endpoint.
383
+ *
384
+ * Transport failures (port-forward down, connection refused, HTTP error)
385
+ * are reported as `{ ok: false }` and must never be interpreted as success
386
+ * by callers — they are retried, not promoted. A reachable endpoint returns
387
+ * `{ ok: true, status }` with the normalized runtime contract value.
388
+ *
389
+ * @param {string} podName
390
+ * @param {string} namespace
391
+ * @param {number} internalPort
392
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
393
+ * @memberof UnderpostMonitor
394
+ */
395
+ async readRuntimeStatus(podName, namespace, internalPort) {
396
+ // The local side of the tunnel MUST be an ephemeral free port: pinning it
397
+ // to internalPort collides with any host-local service on that number
398
+ // (e.g. a dev runtime on the same machine as the cluster), which makes
399
+ // port-forward fail to bind and every read return a false transport error.
400
+ const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
401
+ const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
402
+ const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
403
+ let portForward;
404
+ try {
405
+ // `exec` collapses the shell so the tracked child PID is the
406
+ // sudo/kubectl process, letting the SIGTERM teardown reach the tunnel.
407
+ portForward = shellExec(
408
+ `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace}`,
409
+ { async: true, silent: true, disableLog: true, silentOnError: true },
410
+ );
411
+ } catch (_) {
412
+ portForward = undefined;
413
+ }
414
+ try {
415
+ let lastError;
416
+ const attempts = parseInt(process.env.UNDERPOST_PF_ATTEMPTS) || 20;
417
+ for (let attempt = 0; attempt < attempts; attempt++) {
418
+ try {
419
+ const res = await axios.get(url, { timeout: 2500 });
420
+ const raw = res?.data?.status ?? null;
421
+ return { ok: true, status: normalizeContainerStatus(raw) ?? raw, payload: res.data };
422
+ } catch (error) {
423
+ lastError = error;
424
+ await timer(350);
425
+ }
426
+ }
427
+ return { ok: false, transportError: lastError?.code || lastError?.message || 'transport_failed' };
428
+ } finally {
429
+ if (portForward && typeof portForward.kill === 'function') {
430
+ try {
431
+ portForward.kill('SIGTERM');
432
+ } catch (_) {
433
+ /* tunnel already gone */
434
+ }
435
+ }
436
+ }
437
+ },
438
+ /**
439
+ * Monitors a deployment to terminal readiness using a deterministic
440
+ * two-phase state machine.
441
+ *
442
+ * Phase 1 (Kubernetes): pod `Ready` condition via `checkDeploymentReadyStatus`.
443
+ * Phase 2 (Runtime): `running-deployment` from the in-pod internal
444
+ * status endpoint, read over HTTP (`readRuntimeStatus`).
445
+ *
446
+ * Contract:
447
+ * - Success requires BOTH phases; runtime readiness is never declared
448
+ * before Kubernetes readiness.
449
+ * - An explicit runtime `error` (or a fatal pod status) transitions
450
+ * immediately to `failed` (throw → CD exit 1).
451
+ * - Transport failures never count as success and never advance state.
452
+ * - `timeout` is a distinct terminal state from `failed`.
453
+ * - Every transition emits a structured, secret-free event.
454
+ *
455
+ * @param {string} deployId - Deployment ID for which the ready status is being monitored.
456
+ * @param {string} env - Environment for which the ready status is being monitored.
457
+ * @param {string} targetTraffic - Target traffic status for the deployment.
458
+ * @param {Array<string>} ignorePods - List of pod names to ignore.
459
+ * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
460
+ * @returns {object} - Object containing the ready status of the deployment.
461
+ * @memberof UnderpostMonitor
462
+ */
463
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
464
+ const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
465
+ const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
466
+ const deploymentId = `${deployId}-${env}-${targetTraffic}`;
467
+ const tag = `[${deploymentId}]`;
468
+ const expectedStatus = RUNTIME_STATUS.RUNNING;
469
+ const internalPort = await Underpost.monitor.deployInternalPort(deployId, env);
470
+ const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
471
+
472
+ const emit = (state, status) =>
473
+ logger.info('deploy-monitor', {
474
+ deployId: deploymentId,
475
+ phase: state.startsWith('runtime') ? 'runtime' : 'kubernetes',
476
+ state,
477
+ status: status ?? null,
478
+ timestamp: new Date().toISOString(),
479
+ });
480
+
481
+ logger.info('Deployment init', { deployId, env, targetTraffic, namespace, internalPort });
482
+ emit('pending');
483
+
484
+ const runtimeStatusCache = new Map();
485
+ const advancedPods = new Set();
486
+
487
+ for (let i = 0; i < maxIterations; i++) {
488
+ const result = await Underpost.monitor.checkDeploymentReadyStatus(
489
+ deployId,
490
+ env,
491
+ targetTraffic,
492
+ ignorePods,
493
+ namespace,
494
+ );
495
+ const allPods = [...result.readyPods, ...result.notReadyPods];
496
+
497
+ if (allPods.length === 0) {
498
+ emit('pending');
499
+ await timer(delayMs);
500
+ continue;
501
+ }
502
+ emit('pod_scheduled');
503
+
504
+ // Phase 1 fatal: a Kubernetes-level pod failure is terminal (failed,
505
+ // not timeout) — fail the CD runner immediately instead of waiting out
506
+ // the full window.
507
+ for (const pod of allPods) {
508
+ const podStatus = (pod.STATUS || '').toLowerCase().trim();
509
+ if (podErrorStates.find((s) => podStatus.includes(s)))
510
+ throw new Error(`Pod ${pod.NAME} has error pod status: ${pod.STATUS}`);
511
+ }
512
+
513
+ const allPodsK8sReady = result.notReadyPods.length === 0;
514
+ if (allPodsK8sReady) emit('pod_ready');
515
+
516
+ // Phase 2: runtime readiness over HTTP. Transport failures neither
517
+ // advance state nor count as success; explicit `error` is terminal.
518
+ let allRuntimeRead = true;
519
+ for (const pod of allPods) {
520
+ if (!pod?.NAME) continue;
521
+ const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort);
522
+ if (!read.ok) {
523
+ allRuntimeRead = false;
524
+ emit('runtime_booting', `transport:${read.transportError}`);
525
+ continue;
526
+ }
527
+ const status = read.status;
528
+ if (status === RUNTIME_STATUS.ERROR) throw new Error(`Pod ${pod.NAME} reported runtime status=error`);
529
+ if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
530
+ throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
531
+ if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
532
+ runtimeStatusCache.set(pod.NAME, status);
533
+ emit('runtime_booting', status);
534
+ }
535
+
536
+ const allRuntimeReady =
537
+ allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
538
+
539
+ for (const pod of allPods) {
540
+ const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
541
+ const podStatus = pod.STATUS || 'Unknown';
542
+ const statusDisplay = status === expectedStatus ? status : `${status} (pending)`;
543
+ console.log(
544
+ 'Target pod:',
545
+ pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
546
+ '| Pod status:',
547
+ podStatus.bold.yellow,
548
+ '| Runtime status:',
549
+ statusDisplay.bold.cyan,
550
+ );
551
+ }
552
+
553
+ // Terminal success requires BOTH phases. runtime_ready cannot precede
554
+ // Kubernetes readiness.
555
+ if (allPodsK8sReady && allRuntimeReady) {
556
+ emit('runtime_ready', expectedStatus);
557
+ logger.info(`${tag} | Deployment ready (K8S Ready + runtime ${expectedStatus})`);
558
+ return result;
559
+ }
560
+
561
+ await timer(delayMs);
562
+ if ((i + 1) % 10 === 0) logger.info(`${tag} | In progress... iteration ${i + 1}`);
563
+ }
564
+
565
+ emit('timeout');
566
+ logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
567
+ throw new Error(
568
+ `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
569
+ );
570
+ },
275
571
  };
276
572
  }
277
573
 
@@ -264,18 +264,29 @@ const ISOLATED_ENV = 'env -i HOME="$HOME" PATH="$PATH" USER="$USER" LOGNAME="$LO
264
264
  *
265
265
  * @returns {boolean} true when the template started cleanly, false otherwise.
266
266
  */
267
- async function buildAndTestTemplate() {
267
+ async function buildAndTestTemplate(opts = {}) {
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
+ fs.removeSync(TEMPLATE_PATH);
272
+ shellExec(`npm run build:template`);
272
273
  shellExec(`node bin run shared-dir ${TEMPLATE_PATH}`);
273
274
 
275
+ const upsertEnvVar = (content, key, value) => {
276
+ const re = new RegExp(`^(${key}=).*`, 'm');
277
+ if (re.test(content)) return content.replace(re, `$1${value}`);
278
+ return `${content.trimEnd()}\n${key}=${value}\n`;
279
+ };
280
+
274
281
  const dhcpHostIp = Dns.getLocalIPv4Address();
275
282
  logger.info(`DHCP host IP for template test: ${dhcpHostIp}`);
276
283
  let envContent = fs.readFileSync(`${TEMPLATE_PATH}/.env.example`, 'utf8');
277
284
  if (dhcpHostIp) envContent = envContent.replace(/127\.0\.0\.1/g, dhcpHostIp);
278
- envContent = envContent.replace(/^ENABLE_FILE_LOGS=.*/m, 'ENABLE_FILE_LOGS=true');
285
+ envContent = upsertEnvVar(envContent, 'ENABLE_FILE_LOGS', 'true');
286
+ if (opts.mongoHost) envContent = upsertEnvVar(envContent, 'DB_HOST', opts.mongoHost);
287
+ if (opts.mongoUser) envContent = upsertEnvVar(envContent, 'DB_USER', opts.mongoUser);
288
+ if (opts.mongoPassword) envContent = upsertEnvVar(envContent, 'DB_PASSWORD', opts.mongoPassword);
289
+ if (opts.valkeyHost) envContent = upsertEnvVar(envContent, 'VALKEY_HOST', opts.valkeyHost);
279
290
  // fs.writeFileSync(`${TEMPLATE_PATH}/.env`, envContent, 'utf8');
280
291
  fs.writeFileSync(`${TEMPLATE_PATH}/.env.example`, envContent, 'utf8');
281
292
  shellExec(`cd ${TEMPLATE_PATH} && npm install`);
@@ -337,7 +348,12 @@ class UnderpostRelease {
337
348
  *
338
349
  * @method build
339
350
  * @param {string} [newVersion] - The new version string to set. Defaults to current version if not provided.
340
- * @param {{dryRun?: boolean}} [options] - Commander options. `--dry-run` previews changes.
351
+ * @param {{dryRun?: boolean, mongoHost?: string, mongoUser?: string, mongoPassword?: string, valkeyHost?: string}} [options] - Commander options.
352
+ * `--dry-run` previews changes without writing files.
353
+ * `--mongo-host` overrides `DB_HOST` in the template `.env.example` smoke test.
354
+ * `--mongo-user` overrides `DB_USER` in the template `.env.example` smoke test.
355
+ * `--mongo-password` overrides `DB_PASSWORD` in the template `.env.example` smoke test.
356
+ * `--valkey-host` overrides `VALKEY_HOST` in the template `.env.example` smoke test.
341
357
  * @memberof UnderpostRelease
342
358
  */
343
359
  async build(newVersion, options = {}) {
@@ -352,7 +368,7 @@ class UnderpostRelease {
352
368
  logger.info(`Release build — bumping ${version} → ${newVersion}${dryRun ? ' (dry-run)' : ''}`);
353
369
 
354
370
  if (!dryRun) {
355
- const templateOk = await buildAndTestTemplate();
371
+ const templateOk = await buildAndTestTemplate(options);
356
372
  if (!templateOk) return;
357
373
  }
358
374
 
@@ -390,9 +406,8 @@ class UnderpostRelease {
390
406
  shellExec(`node bin/deploy cli-docs ${version} ${newVersion}`);
391
407
  shellExec(`node bin/deploy update-dependencies`);
392
408
  shellExec(`node bin/build dd`);
393
- shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd production`);
394
- shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd development`);
395
- shellExec(`node bin/deploy build-default-confs`);
409
+ shellExec(`node bin run build-cluster-deployment-manifests`);
410
+ shellExec(`node bin new --default-conf --conf-workflow-id template`);
396
411
  shellExec(`sudo rm -rf ./engine-private/conf/dd-default`);
397
412
  shellExec(`node bin new --deploy-id dd-default`);
398
413
  console.log(fs.existsSync(`./engine-private/conf/dd-default`));
@@ -460,7 +475,7 @@ class UnderpostRelease {
460
475
  * Runs the pwa-microservices-template update and push flow locally.
461
476
  *
462
477
  * Always removes and re-clones pwa-microservices-template, then:
463
- * 1. Runs update:template (node bin/build.template) to sync engine sources.
478
+ * 1. Runs build:template (node bin/build.template) to sync engine sources.
464
479
  * 2. Installs dependencies and builds the template.
465
480
  * 3. Commits and pushes to the pwa-microservices-template remote repository.
466
481
  *
@@ -488,7 +503,7 @@ class UnderpostRelease {
488
503
  shellExec(`sudo rm -rf /home/dd/pwa-microservices-template`);
489
504
  shellExec(`node engine/bin clone ${githubOrg}/pwa-microservices-template`);
490
505
  shellCd('/home/dd/engine');
491
- shellExec(`npm run update:template`);
506
+ shellExec(`npm run build:template`);
492
507
  shellExec(`cd ../pwa-microservices-template && npm install && npm run build`);
493
508
  shellCd('/home/dd/pwa-microservices-template');
494
509
  shellExec(`git add .`);
@@ -520,7 +535,7 @@ class UnderpostRelease {
520
535
  shellExec(
521
536
  `node bin secret underpost --create-from-file /home/dd/engine/engine-private/conf/dd-cron/.env.production`,
522
537
  );
523
- shellExec(`node bin/build dd conf`);
538
+ shellExec(`node bin/build dd --conf`);
524
539
  shellExec(`git add . && cd ./engine-private && git add .`);
525
540
  shellExec(`node bin cmt . ci package-pwa-microservices-template 'New release v:${version}'`);
526
541
  shellExec(`node bin cmt ./engine-private ci package-pwa-microservices-template`);