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.
- package/CHANGELOG.md +182 -1
- package/CLI-HELP.md +37 -16
- package/README.md +2 -2
- package/bin/deploy.js +18 -16
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +17 -17
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/cluster.js +740 -55
- package/src/cli/db.js +2 -2
- package/src/cli/deploy.js +1679 -174
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/image.js +15 -6
- package/src/cli/index.js +124 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2161 -420
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +8 -28
- package/src/client-builder/client-build.js +94 -11
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +47 -32
- package/src/index.js +1 -1
- package/src/server/conf.js +1208 -70
- package/src/server/cri.js +70 -0
- package/src/server/underpost-gateway.js +1073 -0
- package/src/server/underpost-ingress.js +364 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +469 -0
- 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
|
-
|
|
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] -
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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] -
|
|
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
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|