underpost 3.2.70 → 3.2.90

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 (60) hide show
  1. package/.github/workflows/publish.ci.yml +3 -3
  2. package/.github/workflows/release.cd.yml +1 -1
  3. package/CHANGELOG.md +1358 -1038
  4. package/CLI-HELP.md +39 -16
  5. package/README.md +3 -3
  6. package/bin/build.js +10 -4
  7. package/bin/deploy.js +18 -16
  8. package/docker-compose.yml +1 -1
  9. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  10. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  11. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  12. package/manifests/deployment/playwright/deployment.yaml +1 -1
  13. package/manifests/mongodb/kustomization.yaml +4 -1
  14. package/manifests/mongodb/statefulset.yaml +4 -0
  15. package/manifests/mongodb/storage-class.yaml +9 -2
  16. package/package.json +20 -20
  17. package/scripts/nat-iptables.sh +10 -4
  18. package/scripts/test-monitor.sh +4 -3
  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 +771 -66
  30. package/src/cli/db.js +6 -4
  31. package/src/cli/deploy.js +1715 -168
  32. package/src/cli/docker-compose.js +19 -24
  33. package/src/cli/fs.js +0 -1
  34. package/src/cli/image.js +40 -13
  35. package/src/cli/index.js +129 -35
  36. package/src/cli/ipfs.js +82 -11
  37. package/src/cli/monitor.js +1 -1
  38. package/src/cli/release.js +4 -0
  39. package/src/cli/repository.js +14 -3
  40. package/src/cli/run.js +2253 -439
  41. package/src/cli/secrets.js +969 -0
  42. package/src/cli/ssh.js +38 -39
  43. package/src/client/components/core/Modal.js +38 -4
  44. package/src/client-builder/client-build.js +94 -11
  45. package/src/client-builder/ssr.js +27 -73
  46. package/src/db/mongo/MongoBootstrap.js +295 -54
  47. package/src/db/mongo/MongooseDB.js +47 -32
  48. package/src/index.js +1 -1
  49. package/src/server/conf.js +1307 -6
  50. package/src/server/cri.js +70 -0
  51. package/src/server/downloader.js +3 -3
  52. package/src/server/middlewares.js +152 -0
  53. package/src/server/underpost-gateway.js +1073 -0
  54. package/src/server/underpost-ingress.js +364 -0
  55. package/test/cluster-instances.test.js +435 -0
  56. package/test/deploy-node-placement.test.js +45 -0
  57. package/test/instance-traffic-plan.test.js +710 -0
  58. package/test/sops-secret-store.test.js +612 -0
  59. package/test/underpost-gateway.test.js +469 -0
  60. package/test/underpost-ingress.test.js +253 -0
@@ -4,9 +4,18 @@
4
4
  * @namespace UnderpostCluster
5
5
  */
6
6
 
7
- import { getNpmRootPath } from '../server/conf.js';
7
+ import { clusterTypeFactory, gatewayApiEnabledFactory, getNpmRootPath, resolveReplicaCount } from '../server/conf.js';
8
8
  import { loggerFactory } from '../server/logger.js';
9
9
  import { shellExec } from '../server/process.js';
10
+ import { crictlCommandFactory, resolveCriSocket } from '../server/cri.js';
11
+ import { UNDERPOST_GATEWAY, seedDefaultStatusPage } from '../server/underpost-gateway.js';
12
+ import {
13
+ UNDERPOST_INGRESS,
14
+ gatewayBackendFactory,
15
+ underpostIngressConfFactory,
16
+ underpostIngressHostMapFactory,
17
+ underpostIngressManifestsFactory,
18
+ } from '../server/underpost-ingress.js';
10
19
  import { MONGODB_DEFAULT_REPLICA_COUNT } from '../db/mongo/MongooseDB.js';
11
20
  import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
12
21
  import os from 'os';
@@ -15,6 +24,18 @@ import Underpost from '../index.js';
15
24
 
16
25
  const logger = loggerFactory(import.meta);
17
26
 
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
+ const GATEWAY_API_RELEASE = 'v1.5.1';
34
+ 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
+ const CONTOUR_NAMESPACE = 'projectcontour';
38
+
18
39
  /**
19
40
  * @class UnderpostCluster
20
41
  * @description Manages Kubernetes cluster initialization, configuration, and component deployment.
@@ -41,9 +62,11 @@ class UnderpostCluster {
41
62
  * @param {boolean} [options.ipfs=false] - Deploy ipfs-cluster statefulset.
42
63
  * @param {boolean} [options.info=false] - Display extensive Kubernetes cluster information.
43
64
  * @param {boolean} [options.certManager=false] - Deploy Cert-Manager for certificate management.
65
+ * @param {boolean} [options.gatewayApi=false] - Install the Gateway API control plane (CRDs, Envoy Gateway, and the GatewayClass generated Gateways attach to). Exposure follows the environment: host network in `--dev`, NodePort otherwise.
66
+ * @param {string} [options.gatewayClass=''] - GatewayClass name to provision; must match the one baked into generated manifests.
44
67
  * @param {boolean} [options.listPods=false] - List Kubernetes pods.
45
68
  * @param {boolean} [options.reset=false] - Perform a comprehensive reset of Kubernetes and container environments.
46
- * @param {boolean} [options.resetMongodb=false] - Perform a targeted reset of MongoDB components without restarting the entire cluster.
69
+ * @param {boolean} [options.resetMongodb=false] - Perform a targeted reset of MongoDB components without restarting the entire cluster. Combined with `--mongodb` it instead wipes the retained volumes as part of that deploy.
47
70
  * @param {boolean} [options.dev=false] - Run in development mode (adjusts paths).
48
71
  * @param {string} [options.nsUse=''] - Set the current kubectl namespace (creates namespace if it doesn't exist).
49
72
  * @param {string} [options.namespace='default'] - Kubernetes namespace for cluster operations.
@@ -118,7 +141,7 @@ class UnderpostCluster {
118
141
 
119
142
  if (options.config) return options.k3s ? Underpost.cluster.configMinimalK3s() : Underpost.cluster.config();
120
143
 
121
- if (options.chown) return Underpost.cluster.chown(options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind');
144
+ if (options.chown) return Underpost.cluster.chown(clusterTypeFactory(options));
122
145
 
123
146
  const npmRoot = getNpmRootPath();
124
147
  const underpostRoot = options.dev ? '.' : `${npmRoot}/underpost`;
@@ -165,9 +188,13 @@ class UnderpostCluster {
165
188
  });
166
189
  }
167
190
 
168
- // Targeted MongoDB-only reset (does not restart the whole node)
169
- if (options.resetMongodb) {
170
- const clusterType = options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind';
191
+ // Targeted MongoDB-only reset (does not restart the whole node). Combined
192
+ // with --mongodb it is a modifier instead: the deploy below wipes the
193
+ // retained volumes before rolling the StatefulSet out. `--reset` cannot
194
+ // serve that purpose — it short-circuits into the whole-node reset above,
195
+ // which is why `initReplicaSet`'s reset branch was unreachable from the CLI.
196
+ if (options.resetMongodb && !options.mongodb) {
197
+ const clusterType = clusterTypeFactory(options);
171
198
  return await MongoBootstrap.reset({
172
199
  namespace: options.namespace,
173
200
  clusterType,
@@ -218,17 +245,9 @@ class UnderpostCluster {
218
245
  const podNetworkCidr = options.podNetworkCidr || '192.168.0.0/16';
219
246
  const controlPlaneEndpoint = options.controlPlaneEndpoint || `${os.hostname()}:6443`;
220
247
 
221
- // Initialize kubeadm control plane.
222
- // Use CRI-O socket when available, otherwise fall back to containerd.
223
- const crioSocket = 'unix:///var/run/crio/crio.sock';
224
- const containerdSocket = 'unix:///run/containerd/containerd.sock';
225
- const criSocket =
226
- shellExec(`test -S /var/run/crio/crio.sock && echo crio || echo containerd`, {
227
- stdout: true,
228
- silent: true,
229
- }).trim() === 'crio'
230
- ? crioSocket
231
- : containerdSocket;
248
+ // Initialize kubeadm control plane against whichever CRI runtime the
249
+ // host actually exposes.
250
+ const criSocket = resolveCriSocket(options);
232
251
  shellExec(
233
252
  `sudo kubeadm init --pod-network-cidr=${podNetworkCidr} --control-plane-endpoint="${controlPlaneEndpoint}" --cri-socket=${criSocket}`,
234
253
  );
@@ -258,7 +277,7 @@ class UnderpostCluster {
258
277
  Underpost.cluster.natSetup({ underpostRoot });
259
278
  // Kind cluster initialization (default for development)
260
279
  logger.info('Initializing Kind cluster...');
261
- const devReplicaCount = Math.max(Number(options.replicas) || MONGODB_DEFAULT_REPLICA_COUNT, 3);
280
+ const devReplicaCount = resolveReplicaCount(options.replicas, MONGODB_DEFAULT_REPLICA_COUNT);
262
281
  shellExec(`sudo mkdir -p /data/mongodb`);
263
282
  for (let index = 0; index < devReplicaCount; index++) {
264
283
  shellExec(`sudo mkdir -p /data/mongodb/v${index}`);
@@ -310,7 +329,7 @@ class UnderpostCluster {
310
329
  .readFileSync(`${underpostRoot}/manifests/grafana/deployment.yaml`, 'utf8')
311
330
  .replace('{{GF_SERVER_ROOT_URL}}', options.hosts.split(',')[0])}`;
312
331
  console.log(yaml);
313
- shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
332
+ shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
314
333
  ${yaml}
315
334
  EOF
316
335
  `);
@@ -329,7 +348,7 @@ EOF
329
348
  .join(',')}]`,
330
349
  )}`;
331
350
  console.log(yaml);
332
- shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
351
+ shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
333
352
  ${yaml}
334
353
  EOF
335
354
  `);
@@ -361,9 +380,14 @@ EOF
361
380
  await Underpost.ipfs.deploy(options, underpostRoot);
362
381
  }
363
382
  if (options.mariadb) {
364
- shellExec(
365
- `sudo kubectl create secret generic mariadb-secret --from-file=username=/home/dd/engine/engine-private/mariadb-username --from-file=password=/home/dd/engine/engine-private/mariadb-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
366
- );
383
+ // Secrets before workloads: the StatefulSet's secretKeyRef must resolve at pod admission.
384
+ // Encrypted store first; when no manifest exists the secret is seeded from its origin
385
+ // seed path below. A manifest that exists but fails validation raises rather than
386
+ // silently seeding stale credentials.
387
+ if (!Underpost.secret.sops.applyIfPresent('mariadb-secret', options.namespace))
388
+ shellExec(
389
+ `sudo kubectl create secret generic mariadb-secret --from-file=username=/home/dd/engine/engine-private/mariadb-username --from-file=password=/home/dd/engine/engine-private/mariadb-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
390
+ );
367
391
  shellExec(`kubectl delete statefulset mariadb-statefulset -n ${options.namespace} --ignore-not-found`);
368
392
 
369
393
  if (options.pullImage) Underpost.cluster.pullImage('mariadb:latest', options);
@@ -371,9 +395,10 @@ EOF
371
395
  shellExec(`kubectl apply -k ${underpostRoot}/manifests/mariadb -n ${options.namespace}`);
372
396
  }
373
397
  if (options.mysql) {
374
- shellExec(
375
- `sudo kubectl create secret generic mysql-secret --from-file=username=/home/dd/engine/engine-private/mysql-username --from-file=password=/home/dd/engine/engine-private/mysql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
376
- );
398
+ if (!Underpost.secret.sops.applyIfPresent('mysql-secret', options.namespace))
399
+ shellExec(
400
+ `sudo kubectl create secret generic mysql-secret --from-file=username=/home/dd/engine/engine-private/mysql-username --from-file=password=/home/dd/engine/engine-private/mysql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
401
+ );
377
402
  shellExec(`sudo mkdir -p /mnt/data`);
378
403
  shellExec(`sudo chmod 777 /mnt/data`);
379
404
  shellExec(`sudo chown -R $(whoami):$(whoami) /mnt/data`);
@@ -381,9 +406,10 @@ EOF
381
406
  }
382
407
  if (options.postgresql) {
383
408
  if (options.pullImage) Underpost.cluster.pullImage('postgres:latest', options);
384
- shellExec(
385
- `sudo kubectl create secret generic postgres-secret --from-file=password=/home/dd/engine/engine-private/postgresql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
386
- );
409
+ if (!Underpost.secret.sops.applyIfPresent('postgres-secret', options.namespace))
410
+ shellExec(
411
+ `sudo kubectl create secret generic postgres-secret --from-file=password=/home/dd/engine/engine-private/postgresql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
412
+ );
387
413
  shellExec(`kubectl apply -k ${underpostRoot}/manifests/postgresql -n ${options.namespace}`);
388
414
  }
389
415
  if (options.mongodb4) {
@@ -441,13 +467,13 @@ EOF
441
467
  });
442
468
  }
443
469
  } else if (options.mongodb) {
444
- const clusterType = options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind';
470
+ const clusterType = clusterTypeFactory(options);
445
471
  await MongoBootstrap.initReplicaSet({
446
472
  namespace: options.namespace,
447
- replicaCount: Number(options.replicas) || MONGODB_DEFAULT_REPLICA_COUNT,
473
+ replicaCount: resolveReplicaCount(options.replicas, MONGODB_DEFAULT_REPLICA_COUNT),
448
474
  hostList: serviceHostInput,
449
475
  pullImage: options.pullImage,
450
- reset: options.reset,
476
+ reset: options.resetMongodb === true,
451
477
  clusterType,
452
478
  underpostRoot,
453
479
  });
@@ -459,20 +485,160 @@ EOF
459
485
  });
460
486
  }
461
487
 
488
+ // Installing one stack must never break the other. Whichever is already
489
+ // present decides whether this install takes the node's 80/443 for itself
490
+ // or joins the underpost ingress, so it is read before anything is applied.
491
+ const ingressPresence = Underpost.cluster.ingressStackPresence();
492
+ const sharedIngress =
493
+ (options.contour && ingressPresence.gateway) || (options.gatewayApi && ingressPresence.contour);
494
+
462
495
  if (options.contour) {
463
496
  shellExec(
464
497
  `kubectl apply -f https://cdn.jsdelivr.net/gh/projectcontour/contour@release-1.33/examples/render/contour.yaml`,
465
498
  );
499
+ // The NodePort patch belongs to Contour's own namespace: its `app=envoy`
500
+ // selector only matches the Envoy DaemonSet there. Applied anywhere else
501
+ // the Service is born with no endpoints, and kube-proxy then answers
502
+ // EVERY connection to its ports with an ICMP port-unreachable REJECT —
503
+ // including 443 on every local address, which silently breaks whatever
504
+ // else serves HTTPS on the node.
505
+ Underpost.cluster.pruneEndpointlessService({ name: 'envoy', namespace: options.namespace });
506
+ // Contour's render always claims hostPort 80/443. Left in place beside a
507
+ // Gateway API data plane it would DNAT every packet away from it, so the
508
+ // claim is released and both are reached through the underpost ingress instead.
509
+ if (sharedIngress)
510
+ Underpost.cluster.releaseHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
466
511
  if (options.kubeadm) {
467
512
  // Envoy service might need NodePort for kubeadm
468
513
  shellExec(
469
- `sudo kubectl apply -f ${underpostRoot}/manifests/envoy-service-nodeport.yaml -n ${options.namespace}`,
514
+ `sudo kubectl apply -f ${underpostRoot}/manifests/envoy-service-nodeport.yaml -n ${CONTOUR_NAMESPACE}`,
470
515
  );
471
516
  }
472
517
  // K3s has a built-in LoadBalancer (Klipper-lb) that can expose services,
473
518
  // so a specific NodePort service might not be needed or can be configured differently.
474
519
  }
475
520
 
521
+ if (options.gatewayApi) {
522
+ // Gateway API stack: the CRDs, the implementation that serves them, and
523
+ // the GatewayClass every generated Gateway attaches to. Envoy Gateway is
524
+ // the implementation because the manifests this repo generates use two of
525
+ // its ClientTrafficPolicy extension, which carries the QUIC/HTTP3
526
+ // listener config. Both versions are pinned so a rebuild provisions the
527
+ // same control plane.
528
+ const env = options.dev ? 'development' : 'production';
529
+ // Envoy Gateway provisions and owns its own data plane Service in
530
+ // envoy-gateway-system. Any hand-managed Service elsewhere that publishes
531
+ // the same ports without endpoints would have kube-proxy reject 80/443
532
+ // out from under it, and any DaemonSet holding hostPort 80/443 would
533
+ // swallow the traffic before the data plane's listener sees it.
534
+ Underpost.cluster.pruneEndpointlessService({ name: 'envoy', namespace: options.namespace });
535
+ // Contour keeps serving beside this stack when it is already installed —
536
+ // it only gives up the node's ports, which the underpost ingress takes over.
537
+ // With no Contour present the claim is removed outright, since a leftover
538
+ // DaemonSet from an uninstalled stack has nothing left to serve.
539
+ if (sharedIngress)
540
+ Underpost.cluster.releaseHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
541
+ else Underpost.cluster.pruneHostPortClaim({ name: 'envoy', namespace: CONTOUR_NAMESPACE, ports: [80, 443] });
542
+ shellExec(
543
+ `kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_RELEASE}/standard-install.yaml`,
544
+ );
545
+ shellExec(
546
+ `helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm --version ${ENVOY_GATEWAY_VERSION} ` +
547
+ `--namespace envoy-gateway-system --create-namespace`,
548
+ );
549
+ // The GatewayClass is rejected while the webhook is still coming up.
550
+ shellExec(
551
+ `kubectl wait --namespace envoy-gateway-system --for=condition=Available --timeout=5m deployment/envoy-gateway`,
552
+ { silentOnError: true },
553
+ );
554
+ // Validate against the live CRD schema before creating anything. These
555
+ // are vendor CRDs whose optional fields move between releases, and
556
+ // kubectl applies documents independently: without the pre-flight, a
557
+ // field the installed Envoy Gateway does not know leaves a GatewayClass
558
+ // pointing at an EnvoyProxy that never applied.
559
+ const gatewayClassYaml = Underpost.deploy.gatewayClassYamlFactory({
560
+ env,
561
+ options: { ...options, sharedIngress },
562
+ });
563
+ shellExec(`kubectl apply --dry-run=server -f - <<'EOF'
564
+ ${gatewayClassYaml}
565
+ EOF
566
+ `);
567
+ shellExec(`kubectl apply -f - <<'EOF'
568
+ ${gatewayClassYaml}
569
+ EOF
570
+ `);
571
+ // The static utility that serves status pages and intercepted contexts.
572
+ // It belongs to the gateway tier, not to any one deploy: a single Nginx
573
+ // holds every host's documents, and routes reach it as an ordinary
574
+ // backend — which is what removes the 4096-byte direct-response ceiling.
575
+ //
576
+ // The heredoc delimiter is quoted, as it must be for every generated
577
+ // manifest: the values are already substituted by the template literal,
578
+ // so anything the shell expands here is content. This one carries
579
+ // `nginx.conf`, whose `$uri` an unquoted delimiter deletes — leaving a
580
+ // `try_files` that matches nothing and answers every host's status page
581
+ // with the shared default.
582
+ const underpostGatewayYaml = Underpost.deploy.underpostGatewayYamlFactory(options);
583
+ shellExec(`kubectl apply -f - -n ${options.namespace} <<'EOF'
584
+ ${underpostGatewayYaml}
585
+ EOF
586
+ `);
587
+ const underpostGatewayRoot = Underpost.deploy.underpostGatewayRootFactory(options);
588
+ seedDefaultStatusPage(underpostGatewayRoot);
589
+ logger.info('Gateway static utility applied', {
590
+ name: UNDERPOST_GATEWAY.name,
591
+ root: underpostGatewayRoot,
592
+ });
593
+
594
+ logger.info('Gateway API control plane installed', {
595
+ gatewayApiRelease: GATEWAY_API_RELEASE,
596
+ envoyGateway: ENVOY_GATEWAY_VERSION,
597
+ gatewayClass: Underpost.deploy.gatewayApiConfigFactory(options).gatewayClassName,
598
+ env,
599
+ });
600
+ }
601
+
602
+ // Both stacks are now installed, so the node's 80/443 belong to neither of
603
+ // them. Contour was installed first and Envoy Gateway is arriving, or the
604
+ // reverse — either way this is the point where the underpost ingress has to exist,
605
+ // and the data plane that just came up has to stop binding the host.
606
+ if (sharedIngress) {
607
+ if (options.contour && ingressPresence.gateway) {
608
+ // `--contour` against a cluster that already runs Envoy Gateway: its
609
+ // data plane is still on the host network from a single-stack install,
610
+ // so the EnvoyProxy is re-applied to move it behind the underpost ingress.
611
+ const gatewayClassYaml = Underpost.deploy.gatewayClassYamlFactory({
612
+ env: options.dev ? 'development' : 'production',
613
+ options: { ...options, sharedIngress: true },
614
+ });
615
+ shellExec(`kubectl apply -f - <<'EOF'
616
+ ${gatewayClassYaml}
617
+ EOF
618
+ `);
619
+ shellExec(`kubectl rollout status deployment/envoy-gateway -n envoy-gateway-system --timeout=5m`, {
620
+ silentOnError: true,
621
+ });
622
+ }
623
+ // The edge binds the node directly, so it can only come up once both data
624
+ // planes have actually let go — not merely once their templates say so.
625
+ if (!Underpost.cluster.awaitHostPortsFree({ ports: [80, 443] })) {
626
+ logger.error('Skipping the underpost ingress: it would not be able to bind the node ports', {
627
+ hint: 'resolve the holder above, then re-run this command',
628
+ });
629
+ } else {
630
+ Underpost.cluster.installUnderpostIngress({ namespace: options.namespace, options });
631
+ shellExec(
632
+ `kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${options.namespace} --timeout=3m`,
633
+ { silentOnError: true },
634
+ );
635
+ logger.info('Both ingress stacks are live behind the underpost ingress', {
636
+ ingress: UNDERPOST_INGRESS.name,
637
+ note: 'HTTP/3 is served only by the Gateway API data plane; see the underpost-ingress module',
638
+ });
639
+ }
640
+ }
641
+
476
642
  if (options.certManager) {
477
643
  if (!Underpost.kubectl.get('cert-manager').find((p) => p.STATUS === 'Running')) {
478
644
  shellExec(`helm repo add jetstack https://charts.jetstack.io --force-update`);
@@ -637,27 +803,540 @@ EOF
637
803
  `for node in $(kind get nodes); do cat ${tarPath} | docker exec -i $node ctr --namespace=k8s.io images import -; done`,
638
804
  );
639
805
  shellExec(`rm -f ${tarPath}`);
640
- } else if (options.k3s) {
641
- // K3s uses its own embedded containerd socket, not the host-level one
642
- // used by kubeadm/containerd installations.
806
+ } else {
807
+ // Kubeadm / K3s: pull directly into the active CRI runtime.
808
+ shellExec(crictlCommandFactory(`pull ${image}`, options));
809
+ }
810
+ },
811
+
812
+ /**
813
+ * @method pruneHostPortClaim
814
+ * @description Removes a DaemonSet that reserves node ports the caller needs.
815
+ * A `hostPort` is not a soft claim: the CNI hostport plugin installs a DNAT
816
+ * that rewrites every packet arriving on that port to the claiming pod,
817
+ * ahead of any process listening on the node. A second ingress stack bound
818
+ * to the same port therefore never sees a single packet, and the symptom is
819
+ * a connection refused by the *other* stack — with nothing in its own logs.
820
+ *
821
+ * Only a DaemonSet that actually claims one of `ports` is removed.
822
+ * @param {string} name - DaemonSet name.
823
+ * @param {string} namespace - Namespace to inspect.
824
+ * @param {Array<number>} ports - Host ports the caller requires.
825
+ * @returns {boolean} True when a DaemonSet was pruned.
826
+ * @memberof UnderpostCluster
827
+ */
828
+ pruneHostPortClaim({ name, namespace, ports = [80, 443] }) {
829
+ const claimed = shellExec(
830
+ `kubectl get daemonset ${name} -n ${namespace} ` +
831
+ `-o jsonpath='{.spec.template.spec.containers[*].ports[*].hostPort}'`,
832
+ { stdout: true, silent: true, silentOnError: true },
833
+ );
834
+ const claimedPorts = `${claimed || ''}`
835
+ .split(/\s+/)
836
+ .map((port) => parseInt(port, 10))
837
+ .filter((port) => !isNaN(port));
838
+ const conflicts = claimedPorts.filter((port) => ports.includes(port));
839
+ if (conflicts.length === 0) return false;
840
+ logger.warn(`Pruning DaemonSet ${namespace}/${name}: it reserves host ports needed by this ingress stack`, {
841
+ conflicts,
842
+ reason: 'the CNI hostport DNAT redirects those ports before any node listener receives them',
843
+ });
844
+ shellExec(`kubectl delete daemonset ${name} -n ${namespace} --ignore-not-found`);
845
+ return true;
846
+ },
847
+
848
+ /**
849
+ * @method releaseHostPortClaim
850
+ * @description Strips a DaemonSet's `hostPort` claims without removing the
851
+ * workload, so it keeps serving through its ClusterIP.
852
+ *
853
+ * The non-destructive half of {@link UnderpostCluster.pruneHostPortClaim}.
854
+ * Deleting the DaemonSet is right when one stack replaces the other, and
855
+ * wrong when both are meant to stay: here the data plane is still wanted,
856
+ * it just must not hold the node's ports any more — the underpost ingress does.
857
+ *
858
+ * Applied as a JSON Patch, one `remove` op per claiming port. A
859
+ * strategic-merge patch cannot express this: `ports` is a list merged by
860
+ * `containerPort`, so a patch that simply omits `hostPort` merges into the
861
+ * existing element and leaves the claim exactly where it was — reported as
862
+ * `patched (no change)` while the port stays held. Removing an object field
863
+ * does not shift array indices, so every op in one patch stays valid.
864
+ * @param {string} name - DaemonSet name.
865
+ * @param {string} namespace - Namespace to inspect.
866
+ * @param {Array<number>} [ports] - Host ports to release.
867
+ * @returns {boolean} True when the claim is gone afterwards.
868
+ * @memberof UnderpostCluster
869
+ */
870
+ releaseHostPortClaim({ name, namespace, ports = [80, 443] }) {
871
+ const readClaims = () => {
872
+ const raw = shellExec(
873
+ `kubectl get daemonset ${name} -n ${namespace} -o jsonpath='{.spec.template.spec.containers}'`,
874
+ { stdout: true, silent: true, silentOnError: true },
875
+ );
876
+ try {
877
+ const containers = JSON.parse(`${raw || ''}`);
878
+ return Array.isArray(containers) ? containers : null;
879
+ } catch {
880
+ return null;
881
+ }
882
+ };
883
+
884
+ const containers = readClaims();
885
+ if (!containers) return false;
886
+ const operations = [];
887
+ containers.forEach((container, containerIndex) =>
888
+ (container.ports || []).forEach((port, portIndex) => {
889
+ if (port.hostPort === undefined || !ports.includes(port.hostPort)) return;
890
+ operations.push({
891
+ op: 'remove',
892
+ path: `/spec/template/spec/containers/${containerIndex}/ports/${portIndex}/hostPort`,
893
+ });
894
+ }),
895
+ );
896
+ if (operations.length === 0) return false;
897
+
898
+ logger.warn(`Releasing host ports on DaemonSet ${namespace}/${name}: the underpost ingress owns them now`, {
899
+ ports,
900
+ reason:
901
+ 'its ClusterIP keeps the data plane reachable, and the hostPort DNAT would outrank the underpost ingress listener',
902
+ });
903
+ shellExec(`kubectl patch daemonset ${name} -n ${namespace} --type=json -p '${JSON.stringify(operations)}'`);
904
+
905
+ // Verified rather than assumed: this silently did nothing once already,
906
+ // and the failure only surfaced later as an unschedulable edge.
907
+ const remaining = (readClaims() || [])
908
+ .flatMap((container) => container.ports || [])
909
+ .filter((port) => ports.includes(port.hostPort));
910
+ if (remaining.length > 0) {
911
+ logger.error(`Failed to release host ports on DaemonSet ${namespace}/${name}`, {
912
+ stillClaimed: remaining.map((port) => port.hostPort),
913
+ });
914
+ return false;
915
+ }
916
+ shellExec(`kubectl rollout status daemonset/${name} -n ${namespace} --timeout=3m`, { silentOnError: true });
917
+ return true;
918
+ },
919
+
920
+ /**
921
+ * @method awaitHostPortsFree
922
+ * @description Blocks until no known ingress data plane still holds the node's ports.
923
+ *
924
+ * Releasing a claim edits a template; the pod holding the socket goes away
925
+ * only once the rollout completes. Applying the edge before that leaves it
926
+ * either unschedulable or crash-looping on `bind() … Address in use`, and
927
+ * neither failure names the pod that is actually holding the port.
928
+ * @param {Array<number>} [ports] - Host ports that must be free.
929
+ * @param {string} [contourNamespace] - Namespace holding Contour.
930
+ * @param {number} [timeoutMs] - How long to wait.
931
+ * @returns {boolean} True once nothing claims them.
932
+ * @memberof UnderpostCluster
933
+ */
934
+ awaitHostPortsFree({ ports = [80, 443], contourNamespace = CONTOUR_NAMESPACE, timeoutMs = 3 * 60 * 1000 } = {}) {
935
+ const deadline = Date.now() + timeoutMs;
936
+ const holders = () => {
937
+ const found = [];
938
+ const contourPorts = `${
939
+ shellExec(
940
+ `kubectl get daemonset envoy -n ${contourNamespace} ` +
941
+ `-o jsonpath='{.spec.template.spec.containers[*].ports[*].hostPort}'`,
942
+ { stdout: true, silent: true, silentOnError: true },
943
+ ) || ''
944
+ }`
945
+ .split(/\s+/)
946
+ .map((port) => parseInt(port, 10))
947
+ .filter((port) => ports.includes(port));
948
+ if (contourPorts.length > 0) found.push(`${contourNamespace}/envoy hostPort ${contourPorts.join(',')}`);
949
+ // The Gateway API data plane takes the ports through the host network
950
+ // rather than a hostPort, so its claim is the network mode itself.
951
+ const gatewayHostNetwork = `${
952
+ shellExec(
953
+ `kubectl get deployment -n envoy-gateway-system -l app.kubernetes.io/name=envoy ` +
954
+ `-o jsonpath='{.items[*].spec.template.spec.hostNetwork}'`,
955
+ { stdout: true, silent: true, silentOnError: true },
956
+ ) || ''
957
+ }`.trim();
958
+ if (gatewayHostNetwork.includes('true')) found.push('envoy-gateway-system data plane hostNetwork');
959
+ return found;
960
+ };
961
+
962
+ let current = holders();
963
+ while (current.length > 0 && Date.now() < deadline) {
964
+ logger.info('Waiting for the node ports to be released', { ports, heldBy: current });
965
+ shellExec('sleep 5', { silent: true });
966
+ current = holders();
967
+ }
968
+ if (current.length > 0) {
969
+ logger.error('Node ports are still held; the underpost ingress cannot bind them', {
970
+ ports,
971
+ heldBy: current,
972
+ });
973
+ return false;
974
+ }
975
+ return true;
976
+ },
977
+
978
+ /**
979
+ * @method ingressStackPresence
980
+ * @description Which ingress data planes are installed right now.
981
+ *
982
+ * Read from the cluster rather than from the flags, because the whole point
983
+ * is to react to what a previous invocation left behind: `--contour` run
984
+ * against a cluster that already has Envoy Gateway has to behave differently
985
+ * from `--contour` on an empty one.
986
+ * @param {string} [contourNamespace] - Namespace holding Contour.
987
+ * @returns {{contour: boolean, gateway: boolean}} Presence of each stack.
988
+ * @memberof UnderpostCluster
989
+ */
990
+ ingressStackPresence(contourNamespace = CONTOUR_NAMESPACE) {
991
+ const exists = (cmd) =>
992
+ `${shellExec(cmd, { stdout: true, silent: true, silentOnError: true }) || ''}`.trim().length > 0;
993
+ return {
994
+ contour: exists(`kubectl get daemonset envoy -n ${contourNamespace} -o name`),
995
+ gateway: exists(`kubectl get deployment envoy-gateway -n envoy-gateway-system -o name`),
996
+ };
997
+ },
998
+
999
+ /**
1000
+ * @method refreshUnderpostIngress
1001
+ * @description Rebuilds the underpost ingress host table after routes change.
1002
+ *
1003
+ * The table maps each hostname to the data plane that has a route object for
1004
+ * it, so it goes stale the moment a host is published — a hostname added
1005
+ * after the last build falls through to the default backend, which is the
1006
+ * *other* stack, and answers 404 for a workload that is running perfectly.
1007
+ * Every path that publishes a route therefore ends here.
1008
+ *
1009
+ * A no-op when the ingress is not installed, which is the single-stack case:
1010
+ * there is no table to keep, because the one data plane owns the ports.
1011
+ * The rendered config is unchanged by a blue/green promotion — the table is
1012
+ * host to stack, not host to colour — so an ordinary deploy re-applies the
1013
+ * same bytes and nothing restarts.
1014
+ * @param {string} [namespace] - Namespace holding the ingress.
1015
+ * @param {object} [options] - Cluster options. `ingressNode` is the only
1016
+ * placement override; application `node` / `nodeName` flags are ignored.
1017
+ * @returns {boolean} True when the table was rebuilt.
1018
+ * @memberof UnderpostCluster
1019
+ */
1020
+ refreshUnderpostIngress({ namespace = 'default', options = {} } = {}) {
1021
+ const installed = `${
1022
+ shellExec(`kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} -o name`, {
1023
+ stdout: true,
1024
+ silent: true,
1025
+ silentOnError: true,
1026
+ }) || ''
1027
+ }`.trim();
1028
+ if (!installed) return false;
1029
+ return Underpost.cluster.installUnderpostIngress({ namespace, options });
1030
+ },
1031
+
1032
+ /**
1033
+ * @method installUnderpostIngress
1034
+ * @description Installs or refreshes the underpost ingress in front of both data planes.
1035
+ *
1036
+ * The host table is built from the route objects that actually exist, so a
1037
+ * hostname reaches the stack that describes it no matter which flag was used
1038
+ * last. Rebuilt on every call, which is what keeps it correct after routes
1039
+ * move between stacks.
1040
+ * @param {string} [namespace] - Namespace to deploy the underpost ingress into.
1041
+ * @param {object} [options] - Cluster options. Use `ingressNode` to
1042
+ * explicitly place or recover the public listener.
1043
+ * @returns {boolean} True when the underpost ingress was applied.
1044
+ * @memberof UnderpostCluster
1045
+ */
1046
+ installUnderpostIngress({ namespace = 'default', options = {} } = {}) {
1047
+ const presence = Underpost.cluster.ingressStackPresence();
1048
+ // Each kind names its hosts in its own field, so each is read with its own
1049
+ // jsonpath rather than one expression that happens to tolerate the other's
1050
+ // shape being absent.
1051
+ const HOST_FIELD = { httpproxy: '.spec.virtualhost.fqdn', httproute: '.spec.hostnames[*]' };
1052
+ const hostsOf = (kind) =>
1053
+ `${
1054
+ shellExec(`kubectl get ${kind} -A -o jsonpath='{range .items[*]}{${HOST_FIELD[kind]}}{" "}{end}'`, {
1055
+ stdout: true,
1056
+ silent: true,
1057
+ silentOnError: true,
1058
+ }) || ''
1059
+ }`
1060
+ .split(/\s+/)
1061
+ .map((host) => host.trim())
1062
+ .filter(Boolean);
1063
+
1064
+ const backends = {};
1065
+ if (presence.contour) backends.contour = UNDERPOST_INGRESS.backends.contour;
1066
+ if (presence.gateway) {
1067
+ // Envoy Gateway names its own data plane Service, so it is discovered
1068
+ // rather than assumed; without it the underpost ingress would proxy to nothing.
1069
+ const service = `${
1070
+ shellExec(
1071
+ `kubectl get svc -n envoy-gateway-system -l app.kubernetes.io/name=envoy -o jsonpath='{.items[0].metadata.name}'`,
1072
+ { stdout: true, silent: true, silentOnError: true },
1073
+ ) || ''
1074
+ }`.trim();
1075
+ if (service) backends.gateway = gatewayBackendFactory(service);
1076
+ else logger.warn('Envoy Gateway is installed but has provisioned no data plane Service yet');
1077
+ }
1078
+ if (Object.keys(backends).length === 0) {
1079
+ logger.warn('No ingress data plane installed; skipping the underpost ingress');
1080
+ return false;
1081
+ }
1082
+
1083
+ const { entries, conflicts } = underpostIngressHostMapFactory({
1084
+ contourHosts: presence.contour ? hostsOf('httpproxy') : [],
1085
+ gatewayHosts: presence.gateway ? hostsOf('httproute') : [],
1086
+ // During migration both route kinds deliberately coexist. Point the
1087
+ // host at the destination stack before the old object is deleted; a
1088
+ // fixed Gateway preference would create a 404 window when migrating in
1089
+ // the opposite direction (Gateway API -> Contour).
1090
+ preferred: gatewayApiEnabledFactory(options) ? 'gateway' : 'contour',
1091
+ });
1092
+ if (conflicts.length > 0)
1093
+ logger.warn('Hosts described by both stacks; the destination stack wins until the old route is removed', {
1094
+ conflicts,
1095
+ destination: gatewayApiEnabledFactory(options) ? 'gateway' : 'contour',
1096
+ });
1097
+
1098
+ const conf = underpostIngressConfFactory({
1099
+ entries,
1100
+ backends,
1101
+ resolver: Underpost.deploy.clusterDnsFactory(),
1102
+ defaultBackend: backends.gateway ? 'gateway' : 'contour',
1103
+ });
1104
+ const liveNode = `${
643
1105
  shellExec(
644
- `sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl --runtime-endpoint unix:///run/k3s/containerd/containerd.sock pull ${image}`,
1106
+ `kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} ` +
1107
+ `-o jsonpath='{.spec.template.spec.nodeSelector.kubernetes\\.io/hostname}'`,
1108
+ { stdout: true, silent: true, silentOnError: true },
1109
+ ) || ''
1110
+ }`.trim();
1111
+ // `node` and `nodeName` place application workloads. Reusing either here
1112
+ // relocates the public listener during an ordinary deploy; with Recreate
1113
+ // that first deletes the healthy edge and can leave the whole site on
1114
+ // connection-refused if the destination cannot pull Nginx. Only the
1115
+ // dedicated ingressNode option may move this workload. Every route-table
1116
+ // refresh otherwise preserves the established edge node.
1117
+ const requestedNode = options.ingressNode || '';
1118
+ const chosenNode =
1119
+ requestedNode ||
1120
+ liveNode ||
1121
+ Underpost.deploy.resolveDeployNode({
1122
+ node: '',
1123
+ kind: options.kind,
1124
+ kubeadm: options.kubeadm,
1125
+ k3s: options.k3s,
1126
+ env: options.dev ? 'development' : 'production',
1127
+ });
1128
+ // The chosen name is a guess unless it came from `--ingress-node`: the
1129
+ // cluster-type default reads `--dev` as kind, and `liveNode` re-reads
1130
+ // whatever a previous run wrote. This workload is pinned by `nodeSelector`
1131
+ // with `hostNetwork`, so a name no node carries does not degrade — the pod
1132
+ // stays Pending and every rollout wait times out.
1133
+ const { node: ingressNode, corrected } = Underpost.deploy.resolveSchedulableNode({ node: chosenNode });
1134
+ if (corrected && requestedNode)
1135
+ throw new Error(
1136
+ `[underpost-ingress] --ingress-node ${requestedNode} is not a node in this cluster (schedulable: ${ingressNode})`,
645
1137
  );
646
- } else if (options.kubeadm) {
647
- // Kubeadm / K3s: use crictl to pull directly into the active CRI runtime.
648
- // crictl is not in sudo's secure_path; pass full PATH through env.
649
- // Point crictl at CRI-O when the socket exists, otherwise fall back to containerd.
650
- const criSock =
651
- shellExec(`test -S /var/run/crio/crio.sock && echo crio || echo containerd`, {
1138
+ if (corrected)
1139
+ logger.warn('Ingress node does not exist in this cluster; using a schedulable node instead', {
1140
+ chosen: chosenNode,
1141
+ from: liveNode === chosenNode ? 'the live deployment' : 'the cluster-type default',
1142
+ using: ingressNode,
1143
+ });
1144
+ const liveMount = `${
1145
+ shellExec(
1146
+ `kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} ` +
1147
+ `-o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="nginx-conf")].mountPath}'`,
1148
+ { stdout: true, silent: true, silentOnError: true },
1149
+ ) || ''
1150
+ }`.trim();
1151
+ const liveReady = `${
1152
+ shellExec(
1153
+ `kubectl get deployment ${UNDERPOST_INGRESS.name} -n ${namespace} -o jsonpath='{.status.readyReplicas}'`,
1154
+ { stdout: true, silent: true, silentOnError: true },
1155
+ ) || ''
1156
+ }`.trim();
1157
+ const canHotReload = liveMount === '/etc/underpost-ingress' && parseInt(liveReady || '0', 10) > 0;
1158
+ const changesNode = !!liveNode && ingressNode !== liveNode;
1159
+ const execIngress = (command, execOptions = {}) =>
1160
+ shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_INGRESS.name} -- ${command}`, execOptions);
1161
+
1162
+ if (changesNode) {
1163
+ // Prove the destination can start the exact edge image before Recreate
1164
+ // removes the listener that is serving now. This pod has no hostNetwork,
1165
+ // so it cannot contend for 80/443. On an offline node, IfNotPresent uses
1166
+ // the cache; a missing image fails here while the current edge remains.
1167
+ const preflightPod = `${UNDERPOST_INGRESS.name}-image-preflight`;
1168
+ shellExec(`kubectl delete pod ${preflightPod} -n ${namespace} --ignore-not-found`, {
1169
+ silent: true,
1170
+ silentOnError: true,
1171
+ });
1172
+ try {
1173
+ shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
1174
+ apiVersion: v1
1175
+ kind: Pod
1176
+ metadata:
1177
+ name: ${preflightPod}
1178
+ namespace: ${namespace}
1179
+ spec:
1180
+ restartPolicy: Never
1181
+ nodeSelector:
1182
+ kubernetes.io/hostname: ${ingressNode}
1183
+ containers:
1184
+ - name: nginx
1185
+ image: ${UNDERPOST_INGRESS.image}
1186
+ imagePullPolicy: IfNotPresent
1187
+ command: ['/bin/sh', '-c', 'nginx -v && sleep 300']
1188
+ EOF
1189
+ `);
1190
+ shellExec(`kubectl wait --for=condition=Ready pod/${preflightPod} -n ${namespace} --timeout=2m`, {
1191
+ silent: true,
1192
+ });
1193
+ } finally {
1194
+ shellExec(`kubectl delete pod ${preflightPod} -n ${namespace} --ignore-not-found`, {
1195
+ silent: true,
1196
+ silentOnError: true,
1197
+ });
1198
+ }
1199
+ }
1200
+ const liveConf = canHotReload
1201
+ ? `${execIngress('cat /tmp/nginx.conf', {
652
1202
  stdout: true,
653
1203
  silent: true,
654
- }).trim() === 'crio'
655
- ? 'unix:///var/run/crio/crio.sock'
656
- : 'unix:///run/containerd/containerd.sock';
657
- shellExec(`sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl --runtime-endpoint ${criSock} pull ${image}`);
1204
+ silentOnError: true,
1205
+ }) || ''}`
1206
+ : '';
1207
+ const shouldHotReload = canHotReload && liveConf.trimEnd() !== `${conf}`.trimEnd();
1208
+
1209
+ if (shouldHotReload) {
1210
+ // Validate the exact candidate before either the running master or the
1211
+ // persisted ConfigMap sees it. The quoted heredoc preserves every Nginx
1212
+ // variable and keeps a malformed host table from reaching the edge.
1213
+ shellExec(`kubectl exec -i -n ${namespace} deploy/${UNDERPOST_INGRESS.name} -- sh -c 'cat > /tmp/nginx.candidate.conf' <<'EOF'
1214
+ ${conf}
1215
+ EOF
1216
+ `);
1217
+ execIngress('nginx -t -c /tmp/nginx.candidate.conf', { silent: true });
1218
+ try {
1219
+ execIngress(
1220
+ `sh -c 'cp /tmp/nginx.conf /tmp/nginx.previous.conf && ` +
1221
+ `cp /tmp/nginx.candidate.conf /tmp/nginx.conf && nginx -s reload -c /tmp/nginx.conf'`,
1222
+ { silent: true },
1223
+ );
1224
+ } 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
+ );
1229
+ throw error;
1230
+ }
1231
+ }
1232
+
1233
+ try {
1234
+ shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
1235
+ ${underpostIngressManifestsFactory({
1236
+ namespace,
1237
+ conf,
1238
+ nodeName: ingressNode,
1239
+ })}
1240
+ EOF
1241
+ `);
1242
+ } catch (error) {
1243
+ 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
+ );
1248
+ throw error;
658
1249
  }
1250
+ if (shouldHotReload)
1251
+ execIngress('rm -f /tmp/nginx.previous.conf /tmp/nginx.candidate.conf', {
1252
+ silent: true,
1253
+ silentOnError: true,
1254
+ });
1255
+ // A live pod before apply is not proof the desired template rolled out.
1256
+ // In particular, changing nodeSelector uses Recreate and invalidates the
1257
+ // ready replica that made canHotReload true. Do not let the caller delete
1258
+ // the old route kind until the replacement edge is actually Available.
1259
+ if (!canHotReload || changesNode)
1260
+ shellExec(
1261
+ `kubectl rollout status deployment/${UNDERPOST_INGRESS.name} -n ${namespace} --timeout=5m`,
1262
+ { silent: true },
1263
+ );
1264
+ else if (shouldHotReload)
1265
+ // `nginx -s reload` returns after signalling the master. Give it one
1266
+ // scheduling turn to start the new workers before the caller removes
1267
+ // the old stack's route object.
1268
+ shellExec('sleep 1', { silent: true });
1269
+ shellExec(
1270
+ `kubectl wait --for=condition=Available deployment/${UNDERPOST_INGRESS.name} ` +
1271
+ `-n ${namespace} --timeout=5m`,
1272
+ { silent: true },
1273
+ );
1274
+ execIngress('nginx -t -c /tmp/nginx.conf', { silent: true });
1275
+ logger.info('Underpost ingress applied', {
1276
+ namespace,
1277
+ backends: Object.keys(backends),
1278
+ hosts: entries.length,
1279
+ node: ingressNode,
1280
+ hotReloaded: shouldHotReload,
1281
+ });
1282
+ return true;
1283
+ },
1284
+
1285
+ /**
1286
+ * @method pruneEndpointlessService
1287
+ * @description Removes a Service that resolves to nothing. kube-proxy does
1288
+ * not ignore such a Service — it installs an ICMP port-unreachable REJECT
1289
+ * for every port it publishes, so an orphan is not inert: it actively
1290
+ * refuses connections on those ports, and the refusal looks exactly like
1291
+ * "no server is listening" even while one is.
1292
+ *
1293
+ * Only an endpointless Service is removed, so a healthy one of the same name
1294
+ * is never touched.
1295
+ * @param {string} name - Service name.
1296
+ * @param {string} [namespace] - Namespace to inspect.
1297
+ * @returns {boolean} True when a Service was pruned.
1298
+ * @memberof UnderpostCluster
1299
+ */
1300
+ pruneEndpointlessService({ name, namespace = 'default' }) {
1301
+ const exists = shellExec(`kubectl get svc ${name} -n ${namespace} -o name`, {
1302
+ stdout: true,
1303
+ silent: true,
1304
+ silentOnError: true,
1305
+ });
1306
+ if (!exists || !`${exists}`.trim()) return false;
1307
+ // EndpointSlice rather than the deprecated Endpoints API.
1308
+ const addresses = shellExec(
1309
+ `kubectl get endpointslice -n ${namespace} -l kubernetes.io/service-name=${name} ` +
1310
+ `-o jsonpath='{.items[*].endpoints[*].addresses[*]}'`,
1311
+ { stdout: true, silent: true, silentOnError: true },
1312
+ );
1313
+ if (addresses && `${addresses}`.trim()) return false;
1314
+ logger.warn(`Pruning endpointless Service ${namespace}/${name}`, {
1315
+ reason: 'kube-proxy REJECTs every port a Service with no endpoints publishes',
1316
+ });
1317
+ shellExec(`kubectl delete svc ${name} -n ${namespace} --ignore-not-found`);
1318
+ return true;
659
1319
  },
660
1320
 
1321
+ /**
1322
+ * @method resolveCriSocket
1323
+ * @description CLI-facing binding of {@link CriEndpoint.resolveCriSocket}.
1324
+ * @param {object} [options] - Cluster options (`k3s`, `criSocket`).
1325
+ * @returns {string} CRI endpoint URI.
1326
+ * @memberof UnderpostCluster
1327
+ */
1328
+ resolveCriSocket,
1329
+
1330
+ /**
1331
+ * @method crictlCommandFactory
1332
+ * @description CLI-facing binding of {@link CriEndpoint.crictlCommandFactory}.
1333
+ * @param {string} args - crictl subcommand and arguments (e.g. `pull mongo:latest`).
1334
+ * @param {object} [options] - Cluster options (`k3s`, `criSocket`).
1335
+ * @returns {string} Full shell command.
1336
+ * @memberof UnderpostCluster
1337
+ */
1338
+ crictlCommandFactory,
1339
+
661
1340
  /**
662
1341
  * @method config
663
1342
  * @description Configures host-level settings required for Kubernetes.
@@ -889,7 +1568,7 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
889
1568
  * @param {object} [options]
890
1569
  * @param {boolean} [options.all=true] - Remove all unused images, not just dangling ones.
891
1570
  * @param {boolean} [options.crictl=false] - Also prune the CRI runtime via crictl.
892
- * @param {string} [options.criSocket] - Optional crictl --runtime-endpoint override.
1571
+ * @param {string} [options.criSocket] - Optional CRI endpoint override; otherwise the live runtime is resolved.
893
1572
  * @private
894
1573
  */
895
1574
  _pruneContainerCaches(options = {}) {
@@ -902,16 +1581,13 @@ net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-k3s.conf > /dev/null`,
902
1581
  { silentOnError: true },
903
1582
  );
904
1583
  // 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
- );
1584
+ shellExec(`if command -v podman >/dev/null 2>&1; then sudo podman system prune ${a}--volumes -f; fi`, {
1585
+ silentOnError: true,
1586
+ });
909
1587
  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
- );
1588
+ shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rmi --prune', options)}; fi`, {
1589
+ silentOnError: true,
1590
+ });
915
1591
  }
916
1592
  Underpost.cluster._unmountOrphanContainerOverlays();
917
1593
  },
@@ -1040,11 +1716,15 @@ fi`);
1040
1716
  Underpost.cluster._lazyUmountKubeletMounts();
1041
1717
 
1042
1718
  logger.info('Phase 4/7: Killing control-plane processes and running kubeadm reset...');
1043
- shellExec(`if command -v crictl >/dev/null 2>&1; then sudo crictl rm -a -f; fi`, { silentOnError: true });
1719
+ shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rm -a -f')}; fi`, {
1720
+ silentOnError: true,
1721
+ });
1044
1722
  // Remove CNI config before stopping sandboxes so Calico's CNI delete hook is
1045
1723
  // not invoked (the API server is already down and the hook would fail).
1046
1724
  shellExec(`sudo rm -rf /etc/cni/net.d/*`);
1047
- shellExec(`if command -v crictl >/dev/null 2>&1; then sudo crictl rmp -a -f; fi`, { silentOnError: true });
1725
+ shellExec(`if command -v crictl >/dev/null 2>&1; then ${crictlCommandFactory('rmp -a -f')}; fi`, {
1726
+ silentOnError: true,
1727
+ });
1048
1728
  shellExec(`if systemctl is-active --quiet etcd; then sudo systemctl stop etcd; fi`);
1049
1729
  for (const port of [6443, 10259, 10257, 2379, 2380]) {
1050
1730
  shellExec(`if sudo fuser ${port}/tcp >/dev/null 2>&1; then sudo fuser -k ${port}/tcp; fi`);
@@ -1198,13 +1878,38 @@ exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
1198
1878
  EOF`);
1199
1879
  shellExec(`sudo yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes`);
1200
1880
 
1201
- // Install Helm
1202
- shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1203
- shellExec(`chmod 700 get_helm.sh`);
1204
- shellExec(`./get_helm.sh`);
1205
- shellExec(`chmod +x /usr/local/bin/helm`);
1206
- shellExec(`sudo mv /usr/local/bin/helm /bin/helm`);
1207
- shellExec(`sudo rm -rf get_helm.sh`);
1881
+ // Install Helm — check if already installed and linked to /bin/helm first
1882
+ const helmBin = shellExec(`test -x /bin/helm && echo exists || echo missing`, {
1883
+ stdout: true,
1884
+ silent: true,
1885
+ }).trim();
1886
+ const helmLocalBin = shellExec(`test -x /usr/local/bin/helm && echo exists || echo missing`, {
1887
+ stdout: true,
1888
+ silent: true,
1889
+ }).trim();
1890
+
1891
+ if (helmBin === 'exists') {
1892
+ logger.info('Helm is already installed and linked to /bin/helm; skipping.');
1893
+ } else if (helmLocalBin === 'exists') {
1894
+ // Helm binary exists at /usr/local/bin but not linked to /bin/helm
1895
+ logger.info('Helm found at /usr/local/bin; linking to /bin/helm...');
1896
+ shellExec(`sudo ln -sf /usr/local/bin/helm /bin/helm`);
1897
+ } else {
1898
+ // Helm not installed — download and install
1899
+ shellExec(`curl -fsSL -o get_helm.sh https://cdn.jsdelivr.net/gh/helm/helm@main/scripts/get-helm-3`);
1900
+ shellExec(`chmod 700 get_helm.sh`);
1901
+ // Run get_helm.sh but ignore its exit code — it may fail on PATH check
1902
+ // even when installation succeeds (binary placed at /usr/local/bin).
1903
+ shellExec(`bash ./get_helm.sh || true`);
1904
+ // Ensure the binary is executable and linked to /bin/helm
1905
+ shellExec(`test -x /usr/local/bin/helm && sudo chmod +x /usr/local/bin/helm || true`);
1906
+ shellExec(`test -x /usr/local/bin/helm && sudo ln -sf /usr/local/bin/helm /bin/helm || true`);
1907
+ shellExec(`sudo rm -rf get_helm.sh`);
1908
+ }
1909
+
1910
+ // SOPS and Age for Git-native encrypted secret manifests. Owned by UnderpostSecret so the
1911
+ // same idempotent install backs `underpost secret --install-tools`.
1912
+ Underpost.secret.sops.installTooling();
1208
1913
 
1209
1914
  // Install snap
1210
1915
  shellExec(`sudo yum install -y snapd`);