underpost 3.2.21 → 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.
@@ -378,34 +378,77 @@ class UnderpostMonitor {
378
378
  return deployStatusPort(deployId, env) ?? 3000;
379
379
  },
380
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.
381
+ * Reads Phase-2 runtime status from a single pod using the selected transport.
383
382
  *
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.
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.
389
+ *
390
+ * Transport failures are reported as `{ ok: false }` and must never be read
391
+ * as success — they are retried, not promoted.
388
392
  *
389
393
  * @param {string} podName
390
394
  * @param {string} namespace
391
395
  * @param {number} internalPort
396
+ * @param {('http'|'exec')} [transport='exec']
392
397
  * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
393
398
  * @memberof UnderpostMonitor
394
399
  */
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
+ 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`.
428
+ *
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) {
400
441
  const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
401
442
  const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
402
443
  const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
403
444
  let portForward;
404
445
  try {
405
- // `exec` collapses the shell so the tracked child PID is the
406
- // sudo/kubectl process, letting the SIGTERM teardown reach the tunnel.
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.
407
450
  portForward = shellExec(
408
- `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace}`,
451
+ `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace} </dev/null >/dev/null 2>&1`,
409
452
  { async: true, silent: true, disableLog: true, silentOnError: true },
410
453
  );
411
454
  } catch (_) {
@@ -440,12 +483,26 @@ class UnderpostMonitor {
440
483
  * two-phase state machine.
441
484
  *
442
485
  * 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`).
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.
445
503
  *
446
- * Contract:
447
- * - Success requires BOTH phases; runtime readiness is never declared
448
- * before Kubernetes readiness.
504
+ * Contract (both shapes):
505
+ * - Runtime readiness is never declared before Kubernetes readiness.
449
506
  * - An explicit runtime `error` (or a fatal pod status) transitions
450
507
  * immediately to `failed` (throw → CD exit 1).
451
508
  * - Transport failures never count as success and never advance state.
@@ -457,16 +514,27 @@ class UnderpostMonitor {
457
514
  * @param {string} targetTraffic - Target traffic status for the deployment.
458
515
  * @param {Array<string>} ignorePods - List of pod names to ignore.
459
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.
460
520
  * @returns {object} - Object containing the ready status of the deployment.
461
521
  * @memberof UnderpostMonitor
462
522
  */
463
- async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
523
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', options = {}) {
464
524
  const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
465
525
  const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
466
526
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
467
527
  const tag = `[${deploymentId}]`;
468
528
  const expectedStatus = RUNTIME_STATUS.RUNNING;
469
- const internalPort = await Underpost.monitor.deployInternalPort(deployId, env);
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;
470
538
  const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
471
539
 
472
540
  const emit = (state, status) =>
@@ -478,7 +546,15 @@ class UnderpostMonitor {
478
546
  timestamp: new Date().toISOString(),
479
547
  });
480
548
 
481
- logger.info('Deployment init', { deployId, env, targetTraffic, namespace, internalPort });
549
+ logger.info('Deployment init', {
550
+ deployId,
551
+ env,
552
+ targetTraffic,
553
+ namespace,
554
+ internalPort,
555
+ readyGate,
556
+ statusTransport,
557
+ });
482
558
  emit('pending');
483
559
 
484
560
  const runtimeStatusCache = new Map();
@@ -513,12 +589,12 @@ class UnderpostMonitor {
513
589
  const allPodsK8sReady = result.notReadyPods.length === 0;
514
590
  if (allPodsK8sReady) emit('pod_ready');
515
591
 
516
- // Phase 2: runtime readiness over HTTP. Transport failures neither
517
- // advance state nor count as success; explicit `error` is terminal.
592
+ // Phase 2: runtime status via the selected transport. Transport failures
593
+ // neither advance state nor count as success; explicit `error` is terminal.
518
594
  let allRuntimeRead = true;
519
595
  for (const pod of allPods) {
520
596
  if (!pod?.NAME) continue;
521
- const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort);
597
+ const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort, statusTransport);
522
598
  if (!read.ok) {
523
599
  allRuntimeRead = false;
524
600
  emit('runtime_booting', `transport:${read.transportError}`);
@@ -526,6 +602,9 @@ class UnderpostMonitor {
526
602
  }
527
603
  const status = read.status;
528
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.
529
608
  if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
530
609
  throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
531
610
  if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
@@ -533,8 +612,13 @@ class UnderpostMonitor {
533
612
  emit('runtime_booting', status);
534
613
  }
535
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.
536
618
  const allRuntimeReady =
537
- allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
619
+ readyGate === 'kubernetes'
620
+ ? true
621
+ : allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
538
622
 
539
623
  for (const pod of allPods) {
540
624
  const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
@@ -550,11 +634,12 @@ class UnderpostMonitor {
550
634
  );
551
635
  }
552
636
 
553
- // Terminal success requires BOTH phases. runtime_ready cannot precede
637
+ // Terminal success requires both phases. runtime_ready cannot precede
554
638
  // Kubernetes readiness.
555
639
  if (allPodsK8sReady && allRuntimeReady) {
556
- emit('runtime_ready', expectedStatus);
557
- logger.info(`${tag} | Deployment ready (K8S Ready + runtime ${expectedStatus})`);
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})`);
558
643
  return result;
559
644
  }
560
645
 
@@ -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,
@@ -1738,6 +1739,8 @@ Prevent build private config repo.`,
1738
1739
  if (!fs.existsSync(repoPath)) {
1739
1740
  shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
1740
1741
  } else {
1742
+ const repoAbsPath = path.resolve(repoPath);
1743
+ shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
1741
1744
  shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
1742
1745
  silent: true,
1743
1746
  });
package/src/cli/run.js CHANGED
@@ -120,6 +120,7 @@ const logger = loggerFactory(import.meta);
120
120
  * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
121
121
  * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
122
122
  * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
123
+ * @property {boolean} test - Whether to enable test/generic-purpose mode (e.g. use self-signed TLS instead of cert-manager).
123
124
  * @memberof UnderpostRun
124
125
  */
125
126
  const DEFAULT_OPTION = {
@@ -188,6 +189,7 @@ const DEFAULT_OPTION = {
188
189
  skipFullBuild: false,
189
190
  pullBundle: false,
190
191
  remove: false,
192
+ test: false,
191
193
  };
192
194
 
193
195
  /**
@@ -223,7 +225,7 @@ class UnderpostRun {
223
225
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
224
226
 
225
227
  shellExec(
226
- `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb4 --service-host ${mongoHosts.join(
228
+ `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --service-host ${mongoHosts.join(
227
229
  ',',
228
230
  )} --pull-image`,
229
231
  );
@@ -1007,8 +1009,19 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
1007
1009
  // pathRewritePolicy,
1008
1010
  });
1009
1011
  if (options.tls) {
1010
- shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1011
- proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1012
+ if (options.test) {
1013
+ const sslDir = `./engine-private/ssl/${_host}`;
1014
+ const nameSafe = _host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1015
+ fs.mkdirpSync(sslDir);
1016
+ shellExec(`bash ./scripts/ssl.sh "${sslDir}" "${_host}"`);
1017
+ shellExec(`kubectl delete secret ${_host} -n ${options.namespace} --ignore-not-found`);
1018
+ shellExec(
1019
+ `kubectl create secret tls ${_host} --cert="${sslDir}/${nameSafe}.pem" --key="${sslDir}/${nameSafe}-key.pem" -n ${options.namespace}`,
1020
+ );
1021
+ } else {
1022
+ shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1023
+ proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1024
+ }
1012
1025
  }
1013
1026
  // console.log(proxyYaml);
1014
1027
  shellExec(`kubectl delete HTTPProxy ${_host} --namespace ${options.namespace} --ignore-not-found`);
@@ -1077,6 +1090,7 @@ EOF
1077
1090
  // Examples images:
1078
1091
  // `underpost/underpost-engine:${Underpost.version}`
1079
1092
  // `localhost/rockylinux9-underpost:${Underpost.version}`
1093
+ if (options.imageName) _image = options.imageName;
1080
1094
  if (!_image) _image = `underpost/underpost-engine:${Underpost.version}`;
1081
1095
 
1082
1096
  if (_image && !_image.startsWith('localhost'))
@@ -1172,12 +1186,16 @@ EOF
1172
1186
  `,
1173
1187
  { disableLog: true },
1174
1188
  );
1189
+ // Custom instances run a bare binary (no `underpost start` / internal
1190
+ // HTTP endpoint): Kubernetes readiness is the running signal and
1191
+ // container-status is read via exec. See `Deploy custom instance to K8S.md`.
1175
1192
  const { ready, readyPods } = await Underpost.monitor.monitorReadyRunner(
1176
1193
  _deployId,
1177
1194
  env,
1178
1195
  targetTraffic,
1179
1196
  ignorePods,
1180
1197
  options.namespace,
1198
+ { readyGate: 'kubernetes', statusTransport: 'exec' },
1181
1199
  );
1182
1200
 
1183
1201
  if (!ready) {
@@ -1187,7 +1205,7 @@ EOF
1187
1205
  shellExec(
1188
1206
  `${baseCommand} run${baseClusterCommand} --namespace ${options.namespace}` +
1189
1207
  `${options.nodeName ? ` --node-name ${options.nodeName}` : ''}` +
1190
- `${options.tls ? ` --tls` : ''}` +
1208
+ `${options.tls ? ` --tls ${options.test ? '--test' : ''}` : ''}` +
1191
1209
  ` instance-promote '${path}'`,
1192
1210
  );
1193
1211
  }
@@ -73,7 +73,7 @@ class PanelForm {
73
73
  parentIdModal: undefined,
74
74
  route: 'home',
75
75
  htmlFormHeader: async () => '',
76
- firsUpdateEvent: async () => { },
76
+ firsUpdateEvent: async () => {},
77
77
  share: {
78
78
  copyLink: false,
79
79
  copySourceMd: false,
@@ -196,12 +196,12 @@ class PanelForm {
196
196
  <img
197
197
  class="abs center"
198
198
  style="${renderCssAttr({
199
- style: {
200
- width: '100px',
201
- height: '100px',
202
- opacity: 0.2,
203
- },
204
- })}"
199
+ style: {
200
+ width: '100px',
201
+ height: '100px',
202
+ opacity: 0.2,
203
+ },
204
+ })}"
205
205
  src="${defaultUrlImage}"
206
206
  />
207
207
  `,
@@ -382,15 +382,15 @@ class PanelForm {
382
382
  // It will be filtered from the tags array to keep visibility control separate from content tags
383
383
  const tags = data.tags
384
384
  ? uniqueArray(
385
- data.tags
386
- .replaceAll('/', ',')
387
- .replaceAll('-', ',')
388
- .replaceAll(' ', ',')
389
- .split(',')
390
- .map((t) => t.trim())
391
- .filter((t) => t)
392
- .concat(prefixTags),
393
- )
385
+ data.tags
386
+ .replaceAll('/', ',')
387
+ .replaceAll('-', ',')
388
+ .replaceAll(' ', ',')
389
+ .split(',')
390
+ .map((t) => t.trim())
391
+ .filter((t) => t)
392
+ .concat(prefixTags),
393
+ )
394
394
  : prefixTags;
395
395
  let originObj, originFileObj, indexOriginObj;
396
396
  if (editId) {
@@ -432,8 +432,8 @@ class PanelForm {
432
432
  // In edit mode, null means user cleared the file - we need to tell server to remove it
433
433
  const isFileCleared = data.fileId === null && editId;
434
434
  await (async () => {
435
- // When file is null and not the first iteration or not in edit mode, skip upload
436
- if (!file && !isFileCleared) return;
435
+ // When file is null, no markdown content, and not clearing a file, skip upload
436
+ if (!file && !isFileCleared && !hasMdContent) return;
437
437
  // When user cleared file in edit mode, set fileId=null so server removes the reference
438
438
  if (isFileCleared) {
439
439
  fileId = null;
@@ -489,8 +489,8 @@ class PanelForm {
489
489
  message: documentMessage,
490
490
  data: documentData,
491
491
  } = originObj && indexFormDoc === 0
492
- ? await DocumentService.put({ id: originObj._id, body })
493
- : await DocumentService.post({
492
+ ? await DocumentService.put({ id: originObj._id, body })
493
+ : await DocumentService.post({
494
494
  body,
495
495
  });
496
496
  const newDoc = {
@@ -518,12 +518,12 @@ class PanelForm {
518
518
  fileId: {
519
519
  fileBlob: file
520
520
  ? {
521
- data: {
522
- data: await getDataFromInputFile(file),
523
- },
524
- mimetype: file.type,
525
- name: file.name,
526
- }
521
+ data: {
522
+ data: await getDataFromInputFile(file),
523
+ },
524
+ mimetype: file.type,
525
+ name: file.name,
526
+ }
527
527
  : undefined,
528
528
  filePlain: undefined,
529
529
  },
@@ -742,36 +742,36 @@ class PanelForm {
742
742
  <div
743
743
  class="in fll ssr-shimmer-search-box"
744
744
  style="${renderCssAttr({
745
- style: {
746
- width: '80%',
747
- height: '30px',
748
- top: '-13px',
749
- left: '10px',
750
- },
751
- })}"
745
+ style: {
746
+ width: '80%',
747
+ height: '30px',
748
+ top: '-13px',
749
+ left: '10px',
750
+ },
751
+ })}"
752
752
  ></div>
753
753
  </div>`,
754
754
  createdAt: html`<div class="fl">
755
755
  <div
756
756
  class="in fll ssr-shimmer-search-box"
757
757
  style="${renderCssAttr({
758
- style: {
759
- width: '50%',
760
- height: '30px',
761
- left: '-5px',
762
- },
763
- })}"
758
+ style: {
759
+ width: '50%',
760
+ height: '30px',
761
+ left: '-5px',
762
+ },
763
+ })}"
764
764
  ></div>
765
765
  </div>`,
766
766
  mdFileId: html`<div class="fl section-mp">
767
767
  <div
768
768
  class="in fll ssr-shimmer-search-box"
769
769
  style="${renderCssAttr({
770
- style: {
771
- width: '80%',
772
- height: '30px',
773
- },
774
- })}"
770
+ style: {
771
+ width: '80%',
772
+ height: '30px',
773
+ },
774
+ })}"
775
775
  ></div>
776
776
  </div>`.repeat(random(2, 4)),
777
777
  ssr: true,
package/src/index.js CHANGED
@@ -44,7 +44,7 @@ class Underpost {
44
44
  * @type {String}
45
45
  * @memberof Underpost
46
46
  */
47
- static version = 'v3.2.21';
47
+ static version = 'v3.2.22';
48
48
 
49
49
  /**
50
50
  * Required Node.js major version
@@ -1856,6 +1856,7 @@ const syncPrivateConf = (deployId, extraPaths = []) => {
1856
1856
  if (!fs.existsSync(privateRepoPath)) {
1857
1857
  shellExec(`cd .. && underpost clone ${privateGitUri}`, { silent: true });
1858
1858
  } else {
1859
+ shellExec(`git config --global --add safe.directory '${dir.resolve(privateRepoPath)}'`);
1859
1860
  shellExec(`cd ${privateRepoPath} && git checkout . && git clean -f -d && underpost pull . ${privateGitUri}`, {
1860
1861
  silent: true,
1861
1862
  });
@@ -1941,15 +1942,9 @@ const buildTemplate = async ({ srcPath = './', toPath = '../pwa-microservices-te
1941
1942
  )
1942
1943
  ).filter((p) => !p.startsWith('.git'));
1943
1944
 
1944
- // Clone the template from 0 if missing; otherwise reset it to a clean pristine checkout.
1945
- if (!fs.existsSync(toPath)) {
1946
- shellExec(`cd .. && node engine/bin clone ${githubUsername}/pwa-microservices-template`);
1947
- } else {
1948
- shellExec(`cd ${toPath} && git reset && git checkout . && git clean -f -d`);
1949
- shellExec(`node bin pull ${toPath} ${githubUsername}/pwa-microservices-template`);
1950
- shellExec(`sudo rm -rf ${toPath}/engine-private`);
1951
- shellExec(`sudo rm -rf ${toPath}/logs`);
1952
- }
1945
+ fs.removeSync(`${githubUsername}/pwa-microservices-template`);
1946
+ shellExec(`cd .. && node engine/bin clone ${githubUsername}/pwa-microservices-template`);
1947
+
1953
1948
  shellExec(`cd ${toPath} && git config core.filemode false`);
1954
1949
 
1955
1950
  for (const copyPath of sourceFiles) {
@@ -2052,6 +2047,59 @@ git add .`);
2052
2047
  }
2053
2048
  };
2054
2049
 
2050
+ /**
2051
+ * @method updatePrivateEngineTestRepo
2052
+ * @description Publishes a deploy id's freshly assembled template to its private
2053
+ * **test** source repo `engine-test-<idPart>` (separate from the production
2054
+ * `engine-<idPart>`). A pod started with `underpost start --build --private-test-repo`
2055
+ * clones this repo, so work-in-progress engine source can be tested end to end
2056
+ * without touching the production source. Mirrors {@link updatePrivateTemplateRepo}
2057
+ * but per-deploy-id and against the test repo.
2058
+ *
2059
+ * Assumes the deploy id template has already been assembled at the template path
2060
+ * (run `node bin/build <deployId>` first, or use `node bin/build <deployId> --update-private`).
2061
+ * @param {string} deployId - Concrete deploy id (e.g. `dd-core`).
2062
+ * @returns {Promise<void>}
2063
+ * @memberof ServerConfBuilder
2064
+ */
2065
+ const updatePrivateEngineTestRepo = async (deployId) => {
2066
+ const username = process.env.GITHUB_USERNAME || 'underpostnet';
2067
+ const repoName = `engine-test-${deployId.split('-')[1]}`;
2068
+ const templatePath = '/home/dd/pwa-microservices-template';
2069
+ if (!fs.existsSync(templatePath))
2070
+ throw new Error(`updatePrivateEngineTestRepo: assemble the template first (node bin/build ${deployId})`);
2071
+
2072
+ // Detach the assembled working tree from any engine-build git history.
2073
+ shellExec(`sudo rm -rf ${templatePath}/.git`);
2074
+
2075
+ // Adopt the test repo's existing history when present (so the push is a delta);
2076
+ // otherwise publish a fresh history on first push.
2077
+ shellExec(`cd /home/dd && sudo rm -rf ./${repoName}.git && underpost clone --bare ${username}/${repoName}`, {
2078
+ silent: true,
2079
+ disableLog: true,
2080
+ silentOnError: true,
2081
+ });
2082
+ if (fs.existsSync(`/home/dd/${repoName}.git`)) shellExec(`mv /home/dd/${repoName}.git ${templatePath}/.git`);
2083
+
2084
+ // `git init` converts the moved bare repo into a normal work-tree repo (bare
2085
+ // clones have no work tree, so `git add` would fail), and bootstraps a fresh
2086
+ // repo on first publish. Idempotent — mirrors updatePrivateTemplateRepo.
2087
+ shellExec(`cd ${templatePath}
2088
+ git init
2089
+ git config user.name '${username}'
2090
+ git config user.email 'development@underpost.net'
2091
+ git add .`);
2092
+
2093
+ const hasChanges = shellExec(`node bin cmt ${templatePath} --has-changes`, {
2094
+ stdout: true,
2095
+ silent: true,
2096
+ disableLog: true,
2097
+ }).trim();
2098
+ if (hasChanges === '1')
2099
+ shellExec(`cd ${templatePath} && git commit -m 'Update ${repoName}' && underpost push . ${username}/${repoName}`);
2100
+ else logger.info('No changes to publish', { repoName });
2101
+ };
2102
+
2055
2103
  export {
2056
2104
  Config,
2057
2105
  loadConf,
@@ -2104,4 +2152,5 @@ export {
2104
2152
  syncDeployIdSources,
2105
2153
  buildTemplate,
2106
2154
  updatePrivateTemplateRepo,
2155
+ updatePrivateEngineTestRepo,
2107
2156
  };
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import stringify from 'fast-json-stable-stringify';
14
14
  import { loggerFactory } from './logger.js';
15
+ import Underpost from '../index.js';
15
16
  const logger = loggerFactory(import.meta);
16
17
  const DEFAULT_IPFS_HTTP_TIMEOUT_MS = Number(process.env.IPFS_HTTP_TIMEOUT_MS || 10000);
17
18
  const getRequestTimeoutMs = (kind = 'kubo') => {
@@ -46,21 +47,22 @@ const fetchWithTimeout = async (url, options = {}, { kind = 'kubo', label = url
46
47
  * @returns {string}
47
48
  */
48
49
  const getIpfsApiUrl = () =>
49
- process.env.IPFS_API_URL || `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:5001`;
50
+ process.env.IPFS_API_URL ||
51
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:5001`;
50
52
  /**
51
53
  * Base URL of the IPFS Cluster REST API (port 9094).
52
54
  * @returns {string}
53
55
  */
54
56
  const getClusterApiUrl = () =>
55
57
  process.env.IPFS_CLUSTER_API_URL ||
56
- `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:9094`;
58
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:9094`;
57
59
  /**
58
60
  * Base URL of the IPFS HTTP Gateway (port 8080).
59
61
  * @returns {string}
60
62
  */
61
63
  const getGatewayUrl = () =>
62
64
  process.env.IPFS_GATEWAY_URL ||
63
- `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:8080`;
65
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:8080`;
64
66
  // ─────────────────────────────────────────────────────────
65
67
  // Core: add content
66
68
  // ─────────────────────────────────────────────────────────
@@ -152,10 +152,16 @@ class UnderpostStartUp {
152
152
  // observable through every lifecycle phase, including build and init. Bind
153
153
  // the deployment-resolved port so it always matches the monitor's target.
154
154
  startInternalStatusServer(deployStatusPort(deployId, env));
155
- setRuntimeStatus(deployId, env, RUNTIME_STATUS.BUILD);
156
- if (options.build === true) await Underpost.start.build(deployId, env, options);
157
- setRuntimeStatus(deployId, env, RUNTIME_STATUS.INIT);
158
- if (options.run === true) await Underpost.start.run(deployId, env, options);
155
+ try {
156
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.BUILD);
157
+ if (options.build === true) await Underpost.start.build(deployId, env, options);
158
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.INIT);
159
+ if (options.run === true) await Underpost.start.run(deployId, env, options);
160
+ } catch (error) {
161
+ logger.error('Deployment build/init failed', { deployId, env, message: error?.message });
162
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
163
+ if (!Underpost.env.isInsideContainer()) throw error;
164
+ }
159
165
  },
160
166
  /**
161
167
  * Run itc-scripts and builds client bundle.
@@ -167,6 +173,8 @@ class UnderpostStartUp {
167
173
  * @param {boolean} options.skipFullBuild - Whether to skip building the full client bundle.
168
174
  * @param {boolean} options.pullBundle - When true, download pre-built client bundle from Cloudinary via pull-bundle (must be pushed first with push-bundle).
169
175
  * This flag is independent of skipFullBuild: it can be combined with skipFullBuild or used alone.
176
+ * @param {boolean} options.privateTestRepo - When true, clone `engine-test-<id>` (the private test source repo
177
+ * published by `node bin/build <deployId> --update-private`) instead of the production `engine-<id>` repo.
170
178
  * @memberof UnderpostStartUp
171
179
  */
172
180
  async build(
@@ -175,7 +183,11 @@ class UnderpostStartUp {
175
183
  options = { underpostQuicklyInstall: false, skipPullBase: false, skipFullBuild: false, pullBundle: false },
176
184
  ) {
177
185
  const buildBasePath = `/home/dd`;
178
- const repoName = `engine-${deployId.split('-')[1]}`;
186
+ // `--private-test-repo` clones the isolated test source repo published by
187
+ // `node bin/build <deployId> --update-private`, instead of the production one.
188
+ const repoName = options?.privateTestRepo
189
+ ? `engine-test-${deployId.split('-')[1]}`
190
+ : `engine-${deployId.split('-')[1]}`;
179
191
  if (!options.skipPullBase) {
180
192
  shellExec(`cd ${buildBasePath} && underpost clone ${process.env.GITHUB_USERNAME}/${repoName}`);
181
193
  shellExec(`mkdir -p ${buildBasePath}/engine`);