underpost 3.2.90 → 3.3.0

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 (71) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +110 -1
  6. package/CLI-HELP.md +139 -9
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +1 -1
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/package.json +5 -5
  19. package/scripts/audit-selinux.sh +64 -0
  20. package/scripts/coverall-test.sh +24 -0
  21. package/scripts/gpu-diag.sh +0 -0
  22. package/scripts/ip-info.sh +0 -0
  23. package/scripts/k3s-node-setup.sh +18 -15
  24. package/scripts/kubeadm-node-setup.sh +12 -23
  25. package/scripts/link-local-underpost-cli.sh +0 -0
  26. package/scripts/lxd-vm-setup.sh +0 -0
  27. package/scripts/maas-nat-firewalld.sh +0 -0
  28. package/scripts/nat-iptables.sh +2 -0
  29. package/scripts/rhel-grpc-setup.sh +0 -0
  30. package/scripts/rocky-kickstart.sh +25 -9
  31. package/scripts/test-monitor.sh +1 -1
  32. package/src/cli/baremetal.js +1 -2
  33. package/src/cli/cloud-init.js +1 -1
  34. package/src/cli/cluster.js +73 -68
  35. package/src/cli/db.js +9 -2
  36. package/src/cli/deploy.js +21 -5
  37. package/src/cli/docker-compose.js +1 -1
  38. package/src/cli/env.js +1 -1
  39. package/src/cli/image.js +0 -1
  40. package/src/cli/index.js +121 -9
  41. package/src/cli/lxd.js +1 -1
  42. package/src/cli/monitor.js +1 -1
  43. package/src/cli/release.js +57 -22
  44. package/src/cli/repository.js +11 -9
  45. package/src/cli/run.js +36 -9
  46. package/src/cli/ssh.js +198 -77
  47. package/src/cli/system.js +26 -13
  48. package/src/cli/test.js +1 -1
  49. package/src/cli/vultr.js +583 -0
  50. package/src/cli/wireguard.js +2125 -0
  51. package/src/client-builder/client-build.js +20 -14
  52. package/src/db/mongo/MongooseDB.js +4 -0
  53. package/src/index.js +25 -1
  54. package/src/projects/underpost/catalog-underpost.js +4 -1
  55. package/src/server/backup.js +1 -1
  56. package/src/server/conf.js +18 -108
  57. package/src/server/cron.js +249 -51
  58. package/src/server/dns.js +100 -6
  59. package/src/server/environment.js +98 -0
  60. package/src/server/forward-proxy.js +549 -0
  61. package/src/server/middlewares.js +56 -1
  62. package/src/server/process.js +0 -1
  63. package/src/server/selinux.js +185 -0
  64. package/src/server/systemd.js +205 -0
  65. package/src/server/underpost-compression.js +186 -0
  66. package/src/server/underpost-gateway.js +20 -10
  67. package/src/server/underpost-ingress.js +18 -2
  68. package/test/selinux.test.js +71 -0
  69. package/test/underpost-gateway.test.js +41 -0
  70. package/test/underpost-ingress.test.js +52 -0
  71. package/test/wireguard-edge.test.js +1177 -0
@@ -4,10 +4,18 @@
4
4
  * @namespace UnderpostCluster
5
5
  */
6
6
 
7
- import { clusterTypeFactory, gatewayApiEnabledFactory, getNpmRootPath, resolveReplicaCount } from '../server/conf.js';
7
+ import { clusterTypeFactory, gatewayApiEnabledFactory, resolveReplicaCount } from '../server/conf.js';
8
+ import { getNpmRootPath, HOST_VOLUME_ROOT } from '../server/environment.js';
8
9
  import { loggerFactory } from '../server/logger.js';
9
10
  import { shellExec } from '../server/process.js';
10
11
  import { crictlCommandFactory, resolveCriSocket } from '../server/cri.js';
12
+ import {
13
+ runSELinuxCommands,
14
+ selinuxContainerSharedContextCommandsFactory,
15
+ selinuxEnforcingCommandsFactory,
16
+ selinuxPackagesCommandFactory,
17
+ selinuxRestoreconCommandFactory,
18
+ } from '../server/selinux.js';
11
19
  import { UNDERPOST_GATEWAY, seedDefaultStatusPage } from '../server/underpost-gateway.js';
12
20
  import {
13
21
  UNDERPOST_INGRESS,
@@ -24,17 +32,31 @@ import Underpost from '../index.js';
24
32
 
25
33
  const logger = loggerFactory(import.meta);
26
34
 
27
- // Pinned Gateway API control plane. The CRD release and the implementation are
28
- // upgraded together, and the pairing is not free choice: Envoy Gateway builds
29
- // against one `sigs.k8s.io/gateway-api` version (v1.8.3 -> v1.5.1) and reads
30
- // fields the older CRD schemas do not define, which the API
31
- // server silently strips. Check the implementation's go.mod before moving
32
- // either pin.
33
35
  const GATEWAY_API_RELEASE = 'v1.5.1';
34
36
  const ENVOY_GATEWAY_VERSION = 'v1.8.3';
35
- // Namespace the upstream Contour render deploys into, and the only one where an
36
- // `app=envoy` selector resolves to its DaemonSet.
37
37
  const CONTOUR_NAMESPACE = 'projectcontour';
38
+ const KUBEADM_CONTAINER_MOUNT_PATHS = ['/etc/kubernetes', '/var/lib/etcd', '/var/lib/calico'];
39
+ const HOST_VOLUME_PATHS = ['/data', '/opt/local-path-provisioner', HOST_VOLUME_ROOT];
40
+ const K3S_SELINUX_PATHS = [
41
+ '/usr/local/bin/k3s',
42
+ '/etc/rancher',
43
+ '/var/lib/rancher',
44
+ '/var/lib/kubelet',
45
+ '/var/lib/cni',
46
+ ];
47
+
48
+ const shareWithContainers = (paths = []) => {
49
+ for (const path of paths) shellExec(`sudo mkdir -p ${path}`);
50
+ runSELinuxCommands(selinuxContainerSharedContextCommandsFactory(paths), { execute: shellExec });
51
+ };
52
+
53
+ const enforceSELinux = (paths = []) => {
54
+ shellExec(
55
+ `if [ -f /etc/redhat-release ] && command -v dnf >/dev/null 2>&1 && { ! command -v restorecon >/dev/null 2>&1 || ! command -v semanage >/dev/null 2>&1; }; then ${selinuxPackagesCommandFactory()}; fi`,
56
+ );
57
+ const commands = selinuxEnforcingCommandsFactory({ restorePaths: paths });
58
+ runSELinuxCommands(commands, { execute: shellExec });
59
+ };
38
60
 
39
61
  /**
40
62
  * @class UnderpostCluster
@@ -227,8 +249,11 @@ class UnderpostCluster {
227
249
  // balancer. The platform exposes services explicitly via Project
228
250
  // Contour / Envoy and NodePort services (see --node-port); leaving the
229
251
  // K3s built-ins enabled would bind the same host ports and conflict.
230
- shellExec(`curl -sfL https://get.k3s.io | sh -s - --disable=traefik --disable=servicelb`);
252
+ shellExec(
253
+ `curl -sfL https://get.k3s.io | sh -s - $(if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then printf '%s' '--selinux'; fi) --disable=traefik --disable=servicelb`,
254
+ );
231
255
  logger.info('K3s installation completed.');
256
+ runSELinuxCommands([selinuxRestoreconCommandFactory(K3S_SELINUX_PATHS)], { execute: shellExec });
232
257
 
233
258
  Underpost.cluster.chown('k3s');
234
259
 
@@ -240,6 +265,7 @@ class UnderpostCluster {
240
265
  } else if (options.kubeadm) {
241
266
  Underpost.cluster.config();
242
267
  Underpost.cluster.natSetup({ underpostRoot });
268
+ shareWithContainers([...KUBEADM_CONTAINER_MOUNT_PATHS, ...HOST_VOLUME_PATHS]);
243
269
  logger.info('Initializing Kubeadm control plane...');
244
270
  // Set default values if not provided
245
271
  const podNetworkCidr = options.podNetworkCidr || '192.168.0.0/16';
@@ -272,6 +298,13 @@ class UnderpostCluster {
272
298
  shellExec(
273
299
  `kubectl apply -f https://cdn.jsdelivr.net/gh/rancher/local-path-provisioner@master/deploy/local-path-storage.yaml`,
274
300
  );
301
+
302
+ // The CNI lands asynchronously, and the node stays NotReady until it
303
+ // does. Block here so the deploy steps that follow (MongoDB and the
304
+ // application workloads) schedule onto a node that can actually run
305
+ // them, instead of racing the Calico rollout.
306
+ logger.info('Waiting for the node to become Ready (Calico rollout)...');
307
+ shellExec(`kubectl wait --for=condition=Ready node --all --timeout=300s`);
275
308
  } else {
276
309
  Underpost.cluster.config();
277
310
  Underpost.cluster.natSetup({ underpostRoot });
@@ -1198,11 +1231,13 @@ EOF
1198
1231
  }
1199
1232
  }
1200
1233
  const liveConf = canHotReload
1201
- ? `${execIngress('cat /tmp/nginx.conf', {
1202
- stdout: true,
1203
- silent: true,
1204
- silentOnError: true,
1205
- }) || ''}`
1234
+ ? `${
1235
+ execIngress('cat /tmp/nginx.conf', {
1236
+ stdout: true,
1237
+ silent: true,
1238
+ silentOnError: true,
1239
+ }) || ''
1240
+ }`
1206
1241
  : '';
1207
1242
  const shouldHotReload = canHotReload && liveConf.trimEnd() !== `${conf}`.trimEnd();
1208
1243
 
@@ -1222,10 +1257,10 @@ EOF
1222
1257
  { silent: true },
1223
1258
  );
1224
1259
  } catch (error) {
1225
- execIngress(
1226
- `sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
1227
- { silent: true, silentOnError: true },
1228
- );
1260
+ execIngress(`sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`, {
1261
+ silent: true,
1262
+ silentOnError: true,
1263
+ });
1229
1264
  throw error;
1230
1265
  }
1231
1266
  }
@@ -1241,10 +1276,10 @@ EOF
1241
1276
  `);
1242
1277
  } catch (error) {
1243
1278
  if (shouldHotReload)
1244
- execIngress(
1245
- `sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
1246
- { silent: true, silentOnError: true },
1247
- );
1279
+ execIngress(`sh -c 'cp /tmp/nginx.previous.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`, {
1280
+ silent: true,
1281
+ silentOnError: true,
1282
+ });
1248
1283
  throw error;
1249
1284
  }
1250
1285
  if (shouldHotReload)
@@ -1257,18 +1292,16 @@ EOF
1257
1292
  // ready replica that made canHotReload true. Do not let the caller delete
1258
1293
  // the old route kind until the replacement edge is actually Available.
1259
1294
  if (!canHotReload || changesNode)
1260
- shellExec(
1261
- `kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${namespace} --timeout=5m`,
1262
- { silent: true },
1263
- );
1295
+ shellExec(`kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${namespace} --timeout=5m`, {
1296
+ silent: true,
1297
+ });
1264
1298
  else if (shouldHotReload)
1265
1299
  // `nginx -s reload` returns after signalling the master. Give it one
1266
1300
  // scheduling turn to start the new workers before the caller removes
1267
1301
  // the old stack's route object.
1268
1302
  shellExec('sleep 1', { silent: true });
1269
1303
  shellExec(
1270
- `kubectl wait --for=condition=Available deployment/${UNDERPOST_INGRESS.name} ` +
1271
- `-n ${namespace} --timeout=5m`,
1304
+ `kubectl wait --for=condition=Available deployment/${UNDERPOST_INGRESS.name} ` + `-n ${namespace} --timeout=5m`,
1272
1305
  { silent: true },
1273
1306
  );
1274
1307
  execIngress('nginx -t -c /tmp/nginx.conf', { silent: true });
@@ -1350,26 +1383,17 @@ EOF
1350
1383
  config(options = { underpostRoot: '.' }) {
1351
1384
  const { underpostRoot } = options;
1352
1385
  console.log('Applying host configuration: SELinux, Docker, Containerd, and Sysctl settings.');
1353
- // Disable SELinux (permissive mode)
1354
- shellExec(`sudo setenforce 0`, {
1355
- silentOnError: true,
1356
- });
1357
- shellExec(`sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config`, {
1358
- silentOnError: true,
1359
- });
1386
+ enforceSELinux(['/var/lib/kubelet', '/var/lib/containerd']);
1360
1387
 
1361
1388
  // Enable and start Docker and Kubelet services
1362
1389
  shellExec(`sudo systemctl enable --now docker`); // Docker might not be needed for K3s
1363
1390
  shellExec(`sudo systemctl enable --now kubelet`); // Kubelet might not be needed for K3s (K3s uses its own agent)
1364
1391
 
1365
- // Configure containerd for SystemdCgroup and explicitly disable SELinux
1366
- // This is crucial for kubelet/k3s to interact correctly with containerd
1392
+ // Configure containerd for systemd cgroups and SELinux labeling.
1367
1393
  shellExec(`containerd config default | sudo tee /etc/containerd/config.toml > /dev/null`);
1368
1394
  shellExec(`sudo sed -i -e "s/SystemdCgroup = false/SystemdCgroup = true/g" /etc/containerd/config.toml`);
1369
- // Add a new line to disable SELinux for the runc runtime
1370
- // shellExec(
1371
- // `sudo sed -i '/SystemdCgroup = true/a selinux_disabled = true' /etc/containerd/config.toml`,
1372
- // );
1395
+ shellExec(`sudo sed -i -e "s/enable_selinux = false/enable_selinux = true/g" /etc/containerd/config.toml`);
1396
+ runSELinuxCommands([selinuxRestoreconCommandFactory('/etc/containerd')], { execute: shellExec });
1373
1397
  // Restart docker after containerd config changes. Rocky 9 uses systemctl,
1374
1398
  // not the legacy service command.
1375
1399
  shellExec(`sudo systemctl restart docker || sudo service docker restart || true`);
@@ -1400,7 +1424,7 @@ EOF
1400
1424
  * This applies only what K3s genuinely requires, and every step is guarded
1401
1425
  * so it is a no-op when the relevant tooling is absent (e.g. minimal images
1402
1426
  * without SELinux userspace):
1403
- * - SELinux permissive (only if SELinux tooling is present).
1427
+ * - SELinux remains Enforcing.
1404
1428
  * - swap off (Kubernetes best practice).
1405
1429
  * - br_netfilter + bridge/forward sysctls (pod networking).
1406
1430
  * - inotify limits.
@@ -1415,16 +1439,8 @@ EOF
1415
1439
  // forever (the upstream unit ships TimeoutStartSec=0).
1416
1440
  shellExec(`if systemctl is-active --quiet firewalld; then sudo systemctl disable --now firewalld; fi`);
1417
1441
 
1418
- // SELinux → permissive, but only when the tooling exists. Rocky has it;
1419
- // minimal LXD images may not. K3s also installs k3s-selinux for enforcing
1420
- // mode, so this is a best-effort dev convenience, not a hard requirement.
1421
- shellExec(`if command -v setenforce >/dev/null 2>&1; then sudo setenforce 0; fi`, {
1422
- silentOnError: true,
1423
- });
1424
- shellExec(
1425
- `if [ -f /etc/selinux/config ]; then sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config; fi`,
1426
- { silentOnError: true },
1427
- );
1442
+ enforceSELinux(K3S_SELINUX_PATHS);
1443
+ shareWithContainers(HOST_VOLUME_PATHS);
1428
1444
 
1429
1445
  // Disable swap. `swapoff -a` is a no-op without swap; the sed only edits
1430
1446
  // fstab when a swap line is present.
@@ -1697,17 +1713,8 @@ fi`);
1697
1713
  if (options.removeVolumeHostPaths) Underpost.cluster._cleanHostPathPvs();
1698
1714
  else logger.info(' -> Skipping (pass --remove-volume-host-paths to enable).');
1699
1715
 
1700
- logger.info('Phase 2/7: SELinux permissive + restore contexts (when present)...');
1701
- shellExec(`if command -v setenforce >/dev/null 2>&1; then sudo setenforce 0; fi`);
1702
- shellExec(
1703
- `if [ -f /etc/selinux/config ]; then sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config; fi`,
1704
- );
1705
- shellExec(
1706
- `if command -v restorecon >/dev/null 2>&1 && [ -d /var/lib/kubelet ]; then sudo restorecon -Rv /var/lib/kubelet; fi`,
1707
- );
1708
- shellExec(
1709
- `if command -v restorecon >/dev/null 2>&1 && [ -d /var/lib/containerd ]; then sudo restorecon -Rv /var/lib/containerd; fi`,
1710
- );
1716
+ logger.info('Phase 2/7: Enforcing SELinux and restoring runtime contexts...');
1717
+ enforceSELinux(['/var/lib/kubelet', '/var/lib/containerd']);
1711
1718
 
1712
1719
  logger.info('Phase 3/7: Stopping host kubelet and container runtimes (kubeadm-scope only)...');
1713
1720
  shellExec(`if systemctl is-active --quiet kubelet; then sudo systemctl stop kubelet; fi`);
@@ -1981,10 +1988,8 @@ EOF`);
1981
1988
  shellExec(`sudo rm -f /etc/sysctl.d/99-k8s-ipforward.conf`);
1982
1989
  shellExec(`sudo rm -f /etc/sysctl.d/99-k8s.conf`);
1983
1990
 
1984
- // Restore SELinux to enforcing
1985
- console.log('Restoring SELinux to enforcing mode...');
1986
- // shellExec(`sudo setenforce 1`);
1987
- // shellExec(`sudo sed -i 's/^SELINUX=permissive$/SELINUX=enforcing/' /etc/selinux/config`);
1991
+ console.log('Keeping SELinux in enforcing mode...');
1992
+ enforceSELinux();
1988
1993
 
1989
1994
  console.log('Uninstall process completed.');
1990
1995
  },
package/src/cli/db.js CHANGED
@@ -6,12 +6,19 @@
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, loadConfInstances } from '../server/conf.js';
9
+ import {
10
+ loadConfInstances,
11
+ loadConfServerJson,
12
+ loadReplicas,
13
+ mergeFile,
14
+ pathPortAssignmentFactory,
15
+ splitFileFactory,
16
+ } from '../server/conf.js';
10
17
  import { loggerFactory } from '../server/logger.js';
11
18
  import { shellExec } from '../server/process.js';
12
19
  import fs from 'fs-extra';
13
20
  import { DataBaseProviderService } from '../db/DataBaseProvider.js';
14
- import { loadReplicas, pathPortAssignmentFactory, loadCronDeployEnv } from '../server/conf.js';
21
+ import { loadCronDeployEnv } from '../server/cron.js';
15
22
  import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
16
23
  import Underpost from '../index.js';
17
24
  import { timer } from '../client/components/core/CommonJs.js';
package/src/cli/deploy.js CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  buildProxyRouter,
11
11
  clusterTypeFactory,
12
12
  Config,
13
- cronDeployIdResolve,
14
13
  deployHostsFactory,
15
14
  deployRangePortFactory,
16
15
  gatewayApiEnabledFactory,
@@ -24,8 +23,11 @@ import {
24
23
  schedulableNodeFactory,
25
24
  trafficFromRoutingInfoFactory,
26
25
  } from '../server/conf.js';
26
+ import { cronDeployIdResolve } from '../server/cron.js';
27
27
  import { loggerFactory } from '../server/logger.js';
28
+ import { HOST_VOLUME_ROOT } from '../server/environment.js';
28
29
  import { shellExec } from '../server/process.js';
30
+ import { runSELinuxCommands, selinuxRestoreconCommandFactory } from '../server/selinux.js';
29
31
  import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-status.js';
30
32
  import { staticContextRoutesFactory, statusPageRoutesFactory } from '../client-builder/client-build.js';
31
33
  import {
@@ -152,6 +154,13 @@ const gatewayDurationFactory = (value) => {
152
154
 
153
155
  const logger = loggerFactory(import.meta);
154
156
 
157
+ // hostPath volume trees are written under the operator's home directory, which
158
+ // carries a label no unprivileged container can read. Cluster bring-up registers
159
+ // the persistent mapping for HOST_VOLUME_ROOT; this applies it to what a deploy
160
+ // just wrote. A no-op where SELinux or its userspace is absent.
161
+ const restoreContainerContext = (path) =>
162
+ runSELinuxCommands([selinuxRestoreconCommandFactory(path)], { execute: shellExec });
163
+
155
164
  /**
156
165
  * @class UnderpostDeploy
157
166
  * @description Manages the deployment of applications and services.
@@ -724,7 +733,7 @@ ${Underpost.deploy
724
733
  if (!volume.claimName) continue;
725
734
  const pvcId = `${volume.claimName}-${deployId}-${env}-${deploymentVersion}`;
726
735
  const pvId = pvcId.replace(/^pvc-/, 'pv-');
727
- const hostPath = `/home/dd/engine/volume/${pvId}`;
736
+ const hostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
728
737
  volumeYaml += `---\n${Underpost.deploy.persistentVolumeFactory({
729
738
  pvcId,
730
739
  namespace: options.namespace,
@@ -1697,13 +1706,13 @@ ${rules}`;
1697
1706
 
1698
1707
  /**
1699
1708
  * Node directory backing the static utility's volume, following the same
1700
- * `/home/dd/engine/volume/<pv>` convention as every other hostPath volume.
1709
+ * `HOST_VOLUME_ROOT/<pv>` convention as every other hostPath volume.
1701
1710
  * @param {object} [options] - Deploy/run options.
1702
1711
  * @returns {string} Absolute host path.
1703
1712
  * @memberof UnderpostDeploy
1704
1713
  */
1705
1714
  underpostGatewayRootFactory(options = {}) {
1706
- return options.underpostGatewayRoot || `/home/dd/engine/volume/${UNDERPOST_GATEWAY.volumeName}`;
1715
+ return options.underpostGatewayRoot || `${HOST_VOLUME_ROOT}/${UNDERPOST_GATEWAY.volumeName}`;
1707
1716
  },
1708
1717
 
1709
1718
  /**
@@ -1881,6 +1890,12 @@ ${rules}`;
1881
1890
  ? 'project'
1882
1891
  : null,
1883
1892
  });
1893
+ // The documents were just written under the operator's home tree, whose
1894
+ // policy label (user_home_t) the unprivileged gateway container cannot
1895
+ // read — it would answer 403 for every one of them. The persistent
1896
+ // mapping is registered at cluster bring-up; restore it here so files
1897
+ // this pass created carry it too.
1898
+ restoreContainerContext(hostRoot);
1884
1899
  logger.info('Static edge documents placed', {
1885
1900
  deployId,
1886
1901
  podName: podName || '(no running workload; placed from this checkout)',
@@ -2632,7 +2647,7 @@ EOF`);
2632
2647
  const clusterContext = options.clusterContext || 'kind';
2633
2648
  const pvcId = `${volume.claimName}-${deployId}-${env}-${version}`;
2634
2649
  const pvId = `${volume.claimName.replace('pvc-', 'pv-')}-${deployId}-${env}-${version}`;
2635
- const rootVolumeHostPath = `/home/dd/engine/volume/${pvId}`;
2650
+ const rootVolumeHostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
2636
2651
  if (options.gitClean && volume.volumeMountPath) {
2637
2652
  Underpost.repo.clean({ paths: [volume.volumeMountPath] });
2638
2653
  }
@@ -2656,6 +2671,7 @@ EOF`);
2656
2671
  // Target node is the control plane / current host: write directly.
2657
2672
  if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
2658
2673
  fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
2674
+ restoreContainerContext(rootVolumeHostPath);
2659
2675
  } else {
2660
2676
  // Target node is remote: fs.copySync would only write the control-plane
2661
2677
  // filesystem, leaving the real node's hostPath empty. Ship the folder to
@@ -192,7 +192,7 @@ services:
192
192
  MONGO_IMAGE=mongo:latest
193
193
  VALKEY_IMAGE=valkey/valkey:latest
194
194
  APP_IMAGE=underpost/underpost-engine
195
- APP_TAG=v3.2.90
195
+ APP_TAG=v3.3.0
196
196
  PROXY_IMAGE=nginx:stable-alpine
197
197
  PROMETHEUS_IMAGE=prom/prometheus:latest
198
198
  GRAFANA_IMAGE=grafana/grafana:latest
package/src/cli/env.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * @namespace UnderpostEnv
5
5
  */
6
6
 
7
- import { getNpmRootPath, writeEnv } from '../server/conf.js';
7
+ import { getNpmRootPath, writeEnv } from '../server/environment.js';
8
8
  import fs from 'fs-extra';
9
9
  import { loggerFactory } from '../server/logger.js';
10
10
  import dotenv from 'dotenv';
package/src/cli/image.js CHANGED
@@ -10,7 +10,6 @@ import nodePath from 'path';
10
10
  import crypto from 'crypto';
11
11
  import { loggerFactory } from '../server/logger.js';
12
12
  import Underpost from '../index.js';
13
- import { getNpmRootPath } from '../server/conf.js';
14
13
  import { shellExec } from '../server/process.js';
15
14
  import { crictlCommandFactory } from '../server/cri.js';
16
15
 
package/src/cli/index.js CHANGED
@@ -2,7 +2,8 @@ import dotenv from 'dotenv';
2
2
  import fs from 'fs-extra';
3
3
 
4
4
  import { Command } from 'commander';
5
- import { getNpmRootPath, getUnderpostRootPath, loadConf } from '../server/conf.js';
5
+ import { loadConf } from '../server/conf.js';
6
+ import { getNpmRootPath, getUnderpostRootPath } from '../server/environment.js';
6
7
  import { commitData } from '../client/components/core/CommonJs.js';
7
8
 
8
9
  import Underpost from '../index.js';
@@ -51,6 +52,7 @@ program
51
52
  .option('--merge-zip <build-prefix>', 'Merge split ZIP parts back into a single ZIP file for the given build prefix')
52
53
  .option('--lite-build', 'Skip full build (default is full build)')
53
54
  .option('--icons-build', 'Build icons')
55
+ .option('--ssr', 'Rebuild only SSR views defined in conf.ssr.json, leaving client assets untouched')
54
56
  .description('Builds client assets, single replicas, and/or syncs environment ports.')
55
57
  .action(Underpost.repo.client);
56
58
 
@@ -143,7 +145,7 @@ program
143
145
  '--switch-repo <url>',
144
146
  'Switches the git remote (origin) to <url> and force-pulls the target branch, overwriting the current working tree (discards local commits and tracked changes). Accepts a full URL or "owner/repo".',
145
147
  )
146
- .option('--target-branch <branch>', 'Target branch for --switch-repo (default: master).')
148
+ .option('--target-branch <branch>', 'Target branch for --switch-repo (default: remote default branch).')
147
149
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
148
150
  .action(Underpost.repo.commit);
149
151
 
@@ -254,6 +256,10 @@ program
254
256
  .option('--ban-egress-clear', 'Clears all banned egress IP addresses.')
255
257
  .option('--ban-both-add', 'Adds IP addresses to both banned ingress and egress lists.')
256
258
  .option('--ban-both-remove', 'Removes IP addresses from both banned ingress and egress lists.')
259
+ .option('--block-all-egress', 'Blocks all outbound traffic from this host (keeps established/related connections).')
260
+ .option('--unblock-all-egress', 'Unblocks all outbound traffic and restores default ACCEPT policy.')
261
+ .option('--block-all-ingress', 'Blocks all new inbound traffic to this host (keeps established/related connections).')
262
+ .option('--unblock-all-ingress', 'Unblocks all inbound traffic and restores default ACCEPT policy.')
257
263
  .option('--mac', 'Prints the MAC address of the main network interface.')
258
264
  .description('Displays the current public machine IP addresses.')
259
265
  .action(Underpost.dns.ipDispatcher);
@@ -592,19 +598,23 @@ program
592
598
 
593
599
  program
594
600
  .command('cron')
595
- .argument('[deploy-list]', 'A comma-separated list of deployment IDs (e.g., "default-a,default-b").')
601
+ .argument(
602
+ '[deploy-list]',
603
+ 'A comma-separated list of deployment IDs (e.g., "default-a,default-b"). In manifest modes its first entry is the manifest owner deploy-id.',
604
+ )
596
605
  .argument(
597
606
  '[job-list]',
598
- `A comma-separated list of job IDs. Options: ${Underpost.cron.getJobsIDs()}. Defaults to all available jobs.`,
607
+ `A comma-separated list of job IDs. Options: ${Underpost.cron.getJobsIDs()}. Defaults to all available jobs, and restricts which jobs are generated in manifest modes.`,
599
608
  )
600
609
  .option('--generate-k8s-cronjobs', 'Generates Kubernetes CronJob YAML manifests from cron configuration.')
601
- .option('--apply', 'Applies generated K8s CronJob manifests to the cluster via kubectl.')
610
+ .option('--apply', 'Generates and applies K8s CronJob manifests to the cluster via kubectl (never runs jobs).')
602
611
  .option(
603
- '--setup-start [deploy-id]',
604
- 'Updates deploy-id package.json start script and generates+applies its K8s CronJob manifests.',
612
+ '--setup-start',
613
+ 'Updates deploy-list package.json start script and generates+applies its K8s CronJob manifests.',
605
614
  )
606
615
  .option('--namespace <namespace>', 'Kubernetes namespace for the CronJob resources (default: "default").')
607
616
  .option('--image <image>', 'Custom container image for the CronJob pods.')
617
+ .option('--node-name <node-name>', 'Pins the CronJob pods to this node via a kubernetes.io/hostname nodeSelector.')
608
618
  .option('--git', 'Pass --git flag to cron job execution.')
609
619
  .option('--cmd <cmd>', 'Optional pre-script commands to run before cron execution.')
610
620
  .option('--dev', 'Use local ./ base path instead of global underpost installation.')
@@ -614,7 +624,7 @@ program
614
624
  .option('--dry-run', 'Preview cron jobs without executing them.')
615
625
  .option(
616
626
  '--create-job-now',
617
- 'After applying manifests, immediately create a Job from each CronJob (requires --apply).',
627
+ 'Creates a Job from each CronJob on the cluster now (implies manifest mode; combine with --apply to publish first).',
618
628
  )
619
629
  .description('Manages cron jobs: execute jobs directly or generate and apply K8s CronJob manifests.')
620
630
  .action(Underpost.cron.callback);
@@ -696,6 +706,108 @@ program
696
706
  .description('Manages SSH credentials and sessions for remote access to cluster nodes or services.')
697
707
  .action(Underpost.ssh.callback);
698
708
 
709
+ // `underpost wireguard` and `underpost haproxy` are two entrypoints onto one
710
+ // subsystem: the L3 transport and the gateway in front of it are configured from
711
+ // the same deploy state, so they share a single option set and a single action
712
+ // rather than drifting into two half-overlapping command surfaces.
713
+ const edgeCommandFactory = (name, description) =>
714
+ program
715
+ .command(name)
716
+ .option(
717
+ '--deploy-id <deploy-id>',
718
+ 'Deploy IDs whose conf.server.json/conf.instances.json define the routes. ' +
719
+ 'Accepts one id or a comma-separated list; defaults to "dd", every deploy in dd.router, ' +
720
+ 'because the edge holds one pair of map files for the whole cluster.',
721
+ )
722
+ .option('--interface <name>', 'WireGuard interface name (default: "wg0").')
723
+ .option('--wireguard-install', 'Installs the wireguard-tools, haproxy and iptables host packages.')
724
+ .option('--wireguard-setup', 'Generates keys, builds the interface config, and applies local network rules.')
725
+ .option('--server', 'Configures this node as the hub endpoint accepting inbound tunnel traffic.')
726
+ .option('--client', 'Configures this node as a spoke endpoint maintaining an outbound tunnel.')
727
+ .option('--port <port>', 'WireGuard UDP listening port (default: 51820).')
728
+ .option(
729
+ '--cidr <cidr>',
730
+ 'Hub interface address with prefix when used with --server (e.g. "10.0.0.1/24"); ' +
731
+ 'the overlay subnet a spoke routes back through the hub when used with --client (default: "10.0.0.0/24").',
732
+ )
733
+ .option('--peer-ip <ip>', 'Tunnel address of the target spoke. Required with --client and --peer-add.')
734
+ .option('--endpoint <host:port>', 'Hub host and port a spoke dials. Required with --client.')
735
+ .option('--public-key <key>', 'Hub public key with --client; spoke public key with --peer-add.')
736
+ .option('--peer-add <peer-id>', 'Registers a spoke and applies it to the running hub without a restart.')
737
+ .option('--peer-remove <peer-id>', 'Removes a spoke from the registry and from the running hub.')
738
+ .option('--allowed-ips <cidrs>', 'Comma-separated CIDRs routed to the spoke (e.g. "10.0.0.2/32,192.168.10.0/24").')
739
+ .option('--hosts <hosts>', 'Comma-separated hostnames bound to the spoke, overriding instance resolution.')
740
+ .option('--instances <instances>', 'Comma-separated conf.instances.json ids bound to the spoke.')
741
+ .option('--default', 'Marks the spoke as the fallback for hostnames that match no other binding.')
742
+ .option('--haproxy-setup', 'Installs HAProxy, publishes the current routes, and enables the daemon.')
743
+ .option('--haproxy-sync', 'Recompiles the SNI/Host maps from deploy config and hot-reloads HAProxy.')
744
+ .option(
745
+ '--status',
746
+ 'Prints the whole edge context without changing anything: role, interface, tunnel address, ' +
747
+ 'public key, daemon states, peers with their bindings and link health, and the resolved routing.',
748
+ )
749
+ .option(
750
+ '--build-conf',
751
+ 'Writes only engine-private/deploy/conf.wireguard.json and touches no host state. ' +
752
+ 'Combine with --wireguard-setup / --peer-add / --peer-remove to author the topology off-box; ' +
753
+ 'alone it normalizes and validates the existing registry.',
754
+ )
755
+ .option(
756
+ '--forward-proxy-server',
757
+ 'Ensures the hub HTTP/CONNECT forward proxy runs as the underpost-forward-proxy systemd service, ' +
758
+ 'bound to the tunnel address only (default port 1080), and returns. Authenticates every request with ' +
759
+ 'FORWARD_PROXY_API_KEY, so spokes can reach the internet through the VPS public address. Idempotent: ' +
760
+ 're-running converges on the one service and restarts it only when the unit actually changed.',
761
+ )
762
+ .option(
763
+ '--forward-proxy-server-host <host>',
764
+ 'Address the forward proxy binds, overriding the hub tunnel address from the registry.',
765
+ )
766
+ .option('--forward-proxy-server-port <port>', 'Port the forward proxy binds (default: 1080).')
767
+ .option(
768
+ '--ssh-forward-port <port>',
769
+ 'Publishes the default spoke SSH port on this public TCP port of the hub, so CI with no fixed ' +
770
+ 'address can reach the cluster node (e.g. 2222). "0" closes it. Stored in the registry.',
771
+ )
772
+ .option('--wireguard-start', 'Enables and starts wg-quick@<interface> and the QUIC forward.')
773
+ .option('--wireguard-stop', 'Tears down the interface and removes its transient packet rules.')
774
+ .option('--wireguard-reset', 'Removes generated configs and packet rules, keeping the key pair and registry.')
775
+ .option('--wireguard-reinstall', 'Full purge, package reinstall and re-key; every spoke must re-register.')
776
+ .option('--dry-run', 'Prints the files and commands the run would apply, without touching the host.')
777
+ .description(description)
778
+ .action(Underpost.wireguard.callback);
779
+
780
+ edgeCommandFactory(
781
+ 'wireguard',
782
+ 'Manages the WireGuard L3 hub-and-spoke transport and the HAProxy edge gateway in front of it.',
783
+ );
784
+ edgeCommandFactory(
785
+ 'haproxy',
786
+ 'Manages the HAProxy edge gateway over the WireGuard transport (same subsystem as `underpost wireguard`).',
787
+ );
788
+
789
+ program
790
+ .command('vultr')
791
+ .argument('[deploy-list]', 'A comma-separated list of deployment IDs, logged for attribution.')
792
+ .option('--instance-id <instance-id>', 'Vultr instance id to meter (default: VULTR_INSTANCE_ID).')
793
+ .option('--api-key <api-key>', 'Vultr API key (default: VULTR_API_KEY). Prefer the environment over this flag.')
794
+ .option(
795
+ '--threshold <ratio>',
796
+ 'Fraction of the plan quota that triggers the egress block; "0.80" and "80" are both accepted (default: 0.80).',
797
+ )
798
+ .option('--metric <metric>', '"total" (incoming + outgoing, default) or "outgoing" for egress alone.')
799
+ .option('--month <yyyy-mm>', 'Billing month to sum (default: the current UTC month).')
800
+ .option('--all-dates', 'Sum every daily bucket the API returns instead of scoping to one month.')
801
+ .option('--host <ip>', 'Edge VPS to block (default: VULTR_VPS_IP, then DEFAULT_SSH_HOST).')
802
+ .option('--user <user>', 'SSH user on the edge VPS (default: VULTR_SSH_USER, then DEFAULT_SSH_USER, then "root").')
803
+ .option('--key-path <path>', 'SSH private key (default: VULTR_SSH_KEY_PATH, then DEFAULT_SSH_KEY_PATH).')
804
+ .option('--port <port>', 'SSH port on the edge VPS (default: VULTR_SSH_PORT, then DEFAULT_SSH_PORT, then 22).')
805
+ .option('--force', 'Re-apply the egress block even if it was already applied for this cycle.')
806
+ .option('--auto-unblock', 'Restore egress automatically once consumption falls back under the threshold.')
807
+ .option('--dry-run', 'Reports the consumption and the action it would take, without touching the edge VPS.')
808
+ .description('Meters the edge VPS bandwidth against its Vultr plan quota and blocks egress before overage accrues.')
809
+ .action(Underpost.vultr.callback);
810
+
699
811
  program
700
812
  .command('run')
701
813
  .argument('<runner-id>', `The runner ID to run. Options: ${Underpost.run.RUNNERS}.`)
@@ -769,7 +881,7 @@ program
769
881
  .option('--cmd-cron-jobs <cmd-cron-jobs>', 'Pre-script commands to run before cron job execution.')
770
882
  .option(
771
883
  '--deploy-id-cron-jobs <deploy-id-cron-jobs>',
772
- 'Specifies deployment IDs to synchronize cron jobs with during execution.',
884
+ 'Cron deploy-id to set up during sync; defaults to dd.cron, "none" skips cron setup entirely.',
773
885
  )
774
886
  .option('--timezone <timezone>', 'Sets the timezone for the runner execution.')
775
887
  .option('--kubeadm', 'Sets the kubeadm cluster context for the runner execution.')
package/src/cli/lxd.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  */
7
7
 
8
- import { getNpmRootPath } from '../server/conf.js';
8
+ import { getNpmRootPath } from '../server/environment.js';
9
9
  import { pbcopy, shellExec } from '../server/process.js';
10
10
  import walk from 'ignore-walk';
11
11
  import fs from 'fs-extra';
@@ -8,10 +8,10 @@ import {
8
8
  loadReplicas,
9
9
  pathPortAssignmentFactory,
10
10
  loadConfServerJson,
11
- loadCronDeployEnv,
12
11
  etcHostFactory,
13
12
  deployRangePortFactory,
14
13
  } from '../server/conf.js';
14
+ import { loadCronDeployEnv } from '../server/cron.js';
15
15
  import { loggerFactory } from '../server/logger.js';
16
16
  import { timer } from '../client/components/core/CommonJs.js';
17
17
  import {