underpost 3.2.70 → 3.2.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.ci.yml +3 -3
- package/.github/workflows/release.cd.yml +1 -1
- package/CHANGELOG.md +1358 -1038
- package/CLI-HELP.md +39 -16
- package/README.md +3 -3
- package/bin/build.js +10 -4
- 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 +20 -20
- package/scripts/nat-iptables.sh +10 -4
- package/scripts/test-monitor.sh +4 -3
- package/src/api/core/core.controller.js +4 -65
- package/src/api/core/core.router.js +8 -14
- package/src/api/default/default.controller.js +2 -70
- package/src/api/default/default.router.js +7 -17
- package/src/api/document/document.controller.js +5 -77
- package/src/api/document/document.router.js +9 -13
- package/src/api/file/file.controller.js +9 -53
- package/src/api/file/file.router.js +14 -6
- package/src/api/test/test.controller.js +8 -53
- package/src/api/test/test.router.js +1 -4
- package/src/cli/cluster.js +771 -66
- package/src/cli/db.js +6 -4
- package/src/cli/deploy.js +1715 -168
- package/src/cli/docker-compose.js +19 -24
- package/src/cli/fs.js +0 -1
- package/src/cli/image.js +40 -13
- package/src/cli/index.js +129 -35
- package/src/cli/ipfs.js +82 -11
- package/src/cli/monitor.js +1 -1
- package/src/cli/release.js +4 -0
- package/src/cli/repository.js +14 -3
- package/src/cli/run.js +2253 -439
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +38 -39
- package/src/client/components/core/Modal.js +38 -4
- 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 +1307 -6
- package/src/server/cri.js +70 -0
- package/src/server/downloader.js +3 -3
- package/src/server/middlewares.js +152 -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,22 +8,148 @@ 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,
|
|
19
|
+
loadConfInstances,
|
|
15
20
|
loadConfServerJson,
|
|
16
21
|
loadReplicas,
|
|
22
|
+
nextTrafficFactory,
|
|
17
23
|
pathPortAssignmentFactory,
|
|
24
|
+
schedulableNodeFactory,
|
|
25
|
+
trafficFromRoutingInfoFactory,
|
|
18
26
|
} from '../server/conf.js';
|
|
19
27
|
import { loggerFactory } from '../server/logger.js';
|
|
20
28
|
import { shellExec } from '../server/process.js';
|
|
21
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';
|
|
22
44
|
import fs from 'fs-extra';
|
|
45
|
+
import nodePath from 'node:path';
|
|
23
46
|
import dotenv from 'dotenv';
|
|
24
47
|
import os from 'node:os';
|
|
48
|
+
import crypto from 'node:crypto';
|
|
25
49
|
import Underpost from '../index.js';
|
|
26
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Clamps an identifier to the Kubernetes DNS-1123 label limit (63 chars),
|
|
53
|
+
* used for pod-local `volumes[].name` / `volumeMounts[].name`. Names within the
|
|
54
|
+
* limit are returned verbatim so existing short names are stable; longer ones
|
|
55
|
+
* are truncated and suffixed with an 8-char content hash to stay unique and
|
|
56
|
+
* deterministic (e.g. the per-variant instance volume names, which append the
|
|
57
|
+
* full `<deployId>-<env>-<traffic>` and can exceed 63).
|
|
58
|
+
* @param {string} name - Candidate name.
|
|
59
|
+
* @returns {string} A name no longer than 63 characters.
|
|
60
|
+
*/
|
|
61
|
+
const k8sVolumeName = (name) => {
|
|
62
|
+
if (typeof name !== 'string' || name.length <= 63) return name;
|
|
63
|
+
const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
|
|
64
|
+
return `${name.slice(0, 54)}-${hash}`;
|
|
65
|
+
};
|
|
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
|
+
|
|
27
153
|
const logger = loggerFactory(import.meta);
|
|
28
154
|
|
|
29
155
|
/**
|
|
@@ -50,6 +176,80 @@ class UnderpostDeploy {
|
|
|
50
176
|
await Config.build('proxy', deployList);
|
|
51
177
|
return buildPortProxyRouter({ port: env === 'development' ? 80 : 443, proxyRouter: buildProxyRouter() });
|
|
52
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
|
+
},
|
|
53
253
|
/**
|
|
54
254
|
* Creates a YAML service configuration for a deployment.
|
|
55
255
|
* @param {string} deployId - Deployment ID for which the service is being created.
|
|
@@ -104,7 +304,7 @@ class UnderpostDeploy {
|
|
|
104
304
|
}
|
|
105
305
|
enableWebsockets: true
|
|
106
306
|
services:
|
|
107
|
-
${deploymentVersions
|
|
307
|
+
${(serviceId ? [null] : deploymentVersions)
|
|
108
308
|
.map(
|
|
109
309
|
(version, i) =>
|
|
110
310
|
` - name: ${serviceId ? serviceId : `${deployId}-${env}-${version}-service`}
|
|
@@ -172,6 +372,19 @@ class UnderpostDeploy {
|
|
|
172
372
|
};
|
|
173
373
|
return probes;
|
|
174
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
|
+
},
|
|
175
388
|
/**
|
|
176
389
|
* Creates a YAML deployment configuration for a deployment.
|
|
177
390
|
* @param {string} deployId - Deployment ID for which the deployment is being created.
|
|
@@ -191,6 +404,7 @@ class UnderpostDeploy {
|
|
|
191
404
|
* @param {object} livenessProbe - Kubernetes liveness probe configuration for the deployment container.
|
|
192
405
|
* @param {object} startupProbe - Kubernetes startup probe configuration for the deployment container.
|
|
193
406
|
* @param {number} containerPort - Container port to expose for the deployment.
|
|
407
|
+
* @param {string} [nodeName] - Kubernetes node hostname that the workload must run on.
|
|
194
408
|
* @returns {string} - YAML deployment configuration for the specified deployment.
|
|
195
409
|
* @memberof UnderpostDeploy
|
|
196
410
|
*/
|
|
@@ -218,11 +432,13 @@ class UnderpostDeploy {
|
|
|
218
432
|
livenessProbe,
|
|
219
433
|
startupProbe,
|
|
220
434
|
containerPort,
|
|
435
|
+
nodeName,
|
|
221
436
|
// Explicit, secret-free internal status port injected as an env var so the
|
|
222
437
|
// in-pod endpoint binds exactly what the probes and the monitor target,
|
|
223
438
|
// independent of the ambient `PORT` baked into the image/secret.
|
|
224
439
|
internalStatusPort,
|
|
225
440
|
}) {
|
|
441
|
+
if (!readinessProbe) throw new Error(`Refusing to build ${deployId}-${env}-${suffix} without a readiness probe`);
|
|
226
442
|
if (!cmd)
|
|
227
443
|
cmd =
|
|
228
444
|
pullBundle || skipFullBuild
|
|
@@ -265,7 +481,13 @@ spec:
|
|
|
265
481
|
app: ${deployId}-${env}-${suffix}
|
|
266
482
|
deploy-id: ${deployId}-${env}
|
|
267
483
|
spec:
|
|
268
|
-
|
|
484
|
+
${
|
|
485
|
+
nodeName
|
|
486
|
+
? ` nodeSelector:
|
|
487
|
+
kubernetes.io/hostname: ${nodeName}
|
|
488
|
+
`
|
|
489
|
+
: ''
|
|
490
|
+
} containers:
|
|
269
491
|
- name: ${deployId}-${env}-${suffix}
|
|
270
492
|
image: ${containerImage}
|
|
271
493
|
imagePullPolicy: ${imagePullPolicy ? imagePullPolicy : containerImage.startsWith('localhost/') ? 'Never' : 'IfNotPresent'}
|
|
@@ -375,13 +597,16 @@ spec:
|
|
|
375
597
|
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
|
|
376
598
|
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
|
|
377
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.
|
|
378
603
|
* @param {string} [options.traffic] - Comma-separated active traffic colour(s) used to select which versions receive traffic (e.g. "blue", "green").
|
|
379
604
|
* @param {boolean} [options.cert] - Whether to include cert-manager Certificate resources in secret.yaml (production only).
|
|
380
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.
|
|
381
606
|
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory.
|
|
382
607
|
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
|
|
383
608
|
* @param {string} [options.imagePullPolicy] - Container imagePullPolicy override (`Always`, `IfNotPresent`, `Never`); forwarded to deploymentYamlPartsFactory. Defaults to `Never` for `localhost/` images and `IfNotPresent` otherwise.
|
|
384
|
-
* @param {boolean} [options.disableRuntimeProbes] -
|
|
609
|
+
* @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
|
|
385
610
|
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes (migration path).
|
|
386
611
|
* @param {string} [options.node] - Explicit target node for hostPath PV nodeAffinity pinning; resolved through {@link UnderpostDeploy.resolveDeployNode} together with the cluster flags.
|
|
387
612
|
* @param {boolean} [options.kind] - Kind cluster context; affects the cluster-type node default when no explicit node is set.
|
|
@@ -416,11 +641,11 @@ spec:
|
|
|
416
641
|
// inside the pod. It is injected into the pod env (UNDERPOST_INTERNAL_PORT)
|
|
417
642
|
// and used for both the probes and the monitor's port-forward target so
|
|
418
643
|
// all three agree regardless of the image's ambient PORT.
|
|
419
|
-
//
|
|
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.
|
|
420
647
|
const internalPort = fromPort - 1;
|
|
421
|
-
const probes = options.
|
|
422
|
-
? {}
|
|
423
|
-
: Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
|
|
648
|
+
const probes = Underpost.deploy.runtimeProbesFactory({ port: internalPort, useHttp: !options.tcpProbes });
|
|
424
649
|
|
|
425
650
|
let deploymentYamlParts = '';
|
|
426
651
|
for (const deploymentVersion of deploymentVersions) {
|
|
@@ -437,7 +662,19 @@ ${Underpost.deploy
|
|
|
437
662
|
skipFullBuild: options.skipFullBuild,
|
|
438
663
|
pullBundle: options.pullBundle,
|
|
439
664
|
imagePullPolicy: options.imagePullPolicy,
|
|
440
|
-
|
|
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,
|
|
441
678
|
readinessProbe: probes.readinessProbe,
|
|
442
679
|
livenessProbe: probes.livenessProbe,
|
|
443
680
|
startupProbe: probes.startupProbe,
|
|
@@ -446,6 +683,19 @@ ${Underpost.deploy
|
|
|
446
683
|
`;
|
|
447
684
|
}
|
|
448
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
|
+
);
|
|
449
699
|
|
|
450
700
|
Underpost.deploy.buildGrpcServiceManifest({
|
|
451
701
|
deployId,
|
|
@@ -488,9 +738,34 @@ ${Underpost.deploy
|
|
|
488
738
|
|
|
489
739
|
let proxyYaml = '';
|
|
490
740
|
let secretYaml = '';
|
|
741
|
+
let gatewayYaml = '';
|
|
742
|
+
let httpRouteYaml = '';
|
|
491
743
|
const customServices = fs.existsSync(`./engine-private/conf/${deployId}/conf.services.json`)
|
|
492
744
|
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.services.json`))
|
|
493
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 = [];
|
|
494
769
|
|
|
495
770
|
for (const host of Object.keys(confServer)) {
|
|
496
771
|
if (env === 'production' && options.cert === true)
|
|
@@ -499,8 +774,11 @@ ${Underpost.deploy
|
|
|
499
774
|
const pathPortAssignment = pathPortAssignmentData[host];
|
|
500
775
|
// logger.info('', { host, pathPortAssignment });
|
|
501
776
|
let _proxyYaml = Underpost.deploy.baseProxyYamlFactory({ host, env, options });
|
|
502
|
-
|
|
503
|
-
|
|
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(',');
|
|
504
782
|
let proxyRoutes = '';
|
|
505
783
|
const globalTimeoutPolicy =
|
|
506
784
|
(options.timeoutResponse && options.timeoutResponse !== '') ||
|
|
@@ -519,18 +797,87 @@ ${Underpost.deploy
|
|
|
519
797
|
perTryTimeout: options.retryPerTryTimeout,
|
|
520
798
|
}
|
|
521
799
|
: undefined;
|
|
800
|
+
let routeRules = '';
|
|
522
801
|
if (!options.disableDeploymentProxy)
|
|
523
802
|
for (const conditionObj of pathPortAssignment) {
|
|
524
803
|
const { path, port } = conditionObj;
|
|
525
804
|
proxyRoutes += Underpost.deploy.deploymentYamlServiceFactory({
|
|
526
805
|
path,
|
|
527
|
-
deployId,
|
|
528
|
-
env,
|
|
529
806
|
port,
|
|
530
|
-
|
|
807
|
+
serviceId: trafficServiceName,
|
|
531
808
|
timeoutPolicy: globalTimeoutPolicy,
|
|
532
809
|
retryPolicy: globalRetryPolicy,
|
|
533
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
|
+
);
|
|
534
881
|
}
|
|
535
882
|
for (const customService of customServices) {
|
|
536
883
|
const {
|
|
@@ -552,24 +899,105 @@ ${Underpost.deploy
|
|
|
552
899
|
timeoutPolicy: _timeoutPolicy ? _timeoutPolicy : globalTimeoutPolicy,
|
|
553
900
|
retryPolicy: _retryPolicy ? _retryPolicy : globalRetryPolicy,
|
|
554
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
|
+
});
|
|
555
912
|
}
|
|
556
913
|
}
|
|
557
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
|
+
});
|
|
558
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
|
+
});
|
|
559
964
|
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/proxy.yaml`;
|
|
560
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
|
+
});
|
|
561
980
|
if (env === 'production') {
|
|
562
981
|
const yamlPath = `./engine-private/conf/${deployId}/build/${env}/secret.yaml`;
|
|
563
982
|
fs.writeFileSync(yamlPath, secretYaml, 'utf8');
|
|
564
983
|
} else {
|
|
565
|
-
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
|
+
];
|
|
566
994
|
for (const file of deploymentsFiles) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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);
|
|
573
1001
|
}
|
|
574
1002
|
}
|
|
575
1003
|
}
|
|
@@ -665,25 +1093,105 @@ spec:
|
|
|
665
1093
|
* @param {object} options - Options for the traffic retrieval.
|
|
666
1094
|
* @param {string} options.hostTest - Hostname to test for traffic status.
|
|
667
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.
|
|
668
1099
|
* @returns {string|null} - Current traffic status ('blue' or 'green') or null if not found.
|
|
669
1100
|
* @memberof UnderpostDeploy
|
|
670
1101
|
*/
|
|
671
|
-
getCurrentTraffic(deployId, options = { hostTest: '', namespace: '' }) {
|
|
1102
|
+
getCurrentTraffic(deployId, options = { hostTest: '', namespace: '', env: '' }) {
|
|
672
1103
|
if (!options.namespace) options.namespace = 'default';
|
|
673
|
-
//
|
|
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
|
+
}
|
|
674
1120
|
const hostTest = options?.hostTest
|
|
675
1121
|
? options.hostTest
|
|
676
1122
|
: Object.keys(loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`))[0];
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
silent: true,
|
|
682
|
-
stdout: true,
|
|
683
|
-
silentOnError: true,
|
|
1123
|
+
return trafficFromRoutingInfoFactory({
|
|
1124
|
+
info: Underpost.deploy.readHostRoutingInfo({ host: hostTest, options }),
|
|
1125
|
+
deployId,
|
|
1126
|
+
env: options.env,
|
|
684
1127
|
});
|
|
685
|
-
|
|
686
|
-
|
|
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
|
+
});
|
|
1186
|
+
}
|
|
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}`;
|
|
687
1195
|
},
|
|
688
1196
|
|
|
689
1197
|
/**
|
|
@@ -716,6 +1224,834 @@ spec:
|
|
|
716
1224
|
routes:`;
|
|
717
1225
|
},
|
|
718
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
|
+
|
|
719
2055
|
/**
|
|
720
2056
|
* Callback function for handling deployment options.
|
|
721
2057
|
* @param {string} deployList - List of deployment IDs to process.
|
|
@@ -726,7 +2062,6 @@ spec:
|
|
|
726
2062
|
* @param {boolean} options.sync - Whether to synchronize deployment configurations.
|
|
727
2063
|
* @param {boolean} options.buildManifest - Whether to build the deployment manifest.
|
|
728
2064
|
* @param {boolean} options.infoUtil - Whether to display utility information.
|
|
729
|
-
* @param {boolean} options.expose - Whether to expose the deployment.
|
|
730
2065
|
* @param {boolean} options.cert - Whether to create cert-manager Certificate resources for the deployment.
|
|
731
2066
|
* @param {string} options.certHosts - Comma-separated list of hosts for which to create cert-manager certificates.
|
|
732
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.
|
|
@@ -737,23 +2072,20 @@ spec:
|
|
|
737
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.
|
|
738
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.
|
|
739
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.
|
|
740
2079
|
* @param {boolean} options.disableUpdateProxy - Whether to disable proxy updates.
|
|
741
2080
|
* @param {boolean} options.disableDeploymentProxy - Whether to disable deployment proxy.
|
|
742
2081
|
* @param {boolean} options.disableUpdateVolume - Whether to disable volume updates.
|
|
743
|
-
* @param {boolean} options.status - Whether to display deployment status.
|
|
744
2082
|
* @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
|
|
745
2083
|
* @param {string} [options.namespace] - Kubernetes namespace for the deployment (defaults to "default").
|
|
746
2084
|
* @param {string} [options.timeoutResponse] - HTTPProxy per-route response timeout (e.g. "300000ms", "infinity").
|
|
747
2085
|
* @param {string} [options.timeoutIdle] - HTTPProxy per-route idle timeout (e.g. "10s", "infinity").
|
|
748
2086
|
* @param {string} [options.retryCount] - HTTPProxy per-route retry count (e.g. 3).
|
|
749
2087
|
* @param {string} [options.retryPerTryTimeout] - HTTPProxy per-route per-try timeout (e.g. "150ms").
|
|
750
|
-
* @param {string} [options.kindType] - Kubernetes resource kind to target when using --expose (defaults to "svc").
|
|
751
|
-
* @param {number} [options.port] - Port number override for exposing the deployment.
|
|
752
2088
|
* @param {string} [options.cmd] - Custom initialization command (comma-separated) for deploymentYamlPartsFactory.
|
|
753
|
-
* @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.
|
|
754
|
-
* @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.
|
|
755
|
-
* @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 /).
|
|
756
|
-
* @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.
|
|
757
2089
|
* @param {boolean} [options.k3s] - Whether to use k3s cluster context.
|
|
758
2090
|
* @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
|
|
759
2091
|
* @param {boolean} [options.kind] - Whether to use kind cluster context.
|
|
@@ -761,7 +2093,7 @@ spec:
|
|
|
761
2093
|
* @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
|
|
762
2094
|
* @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
|
|
763
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.
|
|
764
|
-
* @param {boolean} [options.disableRuntimeProbes] -
|
|
2096
|
+
* @param {boolean} [options.disableRuntimeProbes] - Deprecated compatibility flag; readiness remains mandatory.
|
|
765
2097
|
* @param {boolean} [options.tcpProbes] - Emit legacy TCP socket probes instead of HTTP internal-status probes.
|
|
766
2098
|
* @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
|
|
767
2099
|
* @memberof UnderpostDeploy
|
|
@@ -775,7 +2107,6 @@ spec:
|
|
|
775
2107
|
sync: false,
|
|
776
2108
|
buildManifest: false,
|
|
777
2109
|
infoUtil: false,
|
|
778
|
-
expose: false,
|
|
779
2110
|
cert: false,
|
|
780
2111
|
certHosts: '',
|
|
781
2112
|
versions: '',
|
|
@@ -787,19 +2118,12 @@ spec:
|
|
|
787
2118
|
disableUpdateProxy: false,
|
|
788
2119
|
disableDeploymentProxy: false,
|
|
789
2120
|
disableUpdateVolume: false,
|
|
790
|
-
status: false,
|
|
791
2121
|
disableUpdateUnderpostConfig: false,
|
|
792
2122
|
namespace: '',
|
|
793
2123
|
timeoutResponse: '',
|
|
794
2124
|
timeoutIdle: '',
|
|
795
2125
|
retryCount: '',
|
|
796
2126
|
retryPerTryTimeout: '',
|
|
797
|
-
kindType: '',
|
|
798
|
-
port: 0,
|
|
799
|
-
exposePort: 0,
|
|
800
|
-
exposeLocalPort: 0,
|
|
801
|
-
localProxy: false,
|
|
802
|
-
tls: false,
|
|
803
2127
|
selfSigned: false,
|
|
804
2128
|
cmd: '',
|
|
805
2129
|
k3s: false,
|
|
@@ -809,67 +2133,60 @@ spec:
|
|
|
809
2133
|
imagePullPolicy: '',
|
|
810
2134
|
},
|
|
811
2135
|
) {
|
|
2136
|
+
options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
|
|
812
2137
|
const namespace = options.namespace ? options.namespace : 'default';
|
|
813
2138
|
if (!deployList && options.certHosts) {
|
|
814
2139
|
for (const host of options.certHosts.split(',')) {
|
|
815
|
-
shellExec(`sudo kubectl apply -f - -n ${namespace} <<EOF
|
|
2140
|
+
shellExec(`sudo kubectl apply -f - -n ${namespace} <<'EOF'
|
|
816
2141
|
${Underpost.deploy.buildCertManagerCertificate({ host, namespace })}
|
|
817
2142
|
EOF`);
|
|
818
2143
|
}
|
|
819
2144
|
return;
|
|
820
|
-
} else if (!deployList
|
|
821
|
-
if (deployList === 'dd' && fs.existsSync(`./engine-private/deploy/dd.router`))
|
|
2145
|
+
} else if (!deployList || deployList === 'dd')
|
|
822
2146
|
deployList = fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8');
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
host: instance.host,
|
|
836
|
-
path: instance.path,
|
|
837
|
-
fromPort: instance.fromPort,
|
|
838
|
-
toPort: instance.toPort,
|
|
839
|
-
fromDebugPort: instance.fromDebugPort,
|
|
840
|
-
toDebugPort: instance.toDebugPort,
|
|
841
|
-
traffic: Underpost.deploy.getCurrentTraffic(_deployId, { namespace, hostTest: instance.host }),
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
logger.info('', {
|
|
846
|
-
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,
|
|
847
2159
|
env,
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
ipv4Public: await Underpost.dns.getPublicIp(),
|
|
859
|
-
ipv4Local: Underpost.dns.getLocalIPv4Address(),
|
|
860
|
-
resources: Underpost.cluster.getResourcesCapacity(options.node),
|
|
861
|
-
defaultInterfaceName: interfaceName,
|
|
862
|
-
defaultInterfaceInfo: os.networkInterfaces()[interfaceName],
|
|
863
|
-
});
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
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
|
+
);
|
|
867
2170
|
if (!options.replicas) options.replicas = 1;
|
|
868
2171
|
if (options.sync)
|
|
869
2172
|
await getDataDeploy({
|
|
870
2173
|
buildSingleReplica: true,
|
|
871
2174
|
});
|
|
872
|
-
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
|
+
}
|
|
873
2190
|
if (options.infoRouter === true || options.buildManifest === true) {
|
|
874
2191
|
logger.info('router', await Underpost.deploy.routerFactory(deployList, env));
|
|
875
2192
|
return;
|
|
@@ -879,67 +2196,14 @@ EOF`);
|
|
|
879
2196
|
for (const _deployId of deployList.split(',')) {
|
|
880
2197
|
const deployId = _deployId.trim();
|
|
881
2198
|
if (!deployId) continue;
|
|
882
|
-
|
|
883
|
-
const kindType = options.kindType ? options.kindType : 'svc';
|
|
884
|
-
const svc = Underpost.kubectl.get(deployId, kindType)[0];
|
|
885
|
-
if (!svc) {
|
|
886
|
-
logger.error(`No ${kindType} found matching '${deployId}', skipping expose`);
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
if (options.localProxy) {
|
|
890
|
-
const svcPorts = [
|
|
891
|
-
...new Set(
|
|
892
|
-
svc['PORT(S)']
|
|
893
|
-
.split(',')
|
|
894
|
-
.filter((p) => p.includes('/TCP'))
|
|
895
|
-
.map((p) => parseInt(p.split(':')[0])),
|
|
896
|
-
),
|
|
897
|
-
];
|
|
898
|
-
for (const svcPort of svcPorts) {
|
|
899
|
-
shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${svcPort}:${svcPort}`, {
|
|
900
|
-
async: true,
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
const envFile = `./engine-private/conf/${deployId}/.env.${env}`;
|
|
904
|
-
let basePort = svcPorts[0] - 1;
|
|
905
|
-
if (fs.existsSync(envFile)) {
|
|
906
|
-
const portMatch = fs.readFileSync(envFile, 'utf8').match(/^PORT=(\d+)/m);
|
|
907
|
-
if (portMatch) basePort = parseInt(portMatch[1]);
|
|
908
|
-
}
|
|
909
|
-
logger.info(deployId, { svc, svcPorts, basePort });
|
|
910
|
-
const tlsFlag = options.tls ? ' tls' : '';
|
|
911
|
-
shellExec(
|
|
912
|
-
`NODE_ENV=${env} PORT=${basePort} DEV_PROXY_PORT_OFFSET=0 node src/proxy proxy ${deployId} ${env}${tlsFlag}`,
|
|
913
|
-
{ async: true },
|
|
914
|
-
);
|
|
915
|
-
} else {
|
|
916
|
-
const remotePort = options.exposePort
|
|
917
|
-
? parseInt(options.exposePort)
|
|
918
|
-
: options.port
|
|
919
|
-
? parseInt(options.port)
|
|
920
|
-
: kindType !== 'svc'
|
|
921
|
-
? 80
|
|
922
|
-
: parseInt(svc[`PORT(S)`].split('/TCP')[0]);
|
|
923
|
-
const localPort = options.exposeLocalPort ? parseInt(options.exposeLocalPort) : remotePort;
|
|
924
|
-
logger.info(deployId, {
|
|
925
|
-
svc,
|
|
926
|
-
localPort,
|
|
927
|
-
remotePort,
|
|
928
|
-
});
|
|
929
|
-
shellExec(`sudo kubectl port-forward -n ${namespace} ${kindType}/${svc.NAME} ${localPort}:${remotePort}`, {
|
|
930
|
-
async: true,
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
continue;
|
|
934
|
-
}
|
|
935
|
-
|
|
2199
|
+
const deploymentVersions = versionsByDeployId[deployId].split(',').map((version) => version.trim());
|
|
936
2200
|
const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
|
|
937
2201
|
const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
|
|
938
2202
|
? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
|
|
939
2203
|
: [];
|
|
940
2204
|
|
|
941
2205
|
if (!options.disableUpdateDeployment)
|
|
942
|
-
for (const version of
|
|
2206
|
+
for (const version of deploymentVersions) {
|
|
943
2207
|
shellExec(
|
|
944
2208
|
`sudo kubectl delete svc ${deployId}-${env}-${version}-service -n ${namespace} --ignore-not-found`,
|
|
945
2209
|
);
|
|
@@ -960,7 +2224,7 @@ EOF`);
|
|
|
960
2224
|
k3s: options.k3s,
|
|
961
2225
|
env,
|
|
962
2226
|
}),
|
|
963
|
-
clusterContext: options
|
|
2227
|
+
clusterContext: clusterTypeFactory(options),
|
|
964
2228
|
gitClean: options.gitClean || false,
|
|
965
2229
|
sshKeyPath: options.sshKeyPath || '',
|
|
966
2230
|
});
|
|
@@ -968,8 +2232,29 @@ EOF`);
|
|
|
968
2232
|
|
|
969
2233
|
for (const host of Object.keys(confServer)) {
|
|
970
2234
|
if (!options.disableUpdateProxy) {
|
|
971
|
-
|
|
972
|
-
|
|
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 }))
|
|
973
2258
|
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
|
|
974
2259
|
}
|
|
975
2260
|
}
|
|
@@ -985,13 +2270,105 @@ EOF`);
|
|
|
985
2270
|
const grpcServicePath = `./${manifestsPath}/grpc-service.yaml`;
|
|
986
2271
|
if (fs.existsSync(grpcServicePath)) shellExec(`sudo kubectl apply -f ${grpcServicePath} -n ${namespace}`);
|
|
987
2272
|
}
|
|
988
|
-
|
|
989
|
-
|
|
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
|
+
}
|
|
990
2370
|
|
|
991
|
-
if (
|
|
992
|
-
Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }) &&
|
|
993
|
-
!options.selfSigned
|
|
994
|
-
) {
|
|
2371
|
+
if (Underpost.deploy.isCertManagerContext({ host: Object.keys(confServer)[0], env, options })) {
|
|
995
2372
|
const secretPath = `./${manifestsPath}/secret.yaml`;
|
|
996
2373
|
if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
|
|
997
2374
|
shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
|
|
@@ -1023,6 +2400,11 @@ EOF`);
|
|
|
1023
2400
|
},
|
|
1024
2401
|
/**
|
|
1025
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.
|
|
1026
2408
|
* @param {string} deployId - Deployment ID for which the traffic is being switched.
|
|
1027
2409
|
* @param {string} env - Environment for which the traffic is being switched.
|
|
1028
2410
|
* @param {string} targetTraffic - Target traffic status for the deployment.
|
|
@@ -1050,14 +2432,107 @@ EOF`);
|
|
|
1050
2432
|
imagePullPolicy: '',
|
|
1051
2433
|
},
|
|
1052
2434
|
) {
|
|
2435
|
+
options = { ...options, gatewayApi: gatewayApiEnabledFactory(options) };
|
|
1053
2436
|
const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
|
|
1054
2437
|
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
|
|
2438
|
+
const gatewayApiFlags = Underpost.deploy.gatewayApiFlagsFactory(options);
|
|
1055
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.
|
|
1056
2468
|
shellExec(
|
|
1057
|
-
`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}`,
|
|
1058
2470
|
);
|
|
1059
2471
|
|
|
1060
|
-
|
|
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
|
+
}
|
|
1061
2536
|
|
|
1062
2537
|
const grpcServicePath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
|
|
1063
2538
|
if (fs.existsSync(grpcServicePath)) shellExec(`kubectl apply -f ${grpcServicePath} -n ${namespace}`);
|
|
@@ -1075,7 +2550,13 @@ EOF`);
|
|
|
1075
2550
|
* runners derive it from the comma-path field or `--node-name`
|
|
1076
2551
|
* (`run sync`: `path.split(',')[4]` > `--node-name` > default) and from
|
|
1077
2552
|
* `--node-name` directly (`run instance`).
|
|
1078
|
-
* 2.
|
|
2553
|
+
* 2. **`UNDERPOST_DEPLOY_NODE` env** — for kubeadm / k3s, the configured
|
|
2554
|
+
* target node name. This makes hostPath PV `nodeAffinity` deterministic
|
|
2555
|
+
* regardless of where the manifest is *built*: building inside a
|
|
2556
|
+
* container or CI runner would otherwise leak that box's `os.hostname()`
|
|
2557
|
+
* (e.g. a random container id) into `nodeSelector`, pinning the PV to a
|
|
2558
|
+
* node that does not exist in the cluster.
|
|
2559
|
+
* 3. **Cluster-type default** — when nothing above is set: `kind-worker`
|
|
1079
2560
|
* for a kind cluster (the node that hosts kind hostPath volumes),
|
|
1080
2561
|
* otherwise the control-plane / current host (`os.hostname()`) for
|
|
1081
2562
|
* kubeadm / k3s. With no explicit cluster flag, `development` is treated
|
|
@@ -1093,7 +2574,24 @@ EOF`);
|
|
|
1093
2574
|
resolveDeployNode({ node = '', kind = false, kubeadm = false, k3s = false, env = '' } = {}) {
|
|
1094
2575
|
if (node) return node;
|
|
1095
2576
|
const isKind = kind || (!kubeadm && !k3s && env !== 'production');
|
|
1096
|
-
|
|
2577
|
+
if (isKind) return 'kind-worker';
|
|
2578
|
+
return process.env.UNDERPOST_DEPLOY_NODE || os.hostname();
|
|
2579
|
+
},
|
|
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 });
|
|
1097
2595
|
},
|
|
1098
2596
|
|
|
1099
2597
|
/**
|
|
@@ -1183,7 +2681,7 @@ EOF`);
|
|
|
1183
2681
|
}
|
|
1184
2682
|
shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
|
|
1185
2683
|
shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
|
|
1186
|
-
shellExec(`kubectl apply -f - -n ${namespace} <<EOF
|
|
2684
|
+
shellExec(`kubectl apply -f - -n ${namespace} <<'EOF'
|
|
1187
2685
|
${Underpost.deploy.persistentVolumeFactory({
|
|
1188
2686
|
hostPath: rootVolumeHostPath,
|
|
1189
2687
|
pvcId,
|
|
@@ -1241,13 +2739,18 @@ EOF
|
|
|
1241
2739
|
volumeName = `${volumeName}-${version}`;
|
|
1242
2740
|
claimName = claimName ? `${claimName}-${version}` : null;
|
|
1243
2741
|
}
|
|
2742
|
+
// The pod-local volume name is a DNS-1123 label (max 63 chars); the PVC
|
|
2743
|
+
// `claimName` it references is a subdomain (max 253) and stays verbatim.
|
|
2744
|
+
// Per-variant instance names append <deployId>-<env>-<traffic> and can
|
|
2745
|
+
// exceed 63, so clamp only the pod-local name (mount name must match it).
|
|
2746
|
+
const podVolumeName = k8sVolumeName(volumeName);
|
|
1244
2747
|
_volumeMounts += `
|
|
1245
|
-
- name: ${
|
|
2748
|
+
- name: ${podVolumeName}
|
|
1246
2749
|
mountPath: ${volumeMountPath}
|
|
1247
2750
|
${secret ? ` readOnly: true\n` : ''}`;
|
|
1248
2751
|
|
|
1249
2752
|
_volumes += `
|
|
1250
|
-
- name: ${
|
|
2753
|
+
- name: ${podVolumeName}
|
|
1251
2754
|
${
|
|
1252
2755
|
emptyDir
|
|
1253
2756
|
? ` emptyDir: {}`
|
|
@@ -1334,7 +2837,9 @@ spec:
|
|
|
1334
2837
|
},
|
|
1335
2838
|
|
|
1336
2839
|
/**
|
|
1337
|
-
* 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.
|
|
1338
2843
|
* @param {object} options - Options for the check.
|
|
1339
2844
|
* @param {string} options.host - Host for which the TLS context is being checked.
|
|
1340
2845
|
* @param {string} options.env - Environment for which the TLS context is being checked.
|
|
@@ -1348,6 +2853,22 @@ spec:
|
|
|
1348
2853
|
(!options.certHosts || options.certHosts.split(',').includes(host))) ||
|
|
1349
2854
|
options.selfSigned === true,
|
|
1350
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
|
+
|
|
1351
2872
|
/**
|
|
1352
2873
|
* Predefined resource templates for Kubernetes deployments.
|
|
1353
2874
|
* @memberof UnderpostDeploy
|
|
@@ -1461,7 +2982,7 @@ spec:
|
|
|
1461
2982
|
* - `imagePullPolicy` — the extracted value, or `undefined` if absent.
|
|
1462
2983
|
*
|
|
1463
2984
|
* @param {object|undefined} lifecycle - Env-resolved lifecycle block
|
|
1464
|
-
* (already passed through
|
|
2985
|
+
* (already passed through {@link ServerConfBuilder.resolveEnvScoped}). May be `undefined`.
|
|
1465
2986
|
* @returns {{ lifecycle: (object|undefined), imagePullPolicy: (string|undefined) }}
|
|
1466
2987
|
* @memberof UnderpostDeploy
|
|
1467
2988
|
*/
|
|
@@ -1494,6 +3015,32 @@ spec:
|
|
|
1494
3015
|
`${options.retryPerTryTimeout ? ` --retry-per-try-timeout ${options.retryPerTryTimeout}` : ''}`
|
|
1495
3016
|
);
|
|
1496
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
|
+
},
|
|
1497
3044
|
};
|
|
1498
3045
|
}
|
|
1499
3046
|
|