underpost 3.2.30 → 3.2.70

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "type": "module",
3
3
  "main": "src/index.js",
4
4
  "name": "underpost",
5
- "version": "3.2.30",
5
+ "version": "3.2.70",
6
6
  "description": "Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orchestration. Extensible via downstream CLIs.",
7
7
  "scripts": {
8
8
  "start": "node --max-old-space-size=8192 src/server",
@@ -71,14 +71,14 @@
71
71
  },
72
72
  "homepage": "https://github.com/underpostnet/pwa-microservices-template#readme",
73
73
  "dependencies": {
74
- "@fortawesome/fontawesome-free": "^7.2.0",
74
+ "@fortawesome/fontawesome-free": "^7.3.0",
75
75
  "@fullcalendar/rrule": "^6.1.21",
76
76
  "@grpc/grpc-js": "^1.14.4",
77
77
  "@grpc/proto-loader": "^0.8.1",
78
78
  "@neodrag/vanilla": "^2.3.1",
79
- "adm-zip": "^0.5.17",
80
- "ag-grid-community": "^35.3.1",
81
- "axios": "^1.18.0",
79
+ "adm-zip": "^0.5.18",
80
+ "ag-grid-community": "^36.0.0",
81
+ "axios": "^1.18.1",
82
82
  "bumpp": "^11.1.0",
83
83
  "chai": "^6.2.2",
84
84
  "clipboardy": "^5.3.1",
@@ -100,11 +100,11 @@
100
100
  "express-slow-down": "^3.1.0",
101
101
  "fast-json-stable-stringify": "^2.1.0",
102
102
  "favicons": "^7.3.0",
103
- "fs-extra": "^11.3.5",
103
+ "fs-extra": "^11.3.6",
104
104
  "fullcalendar": "^6.1.21",
105
105
  "helmet": "^8.2.0",
106
106
  "html-minifier-terser": "^7.2.0",
107
- "http-proxy-middleware": "^4.1.1",
107
+ "http-proxy-middleware": "^4.2.0",
108
108
  "ignore-walk": "^9.0.0",
109
109
  "iovalkey": "^0.3.3",
110
110
  "json-colorizer": "^3.0.1",
@@ -112,9 +112,9 @@
112
112
  "mariadb": "^3.5.3",
113
113
  "marked": "^18.0.5",
114
114
  "mocha": "^11.7.6",
115
- "mongoose": "^9.7.1",
115
+ "mongoose": "^9.7.3",
116
116
  "morgan": "^1.11.0",
117
- "nodemailer": "^9.0.0",
117
+ "nodemailer": "^9.0.3",
118
118
  "nodemon": "^3.0.1",
119
119
  "peer": "^1.0.2",
120
120
  "peerjs": "^1.5.5",
@@ -128,7 +128,7 @@
128
128
  "split-file": "^2.3.0",
129
129
  "swagger-autogen": "^2.23.7",
130
130
  "swagger-ui-express": "^5.0.0",
131
- "typedoc": "^0.28.19",
131
+ "typedoc": "^0.28.20",
132
132
  "typescript": "^6.0.3",
133
133
  "validator": "^13.15.35",
134
134
  "vanilla-jsoneditor": "^3.12.0",
@@ -7,20 +7,20 @@ set -euo pipefail
7
7
 
8
8
  VACUUM_SIZE="200M" # journalctl vacuum target
9
9
  LARGE_LOG_SIZE="+100M" # threshold for truncation (find syntax)
10
- TMP_AGE_DAYS=3 # remove files older than this from /tmp and /var/tmp
11
- CACHE_AGE_DAYS=30 # remove caches older than this from /var/cache
10
+ TMP_AGE_DAYS=1 # remove files older than this from /tmp and /var/tmp
11
+ CACHE_AGE_DAYS=5 # remove caches older than this from /var/cache
12
12
  KEEP_KERNELS=2 # keep this many latest kernels
13
13
  REMOVE_OPT=0 # ALWAYS allow removing /opt entries (user requested)
14
14
 
15
15
  timestamp() { date +"%F %T"; }
16
16
  log() {
17
- echo "$(timestamp) $*"
17
+ echo "$(timestamp) $*"
18
18
  }
19
19
 
20
20
  # must be root
21
21
  if [ "$EUID" -ne 0 ]; then
22
- echo "This script must be run as root. Exiting."
23
- exit 1
22
+ echo "This script must be run as root. Exiting."
23
+ exit 1
24
24
  fi
25
25
 
26
26
  log "=== Starting Rocky Linux 9 automated cleanup (batch mode) ==="
@@ -46,8 +46,8 @@ journalctl --vacuum-size="$VACUUM_SIZE"
46
46
  # 3) Truncate very large logs in /var/log (safer than delete)
47
47
  log "Truncating logs in /var/log larger than ${LARGE_LOG_SIZE} (keeps zero-sized file so services don't break)..."
48
48
  find /var/log -type f -size "$LARGE_LOG_SIZE" -print0 2>/dev/null | while IFS= read -r -d '' f; do
49
- echo "Truncating $f"
50
- : > "$f" || echo "Failed to truncate $f"
49
+ echo "Truncating $f"
50
+ : > "$f" || echo "Failed to truncate $f"
51
51
  done
52
52
 
53
53
  # 4) Clean /tmp and /var/tmp older than TMP_AGE_DAYS
@@ -62,87 +62,110 @@ find /var/cache -mindepth 1 -mtime +"$CACHE_AGE_DAYS" -print0 2>/dev/null | xarg
62
62
  # 6) Clean user caches (~/.cache) and Downloads older than CACHE_AGE_DAYS
63
63
  log "Cleaning user caches and old downloads (home dirs)..."
64
64
  for homedir in /home/* /root; do
65
- [ -d "$homedir" ] || continue
66
- usercache="$homedir/.cache"
67
- userdownloads="$homedir/Downloads"
68
- usertrash="$homedir/.local/share/Trash"
69
-
70
- if [ -d "$usercache" ]; then
71
- log "Removing contents of $usercache"
72
- rm -rf "${usercache:?}/"* 2>/dev/null
73
- fi
74
- if [ -d "$userdownloads" ]; then
75
- log "Removing files older than ${CACHE_AGE_DAYS} days from $userdownloads"
76
- find "$userdownloads" -type f -mtime +"$CACHE_AGE_DAYS" -print0 2>/dev/null | xargs -0r rm -f -- 2>/dev/null
77
- fi
78
- if [ -d "$usertrash" ]; then
79
- log "Emptying trash in $usertrash"
80
- rm -rf "${usertrash:?}/"* 2>/dev/null
81
- fi
65
+ [ -d "$homedir" ] || continue
66
+ usercache="$homedir/.cache"
67
+ userdownloads="$homedir/Downloads"
68
+ usertrash="$homedir/.local/share/Trash"
69
+
70
+ if [ -d "$usercache" ]; then
71
+ log "Removing contents of $usercache"
72
+ rm -rf "${usercache:?}/"* 2>/dev/null
73
+ fi
74
+ if [ -d "$userdownloads" ]; then
75
+ log "Removing files older than ${CACHE_AGE_DAYS} days from $userdownloads"
76
+ find "$userdownloads" -type f -mtime +"$CACHE_AGE_DAYS" -print0 2>/dev/null | xargs -0r rm -f -- 2>/dev/null
77
+ fi
78
+ if [ -d "$usertrash" ]; then
79
+ log "Emptying trash in $usertrash"
80
+ rm -rf "${usertrash:?}/"* 2>/dev/null
81
+ fi
82
82
  done
83
83
 
84
- # 7) Docker / Podman cleanup (if present)
84
+ # 7) Docker / Podman cleanup (if present) — this is what actually reclaims the
85
+ # overlay storage behind those duplicated `overlay` df lines.
85
86
  if command -v docker >/dev/null 2>&1; then
86
- log "Docker detected: pruning images/containers/volumes (aggressive)..."
87
- docker system df
88
- docker system prune -a --volumes -f
87
+ log "Docker detected: pruning images/containers/volumes/build cache (aggressive)..."
88
+ docker system df || true
89
+ docker system prune -a --volumes -f || true
90
+ docker builder prune -a -f || true
89
91
  fi
90
92
 
91
93
  if command -v podman >/dev/null 2>&1; then
92
- log "Podman detected: pruning images/containers (aggressive)..."
93
- podman system df
94
- podman system prune -a -f
94
+ log "Podman detected: pruning images/containers/volumes (aggressive)..."
95
+ podman system df || true
96
+ podman system prune -a --volumes -f || true
95
97
  fi
96
98
 
99
+ if command -v buildah >/dev/null 2>&1; then
100
+ log "Buildah detected: removing leftover working containers..."
101
+ buildah rm --all || true
102
+ fi
103
+
104
+ # 7b) Unmount leaked container overlay 'merged' mounts. Pruning frees the data
105
+ # but leaves these mountpoints attached, so they keep flooding `df -h` with
106
+ # identical `overlay ... /merged` rows. Only unmount overlays not backing a
107
+ # still-existing container, so running workloads are never disturbed.
108
+ log "Unmounting orphaned container overlay mounts..."
109
+ if command -v podman >/dev/null 2>&1; then
110
+ podman umount --all >/dev/null 2>&1 || true
111
+ fi
112
+ active_merged="$(podman ps -aq 2>/dev/null | xargs -r -I{} podman inspect --format '{{.GraphDriver.Data.MergedDir}}' {} 2>/dev/null || true)"
113
+ findmnt -rn -o TARGET 2>/dev/null | grep -E '/var/lib/(containers/storage|docker)/overlay.*/merged' | sort -r | while IFS= read -r m; do
114
+ if ! printf '%s\n' "$active_merged" | grep -qxF "$m"; then
115
+ echo "Unmounting orphaned overlay: $m"
116
+ umount -l "$m" 2>/dev/null || true
117
+ fi
118
+ done
119
+
97
120
  # 8) Flatpak unused runtimes/apps (if present)
98
121
  if command -v flatpak >/dev/null 2>&1; then
99
- log "Flatpak detected: trying to uninstall unused runtimes/apps..."
100
- flatpak uninstall --unused -y || flatpak uninstall --unused
101
- flatpak repair
122
+ log "Flatpak detected: trying to uninstall unused runtimes/apps..."
123
+ flatpak uninstall --unused -y || flatpak uninstall --unused
124
+ flatpak repair
102
125
  fi
103
126
 
104
127
  # 9) Python Pip cleanup
105
128
  if command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1; then
106
- log "Python Pip detected: cleaning cache..."
107
- if command -v pip3 >/dev/null 2>&1; then
108
- pip3 cache purge
109
- elif command -v pip >/dev/null 2>&1; then
110
- pip cache purge
111
- fi
129
+ log "Python Pip detected: cleaning cache..."
130
+ if command -v pip3 >/dev/null 2>&1; then
131
+ pip3 cache purge
132
+ elif command -v pip >/dev/null 2>&1; then
133
+ pip cache purge
134
+ fi
112
135
  fi
113
136
 
114
137
  # 10) Conda cleanup
115
138
  if command -v conda >/dev/null 2>&1; then
116
- log "Conda detected: cleaning all..."
117
- conda clean --all -y
139
+ log "Conda detected: cleaning all..."
140
+ conda clean --all -y
118
141
  fi
119
142
 
120
143
  # 11) Remove old kernels but keep the last KEEP_KERNELS (safe)
121
144
  log "Removing old kernels, keeping the last $KEEP_KERNELS kernels..."
122
145
  OLD_KERNELS=$(dnf repoquery --installonly --latest-limit=-$KEEP_KERNELS -q 2>/dev/null)
123
146
  if [ -n "$OLD_KERNELS" ]; then
124
- log "Old kernels to remove: $OLD_KERNELS"
125
- dnf -y remove $OLD_KERNELS
147
+ log "Old kernels to remove: $OLD_KERNELS"
148
+ dnf -y remove $OLD_KERNELS
126
149
  else
127
- log "No old kernels found by DNF repoquery. Only $KEEP_KERNELS or fewer are currently installed."
150
+ log "No old kernels found by DNF repoquery. Only $KEEP_KERNELS or fewer are currently installed."
128
151
  fi
129
152
 
130
153
  # 12) /opt review and removal (ON by default now)
131
154
  if [ -d /opt ]; then
132
- log "/opt usage (top entries):"
133
- du -sh /opt/* 2>/dev/null | sort -h | head -n 20
134
-
135
- if [ "$REMOVE_OPT" = "1" ]; then
136
- OPT_TARGETS=(/opt/local-path-provisioner)
137
- for t in "${OPT_TARGETS[@]}"; do
138
- if [ -d "$t" ]; then
139
- log "Removing $t as REMOVE_OPT=1"
140
- rm -rf "$t"
141
- fi
142
- done
143
- else
144
- log "Automatic /opt removals disabled (set REMOVE_OPT=1 to enable)."
145
- fi
155
+ log "/opt usage (top entries):"
156
+ du -sh /opt/* 2>/dev/null | sort -h | head -n 20
157
+
158
+ if [ "$REMOVE_OPT" = "1" ]; then
159
+ OPT_TARGETS=(/opt/local-path-provisioner)
160
+ for t in "${OPT_TARGETS[@]}"; do
161
+ if [ -d "$t" ]; then
162
+ log "Removing $t as REMOVE_OPT=1"
163
+ rm -rf "$t"
164
+ fi
165
+ done
166
+ else
167
+ log "Automatic /opt removals disabled (set REMOVE_OPT=1 to enable)."
168
+ fi
146
169
  fi
147
170
 
148
171
  # 13) Find large files > 100MB (report only)
@@ -150,8 +173,10 @@ log "Listing files larger than 100MB (report only):"
150
173
  find / -xdev -type f -size +100M -printf '%s\t%p\n' 2>/dev/null | sort -nr | head -n 50 | awk '{printf "%.1fMB\t%s\n",$1/1024/1024,$2}'
151
174
 
152
175
  # Final disk usage
153
- log "Disk usage after cleanup:"
176
+ log "Disk usage cleanup:"
154
177
  df -h
178
+ log "Top-level usage ( / ):"
179
+ du -xh --max-depth=1 / | sort -rh | head -n 20
155
180
 
156
181
  log "=== Cleanup finished ==="
157
182
  exit 0
@@ -24,7 +24,7 @@ MODE=runtime # runtime | instance
24
24
  ENV=development # development | production
25
25
  DEPLOY_ID=dd-test # deploy id (instance mode: parent of conf.instances.json)
26
26
  INSTANCE_IDS= # instance mode: csv of ids (default: all in conf.instances.json)
27
- IMAGE=underpost/wp:v3.2.30 # runtime mode image (instance mode reads image from conf)
27
+ IMAGE=underpost/wp:v3.2.70 # runtime mode image (instance mode reads image from conf)
28
28
  VERSIONS=green # csv of blue/green versions
29
29
  REPLICAS=1 # replicas per deployment
30
30
  NAMESPACE=default # k8s namespace
@@ -876,6 +876,74 @@ 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(
906
+ `if command -v podman >/dev/null 2>&1; then sudo podman system prune ${a}--volumes -f; fi`,
907
+ { silentOnError: true },
908
+ );
909
+ if (options.crictl) {
910
+ const ep = options.criSocket ? `--runtime-endpoint ${options.criSocket} ` : '';
911
+ shellExec(
912
+ `if command -v crictl >/dev/null 2>&1; then sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl ${ep}rmi --prune; fi`,
913
+ { silentOnError: true },
914
+ );
915
+ }
916
+ Underpost.cluster._unmountOrphanContainerOverlays();
917
+ },
918
+
919
+ /**
920
+ * @method _unmountOrphanContainerOverlays
921
+ * @description Unmounts leaked container overlay 'merged' mounts under
922
+ * /var/lib/{containers/storage,docker}/overlay. Pruning images/containers
923
+ * frees the backing data but leaves these mountpoints attached, so they
924
+ * keep showing up as identical `overlay ... /merged` rows in `df -h` long
925
+ * after the containers are gone. Only overlays NOT backing a still-existing
926
+ * container are unmounted, so running workloads are never disturbed.
927
+ * Best-effort; safe to call when no runtime is present.
928
+ * @private
929
+ */
930
+ _unmountOrphanContainerOverlays() {
931
+ logger.info(' -> Unmounting orphaned container overlay mounts...');
932
+ shellExec(`if command -v podman >/dev/null 2>&1; then sudo podman umount --all >/dev/null 2>&1 || true; fi`, {
933
+ silentOnError: true,
934
+ });
935
+ shellExec(
936
+ `active="$(sudo podman ps -aq 2>/dev/null | xargs -r -I{} sudo podman inspect --format '{{.GraphDriver.Data.MergedDir}}' {} 2>/dev/null || true)"
937
+ findmnt -rn -o TARGET 2>/dev/null | grep -E '/var/lib/(containers/storage|docker)/overlay.*/merged' | sort -r | while IFS= read -r m; do
938
+ if ! printf '%s\\n' "$active" | grep -qxF "$m"; then
939
+ echo "Unmounting orphaned overlay: $m"
940
+ sudo umount -l "$m" 2>/dev/null || true
941
+ fi
942
+ done`,
943
+ { silentOnError: true },
944
+ );
945
+ },
946
+
879
947
  /**
880
948
  * @method _lazyUmountKubeletMounts
881
949
  * @description Lazy-unmounts every mount under /var/lib/kubelet so a
@@ -910,7 +978,7 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
910
978
  if (options.removeVolumeHostPaths) Underpost.cluster._cleanHostPathPvs();
911
979
  else logger.info(' -> Skipping (pass --remove-volume-host-paths to enable).');
912
980
 
913
- logger.info('Phase 3/5: Deleting all Kind clusters...');
981
+ logger.info('Phase 3/6: Deleting all Kind clusters...');
914
982
  shellExec(`clusters=$(kind get clusters)
915
983
  if [ -n "$clusters" ]; then
916
984
  for c in $clusters; do
@@ -919,11 +987,14 @@ if [ -n "$clusters" ]; then
919
987
  done
920
988
  fi`);
921
989
 
922
- logger.info('Phase 4/5: Cleaning kubeconfig and Kind Docker networks...');
990
+ logger.info('Phase 4/6: Cleaning kubeconfig and Kind Docker networks...');
923
991
  shellExec(`rm -rf "$HOME/.kube"`);
924
992
  Underpost.cluster.recoverKindDockerNetworks();
925
993
 
926
- logger.info('Phase 5/5: Re-applying host configuration (Docker, containerd, sysctl).');
994
+ logger.info('Phase 5/6: Pruning container-runtime caches (kindest/node images, build cache, volumes)...');
995
+ Underpost.cluster._pruneContainerCaches({ all: true });
996
+
997
+ logger.info('Phase 6/6: Re-applying host configuration (Docker, containerd, sysctl).');
927
998
  Underpost.cluster.config();
928
999
 
929
1000
  logger.info('=== KIND SAFE RESET COMPLETE ===');
@@ -995,7 +1066,7 @@ fi`);
995
1066
  shellExec(`if ip link show tunl0 >/dev/null 2>&1; then sudo ip link del tunl0; fi`);
996
1067
  shellExec(`sudo iptables -F`);
997
1068
  shellExec(`sudo iptables -t nat -F`);
998
- shellExec(`if command -v crictl >/dev/null 2>&1; then sudo crictl rmi --prune; fi`);
1069
+ Underpost.cluster._pruneContainerCaches({ all: true, crictl: true });
999
1070
 
1000
1071
  logger.info('Phase 7/7: Re-applying host configuration (Docker, containerd, sysctl).');
1001
1072
  Underpost.cluster.config();
@@ -1042,6 +1113,8 @@ fi`);
1042
1113
  shellExec(`if [ -d /etc/rancher/k3s ]; then sudo rm -rf /etc/rancher/k3s; fi`);
1043
1114
  shellExec(`if ip link show flannel.1 >/dev/null 2>&1; then sudo ip link del flannel.1; fi`);
1044
1115
  shellExec(`if ip link show cni0 >/dev/null 2>&1; then sudo ip link del cni0; fi`);
1116
+ // k3s-uninstall.sh removes /var/lib/rancher/k3s; still prune any host docker/podman leftovers.
1117
+ Underpost.cluster._pruneContainerCaches({ all: true });
1045
1118
 
1046
1119
  logger.info('Phase 5/5: Re-applying minimal K3s host config.');
1047
1120
  Underpost.cluster.configMinimalK3s();
package/src/cli/deploy.js CHANGED
@@ -562,7 +562,7 @@ ${Underpost.deploy
562
562
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
563
563
  fs.writeFileSync(yamlPath, secretYaml, 'utf8');
564
564
  } else {
565
- const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml'];
565
+ const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
566
566
  for (const file of deploymentsFiles) {
567
567
  if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
568
568
  fs.copyFileSync(
@@ -1009,10 +1009,17 @@ EOF`);
1009
1009
  */
1010
1010
  configMap(env, namespace = 'default') {
1011
1011
  const cronDeployId = cronDeployIdResolve() || 'dd-cron';
1012
+ const envFilePath = `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${env}`;
1013
+ // `--from-env-file` turns every KEY=VALUE into a secret key that the Deployment injects via
1014
+ // `envFrom`. Strip shell/runtime-critical keys (notably PATH) first — an injected PATH
1015
+ // overrides the image's own and breaks coreutils/sudo resolution inside the pod.
1016
+ const sanitizedEnvPath = `${envFilePath}.secret`;
1017
+ fs.writeFileSync(sanitizedEnvPath, Underpost.secret.sanitizeSecretEnvFile(fs.readFileSync(envFilePath, 'utf8')));
1012
1018
  shellExec(`kubectl delete secret underpost-config -n ${namespace} --ignore-not-found`);
1013
1019
  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}`,
1020
+ `kubectl create secret generic underpost-config --from-env-file=${sanitizedEnvPath} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
1015
1021
  );
1022
+ fs.removeSync(sanitizedEnvPath);
1016
1023
  },
1017
1024
  /**
1018
1025
  * Switches the traffic for a deployment.
@@ -77,14 +77,42 @@ class UnderpostDockerCompose {
77
77
  return nodePath.isAbsolute(relPath) ? relPath : nodePath.join(getRootDirectory(), relPath);
78
78
  }
79
79
 
80
+ /**
81
+ * Resolves the canonical directory for a custom docker-compose workflow,
82
+ * keyed by `--deploy-id` + `--docker-compose-id`:
83
+ * `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>`.
84
+ * This directory ships its own `docker-compose.yml`, `compose.env`, and
85
+ * `nginx.conf` (used as-is, never generated). Returns null when
86
+ * `--docker-compose-id` is not set (the default, self-generating workflow).
87
+ * @param {object} options - CLI options.
88
+ * @returns {string|null} Repo-root-relative canonical dir, or null.
89
+ * @memberof UnderpostDockerCompose
90
+ */
91
+ static composeIdBase(options = {}) {
92
+ if (!options.dockerComposeId) return null;
93
+ const deployId = options.deployId || DEFAULT_DEPLOY_ID;
94
+ return `engine-private/conf/${deployId}/docker-compose/${options.dockerComposeId}`;
95
+ }
96
+
80
97
  /**
81
98
  * Builds the base `docker compose` invocation with explicit file and env-file,
82
99
  * so behavior is independent of the caller's working directory.
100
+ *
101
+ * Custom workflow (`--docker-compose-id`): the compose file, env-file, and
102
+ * bind-mounted config (nginx.conf, mongodb/) all live in the canonical dir, so
103
+ * compose runs with `--project-directory` pinned there and no app-override.
83
104
  * @param {object} options - CLI options.
84
105
  * @returns {string} The base command string (without a subcommand).
85
106
  * @memberof UnderpostDockerCompose
86
107
  */
87
108
  static baseCmd(options = {}) {
109
+ const base = UnderpostDockerCompose.composeIdBase(options);
110
+ if (base) {
111
+ const projectDir = UnderpostDockerCompose.resolve(base);
112
+ const composeFile = UnderpostDockerCompose.resolve(options.composeFile || `${base}/docker-compose.yml`);
113
+ const envFile = UnderpostDockerCompose.resolve(options.envFile || `${base}/compose.env`);
114
+ return `docker compose --project-directory ${projectDir} --env-file ${envFile} -f ${composeFile}`;
115
+ }
88
116
  const composeFile = UnderpostDockerCompose.resolve(options.composeFile || 'docker-compose.yml');
89
117
  const envFile = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
90
118
  const overrideFile = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
@@ -163,7 +191,7 @@ services:
163
191
  MONGO_IMAGE=mongo:latest
164
192
  VALKEY_IMAGE=valkey/valkey:latest
165
193
  APP_IMAGE=underpost/underpost-engine
166
- APP_TAG=v3.2.22
194
+ APP_TAG=v3.2.70
167
195
  PROXY_IMAGE=nginx:stable-alpine
168
196
  PROMETHEUS_IMAGE=prom/prometheus:latest
169
197
  GRAFANA_IMAGE=grafana/grafana:latest
@@ -343,6 +371,20 @@ datasources:
343
371
  * @memberof UnderpostDockerCompose
344
372
  */
345
373
  static generate(options = {}) {
374
+ // Custom workflow: docker-compose.yml, compose.env, and nginx.conf are
375
+ // pre-authored in the canonical dir and used as-is — do NOT generate them.
376
+ // Only (re)write the required MongoDB entrypoint (replica-set bootstrap),
377
+ // the single generated infra artifact, into <canonical>/mongodb so the
378
+ // stack stays self-contained under `--project-directory`.
379
+ const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
380
+ if (composeIdBase) {
381
+ const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
382
+ fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
383
+ fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
384
+ logger.info('mongodb entrypoint written (custom workflow)', { path: mongoEntrypointPath });
385
+ return;
386
+ }
387
+
346
388
  Nginx.removeRouter();
347
389
  for (const { host, routes } of PROXY_HOSTS) Nginx.createApp({ host, routes });
348
390
  Nginx.createDefaultServer(DEFAULT_UPSTREAM);
@@ -449,6 +491,20 @@ datasources:
449
491
  silentOnError: true,
450
492
  });
451
493
 
494
+ // Custom workflow: the compose file, compose.env, and nginx.conf are
495
+ // hand-authored source — never prune them. Only drop the one generated
496
+ // artifact (the mongo entrypoint), regenerated on the next --up/--generate.
497
+ const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
498
+ if (composeIdBase) {
499
+ const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
500
+ if (fs.existsSync(mongoEntrypointPath)) {
501
+ fs.removeSync(mongoEntrypointPath);
502
+ logger.info('removed generated artifact', { path: mongoEntrypointPath });
503
+ }
504
+ logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
505
+ return;
506
+ }
507
+
452
508
  // Phase 2: prune generated artifacts (regenerated on the next --generate/--up).
453
509
  const artifacts = options.force
454
510
  ? [...GENERATED_ARTIFACTS, options.envFile || 'docker/compose.env']
@@ -489,6 +545,7 @@ datasources:
489
545
  * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app).
490
546
  * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand.
491
547
  * @param {string} [options.deployId] - Deployment to run as the app (default: dd-default). `dd-default` self-bootstraps a fresh engine; any other id runs the standard `underpost start` command.
548
+ * @param {string} [options.dockerComposeId] - Custom-workflow selector. When set, use the canonical stack at `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>/` (docker-compose.yml + compose.env + nginx.conf, used as-is), skipping nginx/env/app-override generation. Used by the Cyberia MMO ecosystem (`--deploy-id dd-cyberia --docker-compose-id cyberia`).
492
549
  * @param {string} [options.env] - Deployment environment for non-default deploy ids (default: development).
493
550
  * @param {string} [options.composeFile] - Override compose file path.
494
551
  * @param {string} [options.envFile] - Override env-file path.
package/src/cli/fs.js CHANGED
@@ -64,6 +64,31 @@ class UnderpostFileStorage {
64
64
  writeStorageConf(storage, storageConf) {
65
65
  if (storage) fs.writeFileSync(storageConf, JSON.stringify(storage, null, 4), 'utf8');
66
66
  },
67
+ /**
68
+ * @method gitTrack
69
+ * @description Optional, non-fatal Git tracking layer. Any Git error is logged and swallowed
70
+ * so it can never interrupt or roll back the canonical `storage.*.json` workflow.
71
+ * @param {string} gitPath - The working directory to stage/commit.
72
+ * @param {object} [options] - Tracking options.
73
+ * @param {boolean} [options.init=false] - If true, initialize a local repo before staging.
74
+ * @param {string} [options.message=''] - Explicit commit message; when omitted, `underpost cmt` is used.
75
+ * @memberof UnderpostFileStorage
76
+ */
77
+ gitTrack(gitPath, options = { init: false, message: '' }) {
78
+ try {
79
+ if (options.init === true) Underpost.repo.initLocalRepo({ path: gitPath });
80
+ shellExec(`cd ${gitPath} && git add .`, { silentOnError: true, silent: true, disableLog: true });
81
+ if (options.message)
82
+ shellExec(`cd ${gitPath} && git commit -m "${options.message}"`, {
83
+ silentOnError: true,
84
+ silent: true,
85
+ disableLog: true,
86
+ });
87
+ else shellExec(`underpost cmt ${gitPath} feat`, { silentOnError: true, silent: true, disableLog: true });
88
+ } catch (error) {
89
+ logger.warn('git tracking skipped (non-fatal)', { gitPath, error: error?.message });
90
+ }
91
+ },
67
92
  /**
68
93
  * @method recursiveCallback
69
94
  * @description Recursively processes files and directories based on the provided options.
@@ -125,6 +150,7 @@ class UnderpostFileStorage {
125
150
 
126
151
  if (hasPathFilter && options.force === true && fs.existsSync(basePath)) fs.removeSync(basePath);
127
152
 
153
+ // Storage is canonical: persist the removal before any (optional) git tracking runs.
128
154
  Underpost.fs.writeStorageConf(storage, storageConf);
129
155
 
130
156
  if (associatedPaths.length === 0)
@@ -136,13 +162,8 @@ class UnderpostFileStorage {
136
162
  });
137
163
 
138
164
  if (options.git === true) {
139
- const gitPath = hasPathFilter ? basePath : '.';
140
- shellExec(`cd ${gitPath} && git add .`);
141
- shellExec(`underpost cmt ${gitPath} feat`, {
142
- silentOnError: true,
143
- silent: true,
144
- disableLog: true,
145
- });
165
+ const gitPath = !hasPathFilter ? '.' : isSingleFile ? parentDir : basePath;
166
+ Underpost.fs.gitTrack(gitPath);
146
167
  }
147
168
 
148
169
  return;
@@ -151,7 +172,16 @@ class UnderpostFileStorage {
151
172
  // For single files, run getDeleteFiles against the parent directory to avoid
152
173
  // trying to `cd` into a file.
153
174
  const gitContextPath = isSingleFile ? parentDir : path;
154
- const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(gitContextPath);
175
+ // Detecting locally-deleted files is a best-effort enhancement backed by git; if the path is
176
+ // not a repo (or git is unavailable) it must not block the canonical storage workflow.
177
+ let deleteFiles = [];
178
+ if (options.pull !== true) {
179
+ try {
180
+ deleteFiles = Underpost.repo.getDeleteFiles(gitContextPath);
181
+ } catch (error) {
182
+ logger.warn('delete detection skipped (git unavailable)', { path: gitContextPath, error: error?.message });
183
+ }
184
+ }
155
185
 
156
186
  // When processing a single file, only consider it for deletion
157
187
  for (const relativePath of deleteFiles) {
@@ -172,12 +202,7 @@ class UnderpostFileStorage {
172
202
  if (pullSkipCount > 0) logger.warn(`Pull skipped ${pullSkipCount} files that already exist`);
173
203
  // Only run git init/commit when the caller explicitly requests git tracking (--git flag).
174
204
  // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
175
- if (options.git === true) {
176
- Underpost.repo.initLocalRepo({ path: gitContextPath });
177
- shellExec(`cd ${gitContextPath} && git add . && git commit -m "Base pull state"`, {
178
- silentOnError: true,
179
- });
180
- }
205
+ if (options.git === true) Underpost.fs.gitTrack(gitContextPath, { init: true, message: 'Base pull state' });
181
206
  } else {
182
207
  let files;
183
208
  if (isSingleFile) {
@@ -200,15 +225,9 @@ class UnderpostFileStorage {
200
225
  } else logger.warn('File already exists', _path);
201
226
  }
202
227
  }
228
+ // Storage is canonical and always persisted; git is an optional layer on top.
203
229
  Underpost.fs.writeStorageConf(storage, storageConf);
204
- if (options.git === true) {
205
- shellExec(`cd ${gitContextPath} && git add .`);
206
- shellExec(`underpost cmt ${gitContextPath} feat`, {
207
- silentOnError: true,
208
- silent: true,
209
- disableLog: true,
210
- });
211
- }
230
+ if (options.git === true) Underpost.fs.gitTrack(gitContextPath);
212
231
  },
213
232
  /**
214
233
  * @method callback
@@ -230,10 +249,12 @@ class UnderpostFileStorage {
230
249
  path,
231
250
  options = { rm: false, recursive: false, deployId: '', force: false, pull: false, git: false, omitUnzip: false },
232
251
  ) {
233
- if (options.recursive === true || options.git === true)
252
+ // rm always routes through recursiveCallback so storage.*.json is updated regardless of
253
+ // --recursive/--git. The bare `delete` primitive only removes the remote asset and would
254
+ // otherwise leave the tracked storage key orphaned.
255
+ if (options.recursive === true || options.git === true || options.rm === true)
234
256
  return await Underpost.fs.recursiveCallback(path, options);
235
257
  if (options.pull === true) return await Underpost.fs.pull(path, options);
236
- if (options.rm === true) return await Underpost.fs.delete(path, options);
237
258
  return await Underpost.fs.upload(path, options);
238
259
  },
239
260
  /**