underpost 3.2.80 → 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 (84) 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 +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  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/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
package/src/cli/deploy.js CHANGED
@@ -8,19 +8,43 @@ import {
8
8
  buildKindPorts,
9
9
  buildPortProxyRouter,
10
10
  buildProxyRouter,
11
+ clusterTypeFactory,
11
12
  Config,
12
- cronDeployIdResolve,
13
+ deployHostsFactory,
13
14
  deployRangePortFactory,
15
+ gatewayApiEnabledFactory,
14
16
  getDataDeploy,
17
+ instanceStatusPageEntriesFactory,
15
18
  loadConfInstances,
16
19
  loadConfServerJson,
17
20
  loadReplicas,
21
+ nextTrafficFactory,
18
22
  pathPortAssignmentFactory,
23
+ schedulableNodeFactory,
24
+ trafficFromRoutingInfoFactory,
19
25
  } from '../server/conf.js';
26
+ import { cronDeployIdResolve } from '../server/cron.js';
20
27
  import { loggerFactory } from '../server/logger.js';
28
+ import { HOST_VOLUME_ROOT } from '../server/environment.js';
21
29
  import { shellExec } from '../server/process.js';
30
+ import { runSELinuxCommands, selinuxRestoreconCommandFactory } from '../server/selinux.js';
22
31
  import { INTERNAL_READY_PATH, INTERNAL_HEALTH_PATH } from '../server/runtime-status.js';
32
+ import { staticContextRoutesFactory, statusPageRoutesFactory } from '../client-builder/client-build.js';
33
+ import {
34
+ UNDERPOST_GATEWAY,
35
+ hostServerConfFactory,
36
+ installGatewayConf,
37
+ underpostGatewayManifestsFactory,
38
+ staticLocationFactory,
39
+ statusPageAssetPathFactory,
40
+ statusPageBuildSegment,
41
+ syncStaticAssetFromPod,
42
+ writeHostServerConf,
43
+ writeStaticAsset,
44
+ } from '../server/underpost-gateway.js';
45
+ import { getCapVariableName } from '../client/components/core/CommonJs.js';
23
46
  import fs from 'fs-extra';
47
+ import nodePath from 'node:path';
24
48
  import dotenv from 'dotenv';
25
49
  import os from 'node:os';
26
50
  import crypto from 'node:crypto';
@@ -42,8 +66,101 @@ const k8sVolumeName = (name) => {
42
66
  return `${name.slice(0, 54)}-${hash}`;
43
67
  };
44
68
 
69
+ const GATEWAY_API_GROUP = 'gateway.networking.k8s.io';
70
+ const GATEWAY_API_GROUP_VERSION = `${GATEWAY_API_GROUP}/v1`;
71
+ // QUIC/HTTP3 listener config and direct-response status pages are the two route
72
+ // behaviours core Gateway API leaves to the implementation. Both are expressed
73
+ // through the Envoy Gateway extension group, so retargeting another Gateway API
74
+ // implementation is a change to these two constants and nothing else.
75
+ const GATEWAY_EXTENSION_GROUP = 'gateway.envoyproxy.io';
76
+ const GATEWAY_EXTENSION_GROUP_VERSION = `${GATEWAY_EXTENSION_GROUP}/v1alpha1`;
77
+ const GATEWAY_CONTROLLER_NAME = `${GATEWAY_EXTENSION_GROUP}/gatewayclass-controller`;
78
+ // The class `cluster --gateway-api` provisions and the class every generated
79
+ // Gateway references: one name, resolved through gatewayApiConfigFactory, so an
80
+ // override reaches the installer and the manifests together.
81
+ const GATEWAY_CLASS_DEFAULT = 'eg';
82
+ // Where `bin client <deployId> <env>` writes each host's bundle, including the
83
+ // SSR status views declared in conf.ssr.json (`<host><path>/<status>/index.html`).
84
+ // Engine root inside the workload container; the built PWA artifacts live under
85
+ // its `public/` tree, which is where the static edge documents are sourced from.
86
+ // A workload that is gone answers with none of these itself; Envoy or the
87
+ // gateway hop produces them, and a maintenance page is what they mean.
88
+ const UPSTREAM_FAILURE_STATUSES = [502, 503, 504];
89
+
90
+ const CONTAINER_ENGINE_ROOT = '/home/dd/engine';
91
+
92
+ /**
93
+ * Maps a host/path's edge-served views onto the statuses the gateway intercepts
94
+ * for it, and the context directory each status is answered from.
95
+ *
96
+ * The mapping is the config's, not a policy of its own: a declared status page
97
+ * (`/404`) answers that status, and the maintenance view answers the codes that
98
+ * mean the workload is not there — a dead pod is exactly what a maintenance page
99
+ * is for. A host that declares neither is never intercepted and keeps routing
100
+ * straight to its workload.
101
+ * @param {Array<object>} edgeRoutes - Entries from {@link UnderpostDeploy.edgeRouteEntriesFactory}.
102
+ * @returns {Object<string,string>} Status code → context directory under the sub-path.
103
+ */
104
+ const interceptStatusesFactory = (edgeRoutes = []) => {
105
+ const statuses = {};
106
+ for (const route of edgeRoutes) {
107
+ if (route.status) statuses[route.status] = `status-pages/${route.status}`;
108
+ else if (route.context === 'maintenance')
109
+ for (const code of UPSTREAM_FAILURE_STATUSES) statuses[code] = route.context;
110
+ }
111
+ return statuses;
112
+ };
113
+
114
+ /**
115
+ * The API sub-path of a host/path, when it declares one. Kept out of the
116
+ * intercepted route so an API answers with its own status and body.
117
+ * @param {object} confServer - Parsed `conf.server.json`.
118
+ * @param {string} host - Hostname.
119
+ * @param {string} path - Proxy sub-path.
120
+ * @returns {string} API path prefix, or an empty string when the path serves no API.
121
+ */
122
+ const apiPathFactory = ({ confServer, host, path }) => {
123
+ const apis = confServer?.[host]?.[path]?.apis;
124
+ if (!Array.isArray(apis) || apis.length === 0) return '';
125
+ return `${path === '/' ? '' : path}/${process.env.BASE_API || 'api'}`;
126
+ };
127
+ const GATEWAY_DURATION_UNITS = [
128
+ ['h', 3600000],
129
+ ['m', 60000],
130
+ ['s', 1000],
131
+ ['ms', 1],
132
+ ];
133
+
134
+ /**
135
+ * Converts an HTTPProxy duration (`300000ms`, `10s`, `infinity`) into a Gateway
136
+ * API Duration. The Gateway API grammar allows at most 5 digits per component,
137
+ * so a value that overflows in one unit is re-expressed in a coarser one
138
+ * (`300000ms` → `5m`); `infinity` maps to `0s`, which disables the timeout.
139
+ * @param {string|number} value - Source duration.
140
+ * @returns {string|null} Gateway API Duration, or null when unset/unparsable.
141
+ */
142
+ const gatewayDurationFactory = (value) => {
143
+ if (value === undefined || value === null || value === '') return null;
144
+ const raw = `${value}`.trim();
145
+ if (raw === 'infinity' || raw === '0') return '0s';
146
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw);
147
+ if (!match) return null;
148
+ const factor = Object.fromEntries(GATEWAY_DURATION_UNITS)[match[2] || 'ms'];
149
+ const ms = Math.round(parseFloat(match[1]) * factor);
150
+ for (const [suffix, unit] of GATEWAY_DURATION_UNITS)
151
+ if (ms % unit === 0 && ms / unit <= 99999) return `${ms / unit}${suffix}`;
152
+ return `${Math.ceil(ms / 1000)}s`;
153
+ };
154
+
45
155
  const logger = loggerFactory(import.meta);
46
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
+
47
164
  /**
48
165
  * @class UnderpostDeploy
49
166
  * @description Manages the deployment of applications and services.
@@ -68,6 +185,80 @@ class UnderpostDeploy {
68
185
  await Config.build('proxy', deployList);
69
186
  return buildPortProxyRouter({ port: env === 'development' ? 80 : 443, proxyRouter: buildProxyRouter() });
70
187
  },
188
+ /**
189
+ * Stable Service used by every routing layer for one blue/green workload.
190
+ * Its name never carries the colour; promotion changes only its selector.
191
+ * @param {string} deployId - Deployment identifier.
192
+ * @param {string} env - Deployment environment.
193
+ * @returns {string} Kubernetes Service name.
194
+ * @memberof UnderpostDeploy
195
+ */
196
+ trafficServiceNameFactory({ deployId, env }) {
197
+ return k8sVolumeName(`${deployId}-${env}-traffic-service`);
198
+ },
199
+ /**
200
+ * Renders the stable traffic Service. Both Envoy/Contour and the fallback
201
+ * gateway use this object, so one selector update moves every port/path.
202
+ * @param {string} deployId - Deployment identifier.
203
+ * @param {string} env - Deployment environment.
204
+ * @param {string} traffic - Selected colour.
205
+ * @param {string} [namespace] - Kubernetes namespace.
206
+ * @param {number} fromPort - First workload port.
207
+ * @param {number} toPort - Last workload port.
208
+ * @returns {string} Service YAML.
209
+ * @memberof UnderpostDeploy
210
+ */
211
+ trafficServiceYamlFactory({ deployId, env, traffic, namespace = 'default', fromPort, toPort }) {
212
+ if (!['blue', 'green'].includes(traffic)) throw new Error(`Invalid traffic colour: ${traffic}`);
213
+ return `---
214
+ apiVersion: v1
215
+ kind: Service
216
+ metadata:
217
+ name: ${Underpost.deploy.trafficServiceNameFactory({ deployId, env })}
218
+ namespace: ${namespace}
219
+ labels:
220
+ underpost.net/traffic-service: "true"
221
+ underpost.net/deploy-id: ${deployId}-${env}
222
+ spec:
223
+ type: ClusterIP
224
+ selector:
225
+ app: ${deployId}-${env}-${traffic}
226
+ ports:
227
+ ${buildKindPorts(fromPort, toPort)}`;
228
+ },
229
+ /**
230
+ * Applies a stable traffic Service from either a built manifest or explicit
231
+ * port bounds, replacing only its selector colour.
232
+ * @returns {string} Applied Service name.
233
+ * @memberof UnderpostDeploy
234
+ */
235
+ applyTrafficService({ deployId, env, traffic, namespace = 'default', manifestPath, fromPort, toPort }) {
236
+ if (!['blue', 'green'].includes(traffic)) throw new Error(`Invalid traffic colour: ${traffic}`);
237
+ let manifest =
238
+ manifestPath && fs.existsSync(manifestPath)
239
+ ? fs.readFileSync(manifestPath, 'utf8')
240
+ : Underpost.deploy.trafficServiceYamlFactory({ deployId, env, traffic, namespace, fromPort, toPort });
241
+ const escaped = `${deployId}-${env}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
242
+ manifest = manifest.replace(new RegExp(`app: ${escaped}-(?:blue|green)`), `app: ${deployId}-${env}-${traffic}`);
243
+ shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
244
+ ${manifest}
245
+ EOF
246
+ `);
247
+ return Underpost.deploy.trafficServiceNameFactory({ deployId, env });
248
+ },
249
+ /**
250
+ * Removes the route kind owned by the inactive ingress stack. The active
251
+ * route is already published before this runs, so migration never creates a
252
+ * hostname with no route.
253
+ * @param {Array<string>} hosts - Hostnames/resource names to converge.
254
+ * @param {boolean} gatewayApi - Whether HTTPRoute is the destination stack.
255
+ * @param {string} namespace - Kubernetes namespace.
256
+ */
257
+ removeInactiveHostRoutes({ hosts = [], gatewayApi, namespace = 'default' }) {
258
+ const kind = gatewayApi ? 'HTTPProxy' : 'HTTPRoute';
259
+ for (const host of [...new Set(hosts.filter(Boolean))])
260
+ shellExec(`kubectl delete ${kind} ${host} -n ${namespace} --ignore-not-found`, { silent: true });
261
+ },
71
262
  /**
72
263
  * Creates a YAML service configuration for a deployment.
73
264
  * @param {string} deployId - Deployment ID for which the service is being created.
@@ -122,7 +313,7 @@ class UnderpostDeploy {
122
313
  }
123
314
  enableWebsockets: true
124
315
  services:
125
- ${deploymentVersions
316
+ ${(serviceId ? [null] : deploymentVersions)
126
317
  .map(
127
318
  (version, i) =>
128
319
  ` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
@@ -190,6 +381,19 @@ class UnderpostDeploy {
190
381
  };
191
382
  return probes;
192
383
  },
384
+ /**
385
+ * Resolves a required readiness probe for a custom workload. A configured
386
+ * probe wins; otherwise a TCP probe on the instance port is the safe floor.
387
+ * @param {object} [probe] - Configured readiness probe.
388
+ * @param {number} port - Instance container port.
389
+ * @returns {object} A Kubernetes readiness probe.
390
+ */
391
+ requiredReadinessProbeFactory({ probe, port }) {
392
+ if (probe) return probe;
393
+ if (!port) throw new Error('A readiness probe or container port is required');
394
+ return Underpost.deploy.runtimeProbesFactory({ port, useHttp: false, liveness: false, startup: false })
395
+ .readinessProbe;
396
+ },
193
397
  /**
194
398
  * Creates a YAML deployment configuration for a deployment.
195
399
  * @param {string} deployId - Deployment ID for which the deployment is being created.
@@ -209,6 +413,7 @@ class UnderpostDeploy {
209
413
  * @param {object} livenessProbe - Kubernetes liveness probe configuration for the deployment container.
210
414
  * @param {object} startupProbe - Kubernetes startup probe configuration for the deployment container.
211
415
  * @param {number} containerPort - Container port to expose for the deployment.
416
+ * @param {string} [nodeName] - Kubernetes node hostname that the workload must run on.
212
417
  * @returns {string} - YAML deployment configuration for the specified deployment.
213
418
  * @memberof UnderpostDeploy
214
419
  */
@@ -236,11 +441,13 @@ class UnderpostDeploy {
236
441
  livenessProbe,
237
442
  startupProbe,
238
443
  containerPort,
444
+ nodeName,
239
445
  // Explicit, secret-free internal status port injected as an env var so the
240
446
  // in-pod endpoint binds exactly what the probes and the monitor target,
241
447
  // independent of the ambient `PORT` baked into the image/secret.
242
448
  internalStatusPort,
243
449
  }) {
450
+ if (!readinessProbe) throw new Error(`Refusing to build ${deployId}-${env}-${suffix} without a readiness probe`);
244
451
  if (!cmd)
245
452
  cmd =
246
453
  pullBundle || skipFullBuild
@@ -283,7 +490,13 @@ spec:
283
490
  app: ${deployId}-${env}-${suffix}
284
491
  deploy-id: ${deployId}-${env}
285
492
  spec:
286
- containers:
493
+ ${
494
+ nodeName
495
+ ? ` nodeSelector:
496
+ kubernetes.io/hostname: ${nodeName}
497
+ `
498
+ : ''
499
+ } containers:
287
500
  - name: ${deployId}-${env}-${suffix}
288
501
  image: ${containerImage}
289
502
  imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
@@ -393,13 +606,16 @@ spec:
393
606
  * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
394
607
  * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
395
608
  * @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy route generation.
609
+ * @param {string} [options.gatewayClass] - GatewayClass name baked into the generated `gateway.yaml`.
610
+ * @param {boolean} [options.disableHttp3] - Omit QUIC/HTTP3 listener config and the Alt-Svc advertisement from the Gateway API manifests.
611
+ * @param {number|string} [options.quicPort] - UDP port advertised for QUIC/HTTP3.
396
612
  * @param {string} [options.traffic] - Comma-separated active traffic colour(s) used to select which versions receive traffic (e.g. "blue", "green").
397
613
  * @param {boolean} [options.cert] - Whether to include cert-manager Certificate resources in secret.yaml (production only).
398
614
  * @param {boolean} [options.selfSigned] - Whether to include TLS block in HTTPProxy using a pre-created self-signed secret. Enables HTTPS for development without cert-manager.
399
615
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory.
400
616
  * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
401
617
  * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
402
- * @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
618
+ * @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
403
619
  * @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
404
620
  * @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
405
621
  * @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
@@ -434,11 +650,11 @@ spec:
434
650
  // inside the pod. It is injected into the pod env (UNDERPOST_INTERNAL_PORT)
435
651
  // and used for both the probes and the monitor's port-forward target so
436
652
  // all three agree regardless of the image's ambient PORT.
437
- // Opt out with `--disable-runtime-probes` to keep legacy probe-less pods.
653
+ // Readiness is a hard promotion invariant. The legacy disable flag is
654
+ // intentionally ignored; workloads that cannot serve the internal HTTP
655
+ // endpoint can migrate with the explicit TCP probe mode.
438
656
  const internalPort = fromPort - 1;
439
- const probes = options.disableRuntimeProbes
440
- ? {}
441
- : Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
657
+ const probes = Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
442
658
 
443
659
  let deploymentYamlParts = '';
444
660
  for (const deploymentVersion of deploymentVersions) {
@@ -455,7 +671,19 @@ ${Underpost.deploy
455
671
  skipFullBuild: options.skipFullBuild,
456
672
  pullBundle: options.pullBundle,
457
673
  imagePullPolicy: options.imagePullPolicy,
458
- internalStatusPort: options.disableRuntimeProbes ? undefined : internalPort,
674
+ // Workload placement belongs in the manifest submitted for the rollout.
675
+ // Patching it after promotion creates a second ReplicaSet and can leave the
676
+ // old live pod pending termination behind a replacement that is not Ready.
677
+ nodeName: options.node
678
+ ? Underpost.deploy.resolveDeployNode({
679
+ node: options.node,
680
+ kind: options.kind,
681
+ kubeadm: options.kubeadm,
682
+ k3s: options.k3s,
683
+ env,
684
+ })
685
+ : '',
686
+ internalStatusPort: internalPort,
459
687
  readinessProbe: probes.readinessProbe,
460
688
  livenessProbe: probes.livenessProbe,
461
689
  startupProbe: probes.startupProbe,
@@ -464,6 +692,19 @@ ${Underpost.deploy
464
692
  `;
465
693
  }
466
694
  fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
695
+ const builtTraffic = `${options.traffic || deploymentVersions[0] || 'blue'}`.split(',')[0].trim();
696
+ fs.writeFileSync(
697
+ `./engine-private/conf/${deployId}/build/${env}/traffic-service.yaml`,
698
+ Underpost.deploy.trafficServiceYamlFactory({
699
+ deployId,
700
+ env,
701
+ traffic: builtTraffic,
702
+ namespace: options.namespace,
703
+ fromPort,
704
+ toPort,
705
+ }),
706
+ 'utf8',
707
+ );
467
708
 
468
709
  Underpost.deploy.buildGrpcServiceManifest({
469
710
  deployId,
@@ -492,7 +733,7 @@ ${Underpost.deploy
492
733
  if (!volume.claimName) continue;
493
734
  const pvcId = `${volume.claimName}-${deployId}-${env}-${deploymentVersion}`;
494
735
  const pvId = pvcId.replace(/^pvc-/, 'pv-');
495
- const hostPath = `/home/dd/engine/volume/${pvId}`;
736
+ const hostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
496
737
  volumeYaml += `---\n${Underpost.deploy.persistentVolumeFactory({
497
738
  pvcId,
498
739
  namespace: options.namespace,
@@ -506,9 +747,34 @@ ${Underpost.deploy
506
747
 
507
748
  let proxyYaml = '';
508
749
  let secretYaml = '';
750
+ let gatewayYaml = '';
751
+ let httpRouteYaml = '';
509
752
  const customServices = fs.existsSync(`./engine-private/conf/${deployId}/conf.services.json`)
510
753
  ? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.services.json`))
511
754
  : [];
755
+ // PWA status pages are SSR views whose route is a bare status code; the
756
+ // client build writes each to `<path>/index.html` inside the served
757
+ // bundle, so the gateway rewrites to that artifact instead of carrying
758
+ // a copy of the document (which is what custom instances need).
759
+ const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
760
+ const confSSR = fs.existsSync(confSSRPath) ? JSON.parse(fs.readFileSync(confSSRPath, 'utf8')) : {};
761
+ const { altSvc, http3, gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
762
+ // Node directory backing the static utility's volume; documents are
763
+ // placed here rather than in the cluster's object store, which is what
764
+ // lifts the size ceiling entirely.
765
+ const staticHostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
766
+ // Contexts routed to the gateway. Status pages are absent by design: they
767
+ // are reached by interception only, never as a destination.
768
+ const edgeRouteRecords = [];
769
+ // Per-host proxy routes contributed to the shared gateway's own config.
770
+ const gatewayRoutesByHost = {};
771
+ // Every host attaches to one deploy-scoped Gateway. Each hostname gets
772
+ // distinct HTTP/HTTPS listeners: mergeGateways combines every Gateway
773
+ // in the class, and (port, protocol, hostname) must remain unique across
774
+ // that complete set.
775
+ const gatewayName = Underpost.deploy.gatewayNameFactory({ deployId, env });
776
+ const trafficServiceName = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
777
+ const gatewayHosts = [];
512
778
 
513
779
  for (const host of Object.keys(confServer)) {
514
780
  if (env === 'production' && options.cert === true)
@@ -517,8 +783,11 @@ ${Underpost.deploy
517
783
  const pathPortAssignment = pathPortAssignmentData[host];
518
784
  // logger.info('', { host, pathPortAssignment });
519
785
  let _proxyYaml = Underpost.deploy.baseProxyYamlFactory({ host, env, options });
520
- const deploymentVersions =
521
- options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',') : ['blue'];
786
+ // The live colour is a cluster fact, and a build must not need one: with
787
+ // no `--traffic` the routes carry every colour this build emits, the
788
+ // first at full weight. `switchTraffic` passes the promoted colour
789
+ // explicitly, so a real promotion still pins exactly one.
790
+ const deploymentVersions = `${options.traffic || options.versions || 'blue,green'}`.split(',');
522
791
  let proxyRoutes = '';
523
792
  const globalTimeoutPolicy =
524
793
  (options.timeoutResponse && options.timeoutResponse !== '') ||
@@ -537,18 +806,87 @@ ${Underpost.deploy
537
806
  perTryTimeout: options.retryPerTryTimeout,
538
807
  }
539
808
  : undefined;
809
+ let routeRules = '';
540
810
  if (!options.disableDeploymentProxy)
541
811
  for (const conditionObj of pathPortAssignment) {
542
812
  const { path, port } = conditionObj;
543
813
  proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
544
814
  path,
545
- deployId,
546
- env,
547
815
  port,
548
- deploymentVersions,
816
+ serviceId: trafficServiceName,
549
817
  timeoutPolicy: globalTimeoutPolicy,
550
818
  retryPolicy: globalRetryPolicy,
551
819
  });
820
+ // Intercepted contexts get a route of their own — `/offline` and
821
+ // `/maintenance` are addresses a client asks for, and the service
822
+ // worker precaches them by URL.
823
+ //
824
+ // A status page gets none. It is reached only by interception, so
825
+ // the client's URI is always the one it requested: a route for
826
+ // `/404` would make the status page a destination, and any hop to
827
+ // it — a rewrite, or a runtime that redirects its own 404s — is a
828
+ // URI the client did not ask for. `/invalid-path` must answer 404
829
+ // with the configured document while staying `/invalid-path`, which
830
+ // only `proxy_intercept_errors` in the gateway can do.
831
+ const edgeRoutes = Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path });
832
+ const interceptStatuses = Object.keys(interceptStatusesFactory(edgeRoutes));
833
+ for (const edgeRoute of edgeRoutes.filter((route) => !route.status)) {
834
+ routeRules += Underpost.deploy.httpRouteRuleFactory({
835
+ path: edgeRoute.routePath,
836
+ // Onto the directory, not the document: one rule then covers
837
+ // the page and any asset beside it, which is exactly what the
838
+ // static utility's `try_files $uri $uri/index.html` resolves.
839
+ replacePrefixMatch: edgeRoute.dir,
840
+ serviceId: UNDERPOST_GATEWAY.serviceName,
841
+ port: UNDERPOST_GATEWAY.port,
842
+ timeoutPolicy: globalTimeoutPolicy,
843
+ retryPolicy: globalRetryPolicy,
844
+ altSvc: http3 ? altSvc : undefined,
845
+ });
846
+ edgeRouteRecords.push({
847
+ host,
848
+ path: edgeRoute.routePath,
849
+ kind: edgeRoute.kind,
850
+ servedBy: UNDERPOST_GATEWAY.serviceName,
851
+ rewrite: edgeRoute.dir,
852
+ assetPath: edgeRoute.assetPath,
853
+ });
854
+ }
855
+ // The site path goes through the shared gateway, which proxies it
856
+ // to this workload and swaps in a status document when the
857
+ // workload errors or is gone. The API path is routed straight to
858
+ // the workload instead: its errors are its own contract, and a
859
+ // client parsing JSON must not receive an HTML page.
860
+ const intercepted = interceptStatuses.length > 0;
861
+ if (intercepted && apiPathFactory({ confServer, host, path }))
862
+ routeRules += Underpost.deploy.httpRouteRuleFactory({
863
+ path: apiPathFactory({ confServer, host, path }),
864
+ port,
865
+ serviceId: trafficServiceName,
866
+ timeoutPolicy: globalTimeoutPolicy,
867
+ retryPolicy: globalRetryPolicy,
868
+ altSvc: http3 ? altSvc : undefined,
869
+ });
870
+ routeRules += Underpost.deploy.httpRouteRuleFactory({
871
+ path,
872
+ ...(intercepted
873
+ ? { serviceId: UNDERPOST_GATEWAY.serviceName, port: UNDERPOST_GATEWAY.port }
874
+ : { serviceId: trafficServiceName, port }),
875
+ timeoutPolicy: globalTimeoutPolicy,
876
+ retryPolicy: globalRetryPolicy,
877
+ altSvc: http3 ? altSvc : undefined,
878
+ });
879
+ gatewayRoutesByHost[host] = (gatewayRoutesByHost[host] || []).concat(
880
+ intercepted
881
+ ? {
882
+ path,
883
+ upstream: `${trafficServiceName}:${port}`,
884
+ statuses: interceptStatusesFactory(
885
+ Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path }),
886
+ ),
887
+ }
888
+ : [],
889
+ );
552
890
  }
553
891
  for (const customService of customServices) {
554
892
  const {
@@ -570,24 +908,105 @@ ${Underpost.deploy
570
908
  timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
571
909
  retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
572
910
  });
911
+ routeRules += Underpost.deploy.httpRouteRuleFactory({
912
+ path: _path,
913
+ port,
914
+ serviceId,
915
+ deploymentVersions,
916
+ pathRewritePolicy,
917
+ timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
918
+ retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
919
+ altSvc: http3 ? altSvc : undefined,
920
+ });
573
921
  }
574
922
  }
575
923
  if (proxyRoutes) proxyYaml += _proxyYaml + proxyRoutes;
924
+ if (routeRules) {
925
+ gatewayHosts.push(host);
926
+ httpRouteYaml += Underpost.deploy.httpRouteYamlFactory({
927
+ host,
928
+ options,
929
+ rules: routeRules,
930
+ parentName: gatewayName,
931
+ });
932
+ }
933
+ }
934
+ if (gatewayHosts.length > 0) {
935
+ // Instance hostnames belong on this Gateway's certificate list even
936
+ // though their routes are applied later by `instance-promote`: one
937
+ // Gateway terminates every hostname the deploy serves, and a hostname
938
+ // with no certificate listener here has no TLS filter chain to reach.
939
+ // The Gateway remains deploy-scoped, while its listeners are scoped by
940
+ // hostname so they can coexist with the other merged Gateways.
941
+ const allGatewayHosts = [
942
+ ...new Set([...gatewayHosts, ...deployHostsFactory(deployId).filter((host) => !confServer[host])]),
943
+ ].sort();
944
+ gatewayYaml += Underpost.deploy.gatewayYamlFactory({
945
+ name: gatewayName,
946
+ hosts: allGatewayHosts,
947
+ env,
948
+ options,
949
+ });
950
+ gatewayYaml += Underpost.deploy.clientTrafficPolicyYamlFactory({
951
+ name: gatewayName,
952
+ sectionNames: allGatewayHosts.map((host) =>
953
+ Underpost.deploy.gatewayListenerNameFactory({ protocol: 'https', host }),
954
+ ),
955
+ env,
956
+ options,
957
+ });
576
958
  }
959
+ // The shared gateway proxies the intercepted paths, so its own config is
960
+ // part of this build — written as an artifact beside the manifests and
961
+ // nothing more. Installing it into the live workload and reloading it is
962
+ // the apply path's job: a build must work with no cluster running.
963
+ for (const [gatewayHost, routes] of Object.entries(gatewayRoutesByHost))
964
+ writeHostServerConf({
965
+ confDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
966
+ host: gatewayHost,
967
+ conf: hostServerConfFactory({
968
+ host: gatewayHost,
969
+ routes,
970
+ namespace: options.namespace || 'default',
971
+ }),
972
+ });
577
973
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/proxy.yaml`;
578
974
  fs.writeFileSync(yamlPath, proxyYaml, 'utf8');
975
+ const buildPath = `./engine-private/conf/${deployId}/build/${env}`;
976
+ for (const [name, content] of Object.entries({
977
+ 'gateway.yaml': gatewayYaml,
978
+ 'httproute.yaml': httpRouteYaml,
979
+ }))
980
+ Underpost.deploy.writeManifest({ filePath: `${buildPath}/${name}`, content });
981
+ logger.info('Gateway API manifests written', {
982
+ deployId,
983
+ env,
984
+ gatewayClass: gatewayClassName,
985
+ http3,
986
+ altSvc: http3 ? altSvc : null,
987
+ edgeRoutes: edgeRouteRecords,
988
+ });
579
989
  if (env === 'production') {
580
990
  const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
581
991
  fs.writeFileSync(yamlPath, secretYaml, 'utf8');
582
992
  } else {
583
- const deploymentsFiles = ['Dockerfile', 'proxy.yaml', 'deployment.yaml', 'pv-pvc.yaml', 'grpc-service.yaml'];
993
+ const deploymentsFiles = [
994
+ 'Dockerfile',
995
+ 'proxy.yaml',
996
+ 'gateway.yaml',
997
+ 'httproute.yaml',
998
+ 'deployment.yaml',
999
+ 'traffic-service.yaml',
1000
+ 'pv-pvc.yaml',
1001
+ 'grpc-service.yaml',
1002
+ ];
584
1003
  for (const file of deploymentsFiles) {
585
- if (fs.existsSync(`./engine-private/conf/${deployId}/build/${env}/${file}`)) {
586
- fs.copyFileSync(
587
- `./engine-private/conf/${deployId}/build/${env}/${file}`,
588
- `./manifests/deployment/${deployId}-${env}/${file}`,
589
- );
590
- }
1004
+ const source = `./engine-private/conf/${deployId}/build/${env}/${file}`;
1005
+ const target = `./manifests/deployment/${deployId}-${env}/${file}`;
1006
+ // Mirror absence as well as presence: a file this build no longer
1007
+ // produces must not survive here from an earlier one.
1008
+ if (fs.existsSync(source)) fs.copyFileSync(source, target);
1009
+ else fs.removeSync(target);
591
1010
  }
592
1011
  }
593
1012
  }
@@ -683,37 +1102,105 @@ spec:
683
1102
  * @param {object} options - Options for the traffic retrieval.
684
1103
  * @param {string} options.hostTest - Hostname to test for traffic status.
685
1104
  * @param {string} options.namespace - Kubernetes namespace for the deployment.
1105
+ * @param {boolean} [options.gatewayApi] - Force the Gateway API stack; on by default unless `disableGatewayApi` is set.
1106
+ * @param {boolean} [options.disableGatewayApi] - Read the colour from the Contour HTTPProxy instead of the Gateway API HTTPRoute.
1107
+ * @param {string} [options.underpostGatewayRoot] - Node directory backing the gateway volume, where an intercepted host's colour lives.
686
1108
  * @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
687
1109
  * @memberof UnderpostDeploy
688
1110
  */
689
1111
  getCurrentTraffic(deployId, options = { hostTest: '', namespace: '', env: '' }) {
690
1112
  if (!options.namespace) options.namespace = 'default';
691
- // kubectl get deploy,sts,svc,configmap,secret -n default -o yaml --export > default.yaml
1113
+ // The stable Service selector is the blue/green authority. Routes and the
1114
+ // fallback gateway deliberately contain no colour after migration, so
1115
+ // reading them first would make a healthy deployment appear unrouted.
1116
+ for (const env of options.env ? [options.env] : ['production', 'development']) {
1117
+ const service = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
1118
+ const selector = shellExec(
1119
+ `kubectl get service ${service} -n ${options.namespace} -o jsonpath='{.spec.selector.app}'`,
1120
+ {
1121
+ stdout: true,
1122
+ silent: true,
1123
+ silentOnError: true,
1124
+ },
1125
+ );
1126
+ const traffic = trafficFromRoutingInfoFactory({ info: `${selector}`, deployId, env });
1127
+ if (traffic) return traffic;
1128
+ }
692
1129
  const hostTest = options?.hostTest
693
1130
  ? options.hostTest
694
1131
  : Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
695
- // Missing HTTPProxy is the canonical "no traffic colour set yet" state
696
- // for blue/green rollouts. silentOnError swallows kubectl's NotFound
697
- // exit so the function can return null cleanly.
698
- const info = shellExec(`sudo kubectl get HTTPProxy/${hostTest} -n ${options.namespace} -o yaml`, {
699
- silent: true,
700
- stdout: true,
701
- silentOnError: true,
1132
+ return trafficFromRoutingInfoFactory({
1133
+ info: Underpost.deploy.readHostRoutingInfo({ host: hostTest, options }),
1134
+ deployId,
1135
+ env: options.env,
702
1136
  });
703
- if (!info) return null;
704
- // Env-scoped resolution: read THIS deploy's colour from its own service
705
- // name (`<deployId>-<env>-<colour>-service`). Essential for shared
706
- // multi-instance hosts, where one HTTPProxy holds several variants' routes
707
- // on possibly different colours — a whole-document `.match('blue')` would
708
- // return whichever colour appears first, i.e. a sibling's, not this one's.
709
- // The regex is anchored on the full `<deployId>-<env>-` prefix so
710
- // `dd-cyberia-mmo-server` never matches `dd-cyberia-mmo-server-forest`.
711
- if (options.env) {
712
- const escaped = deployId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
713
- const match = info.match(new RegExp(`${escaped}-${options.env}-(blue|green)-service`));
714
- return match ? match[1] : null;
1137
+ },
1138
+
1139
+ /**
1140
+ * All the routing text that can carry a host's traffic colour.
1141
+ *
1142
+ * Two sources, because one is not enough on its own: the route object names
1143
+ * the colour for a host routed straight at its workload, and the Nginx server
1144
+ * block names it for a host whose errors the gateway intercepts — those are
1145
+ * routed at `underpost-gateway-service`, so their colour appears nowhere in
1146
+ * the route object. Reading both means the colour resolves the same way
1147
+ * whichever stack is live and whether or not the host is intercepted.
1148
+ *
1149
+ * Split out of {@link UnderpostDeploy.getCurrentTraffic} so a report covering
1150
+ * many deployments and environments can read each host once instead of once
1151
+ * per row.
1152
+ *
1153
+ * The stack the flags select is read first, and the other one is read when
1154
+ * that finds nothing. Which kind describes a host is a property of the
1155
+ * cluster, not of the invocation: a command run without `--disable-gateway-api`
1156
+ * against a Contour-routed host would otherwise report it as having no colour
1157
+ * at all, and silently act on that — stopping the wrong half of a blue/green
1158
+ * pair, or reporting a live host as unrouted. When neither kind exists the
1159
+ * host genuinely has no route and the caller proceeds with the Nginx block
1160
+ * alone.
1161
+ * @param {string} host - Hostname whose routing is read.
1162
+ * @param {object} [options] - Options carrying namespace, gateway stack and gateway root.
1163
+ * @returns {string} Route object YAML and Nginx block, concatenated; empty when neither exists.
1164
+ * @memberof UnderpostDeploy
1165
+ */
1166
+ readHostRoutingInfo({ host, options = {} }) {
1167
+ const namespace = options.namespace || 'default';
1168
+ // A missing route object is the canonical "no traffic colour set yet"
1169
+ // state for blue/green rollouts. silentOnError swallows kubectl's NotFound
1170
+ // exit so this returns empty text rather than throwing. The `kind:` check
1171
+ // is what distinguishes a real object from anything kubectl printed while
1172
+ // failing — an empty answer has to mean "absent", or the fallback below
1173
+ // would never run.
1174
+ const readRouteObject = (kind) => {
1175
+ const out = shellExec(`sudo kubectl get ${kind}/${host} -n ${namespace} -o yaml`, {
1176
+ silent: true,
1177
+ stdout: true,
1178
+ silentOnError: true,
1179
+ });
1180
+ return `${out || ''}`.includes(`kind: ${kind}`) ? `${out}` : '';
1181
+ };
1182
+
1183
+ const preferred = gatewayApiEnabledFactory(options) ? 'HTTPRoute' : 'HTTPProxy';
1184
+ const fallback = preferred === 'HTTPRoute' ? 'HTTPProxy' : 'HTTPRoute';
1185
+ let routeInfo = readRouteObject(preferred);
1186
+ if (!routeInfo) {
1187
+ routeInfo = readRouteObject(fallback);
1188
+ if (routeInfo)
1189
+ logger.warn('Host is routed by the other stack than the flags select; reading it instead', {
1190
+ host,
1191
+ selected: preferred,
1192
+ found: fallback,
1193
+ namespace,
1194
+ });
715
1195
  }
716
- return info.match('blue') ? 'blue' : info.match('green') ? 'green' : null;
1196
+
1197
+ const gatewayConfPath = nodePath.join(
1198
+ Underpost.deploy.underpostGatewayRootFactory(options),
1199
+ UNDERPOST_GATEWAY.confDir,
1200
+ `${host}.conf`,
1201
+ );
1202
+ const gatewayInfo = fs.existsSync(gatewayConfPath) ? fs.readFileSync(gatewayConfPath, 'utf8') : '';
1203
+ return `${routeInfo || ''}\n${gatewayInfo}`;
717
1204
  },
718
1205
 
719
1206
  /**
@@ -746,6 +1233,840 @@ spec:
746
1233
  routes:`;
747
1234
  },
748
1235
 
1236
+ /**
1237
+ * Resolves the Gateway API transport settings shared by every generated
1238
+ * Gateway and HTTPRoute: the gateway class, whether QUIC/HTTP3 is enabled,
1239
+ * the UDP port QUIC is served on, and the `Alt-Svc` value that advertises
1240
+ * it. Single source of truth — no other factory reads these options.
1241
+ * @param {object} [options] - Deploy/run options.
1242
+ * @param {string} [options.gatewayClass] - GatewayClass name (env: UNDERPOST_GATEWAY_CLASS, default `contour`).
1243
+ * @param {boolean} [options.disableHttp3] - Disables QUIC/HTTP3 listener config and `Alt-Svc` advertisement.
1244
+ * @param {number|string} [options.quicPort] - UDP port QUIC is served on (env: UNDERPOST_QUIC_PORT, default 443).
1245
+ * @param {number|string} [options.altSvcMaxAge] - `Alt-Svc` max-age in seconds (default 86400).
1246
+ * @returns {{ gatewayClassName: string, http3: boolean, quicPort: number, altSvc: string }} Resolved config.
1247
+ * @memberof UnderpostDeploy
1248
+ */
1249
+ gatewayApiConfigFactory(options = {}) {
1250
+ const quicPort = parseInt(options.quicPort || process.env.UNDERPOST_QUIC_PORT || 443, 10);
1251
+ return {
1252
+ gatewayClassName: options.gatewayClass || process.env.UNDERPOST_GATEWAY_CLASS || GATEWAY_CLASS_DEFAULT,
1253
+ http3: options.disableHttp3 !== true,
1254
+ quicPort,
1255
+ altSvc: `h3=":${quicPort}"; ma=${parseInt(options.altSvcMaxAge || 86400, 10)}`,
1256
+ };
1257
+ },
1258
+
1259
+ /**
1260
+ * Creates the cluster-scoped Gateway API provisioning objects: the
1261
+ * GatewayClass every generated Gateway attaches to, and the EnvoyProxy that
1262
+ * decides how the data plane is reachable from outside the cluster.
1263
+ *
1264
+ * Exposure differs by environment because the access path does:
1265
+ * - development — the node *is* the operator's machine, so Envoy binds the
1266
+ * listener ports on the host network. With the `/etc/hosts` entries the
1267
+ * `cluster` runner writes, `https://<host>` resolves to 127.0.0.1 and
1268
+ * reaches the gateway directly, and QUIC gets UDP/443 for free.
1269
+ * - production — NodePort, mirroring the ports the Contour envoy service
1270
+ * already publishes (`manifests/envoy-service-nodeport.yaml`).
1271
+ *
1272
+ * `sharedIngress` overrides the development binding: with Contour also
1273
+ * installed the node's 80/443 belong to the shared edge, and a data plane on
1274
+ * the host network would be competing for the port it is meant to sit behind.
1275
+ * The listener ports are unchanged — only where they are published moves.
1276
+ * @param {string} env - `development` | `production`.
1277
+ * @param {object} [options] - Deploy/run options (gateway class override, shared edge).
1278
+ * @returns {string} GatewayClass + EnvoyProxy YAML.
1279
+ * @memberof UnderpostDeploy
1280
+ */
1281
+ gatewayClassYamlFactory({ env, options = {} }) {
1282
+ const { gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
1283
+ const namespace = 'envoy-gateway-system';
1284
+ const hostBound = env === 'development' && options.sharedIngress !== true;
1285
+ // `hostNetwork` is not a field of EnvoyProxy's KubernetesPodSpec — the
1286
+ // deployment `patch` (StrategicMerge) is the supported way to set plain
1287
+ // PodSpec fields. `dnsPolicy` must move with it: on the host network the
1288
+ // pod would otherwise inherit the node's resolv.conf and lose the cluster
1289
+ // DNS it needs to reach the xDS control plane.
1290
+ //
1291
+ // `useListenerPortAsContainerPort: true` stops Envoy Gateway remapping
1292
+ // privileged ports into the ephemeral range, which is what puts 80/443 on
1293
+ // the host — and by its own contract requires CAP_NET_BIND_SERVICE. The
1294
+ // development profile also runs Envoy as root: ambient capabilities are
1295
+ // not expressible in a Kubernetes securityContext, so a non-root process
1296
+ // cannot reliably hold that capability. Production keeps the hardened
1297
+ // upstream defaults and is reached through NodePort instead.
1298
+ const developmentProvider = `
1299
+ useListenerPortAsContainerPort: true
1300
+ envoyDeployment:
1301
+ patch:
1302
+ type: StrategicMerge
1303
+ value:
1304
+ spec:
1305
+ template:
1306
+ spec:
1307
+ hostNetwork: true
1308
+ dnsPolicy: ClusterFirstWithHostNet
1309
+ container:
1310
+ securityContext:
1311
+ runAsNonRoot: false
1312
+ runAsUser: 0
1313
+ allowPrivilegeEscalation: false
1314
+ capabilities:
1315
+ drop:
1316
+ - ALL
1317
+ add:
1318
+ - NET_BIND_SERVICE
1319
+ envoyService:
1320
+ type: ClusterIP`;
1321
+ const productionProvider = `
1322
+ useListenerPortAsContainerPort: false
1323
+ envoyService:
1324
+ type: NodePort`;
1325
+ // Behind the shared edge the data plane is reached by ClusterIP, so it
1326
+ // needs neither the host network nor a node port — it is an ordinary
1327
+ // upstream, and publishing it anywhere else would only re-create the
1328
+ // contention the edge exists to remove.
1329
+ const sharedProvider = `
1330
+ useListenerPortAsContainerPort: false
1331
+ envoyService:
1332
+ type: ClusterIP`;
1333
+ return `
1334
+ ---
1335
+ apiVersion: ${GATEWAY_EXTENSION_GROUP_VERSION}
1336
+ kind: EnvoyProxy
1337
+ metadata:
1338
+ name: ${gatewayClassName}-proxy-config
1339
+ namespace: ${namespace}
1340
+ spec:
1341
+ # A deploy's hosts share one Gateway, but a cluster can hold more than one
1342
+ # deploy — and each Gateway would otherwise be provisioned its own data plane,
1343
+ # every one contending for the same node ports, so at most one could bind and
1344
+ # the rest would crash-loop. Merging collapses them onto a single Envoy fleet.
1345
+ # Every generated listener is hostname-scoped because the merge key is
1346
+ # (port, protocol, hostname). This lets many deploy-scoped Gateways coexist
1347
+ # in this fleet without an older hostname-less listener shadowing the rest.
1348
+ mergeGateways: true
1349
+ provider:
1350
+ type: Kubernetes
1351
+ kubernetes:${options.sharedIngress === true ? sharedProvider : hostBound ? developmentProvider : productionProvider}
1352
+ ---
1353
+ apiVersion: ${GATEWAY_API_GROUP_VERSION}
1354
+ kind: GatewayClass
1355
+ metadata:
1356
+ name: ${gatewayClassName}
1357
+ spec:
1358
+ controllerName: ${GATEWAY_CONTROLLER_NAME}
1359
+ parametersRef:
1360
+ group: ${GATEWAY_EXTENSION_GROUP}
1361
+ kind: EnvoyProxy
1362
+ name: ${gatewayClassName}-proxy-config
1363
+ namespace: ${namespace}
1364
+ `;
1365
+ },
1366
+
1367
+ /**
1368
+ * Provisions the self-signed TLS secret a host is served with when
1369
+ * cert-manager is not in play (development, and `instance-promote --tls
1370
+ * --test`). `scripts/ssl.sh` generates the pair through mkcert — which also
1371
+ * installs its root CA into the system and NSS trust stores, so the browser
1372
+ * trusts the certificate without a warning — and falls back to OpenSSL.
1373
+ *
1374
+ * The secret is deleted before being recreated so a re-run always ends with
1375
+ * the key pair on disk, and is named after the host because that is the
1376
+ * `secretName` both the HTTPProxy virtualhost and the Gateway listener
1377
+ * reference.
1378
+ * @param {string} host - Hostname to issue the certificate for.
1379
+ * @param {string} [namespace] - Kubernetes namespace.
1380
+ * @param {string} [underpostRoot] - Repo root holding `scripts/ssl.sh`.
1381
+ * @returns {{ sslDir: string, certPath: string, keyPath: string }} Generated artifact paths.
1382
+ * @memberof UnderpostDeploy
1383
+ */
1384
+ selfSignedTlsSecretFactory({ host, namespace = 'default', underpostRoot = '.' }) {
1385
+ const sslDir = `./engine-private/ssl/${host}`;
1386
+ const nameSafe = host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1387
+ const certPath = `${sslDir}/${nameSafe}.pem`;
1388
+ const keyPath = `${sslDir}/${nameSafe}-key.pem`;
1389
+ fs.mkdirpSync(sslDir);
1390
+ shellExec(`bash ${underpostRoot}/scripts/ssl.sh "${sslDir}" "${host}"`);
1391
+ shellExec(`kubectl delete secret ${host} -n ${namespace} --ignore-not-found`);
1392
+ shellExec(`kubectl create secret tls ${host} --cert="${certPath}" --key="${keyPath}" -n ${namespace}`);
1393
+ logger.info('Self-signed TLS secret created', { host, namespace, certPath, keyPath });
1394
+ return { sslDir, certPath, keyPath };
1395
+ },
1396
+
1397
+ /**
1398
+ * Creates the one Gateway a deploy's HTTPRoutes attach to.
1399
+ *
1400
+ * Envoy Gateway's `mergeGateways` mode merges every Gateway of the class
1401
+ * into one data plane. Its required uniqueness key is (port, protocol,
1402
+ * hostname), so every host needs its own listener pair. Hostname-less
1403
+ * listeners from separate deploy Gateways conflict: the older listener is
1404
+ * served and newer hosts receive its certificate and 404 route table.
1405
+ *
1406
+ * The HTTPS listener is emitted under the same TLS rules as the HTTPProxy
1407
+ * virtualhost (production, or development with `--self-signed`); QUIC is
1408
+ * only wired when that listener exists, since HTTP/3 has no cleartext
1409
+ * transport.
1410
+ * @param {string} name - Gateway name, from {@link UnderpostDeploy.gatewayNameFactory}.
1411
+ * @param {Array<string>} hosts - Every hostname the deploy terminates; each becomes a certificate ref.
1412
+ * @param {string} env - `development` | `production`.
1413
+ * @param {object} [options] - Deploy/run options (namespace, gateway/QUIC settings, selfSigned).
1414
+ * @returns {string} Gateway YAML.
1415
+ * @memberof UnderpostDeploy
1416
+ */
1417
+ gatewayYamlFactory({ hosts = [], name, env, options = {} }) {
1418
+ const namespace = options.namespace || 'default';
1419
+ const { gatewayClassName } = Underpost.deploy.gatewayApiConfigFactory(options);
1420
+ const includeTls = env !== 'development' || options.selfSigned === true;
1421
+ const uniqueHosts = [...new Set(hosts.filter(Boolean))].sort();
1422
+ const allowedRoutes = ` allowedRoutes:
1423
+ namespaces:
1424
+ from: Same`;
1425
+ const listeners = uniqueHosts
1426
+ .flatMap((host) => {
1427
+ const http = ` - name: ${Underpost.deploy.gatewayListenerNameFactory({ protocol: 'http', host })}
1428
+ hostname: ${JSON.stringify(host)}
1429
+ protocol: HTTP
1430
+ port: 80
1431
+ ${allowedRoutes}`;
1432
+ if (!includeTls) return [http];
1433
+ return [
1434
+ http,
1435
+ ` - name: ${Underpost.deploy.gatewayListenerNameFactory({ protocol: 'https', host })}
1436
+ hostname: ${JSON.stringify(host)}
1437
+ protocol: HTTPS
1438
+ port: 443
1439
+ tls:
1440
+ mode: Terminate
1441
+ certificateRefs:
1442
+ - group: ""
1443
+ kind: Secret
1444
+ name: ${host}
1445
+ ${allowedRoutes}`,
1446
+ ];
1447
+ })
1448
+ .join('\n');
1449
+ return `
1450
+ ---
1451
+ apiVersion: ${GATEWAY_API_GROUP_VERSION}
1452
+ kind: Gateway
1453
+ metadata:
1454
+ name: ${name}
1455
+ namespace: ${namespace}
1456
+ spec:
1457
+ gatewayClassName: ${gatewayClassName}
1458
+ listeners:
1459
+ ${listeners}
1460
+ `;
1461
+ },
1462
+
1463
+ /**
1464
+ * Produces a stable DNS-label listener name for a hostname and protocol.
1465
+ * The content hash prevents two hosts that normalize to the same label from
1466
+ * colliding, while truncation keeps the Gateway API SectionName at 63 chars.
1467
+ * @param {object} input - Listener identity.
1468
+ * @param {string} input.protocol - `http` or `https`.
1469
+ * @param {string} input.host - Listener hostname.
1470
+ * @returns {string} Stable Kubernetes DNS label.
1471
+ * @memberof UnderpostDeploy
1472
+ */
1473
+ gatewayListenerNameFactory({ protocol, host }) {
1474
+ const prefix = `${protocol || 'http'}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
1475
+ const normalized =
1476
+ `${host || 'host'}`
1477
+ .toLowerCase()
1478
+ .replace(/[^a-z0-9]+/g, '-')
1479
+ .replace(/^-+|-+$/g, '') || 'host';
1480
+ const hash = crypto
1481
+ .createHash('sha1')
1482
+ .update(`${host || ''}`)
1483
+ .digest('hex')
1484
+ .slice(0, 8);
1485
+ const hostLength = 63 - prefix.length - hash.length - 2;
1486
+ return `${prefix}-${normalized.slice(0, hostLength).replace(/-+$/g, '')}-${hash}`;
1487
+ },
1488
+
1489
+ /**
1490
+ * Name of the Gateway a deploy's hosts share. The Gateway is consolidated
1491
+ * per deploy, while its listener pairs remain hostname-scoped so merged
1492
+ * Gateways have distinct traffic selectors.
1493
+ * @param {string} deployId - Deploy id.
1494
+ * @param {string} env - `development` | `production`.
1495
+ * @returns {string} Gateway name.
1496
+ * @memberof UnderpostDeploy
1497
+ */
1498
+ gatewayNameFactory({ deployId, env }) {
1499
+ return `${deployId}-${env}`;
1500
+ },
1501
+
1502
+ /**
1503
+ * Writes a generated manifest, or removes it when there is nothing to
1504
+ * write. An empty file is not an empty set for `kubectl apply` — it fails
1505
+ * with "no objects passed to apply" — so a deploy that declares no objects
1506
+ * of a kind must leave no file behind, including one a previous build wrote.
1507
+ * @param {string} filePath - Destination path.
1508
+ * @param {string} content - Rendered YAML; blank removes the file.
1509
+ * @returns {boolean} True when a file was written.
1510
+ * @memberof UnderpostDeploy
1511
+ */
1512
+ writeManifest({ filePath, content }) {
1513
+ if (content && content.trim()) {
1514
+ fs.writeFileSync(filePath, content, 'utf8');
1515
+ return true;
1516
+ }
1517
+ fs.removeSync(filePath);
1518
+ return false;
1519
+ },
1520
+
1521
+ /**
1522
+ * Creates the QUIC/HTTP3 ClientTrafficPolicy for the merged data plane.
1523
+ *
1524
+ * Emitted once per Gateway with one targetRef for each hostname-scoped HTTPS
1525
+ * listener. A single policy object avoids same-scope policy competition,
1526
+ * while section-specific targets enable QUIC on every distinct listener.
1527
+ * @param {string} name - Gateway the policy attaches to, from {@link UnderpostDeploy.gatewayNameFactory}.
1528
+ * @param {string} [sectionName] - Backward-compatible single listener target.
1529
+ * @param {Array<string>} [sectionNames] - HTTPS listeners the policy targets.
1530
+ * @param {string} env - `development` | `production`.
1531
+ * @param {object} [options] - Deploy/run options (namespace, QUIC settings).
1532
+ * @returns {string} ClientTrafficPolicy YAML, or an empty string when HTTP/3 has no TLS transport.
1533
+ * @memberof UnderpostDeploy
1534
+ */
1535
+ clientTrafficPolicyYamlFactory({ name, sectionName, sectionNames = [], env, options = {} }) {
1536
+ const namespace = options.namespace || 'default';
1537
+ const { http3 } = Underpost.deploy.gatewayApiConfigFactory(options);
1538
+ const includeTls = env !== 'development' || options.selfSigned === true;
1539
+ if (!includeTls || !http3) return '';
1540
+ const targets = [...new Set([...sectionNames, sectionName].filter(Boolean))];
1541
+ if (targets.length === 0) return '';
1542
+ // Scoped to the HTTPS section, not the whole Gateway. QUIC only concerns
1543
+ // the TLS listener, and an unscoped policy is applied to every listener of
1544
+ // the merged set — including the plain-HTTP ones, which the implementation
1545
+ // rejects outright ("applied to multiple http (non https) listeners on the
1546
+ // same port"), leaving HTTP/3 silently off.
1547
+ //
1548
+ return `
1549
+ ---
1550
+ apiVersion: ${GATEWAY_EXTENSION_GROUP_VERSION}
1551
+ kind: ClientTrafficPolicy
1552
+ metadata:
1553
+ name: ${name}-http3
1554
+ namespace: ${namespace}
1555
+ spec:
1556
+ targetRefs:
1557
+ ${targets
1558
+ .map(
1559
+ (target) => ` - group: ${GATEWAY_API_GROUP}
1560
+ kind: Gateway
1561
+ name: ${name}
1562
+ sectionName: ${target}`,
1563
+ )
1564
+ .join('\n')}
1565
+ http3: {}
1566
+ `;
1567
+ },
1568
+
1569
+ /**
1570
+ * Renders one HTTPRoute rule. Websockets need no opt-in here (unlike the
1571
+ * HTTPProxy `enableWebsockets` flag) — Gateway API forwards the upgrade by
1572
+ * default. A rule carrying `extensionRef` short-circuits at the gateway and
1573
+ * therefore emits no backendRefs.
1574
+ *
1575
+ * The HTTPProxy `timeoutPolicy.idle` has no Gateway API rule-level
1576
+ * equivalent (idle timeouts are listener/backend scoped) and is dropped.
1577
+ * @param {string} path - Match value.
1578
+ * @param {string} [matchType] - `PathPrefix` (default) | `Exact`.
1579
+ * @param {number} [port] - Backend service port.
1580
+ * @param {string} [deployId] - Deployment id used to derive the service name.
1581
+ * @param {string} [env] - Environment used to derive the service name.
1582
+ * @param {Array<string>} [deploymentVersions] - Traffic colours; the first carries all weight.
1583
+ * @param {string} [serviceId] - Explicit backend service name (overrides the derived one).
1584
+ * @param {Array<object>} [pathRewritePolicy] - HTTPProxy-shaped prefix rewrite, mapped to ReplacePrefixMatch.
1585
+ * @param {string} [replaceFullPath] - Rewrites the request to a fixed path (a single static document).
1586
+ * @param {string} [replacePrefixMatch] - Rewrites the matched prefix onto a static directory, so the
1587
+ * document and everything beside it resolve through one rule.
1588
+ * @param {object} [extensionRef] - `{ group, kind, name }` of a direct-response filter.
1589
+ * @param {object} [timeoutPolicy] - `{ response }` mapped to Gateway API timeouts.
1590
+ * @param {object} [retryPolicy] - `{ count, perTryTimeout }` mapped to retry.attempts / timeouts.backendRequest.
1591
+ * @param {string} [altSvc] - `Alt-Svc` value advertising the QUIC endpoint.
1592
+ * @returns {string} Rule YAML (indented for `spec.rules`).
1593
+ * @memberof UnderpostDeploy
1594
+ */
1595
+ httpRouteRuleFactory({
1596
+ path,
1597
+ matchType = 'PathPrefix',
1598
+ port,
1599
+ deployId,
1600
+ env,
1601
+ deploymentVersions = ['blue'],
1602
+ serviceId,
1603
+ pathRewritePolicy,
1604
+ replaceFullPath,
1605
+ replacePrefixMatch,
1606
+ extensionRef,
1607
+ timeoutPolicy,
1608
+ retryPolicy,
1609
+ altSvc,
1610
+ }) {
1611
+ const lines = [
1612
+ ` - matches:`,
1613
+ ` - path:`,
1614
+ ` type: ${matchType}`,
1615
+ ` value: ${path}`,
1616
+ ];
1617
+ const filters = [];
1618
+ const prefixRewrite =
1619
+ replacePrefixMatch ?? (pathRewritePolicy?.length ? pathRewritePolicy[0].replacement : undefined);
1620
+ if (replaceFullPath || prefixRewrite) {
1621
+ filters.push(` - type: URLRewrite`, ` urlRewrite:`, ` path:`);
1622
+ if (replaceFullPath)
1623
+ filters.push(` type: ReplaceFullPath`, ` replaceFullPath: ${replaceFullPath}`);
1624
+ else
1625
+ filters.push(` type: ReplacePrefixMatch`, ` replacePrefixMatch: ${prefixRewrite}`);
1626
+ }
1627
+ if (extensionRef)
1628
+ filters.push(
1629
+ ` - type: ExtensionRef`,
1630
+ ` extensionRef:`,
1631
+ ` group: ${extensionRef.group}`,
1632
+ ` kind: ${extensionRef.kind}`,
1633
+ ` name: ${extensionRef.name}`,
1634
+ );
1635
+ if (altSvc)
1636
+ filters.push(
1637
+ ` - type: ResponseHeaderModifier`,
1638
+ ` responseHeaderModifier:`,
1639
+ ` set:`,
1640
+ ` - name: Alt-Svc`,
1641
+ ` value: '${altSvc}'`,
1642
+ );
1643
+ if (filters.length > 0) lines.push(` filters:`, ...filters);
1644
+
1645
+ const timeouts = [];
1646
+ const request = gatewayDurationFactory(timeoutPolicy?.response);
1647
+ const backendRequest = gatewayDurationFactory(retryPolicy?.perTryTimeout ?? timeoutPolicy?.response);
1648
+ if (request) timeouts.push(` request: ${request}`);
1649
+ if (backendRequest) timeouts.push(` backendRequest: ${backendRequest}`);
1650
+ if (timeouts.length > 0) lines.push(` timeouts:`, ...timeouts);
1651
+
1652
+ const attempts = parseInt(retryPolicy?.count, 10);
1653
+ if (!isNaN(attempts)) lines.push(` retry:`, ` attempts: ${attempts}`);
1654
+
1655
+ // A backend is declared even for a rule the direct-response filter
1656
+ // short-circuits: the filter answers before the backend is ever dialled,
1657
+ // but a rule that resolves to nothing at all risks the whole route being
1658
+ // rejected — which takes every other path on that hostname down with it,
1659
+ // and shows up only as a bare 404 from the gateway.
1660
+ if (port !== undefined && (serviceId || deployId)) {
1661
+ lines.push(` backendRefs:`);
1662
+ for (const [i, version] of (serviceId ? [null] : deploymentVersions).entries())
1663
+ lines.push(
1664
+ ` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}`,
1665
+ ` port: ${port}`,
1666
+ ` weight: ${i === 0 ? 100 : 0}`,
1667
+ );
1668
+ }
1669
+ return `${lines.join('\n')}\n`;
1670
+ },
1671
+
1672
+ /**
1673
+ * Wraps rendered rules in an HTTPRoute attached to the host Gateway. The
1674
+ * object is named after the host, exactly like the HTTPProxy it mirrors, so
1675
+ * an apply of a per-instance fragment replaces the host route set the same
1676
+ * way — the complete multi-instance set is assembled by `instance-promote`.
1677
+ * @param {string} host - Hostname (Gateway name and route hostname).
1678
+ * @param {object} options - Deploy/run options (namespace).
1679
+ * @param {string} rules - Rendered rules from {@link UnderpostDeploy.httpRouteRuleFactory}.
1680
+ * @param {string} [name] - Route name override.
1681
+ * @param {string} [parentName] - Gateway the route attaches to; defaults to the host's own Gateway.
1682
+ * @returns {string} HTTPRoute YAML, or an empty string when there are no rules.
1683
+ * @memberof UnderpostDeploy
1684
+ */
1685
+ httpRouteYamlFactory({ host, options = {}, rules, name, parentName }) {
1686
+ if (!rules || !rules.trim()) return '';
1687
+ const namespace = options.namespace || 'default';
1688
+ return `
1689
+ ---
1690
+ apiVersion: ${GATEWAY_API_GROUP_VERSION}
1691
+ kind: HTTPRoute
1692
+ metadata:
1693
+ name: ${name || host}
1694
+ namespace: ${namespace}
1695
+ spec:
1696
+ parentRefs:
1697
+ - group: ${GATEWAY_API_GROUP}
1698
+ kind: Gateway
1699
+ name: ${parentName || host}
1700
+ namespace: ${namespace}
1701
+ hostnames:
1702
+ - ${host}
1703
+ rules:
1704
+ ${rules}`;
1705
+ },
1706
+
1707
+ /**
1708
+ * Node directory backing the static utility's volume, following the same
1709
+ * `HOST_VOLUME_ROOT/<pv>` convention as every other hostPath volume.
1710
+ * @param {object} [options] - Deploy/run options.
1711
+ * @returns {string} Absolute host path.
1712
+ * @memberof UnderpostDeploy
1713
+ */
1714
+ underpostGatewayRootFactory(options = {}) {
1715
+ return options.underpostGatewayRoot || `${HOST_VOLUME_ROOT}/${UNDERPOST_GATEWAY.volumeName}`;
1716
+ },
1717
+
1718
+ /**
1719
+ * Reports whether a Service currently has at least one ready endpoint.
1720
+ *
1721
+ * The single reachability predicate for a colour: a Service with no ready
1722
+ * endpoint cannot serve, so it is neither safe to route to nor something
1723
+ * live traffic can be sitting on.
1724
+ * @param {string} service - Service name.
1725
+ * @param {string} [namespace] - Namespace.
1726
+ * @returns {boolean} True when an endpoint is ready right now.
1727
+ * @memberof UnderpostDeploy
1728
+ */
1729
+ serviceHasReadyEndpoints({ service, namespace = 'default' }) {
1730
+ const ready = shellExec(
1731
+ `kubectl get endpointslice -n ${namespace} -l kubernetes.io/service-name=${service} ` +
1732
+ `-o jsonpath='{.items[*].endpoints[*].conditions.ready}' 2>/dev/null`,
1733
+ { stdout: true, silent: true, silentOnError: true },
1734
+ );
1735
+ return `${ready}`.includes('true');
1736
+ },
1737
+
1738
+ /**
1739
+ * Reports whether the Deployment controller has observed the current
1740
+ * generation and every desired replica is updated, Ready, and Available.
1741
+ * @param {string} deployment - Deployment name.
1742
+ * @param {string} [namespace] - Kubernetes namespace.
1743
+ * @returns {boolean} True only when the full target colour is ready.
1744
+ */
1745
+ deploymentHasReadyReplicas({ deployment, namespace = 'default' }) {
1746
+ const state = `${
1747
+ shellExec(
1748
+ `kubectl get deployment ${deployment} -n ${namespace} ` +
1749
+ `-o jsonpath='{.metadata.generation} {.status.observedGeneration} {.spec.replicas} ` +
1750
+ `{.status.updatedReplicas} {.status.readyReplicas} {.status.availableReplicas}'`,
1751
+ { stdout: true, silent: true, silentOnError: true },
1752
+ ) || ''
1753
+ }`
1754
+ .trim()
1755
+ .split(/\s+/)
1756
+ .map(Number);
1757
+ if (state.length !== 6 || state.some((value) => !Number.isFinite(value))) return false;
1758
+ const [generation, observed, desired, updated, ready, available] = state;
1759
+ return observed >= generation && desired > 0 && updated === desired && ready === desired && available === desired;
1760
+ },
1761
+
1762
+ /**
1763
+ * Waits for all replicas of a target colour, not merely its first endpoint.
1764
+ * @param {string} deployment - Deployment name.
1765
+ * @param {string} [namespace] - Kubernetes namespace.
1766
+ * @param {number} [timeoutMs] - Maximum wait.
1767
+ * @returns {boolean} True when the whole Deployment is ready.
1768
+ */
1769
+ awaitDeploymentReady({ deployment, namespace = 'default', timeoutMs = 15 * 60 * 1000 }) {
1770
+ const deadline = Date.now() + timeoutMs;
1771
+ while (Date.now() < deadline) {
1772
+ if (Underpost.deploy.deploymentHasReadyReplicas({ deployment, namespace })) return true;
1773
+ shellExec('sleep 2', { silent: true });
1774
+ }
1775
+ logger.warn('Deployment never made every desired replica Ready', { deployment, namespace });
1776
+ return false;
1777
+ },
1778
+
1779
+ /**
1780
+ * Blocks until a Service has at least one ready endpoint.
1781
+ *
1782
+ * Envoy Gateway translates a route's backends at translation time, so the
1783
+ * moment a route is applied decides whether it works: with no ready
1784
+ * endpoint the rule is rewritten to a 500 direct response and stays that
1785
+ * way. Returns false on timeout rather than throwing — a Service that never
1786
+ * comes up is the deploy's problem to report, not this helper's.
1787
+ * @param {string} service - Service name.
1788
+ * @param {string} [namespace] - Namespace.
1789
+ * @param {number} [timeoutMs] - How long to wait.
1790
+ * @returns {boolean} True once an endpoint is ready.
1791
+ * @memberof UnderpostDeploy
1792
+ */
1793
+ awaitServiceEndpoints({ service, namespace = 'default', timeoutMs = 15 * 60 * 1000 }) {
1794
+ const deadline = Date.now() + timeoutMs;
1795
+ while (Date.now() < deadline) {
1796
+ if (Underpost.deploy.serviceHasReadyEndpoints({ service, namespace })) return true;
1797
+ shellExec('sleep 2', { silent: true });
1798
+ }
1799
+ logger.warn('Service never reported a ready endpoint; routes may be programmed as 500', {
1800
+ service,
1801
+ namespace,
1802
+ });
1803
+ return false;
1804
+ },
1805
+
1806
+ /**
1807
+ * Places every edge-served document in the static utility's tree.
1808
+ *
1809
+ * Two sources, in that order. The workload is preferred because it is the
1810
+ * only place all of them exist at once: several clients are built from
1811
+ * sources cloned into the container at start-up, so this checkout's `public/`
1812
+ * tree is both incomplete and only as fresh as its last local build. The
1813
+ * checkout is the fallback, and the reason this runs twice in a cluster
1814
+ * bring-up — once before the workload exists, to seed the tree so the routes
1815
+ * are correct the moment they are programmed, and once after it is Ready, to
1816
+ * replace what the container built better.
1817
+ *
1818
+ * A host whose page is in neither place keeps whatever the tree already had,
1819
+ * ending on the shared default page — which answers 404 rather than
1820
+ * presenting itself as that host's page.
1821
+ * @param {string} deployId - Deploy id whose conf declares the views.
1822
+ * @param {string} env - `development` | `production`.
1823
+ * @param {object} [options] - Deploy options (namespace, versions, static root).
1824
+ * @returns {Array<object>} One record per document, with where it came from.
1825
+ * @memberof UnderpostDeploy
1826
+ */
1827
+ syncStaticAssets(deployId, env, options = {}) {
1828
+ const namespace = options.namespace || 'default';
1829
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
1830
+ const confSSRPath = `./engine-private/conf/${deployId}/conf.ssr.json`;
1831
+ if (!fs.existsSync(confServerPath) || !fs.existsSync(confSSRPath)) {
1832
+ logger.warn('No conf.server.json / conf.ssr.json; nothing to sync', { deployId, confServerPath });
1833
+ return [];
1834
+ }
1835
+ // loadReplicas expands a plain `replicas` path (no singleReplica) into its
1836
+ // own literal path key with the canonical path's client/view config
1837
+ // cloned onto it, matching what buildManifest/buildProxyRouter resolve
1838
+ // against — so a replica path this workload actually built (e.g. `/r1`)
1839
+ // gets its edge documents synced too, not just the canonical path.
1840
+ const confServer = loadReplicas(deployId, JSON.parse(fs.readFileSync(confServerPath, 'utf8')));
1841
+ const confSSR = JSON.parse(fs.readFileSync(confSSRPath, 'utf8'));
1842
+ const hostRoot = Underpost.deploy.underpostGatewayRootFactory(options);
1843
+ const version = (options.versions && `${options.versions}`.split(',')[0]) || 'blue';
1844
+ const podName = Underpost.kubectl
1845
+ .get(`${deployId}-${env}-${version}`, 'pods', namespace)
1846
+ .find((pod) => pod.NAME?.startsWith(`${deployId}-${env}-${version}-`) && pod.STATUS === 'Running')?.NAME;
1847
+ const synced = [];
1848
+ for (const host of Object.keys(confServer))
1849
+ for (const path of Object.keys(confServer[host])) {
1850
+ // A singleReplica canonical path is never built under this deploy id —
1851
+ // client-build.js skips it (see `if (singleReplica) continue`) and
1852
+ // buildProxyRouter/buildManifest route none of its own edge documents
1853
+ // for it either. Its replicas are each their own deploy id, built and
1854
+ // synced independently; requiring this path's assets here would fail
1855
+ // on a document that structurally cannot exist for this deploy.
1856
+ if (confServer[host][path].singleReplica) continue;
1857
+ for (const entry of Underpost.deploy.edgeRouteEntriesFactory({ confServer, confSSR, host, path })) {
1858
+ const fromPod =
1859
+ !!podName &&
1860
+ syncStaticAssetFromPod({
1861
+ podName,
1862
+ namespace,
1863
+ sourcePath: entry.containerPath,
1864
+ hostRoot,
1865
+ assetPath: entry.assetPath,
1866
+ });
1867
+ const fromHost =
1868
+ !fromPod && writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.hostPath });
1869
+ synced.push({
1870
+ host,
1871
+ kind: entry.kind,
1872
+ assetPath: entry.assetPath,
1873
+ source: fromPod ? 'workload' : fromHost ? 'checkout' : null,
1874
+ });
1875
+ }
1876
+ }
1877
+ // Instance status pages are the same kind of document under the same
1878
+ // layout, so they are placed by the same pass — but they come from neither
1879
+ // of the sources above. Each is built and versioned by the project its
1880
+ // instance runs, so `customStatusPages[].hostPath` resolves against that
1881
+ // project's checkout on this host, and one document is placed per variant
1882
+ // so `/FOREST/404` and `/404` each land where their own rule rewrites to.
1883
+ if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`))
1884
+ for (const entry of instanceStatusPageEntriesFactory({ instances: loadConfInstances(deployId) }))
1885
+ synced.push({
1886
+ host: entry.host,
1887
+ kind: `status:${entry.status}`,
1888
+ assetPath: entry.assetPath,
1889
+ source: writeStaticAsset({ hostRoot, assetPath: entry.assetPath, sourcePath: entry.sourcePath })
1890
+ ? 'project'
1891
+ : null,
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);
1899
+ logger.info('Static edge documents placed', {
1900
+ deployId,
1901
+ podName: podName || '(no running workload; placed from this checkout)',
1902
+ fromWorkload: synced.filter((entry) => entry.source === 'workload').length,
1903
+ fromCheckout: synced.filter((entry) => entry.source === 'checkout').length,
1904
+ fromProject: synced.filter((entry) => entry.source === 'project').length,
1905
+ missing: synced.filter((entry) => !entry.source).map((entry) => entry.assetPath),
1906
+ });
1907
+ return synced;
1908
+ },
1909
+
1910
+ /**
1911
+ * The SSR views one host/path serves from the static edge tier, with every
1912
+ * address each of them needs: the route to match, the directory the gateway
1913
+ * rewrites onto, where the document sits under the static root, and the two
1914
+ * places the build may have left it — inside the workload, and in this
1915
+ * checkout's own `public/` tree.
1916
+ *
1917
+ * Single source of truth for the two consumers that must agree exactly —
1918
+ * `--build-manifest`, which emits the rules, and `--sync-static`, which
1919
+ * places the documents those rules point at.
1920
+ * @param {object} confServer - Parsed `conf.server.json`.
1921
+ * @param {object} confSSR - Parsed `conf.ssr.json`.
1922
+ * @param {string} host - Hostname.
1923
+ * @param {string} path - Proxy sub-path.
1924
+ * @returns {Array<object>} One entry per edge-served view.
1925
+ * @memberof UnderpostDeploy
1926
+ */
1927
+ edgeRouteEntriesFactory({ confServer, confSSR, host, path }) {
1928
+ const client = confServer?.[host]?.[path]?.client;
1929
+ const views = client ? confSSR?.[getCapVariableName(client)]?.views : undefined;
1930
+ if (!views) return [];
1931
+ // The client build writes each view to `public/<host><path>/<view>/index.html`,
1932
+ // under the container root for the workload's copy and under this repo for
1933
+ // the host's.
1934
+ // A context is built on its own route (`/offline/index.html`) because a
1935
+ // client requests it by URL; a status page is built under `status-pages/`
1936
+ // instead, off any route the runtime could answer with. Both sides read the
1937
+ // segment from one factory so the sync never looks where the build did not
1938
+ // write.
1939
+ const publicPath = (segment) => `public/${host}${path === '/' ? '' : path}/${segment}`;
1940
+ const addresses = (segment) => ({
1941
+ containerPath: `${CONTAINER_ENGINE_ROOT}/${publicPath(segment)}`,
1942
+ hostPath: `./${publicPath(segment)}`,
1943
+ });
1944
+ return [
1945
+ ...statusPageRoutesFactory({ views, proxyPath: path }).map((route) => ({
1946
+ ...route,
1947
+ ...statusPageAssetPathFactory({ host, path, status: route.status }),
1948
+ kind: `status:${route.status}`,
1949
+ ...addresses(statusPageBuildSegment(route.status)),
1950
+ })),
1951
+ ...staticContextRoutesFactory({ views, proxyPath: path }).map((route) => ({
1952
+ ...route,
1953
+ ...staticLocationFactory({ host, path, context: route.context }),
1954
+ kind: `context:${route.context}`,
1955
+ ...addresses(`${route.context}/index.html`),
1956
+ })),
1957
+ ];
1958
+ },
1959
+
1960
+ /**
1961
+ * Renders the static utility workload, resolving its placement from the
1962
+ * deploy options the way every other hostPath volume is resolved — the
1963
+ * documents are written to a node directory, so the pod has to land on the
1964
+ * node that holds them.
1965
+ * @param {object} [options] - Deploy/run options (namespace, node, cluster flags).
1966
+ * @returns {string} Multi-document YAML.
1967
+ * @memberof UnderpostDeploy
1968
+ */
1969
+ underpostGatewayYamlFactory(options = {}) {
1970
+ return underpostGatewayManifestsFactory({
1971
+ namespace: options.namespace || 'default',
1972
+ hostPath: Underpost.deploy.underpostGatewayRootFactory(options),
1973
+ nodeName: Underpost.deploy.resolveDeployNode(options),
1974
+ resolver: Underpost.deploy.clusterDnsFactory(),
1975
+ });
1976
+ },
1977
+
1978
+ /**
1979
+ * Where a build writes the shared gateway's server blocks. A build artifact
1980
+ * like every other manifest, installed into the live workload by the apply
1981
+ * path rather than by the build that produced it.
1982
+ * @param {string} deployId - Deploy id.
1983
+ * @param {string} env - `development` | `production`.
1984
+ * @returns {string} Directory holding the built blocks.
1985
+ * @memberof UnderpostDeploy
1986
+ */
1987
+ gatewayConfDirFactory({ deployId, env }) {
1988
+ return `./engine-private/conf/${deployId}/build/${env}/gateway-conf.d`;
1989
+ },
1990
+
1991
+ /**
1992
+ * The cluster DNS address Nginx resolves upstream Service names through.
1993
+ * Read from the live Service because it follows the cluster's own Service
1994
+ * CIDR, and baked into the config because nginx cannot resolve the name of
1995
+ * its own resolver.
1996
+ * @returns {string} kube-dns ClusterIP, or the conventional default.
1997
+ * @memberof UnderpostDeploy
1998
+ */
1999
+ clusterDnsFactory() {
2000
+ const clusterIp = shellExec(
2001
+ `kubectl get svc kube-dns -n kube-system -o jsonpath='{.spec.clusterIP}' 2>/dev/null`,
2002
+ { stdout: true, silent: true, silentOnError: true },
2003
+ );
2004
+ return /^\d+\.\d+\.\d+\.\d+$/.test(`${clusterIp}`.trim()) ? `${clusterIp}`.trim() : UNDERPOST_GATEWAY.resolver;
2005
+ },
2006
+
2007
+ /**
2008
+ * Renders the HTTPRoute rules that serve an instance's status pages at the
2009
+ * gateway. Each declared page gets a canonical route under the instance's
2010
+ * own sub-path (`/404`, `/FOREST/404`), so a status document is reachable
2011
+ * and cacheable per instance without ever reaching the workload.
2012
+ *
2013
+ * With `catchAll`, the same filter is additionally bound to `/`. That rule
2014
+ * is only ever requested by the host assembly when no instance claims the
2015
+ * root path — two rules with identical matches would otherwise make gateway
2016
+ * precedence ambiguous.
2017
+ * @param {string} deployId - Instance-scoped deploy id.
2018
+ * @param {string} basePath - The instance's URL sub-path.
2019
+ * @param {Array<object>} statusPages - `customStatusPages` entries.
2020
+ * @param {string} [altSvc] - `Alt-Svc` value advertising the QUIC endpoint.
2021
+ * @param {boolean} [catchAll] - Also bind the first page (404 when present) to `/`.
2022
+ * @param {string} [host] - Hostname the documents were placed under; falls back to `deployId`.
2023
+ * @param {Array<string>} [servedStatuses] - Statuses whose document reached the static tree. Undefined means "all declared".
2024
+ * @returns {string} Rule YAML.
2025
+ * @memberof UnderpostDeploy
2026
+ */
2027
+ statusPageRouteRulesFactory({
2028
+ deployId,
2029
+ basePath = '/',
2030
+ statusPages = [],
2031
+ altSvc,
2032
+ catchAll = false,
2033
+ host,
2034
+ servedStatuses,
2035
+ }) {
2036
+ // Only statuses whose document was actually placed in the static tree get
2037
+ // a rule; a rewrite to a missing file would answer with the shared default
2038
+ // page instead of the host's own.
2039
+ const pages = statusPages.filter(
2040
+ (page) =>
2041
+ page?.status && page?.hostPath && (servedStatuses === undefined || servedStatuses.includes(`${page.status}`)),
2042
+ );
2043
+ if (pages.length === 0) return '';
2044
+ const prefix = !basePath || basePath === '/' ? '' : basePath.replace(/\/$/, '');
2045
+ // Served by the static utility rather than carried in the gateway config:
2046
+ // a rendered page is far past the direct-response ceiling, and exceeding
2047
+ // it fails the whole route.
2048
+ const location = (status) => statusPageAssetPathFactory({ host: host || deployId, path: basePath, status });
2049
+ const staticRule = (path, rewrite) =>
2050
+ Underpost.deploy.httpRouteRuleFactory({
2051
+ path,
2052
+ ...rewrite,
2053
+ serviceId: UNDERPOST_GATEWAY.serviceName,
2054
+ port: UNDERPOST_GATEWAY.port,
2055
+ altSvc,
2056
+ });
2057
+ let rules = '';
2058
+ // Canonical routes rewrite onto the directory so assets beside the
2059
+ // document resolve too; the catch-all cannot, because a prefix rewrite of
2060
+ // `/` would carry the rest of the request path into the target.
2061
+ for (const page of pages)
2062
+ rules += staticRule(`${prefix}/${page.status}`, { replacePrefixMatch: location(page.status).dir });
2063
+ if (catchAll) {
2064
+ const fallback = pages.find((page) => `${page.status}` === '404') || pages[0];
2065
+ rules += staticRule('/', { replaceFullPath: location(fallback.status).url });
2066
+ }
2067
+ return rules;
2068
+ },
2069
+
749
2070
  /**
750
2071
  * Callback function for handling deployment options.
751
2072
  * @param {string} deployList - List of deployment IDs to process.
@@ -756,7 +2077,6 @@ spec:
756
2077
  * @param {boolean} options.sync - Whether to synchronize deployment configurations.
757
2078
  * @param {boolean} options.buildManifest - Whether to build the deployment manifest.
758
2079
  * @param {boolean} options.infoUtil - Whether to display utility information.
759
- * @param {boolean} options.expose - Whether to expose the deployment.
760
2080
  * @param {boolean} options.cert - Whether to create cert-manager Certificate resources for the deployment.
761
2081
  * @param {string} options.certHosts - Comma-separated list of hosts for which to create cert-manager certificates.
762
2082
  * @param {boolean} options.selfSigned - Use a pre-created self-signed TLS secret instead of cert-manager. The secret must already exist in the namespace with the same name as the host. Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.
@@ -767,23 +2087,20 @@ spec:
767
2087
  * @param {string} options.node - Explicit target node (highest precedence in the node chain). When empty, {@link UnderpostDeploy.resolveDeployNode} falls back to the cluster-type default (`kind-worker` for kind, host for kubeadm/k3s). Used for both volume placement and hostPath PV nodeAffinity.
768
2088
  * @param {string} [options.sshKeyPath] - Private key path for node SSH operations, forwarded to deployVolume when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.
769
2089
  * @param {boolean} options.disableUpdateDeployment - Whether to disable deployment updates.
2090
+ * @param {boolean} [options.gatewayApi] - Apply the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. Both manifest sets are always generated by `--build-manifest`.
2091
+ * @param {string} [options.gatewayClass] - GatewayClass name baked into generated Gateway manifests.
2092
+ * @param {boolean} [options.disableHttp3] - Omit QUIC/HTTP3 listener config and the Alt-Svc advertisement.
2093
+ * @param {number|string} [options.quicPort] - UDP port advertised for QUIC/HTTP3.
770
2094
  * @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
771
2095
  * @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
772
2096
  * @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
773
- * @param {boolean} options.status - Whether to display deployment status.
774
2097
  * @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
775
2098
  * @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
776
2099
  * @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
777
2100
  * @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
778
2101
  * @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
779
2102
  * @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
780
- * @param {string} [options.kindType] - Kubernetes resource kind to target when using --expose (defaults to "svc").
781
- * @param {number} [options.port] - Port number override for exposing the deployment.
782
2103
  * @param {string} [options.cmd] - Custom initialization command (comma-separated) for deploymentYamlPartsFactory.
783
- * @param {number} [options.exposePort] - Remote port override when --expose is active (overrides auto-detected service port). Used as both local and remote port unless exposeLocalPort is also set.
784
- * @param {number} [options.exposeLocalPort] - Local port override for --expose (e.g. 80); remote port is still auto-detected. Enables /etc/hosts access without a port in the browser URL.
785
- * @param {boolean} [options.localProxy] - When true (with --expose), forward all service TCP ports locally and start the Node.js path-routing proxy for full path-based routing (e.g. /wp alongside /).
786
- * @param {boolean} [options.tls] - When true (with --expose --local-proxy), start the proxy on port 443 with TLS using self-signed certificates resolved from the local SSL store.
787
2104
  * @param {boolean} [options.k3s] - Whether to use k3s cluster context.
788
2105
  * @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
789
2106
  * @param {boolean} [options.kind] - Whether to use kind cluster context.
@@ -791,7 +2108,7 @@ spec:
791
2108
  * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
792
2109
  * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
793
2110
  * @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); passed through to buildManifest/deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
794
- * @param {boolean} [options.disableRuntimeProbes] - Omit internal-status HTTP probes from generated manifests. When true no readiness/liveness/startup probes are emitted.
2111
+ * @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
795
2112
  * @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes.
796
2113
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
797
2114
  * @memberof UnderpostDeploy
@@ -805,7 +2122,6 @@ spec:
805
2122
  sync: false,
806
2123
  buildManifest: false,
807
2124
  infoUtil: false,
808
- expose: false,
809
2125
  cert: false,
810
2126
  certHosts: '',
811
2127
  versions: '',
@@ -817,19 +2133,12 @@ spec:
817
2133
  disableUpdateProxy: false,
818
2134
  disableDeploymentProxy: false,
819
2135
  disableUpdateVolume: false,
820
- status: false,
821
2136
  disableUpdateUnderpostConfig: false,
822
2137
  namespace: '',
823
2138
  timeoutResponse: '',
824
2139
  timeoutIdle: '',
825
2140
  retryCount: '',
826
2141
  retryPerTryTimeout: '',
827
- kindType: '',
828
- port: 0,
829
- exposePort: 0,
830
- exposeLocalPort: 0,
831
- localProxy: false,
832
- tls: false,
833
2142
  selfSigned: false,
834
2143
  cmd: '',
835
2144
  k3s: false,
@@ -839,67 +2148,60 @@ spec:
839
2148
  imagePullPolicy: '',
840
2149
  },
841
2150
  ) {
2151
+ options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
842
2152
  const namespace = options.namespace ? options.namespace : 'default';
843
2153
  if (!deployList && options.certHosts) {
844
2154
  for (const host of options.certHosts.split(',')) {
845
- shellExec(`sudo kubectl apply -f - -n ${namespace} <<EOF
2155
+ shellExec(`sudo kubectl apply -f - -n ${namespace} <<'EOF'
846
2156
  ${Underpost.deploy.buildCertManagerCertificate({ host, namespace })}
847
2157
  EOF`);
848
2158
  }
849
2159
  return;
850
- } else if (!deployList) deployList = 'dd-default';
851
- if (deployList === 'dd' && fs.existsSync(`./engine-private/deploy/dd.router`))
2160
+ } else if (!deployList || deployList === 'dd')
852
2161
  deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8');
853
- if (options.status === true) {
854
- for (const _deployId of deployList.split(',')) {
855
- const deployId = _deployId.trim();
856
- const instances = [];
857
- if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`)) {
858
- // Expands multiInstance variants so status lists every deployed
859
- // instance (mmo-server, mmo-server-forest, …), not just the templates.
860
- const confInstances = loadConfInstances(deployId);
861
- for (const instance of confInstances) {
862
- const _deployId = `${deployId}-${instance.id}`;
863
- instances.push({
864
- id: instance.id,
865
- host: instance.host,
866
- path: instance.path,
867
- fromPort: instance.fromPort,
868
- toPort: instance.toPort,
869
- fromDebugPort: instance.fromDebugPort,
870
- toDebugPort: instance.toDebugPort,
871
- traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host, env }),
872
- });
873
- }
874
- }
875
- logger.info('', {
876
- deployId,
2162
+ const deployIds = deployList
2163
+ .split(',')
2164
+ .map((id) => id.trim())
2165
+ .filter(Boolean);
2166
+ const explicitVersions = options.versions && typeof options.versions === 'string' ? options.versions : '';
2167
+ const explicitTraffic =
2168
+ options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',')[0] : '';
2169
+ const liveTrafficByDeployId = Object.fromEntries(
2170
+ deployIds.map((deployId) => [
2171
+ deployId,
2172
+ Underpost.deploy.getCurrentTraffic(deployId, {
2173
+ namespace,
877
2174
  env,
878
- traffic: Underpost.deploy.getCurrentTraffic(deployId, { namespace }),
879
- router: await Underpost.deploy.routerFactory(deployId, env),
880
- pods: await Underpost.kubectl.get(deployId),
881
- instances,
882
- });
883
- }
884
- const interfaceName = Underpost.dns.getDefaultNetworkInterface();
885
- logger.info('Machine', {
886
- hostname: os.hostname(),
887
- arch: Underpost.baremetal.getHostArch(),
888
- ipv4Public: await Underpost.dns.getPublicIp(),
889
- ipv4Local: Underpost.dns.getLocalIPv4Address(),
890
- resources: Underpost.cluster.getResourcesCapacity(options.node),
891
- defaultInterfaceName: interfaceName,
892
- defaultInterfaceInfo: os.networkInterfaces()[interfaceName],
893
- });
894
- return;
895
- }
896
- if (!(options.versions && typeof options.versions === 'string')) options.versions = 'blue,green';
2175
+ gatewayApi: options.gatewayApi,
2176
+ }),
2177
+ ]),
2178
+ );
2179
+ const versionsByDeployId = Object.fromEntries(
2180
+ deployIds.map((deployId) => [
2181
+ deployId,
2182
+ explicitVersions || explicitTraffic || nextTrafficFactory(liveTrafficByDeployId[deployId]),
2183
+ ]),
2184
+ );
897
2185
  if (!options.replicas) options.replicas = 1;
898
2186
  if (options.sync)
899
2187
  await getDataDeploy({
900
2188
  buildSingleReplica: true,
901
2189
  });
902
- if (options.buildManifest === true) await Underpost.deploy.buildManifest(deployList, env, options);
2190
+ if (options.buildManifest === true)
2191
+ for (const deployId of deployIds)
2192
+ await Underpost.deploy.buildManifest(deployId, env, {
2193
+ ...options,
2194
+ versions: versionsByDeployId[deployId],
2195
+ traffic: explicitTraffic || liveTrafficByDeployId[deployId] || versionsByDeployId[deployId].split(',')[0],
2196
+ });
2197
+ if (options.syncStatic === true) {
2198
+ for (const deployId of deployList
2199
+ .split(',')
2200
+ .map((id) => id.trim())
2201
+ .filter(Boolean))
2202
+ Underpost.deploy.syncStaticAssets(deployId, env, options);
2203
+ return;
2204
+ }
903
2205
  if (options.infoRouter === true || options.buildManifest === true) {
904
2206
  logger.info('router', await Underpost.deploy.routerFactory(deployList, env));
905
2207
  return;
@@ -909,67 +2211,14 @@ EOF`);
909
2211
  for (const _deployId of deployList.split(',')) {
910
2212
  const deployId = _deployId.trim();
911
2213
  if (!deployId) continue;
912
- if (options.expose === true) {
913
- const kindType = options.kindType ? options.kindType : 'svc';
914
- const svc = Underpost.kubectl.get(deployId, kindType)[0];
915
- if (!svc) {
916
- logger.error(`No ${kindType} found matching '${deployId}', skipping expose`);
917
- continue;
918
- }
919
- if (options.localProxy) {
920
- const svcPorts = [
921
- ...new Set(
922
- svc['PORT(S)']
923
- .split(',')
924
- .filter((p) => p.includes('/TCP'))
925
- .map((p) => parseInt(p.split(':')[0])),
926
- ),
927
- ];
928
- for (const svcPort of svcPorts) {
929
- shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${svcPort}:${svcPort}`, {
930
- async: true,
931
- });
932
- }
933
- const envFile = `./engine-private/conf/${deployId}/.env.${env}`;
934
- let basePort = svcPorts[0] - 1;
935
- if (fs.existsSync(envFile)) {
936
- const portMatch = fs.readFileSync(envFile, 'utf8').match(/^PORT=(\d+)/m);
937
- if (portMatch) basePort = parseInt(portMatch[1]);
938
- }
939
- logger.info(deployId, { svc, svcPorts, basePort });
940
- const tlsFlag = options.tls ? ' tls' : '';
941
- shellExec(
942
- `NODE_ENV=${env} PORT=${basePort} DEV_PROXY_PORT_OFFSET=0 node src/proxy proxy ${deployId} ${env}${tlsFlag}`,
943
- { async: true },
944
- );
945
- } else {
946
- const remotePort = options.exposePort
947
- ? parseInt(options.exposePort)
948
- : options.port
949
- ? parseInt(options.port)
950
- : kindType !== 'svc'
951
- ? 80
952
- : parseInt(svc[`PORT(S)`].split('/TCP')[0]);
953
- const localPort = options.exposeLocalPort ? parseInt(options.exposeLocalPort) : remotePort;
954
- logger.info(deployId, {
955
- svc,
956
- localPort,
957
- remotePort,
958
- });
959
- shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${localPort}:${remotePort}`, {
960
- async: true,
961
- });
962
- }
963
- continue;
964
- }
965
-
2214
+ const deploymentVersions = versionsByDeployId[deployId].split(',').map((version) => version.trim());
966
2215
  const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
967
2216
  const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
968
2217
  ? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
969
2218
  : [];
970
2219
 
971
2220
  if (!options.disableUpdateDeployment)
972
- for (const version of options.versions.split(',')) {
2221
+ for (const version of deploymentVersions) {
973
2222
  shellExec(
974
2223
  `sudo kubectl delete svc ${deployId}-${env}-${version}-service -n ${namespace} --ignore-not-found`,
975
2224
  );
@@ -990,7 +2239,7 @@ EOF`);
990
2239
  k3s: options.k3s,
991
2240
  env,
992
2241
  }),
993
- clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
2242
+ clusterContext: clusterTypeFactory(options),
994
2243
  gitClean: options.gitClean || false,
995
2244
  sshKeyPath: options.sshKeyPath || '',
996
2245
  });
@@ -998,8 +2247,29 @@ EOF`);
998
2247
 
999
2248
  for (const host of Object.keys(confServer)) {
1000
2249
  if (!options.disableUpdateProxy) {
1001
- shellExec(`sudo kubectl delete HTTPProxy ${host} -n ${namespace} --ignore-not-found`);
1002
- if (Underpost.deploy.isValidTLSContext({ host, env, options }))
2250
+ // The host's route object is left in place and replaced by the `apply`
2251
+ // below. Deleting it first unpublished the hostname for the whole
2252
+ // reconciliation window, so every promote dropped live requests before
2253
+ // the new colour was ever the question.
2254
+ //
2255
+ // A deploy that previously ran the per-host model left a Gateway
2256
+ // named after each host. Those are superseded by the consolidated
2257
+ // one, and leaving them behind duplicates that hostname's listeners
2258
+ // in the same merged set. The oldest resource would retain traffic.
2259
+ //
2260
+ // `undefined-http3` is the same problem under a different name: the
2261
+ // consolidated policy was briefly emitted with an unresolved host in
2262
+ // its metadata. Merged listeners are configured by the oldest policy
2263
+ // that targets them, so that object outranks the correctly named one
2264
+ // for as long as it exists.
2265
+ if (options.gatewayApi)
2266
+ for (const name of [host, 'undefined']) {
2267
+ shellExec(`sudo kubectl delete Gateway ${name} -n ${namespace} --ignore-not-found`, { silent: true });
2268
+ shellExec(`sudo kubectl delete ClientTrafficPolicy ${name}-http3 -n ${namespace} --ignore-not-found`, {
2269
+ silent: true,
2270
+ });
2271
+ }
2272
+ if (Underpost.deploy.isCertManagerContext({ host, env, options }))
1003
2273
  shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
1004
2274
  }
1005
2275
  }
@@ -1015,13 +2285,105 @@ EOF`);
1015
2285
  const grpcServicePath = `./${manifestsPath}/grpc-service.yaml`;
1016
2286
  if (fs.existsSync(grpcServicePath)) shellExec(`sudo kubectl apply -f ${grpcServicePath} -n ${namespace}`);
1017
2287
  }
1018
- if (!options.disableUpdateProxy)
1019
- shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
2288
+ // Ingress is served by exactly one of the two routing stacks: the
2289
+ // Contour HTTPProxy set, or the Gateway API set (Gateway + HTTPRoute).
2290
+ // Applying both would publish duplicate routes for the same hostnames.
2291
+ if (!options.disableUpdateProxy) {
2292
+ const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, {
2293
+ namespace,
2294
+ env,
2295
+ gatewayApi: options.gatewayApi,
2296
+ });
2297
+ const currentReady =
2298
+ !!currentTraffic &&
2299
+ Underpost.deploy.serviceHasReadyEndpoints({
2300
+ service: `${deployId}-${env}-${currentTraffic}-service`,
2301
+ namespace,
2302
+ });
2303
+ // With no explicit traffic request, deploying the opposite colour is
2304
+ // preparation only: preserve the colour already serving. A first
2305
+ // deployment has no live selector and starts on the first requested
2306
+ // version. Explicit --traffic is the only normal apply-time switch.
2307
+ const desiredTraffic = explicitTraffic || (currentReady ? currentTraffic : deploymentVersions[0]);
2308
+ const desiredDeployment = `${deployId}-${env}-${desiredTraffic}`;
2309
+ if (
2310
+ !options.disableUpdateDeployment &&
2311
+ (!Underpost.deploy.awaitDeploymentReady({ deployment: desiredDeployment, namespace }) ||
2312
+ !Underpost.deploy.awaitServiceEndpoints({ service: `${desiredDeployment}-service`, namespace }))
2313
+ )
2314
+ throw new Error(`Refusing to route ${deployId}-${env} to unready colour ${desiredTraffic}`);
2315
+
2316
+ // Migration is two-phase. First make every route and fallback block
2317
+ // use a stable Service that still selects the current colour. Only
2318
+ // after both stacks are converged is its selector moved to the ready
2319
+ // target, so stack migration cannot create a root/API split.
2320
+ const bootstrapTraffic = currentReady ? currentTraffic : desiredTraffic;
2321
+ const trafficServicePath = `./${manifestsPath}/traffic-service.yaml`;
2322
+ Underpost.deploy.applyTrafficService({
2323
+ deployId,
2324
+ env,
2325
+ traffic: bootstrapTraffic,
2326
+ namespace,
2327
+ manifestPath: trafficServicePath,
2328
+ });
2329
+ if (options.gatewayApi) {
2330
+ // Nginx must know how to proxy and intercept this host before Envoy
2331
+ // can send the first request to it. Installing first removes the
2332
+ // reconciliation window where the HTTPRoute is Accepted but the
2333
+ // shared gateway still serves its default server block.
2334
+ installGatewayConf({
2335
+ hostRoot: Underpost.deploy.underpostGatewayRootFactory(options),
2336
+ confSourceDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
2337
+ namespace,
2338
+ });
2339
+ for (const file of ['gateway.yaml', 'httproute.yaml']) {
2340
+ const gatewayApiPath = `./${manifestsPath}/${file}`;
2341
+ if (fs.existsSync(gatewayApiPath) && fs.readFileSync(gatewayApiPath, 'utf8').trim())
2342
+ shellExec(`sudo kubectl apply -f ${gatewayApiPath} -n ${namespace}`);
2343
+ }
2344
+ } else shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
2345
+ // The hostnames just published have to reach the data plane that now
2346
+ // describes them. A shared edge built before this apply still sends
2347
+ // them to the other stack, which answers 404 for a healthy workload.
2348
+ // No-op when no shared edge is installed.
2349
+ const sharedIngressUpdated = Underpost.cluster.refreshUnderpostIngress({ namespace, options });
2350
+ if (sharedIngressUpdated) {
2351
+ Underpost.deploy.removeInactiveHostRoutes({
2352
+ hosts: Object.keys(confServer),
2353
+ gatewayApi: options.gatewayApi,
2354
+ namespace,
2355
+ });
2356
+ Underpost.cluster.refreshUnderpostIngress({ namespace, options });
2357
+ }
2358
+
2359
+ if (desiredTraffic !== bootstrapTraffic)
2360
+ Underpost.deploy.applyTrafficService({
2361
+ deployId,
2362
+ env,
2363
+ traffic: desiredTraffic,
2364
+ namespace,
2365
+ manifestPath: trafficServicePath,
2366
+ });
2367
+ if (
2368
+ !options.disableUpdateDeployment &&
2369
+ !Underpost.deploy.awaitServiceEndpoints({
2370
+ service: Underpost.deploy.trafficServiceNameFactory({ deployId, env }),
2371
+ namespace,
2372
+ })
2373
+ ) {
2374
+ if (desiredTraffic !== bootstrapTraffic)
2375
+ Underpost.deploy.applyTrafficService({
2376
+ deployId,
2377
+ env,
2378
+ traffic: bootstrapTraffic,
2379
+ namespace,
2380
+ manifestPath: trafficServicePath,
2381
+ });
2382
+ throw new Error(`Traffic Service for ${deployId}-${env} never became ready on ${desiredTraffic}`);
2383
+ }
2384
+ }
1020
2385
 
1021
- if (
1022
- Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }) &&
1023
- !options.selfSigned
1024
- ) {
2386
+ if (Underpost.deploy.isCertManagerContext({ host: Object.keys(confServer)[0], env, options })) {
1025
2387
  const secretPath = `./${manifestsPath}/secret.yaml`;
1026
2388
  if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
1027
2389
  shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
@@ -1053,6 +2415,11 @@ EOF`);
1053
2415
  },
1054
2416
  /**
1055
2417
  * Switches the traffic for a deployment.
2418
+ *
2419
+ * Routing only: the workload is the caller's to deploy and make Ready, and
2420
+ * this must never rebuild it. The colour being switched to is, by definition,
2421
+ * the one about to receive every request, so tearing it down here would make
2422
+ * the flip the outage it exists to avoid.
1056
2423
  * @param {string} deployId - Deployment ID for which the traffic is being switched.
1057
2424
  * @param {string} env - Environment for which the traffic is being switched.
1058
2425
  * @param {string} targetTraffic - Target traffic status for the deployment.
@@ -1080,14 +2447,107 @@ EOF`);
1080
2447
  imagePullPolicy: '',
1081
2448
  },
1082
2449
  ) {
2450
+ options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
1083
2451
  const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
1084
2452
  const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
2453
+ const gatewayApiFlags = Underpost.deploy.gatewayApiFlagsFactory(options);
1085
2454
 
2455
+ // Readiness is a promotion precondition, not advisory. All callers use
2456
+ // this same gate, including monitor/failover paths, so no code path can
2457
+ // publish an endpointless target and turn a healthy opposite colour into
2458
+ // a 500/maintenance response.
2459
+ if (
2460
+ !Underpost.deploy.awaitDeploymentReady({
2461
+ deployment: `${deployId}-${env}-${targetTraffic}`,
2462
+ namespace,
2463
+ }) ||
2464
+ !Underpost.deploy.awaitServiceEndpoints({ service: `${deployId}-${env}-${targetTraffic}-service`, namespace })
2465
+ )
2466
+ throw new Error(`Refusing to switch ${deployId}-${env} to unready colour ${targetTraffic}`);
2467
+ const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, {
2468
+ namespace,
2469
+ env,
2470
+ gatewayApi: options.gatewayApi,
2471
+ });
2472
+ const currentReady =
2473
+ !!currentTraffic &&
2474
+ Underpost.deploy.serviceHasReadyEndpoints({
2475
+ service: `${deployId}-${env}-${currentTraffic}-service`,
2476
+ namespace,
2477
+ });
2478
+ const bootstrapTraffic = currentReady ? currentTraffic : targetTraffic;
2479
+
2480
+ // Regenerates the manifests against the target colour only: `--build-manifest`
2481
+ // returns before any cluster mutation, so the workload is untouched and the
2482
+ // applies below are the whole switch.
1086
2483
  shellExec(
1087
- `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag} ${deployId} ${env}`,
2484
+ `node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace}${timeoutFlags}${imagePullPolicyFlag}${gatewayApiFlags} ${deployId} ${env}`,
1088
2485
  );
1089
2486
 
1090
- shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
2487
+ const buildPath = `./engine-private/conf/${deployId}/build/${env}`;
2488
+ const trafficServicePath = `${buildPath}/traffic-service.yaml`;
2489
+ // On the first stable-Service migration, keep serving the current colour
2490
+ // while the Nginx block and HTTPRoute/HTTPProxy are replaced. Once every
2491
+ // layer references this Service, one selector update below is the switch.
2492
+ Underpost.deploy.applyTrafficService({
2493
+ deployId,
2494
+ env,
2495
+ traffic: bootstrapTraffic,
2496
+ namespace,
2497
+ manifestPath: trafficServicePath,
2498
+ });
2499
+ // A traffic switch rebuilds the underpost-gateway host blocks together
2500
+ // with the HTTPRoutes. Install and validate those blocks before publishing
2501
+ // a route that sends an intercepted site path to the shared gateway. The
2502
+ // regular deploy apply path already does this; omitting it here left Nginx
2503
+ // on its default server (or a previous environment/colour), so every such
2504
+ // request became the shared 404 page even though Envoy reported the route
2505
+ // Accepted and relayed it successfully.
2506
+ if (options.gatewayApi)
2507
+ installGatewayConf({
2508
+ hostRoot: Underpost.deploy.underpostGatewayRootFactory(options),
2509
+ confSourceDir: Underpost.deploy.gatewayConfDirFactory({ deployId, env }),
2510
+ namespace,
2511
+ });
2512
+ for (const file of options.gatewayApi ? ['gateway.yaml', 'httproute.yaml'] : ['proxy.yaml'])
2513
+ if (fs.existsSync(`${buildPath}/${file}`) && fs.readFileSync(`${buildPath}/${file}`, 'utf8').trim())
2514
+ shellExec(`sudo kubectl apply -f ${buildPath}/${file} -n ${namespace}`);
2515
+
2516
+ // The shared front derives its Host/SNI table from the live route objects.
2517
+ // Refresh after applying them so a hostname migrating between HTTPProxy and
2518
+ // HTTPRoute reaches the stack that now owns it. This is also a no-op when
2519
+ // underpost-ingress is not installed.
2520
+ const sharedIngressUpdated = Underpost.cluster.refreshUnderpostIngress({ namespace, options });
2521
+ if (sharedIngressUpdated) {
2522
+ const switchHosts = Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`));
2523
+ Underpost.deploy.removeInactiveHostRoutes({
2524
+ hosts: switchHosts,
2525
+ gatewayApi: options.gatewayApi,
2526
+ namespace,
2527
+ });
2528
+ Underpost.cluster.refreshUnderpostIngress({ namespace, options });
2529
+ }
2530
+
2531
+ if (targetTraffic !== bootstrapTraffic)
2532
+ Underpost.deploy.applyTrafficService({
2533
+ deployId,
2534
+ env,
2535
+ traffic: targetTraffic,
2536
+ namespace,
2537
+ manifestPath: trafficServicePath,
2538
+ });
2539
+ const trafficService = Underpost.deploy.trafficServiceNameFactory({ deployId, env });
2540
+ if (!Underpost.deploy.awaitServiceEndpoints({ service: trafficService, namespace })) {
2541
+ if (targetTraffic !== bootstrapTraffic)
2542
+ Underpost.deploy.applyTrafficService({
2543
+ deployId,
2544
+ env,
2545
+ traffic: bootstrapTraffic,
2546
+ namespace,
2547
+ manifestPath: trafficServicePath,
2548
+ });
2549
+ throw new Error(`Traffic Service ${trafficService} never became ready on ${targetTraffic}`);
2550
+ }
1091
2551
 
1092
2552
  const grpcServicePath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
1093
2553
  if (fs.existsSync(grpcServicePath)) shellExec(`kubectl apply -f ${grpcServicePath} -n ${namespace}`);
@@ -1133,6 +2593,22 @@ EOF`);
1133
2593
  return process.env.UNDERPOST_DEPLOY_NODE || os.hostname();
1134
2594
  },
1135
2595
 
2596
+ /**
2597
+ * Checks a node name against the cluster and substitutes a real one when it
2598
+ * does not exist.
2599
+ *
2600
+ * {@link UnderpostDeploy.resolveDeployNode} guesses from the environment when
2601
+ * no cluster flag is given, so `--dev` against a kubeadm cluster yields
2602
+ * `kind-worker`. For anything pinned by `nodeSelector` that guess does not
2603
+ * degrade — it simply never schedules.
2604
+ * @param {string} [node] - The chosen node name.
2605
+ * @returns {{node: string, corrected: boolean}} The name to use, and whether it had to change.
2606
+ * @memberof UnderpostDeploy
2607
+ */
2608
+ resolveSchedulableNode({ node = '' } = {}) {
2609
+ return schedulableNodeFactory({ nodes: Underpost.kubectl.get('', 'nodes'), node });
2610
+ },
2611
+
1136
2612
  /**
1137
2613
  * Deploys a volume for a deployment.
1138
2614
  * @param {object} volume - Volume configuration.
@@ -1171,7 +2647,7 @@ EOF`);
1171
2647
  const clusterContext = options.clusterContext || 'kind';
1172
2648
  const pvcId = `${volume.claimName}-${deployId}-${env}-${version}`;
1173
2649
  const pvId = `${volume.claimName.replace('pvc-', 'pv-')}-${deployId}-${env}-${version}`;
1174
- const rootVolumeHostPath = `/home/dd/engine/volume/${pvId}`;
2650
+ const rootVolumeHostPath = `${HOST_VOLUME_ROOT}/${pvId}`;
1175
2651
  if (options.gitClean && volume.volumeMountPath) {
1176
2652
  Underpost.repo.clean({ paths: [volume.volumeMountPath] });
1177
2653
  }
@@ -1195,6 +2671,7 @@ EOF`);
1195
2671
  // Target node is the control plane / current host: write directly.
1196
2672
  if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
1197
2673
  fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
2674
+ restoreContainerContext(rootVolumeHostPath);
1198
2675
  } else {
1199
2676
  // Target node is remote: fs.copySync would only write the control-plane
1200
2677
  // filesystem, leaving the real node's hostPath empty. Ship the folder to
@@ -1220,7 +2697,7 @@ EOF`);
1220
2697
  }
1221
2698
  shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
1222
2699
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
1223
- shellExec(`kubectl apply -f - -n ${namespace} <<EOF
2700
+ shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
1224
2701
  ${Underpost.deploy.persistentVolumeFactory({
1225
2702
  hostPath: rootVolumeHostPath,
1226
2703
  pvcId,
@@ -1376,7 +2853,9 @@ spec:
1376
2853
  },
1377
2854
 
1378
2855
  /**
1379
- * Checks if a TLS context is valid.
2856
+ * Checks if a TLS context is valid — i.e. whether the host is served over
2857
+ * HTTPS at all, by either issuer. Drives the TLS block in the generated
2858
+ * HTTPProxy virtualhost and Gateway listener.
1380
2859
  * @param {object} options - Options for the check.
1381
2860
  * @param {string} options.host - Host for which the TLS context is being checked.
1382
2861
  * @param {string} options.env - Environment for which the TLS context is being checked.
@@ -1390,6 +2869,22 @@ spec:
1390
2869
  (!options.certHosts || options.certHosts.split(',').includes(host))) ||
1391
2870
  options.selfSigned === true,
1392
2871
 
2872
+ /**
2873
+ * Checks whether cert-manager is the issuer for a host, as opposed to a
2874
+ * pre-created self-signed secret. Only this predicate may gate operations on
2875
+ * cert-manager's own objects: its CRDs are absent wherever it is not
2876
+ * installed (development, notably), and `kubectl --ignore-not-found`
2877
+ * tolerates a missing object but not a missing resource type.
2878
+ * @param {object} options - Options for the check.
2879
+ * @param {string} options.host - Host being checked.
2880
+ * @param {string} options.env - Environment being checked.
2881
+ * @param {object} options.options - Deploy options.
2882
+ * @returns {boolean} - True when cert-manager issues this host's certificate.
2883
+ * @memberof UnderpostDeploy
2884
+ */
2885
+ isCertManagerContext: ({ host, env, options }) =>
2886
+ options.selfSigned !== true && Underpost.deploy.isValidTLSContext({ host, env, options }),
2887
+
1393
2888
  /**
1394
2889
  * Predefined resource templates for Kubernetes deployments.
1395
2890
  * @memberof UnderpostDeploy
@@ -1503,7 +2998,7 @@ spec:
1503
2998
  * - `imagePullPolicy` — the extracted value, or `undefined` if absent.
1504
2999
  *
1505
3000
  * @param {object|undefined} lifecycle - Env-resolved lifecycle block
1506
- * (already passed through pickEnv). May be `undefined`.
3001
+ * (already passed through {@link ServerConfBuilder.resolveEnvScoped}). May be `undefined`.
1507
3002
  * @returns {{ lifecycle: (object|undefined), imagePullPolicy: (string|undefined) }}
1508
3003
  * @memberof UnderpostDeploy
1509
3004
  */
@@ -1536,6 +3031,32 @@ spec:
1536
3031
  `${options.retryPerTryTimeout ? ` --retry-per-try-timeout ${options.retryPerTryTimeout}` : ''}`
1537
3032
  );
1538
3033
  },
3034
+
3035
+ /**
3036
+ * Generates the Gateway API / QUIC flag string for spawned deploy commands,
3037
+ * so a routing choice made once at the top of a workflow reaches every
3038
+ * child process instead of silently reverting to the HTTPProxy default.
3039
+ * @param {object} options - Options containing the gateway settings.
3040
+ * @param {boolean} [options.gatewayApi] - Apply the Gateway API stack.
3041
+ * @param {boolean} [options.disableGatewayApi] - Apply the legacy Contour HTTPProxy stack.
3042
+ * @param {string} [options.gatewayClass] - GatewayClass name.
3043
+ * @param {boolean} [options.disableHttp3] - Disable QUIC/HTTP3.
3044
+ * @param {string|number} [options.quicPort] - Advertised QUIC port.
3045
+ * @returns {string} The gateway flags string.
3046
+ * @memberof UnderpostDeploy
3047
+ */
3048
+ gatewayApiFlagsFactory: (options = {}) => {
3049
+ return (
3050
+ `${options.gatewayApi ? ' --gateway-api' : ''}` +
3051
+ // The legacy selection has to travel too. Gateway API is the default, so
3052
+ // a child that never receives this flag reverts to it and reads or writes
3053
+ // a different routing kind than the workflow that spawned it.
3054
+ `${options.disableGatewayApi ? ' --disable-gateway-api' : ''}` +
3055
+ `${options.gatewayClass ? ` --gateway-class ${options.gatewayClass}` : ''}` +
3056
+ `${options.disableHttp3 ? ' --disable-http3' : ''}` +
3057
+ `${options.quicPort ? ` --quic-port ${options.quicPort}` : ''}`
3058
+ );
3059
+ },
1539
3060
  };
1540
3061
  }
1541
3062