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