underpost 3.2.14 → 3.2.22

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/env.js CHANGED
@@ -95,10 +95,7 @@ class UnderpostRootEnv {
95
95
  get(key, value, options = { plain: false, disableLog: false, copy: false }) {
96
96
  const exeRootPath = `${getNpmRootPath()}/underpost`;
97
97
  const envPath = `${exeRootPath}/.env`;
98
- if (!fs.existsSync(envPath) || !fs.statSync(envPath).isFile()) {
99
- logger.warn(`Empty environment variables`);
100
- return undefined;
101
- }
98
+ if (!fs.existsSync(envPath) || !fs.statSync(envPath).isFile()) return undefined;
102
99
  const env = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
103
100
  if (!options.disableLog)
104
101
  options?.plain === true ? console.log(env[key]) : logger.info(`${key}(${typeof env[key]})`, env[key]);
package/src/cli/index.js CHANGED
@@ -70,6 +70,10 @@ program
70
70
  '--pull-bundle',
71
71
  'Downloads the pre-built client bundle from Cloudinary via pull-bundle before starting. Use together with --skip-full-build to skip the local build entirely.',
72
72
  )
73
+ .option(
74
+ '--private-test-repo',
75
+ 'During --build, clone the private test source repo (engine-test-<id>) instead of the production engine-<id> repo.',
76
+ )
73
77
  .action(Underpost.start.callback)
74
78
  .description('Initiates application servers, build pipelines, or other defined services based on the deployment ID.');
75
79
 
@@ -124,6 +128,10 @@ program
124
128
  '--is-remote-repo <url-repo>',
125
129
  'Checks whether a remote Git repository URL is reachable. Prints true or false.',
126
130
  )
131
+ .option(
132
+ '--has-changes',
133
+ 'Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise.',
134
+ )
127
135
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
128
136
  .action(Underpost.repo.commit);
129
137
 
@@ -315,6 +323,12 @@ program
315
323
  .option('--expose', 'Exposes services matching the provided deployment ID list.')
316
324
  .option('--cert', 'Resets TLS/SSL certificate secrets for deployments.')
317
325
  .option('--cert-hosts <hosts>', 'Resets TLS/SSL certificate secrets for specified hosts.')
326
+ .option(
327
+ '--self-signed',
328
+ 'Use a pre-created self-signed TLS secret (kubernetes.io/tls) instead of cert-manager. ' +
329
+ 'The secret must already exist in the namespace with the same name as the host. ' +
330
+ 'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
331
+ )
318
332
  .option('--node <node>', 'Sets optional node for deployment operations.')
319
333
  .option(
320
334
  '--build-manifest',
@@ -332,6 +346,8 @@ program
332
346
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
333
347
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
334
348
  .option('--disable-update-deployment', 'Disables updates to deployments.')
349
+ .option('--disable-runtime-probes', 'Omits the internal-status HTTP probes from generated deployment manifests.')
350
+ .option('--tcp-probes', 'Generates legacy TCP socket probes instead of HTTP internal-status probes (migration).')
335
351
  .option('--disable-update-proxy', 'Disables updates to proxies.')
336
352
  .option('--disable-deployment-proxy', 'Disables proxies of deployments.')
337
353
  .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
@@ -351,6 +367,14 @@ program
351
367
  '--expose-port <port>',
352
368
  'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
353
369
  )
370
+ .option(
371
+ '--expose-local-port <port>',
372
+ '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.',
373
+ )
374
+ .option(
375
+ '--local-proxy',
376
+ '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.',
377
+ )
354
378
  .option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
355
379
  .option(
356
380
  '--skip-full-build',
@@ -364,6 +388,12 @@ program
364
388
  '--image-pull-policy <policy>',
365
389
  'Override container imagePullPolicy in the generated deployment manifest (Always, IfNotPresent, Never). Defaults to Never for localhost/ images and IfNotPresent otherwise.',
366
390
  )
391
+ .option(
392
+ '--tls',
393
+ 'Enables TLS for the local proxy started by --expose --local-proxy. ' +
394
+ 'The proxy will serve HTTPS on port 443 using self-signed certificates resolved from the local SSL store. ' +
395
+ 'Use together with --expose and --local-proxy.',
396
+ )
367
397
  .description('Manages application deployments, defaulting to deploying development pods.')
368
398
  .action(Underpost.deploy.callback);
369
399
 
@@ -701,6 +731,10 @@ program
701
731
  'Explicitly download the pre-built client bundle from Cloudinary inside the container (supported by: sync, template-deploy). Use together with --skip-full-build.',
702
732
  )
703
733
  .option('--remove', 'Remove/teardown resources')
734
+ .option(
735
+ '--test',
736
+ 'Enables test/generic-purpose mode for the runner (e.g. use self-signed TLS instead of cert-manager).',
737
+ )
704
738
  .description('Runs specified scripts using various runners.')
705
739
  .action(Underpost.run.callback);
706
740
 
@@ -889,6 +923,19 @@ program
889
923
  '--dry-run',
890
924
  'For --build: previews version-bump changes (per-file substitution counts) without writing files or running downstream commands.',
891
925
  )
926
+ .option(
927
+ '--mongo-host <host>',
928
+ 'For --build: override DB_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82:27017").',
929
+ )
930
+ .option('--mongo-user <user>', 'For --build: override DB_USER in the template .env.example for the smoke test.')
931
+ .option(
932
+ '--mongo-password <password>',
933
+ 'For --build: override DB_PASSWORD in the template .env.example for the smoke test.',
934
+ )
935
+ .option(
936
+ '--valkey-host <host>',
937
+ 'For --build: override VALKEY_HOST in the template .env.example for the smoke test (e.g., "192.168.1.82").',
938
+ )
892
939
  .description('Release orchestrator for building new versions and deploying releases of the Underpost CLI.')
893
940
  .action(async (version, options) => {
894
941
  if (options.build) return Underpost.release.build(version, options);
@@ -10,11 +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';
15
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';
16
23
  import axios from 'axios';
17
24
  import fs from 'fs-extra';
25
+ import net from 'node:net';
18
26
  import { shellExec } from '../server/process.js';
19
27
  import Underpost from '../index.js';
20
28
 
@@ -328,62 +336,230 @@ class UnderpostMonitor {
328
336
  };
329
337
  },
330
338
  /**
331
- * Monitors the ready status of a deployment.
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 status from a single pod using the selected transport.
382
+ *
383
+ * - `exec` (default): `kubectl exec … underpost config get container-status`
384
+ * reads the env-file value. Synchronous, no background process — required
385
+ * for custom instances (cyberia-server/client) and the safe choice for
386
+ * CI/SSH. See `Deploy custom instance to K8S.md`.
387
+ * - `http`: port-forward to the in-pod `/_internal/status` endpoint served
388
+ * by the `underpost start` launcher (dd-* runtime deploys). Opt-in.
332
389
  *
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`.
390
+ * Transport failures are reported as `{ ok: false }` and must never be read
391
+ * as success they are retried, not promoted.
392
+ *
393
+ * @param {string} podName
394
+ * @param {string} namespace
395
+ * @param {number} internalPort
396
+ * @param {('http'|'exec')} [transport='exec']
397
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
398
+ * @memberof UnderpostMonitor
399
+ */
400
+ async readRuntimeStatus(podName, namespace, internalPort, transport = 'exec') {
401
+ return transport === 'exec'
402
+ ? Underpost.monitor.readRuntimeStatusViaExec(podName, namespace)
403
+ : Underpost.monitor.readRuntimeStatusViaHttp(podName, namespace, internalPort);
404
+ },
405
+ /**
406
+ * Phase-2 read over `kubectl exec` (env-file transport). Works for any pod
407
+ * whose image bakes the underpost CLI — notably custom instances that stamp
408
+ * `container-status` from `lifecycle.postStart`/`preStop` hooks.
409
+ * @param {string} podName
410
+ * @param {string} namespace
411
+ * @returns {{ok: boolean, status?: (string|null), transportError?: string}}
412
+ * @memberof UnderpostMonitor
413
+ */
414
+ readRuntimeStatusViaExec(podName, namespace) {
415
+ try {
416
+ const raw = shellExec(
417
+ `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
418
+ { silent: true, disableLog: true, stdout: true, silentOnError: true },
419
+ );
420
+ const status = normalizeContainerStatus(raw ? raw.toString().trim() : '');
421
+ return status === undefined ? { ok: false, transportError: 'empty_status' } : { ok: true, status };
422
+ } catch (error) {
423
+ return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
424
+ }
425
+ },
426
+ /**
427
+ * Phase-2 read over `kubectl port-forward` + HTTP `/_internal/status`.
339
428
  *
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.
429
+ * The local side of the tunnel MUST be an ephemeral free port: pinning it to
430
+ * internalPort collides with any host-local service on that number (e.g. a
431
+ * dev runtime on the same machine as the cluster), making port-forward fail
432
+ * to bind and every read return a false transport error.
433
+ *
434
+ * @param {string} podName
435
+ * @param {string} namespace
436
+ * @param {number} internalPort
437
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
438
+ * @memberof UnderpostMonitor
439
+ */
440
+ async readRuntimeStatusViaHttp(podName, namespace, internalPort) {
441
+ const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
442
+ const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
443
+ const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
444
+ let portForward;
445
+ try {
446
+ // `exec` makes the tracked child the sudo/kubectl process (so kill
447
+ // reaches it); stdio is redirected to /dev/null so the tunnel never
448
+ // inherits — and therefore never holds open — a CI/SSH session's pipes,
449
+ // which would hang the job after a successful deploy.
450
+ portForward = shellExec(
451
+ `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace} </dev/null >/dev/null 2>&1`,
452
+ { async: true, silent: true, disableLog: true, silentOnError: true },
453
+ );
454
+ } catch (_) {
455
+ portForward = undefined;
456
+ }
457
+ try {
458
+ let lastError;
459
+ const attempts = parseInt(process.env.UNDERPOST_PF_ATTEMPTS) || 20;
460
+ for (let attempt = 0; attempt < attempts; attempt++) {
461
+ try {
462
+ const res = await axios.get(url, { timeout: 2500 });
463
+ const raw = res?.data?.status ?? null;
464
+ return { ok: true, status: normalizeContainerStatus(raw) ?? raw, payload: res.data };
465
+ } catch (error) {
466
+ lastError = error;
467
+ await timer(350);
468
+ }
469
+ }
470
+ return { ok: false, transportError: lastError?.code || lastError?.message || 'transport_failed' };
471
+ } finally {
472
+ if (portForward && typeof portForward.kill === 'function') {
473
+ try {
474
+ portForward.kill('SIGTERM');
475
+ } catch (_) {
476
+ /* tunnel already gone */
477
+ }
478
+ }
479
+ }
480
+ },
481
+ /**
482
+ * Monitors a deployment to terminal readiness using a deterministic
483
+ * two-phase state machine.
484
+ *
485
+ * Phase 1 (Kubernetes): pod `Ready` condition via `checkDeploymentReadyStatus`.
486
+ * Phase 2 (Runtime): `container-status`, read via the selected transport.
487
+ *
488
+ * Two deployment shapes are supported via `options`:
489
+ * - `runtime` gate (default, dd-* deploys): the `underpost start` launcher
490
+ * stamps `running-deployment`. Success requires K8S Ready AND every pod
491
+ * reporting `running-deployment`.
492
+ * - `kubernetes` gate (custom instances, e.g. cyberia): the runtime is a
493
+ * bare binary; K8S `readinessProbe` (TCP) IS the running signal and
494
+ * `container-status` is stamped to `initializing`/`stopping` by lifecycle
495
+ * hooks. Success requires K8S Ready; the status read is used only for
496
+ * fast `error` detection and display.
497
+ *
498
+ * Phase-2 transport defaults to `exec` (`kubectl exec`, no background
499
+ * process). The `http` transport (`kubectl port-forward` → `/_internal/status`)
500
+ * is opt-in via `options.statusTransport='http'` or
501
+ * `UNDERPOST_STATUS_TRANSPORT=http`; it must not be used in CI/SSH sessions
502
+ * where a stray tunnel can hang the job.
503
+ *
504
+ * Contract (both shapes):
505
+ * - Runtime readiness is never declared before Kubernetes readiness.
506
+ * - An explicit runtime `error` (or a fatal pod status) transitions
507
+ * immediately to `failed` (throw → CD exit 1).
508
+ * - Transport failures never count as success and never advance state.
509
+ * - `timeout` is a distinct terminal state from `failed`.
510
+ * - Every transition emits a structured, secret-free event.
351
511
  *
352
512
  * @param {string} deployId - Deployment ID for which the ready status is being monitored.
353
513
  * @param {string} env - Environment for which the ready status is being monitored.
354
514
  * @param {string} targetTraffic - Target traffic status for the deployment.
355
515
  * @param {Array<string>} ignorePods - List of pod names to ignore.
356
516
  * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
517
+ * @param {object} [options] - Monitoring shape.
518
+ * @param {('runtime'|'kubernetes')} [options.readyGate='runtime'] - Running-signal owner.
519
+ * @param {('http'|'exec')} [options.statusTransport='http'] - Phase-2 read transport.
357
520
  * @returns {object} - Object containing the ready status of the deployment.
358
521
  * @memberof UnderpostMonitor
359
522
  */
360
- async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
361
- const delayMs = 1000;
362
- const maxIterations = 3000;
523
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', options = {}) {
524
+ const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
525
+ const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
363
526
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
364
- const expectedContainerStatus = `${deployId}-${env}-running-deployment`;
365
527
  const tag = `[${deploymentId}]`;
366
- const containerStatusDefault = 'waiting for status';
367
-
368
- logger.info('Deployment init', { deployId, env, targetTraffic, namespace });
528
+ const expectedStatus = RUNTIME_STATUS.RUNNING;
529
+ const readyGate = options.readyGate === 'kubernetes' ? 'kubernetes' : 'runtime';
530
+ // Default to `exec`: a single synchronous `kubectl exec` read leaves no
531
+ // background process behind. The `http` transport spawns `kubectl
532
+ // port-forward` children that, if orphaned, inherit a CI/SSH session's
533
+ // stdio and hang the job after a successful deploy — opt in explicitly.
534
+ const statusTransport =
535
+ (options.statusTransport || process.env.UNDERPOST_STATUS_TRANSPORT) === 'http' ? 'http' : 'exec';
536
+ const internalPort =
537
+ statusTransport === 'http' ? await Underpost.monitor.deployInternalPort(deployId, env) : null;
538
+ const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
539
+
540
+ const emit = (state, status) =>
541
+ logger.info('deploy-monitor', {
542
+ deployId: deploymentId,
543
+ phase: state.startsWith('runtime') ? 'runtime' : 'kubernetes',
544
+ state,
545
+ status: status ?? null,
546
+ timestamp: new Date().toISOString(),
547
+ });
548
+
549
+ logger.info('Deployment init', {
550
+ deployId,
551
+ env,
552
+ targetTraffic,
553
+ namespace,
554
+ internalPort,
555
+ readyGate,
556
+ statusTransport,
557
+ });
558
+ emit('pending');
369
559
 
370
- const podStatusCache = new Map();
560
+ const runtimeStatusCache = new Map();
371
561
  const advancedPods = new Set();
372
562
 
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
563
  for (let i = 0; i < maxIterations; i++) {
388
564
  const result = await Underpost.monitor.checkDeploymentReadyStatus(
389
565
  deployId,
@@ -392,39 +568,62 @@ class UnderpostMonitor {
392
568
  ignorePods,
393
569
  namespace,
394
570
  );
395
-
396
571
  const allPods = [...result.readyPods, ...result.notReadyPods];
397
572
 
573
+ if (allPods.length === 0) {
574
+ emit('pending');
575
+ await timer(delayMs);
576
+ continue;
577
+ }
578
+ emit('pod_scheduled');
579
+
580
+ // Phase 1 fatal: a Kubernetes-level pod failure is terminal (failed,
581
+ // not timeout) — fail the CD runner immediately instead of waiting out
582
+ // the full window.
398
583
  for (const pod of allPods) {
399
- if (!pod?.NAME) continue;
400
584
  const podStatus = (pod.STATUS || '').toLowerCase().trim();
401
- if (
402
- ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'].find((s) =>
403
- podStatus.match(s),
404
- )
405
- )
585
+ if (podErrorStates.find((s) => podStatus.includes(s)))
406
586
  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
587
  }
414
588
 
415
- const allPodsK8sReady = allPods.length > 0 && result.notReadyPods.length === 0;
589
+ const allPodsK8sReady = result.notReadyPods.length === 0;
590
+ if (allPodsK8sReady) emit('pod_ready');
416
591
 
417
- const allPodsStatusReady =
418
- allPods.length > 0 && allPods.every((pod) => podStatusCache.get(pod.NAME) === expectedContainerStatus);
592
+ // Phase 2: runtime status via the selected transport. Transport failures
593
+ // neither advance state nor count as success; explicit `error` is terminal.
594
+ let allRuntimeRead = true;
595
+ for (const pod of allPods) {
596
+ if (!pod?.NAME) continue;
597
+ const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort, statusTransport);
598
+ if (!read.ok) {
599
+ allRuntimeRead = false;
600
+ emit('runtime_booting', `transport:${read.transportError}`);
601
+ continue;
602
+ }
603
+ const status = read.status;
604
+ if (status === RUNTIME_STATUS.ERROR) throw new Error(`Pod ${pod.NAME} reported runtime status=error`);
605
+ // Regression (advanced → empty/build) means a pod restarted. Under the
606
+ // kubernetes gate the runtime never advances past `initializing`, so
607
+ // only treat a drop to empty/build as a regression there.
608
+ if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
609
+ throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
610
+ if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
611
+ runtimeStatusCache.set(pod.NAME, status);
612
+ emit('runtime_booting', status);
613
+ }
614
+
615
+ // Under the kubernetes gate the readinessProbe is the running signal, so
616
+ // K8S Ready alone confirms Phase 2; the status read above is kept only
617
+ // for `error` fast-fail and display.
618
+ const allRuntimeReady =
619
+ readyGate === 'kubernetes'
620
+ ? true
621
+ : allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
419
622
 
420
- // Print snapshot for every pod — annotate when container-status hasn't caught
421
- // up to the K8S Ready condition yet.
422
623
  for (const pod of allPods) {
423
- const status = podStatusCache.get(pod.NAME) || containerStatusDefault;
624
+ const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
424
625
  const podStatus = pod.STATUS || 'Unknown';
425
- const statusMatchesExpected = status === expectedContainerStatus;
426
- const statusDisplay = statusMatchesExpected ? status : `${status} (pending)`;
427
-
626
+ const statusDisplay = status === expectedStatus ? status : `${status} (pending)`;
428
627
  console.log(
429
628
  'Target pod:',
430
629
  pod.NAME[pod.NAME.includes('green') ? 'bgGreen' : 'bgBlue'].bold.black,
@@ -435,22 +634,20 @@ class UnderpostMonitor {
435
634
  );
436
635
  }
437
636
 
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)`);
637
+ // Terminal success requires both phases. runtime_ready cannot precede
638
+ // Kubernetes readiness.
639
+ if (allPodsK8sReady && allRuntimeReady) {
640
+ const readySignal = readyGate === 'kubernetes' ? 'K8S readinessProbe' : `runtime ${expectedStatus}`;
641
+ emit('runtime_ready', readyGate === 'kubernetes' ? 'k8s-ready' : expectedStatus);
642
+ logger.info(`${tag} | Deployment ready (K8S Ready + ${readySignal})`);
444
643
  return result;
445
644
  }
446
645
 
447
646
  await timer(delayMs);
448
-
449
- if ((i + 1) % 10 === 0) {
450
- logger.info(`${tag} | In progress... iteration ${i + 1}`);
451
- }
647
+ if ((i + 1) % 10 === 0) logger.info(`${tag} | In progress... iteration ${i + 1}`);
452
648
  }
453
649
 
650
+ emit('timeout');
454
651
  logger.error(`${tag} | Deployment timeout after ${maxIterations} iterations`);
455
652
  throw new Error(
456
653
  `monitorReadyRunner timeout: ${deploymentId} did not become Ready within ${maxIterations}*${delayMs}ms`,
@@ -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
+ fs.removeSync(TEMPLATE_PATH);
271
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,8 +406,7 @@ 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`);
409
+ shellExec(`node bin run build-cluster-deployment-manifests`);
395
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`);
@@ -8,6 +8,7 @@ import dotenv from 'dotenv';
8
8
  import { commitData } from '../client/components/core/CommonJs.js';
9
9
  import { pbcopy, shellCd, shellExec } from '../server/process.js';
10
10
  import { actionInitLog, loggerFactory } from '../server/logger.js';
11
+ import path from 'path';
11
12
  import fs from 'fs-extra';
12
13
  import {
13
14
  getNpmRootPath,
@@ -133,10 +134,21 @@ class UnderpostRepository {
133
134
  p: undefined,
134
135
  bc: '',
135
136
  isRemoteRepo: '',
137
+ hasChanges: false,
136
138
  },
137
139
  ) {
138
140
  if (!repoPath) repoPath = '.';
139
141
 
142
+ if (options.hasChanges) {
143
+ const status = shellExec(`cd ${repoPath} && git status --porcelain`, {
144
+ stdout: true,
145
+ silent: true,
146
+ disableLog: true,
147
+ }).trim();
148
+ process.stdout.write(status ? '1' : '');
149
+ return;
150
+ }
151
+
140
152
  if (options.isRemoteRepo) {
141
153
  const accessible = Underpost.repo.isRemoteRepo(options.isRemoteRepo);
142
154
  console.log(accessible);
@@ -608,7 +620,8 @@ class UnderpostRepository {
608
620
  const npmRoot = getNpmRootPath();
609
621
  const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
610
622
  const destFolder = `./${projectName}`;
611
- logger.info('build app', { destFolder });
623
+ const deployId = projectName.startsWith('dd-') ? projectName : `dd-${projectName}`;
624
+ logger.info('build app', { destFolder, deployId });
612
625
  if (fs.existsSync(destFolder)) fs.removeSync(destFolder);
613
626
  fs.mkdirSync(destFolder, { recursive: true });
614
627
  if (!options.dev) {
@@ -621,8 +634,9 @@ class UnderpostRepository {
621
634
  UnderpostRepository.API.initLocalRepo({ path: destFolder });
622
635
  shellExec(`cd ${destFolder} && git add . && git commit -m "Base template implementation"`);
623
636
  }
624
- shellExec(`cd ${destFolder} && npm run build`);
625
- shellExec(`cd ${destFolder} && npm run dev`);
637
+ shellExec(`cd ${destFolder} && node bin new --deploy-id ${deployId} --default-conf`);
638
+ shellExec(`cd ${destFolder} && node bin client ${deployId}`);
639
+ shellExec(`cd ${destFolder} && DEPLOY_ID=${deployId} npm run dev`);
626
640
  }
627
641
  return resolve(true);
628
642
  } catch (error) {
@@ -1380,8 +1394,9 @@ Prevent build private config repo.`,
1380
1394
  const gitEmail = process.env.GITHUB_EMAIL || `development@underpost.net`;
1381
1395
 
1382
1396
  if (!fs.existsSync(`${repoPath}/.git`)) {
1383
- shellExec(`cd "${repoPath}" && git init`);
1397
+ shellExec(`mkdir -p "${repoPath}" && git init "${repoPath}"`);
1384
1398
  }
1399
+
1385
1400
  shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
1386
1401
  shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
1387
1402
  shellExec(`cd "${repoPath}" && git config core.filemode false`);
@@ -1724,6 +1739,8 @@ Prevent build private config repo.`,
1724
1739
  if (!fs.existsSync(repoPath)) {
1725
1740
  shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
1726
1741
  } else {
1742
+ const repoAbsPath = path.resolve(repoPath);
1743
+ shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
1727
1744
  shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
1728
1745
  silent: true,
1729
1746
  });