underpost 3.2.80 → 3.2.90

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