underpost 3.2.30 → 3.2.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.github/workflows/ghpkg.ci.yml +87 -0
  2. package/.github/workflows/npmpkg.ci.yml +9 -6
  3. package/.github/workflows/publish.ci.yml +3 -3
  4. package/.github/workflows/pwa-microservices-template-page.cd.yml +0 -7
  5. package/.github/workflows/release.cd.yml +1 -1
  6. package/CHANGELOG.md +1230 -971
  7. package/CLI-HELP.md +14 -6
  8. package/README.md +3 -3
  9. package/bin/build.js +40 -4
  10. package/bin/deploy.js +2 -2
  11. package/bump.config.js +1 -0
  12. package/docker-compose.yml +26 -26
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +15 -15
  17. package/scripts/disk-clean.sh +85 -60
  18. package/scripts/test-monitor.sh +1 -1
  19. package/src/api/core/core.controller.js +4 -65
  20. package/src/api/core/core.router.js +8 -14
  21. package/src/api/default/default.controller.js +2 -70
  22. package/src/api/default/default.router.js +7 -17
  23. package/src/api/document/document.controller.js +5 -77
  24. package/src/api/document/document.router.js +9 -13
  25. package/src/api/file/file.controller.js +9 -53
  26. package/src/api/file/file.router.js +14 -6
  27. package/src/api/test/test.controller.js +8 -53
  28. package/src/api/test/test.router.js +1 -4
  29. package/src/cli/cluster.js +104 -11
  30. package/src/cli/db.js +4 -2
  31. package/src/cli/deploy.js +60 -11
  32. package/src/cli/docker-compose.js +212 -1
  33. package/src/cli/fs.js +45 -25
  34. package/src/cli/image.js +147 -51
  35. package/src/cli/index.js +26 -6
  36. package/src/cli/release.js +50 -4
  37. package/src/cli/repository.js +98 -24
  38. package/src/cli/run.js +380 -178
  39. package/src/cli/secrets.js +75 -46
  40. package/src/cli/ssh.js +30 -11
  41. package/src/client/components/core/Modal.js +38 -4
  42. package/src/index.js +1 -1
  43. package/src/server/catalog.js +2 -0
  44. package/src/server/conf.js +163 -3
  45. package/src/server/downloader.js +3 -3
  46. package/src/server/middlewares.js +152 -0
@@ -1,12 +1,10 @@
1
+ import express from 'express';
1
2
  import { adminGuard } from '../../server/auth.js';
2
- import { loggerFactory } from '../../server/logger.js';
3
3
  import { FileController } from './file.controller.js';
4
- import express from 'express';
5
-
6
- const logger = loggerFactory(import.meta);
7
4
 
8
5
  class FileRouter {
9
6
  /**
7
+ * authenticated writes, admin-only deletes.
10
8
  * @param {import('../types.js').RouterOptions} options
11
9
  * @returns {import('express').Router}
12
10
  */
@@ -17,8 +15,18 @@ class FileRouter {
17
15
  router.get(`/blob/:id`, async (req, res) => await FileController.get(req, res, options));
18
16
  router.get(`/:id`, async (req, res) => await FileController.get(req, res, options));
19
17
  router.get(`/`, async (req, res) => await FileController.get(req, res, options));
20
- router.delete(`/:id`, options.authMiddleware, adminGuard, async (req, res) => await FileController.delete(req, res, options));
21
- router.delete(`/`, options.authMiddleware, adminGuard, async (req, res) => await FileController.delete(req, res, options));
18
+ router.delete(
19
+ `/:id`,
20
+ options.authMiddleware,
21
+ adminGuard,
22
+ async (req, res) => await FileController.delete(req, res, options),
23
+ );
24
+ router.delete(
25
+ `/`,
26
+ options.authMiddleware,
27
+ adminGuard,
28
+ async (req, res) => await FileController.delete(req, res, options),
29
+ );
22
30
  return router;
23
31
  }
24
32
  }
@@ -1,59 +1,14 @@
1
- import { loggerFactory } from '../../server/logger.js';
1
+ import { controllerHandler, sendSuccess, serviceHandler } from '../../server/middlewares.js';
2
2
  import { TestService } from './test.service.js';
3
3
 
4
- const logger = loggerFactory(import.meta);
5
-
6
4
  class TestController {
7
- static post = async (req, res, options) => {
8
- try {
9
- return res.status(200).json({
10
- status: 'success',
11
- data: await TestService.post(req, res, options),
12
- });
13
- } catch (error) {
14
- logger.error(error, error.stack);
15
- return res.status(400).json({
16
- status: 'error',
17
- message: error.message,
18
- });
19
- }
20
- };
21
- static get = async (req, res, options) => {
22
- try {
23
- const result = await TestService.get(req, res, options);
24
- if (result)
25
- return res.status(200).json({
26
- status: 'success',
27
- data: result,
28
- });
29
- else
30
- return res.status(400).json({
31
- status: 'error',
32
- });
33
- } catch (error) {
34
- logger.error(error, error.stack);
35
- return res.status(400).json({
36
- status: 'error',
37
- message: error.message,
38
- });
39
- }
40
- };
41
- static delete = async (req, res, options) => {
42
- try {
43
- const result = await TestService.delete(req, res, options);
44
-
45
- return res.status(200).json({
46
- status: 'success',
47
- data: result,
48
- });
49
- } catch (error) {
50
- logger.error(error, error.stack);
51
- return res.status(400).json({
52
- status: 'error',
53
- message: error.message,
54
- });
55
- }
56
- };
5
+ static post = serviceHandler(TestService.post);
6
+ static get = controllerHandler(async (req, res, options) => {
7
+ const result = await TestService.get(req, res, options);
8
+ if (result) return sendSuccess(res, result);
9
+ return res.status(400).json({ status: 'error' });
10
+ });
11
+ static delete = serviceHandler(TestService.delete);
57
12
  }
58
13
 
59
14
  export { TestController };
@@ -1,8 +1,5 @@
1
- import { loggerFactory } from '../../server/logger.js';
2
- import { TestController } from './test.controller.js';
3
1
  import express from 'express';
4
-
5
- const logger = loggerFactory(import.meta);
2
+ import { TestController } from './test.controller.js';
6
3
 
7
4
  class TestRouter {
8
5
  /**
@@ -876,6 +876,73 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
876
876
  }
877
877
  },
878
878
 
879
+ /**
880
+ * @method _pruneContainerCaches
881
+ * @description Reclaims container-runtime disk left behind after a cluster
882
+ * teardown: stopped containers, unused images, build cache and anonymous
883
+ * volumes. Best-effort across every runtime present on the host (docker,
884
+ * podman, and optionally the CRI runtime via crictl). Each block is guarded
885
+ * by a command-existence check, so it is safe to call when a runtime is
886
+ * absent. This is what turns a "cluster deleted" into "disk actually freed":
887
+ * `kind delete` / `kubeadm reset` remove the cluster but leave gigabytes of
888
+ * images and overlay layers under /var/lib/{docker,containers}.
889
+ * @param {object} [options]
890
+ * @param {boolean} [options.all=true] - Remove all unused images, not just dangling ones.
891
+ * @param {boolean} [options.crictl=false] - Also prune the CRI runtime via crictl.
892
+ * @param {string} [options.criSocket] - Optional crictl --runtime-endpoint override.
893
+ * @private
894
+ */
895
+ _pruneContainerCaches(options = {}) {
896
+ const all = options.all !== false;
897
+ const a = all ? '-a ' : '';
898
+ logger.info(` -> Pruning container-runtime caches (all=${all})...`);
899
+ // Docker (also matches the podman-docker shim when docker is symlinked to podman).
900
+ shellExec(
901
+ `if command -v docker >/dev/null 2>&1; then sudo docker system prune ${a}--volumes -f; sudo docker builder prune ${a}-f; fi`,
902
+ { silentOnError: true },
903
+ );
904
+ // Podman native — on this host images/overlays live under /var/lib/containers/storage.
905
+ shellExec(`if command -v podman >/dev/null 2>&1; then sudo podman system prune ${a}--volumes -f; fi`, {
906
+ silentOnError: true,
907
+ });
908
+ if (options.crictl) {
909
+ const ep = options.criSocket ? `--runtime-endpoint ${options.criSocket} ` : '';
910
+ shellExec(
911
+ `if command -v crictl >/dev/null 2>&1; then sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl ${ep}rmi --prune; fi`,
912
+ { silentOnError: true },
913
+ );
914
+ }
915
+ Underpost.cluster._unmountOrphanContainerOverlays();
916
+ },
917
+
918
+ /**
919
+ * @method _unmountOrphanContainerOverlays
920
+ * @description Unmounts leaked container overlay 'merged' mounts under
921
+ * /var/lib/{containers/storage,docker}/overlay. Pruning images/containers
922
+ * frees the backing data but leaves these mountpoints attached, so they
923
+ * keep showing up as identical `overlay ... /merged` rows in `df -h` long
924
+ * after the containers are gone. Only overlays NOT backing a still-existing
925
+ * container are unmounted, so running workloads are never disturbed.
926
+ * Best-effort; safe to call when no runtime is present.
927
+ * @private
928
+ */
929
+ _unmountOrphanContainerOverlays() {
930
+ logger.info(' -> Unmounting orphaned container overlay mounts...');
931
+ shellExec(`if command -v podman >/dev/null 2>&1; then sudo podman umount --all >/dev/null 2>&1 || true; fi`, {
932
+ silentOnError: true,
933
+ });
934
+ shellExec(
935
+ `active="$(sudo podman ps -aq 2>/dev/null | xargs -r -I{} sudo podman inspect --format '{{.GraphDriver.Data.MergedDir}}' {} 2>/dev/null || true)"
936
+ findmnt -rn -o TARGET 2>/dev/null | grep -E '/var/lib/(containers/storage|docker)/overlay.*/merged' | sort -r | while IFS= read -r m; do
937
+ if ! printf '%s\\n' "$active" | grep -qxF "$m"; then
938
+ echo "Unmounting orphaned overlay: $m"
939
+ sudo umount -l "$m" 2>/dev/null || true
940
+ fi
941
+ done`,
942
+ { silentOnError: true },
943
+ );
944
+ },
945
+
879
946
  /**
880
947
  * @method _lazyUmountKubeletMounts
881
948
  * @description Lazy-unmounts every mount under /var/lib/kubelet so a
@@ -910,7 +977,7 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
910
977
  if (options.removeVolumeHostPaths) Underpost.cluster._cleanHostPathPvs();
911
978
  else logger.info(' -> Skipping (pass --remove-volume-host-paths to enable).');
912
979
 
913
- logger.info('Phase 3/5: Deleting all Kind clusters...');
980
+ logger.info('Phase 3/6: Deleting all Kind clusters...');
914
981
  shellExec(`clusters=$(kind get clusters)
915
982
  if [ -n "$clusters" ]; then
916
983
  for c in $clusters; do
@@ -919,11 +986,14 @@ if [ -n "$clusters" ]; then
919
986
  done
920
987
  fi`);
921
988
 
922
- logger.info('Phase 4/5: Cleaning kubeconfig and Kind Docker networks...');
989
+ logger.info('Phase 4/6: Cleaning kubeconfig and Kind Docker networks...');
923
990
  shellExec(`rm -rf "$HOME/.kube"`);
924
991
  Underpost.cluster.recoverKindDockerNetworks();
925
992
 
926
- logger.info('Phase 5/5: Re-applying host configuration (Docker, containerd, sysctl).');
993
+ logger.info('Phase 5/6: Pruning container-runtime caches (kindest/node images, build cache, volumes)...');
994
+ Underpost.cluster._pruneContainerCaches({ all: true });
995
+
996
+ logger.info('Phase 6/6: Re-applying host configuration (Docker, containerd, sysctl).');
927
997
  Underpost.cluster.config();
928
998
 
929
999
  logger.info('=== KIND SAFE RESET COMPLETE ===');
@@ -995,7 +1065,7 @@ fi`);
995
1065
  shellExec(`if ip link show tunl0 >/dev/null 2>&1; then sudo ip link del tunl0; fi`);
996
1066
  shellExec(`sudo iptables -F`);
997
1067
  shellExec(`sudo iptables -t nat -F`);
998
- shellExec(`if command -v crictl >/dev/null 2>&1; then sudo crictl rmi --prune; fi`);
1068
+ Underpost.cluster._pruneContainerCaches({ all: true, crictl: true });
999
1069
 
1000
1070
  logger.info('Phase 7/7: Re-applying host configuration (Docker, containerd, sysctl).');
1001
1071
  Underpost.cluster.config();
@@ -1042,6 +1112,8 @@ fi`);
1042
1112
  shellExec(`if [ -d /etc/rancher/k3s ]; then sudo rm -rf /etc/rancher/k3s; fi`);
1043
1113
  shellExec(`if ip link show flannel.1 >/dev/null 2>&1; then sudo ip link del flannel.1; fi`);
1044
1114
  shellExec(`if ip link show cni0 >/dev/null 2>&1; then sudo ip link del cni0; fi`);
1115
+ // k3s-uninstall.sh removes /var/lib/rancher/k3s; still prune any host docker/podman leftovers.
1116
+ Underpost.cluster._pruneContainerCaches({ all: true });
1045
1117
 
1046
1118
  logger.info('Phase 5/5: Re-applying minimal K3s host config.');
1047
1119
  Underpost.cluster.configMinimalK3s();
@@ -1125,13 +1197,34 @@ exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
1125
1197
  EOF`);
1126
1198
  shellExec(`sudo yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes`);
1127
1199
 
1128
- // Install Helm
1129
- shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1130
- shellExec(`chmod 700 get_helm.sh`);
1131
- shellExec(`./get_helm.sh`);
1132
- shellExec(`chmod +x /usr/local/bin/helm`);
1133
- shellExec(`sudo mv /usr/local/bin/helm /bin/helm`);
1134
- shellExec(`sudo rm -rf get_helm.sh`);
1200
+ // Install Helm — check if already installed and linked to /bin/helm first
1201
+ const helmBin = shellExec(`test -x /bin/helm && echo exists || echo missing`, {
1202
+ stdout: true,
1203
+ silent: true,
1204
+ }).trim();
1205
+ const helmLocalBin = shellExec(`test -x /usr/local/bin/helm && echo exists || echo missing`, {
1206
+ stdout: true,
1207
+ silent: true,
1208
+ }).trim();
1209
+
1210
+ if (helmBin === 'exists') {
1211
+ logger.info('Helm is already installed and linked to /bin/helm; skipping.');
1212
+ } else if (helmLocalBin === 'exists') {
1213
+ // Helm binary exists at /usr/local/bin but not linked to /bin/helm
1214
+ logger.info('Helm found at /usr/local/bin; linking to /bin/helm...');
1215
+ shellExec(`sudo ln -sf /usr/local/bin/helm /bin/helm`);
1216
+ } else {
1217
+ // Helm not installed — download and install
1218
+ shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1219
+ shellExec(`chmod 700 get_helm.sh`);
1220
+ // Run get_helm.sh but ignore its exit code — it may fail on PATH check
1221
+ // even when installation succeeds (binary placed at /usr/local/bin).
1222
+ shellExec(`bash ./get_helm.sh || true`);
1223
+ // Ensure the binary is executable and linked to /bin/helm
1224
+ shellExec(`test -x /usr/local/bin/helm && sudo chmod +x /usr/local/bin/helm || true`);
1225
+ shellExec(`test -x /usr/local/bin/helm && sudo ln -sf /usr/local/bin/helm /bin/helm || true`);
1226
+ shellExec(`sudo rm -rf get_helm.sh`);
1227
+ }
1135
1228
 
1136
1229
  // Install snap
1137
1230
  shellExec(`sudo yum install -y snapd`);
package/src/cli/db.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Supports MariaDB and MongoDB with import/export capabilities, Git integration, and multi-pod operations.
7
7
  */
8
8
 
9
- import { mergeFile, splitFileFactory, loadConfServerJson, resolveConfSecrets } from '../server/conf.js';
9
+ import { mergeFile, splitFileFactory, loadConfServerJson, resolveConfSecrets, loadConfInstances } from '../server/conf.js';
10
10
  import { loggerFactory } from '../server/logger.js';
11
11
  import { shellExec } from '../server/process.js';
12
12
  import fs from 'fs-extra';
@@ -1119,7 +1119,9 @@ class UnderpostDB {
1119
1119
  // Process additional instances
1120
1120
  const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
1121
1121
  if (fs.existsSync(confInstancesPath)) {
1122
- const confInstances = JSON.parse(fs.readFileSync(confInstancesPath, 'utf8'));
1122
+ // Expands multiInstance variants so each deployed instance is
1123
+ // registered (handles both the bare-array and { instances } shapes).
1124
+ const confInstances = loadConfInstances(deployId);
1123
1125
  for (const instance of confInstances) {
1124
1126
  const { id, host, path, fromPort, metadata } = instance;
1125
1127
  const { runtime } = metadata;
package/src/cli/deploy.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  cronDeployIdResolve,
13
13
  deployRangePortFactory,
14
14
  getDataDeploy,
15
+ loadConfInstances,
15
16
  loadConfServerJson,
16
17
  loadReplicas,
17
18
  pathPortAssignmentFactory,
@@ -22,8 +23,25 @@ import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-sta
22
23
  import fs from 'fs-extra';
23
24
  import dotenv from 'dotenv';
24
25
  import os from 'node:os';
26
+ import crypto from 'node:crypto';
25
27
  import Underpost from '../index.js';
26
28
 
29
+ /**
30
+ * Clamps an identifier to the Kubernetes DNS-1123 label limit (63 chars),
31
+ * used for pod-local `volumes[].name` / `volumeMounts[].name`. Names within the
32
+ * limit are returned verbatim so existing short names are stable; longer ones
33
+ * are truncated and suffixed with an 8-char content hash to stay unique and
34
+ * deterministic (e.g. the per-variant instance volume names, which append the
35
+ * full `<deployId>-<env>-<traffic>` and can exceed 63).
36
+ * @param {string} name - Candidate name.
37
+ * @returns {string} A name no longer than 63 characters.
38
+ */
39
+ const k8sVolumeName = (name) => {
40
+ if (typeof name !== 'string' || name.length <= 63) return name;
41
+ const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
42
+ return `${name.slice(0, 54)}-${hash}`;
43
+ };
44
+
27
45
  const logger = loggerFactory(import.meta);
28
46
 
29
47
  /**
@@ -562,7 +580,7 @@ ${Underpost.deploy
562
580
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
563
581
  fs.writeFileSync(yamlPath, secretYaml, 'utf8');
564
582
  } else {
565
- const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml'];
583
+ const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
566
584
  for (const file of deploymentsFiles) {
567
585
  if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
568
586
  fs.copyFileSync(
@@ -668,7 +686,7 @@ spec:
668
686
  * @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
669
687
  * @memberof UnderpostDeploy
670
688
  */
671
- getCurrentTraffic(deployId, options = { hostTest: '', namespace: '' }) {
689
+ getCurrentTraffic(deployId, options = { hostTest: '', namespace: '', env: '' }) {
672
690
  if (!options.namespace) options.namespace = 'default';
673
691
  // kubectl get deploy,sts,svc,configmap,secret -n default -o yaml --export > default.yaml
674
692
  const hostTest = options?.hostTest
@@ -683,6 +701,18 @@ spec:
683
701
  silentOnError: true,
684
702
  });
685
703
  if (!info) return null;
704
+ // Env-scoped resolution: read THIS deploy's colour from its own service
705
+ // name (`<deployId>-<env>-<colour>-service`). Essential for shared
706
+ // multi-instance hosts, where one HTTPProxy holds several variants' routes
707
+ // on possibly different colours — a whole-document `.match('blue')` would
708
+ // return whichever colour appears first, i.e. a sibling's, not this one's.
709
+ // The regex is anchored on the full `<deployId>-<env>-` prefix so
710
+ // `dd-cyberia-mmo-server` never matches `dd-cyberia-mmo-server-forest`.
711
+ if (options.env) {
712
+ const escaped = deployId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
713
+ const match = info.match(new RegExp(`${escaped}-${options.env}-(blue|green)-service`));
714
+ return match ? match[1] : null;
715
+ }
686
716
  return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
687
717
  },
688
718
 
@@ -825,9 +855,9 @@ EOF`);
825
855
  const deployId = _deployId.trim();
826
856
  const instances = [];
827
857
  if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`)) {
828
- const confInstances = JSON.parse(
829
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
830
- );
858
+ // Expands multiInstance variants so status lists every deployed
859
+ // instance (mmo-server, mmo-server-forest, …), not just the templates.
860
+ const confInstances = loadConfInstances(deployId);
831
861
  for (const instance of confInstances) {
832
862
  const _deployId = `${deployId}-${instance.id}`;
833
863
  instances.push({
@@ -838,7 +868,7 @@ EOF`);
838
868
  toPort: instance.toPort,
839
869
  fromDebugPort: instance.fromDebugPort,
840
870
  toDebugPort: instance.toDebugPort,
841
- traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host }),
871
+ traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host, env }),
842
872
  });
843
873
  }
844
874
  }
@@ -1009,10 +1039,17 @@ EOF`);
1009
1039
  */
1010
1040
  configMap(env, namespace = 'default') {
1011
1041
  const cronDeployId = cronDeployIdResolve() || 'dd-cron';
1042
+ const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
1043
+ // `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
1044
+ // `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
1045
+ // overrides the image's own and breaks coreutils/sudo resolution inside the pod.
1046
+ const sanitizedEnvPath = `${envFilePath}.secret`;
1047
+ fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
1012
1048
  shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
1013
1049
  shellExec(
1014
- `kubectl create secret generic underpost-config --from-env-file=/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
1050
+ `kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
1015
1051
  );
1052
+ fs.removeSync(sanitizedEnvPath);
1016
1053
  },
1017
1054
  /**
1018
1055
  * Switches the traffic for a deployment.
@@ -1068,7 +1105,13 @@ EOF`);
1068
1105
  * runners derive it from the comma-path field or `--node-name`
1069
1106
  * (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
1070
1107
  * `--node-name` directly (`run instance`).
1071
- * 2. **Cluster-type default** — when no explicit node is given: `kind-worker`
1108
+ * 2. **`UNDERPOST_DEPLOY_NODE` env** — for kubeadm / k3s, the configured
1109
+ * target node name. This makes hostPath PV `nodeAffinity` deterministic
1110
+ * regardless of where the manifest is *built*: building inside a
1111
+ * container or CI runner would otherwise leak that box's `os.hostname()`
1112
+ * (e.g. a random container id) into `nodeSelector`, pinning the PV to a
1113
+ * node that does not exist in the cluster.
1114
+ * 3. **Cluster-type default** — when nothing above is set: `kind-worker`
1072
1115
  * for a kind cluster (the node that hosts kind hostPath volumes),
1073
1116
  * otherwise the control-plane / current host (`os.hostname()`) for
1074
1117
  * kubeadm / k3s. With no explicit cluster flag, `development` is treated
@@ -1086,7 +1129,8 @@ EOF`);
1086
1129
  resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
1087
1130
  if (node) return node;
1088
1131
  const isKind = kind || (!kubeadm && !k3s && env !== 'production');
1089
- return isKind ? 'kind-worker' : os.hostname();
1132
+ if (isKind) return 'kind-worker';
1133
+ return process.env.UNDERPOST_DEPLOY_NODE || os.hostname();
1090
1134
  },
1091
1135
 
1092
1136
  /**
@@ -1234,13 +1278,18 @@ EOF
1234
1278
  volumeName = `${volumeName}-${version}`;
1235
1279
  claimName = claimName ? `${claimName}-${version}` : null;
1236
1280
  }
1281
+ // The pod-local volume name is a DNS-1123 label (max 63 chars); the PVC
1282
+ // `claimName` it references is a subdomain (max 253) and stays verbatim.
1283
+ // Per-variant instance names append <deployId>-<env>-<traffic> and can
1284
+ // exceed 63, so clamp only the pod-local name (mount name must match it).
1285
+ const podVolumeName = k8sVolumeName(volumeName);
1237
1286
  _volumeMounts += `
1238
- - name: ${volumeName}
1287
+ - name: ${podVolumeName}
1239
1288
  mountPath: ${volumeMountPath}
1240
1289
  ${secret ? ` readOnly: true\n` : ''}`;
1241
1290
 
1242
1291
  _volumes += `
1243
- - name: ${volumeName}
1292
+ - name: ${podVolumeName}
1244
1293
  ${
1245
1294
  emptyDir
1246
1295
  ? ` emptyDir: {}`